Skip to content

Commit 0e3efd6

Browse files
authored
Merge branch 'main' into nielsb/agent-fork-sdk-utils
2 parents cc91db8 + da1e6cb commit 0e3efd6

2 files changed

Lines changed: 436 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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

Comments
 (0)