<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Josef Doornink</title>
    <description>The latest articles on DEV Community by Josef Doornink (@josef_doornink_930b2caf1c).</description>
    <link>https://dev.to/josef_doornink_930b2caf1c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4064995%2Fc55026b1-2514-426f-8d4f-08af7cae46f6.png</url>
      <title>DEV Community: Josef Doornink</title>
      <link>https://dev.to/josef_doornink_930b2caf1c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/josef_doornink_930b2caf1c"/>
    <language>en</language>
    <item>
      <title>Standing Up a GPU Cluster on AKS for vLLM</title>
      <dc:creator>Josef Doornink</dc:creator>
      <pubDate>Sun, 30 Aug 2026 00:07:52 +0000</pubDate>
      <link>https://dev.to/josef_doornink_930b2caf1c/standing-up-a-gpu-cluster-on-aks-for-vllm-bif</link>
      <guid>https://dev.to/josef_doornink_930b2caf1c/standing-up-a-gpu-cluster-on-aks-for-vllm-bif</guid>
      <description>&lt;p&gt;This article is &lt;em&gt;Part of a series on running vLLM on AKS&lt;/em&gt; and walks through creating an AKS cluster with a GPU node pool, deploying vLLM onto it, and wiring up Prometheus and Grafana for visibility.&lt;/p&gt;

&lt;p&gt;Companion pieces: &lt;br&gt;
&lt;a href="https://dev.to/josef_doornink_930b2caf1c/choosing-the-right-gpu-for-your-model-a-sizing-method-not-a-guess-4fe5"&gt;Choosing the right GPU&lt;/a&gt; | &lt;a href="https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf"&gt;Why your autoscaler flaps&lt;/a&gt;| &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/write-up-infrastructure.md" rel="noopener noreferrer"&gt;Source&lt;/a&gt;  &lt;/p&gt;
&lt;h2&gt;
  
  
  Setup Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud:&lt;/strong&gt; Azure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU node:&lt;/strong&gt; &lt;code&gt;Standard_NV36ads_A10_v5&lt;/code&gt; (1× A10, 24 GB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image / model:&lt;/strong&gt; &lt;code&gt;vllm/vllm-openai:latest&lt;/code&gt; serving &lt;code&gt;Qwen/Qwen2.5-7B-Instruct-AWQ&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability:&lt;/strong&gt; kube-prometheus-stack (Prometheus + Grafana), KEDA, NVIDIA DCGM exporter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All commands below are bash. The steps are ordered and each one depends on the previous.&lt;/p&gt;


&lt;h2&gt;
  
  
  Dependency chain
&lt;/h2&gt;

&lt;p&gt;The build order follows one chain: &lt;strong&gt;model → VRAM requirement → GPU SKU → region availability → quota.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 0 — Prerequisites (one-time, survives resource group deletion)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GPU quota.&lt;/strong&gt; Request through Portal → Quotas → Compute →  &lt;br&gt;
This article: Requested &lt;code&gt;Standard NVADSA10v5 Family vCPUs = 108&lt;/code&gt; in &lt;code&gt;westus&lt;/code&gt; (108 = 3 nodes × 36 vCPUs, matching the autoscaler's &lt;code&gt;max-count 3&lt;/code&gt; set in step 3).&lt;/p&gt;

&lt;p&gt;Quota is granted per-subscription and survives resource group deletion, so this step happens once, not on every rebuild.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A quota is Azure's per-subscription limit on how much of a resource (here, GPU vCPUs in a specific VM family) you're allowed to provision at once. New subscriptions start at 0 for GPU families since it's expensive and can be abused. &lt;br&gt;
You need it because without an approval, az aks nodepool add for a GPU will fail outright. The request goes through manual Azure approval, so it has to happen before you plan to build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;*&lt;em&gt;Prerequisites *&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing Azure Subscription:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Local tooling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Helm 3+&lt;/li&gt;
&lt;li&gt;kubectl&lt;/li&gt;
&lt;li&gt;Bash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bash Variables to set for use through the setup&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;resource-group-name&amp;gt;
&lt;span class="nv"&gt;CLUSTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;cluster-name&amp;gt;
&lt;span class="nv"&gt;LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;preferred-location&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 1 — Create Resource group
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group create &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;$LOCATION&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — Create AKS cluster, on a CPU system pool
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 1 &lt;span class="nt"&gt;--node-vm-size&lt;/span&gt; Standard_D2s_v5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--generate-ssh-keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The GPU does not go on this pool. Every AKS cluster requires a system node pool for cluster-critical pods (CoreDNS, metrics-server), and system pools cannot scale to zero — so a GPU placed here runs, and bills, 24/7 regardless of load. A &lt;code&gt;D2s_v5&lt;/code&gt; CPU node covers the system pods cheaply; the GPU pool created in step 3 is where scale-to-zero actually happens.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3 — GPU node pool, tainted and scaled from zero
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks nodepool add &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; gpu &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-vm-size&lt;/span&gt; Standard_NV36ads_A10_v5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 0 &lt;span class="nt"&gt;--enable-cluster-autoscaler&lt;/span&gt; &lt;span class="nt"&gt;--min-count&lt;/span&gt; 0 &lt;span class="nt"&gt;--max-count&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-taints&lt;/span&gt; &lt;span class="nv"&gt;sku&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpu:NoSchedule &lt;span class="nt"&gt;--labels&lt;/span&gt; &lt;span class="nv"&gt;sku&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each flag does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--node-count 0&lt;/code&gt; + &lt;code&gt;--enable-cluster-autoscaler&lt;/code&gt;&lt;/strong&gt; — scale-from-zero. No node exists until a pod requires one, so there's no GPU spend until step 7. The cluster autoscaler evaluates a pending pod against the pool's declared taints/labels/VM size to decide whether it would fit, then provisions a node if so — which is why the taint and label must be set on the pool at creation, not discovered later from a running node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--min-count 0 --max-count 3&lt;/code&gt;&lt;/strong&gt; — bounds the pool between 0 and 3 nodes. (Design decision)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--node-taints sku=gpu:NoSchedule&lt;/code&gt;&lt;/strong&gt; — blocks ordinary CPU pods from landing on the GPU node once it exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--labels sku=gpu&lt;/code&gt;&lt;/strong&gt; — the label the vLLM pod's &lt;code&gt;nodeSelector&lt;/code&gt; targets in step 7.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Taint, toleration, and nodeSelector do three separate jobs: the taint repels pods by default, a toleration permits a specific pod to ignore that taint, and a nodeSelector steers a pod toward a specific node. A toleration alone doesn't guarantee placement — it only lifts the block. The vLLM pod spec in step 7 carries both the toleration and the nodeSelector because both are required.&lt;/p&gt;

&lt;p&gt;The above scaling sets the --node-count and --min-count to 0; this may or may not be desirable for your use case. Keeping a node or 2 warm can help with latency, but there is cost associated with that. Choose whichever best fits your use case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4 — Get kubeconfig for cluster communication
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks get-credentials &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;
kubectl get nodes          &lt;span class="c"&gt;# expect only the system node — the GPU pool is still at 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5 — NVIDIA device plugin
&lt;/h2&gt;

&lt;p&gt;AKS does not install this by default. Without it, a GPU node never advertises &lt;code&gt;nvidia.com/gpu&lt;/code&gt; as an allocatable resource, and any pod requesting &lt;code&gt;nvidia.com/gpu: "1"&lt;/code&gt; stays &lt;code&gt;Pending&lt;/code&gt; indefinitely with no error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.17.1/deployments/static/nvidia-device-plugin.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upstream DaemonSet only tolerates the standard &lt;code&gt;nvidia.com/gpu&lt;/code&gt; taint, not the custom &lt;code&gt;sku=gpu&lt;/code&gt; taint set in step 3, so it won't schedule onto the GPU node without a patch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl patch daemonset nvidia-device-plugin-daemonset &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'[{"op":"add","path":"/spec/template/spec/tolerations/-","value":{"operator":"Exists"}}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;Pending&lt;/code&gt; vLLM pod looks identical whether the device plugin is missing, mis-scheduled, or the node just hasn't scaled up yet. &lt;code&gt;kubectl describe node -l sku=gpu&lt;/code&gt; and checking for &lt;code&gt;nvidia.com/gpu&lt;/code&gt; under &lt;code&gt;Allocatable&lt;/code&gt; distinguishes between the three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Observability core: Prometheus + Grafana + KEDA
&lt;/h2&gt;

&lt;p&gt;NOTE: Installed before the GPU node scales up, so the stack builds on the free CPU pool. Values file: &lt;code&gt;observability/kps-values.yaml&lt;/code&gt; — 6-hour retention, an 8 Gi PV on &lt;code&gt;managed-csi&lt;/code&gt;, Grafana on &lt;code&gt;ClusterIP&lt;/code&gt;, Alertmanager disabled.&lt;/p&gt;

&lt;p&gt;No StorageClass to create beforehand — AKS ships a built-in &lt;code&gt;managed-csi&lt;/code&gt; class (provisioner &lt;code&gt;disk.csi.azure.com&lt;/code&gt;, &lt;code&gt;WaitForFirstConsumer&lt;/code&gt; binding), which the Prometheus PVC above uses directly.&lt;/p&gt;

&lt;p&gt;Add the chart repos first (one-time per workstation):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; kps prometheus-community/kube-prometheus-stack &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; observability/kps-values.yaml &lt;span class="nt"&gt;--timeout&lt;/span&gt; 10m

helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; keda kedacore/keda &lt;span class="nt"&gt;-n&lt;/span&gt; keda &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt; 5m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring          &lt;span class="c"&gt;# prometheus, grafana, kube-state-metrics Running&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; keda                &lt;span class="c"&gt;# keda-operator + metrics-apiserver Running&lt;/span&gt;
kubectl api-resources | &lt;span class="nb"&gt;grep &lt;/span&gt;scaledobject   &lt;span class="c"&gt;# confirms KEDA's CRDs landed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prometheus-node-exporter&lt;/code&gt; is configured to tolerate &lt;code&gt;sku=gpu&lt;/code&gt; (&lt;code&gt;operator: Exists&lt;/code&gt;) in the values file, so it lands on the GPU node automatically once it scales up — no separate install step needed for CPU/memory/disk metrics from that node.&lt;/p&gt;

&lt;p&gt;Grafana's admin password is &lt;code&gt;admin&lt;/code&gt;, set in the values file. Acceptable for a cluster torn down daily; not for anything long-lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — Deploy vLLM
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml
kubectl get pods &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected sequence: &lt;code&gt;Pending&lt;/code&gt; → cluster autoscaler provisions an A10 node (~3–5 min) → &lt;code&gt;ContainerCreating&lt;/code&gt; → image pull (~1 min, 8.8 GB) → model weights load → &lt;code&gt;1/1 Running&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pod spec (&lt;code&gt;[deployment.yaml](https://github.com/JDoornink/vLLM_on_K8s/blob/main/deployment.yaml)&lt;/code&gt;) is where the taint/toleration/nodeSelector from step 3 get consumed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sku&lt;/span&gt;
      &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equal&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpu&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;   &lt;span class="c1"&gt;# matches --node-taints sku=gpu:NoSchedule on the GPU nodepool&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpu&lt;/span&gt;               &lt;span class="c1"&gt;# matches --labels sku=gpu on the GPU nodepool&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-gpu&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm/vllm-openai:latest&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--model&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Qwen/Qwen2.5-7B-Instruct-AWQ&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--quantization&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;awq&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--gpu-memory-utilization&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.85"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--max-num-seqs&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;32"&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;    &lt;span class="c1"&gt;# ensures only one pod per GPU node&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--gpu-memory-utilization 0.85&lt;/code&gt;, not the vLLM default of 0.92.&lt;/strong&gt; At 0.92, vLLM tried to claim 21.82 GiB of the 24 GB card but only ~21.37 GiB was free, and engine init failed. The model fits — this is a headroom setting, not a capacity limit. Full VRAM accounting is covered in &lt;a href="//write-up-gpu-sizing.md"&gt;GPU sizing&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nvidia.com/gpu: "1"&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;resources.limits&lt;/code&gt; is what makes one-pod-per-node a scheduling constraint rather than a convention: Kubernetes tracks the node's GPU as consumed once this pod is placed, so a second replica can't land on the same node and the autoscaler brings up a new one instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 8 — Service + smoke test
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;service.yaml]&lt;span class="o"&gt;(&lt;/span&gt;https://github.com/JDoornink/vLLM_on_K8s/blob/main/service.yaml&lt;span class="o"&gt;)&lt;/span&gt;
kubectl get endpoints vllm-openai-gpu        &lt;span class="c"&gt;# must show pod IP:8080, confirming the selector matched&lt;/span&gt;

kubectl port-forward svc/vllm-openai-gpu 8080:8080 &amp;amp;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/v1/models | jq        &lt;span class="c"&gt;# should list id "vllm-openai-gpu"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Service is &lt;code&gt;ClusterIP&lt;/code&gt; — reachable only via &lt;code&gt;port-forward&lt;/code&gt;, no public IP. Switch to &lt;code&gt;type: LoadBalancer&lt;/code&gt; only if the endpoint needs to be reached from outside the cluster (e.g., load-testing from a separate machine).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9 — GPU metrics: DCGM exporter
&lt;/h2&gt;

&lt;p&gt;vLLM's &lt;code&gt;/metrics&lt;/code&gt; endpoint reports request/queue stats but nothing about the GPU itself — no utilization, VRAM, temperature, or power. NVIDIA's DCGM exporter is a DaemonSet that reads the GPU directly and exposes it to Prometheus. It requires the GPU node to already be up (step 7) and, like the device plugin, must tolerate the &lt;code&gt;sku=gpu&lt;/code&gt; taint to schedule there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add gpu-helm-charts https://nvidia.github.io/dcgm-exporter/helm-charts
helm repo update gpu-helm-charts
helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; dcgm-exporter gpu-helm-charts/dcgm-exporter &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;-f&lt;/span&gt; observability/dcgm-values.yaml &lt;span class="nt"&gt;--timeout&lt;/span&gt; 5m

kubectl rollout status ds/dcgm-exporter &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two failure modes, both fixed in &lt;code&gt;observability/dcgm-values.yaml&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OOMKilled at a 256Mi memory limit (exit 137).&lt;/strong&gt; DCGM's field collection needs more headroom than that. Fixed with a &lt;code&gt;1Gi&lt;/code&gt; limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scrapeTimeout&lt;/code&gt; must be ≤ &lt;code&gt;interval&lt;/code&gt;.&lt;/strong&gt; The chart defaults &lt;code&gt;scrapeTimeout&lt;/code&gt; to 25s; with &lt;code&gt;interval: 15s&lt;/code&gt;, that combination makes the generated &lt;code&gt;ServiceMonitor&lt;/code&gt; invalid, and the Prometheus operator drops the target with no visible error — the &lt;code&gt;ServiceMonitor&lt;/code&gt; and &lt;code&gt;Service&lt;/code&gt; objects both exist, Prometheus is healthy, but the target never appears. Fixed by setting &lt;code&gt;serviceMonitor.scrapeTimeout: 10s&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 10 — Wire vLLM into Prometheus
&lt;/h2&gt;

&lt;p&gt;DCGM ships its own &lt;code&gt;ServiceMonitor&lt;/code&gt;, discovered automatically because &lt;code&gt;kps-values.yaml&lt;/code&gt; sets &lt;code&gt;serviceMonitorSelectorNilUsesHelmValues: false&lt;/code&gt; (Prometheus picks up every monitor object in the cluster, not just ones with a specific release label). vLLM needs a &lt;code&gt;PodMonitor&lt;/code&gt; instead, since it's scraped directly on the pod's metrics port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; observability/vllm-podmonitor.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify both targets are &lt;code&gt;up&lt;/code&gt;, not just present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring svc/kps-kube-prometheus-stack-prometheus 9090:9090 &amp;amp;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:9090/api/v1/targets &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.activeTargets[] | select(.labels.job|test("vllm|dcgm";"i")) | "\(.health)  \(.labels.job)"'&lt;/span&gt;
&lt;span class="c"&gt;# expect:  up  default/vllm-openai-gpu   AND   up  dcgm-exporter&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'http://localhost:9090/api/v1/query?query=DCGM_FI_DEV_GPU_UTIL'&lt;/span&gt;      &lt;span class="c"&gt;# GPU utilization series&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'http://localhost:9090/api/v1/query?query=vllm:num_requests_running'&lt;/span&gt; &lt;span class="c"&gt;# vLLM's own series&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 11 — Access Grafana
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring svc/kps-grafana 3000:80 &amp;amp;
&lt;span class="c"&gt;# browse http://localhost:3000  (admin / admin)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GPU-level and application-level metrics now land in the same Prometheus, on the same time axis — the data the capacity-planning math in &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/write-up-gpu-sizing.md" rel="noopener noreferrer"&gt;GPU sizing&lt;/a&gt; and the autoscaler experiments in &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/write-up-flapping.md" rel="noopener noreferrer"&gt;the flapping article&lt;/a&gt; are built from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# device plugin landed on the GPU node and is Running&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;-o&lt;/span&gt; wide | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; nvidia

&lt;span class="c"&gt;# GPU is now advertised as an allocatable resource&lt;/span&gt;
kubectl describe node &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;sku&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpu | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A8&lt;/span&gt; Allocatable      &lt;span class="c"&gt;# expect: nvidia.com/gpu: 1&lt;/span&gt;

&lt;span class="c"&gt;# vLLM is actually serving&lt;/span&gt;
kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vllm-openai-gpu                       &lt;span class="c"&gt;# look for "Application startup complete"&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-o&lt;/span&gt; wide                                     &lt;span class="c"&gt;# confirm one pod per GPU node&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any of these fail, work backward through steps 5 → 3 rather than re-running the deployment. A &lt;code&gt;Pending&lt;/code&gt; vLLM pod is almost always caused upstream of vLLM itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Teardown
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group delete &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--no-wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deletes the cluster, both node pools, and the auto-created node resource group (&lt;code&gt;MC_*&lt;/code&gt;) that holds the managed disks, including the Prometheus PV. The GPU quota grant from step 0 is untouched and persists for the next rebuild.&lt;/p&gt;

&lt;p&gt;To keep the cluster but stop GPU spend without a full teardown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl scale deploy vllm-openai-gpu &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0    &lt;span class="c"&gt;# the GPU pool's autoscaler drains the node 1→0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The GPU pool must be a separate user pool, not the default/system pool.&lt;/strong&gt; System pools can't scale to zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The NVIDIA device plugin needs an explicit toleration for a custom taint.&lt;/strong&gt; It only tolerates the standard &lt;code&gt;nvidia.com/gpu&lt;/code&gt; taint by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--gpu-memory-utilization 0.85&lt;/code&gt;, not the 0.92 default&lt;/strong&gt;, on a 24 GB A10 — a headroom setting, not a capacity limit. See &lt;a href="//write-up-gpu-sizing.md"&gt;GPU sizing&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DCGM's &lt;code&gt;scrapeTimeout&lt;/code&gt; must be ≤ its scrape &lt;code&gt;interval&lt;/code&gt;&lt;/strong&gt;, or the Prometheus operator drops the target with no visible error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A crash-looping vLLM pod leaks GPU memory into the retry.&lt;/strong&gt; Delete the pod so VRAM releases before the next attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Follow the dependency chain&lt;/strong&gt; — model → VRAM → GPU SKU → region → quota — and request quota ahead of time, since it starts at zero and requires manual approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the GPU pool separate from the system pool&lt;/strong&gt; so scale-to-zero is possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taint the GPU pool, and pair it with both a toleration and a nodeSelector on the pod.&lt;/strong&gt; A toleration alone only permits placement; it doesn't steer it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install the NVIDIA device plugin manually&lt;/strong&gt; — AKS doesn't, and it needs the custom taint tolerated before it schedules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tear down with the resource group, not the cluster alone&lt;/strong&gt; — it removes the managed disks and leaves quota untouched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This gets the environment running. The remaining questions — how large a GPU the model actually needs, and what signal the autoscaler should watch — are covered in the companion articles &lt;a href="//write-up-gpu-sizing.md"&gt;GPU sizing&lt;/a&gt; and &lt;a href="//write-up-flapping.md"&gt;the flapping article&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Choosing the Right GPU for Your Model — A Sizing Method, Not a Guess</title>
      <dc:creator>Josef Doornink</dc:creator>
      <pubDate>Wed, 19 Aug 2026 00:24:15 +0000</pubDate>
      <link>https://dev.to/josef_doornink_930b2caf1c/choosing-the-right-gpu-for-your-model-a-sizing-method-not-a-guess-4fe5</link>
      <guid>https://dev.to/josef_doornink_930b2caf1c/choosing-the-right-gpu-for-your-model-a-sizing-method-not-a-guess-4fe5</guid>
      <description>&lt;h1&gt;
  
  
  Choosing the Right GPU for Your Model — A Sizing Method, Not a Guess
&lt;/h1&gt;




&lt;h2&gt;
  
  
  OK, you're a senior SRE, you've been hearing incessantly about AI models, but aren't quite sure how to determine the correct node size to host your model. - If so ... you're in the right place.
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Part of a series on running vLLM on AKS. Companion piece: &lt;a href="https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf"&gt;How to avoid flapping&lt;/a&gt;. GPU infrastructure setup — coming soon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This piece walks through estimating GPU memory requirements from both a model's parameter count or a concurrent requests requirement. &lt;/p&gt;

&lt;p&gt;After reading this article you will have enough knowledge to pick a GPU family with confidence.&lt;/p&gt;

&lt;p&gt;Disclaimer: this process is a rule-of-thumb filter, not a precise calculation — the last step covers how to get exact numbers once the model is actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: What actually consumes GPU memory
&lt;/h2&gt;

&lt;p&gt;AI models live in GPU memory — VRAM — and engines such as vLLM provide novel techniques for managing that memory efficiently [&lt;a href="https://arxiv.org/abs/2309.06180" rel="noopener noreferrer"&gt;paper&lt;/a&gt;], but the model isn't the only thing consuming it. &lt;br&gt;
Below is a short list of things that consume our precious VRAM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model weights&lt;/strong&gt; — the parameters themselves. The big fixed cost: loaded once, never shrinks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV cache&lt;/strong&gt; — working memory for in-flight requests. Every token of every active request holds its attention keys/values here. This is the one that determines &lt;em&gt;throughput&lt;/em&gt;: more KV cache = more concurrent requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else&lt;/strong&gt; — activations (the temporary tensors of a forward pass) plus CUDA/framework overhead.
You don't calculate these by hand; vLLM measures activations with a profiling pass at start-up and prints it for our consumption.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The sizing question is really: &lt;strong&gt;after weights and overhead, how much is left for the KV cache — and is that enough for your traffic?&lt;/strong&gt;  &lt;/p&gt;
&lt;h3&gt;
  
  
  OK, lets get started
&lt;/h3&gt;


&lt;h2&gt;
  
  
  Step 1 — Choose a model
&lt;/h2&gt;

&lt;p&gt;Guidance on &lt;em&gt;which&lt;/em&gt; model to choose is outside the bounds of this article.&lt;br&gt;&lt;br&gt;
What matters here: once you have a candidate, everything below can be read off its spec sheet — you can then run this method on every model on your shortlist and eliminate the ones that don't fit your requirements.&lt;/p&gt;

&lt;p&gt;For demonstration purposes I will use Hugging Face's Qwen2.5-7B-Instruct-AWQ &lt;a href="https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-AWQ" rel="noopener noreferrer"&gt;model card&lt;/a&gt;, &lt;a href="https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-AWQ/blob/main/config.json" rel="noopener noreferrer"&gt;config card&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 2 — What to look for in the spec sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Value Qwen2.5-7B-Instruct-AWQ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Parameter count&lt;/td&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-AWQ" rel="noopener noreferrer"&gt;model card / Number of Paremeters&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;~7.6 B ("7B")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quantization&lt;/td&gt;
&lt;td&gt;&lt;code&gt;quantization_config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AWQ, 4-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;num_hidden_layers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KV heads&lt;/td&gt;
&lt;td&gt;&lt;code&gt;num_key_value_heads&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Head dimension&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hidden_size / num_attention_heads&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3584 / 28 = 128&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two size the &lt;strong&gt;weights&lt;/strong&gt;. The last three size the &lt;strong&gt;KV cache per token&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
That's the whole shopping list.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3 — Calculate the VRAM for the weights
&lt;/h2&gt;

&lt;p&gt;Rule of thumb: &lt;code&gt;weights ≈ parameter_count × bytes_per_parameter&lt;/code&gt; and many models have different precision offerings&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Bytes/param&lt;/th&gt;
&lt;th&gt;7.6 B params&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp16 / bf16&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;~15.2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;int8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~7.6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWQ / 4-bit&lt;/td&gt;
&lt;td&gt;~0.5&lt;/td&gt;
&lt;td&gt;~3.8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note on Weights and quantization: the above table compares the same model, just with different quantization:&lt;br&gt;
&lt;strong&gt;Quantization&lt;/strong&gt; is storing each weight in fewer bits than it was trained in. Models train in 16-bit float, so every parameter costs 2 bytes (16 bits); quantizing re-encodes them as 8-bit or 4-bit integers.&lt;/p&gt;

&lt;p&gt;So the above table shows same model, same parameter count. Only the memory footprint for your model changes. &lt;br&gt;
Every GB you don't spend on weights is a GB left for KV cache, which allows serving more concurrent requests resulting in more happy customers.&lt;/p&gt;

&lt;p&gt;Already this helps guide decisions:&lt;br&gt;&lt;br&gt;
The fp16 model variant (~15 GB) plus workspace would nearly fill a 24 GB GPU before serving a single request. &lt;br&gt;
The AWQ variant (~5.6 GB) leaves the majority of VRAM free for the KV cache.&lt;br&gt;&lt;br&gt;
Same model, same GPU — wildly different serving capacity.^^&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In truth, the AWQ actually consumes ~5.6 GB, not the advertise ~3.8 GB above. This can be seen by looking at the sum of the &lt;code&gt;.safetensors&lt;/code&gt; file sizes on the repo's &lt;a href="https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-AWQ/tree/main" rel="noopener noreferrer"&gt;Files and versions tab&lt;/a&gt; For this model that's exactly two files: &lt;a href="https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-AWQ/blob/main/model-00001-of-00002.safetensors" rel="noopener noreferrer"&gt;model-00001-of-00002.safetensors&lt;/a&gt; (~4.0 GB) and &lt;a href="https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-AWQ/blob/main/model-00002-of-00002.safetensors" rel="noopener noreferrer"&gt;model-00002-of-00002.safetensors&lt;/a&gt; (~1.6 GB). These are the weight tensors themselves — the files vLLM downloads and loads into VRAM at startup — so their combined size &lt;em&gt;is&lt;/em&gt; the weights footprint (~5.6 GB). &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So now you know your weights footprint, - its time to look choose a GPU that fits your requirements.&lt;br&gt;&lt;br&gt;
Below are links to some of the large cloud providers specification sheets.&lt;br&gt;&lt;br&gt;
&lt;a href="https://docs.cloud.google.com/compute/docs/accelerator-optimized-machines" rel="noopener noreferrer"&gt;GCP&lt;/a&gt;, &lt;a href="https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/overview?tabs=breakdownseries%2Cgeneralsizelist%2Ccomputesizelist%2Cmemorysizelist%2Cstoragesizelist%2Cgpusizelist%2Cfpgasizelist%2Chpcsizelist" rel="noopener noreferrer"&gt;Azure&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/ec2/latest/instancetypes/ac.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NOTE — reserve room for the engine:&lt;/strong&gt; vLLM pre-claims a fraction of total VRAM and is set using the &lt;code&gt;--gpu-memory-utilization&lt;/code&gt; flag.&lt;br&gt;&lt;br&gt;
The vLLM engine fits weights + activations + KV cache inside that claim, leaving the remainder as headroom for CUDA overhead and fragmentation.&lt;br&gt;&lt;br&gt;
The default is 0.92; experiments showed that was too aggressive on our GPU and &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/deployment.yaml" rel="noopener noreferrer"&gt;we run 0.85&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So for the first pass, we are going to use Azures Standard_NV36ads_A10_v5 processor. (Note VRAM is listed under the Accelerators Tab in the &lt;a href="https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/gpu-accelerated/nvadsa10v5-series?tabs=sizeaccelerators" rel="noopener noreferrer"&gt;documents&lt;/a&gt; — the "Memory (GiB)" column on the Basics tab is the VM's system RAM, not the GPU's.)&lt;br&gt;
| Name | Accelerators | VRAM (GB) |&lt;br&gt;
|---|---|---|&lt;/p&gt;
&lt;h2&gt;
  
  
  | Standard_NV36ads_A10_v5 | 1 | 24 |
&lt;/h2&gt;
&lt;h2&gt;
  
  
  Step 4 — Determine your token budget.
&lt;/h2&gt;

&lt;p&gt;Now the centerpiece.&lt;br&gt;&lt;br&gt;
On our A10 (24 GB): take 85% of it, subtract ~5.6 GB of weights and vLLM's measured activation/overhead reservation, and roughly &lt;strong&gt;13.76 GiB&lt;/strong&gt; remains for the KV cache.&lt;/p&gt;

&lt;p&gt;How many tokens fit in that? Each token in flight stores a key and a value vector in every layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bytes_per_token* = 2 (K and V) × layers × kv_heads × head_dim × dtype_bytes
                = 2 × 28 × 4 × 128 × 2 (fp16)
                = 57,344 bytes ≈ 57 KB per token

[*Pope et al., 2022](https://arxiv.org/abs/2211.05102) 
Derivation assumes standard attention (MHA/MQA/GQA); sliding-window, MLA and hybrid SSM models cache differently and need a different formula.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Divide the remaining KV cache by the bytes per token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Token budget  = 13.76 GiB / 57,344 bytes ≈ 257,584 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And convert tokens into the unit you actually care about — concurrent requests (assuming ~1,000 tokens per request):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Concurrent_requests = 257,584 / 1,000 ≈ 258
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One A10 can hold roughly &lt;strong&gt;258 average requests in flight&lt;/strong&gt;. That single number is what connects GPU shopping to capacity planning — it's the same &lt;code&gt;Concurrent_requests&lt;/code&gt; the &lt;a href="https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf"&gt;flapping article&lt;/a&gt; builds its theoretical autoscaling threshold from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5 — Pick the GPU from the no. of requests you will have to support. (Which is what you will likely be doing anyway)
&lt;/h2&gt;

&lt;p&gt;In reality the &lt;em&gt;requirement&lt;/em&gt; is the fixed thing (ie 100 concurrent requests at peak) and the hardware is what you get to choose. &lt;/p&gt;

&lt;p&gt;So lets walk through how to solve for the GPU sizing from the given requirements&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KV bytes needed = concurrent_requests × avg_tokens_per_request × bytes_per_token
VRAM target     = (KV bytes + weights) / gpu_memory_utilization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requirements/Assumptions — 100 concurrent requests at ~1,000 tokens each, same token size as above since we have already decided our model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KV bytes needed = 100 × 1,000 × 57,344 bytes ≈ 5.7 GB
VRAM target = (5.7 + 5.6) / 0.85         ≈ 13.3 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;13.3 GB is your shopping floor: any card below it can't hold this workload, and the fractional A10 sizes (4/8/12 GB) are eliminated on the spot. &lt;br&gt;
The A10's 24 GB clears it with room to spare — which is the useful answer, because "fits with headroom" is what lets you absorb a traffic spike without a second replica.&lt;/p&gt;

&lt;p&gt;Now you have baseline requirements and can choose the model that fits those requirements BEFORE you've spent a dollar on hardware.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 6 — Verify at boot: don't trust the estimate
&lt;/h2&gt;

&lt;p&gt;OK, now the cluster and service is alive with the desired GPU (infrastructure setup is covered in a companion piece — coming soon).&lt;br&gt;
The truth at startup is finally available becuase the truth comes from vLLM itself — at startup it profiles the hardware and prints exactly what it measured:.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;INFO ... Available KV cache memory: 13.76 GiB
INFO ... GPU KV cache size: 257,584 tokens
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl logs &amp;lt;vllm-pod&amp;gt; | grep -i "kv cache"&lt;/code&gt; and compare against your Step 4 numbers. &lt;br&gt;
If they're close, your mental model of the card is correct.&lt;br&gt;&lt;br&gt;
If they're way off, something in your assumptions is wrong (usually the quantization variant or the &lt;code&gt;gpu-memory-utilization&lt;/code&gt; value) — better to find out now than after you've sized a node pool around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  OK - there you go ... after a couple of tries you're officially an expert at determining the correct GPU to handle your inference model.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model&lt;/strong&gt; — pick a candidate; everything below reads off its spec sheet, so you can run this on a whole shortlist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spec sheet&lt;/strong&gt; — five values from the model card and &lt;code&gt;config.json&lt;/code&gt;: parameter count, quantization, &lt;code&gt;num_hidden_layers&lt;/code&gt;, &lt;code&gt;num_key_value_heads&lt;/code&gt;, and &lt;code&gt;hidden_size / num_attention_heads&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weights&lt;/strong&gt; — parameter count × bytes per parameter, then confirm against the real &lt;code&gt;.safetensors&lt;/code&gt; file sizes (the rule of thumb runs low on quantized models).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token budget&lt;/strong&gt; — VRAM × &lt;code&gt;gpu-memory-utilization&lt;/code&gt;, minus weights, ÷ bytes-per-token = tokens; ÷ tokens-per-request = &lt;code&gt;Concurrent_requests&lt;/code&gt;. Note 0.85 is a safer starting point than vLLM's 0.92 default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calculate GPU from Requests SLO&lt;/strong&gt; — start from the concurrency you actually need and solve for the VRAM target instead; anything below that number is off the shortlist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; — the vLLM startup log is ground truth; grep it and reconcile against your Step 4 estimate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next in the series: that &lt;code&gt;Concurrent_requests&lt;/code&gt; number is the foundation of a defensible autoscaling threshold — and why even a defensible threshold isn't enough: &lt;a href="https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf"&gt;Your vLLM Autoscaler Is Flapping Because You Picked the Wrong Signal&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What about multiple GPUs?
&lt;/h2&gt;

&lt;p&gt;Everything above sized &lt;em&gt;one&lt;/em&gt; pod on &lt;em&gt;one&lt;/em&gt; GPU. Fair question: what happens when the cluster has more than one? There are three scenarios here, and they're worth keeping distinct — because only one of them changes the math you just learned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 1 — many single-GPU nodes (what this series runs).&lt;/strong&gt; Each pod owns one GPU and holds a full copy of the model; traffic is load-balanced across replicas. This is &lt;em&gt;data parallelism&lt;/em&gt;, and it's already a multi-GPU cluster — the &lt;a href="https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf"&gt;flapping article&lt;/a&gt; scales exactly this fleet from 1 to 3 GPUs. Nothing in the sizing math changes: total capacity is simply &lt;code&gt;C × replicas&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 2 — multi-GPU nodes, still one GPU per pod.&lt;/strong&gt; Some VM sizes pack multiple cards (e.g. Azure's NV72ads_A10_v5 has 2× A10). Keep &lt;code&gt;nvidia.com/gpu: "1"&lt;/code&gt; per pod and Kubernetes schedules two vLLM pods onto one node. The sizing math is &lt;em&gt;still&lt;/em&gt; unchanged — each pod sees its own 24 GB card. This holds as long as &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html" rel="noopener noreferrer"&gt;GPU time-slicing&lt;/a&gt; is disabled; with it enabled, two pods can share one physical card with no memory isolation and none of the numbers above apply.  &lt;/p&gt;

&lt;p&gt;What does change is operational :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scale-up gets faster&lt;/strong&gt; — a new replica can land on the already-running node in seconds, instead of waiting minutes for node provisioning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost granularity gets coarser&lt;/strong&gt; — the node is the billing unit, so one busy pod next to an idle GPU slot still costs full price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius grows&lt;/strong&gt; — losing one node now kills two replicas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Scenario 3 — multiple GPUs per &lt;em&gt;pod&lt;/em&gt;: tensor parallelism. This is the one that changes the math.&lt;/strong&gt; When the model doesn't fit on any single card — Llama-70B at fp16 is ~140 GB of weights alone — vLLM can split every layer &lt;em&gt;across&lt;/em&gt; GPUs with &lt;code&gt;--tensor-parallel-size N&lt;/code&gt;, and the pod requests &lt;code&gt;nvidia.com/gpu: N&lt;/code&gt;. Three consequences for sizing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weights and KV cache are sharded&lt;/strong&gt; across the N GPUs — weights divide roughly evenly, and the &lt;code&gt;kv_heads&lt;/code&gt; term in the bytes-per-token formula divides across GPUs too. (Note the constraint: with Qwen's 4 KV heads, tensor parallelism beyond 4 stops dividing cleanly.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interconnect becomes a sizing axis.&lt;/strong&gt; Every forward pass exchanges activations between the GPUs, so NVLink vs. plain PCIe meaningfully changes throughput. VRAM stops being the only number you shop on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoscaling stakes go up.&lt;/strong&gt; One replica now costs N GPUs, so every flap cycle is N× more expensive — the signal-choice lessons from the flapping article matter &lt;em&gt;more&lt;/em&gt; here, not less.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The clean rule of thumb: &lt;strong&gt;more traffic → more replicas (Scenarios 1–2); bigger model → tensor parallelism (Scenario 3).&lt;/strong&gt; Reach for Scenario 3 only when weights plus a workable KV budget exceed the biggest single card you can buy — otherwise replicas are simpler, cheaper, and fail more gracefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 0.92 default failed on our A10.&lt;/strong&gt; vLLM tried to claim 21.82 GiB but only ~21.37 was actually free → engine init crashed. The model &lt;em&gt;fit&lt;/em&gt; fine — this was a headroom knob, not a "need a bigger GPU" problem. &lt;code&gt;--gpu-memory-utilization 0.85&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The weights rule of thumb underestimates quantized models.&lt;/strong&gt; &lt;code&gt;params × 0.5 bytes&lt;/code&gt; says 3.8 GB; the real artifact is ~5.6 GB (fp16 embeddings + quantization scales). Check the actual file sizes on the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crash-looping pods leak VRAM.&lt;/strong&gt; If the vLLM pod crashes and restarts, the retry can fail on less-than-expected free VRAM. Delete the pod so the VRAM fully releases before the next attempt.&lt;/li&gt;
&lt;/ul&gt;




</description>
      <category>ai</category>
      <category>gpu</category>
      <category>llm</category>
    </item>
    <item>
      <title>how to host GPUS on Azure using KEDA</title>
      <dc:creator>Josef Doornink</dc:creator>
      <pubDate>Tue, 11 Aug 2026 17:52:59 +0000</pubDate>
      <link>https://dev.to/josef_doornink_930b2caf1c/how-to-host-gpus-on-azure-using-keda-2dhf</link>
      <guid>https://dev.to/josef_doornink_930b2caf1c/how-to-host-gpus-on-azure-using-keda-2dhf</guid>
      <description></description>
    </item>
    <item>
      <title>Your vLLM Autoscaler Is Flapping Because You Picked the Wrong Signal — Not the Wrong Number</title>
      <dc:creator>Josef Doornink</dc:creator>
      <pubDate>Mon, 10 Aug 2026 18:11:56 +0000</pubDate>
      <link>https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf</link>
      <guid>https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf</guid>
      <description>&lt;h1&gt;
  
  
  Your vLLM Autoscaler Is Flapping Because You Picked the Wrong Signal — Not the Wrong Number
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Part of a series on running vLLM on AKS. Companion piece: &lt;a href="https://dev.to/josef_doornink_930b2caf1c/choosing-the-right-gpu-for-your-model-a-sizing-method-not-a-guess-4fe5"&gt;GPU sizing&lt;/a&gt;. Infrastructure setup — coming soon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So... you've picked the right GPU for your use case, provisioned the cluster and node pool, deployed your model behind vLLM, installed KEDA and picked the request-queue as the autoscaling parameter as per the common recommendation: &lt;a href="https://docs.cloud.google.com/kubernetes-engine/docs/best-practices/machine-learning/inference/autoscaling" rel="noopener noreferrer"&gt;"Tune the request queue to obtain the preferred latency, and use batch size if you can't hit your preferred latency."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then you watch it in production:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A burst hits, a new pod spins up — and by the time it's ready, the burst is gone. The queue drained, so the autoscaler scales back down.
&lt;/li&gt;
&lt;li&gt;Minutes later the next spike repeats the cycle. The replica count oscillates instead of settling.
&lt;/li&gt;
&lt;li&gt;What gives??&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's &lt;strong&gt;flapping&lt;/strong&gt; and it's costing you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What flapping is, and why you should care
&lt;/h2&gt;

&lt;p&gt;Flapping refers to a resource rapidly switching between two states and in this case: a pod Up/Running or Down/Failed.&lt;/p&gt;

&lt;p&gt;I can hear you say: "So what ... The requests still get served, right?"&lt;/p&gt;

&lt;p&gt;True, but flapping costs you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Money&lt;/strong&gt; — you pay for GPU nodes that spin up and tear down without ever serving traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt; — the mechanism meant to &lt;em&gt;absorb&lt;/em&gt; load spends its time flapping instead. Requests sit in the queue while pods churn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust&lt;/strong&gt; — pods and nodes appearing and vanishing "at random" is exactly the chaos you don't want when debugging under pressure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This article shows how to avoid flapping by using the right &lt;em&gt;type&lt;/em&gt; of signal and why request-queue is the wrong choice.&lt;/p&gt;

&lt;p&gt;We demonstrate this by walking through three progressive designs for the autoscaler and its defining parameter(s), reasoning about the output, and finally coming to a conclusion about a signal that actually holds steady, and why.&lt;/p&gt;

&lt;p&gt;The three progressive steps we will walk through are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 1 — arbitrary threshold&lt;/strong&gt; (flaps, and over-scales)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 2 — calculated threshold&lt;/strong&gt; (still flaps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 3 — stabilized design&lt;/strong&gt; (stable)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud:&lt;/strong&gt; Azure AKS 1.35.6&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU node:&lt;/strong&gt; Standard_NV36ads_A10_v5 (1× A10, 24 GB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image / model:&lt;/strong&gt; &lt;code&gt;vllm/vllm-openai:latest&lt;/code&gt; / Qwen2.5-7B-Instruct-AWQ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoscaler:&lt;/strong&gt; KEDA ScaledObject, Prometheus trigger on &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility:&lt;/strong&gt; Prometheus + Grafana&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  - &lt;strong&gt;Constraint:&lt;/strong&gt; &lt;code&gt;nvidia.com/gpu: "1"&lt;/code&gt; → one pod per GPU node, so every replica needs a new node (this matters later)
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Attempt 1 — an arbitrary threshold (flaps, and over-scales)
&lt;/h2&gt;

&lt;p&gt;Pick a low, round queue number that sounds reasonable (&lt;code&gt;5&lt;/code&gt;) and move on — sound familiar?&lt;/p&gt;

&lt;p&gt;This over-scales badly because the underlying Kubernetes HPA controller — which KEDA delegates the actual scaling decision to — computes &lt;code&gt;desiredReplicas = ceil(total_waiting / threshold)&lt;/code&gt; (&lt;a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/#algorithm-details" rel="noopener noreferrer"&gt;Kubernetes HPA algorithm details&lt;/a&gt;). So a transient queue of 40 demands 8 replicas. Fine for a proof-of-concept, but in production each of those replicas cost you.&lt;/p&gt;

&lt;p&gt;It's better to calculate the threshold from the hardware specs, not a guess.&lt;/p&gt;

&lt;p&gt;So ... let's calculate a real one.&lt;br&gt;&lt;br&gt;
Here is the scaledobject for attempt1 if you are curious. &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt1-guess.yaml" rel="noopener noreferrer"&gt;scaledobject-attempt1-guess.yaml&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt 2 — a calculated threshold (still flaps)
&lt;/h2&gt;

&lt;p&gt;Assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Average tokens/request ≈ 1,000&lt;/li&gt;
&lt;li&gt;Average completion time = 10s (how long it takes vLLM to generate a response once processing starts — prefill + decode)&lt;/li&gt;
&lt;li&gt;Queue-wait budget &lt;code&gt;W_max&lt;/code&gt; ≤ 5 seconds (SLO)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  To calculate the threshold, we first need to determine how much cache is available to our model
&lt;/h3&gt;

&lt;p&gt;Read from the model's startup log (the engine profiles this at boot): this is VRAM left after weights + activation + overhead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Result: Available KV cache memory: 13.76 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next we determine the bytes per token based on the model's specifications (all from the model's config.json):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bytes_per_token = 2 (K and V) × layers × kv_heads × head_dim × dtype_bytes
                = 2 × 28 × 4 × 128 × 2 (fp16) = 57,344

Calculate how many tokens are available given the cache size and the bytes required per token
257,584 ≈ 13.76 GiB / 57,344 bytes_per_token

Determine how many requests our GPU can handle at once given our assumption(s) (~1,000 tok/req)
Concurrent_requests = 257,584 tokens / 1,000 tokens/request ≈ 258
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Full VRAM → token-budget derivation is in the &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/write-up-gpu-sizing.md" rel="noopener noreferrer"&gt;GPU sizing&lt;/a&gt; companion piece.)&lt;/p&gt;

&lt;p&gt;Finally, calculate the &lt;strong&gt;Threshold from an SLO&lt;/strong&gt; (Little's Law — how many requests may queue before wait breaches &lt;code&gt;W_max&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Calculate how many requests will be completed every second&lt;/span&gt;
Drain_rate &lt;span class="o"&gt;=&lt;/span&gt; Concurrent_requests / avg_completion_time &lt;span class="o"&gt;=&lt;/span&gt; 258 / 10s ≈ 25.8 req/s

&lt;span class="c"&gt;# Calculate the threshold to increase the pod number by 1.&lt;/span&gt;
Threshold &lt;span class="o"&gt;=&lt;/span&gt; W_max × Drain_rate &lt;span class="o"&gt;=&lt;/span&gt; 5s × 25.8 ≈ 129
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;129 is a defensible number, derived from measured capacity and a stated SLO. Not a guess.&lt;/p&gt;

&lt;p&gt;However, for demonstration purposes, the rest of this article runs on a deliberately shrunk rig:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Demo vs. production numbers:&lt;/strong&gt; &lt;code&gt;--max-num-seqs 32&lt;/code&gt; caps concurrency at 32 so the queue is reachable at demo scale. Measuring with unique per-request prompts gave a real drain rate of &lt;code&gt;μ ≈ 0.46 req/s&lt;/code&gt;; with a &lt;code&gt;W_max = 15s&lt;/code&gt; budget, the same formula calibrates to &lt;code&gt;T = 15 × 0.46 ≈ 7&lt;/code&gt;, versus &lt;code&gt;C ≈ 258&lt;/code&gt; / &lt;code&gt;T ≈ 129&lt;/code&gt; for the full-size pod. Don't get caught up on the smaller numbers — same method, same core point; it's just easier to demonstrate the flapping behavior and saves me money. &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/deployment.yaml" rel="noopener noreferrer"&gt;deployment.yaml&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Apply the ScaledObject (&lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt2-calculated.yaml" rel="noopener noreferrer"&gt;scaledobject-attempt2-calculated.yaml&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serverAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://kps-kube-prometheus-stack-prometheus.monitoring.svc:9090&lt;/span&gt;
      &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum(vllm:num_requests_waiting)&lt;/span&gt;
      &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the load (&lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/load_test.py" rel="noopener noreferrer"&gt;load_test.py&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1k2y26103tqqlrbc63.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1k2y26103tqqlrbc63.png" alt="Attempt 2 — calculated threshold on queue depth, still flaps" width="799" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's walk through what happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Requests start arriving, running count climbs to 32 (one pod's cap) — no queue yet, all requests are actually running.&lt;/li&gt;
&lt;li&gt;Once running is saturated at 32, new arrivals start queuing instead. The queue grows.&lt;/li&gt;
&lt;li&gt;Queue crosses 7 (the threshold) a little before 20:00 → KEDA scales to 2 replicas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;While that second pod is still loading&lt;/strong&gt; (model weights + readiness, ~90s), the queue keeps growing — the new pod isn't serving yet, so it does nothing to relieve the backlog. By the time it's ready, the queue is way past 7 (peak ~50).&lt;/li&gt;
&lt;li&gt;KEDA polls again, sees the queue still far over threshold, and scales to 3 pods.&lt;/li&gt;
&lt;li&gt;Now with more capacity coming online, the queue starts draining. It falls below threshold, and KEDA scales back down (2, then 1) — the queue graph and replica graph fall together around 20:04–20:07.&lt;/li&gt;
&lt;li&gt;The load never stopped: it's the same continuous arrival rate (0.6 req/s) throughout. The moment replicas dropped to 1 again, that single pod was immediately outmatched by the same steady arrivals, so the queue re-formed and crossed 7 again almost immediately (~20:08–20:10) — a second flap cycle.&lt;/li&gt;
&lt;li&gt;Eventually the load generator's 12-minute run ends, arrivals stop, the queue drains for real, and replicas settle back to the floor of 1.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Under steady load, the oscillation isn't coming from the traffic — it's coming from the shape of the signal.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The queue reads ~0 until the pod is full, then climbs; the autoscaler is just asking "is this threshold met or not?"&lt;br&gt;&lt;br&gt;
The binary nature of the signal is what causes the flap.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why you might not have noticed this in the wild:&lt;/strong&gt; Kubernetes' HPA defaults to a 300s scale-down stabilization window (0s scale-up), so out of the box the flap is slowed to a ~5-minute cycle — infrequent enough to miss at a glance, but you pay for it on every swing. (&lt;a href="https://github.com/kubernetes/enhancements/blob/master/keps/sig-autoscaling/853-configurable-hpa-scale-velocity/README.md#default-values" rel="noopener noreferrer"&gt;Kubernetes HPA defaults&lt;/a&gt;) &lt;br&gt;
For this demo, &lt;code&gt;stabilizationWindowSeconds&lt;/code&gt; was set to 0 in Attempt 2, to expose the flap within a short window instead of stretching it to 5+ minutes:&lt;/p&gt;


&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;advanced&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;horizontalPodAutoscalerConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;scaleDown&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;stabilizationWindowSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/blockquote&gt;

&lt;p&gt;So even picking the "right" queue-depth number doesn't save you.&lt;br&gt;
Is there a different metric we can use instead??&lt;/p&gt;

&lt;p&gt;Now you're asking the right questions.&lt;/p&gt;


&lt;h2&gt;
  
  
  Attempt 3 — the stabilized design (stable)
&lt;/h2&gt;

&lt;p&gt;Two requirements to stabilize the system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scale on a signal that moves &lt;em&gt;before&lt;/em&gt; trouble, and&lt;/li&gt;
&lt;li&gt;Damp scale-down so a dip can't tear down capacity you just paid for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;First, let's look at some metrics and rank vLLM's metrics by &lt;em&gt;what they tell you and when&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;num_requests_waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 until full, then climbs&lt;/td&gt;
&lt;td&gt;you're &lt;strong&gt;already&lt;/strong&gt; overloaded (lagging)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;num_requests_running&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;proportional, flat once at &lt;code&gt;max_num_seqs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;how busy, up to the cap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;kv_cache_usage_perc&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;smooth 0–1 (fraction)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;how close to full — &lt;em&gt;before&lt;/em&gt; the queue forms (leading)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;KV-cache utilization is the right signal for vLLM because it rises &lt;strong&gt;before&lt;/strong&gt; requests queue, and it &lt;em&gt;is&lt;/em&gt; the binding resource the capacity math is about.&lt;br&gt;
Scaling up at ~80% buys you lead time — you add capacity while the current pod still has headroom, INSTEAD OF AFTER it's drowning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serverAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://kps-kube-prometheus-stack-prometheus.monitoring.svc:9090&lt;/span&gt;
      &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum(vllm:kv_cache_usage_perc)&lt;/span&gt;   &lt;span class="c1"&gt;# V1 engine name; older builds use gpu_cache_usage_perc&lt;/span&gt;
      &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.80"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;IMPORTANT: Use &lt;code&gt;sum&lt;/code&gt;, not &lt;code&gt;avg&lt;/code&gt; — this is the part that actually kills the flap.&lt;/strong&gt; &lt;code&gt;sum(kv_cache_usage_perc)&lt;/code&gt; measures &lt;em&gt;total&lt;/em&gt; demand across pods (in "pod-fulls"), which doesn't change when you add a replica — so &lt;code&gt;ceil(sum / 0.80)&lt;/code&gt; has a &lt;strong&gt;stable fixed point&lt;/strong&gt;. An &lt;code&gt;avg&lt;/code&gt; falls as you add pods, drops back under the threshold, and reintroduces the exact oscillation you're trying to kill. That's the deeper rule behind this whole article: a good autoscaling signal is one whose &lt;strong&gt;total is invariant to replica count&lt;/strong&gt;. &lt;code&gt;num_requests_waiting&lt;/code&gt; fails it (collapses to 0 once you have enough capacity); &lt;code&gt;sum(kv_cache_usage_perc)&lt;/code&gt; passes it.&lt;/p&gt;

&lt;p&gt;OK, so how do we dampen the scale-down?&lt;/p&gt;

&lt;p&gt;Pair the signal with &lt;strong&gt;scale-down damping ≥ your measured lead time &lt;code&gt;L&lt;/code&gt;&lt;/strong&gt;, so a momentary dip never tears down capacity you just paid minutes to bring up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;advanced&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;horizontalPodAutoscalerConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;scaleDown&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;stabilizationWindowSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;gt;= L (node + engine init + readiness)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stable ScaledObject &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt3-stabilized.yaml" rel="noopener noreferrer"&gt;scaledobject-attempt3-stabilized.yaml&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;Apply the load again (&lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/load_test.py" rel="noopener noreferrer"&gt;load_test.py&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70glpe0lfolbsmkaeten.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70glpe0lfolbsmkaeten.png" alt="Attempt 3 — kv_cache @ 80% + damping, replicas step up and hold, no oscillation" width="800" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We now have a &lt;strong&gt;stable&lt;/strong&gt; scale-up for processing.&lt;br&gt;
It works without trying to find the exact right threshold from the request queue!&lt;br&gt;
We don't even need to walk through the different points — the autoscaler handles it exactly like we want it to because we chose the right signal!&lt;/p&gt;

&lt;h3&gt;
  
  
  Oh, big deal... "the docs already say to add a cache trigger"
&lt;/h3&gt;

&lt;p&gt;They do — and that's the trap. Some (&lt;a href="https://dev.to/soniarotglam/why-vllm-autoscaling-on-kubernetes-breaks-and-what-to-use-instead-1231"&gt;e.g. this guide&lt;/a&gt;) suggest keeping the queue-depth trigger and &lt;em&gt;adding&lt;/em&gt; a second cache trigger, assuming the two combine into "scale only if both agree."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They don't, by default.&lt;/strong&gt; KEDA evaluates each trigger independently and takes the &lt;strong&gt;max&lt;/strong&gt; of their desired replica counts — that's OR logic, not AND. So the lagging, bistable queue-depth trigger can still drive scale-up and scale-down on its own, and the flap comes right back. (&lt;a href="https://keda.sh/docs/2.20/reference/faq/" rel="noopener noreferrer"&gt;KEDA docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;If you want the leading signal to govern scaling, &lt;strong&gt;use it alone&lt;/strong&gt; — drop the queue-depth trigger. If you genuinely need both conditions, don't just list two triggers and hope: that silently keeps the OR/max default. AND-style logic is possible, but only if you explicitly use the opt-in &lt;a href="https://keda.sh/docs/2.20/reference/scaledobject-spec/#scalingmodifiers" rel="noopener noreferrer"&gt;&lt;code&gt;scalingModifiers.formula&lt;/code&gt;&lt;/a&gt; to combine the named triggers yourself. Adding a plain second trigger feels safer and quietly isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Flapping is a signal-choice bug wearing a threshold costume. In order of impact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scale on a leading, proportional signal&lt;/strong&gt; (&lt;code&gt;kv_cache_usage_perc&lt;/code&gt;), not a lagging saturation one (&lt;code&gt;num_requests_waiting&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query it as a sum, not an average&lt;/strong&gt; — &lt;code&gt;sum(vllm:kv_cache_usage_perc)&lt;/code&gt; measures &lt;em&gt;total&lt;/em&gt; demand across pods, which doesn't change when you add a replica.
Additional Suggestions:&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use scalingModifiers&lt;/strong&gt; if you need to combine metrics. Example here: &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-scalingmodifiers.yaml" rel="noopener noreferrer"&gt;scaledobject-scalingmodifiers.yaml&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Size scale-down damping to your actual lead time.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final working scaledObject used for Attempt 3 can be seen &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt3-stabilized.yaml" rel="noopener noreferrer"&gt;here&lt;/a&gt; — trigger, threshold, and damping in one place.&lt;/p&gt;

&lt;p&gt;You will likely still want to calculate your threshold, but — it's the last decision, not the first. &lt;br&gt;
Get the signal right and most of the flapping is gone before you tune anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Keep pods warm
&lt;/h3&gt;

&lt;p&gt;You may not need the perfect signal. The cheapest lever against the timescale mismatch is &lt;strong&gt;over-provisioning&lt;/strong&gt; — set &lt;code&gt;minReplicaCount &amp;gt; 1&lt;/code&gt; so warm replicas absorb bursts without waiting minutes for a cold node.&lt;/p&gt;

&lt;p&gt;What it fixes: the lead-time problem directly, and it makes any residual flapping &lt;strong&gt;cheaper&lt;/strong&gt; (no cold starts) and &lt;strong&gt;rarer&lt;/strong&gt; (you scale from a higher floor). What it doesn't fix: the marginal 2→3 decision still rides whatever signal you chose. And it costs money — idle GPUs are the expensive kind.&lt;/p&gt;

&lt;p&gt;For a small deployment, &lt;strong&gt;warm headroom + a reasonable threshold is often good enough&lt;/strong&gt; — the leading-signal work is where it pays off at scale or when you're cost-sensitive.&lt;/p&gt;

&lt;h3&gt;
  
  
  More pods per node
&lt;/h3&gt;

&lt;p&gt;If one-pod-per-GPU-node causes node churn, why not pack several pods per node? A couple of reasons this doesn't help:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Flapping is a control-loop stability problem — pack three pods per GPU and the replica count still oscillates, just faster.&lt;/li&gt;
&lt;li&gt;Each pod gets a fraction of the 24 GB, so a fraction of the KV cache and a lower concurrent-request capacity (&lt;code&gt;C&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What changes at real scale
&lt;/h2&gt;

&lt;p&gt;Everything above is one GPU — basically a teaching rig. Once you're running a real fleet, the strategy actually flips.&lt;/p&gt;

&lt;p&gt;Instead of reacting fast to every queue blip, you want to &lt;strong&gt;over-provision warm headroom and scale infrequently&lt;/strong&gt;. If possible, use &lt;strong&gt;predictive&lt;/strong&gt; scaling — forecasting on daily/weekly patterns, not waiting on any live metric — for the traffic you can actually see coming, and let reactive scaling handle only the residual. Even that reactive layer should be the leading-signal kind from Attempt 3, not lagging queue-depth.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next: KV-cache-aware routing
&lt;/h2&gt;

&lt;p&gt;That's a topic for another article, but worth knowing it exists: at fleet scale, how you &lt;em&gt;route&lt;/em&gt; requests across pods (e.g. KV-cache-aware routing) becomes another lever alongside scaling. Not a replacement for getting the scaling signal right — just the next layer once you have more than a few pods.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/use_cases/prefix-aware-routing.html" rel="noopener noreferrer"&gt;vLLM Production Stack — prefix-aware routing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.redhat.com/articles/2025/10/07/master-kv-cache-aware-routing-llm-d-efficient-ai-inference" rel="noopener noreferrer"&gt;Red Hat / llm-d — KV cache aware routing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>kubernetes</category>
      <category>autoscale</category>
      <category>gpu</category>
    </item>
  </channel>
</rss>
