<?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: Harshit Luthra</title>
    <description>The latest articles on DEV Community by Harshit Luthra (@sachincool).</description>
    <link>https://dev.to/sachincool</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%2F324078%2Fd55787a3-0609-4461-a718-e7cd6da8e118.png</url>
      <title>DEV Community: Harshit Luthra</title>
      <link>https://dev.to/sachincool</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sachincool"/>
    <language>en</language>
    <item>
      <title>Why your Docker build caches locally and never in CI</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:47:03 +0000</pubDate>
      <link>https://dev.to/sachincool/why-your-docker-build-caches-locally-and-never-in-ci-2am</link>
      <guid>https://dev.to/sachincool/why-your-docker-build-caches-locally-and-never-in-ci-2am</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/docker-build-cache-buildkit" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-09-02.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The build is quick on your laptop and slow in CI, and the Dockerfile is identical. This is not a mystery about Docker. The layer cache lives in the local layer store, and a CI runner is a clean machine with an empty one. Every CI build is a first build.&lt;/p&gt;

&lt;p&gt;Fixing it is two separate jobs. Make the cache work at all, which is about instruction order and what enters the build context, and then make the cache survive the runner, which is about exporting it somewhere that outlives the job. The first job also speeds up your laptop. The second only matters in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  what actually invalidates a layer
&lt;/h2&gt;

&lt;p&gt;BuildKit keys each instruction on its inputs. For &lt;code&gt;RUN&lt;/code&gt;, the input is the command string plus the state of everything before it. For &lt;code&gt;COPY&lt;/code&gt; and &lt;code&gt;ADD&lt;/code&gt;, the input is the contents and metadata of the files being copied.&lt;/p&gt;

&lt;p&gt;The part that catches people is that invalidation cascades. Once one instruction misses, every instruction after it rebuilds, whether or not its own inputs changed. So a Dockerfile that copies the whole source tree before installing dependencies rebuilds the dependency install on every commit, forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .                  # any source change invalidates here&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci                &lt;span class="c"&gt;# ...so this always reruns&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is old and still the highest-value change in most Dockerfiles. Copy only the files the install reads, install, then copy the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci                &lt;span class="c"&gt;# only reruns when the lockfile changes&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same instructions, different order, and the install stops rerunning for a one-line change in a route handler. The rule generalises to every ecosystem: &lt;code&gt;go.mod&lt;/code&gt; and &lt;code&gt;go.sum&lt;/code&gt; before the source, &lt;code&gt;requirements.txt&lt;/code&gt; before the package, &lt;code&gt;Cargo.toml&lt;/code&gt; and a dummy &lt;code&gt;main.rs&lt;/code&gt; before the crate.&lt;/p&gt;

&lt;h2&gt;
  
  
  the .dockerignore that decides whether any of this works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;COPY . .&lt;/code&gt; hashes the build context. If the context contains &lt;code&gt;.git&lt;/code&gt;, the hash changes on every commit, including commits that touch nothing the image needs. If it contains a local &lt;code&gt;node_modules&lt;/code&gt;, the hash changes whenever you install anything on your laptop, and the image you build is not the image CI builds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.git
node_modules
.next
dist
coverage
*.log
.env*
Dockerfile
.dockerignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things this buys beyond cache hits. The context stops being uploaded to the daemon, which on a repo with a large &lt;code&gt;.git&lt;/code&gt; is most of the wall-clock time before the build even starts. And &lt;code&gt;.env*&lt;/code&gt; stops being copied into a layer where anyone with the image can read it, which is a different post's problem but a real one.&lt;/p&gt;

&lt;p&gt;You can see the size of what you are sending in the first line of build output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker build .
[+] Building 0.4s (8/8) FINISHED
 =&amp;gt; [internal] load build definition from Dockerfile          0.0s
 =&amp;gt; [internal] load .dockerignore                             0.0s
 =&amp;gt; =&amp;gt; transferring context: 2.31kB                           0.0s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;transferring context: 2.31kB&lt;/code&gt; is a healthy number. If it says 340MB, the &lt;code&gt;.dockerignore&lt;/code&gt; is missing or wrong. There is a shorter version of this specific trap in &lt;a href="https://dev.to/til/docker-build-cache-trick"&gt;Docker build cache: the .dockerignore gotcha&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  cache mounts, for the work that repeats
&lt;/h2&gt;

&lt;p&gt;Instruction ordering stops the install rerunning when nothing changed. It does nothing for the case where the lockfile genuinely did change and you would still rather not download every package again.&lt;/p&gt;

&lt;p&gt;That is what cache mounts are for. &lt;code&gt;RUN --mount=type=cache&lt;/code&gt; mounts a persistent directory into one step, and the directory never becomes part of the layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.npm &lt;span class="se"&gt;\
&lt;/span&gt;    npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;# syntax=&lt;/code&gt; line on the first line is required. Without it, the Dockerfile is parsed by the built-in frontend, which does not understand &lt;code&gt;--mount&lt;/code&gt;, and you get a syntax error that reads like the flag does not exist.&lt;/p&gt;

&lt;p&gt;The same shape for the other ecosystems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.cache/pip pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/go/pkg/mod go build ./...
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/var/cache/apt,sharing&lt;span class="o"&gt;=&lt;/span&gt;locked &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; curl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The apt one needs &lt;code&gt;sharing=locked&lt;/code&gt; because two concurrent builds writing to the same apt cache will corrupt it. &lt;code&gt;locked&lt;/code&gt; serialises them. The default, &lt;code&gt;shared&lt;/code&gt;, is right for package managers that handle concurrent readers themselves, which npm, pip, and the Go module cache all do.&lt;/p&gt;

&lt;p&gt;Cache mounts also fix the pattern where people delete the cache to keep the layer small, which is a real cost you no longer have to pay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# no longer necessary: the cache never entered the layer&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; curl &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  making the cache outlive the runner
&lt;/h2&gt;

&lt;p&gt;Everything above still produces a cold build in CI, because none of it survives the machine. BuildKit can export the cache to a registry or to the CI provider's own cache store, and import it at the start of the next run.&lt;/p&gt;

&lt;p&gt;For GitHub Actions, the provider-native backend is the least work:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/example/api:${{ github.sha }}&lt;/span&gt;
    &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
    &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mode=max&lt;/code&gt; is the part people leave off and then wonder why a multi-stage build is still slow. The default, &lt;code&gt;min&lt;/code&gt;, exports only the layers that end up in the final image. In a multi-stage Dockerfile the expensive stage is the builder, and none of it is in the final image, so &lt;code&gt;min&lt;/code&gt; caches precisely the cheap half.&lt;/p&gt;

&lt;p&gt;The GitHub Actions cache is capped at 10 GB per repository and evicts least-recently-used entries, so a repo with several images can quietly evict its own cache between runs. If that happens, or if you build outside GitHub, export to the registry instead:&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;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=registry,ref=ghcr.io/example/api:buildcache&lt;/span&gt;
    &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=registry,ref=ghcr.io/example/api:buildcache,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes the cache as a separate tag next to the image. It costs registry storage and it does not evict on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  proving it worked
&lt;/h2&gt;

&lt;p&gt;The reason to check rather than assume is that a cache configuration can be entirely valid and still miss everything, and the build output looks the same either way unless you ask for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build &lt;span class="nt"&gt;--progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="nt"&gt;--cache-from&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry,ref&lt;span class="o"&gt;=&lt;/span&gt;ghcr.io/example/api:buildcache &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--progress=plain&lt;/code&gt; prints every step with its status instead of collapsing them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#8 [3/5] RUN --mount=type=cache,target=/root/.npm npm ci
#8 CACHED

#9 [4/5] COPY . .
#9 DONE 0.2s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CACHED&lt;/code&gt; on the install step and &lt;code&gt;DONE&lt;/code&gt; on the copy is the correct shape: the dependency layer was reused, the source layer was rebuilt. If the install step says &lt;code&gt;DONE 47.3s&lt;/code&gt; on a run where the lockfile did not change, something above it invalidated, and the step immediately before it is where to look.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; Cache invalidation cascades forward and never backward. When a step misses unexpectedly, the bug is always in a step above it, never in the step that reported the miss. Read the build output from the top down and stop at the first thing that rebuilt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  the one that will still catch you
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ARG&lt;/code&gt; values participate in the cache from the point they are used. A build argument that changes every run, and CI is full of them, invalidates everything downstream of its first reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; GIT_SHA&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GIT_SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /app/version     &lt;span class="c"&gt;# invalidates on every commit&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci                             &lt;span class="c"&gt;# ...and so does this&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Move it as late as possible. The version stamp belongs after the expensive work, not before it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; GIT_SHA&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GIT_SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /app/version     &lt;span class="c"&gt;# invalidates only itself&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to any &lt;code&gt;ENV&lt;/code&gt; set from a build argument, and to &lt;code&gt;LABEL&lt;/code&gt; values containing a timestamp, which is a popular way to make a Dockerfile uncacheable while believing you are adding metadata.&lt;/p&gt;

&lt;p&gt;Check what you actually have with &lt;code&gt;docker buildx du&lt;/code&gt;, which shows what the cache is holding and how much of it is reclaimable. If the number never grows between CI runs, the export is not working and no amount of Dockerfile tuning will help.&lt;/p&gt;

&lt;p&gt;More on the container side of things under &lt;a href="https://dev.to/tags/docker"&gt;Docker&lt;/a&gt; and &lt;a href="https://dev.to/tags/containers"&gt;containers&lt;/a&gt;, the pipeline side under &lt;a href="https://dev.to/tags/ci-cd"&gt;ci-cd&lt;/a&gt;. If your builds are fast and your images are still running as root, &lt;a href="https://dev.to/blog/docker-security-hardening"&gt;Docker security: stop running everything as root&lt;/a&gt; is the other half of the same file. And if the reason you are reading this is that the CI bill went up rather than the builds got slow, &lt;a href="https://dev.to/blog/aws-cost-optimization-tricks"&gt;AWS cost optimization&lt;/a&gt; covers where that money usually actually goes.&lt;/p&gt;

&lt;p&gt;Start with the &lt;code&gt;.dockerignore&lt;/code&gt;. It takes two minutes, it is the one change that helps locally and in CI at the same time, and a repo without one almost always has a &lt;code&gt;COPY . .&lt;/code&gt; that has not hit the cache in months.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>cicd</category>
      <category>devops</category>
      <category>containers</category>
    </item>
    <item>
      <title>Alert on the error budget, not the CPU graph</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:46:23 +0000</pubDate>
      <link>https://dev.to/sachincool/alert-on-the-error-budget-not-the-cpu-graph-31em</link>
      <guid>https://dev.to/sachincool/alert-on-the-error-budget-not-the-cpu-graph-31em</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/prometheus-burn-rate-alerts" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-08-21.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The alert I have silenced the most times in my life is &lt;code&gt;HighCPUOnNode&lt;/code&gt;. It fires, someone opens the dashboard, CPU is at 85%, nothing is wrong, and the alert gets acknowledged and forgotten. Six months of that and the whole channel is furniture.&lt;/p&gt;

&lt;p&gt;The problem is not the threshold. It is that CPU is a cause, and nobody is paid to care about causes at 3am. What matters is whether the service is failing its users fast enough to matter. Burn-rate alerting measures exactly that: how quickly you are spending the error budget the SLO gives you, with a short second window so the page stops when the incident does.&lt;/p&gt;

&lt;h2&gt;
  
  
  the budget, in one line of PromQL
&lt;/h2&gt;

&lt;p&gt;An SLO of 99.9% availability over 30 days gives you a 0.1% error budget. That is the entire quantity being managed. Every alert below is a statement about the rate at which it is being spent.&lt;/p&gt;

&lt;p&gt;The ratio itself is the thing you want precomputed, because you will query it over several windows and Prometheus should not recompute it per alert evaluation:&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;groups&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;slo-api&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job:slo_errors_per_request:ratio_rate5m&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sum(rate(http_requests_total{job="api",code=~"5.."}[5m]))&lt;/span&gt;
            &lt;span class="s"&gt;/&lt;/span&gt;
          &lt;span class="s"&gt;sum(rate(http_requests_total{job="api"}[5m]))&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job:slo_errors_per_request:ratio_rate1h&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sum(rate(http_requests_total{job="api",code=~"5.."}[1h]))&lt;/span&gt;
            &lt;span class="s"&gt;/&lt;/span&gt;
          &lt;span class="s"&gt;sum(rate(http_requests_total{job="api"}[1h]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat for &lt;code&gt;30m&lt;/code&gt;, &lt;code&gt;6h&lt;/code&gt;, &lt;code&gt;6h&lt;/code&gt;, and &lt;code&gt;3d&lt;/code&gt;. It is repetitive and it is worth it: the alert rules that follow are then one comparison each, and the windows are stated in one place where you can audit them.&lt;/p&gt;

&lt;p&gt;Note the numerator. &lt;code&gt;code=~"5.."&lt;/code&gt; counts server errors. It does not count 4xx, because a client sending malformed requests is not the service failing, and the fastest way to make an SLO meaningless is to let someone else's bad client drain your budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  burn rate is just the ratio divided by the budget
&lt;/h2&gt;

&lt;p&gt;If your budget is 0.1% and you are currently serving 1.44% errors, you are burning at 14.4 times the sustainable rate. That is the whole calculation.&lt;/p&gt;

&lt;p&gt;The useful thing about that number is what it implies about time. Burn rate 1 exhausts the budget exactly at the end of the 30-day window. Burn rate 14.4 exhausts it in roughly 50 hours, which means it eats 2% of the month's budget in a single hour. That is the threshold worth waking someone for.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Budget spent&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Burn rate&lt;/th&gt;
&lt;th&gt;Long / short window&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;14.4&lt;/td&gt;
&lt;td&gt;1h / 5m&lt;/td&gt;
&lt;td&gt;page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;6 hours&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6h / 30m&lt;/td&gt;
&lt;td&gt;page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;3 days&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3d / 6h&lt;/td&gt;
&lt;td&gt;ticket&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those three rows are the multi-window setup from the Google SRE Workbook's alerting chapter, and they are a better starting point than anything you will derive from scratch. The first row catches a hard outage in minutes. The second catches the partial degradation that a 1-hour window would take too long to notice. The third catches a slow leak that nobody should be woken for.&lt;/p&gt;

&lt;h2&gt;
  
  
  the short window is what makes it usable
&lt;/h2&gt;

&lt;p&gt;The single-window version of this alert has a well-known failure: it keeps firing long after the problem is gone. A total outage lasting five minutes pushes the 1-hour error ratio above the threshold, and it stays above for close to an hour afterwards while the bad five minutes ages out of the window. The pager goes off, you fix it in four minutes, and it keeps going off.&lt;/p&gt;

&lt;p&gt;The fix is to require both windows to be over the threshold at once:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ErrorBudgetBurnFast&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;(&lt;/span&gt;
      &lt;span class="s"&gt;job:slo_errors_per_request:ratio_rate1h{job="api"} &amp;gt; (14.4 * 0.001)&lt;/span&gt;
      &lt;span class="s"&gt;and&lt;/span&gt;
      &lt;span class="s"&gt;job:slo_errors_per_request:ratio_rate5m{job="api"} &amp;gt; (14.4 * 0.001)&lt;/span&gt;
    &lt;span class="s"&gt;)&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;burning&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;budget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;14.4x&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(2%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;month&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;an&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hour)"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ErrorBudgetBurnSlow&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;(&lt;/span&gt;
      &lt;span class="s"&gt;job:slo_errors_per_request:ratio_rate6h{job="api"} &amp;gt; (6 * 0.001)&lt;/span&gt;
      &lt;span class="s"&gt;and&lt;/span&gt;
      &lt;span class="s"&gt;job:slo_errors_per_request:ratio_rate30m{job="api"} &amp;gt; (6 * 0.001)&lt;/span&gt;
    &lt;span class="s"&gt;)&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;burning&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;budget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6x&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(5%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;month&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;six&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hours)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The long window says the incident is big enough to page for. The short window says it is still going. Convention is short = long / 12, which is where 5m/1h and 30m/6h come from.&lt;/p&gt;

&lt;p&gt;This also lets you drop the &lt;code&gt;for:&lt;/code&gt; clause, which is the other thing people reach for and should not. &lt;code&gt;for:&lt;/code&gt; requires the expression to be continuously true for its whole duration, and a single evaluation where the value dips below the threshold resets the timer. During a flapping incident, &lt;code&gt;for: 10m&lt;/code&gt; can fail to fire for an hour. The short window gives you the same "is this still real" check without the reset behaviour.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; A single-window burn-rate alert will keep paging for up to an hour after the incident is over, because the bad minutes are still inside the window. The short second window is not a refinement. It is the thing that makes the alert trustworthy enough to leave enabled.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  the alert that fires when nothing fires
&lt;/h2&gt;

&lt;p&gt;Every ratio-based alert has the same hole. If the scrape target disappears, &lt;code&gt;http_requests_total&lt;/code&gt; stops producing samples, the division produces no series, and the alert quietly evaluates to nothing. Total outage, silent pager.&lt;/p&gt;

&lt;p&gt;Two lines close it:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ApiMetricsAbsent&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;absent_over_time(http_requests_total{job="api"}[10m])&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;metrics&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;absent_over_time&lt;/code&gt; returns 1 when the selector has matched nothing for the whole range, which covers both a dead target and a metric that got renamed in a refactor. Use &lt;code&gt;absent_over_time&lt;/code&gt; rather than &lt;code&gt;absent&lt;/code&gt;, because &lt;code&gt;absent&lt;/code&gt; fires on a single missed scrape and will page you for a rolling deploy.&lt;/p&gt;

&lt;p&gt;Verify it before you trust it, by asking Prometheus what the expression returns right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sG&lt;/span&gt; http://localhost:9090/api/v1/query &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'query=absent_over_time(http_requests_total{job="api"}[10m])'&lt;/span&gt; | jq &lt;span class="s1"&gt;'.data.result'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty is correct while the target is healthy. If you get a result with &lt;code&gt;"value": [..., "1"]&lt;/code&gt; and the service is up, your label selector does not match the series you think it does, which is worth finding out now rather than during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  what happens to the cause-based alerts
&lt;/h2&gt;

&lt;p&gt;They stay. They just stop paging.&lt;/p&gt;

&lt;p&gt;CPU, memory, disk, queue depth, replica count: all of it remains useful, and all of it is what you look at ten seconds after the page arrives. The change is the routing. A symptom alert wakes someone. A cause alert opens a ticket or sits on a dashboard until a human is already looking.&lt;/p&gt;

&lt;p&gt;There is a good reason to keep a small number of cause-based pages, and it is prediction rather than diagnosis. Disk filling at a rate that hits 100% in four hours is worth a page, because by the time it becomes a symptom the recovery is much more expensive:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DiskWillFill&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4 * 3600) &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the exception, and &lt;code&gt;for: 30m&lt;/code&gt; is appropriate here precisely because &lt;code&gt;predict_linear&lt;/code&gt; is noisy and you do want it to settle.&lt;/p&gt;

&lt;p&gt;The rest of the cause alerts drop to ticket severity, and Alertmanager stops sending three pages for one incident:&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;inhibit_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;severity = "page"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;target_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;severity = "ticket"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;equal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  the failure mode this creates
&lt;/h2&gt;

&lt;p&gt;Being honest about the trade: SLO-based alerting moves the argument from "is this threshold right" to "is this SLO right", and the second argument is harder and more political. A 99.9% target that nobody agreed to is a number you will end up negotiating during an incident, which is the worst possible time.&lt;/p&gt;

&lt;p&gt;It also means a slow, permanent degradation that stays under burn rate 1 never pages at all. That is intentional, and it will still feel wrong the first time a latency regression rides along for three weeks underneath the alerting threshold. The answer is the third row of the table, the ticket-level alert at burn rate 1, and actually reading the tickets.&lt;/p&gt;

&lt;p&gt;The other honest caveat: this only measures what the SLI counts. An availability SLI built on 5xx will not notice a service returning 200 with an empty body, which is a real outage that looks perfect on the graph. &lt;a href="https://dev.to/blog/ja4-fingerprinting-network-security"&gt;How I took down 30% of production with one TLS fingerprinting rule&lt;/a&gt; is the version of that where the requests never reach the application to be counted at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  where to start
&lt;/h2&gt;

&lt;p&gt;If you have one service and no SLO, the smallest useful version is one SLI, one target, two page alerts and one absent alert. That is roughly forty lines of YAML and it will replace most of a threshold-alert file.&lt;/p&gt;

&lt;p&gt;The stack this sits on, including the recording-rule layout and the Grafana side, is in &lt;a href="https://dev.to/blog/prometheus-grafana-monitoring-guide"&gt;Prometheus and Grafana: from zero to production monitoring&lt;/a&gt;. The equivalent question for LLM serving, where GPU utilisation is the &lt;code&gt;HighCPUOnNode&lt;/code&gt; of that world and queue time is the metric that actually predicts a bad experience, is in &lt;a href="https://dev.to/blog/gpu-deployments-part-4-observability"&gt;what a green GPU dashboard hides&lt;/a&gt;. If the cost of keeping all these series is the thing standing in your way, &lt;a href="https://dev.to/blog/victorialogs-vs-loki"&gt;VictoriaLogs vs Loki&lt;/a&gt; benchmarks the logs half of the same problem. More of both under &lt;a href="https://dev.to/tags/monitoring"&gt;monitoring&lt;/a&gt; and &lt;a href="https://dev.to/tags/observability"&gt;observability&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Delete &lt;code&gt;HighCPUOnNode&lt;/code&gt; on the way out. If it has never once been the reason someone found an incident, it was never an alert. It was a metric with a pager attached.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>prometheus</category>
      <category>observability</category>
      <category>sre</category>
    </item>
    <item>
      <title>CrashLoopBackOff: five causes and how to tell them apart</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:46:17 +0000</pubDate>
      <link>https://dev.to/sachincool/crashloopbackoff-five-causes-and-how-to-tell-them-apart-3652</link>
      <guid>https://dev.to/sachincool/crashloopbackoff-five-causes-and-how-to-tell-them-apart-3652</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/kubernetes-crashloopbackoff-triage" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-08-12.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A pod in &lt;code&gt;CrashLoopBackOff&lt;/code&gt; tells you almost nothing. It is the waiting state, not the failure: the container exited, and the kubelet is sitting out a back-off before trying again. The failure happened one restart ago and is recorded somewhere else.&lt;/p&gt;

&lt;p&gt;Two commands narrow it to one of five causes before you open the application logs. &lt;code&gt;kubectl describe pod&lt;/code&gt; gives you the last termination reason and exit code, and &lt;code&gt;kubectl logs --previous&lt;/code&gt; gives you what the dead instance printed. Everything below is a way of reading those two outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  the two commands, in order
&lt;/h2&gt;

&lt;p&gt;Start with describe, because the exit code does most of the classification work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pod api-7f4b9c6d8f-9m2tq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part worth reading is not the top. It is the &lt;code&gt;Last State&lt;/code&gt; block and the events at the bottom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Tue, 12 Aug 2026 09:14:22 +0530
      Finished:     Tue, 12 Aug 2026 09:14:51 +0530
    Restart Count:  6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Reason&lt;/code&gt; and &lt;code&gt;Exit Code&lt;/code&gt; are the diagnosis. &lt;code&gt;Started&lt;/code&gt; and &lt;code&gt;Finished&lt;/code&gt; are the second most useful pair in the block, because the gap between them separates "died instantly" from "ran for a while and then died", and those are different bugs.&lt;/p&gt;

&lt;p&gt;Then read what the dead instance said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs api-7f4b9c6d8f-9m2tq &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--previous&lt;/code&gt; you get the current instance, which in a crash loop has usually just started and printed nothing. This is the single most common reason people conclude a crashing pod has no logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  exit code 1 or 2: the application decided to quit
&lt;/h2&gt;

&lt;p&gt;An exit code of 1 with output in the previous log is the easy case. The process started, hit something it did not like, and exited on purpose. Missing environment variable, a config file it could not parse, a database it could not reach on startup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl logs api-7f4b9c6d8f-9m2tq --previous
2026-08-12T09:14:22Z FATAL config: DATABASE_URL is required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this one is worth naming separately is that it is the only cause where the application log is the answer. For the other four, the log is either empty or misleading, and you need the pod's own state instead.&lt;/p&gt;

&lt;p&gt;If the variable is supposed to come from a Secret or ConfigMap, check that it actually arrived rather than trusting the manifest:&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 pod api-7f4b9c6d8f-9m2tq &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.containers[0].env}'&lt;/span&gt; | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is more on pulling exactly the field you want out of an object in &lt;a href="https://dev.to/til/kubectl-jsonpath-queries"&gt;kubectl JSONPath: extract exactly what you need&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  exit code 137 with OOMKilled: the memory limit
&lt;/h2&gt;

&lt;p&gt;137 is 128 + 9, so the process took a SIGKILL. If &lt;code&gt;Reason&lt;/code&gt; says &lt;code&gt;OOMKilled&lt;/code&gt;, the kernel's OOM killer did it because the container exceeded its memory limit.&lt;/p&gt;

&lt;p&gt;The tell that separates a genuine limit problem from a memory leak is the gap between &lt;code&gt;Started&lt;/code&gt; and &lt;code&gt;Finished&lt;/code&gt;. Killed within seconds every time means the limit is below what the process needs at startup, usually a JVM heap or a model load. Killed after ten minutes, then twenty, then five, means it is leaking or the workload is spiky.&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 pod api-7f4b9c6d8f-9m2tq &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.containers[0].resources}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"limits":{"memory":"256Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raising the limit is the fix for the first case and a delay for the second. What matters more than the number is that &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt; are both set: a container with a limit and no request gets scheduled onto a node that cannot actually give it that memory, and you meet the OOM killer under load rather than at startup.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; Exit code 137 does not always mean out of memory. It means SIGKILL. The kubelet sends the same signal when a liveness probe fails. Read the Reason field next to the exit code, not the exit code alone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  exit code 137 with no OOMKilled: the probe shot it
&lt;/h2&gt;

&lt;p&gt;Same signal, different killer. If the last state reason is &lt;code&gt;Error&lt;/code&gt; rather than &lt;code&gt;OOMKilled&lt;/code&gt;, and there is a probe failure in the events just before the kill, then the liveness probe ended a container that was working.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Events:
  Type     Reason     Age                From     Message
  ----     ------     ----               ----     -------
  Warning  Unhealthy  2m (x9 over 8m)    kubelet  Liveness probe failed: Get "http://10.0.3.14:8080/health": dial tcp 10.0.3.14:8080: connect: connection refused
  Normal   Killing    2m (x3 over 8m)    kubelet  Container api failed liveness probe, will be restarted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connection refused&lt;/code&gt; during startup means the probe arrived before the process was listening. The container gets killed, restarts, and never survives long enough to answer, so the loop is self-sustaining. The fix is a &lt;code&gt;startupProbe&lt;/code&gt;, which suspends liveness and readiness entirely until it passes:&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;startupProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;      &lt;span class="c1"&gt;# 30 x 5s = 150s of startup budget&lt;/span&gt;
&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;       &lt;span class="c1"&gt;# after startup, a real hang is caught in 30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raising &lt;code&gt;initialDelaySeconds&lt;/code&gt; on the liveness probe instead looks like the same fix and is not. It blinds you to genuine deadlocks for the whole delay, on every restart, forever. The startup probe buys the same time and then hands liveness back its tight interval. &lt;a href="https://dev.to/blog/gpu-deployments-part-7-serving-ops"&gt;Your model isn't crashing, your probe is&lt;/a&gt; walks through the version of this that eats an afternoon, where the process is a model server and the startup budget is ten minutes rather than two.&lt;/p&gt;

&lt;h2&gt;
  
  
  exit code 127, 126, or a StartError: the process never ran
&lt;/h2&gt;

&lt;p&gt;An empty &lt;code&gt;--previous&lt;/code&gt; log with a non-zero exit is a different class of problem. Nothing ran, so nothing logged.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;127&lt;/code&gt; is command not found. The entrypoint or the &lt;code&gt;command:&lt;/code&gt; in the manifest points at a path that is not in the image.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;126&lt;/code&gt; is found but not executable. Usually a script without the execute bit, or a shell script with a CRLF line ending so the kernel looks for an interpreter named &lt;code&gt;/bin/sh\r&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StartError&lt;/code&gt; or &lt;code&gt;CreateContainerError&lt;/code&gt; in &lt;code&gt;Reason&lt;/code&gt; means the kubelet could not launch the process at all, and the events say why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The events are more specific than the exit code here, so read them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Warning  Failed  30s (x4 over 90s)  kubelet  Error: failed to create containerd task: failed to create shim task: OCI runtime create failed: exec: "/app/server": stat /app/server: no such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a build problem wearing a runtime costume. The image does not contain the binary the manifest asks for. Checking the image directly beats re-reading the Dockerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; &lt;span class="nb"&gt;ls &lt;/span&gt;ghcr.io/example/api:2.4.1 &lt;span class="nt"&gt;-la&lt;/span&gt; /app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A related trap is a volume mounted over the directory holding the binary, which produces the same error from a perfectly good image. &lt;a href="https://dev.to/til/docker-volume-inspect-trick"&gt;Docker volume debugging: finding where your data actually lives&lt;/a&gt; covers pinning down what is actually at a mount point.&lt;/p&gt;

&lt;h2&gt;
  
  
  exit code 0: it finished, and Kubernetes disagreed
&lt;/h2&gt;

&lt;p&gt;The strange one. Exit code 0 means the process completed successfully, and the pod is still crash-looping, because a Deployment's pods carry &lt;code&gt;restartPolicy: Always&lt;/code&gt; and Kubernetes restarts a successful exit exactly as eagerly as a failed one.&lt;/p&gt;

&lt;p&gt;This is nearly always a workload in the wrong object. A migration script, a backfill, a report generator: something that is supposed to run once and stop. It belongs in a Job, where &lt;code&gt;restartPolicy: OnFailure&lt;/code&gt; or &lt;code&gt;Never&lt;/code&gt; is available and completion is a terminal state rather than an invitation:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Job&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-migrate&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&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;migrate&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;ghcr.io/example/api:2.4.1&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/app/migrate"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other version of exit code 0 is a long-running server whose main process backgrounds itself and lets PID 1 return. The container is doing exactly what you told it to. It is just that what you told it was "start the thing and exit".&lt;/p&gt;

&lt;h2&gt;
  
  
  the sixty-second triage
&lt;/h2&gt;

&lt;p&gt;Everything above collapses into one pass over the pod's state. Get the reason and exit code for every container in one shot rather than reading a screen of describe output:&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 pod api-7f4b9c6d8f-9m2tq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .status.containerStatuses[*]}{.name}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api OOMKilled   137
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then branch on what comes back:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Exit code 1 or 2, log has output. Read the log. It is an application error.&lt;/li&gt;
&lt;li&gt;Exit code 137, reason &lt;code&gt;OOMKilled&lt;/code&gt;. Memory limit. Check the gap between &lt;code&gt;Started&lt;/code&gt; and &lt;code&gt;Finished&lt;/code&gt; to tell a too-small limit from a leak.&lt;/li&gt;
&lt;li&gt;Exit code 137, reason &lt;code&gt;Error&lt;/code&gt;, probe failure in the events. The liveness probe killed a healthy container. Add a &lt;code&gt;startupProbe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exit code 126, 127, or a &lt;code&gt;StartError&lt;/code&gt;. The process never launched. Read the events, then inspect the image.&lt;/li&gt;
&lt;li&gt;Exit code 0. Wrong workload type. This wants to be a Job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason to run this before opening the application logs is that three of the five causes leave no application logs at all, and one of them leaves logs that look fine right up to the moment something external kills the process. The pod's own state is the more honest witness.&lt;/p&gt;

&lt;p&gt;If none of the five fit, the next thing to check is whether the container is being killed before it is scheduled at all, which is a different failure that presents as &lt;code&gt;Pending&lt;/code&gt; rather than &lt;code&gt;CrashLoopBackOff&lt;/code&gt;. That one, along with the networking failures that dress up as application bugs, is in &lt;a href="https://dev.to/blog/kubernetes-debugging-tips"&gt;five Kubernetes debugging tricks that saved my production&lt;/a&gt;. The rest of the Kubernetes writing here is collected under &lt;a href="https://dev.to/tags/kubernetes"&gt;the Kubernetes tag&lt;/a&gt;, and the wider set of symptom-versus-cause posts under &lt;a href="https://dev.to/tags/debugging"&gt;debugging&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The habit worth keeping is smaller than any of this. Before reading a single line of application output, run describe and write down two things: the exit code and the reason. Most crash loops stop being mysterious right there.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>debugging</category>
      <category>devops</category>
      <category>containers</category>
    </item>
    <item>
      <title>Two tenants, one GPU, and no wall between them</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:15:37 +0000</pubDate>
      <link>https://dev.to/sachincool/two-tenants-one-gpu-and-no-wall-between-them-36ba</link>
      <guid>https://dev.to/sachincool/two-tenants-one-gpu-and-no-wall-between-them-36ba</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-8-multi-tenancy-security" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-25.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Run &lt;code&gt;nvidia-smi&lt;/code&gt; inside a pod in one namespace, then inside a pod in another, on a cluster using GPU time-slicing. Look at the GPU UUID and the PCI bus ID.&lt;/p&gt;

&lt;p&gt;This is the last part. Seven parts built the fleet, wired it, scaled it, watched it, routed to it, and kept it alive through deploys. This one is about the moment more than one team shares it, which is where the assumptions that held for a single tenant quietly stop being true. The load-bearing one, the thing most teams get wrong: a Kubernetes namespace is not a wall on the GPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  the isolation you don't have
&lt;/h2&gt;

&lt;p&gt;A namespace is an API-scoping and RBAC boundary. It has nothing to do with the hardware. The device plugin advertises &lt;code&gt;nvidia.com/gpu&lt;/code&gt; resources and the scheduler places pods onto them regardless of which namespace asked, so "one GPU, four replicas" under time-slicing means the scheduler can land a pod from &lt;code&gt;team-a&lt;/code&gt; and a pod from &lt;code&gt;team-b&lt;/code&gt; on the same physical die with no memory partition between them. What isolation you actually get depends entirely on the sharing mechanism, and it's worth stating exactly:&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%2F1eaya2yxl3t1ad7182dn.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%2F1eaya2yxl3t1ad7182dn.png" alt="A comparison matrix titled 'what actually isolates two tenants on a GPU', four rows by three columns. Rows: whole GPU (exclusive), MIG (hardware partition), MPS (multi-process service), time-slicing. Columns: memory isolation, fault isolation, compute QoS. Whole GPU: yes, yes, full device. MIG: yes (dedicated DRAM partition, hardware-enforced), yes (fault contained to the instance), guaranteed per slice. MPS: no (software soft caps only), weak (shared MPS server failure domain), soft percent cap. Time-slicing: none, none, none (equal time-share only). A note reads: only MIG or a whole GPU gives a hardware memory wall; MPS and time-slicing are software conventions, not walls." width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · the only two rows with a real memory wall are "whole GPU" and "MIG." MPS caps are software. Time-slicing has nothing. If your mental model was "different namespace, different memory," this is the row that corrects it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The config that creates this situation is ordinary and common. This is a device-plugin time-slicing setup, the kind people turn on to raise utilization:&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;sharing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;timeSlicing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resources&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;nvidia.com/gpu&lt;/span&gt;
      &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;          &lt;span class="c1"&gt;# one physical GPU -&amp;gt; four schedulable "GPUs", zero isolation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MPS swaps that block for per-client soft caps (&lt;code&gt;CUDA_MPS_PINNED_DEVICE_MEM_LIMIT&lt;/code&gt;, a compute percentage), which are enforced by the driver, not by hardware, and share a failure domain. This isn't only a noisy-neighbor concern. Security researchers have shown covert and side channels through the GPU's shared uncore engines, readable with unprivileged NVML calls, that bypass both MPS and MIG partitioning, and GPU DRAM that isn't zeroed on context teardown has leaked data across tenants under time-slicing. The one-line defensive setting: turn on &lt;code&gt;renameByDefault: true&lt;/code&gt; so a shared GPU advertises as &lt;code&gt;nvidia.com/gpu.shared&lt;/code&gt;, and a tenant can't request a "shared" GPU thinking it's a private one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; A Kubernetes namespace isolates the API and RBAC, never GPU memory. Under time-slicing or MPS, two tenants can sit on the same physical die with no hardware wall between their VRAM. If tenants don't trust each other, only MIG or a whole-GPU allocation is safe. This is the correction most GPU-cluster security models are missing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  MIG when tenants can't trust each other
&lt;/h2&gt;

&lt;p&gt;MIG is the answer when the boundary has to be real. It carves the physical card into hardware partitions, each with its own DRAM slice, L2, and SMs, so one instance cannot read another's memory regardless of driver or kernel bugs. Expose it through the GPU Operator in &lt;code&gt;mixed&lt;/code&gt; strategy, which advertises each profile as its own resource:&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 clusterpolicies.nvidia.com/cluster-policy &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'json'&lt;/span&gt; &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":"replace","path":"/spec/mig/strategy","value":"mixed"}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node then advertises MIG profiles as first-class schedulable resources, and a tenant requests a hardware-isolated slice by name:&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;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/mig-1g.10gb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;     &lt;span class="c1"&gt;# one hardware-isolated 10GB partition&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tradeoff is the one from part 1: partitions are fixed at configure time, reconfiguring drains the node's GPU pods, and a workload can't burst past its slice. That rigidity is the price of a real wall. For untrusted multi-tenancy it's a price worth paying; for a single trusted team it's usually not.&lt;/p&gt;

&lt;h2&gt;
  
  
  quotas that don't waste the cluster
&lt;/h2&gt;

&lt;p&gt;Isolation stops one tenant from reading another's memory. Quotas stop one tenant from eating the whole cluster. The blunt version is a &lt;code&gt;ResourceQuota&lt;/code&gt; capping GPU requests per namespace (extended resources are quotable only via &lt;code&gt;requests.&lt;/code&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ResourceQuota&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;team-a-gpu-quota&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;team-a&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests.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;8"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's static, though, and it strands GPUs whenever a team is idle. Kueue fixes that by admitting jobs against quota and letting teams in a shared cohort borrow each other's idle GPUs while guaranteeing each its floor back on demand. A &lt;code&gt;ClusterQueue&lt;/code&gt; holds the quota; the &lt;code&gt;cohortName&lt;/code&gt; is what enables borrowing:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kueue.x-k8s.io/v1beta2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterQueue&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;team-a-cq&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cohortName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpu-pool"&lt;/span&gt;                 &lt;span class="c1"&gt;# teams in one cohort lend/borrow idle GPUs&lt;/span&gt;
  &lt;span class="na"&gt;resourceGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;coveredResources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nvidia.com/gpu"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;flavors&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;h100-flavor&lt;/span&gt;
      &lt;span class="na"&gt;resources&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;nvidia.com/gpu&lt;/span&gt;
        &lt;span class="na"&gt;nominalQuota&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;                  &lt;span class="c1"&gt;# guaranteed floor&lt;/span&gt;
        &lt;span class="na"&gt;borrowingLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;                &lt;span class="c1"&gt;# cap on what it can borrow from the cohort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One version trap worth checking before you copy that: current Kueue serves &lt;code&gt;v1beta2&lt;/code&gt;, where cohorts are a named field (and can be their own CRD with fair-share weights). Plenty of clusters still run &lt;code&gt;v1beta1&lt;/code&gt;, where the field is a plain &lt;code&gt;spec.cohort&lt;/code&gt; string and the standalone Cohort doesn't exist. &lt;code&gt;kubectl get crd clusterqueues.kueue.x-k8s.io -o jsonpath='{.spec.versions[*].name}'&lt;/code&gt; tells you which you have. NVIDIA's KAI scheduler (the open-sourced core of Run:ai) models the same idea as a hierarchical &lt;code&gt;Queue&lt;/code&gt; with a &lt;code&gt;quota&lt;/code&gt; (the deserved floor) and an &lt;code&gt;overQuotaWeight&lt;/code&gt; (your proportional claim on idle GPUs above it), plus time-based fair-share so a team that under-used earlier gets favored later.&lt;/p&gt;

&lt;h2&gt;
  
  
  the endpoint nobody locked
&lt;/h2&gt;

&lt;p&gt;The most common GPU-cluster security hole isn't exotic. It's a vLLM pod with a Service and no authentication, reachable from anywhere on the cluster or, worse, the internet: free inference, prompt exfiltration, model theft. Lock it at three layers. Default-deny ingress so only the gateway can reach the model server:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;default-deny-ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;team-a&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ingress"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then an authenticated, TLS-terminating gateway in front (Gateway API &lt;code&gt;HTTPRoute&lt;/code&gt; with auth attached upstream), and a least-privilege ServiceAccount so a compromised inference pod can't read the rest of the cluster's secrets:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServiceAccount&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;vllm-sa&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;team-a&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;automountServiceAccountToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;     &lt;span class="c1"&gt;# the model server never calls the API server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which connects to the last quiet failure: secrets. Gated models return a 403 at download time when the pod has no valid Hugging Face token, and that surfaces as exactly the CrashLoopBackOff from part 7, except this time it really is the app. Mount the token from a Secret (or an external manager), never bake it into the image, and pin the model by digest so you don't silently load a tampered checkpoint:&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;env&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;HF_TOKEN&lt;/span&gt;
  &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;hf-token&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;token&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer &lt;code&gt;.safetensors&lt;/code&gt; over pickle formats (loading a pickle can execute arbitrary code), pin the model revision to a commit, and verify its checksum. The supply chain for a 140GB weight file deserves the same suspicion as any other dependency, which is the whole thesis of the lazy-security series if you want the longer version.&lt;/p&gt;

&lt;h2&gt;
  
  
  the frontier: confidential computing
&lt;/h2&gt;

&lt;p&gt;One emerging piece, worth knowing exists even if you're not deploying it yet. Confidential computing extends a CPU trusted execution environment to the GPU, so even the cloud operator hosting your node can't read the weights or activations in VRAM. H100 CC-mode is GA: the driver, running inside a confidential VM, encrypts everything crossing the PCIe bus, and CUDA apps run unmodified once trust is established. Blackwell extends it with NVLink encryption for multi-GPU confidential domains. Independent benchmarks put the overhead under 5% for typical LLM inference (it's the CPU-to-GPU I/O encryption that costs, so small-batch workloads pay a bit more). On Kubernetes it lands through Kata Containers and Confidential Containers, and it's still maturing operationally. For regulated or sensitive-IP inference, it's the direction; for most workloads today, it's a section to file away.&lt;/p&gt;

&lt;h2&gt;
  
  
  who's paying for the idle GPUs
&lt;/h2&gt;

&lt;p&gt;The last shared-cluster problem is money, and it's the biggest one, because cluster GPU utilization commonly sits at 30 to 40%. The gap between GPUs bought and GPUs doing work is enormous, and the fix is making teams see their own idle GPU-hours. Enforce a &lt;code&gt;team&lt;/code&gt; label on every GPU pod (a policy engine like Kyverno rejects pods without one), export per-GPU metrics with DCGM, and attribute GPU-hours per team:&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;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pods&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;must&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;carry&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'team'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chargeback."&lt;/span&gt;
  &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&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;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?*"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the label enforced, DCGM's per-pod metrics roll up into GPU-hours per team, and tagging inference requests with an &lt;code&gt;X-Team&lt;/code&gt; header at the gateway takes it down to token-level attribution. Showback doesn't reclaim a single GPU by itself. It just makes the waste visible to the people who can, which turns out to be most of the battle.&lt;/p&gt;

&lt;p&gt;That's eight parts, and it's time to say the thing the whole series was circling. A GPU deployment is a dozen layers under one pod, a small network in one box, a large one between boxes, a set of graphs that tell you the truth, an autoscaler that turns it off, a router that doubles it for free, a set of probes that keep it breathing, and a tenancy model that decides who it hurts when it breaks. The silicon was the easy part. Everything that makes it hard lives in the layers around it, and every one of those layers is a place you can be the person who saw it coming, or the one explaining it in the incident review. That was the job the entire time. The GPUs were never the point.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>kubernetes</category>
      <category>multitenancy</category>
      <category>security</category>
    </item>
    <item>
      <title>Your model isn't crashing, your probe is</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:15:33 +0000</pubDate>
      <link>https://dev.to/sachincool/your-model-isnt-crashing-your-probe-is-jbc</link>
      <guid>https://dev.to/sachincool/your-model-isnt-crashing-your-probe-is-jbc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-7-serving-ops" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-23.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The pods were in &lt;code&gt;CrashLoopBackOff&lt;/code&gt; and the logs said nothing. vLLM started, printed its usual banner, began loading weights, and then died. Restarted, loaded again, died again. It looked exactly like a broken build, so the first hour went into the model, the image, the CUDA version, everything except the actual culprit, which was a fourteen-line probe config nobody had looked at.&lt;/p&gt;

&lt;p&gt;This is part 7. The first six parts built the fleet, wired it, scaled it, watched it, and routed to it. This part is about keeping a model server alive through the three things that routinely kill it: a health probe that fires too early, a node drain that cuts a request in half, and a model-version rollout that drops traffic on the floor. None of it is glamorous. All of it is what stands between you and a 3am page.&lt;/p&gt;

&lt;h2&gt;
  
  
  your model isn't crashing, your probe is
&lt;/h2&gt;

&lt;p&gt;Here's the tell, and once you've seen it once you never miss it again:&lt;/p&gt;

&lt;p&gt;vLLM binds its HTTP port almost immediately, but &lt;code&gt;/health&lt;/code&gt; on &lt;code&gt;:8000&lt;/code&gt; only returns 200 once the weights are loaded and the engine is warm. For a 70B on a cold pull that's several minutes. A default liveness probe with a small &lt;code&gt;initialDelaySeconds&lt;/code&gt; starts checking during that window, gets a connection refused, decides the container is dead, and the kubelet kills it. It never finishes loading, so it never passes, so it loops forever. The exit code is 137 (128 + SIGKILL) which is the kubelet's fingerprint, not an application crash. That same code turns up for four other reasons outside model serving, and separating them is a two-command job: &lt;a href="https://dev.to/blog/kubernetes-crashloopbackoff-triage"&gt;CrashLoopBackOff: five causes and how to tell them apart&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;startupProbe&lt;/code&gt;. It holds the liveness and readiness probes off entirely until it succeeds, and its &lt;code&gt;failureThreshold × periodSeconds&lt;/code&gt; is your total load budget:&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;startupProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8000&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;      &lt;span class="c1"&gt;# 60 × 10s = 600s (10 min) to finish loading&lt;/span&gt;
&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8000&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;       &lt;span class="c1"&gt;# after startup, catch a real hang in 30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this beats just cranking &lt;code&gt;initialDelaySeconds: 600&lt;/code&gt; on the liveness probe is that once the startup probe passes, liveness reverts to its tight interval and still catches a genuine deadlock in thirty seconds. A giant liveness delay would blind you to real hangs for ten minutes after every restart. With the startup probe in place, the pods ride through the load and come up clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get pods -l app=vllm-llama3-70b
NAME                               READY   STATUS    RESTARTS   AGE
vllm-llama3-70b-7f4b9c6d8f-9m2tq   1/1     Running   0          8m03s
vllm-llama3-70b-7f4b9c6d8f-c8xvn   1/1     Running   0          8m03s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; A model that takes minutes to load needs a startupProbe, not a bigger liveness delay. Without one, the probe that's supposed to detect a dead server is the thing killing a healthy one, and the crash loop looks exactly like an application bug. This is the single most common self-inflicted LLM serving outage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  draining without dropping a stream
&lt;/h2&gt;

&lt;p&gt;The next one bites on every deploy and every node scale-down. When Kubernetes deletes a pod, it sends &lt;code&gt;SIGTERM&lt;/code&gt; and removes the pod from the service endpoints at the same instant. vLLM handles &lt;code&gt;SIGTERM&lt;/code&gt; correctly (it stops accepting new requests and finishes the in-flight ones), but if the load balancer is still routing to it during that beat, new requests land on a server that's shutting down. And if the grace period is too short, the kubelet &lt;code&gt;SIGKILL&lt;/code&gt;s the process mid-generation, dropping a half-finished response the client has to retry from scratch.&lt;/p&gt;

&lt;p&gt;Two fields fix it. A &lt;code&gt;preStop&lt;/code&gt; hook that sleeps long enough for the endpoint removal to propagate before &lt;code&gt;SIGTERM&lt;/code&gt; reaches vLLM, and a &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; set above your longest in-flight generation:&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;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;210&lt;/span&gt;   &lt;span class="c1"&gt;# preStop(15s) + longest decode(~180s) + margin&lt;/span&gt;
&lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;15"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sequence is worth internalizing, because the parallelism is the point: the endpoint removal and the &lt;code&gt;preStop&lt;/code&gt; sleep happen at the same time, so by the time &lt;code&gt;SIGTERM&lt;/code&gt; actually reaches vLLM, the load balancer has already stopped sending it traffic.&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%2Fiuosakkrps5h3zgx3y87.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%2Fiuosakkrps5h3zgx3y87.png" alt="A horizontal timeline titled 'what happens when a vLLM pod is deleted', showing the shutdown sequence left to right. t=0: pod marked Terminating, which forks into two parallel tracks: top track 'removed from EndpointSlice, load balancer stops routing new requests', bottom track 'preStop hook runs: sleep 15s'. Both converge at t=15s: 'SIGTERM sent to vLLM, which stops new admissions and finishes in-flight requests'. Then 'in-flight decode drains'. Finally at t=210s a marker 'SIGKILL if grace period elapses' shown in red as the backstop. A note reads 'endpoint removal and preStop run in parallel, so traffic stops before the process does'." width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · the drain, in order. The whole job of &lt;code&gt;preStop&lt;/code&gt; is to buy time for the load balancer to stop routing before vLLM stops answering.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  surviving a node drain
&lt;/h2&gt;

&lt;p&gt;Parts 3 and 5 leaned on Karpenter to consolidate and remove idle GPU nodes. That same consolidation, unguarded, will happily evict every replica of a service at once and take it to zero. The guard is a &lt;code&gt;PodDisruptionBudget&lt;/code&gt;, and for a small pool of expensive GPU replicas you want &lt;code&gt;maxUnavailable: 1&lt;/code&gt; so at most one goes down for any voluntary disruption:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;policy/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PodDisruptionBudget&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-llama3-70b-pdb&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;maxUnavailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;vllm-llama3-70b&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Karpenter drains through the Eviction API, which respects PDBs, so this is enough to keep the service serving through a consolidation. For a pod that's mid-critical-work and must not be interrupted at all, there's a stronger lever, the &lt;code&gt;karpenter.sh/do-not-disrupt&lt;/code&gt; annotation, which excludes its node from consolidation entirely:&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;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;karpenter.sh/do-not-disrupt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it deliberately, though. Leave it on permanently and you've told Karpenter it can never reclaim that node, which is how you end up back in part 5's problem of expensive GPUs that never scale down. The PDB is the always-on floor; the annotation is for pods you're actively protecting.&lt;/p&gt;

&lt;h2&gt;
  
  
  one replica, many pods
&lt;/h2&gt;

&lt;p&gt;When a model is too big for one node (the 405B from part 3, tensor-parallel across eight GPUs and pipeline-parallel across two nodes), a single replica &lt;em&gt;is&lt;/em&gt; a group of pods, and a normal Deployment can't express that. LeaderWorkerSet can. It treats a leader plus N-1 workers as one unit: &lt;code&gt;replicas&lt;/code&gt; is the number of these groups, &lt;code&gt;size&lt;/code&gt; is the pods per group, and &lt;code&gt;RecreateGroupOnPodRestart&lt;/code&gt; means if any pod in the group dies the whole group restarts, which is correct, because a tensor-parallel replica missing one member is dead weight:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;leaderworkerset.x-k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LeaderWorkerSet&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;                    &lt;span class="c1"&gt;# two independent model replicas&lt;/span&gt;
  &lt;span class="na"&gt;leaderWorkerTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;                      &lt;span class="c1"&gt;# 2 pods each: one leader + one worker&lt;/span&gt;
    &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RecreateGroupOnPodRestart&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leader starts the Ray head and vLLM's OpenAI server with &lt;code&gt;--tensor-parallel-size × --pipeline-parallel-size&lt;/code&gt; equal to the total GPUs across the group; the workers join the Ray cluster via the injected &lt;code&gt;LWS_LEADER_ADDRESS&lt;/code&gt;. The catch from part 2 still applies with force: the group has to land on NVLink-connected GPUs or the cross-node collective crawls, so pair LWS with gang scheduling and topology-aware placement (Kueue's TAS, or the KAI scheduler). Rolling updates are first-class through &lt;code&gt;rolloutStrategy&lt;/code&gt; with &lt;code&gt;maxUnavailable&lt;/code&gt; and &lt;code&gt;maxSurge&lt;/code&gt;, so you can roll a multi-node replica without taking the whole service down.&lt;/p&gt;

&lt;h2&gt;
  
  
  rolling out a new model without an outage
&lt;/h2&gt;

&lt;p&gt;The last way to drop traffic is deploying a new model version badly. KServe makes the careful version cheap: set &lt;code&gt;canaryTrafficPercent&lt;/code&gt; on an &lt;code&gt;InferenceService&lt;/code&gt; and point &lt;code&gt;storageUri&lt;/code&gt; at the new weights, and it splits traffic between the current good revision (which it tracks automatically) and the new one.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serving.kserve.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;InferenceService&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llama3-chat&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;predictor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;canaryTrafficPercent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;       &lt;span class="c1"&gt;# 10% to the new revision, 90% stays on last-good&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storageUri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://models/llama3-chat/v2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You watch the split live, and the metrics from part 4 (TTFT, error rate, and any quality signal) decide whether it graduates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get isvc llama3-chat
NAME          URL                       READY   PREV   LATEST   LATESTREADYREVISION
llama3-chat   http://llama3-chat...     True    90     10       llama3-chat-predictor-00002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Promotion is deleting the &lt;code&gt;canaryTrafficPercent&lt;/code&gt; field and re-applying: all traffic shifts to the new revision and the old one scales to zero. Blue-green is the same mechanism flipped straight to 100; shadow is mirroring live traffic to the candidate without returning its responses, so you can compare outputs with zero user risk. Two things to know before you rely on it. First, this traffic-splitting is a serverless (Knative) mode feature; in raw deployment mode you split with Gateway API route weights instead. Second, version the weights, the serving config, and the prompt template together as one revision, or your canary metrics are comparing two things that differ in ways you didn't track. (And don't reach for ModelMesh for multi-model serving; the project is archived.)&lt;/p&gt;

&lt;p&gt;That's the service staying up through loads, drains, and deploys. The last thing between you and a calm on-call isn't the software at all, it's other people: the tenants sharing your cluster, the ones who can reach your endpoint, and a GPU-memory boundary that turns out to be nowhere near where you think it is. That's the final part, and it's the one most likely to end up in an incident review.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>kubernetes</category>
      <category>vllm</category>
      <category>kserve</category>
    </item>
    <item>
      <title>The cheapest speedup is your load balancer</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:14:53 +0000</pubDate>
      <link>https://dev.to/sachincool/the-cheapest-speedup-is-your-load-balancer-3478</link>
      <guid>https://dev.to/sachincool/the-cheapest-speedup-is-your-load-balancer-3478</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-6-inference-routing" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-18.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A team I know had a slow chatbot and did the obvious thing: added replicas. TTFT barely moved. They added more. Still slow, and now the bill was worse. Every GPU showed healthy utilization, the queue metrics from part 4 were climbing, and adding capacity wasn't buying the speedup capacity is supposed to buy. Then someone swapped the Kubernetes Service in front of the pods for a prefix-cache-aware router, changed nothing else, and the same eight GPUs got more than twice as fast. The problem was never the GPUs or the count. It was the load balancer, quietly throwing away the most expensive thing the servers had built.&lt;/p&gt;

&lt;p&gt;This is part 6. Parts 1 through 5 built the fleet, watched it, and scaled it. This part is about the layer in front of the fleet: how requests get assigned to replicas, and why the default answer (round-robin, the same load balancing you'd use for a stateless web app) is the wrong one for LLM inference. It's the highest ratio of payoff to effort in the whole series, because the win comes from a routing decision, not from hardware you have to buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  the load balancer that throws away your cache
&lt;/h2&gt;

&lt;p&gt;Here's the thing a normal load balancer doesn't know: LLM replicas are not stateless. Each vLLM replica keeps its own KV cache (from part 2, the running memory of tokens it has already processed), and it also keeps a &lt;strong&gt;prefix cache&lt;/strong&gt;: if two requests share the same opening tokens (a long system prompt, a RAG document, the history of a chat), the second one can reuse the first one's cached computation and skip prefill entirely. Prefill is the compute-bound, expensive half of a request. A prefix cache hit makes it nearly free.&lt;/p&gt;

&lt;p&gt;But each replica has its &lt;em&gt;own&lt;/em&gt; prefix cache. A round-robin load balancer scatters requests across replicas blind to what each one holds. So a request that shares a 2,000-token prefix with something served thirty seconds ago lands on a different replica that never saw it, re-runs the entire prefill from scratch, and fills its KV cache doing redundant work. Multiply that across a prefix-heavy workload and the fleet spends most of its compute re-prefilling prompts it already processed, on a different pod. The caches sit there full of answers to questions that keep getting routed elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  the numbers, from one honest benchmark
&lt;/h2&gt;

&lt;p&gt;This isn't a theoretical gain, and the cleanest measurement of it is a benchmark Andy Golubev ran on EKS in June 2026. The setup was deliberately boring: Qwen2.5-7B on vLLM, eight &lt;code&gt;g5.xlarge&lt;/code&gt; nodes with one A10G each, one decode replica per node. Two runs, identical in every way except the front door. First run: a plain Kubernetes Service doing round-robin. Second run: an llm-d prefix-cache-aware router. Same model, same eight GPUs, same eight replicas. The workload was &lt;code&gt;vllm bench serve&lt;/code&gt; replaying 9,000 prompts that shared a pool of 150 long (2,048-token) prefixes, which is exactly the RAG-and-chat shape real traffic takes.&lt;/p&gt;

&lt;p&gt;The gap was not subtle.&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%2F5n6f73u3x9f4qu6q3edk.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%2F5n6f73u3x9f4qu6q3edk.png" alt="A before-and-after stat panel titled 'same 8 GPUs, only the router changed' comparing round-robin to prefix-cache-aware routing across five metrics. Output throughput: 2,742 to 6,423 tokens per second (2.34x). Wall clock for the run: 840 to 359 seconds. Mean TTFT: 19.0 seconds to 0.86 seconds (about 22x). Prefix cache hit rate: 11 percent to 93 percent. Requests waiting in queue: about 180 to 0. KV cache utilization: pinned near 99 percent down to 64 to 71 percent, showing headroom instead of thrash." width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · the Golubev EKS numbers. The one that matters most is the last one: round-robin ran the cache pinned at 99% and thrashing; the smart router left it headroom, because it stopped generating redundant work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read the cache-hit line again: 11% to 93%. Round-robin was reusing almost nothing; the aware router reused almost everything. Mean TTFT dropped from a genuinely broken 19 seconds to under a second. And the fleet stopped queueing (waiting requests went from ~180 to zero) not because it got more capacity, but because it stopped wasting the capacity it had. Google reports the same shape from GKE's managed version: TTFT improvements up to 96% at peak load on prefix-heavy workloads. The lesson is uncomfortable and freeing at once. Before you buy a ninth GPU, check whether your router is making the eight you have re-do each other's work.&lt;/p&gt;

&lt;h2&gt;
  
  
  routing to the replica that already knows
&lt;/h2&gt;

&lt;p&gt;The mechanism is simpler than it sounds. vLLM emits events about what its prefix cache holds. A cache-aware router consumes those events and keeps a live picture of which replica has which prefixes cached. When a request arrives, instead of picking the next replica in rotation, the router picks the one most likely to already hold the request's prefix, so the cache hit actually happens.&lt;/p&gt;

&lt;p&gt;That decision point has a name in the Kubernetes world: the &lt;strong&gt;Endpoint Picker&lt;/strong&gt;, or EPP. It doesn't just look at prefix locality. A good EPP scores every candidate replica on several signals at once and sends the request to the best total score:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;prefix-cache locality&lt;/strong&gt;: does this replica already hold the request's opening tokens? Longer match, higher score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;load&lt;/strong&gt;: what's this replica's KV-cache utilization and queue depth right now? A replica that's already saturated scores lower even if it has the prefix, so you don't stampede one hot pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LoRA affinity&lt;/strong&gt;: if you serve multiple fine-tuned adapters, is the right adapter already loaded here? Loading one is not free, so prefer the replica that has it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The router balances those against each other, and it can queue or shed when everything is overloaded. This is the difference between "which pod is next" and "which pod will serve this request fastest given what it already has warm." Same request, same pods, a decision made with information the round-robin balancer never had.&lt;/p&gt;

&lt;h2&gt;
  
  
  the standard nobody had two years ago
&lt;/h2&gt;

&lt;p&gt;The reason this is worth a whole chapter now, and wasn't in 2024, is that it stopped being a bespoke hack and became a Kubernetes standard. The &lt;strong&gt;Gateway API Inference Extension&lt;/strong&gt; is a SIG-Networking project that adds LLM-aware routing on top of the ordinary Gateway API. It introduces an &lt;code&gt;InferencePool&lt;/code&gt;, which is a group of pods that share the same accelerator, base model, and model server (the LLM-shaped version of a Service), and it wires an Endpoint Picker into the request path through Envoy's external-processing (&lt;code&gt;ext-proc&lt;/code&gt;) protocol, so the proxy calls out to the EPP for a decision on every request.&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%2Fu9tm3vcofreipzhl3orx.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%2Fu9tm3vcofreipzhl3orx.png" alt="An architecture diagram titled 'the inference gateway request path'. A request enters from the left into an Envoy-based Gateway (L7 proxy handling TLS and connection management). The gateway makes an ext-proc call out to the Endpoint Picker (EPP), which reads live signals from the replica pool (prefix-cache state, KV-cache utilization, queue depth, loaded LoRA adapters), scores each replica, and returns the chosen endpoint. The gateway then forwards the request to the selected replica inside an InferencePool of four vLLM pods. A note reads 'InferencePool is the LLM-aware Service; the EPP is the brain'." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 · the request path. The gateway is a normal Envoy proxy; the intelligence lives in the Endpoint Picker it consults per request, over the same ext-proc hook Envoy already uses for auth and rate limiting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The project has reached GA, with &lt;code&gt;InferencePool&lt;/code&gt; graduating to a stable v1 API. (One honest caveat: the exact GA milestone landed over early-to-mid 2026, so pin the version you're deploying rather than trusting a blog's date, this one included.) Google productized it as &lt;strong&gt;GKE Inference Gateway&lt;/strong&gt;, which is GA and is literally powered by the llm-d router underneath. So the "advanced" thing here is also increasingly the default, k8s-native thing, which is exactly the production framing this series cares about.&lt;/p&gt;

&lt;h2&gt;
  
  
  the whole stack, named
&lt;/h2&gt;

&lt;p&gt;"llm-d" gets thrown around as if it were a server. It isn't; it's an assembly, and it's clearer to name the pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vLLM&lt;/strong&gt; is the model server. It owns the per-replica KV and prefix cache and emits the cache events the router needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KServe&lt;/strong&gt; is the serving control plane, exposing an &lt;code&gt;LLMInferenceService&lt;/code&gt; custom resource so you describe the model and its serving config as a normal Kubernetes object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The inference gateway&lt;/strong&gt; is Envoy plus the Gateway API Inference Extension: the data plane plus the LLM-aware routing above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The router itself&lt;/strong&gt; is that L7 proxy plus the Endpoint Picker, making the per-request decision from cache, load, and LoRA signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disaggregated prefill/decode&lt;/strong&gt; from part 3 is an optional add-on here, with the KV cache handed between pools over a connector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're not ready to adopt the full gateway, the vLLM project's own &lt;strong&gt;production-stack&lt;/strong&gt; helm chart is a lighter on-ramp: it deploys a router service in front of your vLLM pods that already does model-aware and prefix-aware routing, plus KV-cache offload through LMCache. Same idea, smaller commitment. Either way, the thing you're installing is a router that knows what a KV cache is.&lt;/p&gt;

&lt;h2&gt;
  
  
  watching the router
&lt;/h2&gt;

&lt;p&gt;Part 4 said the metrics that matter live in the serving engine. Add one more surface: the router. The signals that tell you whether the routing layer is earning its keep are the ones that moved in that benchmark. Prefix cache hit rate is the headline. A round-robin fleet sits low (that 11%); a well-routed prefix-heavy fleet should sit high (the 93%). If you turned on cache-aware routing and the hit rate didn't climb, either your workload doesn't actually share prefixes or the router isn't seeing the cache events.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; Prefix cache hit rate is the one dashboard number that proves the router is working. Watch it alongside per-replica KV utilization and waiting-request count. If hit rate is high and evenly spread, routing is doing its job. If one replica is pinned while others idle, your scorer is over-weighting cache locality and stampeding a hot pod. If hit rate is flat near zero, your traffic isn't prefix-heavy and this whole chapter buys you little.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last line is the honest boundary. Cache-aware routing is close to free money for workloads with real prefix reuse: RAG over a shared corpus, long system prompts, multi-turn chat, agents replaying context. For traffic where every prompt is unique and short, there's no cache to reuse and the fancy router mostly just adds a hop. The KV-connector interfaces underneath are also still moving, so expect some churn if you build on the bleeding edge. Know which workload you have before you reach for this, the same way you'd check the topology in part 2 before promising a throughput number.&lt;/p&gt;

&lt;p&gt;The router is the front door. What sits behind it, the probes that decide when a replica is ready, the way you roll a new model version out without dropping a request, the tenants all fighting over the same pool of GPUs, is where a production inference platform actually gets hard. That's where the series goes next.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>inference</category>
      <category>routing</category>
      <category>vllm</category>
    </item>
    <item>
      <title>Scaling GPU inference to zero and back</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:14:48 +0000</pubDate>
      <link>https://dev.to/sachincool/scaling-gpu-inference-to-zero-and-back-3ebm</link>
      <guid>https://dev.to/sachincool/scaling-gpu-inference-to-zero-and-back-3ebm</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-5-scale-to-zero" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-16.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The finance dashboard is what started it. A staging cluster running two H100 nodes around the clock, mostly to serve a demo that got maybe forty requests a day, all of them during business hours. The nodes sat idle from 7pm to 9am and every weekend, billing the whole time. Somebody added scale-to-zero over a Friday. Monday morning the first person to open the demo waited six minutes for a response, assumed it was broken, and filed a bug. The bill went down and the product got worse, which is scale-to-zero working exactly as designed and nobody being happy about it.&lt;/p&gt;

&lt;p&gt;This is the last part of the series. Parts 1 through 4 covered building GPU infrastructure and seeing what it's doing. This part is about the thing that actually shows up on the invoice: a GPU you're paying for while it does nothing. Scaling GPU inference elastically, all the way to zero when there's no traffic, is the biggest lever on the bill. It's also genuinely hard, because unlike a stateless web pod that starts in a second, a GPU replica has to drag a hundred gigabytes of model weights onto the card before it can serve a single token. The whole post is about that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  the cold-start tax
&lt;/h2&gt;

&lt;p&gt;Scaling a web service to zero is free because starting a new pod is nearly instant. Scaling an LLM to zero is expensive because starting a replica is not. Add up what has to happen before the first token, on a cold node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node provisioning.&lt;/strong&gt; The cluster asks the cloud for a GPU node, waits for it to boot and join. One to five minutes, and that assumes the GPU is even available to hand you (more on that at the end).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image pull.&lt;/strong&gt; A CUDA plus vLLM container image is commonly 5 to 15 GB. On a fresh node with a cold cache, pulling and unpacking it is minutes, not seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model load.&lt;/strong&gt; Llama-3-70B in BF16 is about 140 GB of weights, usually sitting in object storage. Reading that over the network and moving it onto the GPU is the big one, and done naively it's several minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warmup.&lt;/strong&gt; CUDA graph capture, &lt;code&gt;torch.compile&lt;/code&gt;, and a few dummy forward passes to populate caches. Tens of seconds before the first real request is fast.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack those up and a naive 70B cold start runs six to nine minutes, more if those 140 GB of weights come cold over a slow path. That's the tax. Everything else in this post is a way to stop paying it, so that scale-to-zero saves the money without the six-minute Monday.&lt;/p&gt;

&lt;p&gt;One cruel interaction hides in here: a naive Kubernetes health probe treats that multi-minute load as a failure. A liveness probe with default timing restarts the pod mid-load, and now you have a crash loop that looks like a vLLM bug but is really a probe firing too early. Set the liveness &lt;code&gt;initialDelaySeconds&lt;/code&gt; above your worst-case load time, keep it longer than the readiness delay, and add a &lt;code&gt;preStop&lt;/code&gt; sleep so in-flight requests drain before the pod goes down. It's the single most common self-inflicted serving outage, and it costs nothing to avoid once you've seen it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  scale on the queue, not the GPU
&lt;/h2&gt;

&lt;p&gt;Before scaling to zero, get scaling to &lt;em&gt;anything&lt;/em&gt; right, and the first mistake is scaling on GPU utilization. It's the obvious metric and it's the wrong one, for the reason part 4 laid out: a GPU can sit at 95% while the queue is empty, or at 40% while requests pile up. Scaling on GPU-util adds replicas late and removes them at the wrong time.&lt;/p&gt;

&lt;p&gt;Scale on the serving signal instead. The metric that actually tracks unmet demand is the queue: &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt;, or KV-cache utilization as a leading indicator. You'll still find plenty of setups scaling on GPU-util or cache percentage, and those aren't wrong so much as indirect; the point is to scale on demand rather than on how busy the chip happens to look, with tail latency as the guardrail. In practice that means KEDA (the Kubernetes event-driven autoscaler) with a Prometheus trigger reading that metric, or an HPA wired to the same value through the Prometheus adapter. For traffic that's genuinely request-driven and spiky, KEDA's HTTP add-on can scale on in-flight request count directly. The shape is the same: pick the metric that means "users are waiting," set the threshold at the per-replica capacity you measured with the load test in part 4, and let it add replicas before the queue becomes TTFT.&lt;/p&gt;

&lt;h2&gt;
  
  
  actually reaching zero
&lt;/h2&gt;

&lt;p&gt;Scaling to zero is a special case of scaling, with one extra problem: when you're at zero replicas, there's nothing running to receive the request that's supposed to wake you up. Something has to catch that first request and hold it while a replica spins up.&lt;/p&gt;

&lt;p&gt;KEDA does this with an activation threshold: below it, the deployment sits at zero; the first event scales it to one. Knative Serving builds the pattern in more deliberately, with an &lt;strong&gt;activator&lt;/strong&gt; component that buffers incoming requests while a cold replica starts, then releases them once it's ready, so the request is slow but not dropped. KServe (which runs on Knative) exposes this as a simple &lt;code&gt;minReplicas: 0&lt;/code&gt; on an InferenceService, and it's the most common way teams get GPU model servers to zero on Kubernetes.&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%2F1hkgkw42evd32a4pa7pg.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%2F1hkgkw42evd32a4pa7pg.png" alt="A lifecycle state diagram titled 'scale-to-zero, and the request that pays for it'. Four states in a loop: 'ZERO: no replicas, no cost' at rest; an incoming request triggers an arrow to 'ACTIVATING: activator buffers the request, autoscaler asks for a GPU node'; then 'COLD START: pull image, load weights, warm up' (annotated 'the first user waits here'); then 'SERVING: replica ready, requests flow'; and after an idle timeout an arrow back to 'ZERO'. The buffered first request is shown held at the activator through the cold-start state." width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · the lifecycle, and the unlucky first request. Everything after this figure is about shrinking the cold-start box so that request waits seconds, not minutes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The honest catch is that reaching zero and the cold-start tax are the same coin. Zero replicas is where the savings are, and it's also where every request pays the full six minutes. Everything below is about making that first request cost seconds instead, because a scale-to-zero setup with a six-minute cold start is a cost win and a product loss, and you rarely get to keep both.&lt;/p&gt;

&lt;p&gt;There's also a breakeven worth running before you build any of this. Scale-to-zero wins when utilization is low and spiky; it loses somewhere around half-time, where a dedicated node is both cheaper than paying serverless rates by the second and free of cold starts entirely. If a GPU is busy more than half the day, don't scale it to zero. Right-size it and leave it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  killing the image pull
&lt;/h2&gt;

&lt;p&gt;The container image is the first fixable minute. Two families of fix.&lt;/p&gt;

&lt;p&gt;The first is to stop pulling the whole thing before you start. &lt;strong&gt;SOCI&lt;/strong&gt; (Seekable OCI), lazy-loading via a containerd snapshotter, lets a container start running against an index of the image and fetch the actual bytes on demand, so the model server boots while the layers it doesn't need yet are still downloading. &lt;code&gt;estargz&lt;/code&gt; (the containerd stargz snapshotter) and Nydus do the same lazy-pull trick with different formats; GKE's Image Streaming is the managed version, and it cut a 5.4 GB Triton image's start from 191 seconds to 30. There's also a variant for the case where you &lt;em&gt;will&lt;/em&gt; read the whole image anyway (AI images touch most of their bytes immediately): SOCI's parallel-pull mode just parallelizes the download and unpack instead of lazy-loading, and AWS measured roughly 60% off a 10 GB image.&lt;/p&gt;

&lt;p&gt;The second is to not pull over the network at all. Pre-bake the image into the node's disk image so it's local before the pod schedules. Run an in-cluster pull-through cache or a peer-to-peer image mirror (Spegel and friends) so the second node to need an image gets it from a neighbor instead of the registry. Pre-pull hot images with a DaemonSet so they're warm before traffic arrives. None of these is clever; all of them beat pulling 15 GB cold from a registry when a hundred replicas try it at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  killing the model load
&lt;/h2&gt;

&lt;p&gt;The bigger minute is the model weights, and this is where the newer tooling has moved fast. Loading 140 GB of safetensors off a disk or object store the default way is serial and slow. The fixes stream instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NVIDIA's Run:ai Model Streamer&lt;/strong&gt; reads weights from object storage in many parallel streams straight onto the GPU, overlapping download with load, and vLLM supports it directly (&lt;code&gt;--load-format runai_streamer&lt;/code&gt;). NVIDIA's own benchmark took an 8B model's S3 load from 28 seconds at four streams to under five at thirty-two. CoreWeave's &lt;strong&gt;tensorizer&lt;/strong&gt; (&lt;code&gt;--load-format tensorizer&lt;/code&gt;) serializes the model into a format that streams from S3 or local disk with near-zero deserialization overhead. Both turn a multi-minute load into tens of seconds. Underneath, &lt;code&gt;safetensors&lt;/code&gt; already supports memory-mapped zero-copy loading, which helps when the file is local.&lt;/p&gt;

&lt;p&gt;And local is the other half. Cache the weights on the node's NVMe (an instance-store disk) so a restart reads from local flash instead of re-downloading. Or mount a shared, fast filesystem so every replica reads the same warm copy, but mind the access mode: a &lt;code&gt;ReadWriteOnce&lt;/code&gt; PVC serves one replica and then silently blocks the second pod from mounting, so anything that scales past a single replica needs &lt;code&gt;ReadWriteMany&lt;/code&gt; (EFS, FSx for Lustre, NFS, CephFS). That RWO-to-RWX switch is a classic thing to discover the first time an autoscale event never becomes a second pod. Some teams ship the model as its own OCI artifact and let the image machinery above handle it (KServe's modelcar pattern). The principle is the same one from part 2: the bandwidth between the weights and the GPU is the bottleneck, so shorten that path.&lt;/p&gt;

&lt;p&gt;The frontier technique skips loading altogether. GPU memory snapshots (Modal and Cerebrium both ship this, built on the CUDA checkpoint/restore API in recent driver branches) checkpoint a fully warmed replica, weights on the card and CUDA graphs already captured, then restore that image straight onto a GPU. Because it bypasses weight load, &lt;code&gt;torch.compile&lt;/code&gt;, and graph capture in one move, it's the only approach that also kills the warmup tax. Modal reports a vLLM Qwen model dropping from 45 seconds to 5, and Cerebrium measured cold starts down 71 to 88 percent. The catch is portability: a snapshot is pinned to a specific GPU model and driver branch, so it's a per-SKU artifact, not a universal one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; Scale-to-zero is a tradeoff, not a free win. Every second of cold start is latency the first user eats; every minute of warm idle is money you burn. You buy down the cold start with lazy image pulls, model streaming, and a warm node pool, then set a minimum replica floor high enough that your p99 cold start stays inside the SLO. "Zero when truly idle, one when it might not be" beats a dogmatic zero.&lt;/p&gt;
&lt;/blockquote&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%2Flv5960rxc07xy5b29x4a.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%2Flv5960rxc07xy5b29x4a.png" alt="A diagram mapping each cold-start stage to the fixes that attack it, three columns. Column one 'image pull (minutes)': SOCI / stargz / Nydus lazy pull, pre-bake into the node image, P2P mirror (Spegel), slim the image. Column two 'model load (minutes)': Run:ai Model Streamer, tensorizer, local NVMe or shared PVC cache. Column three 'warmup (tens of seconds)': GPU memory snapshots, limited CUDA graph sizes. A banner across the bottom reads 'you have to attack all three: the slowest unfixed stage sets the wait'." width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 · the fix menu, one column per stage. Snapshots are the only trick in the third column, which is why they're the frontier: everything else leaves the warmup tax standing.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  turning off idle nodes
&lt;/h2&gt;

&lt;p&gt;Scaling pods to zero doesn't save anything if the expensive GPU node they were on keeps running. The node has to go too. This is the autoscaler's job below the pod level: Karpenter consolidates workloads and removes nodes that are empty or underutilized (&lt;code&gt;consolidateAfter&lt;/code&gt;, disruption budgets so it doesn't yank capacity mid-request), and the older cluster-autoscaler scales node groups down on the same principle. A GPU node that's been empty for a few minutes is thirty to a hundred-plus dollars a day; letting it linger is the single most common way GPU bills quietly balloon.&lt;/p&gt;

&lt;p&gt;For predictable traffic, the laziest win is scheduled scaling. If the demo only serves business hours, a cron trigger that scales the floor to zero at 7pm and back to one at 9am captures most of the savings with none of the cold-start risk during the day. And to hide cold starts when you do scale up, keep a warm node in reserve with low-priority placeholder pods (over-provisioning): real work evicts the placeholders instantly and lands on a node that already has the image, so the pod cold start doesn't also pay the node cold start.&lt;/p&gt;

&lt;h2&gt;
  
  
  spot, and the capacity trap
&lt;/h2&gt;

&lt;p&gt;Two closing realities that scale-to-zero runs into. Spot instances make idle capacity cheap, and for inference they can work (unlike the gang-scheduled training from part 3, a single inference replica dying is survivable). But a spot GPU can be reclaimed with about two minutes' notice, so you need a node-termination handler that drains in-flight requests and a plan for where the replacement comes from.&lt;/p&gt;

&lt;p&gt;Which is the trap: scaling up assumes there's a GPU to scale up &lt;em&gt;onto&lt;/em&gt;. In 2026, popular GPUs are not always available on demand, and a scale-to-zero service that can't reacquire an H100 at 9am Monday is worse than one that never scaled down. The mitigations are the capacity blocks and reservations from part 3, an on-demand fallback when spot is dry, and a warm floor for anything with a real SLO. Scale-to-zero is a cost strategy, not a capacity strategy, and confusing the two is how you save money right up until the morning you can't get your GPUs back.&lt;/p&gt;

&lt;p&gt;That's the cost side handled. The fleet scales with demand and turns itself off when it's idle, and the first user back doesn't wait five minutes for the privilege. But there's one lever left, and it's the strangest one, because it costs nothing to pull. Everything so far has quietly assumed that a request, once it arrives, lands on some replica and gets served. Part 6 is about that word "some." It turns out that which replica you pick, out of a pool that all look identical from the outside, can make the exact same hardware more than twice as fast. The load balancer, of all the unglamorous things, is where the last big win hides.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>autoscaling</category>
      <category>kubernetes</category>
      <category>scaletozero</category>
    </item>
    <item>
      <title>What a green GPU dashboard hides</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:14:09 +0000</pubDate>
      <link>https://dev.to/sachincool/what-a-green-gpu-dashboard-hides-33f4</link>
      <guid>https://dev.to/sachincool/what-a-green-gpu-dashboard-hides-33f4</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-4-observability" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-11.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The GPU dashboard was a wall of green. Every card pinned at 90-something percent utilization, power near TDP, temperatures fine, no XID errors. By every signal from part 1 of this series, the box was healthy and working hard. Meanwhile the on-call channel had three messages from product asking why the chatbot took eight seconds to say its first word. The hardware was busy. The users were furious. Both were true.&lt;/p&gt;

&lt;p&gt;That is the trap of monitoring GPU inference with GPU metrics. A card at 92% utilization tells you a kernel is running. It tells you nothing about whether requests are piling up in a queue, whether the KV cache is full and requests are being evicted, or whether the first token is landing in 200 milliseconds or eight seconds. This is part 4 of the series. Parts 1 through 3 built the thing: the stack under a pod, the wires in a box, the network between boxes. This part is about seeing what it's doing once real traffic hits it, and the short version is that the numbers users feel live in the serving engine, not on the GPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  the four numbers users actually feel
&lt;/h2&gt;

&lt;p&gt;Before any dashboard, get the vocabulary straight, because LLM latency is not one number. A request has a shape, and four measurements describe it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTFT&lt;/strong&gt;, time to first token, is how long the user stares at a blinking cursor before anything appears. It's dominated by prefill (processing the whole prompt) plus however long the request sat in a queue before the server picked it up. This is the number product complains about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ITL&lt;/strong&gt;, inter-token latency, is the gap between consecutive tokens once generation starts. (Some tools report TPOT, time per output token, the averaged version of the same thing; benchmarks like GuideLLM show both side by side, so don't treat them as interchangeable in a report.) It's what makes text feel like it's streaming smoothly or stuttering out. Decode is memory-bandwidth-bound, so ITL degrades as you pack more concurrent requests onto a GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;End-to-end latency&lt;/strong&gt; is the whole request, first byte to last. For a long generation it's mostly &lt;code&gt;output_tokens × ITL&lt;/code&gt;, which means it's as much about how much the model says as how fast it says it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throughput&lt;/strong&gt; is output tokens per second across all requests, and it moves in the opposite direction from latency. Batch more requests together and throughput climbs while per-request latency gets worse. There's no single "fast" setting. There's a frontier, and where you sit on it is a product decision (a chatbot wants low TTFT, a batch summarization job wants raw throughput) rather than a tuning default.&lt;/p&gt;

&lt;p&gt;The number that captures both at once is &lt;strong&gt;goodput&lt;/strong&gt;: the requests per second you can serve while still meeting your latency SLO. A server can post gorgeous raw throughput while quietly blowing p99 TTFT for half its users, so goodput (throughput, filtered by "did it meet the SLO") is the only throughput figure worth quoting.&lt;/p&gt;

&lt;h2&gt;
  
  
  what vLLM actually tells you
&lt;/h2&gt;

&lt;p&gt;vLLM exposes a Prometheus &lt;code&gt;/metrics&lt;/code&gt; endpoint, and once you've read it a few times the health of the server is obvious at a glance. The metrics that matter split into two groups: what the queue is doing, and what the KV cache is doing.&lt;/p&gt;

&lt;p&gt;The queue first. &lt;code&gt;vllm:num_requests_running&lt;/code&gt; is how many requests are being served right now; &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt; is how many are stuck in line because the server can't fit them yet. A healthy server has a running count near its batch capacity and a waiting count near zero. When &lt;code&gt;num_requests_waiting&lt;/code&gt; starts climbing and staying up, that eight-second TTFT has arrived, and no GPU metric will show it. &lt;code&gt;vllm:request_queue_time_seconds&lt;/code&gt; measures the wait directly.&lt;/p&gt;

&lt;p&gt;Then the KV cache (the model's running memory of the tokens it has already processed, which grows with every active request and every token generated). &lt;code&gt;vllm:kv_cache_usage_perc&lt;/code&gt; is the fraction of KV cache in use. This is the one to stare at, because when it approaches 100% vLLM has to start &lt;strong&gt;preempting&lt;/strong&gt;: evicting a half-finished request to free memory, then recomputing it from scratch later. &lt;code&gt;vllm:num_preemptions_total&lt;/code&gt; counts that happening. A rising preemption rate means the server is thrashing, doing the same work twice, and every latency number is about to get worse.&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%2Fwp6uak8186dse8mqcxyz.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%2Fwp6uak8186dse8mqcxyz.png" alt="A side-by-side comparison of two vLLM server states drawn as metric panels. Left panel labeled 'healthy': running requests near batch capacity, waiting requests at zero, KV-cache usage around 60 percent, preemptions flat at zero, p99 TTFT well under the SLO line. Right panel labeled 'drowning': running requests flat at capacity while waiting requests climb, KV-cache usage pinned near 100 percent, preemptions rising, p99 TTFT crossing above the SLO line. A note reads 'the GPU looks equally busy in both; only the serving metrics tell them apart'." width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · the same server, healthy and drowning. GPU utilization is high in both; the queue, the cache, and the preemption counter are what separate a busy server from a failing one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The rest fill in the picture. &lt;code&gt;vllm:time_to_first_token_seconds&lt;/code&gt; and &lt;code&gt;vllm:inter_token_latency_seconds&lt;/code&gt; are histograms, so you alert on the p95 or p99, not the average (the average hides the user who waited twelve seconds). &lt;code&gt;vllm:prompt_tokens_total&lt;/code&gt; and &lt;code&gt;vllm:generation_tokens_total&lt;/code&gt; give you real throughput, computed with &lt;code&gt;rate()&lt;/code&gt; (vLLM removed its old pre-averaged throughput gauges, so you do the division yourself). And &lt;code&gt;vllm:prefix_cache_hits_total&lt;/code&gt; over &lt;code&gt;vllm:prefix_cache_queries_total&lt;/code&gt; tells you how much prompt reuse you're getting, which matters enormously for the RAG and multi-turn workloads from part 2. It's also the number a smart router watches to decide which replica to send a request to, which is the whole subject of part 6.&lt;/p&gt;

&lt;p&gt;One warning that will save you an afternoon: these names drift between releases. The V1 engine renamed the KV-cache gauge (it used to be &lt;code&gt;gpu_cache_usage_perc&lt;/code&gt;) and swapped the per-output-token metric to &lt;code&gt;inter_token_latency_seconds&lt;/code&gt;. Diff the live &lt;code&gt;/metrics&lt;/code&gt; output of your exact build before you copy anyone's PromQL, this post included.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; GPU utilization tells you the chip is busy; it never tells you users are waiting. The metric that predicts an angry inbox is vllm:num_requests_waiting climbing, usually because vllm:kv_cache_usage_perc hit its ceiling and the server started evicting half-finished requests. Watch the queue and the cache, not GPU-util.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  the same story in SGLang and Triton
&lt;/h2&gt;

&lt;p&gt;The engine changes, the questions don't. SGLang exposes its own Prometheus metrics behind &lt;code&gt;--enable-metrics&lt;/code&gt;: &lt;code&gt;sglang:num_running_reqs&lt;/code&gt; and &lt;code&gt;sglang:num_queue_reqs&lt;/code&gt; are the running-and-waiting pair, &lt;code&gt;sglang:token_usage&lt;/code&gt; is the KV-cache fraction, and &lt;code&gt;sglang:cache_hit_rate&lt;/code&gt; reports how often its RadixAttention prefix cache paid off. (One gotcha of exactly the kind above: SGLang flipped the metric prefix from &lt;code&gt;sglang:&lt;/code&gt; to &lt;code&gt;sglang_&lt;/code&gt; in v0.5.4, and the bundled Grafana dashboard hasn't caught up, so a fresh install can read "No Data" until you fix the prefix.) If you built on SGLang for its prefix-sharing (the reason to pick it in part 2), that last one is how you confirm the bet is paying.&lt;/p&gt;

&lt;p&gt;Triton with the TensorRT-LLM backend reports through Triton's metrics endpoint instead: request and queue durations, inflight-batcher stats, per-model success and failure counts. Different names, same three questions every time: how long are requests waiting, is the cache saturated, is the tail latency inside the SLO.&lt;/p&gt;

&lt;p&gt;The lesson worth internalizing is that these are all the same dashboard with different labels. Queue depth, cache utilization, tail TTFT, tail ITL. Learn to read one engine and you can read all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  compute-bound, memory-bound, or queue-bound
&lt;/h2&gt;

&lt;p&gt;The reason to keep the GPU metrics from part 1 next to the serving metrics is that together they diagnose &lt;em&gt;why&lt;/em&gt; the server is slow, which is the only thing that tells you what to do about it. Three shapes cover most incidents.&lt;/p&gt;

&lt;p&gt;If TTFT is high and &lt;code&gt;num_requests_waiting&lt;/code&gt; is high but &lt;code&gt;gpu_cache_usage_perc&lt;/code&gt; is low, you're &lt;strong&gt;queue-bound&lt;/strong&gt;: requests are backing up faster than you can start them, and the fix is more replicas (which is part 5). If ITL is degrading and &lt;code&gt;DCGM_FI_PROF_DRAM_ACTIVE&lt;/code&gt; is pinned while tensor activity isn't, you're &lt;strong&gt;memory-bound&lt;/strong&gt; on decode, and the fix is a smaller batch, quantization, or better KV-cache management. If tensor cores are saturated during prefill and DRAM isn't, you're &lt;strong&gt;compute-bound&lt;/strong&gt;, which for inference usually means very long prompts and points at chunked prefill or a prompt-length limit.&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%2Fb2o5vv2yr459zozg0809.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%2Fb2o5vv2yr459zozg0809.png" alt="A three-way decision diagram titled 'why is the server slow?'. A central question branches on the combination of serving and GPU metrics into three outcomes. Branch one: high queue (num_requests_waiting up) plus low KV-cache usage routes to 'queue-bound: add replicas'. Branch two: degrading inter-token latency plus DRAM_ACTIVE pinned but low tensor activity routes to 'memory-bound on decode: smaller batch, quantize'. Branch three: tensor cores saturated during prefill routes to 'compute-bound: chunked prefill, cap prompt length'. Each branch pairs a serving metric with a DCGM metric." width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 · the serving metric tells you something is wrong; the GPU metric next to it tells you which kind of wrong. You need both panels on the same screen.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Neither set of metrics is enough alone. GPU metrics without serving metrics miss the queue entirely. Serving metrics without GPU metrics can't tell a memory-bound stall from a compute-bound one. The dashboard that works has both, side by side, on one screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  what to alert on
&lt;/h2&gt;

&lt;p&gt;Most teams over-alert on the GPU and under-alert on the experience. The page that matters fires on the user's SLO, not the hardware's vitals. A working starter set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTFT p99 over budget&lt;/strong&gt; for N minutes. This is the customer-facing SLO. Everything else is a leading indicator of this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;num_requests_waiting&lt;/code&gt; sustained above zero.&lt;/strong&gt; A brief spike is fine; a standing queue means you're under-provisioned and the next thing to break is TTFT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preemption rate climbing.&lt;/strong&gt; &lt;code&gt;num_preemptions_total&lt;/code&gt; moving means the KV cache is saturated and the server is recomputing evicted work. It's the early warning before latency falls off a cliff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error rate.&lt;/strong&gt; Request failures and, specifically, CUDA out-of-memory events, which on an inference server usually mean a batch or context-length setting is too aggressive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The part-1 hardware alerts still stand.&lt;/strong&gt; XID errors, thermal throttle, ECC. A dying GPU shows up as latency variance long before it shows up as an error, so keep those wired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tail latency is the whole game here. Alerting on average TTFT is how you find out about an outage from the customer instead of the pager, because the average stays calm while your p99 is on fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  tracing a single slow request
&lt;/h2&gt;

&lt;p&gt;Aggregate metrics tell you the fleet is unhealthy. They don't tell you why &lt;em&gt;this&lt;/em&gt; request took nine seconds. For that you want per-request tracing, and the ecosystem has standardized on OpenTelemetry's GenAI semantic conventions: spans carry &lt;code&gt;gen_ai.*&lt;/code&gt; attributes (the model, input and output token counts, the request parameters) so a single request's journey through the gateway, the queue, prefill, and decode is one connected trace. When a specific user reports a slow response, a trace tells you whether it sat in a queue, hit a cache miss, or just asked for a 4,000-token essay. The metrics say the kitchen is slow; the trace shows you which order got lost. Which of these should wake someone is a separate decision, and worth making on the error budget rather than a utilisation threshold: &lt;a href="https://dev.to/blog/prometheus-burn-rate-alerts"&gt;alert on the error budget, not the CPU graph&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  proving it before prod
&lt;/h2&gt;

&lt;p&gt;You don't want to discover your latency frontier during a launch. Load-test the serving endpoint before it sees real traffic, with a tool that speaks LLM rather than plain HTTP. vLLM ships &lt;code&gt;vllm bench serve&lt;/code&gt; (the old &lt;code&gt;benchmark_serving.py&lt;/code&gt;), which replays a request distribution, reports TTFT, ITL, and throughput percentiles, and computes goodput directly if you hand it SLO thresholds. GuideLLM (now a Red Hat project) does the same with a &lt;code&gt;sweep&lt;/code&gt; mode that finds your safe operating range on its own; NVIDIA's genai-perf covers the Triton side, though NVIDIA is steering new work to its successor AIPerf; and the Kubernetes serving working group's inference-perf standardizes the numbers across engines so you can compare vLLM to SGLang honestly. Whatever you pick, the output you care about is the same: the curve of tail latency against offered load, and the load at which p99 TTFT crosses your SLO. That crossover is the goodput ceiling, the real per-replica capacity, and it's the input to everything in part 5.&lt;/p&gt;

&lt;p&gt;Because that's the thing this post sets up. Once you can see the queue building and the cache saturating, the obvious next question is: why am I staring at these graphs manually at 2am instead of having the queue depth add a replica by itself, and drop it again when the traffic goes home. That's scaling, and scaling GPUs that cost six dollars an hour is its own kind of problem.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>observability</category>
      <category>vllm</category>
      <category>prometheus</category>
    </item>
    <item>
      <title>Scaling GPUs past one box</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:14:04 +0000</pubDate>
      <link>https://dev.to/sachincool/scaling-gpus-past-one-box-1k62</link>
      <guid>https://dev.to/sachincool/scaling-gpus-past-one-box-1k62</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-3-scaling-out" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-09.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;When Meta trained Llama 3 405B on 16,384 H100s, the cluster hit 419 unexpected interruptions over 54 days. That's one failure every three hours or so, for 54 days straight, and about 78% of them were hardware. GPUs and their HBM3 memory accounted for roughly half. They also watched the datacenter's power draw swing by tens of megawatts as thousands of GPUs idled and resumed in sync, which is a sentence that should make any infrastructure engineer sit up.&lt;/p&gt;

&lt;p&gt;This is the part of the series where the GPU stops being the interesting component. Part 1 was the stack under one pod, part 2 was one box and the wires inside it. Once you cross the node boundary, the network becomes the machine, failures become continuous rather than exceptional, and the scheduler decides whether your very expensive cluster does useful work or deadlocks against itself. Everything here is about the stuff between the boxes.&lt;/p&gt;

&lt;h2&gt;
  
  
  the network is the machine
&lt;/h2&gt;

&lt;p&gt;Synchronous training does an all-reduce of the entire gradient (every parameter, billions of them) every single step. That collective is a hard barrier: the slowest link gates every GPU in the job. Add nodes and your compute scales, but the all-reduce volume grows and so does the chance that one link is slow or dead. This is why, past one node, communication rather than FLOPs sets your scaling efficiency, and why the network gear costs as much attention as the GPUs.&lt;/p&gt;

&lt;p&gt;The fabric itself is a two-horse race. InfiniBand is the incumbent for dedicated training superclusters: the generation ladder runs EDR 100Gb, HDR 200Gb, NDR 400Gb (Quantum-2 switches, ConnectX-7 NICs), and now XDR 800Gb (Quantum-X800, ConnectX-8). Most DGX SuperPODs and the big named clusters run it. RoCE v2, RDMA over Ethernet, is winning share on cost and on letting existing Ethernet teams reuse what they know. The catch with RoCE is that it needs a carefully tuned lossless fabric (Priority Flow Control plus ECN marking) or you get congestion storms, and getting that right at scale is its own discipline.&lt;/p&gt;

&lt;p&gt;Meta is the proof that Ethernet can do it. They built two 24,576-GPU H100 clusters, one on RoCE and one on Quantum-2 InfiniBand, and trained Llama 3 405B on the RoCE one with no network bottleneck, after co-designing the topology, the PFC/ECN thresholds, and an all-reduce-aware load balancer. That's the honest framing: RoCE works at scale, but Meta spent real engineering to make it work. Two more pieces earn their keep on either fabric. GPUDirect RDMA lets the NIC DMA straight into GPU memory, skipping a bounce through host RAM, and without it every hop stages through system memory. SHARP does the reduction inside the switch ASIC, so gradients get summed in the network instead of shuttled between every node, which on the newest Blackwell fabrics is a large multiplier on effective all-reduce bandwidth.&lt;/p&gt;

&lt;h2&gt;
  
  
  NCCL across the wire
&lt;/h2&gt;

&lt;p&gt;The same NCCL from part 2 handles inter-node collectives, and the failure mode here is specific and common: NCCL silently falls back to TCP sockets when it can't find or use the RDMA path, and the job "works" while running an order of magnitude too slow. The env vars that prevent that are worth pinning in your launcher.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NCCL_IB_HCA&lt;/code&gt; names which RDMA NICs to use, and getting it wrong means NCCL picks one NIC and loses your rail parallelism. &lt;code&gt;NCCL_SOCKET_IFNAME&lt;/code&gt; has to point at the real data-plane interface, not &lt;code&gt;eth0&lt;/code&gt; or &lt;code&gt;lo&lt;/code&gt;, a classic container and Kubernetes trap. &lt;code&gt;NCCL_CROSS_NIC=0&lt;/code&gt; on a rail-optimized fabric keeps a ring on the same rail instead of hopping across them. &lt;code&gt;NCCL_IB_GID_INDEX&lt;/code&gt; is the RoCE gotcha: the wrong GID index gives you no traffic or a silent slow path. On a fresh cluster the first all-reduce is routinely two to ten times slower than optimal until the env vars, the GID index, the PFC/ECN config, and the topology file are all correct. The bring-up ritual is always &lt;code&gt;nccl-tests&lt;/code&gt;: run &lt;code&gt;all_reduce_perf&lt;/code&gt;, measure the achieved bus bandwidth, compare it against what 400 or 800 Gb should give you, and don't trust the cluster until the number is close.&lt;/p&gt;

&lt;h2&gt;
  
  
  who schedules the gang
&lt;/h2&gt;

&lt;p&gt;Here's the failure that surprises people coming from web infrastructure. Vanilla Kubernetes schedules pods independently, one at a time, with no concept of a job that needs all its pods at once. Give it a 4-node training job and it will happily place 3 pods and leave the 4th Pending forever, holding three nodes of GPUs idle. Run two such jobs and they can each grab most of what the other needs and starve each other indefinitely. Distributed training is all-or-nothing, and a scheduler that doesn't know that will deadlock your cluster. The first time it happens you assume you're out of GPUs. You're not. They're all sitting idle, reserved by pods that will never get their partners.&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%2F0rddlgo5lqckjzzgx24u.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%2F0rddlgo5lqckjzzgx24u.png" alt="A two-panel diagram titled 'why training needs gang scheduling'. Left panel, labeled 'vanilla Kubernetes': a four-pod job where three pods are Running on GPU nodes and one pod is stuck Pending, with the three running pods marked as holding GPUs idle while waiting, and a red 'deadlock' label. Right panel, labeled 'gang scheduling': the same four-pod job where all four pods are admitted together as one unit or none at all, marked 'all-or-nothing placement, no partial allocation'." width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · gang scheduling in one picture. The left side is how a cluster quietly wedges itself; the right side is the fix, and the reason every GPU scheduler below exists.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fix is gang scheduling: admit all N pods together or none, so partial allocations can't happen. The tools that provide it each have an honest drawback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slurm&lt;/strong&gt; is the HPC default and has gang scheduling and topology awareness built in, plus new block scheduling for aligning jobs to NVL72 racks. Its weakness is that containers and multitenancy are bolted on (Pyxis plus Enroot), and it's a poor fit for long-running inference services. That gap is why Slurm-on-Kubernetes projects like SchedMD's Slinky and CoreWeave's SUNK exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes vanilla&lt;/strong&gt; has no gang scheduling and no topology awareness by default. You add a batch scheduler on top; you don't run training on the default scheduler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volcano&lt;/strong&gt; is the de facto CNCF choice: gang scheduling via PodGroups, queues, fair-share. It runs as a second scheduler that bypasses the default, which complicates coexistence with normally-scheduled workloads, and gang scheduling itself costs maybe 10–15% utilization because resources sit idle waiting for the full gang.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kueue&lt;/strong&gt; is the Kubernetes-native answer for queueing and quota, and it cooperates with the default scheduler instead of replacing it. The tradeoff is that it does admission and quota, not fine-grained placement, so you still need scheduler plugins underneath for the actual gang and topology binding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run:ai&lt;/strong&gt; is the commercial option, now NVIDIA-owned, with fractional GPU and pooling. NVIDIA open-sourced the core as KAI Scheduler (Apache 2.0, now CNCF Sandbox), so a free path exists, but the full enterprise feature set stays paid and KAI is young as a standalone project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YuniKorn&lt;/strong&gt; brings strong hierarchical-queue multitenancy from the Spark world, at the cost of being another full scheduler replacement with a smaller AI-specific ecosystem than Volcano.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cross-cutting truth is that gang scheduling trades utilization for progress. Holding GPUs idle while you wait for the full gang is the price of not deadlocking, and it's a price worth paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  splitting the model
&lt;/h2&gt;

&lt;p&gt;When a model outgrows one GPU, there are three axes to split it on, and real training combines them. Data parallelism replicates the whole model on each GPU and all-reduces gradients; it's the simplest and only works when the model plus its optimizer states fit on one card. Tensor parallelism splits individual matrix multiplies across GPUs and is communication-heavy, so you keep it inside a node on NVLink (TP=8 is the common ceiling). Pipeline parallelism cuts the layers into stages across nodes and passes activations point-to-point, which is cheap enough to cross the network.&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%2Fe02c72a24kliq9gyg02k.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%2Fe02c72a24kliq9gyg02k.png" alt="A diagram titled '3D parallelism: how a frontier model maps to a cluster' showing a grid of GPU nodes. Tensor parallelism is shown splitting a single layer across the eight GPUs within one node, connected by NVLink and labeled 'TP=8, stays inside the node'. Pipeline parallelism is shown splitting the model's layers into stages across several nodes, labeled 'PP across nodes, cheap point-to-point'. Data parallelism is shown replicating the whole arrangement across groups of nodes with an all-reduce between replicas, labeled 'DP / FSDP on top'." width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 · the standard frontier recipe: tensor-parallel inside the NVLink domain, pipeline-parallel across nodes, data-parallel on top. Match each split to the bandwidth it can afford.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the common case of "my model doesn't fit but I want to stay in native PyTorch," FSDP2 shards the parameters, gradients, and optimizer states across GPUs and reconstructs each layer on the fly via all-gather, prefetching the next shard to overlap communication with compute. DeepSpeed's ZeRO does the same idea in stages: stage 1 shards optimizer states, stage 2 adds gradients, stage 3 adds parameters and is functionally equivalent to FSDP. For frontier scale and maximum MFU you reach for Megatron-Core and combine tensor, pipeline, and sequence parallelism into the 3D (now 4D, with expert parallelism for MoE) recipe: TP=8 inside the node, pipeline across nodes, data parallelism on top.&lt;/p&gt;

&lt;p&gt;Checkpointing is the reliability workhorse and used to be the tax that made frequent saves unaffordable. Async distributed checkpointing fixed that by writing state in a background thread that overlaps the next iterations; TorchTitan reports 5–15x lower checkpoint overhead than synchronous saves. That matters directly because of the failure rate: at one interruption every three hours, you want to checkpoint on the order of tens of minutes, and torchrun's elastic mode restarts the job from the last snapshot when a node dies. The newer torchft goes further, recovering a failed replica from a healthy peer without restarting the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  when the model won't fit one node
&lt;/h2&gt;

&lt;p&gt;Serving crosses the node boundary for the same reason training does: the model, or its KV cache, exceeds one node's total HBM. DeepSeek-V3 at 671B, Llama 405B, the big mixture-of-experts (MoE) models. You split them with tensor and pipeline parallelism across nodes, and for MoE you add wide expert parallelism, spreading experts across many nodes so each GPU holds few experts but sees a large batch per expert.&lt;/p&gt;

&lt;p&gt;The pattern that's become standard is disaggregated prefill and decode. Prefill (processing the prompt) is compute-bound; decode (generating tokens one at a time) is memory-bandwidth-bound. Running them in one pool means prefill work stalls decode latency. Splitting them into separate worker pools lets you scale each for its own bottleneck and transfer the KV cache between them over RDMA. It isn't a free win, though. Moving the KV cache between pools costs bandwidth, so disaggregation pays off when prefill interference is genuinely the bottleneck (long prompts, high concurrency) and can be net-negative when it isn't. DeepSeek runs it because at their scale it clearly is; a chatbot with short prompts might not need it at all.&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%2Fi9rekym1jlc5imdiols8.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%2Fi9rekym1jlc5imdiols8.png" alt="A diagram titled 'disaggregated prefill and decode' showing an inference request flowing left to right. The request first hits a pool of prefill workers, labeled 'compute-bound: process the whole prompt', drawn as a small cluster of GPUs. The resulting KV cache is transferred over RDMA to a separate, larger pool of decode workers, labeled 'memory-bandwidth-bound: generate tokens one at a time'. An annotation notes 'scale each pool independently; prefill no longer stalls decode', with a footnote citing DeepSeek-V3's production split of a 32-GPU prefill unit feeding a 320-GPU decode pool." width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 4 · prefill and decode want different hardware ratios, so modern stacks run them as separate pools and ship the KV cache between them. DeepSeek-V3 runs a 32-GPU prefill unit in front of a 320-GPU decode pool.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;DeepSeek's own deployment runs a 32-GPU prefill unit (4 nodes, expert-parallel across 32) feeding a much larger decode pool, and an LMSYS reproduction on 96 H100s hit 52,000 input and 22,000 output tokens per second per node. On Kubernetes the primitive that expresses "this is one model replica made of many pods" is LeaderWorkerSet: one leader, N workers, scheduled and scaled as a unit, which is exactly what gang scheduling and topology-aware placement need to bite on. NVIDIA Dynamo and llm-d sit on top: Dynamo for distributed serving with a KV-cache-aware router, and llm-d for KV-cache-aware routing on the Gateway API Inference Extension. That routing layer turns out to be one of the biggest free wins in the whole stack, which is why it gets its own part 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  everything fails at scale
&lt;/h2&gt;

&lt;p&gt;Reliability stops being a checkbox and becomes the main event. The Llama 3 numbers from the top of this post are the reference point, and ByteDance's MegaScale run on 12,288 GPUs tells the same story: 55.2% MFU and more than a hundred failure-recovery events over a few weeks. The failure you can't see coming is silent data corruption, where a GPU computes a wrong number without erroring. It doesn't crash or log anything. It just hands back the wrong answer, and your loss curve grows a mysterious kink a few hours later. Meta caught six such events in 54 days; Google reports SDC-related disruptions roughly every one to two weeks. A single corrupted gradient contaminates the global update across every worker, and it's now a first-class reliability topic with its own whitepapers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; At cluster scale, hardware failure is the normal state, not an exception you engineer away. A 16k-GPU run loses a GPU every few hours. You can't stop that, so the whole game is checkpointing often enough and keeping enough hot spares that a dead node costs you minutes instead of the whole run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The straggler is the SDC's cousin: a GPU or link that's degraded but not dead, quietly throttling a synchronous job because collectives move at the speed of the slowest member. Detecting it at scale is genuinely hard, so systems run periodic self-check diagnostics that pause the job, measure NVLink and compute per node, diagnose, and resume from checkpoint. Imbue open-sourced their bare-metal playbook for a 4,088-H100 cluster: check VBIOS and baseboard firmware, the Mellanox OFED stack, PCIe link speed and width, then run matmuls to measure actual NVLink bandwidth, cordon anything that fails, and swap in a hot spare automatically. That last part is the operating model at scale. You don't fix nodes in the critical path; you drain them and pull from a spare pool, because the cluster is always partially broken and the job has to keep moving.&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%2Fpz819r7930rluh6gtasw.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%2Fpz819r7930rluh6gtasw.png" alt="A donut chart infographic titled 'Llama 3 405B: what interrupted 54 days of training' showing the breakdown of 419 unexpected interruptions. Segments: GPU faults 30.1 percent, HBM3 memory 17.2 percent, network switch and cable 8.4 percent, GPU SRAM 4.5 percent, GPU processor 4.1 percent, and other or software causes making up the remainder. A center label reads '419 interruptions, ~1 every 3 hours, ~78% hardware'." width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 5 · the failure breakdown from Meta's Llama 3 405B run. GPUs and their memory are roughly half; the rest is the long tail an at-scale operator plans around, not against.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  paying for it
&lt;/h2&gt;

&lt;p&gt;The economics are why all of the above matters. At $2–6 per GPU-hour, a 16,000-GPU cluster idling during a recovery burns thousands of dollars a minute, and gang scheduling means one bad node can idle the whole job. That's the real argument for the reliability engineering: not uptime for its own sake, but goodput, the useful training throughput net of failures and restarts.&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%2Fewk7rics5svj3grlgx2c.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%2Fewk7rics5svj3grlgx2c.png" alt="A horizontal bar chart infographic titled 'GPU cost per hour, on-demand ballpark (2026)' comparing neocloud versus hyperscaler pricing for three GPUs. H100 SXM: neocloud about 2.5 to 3.5 dollars, AWS about 6.88 dollars. H200: neocloud about 3.8 to 4 dollars, Azure about 10 to 13 dollars. B200: neocloud about 5 to 6 dollars, AWS about 14.24 dollars. A note reads 'reserved commits cut 16 to 40 percent; spot is 30 to 70 percent cheaper but nearly unusable for gang-scheduled training'." width="800" height="472"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 6 · the on-demand spread between neoclouds and hyperscalers is wide, and it moves monthly. Big training runs almost never pay on-demand; they live on reserved capacity or capacity blocks.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The pricing spread is wide and moves every month. Neoclouds like Lambda, CoreWeave, and Nebius run an H100 around $2.5–3.5 per hour; AWS lists closer to $6.88. B200s are $5–6 on neoclouds and north of $14 on AWS. Reserved commitments of one to twelve months cut 16–40% off on-demand, and that's where most large training capacity actually lives. Spot is 30–70% cheaper and nearly unusable for gang-scheduled training, because losing any one node preempts the whole synchronous job and reacquiring N contiguous, topology-aligned nodes on spot is a fantasy. That gap is why capacity blocks exist: AWS EC2 Capacity Blocks and GCP's Dynamic Workload Scheduler let you reserve co-located GPUs for a fixed window, booked weeks ahead, because on-demand can't guarantee the topology and reserved is too long a commit for one run. AWS raised those block prices about 15% in early 2026, which tells you which way demand is going.&lt;/p&gt;

&lt;p&gt;That's the whole arc of building this stuff. A GPU deployment is not a GPU. It's a dozen layers under one pod, a small network inside one box, and a large one between boxes, and the interesting failures always live in the wiring rather than the silicon. Meta's cluster lost a GPU every three hours and still trained a frontier model, because the whole apparatus around the GPUs (the fabric, the scheduler, the checkpointing, the spare pool) was built to keep moving while parts of it were on fire. That's how you build it. The next part is how you watch it once real traffic arrives, which turns out to be a different problem than watching the GPUs.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>distributedtraining</category>
      <category>infiniband</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>One box, eight GPUs, and the wires between them</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:13:24 +0000</pubDate>
      <link>https://dev.to/sachincool/one-box-eight-gpus-and-the-wires-between-them-57kl</link>
      <guid>https://dev.to/sachincool/one-box-eight-gpus-and-the-wires-between-them-57kl</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-2-single-node-multi-gpu" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-04.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;We bought two boxes that were supposed to be identical. Eight H100s each, same rack, same image. On one of them a tensor-parallel serve of Llama-3-70B did about 3,000 tokens a second. On the other it did 900, with every GPU pinned at high utilization the whole time. Same model, same code, same card count. The difference was a PCIe switch and a BIOS setting nobody had checked, and it took most of a day to find because every dashboard said both boxes were healthy.&lt;/p&gt;

&lt;p&gt;That's the thing about a multi-GPU box. It looks like a bag of eight GPUs. It behaves like a small, opinionated network, and the wiring between the cards matters more than the cards. This is part 2 of the series. Part 1 was the twelve-layer stack under a single GPU pod. This one stays inside one chassis: how the GPUs talk, how to read the topology, why NCCL is slow, and how a 70B model actually lands on the hardware. Part 3 leaves the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  the box is a network, not a bag of GPUs
&lt;/h2&gt;

&lt;p&gt;The first question about any multi-GPU server is how the GPUs are wired, because that sets a hard ceiling on everything above it. There are two very different animals sold as "8-GPU servers."&lt;/p&gt;

&lt;p&gt;An HGX or DGX baseboard wires all eight GPUs through a bank of NVSwitches. Every GPU reaches every other GPU at the full NVLink rate, non-blocking. On H100 and H200 that's NVLink 4 at 900 GB/s per GPU. On B200 it's NVLink 5 at 1.8 TB/s. That flat, full-bandwidth mesh is the reason you can split a model eight ways and have the halves talk fast enough to keep up.&lt;/p&gt;

&lt;p&gt;A cheaper "8x PCIe" box has no NVSwitch. The GPUs hang off PCIe switches and the CPU root complexes, and GPU-to-GPU traffic crawls through PCIe, often routed up through the CPU. PCIe Gen5 x16 is about 128 GB/s, Gen4 about 64. NVLink 4 is roughly seven times faster than Gen5 and fourteen times faster than Gen4. That gap is the entire reason tensor parallelism cares about your topology. The two "identical" boxes in the opening weren't identical: one had the NVSwitch mesh, the other routed two of its GPU pairs across a PCIe switch with a BIOS feature quietly strangling them.&lt;/p&gt;

&lt;p&gt;One units trap that trips up everyone reading spec sheets: NVIDIA quotes NVLink bandwidth bidirectionally. A100's "600 GB/s" is 300 each way. Pick a convention, state it once, and don't compare someone's unidirectional number to your bidirectional one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; Two servers with the same eight GPUs can differ by 3× on the same job. Before you promise a throughput number, run nvidia-smi topo -m and confirm the GPUs talk over NVLink (the NV-prefixed rows), not over PCIe routed through the CPU (the SYS rows).&lt;/p&gt;
&lt;/blockquote&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%2F5w0fbydxm2xr3za8ebg7.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%2F5w0fbydxm2xr3za8ebg7.png" alt="A horizontal bar chart infographic titled 'GPU-to-GPU bandwidth, per GPU' comparing five interconnects. Bars from shortest to longest: PCIe Gen4 x16 at about 64 GB/s, PCIe Gen5 x16 at about 128 GB/s, NVLink 3 (A100) at 600 GB/s, NVLink 4 (H100/H200) at 900 GB/s, NVLink 5 (B200) at 1800 GB/s. A note reads 'NVLink 4 is roughly 7x PCIe Gen5, which is why tensor parallelism wants NVLink.'" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · bandwidth per GPU across the interconnects you'll actually meet. The jump from PCIe to NVLink is the one that decides whether a split model keeps up with itself.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  reading nvidia-smi topo -m
&lt;/h2&gt;

&lt;p&gt;You don't have to guess at any of this. &lt;code&gt;nvidia-smi topo -m&lt;/code&gt; prints the whole connection matrix, and learning to read it is the single most useful GPU-ops skill after &lt;code&gt;nvidia-smi&lt;/code&gt; itself. Every cell tells you how one GPU reaches another, and the symbols form a quality ladder from best to worst:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NV#&lt;/code&gt;: connected by # bonded NVLinks. Best. &lt;code&gt;NV18&lt;/code&gt; means eighteen links, full H100 mesh.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PIX&lt;/code&gt;: a single PCIe bridge, same switch. Fine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PXB&lt;/code&gt;: multiple PCIe bridges, but not across the CPU host bridge.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PHB&lt;/code&gt;: crosses a PCIe host bridge, through the CPU, same NUMA node.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NODE&lt;/code&gt;: crosses host bridges within a NUMA node.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SYS&lt;/code&gt;: crosses the inter-socket link between CPUs. Worst. This is CPU-to-CPU-to-GPU.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the GPUs in your tensor-parallel group show &lt;code&gt;NV18&lt;/code&gt; to each other, you're golden. If any pair shows &lt;code&gt;SYS&lt;/code&gt;, your collective operations are dragging across the socket interconnect and you've found your 900-tokens-a-second box. The same matrix has a column for GPU-to-NIC affinity, which matters enormously for the multi-node story in part 3: you want the NIC on the same PCIe complex as the GPU it feeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  NCCL picks a road
&lt;/h2&gt;

&lt;p&gt;Every framework that splits work across GPUs (PyTorch DDP and FSDP, DeepSpeed, Megatron, the tensor-parallel path in vLLM) does its cross-GPU communication through NCCL, NVIDIA's collectives library. NCCL is where "the GPUs need to agree on a number" turns into actual bytes on actual wires, and it auto-picks the road.&lt;/p&gt;

&lt;p&gt;Inside one box it prefers, in order: peer-to-peer over NVLink (best), peer-to-peer over PCIe, shared host memory (staged through RAM), then network sockets (worst, and a sign something's misconfigured). When an all-reduce (the step where every GPU merges its numbers with all the others and ends up with the combined result) is slow, the debugging loop is almost always the same handful of moves:&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;# 1. what did NCCL actually choose?&lt;/span&gt;
&lt;span class="nv"&gt;NCCL_DEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;INFO python train.py 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'via|transport|channel'&lt;/span&gt;
&lt;span class="c"&gt;#   "via P2P/direct pointer" = good. "via SHM" or "via NET/Socket" intra-node = bad.&lt;/span&gt;

&lt;span class="c"&gt;# 2. confirm the GPUs are NVLinked, not routed over SYS&lt;/span&gt;
nvidia-smi topo &lt;span class="nt"&gt;-m&lt;/span&gt;

&lt;span class="c"&gt;# 3. benchmark against the ceiling&lt;/span&gt;
all_reduce_perf &lt;span class="nt"&gt;-b&lt;/span&gt; 8 &lt;span class="nt"&gt;-e&lt;/span&gt; 4G &lt;span class="nt"&gt;-f&lt;/span&gt; 2 &lt;span class="nt"&gt;-g&lt;/span&gt; 8   &lt;span class="c"&gt;# from nccl-tests&lt;/span&gt;

&lt;span class="c"&gt;# 4. if a hang clears when you disable P2P, you have an ACS/IOMMU problem&lt;/span&gt;
&lt;span class="nv"&gt;NCCL_P2P_DISABLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 python train.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The env vars worth knowing are few. &lt;code&gt;NCCL_DEBUG=INFO&lt;/code&gt; tells you what topology and transport NCCL chose. &lt;code&gt;NCCL_P2P_LEVEL&lt;/code&gt; and &lt;code&gt;NCCL_P2P_DISABLE&lt;/code&gt; control peer-to-peer. &lt;code&gt;NCCL_SOCKET_IFNAME&lt;/code&gt; picks the bootstrap interface, and pointing it at the wrong one (&lt;code&gt;lo&lt;/code&gt;, &lt;code&gt;docker0&lt;/code&gt;) is a classic way to make init hang. &lt;code&gt;NCCL_TOPO_FILE&lt;/code&gt; lets you hand NCCL a topology description, which you sometimes need on cloud VMs because virtualized PCI hides the real affinity and NCCL guesses wrong. On a healthy 8-GPU NVSwitch box you usually touch none of these and it just works. The trouble starts on anything cheaper or virtualized.&lt;/p&gt;

&lt;h2&gt;
  
  
  the invisible tax: NUMA and ACS
&lt;/h2&gt;

&lt;p&gt;Two settings below the framework quietly decide whether your bandwidth numbers are real, and neither shows up in a GPU dashboard.&lt;/p&gt;

&lt;p&gt;The first is NUMA pinning. A multi-socket server splits its PCIe lanes and RAM between CPU sockets, and each GPU is physically wired to one socket. Run your process on socket 0 while it drives a GPU hung off socket 1, and every host-to-device copy crosses the inter-socket link. NCCL's low-latency protocol stages data through a pinned CPU buffer, so this hits communication, not just data loading. The fix is a one-liner: &lt;code&gt;numactl --cpunodebind=0 --membind=0 &amp;lt;cmd&amp;gt;&lt;/code&gt;, matched to the socket that owns the GPU. &lt;code&gt;nvidia-smi topo -m&lt;/code&gt; prints the affinity so you know which socket that is. It's genuinely deflating to spend an afternoon profiling and find the answer was a &lt;code&gt;numactl&lt;/code&gt; prefix, but that's most of this job.&lt;/p&gt;

&lt;p&gt;The second is PCIe ACS, Access Control Services, and it's the one that cost us most of that day. ACS forces PCIe peer-to-peer transactions to route up through the CPU root complex so the platform can police them. That defeats direct GPU-to-GPU DMA across a PCIe switch: latency climbs, throughput collapses, and NCCL can hang outright. ACS has to be off for peer-to-peer to work across a switch. You check it with &lt;code&gt;lspci -vvv | grep -i acsctl&lt;/code&gt; and disable it in BIOS or with a &lt;code&gt;setpci&lt;/code&gt; loop over the bridges. Its cousin, the IOMMU, does the same routing-through-the-root-complex thing, which is exactly why passthrough GPUs on cloud VMs often show degraded peer-to-peer: the isolation that makes virtualization safe is the isolation that makes GPUDirect slow. If &lt;code&gt;nvidia-smi&lt;/code&gt; insists your GPUs "are not P2P capable," the shortlist is ACS enabled, IOMMU on, consumer cards, or GPUs on different root complexes. &lt;code&gt;p2pBandwidthLatencyTest&lt;/code&gt; from the CUDA samples confirms which.&lt;/p&gt;

&lt;h2&gt;
  
  
  fitting a 70B model on the box
&lt;/h2&gt;

&lt;p&gt;The practical question most teams actually have is: how many GPUs does my model need, and how do I split it. Tensor parallelism splits every layer's weight matrices across GPUs, which means every token, at every layer, triggers a collective to recombine the partial results. That makes TP communication-bound and latency-sensitive, which is the real reason it wants NVLink. On a PCIe-only box, tensor parallelism is frequently slower than just pipelining the layers, and vLLM's own guidance says as much: no NVLink, prefer &lt;code&gt;--pipeline-parallel-size&lt;/code&gt; over &lt;code&gt;--tensor-parallel-size&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The memory math for Llama-3-70B is a worked example worth carrying around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Weights: 70B params times 2 bytes for BF16 is about 140 GB. That already doesn't fit on one 80GB card.&lt;/li&gt;
&lt;li&gt;KV cache (the model's running memory of the tokens it has already processed): roughly 2.5 GB per sequence at 8K context, climbing to tens of GB at 128K. This is what eats whatever VRAM the weights left behind and sets your max batch size.&lt;/li&gt;
&lt;li&gt;Real footprint with activations and framework overhead lands north of 200 GB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So Llama-3-70B at BF16 wants two H100 80GB cards (&lt;code&gt;--tensor-parallel-size 2&lt;/code&gt;), or four A100 40GB, or a fistful of 24GB cards. Tensor parallelism splits the KV cache too, so two GPUs buys you roughly double the batch headroom, not just room for the weights. Quantize to FP8 on Hopper or Blackwell, or INT4 with AWQ, and the weights roughly halve or quarter, which can drop a 70B onto a single card at some quality cost. You can quantize the KV cache too (&lt;code&gt;--kv-cache-dtype=fp8_e5m2&lt;/code&gt;), which shrinks the biggest consumer of leftover VRAM once batch sizes climb. And a rule worth internalizing: tensor-parallel a model only when it genuinely doesn't fit one GPU. For a model that does fit, run several replicas at &lt;code&gt;--tensor-parallel-size 1&lt;/code&gt; instead, because TP's per-layer communication is pure overhead you're paying for nothing when one card already holds the whole model. The constraint people forget: your TP size has to divide the number of attention heads evenly, so you don't get to pick arbitrary GPU counts, and on Kubernetes it also has to equal the pod's &lt;code&gt;nvidia.com/gpu&lt;/code&gt; limit or the server won't start. (You learn this the moment &lt;code&gt;--tensor-parallel-size 6&lt;/code&gt; refuses to start and the error message does nothing to help.)&lt;/p&gt;

&lt;h2&gt;
  
  
  the caveats that page you
&lt;/h2&gt;

&lt;p&gt;Dense GPU boxes fail in ways that a single card never does, and most of them present as "one GPU is a little slow" rather than a clean error.&lt;/p&gt;

&lt;p&gt;Thermal throttling is first. Eight cards share airflow, and a fully loaded chassis runs hot. Data-center GPUs start clocking down before about 85°C, and &lt;code&gt;nvidia-smi --query-gpu=clocks_throttle_reasons.active&lt;/code&gt; will tell you whether it's a software thermal slowdown, a hardware one, or a power cap. Power capping is the sibling: &lt;code&gt;nvidia-smi -pl&lt;/code&gt; sets a limit below the card's TDP (700W on an H100 SXM), and a rack without enough power budget will cap every card and slow the whole box uniformly.&lt;/p&gt;

&lt;p&gt;Then the straggler problem, which is the nastiest because it hides. Collective operations synchronize every step, so the slowest GPU sets the pace for all of them. One card that's throttling, power-capped, or quietly degraded drags the entire tensor-parallel group, and the symptom is maddening: every GPU shows high utilization, throughput is low, and nothing errors. You find it by looking at per-GPU clocks and temperature and hunting the outlier.&lt;/p&gt;

&lt;p&gt;The rest of the list is worth a scan before you sign off on a box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PCIe lane starvation. A card silently negotiating Gen3 x4 instead of Gen5 x16 is pure mystery slowness. &lt;code&gt;lspci -vv&lt;/code&gt; shows the negotiated &lt;code&gt;LnkSta&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;NVLink errors. &lt;code&gt;nvidia-smi nvlink -e&lt;/code&gt; shows CRC and replay counters. Rising counts mean a flaky link or cable and degraded bandwidth.&lt;/li&gt;
&lt;li&gt;Oversubscribed PCIe switch. Cheap boxes put several GPUs behind one switch uplink, and they contend for it.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/dev/shm&lt;/code&gt; trap, if you're on Kubernetes. NCCL stages some intra-node transfers through shared memory, and a container's default 64 MiB &lt;code&gt;/dev/shm&lt;/code&gt; hangs multi-GPU serving with no useful error. Mount an &lt;code&gt;emptyDir&lt;/code&gt; with &lt;code&gt;medium: Memory&lt;/code&gt; at &lt;code&gt;/dev/shm&lt;/code&gt;. It's in every production vLLM manifest, and it's why tensor-parallel works on a bare VM but hangs in a pod.&lt;/li&gt;
&lt;li&gt;Xid 79, the "fell off the bus" from part 1, shows up here too when a card overheats or loses power delivery under a full load it never saw in acceptance testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  picking an inference engine
&lt;/h2&gt;

&lt;p&gt;If the box is for serving rather than training, the engine choice matters, and by 2026 the field has settled. All the live engines converge on the same two tricks: continuous batching (decide the batch membership every decode step so the GPU never idles on the slowest request) and paged KV cache (manage the cache like OS virtual memory so you don't waste most of your VRAM on fragmentation). The differences are in the scheduler and the compile strategy.&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%2Ftzv69e4uy5rl0ceofgpw.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%2Ftzv69e4uy5rl0ceofgpw.png" alt="A two-by-two quadrant diagram titled 'choosing a single-node inference engine'. Axes are ease of operations (horizontal) versus raw performance on NVIDIA hardware (vertical). vLLM sits high on ease and solid on performance, labeled 'the default: broad model + hardware coverage, sane defaults'. TensorRT-LLM sits highest on performance but lower on ease, labeled 'max throughput on NVIDIA, but you compile an engine per model'. SGLang sits mid-high on both, labeled 'RadixAttention: wins on shared-prefix, RAG, agents, MoE'. TGI sits low, greyed out, labeled 'maintenance mode: HuggingFace now points you to vLLM or SGLang'." width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 · where the four engines land. Start at vLLM; move only when a profiled bottleneck points somewhere specific.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Start with vLLM. It has the broadest model and hardware coverage, it invented PagedAttention, and it stands up with sane defaults faster than anything else. Reach for TensorRT-LLM when a profiled bottleneck justifies the cost, because it delivers the highest raw throughput and lowest latency on NVIDIA hardware but makes you compile a per-model, per-GPU engine and run a heavier ops burden for it. Reach for SGLang when your workload shares prefixes: RAG, multi-turn chat, agents, or high-concurrency MoE, where its RadixAttention prefix cache pulls meaningfully ahead. And don't start new work on TGI; HuggingFace put it in maintenance mode and points people at vLLM or SGLang themselves. One layer up from the engine, NVIDIA's Dynamo wraps any of these for distributed, disaggregated serving with a KV-cache-aware router, and NIM packages them as prebuilt microservices. On a single box the raw engine is what you're tuning, but those are the names you'll meet the moment you scale out.&lt;/p&gt;

&lt;p&gt;The two boxes from the opening ran the same vLLM. Same engine, same flags, one served three times the traffic. The engine was never the variable. The wires were. Which is the whole lesson of a single node, and also the reason part 3 is about the wires between nodes, where the same story plays out at a hundred times the scale and a hundred times the cost.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>nvlink</category>
      <category>nccl</category>
      <category>vllm</category>
    </item>
    <item>
      <title>The dozen layers under a GPU pod</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:13:19 +0000</pubDate>
      <link>https://dev.to/sachincool/the-dozen-layers-under-a-gpu-pod-c5f</link>
      <guid>https://dev.to/sachincool/the-dozen-layers-under-a-gpu-pod-c5f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/gpu-deployments-part-1-anatomy" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-07-02.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The pod was stuck in &lt;code&gt;Pending&lt;/code&gt; and the node had eight H100s sitting idle. &lt;code&gt;kubectl describe node&lt;/code&gt; said &lt;code&gt;nvidia.com/gpu: 0&lt;/code&gt;. &lt;code&gt;nvidia-smi&lt;/code&gt; on the host printed all eight cards, healthy, 40°C, nothing running. So the hardware was fine, the driver was fine, and Kubernetes was convinced there were zero GPUs in a box that cost more than my car.&lt;/p&gt;

&lt;p&gt;That gap is the whole job. A GPU pod doesn't run on a GPU. It runs on about a dozen layers stacked between the silicon and your container, and any one of them can be quietly broken while every layer above and below it looks green. If you've shipped normal apps on Kubernetes, a GPU pod looks identical right up until it doesn't: underneath sits a stack of hardware and driver pieces a web pod never touches, and that's where the surprises live. This series is about running those layers in production without getting paged. Part 1 is the anatomy: what the layers are, what breaks at each one, and which numbers actually tell you the truth. Part 2 is a single box with eight GPUs and the wires between them. Part 3 is scaling past one box, where the network becomes the machine. Part 4 is watching the whole thing under real traffic, part 5 is scaling it to zero when nobody's using it without the bill or the cold start eating you, part 6 is the routing layer in front of it all, where the right load balancer buys a 2× speedup for free, part 7 keeps it breathing through loads and deploys, and part 8 is sharing it with other teams without the tenants, or a security boundary that isn't where you think, burning you.&lt;/p&gt;

&lt;h2&gt;
  
  
  the dozen layers between silicon and your pod
&lt;/h2&gt;

&lt;p&gt;Start at the bottom and climb. Each layer trusts the one under it and lies to the one above it when things go wrong.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;silicon&lt;/strong&gt; is the GPU itself plus the NVSwitch fabric that wires the GPUs on a board together, plus the NIC (a ConnectX-7 or BlueField-3) that carries traffic off the box. This is where hardware faults live: ECC errors (bit-flips in memory the card catches and corrects), thermal throttle, a card that stops answering on the PCIe bus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firmware&lt;/strong&gt; sits on the card. Modern GPUs have a GSP, a GPU System Processor, a little RISC-V core that runs firmware and offloads work the host driver used to do. When you hear about a GPU "hanging" for no visible reason, the GSP firmware timing out is a common culprit.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;kernel driver&lt;/strong&gt; is &lt;code&gt;nvidia.ko&lt;/code&gt; and friends (&lt;code&gt;nvidia-uvm&lt;/code&gt;, &lt;code&gt;nvidia-peermem&lt;/code&gt;). It's a kernel module, which means it's compiled against your exact kernel headers. Upgrade the kernel without rebuilding the module and the driver won't load. This is the layer that breaks on a Tuesday because someone patched the base image.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;userspace driver&lt;/strong&gt; is &lt;code&gt;libcuda.so&lt;/code&gt;, the CUDA driver API. It ships with the driver, not with CUDA, and this trips people up constantly. &lt;code&gt;nvidia-smi&lt;/code&gt; talks to the driver through NVML, which is why &lt;code&gt;nvidia-smi&lt;/code&gt; can work while your actual CUDA program fails: they're using different entry points into the same stack.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;CUDA runtime&lt;/strong&gt; is &lt;code&gt;libcudart&lt;/code&gt; plus the math and collective libraries: cuBLAS, cuDNN, NCCL. Here's the thing nobody tells you on day one. PyTorch and TensorFlow wheels bundle their own copy of all of this. When you &lt;code&gt;pip install torch==2.x+cu128&lt;/code&gt;, you are installing CUDA 12.8, cuDNN, and NCCL inside the wheel. The host node doesn't need a CUDA install at all. It needs a driver new enough to satisfy that bundled runtime, and nothing more. Once that clicks, half the version confusion evaporates.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;NVIDIA Container Toolkit&lt;/strong&gt; (&lt;code&gt;nvidia-ctk&lt;/code&gt;, &lt;code&gt;libnvidia-container&lt;/code&gt;, currently 1.19.1) is the bridge between the host driver and the container. At container start it injects &lt;code&gt;/dev/nvidia*&lt;/code&gt; and the driver libraries into the container's filesystem. Miss this and you get the classic symptom: &lt;code&gt;nvidia-smi&lt;/code&gt; works on the host, fails inside the pod.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;container runtime&lt;/strong&gt; is containerd or CRI-O running runc underneath. It has to be told to use the &lt;code&gt;nvidia&lt;/code&gt; runtime. If &lt;code&gt;default_runtime_name&lt;/code&gt; in &lt;code&gt;/etc/containerd/config.toml&lt;/code&gt; isn't set, pods land on the node with no GPU access and no obvious error. (Recent GPU Operator versions wire this through the NRI/CDI plugin instead of a default runtime, but the failure mode is identical: get it wrong and the pod sees no GPU.)&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;device plugin&lt;/strong&gt; (0.19.x) is the piece that talks to the kubelet. It counts the GPUs and advertises them as &lt;code&gt;nvidia.com/gpu: 8&lt;/code&gt;, or &lt;code&gt;nvidia.com/mig-1g.10gb: 56&lt;/code&gt; if you're slicing. When this crashes or can't reach the driver, you get &lt;code&gt;nvidia.com/gpu: 0&lt;/code&gt; and pods stuck Pending. That was my incident at the top. The device plugin had crash-looped after a driver-container restart and never re-registered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node Feature Discovery&lt;/strong&gt; and &lt;strong&gt;GPU Feature Discovery&lt;/strong&gt; label the node with what it has: GPU model, memory, compute capability, MIG profile, driver version. The scheduler reads those labels to place pods. Wrong labels, wrong placement.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;GPU Operator&lt;/strong&gt; (v26.3.x) is the thing that installs and manages every layer above the kernel as a set of DaemonSets. It runs the driver as a container, wires the toolkit, deploys the device plugin, DCGM, the MIG manager. It's a huge convenience and one more control loop to debug when it gets stuck reconciling.&lt;/p&gt;

&lt;p&gt;On top, the &lt;strong&gt;scheduler&lt;/strong&gt; (kube-scheduler, or Kueue, Volcano, Run:ai, or Slurm) decides which pod lands on which GPU. And finally the &lt;strong&gt;workload&lt;/strong&gt;: a training job that needs all N GPUs at once or nothing, or an inference server that would happily take a seventh of one card.&lt;/p&gt;

&lt;p&gt;Twelve layers. The reason GPU infra feels harder than normal infra isn't any single layer. It's that the failure at layer 3 shows up as a symptom at layer 9, and the tooling at layer 9 has no idea layer 3 exists.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; The layer that breaks and the symptom you notice are rarely the same layer. The pod is Pending up at the scheduler, but the cause is a driver that didn't load down near the metal. When a GPU pod misbehaves, debug from the bottom of the stack up, not the top down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One part of this stack is quietly being rebuilt under you. The device plugin advertising &lt;code&gt;nvidia.com/gpu: 8&lt;/code&gt; is a flat count: a pod asks for a number and gets whatever GPUs the node has. Kubernetes 1.34 (September 2025) made Dynamic Resource Allocation (DRA) generally available, and it's the eventual replacement for that model. DRA is a &lt;code&gt;ResourceClaim&lt;/code&gt; API, the way a &lt;code&gt;PersistentVolumeClaim&lt;/code&gt; is for storage, so a pod can ask for "two NVLink-connected GPUs with at least 40GB each" instead of a bare count, and it's how rack-scale multi-node NVLink (GB200 NVL72) gets scheduled at all. NVIDIA ships a DRA driver through the GPU Operator. The device plugin is still the common path in mid-2026, but a stack diagram drawn today should treat it as the model DRA is replacing, not the permanent one.&lt;/p&gt;

&lt;h2&gt;
  
  
  the version matrix that pages you
&lt;/h2&gt;

&lt;p&gt;The single most common self-inflicted outage is a version mismatch, and the matrix has four axes: driver, CUDA toolkit, cuDNN, and framework. The good news is that three compatibility mechanisms mean you rarely have to line up all four exactly. The bad news is that nobody explains which mechanism they're relying on, so it feels like luck.&lt;/p&gt;

&lt;p&gt;Backward compatibility is the easy one. A newer driver runs older CUDA binaries, always. An R580 driver runs a CUDA 12 app and a CUDA 13 app without complaint. So keeping the driver ahead of everything is safe.&lt;/p&gt;

&lt;p&gt;CUDA minor-version compatibility is the one you lean on daily. Any CUDA 12.x toolkit runs on any driver that supports 12.0. A driver from the 525 era will run a CUDA 12.8 binary, because they share the CUDA 12 major. This is why the framework-bundles-its-own-CUDA pattern works: the wheel carries CUDA 12.8, your node has some 12-capable driver, and they meet in the middle.&lt;/p&gt;

&lt;p&gt;Forward compatibility is the escape hatch. The &lt;code&gt;cuda-compat&lt;/code&gt; package ships updated &lt;code&gt;libcuda&lt;/code&gt; stubs that let a &lt;em&gt;newer&lt;/em&gt; CUDA major run on an &lt;em&gt;older&lt;/em&gt; driver branch. It's how you run a CUDA 13 app on a node still pinned to an R535 driver you can't upgrade yet. It only works on data-center GPUs, and it's a deliberate override, not something to build on.&lt;/p&gt;

&lt;p&gt;Here's the current data-center driver picture as of mid-2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;EOL&lt;/th&gt;
&lt;th&gt;CUDA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R535&lt;/td&gt;
&lt;td&gt;LTS&lt;/td&gt;
&lt;td&gt;June 2026&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R580&lt;/td&gt;
&lt;td&gt;LTS&lt;/td&gt;
&lt;td&gt;June 2028&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R595&lt;/td&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;March 2027&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R610&lt;/td&gt;
&lt;td&gt;New Feature&lt;/td&gt;
&lt;td&gt;Aug 2026&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you take one thing from this section: manage the driver, let the framework carry the rest. Pin your training and serving images to NGC base images (&lt;code&gt;nvcr.io/nvidia/pytorch:25.xx&lt;/code&gt;) where NVIDIA has already matched CUDA, cuDNN, and NCCL for you, and you delete an entire class of 2am pages.&lt;/p&gt;

&lt;p&gt;One current gotcha worth flagging if you're on Blackwell. Drivers from 580.65.06 turn on Coherent Driver Memory Management by default for GB200 and GH200 on Kubernetes, and CDMM is incompatible with both MIG and GPUDirect Storage right now. If you buy GB200s planning to slice them with MIG, check that first, because it's not going to be on the datasheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  slicing one GPU three ways
&lt;/h2&gt;

&lt;p&gt;A single H100 has 80GB of HBM (its fast on-board memory) and enough compute to serve dozens of small models. Handing a whole card to a workload that uses 8% of it is how you set money on fire. There are three ways to share a GPU, and they are not interchangeable. The difference is isolation.&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%2Fr5ykjnqr48z7s0djjhz4.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%2Fr5ykjnqr48z7s0djjhz4.png" alt="A diagram showing one physical GPU sliced three ways side by side. Left: MIG, the GPU split into seven hardware partitions each with its own walled-off memory and compute, labeled 'hardware isolation'. Middle: MPS, multiple processes sharing one GPU context running concurrently, labeled 'concurrent, soft limits, weak isolation'. Right: time-slicing, several pods taking turns on the whole GPU in round-robin, labeled 'no isolation, round-robin'." width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 · the same GPU shared three ways. Isolation drops as you move right; utilization convenience goes up. Pick by how much you trust the tenants.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MIG&lt;/strong&gt; (Multi-Instance GPU) is hardware partitioning, available on the data-center cards (A100, A30, H100, H200, B200) but not the smaller inference GPUs like the L4 or A10G, which fall back to MPS. It cuts one GPU into up to seven instances, each with its own SMs (the GPU's compute cores), its own dedicated slice of HBM, its own memory controller and L2. A fault in one instance doesn't touch the others. That's real hardware isolation, the kind you want when tenants don't trust each other. The profiles are named &lt;code&gt;[compute]g.[memory]gb&lt;/code&gt;. On an H100 80GB you get seven &lt;code&gt;1g.10gb&lt;/code&gt; slices, or two &lt;code&gt;3g.40gb&lt;/code&gt;, or one &lt;code&gt;7g.80gb&lt;/code&gt; that's just the whole card back. An H200's 141GB gives you seven &lt;code&gt;1g.18gb&lt;/code&gt; slices; a B200's 180GB gives seven &lt;code&gt;1g.23gb&lt;/code&gt;. The quirk that catches everyone: compute fractions go 1/7, 2/7, 3/7, 4/7, 7/7. There is no 5g or 6g profile. Memory is quantized in eighths. So a &lt;code&gt;1g&lt;/code&gt; slice gets one-seventh of the compute but one-eighth of the memory, and the arithmetic never quite lines up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MPS&lt;/strong&gt; (Multi-Process Service) is a daemon that multiplexes several processes into one GPU context so their kernels run genuinely concurrently, not round-robin. You can cap each client's memory (&lt;code&gt;CUDA_MPS_PINNED_DEVICE_MEM_LIMIT&lt;/code&gt;) and compute share (&lt;code&gt;CUDA_MPS_ACTIVE_THREAD_PERCENTAGE&lt;/code&gt;). What you don't get is hardware memory protection or clean fault isolation. One client that OOMs can take its neighbors down with it. MPS is for high-throughput inference where you own all the tenants and want better SM utilization than time-slicing gives you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-slicing&lt;/strong&gt; is the crude one. The device plugin just lies about the GPU count, advertising one physical card as ten replicas. Ten pods land, and they take turns via context switching. There is no memory partitioning and no fault isolation at all. If one pod grabs 70GB of an 80GB card, the other nine OOM. It's fine for notebooks, CI, and dev clusters where the work is bursty and nobody's SLA depends on it. It has no business in front of production traffic.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Memory isolation&lt;/th&gt;
&lt;th&gt;Fault isolation&lt;/th&gt;
&lt;th&gt;Concurrency&lt;/th&gt;
&lt;th&gt;Use for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MIG&lt;/td&gt;
&lt;td&gt;hardware&lt;/td&gt;
&lt;td&gt;hardware&lt;/td&gt;
&lt;td&gt;spatial&lt;/td&gt;
&lt;td&gt;multi-tenant, untrusted, prod serving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MPS&lt;/td&gt;
&lt;td&gt;soft caps&lt;/td&gt;
&lt;td&gt;weak&lt;/td&gt;
&lt;td&gt;true concurrent&lt;/td&gt;
&lt;td&gt;trusted high-throughput inference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-slicing&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;round-robin&lt;/td&gt;
&lt;td&gt;dev, notebooks, CI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can stack them: partition a card into seven MIG slices, then time-slice each slice for burstier workloads. Most teams don't need that. Most teams need to notice they're running one 8% workload per $30k card and switch to &lt;code&gt;7× 1g.10gb&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  the metrics that lie to you
&lt;/h2&gt;

&lt;p&gt;Here's the number that ruins more capacity planning than any other: &lt;code&gt;GPU-Util&lt;/code&gt;. When &lt;code&gt;nvidia-smi&lt;/code&gt; shows 95% and everyone relaxes, they've misread it. &lt;code&gt;DCGM_FI_DEV_GPU_UTIL&lt;/code&gt; answers exactly one question: was a kernel running during the sample window. It says nothing about how much of the silicon that kernel used. You can see 95% GPU utilization and 25% of the actual compute capacity in use at the same moment, and both numbers are honest about different things. Somebody will still screenshot the 95% into a capacity deck, and now you're being asked to buy more of a card you're already wasting.&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%2Fo6yfjs3v2m25tv5mkxb7.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%2Fo6yfjs3v2m25tv5mkxb7.png" alt="A horizontal bar comparison infographic titled 'the same GPU, two honest numbers'. Top bar shows GPU-Util at 95 percent filled nearly full in one color. Bottom bar shows Model FLOPs Utilization (MFU) at 40 percent, filled less than half in a contrasting color. A caption band notes that a well-run large training job lands at 35 to 50 percent MFU, and the gap between the two bars is where your money goes." width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 · GPU-Util says a kernel ran. MFU says how much of the chip did useful work. The gap between them is memory stalls, collective communication, and non-matmul overhead you paid for anyway.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The number that matters for training is &lt;strong&gt;MFU&lt;/strong&gt;, Model FLOPs Utilization: the FLOPs your model actually did divided by the theoretical peak. It's the metric the Llama and PaLM papers report, and 35–50% is considered excellent for large-scale training. The gap between "a kernel ran" and "the chip did useful math" is memory-bandwidth stalls, NCCL all-reduce waiting on the network, attention softmax and layernorm that aren't matmuls, optimizer steps, and activation recompute. All of it counts against your wall clock. None of it counts as useful FLOPs.&lt;/p&gt;

&lt;p&gt;The money follows directly. On an 8-GPU H100 node at roughly $3 per GPU-hour, the difference between 25% and 45% MFU is about 1.8× the effective cost per token. Sticker price per GPU-hour is the number vendors compete on. Utilization is the number that actually sets your bill.&lt;/p&gt;

&lt;p&gt;For real telemetry, DCGM (Data Center GPU Manager) is the layer, and &lt;code&gt;dcgm-exporter&lt;/code&gt; scrapes it into Prometheus. The fields worth putting on a dashboard from day one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code&gt;: Tensor Core utilization, the one that tracks real training throughput.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_PROF_DRAM_ACTIVE&lt;/code&gt;: HBM bandwidth in use. High here with low tensor activity means you're memory-bound.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_FB_USED&lt;/code&gt;: HBM used. Your OOM early-warning.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_XID_ERRORS&lt;/code&gt;: the last Xid code. The single most important reliability signal on the box.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_ECC_DBE_VOL&lt;/code&gt; and the row-remap fields: memory health, trending toward RMA.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code&gt;: a bitmask telling you whether the card is throttling on power, thermals, or a reliability limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;dcgmi diag -r 3&lt;/code&gt; runs about twelve minutes of escalating health checks (memory bandwidth, PCIe, NVLink, thermals under load) and is the thing to run before you trust a node you just recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  the Xid codes worth memorizing
&lt;/h2&gt;

&lt;p&gt;Xid errors are the driver's way of telling the kernel log that the hardware had to correct or retry something it shouldn't have. They land in &lt;code&gt;dmesg&lt;/code&gt; as &lt;code&gt;NVRM: Xid (PCI:0000:xx:00): &amp;lt;code&amp;gt;&lt;/code&gt;. A nonzero Xid is not always fatal, but it's never nothing. A handful are worth knowing on sight because they change what you do next.&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%2Fins9ojervrwbuh1o4yf7.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%2Fins9ojervrwbuh1o4yf7.png" alt="A decision-flow diagram titled 'a GPU is misbehaving: what the Xid tells you to do'. It branches from a central node reading dmesg for the Xid code. Xid 13/31/43 routes to 'app bug, restart the workload'. Xid 48/94 routes to 'contained ECC, drain and reset the GPU'. Xid 95 routes to 'uncontained ECC, reset GPU before any restart'. Xid 63/64 routes to 'row-remapping, watch the trend, RMA if it fails'. Xid 79 routes to 'fell off the bus, cordon and reboot the node, RMA if it recurs'. Xid 119/120 routes to 'GSP firmware hung, reset GPU'." width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 4 · the Xid triage most on-call runbooks converge on. The split that matters is app-fault versus hardware-fault, because one restarts a pod and the other cordons a node.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xid 13, 31, 43&lt;/strong&gt; are usually your fault, not the hardware's: illegal memory access, a bad kernel, a page fault from application code. Restart the workload, look at the model, not the card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xid 48&lt;/strong&gt; is a double-bit ECC error, uncorrectable. &lt;strong&gt;Xid 94&lt;/strong&gt; is a contained ECC error, where the damage stayed inside the offending app and the other apps on the card survived. &lt;strong&gt;Xid 95&lt;/strong&gt; is the uncontained version, where the blast radius crossed apps, and the GPU has to be reset before anything restarts on it. The 94/95 split is the one to internalize: contained means drain politely, uncontained means the card is compromised until reset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xid 63 and 64&lt;/strong&gt; are row-remapping events. Modern HBM can retire bad memory rows the way a disk retires bad sectors. A 63 is the card recording that it did this; persistent 64s (remap failures) mean it's running out of spare rows and it's an RMA candidate. Watch the trend, don't panic on the first one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xid 79&lt;/strong&gt; is the one that ruins a night: "GPU has fallen off the bus." The card stopped answering on PCIe entirely. Thermal, power delivery, seating, a dying board. The node needs a reset, and if the same physical slot throws it again, that's a card headed back for RMA. Field reports put it around 3% of H100 deployments in the first year, which sounds small until you multiply it by a thousand-GPU fleet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xid 119 and 120&lt;/strong&gt; are the GSP firmware timing out. Reset the GPU. On a few driver versions it's common enough that ops teams disable the GSP firmware as a workaround, which tells you how much fun that particular bug is.&lt;/p&gt;

&lt;p&gt;The remediation ladder most teams settle on is boring and effective: app restart or driver reload or node reboot clears roughly 60% of incidents within fifteen minutes; anything that survives that gets &lt;code&gt;dcgmi diag -r 3&lt;/code&gt;; anything that fails the diag gets cordoned and sent back.&lt;/p&gt;

&lt;h2&gt;
  
  
  what to actually care about
&lt;/h2&gt;

&lt;p&gt;If you're standing up GPU infrastructure and wondering where to spend your attention, the honest ranking isn't the one the marketing implies. It's roughly this. Get the driver-and-toolkit layer boringly stable, because that's where the self-inflicted outages live. Instrument MFU and the Xid stream before you instrument anything pretty, because those two tell you whether you're wasting money and whether the hardware is dying. Decide your sharing model (MIG for multi-tenant, whole cards for training) before you have tenants, because retrofitting isolation is miserable. And treat the twelve-layer stack as the thing it is: a place where a green dashboard at layer 9 can sit directly on top of a card that fell off the bus at layer 1.&lt;/p&gt;

&lt;p&gt;This is also usually the point where teams bring in someone who has already burned a few weeks on Xid codes and MIG partitioning, rather than doing it live on their own GPU bill. GPU and ML infrastructure builds are part of the &lt;a href="https://k8s.org.in" rel="noopener noreferrer"&gt;independent infrastructure consulting&lt;/a&gt; work I take on.&lt;/p&gt;

&lt;p&gt;The pod that was stuck Pending at the top of this post came back the moment the device plugin re-registered. Fifteen seconds of fix, forty minutes of staring at a healthy &lt;code&gt;nvidia-smi&lt;/code&gt; wondering how the machine could see eight GPUs that Kubernetes swore didn't exist. That distance, between what the hardware knows and what the scheduler believes, is where most of your on-call rotation lives too. The next part goes inside a single box with eight of these cards and the wires that decide whether they cooperate or just sit next to each other.&lt;/p&gt;

</description>
      <category>gpu</category>
      <category>kubernetes</category>
      <category>mlops</category>
      <category>nvidia</category>
    </item>
    <item>
      <title>The git commands I actually run every day</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Wed, 20 May 2026 19:10:05 +0000</pubDate>
      <link>https://dev.to/sachincool/the-git-commands-i-actually-run-every-day-423p</link>
      <guid>https://dev.to/sachincool/the-git-commands-i-actually-run-every-day-423p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/daily-git-commands" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-05-20.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I've been using git for a decade and most of what I type still fits on a single hand. The 200-page Pro Git book is wonderful and almost none of it survives contact with a real Tuesday. What survives is a small, boring set of commands that get rerun constantly.&lt;/p&gt;

&lt;p&gt;This post is that list, ordered by how often my fingers actually type them. Aliases are from the oh-my-zsh &lt;code&gt;git&lt;/code&gt; plugin (enabled in most zsh configs that exist); the full command sits next to the alias so it's portable.&lt;/p&gt;

&lt;h2&gt;
  
  
  the daily eight
&lt;/h2&gt;

&lt;p&gt;These are the ones I'd type in my sleep. If you're not using all eight already, picking them up pays back inside a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  gst
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git status&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run this between every other command. It's the cheapest sanity check git has. Branch, ahead/behind, staged, unstaged, untracked. Two seconds. If you only learn one alias, learn this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  glola
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git log --oneline --graph --decorate --all&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;glola | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one true log. Graph of every branch (local + remote), one line per commit, colored refs. Pipe through &lt;code&gt;head&lt;/code&gt; because most of the time you only care about the last 20-30 commits.&lt;/p&gt;

&lt;h3&gt;
  
  
  gd / gds
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git diff / git diff --staged&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gd          &lt;span class="c"&gt;# what's changed but not staged&lt;/span&gt;
gds         &lt;span class="c"&gt;# what's staged and about to be committed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gds&lt;/code&gt; before every commit. If you set &lt;a href="https://github.com/dandavison/delta" rel="noopener noreferrer"&gt;delta&lt;/a&gt; as your pager (&lt;code&gt;brew install git-delta&lt;/code&gt;, then &lt;code&gt;pager = delta&lt;/code&gt; in &lt;code&gt;~/.gitconfig&lt;/code&gt;), the output stops being painful to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  gcam
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git commit -a -m&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcam &lt;span class="s2"&gt;"fix: trailing slash in webhook URL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick one-line commits for small fixes. For anything bigger I drop the &lt;code&gt;-m&lt;/code&gt; and let &lt;code&gt;$EDITOR&lt;/code&gt; open so I can write a proper message with a body.&lt;/p&gt;

&lt;h3&gt;
  
  
  gpsup
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git push --set-upstream origin &amp;lt;current-branch&amp;gt;&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpsup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First push of a new branch. The full command is annoying to type, so &lt;code&gt;gpsup&lt;/code&gt; figures out the current branch name itself. After the first push, plain &lt;code&gt;gp&lt;/code&gt; (just &lt;code&gt;git push&lt;/code&gt;) works because upstream is set.&lt;/p&gt;

&lt;h3&gt;
  
  
  gco / gcb
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git checkout / git checkout -b&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gco main             &lt;span class="c"&gt;# switch to main&lt;/span&gt;
gco -                &lt;span class="c"&gt;# switch to previous branch&lt;/span&gt;
gcb feature/login    &lt;span class="c"&gt;# create + switch to new branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gco -&lt;/code&gt; is the one to notice. Like &lt;code&gt;cd -&lt;/code&gt; for branches. When you're bouncing between two branches all day, it's a single keystroke each way instead of typing the name.&lt;/p&gt;

&lt;h3&gt;
  
  
  gpf
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git push --force-with-lease&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rebasing or amending. &lt;strong&gt;Always use &lt;code&gt;--force-with-lease&lt;/code&gt;, never &lt;code&gt;--force&lt;/code&gt;.&lt;/strong&gt; The lease version refuses to push if someone else has pushed to your branch since your last fetch, saving you from silently overwriting a teammate's work. There is no good reason to ever type &lt;code&gt;--force&lt;/code&gt; in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  gfa
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git fetch --all --prune&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gfa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refresh every remote, prune deleted remote branches. Run before you start anything that depends on knowing the current state of the world. The &lt;code&gt;--prune&lt;/code&gt; half is what makes the cleanup ritual below work.&lt;/p&gt;

&lt;h2&gt;
  
  
  checkout recent branches
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git branch&lt;/code&gt; lists alphabetically, which is useless. What you actually want is "that branch from Tuesday," which means sorting by last commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; alias.recent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"for-each-ref --sort=-committerdate refs/heads/ &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
   --format='%(HEAD) %(color:yellow)%(refname:short)%(color:reset) &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
             %(color:green)(%(committerdate:relative))%(color:reset) %(contents:subject)'"&lt;/span&gt;

git recent | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers looking. For switching, pipe the same list into fzf and you never type a branch name again:&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;# fco: fuzzy-checkout a recent branch&lt;/span&gt;
fco&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;branch
  &lt;span class="nv"&gt;branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="nt"&gt;-each-ref&lt;/span&gt; &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-committerdate&lt;/span&gt; refs/heads/ &lt;span class="se"&gt;\&lt;/span&gt;
             &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(refname:short)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
           | fzf &lt;span class="nt"&gt;--height&lt;/span&gt; 40% &lt;span class="nt"&gt;--reverse&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
                 &lt;span class="nt"&gt;--preview&lt;/span&gt; &lt;span class="s1"&gt;'git log --oneline --decorate --color=always -15 {}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git checkout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Branches arrive sorted by recency, so the one you want is almost always in the top three. Type two letters of its name, Enter, done. The preview pane shows the branch's recent commits so you can confirm it's the right Tuesday. &lt;code&gt;gco -&lt;/code&gt; still wins for bouncing between exactly two branches; &lt;code&gt;fco&lt;/code&gt; wins for everything else. (&lt;code&gt;brew install fzf&lt;/code&gt; if you don't have it. You want it for &lt;code&gt;Ctrl-R&lt;/code&gt; history search anyway.)&lt;/p&gt;

&lt;h2&gt;
  
  
  the cleanup ritual
&lt;/h2&gt;

&lt;p&gt;Run this weekly. If you've ever scrolled through 80 stale branches looking for the one you actually want, you already know why.&lt;/p&gt;

&lt;p&gt;The easy half deletes every local branch whose tip is already in &lt;code&gt;main&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;gfa
git branch &lt;span class="nt"&gt;--merged&lt;/span&gt; main | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'\*\|main\|master'&lt;/span&gt; | xargs &lt;span class="nt"&gt;-n1&lt;/span&gt; git branch &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works only if your team uses merge commits. Most don't. GitHub's "Squash and merge" creates a brand-new commit on &lt;code&gt;main&lt;/code&gt; with a different SHA, so &lt;code&gt;git branch --merged&lt;/code&gt; never catches your local branch. Its commits aren't in main's history at all.&lt;/p&gt;

&lt;p&gt;The workaround: after &lt;code&gt;gfa&lt;/code&gt;, any branch whose tracked remote was deleted shows as &lt;code&gt;[gone]&lt;/code&gt;. Those are &lt;em&gt;usually&lt;/em&gt; your merged-and-deleted PRs.&lt;/p&gt;

&lt;p&gt;Usually, not always. &lt;code&gt;[gone]&lt;/code&gt; only means the remote tracking branch is gone. Nearly always that's a squash-merged PR whose branch GitHub auto-deleted. But it can also be a branch you pushed, someone deleted server-side, and you never merged. So don't force-delete every &lt;code&gt;[gone]&lt;/code&gt; branch with &lt;code&gt;git branch -D&lt;/code&gt;. I once watched one show &lt;code&gt;[gone]&lt;/code&gt; while it still held 26 unmerged commits; a force-delete there loses them for good.&lt;/p&gt;

&lt;p&gt;So check each &lt;code&gt;[gone]&lt;/code&gt; branch for patch-equivalence against the base &lt;em&gt;before&lt;/em&gt; deleting. Squash-merges get caught, genuinely unmerged work gets kept. This lives in my &lt;code&gt;~/.gitconfig&lt;/code&gt; as &lt;code&gt;git gone&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;# ~/.gitconfig, under [alias]  →  run as: git gone&lt;/span&gt;
gone &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"!f() { &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    git fetch --all --prune; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    base=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; origin/HEAD 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;; base=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;/main&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    for b in &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="nt"&gt;-each-ref&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(refname:short) %(upstream:track)'&lt;/span&gt; refs/heads &lt;span class="se"&gt;\&lt;/span&gt;
               | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$2==\"[gone]\"{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;; do &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
      if [ -z &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git cherry &lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt; &lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; ]; then git branch -D &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
      else echo &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;kept &lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt; (commits not in &lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;; fi; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    done; }; f"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command does the whole ritual: the &lt;code&gt;git fetch --all --prune&lt;/code&gt; prunes the dead remote refs, then the loop deletes the merged local branches in the same pass. No separate &lt;code&gt;gfa&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git cherry&lt;/code&gt; compares by patch-id, not SHA. A squash-merged branch shows every commit as &lt;code&gt;-&lt;/code&gt; (an equivalent already exists in the base) and gets deleted; a branch with real unpushed work shows &lt;code&gt;+&lt;/code&gt; lines and stays. The &lt;code&gt;-D&lt;/code&gt; is only reached after patch-equivalence is proven, so it never eats unmerged work.&lt;/p&gt;

&lt;p&gt;Or install &lt;a href="https://github.com/foriequal0/git-trim" rel="noopener noreferrer"&gt;&lt;code&gt;git-trim&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;brew install git-trim&lt;/code&gt;), which does the same classification and more. It catches squash-merges even when the tracking ref isn't &lt;code&gt;[gone]&lt;/code&gt;, and skips diverged branches by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git trim                &lt;span class="c"&gt;# dry-run&lt;/span&gt;
git trim &lt;span class="nt"&gt;--confirm&lt;/span&gt;      &lt;span class="c"&gt;# actually delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the closest thing to "did my PR ship?" you can ask git directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  the weekly four
&lt;/h2&gt;

&lt;p&gt;Not in your fingers yet, but should be.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git switch&lt;/code&gt; and &lt;code&gt;git restore&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch &lt;span class="nt"&gt;-c&lt;/span&gt; new-feature           &lt;span class="c"&gt;# create + switch&lt;/span&gt;
git restore &lt;span class="nt"&gt;--staged&lt;/span&gt; file.txt       &lt;span class="c"&gt;# unstage&lt;/span&gt;
git restore &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;abc123 file.go &lt;span class="c"&gt;# restore single file from any commit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;switch&lt;/code&gt; and &lt;code&gt;restore&lt;/code&gt; split the four jobs &lt;code&gt;checkout&lt;/code&gt; used to do. The one I reach for most is &lt;code&gt;restore --source=&amp;lt;sha&amp;gt; &amp;lt;path&amp;gt;&lt;/code&gt;. Translation: "grab this single file from three commits ago without touching anything else."&lt;/p&gt;

&lt;h3&gt;
  
  
  interactive rebase with autosquash
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--fixup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;abc123       &lt;span class="c"&gt;# fixup commit targeting abc123&lt;/span&gt;
&lt;span class="c"&gt;# ... keep working ...&lt;/span&gt;
git rebase &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--autosquash&lt;/span&gt; main &lt;span class="c"&gt;# all fixups slot into place automatically&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single biggest workflow win I've found in ten years of git. While reviewing your own PR you find a bug four commits back. Don't fix it on top. &lt;code&gt;--fixup=&amp;lt;sha&amp;gt;&lt;/code&gt; creates a commit targeting the offender, and the autosquash rebase squashes everything into place when you're done. Install &lt;a href="https://github.com/tummychow/git-absorb" rel="noopener noreferrer"&gt;git-absorb&lt;/a&gt; (&lt;code&gt;brew install git-absorb&lt;/code&gt;) and it even picks the target SHA for you: edit the files, run &lt;code&gt;git absorb --and-rebase&lt;/code&gt;, done.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git reflog&lt;/code&gt;, the universal undo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reflog
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; HEAD@&lt;span class="o"&gt;{&lt;/span&gt;5&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every change to &lt;code&gt;HEAD&lt;/code&gt; is logged for 90 days. Bad rebase? &lt;code&gt;reflog&lt;/code&gt;. Deleted branch? &lt;code&gt;reflog&lt;/code&gt;. There is almost nothing in git you can't undo if you know about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git worktree&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../proj-hotfix hotfix/prod-down
git worktree remove ../proj-hotfix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Need to fix a prod bug while halfway through a feature? Don't stash. &lt;code&gt;worktree add&lt;/code&gt; gives you a second checkout in a sibling directory, sharing the same &lt;code&gt;.git&lt;/code&gt;. I use it constantly for "let me review your PR" without leaving my own branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  set it once
&lt;/h2&gt;

&lt;p&gt;Five config lines and a daemon. Enable, forget.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; rerere.enabled &lt;span class="nb"&gt;true&lt;/span&gt;          &lt;span class="c"&gt;# remember conflict resolutions, replay them&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; push.default current         &lt;span class="c"&gt;# `git push` pushes current branch to same name&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; push.autoSetupRemote &lt;span class="nb"&gt;true&lt;/span&gt;    &lt;span class="c"&gt;# first push sets upstream automatically&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; diff.algorithm histogram     &lt;span class="c"&gt;# cleaner diffs than the default myers&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; merge.conflictStyle zdiff3   &lt;span class="c"&gt;# conflict markers include the common ancestor&lt;/span&gt;
git maintenance start                            &lt;span class="c"&gt;# background gc/prefetch on a schedule&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;autoSetupRemote&lt;/code&gt; retires &lt;code&gt;gpsup&lt;/code&gt; entirely. &lt;code&gt;zdiff3&lt;/code&gt; shows the original code both sides diverged from; once you've used it, plain &lt;code&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&lt;/code&gt; markers feel like flying blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  when something is broken
&lt;/h2&gt;

&lt;p&gt;Not daily, but when the question is "when did this start," nothing else answers it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"functionName"&lt;/span&gt;          &lt;span class="c"&gt;# pickaxe: commits where this string was added or removed&lt;/span&gt;
git blame &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; file.go      &lt;span class="c"&gt;# blame the logic's actual author, not the formatter&lt;/span&gt;
git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--follow&lt;/span&gt; file.go        &lt;span class="c"&gt;# full file history, including across renames&lt;/span&gt;
git range-diff @&lt;span class="o"&gt;{&lt;/span&gt;u&lt;span class="o"&gt;}&lt;/span&gt; @              &lt;span class="c"&gt;# what a rebase actually changed; run before force-pushing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-S&lt;/code&gt; searches the content of the diff, not commit messages. Different thing entirely from &lt;code&gt;--grep&lt;/code&gt;. And plain &lt;code&gt;blame&lt;/code&gt; gives credit to whoever last ran Prettier; &lt;code&gt;-w -C -C -C&lt;/code&gt; follows the code across whitespace changes, moves, and file boundaries to the person who wrote the logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  the four tools worth installing today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/junegunn/fzf" rel="noopener noreferrer"&gt;fzf&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;brew install fzf&lt;/code&gt;). Powers the &lt;code&gt;fco&lt;/code&gt; branch picker above, plus fuzzy &lt;code&gt;Ctrl-R&lt;/code&gt; history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/tummychow/git-absorb" rel="noopener noreferrer"&gt;git-absorb&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;brew install git-absorb&lt;/code&gt;). Auto-fixup commits without picking SHAs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/dandavison/delta" rel="noopener noreferrer"&gt;delta&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;brew install git-delta&lt;/code&gt;). Diff and blame output that doesn't hurt to look at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/jesseduffield/lazygit" rel="noopener noreferrer"&gt;lazygit&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;brew install lazygit&lt;/code&gt;). TUI for the operations that are tedious on CLI: partial commits, stash management, conflict resolution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post used to end with two AI shell helpers for the stuff git can't tell you; those now live in &lt;a href="https://dev.to/til/one-line-ai-shell-helper"&gt;their own TIL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ten years in, the surprise
&lt;/h2&gt;

&lt;p&gt;After a decade, the command I run most isn't &lt;code&gt;commit&lt;/code&gt;. It isn't &lt;code&gt;push&lt;/code&gt;. It's &lt;code&gt;gst&lt;/code&gt;, hundreds of times a day, between every other operation. The most-used git command in my shell is the one that does nothing.&lt;/p&gt;

</description>
      <category>git</category>
      <category>shell</category>
      <category>zsh</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
