hands-on labon a real NVIDIA GPU

GPU Observability: From nvidia-smi to a Production Monitoring Stack

Go from a raw NVML snapshot to a real monitoring pipeline: capture live GPU telemetry during a workload, diagnose a dataloader bottleneck from the utilization trace, and expose everything as a Prometheus /metrics endpoint.

time
40 min
checked steps
4
level
Intermediate
setup
None

$29.99a month with Pro

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

Lab cockpit40 min · 4 stepsSession running
3 / 4 steps passingProduction bridge: expose metrics in Prometheus format · step 4 of 4
lab.ipynb

Step 4 — Production bridge: expose metrics in Prometheus format

Your Python sampler is useful for ad-hoc debugging. For the ops team you need a pull-based metrics endpoint Prometheus can scrape every 15 seconds. Then Grafana reads Prometheus, renders dashboards, fires alerts.

[ ]:
from prometheus_client import start_http_server, Gaugeimport threading, time, requests # Define metrics — names follow Prometheus conventions (snake_case, unit suffix)gpu_util = Gauge('gpu_utilization_percent', 'GPU SM utilization %', ['gpu_id'])gpu_mem_used = Gauge('gpu_memory_used_mib', 'GPU memory used (MiB)', ['gpu_id'])gpu_mem_total = Gauge('gpu_memory_total_mib', 'GPU memory total (MiB)', ['gpu_id'])gpu_power = Gauge('gpu_power_draw_watts', 'GPU power draw (W)', ['gpu_id'])gpu_temp = Gauge('gpu_temperature_celsius', 'GPU temperature (C)', ['gpu_id']) # Background thread: refresh metrics every 1sdef updater():    while True:        s = gpu_snapshot()        gpu_util.labels(gpu_id='0').set(s['utilization_gpu_pct'])        gpu_mem_used.labels(gpu_id='0').set(s['memory_used_mib'])        gpu_mem_total.labels(gpu_id='0').set(s['memory_total_mib'])        gpu_power.labels(gpu_id='0').set(s['power_draw_w'])        gpu_temp.labels(gpu_id='0').set(s['temperature_c'])        time.sleep(1.0) threading.Thread(target=updater, daemon=True).start() # Start an HTTP server on localhost:9400 (same port dcgm-exporter uses)start_http_server(9400)time.sleep(2)  # let the updater populate metrics # Scrape our own /metrics endpoint — exactly what Prometheus would dometrics_text = requests.get('http://localhost:9400/metrics').textprint(f'Scraped {len(metrics_text)} bytes from /metrics\n')# Show just our custom linesfor line in metrics_text.splitlines():    if line.startswith('gpu_'):        print(f'  {line}')
[ ]:
lab.check(4)
step 4 of 4, replayed: Production bridge: expose metrics in Prometheus format. 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

    Raw GPU telemetry via NVML

    read this step below ↓

  2. 2

    Time-series sampler during a real workload

  3. 3

    Diagnose the classic data-loader bottleneck

  4. 4

    Production bridge: expose metrics in Prometheus format

Step 1 as it appears in the lab

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

Step 1: Raw GPU telemetry via NVML

You cannot optimize what you cannot measure. The most common AI-ops failure mode isn't a crash — it's a training run that appears fine but sits at 30% GPU utilization because the CPU-side data loader is the real bottleneck. At cloud GPU rates ($1–3/hr for an A100), a 30%-utilized cluster is a 70% tax on every project.

The production-grade answer is DCGM + Prometheus + Grafana — NVIDIA's Data Center GPU Manager exports per-GPU telemetry to a time-series database, and Grafana renders dashboards + alerts. In this lab you'll build up the same stack from scratch in Python so you understand each layer, then see the production docker-compose topology.

Why this lab is the foundation of the Infra track

Every downstream infrastructure lab uses GPU telemetry:

  • Profiling (Nsight) — needs util/mem traces to interpret
  • DALI tuning — measures itself by moving the utilization line
  • Cost audit — cost/token = $-per-GPU-hour ÷ (throughput × utilization)
  • Health checks — "is this GPU dying?" is a metrics question
  • Auto-scaling — HPA decisions key off metrics

References

What you'll build

  1. Raw GPU telemetry — pull utilization, memory, power, temperature via NVML
  2. Time-series sampler — poll every 500 ms during a real workload; plot the trace
  3. The classic bottleneck story — show a slow-dataloader training loop (sawtooth utilization) vs an optimized one (steady high utilization)
  4. Production bridge — expose the same metrics in Prometheus format over HTTP; scrape with curl

NVML (NVIDIA Management Library) is the C API under nvidia-smi. pynvml is its Python wrapper. Everything DCGM and dcgm-exporter do, you can also do with ~20 lines of NVML calls.

Key metrics and what they mean:

MetricNVML callInterpretation
Utilization (SM)nvmlDeviceGetUtilizationRates% of time SMs were doing work. <70% = investigate
Memory usednvmlDeviceGetMemoryInfoHow much VRAM your process holds
Power drawnvmlDeviceGetPowerUsageWatts. Full tensor cores draw near TDP
TemperaturenvmlDeviceGetTemperatureThrottles near ~85C on consumer cards
SM clocknvmlDeviceGetClockInfoThrottles if temperature or power hit limits
Encoder/decoder utilnvmlDeviceGetEncoderUtilizationOnly non-zero for video workloads

Don't miss: utilization is time-based, not throughput-based. A GPU can report 100% util while running at 10% of peak FLOPS (e.g., memory-bound kernels). Always pair with workload-specific throughput metrics (tokens/sec, images/sec).

import pynvml

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

def gpu_snapshot():
    name = pynvml.nvmlDeviceGetName(handle)
    # pynvml returns bytes on some versions, str on others
    if isinstance(name, bytes):
        name = name.decode()
    mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
    util = pynvml.nvmlDeviceGetUtilizationRates(handle)
    power_mw = pynvml.nvmlDeviceGetPowerUsage(handle)
    temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
    clock_sm = pynvml.nvmlDeviceGetClockInfo(handle, pynvml.NVML_CLOCK_SM)
    return {
        'name': name,
        'memory_total_mib': mem.total // (1024 * 1024),
        'memory_used_mib': mem.used // (1024 * 1024),
        'memory_free_mib': mem.free // (1024 * 1024),
        'utilization_gpu_pct': util.gpu,
        'utilization_memory_pct': util.memory,
        'power_draw_w': power_mw / 1000.0,
        'temperature_c': temp,
        'sm_clock_mhz': clock_sm,
    }

gpu_info = gpu_snapshot()
for k, v in gpu_info.items():
    print(f'  {k:25s} {v}')

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 threads and background loops
  • Basic PyTorch (tensors, dataloaders)
  • Conceptual familiarity with Prometheus metrics format

Exam domains covered

GPU Acceleration & Distributed TrainingModel Deployment & Inference OptimizationExperimentation

Part of the GPU and Kubernetes track

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

Questions about this lab

Is NVML GPU_UTIL the same as SM occupancy?

No, this is the single most important misconception the lab targets. NVML's nvmlDeviceGetUtilizationRates().gpu reports the percentage of time in the last sample window during which at least one kernel was executing. It tells you nothing about how many SMs were active, how saturated the Tensor Cores were, or whether the kernel was memory-bound waiting on DRAM. You can hit 100% on a deliberately bad kernel that uses a single SM. For true occupancy you need DCGM's SM_ACTIVE, SM_OCCUPANCY, and TENSOR_ACTIVE fields, or Nsight Compute / PyTorch Profiler on the kernel of interest.

Why expose metrics in Prometheus exposition format instead of pushing JSON?

Because Prometheus is pull-based, stateless on the scraper side, cheap to horizontally scale, and universally supported, Grafana, Alertmanager, VictoriaMetrics, Thanos, Cortex, Mimir, Datadog's OpenMetrics ingest, all of them speak the same # HELP/# TYPE/metric_name{labels} value timestamp format. Once your gauge endpoint is valid, you get alerting, long-term storage, recording rules, and multi-cluster federation for free. The lab writes a minimal exposition endpoint on purpose, so you see that dcgm-exporter is not magic, it's the same pattern with a larger metric catalogue.

When should I reach for DCGM or dcgm-exporter instead of raw NVML?

When you want fleet-level telemetry or richer fields than NVML exposes. DCGM adds DCGM_FI_DEV_XID_ERRORS, DCGM_FI_DEV_SM_ACTIVE, DCGM_FI_DEV_DRAM_ACTIVE, DCGM_FI_DEV_TENSOR_ACTIVE, DCGM_FI_DEV_PCIE_TX_BYTES, and so on, exactly the second-order metrics the reflection step pushes you to add. dcgm-exporter is the productionised wrapper: it runs as a DaemonSet on every GPU node, exposes a scrape target, and auto-labels metrics with gpu, modelName, and UUID. For a single workload or a development pod, raw pynvml is faster and lighter; for a cluster, run dcgm-exporter.

Why is 100 ms the right sampling cadence?

It's a pragmatic compromise. NVML query latency is on the order of a few milliseconds per field, so 100 ms leaves plenty of headroom without producing a crushing volume of time-series. It's fast enough to catch the GPU-idle gaps in a slow dataloader (batches typically arrive on the 30, 200 ms cadence), and slow enough that your sampler thread doesn't perturb the workload it's measuring. For production monitoring via dcgm-exporter, 1, 5 s scrape intervals are standard; for profiling a specific run, 10, 100 ms is where most teams land.

How does the dataloader fix actually close the utilization gap?

In the bad_history run, each batch is preprocessed on the CPU and copied to the GPU synchronously before the forward pass, the GPU sits idle waiting on H2D. The trace shows classic 'sawtooth' utilization: spikes during compute, drops to zero during copy. Moving the preprocessing to the GPU (or using pin_memory=True + num_workers>0 + non_blocking=True for async copies, or DALI for GPU-resident decode and augment) overlaps transfer with compute so the GPU stays hot. The 10+ percentage-point gain in the good run is the recovered idle time. The validator just needs good_mean - bad_mean >= 10.

What other metrics belong on a real training dashboard besides GPU util?

Step time (seconds per batch, the truth metric), tokens per second, MFU (Model FLOPS Utilization, achieved FLOPS divided by peak theoretical FLOPS, the only way to know if you're actually using the hardware), gradient allreduce time for distributed runs, PCIe and NVLink bytes for multi-GPU topology bottlenecks, SM_ACTIVE from DCGM, Tensor Core activity, CPU-side dataloader queue depth, and host-to-device bandwidth. The reflection step specifically asks you to pick the ones that would catch 'high utilization but the job is still slow', the answer usually starts with MFU and step time.

What you'll build in this GPU observability lab

GPU observability is table stakes for anyone running AI in production, if you can't tell the difference between a card at 90% 'utilization' that's actually Tensor-Core-bound and a card at 90% that's running a badly-coalesced kernel on a single SM, you can't size your fleet, can't diagnose a regression, and can't justify the spend. This lab takes you from a single nvidia-smi snapshot to a live Prometheus /metrics endpoint a Grafana dashboard can scrape in 40 minutes. You'll leave with a pynvml-based telemetry helper, a 100 ms background sampler running against a live matmul workload, the canonical dataloader-bottleneck diagnostic (CPU-side preprocessing vs GPU-resident data, with the 10+ percentage-point utilization gap made visible), and a prometheus_client Gauge exporter returning valid exposition format with # HELP and # TYPE lines. Once those gauges update correctly, dcgm-exporter + Grafana is a drop-in upgrade, this lab teaches you what it's actually doing.

The core technical lesson is that NVML's nvmlDeviceGetUtilizationRates().gpu, the number nvidia-smi prints, is 'percent of time at least one kernel was resident', not SM occupancy, not Tensor Core activity, not DRAM bandwidth. You can pin it at 100% with a deliberately bad kernel that uses one SM. Real production dashboards layer second-order metrics on top: DCGM_FI_DEV_SM_ACTIVE (SM activity), DCGM_FI_DEV_TENSOR_ACTIVE (Tensor Core utilization), DCGM_FI_DEV_DRAM_ACTIVE (memory bandwidth), DCGM_FI_DEV_PCIE_TX_BYTES / NVLINK_TX_BYTES (interconnect throughput), DCGM_FI_DEV_XID_ERRORS (hardware faults), plus step time, tokens/sec, and MFU, the only metric that tells you whether you're actually using the silicon you're paying for. 100 ms sampling is the right cadence: fast enough to catch dataloader gaps on 30-200 ms batch boundaries, slow enough that the sampler thread doesn't perturb the workload it measures. For cluster-wide monitoring you scrape dcgm-exporter at 1-5 s; for profiling a specific run, 10-100 ms is where teams land. Prometheus exposition format is the universal currency, once your endpoint is valid, Grafana, Alertmanager, VictoriaMetrics, Thanos, Mimir, and Datadog's OpenMetrics ingest all speak the same dialect, and you get alerting, long-term storage, and federation for free.

Prereqs: Python threads and background loops, basic PyTorch (tensors, dataloaders), and conceptual familiarity with the Prometheus metrics format. Preinstalled on the lab pod: pynvml, prometheus_client, PyTorch, the NVIDIA driver. Grading is concrete at every cell, gpu_info must contain all six named fields in plausible ranges, the sampler must collect ≥20 time-series samples with mean utilization >20%, the dataloader fix must close the gap by ≥10 percentage points, and the scraped /metrics must contain all three custom gauges plus valid # HELP / # TYPE headers, so what you build is a functioning exporter, not a toy.