hands-on labon a real NVIDIA GPU

GPU Cost & Efficiency Audit

Build a four-stage cost-audit pipeline, measure, classify, price, recommend, that turns raw NVML samples into dollar-denominated waste and specific remediation actions.

time
35 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 cockpit35 min · 4 stepsSession running
2 / 4 steps passingAttach dollars to the waste · step 3 of 4
lab.ipynb

Step 3 — Attach dollars to the waste

The classifier output becomes actionable only when you translate it into dollars. Below are reserved-instance on-demand rates as of 2026 from major cloud providers.

[ ]:
# On-demand $/hour for common SKUs (approximate, as of 2026).# Real prices vary by region/commitment; update per your contract.gpu_pricing = {    'A10 (AWS g5.xlarge)':       1.01,    'A100-40GB (AWS p4d)':       3.06,    'A100-80GB (Lambda)':        1.79,    'H100-80GB (Lambda)':        2.49,    'H100-80GB (AWS p5)':        4.10,    'H100-80GB SXM (on-demand)': 9.98,} # Pick the SKU that matches your workload. The example uses A100-80GB on Lambda.selected_gpu = 'A100-80GB (Lambda)'hourly = gpu_pricing[selected_gpu]wasted_pct = phase_stats['idle_pct'] + phase_stats['underutilized_pct']cost_report = {    'gpu_type':              selected_gpu,    'hourly_rate':           hourly,    'wasted_pct':            wasted_pct,    'wasted_per_hour_usd':   hourly * wasted_pct / 100,    'wasted_per_month_usd':  hourly * wasted_pct / 100 * 24 * 30,    'wasted_per_year_usd':   hourly * wasted_pct / 100 * 24 * 365,}print(f"=== Cost report for {selected_gpu} at ${hourly}/h ===")print(f"  Wasted time:      {wasted_pct:5.1f}% (idle + underutilized)")print(f"  Hourly waste:     ${cost_report['wasted_per_hour_usd']:8.2f}")print(f"  Monthly (24x7):   ${cost_report['wasted_per_month_usd']:8,.0f}")print(f"  Annual (24x7):    ${cost_report['wasted_per_year_usd']:8,.0f}")print()print("Compare across SKUs (all at current wasted_pct):")for sku, rate in gpu_pricing.items():    waste_h = rate * wasted_pct / 100    print(f"  {sku:<30s}  ${rate:5.2f}/h  wasted ${waste_h:5.2f}/h  ≈ ${waste_h*24*365:7,.0f}/yr")
[ ]:
lab.check(3)
step 3 of 4, replayed: Attach dollars to the waste. 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

    Sample a live workload

    read this step below ↓

  2. 2

    Classify every second into a phase

  3. 3

    Attach dollars to the waste

  4. 4

    The recommender

Step 1 as it appears in the lab

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

Step 1: Sample a live workload

We'll launch a deliberately bad training loop: tiny model, ~40 ms of pure-Python preprocessing per batch, then forward/backward. This guarantees the GPU spends most of its time waiting for the dataloader. That's the common case in the wild.

The sampler runs in a background thread at 10 Hz while the workload executes in the main thread. We capture: GPU utilization (%), memory used (%), temperature (C), power (W), and a monotonic timestamp.

Why 10 Hz and not faster? pynvml calls take ~1 ms each. 100 Hz is feasible but adds thread-contention noise. 10 Hz is the standard for production monitoring (dcgm-exporter default scrape cadence is also ~1 Hz).

Why a background thread and not nvidia-smi --loop=1? Subprocess + disk parsing each tick is 10× slower than pynvml. And we want the samples in-memory anyway.

Build the NVML sampler — a background thread that captures GPU state 10×/sec

A sampler pattern is the right shape for audit: non-intrusive background thread, captures state at a fixed cadence into a list, sampling overhead is ~20µs per call so it doesn't perturb the workload. Context manager boundaries (__enter__/__exit__) make it impossible to forget to stop the thread.

import pynvml, threading, time, torch, torch.nn as nn

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

def sample_once():
    util = pynvml.nvmlDeviceGetUtilizationRates(handle)
    mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
    temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
    power_w = pynvml.nvmlDeviceGetPowerUsage(handle) / 1000.0
    return {
        'timestamp': time.monotonic(),
        'gpu_util': int(util.gpu),
        'mem_util': int(util.memory),
        'memory_pct': 100.0 * mem.used / mem.total,
        'temperature_c': int(temp),
        'power_w': float(power_w),
    }

class Sampler:
    def __init__(self, hz=10):
        self.samples = []
        self.stop_flag = threading.Event()
        self.thread = threading.Thread(target=self._run, args=(1.0 / hz,), daemon=True)
    def _run(self, period):
        while not self.stop_flag.is_set():
            self.samples.append(sample_once())
            time.sleep(period)
    def __enter__(self):
        self.thread.start(); return self
    def __exit__(self, *a):
        self.stop_flag.set(); self.thread.join(timeout=2)

The deliberately-bad training loop

A 40ms sleep before each GPU step simulates slow CPU-side preprocessing (tokenization, augmentation). That's the #1 reason production training GPUs idle. We run it for 15s while the sampler captures utilization so we can see the dips.

# Deliberately-bad training loop: dataloader slower than the GPU can consume.
device = 'cuda'
model = nn.Sequential(nn.Linear(2048, 4096), nn.GELU(), nn.Linear(4096, 1000)).to(device)
opt = torch.optim.SGD(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

def bad_training_step():
    # Simulate slow CPU-side preprocessing (tokenization, augmentation, etc.)
    time.sleep(0.04)
    x = torch.randn(32, 2048, device=device)
    y = torch.randint(0, 1000, (32,), device=device)
    opt.zero_grad()
    loss = loss_fn(model(x), y)
    loss.backward()
    opt.step()
    torch.cuda.synchronize()

# Warm up so first-batch CUDA init isn't counted
for _ in range(3):
    bad_training_step()

with Sampler(hz=10) as s:
    t0 = time.time()
    while time.time() - t0 < 15:
        bad_training_step()

samples = s.samples
utils = [r['gpu_util'] for r in samples]
print(f"Collected {len(samples)} samples over {samples[-1]['timestamp']-samples[0]['timestamp']:.1f}s")
print(f"gpu_util: min={min(utils)}% max={max(utils)}% mean={sum(utils)/len(utils):.1f}%")

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

  • Basic PyTorch training loop
  • Familiarity with nvidia-smi / NVML concepts (utilization, memory, power)
  • Comfortable with Python threading and decorators

Exam domains covered

GPU Infrastructure & OperationsModel Deployment & Inference Optimization

Part of the GPU and Kubernetes track

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

Questions about this lab

Why 10 Hz sampling? Won't that miss bursty kernels?

10 Hz is NVML's comfortable operating range, NVML queries have per-call overhead and coarser averaging windows inside the driver, so sampling much faster mostly buys you correlated samples rather than resolution. For the classification task in step 2, 'what fraction of seconds were idle vs productive?', 10 Hz is plenty. If you want sub-millisecond visibility into kernel-level stalls you use torch.profiler or Nsight Systems instead; NVML is the right tool for the cost-accounting question, not for kernel-level tuning.

Why does the lab split utilization into four buckets instead of a single mean?

A single mean hides the shape. A GPU at 50% mean utilization could be bouncing between 100% and 0% every other second (dataloader-starved, fixable with DALI) or holding steady at 50% (kernel throughput-bound, fixable with larger batches or mixed precision), same mean, completely different remediation. The four-bucket taxonomy, idle / underutilized / productive / compute-bound, separates those cases cleanly and maps each one to a different row in the recommender.

My recommender output total_savings > 100%. Is the validator wrong?

No, the validator is deliberately lenient and that's exactly the antipattern the reflection step calls out. MPS (reclaim idle time) and a DALI upgrade (reclaim underutilized time) partially overlap because they both recover the same wall-clock waste. You can't sum them. The principled combining rule is to convert each remediation to a reclaimed-fraction-of-wall-clock, union them (1 - product(1 - f_i)), cap by the measured baseline waste, and then multiply by the rate-reduction from spot. The lab intentionally lets you emit the naive sum so you notice the mistake.

Do spot-instance savings stack with utilization fixes?

They compose multiplicatively, not additively. A 60% spot discount on top of a fix that reclaims 40% of wasted wall-clock doesn't give you 100% savings, it gives you roughly 1 - (0.4 × 0.6) = 76%. Spot reduces the rate you pay per GPU-hour; utilization fixes reduce the GPU-hours you need. Different axes. The reflection rubric specifically asks you to separate 'compounding dimensions' (rate × utilization) from 'overlapping dimensions' (MPS vs DALI).

Why use pynvml directly instead of DCGM or dcgm-exporter?

Nothing about this pipeline requires DCGM, and NVML is the right level of abstraction for a per-pod cost probe, no extra daemon, no Prometheus dependency, trivial to embed in a training entrypoint. DCGM wins when you want richer fleet-level fields (SM_ACTIVE, DRAM_ACTIVE, TENSOR_CORE_ACTIVE, DCGM_FI_DEV_XID_ERRORS) and a structured metrics pipeline. This lab stays at NVML so you focus on the cost-accounting logic, not on standing up an exporter, the concepts port directly when you swap the sampler for dcgm-exporter queries later.

What counts as 'productive' utilization in the classifier?

The lab uses 70, 90% as the productive band and >=90% as compute-bound. The distinction matters: a workload pinned at 90%+ is already saturating the SMs and your remaining levers are precision (FP16/BF16/FP8), Tensor Core adoption, and fused kernels, not dataloader tweaks. A workload consistently in the 70, 90% band has real headroom from bigger batches, overlap between H2D copies and compute, or moving to a smaller GPU SKU. The recommender branches on this: compute-bound rows suggest AMP; underutilized rows suggest num_workers and DALI.

What you'll build in this GPU cost audit lab

GPU bills dominate AI infra budgets, H100s at $3, 5/hour, A100s at $1.50, $3, and fleets that sit at 15, 30% average utilization are the norm, not the exception. That gap is where GPU FinOps lives, and this lab builds the skeleton every enterprise GPU cost product (Kubecost, CoreWeave dashboards, Run:ai reports) runs under the hood: measure with NVML, classify time into waste buckets, price the waste in dollars, recommend remediations. You walk away with a 10 Hz sampler against a live PyTorch workload, a four-bucket utilization classifier, a cost_report with self-consistent hourly/monthly/yearly arithmetic, a rule-based recommender that speaks the real remediation vocabulary (DALI, num_workers, AMP, MPS, MIG, spot), and, most importantly, a mental model for why stacked savings percentages over 100% are the signature of a bad report. Roughly 35 minutes on a real NVIDIA GPU pod we provision.

The technical substance is in the classifier and the combining math, not the plumbing. A single mean utilization number lies: 50% mean could be a dataloader-starved workload bouncing between 100% and 0% (fix with DALI / pin_memory / num_workers) or a steady 50% kernel-bound workload (fix with larger batches, mixed precision, or a smaller SKU), same mean, completely different remediation. The four-bucket taxonomy, <10% idle, 10-70% underutilized, 70-90% productive, >=90% compute-bound, separates those cases cleanly, and the recommender branches on them: idle-heavy maps to MPS / MIG; underutilized + low memory maps to DALI / num_workers; compute-bound maps to AMP / FP8 / Tensor Cores. Pricing is where the real engineering shows up, spot discounts compose multiplicatively with utilization fixes, MPS and DALI overlap (both recover the same wall-clock waste), and the correct combining rule is 1 - product(1 - f_i) capped by measured baseline waste. If you've ever been asked 'how much can we save on GPUs next quarter', this is the defensible number. NVML sits at the right level for an embedded per-pod probe; when you need fleet telemetry with SM_ACTIVE, DRAM_ACTIVE, TENSOR_ACTIVE, you swap in dcgm-exporter and the rest of the pipeline is unchanged.

Prereqs: a basic PyTorch training loop, comfort with Python threading and decorators, and familiarity with nvidia-smi-style metrics (utilization, memory, power, temperature). Preinstalled on the lab pod: pynvml, PyTorch, the NVIDIA driver, and the CUDA toolkit. Grading is concrete at every cell, the sampler must collect ≥100 observations with real variance; phase percentages must sum to 100; the cost report's monthly projection must equal hourly × 24 × 30 to the cent; the recommender must reference ≥2 entries from a whitelist of real remediation patterns, so you finish with artifacts that would pass a real review, not toy outputs.