<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Cuda-Graphs on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/cuda-graphs/</link><description>Recent content in Cuda-Graphs on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 11 Jun 2026 13:00:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/cuda-graphs/index.xml" rel="self" type="application/rss+xml"/><item><title>Speeding up model cold start: from minutes to seconds</title><link>https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/</link><pubDate>Thu, 11 Jun 2026 13:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>third batch&lt;/strong> of an operational series on squeezing a generic on-premise LLM cluster of &lt;strong>4×H100 SXM 80 GB with NVLink&lt;/strong>. The sister pieces in this batch are &lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal: serving a VLM on-premise with vLLM&lt;/a>, adding vision to the same engine, and &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, sharing out and charging for the GPU across teams. This post takes the problem that &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> laid out conceptually and turns it into a runbook: how you actually bring the cold start down, knob by knob, so that elasticity is usable.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The post &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> left an uncomfortable idea behind: &lt;strong>moving the weights from disk to HBM is only one of the five line items of cold start&lt;/strong>. The other four, starting the process, creating the CUDA context, setting up the allocator and, the most expensive one that hardly anyone looks at, &lt;strong>capturing the CUDA graphs and compiling the JIT kernels&lt;/strong>, are untouched by any fast loader. That is why a server that loads the weights in 6 s can still take &lt;strong>90 s&lt;/strong> to be ready. This runbook attacks all five. For the weights: &lt;strong>safetensors&lt;/strong> (mmap, zero-copy, no bounce through a host FP32 buffer) versus the &lt;code>pickle&lt;/code> of &lt;code>torch.load&lt;/code>; &lt;strong>Tensorizer&lt;/strong> (CoreWeave), which serialises the model into one file and &lt;strong>streams it tensor by tensor straight to the GPU&lt;/strong> from object storage/HTTP/S3; and the &lt;strong>Run:ai Model Streamer&lt;/strong> (from NVIDIA), which &lt;strong>reads concurrently and overlaps the read with the H2D copy&lt;/strong>. All three are switched on with one flag, &lt;code>--load-format&lt;/code>, in vLLM. For the non-weight part: &lt;strong>torch.compile/Inductor compilation cache&lt;/strong> (persisted and reused across start-ups), &lt;strong>bounding the CUDA graph capture&lt;/strong> to the batch sizes you really use (the default capture can eat ~54 s), and &lt;strong>sleep mode&lt;/strong> as a shortcut that skips all five line items. The maths: $t = W/B$ for the weights, the saving from &lt;strong>overlapping&lt;/strong> read and H2D instead of doing them in series, and a 70B cold start broken down item by item. And the economics: when &lt;strong>scale-to-zero with pre-warm&lt;/strong> beats &lt;strong>keeping a warm replica&lt;/strong>. On the generic 4×H100 SXM 80 GB cluster.&lt;/p>
&lt;h2 id="the-analogy-opening-the-kitchen-in-the-morning">The analogy: opening the kitchen in the morning&lt;/h2>
&lt;p>A restaurant does not open by flicking a switch. Anyone who has worked in one knows it: opening time is decided by &lt;strong>everything that happens beforehand&lt;/strong>, and almost none of it is &amp;ldquo;bringing in the food&amp;rdquo;.&lt;/p>
&lt;p>The first cook arrives at a cold, dark place. They have to: &lt;strong>turn on the lights and start the systems&lt;/strong> (the process init); &lt;strong>fire up the ovens and let them come up to temperature&lt;/strong> (the CUDA context, which takes its time to become operational); &lt;strong>get the boards out, sort the cold room, organise the work space&lt;/strong> (the memory allocator, which reserves and structures the HBM); &lt;strong>go down to the store and bring up the produce&lt;/strong>, the crates of vegetables, the meat, the fish, &lt;strong>and put it in its place&lt;/strong> (loading the weights from disk to HBM, the entire subject of &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>); and, the most forgotten of all, &lt;strong>sharpen every knife, set up the mise en place, make the stocks, pre-heat the mother sauces&lt;/strong>, all the preparation that is not an ingredient but without which not a single dish goes out (CUDA graph capture and JIT compilation of the kernels).&lt;/p>
&lt;p>Here is the trap that &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> already anticipated: &lt;strong>if you only optimise the produce, you still open late&lt;/strong>. You can hire the best delivery service in the world, a blazingly fast service lift, crates that put themselves away. But if the cook is still sharpening knives and waiting for the ovens to come up to temperature, the kitchen &lt;strong>does not serve the first dish&lt;/strong>. The produce arrived early and the restaurant is still shut.&lt;/p>
&lt;p>Today&amp;rsquo;s runbook is, literally, the list of everything that has to be done for the kitchen to open on time, and which of those tasks can be sped up, which can be left done from the night before (cached), and when it pays better never to switch the kitchen off (warm replica) than to light it again every morning (scale-to-zero).&lt;/p>
&lt;h2 id="the-five-line-items-of-cold-start">The five line items of cold start&lt;/h2>
&lt;p>From the moment an inference pod is born until it returns its first token, the clock runs in five different places. It is worth naming them, because &lt;strong>each optimisation attacks one or two, never all of them&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Process init.&lt;/strong> Starting the Python interpreter, importing the engine (vLLM and its dependencies), parsing the configuration. Seconds, and it grows with the size of the environment and the CUDA/PyTorch &lt;code>import&lt;/code>s.&lt;/li>
&lt;li>&lt;strong>CUDA context.&lt;/strong> The first &lt;code>cudaSetDevice&lt;/code> / &lt;code>cudaFree(0)&lt;/code> initialises the CUDA runtime against the driver: it loads the context and maps the GPU. It is not instant, on the order of one to several seconds per GPU depending on driver and number of devices.&lt;/li>
&lt;li>&lt;strong>Allocator.&lt;/strong> vLLM sets up its memory manager over the HBM (PyTorch&amp;rsquo;s caching allocator plus the KV-cache layout). Reserving and structuring tens of GB has its cost.&lt;/li>
&lt;li>&lt;strong>Weight loading disk→HBM.&lt;/strong> The journey that &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> dissected: disk → page cache → host buffer → PCIe → HBM. What almost everyone believes cold start &lt;strong>is&lt;/strong>, and it is only one of the five.&lt;/li>
&lt;li>&lt;strong>CUDA graph capture + kernel JIT.&lt;/strong> vLLM captures CUDA graphs for the various batch shapes and compiles kernels with &lt;code>torch.compile&lt;/code>/Inductor (and backends such as DeepGEMM or FlashInfer). This item is the great ignored one: &lt;strong>the default graph capture in vLLM takes on the order of 54 s&lt;/strong> because it covers a wide range of batch sizes (&lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">vLLM, &lt;em>torch.compile integration&lt;/em>&lt;/a>; &lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">Red Hat, &lt;em>vLLM with torch.compile&lt;/em>&lt;/a>).&lt;/li>
&lt;/ol>
&lt;div class="diagram" style="max-width:760px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The five line items of cold start as stacked time bars">
&lt;text x="380" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">The five line items of a cold start (example 70B, default loader)&lt;/text>
&lt;text x="380" y="44" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">width proportional to time · scale 0–110 s&lt;/text>
&lt;!-- axis -->
&lt;line x1="40" y1="250" x2="720" y2="250" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="40" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">0 s&lt;/text>
&lt;text x="380" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">55 s&lt;/text>
&lt;text x="720" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">110 s&lt;/text>
&lt;!-- bars: x=40 base, 680px = 110s => 6.18 px/s -->
&lt;!-- init 4s = 25px -->
&lt;rect x="40" y="70" width="25" height="30" fill="#8b5cf6" fill-opacity="0.85"/>
&lt;text x="160" y="89" font-family="sans-serif" font-size="11" fill="currentColor">1 · process init — ~4 s&lt;/text>
&lt;!-- CUDA context 6s = 37px, start 65 -->
&lt;rect x="65" y="106" width="37" height="30" fill="#3b82f6" fill-opacity="0.85"/>
&lt;text x="160" y="125" font-family="sans-serif" font-size="11" fill="currentColor">2 · CUDA context — ~6 s&lt;/text>
&lt;!-- allocator 4s = 25px, start 102 -->
&lt;rect x="102" y="142" width="25" height="30" fill="#22c55e" fill-opacity="0.85"/>
&lt;text x="160" y="161" font-family="sans-serif" font-size="11" fill="currentColor">3 · allocator — ~4 s&lt;/text>
&lt;!-- weights 40s = 247px, start 127 -->
&lt;rect x="127" y="178" width="247" height="30" fill="#f59e0b" fill-opacity="0.9"/>
&lt;text x="384" y="197" font-family="sans-serif" font-size="11" fill="currentColor">4 · weight loading disk→HBM — ~40 s&lt;/text>
&lt;!-- graphs+jit 50s = 309px, start 374 -->
&lt;rect x="374" y="214" width="309" height="30" fill="#ef4444" fill-opacity="0.9"/>
&lt;text x="690" y="233" text-anchor="end" font-family="sans-serif" font-size="11" fill="#ffffff" font-weight="600">5 · CUDA graphs + JIT — ~50 s&lt;/text>
&lt;text x="380" y="290" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.85">total ≈ 104 s · optimising only item 4 leaves item 5 intact — and 5 is the largest here&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The lesson the diagram is shouting: on a modern server with &lt;code>torch.compile&lt;/code> and CUDA graphs, &lt;strong>item 5 can be as big as or bigger than item 4&lt;/strong>. Speeding up weight loading from 40 s to 6 s is a huge advance, but if you leave graph capture at 50 s, the cold start falls from 104 s to ~70 s, not to 12 s. &lt;strong>You have to attack both halves.&lt;/strong>&lt;/p>
&lt;h2 id="speeding-up-item-4-loading-the-weights">Speeding up item 4: loading the weights&lt;/h2>
&lt;h3 id="safetensors-versus-pickle-the-format-rules">safetensors versus pickle: the format rules&lt;/h3>
&lt;p>The first knob is the on-disk format. The old &lt;code>torch.load&lt;/code> uses &lt;strong>&lt;code>pickle&lt;/code>&lt;/strong>, which has two problems. The security one is well known: deserialising a pickle &lt;strong>executes arbitrary Python code&lt;/strong>, since the &lt;code>__reduce__&lt;/code> protocol lets the file invoke any callable on load, so every model download was a remote execution vector (&lt;a href="https://huggingface.co/docs/safetensors/index">HuggingFace, &lt;em>Safetensors&lt;/em>&lt;/a>). The performance one is what concerns us: loading a pickle rebuilds Python objects and usually passes through an intermediate &lt;strong>host buffer&lt;/strong> before reaching the GPU.&lt;/p>
&lt;p>&lt;strong>safetensors&lt;/strong>, now under the PyTorch Foundation (&lt;a href="https://huggingface.co/blog/safetensors-joins-pytorch-foundation">HuggingFace, &lt;em>Safetensors joins PyTorch Foundation&lt;/em>&lt;/a>), solves both. The file is just a &lt;strong>JSON header + raw tensor bytes&lt;/strong>: loading it cannot do anything except populate tensor buffers. And the key property for us is physical: &lt;strong>the data region is aligned to a page boundary&lt;/strong> (the header is padded so that the first tensor starts at a multiple of the OS page size). That is what makes &lt;strong>zero-copy&lt;/strong> possible: a loader can &lt;code>mmap&lt;/code> the file, &lt;code>cudaHostRegister&lt;/code> over the mapped region and &lt;strong>DMA straight from the page cache into VRAM&lt;/strong>, with no &lt;code>torch.load&lt;/code> deserialisation and no temporary host FP32 buffer (&lt;a href="https://huggingface.co/docs/safetensors/index">HuggingFace, &lt;em>Safetensors&lt;/em>&lt;/a>). It is the base format; everything else is built on top of it.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>The &lt;code>mmap&lt;/code> trap&lt;/strong> (already flagged in &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>): &lt;code>mmap&lt;/code> reads nothing immediately, it defers the cost to the first access to each page. If you do not force the read, the cold start looks short and the &lt;strong>first token&lt;/strong> pays the page faults. And the &amp;ldquo;blazingly fast second load&amp;rdquo; is the page cache lying to you: in production pods are born cold, on nodes where those files are not cached.&lt;/p>
&lt;/blockquote>
&lt;h3 id="tensorizer-streaming-tensor-by-tensor-straight-to-the-gpu">Tensorizer: streaming tensor by tensor straight to the GPU&lt;/h3>
&lt;p>&lt;strong>Tensorizer&lt;/strong>, from CoreWeave, serialises the model&amp;rsquo;s weights and its tensors &lt;strong>into a single file&lt;/strong> and, instead of loading the whole model into RAM before moving it to the GPU, &lt;strong>streams the data tensor by tensor&lt;/strong> from disk, an HTTP/HTTPS endpoint or an S3 bucket, &lt;strong>deserialising on the fly straight onto the GPU&lt;/strong> (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/tensorizer/">vLLM, &lt;em>Loading Models with CoreWeave&amp;rsquo;s Tensorizer&lt;/em>&lt;/a>). The operational advantage: &lt;strong>near-instant loading and low host RAM usage&lt;/strong> during initialisation, which matters especially in serverless and autoscaling scenarios, exactly the scale-to-zero case. And it decouples the weights from the container image: the model lives in object storage, not inflating the image pull.&lt;/p>
&lt;p>In vLLM it is enabled with &lt;code>--load-format tensorizer&lt;/code> (or &lt;code>load_format=&amp;quot;tensorizer&amp;quot;&lt;/code> via the API). It requires serialising the model once into Tensorizer&amp;rsquo;s format; from then on, any pod streams it.&lt;/p>
&lt;h3 id="runai-model-streamer-reading-and-copying-at-the-same-time">Run:ai Model Streamer: reading and copying at the same time&lt;/h3>
&lt;p>The &lt;strong>Run:ai Model Streamer&lt;/strong> (from NVIDIA) attacks the bottleneck from another angle: it &lt;strong>reads the tensors concurrently&lt;/strong>, N OS threads reading from storage into the CPU buffer, &lt;strong>while streaming them to VRAM&lt;/strong>, so that the &lt;strong>storage read and the H2D copy overlap&lt;/strong> instead of happening in series (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>&lt;/a>; &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">NVIDIA, &lt;em>Reducing Cold Start Latency&lt;/em>&lt;/a>). It reads safetensors directly, with no reconversion.&lt;/p>
&lt;p>The numbers published by NVIDIA give the order of magnitude: the streamer reaches &lt;strong>4.88 s reading from S3 at concurrency 32&lt;/strong> and &lt;strong>7.53 s from an SSD IO2 at concurrency 8&lt;/strong>; integrated into vLLM, the total time to &lt;em>ready&lt;/em> drops to &lt;strong>23.18 s from S3&lt;/strong>, &lt;strong>28.28 s from SSD IO2&lt;/strong> and &lt;strong>35.08 s from GP3&lt;/strong> (&lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">NVIDIA, &lt;em>Reducing Cold Start Latency&lt;/em>&lt;/a>). Note the gap between the &lt;strong>weight loading&lt;/strong> time (~5–8 s) and the total to &lt;em>ready&lt;/em> (~23–35 s): those ~18–27 s of difference are &lt;strong>the other four line items&lt;/strong>, above all item 5. The streamer fixed item 4 and the rest is still there, exactly what this post warns about.&lt;/p>
&lt;p>Concurrency is the tuning parameter: it controls how many OS threads read tensors into the CPU buffer (and, for S3, how many client connections the host opens) (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">vLLM, &lt;em>Run:ai Model Streamer&lt;/em>&lt;/a>). &lt;strong>16 is usually enough for local NVMe; 32 for high-throughput object storage.&lt;/strong> One thread does not saturate a Gen5 NVMe; overlapping does.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># default safetensors (the slowest of the fast ones)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format safetensors
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run:ai Model Streamer (concurrent reads, overlaps read + H2D)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format runai_streamer &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --model-loader-extra-config &lt;span class="s1">&amp;#39;{&amp;#34;concurrency&amp;#34;: 32}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Tensorizer (tensor-by-tensor stream straight to GPU from object storage/S3)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format tensorizer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="the-maths-of-the-weights-t--wb-and-the-saving-from-overlapping">The maths of the weights: $t = W/B$ and the saving from overlapping&lt;/h2>
&lt;p>The floor of item 4 is simple: moving $W$ gigabytes at a bandwidth $B$ takes&lt;/p>
$$t = \frac{W}{B}$$
&lt;p>Take a large model, a &lt;strong>70B&lt;/strong>. In BF16 ($b=2$ bytes/param) that is $W = 70 \times 10^9 \cdot 2 = 140$ GB; in FP8 ($b=1$), $W = 70$ GB. Bandwidth depends on the tier (see the table below): local Gen5 NVMe reads on the order of &lt;strong>~14 GB/s per disk&lt;/strong>; PCIe Gen5 x16 copies host→GPU at &lt;strong>~50 GB/s&lt;/strong>; network storage, &lt;strong>~1–3 GB/s&lt;/strong>. The theoretical floor for reading 140 GB from an NVMe is $140/14 = 10$ s; from the network at 2 GB/s, &lt;strong>70 s&lt;/strong>. The bottleneck rules.&lt;/p>
&lt;p>&lt;strong>The overlapping trick.&lt;/strong> The default loader does the two steps, reading from disk and copying H2D, &lt;strong>in series&lt;/strong>: first it fills a buffer, then it copies, then the next one. The time is the &lt;strong>sum&lt;/strong>:&lt;/p>
$$t_{\text{series}} = t_{\text{read}} + t_{\text{H2D}}$$
&lt;p>Tensorizer and the Run:ai Model Streamer &lt;strong>overlap&lt;/strong> them: while one thread copies a chunk to VRAM, another is already reading the next one from disk. With enough concurrency, the total time tends towards the &lt;strong>maximum&lt;/strong> of the two, not the sum:&lt;/p>
$$t_{\text{overlapped}} \approx \max(t_{\text{read}},\, t_{\text{H2D}})$$
&lt;p>With the 70 GB of the 70B in FP8, from NVMe at 14 GB/s and PCIe at 50 GB/s: $t_{\text{read}} = 70/14 = 5.0$ s, $t_{\text{H2D}} = 70/50 = 1.4$ s. In series, $5.0 + 1.4 = 6.4$ s; overlapped, $\max(5.0,\,1.4) = 5.0$ s. The saving here is modest (~22%) because the disk dominates comfortably. &lt;strong>Overlapping shines when the two steps are comparable&lt;/strong>: from network storage at 5 GB/s, $t_{\text{read}} = 70/5 = 14$ s and $t_{\text{H2D}} = 1.4$ s, series 15.4 s vs overlapped 14 s; but with many concurrent streams saturating a fast disk and a matched H2D, going from the sum to the maximum can &lt;strong>almost double&lt;/strong> the effective throughput. The flip side: overlapping only gives you what the &lt;strong>physical bottleneck&lt;/strong> allows. If the disk only delivers 14 GB/s, no streamer brings you below 5 s for 70 GB, and that is what the other lever is for, &lt;strong>moving fewer bytes&lt;/strong> (FP8 versus BF16 halves $W$).&lt;/p>
&lt;h3 id="the-storage-tier">The storage tier&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tier&lt;/th>
&lt;th>Typical bandwidth&lt;/th>
&lt;th>140 GB (70B BF16)&lt;/th>
&lt;th>70 GB (70B FP8)&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Local Gen5 NVMe&lt;/td>
&lt;td>~14 GB/s per disk&lt;/td>
&lt;td>~10 s&lt;/td>
&lt;td>~5 s&lt;/td>
&lt;td>the short path; one disk&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Local Gen5 NVMe (several, RAID0)&lt;/td>
&lt;td>~28–50 GB/s&lt;/td>
&lt;td>~3–5 s&lt;/td>
&lt;td>~1.5–2.5 s&lt;/td>
&lt;td>if the loader saturates several streams&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Object storage (S3/RGW)&lt;/td>
&lt;td>~1–3 GB/s per stream&lt;/td>
&lt;td>~47–140 s&lt;/td>
&lt;td>~23–70 s&lt;/td>
&lt;td>streamer concurrency helps a great deal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NFS / shared network&lt;/td>
&lt;td>~1–2 GB/s&lt;/td>
&lt;td>~70–140 s&lt;/td>
&lt;td>~35–70 s&lt;/td>
&lt;td>puts the network and its contention in the path&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational conclusion is the same as in &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>: &lt;strong>the weights that serve the cold start live on the node&amp;rsquo;s local NVMe&lt;/strong>, not on the network. Network storage is the repository; the inference node has a hot local copy (pre-pull with an &lt;code>initContainer&lt;/code> or a per-node cache DaemonSet). Tensorizer is the interesting exception: it streams from object storage so efficiently that it sometimes makes serving from S3/RGW viable with no local copy, at the price of depending on the bandwidth of the storage network.&lt;/p>
&lt;h2 id="speeding-up-item-5-everything-that-is-not-weights">Speeding up item 5: everything that is not weights&lt;/h2>
&lt;p>Here is the half of the cold start that fast loaders do not touch. Three fronts.&lt;/p>
&lt;h3 id="caching-the-torchcompileinductor-compilation">Caching the torch.compile/Inductor compilation&lt;/h3>
&lt;p>&lt;code>torch.compile&lt;/code> has a &lt;strong>built-in cache system&lt;/strong>: the compiled artefacts are saved after the first start-up and &lt;strong>reused across start-ups&lt;/strong>, even across machines if it is configured properly. In vLLM, the result of the Dynamo compilation is stored in &lt;code>~/.cache/vllm/torch_compile_cache/&lt;/code> (&lt;a href="https://blog.vllm.ai/2025/08/20/torch-compile.html">vLLM Blog, &lt;em>Introduction to torch.compile&lt;/em>&lt;/a>; &lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">vLLM, &lt;em>torch.compile integration&lt;/em>&lt;/a>). The consequence for the runbook: if that directory is &lt;strong>a persistent volume shared between pods&lt;/strong> (a PVC, or a &lt;code>hostPath&lt;/code> on the node&amp;rsquo;s NVMe populated by the first start-up), subsequent pods skip the compilation and start faster. The first pod pays for the compilation; the rest inherit the cache.&lt;/p>
&lt;p>It is exactly &amp;ldquo;sharpening the knives the night before&amp;rdquo;: the expensive preparation is done once and reused. The nuance: the cache is sensitive to the vLLM/PyTorch version, the GPU, the quantisation and the configuration, and changing any of them invalidates the cache and you pay for the compilation once again.&lt;/p>
&lt;h3 id="bounding-the-cuda-graph-capture">Bounding the CUDA graph capture&lt;/h3>
&lt;p>vLLM captures CUDA graphs for a &lt;strong>wide range of batch sizes&lt;/strong> by default, and that is expensive: the default capture is around &lt;strong>54 s&lt;/strong>, and on large configurations up to &lt;strong>294 s&lt;/strong> has been measured. By limiting the capture to the batch sizes your workload &lt;strong>really&lt;/strong> uses (for example &lt;code>1, 2, 4, 8, 16, 24, 32, 64&lt;/code>) the cold start is cut by &lt;strong>more than 70%&lt;/strong>, from 294 s to ~82 s in the measured case (&lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">Red Hat, &lt;em>vLLM with torch.compile&lt;/em>&lt;/a>). The knob is the engine&amp;rsquo;s compilation/CUDA graphs configuration; the principle is not to capture graphs for batch sizes you will never see.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>A real trade-off:&lt;/strong> fewer captured batch sizes = faster start-up but less coverage. A batch outside the captured set falls back to the eager path (slower per request). You bound to the frequent sizes of &lt;strong>your&lt;/strong> traffic, not of any traffic. And there is always the extreme of &lt;strong>disabling CUDA graphs&lt;/strong> (&lt;code>enforce_eager&lt;/code>): near-instant start-up on item 5 at the cost of throughput in the hot state, valid for debugging or for very low-traffic services where cold start weighs more than steady state.&lt;/p>
&lt;/blockquote>
&lt;h3 id="warm-pools-not-paying-for-item-5-on-the-critical-path">Warm pools: not paying for item 5 on the critical path&lt;/h3>
&lt;p>The definitive shortcut for all five items is &lt;strong>not to run them while the user waits&lt;/strong>. A &lt;em>warm pool&lt;/em>, a replica already started, with weights loaded, graphs captured and kernels compiled, waiting for traffic, turns the cold start into zero for that request. It is GPU cost (partly or wholly idle) in exchange for start-up latency; we analyse it in the economics section below.&lt;/p>
&lt;h3 id="sleep-mode-the-shortcut-that-skips-all-five-items">Sleep mode: the shortcut that skips all five items&lt;/h3>
&lt;p>And there is a shortcut halfway between &amp;ldquo;start from scratch&amp;rdquo; and &amp;ldquo;keep a whole replica warm&amp;rdquo;: vLLM&amp;rsquo;s &lt;strong>sleep mode&lt;/strong>, the subject of &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU&lt;/a>. Instead of killing the process, it &lt;strong>puts it to sleep&lt;/strong>: it parks the weights in host RAM (level 1) or discards them (level 2), but &lt;strong>keeps the process alive&lt;/strong>, and with it the CUDA context, the allocator, &lt;strong>the CUDA graphs and the already compiled JIT kernels&lt;/strong>. The &lt;em>wake&lt;/em> pays for neither item 1, 2, 3 nor 5; it only puts the weights back into VRAM (item 4, and from RAM, not from disk). That is why a wake is &lt;strong>18–200× faster than a full cold start&lt;/strong>, and even level 2, which reloads the weights from the same disk, is still 23–45× faster, &lt;strong>because it skips the other four items&lt;/strong> (&lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">vLLM Blog, &lt;em>Sleep Mode&lt;/em>&lt;/a>). Sleep mode is living proof of this post&amp;rsquo;s thesis: if preserving items 1, 2, 3 and 5 gives you 18–200×, then &lt;strong>moving bytes was never the whole cost&lt;/strong>.&lt;/p>
&lt;h2 id="an-example-cold-start-item-by-item">An example cold start, item by item&lt;/h2>
&lt;p>Let us put numbers on a cold start of &lt;strong>70B in FP8 (70 GB)&lt;/strong> from local NVMe and see which optimisation attacks each item:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Line item&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>Optimisation&lt;/th>
&lt;th>Optimised&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Process init&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;td>(little room; minimal environment)&lt;/td>
&lt;td>~3 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>CUDA context&lt;/td>
&lt;td>~6 s&lt;/td>
&lt;td>(driver; fixed)&lt;/td>
&lt;td>~6 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Allocator&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;td>(fixed)&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Weight loading&lt;/td>
&lt;td>~25 s&lt;/td>
&lt;td>Run:ai streamer / Tensorizer (concurrency + overlap)&lt;/td>
&lt;td>~5 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>CUDA graphs + JIT&lt;/td>
&lt;td>~54 s&lt;/td>
&lt;td>torch.compile cache + bounded capture&lt;/td>
&lt;td>~10 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;/td>
&lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~93 s&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~28 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two readings. First: optimising &lt;strong>only&lt;/strong> item 4 (a fast loader and nothing else) brings the total down from 93 to ~73 s, a real but disappointing improvement, because item 5 is still intact. Optimising &lt;strong>only&lt;/strong> item 5 (cache + bounded capture) brings it down to ~49 s. Attacking &lt;strong>both&lt;/strong> brings it down to ~28 s, and that is what turns theoretical scale-to-zero into usable scale-to-zero. Second: items 2 and 3 are practically irreducible, they depend on the driver and the hardware; they constitute the &lt;strong>floor of the cold start&lt;/strong>, on the order of 10 s, which no software optimisation crosses. Below that floor, the only answer is &lt;strong>not to start from scratch&lt;/strong>: sleep mode (wake from sub-second to a few seconds) or a warm replica (zero).&lt;/p>
&lt;h2 id="the-economics-of-scale-to-zero">The economics of scale-to-zero&lt;/h2>
&lt;p>The capacity question all of this serves is: &lt;strong>switch off or keep warm?&lt;/strong> Scale-to-zero saves GPU while there is no traffic, but pays the cold start when it comes back. The decision is a balance between the cost of an idle GPU and the latency SLA.&lt;/p>
&lt;p>The basic criterion: scale-to-zero is only viable if the cold start &lt;strong>fits inside the start-up SLA your users tolerate&lt;/strong>, or if you have a &lt;strong>pre-warming&lt;/strong> mechanism that starts the replica &lt;strong>before&lt;/strong> the request arrives (load prediction, an early signal from the queue). Without pre-warm, a 90 s cold start means the first user after an idle period waits 90 s, unacceptable for almost any interactive SLO. With the cold start brought down to ~28 s it is still a lot for an interactive request, but it is already tolerable for batch workloads or for a pre-warm triggered by &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a> on queue depth.&lt;/p>
&lt;p>The decision rule, in one sentence: &lt;strong>keep a warm replica when the cost of the idle GPU during the troughs is lower than the cost of missing the SLA on every start-up; do scale-to-zero (with pre-warm) when the troughs are long and deep and the optimised cold start fits in your tolerance&lt;/strong>. For a 24×7 interactive service with irregular but continuous traffic, a floor of warm replicas almost always wins. For a large model invoked a few times a day (a 70B for hard tasks), scale-to-zero with an optimised cold start, or sleep mode if it shares a GPU with another model, is the sensible option. It is the same &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> boundary: cold start is a parameter of the replica cushion, not a start-up detail.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us bring the runbook down to the &lt;strong>4 H100 SXM 80 GB with NVLink&lt;/strong>. The concrete decisions:&lt;/p>
&lt;p>&lt;strong>Loader and format.&lt;/strong> Weights in &lt;strong>safetensors&lt;/strong> on disk (never pickle). For the short path from local NVMe, the &lt;strong>Run:ai Model Streamer&lt;/strong> (&lt;code>--load-format runai_streamer&lt;/code>, concurrency 16–32) is the default option: concurrent reads, overlapping read and H2D, and you only change one flag. Keep &lt;strong>Tensorizer&lt;/strong> for the case where you serve from object storage (RGW/S3) and want to decouple the weights from the image with no local copy, since its tensor-by-tensor streaming straight to the GPU is what makes that pattern viable. In both cases, FP8 versus BF16 halves item 4 almost for free (measure the quality, do not assume it).&lt;/p>
&lt;p>&lt;strong>Storage tier.&lt;/strong> Model repository on the network (Ceph RGW), hot copy on the &lt;strong>node&amp;rsquo;s local NVMe&lt;/strong> populated by pre-pull. The cold start that counts is the cold one, on a node where the files are not in page cache; serving that start-up from the network puts its latency and contention into the critical path.&lt;/p>
&lt;p>&lt;strong>Caching item 5.&lt;/strong> The &lt;code>torch_compile_cache&lt;/code> directory on a persistent volume or a &lt;code>hostPath&lt;/code> NVMe shared between pods of the same model: the first pod compiles, the rest inherit. And &lt;strong>bound the CUDA graph capture&lt;/strong> to the real batch sizes of each service&amp;rsquo;s traffic.&lt;/p>
&lt;p>&lt;strong>When scale-to-zero with pre-warm vs a warm replica.&lt;/strong> The &lt;strong>agent LLM&lt;/strong> (main service, continuous traffic) &lt;strong>never&lt;/strong> does scale-to-zero: a floor of warm replicas on one or two GPUs. The &lt;strong>occasional 70B&lt;/strong> does: scale-to-zero with an optimised cold start, or, if it shares a GPU with a mid-sized model, &lt;strong>sleep mode&lt;/strong> so the wake is sub-second instead of a 28 s start-up (exactly the play in &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU&lt;/a>). The optimised cold start in this post is what makes &lt;strong>swap&lt;/strong> and &lt;strong>failover&lt;/strong> between replicas tolerable: a replica that falls over and is replaced in 28 s degrades less than one that takes 90 s. And it connects directly with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">multi-tenancy&lt;/a>: a short cold start lets you share GPUs between teams with per-tenant scale-to-zero without the first user of each tenant paying for an endless start-up.&lt;/p>
&lt;p>The cross-cutting principle: &lt;strong>cold start is the ceiling on elasticity&lt;/strong>. The five line items, with both halves attacked, a fast loader for the weights and cache plus bounded capture for the graphs, bring it down from minutes to seconds. And below the irreducible floor of ~10 s (CUDA context + allocator), the only way out is not to start from scratch: sleep mode or a warm replica.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;I switched to a fast loader and start-up barely improved.&amp;rdquo;&lt;/strong> Your bottleneck probably was not item 4. If CUDA graph capture eats 54 s, bringing weight loading down from 25 to 5 s takes 20 s off 93, which is barely noticeable. Measure &lt;strong>where&lt;/strong> the time goes before optimising: vLLM&amp;rsquo;s start-up logs break down weight loading versus graph capture.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The second time it started in a flash.&amp;rdquo;&lt;/strong> The page cache (for the weights) and the torch.compile cache (for the graphs) lying to you at the same time. The start-up that matters is the &lt;strong>cold&lt;/strong> one, on a clean node. Benchmarking the second start-up measures a situation that almost never occurs in the peak that triggers the autoscaler.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Tensorizer/streamer will give me the 18–200× of sleep mode.&amp;rdquo;&lt;/strong> No. Those loaders speed up &lt;strong>only item 4&lt;/strong>. The 18–200× of &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">sleep mode&lt;/a> comes from &lt;strong>preserving the other four&lt;/strong> by keeping the process alive. They are tools for different problems: a fast loader is for a real cold start (a new process); sleep mode is for alternating models without killing the process.&lt;/p>
&lt;p>&lt;strong>&amp;quot;&lt;code>enforce_eager&lt;/code> fixes the cold start.&amp;quot;&lt;/strong> It fixes item 5 (no graph capture, near-instant start-up) but &lt;strong>degrades throughput in the hot state&lt;/strong>, since CUDA graphs exist because they speed up the steady state. It is a trade: it wins for very low traffic services where start-up weighs more than the hot path; it loses for a service with sustained load.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I bound the CUDA graphs to a single batch size and that is it.&amp;rdquo;&lt;/strong> You bound to the sizes &lt;strong>your traffic uses&lt;/strong>, not to one. A batch outside the captured set falls back to the eager path and runs slower. Too aggressive and you penalise the steady state to gain a few seconds of start-up.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Moving the weights to FP8 also speeds up item 5.&amp;rdquo;&lt;/strong> Not directly. FP8 halves the &lt;strong>bytes&lt;/strong> (item 4) and doubles inference throughput, but graph capture and kernel compilation do not depend on the size of the weights, they depend on the &lt;strong>shape&lt;/strong> of the graph and the batch sizes. Item 5 is attacked with caching and bounded capture, not with quantisation.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>&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> said it conceptually: moving the weights is one of the five line items of cold start. This runbook turns it into action. For the weights, item 4, there is a clear menu: &lt;strong>safetensors&lt;/strong> as the base (mmap, zero-copy, no pickle), &lt;strong>Run:ai Model Streamer&lt;/strong> for the short path from NVMe (concurrent reads overlapping read and H2D), &lt;strong>Tensorizer&lt;/strong> for streaming from object storage with no local copy, and the cross-cutting lever of &lt;strong>moving fewer bytes&lt;/strong> with FP8. But the forgotten half of the start-up is &lt;strong>item 5&lt;/strong>, CUDA graph capture and JIT compilation, which on a modern server can be as big as the weight loading: you attack it by &lt;strong>caching the compilation&lt;/strong> across pods and &lt;strong>bounding the graph capture&lt;/strong> to the real batch sizes. Attacking only one half leaves the cold start half done; attacking both brings it from ~90 s to ~28 s. And below the irreducible floor of ~10 s imposed by the CUDA context and the allocator, the only way out is &lt;strong>not to start from scratch&lt;/strong>: sleep mode (sub-second wake) or a warm replica (zero). The kitchen opens on time when somebody took care of the produce &lt;strong>and&lt;/strong> of sharpening the knives the night before. Optimising only the delivery from the store leaves the ovens cold and the restaurant shut.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal: serving a VLM on-premise with vLLM&lt;/a> — a sister piece in this batch: adding vision to the same inference engine; a VLM has its own cold start (the image encoder weights on top of the LLM).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — a sister piece: a short cold start is what makes per-tenant scale-to-zero viable without penalising the first user of each team.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the conceptual post this runbook continues: why moving bytes is only one of five items, and the path disk→page cache→host→PCIe→HBM.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU: swap and sleep mode&lt;/a> — sleep mode as the shortcut that preserves the four non-weight items and delivers the 18–200× wake.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — cold start is the ceiling on elasticity; pre-warming triggered by queue depth is what makes scale-to-zero usable.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — cold start as a parameter of the warm replica vs scale-to-zero decision and of the replica cushion.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — sharing out one GPU between services; every co-resident service has its own start-up cost to budget for.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): registry, OCI artefacts and distribution&lt;/a> — the link before the cold start: which registry the bytes come from, how they are cached near the node and what mounting the model as an OCI artefact adds.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>HuggingFace, &lt;em>Safetensors&lt;/em> (format, zero-copy, security versus pickle): &lt;a href="https://huggingface.co/docs/safetensors/index">https://huggingface.co/docs/safetensors/index&lt;/a>&lt;/li>
&lt;li>HuggingFace, &lt;em>Safetensors is Joining the PyTorch Foundation&lt;/em>: &lt;a href="https://huggingface.co/blog/safetensors-joins-pytorch-foundation">https://huggingface.co/blog/safetensors-joins-pytorch-foundation&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Loading Models with CoreWeave&amp;rsquo;s Tensorizer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/tensorizer/">https://docs.vllm.ai/en/stable/models/extensions/tensorizer/&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em> (S3/SSD benchmarks, concurrency): &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/&lt;/a>&lt;/li>
&lt;li>vLLM Blog, &lt;em>Introduction to torch.compile and How It Works with vLLM&lt;/em> (compilation cache): &lt;a href="https://blog.vllm.ai/2025/08/20/torch-compile.html">https://blog.vllm.ai/2025/08/20/torch-compile.html&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>torch.compile integration&lt;/em> (cache in &lt;code>~/.cache/vllm/torch_compile_cache/&lt;/code>): &lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">https://docs.vllm.ai/en/latest/design/torch_compile/&lt;/a>&lt;/li>
&lt;li>Red Hat Developer, &lt;em>vLLM with torch.compile: Efficient LLM inference on PyTorch&lt;/em> (CUDA graph capture ~54 s, bounding batch sizes −70%): &lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch&lt;/a>&lt;/li>
&lt;li>vLLM Blog, &lt;em>Zero-Reload Model Switching with vLLM Sleep Mode&lt;/em> (wake 18–200×): &lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">https://blog.vllm.ai/2025/10/26/sleep-mode.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The roofline flips: why optimising small models is a different performance game</title><link>https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/</link><pubDate>Tue, 09 Jun 2026 01:30:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/</guid><description>&lt;blockquote>
&lt;p>This post is the anchor of a mini-series on inference performance in &lt;strong>small models (SLMs)&lt;/strong>. Almost every optimisation post on the blog, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">decode&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation&lt;/a>, was written with a 70B in mind. Here I argue that when the model shrinks by an order of magnitude, the &lt;em>roofline&lt;/em> changes regime and several of those intuitions flip. This is not a nuance: it is a different game.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Autoregressive decode in a large LLM is &lt;strong>memory-bandwidth-bound&lt;/strong>: at every step you have to move all the model weights from HBM to the registers of the SMs, and that dominates over the arithmetic operations. The GPU spends its time waiting for bytes, not computing. That single sentence, that decode &amp;ldquo;waits on HBM&amp;rdquo;, is the root of half the optimisations on this blog. In a small model (SLM, say 0.5B–7B) the sentence stops being true in the simple form we used to tell it. At &lt;em>batch&lt;/em> 1 you are still memory-bound with respect to the hardware, yes, but the forward pass is so cheap (moving 6 GB at 1 TB/s takes ~6 ms, not 70 ms) that the &lt;strong>fixed per-step costs&lt;/strong>, kernel launches, Python scheduler overhead, the &lt;code>sampler&lt;/code>, host↔device copies, the &lt;code>synchronize&lt;/code> calls, stop being noise and start eating 20-30 % of the time. The bottleneck moves from HBM to orchestration. The concrete, quantitative consequences: (1) &lt;strong>CUDA graphs&lt;/strong> and cutting scheduler overhead pay off &lt;em>more&lt;/em> in SLMs than in large models; (2) &lt;strong>weight quantisation&lt;/strong> gives &lt;em>less&lt;/em> latency improvement at batch 1 in SLMs, because proportionally there are fewer weights to move relative to activations, KV cache and fixed overhead; (3) batching has more headroom because you cross the &lt;em>ridge point&lt;/em> late; (4) the KV cache can dominate relative memory. All of this comes out of a single model, the roofline, applied with numerical honesty.&lt;/p>
&lt;h2 id="the-analogy-the-pantry-and-the-waiter">The analogy: the pantry and the waiter&lt;/h2>
&lt;p>A kitchen with two very different services.&lt;/p>
&lt;p>&lt;strong>Tasting menu service, one enormous slow dish (the 70B LLM).&lt;/strong> Every dish involves heavy ingredients that the assistant has to fetch from the pantry at the back, several times, carrying boxes. The chef, by contrast, assembles the dish in a moment: the slow part is fetching the ingredients, not cooking them. If you want the service to go faster, you do not hire a more skilful chef: you widen the corridor to the pantry, or make each trip bring more boxes. The pantry is HBM; the trip is memory bandwidth; cooking is compute. The big dish is bound by the pantry.&lt;/p>
&lt;p>&lt;strong>Tapas service, tiny plates (the SLM).&lt;/strong> Now each tapa has two ingredients and is assembled in a second. The trip to the pantry per tapa is very short. But a cost appears that was negligible for the big dish: the &lt;strong>waiter&lt;/strong>. For every tapa, the waiter has to walk to the kitchen, pick up the plate, carry it to the bar, walk back, take the order, call it out. That back and forth is &lt;strong>fixed&lt;/strong>: it costs the same for one tapa as for the enormous dish. When the tapa is assembled in a second, the waiter, not the pantry, is the bottleneck. Shortening the corridor to the pantry (widening HBM, quantising the weights) barely improves the service any more; what improves it is the waiter chaining several orders together without going back to the kitchen each time (CUDA graphs) or serving several tables in one pass (batching).&lt;/p>
&lt;p>The roofline is the tool that says, with numbers, at what point the waiter dominates over the pantry. That boundary is the &lt;em>ridge point&lt;/em>, and the joke in the title is that in SLMs we cross the regime much earlier than large-model intuition led us to believe.&lt;/p>
&lt;h2 id="the-bare-mechanism-what-the-roofline-says">The bare mechanism: what the roofline says&lt;/h2>
&lt;p>The roofline model (Williams, Waterman and Patterson, 2009) starts from a single quantity: &lt;strong>arithmetic intensity&lt;/strong>, which is how many operations you do per byte moved from memory.&lt;/p>
$$\text{AI} = \frac{\text{FLOPs}}{\text{bytes moved from memory}} \quad [\text{FLOP/byte}]$$
&lt;p>The hardware has two ceilings: compute (peak FLOPS) and memory (peak bandwidth × AI). Achievable performance is the minimum of the two:&lt;/p>
$$\text{Perf} = \min\big(\text{peak FLOPS},\; \text{BW} \times \text{AI}\big)$$
&lt;p>Where the two lines cross is the &lt;strong>ridge point&lt;/strong>, the AI above which you stop being limited by memory and start being limited by compute:&lt;/p>
$$\text{AI}_{\text{ridge}} = \frac{\text{peak FLOPS}}{\text{peak BW}}$$
&lt;p>If your kernel has an AI below the ridge, you are &lt;strong>memory-bound&lt;/strong> (the GPU waits for bytes). Above it, &lt;strong>compute-bound&lt;/strong> (the GPU computes flat out and memory is in surplus). What matters is that the ridge point is a property of the hardware, not of the model. Let us look at the numbers, approximate, and I flag them as such because marketing figures mix &lt;em>dense&lt;/em> and &lt;em>sparse&lt;/em>, different dtypes and unrealistic thermal conditions.&lt;/p>
&lt;p>&lt;strong>Generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> Per GPU, ~989 TFLOPS BF16 &lt;em>dense&lt;/em> (~1,979 TFLOPS FP8 &lt;em>dense&lt;/em>; the figure with &lt;em>sparsity&lt;/em> is double that and almost never applies to LLM inference). HBM3 ~3.35 TB/s. The ridge in BF16:&lt;/p>
$$\text{AI}_{\text{ridge}}^{\text{H100,BF16}} \approx \frac{989 \times 10^{12}}{3.35 \times 10^{12}} \approx 295 \ \text{FLOP/byte}$$
&lt;p>In FP8 the ridge rises to ~590 FLOP/byte (twice the FLOPS against the same BW). Careful: these are datasheet peak figures; in practice a real kernel rarely gets past 70-80 % of either ceiling.&lt;/p>
&lt;p>&lt;strong>RTX 4090 (24 GB, Ada Lovelace).&lt;/strong> ~330 TFLOPS FP16 with FP16 accumulation via tensor cores (the &amp;ldquo;660 TOPS&amp;rdquo; figure that circulates is with sparsity), and ~1 TB/s of GDDR6X. The ridge:&lt;/p>
$$\text{AI}_{\text{ridge}}^{\text{4090,FP16}} \approx \frac{330 \times 10^{12}}{1.0 \times 10^{12}} \approx 330 \ \text{FLOP/byte}$$
&lt;p>Curiously, the same order as the H100 in BF16: the 4090 has less BW but also fewer FLOPS, and the ratio comes out similar. The ridge sits around &lt;strong>300 FLOP/byte&lt;/strong> in both cases. Hold on to that number.&lt;/p>
&lt;p>And where does decode fall? In decode at &lt;em>batch&lt;/em> 1, each weight is loaded once from HBM and used for a single multiply-accumulate (one token, one row of activation). The AI of the decode GEMM at batch 1 is on the order of &lt;strong>AI ≈ 1-2 FLOP/byte&lt;/strong> (each weight byte takes part in ~2 FLOP). With &lt;em>batch&lt;/em> B, the same weight loaded once serves B rows of activation, so the AI scales roughly linearly:&lt;/p>
$$\text{AI}_{\text{decode}}(B) \approx 2B \ \text{FLOP/byte} \quad (\text{for the weight GEMM part})$$
&lt;p>You cross the ridge when &lt;code>2B ≈ 300&lt;/code>, that is B ≈ 150 as an order of magnitude (in practice earlier, because of attention and overheads, but that is the frame). The clean conclusion: &lt;strong>decode at low batch is always deeply memory-bound&lt;/strong>, nowhere near the ridge. That is why we say &amp;ldquo;decode waits on HBM&amp;rdquo;, and why quantising weights (moving fewer bytes) speeds up decode in a large model almost linearly. So far, this is the standard story from the large-model posts.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Roofline diagram with ridge point and where the large and small models fall">
&lt;!-- axes -->
&lt;line x1="80" y1="360" x2="740" y2="360" stroke="currentColor" stroke-width="1.6"/>
&lt;line x1="80" y1="360" x2="80" y2="40" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="410" y="400" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600">Arithmetic intensity (FLOP/byte) — log scale&lt;/text>
&lt;text x="28" y="200" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600" transform="rotate(-90 28 200)">Achievable performance (FLOPS)&lt;/text>
&lt;!-- memory ceiling (ramp) -->
&lt;line x1="80" y1="360" x2="470" y2="90" stroke="#1f5fa8" stroke-width="2.6"/>
&lt;!-- compute ceiling (flat) -->
&lt;line x1="470" y1="90" x2="740" y2="90" stroke="#a52a2a" stroke-width="2.6"/>
&lt;!-- ridge point -->
&lt;line x1="470" y1="90" x2="470" y2="360" stroke="currentColor" stroke-width="1" stroke-dasharray="4 3"/>
&lt;circle cx="470" cy="90" r="5" fill="#5a2db0"/>
&lt;text x="470" y="78" text-anchor="middle" fill="#5a2db0" font-size="12" font-weight="600">ridge ≈ 300 FLOP/byte&lt;/text>
&lt;text x="270" y="200" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600" transform="rotate(-35 270 200)">memory-bound (BW × AI)&lt;/text>
&lt;text x="600" y="80" text-anchor="middle" fill="#a52a2a" font-size="12" font-weight="600">compute-bound (peak FLOPS)&lt;/text>
&lt;!-- decode batch 1 point -->
&lt;circle cx="120" cy="332" r="6" fill="#a48000"/>
&lt;text x="120" y="322" text-anchor="middle" fill="#a48000" font-size="11" font-weight="600">decode B=1 · AI≈2&lt;/text>
&lt;!-- large batch decode point -->
&lt;circle cx="360" cy="167" r="6" fill="#2a7a40"/>
&lt;text x="360" y="157" text-anchor="middle" fill="#2a7a40" font-size="11" font-weight="600">decode B≈64 · AI≈128&lt;/text>
&lt;!-- SLM note -->
&lt;rect x="90" y="300" width="250" height="48" fill="#fff4d6" stroke="#a48000" stroke-width="1.2"/>
&lt;text x="100" y="318" fill="#222" font-size="11" font-weight="600">SLM at B=1: the point is here (memory-bound),&lt;/text>
&lt;text x="100" y="333" fill="#222" font-size="11">but the roofline does NOT model the fixed&lt;/text>
&lt;text x="100" y="346" fill="#222" font-size="11">per-step overhead → see second diagram.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-point-of-the-title-why-it-flips-in-slms">The point of the title: why it flips in SLMs&lt;/h2>
&lt;p>The classic roofline has a blind spot that does not matter in large models and is everything in small ones: &lt;strong>it only models the work inside the kernel&lt;/strong>. It assumes the only time is &lt;code>bytes/BW&lt;/code> or &lt;code>FLOPs/FLOPS&lt;/code>. But a real decode step is not just the GEMM. It is a sequence of dozens of kernels (QKV projections, attention, the two MLP layers, normalisations, residuals, the logits head, sampling) and, around each one, there is a &lt;strong>fixed orchestration cost&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Kernel launches&lt;/strong> (&lt;code>kernel launch&lt;/code>): each &lt;code>cudaLaunchKernel&lt;/code> costs on the order of 5-10 µs of CPU/driver overhead, regardless of kernel size. A decode forward with ~30-60 kernels launched sequentially drags ~0.3-0.6 ms just in launching.&lt;/li>
&lt;li>&lt;strong>Python scheduler overhead&lt;/strong>: the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">vLLM scheduler&lt;/a> loop prepares metadata, decides which requests enter the step, builds the input tensors. In pure Python this is hundreds of µs to a couple of ms per step, especially at low concurrency where it is not amortised.&lt;/li>
&lt;li>&lt;strong>Sampling and post-processing&lt;/strong>: applying temperature, top-p, penalties, the &lt;code>argmax&lt;/code>/multinomial, copying the token back. Another block of hundreds of µs.&lt;/li>
&lt;li>&lt;strong>Synchronisations and host↔device copies&lt;/strong>: every &lt;code>synchronize&lt;/code> or small copy adds latency that is neither FLOPs nor HBM bytes.&lt;/li>
&lt;/ul>
&lt;p>Let us call the sum of all of that &lt;strong>T_fixed&lt;/strong>, the per-step cost independent of model size, on the order of 1-3 ms in an unoptimised Python stack. The real time of a step is now:&lt;/p>
$$T_{\text{step}} \approx \underbrace{\frac{\text{weight bytes}}{\text{BW}}}_{T_{\text{HBM}} \text{ (memory-bound)}} + \; T_{\text{fixed}}$$
&lt;p>In a 70B BF16, moving ~140 GB at 3.35 TB/s takes ~42 ms of &lt;code>T_HBM&lt;/code>. Against that, a &lt;code>T_fixed&lt;/code> of 1-3 ms is &lt;strong>noise (2-7 %)&lt;/strong>. The classic roofline is right: the model &lt;em>is&lt;/em> memory-bound, full stop. But in a 3B BF16, &lt;code>T_HBM&lt;/code> drops to a few ms, and suddenly &lt;code>T_fixed&lt;/code> is the same order as &lt;code>T_HBM&lt;/code>. The bottleneck stops being the pantry and becomes the waiter. This is the flip in the title, and four counterintuitive consequences follow from it:&lt;/p>
&lt;p>&lt;strong>(a) At batch 1 you are still memory-bound &lt;em>with respect to the hardware&lt;/em>.&lt;/strong> The AI has not changed: it is still ~2 FLOP/byte, below the ridge. Anyone reading only the roofline will conclude &amp;ldquo;memory-bound, quantise the weights&amp;rdquo;. That is true but &lt;strong>incomplete&lt;/strong>: the roofline does not see &lt;code>T_fixed&lt;/code>.&lt;/p>
&lt;p>&lt;strong>(b) Fixed costs become a huge fraction of the step.&lt;/strong> This is the central point. In the 70B, &lt;code>T_fixed / T_step ≈ 5 %&lt;/code>. In the 3B it can be 20-30 %. The effective bottleneck of the 3B is half HBM, half orchestration.&lt;/p>
&lt;p>&lt;strong>(c) That is why CUDA graphs and cutting scheduler overhead pay off MORE in SLMs.&lt;/strong> A &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graph&lt;/a> captures the whole kernel sequence of the step and relaunches it with a single &lt;code>cudaGraphLaunch&lt;/code>, removing almost all of the per-kernel launch overhead and much of the per-iteration Python scheduler work. In the 70B, shaving 0.5 ms off a 42 ms step is a +1 % you barely notice. In the 3B, shaving those same 0.5 ms off a ~7 ms step is +7 %, and if you take away nearly all of &lt;code>T_fixed&lt;/code> you can gain 20-30 %. The same optimisation, a different prize, because the denominator changed.&lt;/p>
&lt;p>&lt;strong>(d) Weight quantisation gives LESS latency improvement at batch 1 in SLMs.&lt;/strong> This is the most counterintuitive one. In the 70B, &lt;code>T_HBM&lt;/code> is almost the whole step; going from BF16 to INT4 quadruples the effective weight bandwidth and almost quadruples decode speed. In the 3B, &lt;code>T_HBM&lt;/code> is only &lt;em>part&lt;/em> of the step (the rest is &lt;code>T_fixed&lt;/code> + attention + KV). By Amdahl&amp;rsquo;s law, if the weights are 60 % of the step and you speed them up 4×, the total step improves only &lt;code>1/(0.4 + 0.6/4) = 1.8×&lt;/code>, not 4×. And proportionally there are fewer weights to move relative to activations, KV cache and fixed overhead. Aggressive quantisation in SLMs does help, yes, but &lt;strong>not through raw latency at batch 1&lt;/strong>, where it gives diminishing returns, rather through capacity and concurrency (we will see that at the end).&lt;/p>
&lt;p>&lt;strong>(e) The KV cache can dominate relative memory.&lt;/strong> With 6 GB of weights (3B BF16), a single long-context session can approach that order of magnitude in KV cache. In a 70B (140 GB of weights) the KV is proportionally small until high concurrency. In SLMs the VRAM balance tips towards KV much earlier (the detail is in &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>), and that changes which memory optimisation is the lever.&lt;/p>
&lt;h2 id="the-maths-that-matter-a-3b-on-a-4090">The maths that matter: a 3B on a 4090&lt;/h2>
&lt;p>Let us do the whole calculation, which is where the flip shows up without rhetoric.&lt;/p>
&lt;p>&lt;strong>Model:&lt;/strong> 3B parameters, BF16 → 2 bytes/param → ~6 GB of weights. &lt;strong>Hardware:&lt;/strong> RTX 4090, BW ≈ 1 TB/s.&lt;/p>
&lt;p>&lt;strong>Memory-bound ceiling for decode (batch 1).&lt;/strong> Each token requires loading the 6 GB once:&lt;/p>
$$T_{\text{HBM}} = \frac{6 \times 10^{9} \ \text{bytes}}{1 \times 10^{12} \ \text{bytes/s}} = 6 \times 10^{-3}\ \text{s} = 6\ \text{ms/token}$$
$$\text{Ceiling} = \frac{1}{6\ \text{ms}} \approx 166\ \text{tok/s}$$
&lt;p>That is the theoretical memory-bound ceiling: 166 tok/s, assuming that moving the weights is the only cost. The classic roofline would stop here and say &amp;ldquo;166 tok/s, go get more BW or quantise&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Now the fixed overhead.&lt;/strong> Let us put &lt;code>T_fixed ≈ 2 ms/step&lt;/code> (a reasonable value for a Python scheduler + ~40 kernels launched + sampling, without CUDA graphs). The real step:&lt;/p>
$$T_{\text{step}} = T_{\text{HBM}} + T_{\text{fixed}} = 6 + 2 = 8\ \text{ms} \;\Rightarrow\; \frac{1}{8\ \text{ms}} = 125\ \text{tok/s}$$
&lt;p>The overhead has eaten &lt;strong>41 tok/s out of the theoretical 166&lt;/strong>: &lt;code>T_fixed&lt;/code> is &lt;strong>25 % of the step&lt;/strong> (2 of 8 ms). Compare with the 70B: &lt;code>T_HBM ≈ 42 ms&lt;/code>, &lt;code>T_step ≈ 44 ms&lt;/code>, &lt;code>T_fixed&lt;/code> is 4.5 %. &lt;strong>Same absolute overhead, relative impact 5-6× larger in the SLM.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>What happens if you apply CUDA graphs&lt;/strong> and take away, say, 1.5 of the 2 ms of &lt;code>T_fixed&lt;/code>:&lt;/p>
$$T_{\text{step}}^{\text{graphs}} = 6 + 0.5 = 6.5\ \text{ms} \;\Rightarrow\; 154\ \text{tok/s}$$
&lt;p>From 125 to 154 tok/s: &lt;strong>+23 %&lt;/strong> from orchestration alone, without touching the model or the memory hardware. In the 70B the same intervention would have gone from 44 to 42.5 ms, +3.5 %. There it is, in two numbers: &amp;ldquo;a different game&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>What happens if you quantise the weights to INT4&lt;/strong> (1.5 GB instead of 6 GB), with &lt;code>T_fixed&lt;/code> still at 2 ms:&lt;/p>
$$T_{\text{HBM}}^{\text{INT4}} = \frac{1.5 \times 10^{9}}{1 \times 10^{12}} = 1.5\ \text{ms};\quad T_{\text{step}} = 1.5 + 2 = 3.5\ \text{ms} \;\Rightarrow\; 285\ \text{tok/s}$$
&lt;p>The 4× weight quantisation did &lt;strong>not&lt;/strong> give 4× in latency: it went from 125 to 285 tok/s, a 2.3×, because the 2 ms of &lt;code>T_fixed&lt;/code> now dominates (it is 57 % of the step). In the 70B, quantising to INT4 gives almost the full 4× because &lt;code>T_fixed&lt;/code> is still noise. &lt;strong>The same quantisation delivers twice the speedup on the large model as on the small one&lt;/strong>, at batch 1. And if you also apply CUDA graphs on top of INT4 (&lt;code>T_fixed → 0.5 ms&lt;/code>): &lt;code>1.5 + 0.5 = 2 ms → 500 tok/s&lt;/code>. The order of the optimisations matters: in SLMs, &lt;strong>attacking &lt;code>T_fixed&lt;/code> first&lt;/strong> unlocks the rest.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration (3B, 4090, batch 1)&lt;/th>
&lt;th>T_HBM&lt;/th>
&lt;th>T_fixed&lt;/th>
&lt;th>T_step&lt;/th>
&lt;th>tok/s&lt;/th>
&lt;th>vs. baseline&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16, no graphs (baseline)&lt;/td>
&lt;td>6.0 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>8.0 ms&lt;/td>
&lt;td>125&lt;/td>
&lt;td>1.00×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BF16 + CUDA graphs&lt;/td>
&lt;td>6.0 ms&lt;/td>
&lt;td>0.5 ms&lt;/td>
&lt;td>6.5 ms&lt;/td>
&lt;td>154&lt;/td>
&lt;td>1.23×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4, no graphs&lt;/td>
&lt;td>1.5 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>3.5 ms&lt;/td>
&lt;td>285&lt;/td>
&lt;td>2.28×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 + CUDA graphs&lt;/td>
&lt;td>1.5 ms&lt;/td>
&lt;td>0.5 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>500&lt;/td>
&lt;td>4.00×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;em>(Illustrative figures with &lt;code>T_fixed&lt;/code> rounded; the point is the pattern, not the decimal. The real &lt;code>T_fixed&lt;/code> depends on the stack, the PyTorch/CUDA version and whether there is tensor parallelism. Measure it in your own setup before believing any row.)&lt;/em>&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Per-step time breakdown: large model versus small model">
&lt;text x="390" y="24" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600">Decode time breakdown per step (batch 1)&lt;/text>
&lt;!-- 70B -->
&lt;text x="40" y="78" fill="currentColor" font-size="12" font-weight="600">70B BF16&lt;/text>
&lt;text x="40" y="94" fill="currentColor" font-size="11">step ≈ 44 ms&lt;/text>
&lt;rect x="160" y="60" width="560" height="40" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;rect x="704" y="60" width="16" height="40" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="440" y="85" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">T_HBM (weights from HBM) ≈ 95 %&lt;/text>
&lt;text x="712" y="118" text-anchor="middle" fill="#a52a2a" font-size="10" font-weight="600">T_fixed ~5%&lt;/text>
&lt;!-- 3B -->
&lt;text x="40" y="190" fill="currentColor" font-size="12" font-weight="600">3B BF16&lt;/text>
&lt;text x="40" y="206" fill="currentColor" font-size="11">step ≈ 8 ms&lt;/text>
&lt;rect x="160" y="172" width="420" height="40" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;rect x="580" y="172" width="140" height="40" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="370" y="197" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">T_HBM ≈ 75 %&lt;/text>
&lt;text x="650" y="197" text-anchor="middle" fill="#a52a2a" font-size="11" font-weight="600">T_fixed ≈ 25 %&lt;/text>
&lt;!-- note -->
&lt;text x="160" y="252" fill="currentColor" font-size="11">Same absolute T_fixed (~2 ms): noise in the 70B, a quarter of the step in the 3B.&lt;/text>
&lt;text x="160" y="270" fill="currentColor" font-size="11">CUDA graphs attack the red band → disproportionate impact in the SLM.&lt;/text>
&lt;text x="160" y="288" fill="#5a2db0" font-size="11" font-weight="600">The roofline only models the blue band. The SLM bottleneck lives in the red one.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="implications-per-optimisation">Implications per optimisation&lt;/h2>
&lt;p>With the model in hand, the blog&amp;rsquo;s levers reorder themselves once the regime changes.&lt;/p>
&lt;p>&lt;strong>Batching: far more headroom in SLMs.&lt;/strong> Remember that you cross the ridge at &lt;code>B ≈ ridge/2 ≈ 150&lt;/code> as an order of magnitude. In a large model, VRAM runs out long before you saturate compute (weights + KV do not let you reach batch 150). In an SLM the weights take up little space, so you can fit large batches in VRAM and stay memory-bound over a much wider range: the &lt;code>T_HBM&lt;/code> of the weights is amortised across the B requests (you load them once, they serve B), so aggregate throughput per GPU rises almost linearly with B until very high values. This is exactly the opposite of the 70B&amp;rsquo;s fear of saturating compute. In SLMs, &lt;strong>batching is the throughput lever par excellence&lt;/strong> because you saturate compute late; the &lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">batch grid search in vLLM&lt;/a> has a much wider plateau of good behaviour. Careful: batching improves &lt;em>throughput&lt;/em>, not per-request &lt;em>latency&lt;/em>; for single-stream latency the prize is in &lt;code>T_fixed&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Speculative decoding: a different crossover point.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative&lt;/a> wins when verifying γ tokens is &amp;ldquo;almost free&amp;rdquo; because you are memory-bound. In an SLM the target is already cheap, so the draft has to be tiny for &lt;code>c = T_draft/T_target&lt;/code> to stay small, and the draft&amp;rsquo;s own &lt;code>T_fixed&lt;/code> (launching its kernels) bites harder. The crossover to compute-bound with batching also arrives earlier in absolute terms of tok/s served. The variant that fits best here avoids a separate draft: &lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">self-speculative / early-exit&lt;/a> reuses early layers of the model itself and saves the &lt;code>T_fixed&lt;/code> of orchestrating two models.&lt;/p>
&lt;p>&lt;strong>Quantisation: it helps for capacity, not for latency at batch 1.&lt;/strong> As the table showed, INT4 in an SLM at batch 1 gives diminishing returns in latency. Its real prize in SLMs is &lt;strong>capacity&lt;/strong>: weights 4× smaller free VRAM for more KV cache → more concurrency, and it is at high concurrency (aggregate throughput) that saving bytes pays off again. &lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> takes this to the extreme: in SLMs it makes sense above all for fitting more sessions per GPU, not for lowering the latency of a single one. And it is worth remembering (see &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation&lt;/a>) that at batch 1 the &lt;code>dequantize&lt;/code> adds compute work which, in a regime already grazed by &lt;code>T_fixed&lt;/code>, is not always free.&lt;/p>
&lt;p>&lt;strong>Architecture: fine-grained MoE changes which bytes you move.&lt;/strong> A &lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">fine-grained device-native MoE&lt;/a> activates few parameters per token, so &lt;code>T_HBM&lt;/code> drops relative to a dense model of the same total size, but the &lt;code>T_fixed&lt;/code> fraction rises even further, and the router adds its own fixed overhead. It is the SLM regime taken to its limit: almost the whole game is played in orchestration.&lt;/p>
&lt;p>&lt;strong>Scheduler and CUDA graphs first.&lt;/strong> The operational conclusion, inverted with respect to the large-model posts: in SLMs, &lt;strong>before touching the model, kill &lt;code>T_fixed&lt;/code>&lt;/strong>. CUDA graphs (see &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, streams and graphs&lt;/a>), a &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">vLLM scheduler&lt;/a> with its Python part minimised or compiled, and kernel persistence are the first-order levers. In a 70B they would be marginal polish; in a 3B they are half the available speedup.&lt;/p>
&lt;h2 id="applied-to-on-premise-hardware">Applied to on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB, Ada Lovelace).&lt;/strong> This is the scenario where the flip is most visible, because the 4090 has ~1 TB/s (a third of the H100) but &lt;code>T_fixed&lt;/code> is the same in absolute terms. A 3B BF16 without CUDA graphs leaves ~125 tok/s on the table when the memory-bound ceiling is 166; enabling graphs and cleaning up the scheduler recovers most of that. The 4090 has plenty of VRAM room for an SLM, so the bottleneck is almost never total memory but orchestration and, at high concurrency, the KV cache. Rule of thumb: on a 4090 with an SLM, profile the per-step overhead first (Nsight Systems on the gap between kernels) before you quantise.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> The H100 has 3.35 TB/s, so the &lt;code>T_HBM&lt;/code> of an SLM is even smaller (a 3B FP8 is ~3 GB → ~0.9 ms) and &lt;code>T_fixed&lt;/code> dominates even earlier: a badly orchestrated SLM on an H100 can spend more time in the Python scheduler than moving weights. Serving a single single-stream SLM on an H100 is close to a waste; the right mode is &lt;strong>aggressive batching&lt;/strong> (you saturate compute late, so you push large batches through and per-GPU throughput takes off) or multiplexing many SLMs/sessions per GPU via MPS/MIG. This connects with &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: for SLMs the capacity calculation is governed by concurrency and KV cache, not by the weights. And with the dilemma of &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big versus N small&lt;/a>: replicating SLMs makes sense precisely because each replica saturates compute late and TP brings nothing (the model already fits; TP would only add communication &lt;code>T_fixed&lt;/code>).&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The exact measured &lt;code>T_fixed&lt;/code>&lt;/strong>, kernel by kernel, with Nsight Systems: how much is launch, how much scheduler, how much sampling. That is the content of the next post in the series.&lt;/li>
&lt;li>&lt;strong>&lt;code>torch.compile&lt;/code> / partial captures&lt;/strong>: alternatives and complements to CUDA graphs when there is dynamic control flow.&lt;/li>
&lt;li>&lt;strong>The prefill regime in SLMs&lt;/strong>: prefill is compute-bound even in small models (it processes many tokens at once, high AI), so its roofline is the opposite of decode&amp;rsquo;s; see &lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">prefill&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Attention and KV as the second term of &lt;code>T_HBM&lt;/code>&lt;/strong>: here we have folded them in implicitly; the fine-grained breakdown of attention (which scales with sequence length, not with the weights) deserves its own treatment.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of inference&lt;/a> — the memory-bound nature of decode is born in the KV cache; in SLMs the KV comes to dominate relative VRAM earlier than in large models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">Batch sizing grid search in vLLM&lt;/a> — the plateau of good batch sizes is much wider in SLMs because you cross the ridge late; that post gives the empirical method.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — the concrete flags (CUDA graphs, eager vs captured) whose impact this post reorders for the SLM case.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill in vLLM&lt;/a> — the compute-bound flip side of the roofline: prefill already lives above the ridge even in small models.&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 mechanism that attacks &lt;code>T_fixed&lt;/code>; here we explain why its prize is disproportionate in SLMs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a> — much of &lt;code>T_fixed&lt;/code> lives in this Python loop; in SLMs minimising it is a first-order lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for inference&lt;/a> — why weight quantisation yields less latency at batch 1 in SLMs (Amdahl&amp;rsquo;s law over &lt;code>T_HBM&lt;/code>) and more in capacity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: fundamentals&lt;/a> — the memory/compute crossover point shifts in SLMs, changing when speculative pays off.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — for SLMs, capacity is governed by concurrency and KV, not by the weights; that post gives the formulas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big versus N small&lt;/a> — replicating SLMs beats TP because each replica saturates compute late and TP only adds communication &lt;code>T_fixed&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">Self-speculative decoding / early-exit&lt;/a> — sibling in the series: speeding up without a separate draft, avoiding the &lt;code>T_fixed&lt;/code> of orchestrating two models, a natural fit in SLMs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Fine-grained device-native MoE&lt;/a> — sibling in the series: the SLM regime taken to its limit, where the router and orchestration dominate over &lt;code>T_HBM&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> — sibling in the series: why in SLMs sub-4-bit pays off mostly in capacity/concurrency, not in latency at batch 1.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Williams, S., Waterman, A., Patterson, D. &lt;em>Roofline: An Insightful Visual Performance Model for Multicore Architectures&lt;/em>. Communications of the ACM, 52(4), 2009. &lt;a href="https://doi.org/10.1145/1498765.1498785">https://doi.org/10.1145/1498765.1498785&lt;/a>&lt;/li>
&lt;li>&lt;em>Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference&lt;/em>. arXiv:2503.08311, 2025. &lt;a href="https://arxiv.org/abs/2503.08311">https://arxiv.org/abs/2503.08311&lt;/a>&lt;/li>
&lt;li>Databricks. &lt;em>LLM Inference Performance Engineering: Best Practices&lt;/em>. &lt;a href="https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices">https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices&lt;/a>&lt;/li>
&lt;li>NVIDIA. &lt;em>NVIDIA H100 Tensor Core GPU Datasheet&lt;/em>. &lt;a href="https://resources.nvidia.com/en-us-tensor-core/nvidia-tensor-core-gpu-datasheet">https://resources.nvidia.com/en-us-tensor-core/nvidia-tensor-core-gpu-datasheet&lt;/a>&lt;/li>
&lt;li>NVIDIA. &lt;em>GeForce RTX 4090 — product specifications&lt;/em> (Ada Lovelace tensor core figures; treat as approximate, they mix dense/sparse).&lt;/li>
&lt;li>Yuan, Z. et al. &lt;em>LLM Inference Unveiled: Survey and Roofline Model Insights&lt;/em>. arXiv:2402.16363, 2024 — application of the roofline specifically to LLM inference.&lt;/li>
&lt;/ul></description></item><item><title>The chef who calls out every order: SMs, CUDA streams and CUDA graphs, or why the GPU gets bored generating tokens</title><link>https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/</link><pubDate>Sun, 07 Jun 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/</guid><description>&lt;blockquote>
&lt;p>This closes the &amp;ldquo;outside the API&amp;rdquo; pair. The &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> moved the weights from disk into HBM; here we look at what happens &lt;strong>once they are inside&lt;/strong>, in the silicon that executes them. It is the floor below the kernel launch that the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a> mentioned without opening: &lt;em>who&lt;/em> launches those kernels, &lt;em>how&lt;/em>, and why in decode the GPU spends more time waiting for orders than computing.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An H100 has &lt;strong>~132 streaming multiprocessors (SMs)&lt;/strong>, the &amp;ldquo;hobs&amp;rdquo; that execute the compute, and &lt;strong>occupancy&lt;/strong> measures how many &lt;em>warps&lt;/em> (groups of 32 threads) it has active to hide latency. But the &lt;strong>decode&lt;/strong> bottleneck is rarely the power of those SMs. Each decode step launches &lt;strong>hundreds of tiny kernels&lt;/strong> (several projections per layer × ~80 layers), and &lt;strong>each kernel launch costs 5-10 µs of serial CPU&lt;/strong>. Since decode kernels are small (small batch, a single token), the GPU &lt;strong>finishes them before the CPU calls out the next one&lt;/strong>: bubbles appear and the GPU sits bored waiting for orders. That regime is called &lt;strong>launch-bound&lt;/strong>, and it is the deep reason, not power, not memory, why &lt;code>--enforce-eager&lt;/code> delivers &lt;strong>54 tok/s&lt;/strong> where with optimisations you reach &lt;strong>89-140&lt;/strong>. The solution is &lt;strong>CUDA graphs&lt;/strong>: record the entire kernel sequence &lt;strong>once&lt;/strong> and replay it as &lt;strong>a single submission&lt;/strong>, removing the per-launch overhead (~28% of the latency per iteration). vLLM captures ~&lt;strong>102 graphs&lt;/strong> at startup and &lt;strong>pads&lt;/strong> the real batch to the nearest &lt;em>bucket&lt;/em> so it can replay a graph with fixed shapes. This post explains SM, occupancy, streams, launch overhead with the maths, CUDA graphs, the 10 knobs, and the trap that this capture &lt;strong>is the second half of the cold start&lt;/strong> from the previous post. With scepticism about what actually moves the needle. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-silicon-below-the-kernel-launch">Where you are: the silicon, below the kernel launch&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are in the execution silicon, below the host kernel launch">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are in the silicon&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Engine · vLLM (batching, sampling, scheduler)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Host · CPU launches kernels (NUMA post)&lt;/text>
&lt;rect x="120" y="128" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · CUDA: streams, kernels, CUDA graphs&lt;/text>
&lt;text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">the order queue that reaches the silicon&lt;/text>
&lt;rect x="120" y="192" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="216" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">SMs · 132 hobs execute the warps&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 3.35 TB/s — the weights the SMs read&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">the post's question: are the SMs computing, or waiting for orders?&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-chef-who-calls-out-every-order">The analogy: the chef who calls out every order&lt;/h2>
&lt;p>Last scene in the restaurant of the series. The kitchen is set up, the pantry is stocked (the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a>). Now the food has to be &lt;strong>plated&lt;/strong>. The &lt;strong>hobs&lt;/strong> are the SMs: 132 stations cooking in parallel. The &lt;strong>head chef&lt;/strong> is the CPU: he calls out the orders, each &lt;em>kernel launch&lt;/em> is a shout of &amp;ldquo;one matrix multiplication, coming up!&amp;rdquo;. The cooks (the SMs) execute what the chef calls out.&lt;/p>
&lt;p>In &lt;strong>prefill&lt;/strong>, processing the whole prompt, each order is a huge dish: a giant matmul over hundreds of tokens at once. The chef calls out an order and the hobs take a good while to produce it. The chef has plenty of time to call out the next one. The hobs are &lt;strong>flat out&lt;/strong>: compute-bound.&lt;/p>
&lt;p>In &lt;strong>decode&lt;/strong>, generating one token at a time, each order is minuscule: a matmul over &lt;strong>a single token&lt;/strong>. The hob finishes it in an instant&amp;hellip; and sits staring at the chef waiting for the next one. But the chef can only call out &lt;strong>one order every 5-10 µs&lt;/strong>, and there are &lt;strong>hundreds of orders per token&lt;/strong>. The hobs, lightning fast, &lt;strong>get bored&lt;/strong> between shouts. The restaurant is not slow because the cooks are bad: it is slow because &lt;strong>the chef does not call out fast enough&lt;/strong>. That is the &lt;em>launch-bound&lt;/em> regime.&lt;/p>
&lt;p>The solution is not more hobs or faster cooks. It is &lt;strong>to stop calling out order by order&lt;/strong>. If the chef prints &lt;strong>the whole night&amp;rsquo;s sequence on a single sheet&lt;/strong> and hands it to the line (&amp;ldquo;do this, in this order, without waiting for me&amp;rdquo;), the hobs run without pauses. That is a &lt;strong>CUDA graph&lt;/strong>: record the kernel sequence once and replay it in one go, without the CPU calling out each one. And &lt;code>--enforce-eager&lt;/code> is exactly the opposite: forcing the chef to call out order by order, all night long.&lt;/p>
&lt;h2 id="the-mechanism-sm-warps-and-occupancy">The mechanism: SM, warps and occupancy&lt;/h2>
&lt;p>An H100 SXM has &lt;strong>~132 SMs&lt;/strong>. Each SM executes threads in groups of 32 called &lt;strong>warps&lt;/strong>, and can have several warps &amp;ldquo;in flight&amp;rdquo; at once. &lt;strong>Occupancy&lt;/strong> is the fraction of active warps against the maximum the SM supports. What is the point of having many active warps? To &lt;strong>hide latency&lt;/strong>: while one warp waits for data from HBM (hundreds of cycles), the SM executes another ready warp. With few warps, the SM runs out of anyone to give a turn to and stalls.&lt;/p>
&lt;p>But, and this is key, occupancy is a &lt;strong>necessary, not sufficient&lt;/strong> condition, and it only matters if the SM &lt;strong>has work to do&lt;/strong>. In decode, the typical problem is not low occupancy &lt;strong>inside&lt;/strong> a kernel: it is that &lt;strong>between&lt;/strong> kernels the SM has nothing, because the CPU has not launched the next one yet. Raising the occupancy of a kernel that lasts 8 µs does not help if the GPU spends 6 µs waiting for it to be launched.&lt;/p>
&lt;h2 id="the-mechanism-streams-the-order-queue">The mechanism: streams, the order queue&lt;/h2>
&lt;p>A &lt;strong>CUDA stream&lt;/strong> is a queue of operations that the GPU executes &lt;strong>in order&lt;/strong>. Operations in the same stream are sequential; operations in different streams can &lt;strong>overlap&lt;/strong>. That is what allows, for instance, copying data H2D in one stream while another stream computes, the compute/copy overlap. vLLM uses streams to overlap work, but a stream on its own does &lt;strong>not remove&lt;/strong> the cost of launching each kernel: it only decides ordering and parallelism. The launch cost is still there, order by order, until the graphs come in.&lt;/p>
&lt;h2 id="the-maths-that-matters-when-the-gpu-is-left-waiting">The maths that matters: when the GPU is left waiting&lt;/h2>
&lt;p>The number that governs everything: &lt;strong>a kernel launch costs 5-10 µs of CPU&lt;/strong>, serially. Take a Llama-70B with ~80 layers. Each layer, without fusion, launches on the order of &lt;strong>~10 kernels&lt;/strong> (Q/K/V projections, attention, output projection, the two or three MLP matmuls, the normalisations, RoPE and so on). That gives:&lt;/p>
$$ N_{\text{kernels}} \approx 80 \text{ layers} \times 10 \approx 800 \text{ launches per token} $$
&lt;p>At 5 µs per launch, &lt;strong>serially&lt;/strong>:&lt;/p>
$$ T_{\text{launch}} \approx 800 \times 5\,\mu s = 4{.}0 \text{ ms per token} $$
&lt;p>Those 4 ms are &lt;strong>CPU calling out orders only&lt;/strong>, not counting how long the SMs take to cook. If the GPU could compute instantaneously, the launch ceiling would be ~250 tok/s, and with synchronisation points between kernels, worse. Now compare with the &lt;strong>memory&lt;/strong> ceiling of decode: each token reads the 140 GB of weights once from HBM:&lt;/p>
$$ T_{\text{mem}} = \frac{140 \text{ GB}}{3{.}35 \text{ TB/s}} \approx 42 \text{ ms} \;\Rightarrow\; \approx 24 \text{ tok/s (one sequence, no batch)} $$
&lt;p>Here is the subtlety almost nobody has in mind. For &lt;strong>a single sequence&lt;/strong>, decode is memory-bound at ~24 tok/s, and the 4 ms of launch fit inside the 42 ms of reading: the launch hides. &lt;strong>But batching changes everything.&lt;/strong> When serving a batch of B sequences, the weights are read &lt;strong>once&lt;/strong> and serve all B, so the memory cost per token amortises and falls. The GPU stops being memory-bound&amp;hellip; and what was underneath emerges: the launch cost, which &lt;strong>does not amortise with batch&lt;/strong> because you have to launch the same kernel sequence regardless. The result: &lt;strong>the better you batch, the more launch-bound you become&lt;/strong>, and the more CUDA graphs pay off. That is why the raw measurement confirms it, &lt;code>--enforce-eager&lt;/code> gives &lt;strong>54 tok/s&lt;/strong> where graphs give &lt;strong>89&lt;/strong>, and up to &lt;strong>8×&lt;/strong> in configurations where decode is very small and launch dominates completely.&lt;/p>
&lt;div class="diagram" style="max-width:680px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Eager launches kernel by kernel with bubbles; the CUDA graph replays everything in one go with no gaps">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Eager vs CUDA graph on the GPU timeline&lt;/text>
&lt;text x="60" y="58" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Eager (order by order)&lt;/text>
&lt;rect x="60" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="118" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="176" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="234" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="292" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="100" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="158" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="216" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="274" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;text x="340" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">kernel&lt;/text>
&lt;text x="430" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">↑ gaps = GPU waiting for the CPU to launch&lt;/text>
&lt;text x="60" y="150" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">CUDA graph (whole sheet)&lt;/text>
&lt;rect x="60" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="100" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="140" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="180" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="220" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;text x="340" y="176" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">no gaps: a single submission, replay&lt;/text>
&lt;line x1="60" y1="210" x2="620" y2="210" stroke="currentColor" stroke-width="1.2" opacity="0.4"/>
&lt;text x="60" y="228" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.8">time → same kernels, same GPU; the graph removes the launch bubbles&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="vllms-cuda-graphs-specifically">vLLM&amp;rsquo;s CUDA graphs, specifically&lt;/h2>
&lt;p>vLLM does not capture a single graph: it captures &lt;strong>~102&lt;/strong> at startup, on the order of 51 &lt;em>piecewise&lt;/em> (for the mixed prefill+decode steps) and 51 &lt;em>full&lt;/em> (for pure decode). Each one is recorded for a &lt;strong>fixed batch size&lt;/strong> (a &lt;em>bucket&lt;/em>: 1, 2, 4, 8&amp;hellip; up to a maximum). In service the real batch almost never falls exactly on a bucket, so vLLM &lt;strong>pads with zeros&lt;/strong> up to the next bucket up, replays that graph, and trims the output to the real size. That is the price of graphs: they need &lt;strong>static shapes&lt;/strong>, and padding is what makes them static.&lt;/p>
&lt;p>This has two consequences that show up in the knobs and the traps:&lt;/p>
&lt;p>&lt;strong>Capture costs time and memory.&lt;/strong> Recording 102 graphs at startup adds seconds to the cold start, &lt;strong>the second half&lt;/strong> of the startup that the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> left pending, and consumes HBM (each graph retains its buffers). The &lt;code>FULL_AND_PIECEWISE&lt;/code> mode (the default) is the fastest in service but the one that asks for the most memory and the longest capture; &lt;code>FULL_DECODE_ONLY&lt;/code> saves both in exchange for not accelerating the mixed steps.&lt;/p>
&lt;p>&lt;strong>Padding wastes some compute.&lt;/strong> Padding a batch of 33 up to the bucket of 64 computes 31 phantom sequences. It is a small cost against what removing the launch overhead saves, but it exists, and it grows if the buckets are badly chosen.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;h3 id="knob-1--measure-whether-decode-is-launch-bound">Knob 1 — Measure whether decode is launch-bound&lt;/h3>
&lt;p>Before touching anything: is the GPU computing or waiting? With &lt;code>nsys&lt;/code> (Nsight Systems) you can see the &lt;strong>gaps between kernels&lt;/strong> on the timeline; if there are gaps in decode, it is launch-bound and graphs will help. If the GPU is at 100% with no gaps, the bottleneck is something else (memory or compute) and graphs will not work miracles. &lt;code>nvidia-smi dmon&lt;/code> showing low utilisation in decode but poor TPS is the cheap signal.&lt;/p>
&lt;h3 id="knob-2--do-not-use---enforce-eager-in-production">Knob 2 — Do not use &lt;code>--enforce-eager&lt;/code> in production&lt;/h3>
&lt;p>&lt;code>--enforce-eager&lt;/code> &lt;strong>disables CUDA graphs&lt;/strong>. It is a &lt;strong>debugging&lt;/strong> tool (to isolate which kernel fails), not a production one. Leaving it on &amp;ldquo;because it used to start up&amp;rdquo; throws away 26-50% of decode throughput. If it is in your production command, take it out and measure.&lt;/p>
&lt;h3 id="knob-3--capture-buckets-cudagraph_capture_sizes">Knob 3 — Capture buckets (&lt;code>cudagraph_capture_sizes&lt;/code>)&lt;/h3>
&lt;p>Which batch sizes to capture. Buckets too far apart make padding expensive; too many of them make capture slow and eat HBM. Tuning them to the &lt;strong>real distribution&lt;/strong> of batch sizes you see in production is the fine adjustment, but only after measuring that distribution.&lt;/p>
&lt;h3 id="knob-4--cuda-graph-mode">Knob 4 — CUDA graph mode&lt;/h3>
&lt;p>&lt;code>FULL_AND_PIECEWISE&lt;/code> (default, fastest, more memory/capture), &lt;code>FULL_DECODE_ONLY&lt;/code> (saves memory and capture, ideal for pure decode pods in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>), &lt;code>PIECEWISE&lt;/code>, or &lt;code>NONE&lt;/code> (= eager). The right mode depends on whether the pod does pure or mixed decode.&lt;/p>
&lt;h3 id="knob-5--torchcompile">Knob 5 — &lt;code>torch.compile&lt;/code>&lt;/h3>
&lt;p>vLLM leans on &lt;code>torch.compile&lt;/code> to fuse and optimise kernels before capturing them in graphs. Fewer kernels (fusion) = fewer launches = less dependence on the graph and better decode even in eager. The compilation level is a knob, with its startup time cost.&lt;/p>
&lt;h3 id="knob-6--batch-size-fill-the-hobs">Knob 6 — Batch size: fill the hobs&lt;/h3>
&lt;p>Memory-bound decode amortises with batching (as we saw in &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>): read the weights once for B sequences. More batch = more SM occupancy &lt;strong>and&lt;/strong> more memory amortisation. The limit is set by the HBM available for the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>. It is the knob that moves aggregate throughput most.&lt;/p>
&lt;h3 id="knob-7--do-not-break-stream-overlap">Knob 7 — Do not break stream overlap&lt;/h3>
&lt;p>vLLM overlaps compute and copy with streams. Patching the code to &amp;ldquo;simplify&amp;rdquo; can serialise what was overlapped. If you do not know why there are several streams, do not collapse them.&lt;/p>
&lt;h3 id="knob-8--persistence-mode--locked-clocks">Knob 8 — Persistence mode + locked clocks&lt;/h3>
&lt;p>&lt;code>nvidia-smi -pm 1&lt;/code> keeps the driver resident (avoiding reinitialisations that add launch latency). Locking clocks to the boost frequency stops the GPU dropping P-state between tiny decode kernels and paying the ramp-up latency. It is the same &lt;em>anti-jitter&lt;/em> spirit as the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a>, applied to the GPU.&lt;/p>
&lt;h3 id="knob-9--fused-kernels-flashattention-fp8-kernels">Knob 9 — Fused kernels (FlashAttention, FP8 kernels)&lt;/h3>
&lt;p>Fewer kernels = fewer orders to call out. &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">FlashAttention&lt;/a> fuses attention into one kernel instead of several; fused FP8 kernels reduce the count. Fusion attacks the problem at the root: it does not speed up the launch, it &lt;strong>removes launches&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--accept-the-capture-cost-in-the-cold-start">Knob 10 — Accept the capture cost in the cold start&lt;/h3>
&lt;p>Graph capture adds seconds to startup. In a pod that lives for hours, it amortises easily. In one that scales up and down every minute, that cost is paid over and over; there, &lt;code>FULL_DECODE_ONLY&lt;/code> (shorter capture) or accepting slightly less throughput can be worth it. It is the same warm-vs-elastic tension as in the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it attacks&lt;/th>
&lt;th>Risk / cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>nsys&lt;/code> / &lt;code>dmon&lt;/code>&lt;/td>
&lt;td>knowing whether it is launch-bound&lt;/td>
&lt;td>none; do it first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>remove &lt;code>--enforce-eager&lt;/code>&lt;/td>
&lt;td>graphs disabled&lt;/td>
&lt;td>it was for debugging; reactivate if a bug returns&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>capture buckets&lt;/td>
&lt;td>expensive padding / slow capture&lt;/td>
&lt;td>requires measuring the real distribution&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>graph mode&lt;/td>
&lt;td>memory and capture&lt;/td>
&lt;td>less coverage on mixed steps&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>torch.compile&lt;/code>&lt;/td>
&lt;td>unfused kernels&lt;/td>
&lt;td>startup time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>batch size&lt;/td>
&lt;td>occupancy + memory&lt;/td>
&lt;td>HBM for KV cache&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>streams&lt;/td>
&lt;td>broken overlap&lt;/td>
&lt;td>do not touch if not understood&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>persistence + clocks&lt;/td>
&lt;td>jitter / P-states&lt;/td>
&lt;td>electrical consumption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>fused kernels&lt;/td>
&lt;td>number of launches&lt;/td>
&lt;td>kernel compatibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>capture vs cold start&lt;/td>
&lt;td>slower startup&lt;/td>
&lt;td>less throughput if trimmed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-this-connects-with-the-rest-of-the-stack">How this connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the cold start.&lt;/strong> CUDA graph capture is the &lt;strong>second half&lt;/strong> of the startup that the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> opened: loading weights + capturing graphs = the complete cold start.&lt;/p>
&lt;p>&lt;strong>With continuous batching.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> is what &lt;strong>makes decode launch-bound&lt;/strong> (it amortises memory and leaves the launch exposed), and that is why graphs and batching reinforce each other.&lt;/p>
&lt;p>&lt;strong>With the KV cache.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> decides how much batch fits in HBM, and the batch decides SM occupancy and how much launch overhead matters. Everything is coupled through memory.&lt;/p>
&lt;p>&lt;strong>With the interconnect.&lt;/strong> In TP, between the compute kernels there are &lt;strong>all-reduces&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink/NCCL&lt;/a>) that also get launched and synchronised. vLLM&amp;rsquo;s custom all-reduce is integrated into the same graph so the sequence is not broken by a CPU synchronisation.&lt;/p>
&lt;p>&lt;strong>With NUMA.&lt;/strong> &lt;em>Who&lt;/em> launches the kernels is the CPU from the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>; if that thread suffers jitter or lands on the wrong socket, launch overhead gets worse. Graphs reduce the dependence on that thread, which is another reason they help.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> The &lt;strong>pure decode&lt;/strong> pods of &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>: they maximise the graph benefit exactly in the most launch-bound phase.&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 occupancy will fix slow decode.&amp;rdquo;&lt;/strong> Not if the problem is launch-bound. Occupancy matters &lt;strong>inside&lt;/strong> a kernel that has work; if the GPU is idle &lt;strong>between&lt;/strong> kernels waiting for the CPU, more occupancy does not touch that bubble. Measure before optimising what is not the bottleneck.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;CUDA graphs always speed things up.&amp;rdquo;&lt;/strong> They speed things up when decode is launch-bound. If the GPU is already at 100% (compute-bound in prefill, or memory saturated with a huge batch), graphs add little. Their territory is decode with small kernels.&lt;/p>
&lt;p>&lt;strong>&amp;quot;&lt;code>--enforce-eager&lt;/code> gives more stable results.&amp;quot;&lt;/strong> It gives &lt;strong>slower&lt;/strong> results. The stability it appears to give is that it avoids graph capture bugs on new hardware (for example a newly supported architecture). It is a temporary patch, not a production configuration.&lt;/p>
&lt;p>&lt;strong>Capturing too many buckets &amp;ldquo;just in case&amp;rdquo;.&lt;/strong> Each bucket adds capture time and HBM. Capturing 30 sizes when in production you only see 4 means paying cold start and memory for graphs that are never replayed. Tune to the real distribution.&lt;/p>
&lt;p>&lt;strong>Confusing utilisation with efficiency.&lt;/strong> &lt;code>nvidia-smi&lt;/code> at 100% &amp;ldquo;utilisation&amp;rdquo; only says &lt;strong>there is a kernel running&lt;/strong>, not that the SM is full of useful work. A low-occupancy kernel keeps &amp;ldquo;utilisation&amp;rdquo; high while wasting the SM. &lt;code>nvidia-smi&lt;/code> utilisation is a coarse thermometer; to know whether the silicon is performing you need &lt;code>nsys&lt;/code>/DCGM and to look at real occupancy and gaps.&lt;/p>
&lt;p>&lt;strong>Optimising the silicon before memory.&lt;/strong> If decode is limited by HBM bandwidth (large batch, large model), fighting with graphs and occupancy is polishing what is not the bottleneck. The right order: measure the regime (memory / compute / launch) and attack the one in charge.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Intuition says that a GPU generating slow tokens is &amp;ldquo;working hard&amp;rdquo;. Almost never: in decode it is &lt;strong>waiting for orders&lt;/strong>. The 132 SMs cook a tiny token in an instant and then sit staring at the CPU, which can only call out one order every 5-10 µs and has hundreds to call out per token. That bottleneck, neither power nor memory but &lt;strong>launch&lt;/strong>, is invisible on any dashboard that looks at &amp;ldquo;GPU utilisation&amp;rdquo;, and it is the real reason why &lt;code>--enforce-eager&lt;/code> performs at half speed. CUDA graphs solve it with a simple idea: stop calling out order by order and hand over the &lt;strong>whole sheet&lt;/strong> for the night, so the silicon runs without pauses. And there is an uncomfortable truth that reorders the optimisation priority: &lt;strong>the better you batch, the more launch-bound you become&lt;/strong>, because batching kills the memory bottleneck and exposes the launch one. That is why graphs and batching are not separate optimisations: they are the same lever seen from two sides. The chef who learns not to call out every dish is what finally makes the kitchen run as fast as the hobs always could.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — &lt;em>who&lt;/em> assembles the batch whose sizes should land in the CUDA graph capture buckets; scheduler and graphs are coupled through batch size.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the first half of startup; the graph capture in this post is the second half of the same cold start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">The floor next door: NUMA, hugepages and CPU isolation&lt;/a> — &lt;em>who&lt;/em> launches the kernels is that host thread; its jitter is the launch overhead graphs reduce.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">The shared table: NVLink, NVSwitch and NCCL&lt;/a> — the TP all-reduces are launched and synchronised between kernels; vLLM&amp;rsquo;s custom all-reduce is integrated into the same graph.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — what makes decode launch-bound by amortising memory; hence batching and graphs reinforce each other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — the memory that decides how much batch fits, and therefore SM occupancy and how much launch overhead weighs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — pure decode pods are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a> — fused FP8 kernels reduce the number of launches at the root.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — where real occupancy shows up, and the counters that separate &amp;ldquo;utilisation&amp;rdquo; from efficiency.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>CUDA Graphs&lt;/em> (diseño, modos FULL/PIECEWISE, captura): &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Inside vLLM: Anatomy of a High-Throughput LLM Inference System&lt;/em>: &lt;a href="https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html">https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Getting Started with CUDA Graphs&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/cuda-graphs/">https://developer.nvidia.com/blog/cuda-graphs/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Achieved Occupancy&lt;/em> (ocupación de SM): &lt;a href="https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm">https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm&lt;/a>.&lt;/li>
&lt;li>PyTorch, &lt;em>torch.compile y CUDA Graphs para inferencia LLM&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>&lt;em>Understanding the Overheads of Launching CUDA Kernels&lt;/em> (ICPP 2019): &lt;a href="https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf">https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>