Thursday, October 29, 2026

Two Workloads, One Silicon Budget

When stripping away the marketing gloss, an AI Factory is just an assembly line turning raw compute and power into mathematical weights. But inside that factory, a clear operational reality surfaces: infrastructure teams are dealing with two completely different animals in AI Training and AI Inference.

Training is the brute-force phase. A massive, uneducated neural network is fed terabytes of data over weeks on end. The primary goal here is throughput and convergence speed by getting the model to learn as fast as possible without the hardware stalling. Developing a custom model that understands a specific domain, proprietary code, or sensitive medical data requires training.

Inference is the real-world sprint. This is the model out in the wild, answering user queries, generating text tokens, or making split-second decisions. The goal completely flips: the objective is to minimize latency and keep the cost-per-token as low as humanly possible. This is essential because a model sitting on a disk does not generate value or solve problems; it must be actively served to users.

Historically, teams built separate silos for these jobs. But as hardware lead times stretch out and silicon costs skyrocket, leadership eventually walks into the server room and asks the golden question: "Why can't this organization just run both on the exact same cluster?"

It sounds excellent on paper. The cluster handles massive training runs at night or between major releases, and operators slice it up for real-time inference during peak user hours. Let's walk through what it actually takes to architect for both, and why trying to merge them on the same physical fabric turns into a dogfight over resources very quickly.

Architectural Split Personalities

The architectural blueprint of a shared cluster reveals a deep conflict across every layer of the stack, driven by the fundamentally split personalities of these two workloads. This tension begins at the compute layer. Training demands absolute homogeneity across a dense footprint of premium silicon, such as H100 or Blackwell accelerators. Because distributed training relies on perfectly synchronized execution loops, any minor compute skew, where one GPU finishes its calculation slightly later than its neighbor, causes the entire cluster to hitch, wasting millions of dollars in idle time. Inference, by contrast, operates on a flexible spectrum. While large language models can be served on top-tier chips, inference workloads are frequently deployed across mid-tier GPUs, specialized NPUs, standard CPUs, or even edge devices depending on the specific latency service-level agreements (SLAs) required by the application.

Moving down to the network layer, the data flow patterns diverge completely. Training clusters are optimized for massive East-West synchronization traffic, where GPUs continuously pass gradients back and forth using collective operations like AllReduce. This requires ultra-high bandwidth, lossless interconnects like InfiniBand or dedicated physical NVLink fabrics to handle the sheer volume of intra-cluster communication. Inference clusters invert this model entirely, prioritizing North-South client traffic. Because user requests are highly atomic and independent of one another, inference handles data streams using standard Layer 3 RoCEv2 Ethernet fabrics designed to route incoming public traffic down to individual, isolated worker nodes without the need for inter-node synchronization.

This divergence is equally severe when looking at memory bottlenecks and storage architectures. The memory profile of a training run is fixed and predictable, requiring massive VRAM capacity to simultaneously hold active model weights, gradients, and optimizer states. To keep these processors fed, the storage architecture relies on parallel file systems tuned for crushing sequential block throughput, ensuring the cluster can dump massive checkpoint files to disk quickly before the GPUs stall. Inference memory bandwidth, however, is intensely volatile and bound by dynamic Key-Value (KV) cache management during the token generation phase. To prevent long user context windows from choking the system, inference discards parallel file systems in favor of fast local NVMe storage, optimizing for ultra-low latency random access and memory extensions to handle aggressive KV cache offloading.

Finally, the utilization profiles dictate completely different geographic and deployment strategies. Training runs operate at continuous peak saturation, typically maintaining a relentless 85% to 95% utilization rate through rigorous batch-size tuning. Because training is latency-insensitive, operators can push these clusters out to remote, high-power regions where land, cooling, and electricity are cheapest. Inference represents the exact opposite reality, showing a highly volatile and elastic utilization profile that often hovers under 40% as it scales up and down to absorb real-time traffic bursts. Because round-trip time (RTT) directly impacts the user experience, inference cannot sit in a remote desert; it must be deployed out at the edge or in metropolitan cloud on-ramps as close to the target user populations as possible.

Architectural Comparison Matrix

Architectural Dimension AI Training Clusters AI Inference Clusters
Primary Goal Maximize Throughput & Convergence Speed Minimize Latency & Cost-per-Token
Compute Hardware Dense, Premium GPUs/TPUs; absolute homogeneity to avoid compute skew. Flexible Spectrum: Premium/mid-tier GPUs, specialized NPUs, standard CPUs, or Edge chips.
Network Fabric Ultra-High Bandwidth, Lossless Interconnects (InfiniBand/NVLink). Heavy East-West traffic. Commodity Layer 3 RoCEv2 Ethernet. Optimized for North-South client traffic.
Memory Focus High VRAM Capacity & Predictable Access (Weights, gradients, optimizer states). Dynamic KV Cache Management. Constrained by token generation bandwidth.
Storage Architecture Parallel File Systems optimized for high sequential block throughput (Fast Checkpointing). Fast Local NVMe Storage for ultra-low latency random access and KV cache offloading.
Placement & Power Remote, High-Power, Cheap Regions (Electricity and land cost optimization). Metropolitan & Edge Deployment (Latency-sensitive; close to user populations).
GPU Utilization Profile Continuous Peak Saturation (85–95% via batch size tuning). Highly Volatile / Elastic (Often under 40% due to real-time traffic bursts).
Agentic / Reasoning Impact Minimal effect; training remains a batch operation. High Impact. Long reasoning steps turn stateless calls into stateful sessions.

Shared Cluster Reality Where the Wheels Detach

Attempting to squeeze both workloads into a single shared cluster is an attempt to merge a predictable, high-throughput steamroller with an erratic, low-latency sports car. Operators will run into immediate technical walls:

The Blast Radius of a Training Checkpoint

Imagine an 8-node Blackwell training job running alongside a pool of inference containers serving live customers. The training job hits its interval and begins saving a multi-terabyte model checkpoint to the storage fabric. If storage paths aren't rigorously isolated, that massive sequential write will saturate the shared I/O pipes.

Suddenly, the inference containers, which need fast local or shared NVMe access to reload context or pull weights, experience a spike in random access latency. In the inference world, a 100ms spike means dropped requests and broken SLAs.

Network Congestion and the AllReduce Hammer

Training relies on synchronized network loops. When an AllReduce operation kicks off, it floods the fabric, demanding every drop of bandwidth to pass gradients across the nodes. If inference traffic shares that same physical network switch fabric without strict Quality of Service (QoS) or hardware-level partitioning, North-South client packets get queued behind training packets.

For inference, network jitter translates directly to inter-token latency. A user sits waiting as the LLM response stutters out one agonizing word at a time because a background training job is hogging the backplane.

The Memory Tug-of-War and Agentic Session Stateful-ness

The game gets even harder when factoring in modern agentic or reasoning workloads. Reasoning models do not just take a stateless prompt and spit out a quick answer; they engage in long, multi-step internal monologues. This shifts inference from stateless calls into highly stateful sessions.

These sessions require deep context reloading and session affinity. GPUs must keep massive KV caches alive in VRAM across long user interactions. If a cluster orchestrator tries to aggressively reclaim or shift VRAM allocation back to a training job during an inference traffic lull, it will wipe out those cached user sessions. The next time the agent takes a reasoning step, it faces a massive context reload penalty, completely destroying the real-time user experience.

Can Both Coexist?

The short answer is theoretically yes but trust is not an architecture; evidence and isolation are required.

If sharing the same silicon footprint is mandatory, an organization cannot simply throw Kubernetes or Slurm at the problem and hope the default schedulers figure it out. Architects must design absolute boundary lines across every architectural dimension:

  • Network: Operators must physically or logically isolate East-West training traffic (InfiniBand/NVLink) from North-South inference traffic (Ethernet), utilizing strict SR-IOV or hardware rate-limiting.
  • Compute: Schedulers need hard multi-instance GPU (MIG) partitioning or strict node-level isolation to ensure that an erratic inference spike does not cause compute skew on a training job, and conversely, that a training run does not starve an inference node's CPU/GPU worker threads.
  • Storage: Infrastructure teams must decouple sequential parallel file systems from local, low-latency NVMe cache layers so that a checkpoint dump never stomps on a live user's KV cache.

At the end of the day, running a unified cluster is a classic tradeoff between capital efficiency and operational sanity. It saves capital on hardware provisioning, but the price is paid in architectural complexity. Without building hard fences between the training beast and the inference sprint, they will pull each other down, leaving administrators with stalled training runs and users staring at timeouts.

Saturday, October 24, 2026

When Every Packet Stops: Fixing e1000e Hangs

I sat down to do some work on one of my lab hosts and found I could no longer ssh to it. I went to the console and the system was up and responsive, but I could not ping anything: not the gateway, not another host on the same subnet, nothing. On top of that, the console was scrolling a steady stream of kernel messages that kept repeating every couple of seconds.

A host that is alive but completely cut off from the network turns into guesswork very quickly. Is it the switch port, the cable, the DHCP lease, or the host itself? In this case it turned out to be the network card driver, and the fix was a one line change. Let's walk through what I saw, what the messages mean, and how to make the fix survive a reboot.

The Error Messages

This is what was streaming across the console. The host is named asus2, the interface is eno1, and the messages repeat roughly every two seconds:

Oct 22 15:38:24 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2b4c0>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:26 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2bcc0>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:28 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2c480>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:30 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2cc40>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:32 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2d401>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:34 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2dc00>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:36 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2e3c0>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:38 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2eb80>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10> Oct 22 15:38:40 asus2 kernel: e1000e 0000:00:1f.6 eno1: Detected Hardware Unit Hang:#012 TDH <da>#012 TDT <5>#012 next_to_use <5>#012 next_to_clean <d9>#012buffer_info[next_to_clean]:#012 time_stamp <1d38993a8>#012 next_to_watch <da>#012 jiffies <1d3a2f340>#012 next_to_watch.status <0>#012MAC Status <80083>#012PHY Status <796d>#012PHY 1000BASE-T Status <3800>#012PHY Extended Status <3000>#012PCI Status <10>

The Error Messages Meaning

The first thing to pull out of those lines is the device. e1000e 0000:00:1f.6 eno1 is the Intel network controller built into the motherboard, handled by the e1000e driver, and eno1 is the interface name the system gave it. The messages come from the kernel, so this is the driver talking, not an application.

"Detected Hardware Unit Hang" is the driver telling us it queued packets for transmit and the network card never finished sending them. The transmit descriptor ring has a head and a tail. The tail (TDT, here <5>) is where the driver is adding new work and the head (TDH, here <da>) is where the hardware has gotten to. Healthy hardware keeps chasing the tail. Here the head never moves: next_to_clean is <d9>, next_to_watch is <da>, and the time_stamp stays at <1d38993a8> in every message while jiffies keeps counting up. In other words, the same descriptor has been sitting there for a long time.

The other detail that matters is that the link itself looks healthy. The MAC Status of <80083> and the PHY Status of <796d> along with PHY 1000BASE-T Status tell us the link is up at gigabit speed. Only the transmit path is wedged. That fits my symptoms: the host was up and the console was alive, but nothing I tried to send ever left the machine, which is why ssh to the host failed and ping got no replies. The driver repeats the message every two seconds until it manages to reset the adapter, and on this host that was not enough to recover.

Note: I cannot prove a root cause from this log alone. What I can say is that this pattern is a well documented e1000e problem, and the next section covers what usually causes it.

Common Triggers

There is no single cause, but these are the ones that come up most often with the e1000e driver on Intel onboard controllers:

  • TCP segmentation offload settings (TSO, GSO and GRO). Bugs in how the driver handles offloaded segmentation are one of the most commonly reported triggers.
  • Power management on the card or the PCIe link, such as Energy Efficient Ethernet (EEE) or Active State Power Management (ASPM). EEE lets the card and the switch port drop into a low power idle state, and on some controllers the transmit path does not wake back up cleanly.
  • Less often, an old driver, kernel or BIOS and Intel Management Engine firmware, or a failing card or cable.

It is also worth thinking about what else depends on the port. On a host that runs virtual machines, a hung port stalls the networking of everything behind it, not just the host.  This particular setup does host multiple virtual machines across a bridge port.

Resolution

Before changing anything it helps to see what is currently enabled. We can check the driver details and the current offload and EEE settings with:

ethtool -i eno1 ethtool -k eno1 | grep -E 'tcp-segmentation|generic-segmentation|generic-receive' ethtool --show-eee eno1

The usual first suspect is the offload settings, which can be turned off on the running system with:

sudo ethtool -K eno1 tso off gso off gro off

In my case I did not need that. Turning off Energy Efficient Ethernet is what made my errors go away:

sudo ethtool --set-eee eno1 eee off

The moment I ran it, the stream of messages stopped. That identifies the culprit: with EEE on, the card and the switch port negotiate low power idle and this Intel controller sometimes fails to wake its transmit path, which is the stall the log was describing. Since EEE was the trigger, there was no reason to change the TSO, GSO or GRO settings, so I left them alone.

We can verify the change took effect by asking for the EEE state again and looking at the kernel log:

ethtool --show-eee eno1 sudo journalctl -k -b | grep -i 'e1000e'

The EEE status should now read disabled, and no new "Detected Hardware Unit Hang" lines should show up. At this point the console should be quiet again, and the host should be able to reach the network.

Persistent The Fix

There is one catch. ethtool --set-eee only changes the running state of the card, so the setting is lost on reboot and the hangs would come right back. Since I did not want to find myself at the console again, let's make the fix apply automatically. There are two ways to do it.

The first method is a systemd .link file. It is declarative, and the setting is applied when udev configures the interface. The EnergyEfficientEthernet= option needs systemd 248 or newer. Ubuntu 24.04 ships systemd 255, and we can check what a host has with systemctl --version.

sudo tee /etc/systemd/network/10-eno1-eee.link <<'EOF' [Match] OriginalName=eno1 [Link] EnergyEfficientEthernet=false EOF sudo update-initramfs -u

The update-initramfs -u step makes sure the link file is also present in the initramfs so it is picked up early in the boot. Then reboot the host and check the result:

ethtool --show-eee eno1

If EEE reports disabled after the reboot, the link file did its job. If it shows enabled again, move on to the second option.

Option 2: A oneshot systemd service

While the link file is the cleaner choice in my opinion, a small service that runs the same ethtool command at boot works everywhere and is easy to reason about. It waits for the interface to exist and then turns EEE off:

sudo tee /etc/systemd/system/eno1-eee-off.service <<'EOF' [Unit] Description=Disable EEE on eno1 After=network-pre.target sys-subsystem-net-devices-eno1.device Wants=sys-subsystem-net-devices-eno1.device [Service] Type=oneshot ExecStart=/usr/sbin/ethtool --set-eee eno1 eee off [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now eno1-eee-off.service

If the service reports that it cannot find the binary, confirm where ethtool lives with command -v ethtool and adjust the path in ExecStart. Either way, the check afterward is the same.

Validating After a Reboot

With either option in place, reboot the host and confirm the setting was applied without any manual help. We can verify this by checking the EEE state and then watching the kernel log for any stray hangs:

ethtool --show-eee eno1 sudo journalctl -k -b | grep -i 'e1000e'

If everything looks good the EEE status will read disabled and there will be no "Detected Hardware Unit Hang" lines in the log for the current boot. At this point I would feel pretty confident the node can be rebooted at any time and come back up with a working network.

Keep in mind EEE only saves a few watts of idle power, so leaving it off is essentially free on a lab host. If the hangs ever return with EEE disabled, the next things I would try are the TSO, GSO and GRO offload settings from earlier, the kernel parameter pcie_aspm=off, and a kernel or BIOS and Intel Management Engine firmware update. As a last resort a different network card, such as a USB or add-in adapter, takes the onboard controller out of the picture entirely.

Hopefully this write-up saves someone a trip to the console the next time an Intel e1000e interface hangs and the host drops off the network.

Wednesday, October 21, 2026

Health and Resilience in the AI Factory

Scale Creates Problems

An AI Factory is not a data center that happens to run AI workloads but rather a purpose-built machine for turning power, silicon, and time into trained and served models. At the scale modern training and inference demand, that machine is built from tens of thousands of GPUs, the NVLink and network fabric stitching them together, the racks and power/cooling domains housing them, and the orchestration layers, whether Kubernetes or Slurm, scheduling work across all of it.

At that scale, hardware failure is not an edge case. It is a certainty, continuously, somewhere in the fleet. A single bad GPU, a marginal NVLink connection, a flaky network card, or a rack-level power event can silently degrade a multi-thousand-GPU training job for hours before anyone notices, or quietly return a "repaired" node to service that fails again a day later. The cost of that failure isn't measured in a single server outage; it's measured in wasted GPU-hours across an entire job, in engineers manually sifting through logs to find one bad node in a haystack of thousands, and in capacity that sits offline longer than it needs to because nobody could pinpoint what was actually wrong.

The components described here exist to answer one question, asked continuously and at every layer of the stack: is this hardware and this workload healthy, and if not, what do we do about it automatically, and fast?

Two Layers One Continuous Problem

It helps to think of the AI Factory in two layers, because failure and health look different depending on where we're standing.

The infrastructure layer is the physical and out-of-band reality: GPUs, NVLink and network fabric, racks, power domains, firmware, and the signals that come from the hardware itself independent of whatever is running on top of it. A GPU can be failing in ways that are invisible to the workload until it's too late: a rising XID error rate, or a marginal fabric link. The infrastructure layer is where those signals are collected before they become a training job's problem.

The platform layer is where work actually happens: Kubernetes clusters, Slurm clusters, and the training and inference jobs scheduled onto them. Health here means something more specific than "is the hardware fine." It's "can this cluster actually run the job we're about to hand it," and when a job does fail, "was that a hardware problem, a software problem, or something in between, and can we recover without starting from zero."

The value of the components below is that they don't treat these as separate problems solved by separate teams working from separate data. They form a continuous service loop that validates, monitors, detects, diagnoses, recovers, repairs, and re-validates across both layers, so a signal caught at the infrastructure layer can prevent a failure at the platform layer, and a failure surfaced at the platform layer can be traced back to its root cause in the infrastructure layer in seconds instead of hours.

Certify the Hardware Before Workloads Even Run

The most expensive failure is the one that happens after we've already committed hours or days of GPU time to it. NVIDIA Health Validation exists to answer the most basic question before that commitment is made: "is this node healthy right now?" It runs at the node layer and applies whether the question is being asked before dispatching a single job or before releasing a whole batch of hardware back into a shared pool, such as after a repair, after a tenant workload completes, or at initial bring-up. Nodes that come back critical get excluded before they ever touch a workload, which turns a failure that would otherwise show up mid-job into a non-event.

For workloads running at real scale, whether multi-day or multi-week training runs across large Kubernetes clusters, validating individual nodes isn't enough. The fabric connecting them matters just as much as the GPUs themselves. That's what the NVIDIA Cluster Readiness Engine(NVCRE) is for: it certifies that a Kubernetes cluster as a whole can actually handle real AI workloads, not just that each node individually reports healthy, before anyone commits days of training time to finding out the hard way.

Both of these plug directly into how new capacity comes online. When a new cluster arrives from hardware delivery, it goes through a validated bring-up sequence before it reaches production: Health Validation runs per node, CRE certifies the cluster as a whole, and any failures are handled automatically before that capacity is ever released for real work. The same sequence runs again every time capacity is being prepared for a workload run, just finished a workload run or the node came back online after a repair. Nothing goes back into rotation on trust but instead goes back in on evidence.

Monitoring Workload and Hardware Health

Once a workload is live, health and resilience doesn't stop; it just changes shape, and it has to happen at both layers simultaneously.

At the platform layer, NVSentinel watches Kubernetes clusters continuously to detect and isolate unhealthy GPU nodes before they can quietly degrade a running job or accumulate in the cluster unnoticed. It’s the mechanism that keeps a K8s cluster from slowly filling up with marginal nodes that nobody flagged. It does this by leveraging real-time monitoring to collect hardware and system telemetry directly from NVIDIA DCGM, syslogs, and cloud provider maintenance events, ensuring that silent GPU or network faults are caught the moment they happen. Once a failure is detected, the platform automatically protects ongoing workloads by cordoning the affected node to block new scheduling requests while safely draining active pods before corruption can spread to other jobs. Finally, NVSentinel initiates automated remediation, orchestrating targeted GPU resets or full machine reboots, and leverages an integrated NVCRE provider to trigger the NVIDIA Cluster Readiness Engine (NVCRE), automatically driving real distributed workloads, such as NCCL communication tests and DCGM diagnostics, to run through a comprehensive post-remediation certification check before safely returning the healthy node to service.

Tying all of this together across the fleet is Fleet Intelligence Agent, which answers "how do I track GPU fleet health, availability, integrity, and SLA compliance across my entire fleet, right now?" It maintains a living inventory, surfaces alerts as health status changes, and gives both infrastructure and platform teams a shared, real-time picture of fleet state through a dashboard and API, including recommended remediation actions, reporting, and built-in AI integrations. It's the continuous-operations backbone of what the fleet's health looks like at any point in time, including the moment something went wrong.

From Failure to Diagnosis in Seconds

Even with validation and continuous monitoring, failures happen. What separates a resilient AI Factory from a fragile one is how fast the cause gets identified and how much of the response is automatic.

NVIDIA Attribution exists for exactly this moment: "why did this Slurm or Kubernetes workload fail?" When a workload fails, Attribution attributes that failure to the specific responsible node and the specific signal involved within seconds, not hours of manual log correlation across a cluster with thousands of nodes. That verdict isn't just informational; it's structured evidence. It can be shared directly when escalating a hardware issue, and it feeds directly into automated node-exclusion decisions so the next attempt at the workload doesn't land back on the same bad hardware.

Recovery takes this one step further. NVIDIA Recovery answers "how do I automate recovery when my job fails?" When a training anomaly occurs, such as a hang or a crash, NVIDIA Recovery detects it and restarts the workload automatically from the last checkpoint, rather than requiring a human to notice, diagnose, and manually intervene. When Attribution is deployed alongside it, NVIDIA Recovery consults Attribution's node-exclusion logic before restarting, so the workload resumes only on hardware that's actually been verified healthy, and the verdict that drove that decision is surfaced back to the end user.

This is the difference between a training run that silently loses a day of progress because nobody caught a hang until morning, and one that self-heals within minutes because attribution and recovery are working together instead of as isolated tools.

Together is Better

At the massive scale of an AI Factory, treating infrastructure and scheduling platforms as isolated systems is no longer viable; instead, bringing these layers together into a unified loop is the only way to ensure operational resilience. Rather than forcing separate teams to operate in silos with fragmented data, specialized tools like NVIDIA Health Validation, NVCRE, NVSentinel, NVIDIA Attribution, and NVIDIA Recovery cooperate continuously to form an unbroken service loop. By seamlessly cross-correlating real-time physical hardware signals with application workload behaviors, this collaborative ecosystem can instantly trace a platform failure to its physical root cause, block or remediate faulty components, and automatically restore progress. True resilience proves that together is better, transforming a chaotic avalanche of isolated cluster logs into a collective, automated defense machine that keeps multi-node AI workloads running without missing a beat.

Tuesday, October 20, 2026

Walking Through NVIDIA Attribution

When a GPU job dies in the middle of the night, the first question is almost never "what happened?" It is "whose problem is this?" Is the node bad and does it need to be pulled out of rotation, or did someone submit a job that asked for more memory than the GPU has? NVIDIA Attribution is a service built to answer that question. It starts with a described workload, hand it the evidence collected (log lines from the nodes the job ran on, for example), and it matches that evidence against a ruleset to produce a verdict: the root symptom, the outcome and the failure domain it belongs to.

In this post I am going to walk through a small end to end demo of Attribution running on a single virtual machine. We will build the Attribution Blueprint container image, push it to a registry, deploy it into a MicroK8s cluster, and then call the API by hand with a simulated failed PyTorch job. The failure here will be a CUDA out of memory error, which makes for a nice example because the right answer is that the user owns the problem and the hardware is fine. I should be upfront that the workload here is made up and so is the single log line. It is a way to see the moving pieces, not a real incident. With all that said let's get started together on our journey.

Build and Push the Blueprint Image

The first step is building the container image from the Attribution repository. The virtual machine is an arm64 system so I am passing --platform linux/arm64, and I am tagging the image for the registry that runs in my lab environment on port 5000. This is a multi-stage build. One stage uses a Node 22 image to compile the web dashboard frontend with pnpm and Vite, and the final stage is Python 3.14 on Debian trixie where uv installs the blueprint, the service app and the attribution library. The build also installs gcc just long enough to compile a few dependencies and then purges it again to keep the image small.

$ docker build --platform linux/arm64 -f blueprint/docker/Dockerfile -t registry.schmaustech.com:5000/attribution-blueprint-web:latest . [+] Building 1.4s (32/32) FINISHED docker:default => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 3.07kB 0.0s => resolve image config for docker-image://docker.io/docker/dockerfile:1.7 0.4s => CACHED docker-image://docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e 0.0s => [internal] load metadata for ghcr.io/astral-sh/uv:0.10.12 0.4s => [internal] load metadata for docker.io/library/node:22-bookworm-slim 0.6s => [internal] load metadata for docker.io/library/python:3.14.6-slim-trixie 0.7s => [internal] load build context 0.0s => => transferring context: 60.10kB 0.0s => [web-build 1/4] FROM docker.io/library/node:22-bookworm-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 0.0s => FROM ghcr.io/astral-sh/uv:0.10.12@sha256:72ab0aeb448090480ccabb99fb5f52b0dc3c71923bffb5e2e26517a1c27b7fec 0.0s => [stage-1 1/19] FROM docker.io/library/python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 0.0s => CACHED [stage-1 2/19] COPY --from=ghcr.io/astral-sh/uv:0.10.12 /uv /uvx /bin/ 0.0s => CACHED [stage-1 3/19] WORKDIR /opt/blueprint 0.0s => CACHED [stage-1 4/19] COPY blueprint/pyproject.toml blueprint/uv.lock ./ 0.0s => CACHED [stage-1 5/19] COPY blueprint/src/ ./src/ 0.0s => CACHED [web-build 2/4] WORKDIR /frontend 0.0s => CACHED [web-build 3/4] COPY blueprint/src/attribution_blueprint/webui/frontend/ ./ 0.0s => CACHED [web-build 4/4] RUN corepack enable && pnpm install --frozen-lockfile && pnpm exec tsc -b && pnpm exec vite build --outDir dist 0.0s => CACHED [stage-1 6/19] COPY --from=web-build /frontend/dist ./src/attribution_blueprint/webui/static/ 0.0s => CACHED [stage-1 7/19] COPY service/service-app/nvidia_attribution_client/ /opt/service/service-app/nvidia_attribution_client/ 0.0s => CACHED [stage-1 8/19] RUN uv sync --extra web --no-dev --no-editable --frozen 0.0s => CACHED [stage-1 9/19] WORKDIR /opt/attribution-service 0.0s => CACHED [stage-1 10/19] COPY service/attribution-lib/ /opt/attribution-lib/ 0.0s => CACHED [stage-1 11/19] COPY service/service-app/pyproject.toml service/service-app/uv.lock ./ 0.0s => CACHED [stage-1 12/19] COPY service/service-app/_build_version.py ./ 0.0s => CACHED [stage-1 13/19] COPY service/service-app/README.md service/service-app/LICENSE ./ 0.0s => CACHED [stage-1 14/19] COPY service/service-app/app/ ./app/ 0.0s => CACHED [stage-1 15/19] COPY service/service-app/nvidia_attribution_client/ ./nvidia_attribution_client/ 0.0s => CACHED [stage-1 16/19] RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && uv sync --no-dev --no-editable --frozen && apt-get purge -y --auto-remove gcc libc6-dev && rm -rf /var/lib/ap 0.0s => CACHED [stage-1 17/19] COPY blueprint/docker/start.sh /usr/local/bin/attribution-blueprint-start 0.0s => CACHED [stage-1 18/19] RUN chmod +x /usr/local/bin/attribution-blueprint-start 0.0s => CACHED [stage-1 19/19] WORKDIR /opt/blueprint 0.0s => exporting to image 0.0s => => exporting layers 0.0s => => writing image sha256:7417910ee0d5bc1d35e8030e63996939c804eff310e63c9209131f4cc443df96 0.0s => => naming to registry.schmaustech.com:5000/attribution-blueprint-web:latest

The above output we can observe almost every step says CACHED and the whole build finished in a little over a second. That is only because I had built this image before, so on a first run expect it to take considerably longer while the frontend and Python dependencies are pulled and built. At this point we have an image sitting in the local Docker image store, so let's push it to the registry so the cluster can pull it.

$ docker push registry.schmaustech.com:5000/attribution-blueprint-web:latest The push refers to repository [registry.schmaustech.com:5000/attribution-blueprint-web] 5f70bf18a086: Pushed b78e1eeee5c9: Pushed e5c73dd8dfa2: Pushed bd7bce39331b: Pushed 66a7a0b9edd3: Pushed 6a52fb04e724: Pushed 87fa1d674280: Pushed 199eb863db71: Pushed 9c5d441c4e95: Pushed fe962dda67f4: Pushed 56f16490e6f3: Pushed 5b91b81ea413: Pushed 740cf88824b2: Pushed 2a56874aabc8: Pushed a4a5b6e3a44e: Pushed d142ef99965e: Pushed c0c0660a42bc: Pushed 740399ee44b6: Pushed 5f35d866a418: Pushed 2e68013aee3d: Pushed 86d005034ff8: Pushed c01c35a040a2: Pushed local: digest: sha256:7c387a2c2750c7e780f9ce6d397c97ddc6193da287daf0827e69358753e84119 size: 4923

Twenty-two layers pushed and a digest at the bottom. If everything looks good we can move onto deploying it.

Deploy Attribution to Kubernetes

For the deployment I always prefer yaml manifests, so the following is a single file that holds everything Attribution needs. Let's look at it and then go through what each object does.

$ cat attribution-deployment.yaml apiVersion: v1 kind: Namespace metadata: name: attribution --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: attribution-data namespace: attribution spec: accessModes: - ReadWriteOnce storageClassName: local-path-persistent resources: requests: storage: 1Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: attribution namespace: attribution spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: attribution template: metadata: labels: app: attribution spec: containers: - name: attribution image: registry.schmaustech.com:5000/attribution-blueprint-web:latest imagePullPolicy: Always env: - name: ATTRIBUTION_DB value: /data/attributions.db - name: ATTRIBUTION_SERVICE_PORT value: "8000" - name: ATTRIBUTION_WEB_PORT value: "8080" ports: - name: api containerPort: 8000 - name: web containerPort: 8080 startupProbe: httpGet: path: / port: web periodSeconds: 5 failureThreshold: 36 readinessProbe: httpGet: path: / port: web periodSeconds: 10 livenessProbe: httpGet: path: /v2/healthz port: api periodSeconds: 20 failureThreshold: 3 resources: requests: cpu: 100m memory: 256Mi limits: memory: 1Gi volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: attribution-data --- apiVersion: v1 kind: Service metadata: name: attribution namespace: attribution spec: type: NodePort selector: app: attribution ports: - name: web port: 8080 targetPort: web nodePort: 30080 - name: api port: 8000 targetPort: api

The file starts with an attribution namespace and a one gigabyte PersistentVolumeClaim named attribution-data. That claim is what holds the SQLite database the web dashboard reads from, and I am using the local-path-persistent storage class since this is a single node lab. The Deployment runs one replica and uses the Recreate strategy rather than a rolling update. Since the volume is ReadWriteOnce and SQLite does not like two writers, we want the old pod gone before the new one starts.

The container exposes two ports: 8000 for the Attribution API and 8080 for the web dashboard, and the environment variables point the database at /data/attributions.db on the mounted volume. The probes are worth a quick look. The startup and readiness probes hit the web UI on /, while the liveness probe hits /v2/healthz on the API port. The startup probe gives the pod up to three minutes (5 second period times 36 failures) to come up, which is plenty of time for the first start. Finally the Service is a NodePort, with the web UI pinned to 30080 and the API port left for Kubernetes to assign.

With the manifest ready we can create everything efficiently in one shot.

$ kubectl create -f attribution-deployment.yaml namespace/attribution created persistentvolumeclaim/attribution-data created deployment.apps/attribution created service/attribution created

Now let's see if the pod comes up. Below we are asking for the pods, the claim and the service all at once so we can confirm each piece.

$ kubectl -n attribution get pods,pvc,service -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES pod/attribution-75f57d76d9-rw592 1/1 Running 0 45s 10.1.174.232 gpu-node <none> <none> NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE persistentvolumeclaim/attribution-data Bound pvc-9e497dc7-2b0f-4cef-bb8e-e0d074241f75 1Gi RWO local-path-persistent <unset> 45s Filesystem NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR service/attribution NodePort 10.152.183.26 <none> 8080:30080/TCP,8000:30496/TCP 45s app=attribution

The pod is Running and 1/1 ready, the claim is Bound to a one gigabyte volume, and the service shows the web UI on 8080 mapped to node port 30080 and the API on 8000 mapped to node port 30496. We will use both of those node ports in a moment. A plain pod listing a bit later confirms it has stayed up with no restarts.

$ kubectl get pods -n attribution NAME READY STATUS RESTARTS AGE attribution-75f57d76d9-rw592 1/1 Running 0 74s

Validate the Service

Before sending any real requests I want to confirm both ports answer. We can verify this by calling the health endpoint on the API node port, using the IP address of the virtual machine.

$ curl --max-time 10 -fsS http://192.168.0.111:30496/v2/healthz null

A bare null looks odd, but it is what the health endpoint returns on success. What matters is that curl was run with -f, so it would have failed loudly on any HTTP error. Next up is the web dashboard, where I only care about the status code.

$ curl --max-time 10 -fsS -o /dev/null -w 'Web UI HTTP %{http_code}\n' http://192.168.0.111:30080/ Web UI HTTP 200

An HTTP 200 from the dashboard. At this point I feel pretty confident the deployment is healthy. To save some typing for the rest of the demo I am exporting the API address into a variable and calling the health endpoint once more through it.

$ export SVC="http://192.168.0.111:30496" $ curl --fail-with-body -sS "${SVC}/v2/healthz"

Create an Attributor and Submit an Observation

Now for the interesting part. Everything in the Attribution API hangs off an attributor, which is how I think about it: a record of one workload you want an answer about. To create one we POST to /v2/attributor with a description of the workload and some metadata about where it ran.

The workload here is a made up PyTorch job named curl-example, submitted through Kueue, that ran for thirty minutes on GPUs 0 and 1 of a node called node-a and ended in a FAILED status. The metadata identifies the cluster, the agent that reported it, a tenant and the ruleset to use, which is default. I am piping the response through jq to pull out just the attributor_id and store it in a variable called ATT so we can reuse it.

$ export ATT="$( curl --fail-with-body -sS \ -X POST \ -H 'Content-Type: application/json' \ "${SVC}/v2/attributor" \ --data-binary @- <<'JSON' | jq -er '.attributor_id' { "workload": { "id": "curl-example-001", "type": "kueue", "job_start_time": "2026-10-07T10:00:00Z", "job_end_time": "2026-10-07T10:30:00Z", "interest_start_time": "2026-10-07T10:00:00Z", "interest_end_time": "2026-10-07T10:30:00Z", "timezone": "UTC", "status": "FAILED", "nodes": { "node-a": [0, 1] }, "name": "curl-example", "username": "example-user", "framework": "pytorch" }, "metadata": { "cluster": "local-microk8s", "agent": "curl", "tenant": "example-tenant", "ruleset": "default" } } JSON )" $ 1094296642135612619

That large number at the bottom is the attributor ID. The attributor knows about the workload but it has not seen any evidence yet, so let's give it some. Evidence is submitted as an observation against the attributor. The observation has a context saying it came from an application log (applog), and a body that carries the log line along with the resource it came from. In this case that is a single line, [rank 0] RuntimeError: CUDA out of memory, from node-a. The body is itself a JSON string, which is why you see all of the escaped quotes.

$ curl --fail-with-body -sS \ -X POST \ -H 'Content-Type: application/json' \ "${SVC}/v2/attributor/${ATT}/observation" \ --data-binary @- <<'JSON' { "context": { "time_interval": { "start": null, "end": null }, "source": "applog" }, "body": "[{\"body\":\"[rank 0] RuntimeError: CUDA out of memory\",\"resources\":[{\"kind\":\"node\",\"hostname\":\"node-a\"}]}]" } JSON {"observation_id":-5460837407236178041,"model_type":"ObservationResponseV2"

The API hands back an observation ID, which tells us the line was accepted and recorded against our attributor.

Read the Verdict

With the evidence in place we can ask Attribution what it thinks happened.

$ curl --fail-with-body -sS "${SVC}/v2/attributor/${ATT}/attribution" | jq . { "verdict": { "root_symptom": { "symptom": "cudaError out of memory", "source": { "observation_id": -5460837407236178041, "source": "applog", "pos": 1, "line": "[rank 0] RuntimeError: CUDA out of memory", "rule": "cudaError out of memory", "time": null }, "catalog_id": "cudaerror-out-of-memory" }, "attribution_outcome": "Fail User", "detail": "Not Applicable", "terminal_states": [ "Fail" ], "failure_domains": [ "User" ], "cancellation_actors": [], "uncertainty_type": null }, "symptoms": { "cudaError out of memory": [ { "type": "NodeSource", "node": "node-a", "status": "bad", "source": { "observation_id": -5460837407236178041, "source": "applog", "pos": 1, "line": "[rank 0] RuntimeError: CUDA out of memory", "rule": "cudaError out of memory", "time": null } } ] }, "observation_context": [ -5460837407236178041 ], "collectors": { "nccl_version": "NA", "cuda_version": "NA", "watchdog_error_time": null, "watchdog_timeout_seconds": null, "runtime_args": "{}", "bad_pairs": "", "nvrx_straggler": {}, "megatron_straggler": {}, "xid_pci_mappings": "{}", "recovery_actions": "", "investigatory_actions": "" }, "model_type": "AttributionResponseV2" }

There is a lot in that response, so let's go through the parts that matter. The verdict is the answer. The root_symptom shows that the log line matched the rule cudaError out of memory, and it points back at the observation, the log source and the exact line that triggered it. The attribution_outcome is Fail User and the failure_domains list contains only User. In plain terms, Attribution is telling us this job failed because of something in the job itself and not because of the node it ran on. There is no reason to cordon node-a or open a hardware ticket for this one.

The symptoms section ties the symptom to the specific node and marks it bad, and the collectors section holds extra context that Attribution can gather for a job, such as NCCL and CUDA versions, watchdog timeouts and straggler data. Those are mostly NA or empty here since we only gave it one log line and there was no real job to collect from.

Save the Result to the Dashboard

This next part tripped me up a little at first, so it is worth calling out. The API calls we just made return the attribution result, but they do not populate the web dashboard's SQLite database. The Blueprint CLI normally handles that persistence. Since we are driving the API by hand with curl, I am going to do the same thing the CLI would do, in a few small steps.

First I save the workload definition to a file in /tmp. It is the same workload we posted earlier, just without the metadata block.

$ cat > /tmp/attribution-workload.json <<'JSON' { "workload": { "id": "curl-example-001", "type": "kueue", "job_start_time": "2026-10-07T10:00:00Z", "job_end_time": "2026-10-07T10:30:00Z", "interest_start_time": "2026-10-07T10:00:00Z", "interest_end_time": "2026-10-07T10:30:00Z", "timezone": "UTC", "status": "FAILED", "nodes": { "node-a": [0, 1] }, "name": "curl-example", "username": "example-user", "framework": "pytorch" } } JSON

Then I fetch the attribution result for our attributor and write it to a file.

$ export SVC="http://192.168.0.111:30496" $ curl --fail-with-body -sS "${SVC}/v2/attributor/${ATT}/attribution" -o attribution-result.json

With both pieces on disk, jq merges them into a single dashboard record that holds the workload and its result side by side.

$ jq -n --slurpfile request /tmp/attribution-workload.json --slurpfile result attribution-result.json '{workload: $request[0].workload, result: $result[0]}' > attribution-dashboard-record.json

The last step is writing that record into the database. I do this by running a short Python snippet inside the running Attribution pod with kubectl exec, so it uses the same code and the same /data/attributions.db file on the persistent volume that the dashboard reads. The snippet loads the record from standard input, checks that it really contains a verdict, builds the workload context the writer expects (ID, nodes, start and end times, workload type, state and so on), and uses the blueprint's SQLiteOutput writer to save it.

$ kubectl -n attribution exec -i deployment/attribution -- \ /opt/blueprint/.venv/bin/python -c ' import json import sys from datetime import datetime from attribution_blueprint.connectors.outputs.sqlite import SQLiteOutput from attribution_blueprint.result import enrich_attribution_result record = json.load(sys.stdin) w = record["workload"] result = record["result"] if not isinstance(result.get("verdict"), dict): raise ValueError("Input does not contain an attribution verdict") context = { "id": w["id"], "nodes": list(w["nodes"]), "start_time": datetime.fromisoformat(w["job_start_time"]), "end_time": datetime.fromisoformat(w["job_end_time"]), "timezone": w.get("timezone", "UTC"), "workload_type": w["type"], "state": w["status"], "name": w.get("name", ""), "user_name": w.get("username", ""), "workload_framework": w.get("framework", ""), } writer = SQLiteOutput("/data/attributions.db") try: writer.write(context, enrich_attribution_result(result, [])) finally: writer.close() print("Saved dashboard record:", w["id"]) ' < attribution-dashboard-record.json Saved dashboard record: curl-example-001

The pod confirms with Saved dashboard record: curl-example-001. At this point the record lives in the database the dashboard is reading from, so the failed job should now be visible in the web UI on node port 30080.

Clean Up

Since this was only a demo, the last step is deleting the attributor we created. A zero exit code tells us the delete went through.

$ curl --fail-with-body -sS -X DELETE "${SVC}/v2/attributor/${ATT}" $ echo $? 0

Summary

In this walkthrough we built the NVIDIA Attribution Blueprint image for arm64, pushed it to a local registry, and deployed it to MicroK8s with a namespace, a persistent volume, a single replica Deployment and a NodePort service. After validating the health endpoint and the web dashboard, we created an attributor for a simulated failed PyTorch job, submitted a CUDA out of memory log line as an observation, and asked for the verdict. Attribution traced the log line to a matching rule and concluded the failure belonged to the user and not the infrastructure. We finished by saving the result into the dashboard's SQLite database through the pod and cleaning up the attributor.

The part I like most is how small the contract is. A workload description plus a bit of evidence goes in, and a structured verdict with the rule, the line and the failure domain comes out. That is exactly what we want when you are trying to decide whether a failed job needs a user to fix their code or an operator to fix a node.

The Attribution project lives on GitHub if you want to try this yourself or dig into the rulesets: https://github.com/dsx-ai-factory/attribution

Hopefully this provides a clear example of how to deploy Attribution and walk a failed workload through to a verdict.