<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Cold-Start on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/cold-start/</link><description>Recent content in Cold-Start on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 31 Aug 2026 10:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/cold-start/index.xml" rel="self" type="application/rss+xml"/><item><title>Knative and scale-to-zero for LLM inference: when switching off the GPU saves money and when it costs you the SLO</title><link>https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/</link><pubDate>Mon, 31 Aug 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/</guid><description>&lt;blockquote>
&lt;p>Closing the platform and self-service run, after &lt;a href="https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/">Backstage&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/">Kubeflow&lt;/a>. The question here is purely economic with a technical trap inside: switching off the GPU when nobody is using it looks like free money, until the first user of the day waits two minutes.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A GPU costs the same serving as it does idle. An H100 on dedicated cloud runs at 2 to 4 euros an hour whether it is used or not, so switching the service off when there is no traffic looks like money given away. Knative is the piece that does it: its autoscaler scales down to zero replicas and brings them back when a request arrives, measuring concurrency instead of CPU, which is what the Kubernetes autoscaler cannot do.&lt;/p>
&lt;p>The trap is in the cold start. Knative brings up a pod in seconds, but that is only the beginning: an LLM has to load its weights from disk into HBM and compile its graphs, and there you go from tens of seconds to minutes depending on model size and storage. One reference study measures vLLM startup at around 20 seconds even for a 3 billion parameter model, and weight loading grows linearly with size. The first user of every traffic trough pays that whole wait.&lt;/p>
&lt;p>Hence the rule. Switching off the GPU pays off with sporadic traffic, many lightly used models or development environments, and it is a trap with a strict latency SLO, sustained traffic or huge models whose startup breaks any interactive budget. Between all and nothing there are three middle grounds that almost always win: keep one replica warm, share the GPU with model-swap, or speed up weight loading so that startup fits inside the autoscaler&amp;rsquo;s cycle.&lt;/p>
&lt;h2 id="the-analogy-the-restaurant-that-switches-off-the-kitchen">The analogy: the restaurant that switches off the kitchen&lt;/h2>
&lt;p>A restaurant with the kitchen running at four in the afternoon, without a single customer, burns gas for nothing. The temptation is to switch it off and light it again when somebody walks in. The problem is the time it takes to heat up. If the kitchen is an induction hob, it heats in seconds and switching it off between services is obvious. If it is a wood-fired oven that takes two hours to come up to temperature, switching it off means the first customer of the night eats cold or leaves.&lt;/p>
&lt;p>Serving a small model is an induction hob. Serving a 70 billion parameter model is the wood-fired oven: the weights that have to go up into HBM are the wood you have to burn until you reach temperature, and wishing does not speed it up. The decision to switch off the kitchen, that is, to scale to zero, does not depend on whether you want to save gas, but on how long your kitchen takes to heat and on how long your first customer is willing to wait. This whole article is that calculation.&lt;/p>
&lt;h2 id="how-knative-scales">How Knative scales&lt;/h2>
&lt;p>Knative graduated in the CNCF in October 2025 and has three components; the one that concerns us is &lt;strong>Serving&lt;/strong>, the serverless application component with autoscaling. Its object model has four pieces: the high-level &lt;code>Service&lt;/code>, the &lt;code>Configuration&lt;/code> that describes the desired state, the immutable &lt;code>Revision&lt;/code> which is what actually scales, and the &lt;code>Route&lt;/code> that splits traffic between revisions.&lt;/p>
&lt;p>What makes scaling to zero possible are two data plane components. The &lt;strong>queue-proxy&lt;/strong> is a sidecar in every pod that measures concurrency and applies the simultaneous request limit. The &lt;strong>activator&lt;/strong> is the key piece: when the service is at zero replicas, requests are routed to it, and it &lt;strong>parks them in a queue, tells the autoscaler that capacity is needed, and forwards the parked request&lt;/strong> once a pod is ready. It is the waiter who takes your order and tells you the kitchen is warming up, instead of shutting the door on you.&lt;/p>
&lt;h3 id="kpa-versus-hpa">KPA versus HPA&lt;/h3>
&lt;p>Knative&amp;rsquo;s own autoscaler, the KPA, measures &lt;strong>concurrency and requests per second&lt;/strong>, not CPU or memory. That is the difference that matters: the Kubernetes autoscaler, the HPA, scales on CPU and &lt;strong>does not know how to go down to zero&lt;/strong>, its operational minimum is one. To switch off completely you need the KPA.&lt;/p>
&lt;p>The KPA works with two windows. The &lt;strong>stable&lt;/strong> one, 60 seconds by default, averages normal traffic. The &lt;strong>panic&lt;/strong> one, much shorter, reacts to sharp spikes: when demand exceeds twice the capacity of the current replicas, it enters panic mode and scales in one go. The default concurrency target per replica is 100, but for LLM inference the useful value is usually 1 to 4, because each request occupies the GPU heavily; the official KServe example uses a target of 1.&lt;/p>
&lt;p>Two timers govern the shutdown. The &lt;code>scale-to-zero-grace-period&lt;/code>, 30 seconds by default, is how long the system waits to have the restart machinery ready before withdrawing the last replica. The &lt;code>scale-to-zero-pod-retention-period&lt;/code>, zero by default, is the minimum time the last pod survives after the decision to switch off. Raising this second value is one of the levers for not switching off as soon as there is a micro-trough in traffic.&lt;/p>
&lt;h2 id="why-an-llm-cold-start-is-not-starting-a-pod">Why an LLM cold start is not starting a pod&lt;/h2>
&lt;p>Here is the core of the article, and there is a nuance that most discussions skip. Knative bringing up a pod in seconds does not mean the model is ready in seconds. Starting an inference service has several phases, and the two costs that dominate are not the ones you would expect.&lt;/p>
&lt;p>A recent study measuring vLLM cold start breaks it down step by step. For a 3 billion parameter model, total startup is around &lt;strong>20 seconds&lt;/strong>, and the counterintuitive finding is that &lt;strong>most of it is CPU-bound&lt;/strong>, not GPU-bound: process initialisation, library imports, engine construction. Inside that there are two costs that grow, and they are the ones the acceleration techniques attack.&lt;/p>
&lt;p>The first is &lt;strong>weight loading&lt;/strong>, which scales linearly with model size. In that same study it goes from half a second for a small model to almost five for a 16 billion one. Projected onto real sizes, the on-disk weight rules: an 8 billion model takes around 16 GB in FP16 and 8 in FP8; a 70 billion one, 140 and 70; a 405 billion one, 810 and 405. Loading 140 GB from disk into HBM does not happen in a blink, and if storage is slow or sits on the network, the minute arrives on its own.&lt;/p>
&lt;p>The second is &lt;strong>graph compilation&lt;/strong>. Graph transformation and capture with &lt;code>torch.compile&lt;/code> costs 11 to 21 seconds if there is no compilation cache, and drops to 3 to 6 with one. It is half a battle that has nothing to do with the weights, and it is won by keeping the compilation cache between startups.&lt;/p>
&lt;p>The operational conclusion is that the enemy is not only HBM: it is weight loading plus compilation. For small models the weights are only a fraction of startup, and switching off the GPU is almost painless. For large models the weights dominate, and the cold start becomes the problem. Loading a 70 billion model with the naive loader of the usual library can run to &lt;strong>several minutes&lt;/strong>, and that is exactly the time your first user waits.&lt;/p>
&lt;h3 id="the-techniques-that-shorten-the-wait">The techniques that shorten the wait&lt;/h3>
&lt;p>The good news is that the cold start can be attacked on both fronts. On the weights side, streaming loaders such as NVIDIA&amp;rsquo;s transfer the weights to the GPU concurrently instead of sequentially: the published data takes an 8 billion model from around 48 seconds with the standard loader to around 14 with streaming from SSD, and to under 8 with fast storage. The most graphic case is a 122 billion model, 233 GB on disk, which goes from three and a half minutes to around 37 seconds. That number has a direct consequence: &lt;strong>37 seconds fit inside an autoscaler polling cycle of 30 to 60 seconds; three and a half minutes do not.&lt;/strong> It is the difference between scaling to zero being viable with large models or not being viable at all.&lt;/p>
&lt;p>On the compilation side, the technique is cheaper still: keep the &lt;code>torch.compile&lt;/code> cache between startups, and with it the 11 to 21 seconds become 3 to 6. Tools such as &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Tensorizer&lt;/a> and OCI images with the weights packaged inside attack the same problem from another angle, as we saw in the article on &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">speeding up cold start&lt;/a>.&lt;/p>
&lt;h2 id="knative-and-kserve-the-two-modes">Knative and KServe: the two modes&lt;/h2>
&lt;p>KServe leans on Knative for its serverless mode, and this is what connects everything above to a real platform. KServe has two deployment modes.&lt;/p>
&lt;p>The &lt;strong>Serverless&lt;/strong> mode uses Knative Serving and the KPA, &lt;strong>supports scaling to zero&lt;/strong> and measures concurrency and requests per second. It is enabled by setting &lt;code>minReplicas: 0&lt;/code> in the &lt;code>InferenceService&lt;/code>. It is the natural path for serving many intermittent models.&lt;/p>
&lt;p>The &lt;strong>Standard&lt;/strong> or &lt;em>raw&lt;/em> mode uses the Kubernetes HPA, &lt;strong>does not scale to zero&lt;/strong>, and measures CPU and memory. You pick it when you need something Knative restricts, such as mounting several volumes, and you do not need to switch off completely.&lt;/p>
&lt;p>There is a third path of particular interest to anyone who already has &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">KEDA&lt;/a> deployed: in Standard mode, KServe recommends &lt;strong>KEDA&lt;/strong> for generative inference, and KEDA &lt;strong>can&lt;/strong> scale to zero on Prometheus metrics such as queue length or tokens per second. It is a route to scaling to zero on your own metrics, outside Knative, and it fits better with autoscaling guided by the model&amp;rsquo;s real saturation signal rather than by generic concurrency.&lt;/p>
&lt;p>For anyone serving many lightweight predictive models there is also &lt;strong>ModelMesh&lt;/strong>, the high-density pattern that packs many models into the same pods and rotates them in memory, without leaning on Knative. It is the equivalent of model-swap: instead of switching off and on per model, it shares one GPU between many. It is aimed at classic short-response inference, not at generative LLMs.&lt;/p>
&lt;h2 id="the-economics">The economics&lt;/h2>
&lt;p>The decision comes down to comparing what you save by switching off with what you risk at startup. The saving is easy to estimate. If a GPU costs \( C \) euros per hour and your service is idle \( H \) hours a day, switching it off saves on the order of&lt;/p>
$$\text{daily saving} \approx C \cdot H$$
&lt;p>With an H100 at 3 euros an hour and 16 idle hours a day, that is 48 euros a day per GPU, on the order of 1,400 a month. Across a fleet of several lightly used models the figure multiplies and stops being negligible.&lt;/p>
&lt;p>The cost is on the other side, and it is not measured in euros: it is measured in SLO. Scaling to zero only pays off if the cold start &lt;strong>fits inside what your worst-case latency tolerates&lt;/strong>. With a small model and fast storage, tens of seconds at most, and for an internal assistant used in bursts that may be acceptable. With a 70 billion model and the naive loader, minutes, and no interactive SLO survives that.&lt;/p>
&lt;p>Between switching off completely and never switching off there are three middle grounds, and almost always one of them is the right answer:&lt;/p>
&lt;p>&lt;strong>Warm replica&lt;/strong>, with &lt;code>minReplicas: 1&lt;/code>. You remove the first user&amp;rsquo;s cold start in exchange for paying a continuous GPU-hour. The break-even point is direct: keeping it warm pays off when the cost of that GPU running hot is lower than the reputational or SLO cost of the first user waiting. For a customer-facing service, almost always.&lt;/p>
&lt;p>&lt;strong>Model-swap or sleep on a shared GPU&lt;/strong>, as we saw in &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">serving several models on one GPU&lt;/a>. The process stays alive and the model is unloaded and reloaded in seconds instead of starting from scratch. It is the middle ground for several models that do not justify a GPU each but do not tolerate a full startup either.&lt;/p>
&lt;p>&lt;strong>Speed up loading&lt;/strong> so that the cold start fits inside the autoscaler&amp;rsquo;s cycle, with weight streaming and a compilation cache. It is what makes real scaling to zero viable with large models, and what turns &amp;ldquo;three and a half minutes&amp;rdquo; into &amp;ldquo;37 seconds&amp;rdquo;.&lt;/p>
&lt;h2 id="operational-traps-and-honest-scepticism">Operational traps and honest scepticism&lt;/h2>
&lt;p>&lt;strong>The pod starts in seconds, the model does not.&lt;/strong> Do not confuse Knative&amp;rsquo;s startup time with the time until the model answers. The difference is the whole article.&lt;/p>
&lt;p>&lt;strong>The default concurrency target is 100, and for an LLM that is a disaster.&lt;/strong> Each request occupies the GPU; a target of 1 to 4 is the reasonable one. Leaving the default 100 means accepting a hundred simultaneous requests on a replica that handles four.&lt;/p>
&lt;p>&lt;strong>The cold start is not just weight loading.&lt;/strong> Graph compilation costs what it costs, and it is attacked separately, with a cache. Optimising only storage leaves half the problem untouched.&lt;/p>
&lt;p>&lt;strong>Cilium and Gateway API are not a supported path yet.&lt;/strong> Knative supports Gateway API in beta and tests with Istio, Contour and Envoy Gateway; integration with Cilium does not appear as tested and has documented friction. If your ingress is Cilium, verify it before counting on it.&lt;/p>
&lt;p>&lt;strong>KEDA may be a better signal than the KPA.&lt;/strong> Scaling on generic concurrency is worse than scaling on the model&amp;rsquo;s real saturation metric. If you already have KEDA, Standard mode with scaling to zero on your own metrics deserves a test against the default Serverless.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>The decision to switch off the GPU is not ideological, it is arithmetic, and it depends on two numbers: how long your model takes to start and how long your worst user waits. Three rules close the matter.&lt;/p>
&lt;p>First, &lt;strong>switch off what is intermittent and cheap to start&lt;/strong>: development environments, small models used sporadically, the long tail of models almost nobody touches. There, scaling to zero is direct money with no appreciable SLO cost.&lt;/p>
&lt;p>Second, &lt;strong>do not switch off what is critical and expensive to start&lt;/strong>: the customer-facing model with a strict SLO, and the huge models whose cold start is measured in minutes. There a warm replica costs less than a user waiting.&lt;/p>
&lt;p>Third, &lt;strong>before deciding, measure your real startup and fix it&lt;/strong>. Many services are ruled out of scaling to zero over a three-minute startup that, with weight streaming and a compilation cache, drops to thirty seconds and changes the answer. The most profitable lever is not choosing well between switching off or not: it is making switching off cheap.&lt;/p>
&lt;p>With this we close the platform and self-service run. The &lt;a href="https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/">portal&lt;/a> shows what is there, the &lt;a href="https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/">ML toolbox&lt;/a> gives what is missing without duplicating what you already have, and scaling to zero decides how much of all that is switched on when nobody is looking. All three answer the same question from different angles: how to put an expensive platform at the disposal of its users without going broke keeping it switched on.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM inference with HPA and KEDA&lt;/a> — the own-metrics alternative to Knative&amp;rsquo;s KPA.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU: co-residency, model-swap and sleep&lt;/a> — the middle ground between switching off and keeping warm.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start&lt;/a> — how to make startup fit inside the autoscaler&amp;rsquo;s cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">Full TCO of an on-premise GPU cluster&lt;/a> — the GPU-hour arithmetic that decides whether switching off pays off.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>Cloud Native Computing Foundation announces Knative&amp;rsquo;s graduation&lt;/em> — &lt;a href="https://www.cncf.io/announcements/2025/10/08/cloud-native-computing-foundation-announces-knatives-graduation/">https://www.cncf.io/announcements/2025/10/08/cloud-native-computing-foundation-announces-knatives-graduation/&lt;/a>&lt;/li>
&lt;li>Knative Docs, &lt;em>Serving architecture&lt;/em> — &lt;a href="https://knative.dev/docs/serving/architecture/">https://knative.dev/docs/serving/architecture/&lt;/a>&lt;/li>
&lt;li>Knative Docs, &lt;em>Request flow&lt;/em> — &lt;a href="https://knative.dev/docs/serving/request-flow/">https://knative.dev/docs/serving/request-flow/&lt;/a>&lt;/li>
&lt;li>Knative Docs, &lt;em>Configuring KPA-specific autoscaling&lt;/em> — &lt;a href="https://knative.dev/docs/serving/autoscaling/kpa-specific/">https://knative.dev/docs/serving/autoscaling/kpa-specific/&lt;/a>&lt;/li>
&lt;li>Knative Docs, &lt;em>Configuring concurrency&lt;/em> — &lt;a href="https://knative.dev/docs/serving/autoscaling/concurrency/">https://knative.dev/docs/serving/autoscaling/concurrency/&lt;/a>&lt;/li>
&lt;li>Knative Docs, &lt;em>Configuring scale-to-zero&lt;/em> — &lt;a href="https://knative.dev/docs/serving/autoscaling/scale-to-zero/">https://knative.dev/docs/serving/autoscaling/scale-to-zero/&lt;/a>&lt;/li>
&lt;li>Knative Blog, &lt;em>Managing Gateway API ingress with the Knative Operator&lt;/em> — &lt;a href="https://knative.dev/blog/articles/gateway-api-ingress-with-knative-operator/">https://knative.dev/blog/articles/gateway-api-ingress-with-knative-operator/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>KServe becomes a CNCF incubating project&lt;/em> — &lt;a href="https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/">https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/&lt;/a>&lt;/li>
&lt;li>KServe Docs, &lt;em>Autoscaling with Kubernetes HPA (Serverless vs Standard)&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/predictive-inference/autoscaling/hpa-autoscaler">https://kserve.github.io/website/docs/model-serving/predictive-inference/autoscaling/hpa-autoscaler&lt;/a>&lt;/li>
&lt;li>KServe Docs, &lt;em>Autoscaler for generative inference (KEDA)&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/autoscaling">https://kserve.github.io/website/docs/model-serving/generative-inference/autoscaling&lt;/a>&lt;/li>
&lt;li>KServe GitHub, &lt;em>docs/samples/autoscaling (InferenceService minReplicas 0)&lt;/em> — &lt;a href="https://github.com/kserve/kserve/blob/master/docs/samples/autoscaling/README.md">https://github.com/kserve/kserve/blob/master/docs/samples/autoscaling/README.md&lt;/a>&lt;/li>
&lt;li>Wang et al. (MLSys 2026), &lt;em>Breaking the ice: analyzing cold start latency in vLLM&lt;/em> — &lt;a href="https://arxiv.org/abs/2606.07362">https://arxiv.org/abs/2606.07362&lt;/a>&lt;/li>
&lt;li>NVIDIA Developer Blog, &lt;em>Reducing cold start latency for LLM inference with NVIDIA Run:ai Model Streamer&lt;/em> — &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>Microsoft Azure SDK Blog, &lt;em>Eliminate LLM cold starts: load models up to 6x faster&lt;/em> — &lt;a href="https://devblogs.microsoft.com/azure-sdk/eliminate-llm-cold-starts-load-models-up-to-6x-faster-with-azure-blob-storage-and-runai-model-streamer/">https://devblogs.microsoft.com/azure-sdk/eliminate-llm-cold-starts-load-models-up-to-6x-faster-with-azure-blob-storage-and-runai-model-streamer/&lt;/a>&lt;/li>
&lt;li>Hugging Face Blog, &lt;em>Llama 3.1 (VRAM and precision tables)&lt;/em> — &lt;a href="https://huggingface.co/blog/llama31">https://huggingface.co/blog/llama31&lt;/a>&lt;/li>
&lt;li>CloudZero, &lt;em>H100 GPU cost 2026&lt;/em> — &lt;a href="https://www.cloudzero.com/blog/h100-gpu-cost/">https://www.cloudzero.com/blog/h100-gpu-cost/&lt;/a>&lt;/li>
&lt;/ul></description></item><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 pantry lift: from disk to HBM, or why the kitchen opens late</title><link>https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/</link><pubDate>Sun, 07 Jun 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/</guid><description>&lt;blockquote>
&lt;p>This is a trip down to the basement. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">under the engine&lt;/a> series optimised the &lt;strong>hot path&lt;/strong>, what happens with every token once you are already serving. This post looks at the journey that comes &lt;strong>before&lt;/strong> serving: how the weights climb from disk to HBM. It is the first of a pair on the two things that happen outside the API and that almost nobody times: &lt;strong>model loading&lt;/strong> (this one) and &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">execution on the silicon&lt;/a> (the next one).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Before an inference pod generates its first token, it has to &lt;strong>push the whole model into HBM&lt;/strong>. A Llama-70B in FP16 is &lt;strong>140 GB&lt;/strong> travelling along a path nobody draws: &lt;strong>disk → page cache → host buffer → PCIe → HBM&lt;/strong>. Intuition fails here: HBM is not the bottleneck, it moves 3.35 TB/s and swallows 140 GB in &lt;strong>42 ms&lt;/strong>; the bottleneck is the &lt;strong>supply chain&lt;/strong>. The Gen5 NVMe disk reads at ~14 GB/s (10 s for 140 GB); PCIe Gen5 copies host→GPU at ~50 GB/s (2.8 s); and the &lt;strong>default safetensors loader&lt;/strong>, which deserialises tensor by tensor and bounces every byte through a CPU buffer, inflates all of that up to &lt;strong>30-60 s&lt;/strong>. That time is the &lt;strong>cold start&lt;/strong>, and it is the hidden tax paid by &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling&lt;/a> (scale-from-zero), by &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary/blue-green&lt;/a> and by &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> every time a pod is born. There are three families of solution, &lt;strong>GPUDirect Storage&lt;/strong> (direct DMA disk→HBM, no bounce through the CPU), &lt;strong>fastsafetensors&lt;/strong> (4.8-7.5× over the default loader) and the &lt;strong>Run:ai Model Streamer&lt;/strong> (concurrent reads that saturate the disk), plus the simplest lever of all: &lt;strong>move fewer bytes&lt;/strong> (FP8 is half of FP16). This post explains the path, the maths, the 10 knobs, and the cruellest trap: &lt;em>&amp;ldquo;the second time it loaded fast&amp;rdquo;&lt;/em> is not your loader being good, it is the &lt;strong>page cache&lt;/strong> lying to you. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-basement-before-opening">Where you are: the basement, before opening&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The loading path: from disk to HBM, before the hot path of the token">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The loading path · before the first token&lt;/text>
&lt;rect x="120" y="40" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="65" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 80 GB, 3.35 TB/s — the destination, never the bottleneck&lt;/text>
&lt;rect x="120" y="86" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="111" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">PCIe Gen5 x16 · ~50 GB/s host→GPU (H2D)&lt;/text>
&lt;rect x="120" y="132" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="156" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · loader + page cache + host buffer&lt;/text>
&lt;text x="280" y="174" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">deserialise, bounce through CPU or direct DMA (GDS)&lt;/text>
&lt;rect x="120" y="196" width="320" height="40" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="221" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">NVMe Gen5 · ~14 GB/s per disk — the origin&lt;/text>
&lt;rect x="120" y="248" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="273" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Network / Ceph RGW · shared weights (slower still)&lt;/text>
&lt;text x="280" y="312" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">The hot path of the token lives above; this post opens up what is below&lt;/text>
&lt;text x="280" y="330" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">the journey that decides whether the kitchen opens in 10 s or in 60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-pantry-lift">The analogy: the pantry lift&lt;/h2>
&lt;p>Let us stay in the restaurant of the series. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">shared table&lt;/a> was the NVSwitch, the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">floor next door&lt;/a> was NUMA, the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">maître&lt;/a> was the kubelet. All of that describes the restaurant &lt;strong>running&lt;/strong>, with diners seated. But there is a moment no post looked at: &lt;strong>before opening&lt;/strong>, somebody has to bring the entire pantry up from the basement store to the kitchen.&lt;/p>
&lt;p>The model weights are the ingredients. They live in the &lt;strong>basement store&lt;/strong> (the disk). The kitchen, the &lt;strong>hot line&lt;/strong> where tokens are plated, is the GPU&amp;rsquo;s HBM. And between one and the other there is a &lt;strong>service lift&lt;/strong>: the path disk → host → PCIe → HBM. The kitchen cannot serve the first dish until the pantry is upstairs and in place. That restocking time is the &lt;strong>cold start&lt;/strong>.&lt;/p>
&lt;p>The intuition trap: the kitchen (HBM) is enormous and blazingly fast, it places ingredients at 3.35 TB/s. So we blame the kitchen when the restaurant opens late. But the kitchen is standing idle &lt;strong>waiting for the lift&lt;/strong>. The bottleneck is never the hot line: it is the &lt;strong>lift and the store&lt;/strong>. And worse still, there is a porter (the default loader) who, instead of carrying whole crates, &lt;strong>takes the ingredients out one by one, writes each one down in a notebook and packs them again&lt;/strong> before sending them up. That porter, not the lift, is half the problem.&lt;/p>
&lt;h2 id="the-mechanism-what-really-happens-when-a-model-loads">The mechanism: what really happens when a model loads&lt;/h2>
&lt;p>When vLLM starts up with a model in &lt;code>safetensors&lt;/code>, the 140 GB of the Llama-70B FP16 make this journey:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Disk → page cache.&lt;/strong> The kernel reads the &lt;code>.safetensors&lt;/code> files from NVMe into the &lt;strong>page cache&lt;/strong> (host RAM). If this is the first time after a reboot, it is a physical read from disk (~14 GB/s on Gen5). If the files are already in page cache from an earlier start-up, this is almost free, and here is where the trap we will see is born.&lt;/li>
&lt;li>&lt;strong>Deserialise.&lt;/strong> The default Hugging Face loader &lt;code>mmap&lt;/code>s the file and builds the tensors &lt;strong>one by one&lt;/strong>, copying them into a CPU tensor before moving them. This is single-threaded CPU work that saturates neither the disk nor PCIe: most of the &amp;ldquo;slow&amp;rdquo; loading time goes here, not on moving bytes.&lt;/li>
&lt;li>&lt;strong>Host → HBM (H2D).&lt;/strong> Each tensor is copied from the host buffer into HBM over &lt;strong>PCIe Gen5 x16&lt;/strong> (~50 GB/s in practice). For the DMA to be efficient, the host buffer should be &lt;strong>pinned&lt;/strong>, which connects directly with the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">hugepages and pinned memory&lt;/a> of the NUMA post.&lt;/li>
&lt;li>&lt;strong>Place in HBM.&lt;/strong> HBM receives the 140 GB. At 3.35 TB/s, &lt;strong>this takes 42 ms&lt;/strong>. It is never the bottleneck.&lt;/li>
&lt;/ol>
&lt;p>The path has a &lt;strong>shortcut&lt;/strong>: &lt;strong>GPUDirect Storage (GDS)&lt;/strong>. Instead of bouncing through the CPU buffer (steps 2-3), a DMA engine close to the NVMe controller writes &lt;strong>straight from disk to HBM&lt;/strong>, without involving the CPU. It is the same principle as the &lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">GPUDirect RDMA of the network&lt;/a>: get the CPU out of the way. &lt;code>fastsafetensors&lt;/code> uses GDS and reaches &lt;strong>26.4 GB/s&lt;/strong> reading a Llama-70B from NVMe across 4 GPUs.&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="Two paths: default loader bouncing through the CPU vs direct GPUDirect Storage">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Two paths from disk to HBM&lt;/text>
&lt;text x="170" y="48" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Default: bounce through the CPU&lt;/text>
&lt;rect x="60" y="60" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="190" y="60" width="90" height="40" rx="5" fill="currentColor" fill-opacity="0.06" stroke="#888" stroke-width="1.3"/>
&lt;text x="235" y="78" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">CPU buffer&lt;/text>
&lt;text x="235" y="92" text-anchor="middle" font-family="sans-serif" font-size="9" fill="currentColor" opacity="0.7">+ deserialise&lt;/text>
&lt;line x1="150" y1="80" x2="188" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;defs>&lt;marker id="dha" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c1121f"/>&lt;/marker>&lt;/defs>
&lt;rect x="320" y="60" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;line x1="280" y1="80" x2="318" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;text x="235" y="120" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">the CPU touches every byte · single-threaded · slow&lt;/text>
&lt;text x="170" y="168" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">GPUDirect Storage: direct DMA&lt;/text>
&lt;rect x="60" y="180" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="320" y="180" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;path d="M150,200 C230,200 240,200 318,200" stroke="#2a9d8f" stroke-width="2.4" fill="none" marker-end="url(#dhb)"/>
&lt;defs>&lt;marker id="dhb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#2a9d8f"/>&lt;/marker>&lt;/defs>
&lt;text x="235" y="240" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">the CPU never touches the data · ~26 GB/s NVMe→HBM&lt;/text>
&lt;line x1="470" y1="55" x2="470" y2="240" stroke="currentColor" stroke-width="1" opacity="0.25"/>
&lt;text x="575" y="90" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="currentColor">140 GB FP16&lt;/text>
&lt;text x="575" y="112" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">HBM: 42 ms&lt;/text>
&lt;text x="575" y="130" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">PCIe: 2.8 s&lt;/text>
&lt;text x="575" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">NVMe: 10 s&lt;/text>
&lt;text x="575" y="170" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#c1121f" opacity="0.9">default: 30-60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="why-the-problem-exists-the-economics-of-bytes">Why the problem exists: the economics of bytes&lt;/h2>
&lt;p>The size of the model in bytes is decided by quantisation, and that sets the floor of the cold start, because &lt;strong>all&lt;/strong> those bytes have to move before the first token. For a 70B parameter model:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Bytes/param&lt;/th>
&lt;th>Size 70B&lt;/th>
&lt;th>Read 1 NVMe @14 GB/s&lt;/th>
&lt;th>H2D PCIe @50 GB/s&lt;/th>
&lt;th>HBM @3.35 TB/s&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>FP16 / BF16&lt;/td>
&lt;td>2&lt;/td>
&lt;td>140 GB&lt;/td>
&lt;td>10.0 s&lt;/td>
&lt;td>2.8 s&lt;/td>
&lt;td>42 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FP8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>5.0 s&lt;/td>
&lt;td>1.4 s&lt;/td>
&lt;td>21 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 (GPTQ/AWQ)&lt;/td>
&lt;td>0.5&lt;/td>
&lt;td>~35 GB&lt;/td>
&lt;td>2.5 s&lt;/td>
&lt;td>0.7 s&lt;/td>
&lt;td>10 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three readings of this table:&lt;/p>
&lt;p>&lt;strong>HBM never shows up as a problem.&lt;/strong> The last column is always milliseconds. Anyone saying &amp;ldquo;the GPU is slow to load&amp;rdquo; is blaming the wrong place.&lt;/p>
&lt;p>&lt;strong>Quantising is the most underrated cold start lever.&lt;/strong> Going from FP16 to FP8 does not only double inference throughput (less HBM bandwidth per token, as we saw in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantization&lt;/a>): &lt;strong>it also halves the cold start&lt;/strong>, because there are half as many bytes to push up. It is a two-for-one that sizing usually ignores.&lt;/p>
&lt;p>&lt;strong>The disk is the bottleneck on bytes; the loader is the bottleneck on time.&lt;/strong> The figures in the table are the &lt;strong>theoretical floor&lt;/strong>, just moving bytes. The default loader adds single-threaded deserialisation on top, which is the difference between the theoretical 10 s and the real 30-60 s. That is why the solutions attack on two fronts: &lt;strong>fewer bytes&lt;/strong> (quantisation) and &lt;strong>a better porter&lt;/strong> (concurrent loaders / GDS).&lt;/p>
&lt;h2 id="the-maths-that-matter-cold-start-as-a-tax-on-autoscaling">The maths that matter: cold start as a tax on autoscaling&lt;/h2>
&lt;p>Cold start is not paid once. It is paid &lt;strong>every time a pod is born&lt;/strong>. And on an elastic platform, pods are born continuously.&lt;/p>
&lt;p>Take an &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling setup with KEDA&lt;/a> that scales from 2 to 6 replicas when the queue grows. The 4 new replicas take this long to be ready:&lt;/p>
$$ T_{\text{ready}} = T_{\text{schedule}} + T_{\text{pull-image}} + T_{\text{load-weights}} + T_{\text{cuda-graphs}} $$
&lt;p>With a Llama-70B FP16 and the default loader, $T_{\text{load-weights}}$ dominates: it can be &lt;strong>40 s&lt;/strong> of the ~60 s total. During those 40 s, the queue that triggered the autoscaling &lt;strong>keeps growing&lt;/strong>, since the new replicas absorb no traffic until they have loaded. The real number in the formula is not &amp;ldquo;how many replicas&amp;rdquo;, it is &lt;strong>how long each one takes to start serving&lt;/strong>, and that number is written by the loading path.&lt;/p>
&lt;p>This has a harsh operational consequence: &lt;strong>scale-to-zero is unworkable for workloads with a latency SLO if the cold start is 40 s.&lt;/strong> Nobody waits 40 s for the first token. The real elasticity of an inference platform is not limited by GPU availability, it is limited by &lt;strong>how long that GPU takes to have the model inside it&lt;/strong>. Bringing the cold start down from 40 s to 8 s (with streamer + FP8) is what turns &amp;ldquo;theoretical scale-to-zero&amp;rdquo; into &amp;ldquo;usable scale-to-zero&amp;rdquo;.&lt;/p>
&lt;p>The published numbers give the order of magnitude of the improvement: &lt;code>fastsafetensors&lt;/code> cuts start-up from &lt;strong>12.39 s to 4.74 s&lt;/strong> on a Llama-2-13B over 4×L40S, and from &lt;strong>16.04 s to 6.88 s&lt;/strong> on 1×A100, &lt;strong>4.8-7.5×&lt;/strong> over the default deserialiser. The &lt;strong>Run:ai Model Streamer&lt;/strong> loads in &lt;strong>4.88 s from S3 at concurrency 32&lt;/strong> and &lt;strong>7.53 s from SSD IO2 at concurrency 8&lt;/strong>; integrated into vLLM, total time to &lt;em>ready&lt;/em> drops to ~23 s from S3. There is no magic here: it is &lt;strong>getting the CPU out of the loop&lt;/strong> (GDS) and &lt;strong>reading in parallel&lt;/strong> (concurrency) to saturate the disk instead of leaving it half idle while one thread deserialises.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;h3 id="knob-1--measure-where-the-time-goes-read-vs-deserialise-vs-h2d">Knob 1 — Measure where the time goes (read vs deserialise vs H2D)&lt;/h3>
&lt;p>Before touching anything: time it. Is the time going on reading from disk, on deserialising, or on the H2D? &lt;code>iostat -x 1&lt;/code> during the load tells you whether the NVMe is saturated (disk bottleneck) or nearly idle (loader/CPU bottleneck). If the disk is at 20%, the problem is &lt;strong>not&lt;/strong> the disk: it is the porter. Changing disk would fix nothing; changing loader would.&lt;/p>
&lt;h3 id="knob-2----load-format-choosing-the-porter">Knob 2 — &lt;code>--load-format&lt;/code>: choosing the porter&lt;/h3>
&lt;p>vLLM exposes several loaders via &lt;code>--load-format&lt;/code>: &lt;code>safetensors&lt;/code> (default), &lt;code>runai_streamer&lt;/code>, &lt;code>fastsafetensors&lt;/code>, &lt;code>tensorizer&lt;/code>. The default is the slowest. Changing one flag can be the cheapest 4-7× there is.&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"># Run:ai Model Streamer (concurrent reads, saturates the disk)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format runai_streamer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># fastsafetensors (GPUDirect Storage, direct DMA disk→HBM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format fastsafetensors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-3--streamer-concurrency">Knob 3 — Streamer concurrency&lt;/h3>
&lt;p>The Run:ai Model Streamer splits the read across N threads according to the size of each tensor in order to saturate the storage bandwidth. Concurrency is the key parameter: &lt;strong>16 is usually enough for local NVMe; 32 (sometimes 64)&lt;/strong> for high-throughput network storage. One thread does not saturate a Gen5 NVMe; 32 do.&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">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;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-4--gpudirect-storage-gds--fastsafetensors">Knob 4 — GPUDirect Storage (GDS) + fastsafetensors&lt;/h3>
&lt;p>If there is an &lt;code>nvidia-fs&lt;/code> driver, a supported filesystem and local NVMe, GDS writes straight disk→HBM with no bounce through the CPU. It is the difference between the two paths in the diagram. &lt;strong>But&lt;/strong>: it requires the stack to be in place, and it only wins if the bottleneck is the CPU bounce, not the disk itself. Check with &lt;code>gdscheck&lt;/code>.&lt;/p>
&lt;h3 id="knob-5--local-nvme-for-the-weights-not-network">Knob 5 — Local NVMe for the weights, not network&lt;/h3>
&lt;p>Serving the weights from Ceph RGW / NFS is convenient (one shared place) but it puts the &lt;strong>network&lt;/strong> into the loading path. For cold start, &lt;strong>weights on the node&amp;rsquo;s local NVMe&lt;/strong> (or a local cache). Network storage is for the model repository; the inference node should have a hot local copy.&lt;/p>
&lt;h3 id="knob-6--pre-pull--local-model-cache">Knob 6 — Pre-pull / local model cache&lt;/h3>
&lt;p>An &lt;code>initContainer&lt;/code> that downloads the model onto a &lt;code>local&lt;/code> or &lt;code>hostPath&lt;/code> NVMe volume before starting vLLM turns a cold start &amp;ldquo;from the network&amp;rdquo; into one &amp;ldquo;from local NVMe&amp;rdquo;. Combined with a per-node cache DaemonSet, new pods on an already warm node read from local disk, not from the network.&lt;/p>
&lt;h3 id="knob-7--quantisation-move-fewer-bytes">Knob 7 — Quantisation: move fewer bytes&lt;/h3>
&lt;p>Weights already in FP8 or INT4 on disk = half or a quarter of the cold start. It is the knob from the table above. And it composes with all the others: FP8 + streamer + GDS is multiplicative.&lt;/p>
&lt;h3 id="knob-8--parallel-loading-across-gpus-vllm-v1">Knob 8 — Parallel loading across GPUs (vLLM V1)&lt;/h3>
&lt;p>vLLM&amp;rsquo;s V1 engine (the default since 0.19) loads the weight shards &lt;strong>in parallel&lt;/strong> across the GPUs of a TP group, instead of sequentially. At TP=4, each GPU loads its quarter at the same time. Check it is active; in old versions loading was serial and the cold start of TP=4 was almost 4× that of TP=1.&lt;/p>
&lt;h3 id="knob-9--numa-locality-of-the-nvme">Knob 9 — NUMA locality of the NVMe&lt;/h3>
&lt;p>The NVMe hangs off a &lt;strong>PCIe root under a specific socket&lt;/strong>, exactly the same NUMA map as the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>. If the load&amp;rsquo;s host buffer lands on the wrong socket, the H2D crosses the UPI. The fifth list to align, alongside &lt;code>isolcpus&lt;/code>, &lt;code>reserved-cpus&lt;/code> and the NIC IRQs: &lt;strong>which socket is local to the NVMe and to the destination GPU&lt;/strong>. &lt;code>nvidia-smi topo -m&lt;/code> shows it.&lt;/p>
&lt;h3 id="knob-10--do-not-pay-the-cold-start-keep-pods-warm">Knob 10 — Do not pay the cold start: keep pods warm&lt;/h3>
&lt;p>Sometimes the answer is not to load faster, but &lt;strong>not to unload&lt;/strong>. A floor of always-live replicas (no scale-to-zero), or a pool of preloaded warm standbys, swaps &amp;ldquo;wait 40 s&amp;rdquo; for &amp;ldquo;0 s&amp;rdquo;. It is the cost of idle GPU in exchange for start-up latency: a &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> decision, not a technical one.&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>iostat&lt;/code> while loading&lt;/td>
&lt;td>knowing whether the bottleneck is disk or loader&lt;/td>
&lt;td>none; always do it first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>--load-format&lt;/code>&lt;/td>
&lt;td>single-threaded deserialisation&lt;/td>
&lt;td>format compatibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>streamer concurrency&lt;/td>
&lt;td>underused disk&lt;/td>
&lt;td>host RAM for buffers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>GPUDirect Storage&lt;/td>
&lt;td>bounce through the CPU&lt;/td>
&lt;td>requires nvidia-fs + supported FS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>local NVMe vs network&lt;/td>
&lt;td>the network in the path&lt;/td>
&lt;td>duplicating weights per node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>pre-pull / node cache&lt;/td>
&lt;td>network on every start-up&lt;/td>
&lt;td>local disk space&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>FP8/INT4 quantisation&lt;/td>
&lt;td>bytes to move&lt;/td>
&lt;td>quality (measure, do not assume)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>V1 parallel loading&lt;/td>
&lt;td>serial loading across GPUs&lt;/td>
&lt;td>none if V1 is active&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>NUMA of the NVMe&lt;/td>
&lt;td>H2D crossing the UPI&lt;/td>
&lt;td>align with the rest of the NUMA lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>warm pods / no zero&lt;/td>
&lt;td>the whole cold start&lt;/td>
&lt;td>idle GPU paid for&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> The whole of &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">scale-from-zero with KEDA&lt;/a> rests on this: real elasticity is limited by the cold start, not by GPU availability. An autoscaler with 40 s of loading reacts late to every peak.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a>, bringing up a decode pool on demand pays the cold start of loading the model into each new pod. The elasticity of the pattern depends on how fast those pods start.&lt;/p>
&lt;p>&lt;strong>With canary/blue-green.&lt;/strong> Every &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary deployment&lt;/a> loads a new version of the model alongside the old one. The validation time of a canary includes its cold start; large models make deployments slower and more expensive.&lt;/p>
&lt;p>&lt;strong>With NUMA and hugepages.&lt;/strong> The load&amp;rsquo;s host buffer wants to be &lt;strong>pinned&lt;/strong> and &lt;strong>NUMA-local&lt;/strong>, the same thing the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a> asked for on the hot path. The loading path is another client of the same NUMA map.&lt;/p>
&lt;p>&lt;strong>With quantisation.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8/INT4&lt;/a> is not only inference throughput: it is the direct lever on the bytes of the cold start.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> that ignores cold start underestimates how many replicas are needed to absorb a peak: if they take 40 s to start, you need a bigger permanent cushion.&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;The second time it loaded really fast.&amp;rdquo;&lt;/strong> This is the star trap. The first load filled the &lt;strong>page cache&lt;/strong> (host RAM); the second reads from RAM, not from disk, and flies. But in production pods are ephemeral and are born on different nodes: the start-up that counts is the &lt;strong>cold&lt;/strong> one, on a node where those files are not in page cache. Benchmarking the second load is measuring a situation that almost never happens at the moment that matters (the peak that triggers the autoscaler).&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;GDS always speeds things up.&amp;rdquo;&lt;/strong> No. GDS removes the bounce through the CPU; if your bottleneck is the disk itself (saturated NVMe) or the deserialisation, GDS does not touch that part. Measure first (knob 1). It also demands &lt;code>nvidia-fs&lt;/code>, a supported filesystem, and sometimes it does not work over whatever network storage you have.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;mmap makes loading instant.&amp;rdquo;&lt;/strong> &lt;code>mmap&lt;/code> maps the file but &lt;strong>reads nothing yet&lt;/strong>: the cost is deferred to the first access to each page. The time does not disappear, it moves, and the &lt;strong>first token&lt;/strong> pays the page faults that start-up did not. You have moved the cold start into the latency of the first request, which is probably a worse place to have it.&lt;/p>
&lt;p>&lt;strong>Weights on network storage &amp;ldquo;because it is cleaner&amp;rdquo;.&lt;/strong> Sharing a model repository on Ceph RGW is fine for storing them; serving the cold start from there puts the network (and its latency and its contention) into the critical path. Local NVMe cache on the inference node.&lt;/p>
&lt;p>&lt;strong>Loading FP16 and quantising at start-up.&lt;/strong> Quantising on the fly during loading (for example FP16→FP8 on the GPU) can be &lt;strong>slower&lt;/strong> than having the weights already quantised on disk: you move twice the bytes and on top of that you do conversion work. If you are going to serve in FP8, store the weights in FP8.&lt;/p>
&lt;p>&lt;strong>Optimising the load and ignoring &lt;code>T_cuda-graphs&lt;/code>.&lt;/strong> Bringing weight loading down to 8 s and forgetting that CUDA graph capture adds several more seconds leaves the cold start half done. That second half of start-up is the subject of the &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">next post&lt;/a>.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The whole series optimised what happens &lt;strong>with every token&lt;/strong>: the cable, the host, the network, the silicon. But before the first token there is a journey almost nobody times and that decides whether an inference pod opens in 10 s or in 60 s: pushing the model from disk to HBM. Intuition blames the GPU, and the GPU is innocent: HBM swallows 140 GB in 42 ms. The bottleneck is the &lt;strong>supply chain&lt;/strong>: a disk reading at 14 GB/s, a PCIe copying at 50, and above all a default loader that deserialises tensor by tensor with a single thread and turns 10 s of bytes into 60 s of waiting. The solutions attack the two right fronts, &lt;strong>fewer bytes&lt;/strong> (quantisation) and &lt;strong>better transport&lt;/strong> (GDS, concurrent streamers, local NVMe), and give 4-7× almost for free. And above the technique, an idea that reorders the priorities: on an elastic platform, cold start &lt;strong>is not a start-up detail, it is the ceiling on elasticity&lt;/strong>. The fastest GPU in the world does not scale if it takes 40 s to have the model inside. The pantry lift, the one nobody timed, is what decides what time the kitchen really opens.&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/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — the runbook that attacks each of the five line items of start-up.&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: co-residency, model-swapping and sleep mode&lt;/a> — sleep mode dodges this cold start by parking the weights in RAM instead of on disk.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the bus GDS uses to push the weights up from NVMe; ACS enabled throttles that direct path just as it throttles P2P between GPUs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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> — cold start is the ceiling on the elasticity the scheduler exercises: preemption and scale-from-zero pay this load in every new pod.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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 first of &amp;ldquo;under the engine&amp;rdquo;; this post goes down to the basement that series took for granted as full.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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> — the pinned, NUMA-local memory the loading path needs for an efficient H2D.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door: network NUMA, Cilium eBPF and DRANET&lt;/a> — the same &amp;ldquo;get the CPU out of the way&amp;rdquo; principle (GPUDirect) that GDS applies here to the disk.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — the second half of the cold start (graph capture) and what happens on the silicon once the weights are inside.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a> — the lever that halves the bytes of the cold start, not just HBM bandwidth per token.&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> — why cold start is the real ceiling on elasticity and kills scale-to-zero.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — bringing up pools on demand pays the cold start in every new pod.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for models&lt;/a> — the validation time of a canary includes its model loading.&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 replica cushion and of the warm-vs-zero decision.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em>: &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, &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>vLLM, &lt;em>Loading model weights with fastsafetensors&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/">https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/&lt;/a>.&lt;/li>
&lt;li>foundation-model-stack, &lt;em>fastsafetensors&lt;/em> (high-performance loader, GDS): &lt;a href="https://github.com/foundation-model-stack/fastsafetensors">https://github.com/foundation-model-stack/fastsafetensors&lt;/a>.&lt;/li>
&lt;li>&lt;em>Speeding up Model Loading with fastsafetensors&lt;/em> (arXiv 2505.23072): &lt;a href="https://arxiv.org/html/2505.23072v1">https://arxiv.org/html/2505.23072v1&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>GPUDirect Storage: A Direct Path Between Storage and GPU Memory&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/gpudirect-storage/">https://developer.nvidia.com/blog/gpudirect-storage/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Magnum IO GPUDirect Storage&lt;/em> (benchmarking and configuration): &lt;a href="https://developer.nvidia.com/gpudirect-storage">https://developer.nvidia.com/gpudirect-storage&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Autoscaling LLM inference on Kubernetes: HPA with custom metrics and KEDA for vLLM</title><link>https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/</link><pubDate>Mon, 01 Jun 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> (where the metrics that feed the HPA come from), &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> (what ceiling and what head-room the autoscaler assumes) and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (which explains why &lt;code>num_requests_waiting&lt;/code> is the primary metric).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Classic Kubernetes autoscaling, HPA on &lt;code>cpu&lt;/code> or &lt;code>memory&lt;/code>, &lt;strong>is no use for LLM inference&lt;/strong>. The reason: the vLLM pod consumes little CPU (the GPU does the work) and the process RSS is flat; both metrics can sit at 30 % while the GPU is saturated and the request queue grows unchecked. The four viable signals that do respond to the real load are: &lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/strong> (the queue, the primary metric), &lt;strong>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/strong> (pressure on the KV cache pool), &lt;strong>TTFT P95&lt;/strong> via the &lt;code>vllm:time_to_first_token_seconds_bucket&lt;/code> histogram (the SLO guarantee) and the &lt;strong>batch fill ratio&lt;/strong> &lt;code>num_requests_running / max_num_seqs&lt;/code> (utilisation of the concurrency ceiling). For an HPA to consume Prometheus metrics you need an adapter; as of May 2026 there are two mature options: &lt;strong>&lt;code>prometheus-adapter&lt;/code>&lt;/strong> (cluster-wide sigma, static configuration, &lt;code>external.metrics.k8s.io&lt;/code> output) and &lt;strong>KEDA&lt;/strong> (&lt;code>ScaledObject&lt;/code> with a Prometheus trigger, configurable polling, optional scale to zero, cron integration). KEDA is the dominant option for LLM on a generic cluster because it solves the &amp;ldquo;warm pool + cron + engine metric&amp;rdquo; pattern in a single CRD. The dominant operational challenge is not the scaling logic but the &lt;strong>cold start&lt;/strong>: a vLLM pod with Llama 70B BF16 (140 GB) takes between &lt;strong>90 seconds&lt;/strong> (model pre-cached on a local PV) and &lt;strong>6 minutes&lt;/strong> (image pull + model download from object store) to serve the first token. The five levers that cut it down are an image pre-pulled via DaemonSet, the model cached on a regional PV or tmpfs, a &lt;strong>warm pool&lt;/strong> with &lt;code>minReplicaCount &amp;gt; 0&lt;/code>, &lt;strong>predictive scaling&lt;/strong> via KEDA cron when the traffic pattern is predictable (offices 9–18 h), and parallel model download. The three specific LLM scale-down pitfalls: cutting streaming SSE connections halfway through a response (graceful drain with &lt;code>terminationGracePeriodSeconds&lt;/code> ≥ 60 s), scale-out/in oscillation from a badly calibrated stabilization window, and forgetting that the HPA only scales pods; &lt;strong>GPU nodes&lt;/strong> are scaled with cluster-autoscaler over labelled nodepools. This post includes the minimal YAML manifests.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#asm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#asm)}&lt;/style>
&lt;defs>&lt;marker id="asm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · autoscaling on the metrics OBSERVE measures&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-bakery-with-wood-fired-ovens">The analogy: the bakery with wood-fired ovens&lt;/h2>
&lt;p>An artisan bakery has three wood-fired ovens. Each oven takes 25 minutes to reach temperature from cold. Once hot, it bakes bread continuously with a run of 18 minutes per batch. The manager wants to maximise bread sold per day without burning wood for nothing, and she knows three things: that there is a demand peak at 7:30 every morning, that Mondays sell almost nothing, and that when the bread on the counter runs out the customers go to the supermarket next door.&lt;/p>
&lt;p>The cheap strategy, lighting ovens when there is a queue in the shop, &lt;strong>does not work&lt;/strong>. By the time the queue grows and the manager lights the second oven, that oven will not be ready until 25 minutes later; the customers in that window are lost. The &amp;ldquo;queue at the counter&amp;rdquo; signal arrives late.&lt;/p>
&lt;p>The smart strategy: light the second oven at 6:55, ahead of the predictable 7:30 peak, and leave it running until 10:00 even though the queue drops at 8:15. Keep the third oven off from Monday to Wednesday because demand does not arrive; light it proactively on Thursdays at 12:00 because historically it goes up. Keep a stock of pre-proved raw dough in the chiller so that when the oven is ready the bread goes in within 30 seconds and there is no two-hour proving wait.&lt;/p>
&lt;p>Autoscaling an LLM inference cluster works the same way:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Lighting ovens from cold&lt;/strong> = reactive scale-out when the queue grows (slow, loses customers).&lt;/li>
&lt;li>&lt;strong>Proactive cron&lt;/strong> = predictive scaling when the pattern is known (working hours, expected peaks).&lt;/li>
&lt;li>&lt;strong>Pre-proved dough&lt;/strong> = warm pool of replicas with the model loaded but under 0 load.&lt;/li>
&lt;li>&lt;strong>Switching off ovens with no bread inside&lt;/strong> = scale-down respecting the active streams (you do not shut the oven with bread in it).&lt;/li>
&lt;/ul>
&lt;p>The key metric, &amp;ldquo;how many customers are in the queue&amp;rdquo;, is called &lt;code>num_requests_waiting&lt;/code>. The metric that says &amp;ldquo;the oven is about to run out of dough for new loaves&amp;rdquo; is called &lt;code>gpu_cache_usage_perc&lt;/code>. And the quality-of-service metric, &amp;ldquo;how long the first loaf takes to come out when a new customer walks in&amp;rdquo;, is called TTFT.&lt;/p>
&lt;h2 id="why-hpa-on-cpu-is-no-use">Why HPA on CPU is no use&lt;/h2>
&lt;p>The classic Kubernetes HPA looks at the pod&amp;rsquo;s &lt;code>resource.cpu&lt;/code>. For a conventional HTTP service, Node.js or a REST API, CPU moves linearly with traffic and the HPA scales with reasonable accuracy. For a vLLM or SGLang pod on GPU, the pod&amp;rsquo;s CPU typically lives between 5 % and 15 % regardless of whether the GPU is at 30 % or 99 % load: the real work is done by the device, not the process. Result: the CPU-based HPA &lt;strong>never&lt;/strong> triggers scale-out even though the GPU is bursting, and clients pile up in the queue until TTFT P95 crosses the SLO. The operator discovers the problem through the TTFT alert, not through the HPA.&lt;/p>
&lt;p>&lt;code>memory&lt;/code> is no use either: the vLLM process RSS is flat after startup (model plus buffers loaded in one go); it does not reflect the real pressure on the GPU. The only things that rise and fall with the useful inference load are metrics the engine publishes explicitly: request queue, KV cache pool, SLO latencies. Without an adapter that makes them visible to the HPA, autoscaling is blind.&lt;/p>
&lt;h2 id="the-four-viable-signals">The four viable signals&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="four LLM autoscaling signals">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.p{fill:#dfe9f5;stroke:#356}.s{fill:#eef0d0;stroke:#7a3}.t{fill:#f4e3cf;stroke:#a63}.f{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.m{font:11px monospace;fill:#222}.n{font:italic 11px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">The four metrics that feed the LLM HPA&lt;/text>
&lt;rect x="30" y="40" width="370" height="120" class="b p"/>
&lt;text x="40" y="62" class="h">1 · QUEUE (PRIMARY)&lt;/text>
&lt;text x="40" y="82" class="m">vllm:num_requests_waiting&lt;/text>
&lt;text x="40" y="105" class="n">Any requests waiting to enter the batch?&lt;/text>
&lt;text x="40" y="125" class="n">Reacts instantly. Robust to model changes.&lt;/text>
&lt;text x="40" y="145" class="n">Typical HPA threshold: target = 5 (scale-out if &amp;gt; 5 sustained).&lt;/text>
&lt;rect x="420" y="40" width="370" height="120" class="b s"/>
&lt;text x="430" y="62" class="h">2 · KV CACHE POOL&lt;/text>
&lt;text x="430" y="82" class="m">vllm:gpu_cache_usage_perc&lt;/text>
&lt;text x="430" y="105" class="n">How much KV cache VRAM is in use?&lt;/text>
&lt;text x="430" y="125" class="n">Predictive: warns before the queue starts.&lt;/text>
&lt;text x="430" y="145" class="n">Typical threshold: target = 0.85 (scale-out if &amp;gt; 0.85).&lt;/text>
&lt;rect x="30" y="170" width="370" height="120" class="b t"/>
&lt;text x="40" y="192" class="h">3 · TTFT P95 (SLO)&lt;/text>
&lt;text x="40" y="212" class="m">histogram_quantile(0.95,&lt;/text>
&lt;text x="40" y="226" class="m"> rate(vllm:time_to_first_token_seconds_bucket[5m]))&lt;/text>
&lt;text x="40" y="246" class="n">The contractual guarantee to the customer.&lt;/text>
&lt;text x="40" y="266" class="n">Backup for the other two; reacts late but defends the SLO.&lt;/text>
&lt;rect x="420" y="170" width="370" height="120" class="b f"/>
&lt;text x="430" y="192" class="h">4 · BATCH FILL RATIO&lt;/text>
&lt;text x="430" y="212" class="m">vllm:num_requests_running&lt;/text>
&lt;text x="430" y="226" class="m"> / max_num_seqs (config)&lt;/text>
&lt;text x="430" y="246" class="n">Utilisation of the engine's concurrency ceiling.&lt;/text>
&lt;text x="430" y="266" class="n">Useful for scale-down: if ratio &amp;lt; 0.4 sustained, a replica is spare.&lt;/text>
&lt;text x="410" y="310" text-anchor="middle" class="n">Recommended policy: queue as primary, KV cache as secondary, TTFT as guardrail&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>Signal 1, &lt;code>vllm:num_requests_waiting&lt;/code> (queue).&lt;/strong> It is the most direct metric: how many requests are waiting to enter the batch. It reacts the instant the target concurrency saturates. It is robust against model changes (the number of requests is the same concept whether it is Llama 7B or 70B). It is the &lt;strong>primary metric&lt;/strong> of the LLM HPA. Typical threshold: &lt;code>target = 5&lt;/code> requests waiting on average; if the queue grows above 5 sustained for 2 minutes, scale out.&lt;/p>
&lt;p>&lt;strong>Signal 2, &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (KV pool).&lt;/strong> It moves &lt;strong>before&lt;/strong> the queue does: the KV pool fills up while the batch slots are still free, until the engine starts rejecting new requests for OOM prevention and the queue forms. It is therefore &lt;strong>predictive&lt;/strong>: it triggers scale-out before the client notices degradation. Typical threshold: &lt;code>target = 0.85&lt;/code> (85 % of pool used).&lt;/p>
&lt;p>&lt;strong>Signal 3, TTFT P95.&lt;/strong> The contractual guarantee. If TTFT P95 leaves the SLO, scale out even though the queue and the KV pool look reasonable (there may be a spike of long prompts). It is &lt;strong>reactive&lt;/strong>, it leaves the SLO before your HPA reacts, but it works as a final guardrail.&lt;/p>
&lt;p>&lt;strong>Signal 4, batch fill ratio.&lt;/strong> The &lt;code>num_requests_running / max_num_seqs&lt;/code> quotient (the latter is engine config, not a metric). Useful for &lt;strong>scale-down&lt;/strong>: if the ratio stays below 0.4 for 10 minutes, there is spare capacity and replicas can be reduced safely.&lt;/p>
&lt;p>The recommended policy combines all four: the queue and the KV pool trigger scale-out (whichever arrives first), TTFT confirms it as a guardrail, and the batch fill ratio manages scale-down. Implementing that in a single HPA demands external metrics; KEDA makes this manageable.&lt;/p>
&lt;h2 id="the-wiring-keda-as-the-prometheus-adapter">The wiring: KEDA as the Prometheus adapter&lt;/h2>
&lt;p>KEDA introduces two main CRDs: &lt;code>TriggerAuthentication&lt;/code> (how to authenticate against the source) and &lt;code>ScaledObject&lt;/code> (which deployment to scale with which triggers). For a vLLM deployment with Prometheus as the source:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b-scaler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warm pool&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 5 min before scale-down&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advanced&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">horizontalPodAutoscalerConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">behavior&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleDown&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># large window to avoid oscillation&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleUp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_queue_depth&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> avg(vllm:num_requests_waiting{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_kv_cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.85&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> avg(vllm:gpu_cache_usage_perc{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_ttft_p95&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> histogram_quantile(0.95,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{deployment=&amp;#34;vllm-llama70b&amp;#34;}[5m])))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three non-obvious operational details:&lt;/p>
&lt;p>&lt;strong>&lt;code>minReplicaCount: 2&lt;/code>.&lt;/strong> This is the warm pool. Keeping at least two replicas guarantees availability if a node is lost and absorbs spikes without waiting for the cold start of the first scale-up. Dropping it to 0 saves GPU off-peak but introduces 90 s–6 min of latency for the first new client.&lt;/p>
&lt;p>&lt;strong>&lt;code>stabilizationWindowSeconds: 600&lt;/code> on scale-down.&lt;/strong> Ten minutes. Models are not &lt;code>nginx&lt;/code>: if a replica closes prematurely and two minutes later there is another peak, the cold start of a new pod is what the client waits. Better to keep extra replicas around for twice as long as you would for a normal web service.&lt;/p>
&lt;p>&lt;strong>&lt;code>scaleUp: stabilizationWindowSeconds: 30&lt;/code>.&lt;/strong> Thirty seconds. Scale-out has to be fast: the new pod&amp;rsquo;s cold start adds its own delay, and if on top of that the HPA waits several more minutes before triggering, the SLO is already broken.&lt;/p>
&lt;h2 id="the-big-operational-problem-cold-start">The big operational problem: cold start&lt;/h2>
&lt;p>A vLLM pod loading Llama 70B goes through these phases before serving the first token:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Typical time&lt;/th>
&lt;th>Can be accelerated with&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Image pull (4–6 GB)&lt;/td>
&lt;td>30–90 s&lt;/td>
&lt;td>DaemonSet pre-pull&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model download (140 GB BF16)&lt;/td>
&lt;td>60–300 s&lt;/td>
&lt;td>Cached regional PV, S3 + multi-thread&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Loading the model into HBM&lt;/td>
&lt;td>30–90 s&lt;/td>
&lt;td>tmpfs or local NVMe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CUDA graph capture&lt;/td>
&lt;td>20–60 s&lt;/td>
&lt;td>&lt;code>--enforce-eager&lt;/code> (slower at runtime but fast startup)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PagedAttention warmup&lt;/td>
&lt;td>5–15 s&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Health check ready&lt;/td>
&lt;td>10–30 s&lt;/td>
&lt;td>probe tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Total without optimisation: 4–10 minutes.&lt;/strong> That is how long a new replica takes to absorb traffic. With all the levers combined: &lt;strong>45–90 seconds&lt;/strong>. The difference between those two numbers is the main platform work for LLM autoscaling.&lt;/p>
&lt;h3 id="the-five-levers">The five levers&lt;/h3>
&lt;p>&lt;strong>Lever 1, pre-pulled image.&lt;/strong> A trivial DaemonSet runs &lt;code>ctr image pull&lt;/code> (or &lt;code>crictl pull&lt;/code>) on the GPU nodes as soon as they join the cluster. The inference engine image stays on disk; new pods skip the 30–90 s of pull. Cost: around 6 GB of disk per node.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DaemonSet&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-image-warmer }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-warmer } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-warmer } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">workload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initContainers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pull&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.10.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;/bin/true&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pause&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.k8s.io/pause:3.10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Lever 2, model on a regional PV.&lt;/strong> The model download (140 GB BF16 or 35 GB FP8) from central object storage is the dominant component of the cold start. Caching the model on a &lt;strong>zone or rack PV&lt;/strong>, Rook-Ceph RBD or local NVMe provisioned by the operator, cuts 60–300 s down to 5–15 s. The antipattern: downloading the model on every startup from external S3.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama70b-fp8-pvc &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># RWX shared, filled offline&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Lever 3, warm pool.&lt;/strong> &lt;code>minReplicaCount &amp;gt; 0&lt;/code> keeps pre-loaded replicas idle. The cost is idle GPU; the benefit is 0 s of cold start for the first client of a peak. For production clusters with continuous traffic: a warm pool of 2–3 replicas. For nightly batch clusters with 0 traffic: warm pool 0 and accept the cold start, or KEDA with a cron that switches on 10 minutes earlier.&lt;/p>
&lt;p>&lt;strong>Lever 4, predictive scaling with cron.&lt;/strong> When the pattern is predictable (offices 9–18 h):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cron&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timezone&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Europe/Madrid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">start&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;30 8 * * 1-5&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 8:30 Monday–Friday&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 19 * * 1-5&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 19:00&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">desiredReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;6&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Combined with reactive triggers. The HPA scales according to the maximum of the signals: if the cron asks for 6 and the queue asks for 10, the result is 10.&lt;/p>
&lt;p>&lt;strong>Lever 5, parallel download and an efficient format.&lt;/strong> For PVs that are not pre-loaded, tools such as &lt;code>nvidia-modelmanager&lt;/code>, &lt;code>s5cmd&lt;/code> or &lt;code>aria2c&lt;/code> parallelise the model download. Going from serial download (~150 MB/s) to parallel with 8 threads (~1.2 GB/s) divides the time by 8. And formats such as &lt;strong>safetensors&lt;/strong> load into HBM faster than the original &lt;strong>PyTorch pickle&lt;/strong>.&lt;/p>
&lt;h2 id="when-to-scale-nodes-not-just-pods">When to scale nodes, not just pods&lt;/h2>
&lt;p>The HPA scales &lt;strong>pods&lt;/strong>. If the cluster has no free GPU nodes, the new pod stays &lt;code>Pending&lt;/code> for lack of resources. To scale &lt;strong>nodes&lt;/strong>, you need cluster-autoscaler with a specific GPU nodepool, labelled:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># nodepool config (Karpenter or cluster-autoscaler equivalent)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gpu-model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm-80gb&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">taints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NoSchedule&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">min&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nodes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nodes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without this, the HPA can ask for 10 replicas but the cluster only delivers those that fit on nodes already up. The cold start of a new node (bare metal or cloud provisioning, PXE, OS boot, NVIDIA drivers, cluster join) is &lt;strong>much&lt;/strong> longer than the cold start of a pod: typically 5–15 minutes on preconfigured bare metal, 30–60 minutes on real provisioning. For on-premise clusters, the nodepool must be &lt;strong>always sized to the expected maximum&lt;/strong>, and the &amp;ldquo;scaling&amp;rdquo; is only on the pod side. The concept of reactive node scale-out only applies to clouds; on-premise you have to buy for the peak.&lt;/p>
&lt;h2 id="three-pitfalls-specific-to-llm-scale-down">Three pitfalls specific to LLM scale-down&lt;/h2>
&lt;p>&lt;strong>Pitfall 1, cutting streaming SSE connections.&lt;/strong> When a replica enters &lt;code>Terminating&lt;/code>, Kubernetes sends SIGTERM to the pod and, by default, kills it 30 seconds later. For vLLM that means &lt;strong>cutting streaming SSE connections halfway through the response&lt;/strong>. The client gets a 502 error with the partial output lost. Solution: &lt;code>terminationGracePeriodSeconds: 120&lt;/code> plus a preStop hook that tells the engine not to accept new requests but to finish those in flight:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preStop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/shutdown&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This requires the engine to expose a graceful shutdown endpoint; vLLM v1 supports it via &lt;code>--enable-graceful-shutdown&lt;/code>. Without it, scale-down breaks the SLO even though the metrics do not capture it (cut requests never enter the TTFT histogram).&lt;/p>
&lt;p>&lt;strong>Pitfall 2, scale-up/scale-down oscillation.&lt;/strong> If the scale-down &lt;code>stabilizationWindowSeconds&lt;/code> is short (~60 s default), the next drop in the queue triggers scale-down, and two minutes later the next peak triggers scale-up. The system oscillates, pays repeated cold starts, and never reaches a stable regime. Solution: scale-down with a window of 10 minutes minimum and conservative policies (&lt;code>type: Pods, value: 1, periodSeconds: 120&lt;/code>, at most one replica fewer every 2 minutes).&lt;/p>
&lt;p>&lt;strong>Pitfall 3, &lt;code>vllm:num_requests_waiting&lt;/code> with &lt;code>avg&lt;/code> when there is rebalancing.&lt;/strong> If two replicas are unbalanced (one with queue 20, the other with queue 0), &lt;code>avg&lt;/code> gives 10 and the HPA triggers scale-out when the right move would be to rebalance via the load balancer. To detect it: add an alert on &lt;code>stddev(vllm:num_requests_waiting)&lt;/code> per deployment. If the dispersion is high, the problem is not capacity but routing.&lt;/p>
&lt;h2 id="full-example-manifest">Full example manifest&lt;/h2>
&lt;p>For a vLLM deployment with Llama 70B FP8 on 4×H100 SXM per replica, KEDA with warm pool 2:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># managed by KEDA afterwards&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app: vllm-llama70b, deployment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">workload: gpu, gpu-model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm-80gb }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Exists&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NoSchedule&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.10.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3.3-70b-fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-num-seqs=64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-graceful-shutdown&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: http, containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: metrics, containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">200Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initialDelaySeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># tolerates the warmup&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preStop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /shutdown, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: model-cache, mountPath: /models, readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: dshm, mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/dev/shm }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama70b-fp8-pvc }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dshm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">medium: Memory, sizeLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodMonitor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: vllm-llama70b-metrics, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podMetricsEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: vllm-llama70b-scaler, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advanced&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">horizontalPodAutoscalerConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">behavior&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleDown&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">type: Pods, value: 1, periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleUp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">type: Pods, value: 2, periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg(vllm:num_requests_waiting{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_kv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.85&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg(vllm:gpu_cache_usage_perc{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cron&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timezone&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Europe/Madrid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">start&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;30 8 * * 1-5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 19 * * 1-5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">desiredReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;6&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This set is the minimum viable one for LLM autoscaling on a generic cluster with the NVIDIA GPU Operator. Each team adapts it to its own SLO.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB per node, 4 GPU nodes&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Each node hosts one vLLM replica with TP=4 and Llama 70B FP8 (one model per node, they are not shared).&lt;/li>
&lt;li>Warm pool of 2 replicas off-peak; the KEDA cron raises it to 4 during working hours.&lt;/li>
&lt;li>Cluster-autoscaler &lt;strong>does not apply&lt;/strong> (4 physical nodes bought; scaling is pods only). The number of concurrent replicas is at most the number of available nodes (if each replica uses all 4 GPUs of the whole node).&lt;/li>
&lt;li>If the sizing requires more simultaneous replicas than nodes, there are two routes: (a) lower the TP of each replica so two fit per node, (b) expand the physical nodepool. The decision is dictated by capacity planning, see &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>KEDA event volume: around 5 evaluations/min per ScaledObject. For 10 models served in parallel, 3,000 evaluations/h. Manageable with one KEDA operator per cluster.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cluster-autoscaler for on-premise GPU nodes&lt;/strong>: how to orchestrate bare metal provisioning (Tinkerbell, Metal³) as a function of demand.&lt;/li>
&lt;li>&lt;strong>Multi-cluster autoscaling&lt;/strong>: scaling across clusters in different DCs for geographic resilience.&lt;/li>
&lt;li>&lt;strong>Cost-aware autoscaling&lt;/strong>: prioritising nodes by hourly energy cost (on clusters with indexed tariffs).&lt;/li>
&lt;li>&lt;strong>Predictive ML-based scaling&lt;/strong>: instead of a static cron, training a model that predicts demand 30 minutes ahead.&lt;/li>
&lt;li>&lt;strong>Multi-tenant quotas and fairness&lt;/strong>: KEDA with namespace quotas so that one tenant does not monopolise the HPA.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — the cold start that makes scale-to-zero viable, or not.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how autoscaling is managed declaratively under GitOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — source of the metrics that feed the HPA.&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 LLM inference&lt;/a> — what ceiling and what head-room the autoscaler assumes.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — explains &lt;code>num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — governs the KV pool and therefore the thresholds.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — KEDA is a piece of level 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> — the autoscaler coexists with the deployment strategy.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the router consumes &lt;code>vllm:num_requests_running&lt;/code> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (the same metrics as the autoscaler) to pick a replica with token-aware LB and prefix-aware routing; the two components share a cockpit but decide different things.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — runbooks RB-01 (&lt;code>GpuHbmNearOom&lt;/code>) and RB-05 (&lt;code>VllmKvCachePoolNearFull&lt;/code>) use the autoscaler as an immediate mitigation lever.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers: CPU, Memory and Topology Manager&lt;/a> — every replica the autoscaler creates goes through Topology Manager admission; if the node has no free NUMA &amp;ldquo;table&amp;rdquo;, the pod stays pending. Autoscaling has to account for NUMA granularity, not just aggregate CPU and memory.&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 real ceiling on elasticity is not available GPU, it is how long each new replica takes to load the model: 40 s of cold start make scale-to-zero unviable under a latency SLO.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>KEDA project — &lt;code>keda.sh&lt;/code> (documentación oficial de triggers Prometheus y cron).&lt;/li>
&lt;li>Kubernetes — &lt;em>Horizontal Pod Autoscaler walkthrough&lt;/em> (kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale).&lt;/li>
&lt;li>NVIDIA — &lt;em>GPU Operator on Kubernetes&lt;/em> (Helm chart oficial con DaemonSet de drivers y DCGM).&lt;/li>
&lt;li>vLLM project — &lt;code>production_monitoring/&lt;/code> (métricas Prometheus expuestas por el servidor).&lt;/li>
&lt;li>Karpenter — &lt;em>NodePool spec&lt;/em> (etiquetado y taints para nodepools GPU).&lt;/li>
&lt;li>Cluster Autoscaler — &lt;em>Scaling GPU nodes&lt;/em> (caveats de descubrimiento de recursos GPU).&lt;/li>
&lt;li>Kubernetes — &lt;em>Pod lifecycle and termination&lt;/em> (preStop, terminationGracePeriodSeconds).&lt;/li>
&lt;/ul></description></item></channel></rss>