|
| 1 | +# /// script |
| 2 | +# requires-python = "==3.13" |
| 3 | +# dependencies = [ |
| 4 | +# "flyte", |
| 5 | +# "torch==2.7.1", |
| 6 | +# ] |
| 7 | +# /// |
| 8 | +""" |
| 9 | +Exercise every GPU panel on the run details Metrics tab. |
| 10 | +
|
| 11 | +The task walks through distinct load regimes, each held long enough to be visible at the |
| 12 | +console's polling resolution, so each chart has a recognizable signature: |
| 13 | +
|
| 14 | + tensor dense fp16 matmul -> GPU util, SM active/occupancy, tensor-core activity, |
| 15 | + power draw, temperature, SM clock |
| 16 | + bandwidth large elementwise sweeps -> DRAM active, memory clock, framebuffer used |
| 17 | + pcie pinned host<->device copies -> PCIe TX/RX |
| 18 | + nvlink peer-to-peer device copies -> NVLink bandwidth (needs 2+ GPUs on one node) |
| 19 | + idle sleep -> everything drops, so regime edges are obvious |
| 20 | +
|
| 21 | +Pass trigger_xid=True to end the run with a deliberate out-of-bounds store from a Triton |
| 22 | +kernel. That produces Xid 31 (GPU memory page fault) in the driver log, which DCGM reports |
| 23 | +through DCGM_FI_DEV_XID_ERRORS and the console renders on the Xid strip. It kills the CUDA |
| 24 | +context, so it always runs last; the task idles briefly, triggers, then lingers xid_linger_s |
| 25 | +(default 45s) so DCGM scrapes the code while this pod still owns the GPU, and returns |
| 26 | +successfully. After the fault the DCP profiling counters (SM active/occupancy, tensor, DRAM) |
| 27 | +hold their last value until the process exits, so keep the linger short and use the default |
| 28 | +duration so the phases before the fault are what dominates the charts. |
| 29 | +
|
| 30 | +after_xid decides how the attempt ends once the fault has been scraped, which is what |
| 31 | +selects between the three ways a GPU fault reaches the console: |
| 32 | +
|
| 33 | + return finish normally -> the fault shows on the charts only |
| 34 | + fail raise a RuntimeError -> a task failure with the fault alongside it, |
| 35 | + the shape a workload takes when its next CUDA |
| 36 | + call raises after the context has died |
| 37 | + kill exit the process with 137 -> a pod-level failure with no error record, the |
| 38 | + shape a hardware fault usually takes |
| 39 | +
|
| 40 | + flyte run examples/accelerators/gpu_metrics.py main --trigger_xid --after_xid fail |
| 41 | +
|
| 42 | +Pick the accelerator with GPU_METRICS_DEVICE. The default is T4:1. A bare number requests |
| 43 | +that many GPUs with no device pin, which is the way to reach a multi-GPU node whose |
| 44 | +accelerator label the device map does not know: |
| 45 | +
|
| 46 | + flyte run examples/accelerators/gpu_metrics.py main --duration_s 480 |
| 47 | + GPU_METRICS_DEVICE=2 flyte run examples/accelerators/gpu_metrics.py main --duration_s 600 |
| 48 | +
|
| 49 | +Throttling and memory-error panels need the matching dcgm-exporter fields enabled on the |
| 50 | +cluster; on a T4, remapped rows is absent (Ampere and newer only). |
| 51 | +""" |
| 52 | + |
| 53 | +import os |
| 54 | +import subprocess |
| 55 | +import time |
| 56 | +from typing import Any |
| 57 | + |
| 58 | +import flyte |
| 59 | + |
| 60 | +# Triton ships with torch on Linux but not on macOS, and the script is also imported locally by |
| 61 | +# `flyte run`, so keep it optional. The kernel has to be defined at module scope: Triton's JIT |
| 62 | +# resolves names in the kernel body and its annotations (`tl.constexpr`) from the function's |
| 63 | +# globals, so a `tl` imported inside a helper is invisible to it. |
| 64 | +try: |
| 65 | + import triton |
| 66 | + import triton.language as tl |
| 67 | +except ImportError: # pragma: no cover - local import on macOS |
| 68 | + triton = None |
| 69 | + tl = None |
| 70 | + |
| 71 | +if triton is not None and tl is not None: |
| 72 | + |
| 73 | + @triton.jit |
| 74 | + def _oob_store(ptr, stride, BLOCK: tl.constexpr): |
| 75 | + # stride of 2^28 floats = 1 GiB per lane, so every lane past the first lands far outside |
| 76 | + # the 16-float buffer this is launched with: an illegal address, i.e. Xid 31. |
| 77 | + # int64 offsets: 1023 lanes x 2^28 overflows int32 and would wrap back into range. |
| 78 | + # The module-level guard cannot narrow `tl` here: this body runs when the kernel is |
| 79 | + # launched, so the checker sees the optional import rather than the guarded value. |
| 80 | + offs = tl.arange(0, BLOCK).to(tl.int64) * stride # ty: ignore[unresolved-attribute] |
| 81 | + tl.store(ptr + offs, tl.zeros([BLOCK], dtype=tl.float32)) # ty: ignore[unresolved-attribute] |
| 82 | + |
| 83 | + |
| 84 | +# What the task does once the Xid has been scraped. "return" finishes normally, "fail" |
| 85 | +# raises, and "kill" exits the process outright. See the module docstring. |
| 86 | +_AFTER_XID_MODES = ("return", "fail", "kill") |
| 87 | + |
| 88 | +_device_env = os.environ.get("GPU_METRICS_DEVICE") or "T4:1" |
| 89 | +# "2" -> two GPUs of any kind (no accelerator selector); "T4:1" -> the pinned device. |
| 90 | +DEVICE: str | int = int(_device_env) if _device_env.isdigit() else _device_env |
| 91 | + |
| 92 | +image = flyte.Image.from_uv_script(__file__, name="gpu-metrics") |
| 93 | + |
| 94 | +env = flyte.TaskEnvironment( |
| 95 | + name="gpu_metrics", |
| 96 | + resources=flyte.Resources(cpu=2, memory="10Gi", gpu=DEVICE, shm="auto"), # type: ignore[arg-type] # ty: ignore[invalid-argument-type] |
| 97 | + image=image, |
| 98 | +) |
| 99 | + |
| 100 | + |
| 101 | +def _log(msg: str) -> None: |
| 102 | + print(f"[gpu-metrics {time.strftime('%H:%M:%S')}] {msg}", flush=True) |
| 103 | + |
| 104 | + |
| 105 | +def _nvidia_smi() -> None: |
| 106 | + try: |
| 107 | + out = subprocess.run( |
| 108 | + ["nvidia-smi", "--query-gpu=index,name,uuid,driver_version,memory.total", "--format=csv"], |
| 109 | + capture_output=True, |
| 110 | + text=True, |
| 111 | + timeout=20, |
| 112 | + check=False, |
| 113 | + ) |
| 114 | + _log("nvidia-smi:\n" + out.stdout.strip()) |
| 115 | + except (OSError, subprocess.SubprocessError) as e: # nvidia-smi absent inside some images |
| 116 | + _log(f"nvidia-smi unavailable: {e}") |
| 117 | + |
| 118 | + |
| 119 | +def _phase_tensor(torch: Any, dev: Any, seconds: float, n: int = 8192) -> float: |
| 120 | + """Dense fp16 matmul, the tensor-core hot loop. Returns achieved TFLOP/s.""" |
| 121 | + a = torch.randn(n, n, device=dev, dtype=torch.float16) |
| 122 | + b = torch.randn(n, n, device=dev, dtype=torch.float16) |
| 123 | + flops_per = 2.0 * n * n * n |
| 124 | + iters, t0 = 0, time.perf_counter() |
| 125 | + while time.perf_counter() - t0 < seconds: |
| 126 | + for _ in range(8): |
| 127 | + a = a @ b |
| 128 | + a.mul_(1e-3) # keep values from overflowing fp16 |
| 129 | + torch.cuda.synchronize(dev) |
| 130 | + iters += 8 |
| 131 | + return iters * flops_per / (time.perf_counter() - t0) / 1e12 |
| 132 | + |
| 133 | + |
| 134 | +def _phase_bandwidth(torch: Any, dev: Any, seconds: float, gib: float = 4.0) -> float: |
| 135 | + """Elementwise sweeps over a large buffer, memory-bound. Returns GB/s of traffic.""" |
| 136 | + n = int(gib * (1 << 30) / 4) |
| 137 | + x = torch.randn(n, device=dev, dtype=torch.float32) |
| 138 | + y = torch.empty_like(x) |
| 139 | + moved, t0 = 0, time.perf_counter() |
| 140 | + while time.perf_counter() - t0 < seconds: |
| 141 | + for _ in range(4): |
| 142 | + torch.add(x, 1.0, out=y) # read x, write y |
| 143 | + torch.mul(y, 0.5, out=x) # read y, write x |
| 144 | + torch.cuda.synchronize(dev) |
| 145 | + moved += 4 * 2 * 2 * x.numel() * 4 |
| 146 | + return moved / (time.perf_counter() - t0) / 1e9 |
| 147 | + |
| 148 | + |
| 149 | +def _phase_pcie(torch: Any, dev: Any, seconds: float, mib: int = 512) -> float: |
| 150 | + """Pinned host<->device round trips. Returns GB/s across the bus (both directions).""" |
| 151 | + host = torch.empty(mib << 20, dtype=torch.uint8).pin_memory() |
| 152 | + device = torch.empty(mib << 20, dtype=torch.uint8, device=dev) |
| 153 | + moved, t0 = 0, time.perf_counter() |
| 154 | + while time.perf_counter() - t0 < seconds: |
| 155 | + device.copy_(host, non_blocking=True) |
| 156 | + host.copy_(device, non_blocking=True) |
| 157 | + torch.cuda.synchronize(dev) |
| 158 | + moved += 2 * host.numel() |
| 159 | + return moved / (time.perf_counter() - t0) / 1e9 |
| 160 | + |
| 161 | + |
| 162 | +def _phase_nvlink(torch: Any, seconds: float, gib: float = 1.0) -> float | None: |
| 163 | + """Peer-to-peer copies between GPU 0 and GPU 1. Returns GB/s, or None with one GPU.""" |
| 164 | + if torch.cuda.device_count() < 2: |
| 165 | + _log("nvlink phase skipped: fewer than 2 GPUs visible") |
| 166 | + return None |
| 167 | + if not torch.cuda.can_device_access_peer(0, 1): |
| 168 | + _log("nvlink phase: peer access not available between GPU 0 and 1; copies go via host") |
| 169 | + n = int(gib * (1 << 30)) |
| 170 | + src = torch.empty(n, dtype=torch.uint8, device="cuda:0") |
| 171 | + dst = torch.empty(n, dtype=torch.uint8, device="cuda:1") |
| 172 | + moved, t0 = 0, time.perf_counter() |
| 173 | + while time.perf_counter() - t0 < seconds: |
| 174 | + dst.copy_(src, non_blocking=True) |
| 175 | + src.copy_(dst, non_blocking=True) |
| 176 | + torch.cuda.synchronize(0) |
| 177 | + torch.cuda.synchronize(1) |
| 178 | + moved += 2 * n |
| 179 | + return moved / (time.perf_counter() - t0) / 1e9 |
| 180 | + |
| 181 | + |
| 182 | +def _trigger_xid31(torch: Any) -> str: |
| 183 | + """Out-of-bounds store from the module-level Triton kernel: an illegal address, i.e. Xid 31.""" |
| 184 | + if triton is None: |
| 185 | + return "triton not importable in this image; Xid not triggered" |
| 186 | + |
| 187 | + buf = torch.zeros(16, device="cuda:0", dtype=torch.float32) |
| 188 | + _log("triggering Xid 31 with a deliberate out-of-bounds store; the CUDA context will die") |
| 189 | + try: |
| 190 | + _oob_store[(1,)](buf, 1 << 28, BLOCK=1024) |
| 191 | + torch.cuda.synchronize(0) |
| 192 | + except RuntimeError as e: # "an illegal memory access was encountered" |
| 193 | + return f"Xid 31 triggered: {str(e).splitlines()[0]}" |
| 194 | + except Exception as e: # compile-time surprises should not hide the rest of the run's results |
| 195 | + return f"Xid trigger failed before launch: {type(e).__name__}: {e}" |
| 196 | + return "kernel completed without a fault (unexpected); Xid not triggered" |
| 197 | + |
| 198 | + |
| 199 | +@env.task |
| 200 | +def main( |
| 201 | + duration_s: int = 600, |
| 202 | + phase_s: int = 60, |
| 203 | + idle_s: int = 20, |
| 204 | + trigger_xid: bool = False, |
| 205 | + xid_linger_s: int = 45, |
| 206 | + after_xid: str = "return", |
| 207 | +) -> dict[str, Any]: |
| 208 | + import torch |
| 209 | + |
| 210 | + if after_xid not in _AFTER_XID_MODES: |
| 211 | + raise ValueError(f"after_xid must be one of {sorted(_AFTER_XID_MODES)}, got {after_xid!r}") |
| 212 | + |
| 213 | + _nvidia_smi() |
| 214 | + if not torch.cuda.is_available(): |
| 215 | + raise RuntimeError("CUDA is not available in this task; check the accelerator request") |
| 216 | + |
| 217 | + dev = torch.device("cuda:0") |
| 218 | + props = torch.cuda.get_device_properties(dev) |
| 219 | + _log(f"{torch.cuda.device_count()} GPU(s); GPU 0 = {props.name}, {props.total_memory / 2**30:.0f} GiB") |
| 220 | + |
| 221 | + results: dict[str, Any] = {"device": props.name, "gpu_count": torch.cuda.device_count(), "phases": []} |
| 222 | + t_end = time.time() + duration_s |
| 223 | + cycle = 0 |
| 224 | + while time.time() < t_end: |
| 225 | + cycle += 1 |
| 226 | + for name, fn in ( |
| 227 | + ("tensor", lambda: _phase_tensor(torch, dev, phase_s)), |
| 228 | + ("bandwidth", lambda: _phase_bandwidth(torch, dev, phase_s)), |
| 229 | + ("pcie", lambda: _phase_pcie(torch, dev, phase_s)), |
| 230 | + ("nvlink", lambda: _phase_nvlink(torch, phase_s)), |
| 231 | + ): |
| 232 | + if time.time() >= t_end: |
| 233 | + break |
| 234 | + _log(f"cycle {cycle}: {name} for {phase_s}s") |
| 235 | + value = fn() |
| 236 | + _log(f"cycle {cycle}: {name} -> {value}") |
| 237 | + results["phases"].append({"cycle": cycle, "phase": name, "value": value}) |
| 238 | + _log(f"idle for {idle_s}s") |
| 239 | + time.sleep(idle_s) |
| 240 | + |
| 241 | + if trigger_xid: |
| 242 | + # Let the GPU go quiet first, so the last real samples before the fault are a clean |
| 243 | + # idle baseline rather than the tail of a busy phase. |
| 244 | + _log(f"idle for {idle_s}s before triggering the Xid") |
| 245 | + time.sleep(idle_s) |
| 246 | + results["xid"] = _trigger_xid31(torch) |
| 247 | + _log(results["xid"]) |
| 248 | + # DCGM only attributes a GPU's samples to this pod while the pod is Running and holds |
| 249 | + # the device, and the Xid gauge is scraped every 15-30s. Returning right away would let |
| 250 | + # the pod exit before the scrape that carries the code, so stay alive for a few scrapes. |
| 251 | + # Keep this short: once the CUDA context is dead the DCP profiling counters (SM active, |
| 252 | + # SM occupancy, tensor, DRAM) hold their last value until the process exits, so a long |
| 253 | + # linger reads as a flat line on those charts. |
| 254 | + _log(f"lingering {xid_linger_s}s so DCGM scrapes the Xid while this pod still owns the GPU") |
| 255 | + time.sleep(xid_linger_s) |
| 256 | + if after_xid == "kill": |
| 257 | + # End the attempt the way a hardware fault usually does: the pod dies without |
| 258 | + # writing an error record, so the platform sees a pod-level failure rather than |
| 259 | + # a task that reported its own error. This cannot be a signal. The task is PID 1 |
| 260 | + # of its container, and the kernel drops signals sent to PID 1 from inside its |
| 261 | + # own namespace, SIGKILL included, so os.kill would return without doing |
| 262 | + # anything. Exit status 137 (128 + SIGKILL) is what the kubelet records for a |
| 263 | + # killed container, and exiting this way skips the runtime's error handling |
| 264 | + # just as a real kill would. |
| 265 | + _log("exiting with status 137 so the pod fails at the pod level") |
| 266 | + os._exit(137) |
| 267 | + if after_xid == "fail": |
| 268 | + # End the attempt as an ordinary task failure, the shape a workload takes when |
| 269 | + # its next CUDA call raises after the context has died. |
| 270 | + raise RuntimeError("failing after the deliberate Xid") |
| 271 | + |
| 272 | + return results |
| 273 | + |
| 274 | + |
| 275 | +if __name__ == "__main__": |
| 276 | + flyte.init_from_config() |
| 277 | + r = flyte.run(main, duration_s=480, trigger_xid=True) |
| 278 | + print(r.url) |
0 commit comments