<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Ariff Nazhan</title>
    <link>https://ariffnazhan.org/</link>
    <description>Articles by Ariff Nazhan on GPU infrastructure, cloud native and MLOps.</description>
    <language>en</language>
    <atom:link href="https://ariffnazhan.org/index.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>KAI Scheduler vs HAMi</title>
      <link>https://ariffnazhan.org/articles/kai-scheduler-vs-hami/</link>
      <guid>https://ariffnazhan.org/articles/kai-scheduler-vs-hami/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 GMT</pubDate>
      <description>&lt;style&gt;
.xd { margin: 2rem 0; }
.xd img { display: block; width: 100%; height: auto; }
.xd .xd-dark { display: none; }
html.dark .xd .xd-light { display: none; }
html.dark .xd .xd-dark { display: block; }
.xd img { max-width: 28rem; margin: 0 auto; }
.xd.xd-narrow img { max-width: 17rem; }
.xd figcaption { text-align: center; }
.xd figcaption { margin-top: 0.75rem; color: var(--muted); font-size: 0.8125rem; }
&lt;/style&gt;
&lt;p&gt;KAI Scheduler and HAMi both decide which GPU a pod gets, and both can put several pods on one card. They solve different problems. KAI Scheduler decides who gets GPUs and when: queues, quotas, fair share and gang scheduling. HAMi decides which card and how much of it: it slices cards, caps memory per container, and lets a pod name the exact cards it runs on.&lt;/p&gt;
&lt;p&gt;In short, they work at different levels. KAI is a Kubernetes scheduler: it works at the workload level and decides which pod or job runs, when, and on which node. HAMi works at the GPU level: it decides which card a pod gets and how much of it, and HAMi-core enforces that share inside the container. HAMi does not replace the Kubernetes scheduler, it extends it.&lt;/p&gt;
&lt;figure class=&quot;xd&quot;&gt;
&lt;a href=&quot;/images/kai-hami-levels-light.svg&quot;&gt;&lt;img class=&quot;xd-light&quot; src=&quot;/images/kai-hami-levels-light.svg&quot; width=&quot;880&quot; height=&quot;790&quot; alt=&quot;Where KAI Scheduler and HAMi sit. Workload level: KAI Scheduler, with queues, quotas, fair share, gang scheduling and preemption, decides who runs, when and on which node; without KAI this is the default kube-scheduler, which HAMi extends. Node level: a node with 8 GPUs. GPU level: HAMi, a device plugin and scheduler extender, decides which card and how much of it, pinning by UUID with a memory and compute share. Container level: HAMi-core caps memory and compute inside the container. Hardware: GPU 0 to GPU 7.&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;/images/kai-hami-levels-dark.svg&quot;&gt;&lt;img class=&quot;xd-dark&quot; src=&quot;/images/kai-hami-levels-dark.svg&quot; width=&quot;880&quot; height=&quot;790&quot; alt=&quot;&quot; aria-hidden=&quot;true&quot;&gt;&lt;/a&gt;
&lt;figcaption&gt;Where each one sits. Blue is KAI, green is HAMi.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;We run several workloads on the same GPU nodes, and we had trouble putting each of them on specific GPUs. We tried both. This post compares them. The versions are HAMi 2.9.0 and KAI Scheduler v0.18.6.&lt;/p&gt;
&lt;h2 id=&quot;what-each-one-is&quot;&gt;What each one is&lt;/h2&gt;
&lt;p&gt;HAMi:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It is the device plugin, plus a scheduler extender and a webhook. It hands out the cards itself.&lt;/li&gt;
&lt;li&gt;A pod can name the exact cards it runs on, by UUID.&lt;/li&gt;
&lt;li&gt;A pod asks for a share of each card&amp;#39;s memory, and HAMi-core can enforce that share inside the container.&lt;/li&gt;
&lt;li&gt;It has no queues, quotas or gang scheduling.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;KAI Scheduler:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It is a scheduler only. The cards come from the NVIDIA device plugin or from DRA.&lt;/li&gt;
&lt;li&gt;A pod cannot name its cards. The device plugin picks them.&lt;/li&gt;
&lt;li&gt;Card sharing works through reservation pods that hold whole cards. Memory is not enforced by default.&lt;/li&gt;
&lt;li&gt;It has queues with quotas, fair share, preemption and gang scheduling.&lt;/li&gt;
&lt;li&gt;It is bigger: 7 controllers, 6 CRDs and 4 PriorityClasses, against HAMi&amp;#39;s DaemonSet, Deployment and webhook.&lt;/li&gt;
&lt;/ol&gt;
&lt;figure class=&quot;xd&quot;&gt;
&lt;a href=&quot;/images/kai-vs-hami-light.svg&quot;&gt;&lt;img class=&quot;xd-light&quot; src=&quot;/images/kai-vs-hami-light.svg&quot; width=&quot;880&quot; height=&quot;770&quot; alt=&quot;How a pod that shares two cards gets them. HAMi: the pod names its cards, the webhook sends it to hami-scheduler, which takes the named cards if each has room, and the HAMi device plugin hands over exactly those cards. KAI Scheduler: the pod asks for a gpu-fraction in a queue, the scheduler checks the quota and picks GPU groups, the binder creates one reservation pod per card, and the NVIDIA device plugin gives each reservation pod any free card.&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;/images/kai-vs-hami-dark.svg&quot;&gt;&lt;img class=&quot;xd-dark&quot; src=&quot;/images/kai-vs-hami-dark.svg&quot; width=&quot;880&quot; height=&quot;770&quot; alt=&quot;&quot; aria-hidden=&quot;true&quot;&gt;&lt;/a&gt;
&lt;figcaption&gt;How a pod that shares two cards gets them. The shaded step is where the physical card is chosen. Drawn from HAMi's &lt;a href=&quot;https://github.com/Project-HAMi/HAMi&quot;&gt;architecture&lt;/a&gt; and KAI's &lt;a href=&quot;https://github.com/NVIDIA/KAI-Scheduler/blob/v0.18.6/docs/gpu-sharing/README.md&quot;&gt;GPU sharing docs&lt;/a&gt;.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;what-each-one-is-good-at&quot;&gt;What each one is good at&lt;/h2&gt;
&lt;figure class=&quot;xd xd-narrow&quot;&gt;
&lt;a href=&quot;/images/kai-architecture-light.svg&quot;&gt;&lt;img class=&quot;xd-light&quot; src=&quot;/images/kai-architecture-light.svg&quot; width=&quot;450&quot; height=&quot;852&quot; alt=&quot;KAI Scheduler architecture: workloads; the pod-grouper that groups pods into PodGroups; a tree of queues with quotas; the scheduling cycle (allocate, consolidate, reclaim, preempt, stale gang eviction); the binder, which also creates reservation pods for GPU sharing; and the device plugin or DRA that hands out the cards.&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;/images/kai-architecture-dark.svg&quot;&gt;&lt;img class=&quot;xd-dark&quot; src=&quot;/images/kai-architecture-dark.svg&quot; width=&quot;450&quot; height=&quot;852&quot; alt=&quot;&quot; aria-hidden=&quot;true&quot;&gt;&lt;/a&gt;
&lt;figcaption&gt;KAI Scheduler, after its &lt;a href=&quot;https://github.com/NVIDIA/KAI-Scheduler/blob/v0.18.6/docs/scheduling-deep-dive/README.md&quot;&gt;scheduling deep dive&lt;/a&gt;. Blue is where KAI works.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;KAI Scheduler (v0.18.6) is built around queues and gangs. A pod-grouper puts related pods into PodGroups that start together or not at all. The groups wait in a tree of queues, each with a guaranteed quota, a limit, an over-quota weight and a priority. Every scheduling cycle allocates first, then consolidates running pods to cut fragmentation, reclaims over-quota usage between queues, and preempts lower-priority work inside a queue, with fairness by DRF and optionally by usage over time. Priority and preemptibility are separate settings, and a minimum runtime protects a pod from early preemption. On top of that it places by binpack or spread, can be topology-aware, runs elastic jobs with a minimum and maximum size, shares GPUs with &lt;code&gt;gpu-fraction&lt;/code&gt; or &lt;code&gt;gpu-memory&lt;/code&gt;, schedules DRA ResourceClaims, works with cluster autoscalers, and records each decision as a Kubernetes Event.&lt;/p&gt;
&lt;figure class=&quot;xd xd-narrow&quot;&gt;
&lt;a href=&quot;/images/hami-architecture-light.svg&quot;&gt;&lt;img class=&quot;xd-light&quot; src=&quot;/images/hami-architecture-light.svg&quot; width=&quot;450&quot; height=&quot;832&quot; alt=&quot;HAMi architecture: workloads; a mutating webhook that routes GPU pods to the HAMi scheduler; the scheduler (kube-scheduler with the HAMi extender, or Volcano or Koordinator); device plugins for NVIDIA, Ascend, Cambricon, Hygon, Iluvatar and MetaX; inside the container HAMi-core for NVIDIA or a vendor runtime; and metrics of what each pod holds on each card.&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;/images/hami-architecture-dark.svg&quot;&gt;&lt;img class=&quot;xd-dark&quot; src=&quot;/images/hami-architecture-dark.svg&quot; width=&quot;450&quot; height=&quot;832&quot; alt=&quot;&quot; aria-hidden=&quot;true&quot;&gt;&lt;/a&gt;
&lt;figcaption&gt;HAMi, after its &lt;a href=&quot;https://github.com/Project-HAMi/HAMi&quot;&gt;architecture diagram&lt;/a&gt;. Green is where HAMi works.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;HAMi (v2.9.0) is built around the device. Its device plugin splits each card into slots, and a pod asks for N different cards and a share of each: memory in MiB or percent, and compute as a percent of the SMs. The pod can name its cards by UUID (&lt;code&gt;nvidia.com/use-gpuuuid&lt;/code&gt;) or by model, or keep away from some. HAMi-core enforces those shares inside the container with no change to the application, and the container sees its cap as the card&amp;#39;s memory; &lt;code&gt;CUDA_DISABLE_CONTROL=true&lt;/code&gt; turns it off for one container. Placement is binpack or spread, for nodes and for cards, chosen per pod, and dynamic MIG is available per pod. It supports accelerators from several vendors, exports what each pod holds on each card, and needs no CRDs: a scheduler extender on kube-scheduler, a device plugin and a webhook.&lt;/p&gt;
&lt;h2 id=&quot;things-to-know-before-choosing&quot;&gt;Things to know before choosing&lt;/h2&gt;
&lt;p&gt;KAI cannot pin a pod to given cards. The device plugin picks them, so the only way to land on specific cards is to have them be the only free ones when the pod binds, and the placement can change after a restart. Its GPU sharing needs the &lt;code&gt;nvidia.com/gpu.memory&lt;/code&gt; node label from GPU Feature Discovery; without it every bind of a &lt;code&gt;gpu-fraction&lt;/code&gt; pod is rolled back. In v0.18.6 it also counts admin-access DRA claims as GPU usage, so with the GPU Operator&amp;#39;s DRA driver a node looks fuller than it is. Uninstalling it through ArgoCD leaves its CRDs and the namespace of its reservation pods behind, and leftover reservation pods keep holding cards.&lt;/p&gt;
&lt;p&gt;HAMi cannot run next to the NVIDIA device plugin, since both advertise &lt;code&gt;nvidia.com/gpu&lt;/code&gt;. It counts slots, not cards: with &lt;code&gt;deviceSplitCount: 10&lt;/code&gt;, an 8-GPU node shows &lt;code&gt;nvidia.com/gpu: 80&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;With either tool, engines that share a card should start one at a time. vLLM measures free GPU memory when it starts, and two engines measuring at once count each other&amp;#39;s memory wrong.&lt;/p&gt;
&lt;h2 id=&quot;why-we-use-hami&quot;&gt;Why we use HAMi&lt;/h2&gt;
&lt;p&gt;Our problem was placement: several workloads, each meant for specific cards, some sharing a card. HAMi covers that in one component.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pinning.&lt;/strong&gt; A pod lists the card UUIDs it may use. The spec is the placement, and it survives restarts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sharing by memory share.&lt;/strong&gt; A pod asks for N cards and a share of each. HAMi will not place a pod past 100 % of a card.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enforcement per container.&lt;/strong&gt; vLLM sizes its own memory, so vLLM pods run with HAMi-core off and the share is bookkeeping. Small services run with HAMi-core on, so they cannot grow into a neighbour&amp;#39;s memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small footprint.&lt;/strong&gt; A device plugin, a scheduler extender and a webhook, no CRDs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A vLLM engine pinned to two cards that it shares with another engine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-yaml&quot;&gt;&lt;span class=&quot;hljs-attr&quot;&gt;metadata:&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;annotations:&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;nvidia.com/use-gpuuuid:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&amp;quot;GPU-aaaa...,GPU-bbbb...&amp;quot;&lt;/span&gt;   &lt;span class=&quot;hljs-comment&quot;&gt;# exactly these two cards&lt;/span&gt;
&lt;span class=&quot;hljs-attr&quot;&gt;spec:&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;runtimeClassName:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;nvidia&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;containers:&lt;/span&gt;
    &lt;span class=&quot;hljs-bullet&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;name:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;vllm&lt;/span&gt;
      &lt;span class=&quot;hljs-attr&quot;&gt;env:&lt;/span&gt;
        &lt;span class=&quot;hljs-bullet&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;name:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;CUDA_DISABLE_CONTROL&lt;/span&gt;      &lt;span class=&quot;hljs-comment&quot;&gt;# HAMi-core off: vLLM manages its own memory&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;value:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&amp;quot;true&amp;quot;&lt;/span&gt;
      &lt;span class=&quot;hljs-attr&quot;&gt;resources:&lt;/span&gt;
        &lt;span class=&quot;hljs-attr&quot;&gt;limits:&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;nvidia.com/gpu:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&amp;quot;2&amp;quot;&lt;/span&gt;                   &lt;span class=&quot;hljs-comment&quot;&gt;# two different cards&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;nvidia.com/gpumem-percentage:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&amp;quot;45&amp;quot;&lt;/span&gt;    &lt;span class=&quot;hljs-comment&quot;&gt;# of each card&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;when-kai-is-the-better-choice&quot;&gt;When KAI is the better choice&lt;/h2&gt;
&lt;p&gt;KAI is built for a shared training or batch cluster: many teams, jobs that come and go, quotas that must hold, gang scheduling for multi-node training, and preemption between priorities. If which card a pod lands on does not matter and the problem is who gets GPUs first, KAI solves that and HAMi does not.&lt;/p&gt;
&lt;p&gt;If the problem is which card, and every card has a planned owner, HAMi does it directly. KAI gets there only through rollout order, with a reservation-pod layer and a GPU Feature Discovery dependency on top.&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Proxmox with GPU passthrough</title>
      <link>https://ariffnazhan.org/articles/proxmox-with-gpu-passthrough/</link>
      <guid>https://ariffnazhan.org/articles/proxmox-with-gpu-passthrough/</guid>
      <pubDate>Sun, 11 Oct 2026 00:00:00 GMT</pubDate>
      <description>&lt;style&gt;
.px { margin: 2rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: 0.5rem; font-size: 0.875rem; line-height: 1.5; }
.px figcaption { margin-top: 0.75rem; color: var(--muted); font-size: 0.8125rem; }
.px-img { background: #ffffff; border-radius: 0.375rem; padding: 0.5rem; }
.prose .px .px-img img { display: block; max-width: 100%; width: auto; height: auto; margin: 0 auto; }
&lt;/style&gt;

&lt;p&gt;A k3s cluster with one control plane and two workers needs three machines. I have one PC: an i9-10900K, 32 GB of RAM and one RTX 3090. So the PC runs &lt;a href=&quot;https://www.proxmox.com/en/products/proxmox-virtual-environment/overview&quot;&gt;Proxmox VE&lt;/a&gt; as the hypervisor and the three nodes are virtual machines, one of them with GPU passthrough enabled.&lt;/p&gt;
&lt;p&gt;This post covers the Proxmox side only: the three VMs, PCI passthrough of the RTX 3090 to one of them, and static DHCP addresses on the router. The k3s installation is a separate post.&lt;/p&gt;
&lt;h2 id=&quot;the-pc&quot;&gt;The PC&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;Intel i9-10900K, 10 cores, 20 threads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;32 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;2 TB, ZFS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;NVIDIA RTX 3090, 24 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hypervisor&lt;/td&gt;
&lt;td&gt;Proxmox VE 9.2.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;one bridge, &lt;code&gt;vmbr0&lt;/code&gt;, on my home LAN. The host is &lt;code&gt;192.168.100.5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;Proxmox is managed from a web UI on port 8006. It shows two storages on this host: &lt;code&gt;local&lt;/code&gt; holds ISO images, &lt;code&gt;local-zfs&lt;/code&gt; holds the VM disks.&lt;/p&gt;
&lt;h2 id=&quot;installing-proxmox-keep-host-drivers-off-the-gpu&quot;&gt;Installing Proxmox: keep host drivers off the GPU&lt;/h2&gt;
&lt;p&gt;For passthrough, no driver on the Proxmox host may hold the GPU. A driver that has bound the card must release it before a VM can take it. The simpler state is that no host driver ever binds it.&lt;/p&gt;
&lt;p&gt;The kernel in Proxmox VE 9.2 has three drivers that match an RTX 3090:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nouveau&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the older open-source NVIDIA driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nova_core&lt;/code&gt; (with &lt;code&gt;nova_drm&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;the new open-source NVIDIA driver in recent kernels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidiafb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;an old framebuffer driver&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;Blocking them starts at the installer. At the installer&amp;#39;s boot menu, &lt;code&gt;e&lt;/code&gt; opens the selected entry for editing. I added two parameters to the end of the line that starts with &lt;code&gt;linux&lt;/code&gt;, then booted it with Ctrl+X:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nomodeset modprobe.blacklist=nova_core,nova_drm,nova,nouveau
&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nomodeset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the kernel loads no mode-setting graphics driver; the console stays on the plain framebuffer the firmware set up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;modprobe.blacklist=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the listed modules are not loaded automatically, from the start of boot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;The installed system has the same settings in two places. The kernel command line, &lt;code&gt;/etc/kernel/cmdline&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;root=ZFS=rpool/ROOT/pve-1 boot=zfs nomodeset modprobe.blacklist=nova_core,nova_drm,nova,nouveau
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And a modprobe file, &lt;code&gt;/etc/modprobe.d/pve-blacklist.conf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;blacklist nova_core
blacklist nova_drm
blacklist nova
blacklist nouveau
blacklist nvidiafb
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The result can be read on the host while the GPU VM runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot;&gt;lspci -nnk -s 02:00
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;02:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA102 [GeForce RTX 3090] [10de:2204] (rev a1)
	Kernel driver in use: vfio-pci
	Kernel modules: nvidiafb, nouveau, nova_core
02:00.1 Audio device [0403]: NVIDIA Corporation GA102 High Definition Audio Controller [10de:1aef] (rev a1)
	Kernel driver in use: vfio-pci
	Kernel modules: snd_hda_intel
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;quot;Kernel modules&amp;quot; lists the drivers that could handle the device. &amp;quot;Kernel driver in use&amp;quot; is the one that has it: &lt;code&gt;vfio-pci&lt;/code&gt;, the stub driver Proxmox uses to hand a device to a VM. &lt;code&gt;lsmod&lt;/code&gt; on the host shows no &lt;code&gt;nouveau&lt;/code&gt;, &lt;code&gt;nova&lt;/code&gt; or &lt;code&gt;nvidia&lt;/code&gt; module loaded.&lt;/p&gt;
&lt;h2 id=&quot;the-plan-three-vms&quot;&gt;The plan: three VMs&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;VM&lt;/th&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;vCPU&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Disk&lt;/th&gt;
&lt;th&gt;What it becomes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vm-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4 GB&lt;/td&gt;
&lt;td&gt;60 GB&lt;/td&gt;
&lt;td&gt;k3s control plane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vm-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4 GB&lt;/td&gt;
&lt;td&gt;60 GB&lt;/td&gt;
&lt;td&gt;k3s worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vm-gpu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;16 GB&lt;/td&gt;
&lt;td&gt;200 GB&lt;/td&gt;
&lt;td&gt;k3s worker with the RTX 3090&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;The GPU VM gets most of the resources because GPU workloads also need CPU, memory and disk for the model files. The other two only have to run Kubernetes itself and small services.&lt;/p&gt;
&lt;p&gt;All three run Ubuntu Server 24.04.&lt;/p&gt;
&lt;h2 id=&quot;step-1-the-ubuntu-iso&quot;&gt;Step 1: the Ubuntu ISO&lt;/h2&gt;
&lt;p&gt;Proxmox can download an ISO by itself, so nothing has to go through my laptop. In the UI: &lt;code&gt;local&lt;/code&gt; storage, ISO Images, Download from URL. Paste the URL, press Query URL, and Proxmox fills in the file name and the size.&lt;/p&gt;
&lt;figure class=&quot;px&quot;&gt;
&lt;div class=&quot;px-img&quot;&gt;&lt;a href=&quot;/images/proxmox-iso-download.jpg&quot;&gt;&lt;img src=&quot;/images/proxmox-iso-download.jpg&quot; alt=&quot;Proxmox Download from URL dialog with the Ubuntu 24.04.5 live server ISO URL, file size 3.80 GiB, hash algorithm still set to None.&quot;&gt;&lt;/a&gt;&lt;/div&gt;
&lt;figcaption&gt;The download dialog after &quot;Query URL&quot;, before the checksum is filled in.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Before pressing Download I set Hash algorithm to SHA-256 and pasted the checksum from Ubuntu&amp;#39;s &lt;code&gt;SHA256SUMS&lt;/code&gt; file. Proxmox then verifies the 3.80 GiB file itself and refuses it if it does not match.&lt;/p&gt;
&lt;h2 id=&quot;step-2-two-plain-vms&quot;&gt;Step 2: two plain VMs&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;vm-1&lt;/code&gt; and &lt;code&gt;vm-2&lt;/code&gt; are the Create VM wizard with its defaults, except three values: the ISO from step 1, a 60 GB disk on &lt;code&gt;local-zfs&lt;/code&gt;, and 4 cores with 4096 MiB of memory.&lt;/p&gt;
&lt;figure class=&quot;px&quot;&gt;
&lt;div class=&quot;px-img&quot;&gt;&lt;a href=&quot;/images/proxmox-vm-1-confirm.jpg&quot;&gt;&lt;img src=&quot;/images/proxmox-vm-1-confirm.jpg&quot; alt=&quot;Confirm tab of the Create VM wizard for vm-1: 4 cores, cpu x86-64-v2-AES, memory 4096, network virtio on bridge vmbr0, disk local-zfs 60 GB with iothread, controller virtio-scsi-single.&quot;&gt;&lt;/a&gt;&lt;/div&gt;
&lt;figcaption&gt;The wizard's last tab for &lt;code&gt;vm-1&lt;/code&gt;. &lt;code&gt;vm-2&lt;/code&gt; is the same with ID 101.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The defaults mean a SeaBIOS firmware and the &lt;code&gt;i440fx&lt;/code&gt; machine type. That is fine for a VM with no physical device attached.&lt;/p&gt;
&lt;h2 id=&quot;step-3-the-gpu-vm&quot;&gt;Step 3: the GPU VM&lt;/h2&gt;
&lt;h3 id=&quot;what-passthrough-needs&quot;&gt;What passthrough needs&lt;/h3&gt;
&lt;p&gt;PCI passthrough hands one physical device to one VM. The VM&amp;#39;s operating system loads the normal driver and uses the card directly. The host and the other VMs cannot use it while that VM runs.&lt;/p&gt;
&lt;p&gt;It has two requirements on the host. The first is the one above: no host driver holds the card. The second is the IOMMU (Intel calls it VT-d), a CPU and motherboard feature that limits a device to the memory of the VM it is given to. The IOMMU puts devices into groups, and a group can only be passed through as a whole. So the question for any card is: what else is in its group?&lt;/p&gt;
&lt;p&gt;On this host the IOMMU was already active when I looked. Proxmox listed 16 groups, and the answer was the good one:&lt;/p&gt;
&lt;figure class=&quot;px&quot;&gt;
&lt;div class=&quot;px-img&quot;&gt;&lt;a href=&quot;/images/homelab-proxmox-pci-device.jpg&quot;&gt;&lt;img src=&quot;/images/homelab-proxmox-pci-device.jpg&quot; alt=&quot;Proxmox Add PCI Device dialog listing the host devices with their IOMMU groups. The GeForce RTX 3090 and its High Definition Audio Controller are both in group 13.&quot;&gt;&lt;/a&gt;&lt;/div&gt;
&lt;figcaption&gt;The device list in the &quot;Add: PCI Device&quot; dialog. The RTX 3090 (&lt;code&gt;0000:02:00.0&lt;/code&gt;) and its HDMI audio function (&lt;code&gt;0000:02:00.1&lt;/code&gt;) are the two devices in IOMMU group 13. Nothing else comes with them.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;the-vm-settings&quot;&gt;The VM settings&lt;/h3&gt;
&lt;p&gt;Five settings differ from the two plain VMs:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Machine&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q35&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the default &lt;code&gt;i440fx&lt;/code&gt; has no PCI Express; &lt;code&gt;q35&lt;/code&gt; does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BIOS&lt;/td&gt;
&lt;td&gt;OVMF (UEFI), Pre-Enroll keys off&lt;/td&gt;
&lt;td&gt;with the keys enrolled, Secure Boot is on and the NVIDIA kernel module has to be signed before it loads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the guest sees the real CPU and its flags instead of a generic model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;16384 MiB, Ballooning Device off&lt;/td&gt;
&lt;td&gt;all memory of a VM with a passed-through device is pinned, so the balloon cannot give any of it back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;200 GB&lt;/td&gt;
&lt;td&gt;room for container images and models&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;The first two are on the wizard&amp;#39;s System tab. Choosing OVMF adds an EFI disk, which needs a storage:&lt;/p&gt;
&lt;figure class=&quot;px&quot;&gt;
&lt;div class=&quot;px-img&quot;&gt;&lt;a href=&quot;/images/proxmox-vm-gpu-system.jpg&quot;&gt;&lt;img src=&quot;/images/proxmox-vm-gpu-system.jpg&quot; alt=&quot;System tab of the Create VM wizard for the GPU VM: machine q35, BIOS OVMF (UEFI), EFI storage local-zfs, Pre-Enroll keys unticked.&quot;&gt;&lt;/a&gt;&lt;/div&gt;
&lt;figcaption&gt;System tab: &lt;code&gt;q35&lt;/code&gt;, OVMF, the EFI disk on &lt;code&gt;local-zfs&lt;/code&gt;, and &quot;Pre-Enroll keys&quot; unticked.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure class=&quot;px&quot;&gt;
&lt;div class=&quot;px-img&quot;&gt;&lt;a href=&quot;/images/proxmox-vm-gpu-confirm.jpg&quot;&gt;&lt;img src=&quot;/images/proxmox-vm-gpu-confirm.jpg&quot; alt=&quot;Confirm tab for vm-gpu: balloon 0, bios ovmf, 8 cores, cpu host, EFI disk on local-zfs, machine q35, memory 16384, disk local-zfs 200 GB, Start after created unticked.&quot;&gt;&lt;/a&gt;&lt;/div&gt;
&lt;figcaption&gt;The last tab for &lt;code&gt;vm-gpu&lt;/code&gt;. &quot;Start after created&quot; stays unticked, because the GPU is not attached yet.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;attaching-the-card&quot;&gt;Attaching the card&lt;/h3&gt;
&lt;p&gt;The wizard has no step for PCI devices. After the VM exists: Hardware, Add, PCI Device, Raw Device, pick &lt;code&gt;0000:02:00.0&lt;/code&gt;, then tick two boxes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All Functions passes &lt;code&gt;02:00.0&lt;/code&gt; and &lt;code&gt;02:00.1&lt;/code&gt; together, the card and its audio function.&lt;/li&gt;
&lt;li&gt;PCI-Express presents it to the guest as a PCI Express device. This is the option that needs &lt;code&gt;q35&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;px&quot;&gt;
&lt;div class=&quot;px-img&quot;&gt;&lt;a href=&quot;/images/homelab-proxmox-vm-gpu-hardware.jpg&quot;&gt;&lt;img src=&quot;/images/homelab-proxmox-vm-gpu-hardware.jpg&quot; alt=&quot;Proxmox hardware tab of the VM vm-gpu: 16 GiB memory with balloon 0, 8 cores of type host, OVMF BIOS, q35 machine, and a PCI device hostpci0 at 0000:02:00 with pcie=1.&quot;&gt;&lt;/a&gt;&lt;/div&gt;
&lt;figcaption&gt;The finished hardware list. The last line, &lt;code&gt;hostpci0: 0000:02:00,pcie=1&lt;/code&gt;, is the GPU. The network card's MAC address is covered.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;the-gpu-vm-takes-the-monitor&quot;&gt;The GPU VM takes the monitor&lt;/h2&gt;
&lt;p&gt;Proxmox lists no other display adapter on this PC, so the RTX 3090 is also what drives the host&amp;#39;s monitor. The moment &lt;code&gt;vm-gpu&lt;/code&gt; starts, the card belongs to the VM and the monitor goes dark. The web UI and SSH keep working, because they do not need the screen.&lt;/p&gt;
&lt;p&gt;That matters after a host reboot. If &lt;code&gt;vm-gpu&lt;/code&gt; started by itself at boot, the host would have no screen, and I could not get to it from the monitor.&lt;/p&gt;
&lt;p&gt;So the rule for this host: &lt;code&gt;vm-gpu&lt;/code&gt; never has &amp;quot;Start at boot&amp;quot; on. The setting is on the VM&amp;#39;s Options tab and is off by default. I checked it after creating the VM, and it is the first thing to check again after changing that VM. After a reboot the screen stays with the host until I start the GPU VM by hand.&lt;/p&gt;
&lt;p&gt;The second thing to know is memory. With all three VMs running, the host showed 27 of 31 GiB in use. The VMs add up to 24 GB, and the GPU VM&amp;#39;s 16 GB are pinned. There is no room for a fourth VM.&lt;/p&gt;
&lt;h2 id=&quot;step-4-install-ubuntu&quot;&gt;Step 4: install Ubuntu&lt;/h2&gt;
&lt;p&gt;Each VM boots the ISO, and the installer runs in the VM&amp;#39;s Console tab. I took the defaults, with two things to note:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Network: leave it on DHCP. The installer shows the address the router handed out, for example &lt;code&gt;192.168.100.54/24&lt;/code&gt; on &lt;code&gt;vm-2&lt;/code&gt;. Write the three addresses down, they are needed in step 5.&lt;/li&gt;
&lt;li&gt;SSH: tick &amp;quot;Install OpenSSH server&amp;quot;, or the console stays the only way in.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One default I did not expect: Ubuntu&amp;#39;s installer gave the root filesystem only half of each disk, 29 GB of the 60 GB and 97 GB of the 200 GB. The rest is free space in the LVM volume group. I have not extended it yet.&lt;/p&gt;
&lt;h2 id=&quot;step-5-fixed-addresses-on-the-router&quot;&gt;Step 5: fixed addresses on the router&lt;/h2&gt;
&lt;p&gt;The VMs get their addresses from my router by DHCP, and a DHCP address is a lease: it can change. That will matter later, because the k3s workers find the control plane by its address. If &lt;code&gt;vm-1&lt;/code&gt; comes back with a different one, the workers lose the control plane.&lt;/p&gt;
&lt;p&gt;There are two ways to pin an address:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Way&lt;/th&gt;
&lt;th&gt;Where it is set&lt;/th&gt;
&lt;th&gt;What I chose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Static IP&lt;/td&gt;
&lt;td&gt;inside each VM&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static DHCP (a reservation)&lt;/td&gt;
&lt;td&gt;on the router, per MAC address&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;With a reservation the router always hands the same address to the same network card. Nothing is configured inside the VMs, and the addresses are all in one place.&lt;/p&gt;
&lt;p&gt;The router identifies a card by its MAC address. Proxmox shows it on the VM&amp;#39;s Hardware tab, in the Network Device (net0) line, as &lt;code&gt;virtio=BC:24:11:…&lt;/code&gt;. Proxmox generates these, they all start with &lt;code&gt;BC:24:11&lt;/code&gt;, and a VM keeps its MAC as long as the network device is not removed and added again.&lt;/p&gt;
&lt;p&gt;My internet is TIME, and on its router the page is called DHCP Static IP Configuration: New, type the MAC address and the IP address, Apply. One line per machine:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Address&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Proxmox host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.100.5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vm-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.100.53&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vm-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.100.54&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vm-gpu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.100.55&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;figure class=&quot;px&quot;&gt;
&lt;div class=&quot;px-img&quot;&gt;&lt;a href=&quot;/images/homelab-router-static-dhcp.png&quot;&gt;&lt;img src=&quot;/images/homelab-router-static-dhcp.png&quot; alt=&quot;Router page titled DHCP Static IP Configuration with four reservations: 192.168.100.5, .53, .54 and .55. The MAC addresses are covered.&quot;&gt;&lt;/a&gt;&lt;/div&gt;
&lt;figcaption&gt;The router page with the four reservations. MAC addresses are covered.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;I did not pick &lt;code&gt;.53&lt;/code&gt; to &lt;code&gt;.55&lt;/code&gt;. They are the addresses DHCP had already given the three VMs, and I reserved exactly those. A machine that holds a lease keeps it until it renews, so reserving the address it already has changes nothing on the VM. Reserving a different one would only take effect at the next renewal or reboot.&lt;/p&gt;
&lt;p&gt;The router&amp;#39;s DHCP range is &lt;code&gt;192.168.100.2&lt;/code&gt; to &lt;code&gt;192.168.100.254&lt;/code&gt;. The four reserved addresses are inside that range, and this router accepts that.&lt;/p&gt;
&lt;h2 id=&quot;check-the-card-inside-the-vm&quot;&gt;Check: the card inside the VM&lt;/h2&gt;
&lt;p&gt;Passthrough is only proven from inside the guest. In &lt;code&gt;vm-gpu&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot;&gt;lspci -nnk | grep -iA3 nvidia
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA102 [GeForce RTX 3090] [10de:2204] (rev a1)
	Subsystem: Micro-Star International Co., Ltd. [MSI] GA102 [GeForce RTX 3090] [1462:3881]
	Kernel driver in use: nvidia
	Kernel modules: nvidiafb, nouveau, nvidia_drm, nvidia
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The card is at &lt;code&gt;01:00.0&lt;/code&gt; in the guest and &lt;code&gt;02:00.0&lt;/code&gt; on the host. The guest has its own PCI layout, the two numbers do not have to match.&lt;/p&gt;
&lt;p&gt;&amp;quot;Kernel driver in use: nvidia&amp;quot; is there because I had installed Ubuntu&amp;#39;s &lt;code&gt;nvidia-driver-580-server&lt;/code&gt; package and rebooted. With the driver loaded, &lt;code&gt;nvidia-smi&lt;/code&gt; reports the whole card:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot;&gt;nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;NVIDIA GeForce RTX 3090, 24576 MiB, 580.178.04
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;what-it-looks-like-at-the-end&quot;&gt;What it looks like at the end&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;vm-1&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;vm-2&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;vm-gpu&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.100.53&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.100.54&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.100.55&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vCPU / RAM / disk&lt;/td&gt;
&lt;td&gt;4 / 4 GB / 60 GB&lt;/td&gt;
&lt;td&gt;4 / 4 GB / 60 GB&lt;/td&gt;
&lt;td&gt;8 / 16 GB / 200 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine and firmware&lt;/td&gt;
&lt;td&gt;&lt;code&gt;i440fx&lt;/code&gt;, SeaBIOS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;i440fx&lt;/code&gt;, SeaBIOS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q35&lt;/code&gt;, OVMF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;RTX 3090, all 24 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start at boot&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;off, and it stays off&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;Three machines with fixed addresses, one of them with a GPU that works. The next step is k3s: &lt;code&gt;vm-1&lt;/code&gt; as the control plane, &lt;code&gt;vm-2&lt;/code&gt; and &lt;code&gt;vm-gpu&lt;/code&gt; as workers.&lt;/p&gt;
</description>
    </item>
    <item>
      <title>RoCE vs NVLink for KV transfer</title>
      <link>https://ariffnazhan.org/articles/roce-vs-nvlink-kv-transfer/</link>
      <guid>https://ariffnazhan.org/articles/roce-vs-nvlink-kv-transfer/</guid>
      <pubDate>Fri, 02 Oct 2026 10:00:00 GMT</pubDate>
      <description>&lt;p&gt;I expected NVLink to win this one.&lt;/p&gt;
&lt;p&gt;In disaggregated serving on Dynamo, the decode engine pulls the KV cache out of the prefill engine through NIXL, and UCX picks the transport underneath. NVIDIA&amp;#39;s own transport table puts NVLink at 450 to 900 GB/s and RoCE at 10 to 25 GB/s. So wherever NVLink can be used, it should be the faster road for the cache.&lt;/p&gt;
&lt;p&gt;I tested it on a B300 node, same engines, same prompts, only the transport changed. RoCE moved the cache in 14 to 34 % less time. These are my notes on why.&lt;/p&gt;
&lt;h2 id=&quot;where-nvlink-is-even-an-option&quot;&gt;Where NVLink is even an option&lt;/h2&gt;
&lt;p&gt;NVIDIA&amp;#39;s guide draws the stack like this.&lt;/p&gt;
&lt;style&gt;
.nx { margin: 2rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: 0.5rem; font-size: 0.875rem; line-height: 1.5; --nx-nv: #2a78d6; --nx-roce: #c9531f; --nx-tcp: #6b7280; --nx-post: #6250d6; --nx-rest: #c9ccd2; }
html.dark .nx { --nx-nv: #5a9bea; --nx-roce: #f08a5b; --nx-tcp: #9ca3af; --nx-post: #9085e9; --nx-rest: #4b5563; }
.prose .nx p.nx-title { margin: 0 0 0.75rem; font-weight: 600; }
.nx figcaption { margin-top: 0.75rem; color: var(--muted); font-size: 0.8125rem; }
.nx-img { background: #ffffff; border-radius: 0.375rem; padding: 0.5rem; }
.prose .nx .nx-img img { display: block; width: 100%; height: auto; margin: 0; }
.nx-rows { display: grid; gap: 0.375rem; }
.nx-row { display: grid; grid-template-columns: 11rem minmax(0, 1fr) 5.5rem; gap: 0.5rem; align-items: center; }
.nx-lab { color: var(--muted); font-size: 0.8125rem; }
.nx-track { display: flex; height: 0.875rem; border-left: 1px solid var(--muted); }
.nx-bar { height: 0.875rem; border-radius: 0 0.25rem 0.25rem 0; }
.nx-bar.nv { background: var(--nx-nv); }
.nx-bar.roce { background: var(--nx-roce); }
.nx-bar.tcp { background: var(--nx-tcp); }
.nx-bar.post { background: var(--nx-post); border-radius: 0; }
.nx-bar.rest { background: var(--nx-rest); margin-left: 2px; }
.nx-val { font-variant-numeric: tabular-nums; font-size: 0.8125rem; white-space: nowrap; }
.prose .nx p.nx-group { margin: 0.625rem 0 0.125rem; font-weight: 600; font-size: 0.8125rem; }
.prose .nx p.nx-group:first-child { margin-top: 0; }
.nx-axis { display: grid; grid-template-columns: 11rem minmax(0, 1fr) 5.5rem; gap: 0.5rem; margin-top: 0.25rem; }
.nx-ticks { display: flex; justify-content: space-between; border-top: 1px solid var(--border); padding-top: 0.125rem; color: var(--muted); font-size: 0.75rem; }
.prose .nx p.nx-key { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin: 0 0 0.75rem; color: var(--muted); font-size: 0.8125rem; }
.nx-key i { display: inline-block; width: 0.625rem; height: 0.625rem; border-radius: 2px; margin-right: 0.375rem; }
@media (max-width: 34rem) {
  .nx-row, .nx-axis { grid-template-columns: 6.5rem minmax(0, 1fr) 4.25rem; }
}
&lt;/style&gt;

&lt;figure class=&quot;nx&quot;&gt;
&lt;div class=&quot;nx-img&quot;&gt;&lt;img src=&quot;/images/nvidia-dynamo-disagg-comm-stack.svg&quot; alt=&quot;NVIDIA's diagram of the disaggregated communication stack: prefill and decode worker pods, the NIXL library, UCX, and the transport layer with RDMA, cuda_copy and tcp&quot;&gt;&lt;/div&gt;
&lt;figcaption&gt;From NVIDIA's Disaggregated Inference Communication Guide (&lt;a href=&quot;https://github.com/ai-dynamo/dynamo&quot;&gt;ai-dynamo/dynamo&lt;/a&gt;, Apache 2.0). Look at the transport layer: RDMA, host staging and TCP. NVLink is not on it.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;NVLink is missing from that picture on purpose. The guide is about the operator, where prefill and decode are two pods, and it has a second diagram for why NVLink is not available between them.&lt;/p&gt;
&lt;figure class=&quot;nx&quot;&gt;
&lt;div class=&quot;nx-img&quot;&gt;&lt;img src=&quot;/images/nvidia-dynamo-disagg-nvlink-limitation.svg&quot; alt=&quot;NVIDIA's diagram of one physical node with a prefill pod and a decode pod: NVLink works between the GPUs inside each pod, and there is no NVLink between the two pods&quot;&gt;&lt;/div&gt;
&lt;figcaption&gt;From the same guide. NVLink works inside a pod, which is what tensor parallelism uses. Between two pods there is none: separate process namespaces, separate CUDA contexts.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;So on the operator the question never comes up. Even with both pods on one node, the cache goes through the NIC.&lt;/p&gt;
&lt;figure class=&quot;nx&quot;&gt;
&lt;div class=&quot;nx-img&quot;&gt;&lt;img src=&quot;/images/nvidia-dynamo-disagg-same-node.svg&quot; alt=&quot;NVIDIA's diagram of a prefill pod and a decode pod on the same node, with the KV cache going from one GPU to the other over RDMA&quot;&gt;&lt;/div&gt;
&lt;figcaption&gt;From the same guide: two pods on the same node still use RDMA.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The question only comes up when Dynamo is started by hand, as plain processes with no pods. Then the prefill and the decode engine share one process namespace, UCX&amp;#39;s &lt;code&gt;cuda_ipc&lt;/code&gt; transport can open the other engine&amp;#39;s GPU memory, and it rates itself far above the NIC. With &lt;code&gt;UCX_TLS=rc_x,cuda_copy,cuda_ipc&lt;/code&gt; it wins the lane selection, and the KV cache moves GPU to GPU over NVLink with nothing on the rails.&lt;/p&gt;
&lt;p&gt;That is the setup where I could put the two roads side by side.&lt;/p&gt;
&lt;h2 id=&quot;the-test&quot;&gt;The test&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;One node, 8 B300 GPUs, NVLink between them, 400G-class RoCE NICs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;&lt;code&gt;google/gemma-4-31B-it&lt;/code&gt;, BF16 weights and KV cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layout&lt;/td&gt;
&lt;td&gt;1 prefill TP4 + 1 decode TP4 on the same node, started by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software&lt;/td&gt;
&lt;td&gt;Dynamo with vLLM 0.23 and NIXL 1.3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load&lt;/td&gt;
&lt;td&gt;4k, 8k and 16k-token prompts, 256 output tokens, 30 requests at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Without RoCE&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UCX_TLS=rc_x,cuda_copy,cuda_ipc&lt;/code&gt;: UCX picks &lt;code&gt;cuda_ipc&lt;/code&gt;, so NVLink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With RoCE&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UCX_TLS=rc_x,cuda_copy&lt;/code&gt;: &lt;code&gt;cuda_ipc&lt;/code&gt; removed, so &lt;code&gt;rc_mlx5&lt;/code&gt; through the NIC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;Same prompts in both runs. I checked the road each time with &lt;code&gt;UCX_PROTO_INFO=y&lt;/code&gt; and with the RoCE port counters: without RoCE the NICs received 1.3 MB over the whole run, with RoCE they received 1,121 GB.&lt;/p&gt;
&lt;figure class=&quot;nx&quot;&gt;
&lt;p class=&quot;nx-title&quot;&gt;Time for one KV pull, per TP rank, 30 requests at a time&lt;/p&gt;
&lt;p class=&quot;nx-key&quot;&gt;&lt;span&gt;&lt;i style=&quot;background:var(--nx-nv)&quot;&gt;&lt;/i&gt;without RoCE (NVLink)&lt;/span&gt;&lt;span&gt;&lt;i style=&quot;background:var(--nx-roce)&quot;&gt;&lt;/i&gt;with RoCE&lt;/span&gt;&lt;/p&gt;
&lt;div class=&quot;nx-rows&quot;&gt;
&lt;p class=&quot;nx-group&quot;&gt;4k-token prompts, 0.3 GB per pull&lt;/p&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;without RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar nv&quot; style=&quot;width:74.7%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;23.9 ms&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;with RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar roce&quot; style=&quot;width:49.1%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;15.7 ms&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;nx-group&quot;&gt;8k-token prompts, 0.4 GB per pull&lt;/p&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;without RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar nv&quot; style=&quot;width:63.8%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;20.4 ms&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;with RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar roce&quot; style=&quot;width:54.7%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;17.5 ms&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;nx-group&quot;&gt;16k-token prompts, 0.6 GB per pull&lt;/p&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;without RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar nv&quot; style=&quot;width:91.3%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;29.2 ms&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;with RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar roce&quot; style=&quot;width:66.9%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;21.4 ms&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;nx-axis&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;nx-ticks&quot;&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;8&lt;/span&gt;&lt;span&gt;16&lt;/span&gt;&lt;span&gt;24&lt;/span&gt;&lt;span&gt;32 ms&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;figcaption&gt;Average over the pulls of each run. A request is four pulls, one per rank, at the same time. Linear scale from zero.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;34 % less time at 4k, 14 % less at 8k, 27 % less at 16k. The road with more than ten times the bandwidth lost at every prompt length.&lt;/p&gt;
&lt;h2 id=&quot;why&quot;&gt;Why&lt;/h2&gt;
&lt;p&gt;NIXL reports two times per transfer: the whole transfer, and the post, which is how long it took just to issue the reads. Splitting the 16k pull that way shows where NVLink loses.&lt;/p&gt;
&lt;figure class=&quot;nx&quot;&gt;
&lt;p class=&quot;nx-title&quot;&gt;The same pull at 16k tokens, split in two&lt;/p&gt;
&lt;p class=&quot;nx-key&quot;&gt;&lt;span&gt;&lt;i style=&quot;background:var(--nx-post)&quot;&gt;&lt;/i&gt;post time: issuing the reads&lt;/span&gt;&lt;span&gt;&lt;i style=&quot;background:var(--nx-rest)&quot;&gt;&lt;/i&gt;after the post, until the data has arrived&lt;/span&gt;&lt;/p&gt;
&lt;div class=&quot;nx-rows&quot;&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;without RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar post&quot; style=&quot;width:36.6%&quot;&gt;&lt;/span&gt;&lt;span class=&quot;nx-bar rest&quot; style=&quot;width:54.7%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;11.7 + 17.5&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;with RoCE&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar post&quot; style=&quot;width:2.5%&quot;&gt;&lt;/span&gt;&lt;span class=&quot;nx-bar rest&quot; style=&quot;width:64.4%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;0.8 + 20.6&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;nx-axis&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;nx-ticks&quot;&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;8&lt;/span&gt;&lt;span&gt;16&lt;/span&gt;&lt;span&gt;24&lt;/span&gt;&lt;span&gt;32 ms&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;figcaption&gt;Over NVLink, 11.7 ms goes to the post alone. Over RoCE the post is 0.8 ms.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The reason is the shape of the transfer. A KV pull is not one big copy. vLLM registers the cache per block and per layer, so one pull is a list of small descriptors: about 2,350 for a 4k prompt and 4,250 for a 16k prompt, around 125 KB each. NIXL&amp;#39;s UCX backend issues one &lt;code&gt;ucp_get_nbx&lt;/code&gt; per descriptor.&lt;/p&gt;
&lt;p&gt;What one of those reads costs depends on the transport.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cuda_ipc&lt;/code&gt;.&lt;/strong&gt; Each read becomes its own &lt;code&gt;cuMemcpyDtoDAsync&lt;/code&gt; plus a &lt;code&gt;cuEventRecord&lt;/code&gt;, issued one after another from a CPU thread. All the copies to one peer GPU go onto a single CUDA stream, so they run in order, and completions are collected 16 at a time. That is two driver calls and an event per 125 KB. Per descriptor, the post alone is 2.7 µs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;rc_mlx5&lt;/code&gt;.&lt;/strong&gt; Each read becomes an RDMA READ work request on the NIC&amp;#39;s send queue. The CPU queues all 4,250 in under a millisecond, 0.19 µs each, and from there the NIC does the work: zero-copy from GPU memory to GPU memory, striped over two paths.&lt;/p&gt;
&lt;p&gt;So the NVLink run was never limited by NVLink. It moved about 19 GB/s per rank on a link that carries hundreds. It was limited by how fast one thread can push small copies through the CUDA driver.&lt;/p&gt;
&lt;p&gt;Bandwidth is the wrong number to compare here. It is the same split as in storage, throughput against IOPS. This transfer is bound by operations, and RoCE has the cheaper operation because the NIC takes the whole batch off the CPU.&lt;/p&gt;
&lt;p&gt;I think NVLink would win if the connector handed over a few large contiguous regions instead of thousands of blocks. I have not tested that.&lt;/p&gt;
&lt;h2 id=&quot;does-it-matter&quot;&gt;Does it matter&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Without RoCE (NVLink)&lt;/th&gt;
&lt;th&gt;With RoCE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;KV pull time, 4k / 8k / 16k&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;0.66x / 0.86x / 0.73x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post time, 4k / 8k / 16k&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;0.08x / 0.12x / 0.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTFT p50, one request at a time&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;0.99x / 1.00x / 1.00x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests per second, 30 at a time&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;1.02x / 1.00x / 1.00x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;Each figure is relative to the NVLink run at the same prompt length.&lt;/p&gt;
&lt;p&gt;Not to the user. TTFT is the same to within 1 %, because a 20 ms pull is a small part of a wait that is mostly prefill. Throughput is the same too.&lt;/p&gt;
&lt;p&gt;What it changes is how I think about the two links. NVLink is for the model: the TP ranks of one engine exchanging activations on every layer, with buffers set up once. RoCE is for moving the cache between engines. On the operator that is the only choice anyway, and after this test I would make the same choice by hand: drop &lt;code&gt;cuda_ipc&lt;/code&gt; from &lt;code&gt;UCX_TLS&lt;/code&gt; and let the NIC do it.&lt;/p&gt;
&lt;p&gt;The case that does hurt is the one without RDMA at all. The same guide has the numbers for it.&lt;/p&gt;
&lt;figure class=&quot;nx&quot;&gt;
&lt;p class=&quot;nx-title&quot;&gt;Extra TTFT from the KV transfer, by transport (NVIDIA's numbers, log scale)&lt;/p&gt;
&lt;div class=&quot;nx-rows&quot;&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;AWS EFA + libfabric, measured&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar roce&quot; style=&quot;width:14.2%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;+37 ms&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;InfiniBand RDMA, expected&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar roce&quot; style=&quot;width:42.5%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;+0.2 to 0.5 s&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;RoCE RDMA, expected&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar roce&quot; style=&quot;width:47.6%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;+0.3 to 0.8 s&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;Staged through the host, expected&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar tcp&quot; style=&quot;width:62%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;+1 to 3 s&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;nx-row&quot;&gt;&lt;span class=&quot;nx-lab&quot;&gt;TCP fallback, measured&lt;/span&gt;&lt;span class=&quot;nx-track&quot;&gt;&lt;span class=&quot;nx-bar tcp&quot; style=&quot;width:100%&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;nx-val&quot;&gt;+90 to 100 s&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;nx-axis&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;nx-ticks&quot;&gt;&lt;span&gt;10 ms&lt;/span&gt;&lt;span&gt;100 ms&lt;/span&gt;&lt;span&gt;1 s&lt;/span&gt;&lt;span&gt;10 s&lt;/span&gt;&lt;span&gt;100 s&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;figcaption&gt;My chart of the performance table in NVIDIA's guide (Dynamo 1.4.2). Each bar ends at the top of its range, on a logarithmic scale. &quot;Measured&quot; rows are from an AWS p5.48xlarge with an 8B model and 8,000-token prompts; &quot;expected&quot; rows are the guide's estimate from hardware specs.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;A first token after about 98 seconds on TCP, with nothing logged as an error. If a disaggregated deployment feels slow, check the transport before anything else.&lt;/p&gt;
</description>
    </item>
    <item>
      <title>NVIDIA Dynamo prom metrics patch</title>
      <link>https://ariffnazhan.org/articles/dynamo-prometheus-metrics-patch/</link>
      <guid>https://ariffnazhan.org/articles/dynamo-prometheus-metrics-patch/</guid>
      <pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate>
      <description>&lt;p&gt;I serve an LLM with &lt;a href=&quot;https://github.com/ai-dynamo/dynamo&quot;&gt;NVIDIA Dynamo&lt;/a&gt;. Its frontend exports latency metrics to Prometheus, and my dashboard said time to first token (TTFT) was about 2 seconds at p95.&lt;/p&gt;
&lt;p&gt;What the dashboard could not tell me: is every request waiting 2 seconds, or only the ones with a long prompt? This post is about the small patch that answers that.&lt;/p&gt;
&lt;p&gt;Dynamo can run prefill and decode on separate workers (disaggregated serving), which makes the question sharper: if TTFT is bad, I want to know whether to add prefill capacity, and for which kind of request.&lt;/p&gt;
&lt;h2 id=&quot;the-problem-one-number-for-every-prompt-size&quot;&gt;The problem: one number for every prompt size&lt;/h2&gt;
&lt;p&gt;Dynamo exports a good set of metrics out of the box. NVIDIA lists them all in the &lt;a href=&quot;https://docs.nvidia.com/dynamo/reference/observability/metrics-catalog&quot;&gt;Metrics Catalog&lt;/a&gt;. They have one gap.&lt;/p&gt;
&lt;p&gt;Look at the labels on the latency histograms (TTFT, ITL, request duration). They carry &lt;code&gt;model&lt;/code&gt;, and nothing about the request. Input length is exported too, but as its own separate histogram.&lt;/p&gt;
&lt;p&gt;You cannot join two histograms. A Prometheus histogram is a set of counters (&amp;quot;how many observations were at most 0.5 s&amp;quot;), with no row per request. The TTFT histogram knows 900 requests came back in under half a second. The ISL histogram knows 4,000 prompts were under 1k tokens. Nothing records which were which.&lt;/p&gt;
&lt;p&gt;So a 300-token chat turn and a 12,000-token RAG prompt go into the same TTFT histogram, even though prefill for the second one is far more work. When p95 moves, I cannot tell whether the system got slower or the traffic just shifted toward longer prompts. And I cannot tell which context length is the bottleneck.&lt;/p&gt;
&lt;p&gt;Here is the same data both ways. Start on &lt;strong&gt;Stock&lt;/strong&gt;, then switch to &lt;strong&gt;Patched&lt;/strong&gt;.&lt;/p&gt;
&lt;style&gt;
/* Styles for the interactive figures in this post */

.viz {
  --viz-series: #2a78d6;
  --viz-accent: #eb6834;
  --viz-neutral: #9ca3af;
  --viz-track: #e5e7eb;
  --viz-grid: #eef0f3;
  --viz-wash: rgba(235, 104, 52, 0.12);
  margin: 2rem 0;
  padding: 1rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  font-size: 0.875rem;
  line-height: 1.5;
}

html.dark .viz {
  --viz-series: #3987e5;
  --viz-accent: #d95926;
  --viz-neutral: #6b7280;
  --viz-track: #374151;
  --viz-grid: #1f2937;
  --viz-wash: rgba(217, 89, 38, 0.18);
}

.viz-title {
  margin: 0 0 0.75rem;
  font-weight: 600;
}

.viz-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
  margin-bottom: 0.75rem;
}

.viz-group {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
}

.viz-group-label {
  color: var(--muted);
  font-size: 0.75rem;
  white-space: nowrap;
}

.viz-seg {
  display: inline-flex;
  border: 1px solid var(--border);
  border-radius: 0.375rem;
  overflow: hidden;
}

.viz-seg button {
  padding: 0.25rem 0.625rem;
  border: 0;
  border-left: 1px solid var(--border);
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 0.8125rem;
  cursor: pointer;
}

.viz-seg button:first-child {
  border-left: 0;
}

.viz-seg button:hover {
  background: var(--code-bg);
}

.viz-seg button[aria-pressed=&quot;true&quot;] {
  background: var(--fg);
  color: var(--bg);
}

.viz-seg button:focus-visible,
.viz input:focus-visible {
  outline: 2px solid var(--viz-series);
  outline-offset: 2px;
}

.viz-field {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
}

.viz-field label {
  color: var(--muted);
  font-size: 0.75rem;
}

.viz-field input[type=&quot;text&quot;] {
  flex: 1 1 14rem;
  min-width: 0;
  padding: 0.3rem 0.5rem;
  border: 1px solid var(--border);
  border-radius: 0.375rem;
  background: var(--bg);
  color: var(--fg);
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  font-size: 0.8125rem;
}

.viz-field input[type=&quot;range&quot;] {
  flex: 1 1 10rem;
  min-width: 0;
  accent-color: var(--viz-series);
}

.viz-field output {
  min-width: 5.5rem;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}

.viz-plot {
  position: relative;
}

.viz-plot svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

.viz text {
  fill: var(--muted);
  font-family: inherit;
  font-size: 11px;
}

.viz text.viz-value {
  fill: var(--fg);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.viz text.viz-strong {
  fill: var(--fg);
  font-weight: 600;
}

.viz-gridline {
  stroke: var(--viz-grid);
  stroke-width: 1;
}

.viz-axis {
  stroke: var(--border);
  stroke-width: 1;
}

.viz-bar {
  fill: var(--viz-series);
  transition: d 0.3s ease, opacity 0.2s;
}

.viz-bar-all {
  fill: var(--viz-neutral);
}

.viz-bar-thin {
  opacity: 0.55;
}

.viz-hit {
  fill: transparent;
  cursor: default;
  outline: none;
}

.viz-hit:hover + .viz-bar,
.viz-hit:focus + .viz-bar {
  opacity: 0.8;
}

.viz-unknown {
  fill: none;
  stroke: var(--viz-neutral);
  stroke-width: 1;
  stroke-dasharray: 3 3;
}

.viz-segment {
  fill: var(--viz-track);
  transition: fill 0.15s;
}

.viz-segment-on {
  fill: var(--viz-series);
}

.viz-marker {
  fill: var(--fg);
}

.viz-scrape {
  stroke: var(--viz-grid);
  stroke-width: 1;
}

.viz-tip {
  position: absolute;
  z-index: 2;
  max-width: 14rem;
  padding: 0.375rem 0.5rem;
  border: 1px solid var(--border);
  border-radius: 0.375rem;
  background: var(--bg);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
  font-size: 0.75rem;
  line-height: 1.4;
  pointer-events: none;
  transform: translate(-50%, -100%);
}

.viz-tip strong {
  display: block;
  font-size: 0.875rem;
  font-variant-numeric: tabular-nums;
}

.viz-tip span {
  color: var(--muted);
}

.viz-readout {
  margin: 0.75rem 0 0;
  padding: 0.625rem 0.75rem;
  overflow-x: auto;
  border-radius: 0.375rem;
  background: var(--pre-bg);
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  font-size: 0.75rem;
  line-height: 1.7;
  white-space: pre;
}

.viz-readout b {
  color: var(--viz-series);
}

.viz-readout .viz-bad {
  color: var(--viz-accent);
}

.prose .viz figcaption {
  margin-top: 0.75rem;
  color: var(--muted);
  font-size: 0.8125rem;
}

.prose .viz details {
  margin-top: 0.75rem;
}

.prose .viz summary {
  color: var(--muted);
  font-size: 0.8125rem;
  cursor: pointer;
}

.prose .viz table {
  margin: 0.5rem 0 0;
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

.prose .viz th,
.prose .viz td {
  padding: 0.3rem 0.6rem;
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .viz-bar,
  .viz-segment {
    transition: none;
  }
}
&lt;/style&gt;

&lt;figure class=&quot;viz&quot; id=&quot;fig-split&quot;&gt;
&lt;div class=&quot;viz-title&quot;&gt;One latency number, or one per input-length bucket&lt;/div&gt;
&lt;div class=&quot;viz-controls&quot;&gt;&lt;noscript&gt;This chart needs JavaScript. The table below has the same numbers.&lt;/noscript&gt;&lt;/div&gt;
&lt;div class=&quot;viz-plot&quot;&gt;&lt;/div&gt;
&lt;figcaption&gt;About 18,000 requests from load tests against a 31B model on a test cluster, one prefill and one decode worker. Faded bars are buckets with only 16 requests each, so treat those as noise. Hover or tab to a bar for the request count.&lt;/figcaption&gt;
&lt;details&gt;&lt;summary&gt;Table view&lt;/summary&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Input length (tokens)&lt;/th&gt;&lt;th&gt;Requests&lt;/th&gt;&lt;th&gt;TTFT p50&lt;/th&gt;&lt;th&gt;TTFT p95&lt;/th&gt;&lt;th&gt;TTFT p99&lt;/th&gt;&lt;th&gt;ITL p50&lt;/th&gt;&lt;th&gt;ITL p95&lt;/th&gt;&lt;th&gt;Avg cached tokens&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;all requests&lt;/td&gt;&lt;td&gt;17,944&lt;/td&gt;&lt;td&gt;0.17 s&lt;/td&gt;&lt;td&gt;2.00 s&lt;/td&gt;&lt;td&gt;5.29 s&lt;/td&gt;&lt;td&gt;7.3 ms&lt;/td&gt;&lt;td&gt;12.5 ms&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;0-512&lt;/td&gt;&lt;td&gt;4,700&lt;/td&gt;&lt;td&gt;0.07 s&lt;/td&gt;&lt;td&gt;0.19 s&lt;/td&gt;&lt;td&gt;0.22 s&lt;/td&gt;&lt;td&gt;6.4 ms&lt;/td&gt;&lt;td&gt;12.3 ms&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;513-1024&lt;/td&gt;&lt;td&gt;16&lt;/td&gt;&lt;td&gt;0.08 s&lt;/td&gt;&lt;td&gt;0.12 s&lt;/td&gt;&lt;td&gt;0.20 s&lt;/td&gt;&lt;td&gt;6.3 ms&lt;/td&gt;&lt;td&gt;12.3 ms&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1025-2048&lt;/td&gt;&lt;td&gt;16&lt;/td&gt;&lt;td&gt;0.08 s&lt;/td&gt;&lt;td&gt;0.20 s&lt;/td&gt;&lt;td&gt;0.22 s&lt;/td&gt;&lt;td&gt;6.7 ms&lt;/td&gt;&lt;td&gt;12.4 ms&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2049-4096&lt;/td&gt;&lt;td&gt;118&lt;/td&gt;&lt;td&gt;0.23 s&lt;/td&gt;&lt;td&gt;1.32 s&lt;/td&gt;&lt;td&gt;2.02 s&lt;/td&gt;&lt;td&gt;9.3 ms&lt;/td&gt;&lt;td&gt;12.7 ms&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4097-8192&lt;/td&gt;&lt;td&gt;247&lt;/td&gt;&lt;td&gt;0.40 s&lt;/td&gt;&lt;td&gt;3.98 s&lt;/td&gt;&lt;td&gt;4.56 s&lt;/td&gt;&lt;td&gt;9.3 ms&lt;/td&gt;&lt;td&gt;12.6 ms&lt;/td&gt;&lt;td&gt;30&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;8193-16384&lt;/td&gt;&lt;td&gt;1,067&lt;/td&gt;&lt;td&gt;0.58 s&lt;/td&gt;&lt;td&gt;5.90 s&lt;/td&gt;&lt;td&gt;9.18 s&lt;/td&gt;&lt;td&gt;8.1 ms&lt;/td&gt;&lt;td&gt;12.5 ms&lt;/td&gt;&lt;td&gt;8,745&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;16385+&lt;/td&gt;&lt;td&gt;11,780&lt;/td&gt;&lt;td&gt;0.31 s&lt;/td&gt;&lt;td&gt;2.06 s&lt;/td&gt;&lt;td&gt;5.30 s&lt;/td&gt;&lt;td&gt;7.4 ms&lt;/td&gt;&lt;td&gt;12.5 ms&lt;/td&gt;&lt;td&gt;21,074&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/details&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;the-patch-an-isl-bucket-label&quot;&gt;The patch: an &lt;code&gt;isl_bucket&lt;/code&gt; label&lt;/h2&gt;
&lt;p&gt;The fix is to label the latency histograms with the bucket the prompt falls in.&lt;/p&gt;
&lt;p&gt;It is a small patch to the Dynamo frontend, on top of Dynamo 1.5.0. In general terms it does three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Adds one opt-in setting on the frontend&lt;/strong&gt;: a list of bucket boundaries, as inclusive upper bounds in tokens. Six boundaries make seven buckets, and each bucket becomes a label value: &lt;code&gt;0-512&lt;/code&gt;, &lt;code&gt;513-1024&lt;/code&gt;, and so on up to the open-ended &lt;code&gt;16385+&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exports five more histograms next to the originals&lt;/strong&gt;: TTFT, ITL, request duration, output length and cached tokens, each with an &lt;code&gt;isl_bucket&lt;/code&gt; label beside &lt;code&gt;model&lt;/code&gt;. They use the same &lt;code&gt;le&lt;/code&gt; buckets as the originals, so a quantile from one is comparable to a quantile from the other.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Creates every bucket&amp;#39;s series at zero when the model registers&lt;/strong&gt;, so &lt;code&gt;rate()&lt;/code&gt; and &lt;code&gt;increase()&lt;/code&gt; do not miss a bucket&amp;#39;s first burst of requests.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-does-the-frontend-know-the-bucket&quot;&gt;When does the frontend know the bucket?&lt;/h3&gt;
&lt;p&gt;At the first token. That is the point where the frontend has the prompt&amp;#39;s token count and records TTFT, so the request&amp;#39;s bucket is latched there and reused for everything after:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;TTFT is observed straight away, into the bucket&amp;#39;s series.&lt;/li&gt;
&lt;li&gt;ITL goes through a request-local histogram, the same way the stock ITL metric does, so the per-token path stays cheap.&lt;/li&gt;
&lt;li&gt;Request duration, OSL and cached tokens are recorded when the request ends.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A request that fails before its first token never gets a bucket. It still shows up in &lt;code&gt;dynamo_frontend_requests_total&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;what-it-costs&quot;&gt;What it costs&lt;/h3&gt;
&lt;p&gt;Every bucket multiplies the series of five histograms. For one model and seven buckets that came to about 270 extra series per frontend, which is nothing for Prometheus. The patch refuses more than 32 boundaries so a typo cannot blow up cardinality.&lt;/p&gt;
&lt;p&gt;When the setting is off, nothing extra is registered and requests pay nothing for it.&lt;/p&gt;
&lt;h2 id=&quot;querying-it&quot;&gt;Querying it&lt;/h2&gt;
&lt;p&gt;p95 TTFT per bucket:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-promql&quot;&gt;histogram_quantile(0.95,
  sum by (le, isl_bucket) (
    rate(dynamo_frontend_time_to_first_token_by_isl_seconds_bucket[5m])
  )
)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Mean TTFT per bucket:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-promql&quot;&gt;  sum by (isl_bucket) (rate(dynamo_frontend_time_to_first_token_by_isl_seconds_sum[5m]))
/
  sum by (isl_bucket) (rate(dynamo_frontend_time_to_first_token_by_isl_seconds_count[5m]))
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&quot;without-and-with-on-a-real-dashboard&quot;&gt;Without and with, on a real dashboard&lt;/h3&gt;
&lt;p&gt;The same Grafana panel, TTFT p95, over the same ten minutes of a load test.&lt;/p&gt;
&lt;p&gt;Without the label, which is all stock Dynamo can give you:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/dynamo-ttft-p95-without.png&quot; alt=&quot;Grafana panel of TTFT p95 as a single line, climbing from about 2 seconds to almost 10 seconds&quot;&gt;&lt;/p&gt;
&lt;p&gt;One line. It climbs from about 2 s to almost 10 s, and all I can say is that it got slow.&lt;/p&gt;
&lt;p&gt;With the &lt;code&gt;isl_bucket&lt;/code&gt; label:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/dynamo-ttft-p95-with.png&quot; alt=&quot;The same Grafana panel with one TTFT p95 line per input-length bucket&quot;&gt;&lt;/p&gt;
&lt;p&gt;Five lines, and now there is something to act on. Prompts of 2k to 4k tokens stayed around 1.3 s the whole time. The 4k to 8k bucket settled near 4.4 s. Everything above 8k tokens went to almost 10 s, and that is what dragged the single line up.&lt;/p&gt;
&lt;p&gt;Two things I learned building the dashboard:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bucket labels sort as strings&lt;/strong&gt;, so &lt;code&gt;1025-2048&lt;/code&gt; comes before &lt;code&gt;513-1024&lt;/code&gt;. On VictoriaMetrics, wrapping the query in &lt;code&gt;sort_by_label_numeric(..., &amp;quot;isl_bucket&amp;quot;)&lt;/code&gt; puts them in order.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give avg, p50, p95 and p99 their own panels.&lt;/strong&gt; On one shared panel the p99 spikes set the y-axis and flatten everything else into a line along the bottom.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;These metrics are worth having. With one latency number, a slow dashboard only tells me that something is slow. With one number per prompt-length bucket, when a bottleneck shows up, at least I know which bottleneck it is: which prompt sizes are waiting, and so where the capacity is short.&lt;/p&gt;
&lt;script&gt;
// Interactive figures for this post
(function () {
  'use strict';

  var SVG = 'http://www.w3.org/2000/svg';

  function svg(name, attrs, parent) {
    var node = document.createElementNS(SVG, name);
    for (var key in attrs) node.setAttribute(key, attrs[key]);
    if (parent) parent.appendChild(node);
    return node;
  }

  function html(tag, cls, text, parent) {
    var node = document.createElement(tag);
    if (cls) node.className = cls;
    if (text != null) node.textContent = text;
    if (parent) parent.appendChild(node);
    return node;
  }

  function clear(node) {
    while (node.firstChild) node.removeChild(node.firstChild);
  }

  // A row of mutually exclusive buttons. onPick gets the chosen value.
  function segmented(parent, label, options, initial, onPick) {
    var group = html('div', 'viz-group', null, parent);
    html('span', 'viz-group-label', label, group);
    var seg = html('div', 'viz-seg', null, group);
    seg.setAttribute('role', 'group');
    seg.setAttribute('aria-label', label);
    var buttons = options.map(function (opt) {
      var b = html('button', null, opt.label, seg);
      b.type = 'button';
      b.setAttribute('aria-pressed', String(opt.value === initial));
      b.addEventListener('click', function () {
        buttons.forEach(function (other) {
          other.setAttribute('aria-pressed', String(other === b));
        });
        onPick(opt.value);
      });
      return b;
    });
  }

  function tooltip(plot) {
    var tip = html('div', 'viz-tip', null, plot);
    tip.hidden = true;
    return {
      show: function (x, y, value, detail) {
        clear(tip);
        html('strong', null, value, tip);
        html('span', null, detail, tip);
        tip.style.left = x + 'px';
        tip.style.top = y - 8 + 'px';
        tip.hidden = false;
      },
      hide: function () {
        tip.hidden = true;
      },
    };
  }

  function plotWidth(plot) {
    return Math.max(280, Math.round(plot.clientWidth || 600));
  }

  function onResize(render) {
    var timer;
    window.addEventListener('resize', function () {
      clearTimeout(timer);
      timer = setTimeout(render, 120);
    });
  }

  function niceMax(value) {
    var pow = Math.pow(10, Math.floor(Math.log10(value)));
    var steps = [1, 1.5, 2, 3, 4, 5, 6, 8, 10];
    for (var i = 0; i &lt; steps.length; i++) {
      if (steps[i] * pow &gt;= value) return steps[i] * pow;
    }
    return 10 * pow;
  }

  var number = new Intl.NumberFormat('en-US');

  // ---------------------------------------------------------------------------
  // Figure 1: one number for every prompt, or one per input-length bucket
  // ---------------------------------------------------------------------------

  var BUCKETS = ['all', '0-512', '513-1024', '1025-2048', '2049-4096', '4097-8192', '8193-16384', '16385+'];
  var SHORT = ['all', '≤512', '≤1k', '≤2k', '≤4k', '≤8k', '≤16k', '&gt;16k'];
  var REQUESTS = [17944, 4700, 16, 16, 118, 247, 1067, 11780];
  var METRICS = {
    ttft: {
      label: 'TTFT',
      unit: 'seconds',
      suffix: ' s',
      digits: function (v) {
        return v &lt; 1 ? v.toFixed(2) : v.toFixed(1);
      },
      avg: [0.51, 0.085, 0.078, 0.092, 0.36, 0.93, 1.13, 0.61],
      p50: [0.17, 0.071, 0.075, 0.082, 0.23, 0.4, 0.58, 0.31],
      p95: [2.0, 0.19, 0.12, 0.2, 1.32, 3.98, 5.9, 2.06],
      p99: [5.29, 0.22, 0.2, 0.22, 2.02, 4.56, 9.18, 5.3],
    },
    itl: {
      label: 'ITL',
      unit: 'milliseconds',
      suffix: ' ms',
      digits: function (v) {
        return v.toFixed(1);
      },
      avg: [7.1, 6.4, 15.7, 18.0, 7.8, 7.5, 7.2, 7.2],
      p50: [7.3, 6.4, 6.3, 6.7, 9.3, 9.3, 8.1, 7.4],
      p95: [12.5, 12.3, 12.3, 12.4, 12.7, 12.6, 12.5, 12.5],
      p99: [12.9, 12.9, 12.9, 12.9, 13.0, 12.9, 12.9, 12.9],
    },
  };

  function figureSplit(root) {
    var state = { view: 'one', metric: 'ttft', stat: 'p95' };
    var controls = root.querySelector('.viz-controls');
    var plot = root.querySelector('.viz-plot');
    clear(controls);

    segmented(
      controls,
      'Frontend',
      [
        { value: 'one', label: 'Stock' },
        { value: 'split', label: 'Patched' },
      ],
      state.view,
      function (v) {
        state.view = v;
        render();
      }
    );
    segmented(
      controls,
      'Metric',
      [
        { value: 'ttft', label: 'TTFT' },
        { value: 'itl', label: 'ITL' },
      ],
      state.metric,
      function (v) {
        state.metric = v;
        render();
      }
    );
    segmented(
      controls,
      'Stat',
      ['avg', 'p50', 'p95', 'p99'].map(function (s) {
        return { value: s, label: s };
      }),
      state.stat,
      function (v) {
        state.stat = v;
        render();
      }
    );

    var bars = [];
    var built = 0;
    var tip;

    function render() {
      var W = plotWidth(plot);
      if (W !== built) {
        build(W);
        built = W;
      }
      update(W);
    }

    var H = 250;
    var M = { top: 34, right: 6, bottom: 46, left: 40 };
    var gap = 14;
    var nodes = {};

    function slotX(W, i) {
      var inner = W - M.left - M.right - gap;
      var slot = inner / BUCKETS.length;
      return M.left + slot * i + (i &gt; 0 ? gap : 0) + slot / 2;
    }

    function build(W) {
      clear(plot);
      bars = [];
      var root = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, role: 'img' }, plot);
      nodes.svg = root;
      nodes.grid = svg('g', {}, root);
      var base = H - M.bottom;
      svg('line', { class: 'viz-axis', x1: M.left, x2: W - M.right, y1: base, y2: base }, root);
      nodes.unit = svg('text', { x: M.left - 34, y: 12 }, root);
      var slot = (W - M.left - M.right - gap) / BUCKETS.length;
      var barW = Math.min(24, slot * 0.55);

      BUCKETS.forEach(function (name, i) {
        var cx = slotX(W, i);
        var g = svg('g', {}, root);
        var hit = svg(
          'rect',
          { class: 'viz-hit', x: cx - slot / 2, y: M.top, width: slot, height: base - M.top, tabindex: 0 },
          g
        );
        var bar = svg(
          'path',
          { class: 'viz-bar' + (i === 0 ? ' viz-bar-all' : '') + (REQUESTS[i] &lt; 30 ? ' viz-bar-thin' : '') },
          g
        );
        var unknown = svg(
          'rect',
          { class: 'viz-unknown', x: cx - barW / 2, width: barW, rx: 4, y: base - 70, height: 70 },
          g
        );
        var mark = svg('text', { x: cx, y: base - 30, 'text-anchor': 'middle' }, g);
        mark.textContent = '?';
        var value = svg('text', { class: 'viz-value', x: cx, 'text-anchor': 'middle' }, g);
        var label = svg('text', { x: cx, y: base + 16, 'text-anchor': 'middle' }, g);
        label.textContent = SHORT[i];
        bars.push({ cx: cx, w: barW, bar: bar, hit: hit, unknown: unknown, mark: mark, value: value });

        function show() {
          var m = METRICS[state.metric];
          var hidden = state.view === 'one' &amp;&amp; i &gt; 0;
          var top = hidden ? base - 70 : bars[i].top;
          tip.show(
            cx,
            top,
            hidden ? 'not exported' : m.digits(m[state.stat][i]) + m.suffix,
            (i === 0 ? 'all requests' : name + ' tokens') + ' · ' + number.format(REQUESTS[i]) + ' requests'
          );
        }
        hit.addEventListener('pointerenter', show);
        hit.addEventListener('focus', show);
        hit.addEventListener('pointerleave', function () {
          tip.hide();
        });
        hit.addEventListener('blur', function () {
          tip.hide();
        });
      });

      var mid = (slotX(W, 1) + slotX(W, BUCKETS.length - 1)) / 2;
      var axis = svg('text', { x: mid, y: base + 36, 'text-anchor': 'middle' }, root);
      axis.textContent = 'input length (tokens)';
      tip = tooltip(plot);
    }

    function update(W) {
      var m = METRICS[state.metric];
      var values = m[state.stat];
      var max = niceMax(Math.max.apply(null, values));
      var base = H - M.bottom;
      var span = base - M.top;

      clear(nodes.grid);
      for (var t = 1; t &lt;= 4; t++) {
        var v = (max / 4) * t;
        var y = base - (v / max) * span;
        svg('line', { class: 'viz-gridline', x1: M.left, x2: W - M.right, y1: y, y2: y }, nodes.grid);
        var tick = svg('text', { x: M.left - 6, y: y + 4, 'text-anchor': 'end' }, nodes.grid);
        tick.textContent = String(parseFloat(v.toPrecision(3)));
      }
      nodes.unit.textContent = m.label + ' ' + state.stat + ', ' + m.unit;
      nodes.svg.setAttribute(
        'aria-label',
        m.label + ' ' + state.stat + (state.view === 'one' ? ' for all requests' : ' per input-length bucket')
      );

      bars.forEach(function (b, i) {
        var hidden = state.view === 'one' &amp;&amp; i &gt; 0;
        var h = hidden ? 0 : Math.max(2, (values[i] / max) * span);
        var top = base - h;
        var r = Math.min(4, h / 2);
        var x0 = b.cx - b.w / 2;
        var x1 = b.cx + b.w / 2;
        // Column with a rounded cap and a square foot on the baseline.
        b.bar.setAttribute(
          'd',
          'M' + x0 + ',' + base + 'V' + (top + r) + 'Q' + x0 + ',' + top + ' ' + (x0 + r) + ',' + top +
              'H' + (x1 - r) + 'Q' + x1 + ',' + top + ' ' + x1 + ',' + (top + r) + 'V' + base + 'Z'
        );
        b.top = top;
        b.unknown.style.display = hidden ? '' : 'none';
        b.mark.style.display = hidden ? '' : 'none';
        b.value.style.display = hidden ? 'none' : '';
        b.value.setAttribute('y', top - 6);
        b.value.textContent = m.digits(values[i]);
      });
    }

    render();
    onResize(render);
  }

  function start() {
    var figures = { 'fig-split': figureSplit };
    Object.keys(figures).forEach(function (id) {
      var node = document.getElementById(id);
      if (node) figures[id](node);
    });
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();
})();
&lt;/script&gt;
</description>
    </item>
    <item>
      <title>NVFP4 eval bench on B300</title>
      <link>https://ariffnazhan.org/articles/nvfp4-vs-bf16-accuracy-eval/</link>
      <guid>https://ariffnazhan.org/articles/nvfp4-vs-bf16-accuracy-eval/</guid>
      <pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate>
      <description>&lt;p&gt;I had B300 GPUs to work with (NVIDIA&amp;#39;s Blackwell generation), and NVIDIA publishes a 4-bit version of a model I serve:
&lt;a href=&quot;https://huggingface.co/nvidia/Gemma-4-31B-IT-NVFP4&quot;&gt;&lt;code&gt;nvidia/Gemma-4-31B-IT-NVFP4&lt;/code&gt;&lt;/a&gt;, next to the
normal &lt;a href=&quot;https://huggingface.co/google/gemma-4-31B-it&quot;&gt;&lt;code&gt;google/gemma-4-31B-it&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;On paper it is an easy choice if you have Blackwell: the NVFP4 checkpoint is about half the size
(33 GB against 62 GB) and it serves faster. The open question is quality: does the model get
dumber? So before using it, I ran an eval benchmark on both models and compared them.&lt;/p&gt;
&lt;style&gt;
/* Figures in this post */
.viz {
  --viz-series: #2a78d6;
  --viz-accent: #eb6834;
  --viz-neutral: #9ca3af;
  --viz-grid: #eef0f3;
  position: relative;
  margin: 2rem 0;
  padding: 1rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  font-size: 0.875rem;
  line-height: 1.5;
}
html.dark .viz {
  --viz-series: #3987e5;
  --viz-accent: #d95926;
  --viz-neutral: #6b7280;
  --viz-grid: #1f2937;
}
.viz .viz-title { margin: 0 0 0.5rem; font-weight: 600; }
.viz .viz-legend { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin: 0 0 0.75rem; color: var(--muted); font-size: 0.8125rem; }
.viz-key { display: inline-flex; align-items: center; gap: 0.375rem; }
.viz-key i { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--viz-series); }
.viz-key.b i { background: var(--viz-accent); }
.viz-key.sq i { border-radius: 2px; }
.viz figcaption { margin-top: 0.75rem; color: var(--muted); font-size: 0.8125rem; }
.viz-tip {
  position: absolute; z-index: 2; transform: translate(-50%, -100%);
  width: max-content; max-width: 15rem; padding: 0.25rem 0.5rem; border-radius: 0.25rem;
  background: var(--fg); color: var(--bg); font-size: 0.75rem; line-height: 1.4; pointer-events: none;
}
.viz [data-tip]:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }

/* bars */
.vb-row { display: grid; grid-template-columns: 7.5rem 1fr; gap: 0.75rem; align-items: center; padding: 0.5rem 0; }
.vb-label { line-height: 1.3; }
.vb-label span { display: block; color: var(--muted); font-size: 0.75rem; }
.vb-bars { display: grid; gap: 2px; border-left: 1px solid var(--border); }
.vb-bar { position: relative; height: 14px; width: calc((100% - 3.25rem) * var(--v)); border-radius: 0 4px 4px 0; background: var(--viz-series); }
.vb-bar.b { background: var(--viz-accent); }
.vb-bar::after { content: &quot;&quot;; position: absolute; inset: -1px 0; }
.vb-bar:hover { filter: brightness(1.12); }
.vb-bar span { position: absolute; left: 100%; top: 50%; transform: translateY(-50%); margin-left: 0.375rem; color: var(--fg); font-size: 0.75rem; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* dots on a track */
.vd-row { display: grid; grid-template-columns: 4.75rem 1fr 6.5rem; gap: 0.75rem; align-items: center; }
.vd-track { position: relative; height: 30px; margin: 0 6px; background-image: repeating-linear-gradient(to right, var(--viz-grid) 0 1px, transparent 1px 33.333%); border-right: 1px solid var(--viz-grid); }
.vd-link { position: absolute; top: 50%; height: 2px; margin-top: -1px; background: var(--viz-neutral); }
.vd-mean { position: absolute; top: 5px; bottom: 5px; width: 2px; margin-left: -1px; background: var(--viz-neutral); }
.vd-dot { position: absolute; top: 50%; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: var(--viz-series); box-shadow: 0 0 0 2px var(--bg); }
.vd-dot.b { background: var(--viz-accent); }
.vd-dot::after { content: &quot;&quot;; position: absolute; inset: -8px; border-radius: 50%; }
.vd-dot:hover { filter: brightness(1.12); }
.vd-val { color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.vd-axis { position: relative; height: 1.25rem; margin: 0 6px; color: var(--muted); font-size: 0.6875rem; font-variant-numeric: tabular-nums; }
.vd-axis span { position: absolute; top: 0.125rem; transform: translateX(-50%); }

@media (max-width: 30rem) {
  .vb-row { grid-template-columns: 5.75rem 1fr; }
  .vd-row { grid-template-columns: 4rem 1fr; }
  .vd-val { grid-column: 2; margin: -0.375rem 0 0.25rem 6px; }
}
&lt;/style&gt;

&lt;h2 id=&quot;the-experiment&quot;&gt;The experiment&lt;/h2&gt;
&lt;p&gt;Both models ran on the same machine, the same serving software and the same flags. Only the
weights changed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One node with 8 NVIDIA B300 GPUs.&lt;/li&gt;
&lt;li&gt;vLLM 0.28 behind NVIDIA Dynamo: one prefill worker and one decode worker, 4 GPUs each.&lt;/li&gt;
&lt;li&gt;65k context window.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Only one model fits at a time, so I ran every test on BF16, swapped the weights, and ran every test
again. The comparison is BF16 against the checkpoint as NVIDIA ships it.&lt;/p&gt;
&lt;h3 id=&quot;three-kinds-of-eval&quot;&gt;Three kinds of eval&lt;/h3&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Eval&lt;/th&gt;
&lt;th&gt;What it asks&lt;/th&gt;
&lt;th&gt;How it is scored&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Multi-turn function calling&lt;/td&gt;
&lt;td&gt;Can the model pick and call the right tool across a long conversation?&lt;/td&gt;
&lt;td&gt;Tool-call F1&lt;/td&gt;
&lt;td&gt;100 conversations, about 20 turns each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://huggingface.co/datasets/openai/gsm8k&quot;&gt;GSM8K&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Grade-school maths word problems&lt;/td&gt;
&lt;td&gt;The last number in the reply must match&lt;/td&gt;
&lt;td&gt;1319 questions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multilingual MMLU (&lt;a href=&quot;https://huggingface.co/datasets/openai/MMMLU&quot;&gt;&lt;code&gt;openai/MMMLU&lt;/code&gt;&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;General knowledge, multiple choice, in French, German, Spanish, Chinese and Japanese&lt;/td&gt;
&lt;td&gt;The reply must be the right letter&lt;/td&gt;
&lt;td&gt;200 per language, 1000 in total&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Function calling&lt;/strong&gt; is the one closest to real work. Each conversation has more than 40 tools and
is written in Manglish. The eval replays it turn by turn:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The user message goes in.&lt;/li&gt;
&lt;li&gt;The model replies with a tool call or with text.&lt;/li&gt;
&lt;li&gt;The reply is compared with the reference.&lt;/li&gt;
&lt;li&gt;The reference tool result is injected, because there is no real backend.&lt;/li&gt;
&lt;li&gt;The next user message goes in.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The headline number is tool-call F1: did the model call a tool when it should, and stay quiet when
it should not. The eval also reports whether it picked the right function, whether the arguments
are valid JSON, whether it invented a function, and whether the required arguments are there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GSM8K&lt;/strong&gt; asks the model to end its reply with &lt;code&gt;#### &amp;lt;number&amp;gt;&lt;/code&gt;. &lt;strong&gt;MMMLU&lt;/strong&gt; asks for a single letter.
Both are easy to score and hard to argue with.&lt;/p&gt;
&lt;p&gt;The function-calling set is synthetic: the conversations were written by LLMs for our own use case. So read its score as a comparison between the two models on the same test, not as an absolute score.&lt;/p&gt;
&lt;h3 id=&quot;telling-a-real-drop-from-noise&quot;&gt;Telling a real drop from noise&lt;/h3&gt;
&lt;p&gt;There are two situations, and they need different handling.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When the model samples, repeat.&lt;/strong&gt; The function-calling eval sends no temperature, so the model
uses its own default (temperature 1.0). The same model on the same test gave three different
scores: 0.950, 0.946, 0.944. So each model ran 3 times and I report the mean and the spread. A gap
only counts if it is clearly bigger than that spread.&lt;/p&gt;
&lt;figure class=&quot;viz&quot;&gt;
&lt;p class=&quot;viz-title&quot;&gt;Tool-call F1 of each run, 3 runs per model&lt;/p&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;BF16&lt;/div&gt;&lt;div class=&quot;vd-track&quot;&gt;&lt;i class=&quot;vd-mean&quot; style=&quot;left:56.67%&quot; data-tip=&quot;BF16 mean: 0.9468&quot;&gt;&lt;/i&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:84.17%&quot; tabindex=&quot;0&quot; data-tip=&quot;BF16, run 1: 0.9501&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:51.67%&quot; tabindex=&quot;0&quot; data-tip=&quot;BF16, run 2: 0.9462&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:34.17%&quot; tabindex=&quot;0&quot; data-tip=&quot;BF16, run 3: 0.9441&quot;&gt;&lt;/b&gt;&lt;/div&gt;&lt;div class=&quot;vd-val&quot;&gt;mean 0.947&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;NVFP4&lt;/div&gt;&lt;div class=&quot;vd-track&quot;&gt;&lt;i class=&quot;vd-mean&quot; style=&quot;left:40.83%&quot; data-tip=&quot;NVFP4 mean: 0.9449&quot;&gt;&lt;/i&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:25.00%&quot; tabindex=&quot;0&quot; data-tip=&quot;NVFP4, run 1: 0.9430&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:55.00%&quot; tabindex=&quot;0&quot; data-tip=&quot;NVFP4, run 2: 0.9466&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:42.50%&quot; tabindex=&quot;0&quot; data-tip=&quot;NVFP4, run 3: 0.9451&quot;&gt;&lt;/b&gt;&lt;/div&gt;&lt;div class=&quot;vd-val&quot;&gt;mean 0.945&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;&lt;/div&gt;&lt;div class=&quot;vd-axis&quot;&gt;&lt;span style=&quot;left:0.00%&quot;&gt;0.940&lt;/span&gt;&lt;span style=&quot;left:33.33%&quot;&gt;0.944&lt;/span&gt;&lt;span style=&quot;left:66.67%&quot;&gt;0.948&lt;/span&gt;&lt;span style=&quot;left:100.00%&quot;&gt;0.952&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figcaption&gt;Each dot is one full pass over the 100 conversations. The grey bar is the mean. The two models overlap, so the 0.002 gap between their means is noise. The axis is zoomed in: it spans only 0.012.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;strong&gt;When the model is deterministic, pair the questions.&lt;/strong&gt; GSM8K and MMMLU run at temperature 0, so
one run is enough. Here I compare question by question, and count two things: the questions only
BF16 got right, and the questions only NVFP4 got right. If the 4-bit model is no worse, the two
counts should be about equal. McNemar&amp;#39;s exact test puts a p-value on that.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Eval&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;BF16&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;NVFP4&lt;/th&gt;
&lt;th&gt;Real difference?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Function calling, tool-call F1 (3 runs each)&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0.947 ± 0.003&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0.945 ± 0.002&lt;/td&gt;
&lt;td&gt;No. The gap is smaller than the run-to-run spread.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K (1319)&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0.970&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0.971&lt;/td&gt;
&lt;td&gt;No. Only 2 questions flipped one way and 3 the other.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multilingual MMLU (1000)&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0.801&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0.779&lt;/td&gt;
&lt;td&gt;Yes. 46 questions only BF16 got right, 24 only NVFP4 got right, p = 0.012.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;figure class=&quot;viz&quot;&gt;
&lt;p class=&quot;viz-title&quot;&gt;Score on each eval, BF16 against NVFP4&lt;/p&gt;
&lt;p class=&quot;viz-legend&quot;&gt;&lt;span class=&quot;viz-key sq&quot;&gt;&lt;i&gt;&lt;/i&gt;BF16&lt;/span&gt;&lt;span class=&quot;viz-key sq b&quot;&gt;&lt;i&gt;&lt;/i&gt;NVFP4&lt;/span&gt;&lt;/p&gt;
&lt;div class=&quot;vb-row&quot;&gt;&lt;div class=&quot;vb-label&quot;&gt;Function calling&lt;span&gt;tool-call F1&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;vb-bars&quot;&gt;&lt;div class=&quot;vb-bar a&quot; style=&quot;--v:0.947&quot; tabindex=&quot;0&quot; data-tip=&quot;BF16: 0.947 ± 0.003 (runs 0.950, 0.946, 0.944)&quot;&gt;&lt;span&gt;0.947&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;vb-bar b&quot; style=&quot;--v:0.945&quot; tabindex=&quot;0&quot; data-tip=&quot;NVFP4: 0.945 ± 0.002 (runs 0.943, 0.947, 0.945)&quot;&gt;&lt;span&gt;0.945&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vb-row&quot;&gt;&lt;div class=&quot;vb-label&quot;&gt;GSM8K&lt;span&gt;1319 questions&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;vb-bars&quot;&gt;&lt;div class=&quot;vb-bar a&quot; style=&quot;--v:0.97&quot; tabindex=&quot;0&quot; data-tip=&quot;BF16: 0.970 (1280 of 1319)&quot;&gt;&lt;span&gt;0.970&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;vb-bar b&quot; style=&quot;--v:0.971&quot; tabindex=&quot;0&quot; data-tip=&quot;NVFP4: 0.971 (1281 of 1319)&quot;&gt;&lt;span&gt;0.971&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vb-row&quot;&gt;&lt;div class=&quot;vb-label&quot;&gt;Multilingual MMLU&lt;span&gt;1000 questions&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;vb-bars&quot;&gt;&lt;div class=&quot;vb-bar a&quot; style=&quot;--v:0.801&quot; tabindex=&quot;0&quot; data-tip=&quot;BF16: 0.801 (801 of 1000)&quot;&gt;&lt;span&gt;0.801&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;vb-bar b&quot; style=&quot;--v:0.779&quot; tabindex=&quot;0&quot; data-tip=&quot;NVFP4: 0.779 (779 of 1000)&quot;&gt;&lt;span&gt;0.779&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figcaption&gt;Bars start at 0 and a full-width bar would be 1.0. Only the multilingual MMLU pair differs by more than noise.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure class=&quot;viz&quot;&gt;
&lt;p class=&quot;viz-title&quot;&gt;Multilingual MMLU by language, 200 questions each&lt;/p&gt;
&lt;p class=&quot;viz-legend&quot;&gt;&lt;span class=&quot;viz-key&quot;&gt;&lt;i&gt;&lt;/i&gt;BF16&lt;/span&gt;&lt;span class=&quot;viz-key b&quot;&gt;&lt;i&gt;&lt;/i&gt;NVFP4&lt;/span&gt;&lt;/p&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;French&lt;/div&gt;&lt;div class=&quot;vd-track&quot;&gt;&lt;i class=&quot;vd-link&quot; style=&quot;left:76.67%;width:3.33%&quot;&gt;&lt;/i&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:76.67%&quot; tabindex=&quot;0&quot; data-tip=&quot;French, BF16: 0.815&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:80.00%&quot; tabindex=&quot;0&quot; data-tip=&quot;French, NVFP4: 0.820&quot;&gt;&lt;/b&gt;&lt;/div&gt;&lt;div class=&quot;vd-val&quot;&gt;0.815 → 0.820&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;German&lt;/div&gt;&lt;div class=&quot;vd-track&quot;&gt;&lt;i class=&quot;vd-link&quot; style=&quot;left:66.67%;width:13.33%&quot;&gt;&lt;/i&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:80.00%&quot; tabindex=&quot;0&quot; data-tip=&quot;German, BF16: 0.820&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:66.67%&quot; tabindex=&quot;0&quot; data-tip=&quot;German, NVFP4: 0.800&quot;&gt;&lt;/b&gt;&lt;/div&gt;&lt;div class=&quot;vd-val&quot;&gt;0.820 → 0.800&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;Spanish&lt;/div&gt;&lt;div class=&quot;vd-track&quot;&gt;&lt;i class=&quot;vd-link&quot; style=&quot;left:43.33%;width:20.00%&quot;&gt;&lt;/i&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:63.33%&quot; tabindex=&quot;0&quot; data-tip=&quot;Spanish, BF16: 0.795&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:43.33%&quot; tabindex=&quot;0&quot; data-tip=&quot;Spanish, NVFP4: 0.765&quot;&gt;&lt;/b&gt;&lt;/div&gt;&lt;div class=&quot;vd-val&quot;&gt;0.795 → 0.765&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;Chinese&lt;/div&gt;&lt;div class=&quot;vd-track&quot;&gt;&lt;i class=&quot;vd-link&quot; style=&quot;left:40.00%;width:16.67%&quot;&gt;&lt;/i&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:56.67%&quot; tabindex=&quot;0&quot; data-tip=&quot;Chinese, BF16: 0.785&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:40.00%&quot; tabindex=&quot;0&quot; data-tip=&quot;Chinese, NVFP4: 0.760&quot;&gt;&lt;/b&gt;&lt;/div&gt;&lt;div class=&quot;vd-val&quot;&gt;0.785 → 0.760&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;Japanese&lt;/div&gt;&lt;div class=&quot;vd-track&quot;&gt;&lt;i class=&quot;vd-link&quot; style=&quot;left:33.33%;width:26.67%&quot;&gt;&lt;/i&gt;&lt;b class=&quot;vd-dot a&quot; style=&quot;left:60.00%&quot; tabindex=&quot;0&quot; data-tip=&quot;Japanese, BF16: 0.790&quot;&gt;&lt;/b&gt;&lt;b class=&quot;vd-dot b&quot; style=&quot;left:33.33%&quot; tabindex=&quot;0&quot; data-tip=&quot;Japanese, NVFP4: 0.750&quot;&gt;&lt;/b&gt;&lt;/div&gt;&lt;div class=&quot;vd-val&quot;&gt;0.790 → 0.750&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;vd-row&quot;&gt;&lt;div&gt;&lt;/div&gt;&lt;div class=&quot;vd-axis&quot;&gt;&lt;span style=&quot;left:0.00%&quot;&gt;0.70&lt;/span&gt;&lt;span style=&quot;left:33.33%&quot;&gt;0.75&lt;/span&gt;&lt;span style=&quot;left:66.67%&quot;&gt;0.80&lt;/span&gt;&lt;span style=&quot;left:100.00%&quot;&gt;0.85&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figcaption&gt;The line between the dots is what the 4-bit model lost or gained. The axis is zoomed in, from 0.70 to 0.85.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The 4-bit model holds on tool calling and on maths. It loses about 2 points of general knowledge
across the five languages. The loss sits mostly in Spanish, Chinese and Japanese. French did not
drop at all.&lt;/p&gt;
&lt;p&gt;As a side effect, NVFP4 was faster on the same work: 29% more output tokens per second on the GSM8K
run, at 32 requests at a time. That number comes from an accuracy run, not from a speed benchmark.&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;NVFP4 keeps the scores that matter for my use: tool calling and maths are the same as BF16, within
noise. The cost is about 2 points of multilingual general knowledge. For that, the model is half
the size and faster, so on Blackwell it is worth using. If your work leans on general knowledge in
other languages, run the eval on your own data first.&lt;/p&gt;
&lt;script&gt;
(function () {
  // one tooltip for every [data-tip] mark, on hover and on keyboard focus
  var tip = document.createElement('div');
  tip.className = 'viz-tip';
  tip.setAttribute('role', 'tooltip');
  tip.hidden = true;
  function show(el) {
    var fig = el.closest('.viz');
    if (!fig) return;
    fig.appendChild(tip);
    tip.textContent = el.getAttribute('data-tip');
    tip.hidden = false;
    var f = fig.getBoundingClientRect(), r = el.getBoundingClientRect();
    var x = (el.classList.contains('vb-bar') ? r.right : r.left + r.width / 2) - f.left;
    var half = tip.offsetWidth / 2;
    x = Math.max(8 + half, Math.min(f.width - 8 - half, x));
    tip.style.left = x + 'px';
    tip.style.top = (r.top - f.top - 8) + 'px';
  }
  function hide() { tip.hidden = true; }
  function mark(e) { return e.target.closest ? e.target.closest('.viz [data-tip]') : null; }
  document.addEventListener('pointerover', function (e) { var el = mark(e); if (el) show(el); else hide(); });
  document.addEventListener('focusin', function (e) { var el = mark(e); if (el) show(el); });
  document.addEventListener('focusout', hide);

})();
&lt;/script&gt;
</description>
    </item>
  </channel>
</rss>
