hands-on labon a real NVIDIA GPU

GPU Health Checks + Auto-Remediation

Build a production-grade GPU watchdog: multi-dimensional NVML health probe, rogue-process detection, auto-remediation that kills the offender and verifies recovery, then wire it up with Prometheus alerts and Kubernetes liveness probes.

time
50 min
checked steps
4
level
Advanced
setup
None

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit50 min · 4 stepsSession running
3 / 4 steps passingWire it into production: Prometheus + Kubernetes + watchdog · step 4 of 4
lab.ipynb

Step 4 — Wire it into production: Prometheus + Kubernetes + watchdog

You've got health_check() and remediate(). The last 20% is how you hand this to the rest of the stack.

[ ]:
from pathlib import Pathfrom IPython.display import Code yaml_path = Path('workspace/gpu-alerts.yaml')print(f"{yaml_path} — {yaml_path.stat().st_size} bytes, "      f"{Path(yaml_path).read_text().count('alert:')} alert rules")Code(filename=str(yaml_path), language='yaml')
[ ]:
def liveness_exit_for(report):    if report['status'] == 'healthy':        return 0      # kubelet: keep the pod    if report['status'] == 'degraded':        return 0      # soft warning — Prometheus alerts; don't restart the pod    return 2          # critical — kubelet restarts the container # After remediation we should be back to healthycurrent_report = health_check()liveness_healthy_exit = liveness_exit_for(current_report) # For the unhealthy case, synthesize a fake critical reportfake_critical = {**current_report, 'status': 'critical'}liveness_unhealthy_exit = liveness_exit_for(fake_critical) print(f"liveness probe on current GPU ({current_report['status']}): exit {liveness_healthy_exit}")print(f"liveness probe on synthetic critical state: exit {liveness_unhealthy_exit}")
[ ]:
from IPython.display import CodeCode(filename='workspace/gpu-liveness-pod.yaml', language='yaml')
[ ]:
# --- Mini watchdog loop ---# In production this is a long-running daemon. Here we run 5 ticks to demonstrate# the detect → classify → log shape. Remediation is wired in but no-ops on a# healthy GPU (offenders list is empty).from datetime import datetime, timezone watchdog_log = []N_TICKS = 5TICK_SECONDS = 1for i in range(N_TICKS):    r = health_check()    entry = {        'timestamp': datetime.now(timezone.utc).isoformat(),        'tick': i,        'status': r['status'],        'memory_pct': round(r['memory_pct'], 1),        'temperature_c': r['temperature_c'],        'issues_n': len(r['issues']),    }    if r['status'] == 'critical':        action = remediate(r)        entry['remediation'] = action.get('reason')    watchdog_log.append(entry)    print(f"  [tick {i}] {entry['status']:8s} mem={entry['memory_pct']:5.1f}% temp={entry['temperature_c']}C issues={entry['issues_n']}")    time.sleep(TICK_SECONDS) print(f"\nWatchdog recorded {len(watchdog_log)} ticks; all statuses: {[e['status'] for e in watchdog_log]}")
[ ]:
lab.check(4)
step 4 of 4, replayed: Wire it into production: Prometheus + Kubernetes + watchdog. The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Build the health probe

    read this step below ↓

  2. 2

    Simulate the #1 production failure: a PID hoarding VRAM

  3. 3

    Write the auto-remediator

  4. 4

    Wire it into production: Prometheus + Kubernetes + watchdog

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Build the health probe

Goal: a single health_check() function that returns a structured report:

{
    'status': 'healthy' | 'degraded' | 'critical',
    'gpu_id': 0,
    'checks': {'memory_ok': True, 'temperature_ok': True, ...},
    'issues': ['rogue process holding 2048 MiB', ...],
    'memory_used_mib': 734, 'memory_pct': 9.3, 'temperature_c': 52,
    ...
}

Each individual *_ok check is a boolean. The aggregate status is critical if any hard-fail check trips, degraded for soft warnings, healthy otherwise. This separation matters — your alert rules and your dashboards want different views of the same data.

The two kinds of checks you'll combine:

  1. Threshold checks — memory %, temperature, ECC errors, Xid events. The classic production alarms.
  2. Rogue-process detection — any compute process not on your protected allowlist is suspicious. On a dedicated training node, only your training PID should hold a CUDA context. Anything else — orphaned Jupyter kernel, leaked Ray worker, someone's ad-hoc debug session — is an anomaly regardless of how much VRAM it holds. This catches the 2 GB leak on an 80 GB H100 where the threshold wouldn't trigger.

Thresholds used below (tuned for H100-class production — an 80% VRAM trip won't fire on a 2 GB rogue, which is why the rogue-process check matters):

DimensionWarn (degraded)Fail (critical)
Memory80% used90% used
Temp (C)8090
Powerreal throttling bit set—
ECC DBEanyany
Xid logany non-fatalany fatal (13, 43, 44, 48, 62, 79)
Rogue PIDany non-protected CUDA processany holding >1 GB

Thresholds — production-realistic alerting levels

We define thresholds once at the top so the whole probe reads declaratively. These are the values we'd send to PagerDuty in production:

  • Memory 80% warn / 90% critical — below 80% is normal under load.
  • Temp 80°C warn / 90°C critical — H100 thermal limit is ~87°C.
  • Xid events — a small set of truly fatal codes (13, 43, 44, 48, 62, 79). Others are informational.
  • Throttle mask — only real throttling (power cap, thermal slowdown) counts; an idle GPU reporting IDLE is not a problem.
  • Rogue PID — any non-protected CUDA process holding ≥1 GiB is treated as critical (hoarding leak).
import os, pynvml

pynvml.nvmlInit()
handle = pynvml.nvmlDeviceGetHandleByIndex(0)

# Production-realistic thresholds
MEM_WARN_PCT, MEM_CRIT_PCT = 80.0, 90.0
TEMP_WARN_C, TEMP_CRIT_C = 80, 90
ROGUE_CRIT_MIB = 1024  # a single non-protected PID holding >1 GB is critical
FATAL_XIDS = {13, 43, 44, 48, 62, 79}

# Only these throttle bits mean 'something is wrong'. IDLE (0x1), APPLICATIONS_CLOCKS,
# SYNC_BOOST, DISPLAY_CLOCK are normal operating reasons. An idle GPU reports 0x1 — not
# a problem, don't alert on it.
REAL_THROTTLE_MASK = (
    0x4    # SW_POWER_CAP
    | 0x8  # HW_SLOWDOWN
    | 0x20 # SW_THERMAL_SLOWDOWN
    | 0x40 # HW_THERMAL_SLOWDOWN
    | 0x80 # HW_POWER_BRAKE_SLOWDOWN
)

# Status priority — always take the worst finding across all checks.
_STATUS_RANK = {'healthy': 0, 'degraded': 1, 'critical': 2}
def _worst(a, b):
    return a if _STATUS_RANK[a] >= _STATUS_RANK[b] else b

Reading GPU metrics via NVML

Why NVML (Python binding to NVIDIA Management Library) and not nvidia-smi --query-gpu=...?

  1. Structured — you get a dict back, not parsed CSV.
  2. No subprocess cost — a 30ms call vs 200ms for nvidia-smi.
  3. Access to events — Xid errors, ECC counters, clock throttle reasons aren't exposed by the CLI at all.

Notes on edge cases:

  • ECC may not be supported on consumer cards — we catch NVMLError and treat as zero.
  • bad_throttle ANDs the mask, so IDLE doesn't fire a false alarm.
def _read_metrics():
    mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
    temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
    power_w = pynvml.nvmlDeviceGetPowerUsage(handle) / 1000.0
    power_limit_w = pynvml.nvmlDeviceGetEnforcedPowerLimit(handle) / 1000.0
    try:
        ecc_dbe = pynvml.nvmlDeviceGetTotalEccErrors(
            handle,
            pynvml.NVML_MEMORY_ERROR_TYPE_UNCORRECTED,
            pynvml.NVML_VOLATILE_ECC,
        )
    except pynvml.NVMLError:
        ecc_dbe = 0  # not all GPUs support ECC — treat as zero errors
    throttle = pynvml.nvmlDeviceGetCurrentClocksThrottleReasons(handle)
    return {
        'memory_used_mib': mem.used // (1024 * 1024),
        'memory_total_mib': mem.total // (1024 * 1024),
        'memory_pct': 100.0 * mem.used / mem.total,
        'temperature_c': temp,
        'power_w': power_w,
        'power_limit_w': power_limit_w,
        'ecc_dbe': int(ecc_dbe),
        'throttle_mask': int(throttle),
        'bad_throttle': int(throttle) & REAL_THROTTLE_MASK,
    }

def _read_xid_events():
    # Xid events are kernel-level GPU fault codes. Some (like 13, 43) indicate
    # memory errors bad enough that you should drain the node before the next job.
    try:
        event_set = pynvml.nvmlEventSetCreate()
        pynvml.nvmlDeviceRegisterEvents(
            handle, pynvml.nvmlEventTypeXidCriticalError, event_set
        )
        events = []
        for _ in range(10):
            try:
                data = pynvml.nvmlEventSetWait_v2(event_set, 10)
                events.append(int(data.eventData))
            except pynvml.NVMLError_Timeout:
                break
        pynvml.nvmlEventSetFree(event_set)
        return events
    except pynvml.NVMLError:
        return []

Rogue process detection + protected PIDs

The JupyterLab kernel you're running right now is a CUDA process. If our check naively flagged every CUDA PID as rogue, it would kill itself.

DEFAULT_PROTECTED holds the PIDs we must never treat as rogue: this notebook's own process, its parent (the Jupyter server), and PID 1 (the container init). In a real watchdog you'd also add the training job's parent supervisor PID.

DEFAULT_PROTECTED = {os.getpid(), os.getppid(), 1}

def _list_gpu_processes(protected):
    """Return non-protected compute processes as [{pid, used_mib}, ...].
    NVML sometimes reports usedGpuMemory=None inside a container — we keep the
    PID and treat unknown memory as 0 (the PID is still an anomaly)."""
    raw = pynvml.nvmlDeviceGetComputeRunningProcesses(handle)
    return [
        {'pid': p.pid, 'used_mib': (p.usedGpuMemory or 0) // (1024 * 1024)}
        for p in raw if p.pid not in protected
    ]

The main health_check — compose the findings

Now assemble everything: read the metrics, read Xid events, list non-protected processes, run each check against its threshold, and return a single report. The status is always the worst finding across all checks — one critical issue beats ten healthy ones.

def health_check(protected=None):
    if protected is None:
        protected = DEFAULT_PROTECTED
    m = _read_metrics()
    xids = _read_xid_events()
    rogue = _list_gpu_processes(protected)
    big_rogue = [r for r in rogue if r['used_mib'] >= ROGUE_CRIT_MIB]

    checks = {
        'memory_ok': m['memory_pct'] < MEM_CRIT_PCT,
        'temperature_ok': m['temperature_c'] < TEMP_CRIT_C,
        'ecc_ok': m['ecc_dbe'] == 0,
        'power_ok': m['bad_throttle'] == 0,
        'xid_errors_ok': not any(x in FATAL_XIDS for x in xids),
        'no_rogue_processes': len(rogue) == 0,
    }
    issues = []
    status = 'healthy'
    if m['memory_pct'] >= MEM_CRIT_PCT:
        issues.append(f"memory {m['memory_pct']:.1f}% >= {MEM_CRIT_PCT}% (critical)"); status = _worst(status, 'critical')
    elif m['memory_pct'] >= MEM_WARN_PCT:
        issues.append(f"memory {m['memory_pct']:.1f}% >= {MEM_WARN_PCT}% (warn)"); status = _worst(status, 'degraded')
    if m['temperature_c'] >= TEMP_CRIT_C:
        issues.append(f"temp {m['temperature_c']}C >= {TEMP_CRIT_C}C (critical)"); status = _worst(status, 'critical')
    elif m['temperature_c'] >= TEMP_WARN_C:
        issues.append(f"temp {m['temperature_c']}C >= {TEMP_WARN_C}C (warn)"); status = _worst(status, 'degraded')
    if m['ecc_dbe'] > 0:
        issues.append(f"{m['ecc_dbe']} uncorrected ECC error(s)"); status = _worst(status, 'critical')
    if m['bad_throttle']:
        issues.append(f"thermal/power throttling active (mask={m['bad_throttle']:#x})"); status = _worst(status, 'degraded')
    fatal_xids = [x for x in xids if x in FATAL_XIDS]
    if fatal_xids:
        issues.append(f"fatal Xid(s): {fatal_xids}"); status = _worst(status, 'critical')
    if big_rogue:
        issues.append(f"{len(big_rogue)} rogue process(es) >={ROGUE_CRIT_MIB} MiB: {big_rogue}"); status = _worst(status, 'critical')
    elif rogue:
        issues.append(f"{len(rogue)} non-protected CUDA process(es): {rogue}"); status = _worst(status, 'degraded')

    return {
        'status': status, 'gpu_id': 0,
        'checks': checks, 'issues': issues,
        'rogue_processes': rogue,
        **m,
        'xid_events': xids,
    }

Run it — what does a healthy idle GPU look like?

On a freshly provisioned lab pod with no training job running, you should see:

  • status: healthy
  • memory_pct near zero
  • temperature_c in the 30–50°C range
  • rogue_processes: [] (no one else is on the GPU)
  • issues: [] (nothing triggered any threshold)

Step 2 will simulate a leak and you'll watch this exact report flip to critical.

health_report = health_check()
for k, v in health_report.items():
    if k not in ('checks', 'issues', 'rogue_processes'):
        print(f"  {k}: {v}")
print(f"  checks: {health_report['checks']}")
print(f"  rogue_processes: {health_report['rogue_processes']}")
print(f"  issues: {health_report['issues']}")

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Comfortable with Python subprocesses and signals (SIGTERM/SIGKILL)
  • Basic familiarity with NVML or `nvidia-smi` output
  • Conceptual knowledge of Prometheus alerting and Kubernetes probes

Exam domains covered

GPU Acceleration & Distributed TrainingModel Deployment & Inference Optimization

Part of the GPU and Kubernetes track

Lab 13 of 20. GPU scheduling on your own Kubernetes cluster, the GPU Operator, CUDA kernels and profiling.

Questions about this lab

Why does the lab insist you re-query NVML after the kill?

Because before the kill, nvmlDeviceGetMemoryInfo reports the leaked memory as 'used'. If you compute memory_freed_mib = pre_memory_mib - pre_memory_mib, you'll get zero and report the remediation as a no-op. If you cache the pre-kill value and subtract a stale post-kill value, you can accidentally report negative savings. The only trustworthy order of operations is: read pre, kill, wait for NVML to update (the device context takes a moment to tear down), read post, then subtract. The validator specifically checks that memory_freed_mib > 500, anything lower almost always means you skipped the post-kill query.

Why ignore the IDLE throttle bit (0x1) in the health probe?

Every idle GPU reports the IDLE throttle reason, that's literally what 'the GPU is not currently under load' means to NVML. If you flag every non-zero currentClocksThrottleReasons value as degraded, your probe will be 'critical' 90% of the time on a healthy inference box. The production pattern is a mask: throttle & ~0x1, alert on HW_SLOWDOWN, HW_THERMAL_SLOWDOWN, SW_THERMAL_SLOWDOWN, or HW_POWER_BRAKE, ignore IDLE and (usually) APPLICATIONS_CLOCKS_SETTING.

SIGTERM then SIGKILL, why not just SIGKILL right away?

Because SIGKILL cannot be caught and gives the process zero chance to release resources cleanly. A PyTorch training job killed mid-step can leave corrupted checkpoint files, dangling NCCL communicators, or a half-written optimizer state that takes down the next replica when it tries to resume. SIGTERM gives the process (and any atexit / signal handlers it registered) a grace window, typically 10 to 30 seconds, to flush state, release GPU memory, and exit. Only if the grace window expires do you escalate to SIGKILL. Kubernetes' terminationGracePeriodSeconds encodes exactly this idea at the pod level.

Is auto-remediation ever a bad idea?

Yes, and the reflection step is built around exactly this question. ECC errors, Xid hardware faults, thermal runaway, and anything involving multi-node training state (NCCL collectives, checkpoint coordination, allreduce stragglers) are all cases where the safest action is to page a human and stop the bleed, not kill the PID and pretend it's fixed. The pattern is: observe for well-characterized failure modes first, auto-remediate only for the ones where the failure mode is understood and the blast radius is bounded. Everything else is a PagerDuty event.

How does this differ from the GPU monitoring lab?

The monitoring lab is about telemetry at rest, DCGM-style metric pipelines, Prometheus gauges, dashboards that help you notice a problem. This lab is about incident response, detecting a broken GPU, making a decision, taking an action, and proving the action worked. You'll often deploy both: dcgm-exporter surfaces the raw signal on a dashboard, and this watchdog is the component that translates a degraded signal into 'kill the rogue PID, alert on the hardware fault, restart the pod' without a human in the loop.

What exit codes should the Kubernetes liveness probe use?

Exit 0 on healthy, any non-zero on unhealthy, kubelet interprets any non-zero exit as a probe failure. The convention many teams use is to reserve a namespace: exit 2 for software-level failure (can't import torch, NVML init failed), exit 3 for hardware-level failure (ECC, Xid, temperature), exit 0 for healthy. That way kubectl logs on the crashlooping pod plus the last exit code tells you which kind of outage you're dealing with. The lab just requires healthy -> 0 and unhealthy -> non-zero, but it's worth picking richer codes once you go to production.

What you'll build in this GPU health and auto-remediation lab

GPU dashboards show you that something is wrong; a watchdog actually does something about it. This lab builds the second one, a production-grade GPU auto-remediator that detects a rogue process hoarding VRAM, kills it safely, verifies real recovery by re-querying NVML, and emits Prometheus alerts and Kubernetes liveness signals so the rest of your SRE stack can respond. The failure mode it's built for is the single most common one in real fleets: a Python training script crashes holding a 2 GiB torch.cuda allocation, never releases it, and the next job on the card fails with CUDA out of memory while nvidia-smi shows a perfectly calm GPU. You leave with a working health_check() covering memory/temperature/ECC/power/Xid, a remediation function that measures actual freed VRAM, prometheus_rules YAML with real alert expressions, richer liveness exit codes (0 healthy, 2 software fault, 3 hardware fault), and a watchdog loop you can drop into a sidecar. ~50 minutes on a real NVIDIA GPU pod we provision.

The technical substance lives in the details that separate probes that work from probes that lie. The IDLE throttle bit (0x1) in currentClocksThrottleReasons is always set on an idle GPU, so alerting on any non-zero throttle mask flags 90% of healthy inference boxes; the right mask is throttle & ~0x1, alert on HW_SLOWDOWN, HW_THERMAL_SLOWDOWN, SW_THERMAL_SLOWDOWN, HW_POWER_BRAKE. Rogue-process enumeration uses nvmlDeviceGetComputeRunningProcesses so you get real per-PID accounting, not a guess from ps. Remediation sends SIGTERM with a grace window (the process gets to flush checkpoints, close NCCL communicators, release the CUDA context) before escalating to SIGKILL, the same pattern Kubernetes terminationGracePeriodSeconds encodes at pod level. The trap the lab intentionally trips: if you don't re-query nvmlDeviceGetMemoryInfo after the kill, memory_freed_mib comes out zero or negative, and the validator catches it with a >500 MiB assertion. The reflection step is the real lesson, auto-remediation is dangerous for ECC errors, Xid hardware faults, thermal runaway, and anything involving multi-node NCCL state, so production policy needs cooldowns, rate limits, allowlists, and human-in-the-loop gates.

Prereqs: Python subprocesses and signals (SIGTERM / SIGKILL), basic familiarity with NVML or nvidia-smi output, and conceptual knowledge of Prometheus alerting and Kubernetes probes. Preinstalled on the lab pod: pynvml, PyTorch, the CUDA toolkit, the NVIDIA driver. Grading is concrete at every step, baseline probe returns healthy with zero issues; after the simulated leak it escalates to degraded/critical and lists the rogue PID via per-process accounting; the remediator frees >500 MiB and returns the card to healthy; the alert YAML defines ≥2 rules with the canonical Prometheus alert: / expr: / for: / severity fields. Pair this with the GPU monitoring lab (telemetry at rest) and you have the two halves of production GPU SRE: detection plus response.