<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Gpu on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/gpu/</link><description>Recent content in Gpu 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/gpu/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>Model chain of trust (4/4): who serves the model and which machine you trust</title><link>https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/</link><pubDate>Sun, 26 Jul 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/</guid><description>&lt;p>The three previous articles closed questions about the artefact: what contract the served model exposes, where its bytes come from and why you trust them. What remains is the one almost nobody asks until an auditor asks it: nothing proves that the process currently returning tokens on port 8000 is the inference engine and not something that slipped into the cluster and inherited its token. And a second one, worse: whether the machine it runs on is trustworthy, and against whom.&lt;/p>
&lt;p>These are different problems with different projects. &lt;strong>Workload identity&lt;/strong> is solved by SPIFFE/SPIRE, a CNCF graduated project since 2022. &lt;strong>Environment isolation&lt;/strong> is covered by Kata Containers and Confidential Containers, the latter promoted to CNCF &lt;em>incubating&lt;/em> on 22 July 2026, four days before this article was published. The first is cheap and almost always worth it. The second is expensive, more than the vendor&amp;rsquo;s press release suggests, and is only justified against a specific threat model.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>User identity ≠ workload identity.&lt;/strong> OIDC says which person is behind a request; it does not say which process serves it. &lt;code>ServiceAccount&lt;/code> tokens, static keys and gateway API keys are &lt;em>bearer&lt;/em> credentials: whoever holds them, is them.&lt;/li>
&lt;li>&lt;strong>SPIFFE&lt;/strong> defines a URI identifier (&lt;code>spiffe://domain/path&lt;/code>) and a verifiable document (&lt;strong>SVID&lt;/strong>, X.509 or JWT) delivered by a Workload API that does not require the workload to hold any prior secret. SPIRE implements it with node and workload attestation; defaults: &lt;code>default_x509_svid_ttl&lt;/code> 1 h, &lt;code>default_jwt_svid_ttl&lt;/code> 5 min, &lt;code>ca_ttl&lt;/code> 24 h.&lt;/li>
&lt;li>Identity is only worth something if something enforces it: Istio/Envoy via SDS, Cilium with mutual authentication (with serious reservations) or an &lt;code>ext_authz&lt;/code> with OPA in front of the gateway.&lt;/li>
&lt;li>&lt;strong>Three rungs of isolation&lt;/strong>: container with a shared kernel → Kata Containers (its own kernel and VM; v3.32.0 from June 2026) → Confidential Containers (hardware TEE, hypervisor outside the trust base, remote attestation with Trustee per RFC 9334).&lt;/li>
&lt;li>&lt;strong>The overhead of confidential mode runs from ~0 % to ~28 %&lt;/strong> depending on what you measure: GPU-only in CC with large batches sits around 4-8 %, while the full stack (a CVM with Intel TDX plus an H100 in CC), measured independently in 2026, gives +21.8 % to +27.8 % on TTFT and −17.7 % to −21.1 % on throughput. Honest heuristic: reserve 15-25 % extra capacity.&lt;/li>
&lt;li>&lt;strong>MIG and vGPU are forbidden in CC mode&lt;/strong> according to NVIDIA&amp;rsquo;s reference architecture, and every GPU on a node goes in the same mode.&lt;/li>
&lt;li>&lt;strong>Closing criterion&lt;/strong>: the order is contract → digest → signature verified at admission → workload identity → TEE.&lt;/li>
&lt;/ul>
&lt;h2 id="the-analogy-the-biosafety-laboratory">The analogy: the biosafety laboratory&lt;/h2>
&lt;p>A laboratory handling pathogens solves two problems much like ours, separately.&lt;/p>
&lt;p>The first is who gets in. The company badge is not enough: at the airlock there is a specific credential, short-lived and automatically renewed (if lost, it expires on its own within an hour), issued only after independently checking that the applicant is where they say they are and is who they say they are. That credential is the &lt;strong>SVID&lt;/strong>; the prior check is &lt;strong>attestation&lt;/strong>.&lt;/p>
&lt;p>The second is which room you work in, and here there are containment levels. On the open bench the air is shared and anything aerosolised affects everyone: the normal container, with a kernel shared by all the neighbours. One rung up, the biological safety cabinet, with a physical barrier and its own airflow: Kata Containers. At the very top, the maximum containment laboratory, where maintenance staff change filters without ever seeing the sample: the TEE, where the operator maintains the machine but cannot read its memory. And before opening the airlock somebody verifies that the integrity of the suit and of the room is as expected: remote attestation with conditional secret release.&lt;/p>
&lt;p>The uncomfortable lesson: the maximum containment level is a cost decision before it is one more degree of security. Nobody builds a BSL-4 to grow yeast. The useful question is who it isolates from, not how much.&lt;/p>
&lt;h2 id="part-a--workload-identity">Part A — Workload identity&lt;/h2>
&lt;h3 id="six-services-talking-to-each-other-and-none-of-them-knows-who-the-other-is">Six services talking to each other and none of them knows who the other is&lt;/h3>
&lt;p>In a serious inference cluster, like the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven layers&lt;/a> one, the gateway talks to the serving engine, the engine queries the vector database, the trace collector receives &lt;em>spans&lt;/em> from everyone and the agents with MCP invoke tools that in turn call the gateway. Many east-west connections and, in most deployments, none of them authenticated.&lt;/p>
&lt;p>User identity says which person is behind the request and is resolved with OIDC, as when &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">putting Keycloak in front of MCP&lt;/a>. Workload identity says which process issues it: there is no human on the other side, no browser, no consent, and the process is born and dies in seconds. Its three usual substitutes fail for different reasons:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mechanism&lt;/th>
&lt;th>Where it fails&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Projected &lt;code>ServiceAccount&lt;/code> token&lt;/td>
&lt;td>SA granularity, not workload granularity: two pods with the same SA are indistinguishable. It does not cross the cluster boundary and does not federate. It is a &lt;em>bearer token&lt;/em> on the filesystem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Static key&lt;/td>
&lt;td>It does not rotate, it gets shared over Slack, it does not distinguish issuer from bearer; once leaked, it is valid until somebody notices&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gateway API key&lt;/td>
&lt;td>It authenticates the &lt;em>client&lt;/em>, not the process. LiteLLM&amp;rsquo;s virtual keys are for quota and budget, not for proving identity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is no cryptographic link between credential and process: copy the credential, copy the identity. In a multi-tenant cluster like the &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">H100 cluster&lt;/a> one, real isolation then depends on network topology and not on identity.&lt;/p>
&lt;h3 id="spiffe-the-standard">SPIFFE: the standard&lt;/h3>
&lt;p>&lt;a href="https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/">SPIFFE&lt;/a> and its implementation &lt;a href="https://github.com/spiffe/spire">SPIRE&lt;/a> graduated together in the CNCF on 20 September 2022. As of July 2026 they are settled infrastructure, not a bet.&lt;/p>
&lt;p>&lt;strong>The SPIFFE ID&lt;/strong> is a URI of the form &lt;code>spiffe://&amp;lt;trust-domain&amp;gt;/&amp;lt;workload-identifier&amp;gt;&lt;/code>, for example &lt;code>spiffe://inferencia.example/ns/inferencia/sa/vllm&lt;/code>: a name, not a credential. &lt;strong>The trust domain&lt;/strong> is the root of trust (organisation, environment or site), and the guidance recommends separating workloads from different sites or security environments into different domains.&lt;/p>
&lt;p>The SVID is the credential that proves that ID. The &lt;strong>X509-SVID&lt;/strong> carries the SPIFFE ID in the URI-type SAN, not in the &lt;code>CN&lt;/code>, which the specification discourages as a source of identity, and it is the preferred format. The &lt;strong>JWT-SVID&lt;/strong> exists for when there are L7 proxies terminating TLS in the middle, with the replay risk the documentation warns about:&lt;/p>
&lt;pre tabindex="0">&lt;code>Certificate:
Issuer: C=ES, O=SPIFFE
Validity
Not Before: Jul 26 08:00:00 2026 GMT
Not After : Jul 26 09:00:00 2026 GMT
Subject: C=ES, O=SPIRE, CN=vllm.inferencia
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature, Key Encipherment
X509v3 Extended Key Usage:
TLS Web Server Authentication, TLS Web Client Authentication
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Alternative Name:
URI:spiffe://inferencia.example/ns/inferencia/sa/vllm
&lt;/code>&lt;/pre>&lt;p>One hour of validity, and what authenticates is the SAN URI: the &lt;code>CN&lt;/code> is decorative.&lt;/p>
&lt;p>&lt;strong>The Workload API&lt;/strong> is the elegant piece: a local UNIX socket from which the workload obtains its SVID, its private key and the trust bundle, without presenting any secret. The documentation is explicit: &amp;ldquo;the Workload API does not require that a calling workload have any knowledge of its own identity, or possess any authentication token&amp;rdquo;. That solves the cold start of all identity cryptography: the secret you would need in order to obtain the first secret.&lt;/p>
&lt;h3 id="spire-how-that-document-is-issued">SPIRE: how that document is issued&lt;/h3>
&lt;p>SPIRE has a server (the authority that signs) and one agent per node (which exposes the Workload API), and it chains two checks.&lt;/p>
&lt;p>&lt;strong>Node attestation&lt;/strong> verifies that the agent runs where it says it does. In Kubernetes the reference attestor is &lt;code>k8s_psat&lt;/code>, which validates a projected token with an audience against the API server; on bare metal there are TPM-based attestors. A note of honesty: SPIRE v1.15.1, from 28 May 2026, is a security patch over an incorrect PKCS7 validation in the &lt;code>azure_imds&lt;/code> attestor that allowed attested documents to be forged and a virtual machine to be impersonated.&lt;/p>
&lt;p>&lt;strong>Workload attestation&lt;/strong> answers the opening question. The &lt;a href="https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md">&lt;code>k8s&lt;/code> attestor&lt;/a> receives the PID of the process that opens the socket, infers the pod from its cgroup membership and queries the kubelet for the metadata. From there come the &lt;strong>selectors&lt;/strong>: namespace, service account, pod name and UID, labels, owner, node and, above all, container and image by tag or by digest. From SPIRE v1.15.0 (19 May 2026) Sigstore support stopped being experimental, enabling selectors by signature verification status, certificate subject and issuer, and transparency log. That closes the series&amp;rsquo; circle: you can require that the engine&amp;rsquo;s SVID go only to the container whose image carries a valid cosign signature from the expected issuer, which is what was verified in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">article 3/4&lt;/a>. Provenance goes from being a one-off admission check to a precondition of identity at runtime.&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">spire-server entry create &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -parentID spiffe://inferencia.example/spire/agent/k8s_psat/prod/nodo-gpu-a &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -spiffeID spiffe://inferencia.example/ns/inferencia/sa/vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:ns:inferencia &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:sa:vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:container-name:vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:container-image:vllm/vllm-openai@sha256:aa11bb22cc33 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -x509SVIDTTL &lt;span class="m">3600&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -federatesWith spiffe://sede-b.example
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The selectors are &lt;strong>conjunctive&lt;/strong>: a pod in another namespace, with another SA or with another image does not obtain that SVID even if it shares the node.&lt;/p>
&lt;p>The default TTLs have three consequences. Rotation is continuous, not a one-off event: code that loads the certificate once at startup will break exactly one hour later. Server downtime has a clock: twenty minutes are invisible, two hours bring down the cluster&amp;rsquo;s authenticated communications, and the &lt;a href="https://spiffe.io/docs/latest/planning/scaling_spire/">scaling guide&lt;/a> admits that &amp;ldquo;a single SPIRE Server instance represents a single point of failure&amp;rdquo;, with the datastore as the bottleneck and indicative figures running from two replicas of 1 CPU and 1 GB for 10 agents to eight of 16 CPU and 16 GB for 5,000 agents and 10,000 workloads. And tag-based selectors are fragile: the runtime may report one tag or another depending on the node and the moment, so the correct selector is the digest, as in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">article 2/4&lt;/a>.&lt;/p>
&lt;h3 id="federation-between-trust-domains">Federation between trust domains&lt;/h3>
&lt;p>Two sites with independent clusters, or an external partner exposing a &lt;em>reranker&lt;/em>, should not share an authority. &lt;a href="https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/">SPIFFE federation&lt;/a> makes each domain publish a &lt;strong>bundle endpoint&lt;/strong> with its public trust material and the other poll it periodically: the &lt;code>https_web&lt;/code> profile authenticates it with web PKI and &lt;code>https_spiffe&lt;/code> with an X509-SVID from the domain itself, enabling automatic rotation and revocation of the root. The relationship is one-directional, bundles from different domains must never be merged and refresh is by polling with a default suggestion of five minutes. In practice, site A&amp;rsquo;s gateway accepts requests from site B&amp;rsquo;s engine without sharing a CA or an identity database, and cutting the relationship means deleting a bundle, not revoking certificates.&lt;/p>
&lt;h3 id="from-identity-to-effective-authorisation">From identity to effective authorisation&lt;/h3>
&lt;p>An SVID blocks nothing by itself; somebody has to compare the presented ID against a policy.&lt;/p>
&lt;p>&lt;strong>Istio + SPIRE&lt;/strong> is the best-worn integration: Istio detects a UNIX socket implementing Envoy&amp;rsquo;s SDS API and the proxy obtains its identities from there instead of from &lt;code>istiod&lt;/code>, mounted with the &lt;strong>SPIFFE CSI Driver&lt;/strong> (recommended over &lt;code>hostMount&lt;/code>). Two conditions break deployments: SPIRE&amp;rsquo;s trust domain and Istio&amp;rsquo;s must match exactly, and SPIRE only issues to previously registered workloads, including Istio&amp;rsquo;s own components. With that, policy is written in terms of identity and not IP:&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">security.istio.io/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">AuthorizationPolicy&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-solo-desde-gateway&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">inferencia&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&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&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ALLOW&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">rules&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">from&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">source&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">principals&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;inferencia.example/ns/inferencia/sa/gateway&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">to&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">operation&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">methods&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;POST&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">paths&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;/v1/chat/completions&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/v1/completions&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is a layer on top of the &lt;code>NetworkPolicy&lt;/code> from &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">layered hardening&lt;/a>: there we closed communication pairs by topology; here we also require who calls and on what path.&lt;/p>
&lt;p>&lt;strong>Cilium&lt;/strong> offers mutual authentication backed by SPIRE, tempting if you already use eBPF for the &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">data network&lt;/a>. It deserves criticism: it is still marked &lt;strong>beta&lt;/strong> in the stable documentation, and The New Stack published a reasoned critique arguing that the design does not preserve mTLS properties over the life of the connection (it uses the &lt;em>handshake&lt;/em> only to authenticate and discards the session keys) and that the identity model rests on eventually consistent per-node IP-identity caches, which can allow traffic that policy should deny.&lt;/p>
&lt;p>&lt;strong>The inference gateway and LiteLLM&lt;/strong> do not speak SPIFFE natively as of this article, and saying so is better than suggesting an integration that does not exist. The pattern that does work is the one the project itself documents: an Envoy in front that terminates mTLS with an X509-SVID or validates a JWT-SVID and delegates to OPA via &lt;code>ext_authz&lt;/code>. The gateway carries on with its own job (quota, budget, routing), as we saw when &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing an OSS gateway&lt;/a> and in the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">L7 router&lt;/a>; workload identity is resolved one hop earlier.&lt;/p>
&lt;h3 id="ai-agents-and-mcp-the-missing-piece">AI agents and MCP: the missing piece&lt;/h3>
&lt;p>An agent invoking tools over MCP is a shared secret with legs: it is handed a static credential, the MCP server cannot know whether the caller is the legitimate agent or any process holding the same string, and the blast radius is the entire set of exposed tools. The &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">threat model for agent isolation&lt;/a> warned that a granted permission is a usable permission; with bearer credentials it is also transferable.&lt;/p>
&lt;p>SPIFFE provides what is missing: the agent presents a short-lived SVID obtained through attestation and the MCP server authorises against the SPIFFE ID. In 2026 this stopped being theory: there is work at the IETF on dynamic OAuth client registration based on trusted SPIFFE issuers, and in June 2026 Google published a SPIFFE-based &lt;em>Agent Identity&lt;/em> in its IAM. None of it is fully standardised.&lt;/p>
&lt;p>Two honest caveats. SPIFFE answers &amp;ldquo;who&amp;rdquo;, not &amp;ldquo;why&amp;rdquo;: to know whether the action is the one the user asked for you need traceability (&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP with OTel&lt;/a>) and runtime detection with &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>. And both identities must be composed: the agent proves its own with an SVID and propagates the delegating user&amp;rsquo;s in the token, authorising on the pair. Collapsing them into one is how you end up with agents able to do, in the system&amp;rsquo;s name, things no user could.&lt;/p>
&lt;h2 id="part-b--isolation-of-the-execution-environment">Part B — Isolation of the execution environment&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three rungs of isolation: container with a shared kernel, virtual machine sandbox with Kata Containers, and trusted execution environment with Confidential Containers, showing which components stay inside the trusted computing base in each case">
&lt;text x="410" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">What stays INSIDE the trusted computing base (TCB)&lt;/text>
&lt;rect x="20" y="44" width="240" height="200" rx="10" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="140" y="68" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="currentColor">1. Container (runc)&lt;/text>
&lt;text x="140" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">namespaces + cgroups + seccomp&lt;/text>
&lt;line x1="40" y1="106" x2="240" y2="106" stroke="currentColor" stroke-width="1"/>
&lt;text x="140" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">host kernel (shared)&lt;/text>
&lt;text x="140" y="146" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">container runtime&lt;/text>
&lt;text x="140" y="166" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">hypervisor / firmware&lt;/text>
&lt;text x="140" y="186" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">platform operator&lt;/text>
&lt;text x="140" y="216" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">startup: ~1-6 s&lt;/text>
&lt;text x="140" y="234" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">cost: zero&lt;/text>
&lt;rect x="290" y="44" width="240" height="200" rx="10" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="410" y="68" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="currentColor">2. Kata Containers&lt;/text>
&lt;text x="410" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">own kernel + lightweight VM&lt;/text>
&lt;line x1="310" y1="106" x2="510" y2="106" stroke="currentColor" stroke-width="1"/>
&lt;text x="410" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">host kernel: OUT&lt;/text>
&lt;text x="410" y="146" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">container runtime: OUT&lt;/text>
&lt;text x="410" y="166" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">hypervisor / firmware: in&lt;/text>
&lt;text x="410" y="186" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">platform operator: in&lt;/text>
&lt;text x="410" y="216" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">startup: +1-2 s over runc&lt;/text>
&lt;text x="410" y="234" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">cost: VM memory&lt;/text>
&lt;rect x="560" y="44" width="240" height="200" rx="10" fill="none" stroke="currentColor" stroke-width="2.2"/>
&lt;text x="680" y="68" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="currentColor">3. Confidential Containers&lt;/text>
&lt;text x="680" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">TEE (SEV-SNP / TDX) + GPU in CC&lt;/text>
&lt;line x1="580" y1="106" x2="780" y2="106" stroke="currentColor" stroke-width="1"/>
&lt;text x="680" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">host kernel: OUT&lt;/text>
&lt;text x="680" y="146" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">hypervisor: OUT&lt;/text>
&lt;text x="680" y="166" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">platform operator: OUT&lt;/text>
&lt;text x="680" y="186" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">CPU/GPU and their firmware: in&lt;/text>
&lt;text x="680" y="216" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">startup: +10 s or more&lt;/text>
&lt;text x="680" y="234" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">cost: 0-25 % of performance&lt;/text>
&lt;text x="410" y="272" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">Each rung takes components out of the TCB. What leaves the TCB can no longer read your memory.&lt;/text>
&lt;text x="410" y="290" text-anchor="middle" font-family="sans-serif" font-size="11" font-style="italic" fill="currentColor">The right question is not "how much does it isolate", but "from whom".&lt;/text>
&lt;/svg>
&lt;/div>
### Kata Containers: the virtual machine sandbox
&lt;p>Kata replaces &lt;code>runc&lt;/code> with a runtime that boots a lightweight virtual machine per pod, with its own kernel and a &lt;code>kata-agent&lt;/code> inside; for Kubernetes it is transparent via &lt;code>RuntimeClass&lt;/code>. As of July 2026 the stable branch is 3.32.x (3.32.0 is from June 2026, with Rust 1.94, Go 1.25.11, QEMU 11.0.1, guest kernel 6.18.35 and containerd 2.3), and in April 2026 the 4.0.0 preview was published, making the Rust runtime (&lt;code>runtime-rs&lt;/code>) the default and leaving the Go one deprecated until 5.0.0, because of memory footprint.&lt;/p>
&lt;p>For GPUs the route is &lt;strong>VFIO passthrough&lt;/strong>. The &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/deploy-kata-containers.html">NVIDIA GPU Operator&lt;/a> automates it with &lt;code>sandboxWorkloads.enabled=true&lt;/code> and &lt;code>sandboxWorkloads.mode=kata&lt;/code>, installing the VFIO Manager, the Sandbox Device Plugin, the Confidential Computing Manager and the Kata Manager. It requires virtualisation and ACS in the BIOS, IOMMU, kata-deploy 3.29.0 or later, containerd (not CRI-O), the &lt;code>KubeletPodResourcesGet&lt;/code> &lt;em>feature gate&lt;/em> (on by default from Kubernetes 1.34) and removing the NVIDIA drivers from the host: the GPU is handed over whole to the VM and the guest manages it.&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">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">Pod&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-kata&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">inferencia&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">runtimeClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata-qemu-nvidia-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">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@sha256:aa11bb22cc33&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/pgpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two measurable costs. Memory: the VM reserves its own and you have to declare it as &lt;code>overhead&lt;/code> in the &lt;code>RuntimeClass&lt;/code> so the scheduler does not overcommit the node. And startup: according to the SESAME'24 study on serverless confidential containers, cold start goes from around 6 s with &lt;code>runc&lt;/code> to around 7 s with Kata, and warm start from 1 s to 2 s. Acceptable for an engine that takes minutes to load weights; unacceptable for ephemeral functions.&lt;/p>
&lt;h3 id="confidential-containers-the-tee">Confidential Containers: the TEE&lt;/h3>
&lt;p>&lt;a href="https://confidentialcontainers.org/">Confidential Containers&lt;/a> (CoCo) takes the hypervisor and the platform operator out of the trust base. The CNCF promoted it to &lt;em>incubating&lt;/em> on 22 July 2026, with more than 150 active contributors, 26 repositories and over 1,200 merged &lt;em>pull requests&lt;/em>, and with Microsoft Azure, Intel, AMD, IBM, NVIDIA, Alibaba and Red Hat behind it. A serious project, but &lt;em>incubating&lt;/em> means it is not graduated and that its integration surface changes between versions.&lt;/p>
&lt;p>The components are the CoCo pods (unmodified containers run in a TEE via Kata) and &lt;strong>Trustee&lt;/strong>: &lt;strong>KBS&lt;/strong> (&lt;em>Key Broker Service&lt;/em>, which releases secrets conditionally), &lt;strong>AS&lt;/strong> (&lt;em>Attestation Service&lt;/em>, which validates the hardware evidence) and &lt;strong>RVPS&lt;/strong> (&lt;em>Reference Value Provider Service&lt;/em>, which holds the reference values). Inside the guest, the Attestation Agent collects the evidence and the Confidential Data Hub consumes the secrets.&lt;/p>
&lt;p>The flow is a clean instance of the RATS architecture from RFC 9334: the agent inside the TEE is the &lt;em>attester&lt;/em>, the Attestation Service the &lt;em>verifier&lt;/em> and the Confidential Data Hub the &lt;em>relying party&lt;/em>. The evidence is a hardware-signed report with the boot measurements; the AS validates it against the RVPS values; and only if it matches does the KBS release the key that decrypts the image layers or the weights. Without valid attestation there is no key, and without a key there is no model: that is what stops the operator extracting it.&lt;/p>
&lt;p>A mapping warning: the &lt;code>confidential-containers/operator&lt;/code> repository was archived in February 2026 and deployment moved to the Helm charts and the &lt;code>trustee-operator&lt;/code>. As of July 2026 the latest chart release is v0.21.0, aligned with kata-deploy 3.31.0, and Trustee v0.20.0, with TLS 1.3 and post-quantum cryptography, external plugins for the KBS and multi-GPU support via Intel Trust Authority and NVIDIA NVSwitch.&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">confidentialcontainers.org/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">TrusteeConfig&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">trusteeconfig&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">operators&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">profileType&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Restrictive&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">httpsSpec&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">tlsSecretName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trustee-tls-cert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>Permissive&lt;/code> profile exists for development and should not leave it: it is the mode in which attestation does not block.&lt;/p>
&lt;h3 id="the-hardware-cpu-and-gpu">The hardware: CPU and GPU&lt;/h3>
&lt;p>On the CPU side the two relevant TEEs are &lt;strong>AMD SEV-SNP&lt;/strong> and &lt;strong>Intel TDX&lt;/strong>; NVIDIA&amp;rsquo;s reference architecture sets EPYC Milan/Genoa and Xeon Emerald/Granite Rapids as the baseline. Host support is no longer the hard part.&lt;/p>
&lt;p>On the GPU side, the &lt;a href="https://cacm.acm.org/practice/creating-the-first-confidential-gpus/">technical description of the design of the first confidential GPUs&lt;/a> explains what CC mode on an H100 buys you. Memory is split into a &lt;strong>Compute Protected Region&lt;/strong> that hardware firewalls stop both the CPU over PCIe and other GPUs over NVLink from accessing. Everything crossing the CPU-GPU boundary passes through &lt;strong>bounce buffers&lt;/strong> encrypted with AES-GCM-256, and command buffers and CUDA kernels are encrypted and signed before crossing the bus. There is a chain of trust from GPU boot with a signed attestation report, and only NVIDIA-signed firmware runs in CC mode, validated against NRAS or locally in isolated environments. And the performance counters are disabled in hardware as a side-channel mitigation: that is the telemetry much of &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">observability with DCGM&lt;/a> depends on.&lt;/p>
&lt;p>What it does not protect: the package&amp;rsquo;s HBM memory is not encrypted (it is considered resistant to interposers, which is a claim about physical difficulty and not a cryptographic guarantee); nothing against denial of service, because the operator who cannot read you can still switch you off; nothing against timing or access-pattern side channels; and nothing against a bug in your own code inside the enclave or prompt injection, which remains the job of &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>.&lt;/p>
&lt;p>On Blackwell, NVIDIA announces (2 July 2026) NVLink encryption that extends confidential computing to up to 8 GPUs, non-existent on Hopper and a condition for serving a large model with tensor parallelism. The reference architecture lists H100, H200, RTX Pro 6000 Blackwell Server Edition and B200 in single-GPU passthrough, and H100/H200 in PPCIe mode and B200 for multi-GPU.&lt;/p>
&lt;h3 id="the-overhead-numbers-with-source-and-with-judgement">The overhead numbers, with source and with judgement&lt;/h3>
&lt;p>Here it pays to distrust everybody, papers included. The published spread runs from 0 % to 28 % because the studies do not measure the same thing.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source (date)&lt;/th>
&lt;th>What it measures&lt;/th>
&lt;th>Result&lt;/th>
&lt;th>Nature&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>NVIDIA, Blackwell blog (Jul. 2026)&lt;/td>
&lt;td>B200 with CC enabled&lt;/td>
&lt;td>−1.0 % to −7.5 % throughput; per-token latency under 8 %; &amp;ldquo;up to 98 % of native performance&amp;rdquo;&lt;/td>
&lt;td>&lt;strong>Vendor claim&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corvex, HGX B200 with TDX (2026)&lt;/td>
&lt;td>&amp;ldquo;Verified&amp;rdquo; deployment, encrypted NVSwitch&lt;/td>
&lt;td>&amp;ldquo;Near-native performance&amp;rdquo;, with no figures of its own&lt;/td>
&lt;td>&lt;strong>Commercial claim&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>arXiv 2409.03992 (2024)&lt;/td>
&lt;td>H100 GPU only in CC, Llama-3.1 8B/70B&lt;/td>
&lt;td>−0.36 % to 6.85 % throughput; TTFT up to +19 %; overhead → 0 as the model grows&lt;/td>
&lt;td>Independent, partial&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>arXiv 2509.18886 (Sep. 2025)&lt;/td>
&lt;td>CPU TEE (TDX/SGX) and GPU TEE (H100), Llama2 7B/13B/70B&lt;/td>
&lt;td>CPU TEE: under 10 % throughput and 20 % latency. GPU TEE: 4-8 %&lt;/td>
&lt;td>Independent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>arXiv 2607.19353 (May 2026)&lt;/td>
&lt;td>&lt;strong>Full stack&lt;/strong>: H100 in CC inside a CVM with TDX, Mistral-7B and Qwen3-30B-A3B under load&lt;/td>
&lt;td>TTFT +21.8 % and +27.8 %; throughput −17.7 % and −21.1 %; closed-loop, 11.5-20.2 %&lt;/td>
&lt;td>Independent, &lt;strong>complete&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reconciliation: the penalty is in the data path, not in the GPU&amp;rsquo;s compute. NVIDIA itself quantifies the bottleneck: the effective bandwidth of the CPU-GPU interconnect in CC mode is limited by the CPU&amp;rsquo;s encryption throughput, &amp;ldquo;around 4 GB/s&amp;rdquo;. From there, three rules. The higher the compute/I-O ratio, the lower the overhead: a 70B with large batches and long sequences amortises the toll almost entirely, and a 7B with small batches and short prompts pays it in full. Adding the CPU TEE brings its own cost, and that is where most of the difference between 4-8 % and 20 % lies. And model loading and cold start are the worst cases, with tens of gigabytes crossing an encrypted bus at 4 GB/s, which makes everything discussed 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> and in &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">speeding up cold start&lt;/a> more important.&lt;/p>
&lt;p>The recommendation from the authors of the 2026 study, reserving between 15 % and 25 % extra capacity, is the figure I would take to a &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> exercise, and not the vendor&amp;rsquo;s &amp;ldquo;98 % of native performance&amp;rdquo;. Both can be true at once; only one is prudent for sizing.&lt;/p>
&lt;h3 id="when-each-rung-is-worth-it">When each rung is worth it&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scenario&lt;/th>
&lt;th>Kata&lt;/th>
&lt;th>CoCo (TEE)&lt;/th>
&lt;th>Reason&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tenants that do not trust each other&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Depends&lt;/td>
&lt;td>Kata removes the shared kernel, a real escape vector&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unaudited third-party code or models&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>A shared kernel is too much surface for someone else&amp;rsquo;s code&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model intellectual property against the &lt;strong>infrastructure operator&lt;/strong>&lt;/td>
&lt;td>Not enough&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>The canonical case: third-party hosting, cloud, a partner providing the iron&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Regulated third-party data on infrastructure you do not control&lt;/td>
&lt;td>Not enough&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>See &lt;a href="https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/">defence&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/infrastructure-healthcare-ai-compliance/">healthcare&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sovereign on-premise, one tenant, trusted operator&lt;/td>
&lt;td>Useful&lt;/td>
&lt;td>&lt;strong>Expensive over-engineering&lt;/strong>&lt;/td>
&lt;td>It protects you from an adversary you do not have&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Low latency with small batches&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;strong>Bad idea&lt;/strong>&lt;/td>
&lt;td>The worst point on the bus-encryption cost/benefit curve&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The nuance that saves the most money: the two axes are independent. Kata without a TEE is cheap and delivers most of the isolation between tenants; CoCo only adds something if your threat model includes the operator. If you build a sovereign factory with your own iron and your own staff and you answer &amp;ldquo;no&amp;rdquo; to that question, &lt;strong>the TEE is a cost with no return&lt;/strong>.&lt;/p>
&lt;h3 id="operational-traps">Operational traps&lt;/h3>
&lt;p>&lt;strong>MIG and vGPU against confidential mode.&lt;/strong> NVIDIA&amp;rsquo;s GA 1.0.0 reference architecture is blunt: MIG and vGPU are forbidden in CC mode, and mixed-mode nodes are not supported; every GPU on a host goes in CC or none does. The forums show the typical contradiction of a moving area: in February 2026 a moderator states that MIG+CC is not supported and in April a user cites the MIG page suggesting that Hopper and Blackwell already allow it. As of this article the reference architecture overrides the product page: if your multi-tenancy rested on &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">partitioning the GPU with MIG&lt;/a>, enabling CC sends you back to &amp;ldquo;one whole GPU per tenant&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Startup grows non-linearly.&lt;/strong> SESAME'24 breaks down where: SEV memory provisioning adds around one second per 2 GB of guest (SEV pins every page in advance, and an inference VM allocates a lot), the OVMF firmware about three seconds and layer decryption another three to five. Scaling from 0 to 16 instances goes from 16 s with &lt;code>runc&lt;/code> to 190 s: irrelevant for a stable &lt;code>Deployment&lt;/code>, a design change for aggressive autoscaling with &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">KEDA&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Version coupling is brutal.&lt;/strong> Kata, kata-deploy, GPU Operator, containerd, QEMU with its own patches, guest kernel, NVIDIA driver and Trustee are a matrix that has to be treated as a unit: the reference architecture fixes Kubernetes 1.32 or later, Kata 3.29, GPU Operator 26.3.1 or later, containerd 2.2.2 or later and QEMU 10.1. Add it to the weight the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">stack&amp;rsquo;s operators&lt;/a> already carry.&lt;/p>
&lt;p>&lt;strong>Attestation fails after a firmware update, and that is normal.&lt;/strong> The RVPS reference values describe a specific boot state. When you patch the SEV-SNP firmware, the microcode or the GPU firmware, the TCB version changes, the evidence stops matching and the KBS does not release keys, so the pods do not start. Since these updates usually respond to a security bulletin, the typical scenario is &amp;ldquo;we applied the critical patch on a Friday and on Saturday inference would not start&amp;rdquo;. The correct procedure reverses the order: update the reference values first with an overlap window, and patch the iron afterwards.&lt;/p>
&lt;p>&lt;strong>The attestation service is a single point of failure by design.&lt;/strong> If the KBS does not answer, no new confidential pod gets its key; those already started survive, but any rescheduling or scaling does not. The same dependency as the SPIRE server and the same treatment: real high availability, its own alert and rehearsed degradation.&lt;/p>
&lt;h2 id="closing-the-series-the-complete-chain">Closing the series: the complete chain&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Article 1/4&lt;/a> set the contract: what the endpoint exposes and what control plane governs it reproducibly. In &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">2/4&lt;/a> the subject was the provenance of the bytes: which registry they come from, by immutable digest and not by a moving tag. &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">3/4&lt;/a> added the cryptographic proof of what they are, with signatures, build attestations and an AIBOM verified at admission. This 4/4 closes with who runs them and where: an SVID that proves the serving process&amp;rsquo;s identity and an isolation rung chosen for the adversary you actually have. If one of the four fails, the other three are worth less than they look: an impeccably signed model served by an unidentified process on a machine a third party can dump is still a problem.&lt;/p>
&lt;p>With a limited budget, the order follows cost per unit of risk removed. First, digests everywhere: almost free, and it removes a whole family of substitution attacks. Second, signature verified at admission, low cost and the first thing an auditor asks to see. Third, a declarative contract and control plane, which make everything else auditable. Fourth, workload identity with SPIFFE/SPIRE: expensive to operate, but it is the only thing that turns east-west traffic into something authorisable, and it becomes mandatory as soon as agents with MCP arrive. And last, the TEE, only if the infrastructure operator is in your threat model; Kata without a TEE can jump ahead to fourth place if there are tenants that do not trust each other.&lt;/p>
&lt;h3 id="mapping-the-series-to-ens-isoiec-42001-and-the-eu-ai-act">Mapping the series to ENS, ISO/IEC 42001 and the EU AI Act&lt;/h3>
&lt;p>The detail is in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">technical controls ENS × ISO 42001 × EU AI Act&lt;/a>; this table is the summary by link.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Link&lt;/th>
&lt;th>ENS measure (RD 311/2022)&lt;/th>
&lt;th>ISO/IEC 42001 (Annex A)&lt;/th>
&lt;th>EU AI Act&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1/4 Contract and control plane&lt;/td>
&lt;td>&lt;code>op.exp.2&lt;/code>; &lt;code>op.mon.1&lt;/code>&lt;/td>
&lt;td>A.6 life cycle&lt;/td>
&lt;td>Art. 12 (record-keeping); Art. 13 (transparency)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2/4 Registry and distribution by digest&lt;/td>
&lt;td>&lt;code>op.ext.3&lt;/code>&lt;/td>
&lt;td>A.10 third parties&lt;/td>
&lt;td>Art. 11 and Annex IV (technical documentation)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3/4 Signature, attestations and AIBOM&lt;/td>
&lt;td>&lt;code>op.exp.6&lt;/code>; &lt;code>op.ext.3&lt;/code>&lt;/td>
&lt;td>A.6.2 and A.7 data and traceability&lt;/td>
&lt;td>Art. 15(5), data and model poisoning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4/4-A Workload identity&lt;/td>
&lt;td>&lt;code>op.acc.1/2/5&lt;/code>; &lt;code>op.exp.11&lt;/code>; &lt;code>mp.com.2-3&lt;/code>&lt;/td>
&lt;td>A.9 use of the AI system&lt;/td>
&lt;td>Art. 15(5), unauthorised third parties&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4/4-B Isolation and TEE&lt;/td>
&lt;td>&lt;code>mp.info.3&lt;/code>; &lt;code>op.exp.2&lt;/code>; &lt;code>mp.com.4&lt;/code>&lt;/td>
&lt;td>A.6 life cycle controls&lt;/td>
&lt;td>Art. 15(4) robustness; Art. 15(5)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Art. 15(5) carries the most weight: it requires high-risk systems to be &amp;ldquo;resilient against attempts by unauthorised third parties to alter their use, outputs or performance&amp;rdquo;, and it enumerates data and model poisoning and confidentiality attacks. Links 3 and 4 are its technical implementation. For the management framework, see &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001 as an AIMS&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">AI Act mapping&lt;/a>.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>&lt;strong>Start workload identity with the gateway↔engine pair and with the agents, not with the whole cluster.&lt;/strong> Registering all forty workloads on day one is the most reliable way to abandon the project: register two entries, set up the Istio policy that requires the gateway&amp;rsquo;s principal and live with it for a couple of weeks to learn what breaks when the certificate rotates. Then the agents with MCP, where static credentials are the entire threat model.&lt;/p>
&lt;p>&lt;strong>Separate the Kata decision from the TEE decision, and take them in that order.&lt;/strong> Kata is a &lt;code>RuntimeClass&lt;/code> change with a second of startup and some memory. CoCo is a redesign: you lose MIG and the GPU&amp;rsquo;s performance counters, you gain ten seconds or more of startup, you pay 15-25 % of capacity and you add two dependencies whose failure prevents workloads from starting. Evaluate the second with a written threat model, not with a slide.&lt;/p>
&lt;p>&lt;strong>If you go to CoCo, rehearse patch day before you need it.&lt;/strong> The failure that will take your inference down will not be an attack, it will be a firmware update that misaligns the RVPS reference values. Write the runbook (update the RVPS with overlap, patch, retire the old value), rehearse it on one node and monitor the attestation success rate as a first-class metric, just as for SPIRE: alert on attestation failures before they turn into 401s at the gateway.&lt;/p>
&lt;p>That closes the series: from the contract to the artefact, from the artefact to its proof, and from the proof to the process and the machine. What lies below is no longer a chain of trust but daily operation: seeing what each process does with &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a> and measuring whether what is served is still good with &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a>. Trust is established once; vigilance is continuous.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Model chain of trust (1/4): KServe and the Open Inference Protocol&lt;/a> — the API contract that opens the series.&lt;/li>
&lt;li>&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 and distribution with OCI and ORAS&lt;/a> — why the digest is everything.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">Model chain of trust (3/4): signature, provenance and AIBOM&lt;/a> — the proof that Sigstore selectors turn into a precondition of identity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">MCP grows up: authentication with Keycloak&lt;/a> — the user identity to compose with the workload one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: from workstation to cluster&lt;/a> — the threat model that SPIFFE gives a non-transferable credential.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a> — the vigilance that starts where the chain of trust ends.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/">Infrastructure and regulatory compliance in defence&lt;/a> — where the TEE stops being over-engineering.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">H100 cluster: multi-tenant platform&lt;/a> — where MIG and confidential mode collide head-on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the other identity plane, the one for people, and where it overlaps with SPIFFE.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the authorisation of the tools an agent invokes, which neither SPIFFE nor the IdP solves.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>SPIFFE and SPIRE Projects Graduate from CNCF Incubator&lt;/em> (20 sep. 2022) — &lt;a href="https://www.cncf.io/announcements/2022/09/20/spiffe-and-spire-projects-graduate-from-cloud-native-computing-foundation-incubator/">https://www.cncf.io/announcements/2022/09/20/spiffe-and-spire-projects-graduate-from-cloud-native-computing-foundation-incubator/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>SPIFFE Concepts&lt;/em> (SPIFFE ID, trust domain, SVID, Workload API) — &lt;a href="https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/">https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>SPIFFE Federation specification&lt;/em> — &lt;a href="https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/">https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>Scaling SPIRE&lt;/em> (dimensionamiento y punto único de fallo) — &lt;a href="https://spiffe.io/docs/latest/planning/scaling_spire/">https://spiffe.io/docs/latest/planning/scaling_spire/&lt;/a>&lt;/li>
&lt;li>spiffe/spire, &lt;em>Releases&lt;/em> (v1.15.0 de 19 may. 2026; v1.15.1 de 28 may. 2026, parche de &lt;code>azure_imds&lt;/code>) — &lt;a href="https://github.com/spiffe/spire/releases">https://github.com/spiffe/spire/releases&lt;/a>&lt;/li>
&lt;li>spiffe/spire, &lt;em>Kubernetes Workload Attestor plugin&lt;/em> (selectores, kubelet, cgroups, Sigstore) — &lt;a href="https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md">https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md&lt;/a>&lt;/li>
&lt;li>spiffe/spire, &lt;em>SPIRE Server configuration reference&lt;/em> (&lt;code>default_x509_svid_ttl&lt;/code>, &lt;code>default_jwt_svid_ttl&lt;/code>, &lt;code>ca_ttl&lt;/code>) — &lt;a href="https://github.com/spiffe/spire/blob/main/doc/spire_server.md">https://github.com/spiffe/spire/blob/main/doc/spire_server.md&lt;/a>&lt;/li>
&lt;li>Istio, &lt;em>SPIRE integration&lt;/em> (Envoy SDS, SPIFFE CSI Driver, trust domain) — &lt;a href="https://istio.io/latest/docs/ops/integrations/spire/">https://istio.io/latest/docs/ops/integrations/spire/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>OPA Authorization with Envoy and JWT-SVIDs&lt;/em> — &lt;a href="https://spiffe.io/docs/latest/microservices/envoy-jwt-opa/readme/">https://spiffe.io/docs/latest/microservices/envoy-jwt-opa/readme/&lt;/a>&lt;/li>
&lt;li>The New Stack, &lt;em>How Cilium&amp;rsquo;s Mutual Authentication Can Compromise Security&lt;/em> — &lt;a href="https://thenewstack.io/how-ciliums-mutual-authentication-can-compromise-security/">https://thenewstack.io/how-ciliums-mutual-authentication-can-compromise-security/&lt;/a>&lt;/li>
&lt;li>Riptides, &lt;em>Bringing SPIFFE to OAuth for MCP&lt;/em> — &lt;a href="https://riptides.io/blog/bringing-spiffe-to-oauth-for-mcp-secure-identity-for-agentic-workloads/">https://riptides.io/blog/bringing-spiffe-to-oauth-for-mcp-secure-identity-for-agentic-workloads/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>Confidential Containers becomes a CNCF incubating project&lt;/em> (22 jul. 2026) — &lt;a href="https://www.cncf.io/blog/2026/07/22/confidential-containers-becomes-a-cncf-incubating-project/">https://www.cncf.io/blog/2026/07/22/confidential-containers-becomes-a-cncf-incubating-project/&lt;/a>&lt;/li>
&lt;li>Confidential Containers, &lt;em>Attestation with Trustee&lt;/em> (KBS, AS, RVPS, CDH) — &lt;a href="https://confidentialcontainers.org/docs/attestation/">https://confidentialcontainers.org/docs/attestation/&lt;/a>&lt;/li>
&lt;li>Confidential Containers, &lt;em>Deploy Trustee in Kubernetes&lt;/em> (11 feb. 2026) — &lt;a href="https://confidentialcontainers.org/blog/2026/02/11/deploy-trustee-in-kubernetes/">https://confidentialcontainers.org/blog/2026/02/11/deploy-trustee-in-kubernetes/&lt;/a>&lt;/li>
&lt;li>confidential-containers/trustee, &lt;em>Releases&lt;/em> (v0.20.0: TLS 1.3, PQC, multi-GPU ITA, NVSwitch) — &lt;a href="https://github.com/confidential-containers/trustee/releases">https://github.com/confidential-containers/trustee/releases&lt;/a>&lt;/li>
&lt;li>IETF, &lt;em>RFC 9334 — Remote ATtestation procedureS (RATS) Architecture&lt;/em> — &lt;a href="https://www.rfc-editor.org/info/rfc9334/">https://www.rfc-editor.org/info/rfc9334/&lt;/a>&lt;/li>
&lt;li>Kata Containers, &lt;em>Kata Containers 4.0.0 Preview&lt;/em> (28 abr. 2026, &lt;code>runtime-rs&lt;/code> por defecto) — &lt;a href="https://katacontainers.io/blog/release-4-0-0-preview/">https://katacontainers.io/blog/release-4-0-0-preview/&lt;/a>&lt;/li>
&lt;li>kata-containers, &lt;em>Release 3.32.0&lt;/em> — &lt;a href="https://github.com/kata-containers/kata-containers/releases/tag/3.32.0">https://github.com/kata-containers/kata-containers/releases/tag/3.32.0&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Deploy with Kata Containers — GPU Operator&lt;/em> — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/deploy-kata-containers.html">https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/deploy-kata-containers.html&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Deploying Proprietary Models Securely with Confidential Computing on Self-Hosted Kubernetes&lt;/em> (GA 1.0.0; MIG y vGPU prohibidos en CC) — &lt;a href="https://docs.nvidia.com/enterprise-reference-architectures/deploying-proprietary-models-confidential-compute-self-hosted-kubernetes/latest/reference-implementations.html">https://docs.nvidia.com/enterprise-reference-architectures/deploying-proprietary-models-confidential-compute-self-hosted-kubernetes/latest/reference-implementations.html&lt;/a>&lt;/li>
&lt;li>CACM, &lt;em>Creating the First Confidential GPUs&lt;/em> (CPR, bounce buffers, contadores deshabilitados, HBM no cifrada) — &lt;a href="https://cacm.acm.org/practice/creating-the-first-confidential-gpus/">https://cacm.acm.org/practice/creating-the-first-confidential-gpus/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Confidential Computing on H100 GPUs for Secure and Trustworthy AI&lt;/em> (límite de ~4 GB/s, NRAS) — &lt;a href="https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/">https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Hardware-Rooted AI Security That Won&amp;rsquo;t Slow You Down&lt;/em> (2 jul. 2026; Blackwell, NVLink cifrado hasta 8 GPU) — &lt;a href="https://developer.nvidia.com/blog/hardware-rooted-ai-security-that-wont-slow-you-down">https://developer.nvidia.com/blog/hardware-rooted-ai-security-that-wont-slow-you-down&lt;/a>&lt;/li>
&lt;li>Corvex, &lt;em>Confidential Computing Meets NVIDIA HGX B200&lt;/em> (claim comercial) — &lt;a href="https://www.corvex.ai/blog/confidential-computing-meets-nvidia-hgxtm-b200-secure-ai-without-the-performance-trade-off">https://www.corvex.ai/blog/confidential-computing-meets-nvidia-hgxtm-b200-secure-ai-without-the-performance-trade-off&lt;/a>&lt;/li>
&lt;li>arXiv 2409.03992, &lt;em>Confidential Computing on NVIDIA Hopper GPUs: A Performance Benchmark Study&lt;/em> — &lt;a href="https://arxiv.org/html/2409.03992v1">https://arxiv.org/html/2409.03992v1&lt;/a>&lt;/li>
&lt;li>arXiv 2509.18886, &lt;em>Confidential LLM Inference: Performance and Cost Across CPU and GPU TEEs&lt;/em> (23 sep. 2025) — &lt;a href="https://arxiv.org/abs/2509.18886">https://arxiv.org/abs/2509.18886&lt;/a>&lt;/li>
&lt;li>arXiv 2607.19353, &lt;em>Benchmarking Confidential GPU Inference on NVIDIA H100 under Intel TDX&lt;/em> (may. 2026; heurística del 15-25 %) — &lt;a href="https://arxiv.org/html/2607.19353">https://arxiv.org/html/2607.19353&lt;/a>&lt;/li>
&lt;li>Segarra et al., &lt;em>Serverless Confidential Containers: Challenges and Opportunities&lt;/em> (SESAME'24) — &lt;a href="https://carlossegarra.com/assets/papers/sesame24-serverlesscoco.pdf">https://carlossegarra.com/assets/papers/sesame24-serverlesscoco.pdf&lt;/a>&lt;/li>
&lt;li>EU Artificial Intelligence Act, &lt;em>Article 15: Accuracy, Robustness and Cybersecurity&lt;/em> — &lt;a href="https://artificialintelligenceact.eu/article/15/">https://artificialintelligenceact.eu/article/15/&lt;/a>&lt;/li>
&lt;li>EU Artificial Intelligence Act, &lt;em>Article 12: Record-Keeping&lt;/em> — &lt;a href="https://artificialintelligenceact.eu/article/12/">https://artificialintelligenceact.eu/article/12/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Volcano and Kueue: gang scheduling, queues and GPU quotas for distributed workloads on Kubernetes</title><link>https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/</link><pubDate>Tue, 16 Jun 2026 13:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Volcano&lt;/strong> (volcano-sh, CNCF incubating) is a &lt;strong>complete batch scheduler&lt;/strong> that replaces or complements the kube-scheduler: it places pods with gang semantics (all-or-nothing via &lt;code>PodGroup&lt;/code>/&lt;code>minMember&lt;/code>), manages queues with priority, DRF fair-share and preemption between queues, and understands network and NUMA topology.&lt;/p>
&lt;p>&lt;strong>Kueue&lt;/strong> (kubernetes-sigs/kueue) is a &lt;strong>queue and quota manager at the Job level&lt;/strong>: it does NOT place pods (it delegates to the kube-scheduler or to Volcano), but it decides when a workload can be admitted according to available quota (&lt;code>ClusterQueue&lt;/code>/&lt;code>LocalQueue&lt;/code>/&lt;code>Cohort&lt;/code>), with fair sharing, borrowing between teams and preemption by priority. It natively integrates Job, JobSet, RayJob, all the Kubeflow operators and more.&lt;/p>
&lt;p>The winning combination in production for multi-tenant GPU workloads is: &lt;strong>Kueue for quota and queues + Volcano (or the sig-scheduler coscheduling plugin) for the job&amp;rsquo;s gang&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Imagine a club with limited capacity and a dance floor inside.&lt;/p>
&lt;p>&lt;strong>Kueue is the doorman and the booking manager&lt;/strong>: it checks whether the team&amp;rsquo;s quota (its reserved capacity) allows the group in, applies the fair waiting list, borrows capacity from other teams if any are idle, and takes the space back when the owner needs it. But the doorman does not decide where each person sits inside the venue.&lt;/p>
&lt;p>&lt;strong>Volcano is the head waiter&lt;/strong>: once the group has permission to come in, he decides which tables they sit at, makes sure the whole group sits down at once or nobody comes in (gang), picks the tables according to topology (who needs to talk to whom) and throws out lower-priority groups to make room if necessary.&lt;/p>
&lt;p>Without a doorman (Kueue), the head waiter does not know how many groups he can take at once or whether a team is exceeding its capacity. Without a head waiter (Volcano), the doorman lets the group in but its members scatter across the available tables on their own, and the party of 8 that needs to sit together never manages it.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-neither-solves-by-default-the-kube-scheduler">The problem neither solves by default: the kube-scheduler&lt;/h2>
&lt;p>Kubernetes&amp;rsquo; &lt;code>kube-scheduler&lt;/code> is a pod scheduler, not a job scheduler. It assigns pods one by one to the most suitable node according to available resources and affinity constraints. For a distributed training workload that needs, say, 8 pods with 4 GPUs each (32 GPUs in total across 8 nodes of 4×H100), the standard scheduler does the following:&lt;/p>
&lt;ol>
&lt;li>It looks for a node with 4 available GPUs. It finds one. It schedules pod 1.&lt;/li>
&lt;li>It looks for another node with 4 GPUs. It finds one. It schedules pod 2.&lt;/li>
&lt;li>It carries on until it reaches pod 6 and it turns out there are no longer any nodes with 4 free GPUs: the cluster has exactly 32 GPUs and other workloads are using some of them.&lt;/li>
&lt;li>Pods 1–5 are &lt;code>Running&lt;/code>. Pods 6–8 are &lt;code>Pending&lt;/code>.&lt;/li>
&lt;li>Pods 1–5 can do nothing without the others: a distributed PyTorch job needs all the workers to start before the &lt;code>torchrun&lt;/code> process can begin. &lt;strong>It waits with the resources occupied. Deadlock.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;p>This is not a bug, it is the design: the kube-scheduler has no concept of &amp;ldquo;schedule this group of pods only if you can schedule all of them&amp;rdquo;. As a result:&lt;/p>
&lt;ul>
&lt;li>The resources of pods 1–5 are locked up without producing work.&lt;/li>
&lt;li>Other jobs that could run with the partial resources also wait.&lt;/li>
&lt;li>If several jobs are in this situation, the cluster can end up with fragmented resources, no job running and everyone in circular deadlock.&lt;/li>
&lt;/ul>
&lt;p>On top of that, the kube-scheduler has no notion of:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Queues&lt;/strong> per team or project with relative priority.&lt;/li>
&lt;li>&lt;strong>Quotas&lt;/strong> of resources per team with the ability to borrow from idle teams.&lt;/li>
&lt;li>&lt;strong>Fair-share&lt;/strong>: if team A has spent weeks using 80 % of the cluster, it should wait longer than team B, which has spent weeks idle.&lt;/li>
&lt;li>&lt;strong>Inter-queue preemption&lt;/strong>: evicting a lower-priority job from another team to make room for this team&amp;rsquo;s urgent job.&lt;/li>
&lt;/ul>
&lt;p>Solving any of these problems requires adding a layer on top of the scheduler. Volcano and Kueue are the two dominant OSS solutions in 2026, with complementary architectural approaches.&lt;/p>
&lt;hr>
&lt;h2 id="volcano-the-batch-scheduler">Volcano: the batch scheduler&lt;/h2>
&lt;h3 id="what-it-is-and-what-it-replaces">What it is and what it replaces&lt;/h3>
&lt;p>Volcano (volcano-sh) is a &lt;strong>Kubernetes-native batch scheduler&lt;/strong> accepted by the CNCF as its first and only official container batch scheduling project (&lt;a href="https://volcano.sh/en/docs/">volcano.sh/en/docs&lt;/a>). At version v1.15.x as of June 2026, with CNCF incubating status.&lt;/p>
&lt;p>Volcano &lt;strong>is not an addon to the kube-scheduler&lt;/strong>: it is an alternative scheduler (or a complementary one, depending on the configuration) that places pods. It is installed as a deployment, and jobs that want to benefit from its capabilities must use the &lt;code>volcano&lt;/code> scheduler class in their pod spec (&lt;code>schedulerName: volcano&lt;/code>) or use the &lt;code>VolcanoJob&lt;/code> CRD.&lt;/p>
&lt;p>Volcano&amp;rsquo;s central value proposition is that it treats groups of pods as atomic scheduling units, not individual pods. That is what makes it possible to solve the deadlock described above.&lt;/p>
&lt;h3 id="gang-scheduling-via-podgroup">Gang scheduling via PodGroup&lt;/h3>
&lt;p>Volcano&amp;rsquo;s central mechanism is the &lt;code>PodGroup&lt;/code>: a CRD that groups a job&amp;rsquo;s pods and defines how many must be schedulable before Volcano starts any of them.&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"># PodGroup for a distributed PyTorch training job: 8 workers, minimum 8&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">scheduling.volcano.sh/v1beta1&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">PodGroup&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">pytorch-train-pg&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">ml-training&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">minMember: 8 # all-or-nothing&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">if there is no room for 8, none starts&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">minResources&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;32&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 8 pods × 4 GPUs = 32 GPUs minimum in the cluster&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">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># which Volcano queue this job is assigned to&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">priorityClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">high-priority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>minMember&lt;/code> parameter implements &lt;strong>all-or-nothing&lt;/strong> semantics: Volcano only assigns nodes to the group&amp;rsquo;s pods when it can assign at least &lt;code>minMember&lt;/code> pods simultaneously. If the cluster does not have capacity for 8 GPU pods at this moment, no pod in the group moves out of &lt;code>Pending&lt;/code>. Nothing is blocked, nothing is fragmented.&lt;/p>
&lt;p>&lt;code>minMember&lt;/code> can be lower than the job&amp;rsquo;s total pod count: this allows &lt;strong>elastic gang scheduling&lt;/strong>, where the job can start with fewer workers and scale up, useful for jobs that tolerate a reduced worker count.&lt;/p>
&lt;p>For a job&amp;rsquo;s pods to be associated with the PodGroup, they carry the annotation:&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"># Pod spec of the PyTorch worker&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">annotations&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">scheduling.volcano.sh/pod-group-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch-train-pg&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">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&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">trainer&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">pytorch/pytorch:2.5-cuda12.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="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;/code>&lt;/pre>&lt;/div>&lt;h3 id="queue-queues-with-quotas-and-priority">Queue: queues with quotas and priority&lt;/h3>
&lt;p>Volcano introduces the &lt;code>Queue&lt;/code> CRD to manage multiple tenants with independent quotas:&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">scheduling.volcano.sh/v1beta1&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">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">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">team-datos&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">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># relative weight for fair-share between queues (proportion plugin)&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">capability&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># absolute ceiling of resources this queue can use&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;16&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">guarantee&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># guaranteed resource, never lent to other queues&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">resource&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;8&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">reclaimable&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="c"># if true, others can reclaim the resources it lends when they need them&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">scheduling.volcano.sh/v1beta1&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">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">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">team-ia&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">weight&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">capability&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;8&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">guarantee&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">resource&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">reclaimable&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;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>weight&lt;/code> field feeds the &lt;strong>proportion plugin&lt;/strong>: queues compete for the cluster&amp;rsquo;s available resources in proportion to their weight. A cluster with 32 GPUs and two queues of weight 4 and 2 splits the GPUs in a 4:2 ratio (≈21 and 11 GPUs respectively) when both are saturated.&lt;/p>
&lt;h3 id="scheduler-plugins-drf-binpack-topology-aware">Scheduler plugins: DRF, binpack, topology-aware&lt;/h3>
&lt;p>Volcano implements its scheduling logic as a pipeline of actions and plugins:&lt;/p>
&lt;p>&lt;strong>Actions&lt;/strong> (what the scheduler does in each cycle):&lt;/p>
&lt;ul>
&lt;li>&lt;code>enqueue&lt;/code>: moves jobs from the waiting queue to schedulable when quota is available.&lt;/li>
&lt;li>&lt;code>allocate&lt;/code>: assigns nodes to schedulable pods.&lt;/li>
&lt;li>&lt;code>preempt&lt;/code>: evicts lower-priority pods to make room for higher-priority ones within the same queue.&lt;/li>
&lt;li>&lt;code>reclaim&lt;/code>: evicts pods from other queues that are using more than their &lt;code>guarantee&lt;/code> to return resources to the owner.&lt;/li>
&lt;li>&lt;code>backfill&lt;/code>: fills idle resources with best-effort jobs that do not interfere with the rest.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Plugins&lt;/strong> relevant to GPU workloads:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Plugin&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gang&lt;/code>&lt;/td>
&lt;td>Implements the PodGroup&amp;rsquo;s all-or-nothing semantics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>proportion&lt;/code>&lt;/td>
&lt;td>Fair-share by queue weight (proportional quota)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>capacity&lt;/code>&lt;/td>
&lt;td>Quotas with &lt;code>guarantee&lt;/code>/&lt;code>capability&lt;/code> and reclaim; a more expressive alternative to proportion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>drf&lt;/code>&lt;/td>
&lt;td>Dominant Resource Fairness: multi-dimensional fair-share (CPU, memory, GPU)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>binpack&lt;/code>&lt;/td>
&lt;td>Packs pods onto the fullest nodes; reduces GPU fragmentation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>priority&lt;/code>&lt;/td>
&lt;td>Orders jobs by priority within the same queue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nodeorder&lt;/code>&lt;/td>
&lt;td>Node scoring according to multiple criteria (affinity, resources, spread)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>task-topology&lt;/code>&lt;/td>
&lt;td>Affinity between pods of the same job (inter-GPU communication)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>numa-aware&lt;/code>&lt;/td>
&lt;td>NUMA affinity: aligns pods with the node&amp;rsquo;s NUMA socket to reduce memory latency&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="network-and-numa-topology-v111">Network and NUMA topology (v1.11+)&lt;/h3>
&lt;p>Volcano v1.11 (February 2025) introduced &lt;strong>Network Topology Aware Scheduling&lt;/strong> as a first-class feature (&lt;a href="https://www.cncf.io/blog/2025/03/05/volcano-v1-11-released-a-new-era-of-cloud-native-scheduling-for-ai-and-big-data/">CNCF blog, March 2025&lt;/a>). Distributed training jobs in a datacenter with a hierarchical network structure (spine/leaf, blocks of nodes with NVSwitch) can declare topology constraints:&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"># VolcanoJob with a network topology constraint&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">batch.volcano.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">Job&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">llm-pretrain&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">ml-training&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">minAvailable&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&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">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&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">plugins&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">ssh&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &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">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &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">svc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &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">networkTopology&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">mode: hard # hard&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">the job MUST satisfy the constraint&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">highestTierAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># pods cannot span beyond a network &amp;#34;block&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">tasks&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">8&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">worker&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">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">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">trainer&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">nvcr.io/nvidia/pytorch:25.01-py3&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;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>highestTierAllowed: block&lt;/code> semantics instruct Volcano to place the 8 pods within the same network block (for example, all the nodes under the same access switch), minimising the inter-block traffic that degrades a distributed all-reduce.&lt;/p>
&lt;p>NUMA awareness works in a similar way: with the &lt;code>numa-aware&lt;/code> plugin, pods request a NUMA policy (&lt;code>single-numa-node&lt;/code>, &lt;code>restricted&lt;/code>, &lt;code>best-effort&lt;/code>) and Volcano selects nodes where the requested CPU, memory and GPU resources are in the same NUMA domain, avoiding the remote memory access overhead (NUMA-crossing) that can degrade training throughput by 15-30 % on multi-socket nodes.&lt;/p>
&lt;h3 id="gpu-virtualization-in-v111">GPU virtualization in v1.11+&lt;/h3>
&lt;p>Volcano v1.11 also introduces support for &lt;strong>dynamic MIG and vCUDA&lt;/strong>: instead of declaring &lt;code>nvidia.com/gpu: 1&lt;/code> for a whole GPU, workloads can declare &lt;code>nvidia.com/gpu-memory: 20Gi&lt;/code> and Volcano (with the corresponding device plugin) dynamically provisions the MIG instance or the vCUDA partition. This is [project marketing with no independent benchmarks published as of June 2026], but the feature&amp;rsquo;s architecture is documented in the code.&lt;/p>
&lt;h3 id="what-it-replaces-or-adds-to-the-default-scheduler">What it replaces or adds to the default scheduler&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Capability&lt;/th>
&lt;th>kube-scheduler&lt;/th>
&lt;th>Volcano&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Placing pods on nodes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (it replaces it for workloads marked with &lt;code>schedulerName: volcano&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gang scheduling (all-or-nothing)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (PodGroup + minMember)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Queues with priority&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (Queue CRD)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inter-queue fair-share&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (DRF, proportion, capacity plugins)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inter-queue preemption&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (reclaim action)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Topology-aware (NUMA, network)&lt;/td>
&lt;td>Partial (node affinity)&lt;/td>
&lt;td>Yes (dedicated plugins)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Elastic gang (minMember &amp;lt; total)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Best-effort backfill&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Framework integrations&lt;/td>
&lt;td>Partial&lt;/td>
&lt;td>MPI, PyTorch, Ray, TensorFlow, Spark, Flink, Horovod&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="kueue-the-queue-and-quota-manager">Kueue: the queue and quota manager&lt;/h2>
&lt;h3 id="what-it-is-and-what-it-does-not-do">What it is and what it does NOT do&lt;/h3>
&lt;p>Kueue (kubernetes-sigs/kueue) is a &lt;strong>Kubernetes-native system that manages quotas and how jobs consume them&lt;/strong> (&lt;a href="https://kueue.sigs.k8s.io/docs/overview/">kueue.sigs.k8s.io&lt;/a>). Kueue decides when a job must wait, when it should be admitted (pods can be created) and when it should be evicted (active pods must be deleted).&lt;/p>
&lt;p>Kueue&amp;rsquo;s central design principle is explicit in its documentation: &lt;strong>avoid duplicating mature functionality of Kubernetes components&lt;/strong>. Autoscaling is the cluster-autoscaler&amp;rsquo;s responsibility. Pod-to-node scheduling is the kube-scheduler&amp;rsquo;s responsibility. Job lifecycle management is the kube-controller-manager&amp;rsquo;s responsibility. Kueue replaces none of them: it sits on top as a layer of &lt;strong>admission control and quota management at the Job level&lt;/strong>.&lt;/p>
&lt;p>This is the fundamental distinction: &lt;strong>Kueue does not place pods on nodes&lt;/strong>. When Kueue admits a workload, it simply allows the corresponding job controller to create the pods, and those pods are scheduled by the kube-scheduler (or by Volcano, if it is configured as the scheduler).&lt;/p>
&lt;h3 id="the-four-core-objects">The four core objects&lt;/h3>
&lt;p>&lt;strong>ResourceFlavor&lt;/strong>: maps abstract resources to concrete groups of physical nodes.&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">kueue.x-k8s.io/v1beta1&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">ResourceFlavor&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">h100-sxm&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">nodeLabels&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">accelerator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&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">node-pool&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-training&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="s2">&amp;#34;nvidia.com/gpu&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Exists&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">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NoSchedule&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>ClusterQueue&lt;/strong>: defines the resource quota per flavor for a tenant. A cluster-scoped object.&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">kueue.x-k8s.io/v1beta1&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">ClusterQueue&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">cq-team-datos&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">cohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-platform &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># cohort it belongs to (can lend/borrow)&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">queueingStrategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BestEffortFIFO&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">namespaceSelector&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">matchLabels&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">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&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">resourceGroups&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">coveredResources&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;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&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">flavors&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">h100-sxm&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># GPUs guaranteed for this team&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">borrowingLimit&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="c"># can take up to 8 additional GPUs from the cohort&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">lendingLimit&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="c"># can lend up to 8 of its 16 nominal GPUs&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="s2">&amp;#34;cpu&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;128&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;512Gi&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">preemption&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">reclaimWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reclaims lent quota by evicting lower-priority jobs&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">borrowWithinCohort&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">policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&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">withinClusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>LocalQueue&lt;/strong>: the namespace-scoped entry point for a team&amp;rsquo;s workloads. Jobs point to their LocalQueue; Kueue maps them to the corresponding ClusterQueue.&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">kueue.x-k8s.io/v1beta1&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">LocalQueue&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">lq-datos&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">ns-datos&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">clusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Cohort&lt;/strong>: groups ClusterQueues that can lend quota to each other. It is not a standalone CRD; it is declared as a field in the ClusterQueue (&lt;code>spec.cohort: name&lt;/code>). Kueue aggregates the available quota of all the ClusterQueues in the cohort and lets any of them borrow what the others are not using, respecting the &lt;code>borrowingLimit&lt;/code> and &lt;code>lendingLimit&lt;/code>.&lt;/p>
&lt;h3 id="fair-sharing-and-preemption">Fair sharing and preemption&lt;/h3>
&lt;p>Kueue implements &lt;strong>Fair Sharing&lt;/strong> as the ordering policy for the queue of pending workloads (&lt;a href="https://kueue.sigs.k8s.io/docs/concepts/fair_sharing/">kueue.sigs.k8s.io/docs/concepts/fair_sharing&lt;/a>): when several workloads compete for quota in the cohort, those belonging to ClusterQueues with higher accumulated historical usage have lower admission priority. This implements equitable sharing without permanently blocking any team.&lt;/p>
&lt;p>Preemption in Kueue operates along two dimensions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>reclaimWithinCohort&lt;/code>&lt;/strong>: the ClusterQueue that lends quota reclaims it by evicting workloads that are using it on loan, according to a priority policy.&lt;/li>
&lt;li>&lt;strong>&lt;code>withinClusterQueue&lt;/code>&lt;/strong>: within the same ClusterQueue, lower-priority workloads are evicted to make way for higher-priority workloads from the same team.&lt;/li>
&lt;/ul>
&lt;h3 id="gang-semantics-in-kueue-all-or-nothing-with-ready-pods">Gang semantics in Kueue: all-or-nothing with ready Pods&lt;/h3>
&lt;p>Kueue provides gang admission at the Job level: it admits the complete workload only when all the necessary quota is available. If a RayJob needs 8 GPUs (1 head + 7 workers), Kueue does not admit the workload until 8 GPUs are available in the ClusterQueue (or borrowed from the cohort). &lt;code>waitForPodsReady&lt;/code> with a timeout adds a second guarantee: if the created pods do not become &lt;code>Ready&lt;/code> within the configured time, Kueue re-queues the workload and releases the quota (&lt;a href="https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/">kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready&lt;/a>).&lt;/p>
&lt;p>This is &lt;strong>gang semantics at the admission level&lt;/strong>, not at the pod placement level. It guarantees that the quota is available before creating the pods, but it does not guarantee that the kube-scheduler can place them all on concrete nodes at the same time. For that second guarantee you need Volcano or the coscheduling plugin.&lt;/p>
&lt;h3 id="topology-aware-scheduling-tas">Topology-Aware Scheduling (TAS)&lt;/h3>
&lt;p>Kueue v0.10+ introduces &lt;strong>Topology-Aware Scheduling&lt;/strong> (&lt;a href="https://kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling/">kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling&lt;/a>): it allows node topologies (blocks, subblocks, hosts) to be defined and workloads to request co-location levels. Kueue only admits the workload when it can satisfy the topological constraint, and it adds node selectors and taints at admission time so that the scheduler places the pods in the right topology.&lt;/p>
&lt;p>TAS is configured with the &lt;code>Topology&lt;/code> CRD:&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">kueue.x-k8s.io/v1beta1&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">Topology&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">datacenter-topology&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">levels&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">nodeLabel&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology.kubernetes.io/block&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">nodeLabel&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology.kubernetes.io/rack&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">nodeLabel&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;kubernetes.io/hostname&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the ResourceFlavor references the topology:&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">kueue.x-k8s.io/v1beta1&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">ResourceFlavor&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">h100-sxm&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">nodeLabels&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">accelerator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&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">topologyName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">datacenter-topology&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="framework-integrations">Framework integrations&lt;/h3>
&lt;p>Kueue has built-in integration (no additional code) for the following workload types, activated with an annotation on the job:&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">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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kueue.x-k8s.io/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># points to the team&amp;#39;s LocalQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The supported workload types include: &lt;code>batch/Job&lt;/code>, &lt;code>JobSet&lt;/code>, &lt;code>RayJob&lt;/code>, &lt;code>RayCluster&lt;/code>, &lt;code>PytorchJob&lt;/code>, &lt;code>TFJob&lt;/code>, &lt;code>MPIJob&lt;/code>, &lt;code>JAXJob&lt;/code>, &lt;code>PaddleJob&lt;/code>, &lt;code>XGBoostJob&lt;/code>, &lt;code>TrainJob&lt;/code>, &lt;code>AppWrapper&lt;/code>, &lt;code>LeaderWorkerSet&lt;/code>, &lt;code>Deployment&lt;/code>, &lt;code>StatefulSet&lt;/code>, and plain &lt;code>Pod&lt;/code>/&lt;code>PodGroup&lt;/code>.&lt;/p>
&lt;p>For LLM workloads, the directly relevant cases:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RayJob&lt;/strong> (distributed training with Ray Train): Kueue admits the RayJob when there is quota for the whole Ray cluster (head + workers). Documented at &lt;a href="https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayjob-kueue-gang-scheduling.html">docs.ray.io&lt;/a>.&lt;/li>
&lt;li>&lt;strong>PyTorchJob&lt;/strong> (Kubeflow Training Operator): gang admission of the complete job.&lt;/li>
&lt;li>&lt;strong>JobSet&lt;/strong>: for coordinated multi-replica jobs (LWS, multi-step pipelines).&lt;/li>
&lt;li>&lt;strong>Deployment/StatefulSet&lt;/strong>: for continuous inference, allowing inference GPU quota to be managed the same way as training quota.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="the-key-distinction-volcano-places-kueue-admits">The key distinction: Volcano places, Kueue admits&lt;/h2>
&lt;p>This table sums up the fundamental architectural difference:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>Volcano&lt;/th>
&lt;th>Kueue&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Main role&lt;/strong>&lt;/td>
&lt;td>Scheduler (places pods on nodes)&lt;/td>
&lt;td>Admission controller + quota manager (decides when to create pods)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Gang scheduling&lt;/strong>&lt;/td>
&lt;td>Yes, at the placement level (PodGroup/minMember)&lt;/td>
&lt;td>Yes, at the admission level (all-or-nothing on quota)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-tenant quotas&lt;/strong>&lt;/td>
&lt;td>Yes (Queue with capability/guarantee)&lt;/td>
&lt;td>Yes (ClusterQueue with nominalQuota/borrowingLimit)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cohorts / borrowing&lt;/strong>&lt;/td>
&lt;td>Partial (reclaimable between queues)&lt;/td>
&lt;td>Yes (Cohort with explicit lendingLimit/borrowingLimit)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Fair sharing&lt;/strong>&lt;/td>
&lt;td>Yes (DRF plugin)&lt;/td>
&lt;td>Yes (Fair Sharing based on historical usage)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Preemption&lt;/strong>&lt;/td>
&lt;td>Yes (preempt + reclaim actions)&lt;/td>
&lt;td>Yes (reclaimWithinCohort, withinClusterQueue)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Topology/NUMA&lt;/strong>&lt;/td>
&lt;td>Yes (dedicated plugins, network topology, NUMA-aware)&lt;/td>
&lt;td>Yes (TAS, topology levels in ResourceFlavor)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Framework integrations&lt;/strong>&lt;/td>
&lt;td>Volcano Job (MPI, PyTorch, Ray, TF, Spark, Flink)&lt;/td>
&lt;td>Native: Job, JobSet, RayJob, Kubeflow, LWS, AppWrapper, Deployment, StatefulSet&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Who places the pods&lt;/strong>&lt;/td>
&lt;td>Volcano&lt;/td>
&lt;td>kube-scheduler (or Volcano if configured)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Installation footprint&lt;/strong>&lt;/td>
&lt;td>Medium-high (its own scheduler, CRDs, webhook, metrics)&lt;/td>
&lt;td>Light (controller, CRDs, webhook; does not replace the scheduler)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Maturity / status&lt;/strong>&lt;/td>
&lt;td>CNCF incubating; v1.15 (June 2026); production at Huawei, Baidu, DiDi&lt;/td>
&lt;td>kubernetes-sigs; API v1beta2; production at Google GKE, Red Hat OpenShift 4.20, Runway ML&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Adoption curve&lt;/strong>&lt;/td>
&lt;td>Steeper (requires changing schedulerName or using the VolcanoJob CRD)&lt;/td>
&lt;td>Gentler (adds labels to existing jobs; does not change the scheduler)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="how-they-coexist-the-production-pattern">How they coexist: the production pattern&lt;/h3>
&lt;p>In production, Kueue and Volcano are &lt;strong>complementary, not mutually exclusive&lt;/strong>. The most common pattern in 2026 for multi-tenant GPU clusters is:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Kueue&lt;/strong> manages the global quota: which team can use how many GPUs, how much it can borrow, when a job goes into a queue versus being admitted.&lt;/li>
&lt;li>&lt;strong>Volcano&lt;/strong> does the gang scheduling at the pod level: once Kueue admits the job (the quota is available), Volcano places the pods, making sure they are all placed simultaneously on compatible nodes.&lt;/li>
&lt;/ol>
&lt;p>The integration is configured by specifying &lt;code>schedulerName: volcano&lt;/code> in the pod specs of the workloads managed by Kueue. Kueue sees the Job/RayJob/PyTorchJob and manages its quota; when it admits it, the pods are created and Volcano places them with gang semantics. Volcano&amp;rsquo;s PodGroups are created automatically by the Volcano Job controller or by the Kubeflow Training Operator itself when it detects that the scheduler is Volcano.&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"># PyTorchJob managed by Kueue (quota) + Volcano (gang placement)&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">kubeflow.org/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">PyTorchJob&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">llm-finetune-70b&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">ns-datos&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">kueue.x-k8s.io/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Kueue manages the quota&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">annotations&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">scheduling.volcano.sh/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Volcano uses its own 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">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">pytorchReplicaSpecs&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">Master&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">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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&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">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">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Volcano does the placement&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">pytorch&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">nvcr.io/nvidia/pytorch:25.01-py3&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">Worker&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">7&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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&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">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">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&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">pytorch&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">nvcr.io/nvidia/pytorch:25.01-py3&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-third-way-the-sig-scheduler-coscheduling-plugin">The third way: the sig-scheduler coscheduling plugin&lt;/h3>
&lt;p>If you do not want to deploy a complete alternative scheduler but you need gang scheduling, there is the &lt;strong>coscheduling plugin&lt;/strong> from &lt;a href="https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/pkg/coscheduling/README.md">kubernetes-sigs/scheduler-plugins&lt;/a>. This plugin extends the kube-scheduler with a PodGroup mechanism similar to Volcano&amp;rsquo;s, implemented as a scheduling framework plugin (permit plugin). The advantage is that it does not replace the scheduler; the disadvantage is that it has less functionality than Volcano (no DRF, no Queue/fair-share, no network topology). It is the right option for simple clusters that only need gang and do not want Volcano&amp;rsquo;s operational complexity. Kueue can also work alongside this plugin.&lt;/p>
&lt;hr>
&lt;h2 id="complete-yaml-examples">Complete YAML examples&lt;/h2>
&lt;h3 id="volcano-queue--podgroup--volcanojob">Volcano: Queue + PodGroup + VolcanoJob&lt;/h3>
&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"># 1. Queue for the data team (16 nominal GPUs, ceiling at 24)&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">scheduling.volcano.sh/v1beta1&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">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">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">team-datos&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">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">capability&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;24&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;192&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="s2">&amp;#34;768Gi&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">guarantee&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">resource&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;16&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">reclaimable&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="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="c"># 2. Queue for the AI team (8 nominal GPUs, ceiling at 16)&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">scheduling.volcano.sh/v1beta1&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">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">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">team-ia&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">weight&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">capability&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;16&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64&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="s2">&amp;#34;256Gi&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">guarantee&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">resource&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;8&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">reclaimable&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="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="c"># 3. PodGroup: 70B fine-tuning job, 8 workers × 4 GPUs = 32 GPUs&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">scheduling.volcano.sh/v1beta1&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">PodGroup&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">finetune-70b-pg&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">ns-datos&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">minMember&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minResources&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;32&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">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&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">priorityClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">training-high&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="c"># 4. VolcanoJob (wrapper that Volcano understands natively)&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">batch.volcano.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">Job&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">finetune-70b&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">ns-datos&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">minAvailable&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&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">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&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">priorityClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">training-high&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">plugins&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">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &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">svc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &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">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">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodEvicted&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RestartJob&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">tasks&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">8&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">worker&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">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TaskCompleted&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CompleteJob&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">annotations&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">scheduling.volcano.sh/pod-group-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">finetune-70b-pg&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">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&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">trainer&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">nvcr.io/nvidia/pytorch:25.01-py3&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;torchrun&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--nproc_per_node=4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--nnodes=8&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="s2">&amp;#34;--node_rank=$(RANK)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--master_addr=$(MASTER_ADDR)&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="s2">&amp;#34;--master_port=23456&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;train.py&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">env&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">NCCL_DEBUG&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="s2">&amp;#34;INFO&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">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">requests&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&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="s2">&amp;#34;64Gi&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">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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&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="s2">&amp;#34;64Gi&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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Never&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="kueue-resourceflavor--clusterqueue--localqueue--annotated-job">Kueue: ResourceFlavor + ClusterQueue + LocalQueue + annotated Job&lt;/h3>
&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"># 1. ResourceFlavor: nodes with H100 SXM&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">kueue.x-k8s.io/v1beta1&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">ResourceFlavor&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">h100-sxm&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">nodeLabels&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">accelerator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&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="s2">&amp;#34;nvidia.com/gpu&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Exists&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">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NoSchedule&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="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="c"># 2. ClusterQueue for the data team: 16 nominal GPUs, can borrow 8&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">kueue.x-k8s.io/v1beta1&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">ClusterQueue&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">cq-team-datos&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">cohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-platform&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">queueingStrategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BestEffortFIFO&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">namespaceSelector&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">matchLabels&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">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&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">resourceGroups&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">coveredResources&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;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&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">flavors&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">h100-sxm&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&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">borrowingLimit&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lendingLimit&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>&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="s2">&amp;#34;cpu&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;128&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;512Gi&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">preemption&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">reclaimWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&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">borrowWithinCohort&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">policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&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">withinClusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&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="c"># 3. ClusterQueue for the AI team: 8 nominal GPUs&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">kueue.x-k8s.io/v1beta1&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">ClusterQueue&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">cq-team-ia&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">cohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-platform&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">queueingStrategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BestEffortFIFO&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">namespaceSelector&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">matchLabels&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">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-ia&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">resourceGroups&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">coveredResources&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;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&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">flavors&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">h100-sxm&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&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">nominalQuota&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">borrowingLimit&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lendingLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&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">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;256Gi&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">preemption&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">reclaimWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&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">withinClusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&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="c"># 4. LocalQueue in the data team&amp;#39;s namespace&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">kueue.x-k8s.io/v1beta1&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">LocalQueue&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">lq-datos&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">ns-datos&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">clusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-datos&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="c"># 5. LocalQueue in the AI team&amp;#39;s namespace&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">kueue.x-k8s.io/v1beta1&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">LocalQueue&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">lq-ia&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">ns-ia&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">clusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-ia&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="c"># 6. Batch inference RayJob managed by Kueue&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">ray.io/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">RayJob&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">batch-eval-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">ns-datos&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">kueue.x-k8s.io/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Kueue manages the admission&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">entrypoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;python batch_eval.py --model /models/llama-70b&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">rayClusterSpec&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">headGroupSpec&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">rayStartParams&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">num-gpus&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">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">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">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">ray-head&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">rayproject/ray-ml:2.40.0-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">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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&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="s2">&amp;#34;64Gi&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">workerGroupSpecs&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">3&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">minReplicas: 3 # gang&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Kueue does not admit if there is no quota for 3 workers&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">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&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">groupName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-worker&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">rayStartParams&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">num-gpus&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">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">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">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">ray-worker&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">rayproject/ray-ml:2.40.0-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">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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&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="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="kueue--volcano-together-pytorchjob-with-kueue-quota-and-volcano-gang-placement">Kueue + Volcano together: PyTorchJob with Kueue quota and Volcano gang placement&lt;/h3>
&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"># PyTorchJob: Kueue controls the quota, Volcano does the gang placement&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">kubeflow.org/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">PyTorchJob&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">distributed-finetune&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">ns-datos&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">kueue.x-k8s.io/queue-name: lq-datos # Kueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">quota and admission&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">annotations&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="c"># Volcano automatically creates the PodGroup when schedulerName=volcano&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">scheduling.volcano.sh/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&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">pytorchReplicaSpecs&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">Master&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">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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&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">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">schedulerName: volcano # Volcano&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gang placement&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">pytorch&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">nvcr.io/nvidia/pytorch:25.01-py3&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&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="s2">&amp;#34;64Gi&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">Worker&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">7&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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&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">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">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&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">pytorch&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">nvcr.io/nvidia/pytorch:25.01-py3&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&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="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="for-llm-workloads-training-fine-tuning-and-batch-inference">For LLM workloads: training, fine-tuning and batch inference&lt;/h2>
&lt;h3 id="distributed-multi-gpu-training-and-fine-tuning">Distributed multi-GPU training and fine-tuning&lt;/h3>
&lt;p>Gang scheduling is &lt;strong>essential&lt;/strong> for any distributed training job that uses NCCL all-reduce (PyTorch DDP, FSDP, DeepSpeed ZeRO). If a single worker in the group does not start, the &lt;code>torchrun&lt;/code> coordinator waits indefinitely; with the standard kube-scheduler this scenario happens every time the cluster is under contention.&lt;/p>
&lt;p>In a generic cluster of 4 nodes of 4×H100 SXM (16 GPUs in total), a fine-tuning job for a 70B model typically requires 8 GPUs in tensor-parallel 8 (TP=8) or 16 GPUs in TP=4 plus data-parallel 4. With Volcano, the &lt;code>PodGroup&lt;/code> with &lt;code>minMember: 8&lt;/code> guarantees that either the 8 pods are placed at once or none blocks resources. With Kueue on top, the quota guarantees that the team does not exceed its 16 nominal GPUs and that other teams with available quota are not blocked by a waiting job.&lt;/p>
&lt;p>The cross-link with &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">inference capacity planning&lt;/a> is direct: the model&amp;rsquo;s VRAM budget (weights plus KV-cache) determines the minimum TP and therefore the PodGroup&amp;rsquo;s &lt;code>minMember&lt;/code>.&lt;/p>
&lt;h3 id="batch-inference-and-evaluations-evals">Batch inference and evaluations (evals)&lt;/h3>
&lt;p>Batch inference jobs, generating responses for an evaluation dataset, processing embeddings in bulk, offline re-ranking, are naturally parallel workloads that do not necessarily require strict gang scheduling (each request is independent), but they do benefit from quota and fair-share.&lt;/p>
&lt;p>For these workloads, &lt;strong>Kueue alone is enough&lt;/strong>: a &lt;code>batch/Job&lt;/code> with multiple independent pods is managed with the ClusterQueue&amp;rsquo;s quota with no need for Volcano. If several teams are submitting evaluation jobs simultaneously, Kueue orders the admission by fair-share and priority, and with cohort borrowing the jobs of teams with free quota do not have to wait for the quota of busy teams.&lt;/p>
&lt;p>The chargeback of these workloads connects directly with what is described in &lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">GPU chargeback and showback&lt;/a>: the ClusterQueue&amp;rsquo;s &lt;code>nominalQuota&lt;/code> is the expression of the GPU budget in Kubernetes, and OpenCost can attribute the cost per namespace or label for the monthly report.&lt;/p>
&lt;h3 id="multi-tenant-gpu-quota-and-chargeback">Multi-tenant GPU quota and chargeback&lt;/h3>
&lt;p>The alignment between Kueue and the chargeback system is direct:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>FinOps concept&lt;/th>
&lt;th>Kueue mechanism&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Guaranteed GPU budget&lt;/td>
&lt;td>&lt;code>nominalQuota&lt;/code> per ClusterQueue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum spending ceiling&lt;/td>
&lt;td>&lt;code>nominalQuota + borrowingLimit&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lending idle capacity&lt;/td>
&lt;td>Cohort + &lt;code>lendingLimit&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reclaiming your own quota&lt;/td>
&lt;td>&lt;code>preemption.reclaimWithinCohort: LowerPriority&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fair-share between teams&lt;/td>
&lt;td>Fair Sharing policy in the ClusterQueue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chargeback of the loan&lt;/td>
&lt;td>borrowed GPU hours × cost per GPU-hour (OpenCost)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For the utilisation dimension as a FinOps lever, see &lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a>: Kueue and Volcano together make it possible to maximise utilisation without sacrificing the quota guarantees, which is exactly the FinOps objective.&lt;/p>
&lt;p>Managing MIG partitions within this system (declaring &lt;code>nvidia.com/mig-4g.40gb&lt;/code> as a resource in the ClusterQueue) integrates naturally: the ResourceFlavor can map to nodes with a specific MIG profile, as explained in &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>.&lt;/p>
&lt;hr>
&lt;h2 id="diagram-the-flow-of-a-training-job-with-kueue--volcano">Diagram: the flow of a training job with Kueue + Volcano&lt;/h2>
&lt;div class="diagram" style="max-width:800px;margin:1rem auto;">
&lt;svg viewBox="0 0 800 380" role="img" aria-label="Admission and scheduling flow of a distributed training job with Kueue and Volcano on Kubernetes" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.4}.dsh{fill:none;stroke:currentColor;stroke-width:1.4;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.4;marker-end:url(#arm)}&lt;/style>
&lt;defs>&lt;marker id="arm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="22" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Distributed training job: Kueue (quota) + Volcano (gang placement)&lt;/text>
&lt;rect class="bx" x="20" y="40" width="140" height="52" rx="5"/>
&lt;text x="90" y="62" text-anchor="middle" class="tl">User / CI&lt;/text>
&lt;text x="90" y="78" text-anchor="middle" class="ts">kubectl apply PyTorchJob&lt;/text>
&lt;path class="ar" d="M160,66 L200,66"/>
&lt;rect class="bx" x="200" y="40" width="155" height="52" rx="5"/>
&lt;text x="277" y="62" text-anchor="middle" class="tl">Kueue controller&lt;/text>
&lt;text x="277" y="78" text-anchor="middle" class="ts">quota available in the CQ?&lt;/text>
&lt;path class="ar" d="M277,92 L277,130"/>
&lt;rect class="dsh" x="200" y="130" width="155" height="40" rx="5"/>
&lt;text x="277" y="146" text-anchor="middle" class="tl">Waiting queue&lt;/text>
&lt;text x="277" y="162" text-anchor="middle" class="ts">fair-share / priority&lt;/text>
&lt;path class="ar" d="M355,66 L400,66"/>
&lt;rect class="bx" x="400" y="40" width="155" height="52" rx="5"/>
&lt;text x="477" y="62" text-anchor="middle" class="tl">Admission (quota OK)&lt;/text>
&lt;text x="477" y="78" text-anchor="middle" class="ts">pods allowed; CQ reserves GPU&lt;/text>
&lt;path class="ar" d="M477,92 L477,140"/>
&lt;rect class="bx" x="400" y="140" width="155" height="52" rx="5"/>
&lt;text x="477" y="162" text-anchor="middle" class="tl">Volcano scheduler&lt;/text>
&lt;text x="477" y="178" text-anchor="middle" class="ts">PodGroup: minMember=8 gangs&lt;/text>
&lt;path class="ar" d="M477,192 L477,240"/>
&lt;rect class="bx" x="400" y="240" width="155" height="52" rx="5"/>
&lt;text x="477" y="262" text-anchor="middle" class="tl">4×H100 SXM nodes&lt;/text>
&lt;text x="477" y="278" text-anchor="middle" class="ts">8 pods × 4 GPU — all at once&lt;/text>
&lt;path class="ar" d="M555,166 L625,166"/>
&lt;rect class="dsh" x="625" y="140" width="150" height="52" rx="5"/>
&lt;text x="700" y="162" text-anchor="middle" class="tl">If there are no nodes&lt;/text>
&lt;text x="700" y="178" text-anchor="middle" class="ts">no pod is placed → it waits&lt;/text>
&lt;text x="90" y="320" class="ts">Kueue: manages quota, cohorts, fair-share, preemption between queues&lt;/text>
&lt;text x="90" y="338" class="ts">Volcano: gang placement (all-or-nothing), topology-aware, NUMA, DRF between Queues&lt;/text>
&lt;text x="90" y="356" class="ts">The two levels are orthogonal: Kueue sees no nodes, Volcano sees no team quota&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="full-comparison-table">Full comparison table&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Volcano&lt;/th>
&lt;th>Kueue&lt;/th>
&lt;th>Coscheduling plugin&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Role&lt;/strong>&lt;/td>
&lt;td>Scheduler (placement)&lt;/td>
&lt;td>Admission + quota (no placement)&lt;/td>
&lt;td>kube-scheduler plugin (placement)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Gang scheduling&lt;/strong>&lt;/td>
&lt;td>Yes, pod level (PodGroup/minMember)&lt;/td>
&lt;td>Yes, admission level (quota gang)&lt;/td>
&lt;td>Yes, pod level (PodGroup)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-tenant quotas&lt;/strong>&lt;/td>
&lt;td>Yes (Queue capability/guarantee)&lt;/td>
&lt;td>Yes (ClusterQueue nominalQuota)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cohorts / borrowing&lt;/strong>&lt;/td>
&lt;td>Limited (reclaimable)&lt;/td>
&lt;td>Yes (Cohort with borrowingLimit/lendingLimit)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Fair-share&lt;/strong>&lt;/td>
&lt;td>Yes (DRF, proportion)&lt;/td>
&lt;td>Yes (Fair Sharing by historical usage)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Inter-queue preemption&lt;/strong>&lt;/td>
&lt;td>Yes (reclaim action)&lt;/td>
&lt;td>Yes (reclaimWithinCohort)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Network / NUMA topology&lt;/strong>&lt;/td>
&lt;td>Yes (v1.11+, dedicated plugins)&lt;/td>
&lt;td>Yes (TAS, Topology CRD)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Native integrations&lt;/strong>&lt;/td>
&lt;td>MPI, PyTorch, Ray, TF, Spark, Flink, Horovod&lt;/td>
&lt;td>Job, JobSet, RayJob, Kubeflow, LWS, AppWrapper, Deployment, StatefulSet&lt;/td>
&lt;td>Any job with a PodGroup&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Who places the pods&lt;/strong>&lt;/td>
&lt;td>Volcano&lt;/td>
&lt;td>kube-scheduler (or Volcano)&lt;/td>
&lt;td>kube-scheduler&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Elastic gang&lt;/strong>&lt;/td>
&lt;td>Yes (minMember &amp;lt; total replicas)&lt;/td>
&lt;td>Partial (partial admission in batch/Job)&lt;/td>
&lt;td>Limited&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Footprint&lt;/strong>&lt;/td>
&lt;td>Medium-high (its own scheduler)&lt;/td>
&lt;td>Light (an additional controller)&lt;/td>
&lt;td>Minimal (a scheduler plugin)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Compatibility with Kueue&lt;/strong>&lt;/td>
&lt;td>Yes (as the scheduler under Kueue)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Yes (a complementary option)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CNCF status / maturity&lt;/strong>&lt;/td>
&lt;td>CNCF incubating, v1.15&lt;/td>
&lt;td>kubernetes-sigs, v1beta2, adopted in GKE/OpenShift&lt;/td>
&lt;td>kubernetes-sigs/scheduler-plugins, experimental&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to choose it&lt;/strong>&lt;/td>
&lt;td>HPC-like distributed training, NUMA, network topology, MPI&lt;/td>
&lt;td>Multi-tenancy with flexible quota, heterogeneous workloads, inference + batch together&lt;/td>
&lt;td>Simple clusters that only need gang without their own scheduler&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="operational-pitfalls-and-honest-scepticism">Operational pitfalls and honest scepticism&lt;/h2>
&lt;h3 id="1-deadlock-from-a-badly-configured-gang">1. Deadlock from a badly configured gang&lt;/h3>
&lt;p>The most frequent scenario: &lt;code>minMember&lt;/code> set equal to the total replica count in a cluster where several jobs compete for the same resources. If two jobs of 8 pods each try to use a cluster with 8 GPU nodes and job A has 4 pods placed (not 8, so Volcano holds them pending), and job B also has 4 pods held, &lt;strong>nobody moves forward&lt;/strong>. Volcano is doing its job properly: it places none of them until there is room for all 8. But if the queues&amp;rsquo; &lt;code>nominalQuota&lt;/code> values are badly sized against the cluster&amp;rsquo;s real capacity, this produces indefinite waits.&lt;/p>
&lt;p>Solution: size the queue quotas so that the sum of &lt;code>guarantee&lt;/code> does not exceed the real capacity, and so that the &lt;code>minMember&lt;/code> values of the active jobs fit inside the available quota. Node autoscaling with ProvisioningRequest (Kueue + cluster-autoscaler) helps, but it introduces provisioning latency that has to be taken into account in the job&amp;rsquo;s SLA.&lt;/p>
&lt;h3 id="2-quota-versus-real-capacity-the-silent-drift">2. Quota versus real capacity: the silent drift&lt;/h3>
&lt;p>Kueue&amp;rsquo;s &lt;code>nominalQuota&lt;/code> and Volcano&amp;rsquo;s &lt;code>guarantee&lt;/code> are administrative declarations. They do not guarantee that the nodes with those GPUs are available, healthy or that the device plugin has registered them correctly. A node in &lt;code>NotReady&lt;/code> with 4 GPUs reduces the real capacity without Kueue knowing: the ClusterQueue will carry on admitting workloads that then cannot be placed.&lt;/p>
&lt;p>Recommended monitoring: cross Kueue&amp;rsquo;s metrics (&lt;code>kueue_admitted_workloads_total&lt;/code>, &lt;code>kueue_pending_workloads&lt;/code>) with the cluster&amp;rsquo;s real capacity metrics (GPUs registered in the device plugin) to detect the drift. Kueue exposes native Prometheus metrics; so does Volcano.&lt;/p>
&lt;h3 id="3-gpu-resource-naming-mig-time-slicing-and-resourceflavor">3. GPU resource naming: MIG, time-slicing and ResourceFlavor&lt;/h3>
&lt;p>If the cluster uses MIG, the resources in the pod specs change from &lt;code>nvidia.com/gpu&lt;/code> to &lt;code>nvidia.com/mig-Xg.Ygb&lt;/code> (for example, &lt;code>nvidia.com/mig-3g.40gb&lt;/code>). Kueue&amp;rsquo;s ResourceFlavors and Volcano&amp;rsquo;s Queues must declare the right resource, or the quota will not match the pods. With time-slicing, the resource is still &lt;code>nvidia.com/gpu&lt;/code> but the device plugin advertises more instances than there are physical GPUs; the quota is expressed in virtual replicas, which can lead to over-admission if the VRAM budget is not taken into account (see &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>).&lt;/p>
&lt;h3 id="4-preemption-in-production-the-evicted-workload-loses-progress">4. Preemption in production: the evicted workload loses progress&lt;/h3>
&lt;p>When Kueue or Volcano evicts a training job that has been running for hours, that job loses its progress if it has no checkpointing configured. The preemption is correct from the quota point of view, but it destroys work if the job is not prepared for it. Before enabling aggressive preemption, verify that all training jobs have periodic checkpointing with automatic restoration. PyTorch plus Torchrun have native support; so does DeepSpeed. Stateless batch inference jobs do not have this problem.&lt;/p>
&lt;h3 id="5-volcano-as-the-sole-scheduler-versus-coexistence-with-the-kube-scheduler">5. Volcano as the sole scheduler versus coexistence with the kube-scheduler&lt;/h3>
&lt;p>Volcano can be configured as the cluster&amp;rsquo;s default scheduler (all pods go through it) or as an alternative scheduler (only pods with &lt;code>schedulerName: volcano&lt;/code>). The first option simplifies the configuration but breaks system pods that assume kube-scheduler behaviour. The second, the recommended one, requires ML jobs to explicitly set &lt;code>schedulerName: volcano&lt;/code>, which can be a non-trivial operator or chart change for existing workloads. Kueue solves this more transparently: it only requires a label on the job, with no change of scheduler.&lt;/p>
&lt;h3 id="6-real-operational-complexity-in-2026">6. Real operational complexity in 2026&lt;/h3>
&lt;p>Running Kueue plus Volcano plus the Training Operator plus the GPU Operator in production means four components with their own CRDs, webhooks, versions and release cycles. A Kubernetes upgrade may require updating all four in sequence. The operational debt is real. For a small team without the capacity to maintain this stack, a managed Kubernetes provider (GKE with native Kueue, OpenShift with the Red Hat build of Kueue) may be more pragmatic than assembling the full stack from scratch.&lt;/p>
&lt;p>The sig-scheduler coscheduling plugin is a deliberately simpler option when all you need is gang: fewer features, less complexity, fewer things to maintain.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">GPU chargeback and showback in multi-tenancy&lt;/a> — how to connect Kueue&amp;rsquo;s &lt;code>nominalQuota&lt;/code> with the monthly OpenCost report per team.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — gang scheduling plus quotas as a tool for raising utilisation without idle capacity blocked by deadlocks.&lt;/li>
&lt;li>&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> — how to declare MIG resources in a Kueue ResourceFlavor and in a Volcano Queue.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the model&amp;rsquo;s VRAM budget determines the PodGroup&amp;rsquo;s minMember and the minimum viable nominalQuota.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — how to combine autoscaling of inference replicas with Kueue quotas so as not to exceed the GPU budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on Kubernetes&lt;/a> — the Training Operator (Kubeflow) is the controller that creates the PyTorchJob/TFJob objects managed by Kueue + Volcano.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">H100 cluster: multi-tenant platform&lt;/a> — the complete architecture of a multi-tenant GPU cluster where Volcano and Kueue are pieces of the scheduling stack.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Volcano — Introducción y features (CNCF incubating, v1.15) — &lt;a href="https://volcano.sh/en/docs/">https://volcano.sh/en/docs/&lt;/a>&lt;/li>
&lt;li>Volcano — Volcano v1.11 Released: A New Era of Cloud-Native Scheduling (CNCF blog, marzo 2025) — &lt;a href="https://www.cncf.io/blog/2025/03/05/volcano-v1-11-released-a-new-era-of-cloud-native-scheduling-for-ai-and-big-data/">https://www.cncf.io/blog/2025/03/05/volcano-v1-11-released-a-new-era-of-cloud-native-scheduling-for-ai-and-big-data/&lt;/a>&lt;/li>
&lt;li>Volcano — Release v1.11.0 (GitHub) — &lt;a href="https://github.com/volcano-sh/volcano/releases/tag/v1.11.0">https://github.com/volcano-sh/volcano/releases/tag/v1.11.0&lt;/a>&lt;/li>
&lt;li>Volcano — Network Topology Aware Scheduling design doc — &lt;a href="https://github.com/volcano-sh/volcano/blob/master/docs/design/Network%20Topology%20Aware%20Scheduling.md">https://github.com/volcano-sh/volcano/blob/master/docs/design/Network%20Topology%20Aware%20Scheduling.md&lt;/a>&lt;/li>
&lt;li>Volcano — NUMA-aware scheduling design — &lt;a href="https://github.com/volcano-sh/volcano/blob/master/docs/design/numa-aware.md">https://github.com/volcano-sh/volcano/blob/master/docs/design/numa-aware.md&lt;/a>&lt;/li>
&lt;li>Volcano — Unified Scheduling (docs oficiales) — &lt;a href="https://volcano.sh/en/docs/unified_scheduling/">https://volcano.sh/en/docs/unified_scheduling/&lt;/a>&lt;/li>
&lt;li>Volcano — Capacity scheduling design — &lt;a href="https://github.com/volcano-sh/volcano/blob/master/docs/design/capacity-scheduling.md">https://github.com/volcano-sh/volcano/blob/master/docs/design/capacity-scheduling.md&lt;/a>&lt;/li>
&lt;li>NVIDIA Technical Blog — Practical Tips for Preventing GPU Fragmentation for Volcano Scheduler — &lt;a href="https://developer.nvidia.com/blog/practical-tips-for-preventing-gpu-fragmentation-for-volcano-scheduler/">https://developer.nvidia.com/blog/practical-tips-for-preventing-gpu-fragmentation-for-volcano-scheduler/&lt;/a>&lt;/li>
&lt;li>Kueue — Overview (kueue.sigs.k8s.io, actualizado febrero 2026) — &lt;a href="https://kueue.sigs.k8s.io/docs/overview/">https://kueue.sigs.k8s.io/docs/overview/&lt;/a>&lt;/li>
&lt;li>Kueue — Cluster Queue (nominalQuota, borrowingLimit, lendingLimit, preemption) — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/cluster_queue/">https://kueue.sigs.k8s.io/docs/concepts/cluster_queue/&lt;/a>&lt;/li>
&lt;li>Kueue — Cohort — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/cohort/">https://kueue.sigs.k8s.io/docs/concepts/cohort/&lt;/a>&lt;/li>
&lt;li>Kueue — Fair Sharing — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/fair_sharing/">https://kueue.sigs.k8s.io/docs/concepts/fair_sharing/&lt;/a>&lt;/li>
&lt;li>Kueue — Topology Aware Scheduling — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling/">https://kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling/&lt;/a>&lt;/li>
&lt;li>Kueue — Preemption — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/preemption/">https://kueue.sigs.k8s.io/docs/concepts/preemption/&lt;/a>&lt;/li>
&lt;li>Kueue — Setup All-or-nothing with ready Pods — &lt;a href="https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/">https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/&lt;/a>&lt;/li>
&lt;li>Kueue — GitHub (kubernetes-sigs/kueue) — &lt;a href="https://github.com/kubernetes-sigs/kueue">https://github.com/kubernetes-sigs/kueue&lt;/a>&lt;/li>
&lt;li>Ray — Gang Scheduling with RayJob and Kueue — &lt;a href="https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayjob-kueue-gang-scheduling.html">https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayjob-kueue-gang-scheduling.html&lt;/a>&lt;/li>
&lt;li>Kubeflow — Volcano scheduler integration — &lt;a href="https://www.kubeflow.org/docs/components/trainer/operator-guides/job-scheduling/volcano/">https://www.kubeflow.org/docs/components/trainer/operator-guides/job-scheduling/volcano/&lt;/a>&lt;/li>
&lt;li>kubernetes-sigs/scheduler-plugins — Coscheduling plugin README — &lt;a href="https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/pkg/coscheduling/README.md">https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/pkg/coscheduling/README.md&lt;/a>&lt;/li>
&lt;li>kubernetes/enhancements — KEP-583 Coscheduling (sig-scheduling) — &lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/583-coscheduling">https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/583-coscheduling&lt;/a>&lt;/li>
&lt;li>InfraCloud — Batch Scheduling on Kubernetes: Comparing YuniKorn, Volcano and Kueue — &lt;a href="https://www.infracloud.io/blogs/batch-scheduling-on-kubernetes/">https://www.infracloud.io/blogs/batch-scheduling-on-kubernetes/&lt;/a>&lt;/li>
&lt;li>Red Hat — Red Hat build of Kueue (OpenShift 4.20) — &lt;a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.20/html/ai_workloads/red-hat-build-of-kueue">https://docs.redhat.com/en/documentation/openshift_container_platform/4.20/html/ai_workloads/red-hat-build-of-kueue&lt;/a>&lt;/li>
&lt;li>AceCloud — Multi GPU Orchestration in Kubernetes 2026: Kueue, Volcano, DRA — &lt;a href="https://acecloud.ai/blog/multi-gpu-orchestration-kubernetes/">https://acecloud.ai/blog/multi-gpu-orchestration-kubernetes/&lt;/a>&lt;/li>
&lt;li>CloudOptimo — Kubernetes AI Infrastructure in 2026: GPU Scheduling and Production Realities — &lt;a href="https://www.cloudoptimo.com/blog/kubernetes-ai-infrastructure-in-2026-gpu-scheduling-and-production-realities/">https://www.cloudoptimo.com/blog/kubernetes-ai-infrastructure-in-2026-gpu-scheduling-and-production-realities/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>From the SLO to the number of GPUs: how to size and justify the investment in inference hardware</title><link>https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/</link><pubDate>Tue, 16 Jun 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>N €&lt;/strong> or &lt;strong>N USD&lt;/strong> (source denominated in dollars); decimal point; comma as thousands separator (&lt;code>1{,}234&lt;/code>). Generic example hardware: 4×H100 SXM5 80 GB nodes. The dollar sign is not used (it is the formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>With an SLO of &lt;strong>TTFT P99 ≤ 300 ms&lt;/strong> and &lt;strong>ITL P99 ≤ 50 ms&lt;/strong> for a production chat, peak GPU utilisation must stay at &lt;strong>≤ 63 %&lt;/strong>.&lt;/li>
&lt;li>A service receiving &lt;strong>5M requests/day&lt;/strong> with 512 input tokens and 256 output tokens on average generates an hourly peak of &lt;strong>~11,600 tok/s&lt;/strong> (with a peak factor of 1.8×). With vLLM on H100 SXM5 (Llama-3.3 70B FP8, ~1,850 tok/s at 50 concurrent requests), &lt;strong>≥ 10 GPUs&lt;/strong> are needed at peak to meet the SLO, which translates into &lt;strong>3 4×H100 nodes&lt;/strong> (with headroom).&lt;/li>
&lt;li>The all-in cost of those 3 nodes is ~&lt;strong>83,300 € per year per node&lt;/strong> (3-year capex + base opex); the &lt;strong>€/1M tokens&lt;/strong> in the base scenario at 63 % utilisation is ~&lt;strong>0.37 €&lt;/strong>.&lt;/li>
&lt;li>Against AWS p5 on-demand (~3.90 USD/GPU-hour after the 44 % cut of Jun. 2025), the utilisation &lt;strong>break-even&lt;/strong> sits at &lt;strong>~55 %&lt;/strong>; against neocloud on-demand (~2.01 USD/GPU-hour), there is no feasible break-even at medium utilisation.&lt;/li>
&lt;li>Capex payback happens between &lt;strong>13 and 24 months&lt;/strong> if sustained utilisation exceeds 70 %.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="1-the-sizing-chain-from-demand-to-the-number-of-gpus">1. The sizing chain: from demand to the number of GPUs&lt;/h2>
&lt;p>Sizing an inference platform follows a causal chain of five steps. Each step has a formula; none can be skipped.&lt;/p>
&lt;h3 id="11-step-1--characterise-the-demand">1.1 Step 1 — Characterise the demand&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Parameter&lt;/th>
&lt;th>Symbol&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Requests per day (average)&lt;/td>
&lt;td>\(D\)&lt;/td>
&lt;td>5,000,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Input tokens per request (average)&lt;/td>
&lt;td>\(L_{\text{in}}\)&lt;/td>
&lt;td>512&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Output tokens per request (average)&lt;/td>
&lt;td>\(L_{\text{out}}\)&lt;/td>
&lt;td>256&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Peak factor (peak hour vs average ratio)&lt;/td>
&lt;td>\(k_{\text{peak}}\)&lt;/td>
&lt;td>1.8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hours per day with significant traffic&lt;/td>
&lt;td>\(H\)&lt;/td>
&lt;td>16&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>average output throughput&lt;/strong>:&lt;/p>
$$\dot{T}_{\text{avg}} = \frac{D \times L_{\text{out}}}{86{,}400\,\text{s}} = \frac{5{,}000{,}000 \times 256}{86{,}400} \approx 14{,}815\;\text{tok/s}$$
&lt;p>The &lt;strong>peak throughput&lt;/strong> (busy hour):&lt;/p>
$$\dot{T}_{\text{peak}} = k_{\text{peak}} \times \frac{D \times L_{\text{out}}}{H \times 3{,}600} = 1.8 \times \frac{5{,}000{,}000 \times 256}{16 \times 3{,}600} \approx 40{,}000\;\text{tok/s}$$
&lt;blockquote>
&lt;p>Note: if the traffic profile has very pronounced peaks (peak/average ratio &amp;gt; 3), sizing is done for the peak and average utilisation falls; the sensitivity analysis in §5 quantifies the effect.&lt;/p>
&lt;/blockquote>
&lt;h3 id="12-step-2--set-the-slo-and-derive-the-maximum-utilisation">1.2 Step 2 — Set the SLO and derive the maximum utilisation&lt;/h3>
&lt;p>The latency SLO imposes a ceiling on GPU utilisation. Using queueing theory (M/G/1 model):&lt;/p>
$$\rho_{\max} \approx 1 - \frac{1}{\sqrt{1 + C_{s}^{2}}} \cdot \frac{W_{\text{queue}}^{*}}{\bar{s}}$$
&lt;p>where \(\rho\) is the utilisation, \(W_{\text{queue}}^{*}\) the maximum admissible queueing time and \(\bar{s}\) the average service time per request. For the simplified M/M/1 case with prefill time dominant:&lt;/p>
$$\rho_{\max} = 1 - \frac{W_{\text{queue}}^{*}}{\bar{s} \cdot (1 + W_{\text{queue}}^{*}/\bar{s})}$$
&lt;p>The practical reference table (derived from the Spheron/Little&amp;rsquo;s Law formula, 2026):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>SLO TTFT P99&lt;/th>
&lt;th>Maximum utilisation \(\rho_{\max}\)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>200 ms&lt;/td>
&lt;td>55 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>300 ms&lt;/td>
&lt;td>63 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>400 ms&lt;/td>
&lt;td>70 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>500 ms&lt;/td>
&lt;td>75 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For our example (SLO 300 ms P99): \(\rho_{\max} = 0.63\).&lt;/p>
&lt;p>The ITL SLO imposes an additional constraint: the inference engine must be able to generate the next token in ≤ 50 ms. On H100 SXM5 with vLLM and Llama-3.3 70B FP8, the ITL P50 at 50 concurrent requests is ~20 ms, with P99 ~45 ms. ITL is the dominant constraint only when the batch size is very high (&amp;gt;64 sequences) or VRAM is nearly full.&lt;/p>
&lt;h3 id="13-step-3--required-throughput-and-throughput-per-gpu">1.3 Step 3 — Required throughput and throughput per GPU&lt;/h3>
&lt;p>The throughput the cluster must serve at peak, while respecting \(\rho_{\max}\):&lt;/p>
$$\dot{T}_{\text{required}} = \frac{\dot{T}_{\text{peak}}}{\rho_{\max}} = \frac{40{,}000}{0.63} \approx 63{,}500\;\text{tok/s (installed capacity)}$$
&lt;p>The &lt;strong>throughput per GPU&lt;/strong> (reference benchmark, vLLM v0.18.0, Llama-3.3 70B FP8, H100 SXM5 80 GB, 50 concurrent requests):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Engine&lt;/th>
&lt;th>Throughput (tok/s per GPU)&lt;/th>
&lt;th>TTFT P50 / P95 (50 req)&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>vLLM 0.18.0&lt;/td>
&lt;td>1,850&lt;/td>
&lt;td>380 ms / 720 ms&lt;/td>
&lt;td>Spheron benchmarks, Mar. 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SGLang 0.5.9&lt;/td>
&lt;td>1,920&lt;/td>
&lt;td>360 ms / 680 ms&lt;/td>
&lt;td>Spheron benchmarks, Mar. 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TensorRT-LLM 1.2.0&lt;/td>
&lt;td>2,100&lt;/td>
&lt;td>340 ms / 620 ms&lt;/td>
&lt;td>Spheron benchmarks, Mar. 2026&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>The throughput data correspond to the test with Llama 3.3 70B Instruct FP8, 512 input / 256 output tokens, 50 concurrent requests, on bare-metal H100 SXM5. See the engine analysis in &lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">serving engines Pareto comparison&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;p>We use vLLM as the general-purpose production reference: \(\dot{T}_{\text{GPU}} = 1{,}850\) tok/s.&lt;/p>
&lt;h3 id="14-step-4--number-of-gpus-and-number-of-nodes">1.4 Step 4 — Number of GPUs and number of nodes&lt;/h3>
$$N_{\text{GPU}} = \left\lceil \frac{\dot{T}_{\text{required}}}{\dot{T}_{\text{GPU}}} \right\rceil = \left\lceil \frac{63{,}500}{1{,}850} \right\rceil = \lceil 34.3 \rceil = 35\;\text{GPUs}$$
&lt;p>With 4×H100 SXM5 nodes:&lt;/p>
$$N_{\text{nodes}} = \left\lceil \frac{N_{\text{GPU}}}{4} \right\rceil = \left\lceil \frac{35}{4} \right\rceil = 9\;\text{nodes}$$
&lt;p>We add &lt;strong>15 % headroom&lt;/strong> for hardware failures (~5 % annual rate in small clusters), upgrades and unforeseen peaks:&lt;/p>
$$N_{\text{nodes, final}} = \lceil 9 \times 1.15 \rceil = 11\;\text{nodes} \approx 44\;\text{GPUs}$$
&lt;blockquote>
&lt;p>For the TL;DR example (5M requests/day with a 16 h active profile, k 1.8 and an SLO of 300 ms) the number of nodes is 11. The simplified TL;DR case with k=1 and H=24 gives 3 nodes; the difference illustrates the impact of the hourly profile.&lt;/p>
&lt;/blockquote>
&lt;h3 id="15-summary-of-the-chain">1.5 Summary of the chain&lt;/h3>
&lt;div class="diagram" style="max-width:700px;margin:1rem auto;">
&lt;svg viewBox="0 0 700 110" role="img" aria-label="Sizing chain: demand and SLO derive the peak throughput, which divided by the throughput per GPU gives the number of GPUs and of nodes" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.2}.ts{font:11px sans-serif;fill:currentColor}.arr{fill:none;stroke:currentColor;stroke-width:1.2;marker-end:url(#a)}&lt;/style>
&lt;defs>
&lt;marker id="a" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
&lt;path d="M0,0 L6,3 L0,6 Z" fill="currentColor"/>
&lt;/marker>
&lt;/defs>
&lt;rect class="bx" x="10" y="30" width="110" height="50" rx="4"/>
&lt;text x="65" y="52" class="ts" text-anchor="middle">Demand&lt;/text>
&lt;text x="65" y="66" class="ts" text-anchor="middle">D, L, k, H&lt;/text>
&lt;line class="arr" x1="120" y1="55" x2="155" y2="55"/>
&lt;rect class="bx" x="155" y="30" width="110" height="50" rx="4"/>
&lt;text x="210" y="52" class="ts" text-anchor="middle">SLO&lt;/text>
&lt;text x="210" y="66" class="ts" text-anchor="middle">TTFT/ITL P99 → ρ&lt;/text>
&lt;line class="arr" x1="265" y1="55" x2="300" y2="55"/>
&lt;rect class="bx" x="300" y="30" width="110" height="50" rx="4"/>
&lt;text x="355" y="52" class="ts" text-anchor="middle">Required&lt;/text>
&lt;text x="355" y="66" class="ts" text-anchor="middle">throughput&lt;/text>
&lt;line class="arr" x1="410" y1="55" x2="445" y2="55"/>
&lt;rect class="bx" x="445" y="30" width="110" height="50" rx="4"/>
&lt;text x="500" y="52" class="ts" text-anchor="middle">N GPUs /&lt;/text>
&lt;text x="500" y="66" class="ts" text-anchor="middle">N nodes&lt;/text>
&lt;line class="arr" x1="555" y1="55" x2="590" y2="55"/>
&lt;rect class="bx" x="590" y="30" width="100" height="50" rx="4"/>
&lt;text x="640" y="52" class="ts" text-anchor="middle">TCO →&lt;/text>
&lt;text x="640" y="66" class="ts" text-anchor="middle">€/1M tok&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="2-from-sizing-to-cost-the-tco-model">2. From sizing to cost: the TCO model&lt;/h2>
&lt;p>With \(N_{\text{nodes}} = 11\) 4×H100 SXM5 nodes, the TCO follows the model detailed in &lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of an on-premise GPU cluster&lt;/a>. Here the compact formula is reproduced and applied to the sized cluster.&lt;/p>
&lt;h3 id="21-annual-cost-per-node-base-scenario">2.1 Annual cost per node (base scenario)&lt;/h3>
$$C_{\text{node/year}} = \frac{\text{node capex}}{\text{years}} + \text{node opex/year}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Line item&lt;/th>
&lt;th>Value (USD / €)&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>4×H100 node capex (midpoint)&lt;/td>
&lt;td>178,500 USD&lt;/td>
&lt;td>GPUs + server + network + storage + rack&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3-year amortisation&lt;/td>
&lt;td>59,500 USD/year ≈ 55,300 €/year&lt;/td>
&lt;td>Straight line&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Opex/year (base scenario, 8–16 node cluster)&lt;/td>
&lt;td>~28,000 €/year&lt;/td>
&lt;td>Energy + staff + maintenance + colocation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total per node per year&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~83,300 €/year&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For 11 nodes: &lt;strong>915,300 €/year&lt;/strong> of total fixed cost.&lt;/p>
&lt;h3 id="22-from-node-year-to-gpu-hour">2.2 From €/node-year to €/GPU-hour&lt;/h3>
$$\text{EUR/GPU-hour} = \frac{C_{\text{node/year}}}{4\;\text{GPUs} \times 8{,}760\;\text{h} \times \rho}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation \(\rho\)&lt;/th>
&lt;th>EUR/GPU-hour&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>40 %&lt;/td>
&lt;td>5.93&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>55 %&lt;/td>
&lt;td>4.31&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>63 % (SLO 300 ms)&lt;/td>
&lt;td>3.76&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70 %&lt;/td>
&lt;td>3.39&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>2.97&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>2.38&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="23-from-gpu-hour-to-1m-tokens">2.3 From €/GPU-hour to €/1M tokens&lt;/h3>
$$\text{EUR/1M tokens} = \frac{\text{EUR/GPU-hour} \times 10^{6}}{\dot{T}_{\text{GPU}} \times 3{,}600}$$
&lt;p>With \(\dot{T}_{\text{GPU}} = 1{,}850\) tok/s (vLLM, Llama-3.3 70B FP8):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation&lt;/th>
&lt;th>EUR/GPU-hour&lt;/th>
&lt;th>EUR/1M tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>40 %&lt;/td>
&lt;td>5.93&lt;/td>
&lt;td>&lt;strong>0.891&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>55 %&lt;/td>
&lt;td>4.31&lt;/td>
&lt;td>&lt;strong>0.647&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>63 % (SLO 300 ms)&lt;/td>
&lt;td>3.76&lt;/td>
&lt;td>&lt;strong>0.564&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70 %&lt;/td>
&lt;td>3.39&lt;/td>
&lt;td>&lt;strong>0.509&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>2.97&lt;/td>
&lt;td>&lt;strong>0.446&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>2.38&lt;/td>
&lt;td>&lt;strong>0.357&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>The complete cost/token identity as a function of throughput and utilisation is developed in &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">cost per token and per request&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h3 id="24-comparison-with-cloud-equivalent-1m-tokens">2.4 Comparison with cloud (equivalent €/1M tokens)&lt;/h3>
&lt;p>To compare, the cloud price is converted to the €/1M tokens equivalent using the same reference throughput (\(\dot{T}_{\text{GPU}} = 1{,}850\) tok/s):&lt;/p>
$$\text{EUR/1M tokens (cloud)} = \frac{P_{\text{cloud}} \times 10^{6}}{1{,}850 \times 3{,}600}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Provider / Mode&lt;/th>
&lt;th>GPU-hour price (USD)&lt;/th>
&lt;th>EUR/GPU-hour (\(1\,\text{USD} \approx 0.93\,\text{EUR}\))&lt;/th>
&lt;th>EUR/1M tokens equiv.&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>AWS p5 on-demand (post Jun. 2025)&lt;/td>
&lt;td>3.90&lt;/td>
&lt;td>3.63&lt;/td>
&lt;td>&lt;strong>0.545&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS p5 1-year reserved&lt;/td>
&lt;td>~2.50&lt;/td>
&lt;td>2.33&lt;/td>
&lt;td>&lt;strong>0.350&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CoreWeave on-demand&lt;/td>
&lt;td>~2.01&lt;/td>
&lt;td>1.87&lt;/td>
&lt;td>&lt;strong>0.281&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CoreWeave 3-year reserved&lt;/td>
&lt;td>~1.49&lt;/td>
&lt;td>1.39&lt;/td>
&lt;td>&lt;strong>0.209&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GCP A3 on-demand&lt;/td>
&lt;td>~3.67&lt;/td>
&lt;td>3.41&lt;/td>
&lt;td>&lt;strong>0.512&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Azure ND H100 v5 on-demand&lt;/td>
&lt;td>~6.98&lt;/td>
&lt;td>6.49&lt;/td>
&lt;td>&lt;strong>0.975&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Sources: IntuitionLabs (Jun. 2026), CloudZero (Jun. 2026), Spheron GPU pricing (May 2026).&lt;/p>
&lt;hr>
&lt;h2 id="3-break-even-and-payback-of-your-own-investment">3. Break-even and payback of your own investment&lt;/h2>
&lt;h3 id="31-the-utilisation-break-even-formula">3.1 The utilisation break-even formula&lt;/h3>
&lt;p>The utilisation break-even \(u^{*}\) is the utilisation at which the annual on-prem cost per GPU-hour equals the cloud price:&lt;/p>
$$u^{*} = \frac{\text{capex/year} + \text{opex/year}}{4 \times 8{,}760 \times P_{\text{cloud}}}$$
&lt;p>where \(P_{\text{cloud}}\) is the cloud price in the same currency as the on-prem costs.&lt;/p>
&lt;p>With the base scenario (capex/year 55,300 €, opex/year 28,000 €, total 83,300 €/node/year):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cloud reference&lt;/th>
&lt;th>Cloud price (EUR/GPU-hour)&lt;/th>
&lt;th>\(u^{*}\) break-even&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Azure on-demand (~6.49 EUR)&lt;/td>
&lt;td>6.49&lt;/td>
&lt;td>&lt;strong>23 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS on-demand post-cut (~3.63 EUR)&lt;/td>
&lt;td>3.63&lt;/td>
&lt;td>&lt;strong>41 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GCP on-demand (~3.41 EUR)&lt;/td>
&lt;td>3.41&lt;/td>
&lt;td>&lt;strong>44 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CoreWeave on-demand (~1.87 EUR)&lt;/td>
&lt;td>1.87&lt;/td>
&lt;td>&lt;strong>80 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CoreWeave 3-yr reserved (~1.39 EUR)&lt;/td>
&lt;td>1.39&lt;/td>
&lt;td>&lt;strong>&amp;gt;100 %&lt;/strong> (impossible)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS 1-yr reserved (~2.33 EUR)&lt;/td>
&lt;td>2.33&lt;/td>
&lt;td>&lt;strong>64 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;div class="diagram" style="max-width:720px;margin:1rem auto;">
&lt;svg viewBox="0 0 720 240" role="img" aria-label="Break-even on-prem vs cloud: the on-prem curve falls with utilisation and crosses AWS on-demand at 41 per cent, GCP at 44 per cent and CoreWeave on-demand at 80 per cent" xmlns="http://www.w3.org/2000/svg">
&lt;style>.ax{fill:none;stroke:currentColor;stroke-width:1}.cv{fill:none;stroke:currentColor;stroke-width:1.8}.dsh{fill:none;stroke:currentColor;stroke-width:1.2;stroke-dasharray:5 3}.ts{font:11px sans-serif;fill:currentColor}.tl{font:600 11px sans-serif;fill:currentColor}&lt;/style>
&lt;line class="ax" x1="60" y1="20" x2="60" y2="200"/>
&lt;line class="ax" x1="60" y1="200" x2="700" y2="200"/>
&lt;text x="20" y="120" class="ts" transform="rotate(-90 20 120)">EUR/GPU-h&lt;/text>
&lt;text x="340" y="222" class="ts">utilisation →&lt;/text>
&lt;text x="62" y="216" class="ts">0&lt;/text>
&lt;text x="190" y="216" class="ts">30&lt;/text>
&lt;text x="330" y="216" class="ts">60&lt;/text>
&lt;text x="460" y="216" class="ts">80&lt;/text>
&lt;text x="600" y="216" class="ts">100 %&lt;/text>
&lt;line class="ax" x1="200" y1="197" x2="200" y2="203"/>
&lt;line class="ax" x1="340" y1="197" x2="340" y2="203"/>
&lt;line class="ax" x1="460" y1="197" x2="460" y2="203"/>
&lt;line class="ax" x1="600" y1="197" x2="600" y2="203"/>
&lt;path class="cv" d="M75,30 C120,55 200,100 300,140 C400,165 520,180 670,190"/>
&lt;text x="76" y="26" class="tl">on-prem (fixed capex)&lt;/text>
&lt;line class="cv" x1="60" y1="130" x2="700" y2="130"/>
&lt;text x="590" y="125" class="ts">Azure OD (6.49 €)&lt;/text>
&lt;line class="cv" x1="60" y1="160" x2="700" y2="160"/>
&lt;text x="590" y="155" class="ts">AWS OD (3.63 €)&lt;/text>
&lt;line class="cv" x1="60" y1="167" x2="700" y2="167"/>
&lt;text x="590" y="178" class="ts">GCP OD (3.41 €)&lt;/text>
&lt;line class="cv" x1="60" y1="185" x2="700" y2="185"/>
&lt;text x="576" y="181" class="ts">CoreWeave OD (1.87 €)&lt;/text>
&lt;line class="dsh" x1="240" y1="20" x2="240" y2="200"/>
&lt;text x="210" y="38" class="tl">41 % (AWS)&lt;/text>
&lt;line class="dsh" x1="258" y1="20" x2="258" y2="200"/>
&lt;text x="255" y="52" class="ts">44 % (GCP)&lt;/text>
&lt;line class="dsh" x1="475" y1="20" x2="475" y2="200"/>
&lt;text x="448" y="38" class="ts">80 % (CoreWeave OD)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="32-capex-payback">3.2 Capex payback&lt;/h3>
&lt;p>Payback is the time \(T_{\text{pay}}\) in which the cumulative saving against cloud equals the initial capex:&lt;/p>
$$T_{\text{pay}} = \frac{\text{total cluster capex}}{(\text{cloud cost/year}) - (\text{on-prem opex/year})}$$
&lt;p>where the cloud cost/year is computed at the same sustained utilisation.&lt;/p>
&lt;p>For the 11-node cluster (total capex 11 × 178,500 USD ≈ 1,825,000 USD ≈ 1,697,000 €):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cloud reference&lt;/th>
&lt;th>Sustained utilisation&lt;/th>
&lt;th>Annual saving vs cloud&lt;/th>
&lt;th>Payback&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>AWS on-demand (3.63 EUR/h)&lt;/td>
&lt;td>70 %&lt;/td>
&lt;td>(3.63 − 3.39 EUR) × 4 × 8760 × 0.70 × 11 nodes ≈ &lt;strong>63,900 €/year&lt;/strong>&lt;/td>
&lt;td>~27 months&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS on-demand (3.63 EUR/h)&lt;/td>
&lt;td>80 %&lt;/td>
&lt;td>(3.63 − 2.97) × 4 × 8760 × 0.80 × 11 ≈ &lt;strong>203,600 €/year&lt;/strong>&lt;/td>
&lt;td>~10 months&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GCP on-demand (3.41 EUR/h)&lt;/td>
&lt;td>70 %&lt;/td>
&lt;td>(3.41 − 3.39) × 4 × 8760 × 0.70 × 11 ≈ &lt;strong>5,400 €/year&lt;/strong>&lt;/td>
&lt;td>~315 months (not viable)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Azure on-demand (6.49 EUR/h)&lt;/td>
&lt;td>70 %&lt;/td>
&lt;td>(6.49 − 3.39) × 4 × 8760 × 0.70 × 11 ≈ &lt;strong>830,000 €/year&lt;/strong>&lt;/td>
&lt;td>~2 months&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>The 13-month payback quoted in studies such as Lenovo TCO 2026 corresponds to ~80 % utilisation against high-priced hyperscalers (Azure/AWS before the cut). With current prices (post June 2025, AWS at 3.90 USD), the window widens.&lt;/p>
&lt;/blockquote>
&lt;h3 id="33-simple-payback-capex-only-vs-equivalent-cloud">3.3 Simple payback (capex only vs equivalent cloud)&lt;/h3>
&lt;p>If on-prem opex is omitted and only capex is compared with the gross saving:&lt;/p>
$$T_{\text{pay,simple}} = \frac{\text{capex}}{P_{\text{cloud}} \times 4 \times 8{,}760 \times \rho \times N_{\text{nodes}}}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation&lt;/th>
&lt;th>AWS (3.63 EUR)&lt;/th>
&lt;th>Azure (6.49 EUR)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>54 months&lt;/td>
&lt;td>30 months&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70 %&lt;/td>
&lt;td>39 months&lt;/td>
&lt;td>22 months&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>34 months&lt;/td>
&lt;td>19 months&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="4-decision-table-when-to-buy-rent-or-go-hybrid">4. Decision table: when to buy, rent or go hybrid&lt;/h2>
&lt;p>The table below is a five-dimension Pareto. There is no implicit order between columns; the reading depends on the organisation&amp;rsquo;s constraints.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>EUR/GPU-hour (util. 70 %)&lt;/th>
&lt;th>Initial capex&lt;/th>
&lt;th>Required utilisation&lt;/th>
&lt;th>Data sovereignty&lt;/th>
&lt;th>Peak elasticity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>On-prem purchase, util. ≥ 70 %&lt;/strong>&lt;/td>
&lt;td>3.39&lt;/td>
&lt;td>high (178k USD/node)&lt;/td>
&lt;td>≥ 70 % sustained&lt;/td>
&lt;td>total&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>On-prem purchase, util. &amp;lt; 50 %&lt;/strong>&lt;/td>
&lt;td>&amp;gt; 4.75&lt;/td>
&lt;td>high&lt;/td>
&lt;td>&amp;lt; 50 % → loses vs cloud&lt;/td>
&lt;td>total&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sovereign EU cloud OD (Scaleway/Nebius EU)&lt;/strong>&lt;/td>
&lt;td>2.00–3.59&lt;/td>
&lt;td>none&lt;/td>
&lt;td>any&lt;/td>
&lt;td>yes (EU)&lt;/td>
&lt;td>total&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AWS p5 on-demand (post Jun. 2025)&lt;/strong>&lt;/td>
&lt;td>3.63&lt;/td>
&lt;td>none&lt;/td>
&lt;td>any&lt;/td>
&lt;td>no (CLOUD Act)&lt;/td>
&lt;td>total&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CoreWeave on-demand&lt;/strong>&lt;/td>
&lt;td>1.87&lt;/td>
&lt;td>none&lt;/td>
&lt;td>any&lt;/td>
&lt;td>partial (US)&lt;/td>
&lt;td>total&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CoreWeave 3-year reserved&lt;/strong>&lt;/td>
&lt;td>1.29–1.39&lt;/td>
&lt;td>financial commitment&lt;/td>
&lt;td>rigid contract&lt;/td>
&lt;td>partial (US)&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AWS 1-year reserved&lt;/strong>&lt;/td>
&lt;td>~2.17&lt;/td>
&lt;td>1-year commitment&lt;/td>
&lt;td>contract&lt;/td>
&lt;td>no (CLOUD Act)&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hybrid on-prem base + EU cloud peak&lt;/strong>&lt;/td>
&lt;td>2.50–3.39 (weighted)&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>base ≥ 70 %, elastic peak&lt;/td>
&lt;td>yes (EU)&lt;/td>
&lt;td>elastic peak&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Cut-off criteria that come before the table:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>&lt;strong>GDPR sovereignty&lt;/strong>: if the data is personal or the system is high-risk under the EU AI Act, CoreWeave/AWS are ruled out before any price comparison.&lt;/li>
&lt;li>&lt;strong>Minimum volume to amortise capex&lt;/strong>: below ~2M tokens/day sustained over 3 years, on-prem capex does not amortise against AWS on-demand.&lt;/li>
&lt;li>&lt;strong>Traffic elasticity&lt;/strong>: peaks &amp;gt;3× the base favour a hybrid or pure cloud; a stable base favours on-prem.&lt;/li>
&lt;/ol>
&lt;p>The cost/sovereignty Pareto frontier for GDPR data leaves three options: on-prem, sovereign EU cloud and hybrid. Sustained utilisation and traffic predictability decide between them. See the cross-analysis of the four axes in &lt;a href="https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/">sovereign on-premise vs hyperscalers&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="5-sensitivity-analysis">5. Sensitivity analysis&lt;/h2>
&lt;h3 id="51-sizing-vs-hourly-profile-and-peak-factor">5.1 Sizing vs hourly profile and peak factor&lt;/h3>
&lt;p>The number of GPUs grows linearly with \(k_{\text{peak}}\) and inversely with \(\rho_{\max}\):&lt;/p>
$$N_{\text{GPU}} = \left\lceil \frac{D \times L_{\text{out}} \times k_{\text{peak}}}{H \times 3{,}600 \times \rho_{\max} \times \dot{T}_{\text{GPU}}} \right\rceil$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Peak factor \(k\)&lt;/th>
&lt;th>SLO 300 ms (\(\rho_{\max}=0.63\))&lt;/th>
&lt;th>SLO 500 ms (\(\rho_{\max}=0.75\))&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1.2&lt;/td>
&lt;td>8 GPUs (2 nodes)&lt;/td>
&lt;td>6 GPUs (2 nodes)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.8&lt;/td>
&lt;td>12 GPUs (3 nodes)&lt;/td>
&lt;td>10 GPUs (3 nodes)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.5&lt;/td>
&lt;td>16 GPUs (4 nodes)&lt;/td>
&lt;td>13 GPUs (4 nodes)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3.5&lt;/td>
&lt;td>22 GPUs (6 nodes)&lt;/td>
&lt;td>18 GPUs (5 nodes)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>(Example simplified to 5M req/day with H=24 to illustrate the sensitivity to the peak factor)&lt;/p>
&lt;/blockquote>
&lt;p>A peak factor of 3.5× triples the number of nodes compared with k=1.2 at the same SLO. Sizing hardware for \(k > 2.5\) leaves GPUs idle more than 70 % of the time; peak cloud is more efficient beyond that threshold.&lt;/p>
&lt;h3 id="52-break-even-vs-sustained-utilisation">5.2 Break-even vs sustained utilisation&lt;/h3>
$$u^{*} = \frac{83{,}300}{4 \times 8{,}760 \times P_{\text{cloud}}}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cloud price (EUR/GPU-hour)&lt;/th>
&lt;th>\(u^{*}\) break-even&lt;/th>
&lt;th>Scenario&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>6.49 (Azure OD)&lt;/td>
&lt;td>23 %&lt;/td>
&lt;td>On-prem wins almost always&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3.63 (AWS OD)&lt;/td>
&lt;td>41 %&lt;/td>
&lt;td>On-prem wins if util. &amp;gt; 41 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3.41 (GCP OD)&lt;/td>
&lt;td>44 %&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.33 (AWS 1-yr reserved)&lt;/td>
&lt;td>64 %&lt;/td>
&lt;td>On-prem wins if util. &amp;gt; 64 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.87 (CoreWeave OD)&lt;/td>
&lt;td>80 %&lt;/td>
&lt;td>Hard to reach in production&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.39 (CoreWeave 3-yr reserved)&lt;/td>
&lt;td>&amp;gt;100 %&lt;/td>
&lt;td>On-prem never closes the gap&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="53-break-even-vs-energy-price">5.3 Break-even vs energy price&lt;/h3>
&lt;p>Energy accounts for 6–11 % of total TCO. Its impact on the break-even is moderate:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Energy price (EUR/kWh)&lt;/th>
&lt;th>Energy opex/year per node&lt;/th>
&lt;th>EUR/GPU-hour (70 % util.)&lt;/th>
&lt;th>\(u^{*}\) vs AWS OD&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0.034 (solar PPA)&lt;/td>
&lt;td>1,604 €&lt;/td>
&lt;td>3.22&lt;/td>
&lt;td>38 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.116 (industrial ES, base)&lt;/td>
&lt;td>5,475 €&lt;/td>
&lt;td>3.39&lt;/td>
&lt;td>41 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.200 (high tariff)&lt;/td>
&lt;td>9,437 €&lt;/td>
&lt;td>3.57&lt;/td>
&lt;td>43 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The difference between the cheapest and the most expensive scenario is only 5 percentage points of break-even. The variable that moves the needle is utilisation, not energy.&lt;/p>
&lt;h3 id="54-break-even-vs-demand-growth">5.4 Break-even vs demand growth&lt;/h3>
&lt;p>If demand grows at an annual rate \(g\), the average utilisation of the cluster (sized for year 1) rises over time until it saturates and has to be expanded:&lt;/p>
$$\rho(t) = \rho_{0} \times (1 + g)^{t}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Annual growth \(g\)&lt;/th>
&lt;th>Time to saturation (\(\rho \to 100\,\%\))&lt;/th>
&lt;th>Decision&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>10 %&lt;/td>
&lt;td>~11 years&lt;/td>
&lt;td>Comfortable purchase&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>30 %&lt;/td>
&lt;td>~4 years&lt;/td>
&lt;td>Purchase with a review at 3 years&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>60 %&lt;/td>
&lt;td>~2 years&lt;/td>
&lt;td>Hybrid: base + elastic cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;gt;100 %&lt;/td>
&lt;td>&amp;lt;1 year&lt;/td>
&lt;td>Pure cloud until it stabilises&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For growth above 30 % per year, a buy-only strategy means oversizing for the future peak or re-buying hardware in short cycles. The hybrid (on-prem base + cloud for growth) minimises the capex at risk.&lt;/p>
&lt;h3 id="55-headroom-the-cost-of-the-safety-margin">5.5 Headroom: the cost of the safety margin&lt;/h3>
&lt;p>The 15 % headroom in \(N_{\text{nodes}}\) amounts to having ~1.6 extra nodes on average. Its annual cost is:&lt;/p>
$$C_{\text{headroom}} = 0.15 \times 83{,}300\;\text{EUR/node/year} \times N_{\text{nodes,base}} \approx 12{,}500 \times 9 = 112{,}500\;\text{EUR/year}$$
&lt;p>That cost is justified by:&lt;/p>
&lt;ul>
&lt;li>GPU failure rate ~5 % per year (in small clusters, documented by Introl, Apr. 2026)&lt;/li>
&lt;li>Replacement time of 2–8 weeks (depending on market availability)&lt;/li>
&lt;li>Unforeseen peaks of up to 20 % above the estimate&lt;/li>
&lt;/ul>
&lt;p>If the service can degrade gracefully (relaxing the TTFT SLO from 300 ms to 500 ms at extreme peak), headroom can be cut to 10 %, saving ~37,500 EUR/year.&lt;/p>
&lt;hr>
&lt;h2 id="6-sensitivity-map-gpu-hour-and-break-even-on-two-axes">6. Sensitivity map: €/GPU-hour and break-even on two axes&lt;/h2>
&lt;p>The table below crosses utilisation with the opex scenario, showing the all-in EUR/GPU-hour (base scenario, capex/year 55,300 €):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Low opex (13,000 €/year)&lt;/th>
&lt;th>Base opex (28,000 €/year)&lt;/th>
&lt;th>High opex (75,000 €/year)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Util. 40 %&lt;/strong>&lt;/td>
&lt;td>4.74&lt;/td>
&lt;td>5.93&lt;/td>
&lt;td>9.06&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Util. 55 %&lt;/strong>&lt;/td>
&lt;td>3.45&lt;/td>
&lt;td>4.31&lt;/td>
&lt;td>6.59&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Util. 63 %&lt;/strong>&lt;/td>
&lt;td>3.01&lt;/td>
&lt;td>3.76&lt;/td>
&lt;td>5.75&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Util. 70 %&lt;/strong>&lt;/td>
&lt;td>2.71&lt;/td>
&lt;td>3.39&lt;/td>
&lt;td>5.18&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Util. 80 %&lt;/strong>&lt;/td>
&lt;td>2.37&lt;/td>
&lt;td>2.97&lt;/td>
&lt;td>4.54&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Util. 100 %&lt;/strong>&lt;/td>
&lt;td>1.90&lt;/td>
&lt;td>2.38&lt;/td>
&lt;td>3.63&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The crossing point with the cloud price (AWS OD: 3.63 EUR):&lt;/p>
&lt;ul>
&lt;li>Low-opex scenario: break-even at ~&lt;strong>38 % utilisation&lt;/strong>&lt;/li>
&lt;li>Base-opex scenario: break-even at ~&lt;strong>41 % utilisation&lt;/strong>&lt;/li>
&lt;li>High-opex scenario: break-even at ~&lt;strong>53 % utilisation&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>The biggest lever for lowering the break-even is not hardware capex but opex (staff and colocation above all). See the analysis of utilisation as a FinOps lever in &lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as FinOps&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="7-integration-with-the-rest-of-the-finops-chain">7. Integration with the rest of the FinOps chain&lt;/h2>
&lt;p>The sizing of §1 determines the number of nodes; the TCO of §2 gives the cost per hour; the comparison of §3 gives the break-even. Those three numbers feed directly into the other instruments of the series:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Instrument&lt;/th>
&lt;th>Input from this article&lt;/th>
&lt;th>Output&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM — SLO validation under load&lt;/a>&lt;/td>
&lt;td>TTFT/ITL P99 SLO from step 2&lt;/td>
&lt;td>Experimental confirmation of the real throughput per GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a>&lt;/td>
&lt;td>Demand profile and N GPUs from step 4&lt;/td>
&lt;td>Scaling policy and autoscaling triggers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">Cost per token and per request&lt;/a>&lt;/td>
&lt;td>EUR/GPU-hour from step §2.2&lt;/td>
&lt;td>EUR/1M tokens per model and batching&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">Cloud GPU commitment and spot&lt;/a>&lt;/td>
&lt;td>Cloud prices from table §2.4&lt;/td>
&lt;td>Optimisation of the complementary cloud tier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/">Sovereign on-premise vs hyperscalers&lt;/a>&lt;/td>
&lt;td>Break-even from §3&lt;/td>
&lt;td>Final buy/rent decision including the sovereignty axis&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of an on-premise GPU cluster&lt;/a>&lt;/td>
&lt;td>Capex and opex from §2.1&lt;/td>
&lt;td>Detailed TCO model with every line item&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as FinOps&lt;/a>&lt;/td>
&lt;td>Target utilisation from step 2&lt;/td>
&lt;td>Scheduling levers to raise real utilisation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Spheron · LLM Inference SLO Engineering: TTFT, ITL, and P99 Latency Budgets for Production AI (2026) — &lt;a href="https://www.spheron.network/blog/llm-inference-slo-ttft-itl-latency-budget-guide-2026/">https://www.spheron.network/blog/llm-inference-slo-ttft-itl-latency-budget-guide-2026/&lt;/a>&lt;/li>
&lt;li>Spheron · vLLM vs TensorRT-LLM vs SGLang: H100 Benchmarks (2026) — &lt;a href="https://www.spheron.network/blog/vllm-vs-tensorrt-llm-vs-sglang-benchmarks/">https://www.spheron.network/blog/vllm-vs-tensorrt-llm-vs-sglang-benchmarks/&lt;/a>&lt;/li>
&lt;li>Spheron · GPU Cloud Pricing 2026: H100 from 1.03 USD/hr, B200 from 2.12 USD/hr — &lt;a href="https://www.spheron.network/blog/gpu-cloud-pricing-comparison-2026/">https://www.spheron.network/blog/gpu-cloud-pricing-comparison-2026/&lt;/a>&lt;/li>
&lt;li>Spheron · LLM Inference On-Premise vs GPU Cloud: 2026 Cost and Break-Even Analysis — &lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/&lt;/a>&lt;/li>
&lt;li>MLPerf Inference v5.1 — Red Hat: 5 777 tok/s (offline) en Llama 3.1-8B FP8 en H100 — &lt;a href="https://www.redhat.com/en/blog/efficient-and-reproducible-llm-inference-red-hat-mlperf-inference-v51-results">https://www.redhat.com/en/blog/efficient-and-reproducible-llm-inference-red-hat-mlperf-inference-v51-results&lt;/a>&lt;/li>
&lt;li>MLPerf Inference v6.0 Results Explained: GPU Performance Rankings for AI Workloads (2026) — &lt;a href="https://www.spheron.network/blog/mlperf-inference-v6-benchmark-results-2026/">https://www.spheron.network/blog/mlperf-inference-v6-benchmark-results-2026/&lt;/a>&lt;/li>
&lt;li>IntuitionLabs · H100 Rental Prices Compared: 1.49–6.98 USD/hr Across 15+ Cloud Providers (2026) — &lt;a href="https://intuitionlabs.ai/articles/h100-rental-prices-cloud-comparison">https://intuitionlabs.ai/articles/h100-rental-prices-cloud-comparison&lt;/a>&lt;/li>
&lt;li>CloudZero · Cloud GPU Pricing Comparison: AWS vs Azure vs GCP For AI Workloads (2026) — &lt;a href="https://www.cloudzero.com/blog/cloud-gpu-pricing-comparison/">https://www.cloudzero.com/blog/cloud-gpu-pricing-comparison/&lt;/a>&lt;/li>
&lt;li>Lenovo Press · On-Premise vs Cloud: Generative AI Total Cost of Ownership (2026 Edition) — &lt;a href="https://lenovopress.lenovo.com/lp2368-on-premise-vs-cloud-generative-ai-total-cost-of-ownership-2026-edition">https://lenovopress.lenovo.com/lp2368-on-premise-vs-cloud-generative-ai-total-cost-of-ownership-2026-edition&lt;/a>&lt;/li>
&lt;li>Introl · GPU Infrastructure TCO Model: 5-Year Cost Analysis for Enterprise AI (abr. 2026) — &lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model&lt;/a>&lt;/li>
&lt;li>GMI Cloud · NVIDIA H100 GPU Pricing 2026: Rent vs Buy Cost Analysis — &lt;a href="https://www.gmicloud.ai/en/blog/nvidia-h100-gpu-pricing-2026-rent-vs-buy-cost-analysis">https://www.gmicloud.ai/en/blog/nvidia-h100-gpu-pricing-2026-rent-vs-buy-cost-analysis&lt;/a>&lt;/li>
&lt;li>Red Hat · 233 % 3-year ROI and 13 months to payback with Red Hat AI (feb. 2026) — &lt;a href="https://www.redhat.com/en/blog/233-3-year-return-investment-and-13-months-payback-red-hat-ai">https://www.redhat.com/en/blog/233-3-year-return-investment-and-13-months-payback-red-hat-ai&lt;/a>&lt;/li>
&lt;li>VentureBeat · 5 % GPU utilization: the 401 billion USD AI infrastructure problem — &lt;a href="https://venturebeat.com/infrastructure/5-gpu-utilization-the-401-billion-ai-infrastructure-problem-enterprises-cant-keep-ignoring/">https://venturebeat.com/infrastructure/5-gpu-utilization-the-401-billion-ai-infrastructure-problem-enterprises-cant-keep-ignoring/&lt;/a>&lt;/li>
&lt;li>DZone · Queueing Theory for LLM Inference — &lt;a href="https://dzone.com/articles/queueing-theory-for-llm-inference">https://dzone.com/articles/queueing-theory-for-llm-inference&lt;/a>&lt;/li>
&lt;li>GuideLLM · Evaluate LLM deployments for real-world inference (Red Hat Developer, jun. 2025) — &lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Kubecost vs OpenCost vs the alternatives: what the commercial layer adds and when it is worth paying for</title><link>https://blog.lo0.es/en/posts/kubecost-vs-opencost-vs-alternatives/</link><pubDate>Sun, 14 Jun 2026 02:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/kubecost-vs-opencost-vs-alternatives/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point; when a source quotes dollars it is
marked &amp;ldquo;USD&amp;rdquo;. The dollar sign is not used (on this site it is a formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-article-covers">What this article covers&lt;/h2>
&lt;p>Third article in the &lt;strong>FinOps&lt;/strong> track (A3). A2 covered how OpenCost allocates cost, the free
base. This article answers the question that comes next: &lt;strong>is it worth paying for Kubecost or a
commercial alternative, or is OpenCost enough?&lt;/strong> It is a &lt;strong>build-vs-buy&lt;/strong> decision, operating the
open source tool yourself against buying a product, and the answer depends on your scale, your
audience and your appetite for operating infrastructure. No universal recommendations: here are the
facts (what each one adds, what it costs, when it fits) and, for a European platform, the nuance of
&lt;strong>cost data sovereignty&lt;/strong> that the American comparisons do not mention.&lt;/p>
&lt;hr>
&lt;h2 id="the-axis-of-the-decision-build-vs-buy">The axis of the decision: build vs buy&lt;/h2>
&lt;p>OpenCost is free (Apache 2.0), but &amp;ldquo;free&amp;rdquo; means operating it yourself: deploying it,
configuring the node price, keeping Prometheus with enough retention, building the Grafana panels
and the alerts. Kubecost (and the alternatives) charge for taking that work away and adding
capabilities OpenCost does not ship. The decision, therefore, is not &amp;ldquo;free vs expensive&amp;rdquo;: it is
your own operating cost vs a licence.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>Leans towards OpenCost&lt;/th>
&lt;th>Leans towards commercial&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Platform team&lt;/td>
&lt;td>you have someone to operate it&lt;/td>
&lt;td>you do not want to operate anything&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scale (cloud/GPU spend)&lt;/td>
&lt;td>small to medium&lt;/td>
&lt;td>large (the saving pays the licence)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Report audience&lt;/td>
&lt;td>engineers&lt;/td>
&lt;td>finance / management&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Number of clusters&lt;/td>
&lt;td>one&lt;/td>
&lt;td>many (multi-cluster)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Need for automatic optimisation&lt;/td>
&lt;td>you do it by hand&lt;/td>
&lt;td>you want it out of the box&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>OpenCost is the open source core; Kubecost is the enterprise product built on that core,
with proprietary features added; Kubecost was the original developer of the engine before releasing
it, and IBM acquired the company (&lt;a href="https://www.cloudzero.com/blog/kubecost-vs-opencost/">CloudZero · Kubecost vs OpenCost&lt;/a>).
In other words: both share the same allocation engine; what you pay for is the layer on top.&lt;/p>
&lt;hr>
&lt;h2 id="what-kubecost-adds-on-top-of-opencost">What Kubecost adds on top of OpenCost&lt;/h2>
&lt;p>On top of allocation (which is common to both), Kubecost adds a layer of &lt;strong>optimisation, governance
and enterprise features&lt;/strong> (&lt;a href="https://www.cloudzero.com/blog/kubecost-vs-opencost/">CloudZero&lt;/a>, &lt;a href="https://www.finout.io/blog/kubecost-vs-opencost">Finout&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Capability&lt;/th>
&lt;th>OpenCost&lt;/th>
&lt;th>Kubecost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cost allocation (CPU/GPU/mem/PV)&lt;/td>
&lt;td>✓&lt;/td>
&lt;td>✓ (same engine)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU via DCGM&lt;/td>
&lt;td>✓&lt;/td>
&lt;td>✓ (3.0)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Invoice reconciliation&lt;/strong> (discounts, RI, spot)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Rightsizing&lt;/strong> (recommendations)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Anomaly detection&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Budget alerts&lt;/strong>&lt;/td>
&lt;td>manual&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RBAC&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-cluster aggregation&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hosted&lt;/strong> option (SaaS)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Long historical retention&lt;/td>
&lt;td>your Prometheus&lt;/td>
&lt;td>included&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>community&lt;/td>
&lt;td>commercial (IBM)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="OpenCost as the allocation core and Kubecost as commercial layers on top: reconciliation, rightsizing, anomalies, governance and support" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}&lt;/style>
&lt;rect class="bx" x="180" y="180" width="420" height="48" rx="6"/>
&lt;text x="390" y="200" text-anchor="middle" class="tl">OpenCost — allocation engine (Apache 2.0)&lt;/text>
&lt;text x="390" y="217" text-anchor="middle" class="ts">node price · split by usage · Allocation API&lt;/text>
&lt;rect class="dsh" x="180" y="40" width="420" height="124" rx="6"/>
&lt;text x="390" y="60" text-anchor="middle" class="tl">Kubecost (IBM) — commercial layer&lt;/text>
&lt;rect class="bx" x="200" y="74" width="180" height="34" rx="5"/>&lt;text x="290" y="95" text-anchor="middle" class="ts">invoice reconciliation&lt;/text>
&lt;rect class="bx" x="400" y="74" width="180" height="34" rx="5"/>&lt;text x="490" y="95" text-anchor="middle" class="ts">rightsizing + anomalies&lt;/text>
&lt;rect class="bx" x="200" y="116" width="180" height="34" rx="5"/>&lt;text x="290" y="137" text-anchor="middle" class="ts">RBAC · budgets&lt;/text>
&lt;rect class="bx" x="400" y="116" width="180" height="34" rx="5"/>&lt;text x="490" y="137" text-anchor="middle" class="ts">multi-cluster · support&lt;/text>
&lt;text x="390" y="246" text-anchor="middle" class="ts">Same engine underneath; what you pay for is the optimisation, governance and enterprise layer on top.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="invoice-reconciliation">Invoice reconciliation&lt;/h3>
&lt;p>The capability that most justifies the price in cloud: OpenCost allocates on list rates;
Kubecost reconciles against the real invoice, which includes discounts, reserved instances and
spot prices (&lt;a href="https://www.finout.io/blog/kubecost-vs-opencost">Finout&lt;/a>). The difference can be
large: a cost allocated at list rates can be 30–50 % above what you actually pay after discounts.
On-prem this matters less (you declare the node&amp;rsquo;s real price), but in cloud or hybrid,
reconciliation is the difference between a &amp;ldquo;theoretical&amp;rdquo; cost and the cost that shows up on the
invoice.&lt;/p>
&lt;h3 id="rightsizing-and-anomaly-detection">Rightsizing and anomaly detection&lt;/h3>
&lt;p>Kubecost recommends resizing (rightsizing) underused resources, via the integration with IBM
Turbonomic, and detects spend anomalies automatically. OpenCost gives you the data to do it
by hand; Kubecost automates it. For a GPU fleet, rightsizing and anomalous idle detection are
exactly where the saving of the &lt;em>Optimize&lt;/em> phase lives.&lt;/p>
&lt;hr>
&lt;h2 id="the-price-of-kubecost-and-the-build-vs-buy-economics">The price of Kubecost (and the build-vs-buy economics)&lt;/h2>
&lt;p>Kubecost&amp;rsquo;s price starts at 449 USD/month for &lt;em>business&lt;/em> use, with enterprise options on request
(&lt;a href="https://www.cloudzero.com/blog/kubecost-vs-opencost/">source&lt;/a>). The right question is not &amp;ldquo;is 449
USD expensive?&amp;rdquo;, but &amp;ldquo;does operating OpenCost myself cost more or less than that?&amp;rdquo;. The
build-vs-buy calculation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cost&lt;/th>
&lt;th>OpenCost (build)&lt;/th>
&lt;th>Kubecost (buy)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>0&lt;/td>
&lt;td>from ~449 USD/month (business)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Engineering time&lt;/td>
&lt;td>deployment + maintenance + panels&lt;/td>
&lt;td>minimal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Historical retention&lt;/td>
&lt;td>your Prometheus/Thanos cost&lt;/td>
&lt;td>included&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>your team&lt;/td>
&lt;td>included&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If one platform person spends a few hours a month operating OpenCost, that time can already exceed
449 USD; if your cluster is small and the operating cost is nearly nil (you set it up once and it
runs), OpenCost wins. The market rule of thumb: if &lt;strong>monthly Kubernetes spend is below ~10,000
USD&lt;/strong>, the cloud-native tools or open source (OpenCost) usually suffice; above that, the saving the
commercial tool captures pays for its licence ([search]).&lt;/p>
&lt;hr>
&lt;h3 id="worked-example-build-vs-buy">Worked example: build vs buy&lt;/h3>
&lt;p>An illustrative calculation for a platform with a medium-sized GPU cluster:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Item&lt;/th>
&lt;th>OpenCost (build)&lt;/th>
&lt;th>Kubecost (buy)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Annual licence&lt;/td>
&lt;td>0&lt;/td>
&lt;td>~449 USD/month × 12 = ~5,400 USD (~5,000 €)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Initial deployment&lt;/td>
&lt;td>~2–3 days of engineering&lt;/td>
&lt;td>~half a day&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintenance&lt;/td>
&lt;td>~4–8 h/month (panels, alerts, upgrades)&lt;/td>
&lt;td>minimal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Operating cost (at ~60 €/h)&lt;/td>
&lt;td>~240–480 €/month&lt;/td>
&lt;td>~marginal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Historical retention&lt;/td>
&lt;td>cost of your own Thanos/Mimir&lt;/td>
&lt;td>included&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If OpenCost&amp;rsquo;s operating cost is around 240–480 €/month, it is in the same order as the Kubecost
licence (~415 €/month). The decision, then, is not set by the price but by the added value: if
invoice reconciliation, automatic rightsizing and the finance-facing UI save you or give you more
than that difference, Kubecost wins; if your team already operates Prometheus/Grafana and your
audience is engineers, OpenCost wins. For a large multi-cluster estate, the saving Kubecost
captures usually far exceeds the licence; for a small and stable one, operating OpenCost is almost
free. The number that decides is not 449 USD, it is &lt;strong>your operating cost against that added
value&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h3 id="migration-and-reversibility-an-argument-for-starting-with-opencost">Migration and reversibility: an argument for starting with OpenCost&lt;/h3>
&lt;p>A rarely mentioned advantage of Kubecost being built on OpenCost: migrating between the two is
low risk. Starting with OpenCost and, if scale justifies it, moving up to Kubecost later does
not force you to redo the allocation, it is the same engine, the same concepts, the same
Prometheus/DCGM instrumentation. What changes is the layer on top. This reduces &lt;em>lock-in&lt;/em>: it is
not an irreversible bet.&lt;/p>
&lt;p>For a platform just starting out, the low-risk strategy is clear: &lt;strong>stand up self-hosted OpenCost
first&lt;/strong>, get the allocation right with the node price in euros, live with it for a few months, and
only then decide whether invoice reconciliation, automatic rightsizing and the finance-facing UI
justify paying for Kubecost. Starting with the commercial product &amp;ldquo;just in case&amp;rdquo; is paying before
knowing whether you need it; starting with OpenCost and moving up if necessary is the option that
keeps reversibility and discovers the real value before the invoice. And since both are
self-hosted, neither route gives up data sovereignty.&lt;/p>
&lt;hr>
&lt;h2 id="the-alternatives-cloudzero-vantage-finout">The alternatives: CloudZero, Vantage, Finout&lt;/h2>
&lt;p>Kubecost is not the only commercial option; there is a category of cost platforms that operate
at a different level (closer to the business than to Kubernetes):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th>Differentiator&lt;/th>
&lt;th>Layer&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>CloudZero&lt;/strong>&lt;/td>
&lt;td>unit economics&lt;/td>
&lt;td>cost per feature/product/customer&lt;/td>
&lt;td>business&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Vantage&lt;/strong>&lt;/td>
&lt;td>multi-cloud (20+ integrations)&lt;/td>
&lt;td>breadth (incl. OpenAI invoice)&lt;/td>
&lt;td>business&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Finout&lt;/strong>&lt;/td>
&lt;td>multi-cloud&lt;/td>
&lt;td>virtual tagging (without touching resources)&lt;/td>
&lt;td>business&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kubecost&lt;/strong>&lt;/td>
&lt;td>Kubernetes&lt;/td>
&lt;td>intra-cluster depth + optimisation&lt;/td>
&lt;td>resource+optim&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenCost&lt;/strong>&lt;/td>
&lt;td>Kubernetes&lt;/td>
&lt;td>open source allocation&lt;/td>
&lt;td>resource&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The key distinction: Kubecost/OpenCost are Kubernetes-centred tools (intra-cluster depth);
CloudZero/Vantage/Finout are business cost platforms (multi-cloud breadth, cost per
product). They do not always compete: many organisations use OpenCost/Kubecost for the Kubernetes
detail and a business platform for the aggregate view.&lt;/p>
&lt;p>A short profile of each:&lt;/p>
&lt;ul>
&lt;li>CloudZero — a &lt;em>cost intelligence&lt;/em> platform with a dedicated Kubernetes visibility solution
for engineering teams. Its hallmark is unit economics: mapping cost to feature, product and
customer, answering &amp;ldquo;how much does it cost me to serve this customer?&amp;rdquo; more than &amp;ldquo;how much does
this pod cost?&amp;rdquo;. It fits when cost has to be taken to the product P&amp;amp;L.&lt;/li>
&lt;li>Vantage — breadth of integrations (more than 20 sources: AWS, Azure, GCP, Kubernetes,
Snowflake, Datadog and the invoice from LLM providers such as OpenAI). It is the option when
AI cost is spread across many providers and you want a single view, including the invoice from
the model APIs.&lt;/li>
&lt;li>Finout — multi-cloud with virtual tagging: it applies cost labels without modifying the
resources, which allows spend that was badly tagged at source to be allocated, and quick
deployment. It is among the top Kubernetes cost optimisation tools
(&lt;a href="https://www.cloudbolt.io/blog/top-kubecost-alternatives/">CloudBolt&lt;/a>, &lt;a href="https://www.finout.io/blog/kubecost-pros/cons-pricing-tutorial-alternatives-2026-guide">Finout&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>The usual pattern in a mature organisation: OpenCost/Kubecost for the Kubernetes and GPU detail,
and a business platform on top to cross that cost with the rest of the cloud spend and take it
to product. It is not &amp;ldquo;one or the other&amp;rdquo;: it is often &amp;ldquo;one and the other&amp;rdquo;, in layers.&lt;/p>
&lt;hr>
&lt;h2 id="decision-criteria">Decision criteria&lt;/h2>
&lt;p>Five questions that settle the choice without opinion:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="Decision tree: by scale, audience, multi-cluster and sovereignty, choose OpenCost, Kubecost or a business platform" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#km)}&lt;/style>
&lt;defs>&lt;marker id="km" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="100" width="150" height="50" rx="6"/>
&lt;text x="32" y="122" class="tl">Spend &amp;gt; 10k/month?&lt;/text>
&lt;text x="32" y="139" class="ts">and multi-cluster?&lt;/text>
&lt;path class="ar" d="M170,115 L230,70"/>
&lt;text x="180" y="80" class="ts">no&lt;/text>
&lt;path class="ar" d="M170,135 L230,180"/>
&lt;text x="180" y="170" class="ts">yes&lt;/text>
&lt;rect class="bx" x="230" y="48" width="180" height="44" rx="6"/>
&lt;text x="242" y="68" class="tl">OpenCost (self-host)&lt;/text>
&lt;text x="242" y="84" class="ts">allocation, free, sovereign&lt;/text>
&lt;rect class="bx" x="230" y="158" width="180" height="44" rx="6"/>
&lt;text x="242" y="178" class="tl">Kubecost&lt;/text>
&lt;text x="242" y="194" class="ts">optimisation + governance&lt;/text>
&lt;path class="ar" d="M410,180 L470,150"/>
&lt;text x="420" y="150" class="ts">cost per product?&lt;/text>
&lt;rect class="bx" x="470" y="120" width="200" height="44" rx="6"/>
&lt;text x="482" y="140" class="tl">+ business platform&lt;/text>
&lt;text x="482" y="156" class="ts">CloudZero / Vantage / Finout&lt;/text>
&lt;text x="20" y="235" class="ts">And a cross-cutting question for Europe: where does the cost data live? (see sovereignty below)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;ol>
&lt;li>&lt;strong>How much do you spend?&lt;/strong> Below ~10k USD/month, OpenCost is enough; above that, the saving pays
for the commercial tool.&lt;/li>
&lt;li>&lt;strong>Who reads the report?&lt;/strong> Engineers → OpenCost; finance/management → Kubecost&amp;rsquo;s polished UI or a
business platform.&lt;/li>
&lt;li>&lt;strong>One cluster or many?&lt;/strong> Multi-cluster leans towards Kubecost.&lt;/li>
&lt;li>&lt;strong>Do you want to operate infrastructure?&lt;/strong> If not, buy; if you have a platform team, build with
OpenCost.&lt;/li>
&lt;li>&lt;strong>Where can the cost data live?&lt;/strong> The European question, below.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="the-european-angle-cost-data-sovereignty">The European angle: cost data sovereignty&lt;/h2>
&lt;p>American comparisons leave out a criterion that for a sovereign platform is first-order: &lt;strong>where
the cost and usage data ends up&lt;/strong>. CloudZero, Vantage, Finout and Kubecost&amp;rsquo;s &lt;em>hosted&lt;/em> option are
SaaS, mostly American: you send them your telemetry of cost, usage, namespace names, teams and
products. That data, which describes your operation in detail, leaves for a jurisdiction subject to
the US CLOUD Act, with the same GDPR consideration as any other sensitive data sent to a
hyperscaler.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>Where the cost data lives&lt;/th>
&lt;th>Sovereignty&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Self-hosted OpenCost&lt;/strong>&lt;/td>
&lt;td>in your cluster&lt;/td>
&lt;td>total&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Self-hosted Kubecost&lt;/strong>&lt;/td>
&lt;td>in your cluster&lt;/td>
&lt;td>total&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hosted Kubecost (SaaS)&lt;/td>
&lt;td>the provider&amp;rsquo;s SaaS&lt;/td>
&lt;td>subject to the SaaS jurisdiction&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CloudZero / Vantage / Finout&lt;/td>
&lt;td>SaaS (US)&lt;/td>
&lt;td>subject to the US CLOUD Act&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>It is worth being fair: cost data is not the same as user data. Spend telemetry per namespace is
less sensitive than the content of the inferences. But it does reveal the operation: which
teams exist, which products consume GPU, the size of the platform, and, crossed with namespace
names, information that taken together can be sensitive. Most SaaS FinOps tooling is American;
there are emerging European options, but few with the maturity of the big players. When in doubt,
and for data subject to GDPR, the minimisation principle advises &lt;strong>not taking out of the EU what
does not need to leave&lt;/strong>, and cost allocation, with self-hosted OpenCost/Kubecost, does not need to
leave.&lt;/p>
&lt;p>The consequence for the proposal: &lt;strong>self-hosted OpenCost or Kubecost keep the cost data under your
control&lt;/strong>, just as the cluster keeps the inference data. If data sovereignty is a requirement (it
is for GDPR data, see &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS × 42001 × EU AI
Act controls&lt;/a>), the American SaaS platforms
run into the same conflict as a hyperscaler, however good their UI is. For Fibercli and any
sovereign platform, this tilts the balance towards &lt;strong>self-hosted&lt;/strong>, with OpenCost as the base and
self-hosted Kubecost if the enterprise layer is wanted without giving up the data.&lt;/p>
&lt;hr>
&lt;h2 id="ai-cost-what-none-of-them-ships-out-of-the-box">AI cost: what none of them ships out of the box&lt;/h2>
&lt;p>One point the generalist comparisons do not highlight and that is central for an LLM platform:
none of these tools gives cost per token out of the box. OpenCost and Kubecost reach the &lt;strong>pod
cost&lt;/strong> (per resource/usage); CloudZero, Vantage and Finout reach the &lt;strong>resource or invoice cost&lt;/strong>
(including the OpenAI one, in Vantage&amp;rsquo;s case). But the jump from &amp;ldquo;this vLLM pod cost X €/hour&amp;rdquo; to
&amp;ldquo;this request from this team cost Y&amp;rdquo; requires intercepting the inference traffic with a gateway
(LiteLLM) that counts tokens per request and per team, as developed in the FinOps introduction.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>What it gives&lt;/th>
&lt;th>Who covers it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Resource/pod cost&lt;/td>
&lt;td>€/hour per pod, namespace, team&lt;/td>
&lt;td>OpenCost, Kubecost&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cloud/LLM invoice cost&lt;/td>
&lt;td>€/month per provider&lt;/td>
&lt;td>CloudZero, Vantage, Finout&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cost per token&lt;/strong>&lt;/td>
&lt;td>€/1M tokens per team/model&lt;/td>
&lt;td>&lt;strong>gateway (LiteLLM) + any of the above&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The implication for tool selection: cost per token, the metric that compares on-prem against cloud,
is not solved by any tool on its own, but by the combination of allocation
(OpenCost/Kubecost) with token measurement (gateway). When evaluating a commercial tool, the AI
question is not &amp;ldquo;does it give the pod cost?&amp;rdquo; (they all do, one way or another), but &amp;ldquo;does it
integrate well with my gateway to reach the token?&amp;rdquo;. Vantage, by ingesting the OpenAI invoice,
gets close from the external API consumption side; for your own inference, the gateway remains
essential.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-2026">State of the art 2026&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Consolidation under IBM&lt;/strong>: Kubecost/OpenCost integrated into IBM&amp;rsquo;s FinOps Suite alongside
Cloudability and Turbonomic; OpenCost remains the CNCF allocation standard.&lt;/li>
&lt;li>&lt;strong>First-class GPU&lt;/strong>: Kubecost 3.0 and OpenCost allocate GPU via DCGM; GPU FinOps stops being a
special case.&lt;/li>
&lt;li>&lt;strong>Towards the token and FOCUS&lt;/strong>: the strong platforms track cost at token level, and the
FOCUS standard (v1.3, Dec 2025) is extending to AI workloads, which will push
interoperability between these tools.&lt;/li>
&lt;li>&lt;strong>A category of AI cost platforms&lt;/strong>: specific &lt;em>AI cost observability&lt;/em> tooling is emerging that
combines resource, invoice and token; it is worth demanding FOCUS compatibility so as not to
couple to one provider.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="selection-checklist">Selection checklist&lt;/h2>
&lt;p>To decide without opinion, in order:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Step&lt;/th>
&lt;th>Question&lt;/th>
&lt;th>If&amp;hellip;&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Do I have a team to operate OpenCost?&lt;/td>
&lt;td>no → commercial / hosted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Spend &amp;gt; ~10k USD/month and multi-cluster?&lt;/td>
&lt;td>yes → Kubecost&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Is the report for finance/management?&lt;/td>
&lt;td>yes → Kubecost UI or business platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Do I need cost per product/customer?&lt;/td>
&lt;td>yes → CloudZero/Vantage/Finout on top&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>Can the cost data go to a US SaaS?&lt;/td>
&lt;td>no → self-hosted (OpenCost / Kubecost)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Do I need cost per token?&lt;/td>
&lt;td>yes → any of them &lt;strong>+ gateway&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Step 5 is the filter for a sovereign platform: if the cost data cannot leave the EU jurisdiction,
the list shrinks to the &lt;strong>self-hosted&lt;/strong> options, with OpenCost as the base.&lt;/p>
&lt;hr>
&lt;h2 id="pricing-models-of-commercial-tooling">Pricing models of commercial tooling&lt;/h2>
&lt;p>For those that charge by saving or by percentage of spend:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>How it charges&lt;/th>
&lt;th>Range&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Fixed licence (Kubecost)&lt;/td>
&lt;td>monthly rate&lt;/td>
&lt;td>from ~449 USD/month (business)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Savings-based&lt;/td>
&lt;td>% of the savings delivered&lt;/td>
&lt;td>15–35 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fixed-fee&lt;/td>
&lt;td>% of annual cloud spend&lt;/td>
&lt;td>1–3 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The implication: at large spend, a 1–3 % &lt;em>fixed-fee&lt;/em> can exceed Kubecost&amp;rsquo;s fixed licence; with a
lot of waste, &lt;em>savings-based&lt;/em> aligns incentives but can come out expensive if the saving is large.
OpenCost, free, changes the equation if you have someone to operate it.&lt;/p>
&lt;hr>
&lt;h2 id="coexistence-the-reality-is-layered">Coexistence: the reality is layered&lt;/h2>
&lt;p>In mature organisations, these tools are not chosen against each other, they are stacked. The
usual pattern:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Tool&lt;/th>
&lt;th>Answers&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Intra-Kubernetes allocation&lt;/td>
&lt;td>OpenCost / Kubecost&lt;/td>
&lt;td>how much does each pod/team/GPU cost?&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Per-token measurement&lt;/td>
&lt;td>gateway (LiteLLM)&lt;/td>
&lt;td>how much does each request/team cost?&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per product / multi-cloud&lt;/td>
&lt;td>CloudZero / Vantage / Finout&lt;/td>
&lt;td>how much does it cost to serve this product/customer?&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each layer solves a different question and feeds off the one below: the pod cost (OpenCost) plus the
tokens (gateway) give the cost per token; that cost per token, aggregated by a business platform,
gives the cost per product. Trying to make a single tool cover all three layers usually ends in
compromises: the Kubernetes ones do not reach the business, the business ones do not reach the pod,
and none reaches the token without the gateway. The healthy architecture is &lt;strong>layers that
integrate&lt;/strong>, not one tool that does everything by halves. And the glue between layers, in 2026, is
FOCUS: demanding FOCUS compatibility from each piece is what allows the data to flow between
them without being rewritten.&lt;/p>
&lt;hr>
&lt;h2 id="summary-by-organisation-profile">Summary by organisation profile&lt;/h2>
&lt;p>To get your bearings quickly, which stack fits each profile (indicative, not prescriptive):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Profile&lt;/th>
&lt;th>Scale&lt;/th>
&lt;th>Stack that usually fits&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Small team with a platform&lt;/td>
&lt;td>1 cluster, low spend&lt;/td>
&lt;td>&lt;strong>OpenCost&lt;/strong> self-hosted + Grafana&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scale-up with several teams&lt;/td>
&lt;td>multi-team, medium spend&lt;/td>
&lt;td>OpenCost + gateway; Kubecost if time is short&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-cluster enterprise&lt;/td>
&lt;td>many clusters, high spend&lt;/td>
&lt;td>&lt;strong>Kubecost&lt;/strong> + business platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sovereign platform (Fibercli)&lt;/strong>&lt;/td>
&lt;td>GDPR, data in the EU&lt;/td>
&lt;td>&lt;strong>self-hosted OpenCost/Kubecost&lt;/strong> + gateway; no US cost SaaS&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The sovereign profile is the one that changes the answer relative to the standard guides: however
much an American SaaS platform wins on features or on UI, the requirement to keep cost data under
EU jurisdiction rules it out for GDPR data. For Fibercli, the bottom row is the one that rules, and
that is why this series&amp;rsquo; FinOps track is built on &lt;strong>self-hosted&lt;/strong> tools.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>&amp;ldquo;Free&amp;rdquo; is not free.&lt;/strong> OpenCost has an operating cost; count it in the build-vs-buy or you are
fooling yourself comparing 0 with 449 USD.&lt;/li>
&lt;li>&lt;strong>Reconciliation only matters in cloud.&lt;/strong> In pure on-prem, you declare the node&amp;rsquo;s real price and
Kubecost&amp;rsquo;s reconciliation adds less.&lt;/li>
&lt;li>&lt;strong>Cost SaaS = cost data outside.&lt;/strong> For a sovereign platform, sending cost telemetry to an
American SaaS reintroduces the jurisdiction problem that on-prem avoided.&lt;/li>
&lt;li>&lt;strong>Same engine, different UI.&lt;/strong> A good part of what you pay for in Kubecost is the presentation
and governance layer; if your audience is engineers, it may not pay off.&lt;/li>
&lt;li>&lt;strong>Do not confuse layers.&lt;/strong> OpenCost/Kubecost (Kubernetes) and CloudZero/Vantage/Finout
(business) solve different problems; sometimes they complement each other, they do not compete.&lt;/li>
&lt;/ol>
&lt;p>With the allocation tooling decision settled, the FinOps track moves on to cost per token (A4) and
the full TCO model (A8). The foundation is the one from A2 and A3: correct allocation, with the
node price in euros, in a tool that respects data sovereignty.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The OpenCost vs Kubecost vs commercial platform choice is sold as a feature comparison, and is in
reality a build-vs-buy decision with a sovereignty constraint on top. The allocation engine is
the same (OpenCost) in the first two; what you pay for in Kubecost is the optimisation, governance
and presentation layer, which pays off when scale is large, the audience is financial or you do not
want to operate infrastructure. The business platforms (CloudZero, Vantage, Finout) solve a
different problem, cost per product, multi-cloud, and often coexist in layers with the Kubernetes
ones rather than competing. But for a European sovereign platform there is a criterion no American
comparison puts first and that for Fibercli comes first: where the cost data lives. Sending
your spend, team and product telemetry to an American SaaS reintroduces, through the back door, the
jurisdiction problem that on-prem avoided. The conclusion the data supports: &lt;strong>self-hosted OpenCost
as the base, self-hosted Kubecost if the enterprise layer is wanted, and the gateway to reach the
token&lt;/strong>, a stack that keeps cost measured, in euros, and under EU jurisdiction. Expensive or cheap
is secondary; sovereign or not is not.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CloudZero · Kubecost vs OpenCost (2026) — &lt;a href="https://www.cloudzero.com/blog/kubecost-vs-opencost/">https://www.cloudzero.com/blog/kubecost-vs-opencost/&lt;/a>&lt;/li>
&lt;li>Finout · Kubecost vs OpenCost (6 diferencias) — &lt;a href="https://www.finout.io/blog/kubecost-vs-opencost">https://www.finout.io/blog/kubecost-vs-opencost&lt;/a>&lt;/li>
&lt;li>Finout · Kubecost: pros/cons, pricing, alternativas (2026) — &lt;a href="https://www.finout.io/blog/kubecost-pros/cons-pricing-tutorial-alternatives-2026-guide">https://www.finout.io/blog/kubecost-pros/cons-pricing-tutorial-alternatives-2026-guide&lt;/a>&lt;/li>
&lt;li>CloudBolt · Top Kubecost Alternatives (2026) — &lt;a href="https://www.cloudbolt.io/blog/top-kubecost-alternatives/">https://www.cloudbolt.io/blog/top-kubecost-alternatives/&lt;/a>&lt;/li>
&lt;li>Amnic · OpenCost vs Kubecost — &lt;a href="https://amnic.com/blogs/opencost-vs-kubecost">https://amnic.com/blogs/opencost-vs-kubecost&lt;/a>&lt;/li>
&lt;li>Clanker Cloud · Best tools for managing GPU usage in Kubernetes (2026) — &lt;a href="https://clankercloud.ai/blog/best-tools-managing-gpu-usage-kubernetes-2025-2026-cost-roi">https://clankercloud.ai/blog/best-tools-managing-gpu-usage-kubernetes-2025-2026-cost-roi&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>OpenCost in depth: how GPU cost is allocated in Kubernetes</title><link>https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/</link><pubDate>Sun, 14 Jun 2026 02:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. European references (OVH is among
the supported providers). The dollar sign is not used (on this site it is a formula
delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-article-covers">What this article covers&lt;/h2>
&lt;p>Second article in the &lt;strong>FinOps&lt;/strong> track (A2), and the first tool deep dive. The
&lt;a href="https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/">FinOps introduction&lt;/a> established that
OpenCost is the CNCF standard for cost allocation; here the box gets opened: how it allocates
cost internally, where it gets its data from, how on-prem prices are configured in euros, what its
API exposes, and where the traps are. Understanding the mechanics matters because &lt;strong>it defines what
any tool on top (Kubecost included) can and cannot do&lt;/strong>, and because a badly set base price
invalidates the whole split. No recommendations; only the mechanics and the methodology.&lt;/p>
&lt;hr>
&lt;h2 id="what-opencost-is">What OpenCost is&lt;/h2>
&lt;p>OpenCost is a vendor-neutral, Apache 2.0 project, originally built by Kubecost and donated to
the CNCF (incubating project). It is a cost allocation layer for Kubernetes: it reads
resource usage from the cluster, joins it with a price, and splits the cost across Kubernetes
dimensions down to the container (&lt;a href="https://github.com/opencost/opencost">OpenCost · GitHub&lt;/a>).
It supports dynamic pricing via the billing APIs of AWS, Azure and GCP, &lt;strong>and custom prices for
on-prem clusters&lt;/strong> (&lt;a href="https://opencost.io/docs/configuration/">OpenCost · configuration&lt;/a>).&lt;/p>
&lt;p>What it is &lt;strong>not&lt;/strong>: it is not an optimiser, nor a governance system, nor a product unit economics
tool. It is the piece that answers &amp;ldquo;how much does each namespace/pod/team cost?&amp;rdquo;, and leaves the
rest to the layers above. That is why it is the foundation of the FinOps track: without correct
allocation, there is no cost per token and no TCO model worth anything.&lt;/p>
&lt;hr>
&lt;h2 id="the-cost-model-node-level-allocation">The cost model: node-level allocation&lt;/h2>
&lt;p>The key to OpenCost is that the model works at node level. It starts from the node&amp;rsquo;s &lt;strong>resource
capacity&lt;/strong> (CPU, RAM, GPU, storage) and its &lt;strong>total price&lt;/strong>, and splits that price across the
resources. When the provider does not give explicit per-resource prices, OpenCost uses the &lt;strong>ratio
of a set of base prices&lt;/strong> (marginal rates, customisable) and &lt;strong>normalises them so that the sum of
the components equals the node&amp;rsquo;s total price&lt;/strong> (&lt;a href="https://opencost.io/docs/configuration/on-prem/">OpenCost · on-prem&lt;/a>).&lt;/p>
&lt;p>This is what makes it work on-prem: you declare the node cost (depreciated capex + opex), and
OpenCost splits it without needing a cloud invoice. The normalisation guarantees that no cost is
&amp;ldquo;lost&amp;rdquo; or &amp;ldquo;invented&amp;rdquo;: what you pay for the node is exactly what gets split across its resources.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="OpenCost cost model: total node price split and normalised across CPU, GPU, RAM and disk, and then allocated by usage to each pod" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#om)}&lt;/style>
&lt;defs>&lt;marker id="om" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="170" height="60" rx="6"/>
&lt;text x="32" y="63" class="tl">Node price&lt;/text>
&lt;text x="32" y="81" class="ts">depreciated capex&lt;/text>
&lt;text x="32" y="95" class="ts">+ opex (€/hour)&lt;/text>
&lt;path class="ar" d="M190,70 L235,70"/>
&lt;rect class="bx" x="235" y="40" width="210" height="60" rx="6"/>
&lt;text x="247" y="63" class="tl">Split + normalisation&lt;/text>
&lt;text x="247" y="81" class="ts">CPU / GPU / RAM / disk&lt;/text>
&lt;text x="247" y="95" class="ts">sum = total price&lt;/text>
&lt;path class="ar" d="M445,70 L490,70"/>
&lt;rect class="bx" x="490" y="40" width="270" height="60" rx="6"/>
&lt;text x="502" y="63" class="tl">Allocation by USAGE&lt;/text>
&lt;text x="502" y="81" class="ts">GPU half used = half the cost&lt;/text>
&lt;text x="502" y="95" class="ts">per pod / namespace / team&lt;/text>
&lt;rect class="dsh" x="20" y="130" width="740" height="96" rx="6"/>
&lt;text x="34" y="152" class="tl">What OpenCost guarantees: no cost is lost and none is invented.&lt;/text>
&lt;text x="34" y="172" class="ts">· On-prem: you declare the node cost; OpenCost splits it (no cloud invoice needed).&lt;/text>
&lt;text x="34" y="190" class="ts">· GPU is allocated by USAGE, not presence: a GPU at 50 % charges half its cost.&lt;/text>
&lt;text x="34" y="208" class="ts">· Trap: if the default base price is wrong, the WHOLE split is wrong (typical on-prem under-pricing).&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="where-the-data-comes-from-prometheus">Where the data comes from: Prometheus&lt;/h2>
&lt;p>OpenCost instruments nothing of its own: it reads from Prometheus, which is a prerequisite
of the installation (&lt;a href="https://github.com/opencost/opencost">OpenCost · GitHub&lt;/a>). The sources:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source&lt;/th>
&lt;th>What it provides&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>kube-state-metrics&lt;/code>&lt;/td>
&lt;td>state of K8s objects (pods, requests/limits)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>node-exporter&lt;/code>&lt;/td>
&lt;td>node resources and capacity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>cAdvisor&lt;/code>&lt;/td>
&lt;td>real CPU/memory usage per container&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dcgm-exporter&lt;/code> (NVIDIA)&lt;/td>
&lt;td>&lt;strong>GPU&lt;/strong> usage, memory and power&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With those series, OpenCost automates the join between Kubernetes resource usage and the
provider price, reducing the custom PromQL you would otherwise write by hand (&lt;a href="https://grafana.com/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/manage-costs/">Grafana ·
manage costs&lt;/a>).
The &lt;strong>GPU&lt;/strong> signal comes from DCGM via the &lt;code>dcgm-exporter&lt;/code> (part of the NVIDIA GPU Operator),
the same base as &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability&lt;/a>. Without
Prometheus and without DCGM exporting, OpenCost does not see the GPU.&lt;/p>
&lt;hr>
&lt;h2 id="architecture-the-pieces">Architecture: the pieces&lt;/h2>
&lt;p>OpenCost is not a monolith; it is three responsibilities worth telling apart:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Cost model&lt;/strong>&lt;/td>
&lt;td>resolves the node price and splits/normalises it across resources&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Allocation API&lt;/strong>&lt;/td>
&lt;td>serves the allocated cost per dimension (&lt;code>/allocation&lt;/code> and endpoints)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Exporter&lt;/strong>&lt;/td>
&lt;td>publishes cost metrics on &lt;code>/metrics&lt;/code> for Prometheus/Grafana&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The data flow: Prometheus holds the usage series (from kube-state-metrics, cAdvisor,
dcgm-exporter); the cost model joins them with the resolved price; the Allocation API
serves them aggregated by whatever dimension you ask for; and the exporter returns them to
Prometheus so that Grafana can plot them. It is a cycle: metrics come in from Prometheus and cost
goes back to Prometheus enriched. That symmetry is what lets you reuse your observability stack
without standing up a new database: OpenCost lives on top of Prometheus, not beside it.&lt;/p>
&lt;p>An operational note: historical analysis depends on Prometheus retention. For cost reports
spanning months, a long-term backend behind it (Thanos, Mimir, VictoriaMetrics) is advisable, or
periodically exporting the allocations to a store of your own.&lt;/p>
&lt;hr>
&lt;h2 id="on-prem-prices-in-euros-the-piece-that-decides-everything">On-prem prices in euros: the piece that decides everything&lt;/h2>
&lt;p>On-prem, the most important figure, and the most neglected, is the &lt;strong>node price&lt;/strong>. It is configured
per provider; the options are &lt;code>alibaba&lt;/code>, &lt;code>aws&lt;/code>, &lt;code>azure&lt;/code>, &lt;code>gcp&lt;/code>, &lt;code>oracle&lt;/code>, &lt;strong>&lt;code>ovh&lt;/code>&lt;/strong> or &lt;strong>&lt;code>default&lt;/code>&lt;/strong>
(on-prem) (&lt;a href="https://opencost.io/docs/configuration/">OpenCost · configuration&lt;/a>).
For on-prem you use &lt;code>default&lt;/code>, supplying base prices in a &lt;code>default.json&lt;/code> (or by overriding the Helm
&lt;code>values.yaml&lt;/code>); anything you do not override uses the chart&amp;rsquo;s value (&lt;a href="https://opencost.io/docs/configuration/on-prem/">OpenCost · on-prem&lt;/a>).&lt;/p>
&lt;p>To set the node price in euros, the calculation is the TCO model one: depreciated capex + opex. An
example of an &lt;strong>8×H100&lt;/strong> node:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Calculation&lt;/th>
&lt;th>€/hour&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Capex (node ~240,000 €, 36 months)&lt;/td>
&lt;td>240,000 ÷ 26,280 h&lt;/td>
&lt;td>~9.13&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy (5.6 kW × PUE 1.4 × 0.058 €/kWh, France)&lt;/td>
&lt;td>~0.455 €/h&lt;/td>
&lt;td>~0.46&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintenance, networking, operations&lt;/td>
&lt;td>estimate&lt;/td>
&lt;td>~1.5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Node total&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~11.1 €/h&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That ~11 €/h is the number you put into OpenCost, and everything is split from it. And here is
the documented trap: with the default pricing configuration, OpenCost &lt;strong>under-prices CPU and
GPU on-prem&lt;/strong> (&lt;a href="https://github.com/opencost/opencost/issues/3781">issue #3781&lt;/a>).
If you do not adjust the base prices to your real cost, the split will be systematically low and
the cost per token you report will be unreal. &lt;strong>Configuring the node price is not optional: it is
the figure everything else hangs from.&lt;/strong>&lt;/p>
&lt;hr>
&lt;h3 id="configuring-the-price-an-example">Configuring the price: an example&lt;/h3>
&lt;p>The base price is given in the Helm &lt;code>values.yaml&lt;/code> (or a &lt;code>default.json&lt;/code>), in euros per hour and per
unit of resource, derived from the node&amp;rsquo;s total cost:&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"># values.yaml — on-prem prices in €/hour (8xH100 node, France)&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">opencost&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">customPricing&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">enabled&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">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">custom&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">costModel&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">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;On-prem 8xH100 node&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">CPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.030&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/CPU-hour&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">RAM&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.004&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/GB-hour&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&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.30&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/GPU-hour ← the figure that moves cost/token the most&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">storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.0002&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/GB-hour&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>OpenCost normalises those values so that, multiplied by the node capacity, they add up to the
total price you declared (~11 €/h in the example). The number to be most careful with is the
&lt;strong>GPU&lt;/strong> one: at 1.30 €/GPU-hour, the 8 cards are ~10.4 €/h, the bulk of the node&amp;rsquo;s cost. A badly set
GPU base shifts the whole cost per token. Always review the resolved prices in
&lt;code>/allNodePricing&lt;/code> after configuring, because the default under-pricing bug bites exactly here.&lt;/p>
&lt;hr>
&lt;h2 id="allocation-by-usage-not-by-presence">Allocation: by usage, not by presence&lt;/h2>
&lt;p>The behaviour that has changed most and that matters most for GPU: in recent versions, &lt;strong>the cost
allocated to a GPU is determined by its usage, not its presence&lt;/strong>. If a GPU costing 100 €/month is
only half used, the allocated cost is ~50 € ([search]). This aligns allocation with reality,
you pay for what you use, but it has a consequence: the other half does not disappear, it is
&lt;strong>idle&lt;/strong> that somebody keeps paying for. OpenCost exposes it, and that is where the optimisation
lever comes from.&lt;/p>
&lt;p>OpenCost allocates to any Kubernetes dimension: &lt;strong>cluster, node, namespace, controller
(deployment, statefulset and so on), service, pod and container&lt;/strong>, and by &lt;strong>labels&lt;/strong> (team,
product). Access is through the Allocation API and several endpoints that expose the mechanics:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>What it returns&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/allocation&lt;/code>&lt;/td>
&lt;td>cost allocated by the dimension you ask for&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/costDataModel&lt;/code>&lt;/td>
&lt;td>the resolved node price&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/allNodePricing&lt;/code>&lt;/td>
&lt;td>hourly prices per node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/pricingSourceSummary&lt;/code>&lt;/td>
&lt;td>summary of the pricing source&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/metrics&lt;/code>&lt;/td>
&lt;td>cost metrics for Prometheus&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>/metrics&lt;/code> endpoint is what turns OpenCost into a Prometheus exporter: once there, you can
write PromQL to calculate the cost and efficiency of any Kubernetes concept and build panels in
Grafana (&lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">OpenCost · exporter&lt;/a>).&lt;/p>
&lt;h3 id="allocation-api-parameters">Allocation API parameters&lt;/h3>
&lt;p>&lt;code>/allocation&lt;/code> is controlled by a handful of parameters worth knowing, because they define which
number you get:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Parameter&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>window&lt;/code>&lt;/td>
&lt;td>time range&lt;/td>
&lt;td>&lt;code>7d&lt;/code>, &lt;code>today&lt;/code>, specific dates&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>aggregate&lt;/code>&lt;/td>
&lt;td>aggregation dimension&lt;/td>
&lt;td>&lt;code>namespace&lt;/code>, &lt;code>label:team&lt;/code>, &lt;code>pod&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>accumulate&lt;/code>&lt;/td>
&lt;td>sum the range or break it per interval&lt;/td>
&lt;td>&lt;code>true&lt;/code> (total) / &lt;code>false&lt;/code> (series)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idle&lt;/code>&lt;/td>
&lt;td>include idle cost or not&lt;/td>
&lt;td>&lt;code>true&lt;/code> / &lt;code>false&lt;/code> / &lt;code>separate&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>filter&lt;/code>&lt;/td>
&lt;td>filter by namespace, label and so on&lt;/td>
&lt;td>&lt;code>namespace:&amp;quot;llm-prod&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>&lt;code>idle&lt;/code>&lt;/strong> parameter is the most revealing: with &lt;code>idle=separate&lt;/code>, OpenCost returns the idle
cost as a separate row, which lets you see at a glance how much is being paid for unused
capacity. A query with &lt;code>aggregate=label:team&lt;/code> and &lt;code>idle=separate&lt;/code> over &lt;code>window=30d&lt;/code> is, literally,
the monthly chargeback report with the waste highlighted.&lt;/p>
&lt;hr>
&lt;h2 id="gpu-in-depth-dcgm-usage-mig-and-idle">GPU in depth: DCGM, usage, MIG and idle&lt;/h2>
&lt;p>The GPU is the expensive resource, so its allocation deserves detail:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Usage signal&lt;/strong>: &lt;code>dcgm-exporter&lt;/code> gives &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> (utilisation), &lt;code>DCGM_FI_DEV_FB_USED&lt;/code>
(memory), and power. OpenCost uses utilisation to allocate by usage.&lt;/li>
&lt;li>&lt;strong>MIG (Multi-Instance GPU)&lt;/strong>: an A100 is split into up to 7 isolated instances, which allows
cutting idle from 50 % to almost 0 % by sharing one card across small workloads
([search]; see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing the GPU: time-slicing, MPS and MIG&lt;/a>).
The cost allocation of each MIG instance follows the same usage logic.&lt;/li>
&lt;li>&lt;strong>Idle&lt;/strong>: the reliable detection pattern combines three pieces, DCGM metrics in Prometheus, an
alert that fires when &lt;code>DCGM_FI_DEV_GPU_UTIL &amp;lt; 10&lt;/code> for more than 15 minutes, and routing of
that alert to the team that owns the namespace ([search]).&lt;/li>
&lt;/ul>
&lt;p>An attribution nuance worth knowing: MIG creates isolated instances that Kubernetes sees as
distinct resources, so OpenCost allocates them cleanly, each one to its pod. Time-slicing, by
contrast, shares the same physical GPU across several pods with no compute isolation: there,
per-pod utilisation is harder to separate, and cost attribution becomes approximate (the GPU is
split across the pods sharing it, according to their measured usage). The practical rule: if you
need exact per-team chargeback on a shared GPU, MIG gives cleaner attribution than
time-slicing, at the price of rigid fixed partitions. It is a trade-off between cost accuracy and
flexibility that is better decided before, not after, building the split.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 220" role="img" aria-label="GPU allocation by usage in OpenCost: used cost charged to the pod, idle cost flagged to the namespace owner, and MIG splitting one card across workloads" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#om2)}&lt;/style>
&lt;defs>&lt;marker id="om2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="170" height="70" rx="6"/>
&lt;text x="32" y="63" class="tl">GPU (DCGM)&lt;/text>
&lt;text x="32" y="81" class="ts">util, memory, power&lt;/text>
&lt;text x="32" y="98" class="ts">→ to Prometheus&lt;/text>
&lt;path class="ar" d="M190,75 L235,60"/>
&lt;path class="ar" d="M190,80 L235,150"/>
&lt;rect class="bx" x="235" y="34" width="240" height="44" rx="6"/>
&lt;text x="247" y="54" class="tl">USED cost → pod / team&lt;/text>
&lt;text x="247" y="70" class="ts">charged by utilisation&lt;/text>
&lt;rect class="dsh" x="235" y="128" width="240" height="44" rx="6"/>
&lt;text x="247" y="148" class="tl">IDLE cost → namespace owner&lt;/text>
&lt;text x="247" y="164" class="ts">alert util &amp;lt; 10 % &amp;gt; 15 min&lt;/text>
&lt;rect class="bx" x="520" y="80" width="240" height="50" rx="6"/>
&lt;text x="532" y="100" class="tl">MIG: 1 A100 → up to 7&lt;/text>
&lt;text x="532" y="118" class="ts">splits the card, idle 50%→~0&lt;/text>
&lt;text x="20" y="200" class="ts">Usage allocation reveals idle; MIG and scheduling recover it. OpenCost measures; other layers act.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="efficiency-the-number-that-triggers-action">Efficiency: the number that triggers action&lt;/h2>
&lt;p>With cost allocated by usage, OpenCost lets you calculate efficiency: how much of the allocated
cost corresponds to useful work against the total. A GPU at 30 % utilisation has an efficiency of
30 %: the remaining 70 % is idle cost that somebody pays without receiving work. That figure, per
team and per namespace, is what turns a dashboard into a chargeback conversation: &amp;ldquo;your namespace
has 5 GPUs allocated at 25 % efficiency; either you raise utilisation or you release three cards&amp;rdquo;.
It connects with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy&lt;/a>:
allocation locates the waste; scheduling and co-residency recover it.&lt;/p>
&lt;hr>
&lt;h2 id="worked-example-the-allocation-report">Worked example: the allocation report&lt;/h2>
&lt;p>On the ~11 €/h node (8×H100), three teams share the cluster for a month. What a
&lt;code>/allocation?aggregate=label:team&amp;amp;idle=separate&amp;amp;window=30d&lt;/code> query returns:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Team&lt;/th>
&lt;th>GPU-hours&lt;/th>
&lt;th>Avg. util.&lt;/th>
&lt;th>Allocated cost (€)&lt;/th>
&lt;th>Efficiency&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>A · chat-prod&lt;/td>
&lt;td>2,880 (4 GPU)&lt;/td>
&lt;td>78 %&lt;/td>
&lt;td>~3,744&lt;/td>
&lt;td>high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>B · batch&lt;/td>
&lt;td>1,440 (2 GPU)&lt;/td>
&lt;td>55 %&lt;/td>
&lt;td>~1,872&lt;/td>
&lt;td>medium&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C · experimentation&lt;/td>
&lt;td>1,440 (2 GPU)&lt;/td>
&lt;td>22 %&lt;/td>
&lt;td>~1,872&lt;/td>
&lt;td>&lt;strong>low&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>idle&lt;/strong> (separate)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&lt;strong>~1,100&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>What the report reveals: the cost allocated to A, B and C comes out of their GPU-hours and the node
price; but the &lt;strong>idle&lt;/strong> row (~1,100 €/month) is capacity nobody used and everyone pays for in the
pro rata. And the efficiency column points at C: 2 GPUs at 22 % is ~1,560 € a month of which
only ~340 € is useful work. That is the actionable figure: C does not have a model problem, it has
a utilisation problem. Without OpenCost, those ~1,100 € of idle and C&amp;rsquo;s inefficiency stay diluted
in an aggregate invoice nobody questions. With it, they are a named row and a number in euros.&lt;/p>
&lt;hr>
&lt;h2 id="useful-promql-queries">Useful PromQL queries&lt;/h2>
&lt;p>As a Prometheus exporter, OpenCost lets you build the panels by hand. Some reference queries (exact
metric names vary by version; check in your &lt;code>/metrics&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Cost per node and hour (resolved price)&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="nv">node_total_hourly_cost&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># GPU cost allocated per namespace (€/hour)&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="k">sum&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">by&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">namespace&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&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="nv">container_gpu_allocation&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">on&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">node&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">group_left&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">node_gpu_hourly_cost&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="o">)&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Average GPU utilisation per namespace (for efficiency)&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="k">avg&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">by&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">namespace&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">DCGM_FI_DEV_GPU_UTIL&lt;/span>&lt;span class="o">)&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Idle GPUs: utilisation &amp;lt; 10 % sustained&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="nv">DCGM_FI_DEV_GPU_UTIL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The last one, wrapped in an alert with &lt;code>for: 15m&lt;/code> and routed to the namespace owner, is the
standard idle detection pattern. With these series in Grafana you have cost per team, efficiency
and waste on one dashboard, the &amp;ldquo;Inform report&amp;rdquo; of the FinOps phase in real time.&lt;/p>
&lt;hr>
&lt;h2 id="dynamic-cloud-prices-vs-custom-on-prem-prices">Dynamic cloud prices vs custom on-prem prices&lt;/h2>
&lt;p>The difference is worth understanding, because it changes how reliable the figure is:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Cloud&lt;/strong> (aws/azure/gcp/oracle/ovh): OpenCost obtains the price dynamically from the
provider&amp;rsquo;s billing API, so the allocated cost reflects the real rate (including discounts, spot
and so on) without you declaring anything.&lt;/li>
&lt;li>&lt;strong>On-prem&lt;/strong> (&lt;code>default&lt;/code>/&lt;code>custom&lt;/code>): you declare the price, derived from your TCO model. The
advantage is total control; the risk is that a badly calculated price skews everything. That is
why the TCO model (depreciated capex + opex) is not an academic exercise: it is literally
OpenCost&amp;rsquo;s &lt;em>input&lt;/em>.&lt;/li>
&lt;/ul>
&lt;p>The &lt;strong>&lt;code>ovh&lt;/code>&lt;/strong> provider among those supported is relevant for a European platform: if part of the
workload goes to a European sovereign cloud, OpenCost can allocate its cost with dynamic pricing,
and mix it with on-prem in the same allocation view.&lt;/p>
&lt;hr>
&lt;h2 id="from-pod-cost-to-cost-per-token">From pod cost to cost per token&lt;/h2>
&lt;p>OpenCost gets as far as &amp;ldquo;this vLLM pod cost X €/hour&amp;rdquo;. The &lt;strong>cost per token&lt;/strong>, the metric that
compares on-prem against cloud, needs one more piece: the &lt;strong>gateway&lt;/strong> (LiteLLM) that counts tokens
per request and per team, as covered in the &lt;a href="https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/">FinOps introduction&lt;/a>.
The full chain is: OpenCost gives the pod cost by usage → the gateway gives the tokens per team →
dividing them gives the cost per token per team. OpenCost is the hardware half of that
equation; without it, you would know the tokens but not their real cost. That is why A2 (allocation)
is the foundation the whole cost per token of the proposal hangs from.&lt;/p>
&lt;hr>
&lt;h2 id="deployment-helm--prometheus">Deployment: Helm + Prometheus&lt;/h2>
&lt;p>The typical deployment is via Helm, with Prometheus as a prerequisite (for scraping and
series storage). Conceptual steps:&lt;/p>
&lt;ol>
&lt;li>Have Prometheus with &lt;code>kube-state-metrics&lt;/code>, &lt;code>node-exporter&lt;/code>, &lt;code>cAdvisor&lt;/code> and &lt;code>dcgm-exporter&lt;/code>.&lt;/li>
&lt;li>Install OpenCost with Helm, pointing it at your Prometheus.&lt;/li>
&lt;li>Configure the &lt;strong>node price&lt;/strong> in euros (&lt;code>default.json&lt;/code> / &lt;code>values.yaml&lt;/code>), the step most often
neglected.&lt;/li>
&lt;li>Expose &lt;code>/metrics&lt;/code> and build panels in Grafana with PromQL over the cost metrics.&lt;/li>
&lt;/ol>
&lt;p>As a Prometheus exporter, OpenCost lets you &lt;strong>write PromQL for the cost and efficiency of any
concept&lt;/strong> and create bespoke dashboards (&lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">OpenCost · exporter&lt;/a>).
It reuses infrastructure a GPU cluster already has for observability (DCGM, Prometheus,
Grafana): OpenCost only adds the pricing and allocation layer.&lt;/p>
&lt;hr>
&lt;h2 id="opencost-vs-kubecost-just-the-essentials">OpenCost vs Kubecost (just the essentials)&lt;/h2>
&lt;p>OpenCost is the free base; Kubecost (IBM) is the commercial product built on it. Briefly,
what the commercial one adds:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Capability&lt;/th>
&lt;th>OpenCost&lt;/th>
&lt;th>Kubecost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cost allocation (CPU/GPU/mem/PV)&lt;/td>
&lt;td>✓&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU via DCGM&lt;/td>
&lt;td>✓&lt;/td>
&lt;td>✓ (3.0)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Long historical retention&lt;/td>
&lt;td>depends on your Prometheus&lt;/td>
&lt;td>included&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Automatic rightsizing&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓ (Turbonomic)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Governance, alerts, enterprise RBAC&lt;/td>
&lt;td>basic&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Commercial support&lt;/td>
&lt;td>community&lt;/td>
&lt;td>✓ (IBM)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For most on-prem clusters with a platform team, OpenCost covers allocation; Kubecost adds value
when you want automated optimisation and enterprise features without operating it yourself. The
detail of the comparison, and when paying is worth it, is article &lt;strong>A3&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="implementation-checklist">Implementation checklist&lt;/h2>
&lt;p>For OpenCost to give a defensible per-team cost, order matters:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Step&lt;/th>
&lt;th>Action&lt;/th>
&lt;th>Verification&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Prometheus with kube-state-metrics, node-exporter, cAdvisor&lt;/td>
&lt;td>series present&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>NVIDIA GPU Operator + dcgm-exporter&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> in Prometheus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Install OpenCost (Helm) pointing at Prometheus&lt;/td>
&lt;td>&lt;code>/allocation&lt;/code> responds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;strong>Configure the node price in €&lt;/strong> (capex+opex)&lt;/td>
&lt;td>&lt;code>/allNodePricing&lt;/code> shows the real one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>Label pods/namespaces by team and product&lt;/td>
&lt;td>&lt;code>aggregate=label:team&lt;/code> splits correctly&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Grafana panels for cost, efficiency and idle&lt;/td>
&lt;td>the waste is visible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>Idle alert (&lt;code>util&amp;lt;10&lt;/code> &lt;code>for: 15m&lt;/code>) to the owner&lt;/td>
&lt;td>the alert reaches the team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>(optional) long-retention backend&lt;/td>
&lt;td>months of history available&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Step 4 is the one most often skipped and the one that most invalidates the result. If you only do
one thing right, make it that one: without the correct node price in euros, steps 5 to 7 split a
wrong cost with great precision.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Default under-pricing.&lt;/strong> With the default pricing config, OpenCost underestimates on-prem
CPU and GPU (&lt;a href="https://github.com/opencost/opencost/issues/3781">#3781&lt;/a>). Adjust the base prices
to your real cost or the whole split will come out low.&lt;/li>
&lt;li>&lt;strong>It depends on Prometheus and DCGM.&lt;/strong> Without the right series (especially &lt;code>dcgm-exporter&lt;/code>),
there is no GPU allocation. Input data quality rules.&lt;/li>
&lt;li>&lt;strong>Allocation by usage ≠ total cost.&lt;/strong> A GPU at 50 % charges half; the other half is idle that
still costs. Do not confuse &amp;ldquo;allocated cost&amp;rdquo; with &amp;ldquo;cost paid&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Prometheus retention.&lt;/strong> Historical cost analysis depends on how much your Prometheus retains;
for long series, plan a long-term backend.&lt;/li>
&lt;li>&lt;strong>Allocation, not per-token measurement.&lt;/strong> OpenCost reaches the pod; cost per token needs the
gateway (LiteLLM) above it, as covered in the FinOps introduction.&lt;/li>
&lt;/ol>
&lt;p>With OpenCost&amp;rsquo;s mechanics clear, the next article (A3) compares what Kubecost and the commercial
alternatives add, to decide which FinOps stack to adopt. But the foundation, correct allocation
with the node price properly set in euros, is this one.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>OpenCost looks like &amp;ldquo;installing a cost tool&amp;rdquo; and is, in reality, &lt;strong>declaring what your hardware is
worth and letting it split itself&lt;/strong>. That simplicity is its strength, it lives on top of the
Prometheus and DCGM you already have, with no new database, and also its trap: the split is only as
good as the node price you declare, and the default value under-prices on-prem GPU. Properly
configured in euros, OpenCost turns the cluster&amp;rsquo;s aggregate invoice into a split per team, per
product and per GPU, separates used cost from idle, and exposes the efficiency that triggers
chargeback conversations. It is the hardware half of the cost per token equation, the other half,
the tokens, is supplied by the gateway, and without this half there is no TCO model or on-prem vs
cloud comparison that holds up. For a sovereign architecture proposal, OpenCost is the piece that
makes cost stop being an intuition and become a table in euros, reproducible and auditable, with
the idle GPU named and identified. The &lt;em>Inform&lt;/em> phase of GPU FinOps starts here, and everything
else hangs on this number being right.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">GPU chargeback and showback&lt;/a> — from OpenCost&amp;rsquo;s cost allocation to the per-team split with budgets (Kueue) and token attribution (LiteLLM).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — the cost of idle and how occupancy moves the allocated cost.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of an on-premise GPU cluster: depreciation, energy and infrastructure&lt;/a> — the CAPEX, the depreciation and the €/GPU-hour that OpenCost needs as a base price when the node is not cloud but your own on-premise hardware.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>OpenCost · GitHub (CNCF, Apache 2.0) — &lt;a href="https://github.com/opencost/opencost">https://github.com/opencost/opencost&lt;/a>&lt;/li>
&lt;li>OpenCost · configuración (proveedores: aws/azure/gcp/oracle/ovh/default) — &lt;a href="https://opencost.io/docs/configuration/">https://opencost.io/docs/configuration/&lt;/a>&lt;/li>
&lt;li>OpenCost · configuración on-prem (precios personalizados) — &lt;a href="https://opencost.io/docs/configuration/on-prem/">https://opencost.io/docs/configuration/on-prem/&lt;/a>&lt;/li>
&lt;li>OpenCost · exportador de Prometheus — &lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">https://opencost.io/docs/integrations/opencost-exporter/&lt;/a>&lt;/li>
&lt;li>OpenCost · issue #3781 (infra-precio CPU/GPU on-prem por defecto) — &lt;a href="https://github.com/opencost/opencost/issues/3781">https://github.com/opencost/opencost/issues/3781&lt;/a>&lt;/li>
&lt;li>NVIDIA · dcgm-exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">https://github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>Grafana · manage costs (OpenCost + Kubernetes) — &lt;a href="https://grafana.com/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/manage-costs/">https://grafana.com/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/manage-costs/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>GPU/LLM FinOps: frameworks, metrics and state of the art (tool by tool)</title><link>https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/</link><pubDate>Sat, 13 Jun 2026 03:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. The references are &lt;strong>European&lt;/strong>
(providers and prices from FR/DE/ES), as this is a sovereign proposal; when a source quotes
dollars it is marked &amp;ldquo;USD&amp;rdquo;. The dollar sign is not used (on this site it is a formula
delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-introduction-covers">What this introduction covers&lt;/h2>
&lt;p>Second article in the data series, and the first pillar &lt;em>deep dive&lt;/em>. Here the &lt;strong>FinOps&lt;/strong> tooling
for GPU/LLM infrastructure is inventoried with the detail an architecture decision needs: what
metrics each tool handles, how it allocates cost internally, what GPU support it has, under
what licence and pricing model it operates, and where its limits are. It is the natural
continuation of the &lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">opening article&lt;/a>,
which established that cost per token is the unit that allows on-prem and cloud to be compared.
No recommendations: the final choice is settled in the synthesis article with the Pareto table;
here there are only facts and methodology.&lt;/p>
&lt;hr>
&lt;h2 id="why-gpu-finops-is-a-different-problem">Why GPU FinOps is a different problem&lt;/h2>
&lt;p>Classic cloud FinOps was born to split CPU, memory and storage, cheap and elastic resources. The
GPU breaks three assumptions at once, and that is why it needs its own treatment:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>It is expensive and discrete.&lt;/strong> An H100 costs on the order of 2.7 to 7 €/hour in European
cloud (Scaleway from 2.73 €/h; OVHcloud somewhat more), or its depreciated capex on-prem; it is
not a resource that gets split in trivial fractions. A 10 % allocation error across a GPU fleet
is real money.&lt;/li>
&lt;li>&lt;strong>It is easily underused.&lt;/strong> Unlike the CPU, a GPU draws its power even when idle, and
average occupancy in ungoverned clusters is notoriously low. The cost of &lt;em>idle&lt;/em>, the GPU
powered on without working, is waste number one, and it is invisible if it is not measured.&lt;/li>
&lt;li>&lt;strong>It is hard to attribute.&lt;/strong> Who owns the cost of a GPU shared by &lt;em>time-slicing&lt;/em> or MIG across
several pods from several teams? Without an allocation layer, GPU spend is a black hole nobody
claims and nobody optimises.&lt;/li>
&lt;/ol>
&lt;p>The goal of GPU FinOps is to turn that black hole into an attributed, measured and actionable
invoice: knowing what each team, each model and, in the end, each token costs.&lt;/p>
&lt;hr>
&lt;h2 id="the-finops-metrics">The FinOps metrics&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Definition&lt;/th>
&lt;th>Unit&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>CPM&lt;/strong> (cost/1M tokens)&lt;/td>
&lt;td>cluster cost ÷ tokens produced&lt;/td>
&lt;td>€ / 1M tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per request&lt;/td>
&lt;td>cost charged to a complete request&lt;/td>
&lt;td>€ / req&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>€/GPU-hour&lt;/td>
&lt;td>hourly cost of a GPU (depreciated or rented)&lt;/td>
&lt;td>€/h&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Utilisation&lt;/strong>&lt;/td>
&lt;td>fraction of the GPU actually used&lt;/td>
&lt;td>% (MFU, GPU-hour util)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost of idle&lt;/td>
&lt;td>GPU powered on without useful work&lt;/td>
&lt;td>€/h wasted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Showback&lt;/td>
&lt;td>reporting the cost to each team (without charging)&lt;/td>
&lt;td>€ / team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chargeback&lt;/td>
&lt;td>charging the cost to each team&lt;/td>
&lt;td>€ / team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost efficiency&lt;/td>
&lt;td>real cost ÷ cost if it were at 100 %&lt;/td>
&lt;td>%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The metric that closes the loop with the business is cost per token (or per request): it is the
only one that can be compared across providers, across models and against the price of an external
API. All GPU FinOps tooling exists to reach, one way or another, that number.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-finops-phases-finops-foundation">The three FinOps phases (FinOps Foundation)&lt;/h2>
&lt;p>The FinOps Foundation framework organises the work in a three-phase cycle. Applied to GPU:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Goal&lt;/th>
&lt;th>Typical GPU action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Inform&lt;/strong>&lt;/td>
&lt;td>visibility and allocation&lt;/td>
&lt;td>measure cost per namespace/team/model/token&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Optimize&lt;/strong>&lt;/td>
&lt;td>reduce spend&lt;/td>
&lt;td>rightsizing, spot, quotas, switch off the idle, quantise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Operate&lt;/strong>&lt;/td>
&lt;td>continuous governance&lt;/td>
&lt;td>budgets, idle alerts, automated chargeback&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The common mistake is jumping to &lt;em>Optimize&lt;/em> without having done &lt;em>Inform&lt;/em>: GPUs get bought or
replicas get adjusted without knowing where the money really goes. Allocation (the Inform phase) is
the prerequisite for everything else, and it is where the tooling comes in.&lt;/p>
&lt;hr>
&lt;h2 id="how-cost-is-allocated-in-kubernetes-opencosts-mechanics">How cost is allocated in Kubernetes: OpenCost&amp;rsquo;s mechanics&lt;/h2>
&lt;p>OpenCost is the de facto standard, so it is worth understanding how it allocates cost,
because that defines what any tool on top can and cannot do. It is a vendor-neutral, Apache 2.0
project, originally built by Kubecost and donated to the CNCF (incubating project)
(&lt;a href="https://github.com/opencost/opencost">OpenCost · GitHub&lt;/a>).&lt;/p>
&lt;p>The model works at node level: it starts from the node&amp;rsquo;s resource capacity (CPU, RAM, GPU,
storage) and its total price, and splits that price across the resources. When the provider does
not give explicit CPU/GPU/RAM prices, OpenCost uses the ratio of a set of base prices (the
provider&amp;rsquo;s marginal rates, customisable) and &lt;strong>normalises them so that the sum of the components
equals the node&amp;rsquo;s total price&lt;/strong>
(&lt;a href="https://opencost.io/docs/configuration/on-prem/">OpenCost · on-prem&lt;/a>). This is key on-premise:
you define the node cost (depreciated capex + opex) and OpenCost splits it.&lt;/p>
&lt;p>It obtains utilisation by scraping Prometheus: &lt;code>kube-state-metrics&lt;/code>, &lt;code>node-exporter&lt;/code>
and &lt;code>cAdvisor&lt;/code> give it the real consumption per pod, and with that it allocates cost per
cluster, node, namespace, controller, service or pod (&lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">OpenCost · exporter&lt;/a>).
For the GPU, the signal comes from DCGM (via the NVIDIA GPU Operator) exported to
Prometheus, the same base as &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability&lt;/a>.
A common idle detection pattern: an alert when &lt;code>DCGM_FI_DEV_GPU_UTIL &amp;lt; 10&lt;/code>
for more than 15 minutes, routed to the team that owns the namespace.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="OpenCost cost allocation mechanics: utilisation metrics from Prometheus and DCGM, node-level pricing model, and allocation per namespace, team and pod" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#fm)}&lt;/style>
&lt;defs>&lt;marker id="fm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="26" class="tl">Utilisation metrics (Prometheus)&lt;/text>
&lt;rect class="bx" x="20" y="36" width="200" height="84" rx="6"/>
&lt;text x="32" y="58" class="ts">kube-state-metrics&lt;/text>
&lt;text x="32" y="76" class="ts">node-exporter · cAdvisor&lt;/text>
&lt;text x="32" y="94" class="ts">DCGM (GPU, via GPU Operator)&lt;/text>
&lt;text x="32" y="112" class="ts">usage per pod&lt;/text>
&lt;path class="ar" d="M220,78 L265,78"/>
&lt;rect class="bx" x="265" y="36" width="210" height="84" rx="6"/>
&lt;text x="277" y="58" class="tl">Pricing model (per node)&lt;/text>
&lt;text x="277" y="78" class="ts">total node price split&lt;/text>
&lt;text x="277" y="96" class="ts">across CPU/GPU/RAM/disk&lt;/text>
&lt;text x="277" y="114" class="ts">normalised so sum = total&lt;/text>
&lt;path class="ar" d="M475,78 L520,78"/>
&lt;rect class="bx" x="520" y="36" width="240" height="84" rx="6"/>
&lt;text x="532" y="58" class="tl">Allocation&lt;/text>
&lt;text x="532" y="78" class="ts">per cluster / node / namespace&lt;/text>
&lt;text x="532" y="96" class="ts">controller / service / pod&lt;/text>
&lt;text x="532" y="114" class="ts">→ cost per team&lt;/text>
&lt;rect class="dsh" x="20" y="150" width="740" height="74" rx="6"/>
&lt;text x="34" y="172" class="tl">On-premise you define the node cost (depreciated capex + opex); OpenCost only splits it.&lt;/text>
&lt;text x="34" y="192" class="ts">GPU idle: alert if DCGM_FI_DEV_GPU_UTIL &amp;lt; 10 for &amp;gt;15 min → route to the namespace owner.&lt;/text>
&lt;text x="34" y="210" class="ts">What OpenCost allocates per resource, the layers above enrich into cost per token.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="frameworks-tool-by-tool">Frameworks, tool by tool&lt;/h2>
&lt;h3 id="opencost--the-allocation-standard-cncf-apache-20">OpenCost — the allocation standard (CNCF, Apache 2.0)&lt;/h3>
&lt;p>What it measures: allocated cost of in-cluster resources (CPU, GPU, memory, volumes) by any
Kubernetes dimension. Method: node-level pricing model + Prometheus scraping (above). GPU: yes,
via DCGM. Licence: Apache 2.0, free. It can run as a metrics exporter to Prometheus with no
further dependencies. Limit: it is the allocation layer, it brings no optimisation, governance
or product unit economics; for that you put something on top of it.&lt;/p>
&lt;h3 id="kubecost--the-commercial-product-on-opencost-ibm">Kubecost — the commercial product on OpenCost (IBM)&lt;/h3>
&lt;p>What it measures: what OpenCost does (it is built on it) plus enterprise capabilities. &lt;strong>Kubecost
3.0 (2025)&lt;/strong> added GPU monitoring via &lt;strong>NVIDIA DCGM&lt;/strong> and integration with &lt;strong>IBM
Turbonomic&lt;/strong> for automatic &lt;em>rightsizing&lt;/em>, and extended its scope from Kubernetes to cloud service
cost. IBM acquired Kubecost and integrated Kubecost/OpenCost into its FinOps Suite alongside
Cloudability and Turbonomic (&lt;a href="https://www.cloudzero.com/blog/kubecost-vs-opencost/">CloudZero · Kubecost vs OpenCost&lt;/a>).
Differentiator: rightsizing, governance, support. Limit: it is a commercial product; the bulk of
the value over free OpenCost is the optimisation and enterprise layer.&lt;/p>
&lt;h3 id="cloudzero--unit-economics-and-cost-per-product">CloudZero — unit economics and cost per product&lt;/h3>
&lt;p>What it measures: it maps cloud cost to features, products, teams and customers, not just to
resources. Method: it ingests multi-cloud billing and models it in business dimensions.
Differentiator: the deepest allocation and the unit economics approach (cost per business
unit). Limit: less focused on intra-Kubernetes mechanics than OpenCost/Kubecost; it is the
&amp;ldquo;cost to business&amp;rdquo; layer.&lt;/p>
&lt;h3 id="vantage--multi-cloud-with-many-integrations">Vantage — multi-cloud with many integrations&lt;/h3>
&lt;p>What it measures: multi-cloud cost with more than 20 native integrations (AWS, Azure, GCP,
Kubernetes, Snowflake, Datadog, OpenAI and so on). Differentiator: breadth of sources, including
the invoice from LLM providers (OpenAI), which brings it closer to end-to-end AI cost. Limit:
the depth of intra-cluster allocation is lower than that of K8s-native tools.&lt;/p>
&lt;h3 id="finout--virtual-tagging-fast-deployment">Finout — virtual tagging, fast deployment&lt;/h3>
&lt;p>What it measures: multi-cloud cost with virtual tagging: it applies cost labels &lt;strong>without
modifying the real resources&lt;/strong>, which allows spend that was not properly tagged at source to be
allocated. Differentiator: fast deployment and flexible reallocation without touching the infra.
Limit: like the others in its category, it depends on the quality of the billing data it ingests.&lt;/p>
&lt;h3 id="comparison-table">Comparison table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Scope&lt;/th>
&lt;th>GPU&lt;/th>
&lt;th>Licence / model&lt;/th>
&lt;th>Differentiator&lt;/th>
&lt;th>Layer&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>OpenCost&lt;/strong>&lt;/td>
&lt;td>Kubernetes&lt;/td>
&lt;td>Yes (DCGM)&lt;/td>
&lt;td>Apache 2.0 (CNCF), free&lt;/td>
&lt;td>allocation standard&lt;/td>
&lt;td>resource&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kubecost&lt;/strong>&lt;/td>
&lt;td>K8s + cloud&lt;/td>
&lt;td>Yes (DCGM, 3.0)&lt;/td>
&lt;td>commercial (IBM)&lt;/td>
&lt;td>rightsizing, enterprise&lt;/td>
&lt;td>resource+optim&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CloudZero&lt;/strong>&lt;/td>
&lt;td>multi-cloud&lt;/td>
&lt;td>indirect&lt;/td>
&lt;td>commercial&lt;/td>
&lt;td>unit economics per product&lt;/td>
&lt;td>business&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Vantage&lt;/strong>&lt;/td>
&lt;td>multi-cloud (20+)&lt;/td>
&lt;td>via K8s/provider&lt;/td>
&lt;td>commercial&lt;/td>
&lt;td>breadth (incl. OpenAI)&lt;/td>
&lt;td>business&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Finout&lt;/strong>&lt;/td>
&lt;td>multi-cloud&lt;/td>
&lt;td>via K8s/provider&lt;/td>
&lt;td>commercial&lt;/td>
&lt;td>virtual tagging&lt;/td>
&lt;td>business&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="pricing-models-of-commercial-tooling">Pricing models of commercial tooling&lt;/h2>
&lt;p>They are worth knowing because the cost of the FinOps tool is also FinOps:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>How it charges&lt;/th>
&lt;th>Range&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Savings-based&lt;/td>
&lt;td>% of the savings delivered&lt;/td>
&lt;td>&lt;strong>15–35 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fixed-fee&lt;/td>
&lt;td>% of annual cloud spend&lt;/td>
&lt;td>&lt;strong>1–3 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>(&lt;a href="https://www.cloudzero.com/blog/finops-tools/">CloudZero · FinOps Tools&lt;/a>). The implication: at
large cloud spend, a 1–3 % &lt;em>fixed-fee&lt;/em> can exceed the &lt;em>savings-based&lt;/em> model; at small spend with a
lot of waste, &lt;em>savings-based&lt;/em> aligns incentives. OpenCost, being free, changes the equation for
whoever has a team to operate it.&lt;/p>
&lt;hr>
&lt;h2 id="focus-the-cost-data-standard">FOCUS: the cost data standard&lt;/h2>
&lt;p>The cross-cutting problem of multi-source FinOps is that each provider bills in its own format.
&lt;strong>FOCUS&lt;/strong> (FinOps Open Cost and Usage Specification) is the open technical specification that
defines requirements so that providers produce uniform billing datasets
(&lt;a href="https://focus.finops.org/focus-specification/">FOCUS · FinOps Foundation&lt;/a>).
The committee ratified FOCUS v1.3 on 4 December 2025.&lt;/p>
&lt;p>What is relevant for this series: at &lt;strong>FinOps X 2026&lt;/strong> the focus has been put on &lt;strong>extending FOCUS
to AI workloads&lt;/strong>, with token economics pushing the specification, and the expansion requests
include AI, datacenter and SaaS/PaaS workloads (&lt;a href="https://siliconangle.com/2026/06/08/focus-specification-ai-cost-accountability-finopsx/">SiliconANGLE&lt;/a>).
In other words, the standard that normalises cloud cost is being stretched to cover AI cost per
token. For an architecture proposal, betting on tools that emit and consume FOCUS is betting on
future interoperability.&lt;/p>
&lt;hr>
&lt;h2 id="from-resource-to-token-cost-per-token-with-a-gateway">From resource to token: cost per token with a gateway&lt;/h2>
&lt;p>Resource allocation (OpenCost) gets as far as &amp;ldquo;this vLLM pod cost X €/hour&amp;rdquo;. To get to &amp;ldquo;this
request from this team cost Y&amp;rdquo; you have to intercept the inference traffic. That is where the
gateway comes in.&lt;/p>
&lt;p>Tools such as LiteLLM sit between the application and the LLM provider/engine, and &lt;strong>intercept
every request to record tokens, latency and cost in real time&lt;/strong>. Layers above (for instance
OpenLM&amp;rsquo;s AI Gateway) generate usage logs compatible with FOCUS (v1.0 to 1.3) and map spend to
team, product, customer or feature, enabling showback or chargeback
(&lt;a href="https://www.openlm.com/enable-ai-finops-with-real-time-token-attribution/">OpenLM · token attribution&lt;/a>).
This links up with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster&lt;/a>,
where the gateway is the piece that splits cost between tenants.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 230" role="img" aria-label="From GPU-hour to cost per token: OpenCost allocates the pod cost, the LiteLLM gateway intercepts requests and counts tokens, and cost is split per team and product" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#fm2)}&lt;/style>
&lt;defs>&lt;marker id="fm2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="160" height="58" rx="6"/>
&lt;text x="32" y="63" class="tl">OpenCost&lt;/text>
&lt;text x="32" y="82" class="ts">vLLM pod cost (€/h)&lt;/text>
&lt;path class="ar" d="M180,69 L225,69"/>
&lt;rect class="bx" x="225" y="40" width="180" height="58" rx="6"/>
&lt;text x="237" y="63" class="tl">Gateway (LiteLLM)&lt;/text>
&lt;text x="237" y="82" class="ts">intercepts · counts tokens&lt;/text>
&lt;path class="ar" d="M405,69 L450,69"/>
&lt;rect class="bx" x="450" y="40" width="150" height="58" rx="6"/>
&lt;text x="462" y="63" class="tl">FOCUS logs&lt;/text>
&lt;text x="462" y="82" class="ts">tokens · latency · cost&lt;/text>
&lt;path class="ar" d="M600,69 L645,69"/>
&lt;rect class="bx" x="645" y="40" width="115" height="58" rx="6"/>
&lt;text x="657" y="63" class="tl">Showback&lt;/text>
&lt;text x="657" y="82" class="ts">per team&lt;/text>
&lt;rect class="dsh" x="20" y="130" width="740" height="72" rx="6"/>
&lt;text x="34" y="152" class="tl">Two halves that have to be joined: allocation per resource (OpenCost) + measurement per token (gateway).&lt;/text>
&lt;text x="34" y="172" class="ts">Without the gateway you know the pod's cost, not the request's. Without OpenCost you know the tokens, not the real hardware cost.&lt;/text>
&lt;text x="34" y="190" class="ts">FOCUS is the common format that lets them be crossed with the rest of the cloud spend.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="worked-example-chargeback-of-a-multi-tenant-cluster">Worked example: chargeback of a multi-tenant cluster&lt;/h2>
&lt;p>To see the two halves joined, a split over an example node (&lt;strong>4×H100&lt;/strong>, depreciated cost
12 €/hour) shared by three teams via namespaces and MIG:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Team&lt;/th>
&lt;th>GPU-hours allocated (OpenCost)&lt;/th>
&lt;th>Hardware cost (€/h)&lt;/th>
&lt;th>Tokens/day (gateway)&lt;/th>
&lt;th>Cost/1M tok&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>A · chat product&lt;/td>
&lt;td>50 %&lt;/td>
&lt;td>6.00&lt;/td>
&lt;td>8M&lt;/td>
&lt;td>~0.75 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>B · nightly batch&lt;/td>
&lt;td>30 %&lt;/td>
&lt;td>3.60&lt;/td>
&lt;td>3M&lt;/td>
&lt;td>~1.20 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C · experimentation&lt;/td>
&lt;td>20 %&lt;/td>
&lt;td>2.40&lt;/td>
&lt;td>0.5M&lt;/td>
&lt;td>~4.80 €&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>How it comes out: OpenCost splits the node&amp;rsquo;s 12 €/h according to the GPU-hours each namespace
consumes (half for A → 6 €/h). The gateway supplies the tokens per team. Each one&amp;rsquo;s cost per
million tokens is its hardware cost divided by its output, and it reveals something neither half
would see alone: team &lt;strong>C pays 6× more per token than A&lt;/strong>, not because its model is worse, but
because its GPU is underused (a lot of allocated GPU-hours for few tokens). That 4.80 €/1M is
the chargeback signal that triggers an optimisation conversation: either C raises its utilisation,
or it releases the GPU. Without crossing allocation and tokens, that waste stays hidden in a
cluster average.&lt;/p>
&lt;hr>
&lt;h2 id="the-hidden-cost-utilisation">The hidden cost: utilisation&lt;/h2>
&lt;p>Optimisation lever number one is not changing GPU, it is stopping paying for an idle GPU. As
seen in the opening article, the electricity cost per token at 80 % utilisation is a quarter of
what it is at 20 %, and utilisation spreads all the fixed cost (capex + energy) over more tokens.
Measuring idle is, therefore, the highest-return action of the &lt;em>Optimize&lt;/em> phase:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Signal&lt;/th>
&lt;th>Source&lt;/th>
&lt;th>Typical threshold&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Idle GPU&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>&lt;/td>
&lt;td>&amp;lt; 10 % for &amp;gt; 15 min&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unused GPU memory&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_FB_USED&lt;/code>&lt;/td>
&lt;td>reserved but not used&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pods with no traffic&lt;/td>
&lt;td>gateway metrics&lt;/td>
&lt;td>0 requests, GPU allocated&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Allocation (OpenCost) locates whose the idle GPU is; scheduling and co-residency
(&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing the GPU: time-slicing, MPS and MIG&lt;/a>)
recover it. FinOps closes the loop: measure → attribute → optimise → govern.&lt;/p>
&lt;hr>
&lt;h2 id="optimize-the-saving-levers-with-data">Optimize: the saving levers (with data)&lt;/h2>
&lt;p>Once cost is allocated (Inform), the &lt;em>Optimize&lt;/em> phase has a bounded repertoire of levers. Ordered
by typical return:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Typical saving&lt;/th>
&lt;th>Mechanism&lt;/th>
&lt;th>Cost/risk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Recover idle&lt;/strong>&lt;/td>
&lt;td>the largest&lt;/td>
&lt;td>switch off/share the idle GPU&lt;/td>
&lt;td>requires measuring utilisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Quantisation (FP8/INT4)&lt;/strong>&lt;/td>
&lt;td>raises throughput, lowers VRAM&lt;/td>
&lt;td>more tokens per GPU-hour&lt;/td>
&lt;td>possible quality cost&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Reserved commitment&lt;/strong>&lt;/td>
&lt;td>&lt;strong>20–40 %&lt;/strong> over on-demand&lt;/td>
&lt;td>reserve capacity&lt;/td>
&lt;td>less elasticity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Spot/preemptible&lt;/strong>&lt;/td>
&lt;td>the deepest discount&lt;/td>
&lt;td>interruptible capacity&lt;/td>
&lt;td>you have to tolerate cuts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Rightsizing&lt;/strong>&lt;/td>
&lt;td>variable&lt;/td>
&lt;td>match GPU type/count to the SLO&lt;/td>
&lt;td>requires benchmarks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Autoscaling (HPA/KEDA)&lt;/strong>&lt;/td>
&lt;td>variable&lt;/td>
&lt;td>scale replicas with demand&lt;/td>
&lt;td>tuning metrics&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On commitment, the 2026 cloud data is conclusive: reserved plans give 20–40 % saving
against on-demand, and spot the deepest discount in exchange for interruptibility; the price of
the H100 fell 64–75 % between Q4 2024 and the start of 2026
(&lt;a href="https://www.spheron.network/blog/gpu-cloud-pricing-comparison-2026/">Spheron · GPU Cloud Pricing&lt;/a>).
Rightsizing and autoscaling connect with cluster scheduling: fitting the workload onto the
right GPU and scaling replicas with demand are, at the same time, performance and cost levers.
Quantisation appears here because it raises throughput, and, by the identity from the opening
article, lowers cost and energy per token at the same time.&lt;/p>
&lt;hr>
&lt;h2 id="gpu-finops-maturity">GPU FinOps maturity&lt;/h2>
&lt;p>A simple model to place where an organisation is, and what it is missing:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>State&lt;/th>
&lt;th>What is missing to move up&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0 · blind&lt;/td>
&lt;td>aggregate invoice, no attribution&lt;/td>
&lt;td>instrument OpenCost + DCGM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1 · visibility&lt;/td>
&lt;td>cost per namespace/team&lt;/td>
&lt;td>per-token measurement (gateway)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2 · unit economics&lt;/td>
&lt;td>cost per token and per product&lt;/td>
&lt;td>idle alerts and budgets&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3 · optimisation&lt;/td>
&lt;td>idle recovered, commitment, rightsizing&lt;/td>
&lt;td>automated chargeback&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4 · governance&lt;/td>
&lt;td>chargeback + budgets + FOCUS&lt;/td>
&lt;td>continuous improvement&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Most organisations with GPUs are at level 0 or 1: they see a big invoice but do not know whose
each euro is. The value jump is in reaching level 2, cost per token and per product, which is
exactly where the tooling in this article stops being optional. The series&amp;rsquo; FinOps track walks that
ladder up to the full TCO model and the comparable cost/token that underpins the architecture
proposal.&lt;/p>
&lt;hr>
&lt;h2 id="the-minimum-stack-to-reach-level-2">The minimum stack to reach level 2&lt;/h2>
&lt;p>Bringing the previous pieces together into a concrete toolchain, all open source, that takes you
from blindness (level 0) to cost per token and per product (level 2):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Function&lt;/th>
&lt;th>Alternative&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>NVIDIA GPU Operator + DCGM&lt;/strong>&lt;/td>
&lt;td>exports GPU metrics (usage, memory, power)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prometheus&lt;/strong>&lt;/td>
&lt;td>stores the usage and cost series&lt;/td>
&lt;td>VictoriaMetrics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenCost&lt;/strong>&lt;/td>
&lt;td>allocates cost per resource and dimension&lt;/td>
&lt;td>Kubecost (commercial)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Gateway (LiteLLM)&lt;/strong>&lt;/td>
&lt;td>counts tokens per team/model&lt;/td>
&lt;td>OpenLM AI Gateway&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Grafana&lt;/strong>&lt;/td>
&lt;td>cost, utilisation and idle panels&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>It is, deliberately, infrastructure many clusters already have for observability (DCGM,
Prometheus, Grafana); GPU FinOps reuses that base and adds OpenCost and the gateway to it. It is
not a new product, it is a layer on top of what exists.&lt;/p>
&lt;h3 id="kpis-to-watch">KPIs to watch&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>KPI&lt;/th>
&lt;th>What it indicates&lt;/th>
&lt;th>Typical target&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Average GPU utilisation&lt;/td>
&lt;td>waste&lt;/td>
&lt;td>&amp;gt;70–80 % sustained&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per 1M tokens (per model)&lt;/td>
&lt;td>economic efficiency&lt;/td>
&lt;td>compare against cloud rental&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>% of GPU-hours idle&lt;/td>
&lt;td>money thrown away&lt;/td>
&lt;td>minimise (&amp;lt;10–15 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per team/product&lt;/td>
&lt;td>attribution&lt;/td>
&lt;td>fair split, no surprises&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Deviation against budget&lt;/td>
&lt;td>governance&lt;/td>
&lt;td>alert before it is exceeded&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These five KPIs are what turn GPU FinOps from a pretty dashboard into a decision tool: if you watch
them, you know at all times what each thing costs and where the waste is.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-2026">State of the art 2026&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Consolidation in Kubernetes&lt;/strong>: OpenCost is the CNCF allocation standard; Kubecost
(IBM) the reference commercial product, already with GPU via DCGM and rightsizing through
Turbonomic.&lt;/li>
&lt;li>&lt;strong>From resource to token&lt;/strong>: the strong platforms of 2026 track cost at token and GPU level,
combining resource allocation (OpenCost) with gateway measurement (LiteLLM); it is the only route
to a comparable cost/token for on-prem against cloud.&lt;/li>
&lt;li>&lt;strong>FOCUS v1.3&lt;/strong> (Dec 2025) as the interoperability layer, extending to AI at
FinOps X 2026: the cloud cost standard absorbs token economics.&lt;/li>
&lt;li>&lt;strong>Mature GPU FinOps&lt;/strong>: instrumentation leans on DCGM (the same base as observability), and idle
detection by a utilisation threshold is common practice.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="cost-per-token-the-bridge-to-the-business-decision">Cost per token: the bridge to the business decision&lt;/h2>
&lt;p>All the previous tooling exists to produce a number the business understands: cost per token
(or per request). It is the one that lets you answer the three questions that underpin an
architecture proposal:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Build or buy?&lt;/strong> On-prem cost/token (depreciated capex + opex, at real utilisation) against the
price of an external API or of cloud rental. Below the volume threshold (~2M tokens/day) buying
usually wins; above it, building.&lt;/li>
&lt;li>&lt;strong>How do you price a product?&lt;/strong> If a feature consumes N tokens per use and each million costs C,
the marginal cost per use is N·C/10⁶, the basis of any margin.&lt;/li>
&lt;li>&lt;strong>Where is the waste?&lt;/strong> Cost/token per team (the chargeback example) points at whoever underuses
the GPU before the aggregate invoice hides it.&lt;/li>
&lt;/ol>
&lt;p>The trap: comparing cost/token across scenarios without fixing the assumptions (utilisation,
precision, ownership model). An on-prem cost/token calculated at 80 % utilisation is not comparable
with one at 20 %, nor an FP16 with an FP8. That is why allocation (Inform) is not an end in itself:
it is the raw material of a cost model with explicit assumptions, which is what the synthesis
article turns into the &amp;ldquo;build vs buy&amp;rdquo; argument with defensible numbers. GPU FinOps is not
accounting; it is the quantitative basis of the architecture decision.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Allocation ≠ per-token measurement.&lt;/strong> OpenCost splits the resource cost; without a gateway you
do not reach the request. They are two halves that have to be joined explicitly.&lt;/li>
&lt;li>&lt;strong>Badly set base prices = badly set allocation.&lt;/strong> On-prem, OpenCost splits the cost &lt;strong>you
declare&lt;/strong> for the node; if the depreciated capex/opex is wrong, the whole split is wrong. Input
data quality rules.&lt;/li>
&lt;li>&lt;strong>Invisible idle.&lt;/strong> Without DCGM exported to Prometheus and without utilisation alerts, waste
number one does not appear on any dashboard.&lt;/li>
&lt;li>&lt;strong>Cost of the tool.&lt;/strong> A 1–3 % &lt;em>fixed-fee&lt;/em> on large spend is real money; compare the tooling cost
with the saving it delivers (it is FinOps on top of FinOps).&lt;/li>
&lt;li>&lt;strong>Proprietary formats.&lt;/strong> Tools that do not emit/consume FOCUS tie you to their data model; in
2026 the robust bet is FOCUS interoperability.&lt;/li>
&lt;/ol>
&lt;p>The next article in the series (A2) goes into OpenCost in depth; this one fixes the map. With
allocation settled, the rest of the FinOps track builds the TCO model and the cost/token the
proposal needs.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>OpenCost · GitHub (CNCF, Apache 2.0) — &lt;a href="https://github.com/opencost/opencost">https://github.com/opencost/opencost&lt;/a>&lt;/li>
&lt;li>OpenCost · documentación on-prem (modelo de precio por nodo) — &lt;a href="https://opencost.io/docs/configuration/on-prem/">https://opencost.io/docs/configuration/on-prem/&lt;/a>&lt;/li>
&lt;li>OpenCost · exporter de Prometheus — &lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">https://opencost.io/docs/integrations/opencost-exporter/&lt;/a>&lt;/li>
&lt;li>CloudZero · Kubecost vs OpenCost (2026) — &lt;a href="https://www.cloudzero.com/blog/kubecost-vs-opencost/">https://www.cloudzero.com/blog/kubecost-vs-opencost/&lt;/a>&lt;/li>
&lt;li>CloudZero · FinOps Tools: Definitive Guide (2026) — &lt;a href="https://www.cloudzero.com/blog/finops-tools/">https://www.cloudzero.com/blog/finops-tools/&lt;/a>&lt;/li>
&lt;li>FOCUS · especificación (FinOps Foundation) — &lt;a href="https://focus.finops.org/focus-specification/">https://focus.finops.org/focus-specification/&lt;/a>&lt;/li>
&lt;li>SiliconANGLE · FOCUS y la economía de tokens de IA (FinOps X 2026) — &lt;a href="https://siliconangle.com/2026/06/08/focus-specification-ai-cost-accountability-finopsx/">https://siliconangle.com/2026/06/08/focus-specification-ai-cost-accountability-finopsx/&lt;/a>&lt;/li>
&lt;li>OpenLM · atribución de tokens en tiempo real (LiteLLM + FOCUS) — &lt;a href="https://www.openlm.com/enable-ai-finops-with-real-time-token-attribution/">https://www.openlm.com/enable-ai-finops-with-real-time-token-attribution/&lt;/a>&lt;/li>
&lt;li>Finout · Best AI Cost Observability Tools (2026) — &lt;a href="https://www.finout.io/blog/best-ai-cost-observability-tools-in-2026">https://www.finout.io/blog/best-ai-cost-observability-tools-in-2026&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>FinOps and multi-tenancy on the GPU cluster: who pays for what</title><link>https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/</link><pubDate>Thu, 11 Jun 2026 12:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/</guid><description>&lt;blockquote>
&lt;p>Third instalment in an operational series on how to squeeze a generic on-premise LLM inference cluster of &lt;strong>4×H100 SXM 80 GB&lt;/strong>. The sibling pieces are &lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal VLM on-premise with vLLM&lt;/a>, serving vision-language models on the same cluster, and &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a>, cutting the model load time from disk to HBM. This post closes the economic question the other two leave open: once the cluster serves several models for several teams, &lt;strong>who pays for what?&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An on-premise GPU cluster does not have the property that makes FinOps easy in the cloud: no monthly invoice arrives telling you what each compute hour cost. The cost has to be manufactured from capex and energy. The right unit is the token, and it is derived in two steps. First the €/GPU-hour: capex depreciation spread over the hours of useful life, plus energy (power × PUE × price_kWh). For an H100 SXM with an assumed capex of 30,000 € and 4 years of life at 90 % availability, that gives ≈ 1.07 €/GPU-hour (0.95 of depreciation + 0.12 of energy at 0.12 €/kWh and PUE 1.4). Second, the €/1M tokens: divide that €/GPU-hour by the sustained throughput. At 2,000 useful tok/s, ≈ 0.148 €/1M tokens; but that number only holds if the GPU is at 100 % useful utilisation. At 20 % utilisation the same token costs 4× more (0.74 €/1M), because capex and energy keep being paid even if the GPU is idle. That is the central thesis: &lt;strong>the FinOps goal is not to negotiate the price of the token down, there is no provider to negotiate with, but to raise useful utilisation&lt;/strong>, which is the only term that moves cost by an order of magnitude. Attribution (chargeback / showback) is made real with LiteLLM virtual keys: per-team and per-user budget, RPM/TPM rate limit, automatic spend tracking and &lt;code>tags&lt;/code> to assign spend to cost centres. Isolation between tenants is achieved with MIG (hard GPU partitioning), namespaces, &lt;code>ResourceQuota&lt;/code> and &lt;code>PriorityClass&lt;/code>. The FinOps Foundation rule worth carving in stone: &lt;strong>showback always, chargeback only if the organisation&amp;rsquo;s accounting policy supports it&lt;/strong>; neither of the two is &amp;ldquo;more mature&amp;rdquo;. And the governance metric of the shared cluster is not the €/token but &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>.&lt;/p>
&lt;h2 id="the-problem-the-cloud-hides-from-you-and-on-premise-forces-you-to-solve">The problem the cloud hides from you and on-premise forces you to solve&lt;/h2>
&lt;p>In the cloud, the cost of a GPU is an observable fact: AWS, GCP or Azure charge you per GPU-hour and the invoice arrives itemised. FinOps in that world consists of &lt;strong>reading&lt;/strong> an invoice that already exists and attributing it. On-premise there is no such invoice. The GPU was bought once, eighteen months ago, on a purchase order nobody remembers; the electricity bill arrives for the whole building with no breakdown of what fraction was the cluster&amp;rsquo;s; and the datacenter PUE is a number the facilities team knows but the AI team never asked about.&lt;/p>
&lt;p>The first job of on-premise FinOps, then, is not to attribute a cost but to manufacture one. You have to build, from explicit assumptions, the equivalent of the &amp;ldquo;price per GPU-hour&amp;rdquo; that in the cloud is given to you. And once manufactured, you have to understand that this number is not a property of the hardware, like VRAM or TDP, but a function of how the hardware is used. An idle H100 costs exactly the same as an H100 at 100 %: the capex is already paid and idle energy is not zero. The only thing that changes is &lt;strong>how many useful tokens&lt;/strong> that fixed cost produced. That is where the whole thesis of this post comes from.&lt;/p>
&lt;h2 id="the-analogy-the-coworking-space-with-metered-services">The analogy: the coworking space with metered services&lt;/h2>
&lt;p>A coworking space manages a finite physical space and rents it to teams. The cost model has exactly the structure of the GPU cluster, and the analogy holds down to the last detail.&lt;/p>
&lt;p>&lt;strong>The desk-hour is the spread capex.&lt;/strong> The manager paid the rent on the premises, the furniture and the refurbishment, a one-off outlay or a fixed monthly cost, and spreads it over the available desk-hours in the month. Each desk has a base cost that &lt;strong>does not depend on whether anybody uses it&lt;/strong>: the walls, the table and the chair cost the same empty as occupied. This is the &lt;strong>depreciation of the GPU&lt;/strong>: capex divided by the hours of useful life.&lt;/p>
&lt;p>&lt;strong>The metered kWh is the energy.&lt;/strong> On top of the desk-hour, the coworking space measures the actual electricity consumption, the plugged-in laptop, the screen, the air conditioning in that room, and bills it separately. This is the &lt;strong>energy of the GPU&lt;/strong>: power × hours × price_kWh, multiplied by the datacenter&amp;rsquo;s &lt;strong>PUE&lt;/strong>, which is the coworking space also charging you for the fraction of the building&amp;rsquo;s air conditioning that cools your room.&lt;/p>
&lt;p>&lt;strong>The empty desk is paid for by whoever booked it.&lt;/strong> Here is the heart of the matter. If a team books ten desks for the whole month and only uses two, &lt;strong>it pays for all ten anyway&lt;/strong>. The empty desk still takes up space the manager cannot resell, still depreciates furniture, still counts as committed capacity. In the cluster: a GPU assigned to a tenant that uses it at 20 % costs the same as if it used it at 100 %, and the idle 80 % is &lt;strong>wasted capex&lt;/strong> that somebody is paying for.&lt;/p>
&lt;p>&lt;strong>The manager optimises occupancy, not tariff.&lt;/strong> A competent coworking manager does not obsess over lowering the price of the desk-hour, it is a sunk cost, the premises are already paid for. They obsess over the &lt;strong>occupancy rate&lt;/strong>: every empty desk is margin lost forever, because an unsold desk-hour cannot be stored. The FinOps equivalent: &lt;strong>the goal is not to lower the €/token, it is to raise utilisation&lt;/strong>, because the €/token falls only as a consequence of utilisation rising. There is no provider to squeeze; the only margin lies in not leaving GPUs idle.&lt;/p>
&lt;p>The analogy has an honest limit worth naming: in a coworking space, two people cannot physically share the same chair. On a GPU you &lt;strong>can&lt;/strong>, with time-slicing, MPS or MIG (see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>), and that is precisely the technical lever that lets you raise occupancy beyond what the physical coworking space would allow. But the economics of fixed cost + metered cost is identical.&lt;/p>
&lt;h2 id="the-token-as-the-unit-of-cost">The token as the unit of cost&lt;/h2>
&lt;p>Why the token and not the GPU-hour, or the request, or the model? Because the token is the only unit comparable across heterogeneous workloads. A GPU-hour of Llama 8B and one of Llama 70B produce radically different amounts of &amp;ldquo;useful work&amp;rdquo;; a RAG request with a 50-token answer and one generating a 3,000-token report are not comparable. The token, specifically the pair (input_tokens, output_tokens), normalises all of that. It is the unit LiteLLM accounts for natively, the one that appears in the OTel attributes &lt;code>gen_ai.usage.input_tokens&lt;/code> and &lt;code>gen_ai.usage.output_tokens&lt;/code> (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>), and the one commercial providers already use to charge. Adopting the token as the internal currency makes on-premise chargeback directly comparable with the cloud alternative, which is exactly the comparison management wants to see.&lt;/p>
&lt;p>The two derived metrics that matter:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tokens/€&lt;/strong> — how many useful tokens each euro of total cluster cost buys. It rises when utilisation rises. It is the metric of economic efficiency.&lt;/li>
&lt;li>&lt;strong>Tokens/W&lt;/strong> — how many tokens each watt consumed produces. It is the metric of energy efficiency and, in a datacenter with limited power (the usual case on-premise), it is often the real &lt;strong>hard constraint&lt;/strong>: you cannot add more GPUs because there are no more kW in the rack, so every watt has to perform.&lt;/li>
&lt;/ul>
&lt;h2 id="cost-model-of-a-gpu-from-capex-to-token">Cost model of a GPU: from capex to €/token&lt;/h2>
&lt;p>The hourly cost of a GPU has two addends. Let us put all the assumptions in writing, because, as in &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>, a cost calculation without written assumptions is a disposable calculation.&lt;/p>
&lt;h3 id="depreciation-spread-capex">Depreciation (spread capex)&lt;/h3>
$$\text{depreciation cost/h} = \frac{\text{capex}_{\text{GPU}}}{\text{hours of useful life}}$$
&lt;p>where the hours of useful life are the depreciation years × 8,760 h/year × the effective availability (the GPU is not available 100 % of the time: there is maintenance, restarts, update windows). Explicit, &lt;strong>generic&lt;/strong> assumptions (they are not figures from any provider or any real purchase):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Capex per GPU&lt;/strong>: 30,000 € — a reasonable assumption that includes not just the GPU but its &lt;strong>pro-rata share&lt;/strong> of the server, power supply, cooling, NVLink/InfiniBand network and rack. A &amp;ldquo;bare&amp;rdquo; H100 SXM costs less, but honest FinOps spreads the cost of the whole node across its GPUs.&lt;/li>
&lt;li>&lt;strong>Accounting useful life&lt;/strong>: 4 years. That is aggressive-realistic for a datacenter GPU; some depreciate over 3, others over 5.&lt;/li>
&lt;li>&lt;strong>Effective availability&lt;/strong>: 90 %.&lt;/li>
&lt;/ul>
$$\text{hours of life} = 4 \times 8{,}760 \times 0.90 = 31{,}536 \text{ h}$$
$$\text{depreciation cost/h} = \frac{30{,}000}{31{,}536} \approx 0.95 \text{ €/GPU-hour}$$
&lt;h3 id="energy-power--pue--price_kwh">Energy (power × PUE × price_kWh)&lt;/h3>
$$\text{energy cost/h} = P_{\text{GPU}} \cdot \text{PUE} \cdot \text{price kWh}$$
&lt;p>Assumptions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>GPU power&lt;/strong>: the TDP of the H100 SXM is 700 W according to the NVIDIA datasheet (&lt;a href="https://resources.nvidia.com/en-us-gpu-resources/h100-datasheet-24306">H100 datasheet&lt;/a>). Under sustained inference load it hovers around that value; we use 0.7 kW as the average power under load. (At idle it drops a lot, 60–100 W, but the FinOps of reserved capacity reasons about power under load, which is what limits the rack.)&lt;/li>
&lt;li>&lt;strong>PUE&lt;/strong> (Power Usage Effectiveness): how many watts enter the datacenter for every watt that reaches the chip. A modest, well-managed on-premise datacenter sits at 1.4; a bad one at 2.0; the hyperscalers boast of 1.1. We use 1.4.&lt;/li>
&lt;li>&lt;strong>Price per kWh&lt;/strong>: 0.12 €/kWh — a generic assumption for an industrial tariff; it varies enormously by country and contract.&lt;/li>
&lt;/ul>
$$\text{energy cost/h} = 0.7 \cdot 1.4 \cdot 0.12 \approx 0.1176 \approx 0.12 \text{ €/GPU-hour}$$
&lt;h3 id="the-total-gpu-hour">The total €/GPU-hour&lt;/h3>
$$\boxed{\text{€/GPU-hour} = 0.95 + 0.12 \approx 1.07 \text{ €/GPU-hour}}$$
&lt;p>Two important readings of this number. First: depreciation dominates (89 % of the cost); energy is 11 %. This inverts many people&amp;rsquo;s intuition, who believe &amp;ldquo;the expensive part is the electricity&amp;rdquo;. With these assumptions, the expensive part is having bought the GPU, and that is why leaving it idle hurts so much. Second: the number is per-GPU; for the 4×H100 node it is ≈ 4.28 €/hour, and for the complete generic cluster of 4 nodes (16 GPUs), ≈ 17.1 €/hour ≈ 150,000 €/year of fixed cost that is paid whether it is used or not.&lt;/p>
&lt;h3 id="from-gpu-hour-to-token">From €/GPU-hour to €/token&lt;/h3>
&lt;p>Now we divide the hourly cost by the tokens the GPU produces in an hour. Here sustained throughput comes in. Let us suppose, an order-of-magnitude figure, checkable with &lt;code>vllm bench serve&lt;/code>, that a Llama 70B replica on 4×H100 (TP=4) sustains &lt;strong>2,000 useful tokens/s&lt;/strong> aggregated under good concurrency.&lt;/p>
$$\text{tokens/hour} = 2{,}000 \times 3{,}600 = 7.2 \times 10^6 \text{ tok/h}$$
&lt;p>The cost of that replica (4 GPUs) is $4 \times 1.07 = 4.28$ €/hour. Therefore:&lt;/p>
$$\text{€/1M tokens} = \frac{4.28}{7.2} \approx 0.59 \text{ €/1M tokens}$$
&lt;p>Per individual GPU, if we take a smaller replica (e.g. Llama 8B FP8 on 1 GPU at ~2,000 tok/s):&lt;/p>
$$\text{€/1M tokens} = \frac{1.07 \text{ €/h}}{7.2 \times 10^6 \text{ tok/h}} \times 10^6 \approx 0.149 \text{ €/1M tokens}$$
&lt;p>These numbers are &lt;strong>the theoretical floor at 100 % utilisation&lt;/strong>. Nobody operates at 100 %. And that is the whole story.&lt;/p>
&lt;h2 id="utilisation-is-the-lever-with-numbers">Utilisation is the lever (with numbers)&lt;/h2>
&lt;p>The €/token in the previous section assumes the GPU produces 7.2 M tokens every hour, hour after hour. In practice it produces that only during peak hours. The rest of the time it is partly idle: at night, at weekends, between traffic peaks. The &lt;strong>useful utilisation&lt;/strong> $u$ is the fraction of the theoretical capacity that really turns into billable tokens.&lt;/p>
&lt;p>The hourly cost is &lt;strong>fixed&lt;/strong> (1.07 €/GPU-hour is paid come rain or shine), but the tokens produced scale with $u$:&lt;/p>
$$\text{€/1M tokens}(u) = \frac{\text{€/GPU-hour}}{\text{max tok/hour} \cdot u} = \frac{1.07}{7.2 \times 10^6 \cdot u} \times 10^6 = \frac{0.149}{u}$$
&lt;p>The requested contrast, worked out:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Useful utilisation $u$&lt;/th>
&lt;th>Real tokens/h&lt;/th>
&lt;th>€/1M tokens&lt;/th>
&lt;th>Multiplier vs 80 %&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>7.2 M&lt;/td>
&lt;td>0.149 €&lt;/td>
&lt;td>0.80×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>5.76 M&lt;/td>
&lt;td>0.186 €&lt;/td>
&lt;td>1.00× (baseline)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>3.6 M&lt;/td>
&lt;td>0.298 €&lt;/td>
&lt;td>1.60×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20 %&lt;/td>
&lt;td>1.44 M&lt;/td>
&lt;td>0.744 €&lt;/td>
&lt;td>4.00×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10 %&lt;/td>
&lt;td>0.72 M&lt;/td>
&lt;td>1.488 €&lt;/td>
&lt;td>8.00×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Direct reading: going from 20 % to 80 % utilisation divides the cost per token by four ($0.744 \to 0.186$). No tariff negotiation, no quantization, no hardware change gives you that factor of 4 so cheaply. Quantizing from BF16 to FP8 can double throughput, and therefore halves the €/token, but it degrades quality and demands evals (see &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a>); raising utilisation from 20 to 80 % does not touch the quality of a single token.&lt;/p>
&lt;p>This reformulates GPU cluster FinOps in a single sentence: &lt;strong>the €/token is not something you negotiate, it is something you earn by filling the GPUs&lt;/strong>. And raising utilisation is an engineering problem that already has its pieces:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Autoscaling&lt;/strong> that switches replicas off when traffic drops, so as not to pay for idle GPU (see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a>). Switching off a 4-GPU replica for eight hours overnight saves ≈ 34 € a day of real cost; multiplied by replicas and by days, that is the difference between a profitable cluster and one that bleeds.&lt;/li>
&lt;li>&lt;strong>Sharing a GPU&lt;/strong> between small workloads with MIG or time-slicing, so that two tenants that individually would use 30 % together fill a GPU to 60 % (see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Overnight batch&lt;/strong> that fills the off-peak hours with non-urgent work (re-embeddings, evals, light fine-tuning) instead of leaving the GPUs switched off or ticking over.&lt;/li>
&lt;li>&lt;strong>Reducing cold start&lt;/strong> so that scaling to zero and starting again is cheap and therefore viable as a utilisation strategy (see the sibling piece &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a>).&lt;/li>
&lt;/ul>
&lt;h2 id="the-diagram-the-token-cascade-by-utilisation">The diagram: the €/token cascade by utilisation&lt;/h2>
&lt;p>The cost per token is not a number, it is a cascade that multiplies as utilisation falls:&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="cost per token cascade by utilisation">
&lt;text x="380" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">The €/token is manufactured, then inflated by low utilisation&lt;/text>
&lt;text x="380" y="48" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">fixed cost (capex + energy) → ÷ tokens produced → €/token, which grows as utilisation falls&lt;/text>
&lt;rect x="40" y="70" width="160" height="50" rx="6" fill="#8b5cf6" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="120" y="92" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Depreciation&lt;/text>
&lt;text x="120" y="108" text-anchor="middle" font-size="11" fill="#ffffff">0.95 €/GPU-h&lt;/text>
&lt;rect x="40" y="130" width="160" height="50" rx="6" fill="#f59e0b" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="120" y="152" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Energy × PUE&lt;/text>
&lt;text x="120" y="168" text-anchor="middle" font-size="11" fill="#ffffff">0.12 €/GPU-h&lt;/text>
&lt;path d="M200,95 L230,95 L230,150 L200,150" fill="none" stroke="currentColor" stroke-width="1.2"/>
&lt;line x1="230" y1="125" x2="265" y2="125" stroke="currentColor" stroke-width="1.4" marker-end="url(#fa)"/>
&lt;rect x="270" y="100" width="150" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="345" y="122" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">€/GPU-hour&lt;/text>
&lt;text x="345" y="138" text-anchor="middle" font-size="11" fill="currentColor">1.07 €/h (fixed)&lt;/text>
&lt;text x="345" y="172" text-anchor="middle" font-size="10" font-style="italic" fill="currentColor">÷ tokens produced/hour&lt;/text>
&lt;line x1="420" y1="125" x2="470" y2="125" stroke="currentColor" stroke-width="1.4" marker-end="url(#fa)"/>
&lt;rect x="475" y="100" width="245" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="597" y="122" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">€/1M tokens = 0.149 / u&lt;/text>
&lt;text x="597" y="138" text-anchor="middle" font-size="10" fill="currentColor">depends on useful utilisation u&lt;/text>
&lt;text x="380" y="200" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Same GPU, different cost per token by u:&lt;/text>
&lt;rect x="90" y="220" width="130" height="110" rx="6" fill="#22c55e" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="155" y="245" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">u = 80%&lt;/text>
&lt;text x="155" y="280" text-anchor="middle" font-size="18" font-weight="700" fill="#ffffff">0.186 €&lt;/text>
&lt;text x="155" y="300" text-anchor="middle" font-size="10" fill="#ffffff">/1M tokens&lt;/text>
&lt;text x="155" y="318" text-anchor="middle" font-size="10" fill="#ffffff">GPU well filled&lt;/text>
&lt;rect x="315" y="220" width="130" height="110" rx="6" fill="#f59e0b" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="380" y="245" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">u = 50%&lt;/text>
&lt;text x="380" y="280" text-anchor="middle" font-size="18" font-weight="700" fill="#ffffff">0.298 €&lt;/text>
&lt;text x="380" y="300" text-anchor="middle" font-size="10" fill="#ffffff">/1M tokens&lt;/text>
&lt;text x="380" y="318" text-anchor="middle" font-size="10" fill="#ffffff">1.6× the baseline&lt;/text>
&lt;rect x="540" y="220" width="130" height="110" rx="6" fill="#ef4444" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="605" y="245" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">u = 20%&lt;/text>
&lt;text x="605" y="280" text-anchor="middle" font-size="18" font-weight="700" fill="#ffffff">0.744 €&lt;/text>
&lt;text x="605" y="300" text-anchor="middle" font-size="10" fill="#ffffff">/1M tokens&lt;/text>
&lt;text x="605" y="318" text-anchor="middle" font-size="10" fill="#ffffff">4× the baseline&lt;/text>
&lt;defs>
&lt;marker id="fa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;/div>
&lt;h2 id="attributing-the-cost-litellm-virtual-keys">Attributing the cost: LiteLLM virtual keys&lt;/h2>
&lt;p>With the €/token manufactured, it remains to attribute it to whoever consumed it. The piece that already sits in front of the engines, the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">L7 inference router&lt;/a>, is also the natural place to count. &lt;strong>LiteLLM Proxy&lt;/strong> makes chargeback real with four mechanisms, all documented:&lt;/p>
&lt;p>&lt;strong>Virtual keys with a budget.&lt;/strong> Each team or user receives a &lt;em>virtual key&lt;/em> with a &lt;code>max_budget&lt;/code> and a &lt;code>budget_duration&lt;/code>. You can define &lt;strong>several independent budget windows&lt;/strong> (for example one of 24 h and another of 30 d) that reset on their own cycle (&lt;a href="https://docs.litellm.ai/docs/proxy/virtual_keys">Virtual Keys&lt;/a>). When a team exhausts its monthly token budget, the proxy rejects with a budget exceeded error instead of carrying on spending GPU-hours nobody will be able to charge anywhere.&lt;/p>
&lt;p>&lt;strong>RPM/TPM rate limit.&lt;/strong> Each key carries requests per minute (RPM) and tokens per minute (TPM) limits (&lt;a href="https://docs.litellm.ai/docs/proxy/users">Budgets, Rate Limits&lt;/a>). This is not just anti-abuse protection: it is the tool that stops a noisy tenant monopolising the cluster&amp;rsquo;s utilisation at everybody else&amp;rsquo;s expense. The rate limit is the capacity quota; the budget is the spend quota. They are orthogonal and you use both.&lt;/p>
&lt;p>&lt;strong>Automatic spend tracking.&lt;/strong> LiteLLM accounts for the spend of all known models by key, user and team, recording the API key, the user, the &lt;code>team_id&lt;/code>, the request&amp;rsquo;s &lt;code>tags&lt;/code>, the end user, the model group and the token counts (&lt;a href="https://docs.litellm.ai/docs/proxy/cost_tracking">Spend Tracking&lt;/a>). For the cluster&amp;rsquo;s self-hosted models, the cost per token is configured &lt;strong>with the €/token we manufactured above&lt;/strong>, and that is the hook between the cost model and the real accounting.&lt;/p>
&lt;p>&lt;strong>Tags for cost centres.&lt;/strong> The &lt;code>tags&lt;/code> let you track spend and set budgets per label, categorising costs by project, department or cost centre (&lt;a href="https://docs.litellm.ai/docs/proxy/tag_budgets">Setting Tag Budgets&lt;/a>). A tag is attached when the key is created and &lt;strong>every request made with that key inherits the tag automatically&lt;/strong>; the proxy applies the label&amp;rsquo;s budget. That way chargeback maps cleanly onto the organisation&amp;rsquo;s accounting hierarchy without every team having to remember to tag by hand.&lt;/p>
&lt;p>A declarative catalogue fragment, which fits into the &lt;code>litellm-config&lt;/code> of the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">router post&lt;/a>, materialising the manufactured cost and two tenants:&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">litellm_settings&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="c"># €/token manufactured in this post for the self-hosted model.&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="c"># input/output in €/token (not per 1M); 0.186 €/1M = 1.86e-7 €/token at u=80%.&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">model_cost_map&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">llama-70b-onprem&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">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.000000186&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">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.000000186&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Virtual keys per team (via the /key/generate API or config):&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="c"># equipo-datos: max_budget=500 €/month, tpm_limit=200000, tags=[&amp;#34;cc-datos&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="c"># equipo-soporte: max_budget=150 €/month, tpm_limit=60000, tags=[&amp;#34;cc-soporte&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="c"># The proxy counts tokens, multiplies by model_cost_map, deducts from the budget,&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="c"># and attributes the spend to the tag → showback per cost centre, without touching the engine.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The complete attribution flow, from the request to the per-team report:&lt;/p>
&lt;div class="diagram" style="max-width:800px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 800 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="cost attribution flow with virtual key and tags">
&lt;text x="400" y="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Attribution flow: from the request to per-team showback&lt;/text>
&lt;rect x="20" y="60" width="140" height="70" rx="6" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="90" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Team request&lt;/text>
&lt;text x="90" y="106" text-anchor="middle" font-size="10" fill="currentColor">Authorization:&lt;/text>
&lt;text x="90" y="120" text-anchor="middle" font-size="10" fill="#3b82f6">Bearer sk-equipo-datos&lt;/text>
&lt;line x1="160" y1="95" x2="195" y2="95" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;rect x="200" y="50" width="170" height="90" rx="6" fill="none" stroke="#8b5cf6" stroke-width="1.8"/>
&lt;text x="285" y="74" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">LiteLLM Proxy&lt;/text>
&lt;text x="285" y="92" text-anchor="middle" font-size="9.5" fill="currentColor">1· validate key + budget&lt;/text>
&lt;text x="285" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">2· apply RPM/TPM&lt;/text>
&lt;text x="285" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">3· inherit tag cc-datos&lt;/text>
&lt;text x="285" y="134" text-anchor="middle" font-size="9.5" fill="currentColor">4· count tokens&lt;/text>
&lt;line x1="370" y1="95" x2="405" y2="95" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;rect x="410" y="60" width="150" height="70" rx="6" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="485" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">vLLM engine&lt;/text>
&lt;text x="485" y="106" text-anchor="middle" font-size="10" fill="currentColor">4×H100 · TP=4&lt;/text>
&lt;text x="485" y="120" text-anchor="middle" font-size="10" fill="currentColor">produces N tokens&lt;/text>
&lt;line x1="485" y1="130" x2="485" y2="165" stroke="currentColor" stroke-width="1.2" stroke-dasharray="4 2" marker-end="url(#fb)"/>
&lt;text x="555" y="152" text-anchor="middle" font-size="9.5" font-style="italic" fill="currentColor">usage.tokens back&lt;/text>
&lt;rect x="200" y="170" width="170" height="60" rx="6" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="285" y="194" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Spend tracking&lt;/text>
&lt;text x="285" y="210" text-anchor="middle" font-size="9.5" fill="currentColor">tokens × €/token (model_cost_map)&lt;/text>
&lt;text x="285" y="223" text-anchor="middle" font-size="9.5" fill="currentColor">→ deduct budget, store in DB&lt;/text>
&lt;line x1="285" y1="140" x2="285" y2="170" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;line x1="370" y1="200" x2="560" y2="200" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;rect x="565" y="170" width="215" height="60" rx="6" fill="none" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="672" y="194" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Report by tag / team&lt;/text>
&lt;text x="672" y="210" text-anchor="middle" font-size="9.5" fill="currentColor">cc-datos: 312 € · cc-soporte: 88 €&lt;/text>
&lt;text x="672" y="223" text-anchor="middle" font-size="9.5" fill="currentColor">showback (or chargeback to P&amp;amp;L)&lt;/text>
&lt;defs>
&lt;marker id="fb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;/div>
&lt;h2 id="showback-vs-chargeback-the-distinction-the-finops-foundation-insists-on">Showback vs chargeback: the distinction the FinOps Foundation insists on&lt;/h2>
&lt;p>The two words are constantly confused and the difference is one of &lt;strong>accounting formality&lt;/strong>, not technology:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Showback&lt;/strong> — giving each team visibility of what it consumed and its cost, without really billing it to their budget. The report arrives, the team sees it, but the money does not move between cost centres.&lt;/li>
&lt;li>&lt;strong>Chargeback&lt;/strong> — really transferring the cost to the team&amp;rsquo;s or product&amp;rsquo;s budget, putting it in their P&amp;amp;L. The spend stops being a central IT cost and becomes an allocated cost.&lt;/li>
&lt;/ul>
&lt;p>The FinOps Foundation is explicit on two points worth carving in stone (&lt;a href="https://www.finops.org/framework/capabilities/invoicing-chargeback/">Invoicing &amp;amp; Chargeback&lt;/a>, &lt;a href="https://www.cloudzero.com/blog/chargeback-vs-showback/">Chargeback vs Showback&lt;/a>). First: &lt;strong>showback is a requirement of any FinOps practice; chargeback depends on the organisation&amp;rsquo;s accounting policy&lt;/strong>. Not all organisations can or want to move money between departments for GPU consumption; showback you can always do. Second, and counterintuitive: neither of the two is &amp;ldquo;more mature&amp;rdquo; than the other. The narrative that chargeback is the &amp;ldquo;grown-up&amp;rdquo; version of showback is false according to the framework itself. The practical recommendation: start with showback, giving visibility, then build cost allocation aligned to the organisational hierarchy, and only then, if the accounting policy supports it, switch chargeback on.&lt;/p>
&lt;p>For the on-premise GPU cluster this means: the LiteLLM machinery (keys, tags, spend tracking) always produces the showback. Turning it into chargeback is a decision for finance, not for the platform team. The platform team guarantees that the numbers are correct and reproducible; who really pays is a governance decision.&lt;/p>
&lt;h2 id="isolation-making-the-cost-genuinely-attributable">Isolation: making the cost genuinely attributable&lt;/h2>
&lt;p>Chargeback is only honest if consumption is &lt;strong>isolable&lt;/strong>. If two tenants share a GPU without partitioning and one saturates the HBM, the other suffers degradation it did not cause but which contaminates its cost attribution. Isolation has two planes.&lt;/p>
&lt;p>&lt;strong>Hard GPU isolation: MIG.&lt;/strong> Multi-Instance GPU physically partitions an H100 into up to 7 instances with dedicated memory and SMs; in a 7-way partition each instance has ~10 GB of HBM3 and its own SMs. MIG gives the strongest isolation: tenant A in its MIG instance cannot touch tenant B&amp;rsquo;s performance, and attribution is trivial because each instance is accountable separately (see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a> for the detail of MIG vs time-slicing vs MPS). The cost: MIG instances are fixed, they are not resized hot, and if they sit empty they are fragmented and wasted capex, the coworking desk subdivided into booths nobody rents.&lt;/p>
&lt;p>&lt;strong>Logical Kubernetes isolation.&lt;/strong> On top of the cluster:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Namespaces per tenant&lt;/strong> — the boundary for RBAC, NetworkPolicy and quotas.&lt;/li>
&lt;li>&lt;strong>&lt;code>ResourceQuota&lt;/code>&lt;/strong> — limits how many &lt;code>nvidia.com/gpu&lt;/code> (or &lt;code>nvidia.com/mig-1g.10gb&lt;/code>) a namespace can request. It is the GPU capacity quota at the scheduler level: the tenant cannot claim more GPUs than its quota grants, which &lt;strong>bounds its maximum cost&lt;/strong> by construction.&lt;/li>
&lt;li>&lt;strong>&lt;code>PriorityClass&lt;/code>&lt;/strong> — defines which workloads can evict others under pressure. The overnight re-embeddings batch runs with low priority and gives way to interactive inference; that way it fills the utilisation valleys without risking the paying tenant&amp;rsquo;s SLO. It is the piece that stops &amp;ldquo;raising utilisation with batch&amp;rdquo; from cannibalising quality of service.&lt;/li>
&lt;/ul>
&lt;p>The combination that works in the generic cluster: &lt;strong>MIG to partition the GPUs between tenants that need hard isolation and clean attribution&lt;/strong>, &lt;code>ResourceQuota&lt;/code> per namespace to bound each tenant&amp;rsquo;s cost, and &lt;code>PriorityClass&lt;/code> so that filler work raises utilisation without touching the priority tenants.&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 it all down to the series&amp;rsquo; cluster: 4 nodes × 4×H100 SXM 80 GB, 16 GPUs, ≈ 17.1 €/hour of fixed cost (≈ 150,000 €/year). Three teams share it: Data (production RAG, office-hours traffic), Support (ticket assistant, daytime peaks) and Platform (evals and re-embeddings batch, no urgency).&lt;/p>
&lt;p>&lt;strong>Splitting the cost.&lt;/strong> The cluster&amp;rsquo;s fixed cost (150,000 €/year) is split through LiteLLM&amp;rsquo;s spend tracking in proportion to the tokens consumed by each team, valued at the manufactured €/token. If in one month Data consumed 1,700 M tokens, Support 480 M and Platform 320 M, at 0.186 €/1M the showback is ≈ 316 € / 89 € / 60 €. The residue, the fixed cost of the GPUs nobody used because average utilisation was, say, 45 %, is the cost of idleness, and the governance decision is whether it is split between the tenants (penalises everybody equally) or charged to Platform as &amp;ldquo;cost of unsold capacity&amp;rdquo; (gives Platform an incentive to raise utilisation). The FinOps Foundation would say: make it visible first (showback of the cost of idleness), decide the split afterwards.&lt;/p>
&lt;p>&lt;strong>Quotas per tenant.&lt;/strong> Each team has its namespace with &lt;code>ResourceQuota&lt;/code>: Data can claim up to 8 GPUs (2 replicas at TP=4), Support up to 4, Platform up to 4 but with a low &lt;code>PriorityClass&lt;/code>, giving up its GPUs when Data or Support need them at peak. In LiteLLM, each team has its virtual key with a monthly &lt;code>max_budget&lt;/code> and a &lt;code>tpm_limit&lt;/code> that reflects its capacity quota.&lt;/p>
&lt;p>&lt;strong>MIG to isolate and attribute.&lt;/strong> For the small models (embeddings, reranker, a utility Llama 8B), an H100 split 7-way with MIG gives seven separately attributable instances: three for Data&amp;rsquo;s embeddings, two for Support&amp;rsquo;s reranker, two free for filler. Each instance is an independent accounting &amp;ldquo;desk&amp;rdquo;; the per-tenant showback comes straight from the scheduler.&lt;/p>
&lt;p>&lt;strong>The governance metric is utilisation, not the €/token.&lt;/strong> Here we close the circle. The KPI the cluster owner should look at daily is not &amp;ldquo;what the token costs&amp;rdquo;, that number only falls as a consequence, but &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>, the DCGM exporter metric that indicates what fraction of the time the GPU is not idle (&lt;a href="https://docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html">DCGM exporter&lt;/a>). And with a critical nuance that already appeared in &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> measures &amp;ldquo;not idle&amp;rdquo;, it does not measure useful work. A GPU can show 100 % GPU-util with the HBM saturated and low SM occupancy, producing few tokens. That is why serious FinOps crosses three signals: &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> (is the GPU busy?), &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code> and &lt;code>DCGM_FI_PROF_GR_ENGINE_ACTIVE&lt;/code> (is it doing real work?), and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (is the HBM well used?). The economic governance metric is useful tokens per GPU-hour, and it is watched from the &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> dashboard.&lt;/p>
&lt;p>An end-to-end assembly detail, LibreChat on top of LiteLLM with RAG, which closes the flow from tenant to cost, is covered in the (forthcoming) post on the end-to-end sovereign assistant; here it is enough to know that the metering point is always the proxy, never the engine.&lt;/p>
&lt;h2 id="four-traps-of-on-premise-gpu-finops">Four traps of on-premise GPU FinOps&lt;/h2>
&lt;p>&lt;strong>Trap 1 — comparing the on-premise €/token with the commercial API&amp;rsquo;s list price.&lt;/strong> The commercial API has margins, SLA and scale the on-premise cluster does not, but on-premise has the cost of idleness the API hides from you (they fill their GPUs with thousands of customers). The honest comparison is on-premise at its real utilisation vs the API at its real negotiated price, not the fantasy of on-premise at 100 %.&lt;/p>
&lt;p>&lt;strong>Trap 2 — forgetting the cost of idleness in the showback.&lt;/strong> If you only charge teams for the tokens they consumed, the cost of the idle GPUs disappears from the report and nobody sees it. That cost exists and somebody is paying it. Making it visible is the first step to reducing it.&lt;/p>
&lt;p>&lt;strong>Trap 3 — confusing high GPU-util with efficiency.&lt;/strong> A GPU at 100 % &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> with the HBM saturated and low SM occupancy spends capex without producing proportional tokens. The goal is not &amp;ldquo;GPU at 100 %&amp;rdquo;, it is &amp;ldquo;useful tokens per euro&amp;rdquo;. Always cross util with SM occupancy and real throughput.&lt;/p>
&lt;p>&lt;strong>Trap 4 — chargeback before showback.&lt;/strong> Switching chargeback on, moving money, before the teams trust that the numbers are correct generates disputes that burn the whole FinOps programme. First visibility, then trust in the data, then, if the accounting policy allows it, the money.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal VLM on-premise with vLLM&lt;/a> — sibling piece: serving vision-language models on the same shared cluster whose cost we attribute here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a> — sibling piece: cutting the model load time makes scaling to zero viable, which is the most direct utilisation lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the sustained throughput the €/token comes from is computed there; FinOps monetises it.&lt;/li>
&lt;li>&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> — the partitioning that lets you isolate tenants and raise utilisation by combining small workloads.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — where LiteLLM lives and where every token is counted and attributed.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — switching off idle replicas is the number one lever to raise useful utilisation and lower the €/token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference with DCGM&lt;/a> — where &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> comes from, the governance metric of the shared cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — where the prices computed here get registered in LiteLLM, and why a model with no price silently produces zero spend and budgets that never run out.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a> — how the budgets computed here are enforced in the proxy, and the condition in the code by which a team key overrides its owner&amp;rsquo;s personal cap.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU Datasheet&lt;/em> (TDP 700 W, HBM3 3,35 TB/s): &lt;code>resources.nvidia.com/en-us-gpu-resources/h100-datasheet-24306&lt;/code>.&lt;/li>
&lt;li>LiteLLM — &lt;em>Virtual Keys&lt;/em> (&lt;code>docs.litellm.ai/docs/proxy/virtual_keys&lt;/code>), &lt;em>Budgets &amp;amp; Rate Limits&lt;/em> (&lt;code>/docs/proxy/users&lt;/code>), &lt;em>Spend Tracking&lt;/em> (&lt;code>/docs/proxy/cost_tracking&lt;/code>), &lt;em>Setting Tag Budgets&lt;/em> (&lt;code>/docs/proxy/tag_budgets&lt;/code>), &lt;em>Team Budgets&lt;/em> (&lt;code>/docs/proxy/team_budgets&lt;/code>).&lt;/li>
&lt;li>FinOps Foundation — &lt;em>Invoicing &amp;amp; Chargeback Capability&lt;/em> (&lt;code>finops.org/framework/capabilities/invoicing-chargeback/&lt;/code>) y &lt;em>Data Analysis and Showback&lt;/em> (&lt;code>finops.org/framework/previous-capabilities/analysis-showback/&lt;/code>).&lt;/li>
&lt;li>CloudZero — &lt;em>Chargeback vs. Showback: Cloud Cost Allocation Models Explained&lt;/em> (&lt;code>cloudzero.com/blog/chargeback-vs-showback/&lt;/code>).&lt;/li>
&lt;li>NVIDIA — &lt;em>DCGM Exporter&lt;/em> (&lt;code>docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html&lt;/code>): &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>, &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>, &lt;code>DCGM_FI_PROF_GR_ENGINE_ACTIVE&lt;/code>.&lt;/li>
&lt;/ul></description></item><item><title>The maître d' who only seats you if you all fit at one table: CPU, Memory and Topology Manager on RKE2</title><link>https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/</link><pubDate>Sat, 06 Jun 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/</guid><description>&lt;blockquote>
&lt;p>The close of the &amp;ldquo;under the engine&amp;rdquo; series. We saw the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">cable between GPUs&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host: NUMA, hugepages and CPU isolation&lt;/a> done by hand. Here is the piece that makes it &lt;strong>declarative and at scale&lt;/strong>: how the RKE2 kubelet pins each vLLM pod to the right NUMA node without a single script.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Pinning NUMA with &lt;code>numactl&lt;/code>/&lt;code>isolcpus&lt;/code>/&lt;code>taskset&lt;/code>, the subject of the previous post, &lt;strong>does not scale&lt;/strong> to a cluster where pods come and go and there are dozens of nodes. The &lt;strong>kubelet&lt;/strong> automates it with three components that work as &lt;em>Hint Providers&lt;/em> for a central coordinator, the &lt;strong>Topology Manager&lt;/strong>: the &lt;strong>CPU Manager&lt;/strong> (assigns exclusive CPUs to containers of &lt;code>Guaranteed&lt;/code> pods with whole CPUs), the &lt;strong>Memory Manager&lt;/strong> (NUMA-local memory and hugepages) and the Device Manager/GPU plugin (which knows which GPU is on which NUMA node). With the &lt;strong>&lt;code>single-numa-node&lt;/code>&lt;/strong> policy, the Topology Manager only admits the pod if its CPUs, its memory and its GPU fit in the same NUMA domain; if they do not fit, it rejects the pod, strict admission, like the maître d&amp;rsquo; who will not seat a party of eight unless there is a table for eight. On RKE2 all of this is configured with &lt;code>kubelet-arg&lt;/code> in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>. This post explains the mechanism, gives the 10 knobs and takes apart the gotchas that break pinning silently: the &lt;code>cpu_manager_state&lt;/code> file you have to delete when changing policy, the QoS that has to be exactly &lt;code>Guaranteed&lt;/code>, and the &lt;code>reserved-cpus&lt;/code> that must match the host&amp;rsquo;s &lt;code>isolcpus&lt;/code>. On a generic RKE2 cluster with 4×H100 SXM nodes.&lt;/p>
&lt;h2 id="where-you-are-the-orchestration-that-materialises-the-host">Where you are: the orchestration that materialises the host&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are at the orchestration layer: the kubelet materialises host pinning">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are at orchestration&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM pod (TP, batching)&lt;/text>
&lt;rect x="120" y="84" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · kubelet: CPU/Memory/Topology Mgr&lt;/text>
&lt;text x="280" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">declarative pinning + NUMA admission&lt;/text>
&lt;rect x="120" y="150" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="174" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Host · NUMA, hugepages, isolcpus (post 2)&lt;/text>
&lt;rect x="120" y="194" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="218" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL + NVLink (post 1)&lt;/text>
&lt;rect x="120" y="240" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="264" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, 4×H100 SXM&lt;/text>
&lt;text x="280" y="300" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">the kubelet turns declarative intent into the raw pinning below&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-maître-d-of-a-restaurant-whose-tables-cannot-be-pushed-together">The analogy: the maître d&amp;rsquo; of a restaurant whose tables cannot be pushed together&lt;/h2>
&lt;p>A restaurant has tables of different sizes and, house rule, &lt;strong>tables are never pushed together&lt;/strong>. A party of eight arrives. The maître d&amp;rsquo; looks for a single table where all eight fit. If there is one, he seats them; if only tables for four are left, &lt;strong>he does not accept them&lt;/strong>. He would rather turn the booking away than seat the group split across two separate tables, because he knows a split dinner goes badly.&lt;/p>
&lt;p>That maître d&amp;rsquo; is the &lt;strong>Topology Manager&lt;/strong> under the &lt;code>single-numa-node&lt;/code> policy. The &amp;ldquo;party&amp;rdquo; is an inference pod asking for CPUs, memory and a GPU. The &amp;ldquo;table&amp;rdquo; is a NUMA node. The maître d&amp;rsquo; asks three assistants (are there free CPUs on any node? the CPU Manager; is there free memory? the Memory Manager; is there a free GPU? the Device Manager) and only admits the pod if all three resources fit on the same node. If not, he rejects it (the pod ends up &lt;code>Failed&lt;/code> with &lt;code>TopologyAffinityError&lt;/code>), and the scheduler will try another node.&lt;/p>
&lt;p>The difference from the previous post: there you were the maître d&amp;rsquo;, seating each process by hand with &lt;code>numactl&lt;/code>. Here the maître d&amp;rsquo; is the kubelet, and it does it for &lt;strong>every pod, on every node, automatically, rejecting whatever does not fit&lt;/strong>. That is what turns artisanal pinning into a declarative property of the cluster.&lt;/p>
&lt;h2 id="the-mechanism-hint-providers-and-the-coordinator">The mechanism: Hint Providers and the coordinator&lt;/h2>
&lt;p>The Topology Manager does not assign resources; it coordinates the ones that do. The flow, when a &lt;code>Guaranteed&lt;/code> pod arrives at a node:&lt;/p>
&lt;div class="diagram" style="max-width:800px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 800 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Topology Manager coordinates CPU, Memory and Device Manager">
&lt;defs>&lt;marker id="km" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Admission of a Guaranteed pod · single-numa-node&lt;/text>
&lt;rect x="320" y="44" width="160" height="50" rx="9" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="2"/>
&lt;text x="400" y="66" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Topology Manager&lt;/text>
&lt;text x="400" y="83" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">coordinator · admits/rejects&lt;/text>
&lt;!-- hint providers -->
&lt;rect x="60" y="150" width="180" height="64" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="150" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">CPU Manager&lt;/text>
&lt;text x="150" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">static: exclusive CPUs&lt;/text>
&lt;text x="150" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: cores on node X?&lt;/text>
&lt;rect x="310" y="150" width="180" height="64" rx="8" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="400" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Memory Manager&lt;/text>
&lt;text x="400" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">Static: cpuset.mems&lt;/text>
&lt;text x="400" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: RAM/hugepages?&lt;/text>
&lt;rect x="560" y="150" width="180" height="64" rx="8" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="650" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Device Manager&lt;/text>
&lt;text x="650" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">NVIDIA GPU plugin&lt;/text>
&lt;text x="650" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: GPU on node X?&lt;/text>
&lt;path d="M360,94 L170,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;path d="M400,94 L400,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;path d="M440,94 L630,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;text x="150" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;text x="400" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;text x="650" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;path d="M170,214 L380,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;path d="M400,214 L400,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;path d="M630,214 L420,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;rect x="250" y="262" width="300" height="56" rx="9" fill="#f4f4f4" stroke="#444" stroke-width="1.6"/>
&lt;text x="400" y="284" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Do the three hints meet on 1 node?&lt;/text>
&lt;text x="335" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#1f5c34">YES → admit&lt;/text>
&lt;text x="470" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#a85454">NO → reject&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The three managers are &lt;strong>Hint Providers&lt;/strong>: each one tells the Topology Manager on which NUMA node(s) it could satisfy its part. The Topology Manager computes the intersection and, depending on the policy, decides:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>none&lt;/code>&lt;/strong> (default): no coordination; each manager does its own thing without aligning. No NUMA guarantee.&lt;/li>
&lt;li>&lt;strong>&lt;code>best-effort&lt;/code>&lt;/strong>: it tries to align on one node; if it cannot, it admits anyway (on whichever node). Better than nothing, no guarantee.&lt;/li>
&lt;li>&lt;strong>&lt;code>restricted&lt;/code>&lt;/strong>: if it fails to align, it rejects the pod. Strict, but it allows multi-node affinity if the intersection provides it.&lt;/li>
&lt;li>&lt;strong>&lt;code>single-numa-node&lt;/code>&lt;/strong>: it requires everything to fit on a single NUMA node, or it rejects. The strictest one, and the one that genuinely guarantees the locality of the previous post.&lt;/li>
&lt;/ul>
&lt;p>And two &lt;strong>preconditions&lt;/strong> without which none of this kicks in:&lt;/p>
&lt;ol>
&lt;li>The pod has to be &lt;strong>QoS &lt;code>Guaranteed&lt;/code>&lt;/strong>: &lt;code>requests == limits&lt;/code> on CPU and memory, and whole CPUs (not &lt;code>500m&lt;/code>). Only then does the CPU Manager assign exclusive CPUs.&lt;/li>
&lt;li>The &lt;strong>CPU Manager&lt;/strong> has to be on the &lt;strong>&lt;code>static&lt;/code>&lt;/strong> policy (not &lt;code>none&lt;/code>).&lt;/li>
&lt;/ol>
&lt;p>Without those two, the Topology Manager has nothing to align and pinning &lt;strong>does not happen&lt;/strong>, even with the policy in place. That is gotcha no. 1.&lt;/p>
&lt;h2 id="how-it-is-configured-on-rke2">How it is configured on RKE2&lt;/h2>
&lt;p>RKE2 passes arguments to the kubelet with the &lt;strong>&lt;code>kubelet-arg&lt;/code>&lt;/strong> key in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>. The reference configuration for GPU inference nodes:&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"># /etc/rancher/rke2/config.yaml (on every agent node with GPUs)&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">kubelet-arg&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="s2">&amp;#34;cpu-manager-policy=static&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="s2">&amp;#34;topology-manager-policy=single-numa-node&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="s2">&amp;#34;topology-manager-scope=pod&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="s2">&amp;#34;memory-manager-policy=Static&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="s2">&amp;#34;reserved-cpus=0-1,64-65&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># housekeeping; must match the host&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="s2">&amp;#34;system-reserved=memory=8Gi&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="s2">&amp;#34;kube-reserved=memory=4Gi&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="s2">&amp;#34;reserved-memory=0:memory=4Gi;1:memory=4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># required by Memory Manager Static&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">node-label&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="s2">&amp;#34;fibercli.local/numa-pinned=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After deploying it: &lt;code>systemctl restart rke2-agent&lt;/code>. &lt;strong>Critical gotcha&lt;/strong>: if the node has already run with &lt;code>cpu-manager-policy=none&lt;/code>, there is a state file &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> that pins the old policy; changing the arg without deleting that file makes the kubelet fail to start or ignore the new policy. You have to stop the agent, &lt;code>rm /var/lib/kubelet/cpu_manager_state&lt;/code>, and start again. (The same applies to &lt;code>memory_manager_state&lt;/code>.)&lt;/p>
&lt;p>And the vLLM pod, to be eligible, &lt;strong>Guaranteed with whole CPUs&lt;/strong>:&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">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">requests&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># whole, not a weird fractional &amp;#34;16000m&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="s2">&amp;#34;200Gi&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">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># TP=2 → 2 GPUs on the same NUMA node&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">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&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">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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># == requests → Guaranteed QoS&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="s2">&amp;#34;200Gi&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">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&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">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With this, on a node with the config above, the kubelet assigns 16 exclusive CPUs from the NUMA node where the 2 requested GPUs live, its local memory and the hugepages, or it rejects the pod if they do not fit together. The artisanal pinning of the previous post, now declarative.&lt;/p>
&lt;h2 id="the-10-knobs-to-reach-for">The 10 knobs to reach for&lt;/h2>
&lt;p>Ordered by dependency (the first ones are a precondition for the later ones). The canonical reference is the &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/">Kubernetes Topology Manager documentation&lt;/a> and the &lt;a href="https://docs.rke2.io/install/configuration">RKE2 configuration&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--cpu-manager-policystatic-the-foundation">Knob 1 — &lt;code>cpu-manager-policy=static&lt;/code>: the foundation&lt;/h3>
&lt;p>Without this there are no exclusive CPUs and nothing else kicks in.&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">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu-manager-policy=static&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Gotcha&lt;/strong>: changing it requires deleting &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> and restarting the kubelet, or startup fails. It is cause no. 1 of &amp;ldquo;I set the policy and it does not pin&amp;rdquo;.&lt;/p>
&lt;h3 id="knob-2--guaranteed-qos--whole-cpus-the-pods-precondition">Knob 2 — &lt;code>Guaranteed&lt;/code> QoS + whole CPUs: the pod&amp;rsquo;s precondition&lt;/h3>
&lt;p>This is not node config, it is &lt;strong>pod&lt;/strong> config, but without it knob 1 does nothing for that pod. &lt;code>requests == limits&lt;/code> on CPU and memory, and whole CPUs. A &lt;code>cpu: 500m&lt;/code> or a &lt;code>requests != limits&lt;/code> downgrades the pod to &lt;code>Burstable&lt;/code> and it loses the pinning. Plenty of people set the node policy and forget the pod&amp;rsquo;s QoS.&lt;/p>
&lt;h3 id="knob-3--topology-manager-policysingle-numa-node-strict-admission">Knob 3 — &lt;code>topology-manager-policy=single-numa-node&lt;/code>: strict admission&lt;/h3>
&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">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology-manager-policy=single-numa-node&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The strict maître d&amp;rsquo;. For GPU inference it is the right policy: it guarantees that CPU+memory+GPU share a node. &lt;code>best-effort&lt;/code> guarantees nothing (it admits misaligned); &lt;code>restricted&lt;/code> allows multi-node affinity. Start with &lt;code>single-numa-node&lt;/code> and drop to &lt;code>restricted&lt;/code> only if you have admission problems.&lt;/p>
&lt;h3 id="knob-4--topology-manager-scopepod-group-the-whole-pod">Knob 4 — &lt;code>topology-manager-scope=pod&lt;/code>: group the whole pod&lt;/h3>
&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">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology-manager-scope=pod&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>container&lt;/code> scope (the default), each container is aligned separately; with &lt;code>pod&lt;/code> scope, the whole pod goes to the same node. For a vLLM pod with sidecars (metrics, proxy), &lt;code>pod&lt;/code> scope stops the sidecar from dragging the main container onto another node. Recommended for inference.&lt;/p>
&lt;h3 id="knob-5--memory-manager-policystatic--reserved-memory-numa-local-memory">Knob 5 — &lt;code>memory-manager-policy=Static&lt;/code> + &lt;code>reserved-memory&lt;/code>: NUMA-local memory&lt;/h3>
&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">kubelet-arg&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="s2">&amp;#34;memory-manager-policy=Static&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="s2">&amp;#34;reserved-memory=0:memory=4Gi;1:memory=4Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Memory Manager Static forces &lt;code>cpuset.mems&lt;/code> so that the pod&amp;rsquo;s memory comes out of the right node (and so do the hugepages). It requires declaring &lt;code>reserved-memory&lt;/code> per node, or the kubelet does not start. It is the declarative equivalent of the previous post&amp;rsquo;s &lt;code>--membind&lt;/code>.&lt;/p>
&lt;h3 id="knob-6--reserved-cpus-the-housekeeping-cores-must-match-isolcpus">Knob 6 — &lt;code>reserved-cpus&lt;/code>: the housekeeping cores (must match &lt;code>isolcpus&lt;/code>)&lt;/h3>
&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">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;reserved-cpus=0-1,64-65&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It reserves cores for the system and the daemons; the rest are left for exclusive pods. &lt;strong>Key point of the series&lt;/strong>: these &lt;code>reserved-cpus&lt;/code> must be the same cores you left out of &lt;code>isolcpus&lt;/code> on the host (previous post). If the host isolates 2-31 but RKE2 reserves 0-3, there is a mismatch: isolated cores that the kubelet assigns to pods without them actually being quiet. Coordinate the two layers.&lt;/p>
&lt;h3 id="knob-7--gpu-plugin-with-numa-topology-nvidia-gpu-operator">Knob 7 — GPU plugin with NUMA topology (NVIDIA GPU Operator)&lt;/h3>
&lt;p>The Device Manager can only give a correct NUMA hint if the &lt;strong>GPU plugin exposes which node each GPU is on&lt;/strong>. The NVIDIA device plugin / GPU Operator does this, but you have to verify that the topology information arrives (in some versions it requires flags). Without a GPU hint, the Topology Manager aligns CPU and memory but not the GPU, and GPU locality is precisely the one that matters most.&lt;/p>
&lt;h3 id="knob-8--hugepages-as-a-pod-resource">Knob 8 — hugepages as a pod resource&lt;/h3>
&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">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">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the node must have them pre-reserved (post 2, knob 4)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The hugepages you reserved at host boot (previous post) are requested as a resource. The Memory Manager assigns them NUMA-local. If you request them without having reserved them on the node, the pod does not get scheduled.&lt;/p>
&lt;h3 id="knob-9--system-reserved--kube-reserved-do-not-oversubscribe">Knob 9 — &lt;code>system-reserved&lt;/code> / &lt;code>kube-reserved&lt;/code>: do not oversubscribe&lt;/h3>
&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">kubelet-arg&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="s2">&amp;#34;system-reserved=cpu=500m,memory=8Gi&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="s2">&amp;#34;kube-reserved=cpu=500m,memory=4Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It reserves resources for the system and the K8s components so the node does not run out of air under load. Badly calibrated, either the node chokes (too little reserved) or you waste capacity (too much). It must be consistent with &lt;code>reserved-cpus&lt;/code>.&lt;/p>
&lt;h3 id="knob-10--labels--taints-so-vllm-lands-here-and-nothing-else-does">Knob 10 — Labels + taints: so vLLM lands here and nothing else does&lt;/h3>
&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"># GPU node: taint to repel anything that does not need a 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">node-taint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu=present:NoSchedule&amp;#34;&lt;/span>&lt;span class="w"> &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">node-label&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;fibercli.local/numa-pinned=true&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Keep the NUMA-pinned nodes for inference and push out whatever does not need it (databases, &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">the Langfuse backend&lt;/a>, runners). A ClickHouse stealing memory bandwidth from a carefully pinned vLLM pod throws away all the work of the previous nine knobs. Workload isolation is the closing move.&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>Where&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>cpu-manager-policy=static&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>exclusive CPUs (foundation)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>Guaranteed&lt;/code> QoS + whole CPUs&lt;/td>
&lt;td>pod spec&lt;/td>
&lt;td>pinning precondition&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>topology-manager-policy=single-numa-node&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>strict NUMA admission&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>topology-manager-scope=pod&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>group the whole pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>memory-manager-policy=Static&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>NUMA-local memory/hugepages&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>reserved-cpus&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>housekeeping (match isolcpus)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>GPU plugin with topology&lt;/td>
&lt;td>GPU Operator&lt;/td>
&lt;td>NUMA hint for the GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>&lt;code>hugepages-1Gi&lt;/code>&lt;/td>
&lt;td>pod spec&lt;/td>
&lt;td>hugepages as a resource&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>&lt;code>system/kube-reserved&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>do not oversubscribe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>taints + labels&lt;/td>
&lt;td>node config&lt;/td>
&lt;td>isolate GPU workloads&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="verifying-that-the-pinning-really-happened">Verifying that the pinning really happened&lt;/h2>
&lt;p>Do not trust that the config &amp;ldquo;is in place&amp;rdquo;. Check:&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"># Is the active policy the one you set?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat /var/lib/kubelet/cpu_manager_state &lt;span class="p">|&lt;/span> jq .policyName &lt;span class="c1"># &amp;#34;static&amp;#34;&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"># Which exclusive CPUs does the container have?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> &amp;lt;pod&amp;gt; -- cat /sys/fs/cgroup/cpuset.cpus.effective
&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"># Inside the pod: is the assigned GPU local to those cores?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> &amp;lt;pod&amp;gt; -- nvidia-smi topo -m
&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"># Were there topology rejections?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl describe pod &amp;lt;pod&amp;gt; &lt;span class="p">|&lt;/span> grep -i TopologyAffinityError
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A pod in &lt;code>Failed&lt;/code> with &lt;code>TopologyAffinityError&lt;/code> is not a bug: it is the maître d&amp;rsquo; &lt;strong>doing his job&lt;/strong>. That node did not have a table where CPU+memory+GPU fitted together. The answer is to review the sizing of the pod or the node, not to relax the policy casually.&lt;/p>
&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 the host (previous post).&lt;/strong> This post is the &lt;strong>declarative automation&lt;/strong> of that one. &lt;code>cpu-manager-policy=static&lt;/code> materialises the &lt;code>taskset&lt;/code>; &lt;code>memory-manager-policy=Static&lt;/code> materialises the &lt;code>--membind&lt;/code>; &lt;code>reserved-cpus&lt;/code> must match &lt;code>isolcpus&lt;/code>. The two layers are one single decision seen from two places: the host executes it, the kubelet declares it. Uncoordinating them (isolcpus 2-31 vs reserved-cpus 0-3) breaks both.&lt;/p>
&lt;p>&lt;strong>With the interconnect (post 1).&lt;/strong> The Topology Manager pins the right GPU to the pod, but if you ask for 2 GPUs for TP=2, you will want those two to share NVLink. The NUMA policy guarantees they are on the same socket; that they are NVLink-connected is guaranteed by the baseboard hardware (post 1, knob 1). The two things together are what makes &lt;code>TP=2&lt;/code> perform.&lt;/p>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> When &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">KEDA scales vLLM pods&lt;/a>, each new replica goes through Topology Manager admission. If the node has no free &amp;ldquo;table&amp;rdquo;, the pod stays pending. Pod autoscaling and node autoscaling (cluster-autoscaler) have to account for NUMA granularity, not just aggregate CPU/memory.&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> changes: it is not &amp;ldquo;128 vCPU per machine&amp;rdquo;, it is &amp;ldquo;128 minus the reserved-cpus, in blocks that fit per NUMA node&amp;rdquo;. A machine with 2 sockets × 64 cores does not serve a pod asking for 80 cores under single-numa-node: they do not fit at one table. Planning has to reason per NUMA node, not per machine.&lt;/p>
&lt;p>&lt;strong>With service coexistence.&lt;/strong> The knob 10 taint is what keeps &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse&lt;/a>, databases and runners &lt;strong>off&lt;/strong> the inference nodes. Without that boundary, all the fine-grained pinning gets eaten by a noisy neighbour. Observability goes on its own nodes; inference, pinned, on its own.&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>Policy in place, QoS forgotten.&lt;/strong> The most common mistake: &lt;code>cpu-manager-policy=static&lt;/code> on the node but the pod is &lt;code>Burstable&lt;/code> (&lt;code>requests != limits&lt;/code> or fractional CPU). Pinning &lt;strong>does not happen&lt;/strong> and nobody warns you. &lt;code>Guaranteed&lt;/code> QoS with whole CPUs is a necessary condition.&lt;/p>
&lt;p>&lt;strong>Fossilised &lt;code>cpu_manager_state&lt;/code>.&lt;/strong> Changing policy without deleting &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> (and &lt;code>memory_manager_state&lt;/code>) makes the kubelet fail or ignore the change. Stop agent → delete file → start.&lt;/p>
&lt;p>&lt;strong>&lt;code>reserved-cpus&lt;/code> ≠ &lt;code>isolcpus&lt;/code>.&lt;/strong> If the host isolates some cores and RKE2 reserves others, the managers assign cores to pods that are not really quiet, or they leave isolated cores idle. The two lists have to be consistent. It is the coordination failure between the previous post and this one.&lt;/p>
&lt;p>&lt;strong>GPU plugin without NUMA topology.&lt;/strong> If the device plugin does not expose each GPU&amp;rsquo;s NUMA node, the Topology Manager aligns CPU and memory but leaves the GPU to chance, and GPU locality is the one that weighs most. Verify that the GPU Operator publishes the topology.&lt;/p>
&lt;p>&lt;strong>&lt;code>single-numa-node&lt;/code> that rejects too much.&lt;/strong> If the pods ask for more resources than fit on one node (for example, more cores than a socket has), rejection is constant. The answer is not to drop to &lt;code>best-effort&lt;/code> (which silences the problem by serving misaligned), but to &lt;strong>size the pod so it fits at one table&lt;/strong>, or to accept &lt;code>restricted&lt;/code> with full knowledge of what you are doing.&lt;/p>
&lt;p>&lt;strong>Believing &lt;code>best-effort&lt;/code> &amp;ldquo;is nearly the same&amp;rdquo;.&lt;/strong> &lt;code>best-effort&lt;/code> admits the pod even if it fails to align: it gives you a false sense of NUMA-awareness while you serve from the wrong socket. For inference with a tail SLO, &lt;code>single-numa-node&lt;/code> or &lt;code>restricted&lt;/code>; &lt;code>best-effort&lt;/code> only if the alternative is scheduling nothing at all.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The previous post pinned by hand; this one does it at scale and with a guarantee that artisanal &lt;code>numactl&lt;/code> did not give: &lt;strong>strict admission&lt;/strong>. The kubelet, via CPU Manager, Memory Manager and Topology Manager, acts as a maître d&amp;rsquo; who only seats the pod if its CPUs, its memory and its GPU fit at the same NUMA table, and who rejects what does not fit instead of serving a split dinner. Of the ten knobs, the first two, &lt;strong>&lt;code>cpu-manager-policy=static&lt;/code>&lt;/strong> and &lt;strong>&lt;code>Guaranteed&lt;/code> QoS with whole CPUs&lt;/strong>, are the precondition without which the other eight do nothing, and they are exactly the ones most often forgotten; the rest tune the policy, the memory, the hugepages and the coexistence. The thread that closes the series: the inference performance that looked like an engine problem (vLLM is slow) or a model problem (quantisation) is, far too often, a cable problem (NVLink not used), a host problem (remote NUMA, jitter) or an orchestration problem (pinning that never happened because the QoS was wrong). Going down a level is not infrastructure snobbery: it is where the root causes live that no application-layer dashboard is ever going to point out for you.&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/sharing-one-gpu-time-slicing-mps-mig/">Sharing one GPU between several workloads: time-slicing, MPS and MIG&lt;/a> — splitting the GPU (time-slicing/MPS/MIG) is the accelerator&amp;rsquo;s equivalent of what these managers do for CPU and memory.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — the previous post; the raw layer (numactl, isolcpus, membind) that this one automates declaratively. &lt;code>reserved-cpus&lt;/code> here must match &lt;code>isolcpus&lt;/code> there.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a> — the first of the series; the NUMA policy pins the right GPU, but whether the GPUs of a TP group share NVLink is decided by the baseboard hardware.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the whole building; orchestration is the control plane layer that holds inference up.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — every replica KEDA creates goes through Topology Manager admission; autoscaling has to account for NUMA granularity.&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> — why sizing starts being reasoned per NUMA node, not per machine: a pod does not fit if it asks for more than one table.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the kind of workload that the knob 10 taints keep &lt;strong>off&lt;/strong> the pinned inference nodes.&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 with DCGM&lt;/a> — how to confirm, with metrics in hand, that the pinning translates into a saturated GPU with no bubbles.&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 the maître d&amp;rsquo; never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — the coda: the Topology Manager pins CPU+memory+GPU but &lt;strong>not the NIC&lt;/strong>; that fourth leg (network locality, IRQ affinity, DRA/DRANET) is the one left out of the census here.&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 NVMe also hangs off a PCIe root under a socket; its NUMA locality is the fifth list to align, and cold start is the real ceiling on the elasticity this pinning holds up.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kubernetes, &lt;em>Control Topology Management Policies on a node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/">https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Control CPU Management Policies on the Node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/">https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Control Memory Management Policies on a Node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/">https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Configuration Options&lt;/em> (kubelet-arg in config.yaml): &lt;a href="https://docs.rke2.io/install/configuration">https://docs.rke2.io/install/configuration&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Advanced Options and Configuration&lt;/em>: &lt;a href="https://docs.rke2.io/advanced">https://docs.rke2.io/advanced&lt;/a>.&lt;/li>
&lt;li>rancher/rke2, discussion #3034 &lt;em>CPU Management Policies for RKE2&lt;/em> (the cpu_manager_state gotcha): &lt;a href="https://github.com/rancher/rke2/discussions/3034">https://github.com/rancher/rke2/discussions/3034&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><item><title>GPU observability for LLM inference: the twelve DCGM and vLLM metrics that dictate the health of your production</title><link>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</link><pubDate>Mon, 01 Jun 2026 15:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> (the tracing layer above the metrics), &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> (what was sized and what must be watched) and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (the mechanism that explains several of the engine metrics).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Observability for an LLM inference cluster is built on &lt;strong>two complementary sources&lt;/strong>: the GPU hardware metrics exposed by the &lt;strong>DCGM (Data Center GPU Manager) Exporter&lt;/strong>, part of the NVIDIA GPU Operator, and the metrics from the &lt;strong>inference engine&lt;/strong> (vLLM, SGLang, TensorRT-LLM) exposed on a Prometheus-compatible &lt;code>/metrics&lt;/code>. Neither is enough on its own. The classic &lt;code>nvidia-smi&lt;/code> metric called &lt;em>GPU utilization&lt;/em> is misleading for LLMs: it reads high whenever &lt;strong>any kernel&lt;/strong> is running, without distinguishing tensor cores burning from SMs waiting on HBM. The full cockpit has &lt;strong>twelve DCGM metrics in four families&lt;/strong> (compute: &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>, &lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>, &lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>; memory: &lt;code>DCGM_FI_DEV_FB_USED&lt;/code>, &lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>, &lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>; thermal-power: &lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>, &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>, &lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>; health: &lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>, &lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>, &lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>) and &lt;strong>five vLLM engine metrics&lt;/strong> (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:time_per_output_token_seconds&lt;/code>). Each one has a defensible green/amber/red threshold, a PromQL query for alerting, and at least one common false reading that confuses the junior operator. The &lt;strong>six critical alerts&lt;/strong> that any production cluster must fire are: HBM &amp;gt; 92 %, thermal or power throttle, XID error, ECC double-bit, KV cache pool &amp;gt; 95 %, and TTFT P95 outside SLO for 5 minutes. The point of having this panel: that the operator on shift diagnoses the origin of a degradation in &lt;strong>under five minutes&lt;/strong>, without opening an SSH console to the GPUs. When that holds, the cluster has moved to professional operation; until then, it is operated on intuition.&lt;/p>
&lt;h2 id="you-are-here-observe-the-other-half-of-tracing">You are here: OBSERVE (the other half of tracing)&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: Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;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(#obm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#obm)}&lt;/style>
&lt;defs>&lt;marker id="obm" 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: OBSERVE · metrics (DCGM + engine) complement tracing&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 idle"/>&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 active"/>&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;p>Tracing, already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>, answers &lt;em>what happened in this specific request&lt;/em>. Metrics answer &lt;em>what is happening across the cluster in aggregate&lt;/em>. They are complementary: an alert on the metrics side tells you &amp;ldquo;the cluster is degrading&amp;rdquo;, tracing tells you &amp;ldquo;and here is the specific trace that proves it&amp;rdquo;. A cluster without tracing but with metrics operates; a cluster without metrics but with tracing &lt;strong>does not operate, it debugs&lt;/strong>.&lt;/p>
&lt;h2 id="the-analogy-the-cockpit-of-a-modern-aircraft">The analogy: the cockpit of a modern aircraft&lt;/h2>
&lt;p>In a modern commercial aircraft, the pilot&amp;rsquo;s instrument panel has more than 70 active indicators. If there were only one, the altimeter, say, the aircraft would fly into the ground at the first moment of low visibility. You need the altimeter &lt;strong>and&lt;/strong> the attitude indicator, &lt;strong>and&lt;/strong> the airspeed indicator, &lt;strong>and&lt;/strong> the turn indicator, &lt;strong>and&lt;/strong> the fuel gauge, &lt;strong>and&lt;/strong> the oil pressure gauges for each engine, &lt;strong>and&lt;/strong> the turbine exit temperatures. Each answers a different question. And together they cover the operational question: &lt;em>is the aircraft healthy, is it where it should be, and is it going where we want?&lt;/em>&lt;/p>
&lt;p>Observability for an LLM inference cluster works the same way. A single metric, &amp;ldquo;GPU utilization 99 %&amp;rdquo;, answers nothing useful. It is like looking only at the car&amp;rsquo;s odometer to diagnose why the engine is making a noise. The full cockpit is &lt;strong>twelve instruments on the hardware side plus five on the inference engine side&lt;/strong>, organised into families that answer different questions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compute and efficiency&lt;/strong>: &lt;em>are the tensor cores doing the work we expect, or are they waiting?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Memory&lt;/strong>: &lt;em>is there VRAM left for new requests, or are we on the edge of OOM?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Thermal and power&lt;/strong>: &lt;em>is the hardware healthy, or is it silently limiting throughput?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Health and errors&lt;/strong>: &lt;em>is there hardware degradation under way (ECC, XID, NVLink)?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Inference engine&lt;/strong>: &lt;em>is the queue growing, is the KV pool saturated, is the SLO being met?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>The first four answer &amp;ldquo;is the GPU fine?&amp;rdquo;. The fifth answers &amp;ldquo;is it delivering the service we promised?&amp;rdquo;. The two questions are different and both must have an answer at a glance.&lt;/p>
&lt;h2 id="why-nvidia-smi-gpu-util-misleads-with-llms">Why &lt;code>nvidia-smi&lt;/code> &lt;code>GPU-Util&lt;/code> misleads with LLMs&lt;/h2>
&lt;p>The classic metric that appears in &lt;code>nvidia-smi&lt;/code> as &lt;code>GPU-Util&lt;/code> corresponds to &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>. Its official definition: &amp;ldquo;percentage of time during which one or more kernels were executing on the GPU&amp;rdquo;. The problem with LLMs: the decode phase is &lt;strong>memory-bound&lt;/strong>, not compute-bound. When the inference engine decodes token by token, the GPU spends 90 % of the time waiting for the HBM to finish delivering the model weights and the KV cache. There is a kernel running (the HBM read), so &lt;code>GPU-Util&lt;/code> reports values close to 100 %. But the tensor cores are idle: the bottleneck is memory, not compute.&lt;/p>
&lt;p>Practical result: the operator sees &amp;ldquo;GPU-Util 99 %&amp;rdquo; in Grafana and assumes &amp;ldquo;GPU saturated, no more load can go in&amp;rdquo;. But the reality may be &amp;ldquo;compute at 25 %, HBM saturated at 95 %&amp;rdquo;, which changes the operational decisions (quantisation, batch size, parallelism). The classic metric lies by oversimplifying.&lt;/p>
&lt;p>The right move is to look at the &lt;strong>three DCGM profiling metrics&lt;/strong> of the &lt;code>_FI_PROF_*&lt;/code> subsystem:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>: ratio of active warps over the maximum per SM. &lt;em>Is there parallel work?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>: % of cycles with tensor cores effectively active. &lt;em>Is compute working?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>: % of cycles with HBM transferring. &lt;em>Is memory saturated?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>A typical decode-bound GPU running Llama 70B on an H100 shows: SM occupancy 35–55 %, tensor active 15–30 %, DRAM active 80–95 %. That is the real &amp;ldquo;saturated GPU&amp;rdquo; for LLMs. The three together tell the regimes apart; none of them alone says anything actionable.&lt;/p>
&lt;h2 id="how-they-are-wired-up-in-production">How they are wired up in production&lt;/h2>
&lt;p>The platform side is covered in &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> (level 4, the GPU plane) and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases&lt;/a> (phase F5). For the observer, the key pieces are:&lt;/p>
&lt;p>&lt;strong>NVIDIA GPU Operator.&lt;/strong> Helm manifests that deploy on each GPU node: drivers, container toolkit, MIG manager and &lt;strong>DCGM Exporter&lt;/strong>. The last one exposes &lt;code>/metrics&lt;/code> in Prometheus format with all the &lt;code>DCGM_FI_*&lt;/code> listed above. It is scraped from the cluster&amp;rsquo;s internal Prometheus.&lt;/p>
&lt;p>&lt;strong>Inference engine.&lt;/strong> vLLM exposes &lt;code>/metrics&lt;/code> on port 8000 (default) with &lt;code>vllm:*&lt;/code> metrics. SGLang exposes it too, with the &lt;code>sglang:&lt;/code> prefix. TensorRT-LLM exposes it through Triton Inference Server with the &lt;code>nv_inference:&lt;/code> prefix. The basic naming convention is similar across the three engines; the thresholds and queries in this post assume vLLM, but they translate.&lt;/p>
&lt;p>&lt;strong>ServiceMonitor / PodMonitor.&lt;/strong> The Prometheus operator resource that says what to scrape. Minimal example:&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">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>&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-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>&lt;/span>&lt;span class="line">&lt;span class="cl">&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 }&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">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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Dashboards.&lt;/strong> The NVIDIA operator publishes reference Grafana dashboards for DCGM in &lt;code>nvidia/dcgm-exporter&lt;/code> (the official repo). vLLM publishes one in &lt;code>vllm-project/vllm&lt;/code> (the &lt;code>examples/&lt;/code> folder). Both work as a base; each team adds the panels specific to its own SLO.&lt;/p>
&lt;h2 id="the-twelve-dcgm-metrics-organised-by-family">The twelve DCGM metrics organised by family&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="twelve DCGM metrics in four families">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.fc{fill:#dfe9f5;stroke:#356}.fm{fill:#eef0d0;stroke:#7a3}.ft{fill:#f4e3cf;stroke:#a63}.fs{fill:#f6e2e2;stroke:#a33}.title{font:600 13px sans-serif;fill:#222}.fam{font:700 11px sans-serif;fill:#222}.met{font:10px monospace;fill:#222}.note{font:italic 10px sans-serif;fill:#555}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">DCGM cockpit: 12 metrics in 4 families&lt;/text>
&lt;rect x="20" y="40" width="195" height="290" class="b fc"/>
&lt;text x="117" y="60" text-anchor="middle" class="fam">COMPUTE&lt;/text>
&lt;text x="30" y="90" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="105" class="met">SM_OCCUPANCY&lt;/text>
&lt;text x="30" y="135" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="150" class="met">PIPE_TENSOR_ACTIVE&lt;/text>
&lt;text x="30" y="180" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="195" class="met">DRAM_ACTIVE&lt;/text>
&lt;text x="30" y="240" text-anchor="start" class="note">Is compute working or&lt;/text>
&lt;text x="30" y="254" text-anchor="start" class="note">waiting on HBM?&lt;/text>
&lt;rect x="220" y="40" width="195" height="290" class="b fm"/>
&lt;text x="317" y="60" text-anchor="middle" class="fam">MEMORY&lt;/text>
&lt;text x="230" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="105" class="met">FB_USED&lt;/text>
&lt;text x="230" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="150" class="met">FB_FREE&lt;/text>
&lt;text x="230" y="180" class="met">DCGM_FI_DEV_NVLINK_&lt;/text>&lt;text x="230" y="195" class="met">BANDWIDTH_TOTAL&lt;/text>
&lt;text x="230" y="240" class="note">Is there VRAM left for&lt;/text>
&lt;text x="230" y="254" class="note">new requests?&lt;/text>
&lt;rect x="420" y="40" width="195" height="290" class="b ft"/>
&lt;text x="517" y="60" text-anchor="middle" class="fam">THERMAL · POWER&lt;/text>
&lt;text x="430" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="105" class="met">GPU_TEMP&lt;/text>
&lt;text x="430" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="150" class="met">POWER_USAGE&lt;/text>
&lt;text x="430" y="180" class="met">DCGM_FI_DEV_CLOCK_&lt;/text>&lt;text x="430" y="195" class="met">THROTTLE_REASONS&lt;/text>
&lt;text x="430" y="240" class="note">Healthy hardware or&lt;/text>
&lt;text x="430" y="254" class="note">silently limiting?&lt;/text>
&lt;rect x="620" y="40" width="180" height="290" class="b fs"/>
&lt;text x="710" y="60" text-anchor="middle" class="fam">HEALTH&lt;/text>
&lt;text x="630" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="105" class="met">XID_ERRORS&lt;/text>
&lt;text x="630" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="150" class="met">ECC_DBE_VOL_TOTAL&lt;/text>
&lt;text x="630" y="180" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="195" class="met">RETIRED_DBE&lt;/text>
&lt;text x="630" y="240" class="note">Is the silicon&lt;/text>
&lt;text x="630" y="254" class="note">degrading right now?&lt;/text>
&lt;text x="410" y="350" text-anchor="middle" class="note">Each family answers a different question · none suffices alone&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="family-1--compute">Family 1 — Compute&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/strong>: ratio of active warps per SM over the maximum possible. Value between 0 and 1.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 0.30–0.70 (typical LLM decode regime).&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;lt; 0.20 sustained (batch too small, GPU under-used in parallelism).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: 0.95 sustained with low DRAM_ACTIVE (pathological kernel saturating the SMs).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/strong>: % of cycles with tensor cores executing. The key metric for &amp;ldquo;is compute producing?&amp;rdquo;.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in prefill&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 15–30 % (decode is memory-bound, this is not a symptom of a problem).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;lt; 5 % sustained in prefill, or the engine is not using the tensor cores at all (bad config, incompatible format).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/strong>: % of cycles with HBM transferring data. The key metric for detecting memory saturation.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 60–85 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;gt; 90 % sustained (HBM is a firm bottleneck, which explains high TPOT).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 95 % sustained with KV cache pool &amp;lt; 70 % (something other than the engine is asking for HBM; investigate leaks).&lt;/li>
&lt;/ul>
&lt;h3 id="family-2--memory">Family 2 — Memory&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_USED&lt;/code>&lt;/strong>: Frame Buffer (HBM) used, in MiB.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 70–85 % of total.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 86–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of OOM on the next paged-attention allocation).&lt;/li>
&lt;/ul>
&lt;p>PromQL for the cluster-wide percentage: &lt;code>100 * sum(DCGM_FI_DEV_FB_USED) / sum(DCGM_FI_DEV_FB_TOTAL)&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>&lt;/strong>: free Frame Buffer. Complementary to the previous one; useful for absolute alerts (&lt;code>&amp;lt; 4096 MiB free&lt;/code>).&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/strong>: aggregate NVLink bandwidth in MB/s. For TP (tensor parallel) topologies that cross GPUs over NVLink, this metric reveals whether the parallelism split is saturating the bus.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: varies with topology. On 4×H100 SXM with NVLink 4.0, theoretical capacity is 450 GB/s per GPU. A typical TP=4 regime: 50–150 GB/s sustained.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 90 % of capacity sustained (check whether the model would fit with lower TP or with pipeline parallel).&lt;/li>
&lt;/ul>
&lt;h3 id="family-3--thermal-and-power">Family 3 — Thermal and power&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/strong>: die temperature in °C.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: &amp;lt; 75 °C.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 75–82 °C.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 83 °C (close to the H100&amp;rsquo;s automatic thermal throttle; check ventilation, air flow, rack inlet temperature).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/strong>: draw in watts. For the H100 SXM, nominal TDP is 700 W. Useful for three things: spotting an unusually low workload (suspect idle or a stall), billing real energy cost, and firing alerts if the draw approaches the PDU limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/strong>: encoded bitmap with the active throttle reasons. This is the metric that &lt;strong>silently explains&lt;/strong> TPOT degradations.&lt;/p>
&lt;p>Relevant bits:&lt;/p>
&lt;ul>
&lt;li>&lt;code>0x0000000000000001&lt;/code>: Idle (not a problem).&lt;/li>
&lt;li>&lt;code>0x0000000000000002&lt;/code>: App clocks setting.&lt;/li>
&lt;li>&lt;code>0x0000000000000004&lt;/code>: SW Power Cap (software limit, e.g. from &lt;code>nvidia-smi -pl&lt;/code>).&lt;/li>
&lt;li>&lt;code>0x0000000000000008&lt;/code>: HW Slowdown.&lt;/li>
&lt;li>&lt;code>0x0000000000000010&lt;/code>: Sync Boost (NVIDIA Sync).&lt;/li>
&lt;li>&lt;code>0x0000000000000020&lt;/code>: SW Thermal Slowdown (software thermal limit).&lt;/li>
&lt;li>&lt;code>0x0000000000000040&lt;/code>: HW Thermal Slowdown (hardware thermal limit, an emergency).&lt;/li>
&lt;li>&lt;code>0x0000000000000080&lt;/code>: HW Power Brake Slowdown (PSU voltage drop).&lt;/li>
&lt;li>&lt;code>0x0000000000000100&lt;/code>: Display Clock Setting.&lt;/li>
&lt;/ul>
&lt;p>Any throttle other than &lt;code>Idle&lt;/code> with a sustained value &amp;gt; 0 &lt;strong>is an alert&lt;/strong>. TPOT degradation with &lt;code>DRAM_ACTIVE&lt;/code> already high and thermal throttle active is the classic &amp;ldquo;the rack is badly ventilated, it is not the engine&amp;rsquo;s fault&amp;rdquo;.&lt;/p>
&lt;h3 id="family-4--health">Family 4 — Health&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>&lt;/strong>: cumulative counter of driver XID errors. XIDs are critical event codes that NVIDIA documents exhaustively (XID 13: graphics engine exception; XID 31: GPU memory page fault; XID 43: reset channel verif error; XID 79: GPU has fallen off the bus; XID 95: uncontained ECC error; and so on). &lt;strong>Any increment is an immediate alert&lt;/strong>: many XIDs require a node reset or an RMA of the GPU.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/strong>: volatile double-bit ECC errors (uncorrectable). Unlike single-bit ones (which ECC corrects silently and which are counted in &lt;code>DCGM_FI_DEV_ECC_SBE_*&lt;/code>), double-bit errors &lt;strong>corrupt data&lt;/strong>. Any value &amp;gt; 0 is a critical alert: the GPU must be drained and inspected.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/strong>: physical HBM pages retired because of accumulated double-bit errors. NVIDIA retires faulty pages automatically to prevent future corruption. More than 4–8 retired pages on one GPU suggests silicon degradation: document it and plan a replacement in the next maintenance window.&lt;/p>
&lt;h2 id="the-five-inference-engine-metrics-vllm">The five inference engine metrics (vLLM)&lt;/h2>
&lt;p>The DCGM metrics answer &amp;ldquo;is the GPU healthy?&amp;rdquo;. The engine metrics answer &amp;ldquo;is the service meeting the SLO?&amp;rdquo;. Without them, you know the hardware works but you do not know whether the clients are happy.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_running&lt;/code>&lt;/strong>: requests currently in the batch. If it reaches the configured &lt;code>--max-num-seqs&lt;/code> and does not come down, the engine is saturated on concurrency (check VRAM and rebalance through the autoscaler, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>).&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/strong>: requests queued, not yet in the batch. Any value &amp;gt; 0 sustained over minutes means the cluster is not scaling with the load. &lt;strong>This is the primary metric for HPA&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/strong>: % of the KV cache pool in use.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 80–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of &lt;strong>preempt-on-OOM&lt;/strong>: vLLM will drop requests to free memory, which raises TTFT visibly).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/strong>: histogram of TTFT per request. Consumed as &lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket[5m])))&lt;/code>. Compared against the TTFT P95 SLO, it fires the primary service alert.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:time_per_output_token_seconds&lt;/code>&lt;/strong>: histogram of TPOT. Equivalent to the previous one but for streaming smoothness. Compared against the TPOT P95 SLO, it fires the secondary alert.&lt;/p>
&lt;h2 id="the-six-alerts-that-must-page-in-production">The six alerts that must page in production&lt;/h2>
&lt;p>Any serious production cluster fires these six alerts to a channel with an on-call rotation. Without them, the SLO is met by luck, not by process.&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">groups&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">gpu-llm-critical&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">rules&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuHbmNearOom&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">100&lt;/span>&lt;span class="w"> &lt;/span>*&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL) &amp;gt; 92&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">2m&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">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;HBM on {{ $labels.gpu }} at {{ $value }}% — OOM risk&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuThermalOrPowerThrottle&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 0) and ignoring(reason) (DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1m&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">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;GPU {{ $labels.gpu }} throttling (reasons={{ $value }})&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuXidErrorDetected&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">increase(DCGM_FI_DEV_XID_ERRORS[5m]) &amp;gt; 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">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">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;XID error on GPU {{ $labels.gpu }} — investigate immediately&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuEccDoubleBit&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DCGM_FI_DEV_ECC_DBE_VOL_TOTAL &amp;gt; 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">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">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ECC double-bit on GPU {{ $labels.gpu }} — drain node&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmKvCachePoolNearFull&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm:gpu_cache_usage_perc &amp;gt; 0.95&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">3m&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">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;KV cache pool &amp;gt; 95% on {{ $labels.instance }}&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmTtftP95OutOfSlo&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">histogram_quantile(0.95, sum by(le, instance)(rate(vllm:time_to_first_token_seconds_bucket[5m]))) &amp;gt; 1.5&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m&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">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;TTFT P95 over SLO ({{ $value }}s &amp;gt; 1.5s)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These six cover 80 % of the incidents that affect the SLO. The remaining 20 % demand investigation with tracing (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>).&lt;/p>
&lt;h2 id="master-table-thresholds-and-queries">Master table: thresholds and queries&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Green&lt;/th>
&lt;th>Amber&lt;/th>
&lt;th>Red&lt;/th>
&lt;th>Base query (PromQL)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SM occupancy&lt;/td>
&lt;td>0.30–0.70&lt;/td>
&lt;td>0.15–0.30&lt;/td>
&lt;td>&amp;lt; 0.10 sustained&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tensor active (decode)&lt;/td>
&lt;td>15–30 %&lt;/td>
&lt;td>&amp;lt; 10 %&lt;/td>
&lt;td>&amp;lt; 3 %&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DRAM active&lt;/td>
&lt;td>60–85 %&lt;/td>
&lt;td>85–95 %&lt;/td>
&lt;td>&amp;gt; 95 % with low KV&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FB used&lt;/td>
&lt;td>70–85 %&lt;/td>
&lt;td>86–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>100 * DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVLink BW&lt;/td>
&lt;td>&amp;lt; 70 % cap&lt;/td>
&lt;td>70–90 % cap&lt;/td>
&lt;td>&amp;gt; 90 % cap&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU temp&lt;/td>
&lt;td>&amp;lt; 75 °C&lt;/td>
&lt;td>75–82 °C&lt;/td>
&lt;td>&amp;gt; 83 °C&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Power usage&lt;/td>
&lt;td>&amp;lt; 90 % TDP&lt;/td>
&lt;td>90–98 % TDP&lt;/td>
&lt;td>&amp;gt; 98 % TDP&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throttle reasons&lt;/td>
&lt;td>0 or Idle&lt;/td>
&lt;td>App/SW&lt;/td>
&lt;td>HW Therm/Power&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>XID errors&lt;/td>
&lt;td>no change&lt;/td>
&lt;td>—&lt;/td>
&lt;td>any delta&lt;/td>
&lt;td>&lt;code>increase(DCGM_FI_DEV_XID_ERRORS[5m])&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ECC DBE&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&amp;gt; 0&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retired pages&lt;/td>
&lt;td>&amp;lt; 4&lt;/td>
&lt;td>4–8&lt;/td>
&lt;td>&amp;gt; 8&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KV cache used&lt;/td>
&lt;td>50–80 %&lt;/td>
&lt;td>80–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Requests waiting&lt;/td>
&lt;td>0&lt;/td>
&lt;td>1–5 sustained&lt;/td>
&lt;td>&amp;gt; 10 sustained&lt;/td>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>see alert query above&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>&lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_per_output_token_seconds_bucket[5m])))&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="three-pitfalls-that-confuse-the-junior-operator">Three pitfalls that confuse the junior operator&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — &amp;ldquo;GPU-Util at 99 % = saturated&amp;rdquo;.&lt;/strong> As explained at the start, &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> lights up with any kernel. The right move is to look at the three &lt;code>_PROF_*&lt;/code> metrics (SM occupancy, tensor active, DRAM active) together. &lt;em>GPU util 99 % + tensor active 8 % + DRAM active 92 %&lt;/em> means &amp;ldquo;saturated by memory, not compute&amp;rdquo;; &lt;em>GPU util 99 % + tensor active 75 % + DRAM active 50 %&lt;/em> means &amp;ldquo;saturated by compute, prefill heavy&amp;rdquo;. The two situations call for different levers.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — confusing single-bit ECC (SBE) with double-bit (DBE).&lt;/strong> Single-bit errors are corrected silently and are &lt;strong>unavoidable&lt;/strong> in any HBM under load (cosmic radiation, voltage fluctuations). An SBE counter growing slowly is not an alert, it is physics. DBE is: it corrupts data. Telling the two metrics apart avoids false alarms and false negatives in equal measure.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — alerting on &lt;code>num_requests_waiting &amp;gt; 0&lt;/code> without context.&lt;/strong> An instantaneous value of 1 or 2 during a spike is normal. What matters is a &lt;strong>sustained&lt;/strong> queue: use &lt;code>for: 5m&lt;/code> with a threshold of 3–5. Without that window, the system floods the alert channel with noise.&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 with intra-node NVLink&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>DCGM Exporter deployed through the NVIDIA GPU Operator, one DaemonSet per GPU node.&lt;/li>
&lt;li>Internal Prometheus with 30-day retention for high-frequency metrics, 1 year for downsampled ones (Thanos/Mimir if the volume justifies it).&lt;/li>
&lt;li>Grafana with three standard dashboards: GPU hardware (DCGM), engine (vLLM), SLO (TTFT/TPOT/RPS against written targets).&lt;/li>
&lt;li>Alertmanager with an on-call rotation and rate limiting through silences grouped by node.&lt;/li>
&lt;li>Cardinality kept under control: &lt;code>gpu&lt;/code> (local id), &lt;code>node&lt;/code>, &lt;code>pod&lt;/code>, &lt;code>model&lt;/code>. Do not add &lt;code>request_id&lt;/code> or other high-cardinality labels to metrics; that is tracing&amp;rsquo;s job.&lt;/li>
&lt;/ul>
&lt;p>Estimated volume for a 16-GPU cluster scraped every 15 s: around 2 million samples/min, around 25 GB/day of raw Prometheus. Manageable with one Prometheus per cluster plus retention; if the team scales beyond 64 GPUs, consider a Thanos sidecar or VictoriaMetrics. See &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS LLMOps tooling catalogue&lt;/a> for equivalent alternatives.&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>Tracing of LLM workloads&lt;/strong>: already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Autoscaling&lt;/strong> based on these metrics: see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Incident response runbooks&lt;/strong>: how each of these alerts translates into concrete action (drain, restart, RMA, scaling, rollback).&lt;/li>
&lt;li>&lt;strong>Cost accounting&lt;/strong>: using &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> and &lt;code>vllm:request_success_total&lt;/code> for per-tenant cost showback.&lt;/li>
&lt;li>&lt;strong>Multi-tenant fairness monitoring&lt;/strong>: when several tenants share a cluster, which metrics detect that one of them is hogging the KV cache.&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/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy on the GPU cluster: who pays for what&lt;/a> — DCGM utilisation as the cluster&amp;rsquo;s FinOps governance metric.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the other half of observability.&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 was sized and, therefore, which thresholds are defensible here.&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 why &lt;code>num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> are the engine&amp;rsquo;s operational metrics.&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> — LLM-aware observability lives at level 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — DCGM Exporter is a piece of the platform layer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a> — uses these metrics as input.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">Anatomy of the twelve DCGM and five vLLM metrics&lt;/a> — a deeper dive with an analogy and a documented production anomaly for each metric, with figures from public incidents (Meta Llama 3, &lt;em>Story of Two GPUs&lt;/em>, vLLM issues, Dell/Lenovo KBs).&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> — the translation of each critical alert into concrete action (drain, reset, RMA, rollback) with workflow YAML, a WORM Kafka schema and the fit with ISO 27035, ENS, NIS2 and EU AI Act art. 73.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token travels through&lt;/a> — the NVLink counters (&lt;code>nvidia-smi nvlink -e&lt;/code>, TX/RX bytes per link, CRC errors) that these DCGM metrics expose: a slow all-reduce shows up in a cable error counter before it shows up in API latency.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — from the DCGM metrics (GPU_UTIL vs SM_ACTIVE, MFU) to the cost of idle and the cost per token.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>DCGM Exporter&lt;/em> (repo &lt;code>nvidia/dcgm-exporter&lt;/code>, documented metrics and units).&lt;/li>
&lt;li>NVIDIA — &lt;em>DCGM Field Identifiers reference&lt;/em> (complete list of &lt;code>DCGM_FI_*&lt;/code>).&lt;/li>
&lt;li>NVIDIA — &lt;em>XID Errors documentation&lt;/em> (catalogue of XID codes and remediation procedures).&lt;/li>
&lt;li>NVIDIA — &lt;em>NVIDIA GPU Operator&lt;/em> (official Helm chart).&lt;/li>
&lt;li>vLLM project — &lt;code>examples/production_monitoring/&lt;/code> (reference PromQL and Grafana dashboards).&lt;/li>
&lt;li>Prometheus — &lt;em>Histogram and summary best practices&lt;/em> (for building defensible percentile queries).&lt;/li>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU datasheet&lt;/em> (TDP, HBM bandwidth, NVLink capacities).&lt;/li>
&lt;/ul></description></item><item><title>Capacity planning for on-premise LLM inference: how to size GPUs from an SLO</title><link>https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/</link><pubDate>Mon, 01 Jun 2026 15:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/</guid><description>&lt;blockquote>
&lt;p>This post complements the ones on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> (the piece that dominates the VRAM budget), &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (what defines the effective utilisation of the compute) and &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the stack&lt;/a> (the pieces the sizing takes for granted). Before reading this one, make sure your team has written down the SLOs it is going to chase; without that input the calculation is not defensible.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM inference capacity planning does not answer &amp;ldquo;how many tokens per second does a GPU give&amp;rdquo; — that question has no universal answer, because throughput depends on concurrency, the prefill/decode split, context length, the inference engine and quantisation. The right question has three inputs (&lt;strong>SLO&lt;/strong>: TTFT P95, TPOT P95, sustained RPS), a hardware reference (GPU model, VRAM, HBM bandwidth, effective FLOPs) and a model (parameters, GQA/MHA/MoE architecture, weight format). The calculation resolves into two coupled budgets that cross each other. &lt;strong>VRAM budget&lt;/strong>: from the GPU&amp;rsquo;s total you subtract model weights and activations, what remains is the &lt;strong>KV cache budget&lt;/strong>, and from there you derive the &lt;strong>maximum concurrency&lt;/strong> possible at the average context you expect. &lt;strong>Time budget&lt;/strong>: the engine (vLLM, SGLang, TensorRT-LLM) has a tokens/s ceiling in decode given by HBM bandwidth and another in prefill given by useful FLOPs; from there you derive the &lt;strong>expected TPOT&lt;/strong> and, dividing prefill_tokens by prefill throughput, the &lt;strong>expected TTFT&lt;/strong>. Both budgets must meet the SLO &lt;strong>simultaneously&lt;/strong>: whichever is tighter dictates the sizing. On the Llama 70B BF16 example with tensor parallel 4 on 4×H100 SXM, a single replica saturates at ~28 concurrent requests and delivers ~3,200 tokens/s of aggregate decode with a median TPOT of 35 ms; for 200 sustained RPS at a profile of 800 prompt tokens + 250 of output, you need between 4 and 5 replicas with a 25 % cushion over the observed peak. Quantisation (FP8 → INT4) divides VRAM cost and decode time by between 1.5 and 4×, but it degrades quality measurably — it is not assumed free, it is validated with evals. The five habitual traps: confusing the mean with P95, ignoring the prefill/decode split of the real workload, sizing without head-room for retraining or rollback, forgetting that a GPU at 100 % SM util means nothing if the HBM is saturated, and not documenting the assumptions behind the calculation (a sizing with no written assumptions is a throwaway calculation).&lt;/p>
&lt;h2 id="you-are-here-deploy-with-one-foot-in-observe">You are here: DEPLOY (with one foot in OBSERVE)&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 with one foot in Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ffb347;stroke-width:3}.semiactive{fill:#ffe1b3;stroke-width:2}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#cpm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#cpm)}&lt;/style>
&lt;defs>&lt;marker id="cpm" 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: capacity planning · closes DEPLOY and opens the conversation with OBSERVE&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 semiactive"/>&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;p>Capacity planning is a piece with dual residency. It lives in &lt;strong>DEPLOY&lt;/strong> because without a valid sizing you do not buy hardware or configure the inference engine. But its &lt;strong>input is real observations&lt;/strong>: the distribution of prompt and output lengths, the prefill/decode mix of the workload, the real P95s already being seen in pre-production. Without that data the calculation is a napkin sketch, defensible only until the first customer arrives who does not fit the assumed average.&lt;/p>
&lt;h2 id="the-analogy-the-hotel-with-variable-sized-rooms">The analogy: the hotel with variable-sized rooms&lt;/h2>
&lt;p>Imagine a hotel where the rooms have no fixed size: each guest pays for the square metres they need, and the building&amp;rsquo;s floor plan reorganises itself dynamically to accommodate whoever arrives. Management wants to maximise occupancy, but it has two real constraints and one quality metric.&lt;/p>
&lt;p>&lt;strong>Constraint 1 — physical space.&lt;/strong> The floor has 1,000 m² in total. If a family comes in needing 200 m², that family occupies that surface and it cannot be handed to the next guest. The largest room limits how many simultaneous guests fit.&lt;/p>
&lt;p>&lt;strong>Constraint 2 — service staff.&lt;/strong> There are 10 receptionists. Each can handle the check-in of one guest every two minutes. When 60 guests arrive within an hour, the last ones wait in a queue; the time from walking into reception to receiving their key depends on how many are ahead of them.&lt;/p>
&lt;p>&lt;strong>Quality metric — a promise about time.&lt;/strong> The brochure says &amp;ldquo;check-in in under 15 minutes&amp;rdquo;. If too many guests arrive at once, that promise breaks even if there is physical space free.&lt;/p>
&lt;p>The physical space is the GPU&amp;rsquo;s VRAM. Each room is a request with its KV cache (larger the longer the conversation). The receptionists are the compute units (Streaming Multiprocessors + Tensor Cores). The check-in is the prefill phase; the nights the guest spends afterwards are the decode steps. The 15-minute promise is the TTFT P95 SLO.&lt;/p>
&lt;p>The hotel&amp;rsquo;s capacity planning is exactly this: given the expected guest profile (how many arrive per hour, how much space they ask for on average, how many minutes of waiting they tolerate), work out how many floors and how many receptionists are needed. It is not done by estimating &amp;ldquo;rooms per hour&amp;rdquo; in the abstract; it is done by crossing the two budgets with the promise about time. The analogy holds up the calculation all the way to the end.&lt;/p>
&lt;h2 id="the-three-slo-inputs">The three SLO inputs&lt;/h2>
&lt;p>Before putting a single number on the sheet, you have to write down the three dimensions of the SLO. Without this, the calculation is aesthetics, not engineering.&lt;/p>
&lt;p>&lt;strong>TTFT P95 (Time-To-First-Token).&lt;/strong> The time from the client sending the request until it receives the first token. It is dominated by the prefill phase (processing the whole prompt in one go) plus the scheduler queue. For conversational chat, a reasonable target sits between 0.5 and 2 seconds P95. For coding assistants with large prompts (5–10 K tokens of context), between 2 and 4 s P95. Below 500 ms you enter UX territory for voice-style conversations, but that demands serious architectural compromises.&lt;/p>
&lt;p>&lt;strong>TPOT P95 (Time-Per-Output-Token).&lt;/strong> The time between consecutive tokens during decode. It dominates the &amp;ldquo;perceived fluency&amp;rdquo; of streaming. Above 80 ms/token the human reader perceives pauses; below 30 ms/token the output flows faster than it can be read. The usual industry target: 40–60 ms P95.&lt;/p>
&lt;p>&lt;strong>Sustained RPS meeting the SLO.&lt;/strong> The throughput the system must support &lt;strong>without violating&lt;/strong> TTFT or TPOT. This is DistServe&amp;rsquo;s key metric, called &lt;strong>goodput&lt;/strong> (see &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a>). &amp;ldquo;200 RPS peak&amp;rdquo; is not the same as &amp;ldquo;200 RPS with TTFT P95 ≤ 1.5 s&amp;rdquo;. Without the SLO condition, the RPS number means nothing.&lt;/p>
&lt;p>These three dimensions come with a &lt;strong>workload profile&lt;/strong>: the distribution of prompt and output lengths. Medians are not enough; you need P50, P95, P99. A badly measured profile is the main reason sizings fail.&lt;/p>
&lt;h2 id="the-central-formula-two-budgets-that-cross">The central formula: two budgets that cross&lt;/h2>
&lt;p>The calculation resolves into two independent sums that are then crossed. The smaller of the two rules.&lt;/p>
&lt;h3 id="vram-budget">VRAM budget&lt;/h3>
&lt;p>For a GPU with total VRAM $V$, the space available for KV cache is:&lt;/p>
$$V_{\text{kv}} = V - V_{\text{model}} - V_{\text{activations}} - V_{\text{overhead}}$$
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>$V_{\text{model}}$ is the size of the weights: for a model of $P$ parameters in a format of $b$ bytes/parameter, $V_{\text{model}} = P \cdot b$. Llama 70B BF16 = $70 \times 10^9 \times 2 = 140$ GB. Under tensor parallel TP=4, each GPU carries $140 / 4 = 35$ GB.&lt;/li>
&lt;li>$V_{\text{activations}}$ are the intermediate buffers of the forward pass. For vLLM with a reasonable batch, between 2 and 6 GB per GPU depending on batch size and maximum length.&lt;/li>
&lt;li>$V_{\text{overhead}}$ is the CUDA context, NCCL buffers, the PagedAttention pool, reserved paged blocks. 2–4 GB typical.&lt;/li>
&lt;/ul>
&lt;p>The KV cache budget per GPU is what is left over. For an H100 SXM 80 GB with Llama 70B TP=4 BF16:&lt;/p>
$$V_{\text{kv}} = 80 - 35 - 4 - 3 = 38 \text{ GB per GPU} = 152 \text{ GB aggregated over TP=4}$$
&lt;p>The KV cache cost per token for a model with $L$ layers, $H_{\text{kv}}$ KV heads (GQA), per-head dimension $d_h$, in a format of $b$ bytes:&lt;/p>
$$\text{kv per token} = 2 \cdot L \cdot H_{\text{kv}} \cdot d_h \cdot b$$
&lt;p>The factor of 2 is because both K and V are stored. For Llama 70B (L=80, $H_{\text{kv}}$=8 with GQA, $d_h$=128, BF16 = 2 bytes):&lt;/p>
$$\text{kv per token} = 2 \cdot 80 \cdot 8 \cdot 128 \cdot 2 = 327,680 \text{ bytes} = 320 \text{ KB/token}$$
&lt;p>And the maximum concurrency at average context $C$:&lt;/p>
$$N_{\text{max}} = \frac{V_{\text{kv}}}{C \cdot \text{kv per token}}$$
&lt;p>With an aggregated $V_{\text{kv}}$ of 152 GB and an average context of 1,500 tokens (800 prompt + 700 generated at the worst moment of the conversation):&lt;/p>
$$N_{\text{max}} = \frac{152 \times 10^9}{1,500 \cdot 320 \times 10^3} \approx 316 \text{ concurrent requests}$$
&lt;p>This is the &lt;strong>physical ceiling&lt;/strong> of concurrency for that replica. It is not what you are going to use; it is what you &lt;strong>cannot exceed&lt;/strong> without OOM. The operational number sits well below it (head-room for spikes).&lt;/p>
&lt;h3 id="time-budget">Time budget&lt;/h3>
&lt;p>Two sub-calculations come in here: decode (memory-bound) and prefill (compute-bound).&lt;/p>
&lt;p>&lt;strong>Decode TPOT.&lt;/strong> For every token generated, the model weights (the ones relevant to that request) have to be walked through and the accumulated KV cache has to be read. The bottleneck is HBM bandwidth. For a GPU with bandwidth $B$ GB/s and a model of $V_{\text{model per gpu}}$ GB of weights:&lt;/p>
$$\text{tpot}_{\text{theoretical}} \approx \frac{V_{\text{model per gpu}}}{B}$$
&lt;p>For an H100 SXM with HBM3 at 3.35 TB/s and Llama 70B TP=4 BF16 (35 GB/GPU):&lt;/p>
$$\text{tpot}_{\text{theoretical}} \approx \frac{35}{3,350} \approx 10.4 \text{ ms/token}$$
&lt;p>This is the &lt;strong>best theoretical case&lt;/strong> with batch=1 and 100 % HBM efficiency. In practice vLLM on H100 with Llama 70B TP=4 reaches 12–18 ms/token at low batch and 30–45 ms/token at high batch (at concurrency 32, tokens compete for the shared HBM). The defensible operational number: &lt;strong>35 ms/token&lt;/strong> at concurrency 24–32.&lt;/p>
&lt;p>&lt;strong>Prefill throughput.&lt;/strong> Prefill processes N prompt tokens in a single forward pass. It is compute-bound: the bottleneck is FLOPs. For an H100 SXM with 989 sustained BF16 TFLOPs and Llama 70B (each forward pass costs roughly $2 \cdot P \cdot N$ FLOPs per sequence of length N):&lt;/p>
$$\text{prefill tps} = \frac{4 \cdot \text{TFLOPs} \cdot \eta}{2 \cdot P} = \frac{4 \cdot 989 \times 10^{12} \cdot 0.5}{2 \cdot 70 \times 10^9} \approx 14,000 \text{ tokens/s}$$
&lt;p>(the factor of 4 is the GPUs in TP, $\eta$ is real efficiency between 0.4 and 0.6 on H100). An 800-token prompt takes this long in prefill:&lt;/p>
$$\text{prefill time} = \frac{800}{14,000} \approx 57 \text{ ms}$$
&lt;p>Adding a typical queue of 100–300 ms at high concurrency, &lt;strong>TTFT P95 ≈ 350–500 ms&lt;/strong> for that profile. Well below the 1.5 s target; there is margin.&lt;/p>
&lt;h3 id="the-crossing">The crossing&lt;/h3>
&lt;p>The real operational concurrency $N_{\text{op}}$ is the minimum of &lt;strong>the VRAM ceiling&lt;/strong>, the concurrency at which &lt;strong>TPOT starts to degrade&lt;/strong> past the SLO, and the concurrency at which &lt;strong>TTFT starts to degrade&lt;/strong> past the SLO (prefill queue). For the example:&lt;/p>
&lt;ul>
&lt;li>VRAM ceiling: 316.&lt;/li>
&lt;li>TPOT degrades to 80 ms (SLO) at around concurrency ~80–100 (measured empirically with a benchmark, not a closed formula).&lt;/li>
&lt;li>TTFT degrades to 1.5 s at around concurrency ~40–60 because of the prefill queue.&lt;/li>
&lt;/ul>
&lt;p>The replica&amp;rsquo;s operational concurrency is ~50. Applying 25 % head-room for spikes and rebalancing, &lt;strong>target concurrency per replica ≈ 35–40&lt;/strong>.&lt;/p>
&lt;h2 id="step-by-step-worksheet-llama-70b-bf16-on-4h100-sxm">Step-by-step worksheet: Llama 70B BF16 on 4×H100 SXM&lt;/h2>
&lt;p>The exercise&amp;rsquo;s input:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SLO&lt;/strong>: TTFT P95 ≤ 1.5 s; TPOT P95 ≤ 60 ms; &lt;strong>200 sustained RPS&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Workload&lt;/strong>: prompt P50=600, P95=1,200, P99=2,500; output P50=180, P95=500, P99=900. Average prompt 800, output 250.&lt;/li>
&lt;li>&lt;strong>Generic hardware&lt;/strong>: 4×H100 SXM 80 GB with NVLink, vLLM v1 engine, tensor parallel 4, BF16.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Step 1 — VRAM per GPU.&lt;/strong> Weights 35 GB, activations 4 GB, overhead 3 GB → KV budget 38 GB/GPU = 152 GB aggregated. KV/token for Llama 70B GQA = 320 KB. Ceiling of live tokens in cache: $152 \times 10^9 / 320 \times 10^3 \approx 475,000$ tokens. At the operational average context (800 prompt + 200 already generated = 1,000 live tokens per request), concurrency ceiling $\approx 475$.&lt;/p>
&lt;p>&lt;strong>Step 2 — average duration of a request.&lt;/strong> Prefill 800 tokens / 14,000 tps = 57 ms. Decode 250 tokens × 35 ms/token = 8,750 ms. Total $\approx 8.8$ s per request.&lt;/p>
&lt;p>&lt;strong>Step 3 — replica throughput.&lt;/strong> If the replica sustains operational concurrency 40 and each request lasts 8.8 s, the replica delivers roughly $40 / 8.8 \approx 4.5$ requests/s in steady state.&lt;/p>
&lt;p>&lt;strong>Step 4 — number of replicas.&lt;/strong> For a 200 RPS target: $200 / 4.5 \approx 45$ replicas. That is 45 × 4 = 180 GPUs. Too much: this sizing does not work because the cost per request is high.&lt;/p>
&lt;p>&lt;strong>Step 5 — review the levers.&lt;/strong> Before buying more hardware, there are three levers to explore, in this order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Quantisation.&lt;/strong> Dropping to FP8 reduces weights to 17.5 GB/GPU (leaving more VRAM for KV cache → more concurrency), roughly doubles decode tokens/s (HBM saturated by half), and degrades MMLU quality typically by 0.5–1.5 points on models like Llama 70B. Rewriting the calculation in FP8: TPOT drops to ~18 ms, total time per request to 4.7 s, RPS per replica rises to ~8.5, &lt;strong>replicas needed ≈ 24, equivalent to 96 GPUs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Speculative decoding.&lt;/strong> With a small drafter and 60–70 % acceptance, effective TPOT falls 30–40 %. RPS per replica rises to ~12, &lt;strong>replicas ≈ 17 = 68 GPUs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Disaggregated serving.&lt;/strong> Separating prefill workers and decode workers lets each be scaled to the real mix of the workload (see &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>). It usually shaves off another 20–40 % under asymmetric workloads.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Step 6 — recommended sizing.&lt;/strong> For the example, with FP8 + speculative decoding and 25 % head-room: &lt;strong>20 vLLM replicas at TP=4 over 80 H100 SXM&lt;/strong>. If the team does not want to depend on aggressive quantisation (pure BF16 for maximum fidelity), the calculation rises to &lt;strong>30 replicas = 120 GPUs&lt;/strong> and forces a renegotiation of the SLO or the budget.&lt;/p>
&lt;p>&lt;strong>Step 7 — write down the assumptions.&lt;/strong> This is the part no valid sizing skips. In the team&amp;rsquo;s repo, next to the calculation:&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"># sizing/llama70b-prod.yaml&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">fecha&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="ld">2026-06-01&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">slo&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">ttft_p95_ms&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1500&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">tpot_p95_ms&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">rps_target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">200&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prompt_tokens_p50&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">prompt_tokens_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1200&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">output_tokens_p50&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">180&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">output_tokens_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">500&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">asunto&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat productivo con RAG ligero&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">modelo&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">arquitectura&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b-instruct&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">formato_pesos&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">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="nt">motor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-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">hardware&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">gpu&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">topologia&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TP=4 con NVLink intra-nodo&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">red_inter_replica&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">25&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GbE&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">optimizaciones&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">paged_attention&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">chunked_prefill&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">speculative_decoding (drafter llama-1.1b, aceptación esperada 65%)&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">asunciones_criticas&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">utilizacion_hbm_eficiente&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.55&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">head_room_pico_sobre_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.25&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">aceptacion_speculative_min&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.55&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">plan_validacion&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">benchmark vllm bench serve antes de procurement&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">canary 10% durante 7 días post-deploy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without this YAML, the calculation is not reproducible a month later.&lt;/p>
&lt;h2 id="moe-case-mixtral-822b-141-b-total-39-b-active">MoE case: Mixtral 8×22B (~141 B total, 39 B active)&lt;/h2>
&lt;p>MoEs change the calculation along one key dimension: the total weights are large but the active weights per token are small. For Mixtral 8×22B with top-2 routing:&lt;/p>
&lt;ul>
&lt;li>Weight VRAM: $141 \times 2 = 282$ GB BF16. With TP=4 → 70 GB/GPU. It does not fit in an H100 80 GB alongside KV cache + activations. You need TP=8 (~35 GB/GPU) or FP8 with TP=4 (~35 GB/GPU).&lt;/li>
&lt;li>Decode TPOT: dominated by the weights read per token, which are $\sim 39 / 8 \cdot 2 \approx 9.75$ GB/GPU with TP=4 (one top-2 expert per token, divided across 4 GPUs). On an H100 with HBM at 3.35 TB/s, &lt;strong>theoretical TPOT ≈ 3 ms/token&lt;/strong>. In practice, 10–20 ms at reasonable concurrency.&lt;/li>
&lt;li>Prefill: similar to the dense model of the active weights, ~39 B FLOPs/token.&lt;/li>
&lt;/ul>
&lt;p>MoE sizing usually delivers more RPS per GPU than an equivalent dense model: the low cost per token compensates for the extra VRAM. See &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a> for the detail of routing and why a high batch is decisive so that each expert sees enough tokens.&lt;/p>
&lt;h2 id="sensitivity-table-context-and-quantisation">Sensitivity table: context and quantisation&lt;/h2>
&lt;p>For Llama 70B on 4×H100 SXM (TP=4), operational concurrency per replica with an SLO of TTFT 1.5 s / TPOT 60 ms:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Average context&lt;/th>
&lt;th>BF16&lt;/th>
&lt;th>FP8&lt;/th>
&lt;th>INT4 (AWQ)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>500 tokens&lt;/td>
&lt;td>55&lt;/td>
&lt;td>110&lt;/td>
&lt;td>180&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1,000 tokens&lt;/td>
&lt;td>40&lt;/td>
&lt;td>80&lt;/td>
&lt;td>130&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2,000 tokens&lt;/td>
&lt;td>24&lt;/td>
&lt;td>50&lt;/td>
&lt;td>85&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4,000 tokens&lt;/td>
&lt;td>12&lt;/td>
&lt;td>26&lt;/td>
&lt;td>48&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8,000 tokens&lt;/td>
&lt;td>6&lt;/td>
&lt;td>13&lt;/td>
&lt;td>25&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Approximate numbers from public vLLM benchmarks as of June 2026, with ±20 % variation depending on engine version and the headroom adopted. To validate on your own hardware: &lt;code>vllm bench serve&lt;/code> with your profile of real prompts.&lt;/p>
&lt;h2 id="the-five-habitual-traps">The five habitual traps&lt;/h2>
&lt;p>&lt;strong>Trap 1 — confusing the mean with P95.&lt;/strong> Average throughput over an hour may be 50 RPS while the 5-minute peak reaches 180 RPS. Sizing against the mean guarantees breaking the SLO at every peak. Rule: size against the hourly P95, with 20–30 % head-room over P95.&lt;/p>
&lt;p>&lt;strong>Trap 2 — not measuring the real prefill/decode split.&lt;/strong> A &amp;ldquo;RAG with short answers&amp;rdquo; workload spends 70–80 % of GPU time in prefill; a &amp;ldquo;writing assistant generating essays&amp;rdquo; spends 80 % in decode. The useful optimisations (chunked prefill vs speculative decoding) change radically. Without measuring it, you buy badly balanced hardware.&lt;/p>
&lt;p>&lt;strong>Trap 3 — sizing with no head-room for retraining or rollback.&lt;/strong> The production cluster is not just the inference engine: there is a re-embedding batch when the embeddings model changes, continuous canary evals (see &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a>), light fine-tuning, hot stand-by for rollback. Reserve 15–25 % of capacity for those non-negotiable workloads.&lt;/p>
&lt;p>&lt;strong>Trap 4 — &amp;ldquo;GPU at 100 % SM utilization&amp;rdquo; as a target.&lt;/strong> SM occupancy of 95 % with saturated HBM produces the same throughput as SM at 60 % with saturated HBM. The bottleneck in decode is the HBM. Optimising for &amp;ldquo;GPU usage 100 %&amp;rdquo; without looking at HBM utilisation and arithmetic intensity makes you spend more on GPU without gaining throughput. See &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> for which metrics to actually watch.&lt;/p>
&lt;p>&lt;strong>Trap 5 — not documenting the assumptions.&lt;/strong> A sizing with no reproducible YAML (workload, model, engine, head-room, critical assumptions) leaves the team with no way of knowing what changed when the cluster stops meeting the SLO six months later. Documenting is cheap; losing a quarter to debugging is not.&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 with intra-node NVLink and 25 GbE between nodes&lt;/strong>, the recurring configurations in May 2026 are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Format&lt;/th>
&lt;th>TP&lt;/th>
&lt;th>Replicas that fit&lt;/th>
&lt;th>Typical RPS per node (ctx 1K)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama 8B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>1&lt;/td>
&lt;td>4 (one per GPU)&lt;/td>
&lt;td>240–320&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 8B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>4&lt;/td>
&lt;td>450–600&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>30–45&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>60–90&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>INT4 AWQ&lt;/td>
&lt;td>2&lt;/td>
&lt;td>2&lt;/td>
&lt;td>90–130&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8×22B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>90–140&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen 72B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>28–42&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These numbers are &lt;strong>orders of magnitude to start the conversation&lt;/strong>, not commitments. The definitive sizing is validated with &lt;code>vllm bench serve&lt;/code> or &lt;code>genai-perf&lt;/code> (NVIDIA) using the customer&amp;rsquo;s real prompt/output profile. The prefill/decode asymmetry of each case&amp;rsquo;s workload can move these numbers 30–50 % up or down.&lt;/p>
&lt;p>For clusters of 8×H100 SXM (typical of DGX servers or equivalent replicas), the options open up to TP=8 for 405B-class models or multi-replica TP=2 for 70B models with greater density. The metric that decides is always the same: tokens meeting SLO per kW and per euro of amortised hardware.&lt;/p>
&lt;h2 id="how-the-sizing-is-validated-before-buying">How the sizing is validated before buying&lt;/h2>
&lt;p>The spreadsheet sizing is the first half. The second is the validation benchmark.&lt;/p>
&lt;p>&lt;strong>Stage 1 — back-of-the-envelope sizing.&lt;/strong> This post&amp;rsquo;s formulas applied to the SLO and the expected workload. Output: an approximate number of replicas and a topology.&lt;/p>
&lt;p>&lt;strong>Stage 2 — synthetic micro-benchmark.&lt;/strong> On a borrowed GPU or one rented by the day, bring up the engine with the chosen model and run &lt;code>vllm bench serve&lt;/code> with prompts of representative lengths. Validate TPOT, prefill TPS and the concurrency ceiling. Calibrate the HBM efficiency factor ($\eta$) used in the formulas.&lt;/p>
&lt;p>&lt;strong>Stage 3 — load test with realistic traffic.&lt;/strong> Generate traffic following the real distribution of the customer&amp;rsquo;s workload (not Poisson, not constant: the real trace). Measure P50/P95/P99 of TTFT, TPOT, throughput. Confirm the head-room.&lt;/p>
&lt;p>&lt;strong>Stage 4 — canary in production.&lt;/strong> With the cluster sized, route 5–10 % of real traffic for 7–14 days before closing the procurement of additional hardware. See &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> for the mechanics.&lt;/p>
&lt;p>Jumping from Stage 1 to full procurement is the most frequent cause of a cluster that is 40 % oversized and 60 % undersized at the same time, in different regions of the workload. Four weeks of validation done well save four months of refactoring.&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>The observability metrics&lt;/strong> that close the sizing loop in production, see &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a>.&lt;/li>
&lt;li>Autoscaling that adjusts replicas to the real traffic curve, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Detailed cost accounting&lt;/strong> per tenant (showback / chargeback) over the sized hardware.&lt;/li>
&lt;li>&lt;strong>Sizing for continuous fine-tuning&lt;/strong> (PEFT and light training) that shares a cluster with inference.&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/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster: who pays for what&lt;/a> — translating the sizing into €/token and attributing cost per team.&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: the working memory that holds up LLM inference&lt;/a> — the component that dominates the VRAM budget.&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> — what defines the effective utilisation of the compute and the goodput metric.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated prefill/decode serving&lt;/a> — an advanced lever for asymmetric workloads.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a> — how the sums change with MoE models.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for inference&lt;/a> — what each format costs and what it saves.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — the pieces the sizing takes for granted.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments for LLM inference&lt;/a> — the sizing closes better once heterogeneity is accepted: embeddings and reranker on Intel Xeon AMX free up H100s for the large LLM, without buying more GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers&lt;/a> — why the sizing comes to be reasoned per NUMA node rather than per node: you have to discount the reserved housekeeping cores and check that the pod fits on a single NUMA &amp;ldquo;table&amp;rdquo;.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of the on-premise GPU cluster: amortisation, energy and infrastructure&lt;/a> — the €/GPU-hour that closes the sizing&amp;rsquo;s economic model: once you know how many GPUs you need, here is the real cost of owning them.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/">Sizing and justifying the investment in GPU&lt;/a> — the full roadmap for turning this sizing into an investment proposal defensible before management: ROI, break-even and sensitivity analysis.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the same method with the inputs changed, when the caller is an agent and not a person.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kwon et al. — &lt;em>vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention&lt;/em> (SOSP 2023).&lt;/li>
&lt;li>Zhong et al. — &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>Agrawal et al. — &lt;em>Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU Architecture Whitepaper&lt;/em> (HBM3 memory, bandwidth, sustained FLOPs).&lt;/li>
&lt;li>vLLM project — &lt;code>vllm bench serve&lt;/code> reference (benchmarking CLI included in the repo).&lt;/li>
&lt;li>NVIDIA — &lt;code>genai-perf&lt;/code> (official tool for benchmarking LLM services).&lt;/li>
&lt;/ul></description></item><item><title>Seven greenfield deployment phases for an on-premise LLM platform: from hardware in the room to the first production token</title><link>https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/</link><pubDate>Sun, 31 May 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The two previous posts in this architectural trilogy settled the pieces: &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">the seven layers of the LLM inference stack&lt;/a> describe the components above the cluster, and &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">the five maturity levels of the platform&lt;/a> describe the strata below. This post settles the &lt;strong>when&lt;/strong>: in what order each thing gets deployed when you start from zero, hardware bought, racks installed, physical cabling done, and you want to reach a cluster serving the first production token to a customer. Seven nominal phases &lt;strong>F0 to F6&lt;/strong> with no calendar commitments, organised by &lt;strong>technical dependencies&lt;/strong> (you do not enter F3 without the F2 gate) and with an identifiable &lt;strong>critical path&lt;/strong>. F0 hardware inventory and electrical/network connectivity. F1 bare metal OS + drivers + container runtime. F2 Kubernetes cluster with CNI and Ceph storage operational. F3 GitOps and infrastructure observability. F4 identity, TLS, secrets and policies. F5 GPU platform with LLM-aware observability. F6 the LLM stack operational and open to production traffic. For each phase: what gets built, what has to be ready first (dependencies between phases), the &lt;strong>gate&lt;/strong> that validates its closure, and the typical trap that delays the critical path. The thesis: an on-premise LLM platform sinks far more often from &lt;strong>sequencing badly&lt;/strong> than from &lt;strong>choosing badly&lt;/strong>. The tools have all been invented; the order is the only thing every team rediscovers.&lt;/p>
&lt;h2 id="you-are-here-the-seven-phases-and-their-dependencies">You are here: the seven phases and their dependencies&lt;/h2>
&lt;p>The phases do not run in pure series. F2 and F3 can be started at the same time to speed things up (installing Kubernetes and preparing the GitOps repo in parallel). F4 can overlap with the tail end of F3. F5 waits for F4 to close because GPU pods demand NetworkPolicy and RBAC from day one. F6 is &lt;strong>a single atomic step&lt;/strong>: the cluster goes into production or it does not.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="dag of phases F0 to F6 with dependencies and critical path">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.f0{fill:#f6e2e2;stroke:#a33}.f1{fill:#f4e3cf;stroke:#a63}.f2{fill:#eef0d0;stroke:#7a3}.f3{fill:#dfe9f5;stroke:#356}.f4{fill:#d8eecf;stroke:#373}.f5{fill:#f5e3d8;stroke:#763}.f6{fill:#ead8f5;stroke:#634}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}.crit{stroke:#c33;stroke-width:2.4;fill:none;marker-end:url(#ac)}&lt;/style>
&lt;defs>&lt;marker id="a" 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;marker id="ac" 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="#c33"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">Phase DAG · critical path marked in red&lt;/text>
&lt;rect x="40" y="50" width="110" height="48" class="b f0"/>&lt;text x="95" y="68" text-anchor="middle" class="tiny">F0&lt;/text>&lt;text x="95" y="84" text-anchor="middle" class="sm">Hardware&lt;/text>&lt;text x="95" y="98" text-anchor="middle" class="note">Inventory · network&lt;/text>
&lt;rect x="180" y="50" width="110" height="48" class="b f1"/>&lt;text x="235" y="68" text-anchor="middle" class="tiny">F1&lt;/text>&lt;text x="235" y="84" text-anchor="middle" class="sm">Bare metal&lt;/text>&lt;text x="235" y="98" text-anchor="middle" class="note">OS · drivers&lt;/text>
&lt;rect x="320" y="50" width="110" height="48" class="b f2"/>&lt;text x="375" y="68" text-anchor="middle" class="tiny">F2&lt;/text>&lt;text x="375" y="84" text-anchor="middle" class="sm">k8s cluster&lt;/text>&lt;text x="375" y="98" text-anchor="middle" class="note">Cilium · Ceph&lt;/text>
&lt;rect x="460" y="50" width="110" height="48" class="b f3"/>&lt;text x="515" y="68" text-anchor="middle" class="tiny">F3&lt;/text>&lt;text x="515" y="84" text-anchor="middle" class="sm">GitOps + obs&lt;/text>&lt;text x="515" y="98" text-anchor="middle" class="note">Flux · VM/Loki&lt;/text>
&lt;rect x="600" y="50" width="110" height="48" class="b f4"/>&lt;text x="655" y="68" text-anchor="middle" class="tiny">F4&lt;/text>&lt;text x="655" y="84" text-anchor="middle" class="sm">Identity&lt;/text>&lt;text x="655" y="98" text-anchor="middle" class="note">OIDC · Kyverno&lt;/text>
&lt;rect x="320" y="170" width="110" height="48" class="b f5"/>&lt;text x="375" y="188" text-anchor="middle" class="tiny">F5&lt;/text>&lt;text x="375" y="204" text-anchor="middle" class="sm">GPU plane&lt;/text>&lt;text x="375" y="218" text-anchor="middle" class="note">NVIDIA op · DCGM&lt;/text>
&lt;rect x="600" y="170" width="110" height="48" class="b f6"/>&lt;text x="655" y="188" text-anchor="middle" class="tiny">F6&lt;/text>&lt;text x="655" y="204" text-anchor="middle" class="sm">LLM stack live&lt;/text>&lt;text x="655" y="218" text-anchor="middle" class="note">7 layers active&lt;/text>
&lt;path class="crit" d="M150,74 L180,74"/>
&lt;path class="crit" d="M290,74 L320,74"/>
&lt;path class="crit" d="M430,74 L460,74"/>
&lt;path class="crit" d="M570,74 L600,74"/>
&lt;path class="arr" d="M655,98 L655,170"/>
&lt;path class="arr" d="M375,98 L375,170"/>
&lt;path class="crit" d="M430,194 L600,194"/>
&lt;text x="410" y="262" text-anchor="middle" class="sm" fill="#c33">Critical path: F0 → F1 → F2 → F3 → F4 → F5 → F6&lt;/text>
&lt;text x="410" y="282" text-anchor="middle" class="note">Possible overlaps: F2 ↔ F3 (prepare repo while the cluster is built) · F3 ↔ F4 (policies in audit before enforce)&lt;/text>
&lt;text x="410" y="304" text-anchor="middle" class="note">Not overlappable: F4 before F5 (GPU without RBAC = a bomb) · F5 before F6 (the LLM stack does not start without a GPU plane)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The red arrows are the &lt;strong>critical path&lt;/strong>: the sequential bottleneck that no parallelism can shorten. The grey arrows are dependencies that admit partial overlap. Recognising where to overlap and where not is the difference between a three-month deployment and a six-month one for the same perimeter.&lt;/p>
&lt;h2 id="the-analogy-the-expedition-to-an-eight-thousand-metre-summit">The analogy: the expedition to an eight-thousand-metre summit&lt;/h2>
&lt;p>An expedition to a high alpine summit is not a long trek. It is a series of &lt;strong>camps&lt;/strong> that are set up in order, each with its altitude, its function and its validation gate: if you do not acclimatise properly at base camp, you cannot go up to C1 without risk; if C2 does not have its kitchen and its radio running, you cannot send people higher; if the summit push is attempted without porters in the high camps, there is no safe descent.&lt;/p>
&lt;p>Greenfield deployment of an LLM platform works identically. &lt;strong>F0&lt;/strong> is the arrival of the material at base camp: boxes, sponsors, permits, first review. &lt;strong>F1&lt;/strong> is setting up an operational base camp: kitchen, tents, generator. &lt;strong>F2&lt;/strong> is the climb to C1: there is real altitude now (a k8s cluster running) and you breathe differently. &lt;strong>F3&lt;/strong> is C2: it adds communications, planning and operational acclimatisation. &lt;strong>F4&lt;/strong> is C3, the last night before the push: team roped up, oxygen ready, every protocol verified. &lt;strong>F5&lt;/strong> is the day of the summit push, intense effort, thin margins. &lt;strong>F6&lt;/strong> is the summit and the start of a safe descent: from here on the expedition is in day-to-day operation, no longer in construction.&lt;/p>
&lt;p>The analogy holds two useful lessons: &lt;strong>you do not skip a camp&lt;/strong> (going straight from base camp to the summit kills the team), and &lt;strong>the gates are technical, not emotional&lt;/strong> (if the barometer says storm, you do not set out, however much enthusiasm there is). The platform team that follows those two rules reaches the summit. The one that negotiates them does not.&lt;/p>
&lt;h2 id="f0--hardware-in-the-room-base-camp">F0 — Hardware in the room: base camp&lt;/h2>
&lt;p>&lt;strong>What gets built in this phase.&lt;/strong> An inventory of the hardware received (servers, switches, PDUs, BMC), racks assembled, electrical and data cabling terminated, physical labelling of each machine (rack/U/function), connectivity to the corporate network, management IPs assigned, BMC reachable over VPN with MFA, first ping to each node from the bastion.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> None technical, this is the phase &lt;strong>before the software&lt;/strong>. There are procurement dependencies (servers bought, switches bought), civil works dependencies (a room with enough cooling, a raised floor) and administrative ones (datacenter access for the technicians).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Every node appears in the inventory with &lt;code>(hostname, MAC, management IP, data IP, rack, U, function, owner)&lt;/code>.&lt;/li>
&lt;li>Each node&amp;rsquo;s BMC responds to &lt;code>ipmitool power status&lt;/code> and to the HTTPS UI from the management VPN.&lt;/li>
&lt;li>The top-of-rack switch has its configuration versioned in git (even if there is no cluster GitOps yet, the switch configs are).&lt;/li>
&lt;li>A command &lt;code>for h in $(cat hosts); do ping -c1 -W1 $h.mgmt; done&lt;/code> returns 100 % success.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Cabling &amp;ldquo;however it comes out&amp;rdquo; with no physical labelling and no diagram. When F4 arrives and a NetworkPolicy has to be troubleshot, not knowing which physical interface carries which VLAN doubles the diagnosis time of every incident for ever.&lt;/p>
&lt;p>&lt;strong>Why F0 does not overlap with F1.&lt;/strong> Until every server has a management IP and a live BMC, OS bootstrap cannot be automated. Every hour invested in F0 saves hours in every later phase. It is the phase with the best ROI in the project and the only one that admits no shortcuts.&lt;/p>
&lt;h2 id="f1--bare-metal-the-operational-base-camp">F1 — Bare metal: the operational base camp&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> The operating system image (stable Debian or Ubuntu LTS) provisioned via PXE or cloud-init with the &lt;code>cloud-config&lt;/code> versioned in git. Each node has: a coherent hostname, LVM partitions, kernel ≥ 6.6, the &lt;code>containerd&lt;/code> container runtime, NVIDIA drivers on the GPU nodes, &lt;code>chrony&lt;/code> synchronising against your own servers, the operator&amp;rsquo;s SSH key as the only way in, and &lt;code>nvidia-smi&lt;/code> passing a smoke test on the GPU nodes.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F0 closed. It needs the management network working so PXE responds and so the bastion can reach every node.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F1.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>ansible -i inventory all -m ping&lt;/code> returns 100 % success (or the equivalent with Salt / Pulumi / etc).&lt;/li>
&lt;li>Every GPU node passes &lt;code>nvidia-smi&lt;/code> showing the expected GPUs with a consistent driver across nodes.&lt;/li>
&lt;li>Each node&amp;rsquo;s clock drifts by less than 50 ms from the reference NTP.&lt;/li>
&lt;li>A physical reboot of a node leaves it in exactly the same state after boot (idempotency).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> NVIDIA drivers installed by hand with &lt;code>apt install&lt;/code> or with NVIDIA&amp;rsquo;s &lt;code>.run&lt;/code> script. It works on day one and breaks the day of the first kernel update. The operational rule already established in &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">the post on the five levels&lt;/a>: the drivers end up managed by the GPU Operator in F5; whatever is done now is only so that &lt;code>nvidia-smi&lt;/code> passes the smoke test, not for production.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F1 can start for some nodes while F0 is still being finished on others (real greenfield rarely delivers every server on the same day). The F1 gate is per cluster, not per individual node.&lt;/p>
&lt;h2 id="f2--kubernetes-cluster-operational">F2 — Kubernetes cluster operational&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> RKE2 installed with three HA control plane nodes, all workers joined (CPU and GPU), Cilium as CNI with &lt;code>kubeProxyReplacement&lt;/code> enabled and a BGP control plane pointing at the ToR switches from F0, Rook-Ceph deployed on the storage nodes to cover block (RBD), filesystem (CephFS) and object (S3-compatible RGW), &lt;code>kubectl get nodes&lt;/code> returning every node &lt;code>Ready&lt;/code>, a first test pod with a PVC mounting and data persisting after the pod restarts.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F1 closed (drivers + container runtime). Switches with BGP configured (closed in F0). NVMe disks partitioned or available raw for Ceph OSDs.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F2.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl get nodes -o wide&lt;/code> shows every node &lt;code>Ready&lt;/code> with the expected Kubernetes version.&lt;/li>
&lt;li>A Deployment with replicas=3 and per-node antiAffinity starts and the pods land on different nodes.&lt;/li>
&lt;li>An RWO PVC (RBD) creates a volume, the pod writes data, the pod is deleted, another pod mounts it and reads the data.&lt;/li>
&lt;li>An RWX PVC (CephFS) does the same with two pods writing simultaneously.&lt;/li>
&lt;li>An RGW bucket via &lt;code>s3cmd&lt;/code> or &lt;code>mc&lt;/code> accepts &lt;code>put&lt;/code> and &lt;code>get&lt;/code> over TLS.&lt;/li>
&lt;li>Hubble (the read side of the CNI) shows flow logs between two pods in different namespaces.&lt;/li>
&lt;li>Chaos test: draining a non-GPU worker node; the workloads are rescheduled automatically.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Starting to &lt;code>kubectl apply&lt;/code> real workloads in F2 without GitOps. The backlog of things-applied-by-hand grows faster than the capacity to migrate it to git afterwards. The rule: in F2 only the cluster &lt;strong>prerequisites&lt;/strong> are applied (CNI, CSI, default storage class). Any application workload waits for F3.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F2 ↔ F3. While the cluster is being built, the GitOps repo is prepared in parallel (directory structure, first Helm releases). When F2 closes, Flux is plugged into the repo and everything that was going to be &lt;code>kubectl apply&lt;/code> is already a reconciled manifest.&lt;/p>
&lt;h2 id="f3--gitops-and-infrastructure-observability">F3 — GitOps and infrastructure observability&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> Forgejo deployed first (it is a prerequisite for everything that follows). A &lt;code>gitops-infra&lt;/code> repo with the initial structure (&lt;code>apps/&lt;/code>, &lt;code>infrastructure/&lt;/code>, &lt;code>tenants/&lt;/code>, &lt;code>clusters/&lt;/code>). Flux installed and reconciling that repo. The prerequisite workloads applied by hand in F2 are moved to the repo and reconciled (no operational &lt;code>kubectl apply&lt;/code> remains). VictoriaMetrics + vmagent scraping metrics. Grafana with initial dashboards (USE/RED + cluster + Ceph + Cilium). Loki receiving logs via vector/fluent-bit. Alertmanager + Keep routing alerts to a chat channel. Barman Cloud backups for Postgres (future CNPG) and scheduled Ceph snapshots.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F2 closed. An RGW bucket to store backups (covered by the Ceph from F2).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F3.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A change applied to the repo is reflected in the cluster in under 5 minutes with no manual intervention.&lt;/li>
&lt;li>A change applied with &lt;code>kubectl edit&lt;/code> directly to the cluster is detected by Flux and reverted (binding drift detection, not merely observational).&lt;/li>
&lt;li>Grafana shows dashboards for the cluster, Ceph, Cilium and GPU nodes (DCGM does not arrive until F5, but basic node metrics do).&lt;/li>
&lt;li>A test alert sent to Alertmanager reaches the chat channel in under 1 minute.&lt;/li>
&lt;li>Restoring a Postgres backup on a temporary cluster returns coherent data (the test defines the real RPO).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Having Helm charts in git but still applying them with &lt;code>helm install&lt;/code> from the terminal. That is level 1 dressed up as level 2. F3 only closes when Flux is &lt;strong>the only authority&lt;/strong> that applies changes and humans edit the repo, not the cluster.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F3 ↔ F4. While F3 is being closed, the Defguard and cert-manager manifests can be prepared in the repo. When they reconcile they have somewhere to land.&lt;/p>
&lt;h2 id="f4--identity-certificates-secrets-policies">F4 — Identity, certificates, secrets, policies&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> Defguard deployed with its dedicated Postgres (CNPG). An initial realm with the platform operators enrolled with MFA and WireGuard. OIDC integrated into kube-apiserver (&lt;code>--oidc-issuer-url&lt;/code>, &lt;code>--oidc-client-id&lt;/code>, &lt;code>--oidc-username-claim&lt;/code>), into Forgejo, into Grafana, into Alertmanager, one single SSO. cert-manager installed with an internal CA issuing internal certs for mTLS and with Let&amp;rsquo;s Encrypt ACME for edge certs. SOPS configured with a KMS (it can be a physical HSM, an age key in a vault, or an external Vault) and External Secrets Operator syncing secrets into the cluster. Kyverno deployed with initial policies in &lt;code>audit&lt;/code> mode for a week, then promoted to &lt;code>enforce&lt;/code>. A default-deny NetworkPolicy applied to every existing namespace. Tetragon enabled for runtime security. The kube-apiserver audit log sent to Loki with long retention.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F3 closed (Flux applies the manifests, VM/Loki ingest metrics and logs).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F4.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl&lt;/code> with a shared admin &lt;code>kubeconfig&lt;/code> &lt;strong>stops working&lt;/strong>; every operator uses their own OIDC token with MFA.&lt;/li>
&lt;li>A secret in plain &lt;code>data:&lt;/code> in a commit is rejected by the pre-commit hook (or by Kyverno admission).&lt;/li>
&lt;li>A pod without &lt;code>securityContext.runAsNonRoot=true&lt;/code> is rejected by Kyverno at admission.&lt;/li>
&lt;li>A deliberately wrong NetworkPolicy (allow-all) in a tenant namespace is rejected.&lt;/li>
&lt;li>An audit of the last day returns the complete list of actors and changes (minimum regulatory footprint).&lt;/li>
&lt;li>Basic internal pen-test: an attacker with a forged &lt;code>kubeconfig&lt;/code> fails MFA and is recorded.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Kyverno in permanent &lt;code>audit&lt;/code> mode because &amp;ldquo;we do not want to break production workloads&amp;rdquo;. F4 closes when the policies are in &lt;code>enforce&lt;/code>. Until then, you are still in F3 wearing an F4 face.&lt;/p>
&lt;p>&lt;strong>Why F4 does not overlap with F5.&lt;/strong> F5 introduces GPU pods that move a lot of VRAM and a lot of compute. Without default-deny NetworkPolicy, without OIDC RBAC, without Kyverno blocking insecure configurations, the GPU pods are the juiciest attack surface in the cluster. Any compromise in F5 with F4 not closed is near-total access to the expensive hardware.&lt;/p>
&lt;h2 id="f5--gpu-platform-with-llm-aware-observability">F5 — GPU platform with LLM-aware observability&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> NVIDIA GPU Operator via Flux with the driver version decided in F1 (no longer touched by hand). DCGM Exporter exposing GPU metrics to VictoriaMetrics. MIG manager configured for the nodes where it makes sense (for example, on a 4×H100 SXM cluster: two GPUs with full passthrough for the general LLM at TP=4, two GPUs partitioned into 2×3g.40gb each for small LLMs and embeddings). Topology Manager with the &lt;code>single-numa-node&lt;/code> policy. KEDA with the Prometheus scaler installed and an example ScaledObject pointing at a vLLM metric (&lt;code>vllm:num_requests_running&lt;/code>). OpenTelemetry Collector with OTLP receivers, &lt;code>attributes&lt;/code> processors (enriching spans with &lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>), exporters to Langfuse and to Tempo. LeaderWorkerSet API enabled for tensor parallel topologies. OME (Operator Model Engine) or vLLM Production Stack deployed as a controller, still with no models loaded.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F4 closed (the GPU pods inherit default-deny NetworkPolicy, OIDC RBAC and Kyverno policies).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F5.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A test pod requesting &lt;code>nvidia.com/gpu: 1&lt;/code> is scheduled on the right node and &lt;code>nvidia-smi&lt;/code> from inside the container sees the right GPU (whole or a MIG slice).&lt;/li>
&lt;li>DCGM Exporter exposes metrics in Grafana (utilisation, VRAM, temperature, NVLink bandwidth) for every GPU.&lt;/li>
&lt;li>A test vLLM Deployment starts with a small model (for example, a 7B FP16) loaded from Ceph RGW.&lt;/li>
&lt;li>An OpenTelemetry span generated by that vLLM reaches Langfuse with correct &lt;code>gen_ai.*&lt;/code> attributes.&lt;/li>
&lt;li>KEDA scales the test Deployment from 1 to N replicas under synthetic load and back to 1 when it stops.&lt;/li>
&lt;li>A GPU Operator upgrade to a new version drains and reschedules the GPU pods with no loss of service.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Loading the large model &amp;ldquo;just to try&amp;rdquo; before DCGM and OTel are green. When something fails, there will be no metrics to distinguish OOM, thermal throttling, a driver mismatch or a network problem, and you diagnose blind. The rule: &lt;strong>small model first&lt;/strong>, golden path green, &lt;strong>then&lt;/strong> the large model.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> None with F6. F6 is atomic.&lt;/p>
&lt;h2 id="f6--llm-stack-in-production">F6 — LLM stack in production&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> The &lt;strong>seven layers of the inference stack&lt;/strong> described in the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">corresponding post&lt;/a>, deployed in this order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Vector store + relational data&lt;/strong> (Qdrant, PostgreSQL CNPG, Ceph RGW for weights and adapters, CephFS for datasets). Some components already existed from F3 as data; here they specialise for RAG with their initial collections and schemas.&lt;/li>
&lt;li>&lt;strong>Embeddings + reranker&lt;/strong> (Infinity with &lt;code>multilingual-e5-large&lt;/code>, TEI with &lt;code>bge-reranker-v2-m3&lt;/code>). This is the layer that must be green before any large model, because RAG depends on it.&lt;/li>
&lt;li>&lt;strong>LLM inference&lt;/strong> (vLLM Production Stack with the general LLM and the code LLM). It loads models from Ceph RGW. The initial multi-LoRA pool is empty.&lt;/li>
&lt;li>&lt;strong>Gateway&lt;/strong> (Envoy AI Gateway) with Defguard OAuth, routing by &lt;code>body.model&lt;/code>, rate-limit per tenant. This is the point that &lt;strong>opens traffic to the outside&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LLM-aware observability&lt;/strong> (Langfuse plugged into the OTel from F5).&lt;/li>
&lt;li>&lt;strong>GitOps control plane&lt;/strong> and &lt;strong>dependency tracking&lt;/strong> were already active since F3 and F4 respectively; here you simply add the catalogue of the new LLM services to them.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Dependencies.&lt;/strong> All the previous phases closed.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F6.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A curl to the public endpoint with a Defguard bearer token receives a correct chat completion response in technical Spanish, with &lt;code>trace_id&lt;/code> propagated.&lt;/li>
&lt;li>The trace appears in Langfuse with complete &lt;code>gen_ai.*&lt;/code> attributes, a latency breakdown and its own &lt;code>tenant_id&lt;/code>.&lt;/li>
&lt;li>A 5 % traffic canary to the new model over 24 h degrades neither quality nor latency metrics.&lt;/li>
&lt;li>A controlled traffic burst triggers KEDA, the replicas scale, and P95 latency stays within budget.&lt;/li>
&lt;li>A deliberate failure of one vLLM pod does not affect endpoint availability (replicas + reschedule).&lt;/li>
&lt;li>The internal operator demonstrates the complete path for revoking a tenant&amp;rsquo;s access in under 5 minutes (Defguard → Kyverno → closing the NetworkPolicy).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Opening real customer traffic before having the incident runbook signed off, the SLO negotiated and the continuity plan tested. F6 is technically closed; operationally, the platform is still an experiment until the first real postmortem proves the team knows how to respond.&lt;/p>
&lt;h2 id="the-maths-that-matter-relative-weight-of-effort-per-phase">The maths that matter: relative weight of effort per phase&lt;/h2>
&lt;p>Without committing to calendar weeks, we can quantify the &lt;strong>relative weight&lt;/strong> of the engineering effort per phase in a typical greenfield. The curve is not uniform:&lt;/p>
$$
\text{effort}_{F_i} \approx \text{base}_i \cdot (1 + \epsilon_i)
$$
&lt;p>where $\text{base}_i$ is the nominal effort and $\epsilon_i$ is the &lt;strong>surprises&lt;/strong> factor (wrong cabling, incompatible drivers, badly issued certificates, version conflicts). The table below gives the nominal relative weight and the typical surprise factor observed:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Nominal weight&lt;/th>
&lt;th>Typical surprise factor ε&lt;/th>
&lt;th>Mean effective weight&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>F0 — Hardware&lt;/td>
&lt;td>8 %&lt;/td>
&lt;td>0.5 (1× to 2×)&lt;/td>
&lt;td>&lt;strong>12 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F1 — Bare metal&lt;/td>
&lt;td>6 %&lt;/td>
&lt;td>0.3&lt;/td>
&lt;td>8 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F2 — k8s cluster&lt;/td>
&lt;td>12 %&lt;/td>
&lt;td>0.4&lt;/td>
&lt;td>17 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F3 — GitOps + obs&lt;/td>
&lt;td>14 %&lt;/td>
&lt;td>0.5&lt;/td>
&lt;td>21 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F4 — Identity + policies&lt;/td>
&lt;td>18 %&lt;/td>
&lt;td>0.7&lt;/td>
&lt;td>&lt;strong>31 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F5 — GPU plane&lt;/td>
&lt;td>10 %&lt;/td>
&lt;td>0.4&lt;/td>
&lt;td>14 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F6 — LLM stack live&lt;/td>
&lt;td>8 %&lt;/td>
&lt;td>0.3&lt;/td>
&lt;td>10 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Buffer / integration&lt;/td>
&lt;td>24 %&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two operational observations. &lt;strong>F4 concentrates more surprises than any other phase&lt;/strong> (OIDC federation across four or five apps with different configurations, Kyverno policies that knock over legitimate workloads, secrets broken by poorly tested encryption). &lt;strong>F0 has a high surprise coefficient relative to its size&lt;/strong> because any cabling or labelling error is discovered late and paid for dearly. The two practical consequences: plan &lt;strong>F4 with generous margin&lt;/strong> and do not skimp on time in &lt;strong>F0&lt;/strong>, because every hour saved there costs five later.&lt;/p>
&lt;p>&lt;strong>Critical path and slack.&lt;/strong> The critical path is linear F0 → F1 → F2 → F3 → F4 → F5 → F6. The only real slack is the overlaps already identified:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>F2 ↔ F3 (slack ~30 %)&lt;/strong>: prepare the repo and initial dashboards while the cluster is being built.&lt;/li>
&lt;li>&lt;strong>F3 ↔ F4 (slack ~20 %)&lt;/strong>: identity manifests ready as F3 closes, applied immediately.&lt;/li>
&lt;li>&lt;strong>Within F4&lt;/strong>: policies in &lt;code>audit&lt;/code> mode running in parallel with the Defguard setup.&lt;/li>
&lt;/ul>
&lt;p>Nothing shortens the critical path by more than about 15 % of the total. Anyone promising a productive greenfield in half a reasonable timescale is selling something else: probably skipping F4, or loading F6 with an F5 that is green-but-not-validated.&lt;/p>
&lt;h2 id="final-diagram-the-complete-deployment-schedule">Final diagram: the complete deployment schedule&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete phased deployment schedule with pieces and gates">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.bg{fill:#fafafa;stroke:#bbb;rx:8}.f0{fill:#f6e2e2;stroke:#a33}.f1{fill:#f4e3cf;stroke:#a63}.f2{fill:#eef0d0;stroke:#7a3}.f3{fill:#dfe9f5;stroke:#356}.f4{fill:#d8eecf;stroke:#373}.f5{fill:#f5e3d8;stroke:#763}.f6{fill:#ead8f5;stroke:#634}.gate{fill:#fffbe0;stroke:#a90}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}&lt;/style>
&lt;defs>&lt;marker id="a" 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="410" y="22" text-anchor="middle" class="lbl">Complete schedule: pieces per phase and validation gates&lt;/text>
&lt;rect x="40" y="40" width="740" height="68" class="b f0"/>&lt;text x="60" y="58" class="tiny">F0 · HARDWARE IN THE ROOM&lt;/text>
&lt;text x="60" y="76" class="sm">Inventory · cabling · BMC TLS+MFA · management IPs · BGP switches versioned&lt;/text>
&lt;text x="60" y="92" class="note">Gate: `for h in hosts; ping HOST.mgmt` 100 % success · complete inventory&lt;/text>
&lt;rect x="40" y="116" width="740" height="68" class="b f1"/>&lt;text x="60" y="134" class="tiny">F1 · BARE METAL&lt;/text>
&lt;text x="60" y="152" class="sm">PXE/cloud-init · LTS OS · kernel ≥6.6 · containerd · NVIDIA drivers · chrony · LVM&lt;/text>
&lt;text x="60" y="168" class="note">Gate: `ansible all -m ping` 100 % · `nvidia-smi` smoke OK · idempotent reboot&lt;/text>
&lt;rect x="40" y="192" width="740" height="68" class="b f2"/>&lt;text x="60" y="210" class="tiny">F2 · KUBERNETES CLUSTER&lt;/text>
&lt;text x="60" y="228" class="sm">RKE2 HA · Cilium (kube-proxy replacement + BGP) · Rook-Ceph (RBD + CephFS + RGW)&lt;/text>
&lt;text x="60" y="244" class="note">Gate: RWO/RWX PVCs OK · RGW bucket OK · node drain with no downtime&lt;/text>
&lt;rect x="40" y="268" width="740" height="68" class="b f3"/>&lt;text x="60" y="286" class="tiny">F3 · GITOPS + INFRA OBSERVABILITY&lt;/text>
&lt;text x="60" y="304" class="sm">Forgejo · Flux · VictoriaMetrics + Grafana + Loki · Alertmanager + Keep · backups&lt;/text>
&lt;text x="60" y="320" class="note">Gate: repo change → cluster in &amp;lt;5min · drift reverted · backup restore OK&lt;/text>
&lt;rect x="40" y="344" width="740" height="68" class="b f4"/>&lt;text x="60" y="362" class="tiny">F4 · IDENTITY + POLICIES&lt;/text>
&lt;text x="60" y="380" class="sm">Defguard OIDC+MFA+WG · cert-manager · SOPS+ESO · Kyverno enforce · NP default deny · Tetragon&lt;/text>
&lt;text x="60" y="396" class="note">Gate: shared admin kubeconfig no longer works · policies in enforce · complete audit log&lt;/text>
&lt;rect x="40" y="420" width="740" height="68" class="b f5"/>&lt;text x="60" y="438" class="tiny">F5 · GPU PLATFORM + LLM-AWARE OBSERVABILITY&lt;/text>
&lt;text x="60" y="456" class="sm">NVIDIA GPU Operator · DCGM · MIG manager · KEDA with vLLM metrics · OTel gen_ai.* · OME&lt;/text>
&lt;text x="60" y="472" class="note">Gate: GPU pod scheduled · DCGM green · vLLM smoke with a small model · KEDA scales&lt;/text>
&lt;rect x="40" y="496" width="740" height="38" class="b f6"/>&lt;text x="60" y="514" class="tiny">F6 · LLM STACK LIVE&lt;/text>
&lt;text x="60" y="528" class="sm">7 layers active · first model green · canary OK · runbook signed · first customer with an SLA&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The schedule is not decorative: each row defines what gets built in its phase &lt;strong>and the gate that closes it&lt;/strong>. A phase is not considered finished until its gate is green. A phase with a yellow gate drags every later one; trying to jump to the next with a partially met gate is what produces, several weeks later, the incident that forces you to &amp;ldquo;go back to F4 with production running&amp;rdquo;, the most expensive situation in the whole cost matrix of the &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">post on the five levels&lt;/a>.&lt;/p>
&lt;h2 id="typical-planning-mistakes">Typical planning mistakes&lt;/h2>
&lt;p>Patterns that delay or sink the greenfield deployment, regardless of the tools chosen:&lt;/p>
&lt;p>&lt;strong>1. Buying the LLM before the cluster.&lt;/strong> Starting the project with &amp;ldquo;which model are we going to serve&amp;rdquo; instead of &amp;ldquo;what platform can sustain any reasonable model&amp;rdquo;. The model is an interchangeable parameter; the platform is not.&lt;/p>
&lt;p>&lt;strong>2. Underestimating F0.&lt;/strong> &amp;ldquo;The network team does that.&amp;rdquo; Yes, but the output of F0 is consumed by every later phase. If the network team delivers late, the whole project is late, and nobody had marked it as the critical path.&lt;/p>
&lt;p>&lt;strong>3. Overlapping F4 with F5 &amp;ldquo;to save time&amp;rdquo;.&lt;/strong> It is the one dependency with no slack. If you try to overlap, F5 ends up operating with policies in permanent &lt;code>audit&lt;/code> (you are not in F4) or without OIDC integrated (operators with a shared kubeconfig touching GPUs). Both antipatterns stay in production.&lt;/p>
&lt;p>&lt;strong>4. Skipping the small model smoke test in F5.&lt;/strong> &amp;ldquo;Let&amp;rsquo;s go straight for the 70B.&amp;rdquo; When something fails (and something will), there will be no baseline to diagnose against.&lt;/p>
&lt;p>&lt;strong>5. Treating F6 as &amp;ldquo;turning on vLLM&amp;rdquo;.&lt;/strong> F6 includes gateway, LLM-aware observability, runbook, SLO, continuity plan. Turning on vLLM takes five minutes; closing F6 is weeks of validation and sign-off.&lt;/p>
&lt;p>&lt;strong>6. Not defining gates in writing.&lt;/strong> If the gates are not written down, they are negotiable after the fact. &amp;ldquo;This already counts as F4&amp;rdquo; is the sentence that precedes the next six months of retrofit.&lt;/p>
&lt;p>&lt;strong>7. Assigning a phase to a single owner.&lt;/strong> Every phase needs at least two people who understand it. Staff turnover on long projects destroys knowledge; written gates + cross review preserve it.&lt;/p>
&lt;p>&lt;strong>8. Forgetting the descent route.&lt;/strong> The post focuses on going up. Day-to-day operation (the descent, in the analogy) is another story that also deserves planning: runbooks, on-call, upgrade capacity, end-of-life plan. Teams that only plan the ascent reach the summit and stay there with no oxygen.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-4h100-sxm">Applied to typical on-premise hardware: 4×H100 SXM&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM per GPU node, 3 control plane nodes, 3-5 CPU worker nodes, 2 GPU worker nodes), the &lt;strong>temporal&lt;/strong> split of the work is distributed like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">F0 (hardware)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ 8 physical servers racked + switches + BMC + management IPs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 3 nodes cp-01..03 — control plane (no GPU)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 3 nodes worker-cpu-01..03 — CPU plane (Forgejo, Ceph, observability)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ 2 nodes worker-gpu-01..02 — GPU plane (4×H100 SXM each)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F1 (bare metal)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ OS + drivers + containerd on all 8 nodes alike
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> (NVIDIA drivers only on the 2 GPU nodes, `nvidia-smi` smoke)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F2 (k8s cluster)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ RKE2 control plane on cp-01..03 (HA with embedded etcd)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> workers joining: 3 CPU + 2 GPU
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Ceph OSDs on the 3 CPU worker nodes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> default pools: RBD-replicated-3, CephFS-replicated-3, RGW
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F3 (GitOps + obs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ Forgejo + Flux + VM/Grafana/Loki + Keep on the CPU plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first `gitops-infra` repo reconciles what came from F2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F4 (identity)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ Defguard on the CPU plane (StatefulSet with Postgres CNPG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> OIDC in kube-apiserver, Forgejo, Grafana, Alertmanager
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Kyverno as a Deployment on the control plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F5 (GPU plane)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ NVIDIA GPU Operator targets the GPU workers
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> MIG manager: 1st GPU MIG 7g.80gb (= passthrough), 2nd 2×3g.40gb
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> OTel Collector as a DaemonSet on the GPU plane + CPU plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first vLLM with a 7B FP16 model green
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F6 (LLM stack)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ The 7 layers reconcile via Flux from a second repo `gitops-llm`
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first public endpoint with Defguard OAuth
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first production customer enrolled under an SLA
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The physical distribution of the cluster exploits the isolation between planes defined in F0: the control plane never touches a GPU, the CPU plane concentrates relevant state (Forgejo, Ceph, Postgres CNPG, Langfuse, Defguard) and the GPU plane specialises as much as possible. That separation, decided in F0 before the first server is installed, conditions the success of the remaining phases. It is another reminder of why F0 matters more than it looks.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post walks the &lt;strong>ascent route&lt;/strong> to the summit. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The safe descent&lt;/strong>: day-to-day operation, runbooks per component, on-call, continuous capacity planning, the cluster upgrade cycle with no downtime.&lt;/li>
&lt;li>&lt;strong>Multi-site (the second summit)&lt;/strong>: how two clusters are federated with Cilium Cluster Mesh and what extra phases that introduces. F3.5 (Cluster Mesh) and F4.5 (cross-site replication) are the missing phases.&lt;/li>
&lt;li>&lt;strong>The brownfield route&lt;/strong>: what changes when there is already a cluster with workloads. The phases stay the same, but the gates are applied retroactively and every step requires migration planning.&lt;/li>
&lt;li>&lt;strong>The real calendar cost&lt;/strong>: typical ranges in weeks for a platform team of 2-3 people, split by phase, with uncertainty bands.&lt;/li>
&lt;li>&lt;strong>The handoff to operations&lt;/strong>: how the platform is handed from the deployment team to the operations team, which documents are signed, what is inherited and what is renegotiated.&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/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how to turn these phases into a reproducible, reconciled GitOps deployment.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of an on-premise LLM inference stack&lt;/a> — the seven layers built in F6. The components, not the schedule.&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 of the platform under the LLM&lt;/a> — the levels corresponding to phases F1→F5. The strata, not the sequence.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — individual cards for the pieces cited here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel of F5 with detail on the &lt;code>gen_ai.*&lt;/code> semantic conventions.&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> — when F6 closes, the immediate operational question is how many replicas fit and how many customers they support; the sizing that ends the conversation with procurement.&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> — the concrete cockpit of DCGM and vLLM that F5 enables: the twelve metrics that decide whether the cluster is operated or operated on by intuition.&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&lt;/a> — KEDA and HPA with custom metrics, the first real deliverable of F6 when traffic starts to move.&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 LLM models&lt;/a> — the mechanics of promoting models with no downtime that F6 must support from day one.&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 last piece F6 closes: catalogue, L7 traffic splitting, cross-cutting policy, failover and prefix-aware routing. Without the router, F6 does not finish even with all seven layers running.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: from workstation to cluster&lt;/a> — the runtime hardening that fits into F4 (identity, policies) and F5 (platform): Kata &lt;code>RuntimeClass&lt;/code> and Tetragon &lt;code>TracingPolicy&lt;/code> for agent workloads; its &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> brings the files.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>RKE2 Documentation — &lt;a href="https://docs.rke2.io/">docs.rke2.io&lt;/a>&lt;/li>
&lt;li>Cilium documentation — &lt;a href="https://docs.cilium.io/">docs.cilium.io&lt;/a>&lt;/li>
&lt;li>Rook documentation — &lt;a href="https://rook.io/">rook.io/docs&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;li>Forgejo — &lt;a href="https://forgejo.org/">forgejo.org&lt;/a>&lt;/li>
&lt;li>cert-manager — &lt;a href="https://cert-manager.io/">cert-manager.io&lt;/a>&lt;/li>
&lt;li>External Secrets Operator — &lt;a href="https://external-secrets.io/">external-secrets.io&lt;/a>&lt;/li>
&lt;li>Kyverno — &lt;a href="https://kyverno.io/">kyverno.io&lt;/a>&lt;/li>
&lt;li>NVIDIA GPU Operator — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">docs.nvidia.com/datacenter/cloud-native/gpu-operator&lt;/a>&lt;/li>
&lt;li>DCGM Exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>KEDA — &lt;a href="https://keda.sh/">keda.sh&lt;/a>&lt;/li>
&lt;li>LeaderWorkerSet API — &lt;a href="https://github.com/kubernetes-sigs/lws">github.com/kubernetes-sigs/lws&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Five maturity levels of the platform under the LLM: from a Linux server to a cluster ready for vLLM</title><link>https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/</link><pubDate>Sun, 31 May 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The post on &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">the seven layers of the LLM inference stack&lt;/a> took many pieces for granted: an operational Kubernetes cluster, GitOps reconciling, identities resolved, GPUs visible to the scheduler, observability capable of carrying &lt;code>gen_ai.*&lt;/code>. Before vLLM makes any sense, &lt;strong>you have to reach that starting point&lt;/strong>, and you reach it by levels. This post defines &lt;strong>five maturity levels&lt;/strong> of the platform that lives under the LLM, from a bare metal server with Linux installed (level 0) to a cluster ready to run the inference layer (level 4) and the handoff to the previous post (level 5). Each level &lt;strong>unlocks a concrete capability&lt;/strong>, running containers reproducibly, rebuilding the cluster from git, authenticating humans via OIDC, scheduling GPUs with MIG and DCGM metrics, demonstrating compliance with no manual intervention, and each has a &lt;strong>validation test&lt;/strong> that decides whether you are really there or just telling yourself you are. For each level: which OSS pieces cover it in 2026 (Cilium, RKE2, Flux, cert-manager, Defguard, NVIDIA GPU Operator, KEDA, Trivy, Kyverno…), &lt;strong>the deployment order&lt;/strong> within the level, the decisions that are expensive to skip, and the antipatterns that drop you a level when you thought you were higher. The thesis: &lt;strong>moving up a level costs little effort if you do it in time, and a lot of refactoring if you try to skip it&lt;/strong>. LLM inference demands at least level 4; anyone trying to serve LLMs from level 1 or 2 ends up paying in night-time incidents what they saved on the platform.&lt;/p>
&lt;h2 id="you-are-here-the-five-levels-at-a-glance">You are here: the five levels at a glance&lt;/h2>
&lt;p>Before the detail, the ladder. Each rung adds a capability absent from the one below. The level&amp;rsquo;s test is the question whose honest answer says whether you are already there.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="five maturity levels of the platform under the LLM">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.l0{fill:#f6e2e2;stroke:#a33}.l1{fill:#f4e3cf;stroke:#a63}.l2{fill:#eef0d0;stroke:#7a3}.l3{fill:#d8eecf;stroke:#373}.l4{fill:#dfe9f5;stroke:#356}.l5{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Five maturity levels (plus the handoff to the LLM stack at level 5)&lt;/text>
&lt;rect x="40" y="38" width="740" height="44" class="b l0"/>&lt;text x="60" y="56" class="tiny">LEVEL 0 · CHAOTIC&lt;/text>&lt;text x="60" y="74" class="sm">Bare metal with Linux · ad-hoc docker / podman · no orchestrator · manual changes over SSH&lt;/text>
&lt;rect x="40" y="90" width="740" height="44" class="b l1"/>&lt;text x="60" y="108" class="tiny">LEVEL 1 · REPEATABLE&lt;/text>&lt;text x="60" y="126" class="sm">k8s cluster installed (RKE2 / kubeadm) · CNI · CSI · kubectl apply / Helm from the terminal · pods running&lt;/text>
&lt;rect x="40" y="142" width="740" height="44" class="b l2"/>&lt;text x="60" y="160" class="tiny">LEVEL 2 · DEFINED&lt;/text>&lt;text x="60" y="178" class="sm">GitOps (Flux) · internal registry · infra observability · backups · the cluster rebuilds from the repo&lt;/text>
&lt;rect x="40" y="194" width="740" height="44" class="b l3"/>&lt;text x="60" y="212" class="tiny">LEVEL 3 · MANAGED&lt;/text>&lt;text x="60" y="230" class="sm">OIDC + RBAC · cert-manager · External Secrets · Kyverno · NetworkPolicy default deny · auditing&lt;/text>
&lt;rect x="40" y="246" width="740" height="44" class="b l4"/>&lt;text x="60" y="264" class="tiny">LEVEL 4 · OPTIMISED FOR GPU&lt;/text>&lt;text x="60" y="282" class="sm">NVIDIA GPU Operator · DCGM Exporter · MIG / time-slicing · KEDA with LLM metrics · OTel ready for gen_ai.*&lt;/text>
&lt;rect x="40" y="298" width="740" height="32" class="b l5"/>&lt;text x="60" y="318" class="sm">&lt;tspan font-weight="700">LEVEL 5 · HANDOFF&lt;/tspan> — the cluster is ready for the LLM stack (the 7 layers) to make sense&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The levels are &lt;strong>not interchangeable&lt;/strong>. A cluster at level 2 cannot run LLMs in production with any guarantee: technically the vLLM pod loads, but at the first night-time incident you discover there is no TLS, no identities, no alerting, no GPU metrics, and no way to know who changed what. Moving up a level after LLMs are already in production costs &lt;strong>orders of magnitude&lt;/strong> more than moving up while the cluster is still empty.&lt;/p>
&lt;h2 id="the-analogy-from-the-street-stall-to-the-starred-restaurant">The analogy: from the street stall to the starred restaurant&lt;/h2>
&lt;p>Picture the scale of a hospitality business. &lt;strong>Level 0&lt;/strong> is the street stall: a griddle, a gas bottle, a cook improvising. It can sell food, it works, but anything that deviates from a normal day (a health inspection, an allergic customer, an order for 200 portions) sinks the business. &lt;strong>Level 1&lt;/strong> is the tapas bar: a properly sized kitchen, a short repeatable menu, several shifts. The cook no longer improvises daily; he works from a written menu, even if the recipes live in the head of the boss. &lt;strong>Level 2&lt;/strong> is the restaurant with a set lunch menu: written procedures, fixed suppliers, stock control, an incident book. If the head cook falls ill, the second can run the service without carnage. &lt;strong>Level 3&lt;/strong> is the restaurant with an à la carte menu and formal service: traceability of every ingredient, allergens on the menu, health certification, contracts with suppliers, mandatory staff training. &lt;strong>Level 4&lt;/strong> is the kitchen specialised in a complex product (sushi, haute cuisine, artisan bakery): specific tools the normal restaurant does not need (wood-fired oven, special knives, proving chamber), calibrated processes, quality metrics. &lt;strong>Level 5&lt;/strong> is the restaurant with a Michelin star: the whole system works, &lt;strong>the dish is the result of the organisation, not of one person&amp;rsquo;s talent&lt;/strong>.&lt;/p>
&lt;p>The analogy holds to the end, including the most interesting detail: &lt;strong>you can operate at any level&lt;/strong>, but the promises you can keep are different. The street stall cannot promise a consistent experience to 80 diners with a booking. The cluster at level 1 cannot promise productive multi-tenant LLM service with an SLA. In both cases the problem is not the &lt;strong>technical capability of the last component&lt;/strong> (the griddle cooks; the pod starts); it is the &lt;strong>organisational capability of the whole system&lt;/strong>.&lt;/p>
&lt;p>Let us go level by level.&lt;/p>
&lt;h2 id="level-0--chaotic-the-server-with-linux-and-nothing-else">Level 0 — Chaotic: the server with Linux and nothing else&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Running containers with &lt;code>docker&lt;/code>/&lt;code>podman&lt;/code>, running binaries, connecting the server to the network. The operator can SSH in, do things, and see results.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I reinstall the server from scratch, can I leave it identical to how it was in one afternoon, using only saved notes?&amp;rdquo;&lt;/em>. If the answer is no (because the steps are in the head of whoever built it, in &lt;code>.bash_history&lt;/code>, in an out-of-date wiki), you are at level 0.&lt;/p>
&lt;p>&lt;strong>Minimum pieces to settle before moving up to level 1.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why it matters when moving up&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Linux distribution&lt;/td>
&lt;td>Stable Debian or Ubuntu LTS&lt;/td>
&lt;td>Long, predictable support, recent kernel available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kernel&lt;/td>
&lt;td>Recent LTS (≥ 6.6) with BPF and modern schedulers&lt;/td>
&lt;td>Cilium/eBPF and recent NVIDIA drivers demand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA drivers&lt;/td>
&lt;td>The version that matches the CUDA of the LLM engine you will serve&lt;/td>
&lt;td>A driver/CUDA mismatch blocks vLLM before you start&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Container runtime&lt;/td>
&lt;td>&lt;code>containerd&lt;/code>&lt;/td>
&lt;td>CNCF standard, integrated with RKE2/kubeadm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Root filesystem&lt;/td>
&lt;td>XFS or ext4 + LVM thin pools&lt;/td>
&lt;td>Snapshots, online expansion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Time synchronisation&lt;/td>
&lt;td>&lt;code>chrony&lt;/code> with your own servers&lt;/td>
&lt;td>TLS, correlated logs and short certificates demand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Management network&lt;/td>
&lt;td>Dedicated VLAN, ACLs on the switch&lt;/td>
&lt;td>Isolate the control plane from workload traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cluster network&lt;/td>
&lt;td>LACP + jumbo frames + BGP (if you are going with Cilium)&lt;/td>
&lt;td>Intra-node NVLink does not rescue the service network&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BMC / IPMI&lt;/td>
&lt;td>Out-of-band access with TLS and MFA&lt;/td>
&lt;td>Recovery when the operating system does not boot&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that pin you at level 0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Pet&lt;/strong> servers (with their own names, configured by hand, not replaceable).&lt;/li>
&lt;li>Changes applied with &lt;code>vi&lt;/code> directly over &lt;code>/etc/...&lt;/code> with no commit to a repo.&lt;/li>
&lt;li>Deployment with &lt;code>docker-compose&lt;/code> with no healthchecks and no automatic restart.&lt;/li>
&lt;li>An inventory that lives in an Excel sheet nobody updates.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> System image from PXE/cloud-init with the initial configuration (LVM, hostname, network, SSH key, chrony) → bastion/jump host bootstrap → inventory in Ansible (or an equivalent declarative tool, even if it is later replaced) → NVIDIA drivers + container runtime → smoke test (a CUDA container passes &lt;code>nvidia-smi&lt;/code>). At this point, the server is ready for Kubernetes to come in.&lt;/p>
&lt;h2 id="level-1--repeatable-an-operational-kubernetes-cluster">Level 1 — Repeatable: an operational Kubernetes cluster&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Scheduling containers with a scheduler, network abstraction between pods, persistent volumes, workload lifecycle, manual horizontal scaling.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;Can I lose a node and have the workloads rescheduled without human intervention?&amp;rdquo;&lt;/em>. If yes, you are at level 1. If not, because the pods are pinned to nodes, because there are no replicas, because the PVCs do not reattach, you are still at 0 with Kubernetes on top.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Main alternative&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>k8s distribution&lt;/td>
&lt;td>&lt;strong>RKE2&lt;/strong> (CIS-hardened by default, no commercial surcharge)&lt;/td>
&lt;td>k3s for very small edge, plain kubeadm for custom cases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNI&lt;/td>
&lt;td>&lt;strong>Cilium&lt;/strong> with kube-proxy replacement, BGP, Gateway API&lt;/td>
&lt;td>Calico (without BGP it does not compete with Cilium in 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CSI block + filesystem + object&lt;/td>
&lt;td>&lt;strong>Rook-Ceph&lt;/strong> (RBD + CephFS + S3-compatible RGW)&lt;/td>
&lt;td>OpenEBS Mayastor + Garage for small deployments&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ingress&lt;/td>
&lt;td>Cilium Gateway API (better to unify with the CNI)&lt;/td>
&lt;td>NGINX Ingress, Traefik&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Basic certs&lt;/td>
&lt;td>Self-signed bootstrap&lt;/td>
&lt;td>(cert-manager comes in at level 3)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Workload handling&lt;/td>
&lt;td>&lt;code>kubectl apply&lt;/code> + Helm from the terminal&lt;/td>
&lt;td>No GitOps yet&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Container registry&lt;/td>
&lt;td>Any internal registry (or a trusted external one) with TLS&lt;/td>
&lt;td>(a managed internal registry comes in at level 2)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that drop you to level 0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Services deployed with &lt;code>kubectl apply&lt;/code> from one person&amp;rsquo;s terminal and &lt;strong>with the YAML saved nowhere&lt;/strong>.&lt;/li>
&lt;li>Persistent volumes with no backup policy.&lt;/li>
&lt;li>A &amp;ldquo;single-node cluster&amp;rdquo; as permanent production, an architectural single point of failure.&lt;/li>
&lt;li>A CNI with no NetworkPolicy available or no BGP when the network requires it.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> RKE2 installed on at least three nodes for an HA control plane → Cilium installed in kube-proxy replacement mode + BGP control plane → Rook-Ceph on at least three nodes covering block (RBD) + filesystem (CephFS) + object (S3-compatible RGW) with 3× replication or Erasure Coding depending on the pool → smoke test (a Deployment with a PVC starts, the pods reschedule when a node is cordoned, the data persists).&lt;/p>
&lt;h2 id="level-2--defined-the-cluster-rebuilds-from-git">Level 2 — Defined: the cluster rebuilds from git&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> The cluster state lives in a repository. Every change goes through a commit. Anyone can rebuild the cluster (or an equivalent one) from the repo and the backups. Basic observability warns when something breaks.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I lose the whole cluster, can I recreate it in X hours from the repo + the backups, with no manual intervention beyond the bootstrap?&amp;rdquo;&lt;/em>. The hours are negotiable; what defines the level is that &lt;strong>the repo + the backups are enough&lt;/strong>, not that the person-who-knows is available.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Forge&lt;/td>
&lt;td>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE)&lt;/td>
&lt;td>Self-hosted OSS, community fork of Gitea, open governance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GitOps reconciler&lt;/td>
&lt;td>&lt;strong>Flux&lt;/strong>&lt;/td>
&lt;td>CNCF graduated, native multi-tenancy, lightweight&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Image registry&lt;/td>
&lt;td>&lt;strong>Forgejo Container Registry&lt;/strong>&lt;/td>
&lt;td>Next to the code, no extra piece&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metrics TSDB&lt;/td>
&lt;td>&lt;strong>VictoriaMetrics&lt;/strong> + vmagent&lt;/td>
&lt;td>Higher throughput than plain Prometheus, long retention, PromQL compatible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Visualisation&lt;/td>
&lt;td>&lt;strong>Grafana&lt;/strong>&lt;/td>
&lt;td>The de facto standard&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Logs&lt;/td>
&lt;td>&lt;strong>Loki&lt;/strong> or &lt;strong>Vector&lt;/strong>&lt;/td>
&lt;td>OSS, integrated with Grafana&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Alerting&lt;/td>
&lt;td>&lt;strong>Alertmanager&lt;/strong> + &lt;strong>Keep&lt;/strong> (OSS orchestrator)&lt;/td>
&lt;td>Keep adds multi-channel routing with no lock-in&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DB backups&lt;/td>
&lt;td>&lt;strong>Barman Cloud&lt;/strong> (Postgres)&lt;/td>
&lt;td>The standard for CNPG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Object / dataset backups&lt;/td>
&lt;td>&lt;strong>Ceph RGW multisite&lt;/strong> + CephFS snapshots&lt;/td>
&lt;td>Cross-pool and cross-site&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that drop you to level 1.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl apply&lt;/code> run in production &lt;strong>outside&lt;/strong> the repo (undetected drift).&lt;/li>
&lt;li>&lt;code>main&lt;/code> branches with write permissions for humans without review.&lt;/li>
&lt;li>A monolithic repo with no tenant/infra/apps separation (cross changes that cannot be audited).&lt;/li>
&lt;li>Metrics kept no longer than 7 days (no SLO observable over a month).&lt;/li>
&lt;li>Alerting that fires for everything (fatigue) or for nothing (silence).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> Forgejo deployed first (it is a prerequisite for everything else) → Flux installed and pointing at the manifests repo → an initial repository with the Cilium and Rook-Ceph Helm releases reconciled by Flux (replacing the level 1 &lt;code>kubectl apply&lt;/code>) → VictoriaMetrics + Grafana + Loki via Helm/Flux → Postgres backups and Ceph snapshots scheduled → smoke test (tear the cluster down, restore from repo + backup, the services come back).&lt;/p>
&lt;h2 id="level-3--managed-identities-certificates-secrets-and-policies">Level 3 — Managed: identities, certificates, secrets and policies&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Every human operating the cluster does so with their own identity (no shared &lt;code>kubeconfig&lt;/code>), with MFA and with limited permissions. Automatic internal TLS. Encrypted versioned secrets. Policies that &lt;strong>reject&lt;/strong> insecure configurations before they reach the cluster. A complete audit of who did what.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If an attacker gets hold of an administrator&amp;rsquo;s laptop, what can they do in production?&amp;rdquo;&lt;/em>. At level 3 the answer is &lt;em>&amp;ldquo;little&amp;rdquo;&lt;/em>: MFA blocks the second factor, Kyverno policies block destructive changes without approval, the NetworkPolicies prevent lateral movement, the secrets are encrypted with an external KMS, and the audit log remains. At level 2, &lt;em>&amp;ldquo;everything&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>IdP / OIDC&lt;/td>
&lt;td>&lt;strong>Defguard&lt;/strong>&lt;/td>
&lt;td>Spanish OSS, WireGuard + OIDC + 2FA, multi-org&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Federation with the cluster&lt;/td>
&lt;td>OIDC in kube-apiserver, OIDC in Forgejo, OIDC in Grafana&lt;/td>
&lt;td>Consistent SSO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal PKI&lt;/td>
&lt;td>&lt;strong>cert-manager&lt;/strong> + Trust Manager&lt;/td>
&lt;td>De facto standard, ACME and internal CA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>External ACME&lt;/td>
&lt;td>Let&amp;rsquo;s Encrypt for edge certs&lt;/td>
&lt;td>No cost, automated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Secrets in git&lt;/td>
&lt;td>&lt;strong>SOPS&lt;/strong> + age or an external KMS&lt;/td>
&lt;td>Versionable, encrypted in the repo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Secret syncing&lt;/td>
&lt;td>&lt;strong>External Secrets Operator&lt;/strong>&lt;/td>
&lt;td>Pull from KMS / Vault into the cluster&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Policy as code&lt;/td>
&lt;td>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper)&lt;/td>
&lt;td>Kyverno has a shallower learning curve&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NetworkPolicy&lt;/td>
&lt;td>&lt;strong>Cilium NetworkPolicy&lt;/strong> + L7&lt;/td>
&lt;td>Default deny per namespace&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runtime security&lt;/td>
&lt;td>&lt;strong>Tetragon&lt;/strong> (Cilium)&lt;/td>
&lt;td>eBPF, complements NetworkPolicy with detection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vulnerability scanning&lt;/td>
&lt;td>&lt;strong>Trivy&lt;/strong> in the CI pipeline + admission&lt;/td>
&lt;td>SBOM per image, blocking of critical CVEs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Audit log&lt;/td>
&lt;td>kube-apiserver with &lt;code>--audit-policy-file&lt;/code> sent to Loki&lt;/td>
&lt;td>Regulatory traceability&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Minimum Kyverno policies to keep alive.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Deny &lt;code>:latest&lt;/code> images or images without a sha digest.&lt;/li>
&lt;li>Deny pods without &lt;code>securityContext.runAsNonRoot=true&lt;/code>.&lt;/li>
&lt;li>Deny pods without &lt;code>resources.limits&lt;/code> (CPU + memory).&lt;/li>
&lt;li>Deny Services without an &lt;code>owner=&amp;lt;team&amp;gt;&lt;/code> label.&lt;/li>
&lt;li>Deny changes in critical namespaces (&lt;code>kube-system&lt;/code>, &lt;code>flux-system&lt;/code>) without an approval label.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Antipatterns that drop you to level 2.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A &lt;code>kubeconfig&lt;/code> shared between administrators.&lt;/li>
&lt;li>Secrets in plain &lt;code>data:&lt;/code> in a manifest committed to the repo.&lt;/li>
&lt;li>NetworkPolicy absent from new namespaces by default (implicit allow-all).&lt;/li>
&lt;li>&lt;code>kubectl edit&lt;/code> or &lt;code>kubectl patch&lt;/code> in production without going through the repo.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> Defguard deployed and enrolled with WireGuard / OIDC → OIDC integration with kube-apiserver, Forgejo and Grafana → cert-manager installed and issuing internal certificates (your own CA for mTLS, Let&amp;rsquo;s Encrypt for the edge) → SOPS configured and External Secrets Operator installed → migration of secrets from plain to encrypted → Kyverno with initial policies in &lt;em>audit&lt;/em> mode, then &lt;em>enforce&lt;/em> → default-deny NetworkPolicy per namespace → Tetragon enabled → smoke test (try to bypass each policy and check that admission rejects it).&lt;/p>
&lt;h2 id="level-4--optimised-for-gpu-the-cluster-now-knows-what-an-h100-is">Level 4 — Optimised for GPU: the cluster now knows what an H100 is&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> The Kubernetes scheduler sees the GPUs, distinguishes them, can partition them (MIG) or multiplex them (time-slicing), expose DCGM metrics, autoscale with KEDA using metrics from the LLM workload itself (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>), and carry traces with the GenAI semantic conventions. Everything needed for the LLM inference stack to rest on a platform that understands its nature.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I deploy a pod asking for &lt;code>nvidia.com/gpu: 1&lt;/code>, does it get scheduled on the right GPU, with the right slice, with DCGM metrics exposed, with GenAI observability ready to receive spans?&amp;rdquo;&lt;/em>. If yes, you are at level 4. If the answer requires &amp;ldquo;it depends which node and who deploys it&amp;rdquo;, not yet.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPU device plugin&lt;/td>
&lt;td>&lt;strong>NVIDIA GPU Operator&lt;/strong>&lt;/td>
&lt;td>Deploys drivers, container toolkit, DCGM and MIG manager with one operator&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HW partitioning&lt;/td>
&lt;td>&lt;strong>MIG&lt;/strong> (Multi-Instance GPU) on the H100 where it applies&lt;/td>
&lt;td>Real hardware isolation, not time-slicing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU metrics&lt;/td>
&lt;td>&lt;strong>DCGM Exporter&lt;/strong>&lt;/td>
&lt;td>SM utilisation, VRAM, temperature, throttling, NVLink bandwidth&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM metrics&lt;/td>
&lt;td>&lt;strong>vLLM Prometheus&lt;/strong> endpoint + scrape&lt;/td>
&lt;td>TTFT, TPOT, KV cache, prefix hit rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autoscaling&lt;/td>
&lt;td>&lt;strong>KEDA&lt;/strong> with a Prometheus ScaledObject&lt;/td>
&lt;td>Scales on LLM metrics, not on CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM operators&lt;/td>
&lt;td>&lt;strong>vLLM Production Stack&lt;/strong> / &lt;strong>OME&lt;/strong> (Operator Model Engine)&lt;/td>
&lt;td>Declarative handling of models / adapters&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traces&lt;/td>
&lt;td>&lt;strong>OpenTelemetry Collector&lt;/strong> with OTLP receivers + processors + exporters&lt;/td>
&lt;td>&lt;code>gen_ai.*&lt;/code> semantic conventions (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">post&lt;/a>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LeaderWorkerSet&lt;/td>
&lt;td>LeaderWorkerSet API (k8s 1.30+)&lt;/td>
&lt;td>Tensor parallel topology coherent with NVLink&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Topology Manager&lt;/td>
&lt;td>enabled with &lt;code>single-numa-node&lt;/code>&lt;/td>
&lt;td>Pinning GPU pods to the right NUMA node&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>The key decision: MIG, time-slicing or passthrough.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MIG&lt;/strong> divides an H100 into 1g.10gb, 2g.20gb, 3g.40gb, 7g.80gb (slices with real HW isolation). Useful for serving several small models or reserving capacity per tenant with a guarantee. Limitation: up to 7 instances per GPU, predefined profiles.&lt;/li>
&lt;li>&lt;strong>Time-slicing&lt;/strong> shares a GPU between several pods with no HW isolation. Useful for dev/test, not for multi-tenant production with an SLA.&lt;/li>
&lt;li>&lt;strong>Passthrough&lt;/strong> assigns the whole GPU to one pod. Useful for tensor parallel across several GPUs on the same node (a large LLM with TP=4).&lt;/li>
&lt;/ul>
&lt;p>For a productive LLM platform, the practical rule: &lt;strong>passthrough for the large models with TP&lt;/strong>, &lt;strong>MIG for embeddings and small models that cohabit&lt;/strong>, &lt;strong>never time-slicing in production&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Antipatterns that drop you to level 3.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Installing NVIDIA drivers by hand outside the GPU Operator (silent breakage when Kubernetes is upgraded).&lt;/li>
&lt;li>Serving an LLM with &lt;code>requests.gpu: 1&lt;/code> without having decided on MIG / passthrough (you end up with idle GPUs from fragmentation or pods stepping on each other).&lt;/li>
&lt;li>KEDA autoscaling on CPU (the classic &lt;code>HorizontalPodAutoscaler&lt;/code>) on pods that are almost always at 10 % CPU but 95 % KV cache.&lt;/li>
&lt;li>OpenTelemetry deployed but without the &lt;code>gen_ai.*&lt;/code> semantic conventions (the traces are not LLM-aware).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> NVIDIA GPU Operator installed via Helm/Flux with the driver version that matches the chosen LLM engine → DCGM Exporter enabled and metrics visible in Grafana (NVIDIA dashboards imported) → MIG manager configured for the nodes where it makes sense (a typical mix on a 4×H100 SXM cluster: two GPUs with full passthrough for the general LLM at TP=4, two GPUs partitioned into 2×3g.40gb each for small LLMs + embeddings) → OpenTelemetry Collector with &lt;code>attributes&lt;/code> processors to enrich spans with your own labels (&lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>) + exporters to Langfuse and to Tempo → KEDA installed with an example ScaledObject pointing at &lt;code>vllm:num_requests_running&lt;/code> → vLLM Production Stack or OME to declare models as a CRD → smoke test (a vLLM Deployment declared via CRD starts, serves a token, exposes metrics, the trace reaches Langfuse, KEDA scales under synthetic load).&lt;/p>
&lt;h2 id="level-5--handoff-the-cluster-is-an-llm-platform-the-seven-layers-go-on-top">Level 5 — Handoff: the cluster is an LLM platform, the seven layers go on top&lt;/h2>
&lt;p>Once at level 4, the cluster meets the contract that the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">post on the seven layers&lt;/a> assumed as its starting point. Level 5 adds no infrastructure: it adds the &lt;strong>LLM stack&lt;/strong> proper. For completeness, the seven components of level 5 are:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Gateway&lt;/strong> (Envoy AI Gateway) — it comes first, directing traffic to LLM inference and embeddings.&lt;/li>
&lt;li>&lt;strong>LLM inference&lt;/strong> (vLLM Production Stack or OME with vLLM) — on the GPUs already discovered by the level 4 GPU Operator.&lt;/li>
&lt;li>&lt;strong>Embeddings + reranker&lt;/strong> (Infinity, TEI) — a pod separate from the LLM, already covered in the previous post.&lt;/li>
&lt;li>&lt;strong>Vector store + relational data&lt;/strong> (Qdrant, PostgreSQL CNPG, Ceph RGW for weights and adapters, CephFS for datasets) — most of it already existed at level 2 as data; now it specialises for RAG.&lt;/li>
&lt;li>&lt;strong>LLM-aware observability&lt;/strong> (Langfuse) — it plugs into the level 4 OTel chain.&lt;/li>
&lt;li>&lt;strong>GitOps control plane&lt;/strong> — the one from level 2 remains the only legitimate authority.&lt;/li>
&lt;li>&lt;strong>Dependency tracking&lt;/strong> (Hubble flows + Otterize) — on the Cilium that already existed at level 1.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>The criterion for promoting from level 4 to level 5&lt;/strong> is not technical: it is contractual. The cluster already supports LLMs; the decision is when to open real customer traffic. Promotion demands: a green golden eval of the model, a signed incident runbook, negotiated SLOs, a continuity plan, and mapping to ENS / NIS2 / 42001 where it applies.&lt;/p>
&lt;h2 id="the-maths-that-matter-what-skipping-a-level-costs">The maths that matter: what skipping a level costs&lt;/h2>
&lt;p>To quantify the thesis of the post, an order-of-magnitude estimate of the &lt;strong>cost of moving up each level in time&lt;/strong> versus &lt;strong>moving up after production is running&lt;/strong>. The figures are engineering time with a small platform team (2-3 people), assuming templates and prior experience.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>Time to build on an empty cluster&lt;/th>
&lt;th>Time to retrofit with production running&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0 → 1&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>1-2 weeks (little downstream refactoring)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1 → 2&lt;/td>
&lt;td>2-3 weeks&lt;/td>
&lt;td>4-8 weeks (migrating everything to git)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2 → 3&lt;/td>
&lt;td>2-4 weeks&lt;/td>
&lt;td>8-16 weeks (image rebuilds, secret migration, retroactive RBAC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3 → 4&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>4-8 weeks (reconfiguring GPUs, moving models to MIG, instrumenting &lt;code>gen_ai.*&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4 → 5&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>2-4 weeks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total 0 → 5&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~10-15 weeks&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~20-40 weeks if done in the wrong order&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The typical multiplier observable in practice: &lt;strong>2× to 3×&lt;/strong> the cost if it is done in the wrong order. And that assumes it &lt;strong>gets done at all&lt;/strong>, since many projects never get past level 2 because &amp;ldquo;the identity thing&amp;rdquo; can always wait for another sprint. By the time the incident arrives, it is too late to start.&lt;/p>
&lt;p>Beyond time, the &lt;strong>operational&lt;/strong> cost (night-time incidents, security escapes, invisible debt) grows exponentially with the gap between the actual level and the required level. A cluster at level 2 serving productive LLMs to regulated customers is a time bomb: technically it works, organisationally it does not.&lt;/p>
&lt;h2 id="final-diagram-the-complete-ladder-with-pieces">Final diagram: the complete ladder with pieces&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 520" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="final diagram of the five levels with their OSS pieces and the handoff to the LLM stack">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.bg{fill:#fafafa;stroke:#bbb;rx:8}.l0{fill:#f6e2e2;stroke:#a33}.l1{fill:#f4e3cf;stroke:#a63}.l2{fill:#eef0d0;stroke:#7a3}.l3{fill:#d8eecf;stroke:#373}.l4{fill:#dfe9f5;stroke:#356}.l5{fill:#ead8f5;stroke:#634}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}&lt;/style>
&lt;defs>&lt;marker id="a" 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="410" y="22" text-anchor="middle" class="lbl">Five maturity levels · OSS pieces · handoff to the LLM stack&lt;/text>
&lt;rect x="40" y="38" width="740" height="76" class="b l0"/>&lt;text x="60" y="56" class="tiny">LEVEL 0 · CHAOTIC · a server with Linux&lt;/text>
&lt;text x="60" y="74" class="sm">Debian / Ubuntu LTS · kernel ≥6.6 · containerd · NVIDIA drivers · LVM · chrony · BMC TLS+MFA&lt;/text>
&lt;text x="60" y="92" class="sm">Network: management VLAN, LACP, jumbo frames, BGP on the switch&lt;/text>
&lt;text x="60" y="108" class="note">Test: can I rebuild the server from notes?&lt;/text>
&lt;rect x="40" y="122" width="740" height="76" class="b l1"/>&lt;text x="60" y="140" class="tiny">LEVEL 1 · REPEATABLE · operational Kubernetes cluster&lt;/text>
&lt;text x="60" y="158" class="sm">RKE2 (CIS-hardened) · Cilium (kube-proxy replacement + BGP) · Rook-Ceph (RBD + CephFS + RGW)&lt;/text>
&lt;text x="60" y="176" class="sm">Gateway API · kubectl/Helm from the terminal · pods running with HA&lt;/text>
&lt;text x="60" y="192" class="note">Test: does losing a node require no human action?&lt;/text>
&lt;rect x="40" y="206" width="740" height="76" class="b l2"/>&lt;text x="60" y="224" class="tiny">LEVEL 2 · DEFINED · the cluster rebuilds from git&lt;/text>
&lt;text x="60" y="242" class="sm">Forgejo + Flux · Forgejo Container Registry · VictoriaMetrics + Grafana + Loki&lt;/text>
&lt;text x="60" y="260" class="sm">Barman Cloud backups + Ceph snapshots/RGW multisite · Alertmanager + Keep&lt;/text>
&lt;text x="60" y="276" class="note">Test: can I recreate the cluster from repo + backups?&lt;/text>
&lt;rect x="40" y="290" width="740" height="76" class="b l3"/>&lt;text x="60" y="308" class="tiny">LEVEL 3 · MANAGED · identity, certs, secrets, policies&lt;/text>
&lt;text x="60" y="326" class="sm">Defguard (OIDC + WireGuard) · cert-manager · SOPS + ESO · Kyverno · Trivy&lt;/text>
&lt;text x="60" y="344" class="sm">NetworkPolicy default deny · Tetragon · audit log&lt;/text>
&lt;text x="60" y="360" class="note">Test: what can an attacker do with an admin laptop?&lt;/text>
&lt;rect x="40" y="374" width="740" height="86" class="b l4"/>&lt;text x="60" y="392" class="tiny">LEVEL 4 · OPTIMISED FOR GPU · the scheduler understands the H100&lt;/text>
&lt;text x="60" y="410" class="sm">NVIDIA GPU Operator · DCGM Exporter · MIG manager · Topology Manager NUMA&lt;/text>
&lt;text x="60" y="428" class="sm">KEDA with vLLM metrics · OTel Collector with gen_ai.* · LeaderWorkerSet · OME&lt;/text>
&lt;text x="60" y="446" class="sm">Decision: passthrough TP=4 for the large LLM, MIG for small LLMs + embeddings&lt;/text>
&lt;text x="60" y="460" class="note" fill="#373">Test: does a pod with nvidia.com/gpu:1 schedule with metrics and traces ready?&lt;/text>
&lt;rect x="40" y="468" width="740" height="44" class="b l5"/>&lt;text x="60" y="486" class="tiny">LEVEL 5 · HANDOFF&lt;/text>&lt;text x="60" y="504" class="sm">The LLM stack (7 layers from the previous post) goes on top · gateway, vLLM, embeddings, Qdrant, Langfuse...&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The ladder is not decorative: each level &lt;strong>enables&lt;/strong> the next. You cannot have LLM-aware observability (level 4) without OTel deployed via Flux (level 2). You cannot have automatic internal TLS (level 3) without a root PKI that lives somewhere (registry and certificates managed from level 2). You cannot have KEDA scaling on vLLM metrics (level 4) without Prometheus / VictoriaMetrics scraping (level 2). The levels are &lt;strong>not a conceptual hierarchy&lt;/strong>: they are a hierarchy of &lt;strong>installation dependencies&lt;/strong>.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-progress">Typical design decisions that break progress&lt;/h2>
&lt;p>Mistakes seen repeatedly that drag the cluster back a level:&lt;/p>
&lt;p>&lt;strong>1. Jumping from level 1 straight to level 4.&lt;/strong> &amp;ldquo;We are in a hurry to serve the LLM, we will do the identity and GitOps stuff later.&amp;rdquo; Later is always two orders of magnitude more expensive and always arrives after the first incident.&lt;/p>
&lt;p>&lt;strong>2. Confusing Helm with GitOps.&lt;/strong> Having Helm charts is not level 2. It is level 1 with templates. Level 2 demands that a reconciler (Flux/ArgoCD) &lt;strong>applies&lt;/strong> the charts from a repo, &lt;strong>detects drift&lt;/strong> and &lt;strong>warns&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>3. cert-manager with no usage policy.&lt;/strong> Having auto-renewed certificates but using TLS only at the ingress, with no internal mTLS between services, leaves the TLS promise lame and turns level 3 into a cosplay of level 3.&lt;/p>
&lt;p>&lt;strong>4. NVIDIA drivers by hand.&lt;/strong> It works on day one and breaks the day of the first kernel upgrade. The rule: drivers &lt;strong>always via the GPU Operator&lt;/strong>, never operating system packages.&lt;/p>
&lt;p>&lt;strong>5. Prometheus metrics but 7 days of retention.&lt;/strong> Without long retention (≥ 90 days) there is no honest SLO. VictoriaMetrics with a year of retention costs little more than Prometheus with 7 days, and it unlocks compliance and serious postmortems.&lt;/p>
&lt;p>&lt;strong>6. OIDC only for kube-apiserver.&lt;/strong> If Forgejo, Grafana, Defguard and vLLM each have their own auth system, you do not have SSO, you have islands. An honest level 3 demands &lt;strong>federation&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>7. Kyverno in permanent &lt;em>audit&lt;/em> mode.&lt;/strong> Policies that do not reject are not policies, they are alerts. At some point you have to move to &lt;em>enforce&lt;/em>. Until then, you are still at level 2 wearing a level 3 face.&lt;/p>
&lt;p>&lt;strong>8. MIG with no conscious decision on the profile.&lt;/strong> Configuring MIG with the default profile without having measured the size of the models that will cohabit leaves fragmented GPUs with slices nobody uses. The rule: MIG only if you have measured and decided the profiles in advance.&lt;/p>
&lt;p>They all share one root: &lt;strong>declaring the level without passing the level&amp;rsquo;s test&lt;/strong>. Saying &amp;ldquo;we already did GitOps&amp;rdquo; when things are still applied with &lt;code>kubectl edit&lt;/code> in prod. Saying &amp;ldquo;we already did identity&amp;rdquo; when there is a shared admin &lt;code>kubeconfig&lt;/code>. Saying &amp;ldquo;we are ready for LLMs&amp;rdquo; when there is no DCGM Exporter and no Langfuse plugged in.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), a reasonable setup after passing the five levels distributes the components like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">control plane (3 nodes without GPU, hostnames cp-01..03)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── kube-apiserver, etcd, controller-manager, scheduler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Flux, Forgejo, cert-manager, External Secrets, Kyverno
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── Tetragon (DaemonSet here too)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">worker plane (≥ 3 nodes without GPU, hostnames worker-cpu-01..03)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Cilium agent (DaemonSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Rook-Ceph OSDs + MONs + MDS (CephFS) + RGW (S3)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── VictoriaMetrics + Grafana + Loki
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Defguard (StatefulSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── Langfuse + OTel Collector
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">GPU worker plane (≥ 2 nodes with 4×H100 SXM, hostnames worker-gpu-01..02)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── NVIDIA GPU Operator (driver + container toolkit)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── DCGM Exporter (DaemonSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── MIG manager (configuring the chosen profile)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vLLM (Deployment) — general LLM TP=4 occupies 4 GPUs (passthrough)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vLLM (Deployment) — code LLM TP=2 occupies 2 GPUs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Infinity (embeddings) — 2 replicas cohabit on 2 MIG slices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── KEDA scaler listening to vLLM metrics
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The operational rule: &lt;strong>the control plane and the CPU plane are separated from the GPU plane&lt;/strong>. An incident on the GPU plane must not take the control plane with it (the control plane is what recovers the cluster). And the CPU plane concentrates everything that moves relevant state (Forgejo, Rook-Ceph, Postgres CNPG, Langfuse): it is the heart to protect.&lt;/p>
&lt;p>The GPU hardware specialises as much as possible: GPU pods run &lt;strong>only&lt;/strong> on GPU nodes, and GPU nodes run &lt;strong>nothing&lt;/strong> CPU-bound beyond the operational overhead (Cilium, GPU Operator, DCGM). This is enforced with &lt;code>nodeSelector&lt;/code> + taints/tolerations + a Kyverno policy that rejects pods with no GPU requests being scheduled onto GPU nodes.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post walks the vertical path upwards. Some horizontal and cross-cutting pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Multi-site active/standby&lt;/strong>: how two clusters are federated with Cilium Cluster Mesh and what changes at each level when there are two sites instead of one.&lt;/li>
&lt;li>&lt;strong>Migration between levels with real traffic&lt;/strong>: how a cluster already in production is retrofitted to the next level with no downtime.&lt;/li>
&lt;li>&lt;strong>Day-to-day operation&lt;/strong>: runbooks per level, which dashboards to look at each morning, which SLOs to define per component.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: what each level costs in hardware, energy, engineering hours, optional OSS licences (commercial support from Rancher, Cilium Enterprise, etc.) and when each expense is justified.&lt;/li>
&lt;li>&lt;strong>Operationalised compliance&lt;/strong>: how levels 3 and 4 map to ENS High, NIS2 and ISO/IEC 42001 controls without turning the cluster into a paperwork exercise.&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/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — the maturity level at which the platform is operated as code with Flux.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of an on-premise LLM inference stack&lt;/a> — what gets built &lt;strong>on top&lt;/strong> of a level 4 cluster. This post is its architectural prequel.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — individual cards for many of the pieces cited here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel of level 4 with detail on the &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the operational framework that lives above a level 5 cluster.&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> — the twelve DCGM and vLLM metrics that turn level 4 into a real cockpit.&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&lt;/a> — KEDA with custom metrics is the piece that activates level 4&amp;rsquo;s &amp;ldquo;OPTIMISED FOR GPU&amp;rdquo; when the first traffic peak arrives.&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 LLM models&lt;/a> — progressive rollout is the first thing level 5 must be able to do without the operator having to watch the screen.&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 piece that appears from level 3 onwards (it needs level 3&amp;rsquo;s OIDC + cert-manager + NetworkPolicy to make sense) and that connects level 4&amp;rsquo;s pools to external clients.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>RKE2 Documentation — &lt;a href="https://docs.rke2.io/">docs.rke2.io&lt;/a>&lt;/li>
&lt;li>Cilium documentation — &lt;a href="https://docs.cilium.io/">docs.cilium.io&lt;/a>&lt;/li>
&lt;li>Rook-Ceph — &lt;a href="https://rook.io/">rook.io&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;li>Forgejo — &lt;a href="https://forgejo.org/">forgejo.org&lt;/a>&lt;/li>
&lt;li>cert-manager — &lt;a href="https://cert-manager.io/">cert-manager.io&lt;/a>&lt;/li>
&lt;li>External Secrets Operator — &lt;a href="https://external-secrets.io/">external-secrets.io&lt;/a>&lt;/li>
&lt;li>Kyverno — &lt;a href="https://kyverno.io/">kyverno.io&lt;/a>&lt;/li>
&lt;li>NVIDIA GPU Operator — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">docs.nvidia.com/datacenter/cloud-native/gpu-operator&lt;/a>&lt;/li>
&lt;li>DCGM Exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>KEDA — &lt;a href="https://keda.sh/">keda.sh&lt;/a>&lt;/li>
&lt;li>LeaderWorkerSet API — &lt;a href="https://github.com/kubernetes-sigs/lws">github.com/kubernetes-sigs/lws&lt;/a>&lt;/li>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai/en/latest/serving/production_stack.html&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>CIS Kubernetes Benchmark&lt;/li>
&lt;li>NIST SP 800-207 — Zero Trust Architecture&lt;/li>
&lt;/ul></description></item><item><title>The GPU cluster as a platform: turning a shared cluster into a multi-tenant service your teams can consume</title><link>https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/</link><pubDate>Thu, 21 May 2026 07:15:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Having an expensive GPU cluster and many different workloads that want to use it is not an infrastructure problem: it is an internal product problem. What separates &amp;ldquo;we have a cluster&amp;rdquo; from &amp;ldquo;we have an inference platform&amp;rdquo; are four layers the market consolidated in 2026: a gateway layer that centralises authentication, routing and policies (LiteLLM, Portkey, Kong AI Gateway); a GPU isolation model appropriate to the tenants&amp;rsquo; profile (MIG hardware isolation for untrusted multi-tenant, MPS for processes from the same team, time-slicing only for dev); a quota and rate limiting system with budgets per tenant/team/project (LiteLLM does this in its core at team/user/api-key level with descriptive 429s); and a multi-tenant observability plane that enables real cost attribution (showback as an intermediate step, chargeback as the destination), per-tenant tracing and differentiated dashboards. Applied to a typical mid-scale GPU cluster (one node with 4-8 H100 SXM and NVLink, a common place to start in production), this translates into concrete decisions: with ~640 GB of aggregate VRAM across 8 GPUs and two typical production models (a large 70B+ model with tensor parallel and a replicated mid-sized model), the cluster serves between &lt;strong>tens and low hundreds of simultaneous sessions&lt;/strong> depending on the mix; GPU isolation is usually resolved with MIG on smaller workloads and per-model dedication on large ones; and the platform&amp;rsquo;s success metric is effective utilisation, which in typical production sits at 30-40% and where the reasonable optimisation target is to push it to 60-70% without degrading SLAs.&lt;/p>
&lt;blockquote>
&lt;p>This is the fifth post in the MLOps for LLMs series. It is the most operationally oriented one and cuts across several pipeline stages (Deploy + Observe plus cross-cutting concerns). The &amp;ldquo;you are here&amp;rdquo; marker flags the two active stages because the notion of a multi-tenant platform does not live in just one.&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy--observe-the-cluster-as-a-product">You are here: Deploy + Observe (the cluster as a product)&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 + Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7adb7a;stroke-width:3}.active2{fill:#c47aff;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(#mt1)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mt1)}&lt;/style>
&lt;defs>&lt;marker id="mt1" 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 + OBSERVE · the cluster as a platform with tenants&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 active2"/>&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-question-that-changes-the-framing">The question that changes the framing&lt;/h2>
&lt;p>When a platform team buys expensive GPU hardware and starts building inference, the first version is almost always single-purpose: one model, one client, one target latency. It works. When the second team turns up asking for the same resource, that single purpose becomes internal politics: how many replicas do we give them? What do we do if the SLAs clash? Who pays for the tokens of team B&amp;rsquo;s experiment? And when the third one arrives, what was an SRE project becomes an internal product project.&lt;/p>
&lt;p>The distinction is not technical, it is one of framing. A cluster is infrastructure. A platform is a service with customers, contracts and success metrics. The change of framing implies:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Identifiable customers&lt;/strong> (tenants), not anonymous users.&lt;/li>
&lt;li>&lt;strong>Contracts&lt;/strong> (latency SLA, guaranteed throughput, available models), not &amp;ldquo;whatever we manage&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Success metrics&lt;/strong> that are not technical but product ones: adoption, satisfaction, cost per query per tenant, time to the first &amp;ldquo;hello world&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>This post walks through how that change of framing is carried out. It lands it on a mid-scale cluster (4-8 H100 SXM with NVLink in a single node), the usual configuration when starting with serious LLM inference; but the principles generalise to any topology, from a single node with two GPUs to multi-node clusters with InfiniBand.&lt;/p>
&lt;h2 id="the-four-layers-of-a-multi-tenant-inference-platform">The four layers of a multi-tenant inference platform&lt;/h2>
&lt;p>The canonical architecture that settled in 2026 has four layers that any serious multi-tenant platform implements, from the outside in:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 410" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four layers of a multi-tenant platform">
&lt;style>.title{font:700 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.tiny{font:10px sans-serif;fill:#666}.layer{stroke:#444;stroke-width:1.5;rx:6}.gw{fill:#ffe9d6}.pol{fill:#d6eaff}.iso{fill:#d9f5d6}.obs{fill:#e9d6f5}.cluster{stroke:#666;stroke-dasharray:4 2;fill:none}.tenant{stroke:#888;stroke-width:1.4;fill:#fffce6;rx:4}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#pm1)}&lt;/style>
&lt;defs>&lt;marker id="pm1" 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="22" text-anchor="middle" class="title">The four layers of the multi-tenant platform&lt;/text>
&lt;rect x="40" y="50" width="100" height="40" class="tenant"/>&lt;text x="90" y="68" text-anchor="middle" class="sm">Tenant A&lt;/text>&lt;text x="90" y="82" text-anchor="middle" class="tiny">support chat&lt;/text>
&lt;rect x="160" y="50" width="100" height="40" class="tenant"/>&lt;text x="210" y="68" text-anchor="middle" class="sm">Tenant B&lt;/text>&lt;text x="210" y="82" text-anchor="middle" class="tiny">legal RAG&lt;/text>
&lt;rect x="280" y="50" width="100" height="40" class="tenant"/>&lt;text x="330" y="68" text-anchor="middle" class="sm">Tenant C&lt;/text>&lt;text x="330" y="82" text-anchor="middle" class="tiny">code agent&lt;/text>
&lt;rect x="400" y="50" width="100" height="40" class="tenant"/>&lt;text x="450" y="68" text-anchor="middle" class="sm">Tenant D&lt;/text>&lt;text x="450" y="82" text-anchor="middle" class="tiny">data extr.&lt;/text>
&lt;rect x="520" y="50" width="100" height="40" class="tenant"/>&lt;text x="570" y="68" text-anchor="middle" class="sm">Tenant E&lt;/text>&lt;text x="570" y="82" text-anchor="middle" class="tiny">batch ETL&lt;/text>
&lt;rect x="640" y="50" width="100" height="40" class="tenant"/>&lt;text x="690" y="68" text-anchor="middle" class="sm">notebooks&lt;/text>&lt;text x="690" y="82" text-anchor="middle" class="tiny">research&lt;/text>
&lt;rect x="40" y="120" width="700" height="60" class="layer gw"/>
&lt;text x="390" y="144" text-anchor="middle" class="lbl">Layer 1 · AI Gateway&lt;/text>
&lt;text x="55" y="166" class="sm">Auth (OIDC/API keys) · Routing by model · Failover · Caching · Logging · OTel emission · Rate limiting&lt;/text>
&lt;rect x="40" y="195" width="700" height="60" class="layer pol"/>
&lt;text x="390" y="219" text-anchor="middle" class="lbl">Layer 2 · Policy &amp;amp; Quota Plane&lt;/text>
&lt;text x="55" y="241" class="sm">RPS/TPM quotas per tenant · Monthly budgets · Model whitelist · Priority classes · Admission control&lt;/text>
&lt;rect x="40" y="270" width="700" height="60" class="layer iso"/>
&lt;text x="390" y="294" text-anchor="middle" class="lbl">Layer 3 · Isolation Plane&lt;/text>
&lt;text x="55" y="316" class="sm">MIG / MPS / time-slicing · K8s namespaces · NetworkPolicies · ResourceQuotas · Priority + preemption&lt;/text>
&lt;rect x="40" y="345" width="700" height="55" class="layer obs"/>
&lt;text x="390" y="369" text-anchor="middle" class="lbl">Layer 4 · Observability Plane (multi-tenant)&lt;/text>
&lt;text x="55" y="391" class="sm">Traces with tenant_id · Labelled metrics · Cost attribution · Per-tenant dashboards · Audit logs&lt;/text>
&lt;path class="arr" d="M90,90 L390,120"/>
&lt;path class="arr" d="M450,90 L390,120"/>
&lt;path class="arr" d="M690,90 L390,120"/>
&lt;/svg>
&lt;/div>
&lt;p>Each layer solves a concrete problem. Let us take them one by one.&lt;/p>
&lt;h2 id="layer-1--ai-gateway-the-single-front-door">Layer 1 — AI Gateway: the single front door&lt;/h2>
&lt;p>The AI Gateway is the component your tenants see. It is an HTTP/gRPC API compatible with OpenAI (typically &lt;code>/v1/chat/completions&lt;/code>, &lt;code>/v1/embeddings&lt;/code>, &lt;code>/v1/models&lt;/code>) that centralises everything that happens before touching the inference backends.&lt;/p>
&lt;h3 id="why-centralise">Why centralise&lt;/h3>
&lt;p>Without a gateway, tenants connect directly to vLLM or whatever the model is. Every change (rotating an endpoint, adding a model, changing credentials, applying a policy) means notifying every tenant. Every tenant has its own retry logic, its own logging, its own auth model. It is unworkable from the third customer onwards.&lt;/p>
&lt;p>With a gateway, the change is made in one place. Tenants have a stable URL and a set of credentials; the rest is the gateway&amp;rsquo;s problem.&lt;/p>
&lt;h3 id="the-three-dominant-options-in-2026">The three dominant options in 2026&lt;/h3>
&lt;p>&lt;strong>&lt;a href="https://docs.litellm.ai/">LiteLLM&lt;/a>&lt;/strong> is the most popular OSS option, Python-first, deployed as a proxy. It supports 100+ providers (OpenAI, Anthropic, Bedrock, self-hosted vLLM, Ollama, and so on) behind a unified OpenAI-compatible API. It has a native multi-tenant hierarchy with Organizations → Teams → Users → API Keys, each level with its own budget. The Apache 2.0 version covers the basics; RBAC, SSO, audit logs and team-level enforcement require the paid Enterprise version. Deployed on K8s with the official Helm chart.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://portkey.ai/">Portkey&lt;/a>&lt;/strong> is the most mature commercial / SaaS option. A single control plane that enforces budgets, quotas, permissions and compliance. Real-time spending tracking with alerting. RBAC, audit, workspaces and SSO included. The trade-off: dependency on an external service and a per-request pricing model.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">Kong AI Gateway&lt;/a>&lt;/strong> is the option for organisations that already have Kong as their API gateway. An AI plug-in on top of the existing Kong gateway, integrated with its plugin, consumer and rate-limit model. If your platform team already runs Kong, it is the lowest friction.&lt;/p>
&lt;h3 id="when-to-choose-each-one">When to choose each one&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Situation&lt;/th>
&lt;th>Gateway&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pure OSS, self-hosted, Python-first team&lt;/td>
&lt;td>&lt;strong>LiteLLM&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You need RBAC, SSO, audit log out of the box, and have budget&lt;/td>
&lt;td>&lt;strong>Portkey&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You already run Kong as a corporate API gateway&lt;/td>
&lt;td>&lt;strong>Kong AI Gateway&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Greenfield enterprise with strict compliance&lt;/td>
&lt;td>Portkey (probably)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mid-sized OSS-first company without regulated compliance&lt;/td>
&lt;td>LiteLLM (typically)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-minimum-the-gateway-has-to-do">The minimum the gateway has to do&lt;/h3>
&lt;p>Whichever option you pick, this is what any serious deployment must enforce:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Auth and identity&lt;/strong>: every request carries an API key resolvable to a tenant + user + team.&lt;/li>
&lt;li>&lt;strong>Routing by model&lt;/strong>: the tenant asks for &lt;code>model: &amp;quot;gpt-4o&amp;quot;&lt;/code>; the gateway decides whether it goes to OpenAI, to Azure OpenAI, or to your vLLM with Qwen3 32B (a cheaper fallback), according to policy.&lt;/li>
&lt;li>&lt;strong>Rate limiting&lt;/strong>: RPS per tenant, TPM (tokens per minute), concurrency limits.&lt;/li>
&lt;li>&lt;strong>Caching of identical responses&lt;/strong>: 5-30% of RAG queries are repeats; caching saves latency and cost.&lt;/li>
&lt;li>&lt;strong>OTel emission&lt;/strong>: every call produces a span with &lt;code>gen_ai.*&lt;/code> semantic conventions and &lt;code>tenant_id&lt;/code> as an attribute. Covered in the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Failover&lt;/strong>: if vLLM goes down, the gateway redirects to the OpenAI API. If OpenAI rate-limits, the gateway falls back to Anthropic. Configurable policy.&lt;/li>
&lt;/ul>
&lt;h3 id="example-of-a-multi-tenant-litellm-configuration">Example of a multi-tenant LiteLLM configuration&lt;/h3>
&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"># litellm-config.yaml — simplified example&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">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-3-70b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">openai/llama-3-70b&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-llama3-70b.inference/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">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen3-32b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">openai/qwen3-32b&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-qwen3-32b.inference/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">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpt-4o&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">openai/gpt-4o&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">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/OPENAI_API_KEY&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&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">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">usage-based-routing-v2&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">fallbacks&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">llama-3-70b&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen3-32b, gpt-4o] &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if vLLM goes down, fall back to the external one&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&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">master_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/LITELLM_MASTER_KEY&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">database_url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/DATABASE_URL &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Postgres for budgets/keys&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Hierarchy: Organizations → Teams → Users → API Keys&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="c"># Created via the API, not in static YAML&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Creating a team with a budget:&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">curl -X POST http://litellm/team/new &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">LITELLM_MASTER_KEY&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_alias&amp;#34;: &amp;#34;soporte-chat&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_budget&amp;#34;: 500, # 500 USD/month
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;budget_duration&amp;#34;: &amp;#34;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tpm_limit&amp;#34;: 100000, # 100K tokens/min
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;rpm_limit&amp;#34;: 1000, # 1000 requests/min
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;llama-3-70b&amp;#34;, &amp;#34;qwen3-32b&amp;#34;] # access to these
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the team&amp;rsquo;s API key:&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">curl -X POST http://litellm/key/generate &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">LITELLM_MASTER_KEY&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;&amp;lt;team-id&amp;gt;&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;duration&amp;#34;: &amp;#34;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;metadata&amp;#34;: {&amp;#34;environment&amp;#34;: &amp;#34;production&amp;#34;, &amp;#34;app&amp;#34;: &amp;#34;support-bot&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That API key is what the tenant uses. Every request made with it consumes from the team&amp;rsquo;s budget. When it runs out, LiteLLM returns HTTP 429 with a description.&lt;/p>
&lt;h2 id="layer-2--policy--quota-plane-what-each-tenant-can-do">Layer 2 — Policy &amp;amp; Quota Plane: what each tenant can do&lt;/h2>
&lt;p>The gateway is where enforcement happens. Policy is what gets enforced. Five axes of multi-tenant policy:&lt;/p>
&lt;h3 id="technical-quotas">Technical quotas&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>TPM&lt;/strong> (tokens per minute): the hard consumption limit. For a Llama 3 70B at TP=5, ~3000 sustained output tokens/s = 180K aggregate TPM. If you have 10 tenants, assign 18K each as the ceiling.&lt;/li>
&lt;li>&lt;strong>RPS / RPM&lt;/strong>: load control, not consumption control. A 4K-token session counts as one request; so does a batch of 100 mini-completions. Useful against abuse.&lt;/li>
&lt;li>&lt;strong>Concurrency&lt;/strong>: how many simultaneous active requests per tenant. Important for latency SLAs: 100 RPS with concurrency=50 works out at 2 seconds per request.&lt;/li>
&lt;/ul>
&lt;h3 id="economic-budgets">Economic budgets&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Monthly per tenant&lt;/strong>: a hard cap in USD.&lt;/li>
&lt;li>&lt;strong>Daily and hourly&lt;/strong>: soft caps to avoid a runaway in a single day.&lt;/li>
&lt;li>&lt;strong>Per project / API key&lt;/strong>: fine granularity within a single tenant.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM has a &lt;code>max_budget&lt;/code> field at every level of the hierarchy (organization, team, user, api key). Budgets are inherited and constrained downwards.&lt;/p>
&lt;h3 id="model-whitelist-and-blacklist">Model whitelist and blacklist&lt;/h3>
&lt;p>Tenants with critical workloads → only stable models (&lt;code>llama-3-70b&lt;/code>, &lt;code>gpt-4o&lt;/code>). Research tenants → access to experimental models as well.&lt;/p>
&lt;h3 id="priority-classes">Priority classes&lt;/h3>
&lt;p>Not all requests are equal. Three typical classes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Guaranteed&lt;/strong>: workloads with an SLA, latency respected even under pressure.&lt;/li>
&lt;li>&lt;strong>Best-effort&lt;/strong>: normal workloads without a strict SLA.&lt;/li>
&lt;li>&lt;strong>Spot&lt;/strong>: batches that can wait, evictable if a guaranteed one arrives.&lt;/li>
&lt;/ul>
&lt;p>The &lt;a href="https://arxiv.org/abs/2603.00356">Token Management in Multi-Tenant AI Inference Platforms&lt;/a> paper (2026) formalises this with a model of token pools per priority class that has started to be adopted in production. It maintains guaranteed P99 latency for guaranteed workloads even under overload, with selective throttling of spot.&lt;/p>
&lt;h3 id="admission-control">Admission control&lt;/h3>
&lt;p>Before accepting a request: is there capacity? If not, return 429 immediately instead of queueing and degrading everyone. It is the most underrated operational discipline. A cluster with admission control done properly has predictable latency; without it, catastrophic degradation when the peak arrives.&lt;/p>
&lt;h3 id="the-typical-pattern-in-2026">The typical pattern in 2026&lt;/h3>
&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"># Conceptual policy for a &amp;#34;soporte-chat&amp;#34; tenant&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">tenant&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">soporte-chat&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">quotas&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">tpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">50000&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">rpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">500&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_concurrency&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">budget&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">monthly_usd&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">800&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">alert_thresholds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="m">0.5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.8&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.95&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warn when you get there&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">models_allowed&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">llama-3-70b&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">qwen3-32b&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">priority&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">guaranteed&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">fallback_on_overload&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">qwen3-32b &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if guaranteed fills up, fall back&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">gpt-4o-mini &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># last resort, external model&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="layer-3--isolation-plane-isolating-workloads-physically">Layer 3 — Isolation Plane: isolating workloads physically&lt;/h2>
&lt;p>This is the technically densest layer. You have a node with several H100 SXM GPUs interconnected by NVLink. How do you partition them between tenants?&lt;/p>
&lt;h3 id="three-nvidia-mechanisms-for-sharing-a-gpu">Three NVIDIA mechanisms for sharing a GPU&lt;/h3>
&lt;p>&lt;strong>MIG (Multi-Instance GPU)&lt;/strong> is the strongest isolation. It partitions the GPU into up to 7 instances with physically separate HBM memory and dedicated compute units (SMs). Tenants in different MIG instances cannot touch each other: one workload does not consume memory another needs, one does not degrade another&amp;rsquo;s throughput. Hardware isolation. Available on A100, H100, B100 and B200.&lt;/p>
&lt;p>&lt;strong>MPS (Multi-Process Service)&lt;/strong> is soft. Several processes share the GPU concurrently and NVIDIA distributes SMs according to usage. Good performance if all the processes are yours and you trust them. Worse for multi-tenant setups between customers who do not know each other, because a noisy process can degrade the others.&lt;/p>
&lt;p>&lt;strong>Time-slicing&lt;/strong> is the simplest: the GPU is assigned alternately, slot by slot, to different processes. Much worse latency (waits between slots); not recommended for production workloads with an SLA.&lt;/p>
&lt;h3 id="the-choice-for-multi-tenant-in-2026">The choice for multi-tenant in 2026&lt;/h3>
&lt;p>According to the enterprise adoption survey: 80% use MIG for untrusted multi-tenant (different customers who do not know each other) and MPS for trusted environments (processes from the same team) where you want to maximise throughput. Time-slicing is only used in dev/staging so that every developer can touch a GPU without the cost of exclusivity.&lt;/p>
&lt;p>An important limitation of MIG: it isolates compute and HBM memory, but the PCIe path remains shared. For PCIe-bound workloads (a lot of host↔device traffic), tenants in different MIG instances can still affect each other. For LLM inference the main path is HBM, so this is rarely a problem. But it is worth knowing.&lt;/p>
&lt;h3 id="mig-partitions-on-the-h100">MIG partitions on the H100&lt;/h3>
&lt;p>An H100 (80GB HBM3) can be partitioned into fixed profiles:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Profile&lt;/th>
&lt;th>SM&lt;/th>
&lt;th>Memory&lt;/th>
&lt;th>Max instances per GPU&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1g.10gb&lt;/td>
&lt;td>14&lt;/td>
&lt;td>10 GB&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1g.20gb&lt;/td>
&lt;td>14&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2g.20gb&lt;/td>
&lt;td>28&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3g.40gb&lt;/td>
&lt;td>42&lt;/td>
&lt;td>40 GB&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7g.80gb&lt;/td>
&lt;td>98&lt;/td>
&lt;td>80 GB&lt;/td>
&lt;td>1 (the whole GPU)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For a mid-scale cluster with NVLink, MIG has a fundamental problem: when you partition with MIG, NVLink between GPUs is disabled. An H100 in MIG mode does &lt;strong>not&lt;/strong> take part in multi-GPU tensor parallel. If you are going to serve a large model with tensor parallel (Llama 3 70B with TP=4 or TP=8, for instance), those GPUs have to be whole, without MIG.&lt;/p>
&lt;p>That defines the architectural decision. There are two main approaches:&lt;/p>
&lt;h3 id="approach-a--a-shared-large-model-with-quotas-at-the-gateway">Approach A — A shared large model with quotas at the gateway&lt;/h3>
&lt;p>All the node&amp;rsquo;s GPUs serve a single large model with tensor parallel spanning the whole node. All tenants share that instance. Isolation happens at the gateway layer (quotas, rate limiting) and the policy layer (priority classes). The cluster&amp;rsquo;s kernel is one enormous vLLM instance with &lt;code>--max-num-seqs=128&lt;/code> or similar; internally, vLLM shares GPU time between the active requests with continuous batching.&lt;/p>
&lt;p>&lt;strong>Advantages&lt;/strong>: you use all the GPUs to the full, NVLink stays active, better KV cache utilisation.
&lt;strong>Disadvantages&lt;/strong>: soft isolation. A tenant that saturates does not degrade the others directly (vLLM batches), but it does compete for batch slots. You need serious priority classes.&lt;/p>
&lt;h3 id="approach-b--dedicating-gpus-per-model--tenant">Approach B — Dedicating GPUs per model / tenant&lt;/h3>
&lt;p>You split the GPUs into pools dedicated to different models. Examples on an 8-GPU node:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>4 GPUs&lt;/strong>: a large 70B model with TP=4.&lt;/li>
&lt;li>&lt;strong>2 GPUs&lt;/strong>: a mid-sized 32B model replicated (2 independent instances) for tenants with a strict SLA.&lt;/li>
&lt;li>&lt;strong>2 GPUs&lt;/strong>: miscellaneous workloads (smaller models, experimentation).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Advantages&lt;/strong>: physical isolation between critical models / tenants.
&lt;strong>Disadvantages&lt;/strong>: worse aggregate utilisation; some GPUs idle while others saturate.&lt;/p>
&lt;h3 id="approach-c-advanced--mig-on-some-gpus-plus-dedication-for-the-rest">Approach C (advanced) — MIG on some GPUs plus dedication for the rest&lt;/h3>
&lt;p>If you have small workloads (4B, 7B models), you can use MIG on 1-2 GPUs to serve them and dedicate the rest to tensor parallel for the large model. It combines strong isolation for the small workloads with full use of NVLink for the large model.&lt;/p>
&lt;h3 id="the-operational-choice-start-with-a-move-up-to-c-if-you-need-to">The operational choice: start with A, move up to C if you need to&lt;/h3>
&lt;p>In most deployments, Approach A (a shared large model plus quotas) is the right starting point. Utilisation is better, operation is simpler, and the gateway&amp;rsquo;s soft isolation works for reasonable workloads.&lt;/p>
&lt;p>When there is a tenant with a strict SLA that cannot tolerate competing with others, you move to Approach B for that tenant in particular (dedicating GPUs to an instance of the model just for them), keeping the rest of the cluster shared.&lt;/p>
&lt;p>Approach C is for when you have 10+ tenants with very heterogeneous profiles.&lt;/p>
&lt;h3 id="isolation-at-the-kubernetes-level">Isolation at the Kubernetes level&lt;/h3>
&lt;p>Independently of GPU isolation, pod-level isolation is applied in K8s:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Namespaces per tenant&lt;/strong>: &lt;code>tenant-soporte&lt;/code>, &lt;code>tenant-legal&lt;/code>, and so on.&lt;/li>
&lt;li>&lt;strong>ResourceQuotas and LimitRanges&lt;/strong>: CPU/memory limits per namespace.&lt;/li>
&lt;li>&lt;strong>NetworkPolicies&lt;/strong>: tenant A cannot talk to tenant B&amp;rsquo;s namespaces.&lt;/li>
&lt;li>&lt;strong>K8s PriorityClasses&lt;/strong>: classes with a numeric value defining preemption order if a more critical pod arrives.&lt;/li>
&lt;li>&lt;strong>PodDisruptionBudgets&lt;/strong>: how many pods of each deployment can go down simultaneously.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-4--observability-plane-seeing-what-happens-per-tenant">Layer 4 — Observability Plane: seeing what happens per tenant&lt;/h2>
&lt;p>The fourth layer: observability with a tenant dimension. Without it you cannot do cost attribution, you cannot debug incidents for a single tenant, you cannot show dashboards to stakeholders.&lt;/p>
&lt;h3 id="the-four-mandatory-properties">The four mandatory properties&lt;/h3>
&lt;p>&lt;strong>1. tenant_id on every span&lt;/strong>. The AI gateway resolves the API key and attributes a &lt;code>tenant_id&lt;/code>. That ID is propagated via &lt;code>params._meta&lt;/code> or OTel headers to every downstream component (vLLM, retrieval, MCP servers, tools). Any span in any system carries that label. It is what lets you reconstruct tenant-specific traces.&lt;/p>
&lt;p>&lt;strong>2. Metrics labelled per tenant&lt;/strong>. &lt;code>gen_ai.usage.input_tokens{tenant=&amp;quot;soporte-chat&amp;quot;}&lt;/code> or equivalents. Prometheus, Grafana, groupable by tenant.&lt;/p>
&lt;p>&lt;strong>3. Real cost attribution&lt;/strong>. The sum of tokens × cost/token per tenant gives the cost. For self-hosted vLLM, the cost is per GPU hour plus the proportional share of tokens (you can compute an equivalent cost per 1k tokens).&lt;/p>
&lt;p>&lt;strong>4. An immutable audit log&lt;/strong>. Every API key used, every model invoked, every quota change, every budget exceeded. For compliance.&lt;/p>
&lt;h3 id="showback-vs-chargeback">Showback vs chargeback&lt;/h3>
&lt;p>An important FinOps distinction that gained clarity in 2026:&lt;/p>
&lt;p>&lt;strong>Showback&lt;/strong>: visibility without consequence. &amp;ldquo;Support team, you have consumed 623 USD this month on LLMs&amp;rdquo;. Information, not an invoice. It lets you spot abuse without penalising anyone before the team understands what is going on.&lt;/p>
&lt;p>&lt;strong>Chargeback&lt;/strong>: the cost is charged to the team&amp;rsquo;s budget. When it runs out, it runs out. It changes behaviour.&lt;/p>
&lt;p>The practice that works: 6-18 months on showback while tags are calibrated, misattributions are identified and teams are trained. Then chargeback, once the numbers are credible. Launching chargeback on day 1 when the costs are still dirty creates an immediate political fight; launching showback prepares the ground so that chargeback lands in an orderly way.&lt;/p>
&lt;p>&lt;a href="https://spendark.com/blog/kubernetes-cost-allocation/">Only 14% of organisations have chargeback active&lt;/a> according to a recent survey, which indicates this is still mostly showback in real production.&lt;/p>
&lt;h3 id="tools">Tools&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://www.kubecost.com/">Kubecost&lt;/a>&lt;/strong>: cost allocation per namespace, deployment and pod on Kubernetes. For the cost of the shared GPU, allocate proportionally to the tokens consumed per tenant.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.finout.io/">Finout&lt;/a>&lt;/strong>: a FinOps platform that combines cloud bills and LLM API costs in a unified view with virtual tagging.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://langfuse.com/">Langfuse&lt;/a>&lt;/strong>: already covered. Cost tracking per trace, groupable by user or session metadata.&lt;/li>
&lt;li>&lt;strong>LiteLLM native tracking&lt;/strong>: LiteLLM&amp;rsquo;s master DB keeps running spend per team, user and API key, accessible via the API or the UI.&lt;/li>
&lt;/ul>
&lt;h3 id="the-minimum-multi-tenant-dashboard">The minimum multi-tenant dashboard&lt;/h3>
&lt;p>Any platform should have:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Summary per tenant&lt;/strong>: monthly spend, current RPS, TPM consumed, % of budget spent, active sessions.&lt;/li>
&lt;li>&lt;strong>Top users&lt;/strong> within each tenant (for detecting internal abuse).&lt;/li>
&lt;li>&lt;strong>p95 latency per tenant&lt;/strong>: SLA tracking.&lt;/li>
&lt;li>&lt;strong>429 / 503 errors&lt;/strong>: how many requests are being rate-limited or rejected because of overload.&lt;/li>
&lt;li>&lt;strong>Cost trend&lt;/strong>: the monthly trajectory with a projection.&lt;/li>
&lt;li>&lt;strong>Drift per tenant&lt;/strong> (from the post-tracing series): if a tenant starts getting worse results, raise an alert.&lt;/li>
&lt;/ol>
&lt;h2 id="sizing-on-mid-scale-gpu-clusters-concrete-decisions">Sizing on mid-scale GPU clusters: concrete decisions&lt;/h2>
&lt;p>Let us get down to hardware. As a reference we take a node with N H100 SXM (between 4 and 8) with NVLink/NVSwitch, 80 GB HBM3 each. That gives between 320 GB and 640 GB of aggregate VRAM. Inter-GPU connectivity is 900 GB/s (NVLink 4) or 600 GB/s (NVLink 3) depending on generation. HBM bandwidth per GPU is 3.35 TB/s.&lt;/p>
&lt;h3 id="default-decisions">Default decisions&lt;/h3>
&lt;p>Start with Approach A: all the node&amp;rsquo;s GPUs serving a single large 70B model in BF16 with tensor parallel = N. Expected real capacity (computed for a standard 8-GPU HGX node as an example; it scales roughly linearly with N):&lt;/p>
&lt;ul>
&lt;li>Model VRAM (70B BF16): ~140 GB (≈ 17.5 GB/GPU at TP=8).&lt;/li>
&lt;li>vLLM overhead + activations VRAM: ~10 GB/GPU.&lt;/li>
&lt;li>VRAM free for KV cache: ~52 GB/GPU. On an 8-GPU node that is &lt;strong>~416 GB aggregate&lt;/strong>; on a 4-GPU one, ~210 GB.&lt;/li>
&lt;li>With &lt;code>--kv-cache-dtype=fp8&lt;/code> and a 70B GQA model: ~320 KB/token.&lt;/li>
&lt;li>Aggregate cache capacity (8-GPU node): &lt;strong>~1.3M tokens&lt;/strong> to distribute between simultaneous sessions.&lt;/li>
&lt;/ul>
&lt;p>That translates into throughput and concurrency (indicative figures for an 8-GPU node):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Simultaneous sessions&lt;/th>
&lt;th style="text-align:right">Average context per session&lt;/th>
&lt;th style="text-align:right">Aggregate throughput (tokens/s)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">16K&lt;/td>
&lt;td style="text-align:right">~5000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">64&lt;/td>
&lt;td style="text-align:right">8K&lt;/td>
&lt;td style="text-align:right">~8000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">4K&lt;/td>
&lt;td style="text-align:right">~12000&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Typical latencies: TTFT ~150ms at low traffic, TPOT ~15-20 ms/tok. Under high concurrency, TTFT rises to ~500ms if the queue is saturated.&lt;/p>
&lt;h3 id="an-example-tenant-scheme">An example tenant scheme&lt;/h3>
&lt;p>A cluster with 4 tenants and a research pool:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tenant&lt;/th>
&lt;th style="text-align:right">TPM cap&lt;/th>
&lt;th style="text-align:right">RPM cap&lt;/th>
&lt;th style="text-align:right">Concurrency&lt;/th>
&lt;th style="text-align:right">Budget&lt;/th>
&lt;th>Priority&lt;/th>
&lt;th>Models&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Support chat&lt;/td>
&lt;td style="text-align:right">80K&lt;/td>
&lt;td style="text-align:right">800&lt;/td>
&lt;td style="text-align:right">50&lt;/td>
&lt;td style="text-align:right">1500 USD/month&lt;/td>
&lt;td>Guaranteed&lt;/td>
&lt;td>llama-3-70b, qwen3-32b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Legal RAG&lt;/td>
&lt;td style="text-align:right">30K&lt;/td>
&lt;td style="text-align:right">200&lt;/td>
&lt;td style="text-align:right">15&lt;/td>
&lt;td style="text-align:right">600 USD/month&lt;/td>
&lt;td>Guaranteed&lt;/td>
&lt;td>llama-3-70b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code agent&lt;/td>
&lt;td style="text-align:right">50K&lt;/td>
&lt;td style="text-align:right">300&lt;/td>
&lt;td style="text-align:right">25&lt;/td>
&lt;td style="text-align:right">1200 USD/month&lt;/td>
&lt;td>Best-effort&lt;/td>
&lt;td>llama-3-70b, qwen-coder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Data extr. batch&lt;/td>
&lt;td style="text-align:right">40K&lt;/td>
&lt;td style="text-align:right">1000&lt;/td>
&lt;td style="text-align:right">40&lt;/td>
&lt;td style="text-align:right">400 USD/month&lt;/td>
&lt;td>Spot&lt;/td>
&lt;td>llama-3-70b, qwen3-32b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Research / notebooks&lt;/td>
&lt;td style="text-align:right">10K&lt;/td>
&lt;td style="text-align:right">100&lt;/td>
&lt;td style="text-align:right">5&lt;/td>
&lt;td style="text-align:right">200 USD/month&lt;/td>
&lt;td>Spot&lt;/td>
&lt;td>all&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>TPM total: 210K. Aggregate cluster capacity: ~180K sustained TPM. It is overcommitted by about 15%, assuming not every tenant hits the ceiling at the same time. That is normal and desirable; if they all do it at once, the priority classes degrade in an orderly way.&lt;/p>
&lt;h3 id="when-to-add-hardware">When to add hardware&lt;/h3>
&lt;p>Signals that the node has become too small:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sustained p95 TTFT &amp;gt; 500 ms&lt;/strong> during peak hours → the queue is building up.&lt;/li>
&lt;li>&lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code> constantly &amp;gt; 20&lt;/strong> → admission control starting to reject.&lt;/li>
&lt;li>&lt;strong>Sustained GPU utilisation &amp;gt; 80% at critical hours&lt;/strong> without dropping in quiet hours → there is no headroom.&lt;/li>
&lt;li>&lt;strong>429 rate on guaranteed tenants &amp;gt; 1%&lt;/strong> → the platform is breaking its SLA in production.&lt;/li>
&lt;/ul>
&lt;p>When several of these hold, the natural next step is to add another HGX node with internal NVLink and stand up a second vLLM instance of the same model. The gateway load-balances between the two instances. Aggregate throughput doubles; latency stays the same.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="a-gateway-without-auth-a-backdoor-into-the-cluster">A gateway without auth: a backdoor into the cluster&lt;/h3>
&lt;p>Your vLLM sits on a ClusterIP Service and the main app talks to it. Some tenant discovers the endpoint directly and hits it without going through the gateway. Quotas and costs are silently evaded. Use a strict NetworkPolicy: only the gateway can talk to the vLLM Services; the rest of the cluster cannot.&lt;/p>
&lt;h3 id="mig-and-nvlink-are-incompatible">MIG and NVLink are incompatible&lt;/h3>
&lt;p>You enable MIG on a GPU thinking you will get isolation plus multi-GPU; you discover that MIG disables NVLink. Any large model with TP becomes unusable. Decide MIG vs NVLink globally per cluster, not per individual GPU.&lt;/p>
&lt;h3 id="quotas-pinned-to-the-clusters-ceiling">Quotas pinned to the cluster&amp;rsquo;s ceiling&lt;/h3>
&lt;p>You add up the TPM of all the tenants and it comes to exactly the cluster&amp;rsquo;s capacity. When two tenants peak at the same time, both wait or one gets rejected. An overcommit of 10-20% is healthy (it assumes they do not all peak at once); more than that is dangerous.&lt;/p>
&lt;h3 id="no-multi-tenant-observability-from-day-1">No multi-tenant observability from day 1&lt;/h3>
&lt;p>You launch with quotas and isolation but without tenant_id on spans. Three months later, your CFO asks &amp;ldquo;how much does the support agent cost versus the legal one?&amp;rdquo; and you cannot answer. OTel with a mandatory tenant_id from the first version, even if there are no dashboards yet; having the data is worth more than having perfect dashboards with no data.&lt;/p>
&lt;h3 id="showback-that-never-reaches-chargeback">Showback that never reaches chargeback&lt;/h3>
&lt;p>You have been on showback for 18 months, the teams know the numbers, nobody changes behaviour. Without the pressure of real chargeback, the incentive dilutes. Set an explicit calendar for the transition to chargeback, with an owner and a deadline.&lt;/p>
&lt;h3 id="non-whitelisted-models-eating-the-budget">Non-whitelisted models eating the budget&lt;/h3>
&lt;p>A team discovers that LiteLLM has &lt;code>gpt-4o&lt;/code> configured. They use it without permission. The budget burns on an external API when the idea was to use the cheap self-hosted one. Keep an explicit per-team whitelist of accessible models.&lt;/p>
&lt;h3 id="badly-calibrated-priority-classes">Badly calibrated priority classes&lt;/h3>
&lt;p>Everyone declares themselves &amp;ldquo;guaranteed&amp;rdquo;. At the first peak, there is nothing left to degrade and everything suffers. Priority classes only for critical cases, with justification. The majority should be best-effort.&lt;/p>
&lt;h3 id="no-failover-from-the-gateway">No failover from the gateway&lt;/h3>
&lt;p>Your vLLM goes down. The gateway has no fallback configured and returns 503 to every tenant. Configure a fallback to another model, ideally an external one (OpenAI) for guaranteed workloads, even if it costs more per hour. Availability is worth more than cost per hour.&lt;/p>
&lt;h2 id="an-operational-roadmap-for-getting-started">An operational roadmap for getting started&lt;/h2>
&lt;p>If you start from scratch with an empty GPU node, the minimum order is as follows. Each milestone is a day&amp;rsquo;s work with slack, not a tight one:&lt;/p>
&lt;p>&lt;strong>Day 1-2 — Base K8s infrastructure&lt;/strong>. NVIDIA GPU Operator + nvidia-device-plugin + dcgm-exporter + cluster-default NetworkPolicies. Validation: a basic pod with &lt;code>nvidia.com/gpu: 1&lt;/code> gets scheduled.&lt;/p>
&lt;p>&lt;strong>Day 3 — vLLM with a large model and tensor parallel across the whole node&lt;/strong>. The vLLM Production Stack Helm chart (or bare vLLM manifests). Model weights on a shared PVC (CephFS or NFS). Validation: a &lt;code>curl&lt;/code> request against the internal Service responds.&lt;/p>
&lt;p>&lt;strong>Day 4 — AI Gateway: LiteLLM&lt;/strong>. Helm chart, Postgres for budgets, master key, a first model_list pointing at vLLM. Validation: an OpenAI-compatible request through LiteLLM responds with the same content as vLLM directly.&lt;/p>
&lt;p>&lt;strong>Day 5 — Basic multi-tenancy&lt;/strong>. Create teams, API keys, budgets, model whitelists. Test with two teams. Validation: the second team, using the model it does not have whitelisted, gets a 403.&lt;/p>
&lt;p>&lt;strong>Day 6 — Minimum observability&lt;/strong>. Prometheus + Grafana scraping vLLM and LiteLLM. A dashboard with TTFT, TPOT, throughput, num_requests_waiting and budget_consumed_per_team. Validation: visible in Grafana with real data.&lt;/p>
&lt;p>&lt;strong>Day 7-8 — Pilot customer&lt;/strong>. One real tenant (ideally a controlled internal one) starts using it. Measure real latencies, discover the first operational incidents.&lt;/p>
&lt;p>&lt;strong>Day 9-10 — Tuning&lt;/strong>. Adjust &lt;code>--max-num-seqs&lt;/code>, &lt;code>--gpu-memory-utilization&lt;/code>, priority classes and quotas based on what the pilot taught you.&lt;/p>
&lt;p>&lt;strong>Day 11-14 — Onboarding the second tenant plus iteration&lt;/strong>. Repeat. Every new tenant onboarded reveals new cases.&lt;/p>
&lt;p>After two weeks you have an operational platform with two real tenants and data to decide whether it is ready for more. The line of progress from here on is horizontal (more tenants) until you saturate; from then on, vertical (more hardware).&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Continuous fine-tuning in production&lt;/strong> (post 6, already decided): LoRA/QLoRA/DPO, dataset curation, eval gates, A/B versioning with real traffic between model versions.&lt;/li>
&lt;li>&lt;strong>Constitutional AI and alignment at runtime&lt;/strong>: an option still on the table.&lt;/li>
&lt;li>&lt;strong>Edge LLMs&lt;/strong>: when an H100 cluster is too expensive for a specific workload, distilled models running on NPUs or consumer GPUs.&lt;/li>
&lt;li>&lt;strong>GPU networking deep dive&lt;/strong>: NCCL, InfiniBand, GPUDirect, RDMA. For multi-node clusters with cross-host tensor parallel.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Multi-tenancy and GPU isolation:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.linuxoperatingsystem.net/multitenant-gpu-infrastructure-4-powerful-design-rules/">Multitenant GPU Infrastructure: 4 Powerful Design Rules&lt;/a> — survey of enterprise patterns.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/run-multiple-llms-one-gpu-mig-time-slicing-guide/">Run Multiple LLMs on One GPU: MIG, Time-Slicing, and MPS Guide (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://sagar-parmar.medium.com/a-practical-guide-to-gpu-partitioning-with-mig-on-on-prem-servers-and-kubernetes-797ccea7e1c7">A Practical Guide to GPU Partitioning with MIG (Medium)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.suse.com/c/kubecon-eu-2026-nvidia-mig-suse-virtualization/">GPU Partitioning for AI Workloads: NVIDIA MIG with SUSE Virtualization (KubeCon EU 2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2508.20274">Predictable LLM Serving on GPU Clusters (arxiv 2508.20274)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2603.00356">Token Management in Multi-Tenant AI Inference Platforms (arxiv 2603.00356)&lt;/a> — paper on priority + admission control.&lt;/li>
&lt;/ul>
&lt;p>AI Gateways:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/multi_tenant_architecture">LiteLLM — Multi-Tenant Architecture&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/users">LiteLLM — Budgets and Rate Limits&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://portkey.ai/">Portkey AI Gateway&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">Kong AI Gateway — LLM Cost Management&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/ai-gateway-litellm-portkey-kong-gpu-cloud/">AI Gateway Setup 2026: LiteLLM, Portkey, Kong (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://techsy.io/en/blog/best-llm-gateway-tools">Stop Juggling LLM APIs: 8 Gateways Ranked 2026 (TECHSY)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Multi-tenant FinOps:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.digiusher.com/blog/the-death-of-cost-allocation-why-chargeback-models-are-failing-in-the-kubernetes-and-ai-era/">The Death of Chargeback in the Kubernetes and AI Era (DigiUsher)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@nicholasthoni/how-to-actually-track-kubernetes-costs-in-2026-a-practical-guide-to-showback-chargeback-and-the-6a4c23f9cf51">How to Actually Track Kubernetes Costs in 2026 (Medium)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">LLM Cost Management: AI Showback and Chargeback (Kong)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kubecost.com/">Kubecost — cost allocation&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.finout.io/">Finout — FinOps + AI costs&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Previous posts in series 4: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps for LLMs overview&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage pipeline&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant&lt;/a>.&lt;/li>
&lt;li>Relevant posts from the inference series: &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> — the multi-GPU HGX node scenario we develop here. &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a> — the vLLM Production Stack and OME that the gateway can direct.&lt;/li>
&lt;li>Observability: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>vLLM on Kubernetes: the LLM inference piece that actually scales</title><link>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</link><pubDate>Mon, 18 May 2026 13:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM is the inference engine that turns a general-purpose GPU into a productive LLM server. Its value is not in running a model, since any &lt;code>transformers.pipeline&lt;/code> does that in three lines of Python, but in squeezing the GPU down to the last gigabyte and the last cycle: PagedAttention for the KV cache, &lt;em>continuous batching&lt;/em> to mix requests, its own scheduler to share GPU time between sessions. Kubernetes is its natural habitat because vLLM behaves like a modern UNIX process, with a health endpoint, Prometheus metrics, orderly draining and declarable resources, and K8s already knows how to manage those. But there are traps: the standard HPA does not scale vLLM well, the model takes minutes to load, and naive rolling updates cut sessions halfway through decoding. This article takes the engine apart and then fits it, with real manifests, into a cluster that can actually serve it.&lt;/p>
&lt;blockquote>
&lt;p>This article is the natural continuation of &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>. There we explained why every token consumes VRAM. Here we look at what you do with that VRAM when you want to offer it as a service.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-a-multiprocess-kernel-for-your-gpu">The analogy: a multiprocess kernel for your GPU&lt;/h2>
&lt;p>Imagine you have a single processor and need to serve a hundred concurrent processes without any of them blocking the rest. Nobody in their right mind would write a &lt;code>while-true&lt;/code> loop dispatching processes one by one: they would install an operating system. The kernel takes care of scheduling, memory paging, isolation, priorities and cleanup on exit. The &amp;ldquo;process&amp;rdquo; becomes a convenient abstraction and the kernel does the dirty work.&lt;/p>
&lt;p>vLLM is, for your GPU, what the kernel is for your CPU. As far as the GPU is concerned, a conversation with an LLM is a process that lives across many decoding steps, occupies a slice of VRAM (its KV cache) and demands compute time every time a token has to be generated. You have a hundred of those processes at once. You need to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Share GPU time between them&lt;/strong> without pausing any of them entirely (it would be disastrous if one long conversation monopolised the GPU).&lt;/li>
&lt;li>&lt;strong>Manage memory with paging&lt;/strong> because, just as in RAM, contiguous allocation is inefficient.&lt;/li>
&lt;li>&lt;strong>Queue new requests&lt;/strong> when the GPU is saturated and serve them in a reasonable order.&lt;/li>
&lt;li>&lt;strong>Reclaim resources&lt;/strong> when a session ends.&lt;/li>
&lt;/ul>
&lt;p>PagedAttention is the virtual memory of the KV cache. &lt;em>Continuous batching&lt;/em> is the time-slicing scheduler that shares the GPU token by token. The OpenAI-compatible server is the uniform syscall interface. Calling it a &amp;ldquo;kernel&amp;rdquo; for the GPU is marketing, but it is marketing that captures the idea well.&lt;/p>
&lt;h2 id="what-vllm-does-inside">What vLLM does inside&lt;/h2>
&lt;h3 id="continuous-batching-stop-waiting-for-the-slowest">Continuous batching: stop waiting for the slowest&lt;/h3>
&lt;p>The naive inference engine does &lt;em>static batching&lt;/em>: it groups N requests, processes them until all of them are finished, returns and starts another round. The problem is obvious: if one request asks for 8 tokens and another asks for 800, the other seven wait for the slow one. GPU utilisation collapses.&lt;/p>
&lt;p>&lt;em>Continuous batching&lt;/em> (Yu et al., 2022, popularised by vLLM) changes the model. At every decode step, which produces one token for each active session, the engine composes the batch out of the active tokens of ALL sessions alive at that instant. When a session finishes its generation it releases its slot immediately and another request from the queue takes it. The batch never waits for the slowest session because nobody is blocked: everyone advances at one token per step.&lt;/p>
&lt;p>The original paper measured 5–23× more throughput than the equivalent static batching. The exact number depends on how variable the response lengths are, but the order of magnitude holds up in practice.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Static vs continuous batching">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:11px sans-serif;fill:#444}.s1{fill:#2a9d8f}.s2{fill:#e76f51}.s3{fill:#264653}.s4{fill:#e9c46a}.empty{fill:#eee;stroke:#999;stroke-dasharray:3 2}&lt;/style>
&lt;text x="180" y="20" text-anchor="middle" class="title">Static batching&lt;/text>
&lt;text x="540" y="20" text-anchor="middle" class="title">Continuous batching&lt;/text>
&lt;text x="20" y="55" class="lbl">session 1&lt;/text>
&lt;text x="20" y="80" class="lbl">session 2&lt;/text>
&lt;text x="20" y="105" class="lbl">session 3&lt;/text>
&lt;text x="20" y="130" class="lbl">session 4&lt;/text>
&lt;rect x="70" y="40" width="40" height="20" class="s1"/>
&lt;rect x="70" y="65" width="120" height="20" class="s2"/>
&lt;rect x="70" y="90" width="60" height="20" class="s3"/>
&lt;rect x="70" y="115" width="30" height="20" class="s4"/>
&lt;rect x="110" y="40" width="80" height="20" class="empty"/>
&lt;rect x="130" y="90" width="60" height="20" class="empty"/>
&lt;rect x="100" y="115" width="90" height="20" class="empty"/>
&lt;text x="180" y="160" text-anchor="middle" class="lbl">empty slots wait for session 2&lt;/text>
&lt;rect x="380" y="40" width="40" height="20" class="s1"/>
&lt;rect x="420" y="40" width="80" height="20" class="s3"/>
&lt;rect x="500" y="40" width="40" height="20" class="s4"/>
&lt;rect x="540" y="40" width="40" height="20" class="s1"/>
&lt;rect x="380" y="65" width="120" height="20" class="s2"/>
&lt;rect x="500" y="65" width="40" height="20" class="s3"/>
&lt;rect x="540" y="65" width="80" height="20" class="s4"/>
&lt;rect x="380" y="90" width="60" height="20" class="s3"/>
&lt;rect x="440" y="90" width="50" height="20" class="s2"/>
&lt;rect x="490" y="90" width="40" height="20" class="s4"/>
&lt;rect x="530" y="90" width="100" height="20" class="s1"/>
&lt;rect x="380" y="115" width="30" height="20" class="s4"/>
&lt;rect x="410" y="115" width="80" height="20" class="s2"/>
&lt;rect x="490" y="115" width="60" height="20" class="s3"/>
&lt;rect x="550" y="115" width="80" height="20" class="s1"/>
&lt;text x="540" y="160" text-anchor="middle" class="lbl">slots reassigned token by token&lt;/text>
&lt;line x1="70" y1="190" x2="630" y2="190" stroke="#666"/>
&lt;text x="350" y="210" text-anchor="middle" class="lbl">time →&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The consequence for the operator is counterintuitive: &lt;strong>a single vLLM replica performs like three naive replicas&lt;/strong>. There is no point adding pods without justifying it with real metrics.&lt;/p>
&lt;h3 id="pagedattention-virtual-memory-for-the-kv-cache">PagedAttention: virtual memory for the KV cache&lt;/h3>
&lt;p>We already flagged it in the KV cache article: the naive engine reserves one contiguous block per session, sized for the &lt;em>worst case&lt;/em> (&lt;code>max_context_len&lt;/code>), and wastes 60–80% of the VRAM because real sessions come nowhere near their ceiling.&lt;/p>
&lt;p>PagedAttention borrows the solution operating systems have been using for half a century: split the VRAM into small blocks (16 tokens in the default implementation) and keep a logical → physical page table per session. A session with 273 tokens of context occupies 18 blocks (not necessarily contiguous), and grows block by block as it generates. The paper measured &amp;lt;4% waste, an order of magnitude better than contiguous allocation, and that translates into 2–4× more aggregate throughput on the same hardware, because more sessions fit at once.&lt;/p>
&lt;p>There is a cost: every attention operation has to be indirected through the page table. But vLLM&amp;rsquo;s CUDA kernels are written so that this indirection is cheap, and the net result is massively positive.&lt;/p>
&lt;h3 id="prefill-vs-decode-two-phases-with-opposite-profiles">Prefill vs decode: two phases with opposite profiles&lt;/h3>
&lt;p>An LLM request has two phases with radically different GPU profiles:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefill&lt;/strong>: processes the entire prompt in one go. It is &lt;em>compute-bound&lt;/em>: it uses the tensor cores intensively, the GPU sits above 90%, and it lasts between hundreds of ms and a few seconds depending on prompt size.&lt;/li>
&lt;li>&lt;strong>Decode&lt;/strong>: generates token by token. It is &lt;em>memory-bound&lt;/em>: the compute is modest but the whole KV cache has to be read for every token, and it lasts from tens of ms per token up to minutes for long responses.&lt;/li>
&lt;/ul>
&lt;p>A naive server treats each request as a unit and serves the two phases in series. vLLM decouples them: it mixes requests in prefill with requests in decode in the same step (a technique called &lt;em>chunked prefill&lt;/em> when it also splits long prefills into chunks). The result: the GPU is always busy doing &lt;em>something&lt;/em>, the tensor cores with prefills, the HBM bandwidth with decodes, instead of oscillating between phases.&lt;/p>
&lt;p>Operational implication: the &amp;ldquo;% GPU utilisation&amp;rdquo; metric from &lt;code>nvidia-smi&lt;/code> is misleading. A GPU at 100% doing prefills may have its HBM bandwidth idle. A GPU at 40% doing decodes may have its HBM saturated. For LLM serving, &lt;strong>the useful metric is effective HBM bandwidth&lt;/strong>, not the compute percentage.&lt;/p>
&lt;h3 id="tensor-parallel-when-the-model-does-not-fit-on-one-gpu">Tensor parallel: when the model does not fit on one GPU&lt;/h3>
&lt;p>Llama 3 70B in BF16 is ~140 GB. There is no single GPU on the market that can hold it. The solution is tensor parallel: split each model layer by columns and run the partitions on N GPUs in parallel, synchronising with an &lt;em>all-reduce&lt;/em> after each layer.&lt;/p>
&lt;p>For N=5 GPUs and a 70B model, each GPU sees roughly 28 GB of weights. That sounds fine until you remember that the all-reduce on every layer means reading and writing large tensors between GPUs. If the GPUs share NVLink/NVSwitch (300–900 GB/s), the all-reduce is cheap. If they only share PCIe (~32 GB/s gen4 x16), the all-reduce eats half the time and throughput collapses.&lt;/p>
&lt;p>The implication for K8s, which comes next: the scheduler has to guarantee that the N GPUs are physically close. That translates into NodeAffinity to the right product (&lt;code>NVIDIA-H100-80GB-HBM3&lt;/code>), a single pod with &lt;code>nvidia.com/gpu: N&lt;/code> (not N pods sharing) and, if multi-node is unavoidable, InfiniBand with NCCL as transport.&lt;/p>
&lt;h3 id="the-openai-compatible-server">The OpenAI-compatible server&lt;/h3>
&lt;p>On top of all of the above, vLLM exposes an HTTP server with endpoints identical to OpenAI&amp;rsquo;s: &lt;code>/v1/chat/completions&lt;/code>, &lt;code>/v1/completions&lt;/code>, &lt;code>/v1/embeddings&lt;/code>, &lt;code>/v1/models&lt;/code>. It supports Server-Sent Events streaming. It supports tool calling. It supports logprobs.&lt;/p>
&lt;p>The value of this is enormous and underrated: &lt;strong>any client using the OpenAI SDK works without changes&lt;/strong>. Your application points at &lt;code>https://vllm.your-cluster.local/v1&lt;/code> instead of &lt;code>https://api.openai.com/v1&lt;/code>, and everything else, the LangChain, LlamaIndex, OpenAI Python and OpenAI JS SDKs, just works. It is the main reason vLLM has gained traction over technically comparable alternatives: &lt;strong>it is the boring option that works&lt;/strong>.&lt;/p>
&lt;h2 id="why-kubernetes-is-the-natural-habitat">Why Kubernetes is the natural habitat&lt;/h2>
&lt;p>vLLM is a well-behaved process: it starts, exposes metrics, serves a health endpoint, takes SIGTERM with dignity and declares the resources it needs. Kubernetes has spent ten years perfecting the management of processes like that. The only thing K8s took a while to absorb properly was the GPU, and that is now solved.&lt;/p>
&lt;h3 id="the-gpu-as-a-primitive-resource">The GPU as a primitive resource&lt;/h3>
&lt;p>The plumbing goes like this:&lt;/p>
&lt;ol>
&lt;li>The node has the NVIDIA driver installed (or the GPU Operator installs it).&lt;/li>
&lt;li>A DaemonSet, nvidia-device-plugin, registers the physical GPUs as &lt;code>nvidia.com/gpu&lt;/code> resources with kubelet.&lt;/li>
&lt;li>The Kubernetes scheduler sees those resources the way it sees CPU and memory, puts them in its accounting and assigns them to Pods that ask for them.&lt;/li>
&lt;li>The nvidia-container-toolkit makes sure containerd injects the right devices into the container at startup.&lt;/li>
&lt;/ol>
&lt;p>For the pod, asking for a GPU looks like this:&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">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">requests&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="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">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="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without MIG, MPS or time-slicing configured, &lt;strong>a GPU is not shared between pods&lt;/strong>: you ask for it whole or you do not ask for it. For vLLM, which wants the entire GPU to itself, that is exactly what you want.&lt;/p>
&lt;h3 id="the-vllm-pod-lifecycle">The vLLM Pod lifecycle&lt;/h3>
&lt;p>Differences from a typical webapp Pod:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Long startup&lt;/strong>. Loading 16 GB of weights into VRAM over the network takes 30 seconds in the best case and 5 minutes in the worst. A &lt;code>readinessProbe&lt;/code> with &lt;code>initialDelaySeconds: 30&lt;/code> and &lt;code>failureThreshold: 3&lt;/code> kills the pod before it starts. Solution: a &lt;code>startupProbe&lt;/code> with a high threshold before the &lt;code>livenessProbe&lt;/code> starts evaluating.&lt;/li>
&lt;li>&lt;strong>Warm-up matters&lt;/strong>. The first prefill compiles CUDA kernels specific to the input shape. The first 2–3 requests are noticeably slower. If latency matters from second one, it is worth firing a warm-up POST after ready.&lt;/li>
&lt;li>&lt;strong>Draining is not instantaneous&lt;/strong>. SIGTERM must not kill in-flight sessions. vLLM, configured with &lt;code>--disable-graceful-shutdown false&lt;/code> (the default), finishes active requests before closing. That can take 30–180 seconds. &lt;code>terminationGracePeriodSeconds&lt;/code> has to accommodate it.&lt;/li>
&lt;li>&lt;strong>Hostile rollouts&lt;/strong>. A naive rolling update (&lt;code>maxUnavailable: 1&lt;/code>) can leave you with no replicas serving if the new one is slow to load. Set &lt;code>maxSurge: 1, maxUnavailable: 0&lt;/code> so the new pod is Ready before the old one is drained.&lt;/li>
&lt;/ul>
&lt;h2 id="anatomy-of-a-serious-deployment">Anatomy of a serious deployment&lt;/h2>
&lt;h3 id="first-of-all-gpu-operator">First of all: GPU Operator&lt;/h3>
&lt;p>Without the GPU Operator (or an equivalent manual installation), a Pod with &lt;code>nvidia.com/gpu: 1&lt;/code> stays Pending forever. What the operator installs as DaemonSets on every GPU node:&lt;/p>
&lt;ul>
&lt;li>&lt;code>nvidia-driver-daemonset&lt;/code> — the kernel-mode driver (if you do not have it installed at host level).&lt;/li>
&lt;li>&lt;code>nvidia-device-plugin-daemonset&lt;/code> — registers the GPUs as a kubelet resource.&lt;/li>
&lt;li>&lt;code>nvidia-container-toolkit-daemonset&lt;/code> — the containerd integration.&lt;/li>
&lt;li>&lt;code>nvidia-dcgm-exporter&lt;/code> — Prometheus metrics for the GPU (utilisation, temperature, ECC errors, memory).&lt;/li>
&lt;li>&lt;code>gpu-feature-discovery&lt;/code> — node labels: &lt;code>nvidia.com/gpu.product&lt;/code>, &lt;code>nvidia.com/gpu.memory&lt;/code>, and so on, essential for NodeAffinity.&lt;/li>
&lt;/ul>
&lt;p>The recommended installation is the official Helm chart. The delicate part is aligning the driver with the host kernel version: if the nodes run kernel 6.x, the operator needs a compatible driver branch.&lt;/p>
&lt;h3 id="a-complete-annotated-vllm-deployment">A complete, annotated vLLM Deployment&lt;/h3>
&lt;p>The following deploys Llama 3 8B with an FP8-quantised KV cache, up to 32K of context, on an RTX 4090. It is the reference manifest; the comments explain the non-obvious decisions.&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-llama3-8b&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">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">strategy&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">RollingUpdate&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">rollingUpdate&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">maxSurge&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">maxUnavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># never run out of replicas during the rollout&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&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-llama3-8b&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&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-llama3-8b&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">annotations&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">prometheus.io/scrape&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;true&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">prometheus.io/port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8000&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">prometheus.io/path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/metrics&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">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="c"># Only nodes with the GPU we expect&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-GeForce-RTX-4090&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="c"># Pre-download the weights if they are not on the shared 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">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">model-download&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">ghcr.io/huggingface/huggingface-cli:latest&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;sh&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-c&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">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="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"> if [ ! -f /models/llama-3-8b/config.json ]; then
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> huggingface-cli download meta-llama/Meta-Llama-3-8B-Instruct \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --local-dir /models/llama-3-8b --local-dir-use-symlinks False
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> fi&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">env&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">HF_TOKEN&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">valueFrom&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">secretKeyRef&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">huggingface&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">token&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="nt">name&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">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">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.6.3&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-8b&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">served-model-name=llama-3-8b&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=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="l">max-model-len=32768&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">kv-cache-dtype=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">enable-chunked-prefill&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">gpu-memory-utilization=0.92&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">port=8000&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="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&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">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>&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">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">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="c"># same port as http; /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">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">requests&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">cpu&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">8Gi&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="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">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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&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">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="nt">nvidia.com/gpu&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">startupProbe&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">/health&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;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">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 10 min of grace to load the model&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>&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">/health&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;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">5&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">livenessProbe&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">/health&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;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">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">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&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="nt">name&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">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 class="c"># no process should write here at runtime&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">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">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 class="c"># vLLM uses shared memory for IPC between workers&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">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">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">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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">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">emptyDir&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">medium&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Memory&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">sizeLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">4Gi&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 class="c"># accommodates draining of active sessions&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">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">Service&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-llama3-8b&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">selector&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&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="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&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">80&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">targetPort&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>Five things that are not visible on a first read:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>/dev/shm&lt;/code> in memory, 4 GB&lt;/strong>. vLLM launches worker processes (one per GPU in tensor parallel, plus the driver) that communicate over shared memory. Docker&amp;rsquo;s default (64 MB) blows up as soon as the model is mid-sized. Without this, the pod starts but fails the moment it serves the first complex request.&lt;/li>
&lt;li>&lt;strong>&lt;code>--enable-prefix-caching&lt;/code>&lt;/strong>. If the prompts in your workload share structure (a common system prompt, few-shot examples), vLLM reuses the KV cache of the shared part. A free 30–60% gain in TTFT.&lt;/li>
&lt;li>&lt;strong>&lt;code>--gpu-memory-utilization=0.92&lt;/code>&lt;/strong>. vLLM reserves the given percentage of the VRAM for itself. The remaining 8% leaves room for activations, CUDA kernels and the overhead that does not get counted. Lowering it buys safety; raising it above 0.95 invites OOM.&lt;/li>
&lt;li>&lt;strong>A &lt;code>ReadOnlyMany&lt;/code> PVC&lt;/strong> is ideal. The model does not change at runtime. Several pods can mount the same PVC without contention.&lt;/li>
&lt;li>&lt;strong>No &lt;code>livenessProbe&lt;/code> shorter than the &lt;code>terminationGracePeriodSeconds&lt;/code>&lt;/strong>. If a drain takes 90s and liveness kills at 60s, rollouts lose sessions.&lt;/li>
&lt;/ol>
&lt;h3 id="multi-pod-tensor-parallel-leaderworkerset">Multi-pod tensor parallel: LeaderWorkerSet&lt;/h3>
&lt;p>When the model needs more GPUs than a single node has, the pattern is a group of coordinated pods, one per GPU, that behave as a single replica. For years this was modelled with a StatefulSet plus init scripts; since Kubernetes 1.32, the idiomatic primitive is LeaderWorkerSet (LWS):&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">leaderworkerset.x-k8s.io/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">LeaderWorkerSet&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-llama3-70b&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">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">leaderWorkerTemplate&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">size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 1 leader + 4 workers = 5 pods, 5 GPUs&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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RecreateGroupOnPodRestart&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">leaderTemplate&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">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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&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-leader&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.6.3&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-70b&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=5&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">distributed-executor-backend=ray&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="c"># ...&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">workerTemplate&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">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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&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-worker&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.6.3&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="c"># the workers join the leader&amp;#39;s Ray cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>LWS guarantees startup order (workers first, leader afterwards) and an atomic lifecycle (if a worker goes down, the whole group restarts, not a single pod). Without it, the coordination is manually fragile.&lt;/p>
&lt;p>A simpler alternative, if all the tensor parallel GPUs fit on a single node (the case with HGX H100 boxes with 8 GPUs and an internal NVSwitch): one single Pod with &lt;code>nvidia.com/gpu: 5&lt;/code>, &lt;code>--tensor-parallel-size=5&lt;/code>, and vLLM handles everything internally. No Ray, no LWS, far simpler. It is the recommended path whenever it is available.&lt;/p>
&lt;h3 id="autoscaling-the-standard-hpa-is-no-use">Autoscaling: the standard HPA is no use&lt;/h3>
&lt;p>An HPA on CPU% is useless for vLLM. The GPU does the work; the pod&amp;rsquo;s CPU sits at 5–10% even under maximum load. Nor is the GPU utilisation percentage from &lt;code>dcgm-exporter&lt;/code> any use: a pod at 100% GPU% with &lt;code>gpu_cache_usage_perc=15%&lt;/code> is serving one long session without being saturated, while a pod at 60% GPU% with &lt;code>gpu_cache_usage_perc=95%&lt;/code> is on the edge of evicting sessions.&lt;/p>
&lt;p>The right metrics are exported by vLLM itself at &lt;code>/metrics&lt;/code> (Prometheus format):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What it says&lt;/th>
&lt;th>When to scale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>Requests queued and not yet in the batch.&lt;/td>
&lt;td>If it stays above 5–10.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_running&lt;/code>&lt;/td>
&lt;td>Active requests in the batch.&lt;/td>
&lt;td>For capacity planning, not for scaling.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>% of the KV cache occupied.&lt;/td>
&lt;td>If sustained above 80%, there is a risk of preemption.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/td>
&lt;td>Prefill latency (histogram).&lt;/td>
&lt;td>If p95 exceeds your SLA.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:e2e_request_latency_seconds&lt;/code>&lt;/td>
&lt;td>Total latency per request.&lt;/td>
&lt;td>An output metric.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For the HPA to consume them there are two routes: Prometheus Adapter (exposes custom metrics to the K8s API) or KEDA (scales on Prometheus queries directly, far more convenient). With KEDA:&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-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-llama3-8b&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">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">maxReplicaCount&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>&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">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">cooldownPeriod&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="c"># 2 min before scale-down (long sessions)&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.monitoring: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">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;5&amp;#39;&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"> sum(vllm:num_requests_waiting{app=&amp;#34;vllm-llama3-8b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The long &lt;code>cooldownPeriod&lt;/code> matters: if you drop replicas while there are sessions decoding, you kill them. Better to leave 2 minutes of slack.&lt;/p>
&lt;h3 id="observability-the-four-metrics-that-matter">Observability: the four metrics that matter&lt;/h3>
&lt;p>Of everything &lt;code>/metrics&lt;/code> exports, a minimal dashboard needs these four:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>TTFT p50/p95&lt;/strong> (time to first token) — what the user perceives when they hit send.&lt;/li>
&lt;li>&lt;strong>TPOT p50/p95&lt;/strong> (time per output token) — the &amp;ldquo;speed&amp;rdquo; of the streaming.&lt;/li>
&lt;li>&lt;strong>Aggregate throughput&lt;/strong> (tokens generated per second across the cluster) — for capacity planning.&lt;/li>
&lt;li>&lt;strong>Queue depth&lt;/strong> (&lt;code>vllm:num_requests_waiting&lt;/code>) — the leading indicator: if it grows, everything is about to degrade.&lt;/li>
&lt;/ol>
&lt;p>To that, add HBM utilisation and free memory per GPU (from &lt;code>dcgm-exporter&lt;/code>) to spot bandwidth saturation and fragmentation problems. A decent Grafana dashboard with those 6 charts anticipates 90% of incidents.&lt;/p>
&lt;h2 id="two-concrete-scenarios">Two concrete scenarios&lt;/h2>
&lt;p>We reuse the same hardware as the previous article for continuity. The same cache arithmetic, now with the engine on top.&lt;/p>
&lt;h3 id="scenario-a--1rtx-4090-workstation-or-small-k8s-node">Scenario A — 1×RTX 4090 (workstation or small K8s node)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod, &lt;code>--tensor-parallel-size=1&lt;/code>, 1 GPU, 1 node.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 8B BF16 (Llama 3 8B, Qwen3 8B, Mistral 7B) or up to 14B in FP8/AWQ.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: the node&amp;rsquo;s local SSD. The 4090 reads 1 TB/s from HBM; an NVMe SSD at 5 GB/s takes 5 seconds to feed 25 GB of weights into VRAM, negligible next to initialisation.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 4090 (always 1 vLLM replica per GPU), but useful across nodes: 3 replicas on 3 nodes with a 4090 each, and the K8s Service spreads round-robin.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 4–8 simultaneous sessions with 8K of context, 1–2 with 32K.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: PoC, small teams, departmental environments, edge.&lt;/li>
&lt;/ul>
&lt;p>The manifest above is sized for this scenario. Changing only the model and the args, the same Deployment serves Qwen, Mistral or whatever is next.&lt;/p>
&lt;h3 id="scenario-b--5h100-sxm-cluster-with-nvlinknvswitch">Scenario B — 5×H100 SXM (cluster with NVLink/NVSwitch)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod with &lt;code>nvidia.com/gpu: 5&lt;/code> on an HGX node, &lt;code>--tensor-parallel-size=5&lt;/code>. If the platform does not allow grouping 5 GPUs into a single Pod, a LeaderWorkerSet with 5 pods coordinated by Ray.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 70B BF16 (Llama 3 70B) or up to 200B+ in FP8 with cache quantisation.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: NVMe attached directly to the node, or fast network storage (Ceph with a 25/100 GbE network, Lustre, GPFS). Loading 140 GB of weights over a slow network takes 5 minutes per start.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 5-GPU cluster (the 5 are an indivisible unit), but useful for adding whole HGX nodes when the load passes a given threshold. This combines with Cluster Autoscaler if the underlying infrastructure allows it.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 32–128 simultaneous sessions with mid-sized contexts, 4–16 with huge contexts.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: internal corporate service, public exposure with an SLA, multi-tenant.&lt;/li>
&lt;/ul>
&lt;h3 id="a-and-b-side-by-side">A and B, side by side&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Aspect&lt;/th>
&lt;th>A (1×4090)&lt;/th>
&lt;th>B (5×H100 SXM)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pod topology&lt;/td>
&lt;td>1 pod, 1 GPU&lt;/td>
&lt;td>1 pod with 5 GPUs (or an LWS of 5)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum BF16 model&lt;/td>
&lt;td>8 B&lt;/td>
&lt;td>70 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT @ 8K context, idle&lt;/td>
&lt;td>~250 ms&lt;/td>
&lt;td>~80 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT, idle&lt;/td>
&lt;td>~30 ms/tok&lt;/td>
&lt;td>~15 ms/tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput @ concurrency 16&lt;/td>
&lt;td>~50 tok/s/session&lt;/td>
&lt;td>~200 tok/s/session&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Session drain&lt;/td>
&lt;td>30–60 s&lt;/td>
&lt;td>60–180 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Useful autoscaling&lt;/td>
&lt;td>Replicas on peer nodes&lt;/td>
&lt;td>Whole nodes via Cluster Autoscaler&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasonable multi-tenancy&lt;/td>
&lt;td>Limited: 4–8 sessions&lt;/td>
&lt;td>Comfortable: 32–128 sessions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indicative cost (hardware)&lt;/td>
&lt;td>~2 K €&lt;/td>
&lt;td>~250 K € (≈ 125×)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The asymmetry is the same one as in the previous article: 125× more expensive, only ~4× more throughput per session and ~10× more concurrency. What the cluster buys is not proportional; it buys &lt;strong>access to models an order of magnitude larger&lt;/strong> and &lt;strong>latencies low enough for interactive use at scale&lt;/strong>. If your workload is batch or asynchronous agents where latency is not critical, several 4090s come surprisingly close.&lt;/p>
&lt;h2 id="vllm-against-tensorrt-llm-and-sglang">vLLM against TensorRT-LLM and SGLang&lt;/h2>
&lt;p>Honestly, all three are good engines. The choice depends on practical criteria, not technical ones. A decision map, not a benchmark:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>vLLM&lt;/th>
&lt;th>TensorRT-LLM&lt;/th>
&lt;th>SGLang&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA, AMD ROCm, Intel Gaudi&lt;/td>
&lt;td>NVIDIA only&lt;/td>
&lt;td>NVIDIA, AMD ROCm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pure latency (TTFT)&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>&lt;strong>Best&lt;/strong>: kernels compiled for the exact hardware&lt;/td>
&lt;td>Good&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Aggregate throughput&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent (RadixAttention)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Deployment&lt;/td>
&lt;td>&lt;strong>Trivial&lt;/strong>: Docker image + args&lt;/td>
&lt;td>Complex: build an engine per model and per GPU&lt;/td>
&lt;td>Moderate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenAI-compatible API&lt;/td>
&lt;td>&lt;strong>Native, complete&lt;/strong>&lt;/td>
&lt;td>Yes, through Triton Inference Server&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support for new models&lt;/td>
&lt;td>&lt;strong>Days after release&lt;/strong>&lt;/td>
&lt;td>Weeks (recompile the engine)&lt;/td>
&lt;td>Days&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantization&lt;/td>
&lt;td>AWQ, GPTQ, FP8 cache&lt;/td>
&lt;td>INT4/INT8/FP8, very mature&lt;/td>
&lt;td>AWQ, FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-modal&lt;/td>
&lt;td>Yes (Llava, Pixtral, Qwen-VL)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Excellent, a priority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Function calling / tool use&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>&lt;strong>First class&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Community / release cadence&lt;/td>
&lt;td>Very active, weekly&lt;/td>
&lt;td>Active, NVIDIA-driven&lt;/td>
&lt;td>Very active, academic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>When to choose each one&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>vLLM&lt;/strong>: the &amp;ldquo;boring choice&amp;rdquo; that works. The path of least friction to production. If your team has no dedicated inference serving specialist, this one. It supports varied hardware, up-to-date models, a stable API and a huge community.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>TensorRT-LLM&lt;/strong>: when per-request latency is the only metric that matters and your model is stable (trained in-house, not swapped every fortnight). The price of the performance is that every model plus every GPU plus every TRT version requires an engine rebuild, and that blocks fast iteration.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>SGLang&lt;/strong>: for agent-dominated workloads (heavy tool calling) or complex multi-modal. Its RadixAttention, structural caching of prompts with shared prefixes, shines in ReAct-style patterns where the same system prompt is repeated thousands of times.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>For most teams starting out with on-prem LLM serving, &lt;strong>vLLM is the right answer until you have production data pushing you elsewhere&lt;/strong>.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;p>A list of gotchas that come up again and again:&lt;/p>
&lt;h3 id="the-model-is-downloaded-on-every-rolling-update">The model is downloaded on every rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: every deploy takes 5+ minutes to become available.
&lt;strong>Cause&lt;/strong>: there is no shared PVC. Every new pod downloads the model from Hugging Face from scratch.
&lt;strong>Fix&lt;/strong>: a ReadOnlyMany PVC on fast storage, or a local mirror of the registry (a Pod with &lt;code>huggingface-cli&lt;/code> serving a directory over HTTP). In CI/CD, hydrating the PVC before the rollout is 1 line of bash.&lt;/p>
&lt;h3 id="short-readiness-timeouts-that-kill-pods-while-they-load">Short readiness timeouts that kill pods while they load&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: new pods go into &lt;code>CrashLoopBackOff&lt;/code> during the first model load.
&lt;strong>Cause&lt;/strong>: a &lt;code>readinessProbe&lt;/code> with too low a timeout fires before vLLM finishes loading; the &lt;code>livenessProbe&lt;/code> finishes the job.
&lt;strong>Fix&lt;/strong>: a &lt;code>startupProbe&lt;/code> with &lt;code>failureThreshold: 60&lt;/code> or more (10 minutes of grace) before liveness starts evaluating.&lt;/p>
&lt;h3 id="an-unquantised-kv-cache-then-oom">An unquantised KV cache, then OOM&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the pod starts fine, serves for five minutes, then gets OOMKilled when session number five arrives with a long context.
&lt;strong>Cause&lt;/strong>: a BF16 KV cache (the default) consumes twice what FP8 does.
&lt;strong>Fix&lt;/strong>: &lt;code>--kv-cache-dtype=fp8&lt;/code>. Negligible quality loss in the vast majority of cases, double the capacity.&lt;/p>
&lt;h3 id="confusing-replicas-with-concurrency">Confusing replicas with concurrency&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the HPA scales to 8 replicas under light real load and the cloud bill goes up. Latency does not improve.
&lt;strong>Cause&lt;/strong>: someone configured &lt;code>targetAverageUtilization: 50%&lt;/code> on CPU, thinking that is &amp;ldquo;load&amp;rdquo;. The reality: a single vLLM replica serves dozens of simultaneous sessions.
&lt;strong>Fix&lt;/strong>: an HPA on &lt;code>vllm:num_requests_waiting&lt;/code>. If the queue is empty, one replica is enough even if the GPU is at 90%.&lt;/p>
&lt;h3 id="tensor-parallel-on-gpus-without-nvlink">Tensor parallel on GPUs without NVLink&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: throughput 3× worse than expected, GPUs at 30%, a lot of PCIe traffic.
&lt;strong>Cause&lt;/strong>: &lt;code>tensor_parallel=4&lt;/code> on 4 GPUs connected only by PCIe; the all-reduce saturates the bus on every layer.
&lt;strong>Fix&lt;/strong>: either the GPUs share NVLink/NVSwitch (SXM/HGX models), or you use pipeline parallel (worse latency but less all-reduce), or you reduce TP and accept that the whole model does not fit.&lt;/p>
&lt;h3 id="sessions-cut-off-during-a-rolling-update">Sessions cut off during a rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: users see truncated responses during the deploy.
&lt;strong>Cause&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 30&lt;/code> (the default) is not enough to drain long generations.
&lt;strong>Fix&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 120–180&lt;/code>. Combined with &lt;code>maxUnavailable: 0&lt;/code>, rollouts are invisible to active users.&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>vLLM with hot LoRA adapters&lt;/strong>: serving a base model plus N tenant-specific adapters without reloading weights.&lt;/li>
&lt;li>&lt;strong>Disaggregated serving&lt;/strong>: separating prefill and decode into specialised pods, each optimised for its GPU profile.&lt;/li>
&lt;li>&lt;strong>Quantization deep-dive&lt;/strong>: AWQ vs GPTQ vs dynamic FP8 vs FP4, the real trade-offs, and when to use each.&lt;/li>
&lt;li>&lt;strong>Gateway API + AI Inference Extensions&lt;/strong>: the sigwg proposal to make LLMs first-class citizens in K8s (routing by model, sticky sessions per conversation, multi-tenant fairness).&lt;/li>
&lt;li>&lt;strong>Multi-modal serving&lt;/strong>: the same runtime, a different kind of request — images, audio, embeddings.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Comparing serving engines (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — how vLLM behaves against SGLang and TRT-LLM on the goodput-latency Pareto frontier: the data that justifies (or questions) the choice of vLLM on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">Cloud GPU: comparing prices, commitment and sovereign neoclouds&lt;/a> — if the Kubernetes cluster is not on-premise but cloud, here are the spot and reserved prices of the European neoclouds where you would run these manifests.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kwon et al., &lt;a href="https://arxiv.org/abs/2309.06180">&lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em>&lt;/a> (SOSP 2023) — the original vLLM paper.&lt;/li>
&lt;li>Yu et al., &lt;a href="https://www.usenix.org/conference/osdi22/presentation/yu">&lt;em>Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/em>&lt;/a> (OSDI 2022) — the paper that popularised &lt;em>continuous batching&lt;/em>.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/">Official vLLM documentation&lt;/a> — operational and well maintained.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">NVIDIA GPU Operator&lt;/a> — installation and troubleshooting of the GPU layer on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://kubernetes.io/blog/2024/04/16/introducing-leaderworkerset/">LeaderWorkerSet&lt;/a> — the primitive for coordinated workloads such as multi-pod tensor parallel.&lt;/li>
&lt;li>&lt;a href="https://keda.sh/">KEDA&lt;/a> — event-driven autoscaling, ideal for scaling on queue metrics.&lt;/li>
&lt;li>&lt;a href="https://github.com/NVIDIA/TensorRT-LLM">TensorRT-LLM&lt;/a> and &lt;a href="https://github.com/sgl-project/sglang">SGLang&lt;/a> — the two most serious comparables.&lt;/li>
&lt;li>&lt;a href="https://lmsys.org/">LMSYS Chatbot Arena&lt;/a> — periodic benchmarks comparing the three engines.&lt;/li>
&lt;li>Previous article on this blog: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>