|
| 1 | +import ctypes |
| 2 | +import ctypes.util |
1 | 3 | import logging |
| 4 | +import os |
2 | 5 | import random |
| 6 | +import statistics |
3 | 7 | from contextlib import contextmanager, nullcontext |
4 | 8 | from dataclasses import dataclass, field |
5 | 9 | from pathlib import Path |
6 | 10 | from typing import Literal |
7 | | -from collections.abc import Callable |
| 11 | +from collections.abc import Callable, Sequence |
8 | 12 |
|
9 | 13 | import torch |
10 | 14 | import torch.utils.benchmark as benchmark |
|
18 | 22 | logger.addHandler(logging.NullHandler()) |
19 | 23 |
|
20 | 24 |
|
| 25 | +def _nvml(): |
| 26 | + library_path = ctypes.util.find_library("nvidia-ml") or "libnvidia-ml.so.1" |
| 27 | + try: |
| 28 | + return ctypes.CDLL(library_path) |
| 29 | + except OSError as exc: |
| 30 | + raise RuntimeError(f"Unable to load NVML from {library_path}") from exc |
| 31 | + |
| 32 | + |
| 33 | +def _check_nvml_status(status: int, operation: str): |
| 34 | + if status != 0: |
| 35 | + raise RuntimeError(f"NVML call failed for {operation} with status {status}") |
| 36 | + |
| 37 | + |
| 38 | +def _get_nvml_handle(nvml, device: int = 0): |
| 39 | + handle = ctypes.c_void_p() |
| 40 | + get_handle = getattr(nvml, "nvmlDeviceGetHandleByIndex_v2", None) |
| 41 | + if get_handle is None: |
| 42 | + get_handle = nvml.nvmlDeviceGetHandleByIndex |
| 43 | + status = get_handle(ctypes.c_uint(device), ctypes.byref(handle)) |
| 44 | + _check_nvml_status(status, f"device handle for device {device}") |
| 45 | + return handle |
| 46 | + |
| 47 | + |
| 48 | +def _get_max_sm_clock(nvml, handle) -> int: |
| 49 | + nvml_clock_type_sm = ctypes.c_uint(1) |
| 50 | + clock_mhz = ctypes.c_uint() |
| 51 | + status = nvml.nvmlDeviceGetMaxClockInfo(handle, nvml_clock_type_sm, ctypes.byref(clock_mhz)) |
| 52 | + _check_nvml_status(status, "max SM clock") |
| 53 | + return int(clock_mhz.value) |
| 54 | + |
| 55 | + |
21 | 56 | @contextmanager |
22 | 57 | def locked_clocks(device: int = 0, clock_mhz: int | None = None): |
23 | 58 | """Lock GPU SM clocks for stable benchmarking. |
24 | 59 |
|
25 | | - Uses ``sudo nvidia-smi -lgc`` to lock and ``sudo nvidia-smi -rgc`` to |
26 | | - reset. Will prompt for a password in interactive terminals. |
| 60 | + Requires root and uses ``nvidia-smi -lgc`` to lock and ``nvidia-smi -rgc`` |
| 61 | + to reset. |
27 | 62 |
|
28 | 63 | Args: |
29 | 64 | device: CUDA device index. |
30 | 65 | clock_mhz: SM clock frequency in MHz. If None, locks to the GPU's max SM clock. |
31 | 66 | """ |
32 | 67 | import subprocess |
33 | 68 |
|
| 69 | + if os.geteuid() != 0: |
| 70 | + raise RuntimeError("Requires root to lock GPU clocks") |
| 71 | + |
34 | 72 | if clock_mhz is None: |
35 | | - clock_mhz = int( |
36 | | - subprocess.check_output( |
37 | | - [ |
38 | | - "nvidia-smi", |
39 | | - "-i", |
40 | | - str(device), |
41 | | - "--query-gpu=clocks.max.sm", |
42 | | - "--format=csv,noheader,nounits", |
43 | | - ], |
44 | | - text=True, |
45 | | - ).strip() |
46 | | - ) |
| 73 | + nvml = _nvml() |
| 74 | + status = nvml.nvmlInit() |
| 75 | + _check_nvml_status(status, "nvmlInit") |
| 76 | + try: |
| 77 | + clock_mhz = _get_max_sm_clock(nvml, _get_nvml_handle(nvml, device)) |
| 78 | + finally: |
| 79 | + shutdown = nvml.nvmlShutdown() |
| 80 | + _check_nvml_status(shutdown, "nvmlShutdown") |
47 | 81 |
|
48 | | - subprocess.check_call( |
49 | | - ["sudo", "nvidia-smi", "-i", str(device), "-lgc", f"{clock_mhz},{clock_mhz}"] |
50 | | - ) |
| 82 | + subprocess.check_call(["nvidia-smi", "-i", str(device), "-lgc", f"{clock_mhz},{clock_mhz}"]) |
51 | 83 | logger.info(f"Locked GPU {device} SM clocks to {clock_mhz} MHz") |
52 | 84 | try: |
53 | 85 | yield clock_mhz |
54 | 86 | finally: |
55 | | - subprocess.call(["sudo", "nvidia-smi", "-i", str(device), "-rgc"]) |
| 87 | + subprocess.call(["nvidia-smi", "-i", str(device), "-rgc"]) |
56 | 88 | logger.info(f"Reset GPU {device} SM clocks") |
57 | 89 |
|
58 | 90 |
|
@@ -97,6 +129,186 @@ class ProfileConfig: |
97 | 129 | row_limit: int = 10 |
98 | 130 |
|
99 | 131 |
|
| 132 | +@dataclass(frozen=True) |
| 133 | +class CudaBenchmarkStats: |
| 134 | + samples_us: tuple[float, ...] |
| 135 | + median_us: float |
| 136 | + median_ci_us: tuple[float, float] |
| 137 | + quantiles_us: tuple[float, float, float] |
| 138 | + confidence: float |
| 139 | + |
| 140 | + @staticmethod |
| 141 | + def quantile(samples: Sequence[float], q: float) -> float: |
| 142 | + if not 0.0 <= q <= 1.0: |
| 143 | + raise ValueError(f"q must be in [0, 1], got {q}") |
| 144 | + if len(samples) == 0: |
| 145 | + raise ValueError("samples must be non-empty") |
| 146 | + |
| 147 | + ordered = sorted(float(sample) for sample in samples) |
| 148 | + if len(ordered) == 1: |
| 149 | + return ordered[0] |
| 150 | + |
| 151 | + position = (len(ordered) - 1) * q |
| 152 | + lower_idx = int(position) |
| 153 | + upper_idx = min(lower_idx + 1, len(ordered) - 1) |
| 154 | + if lower_idx == upper_idx: |
| 155 | + return ordered[lower_idx] |
| 156 | + |
| 157 | + weight = position - lower_idx |
| 158 | + lower = ordered[lower_idx] |
| 159 | + upper = ordered[upper_idx] |
| 160 | + return lower + (upper - lower) * weight |
| 161 | + |
| 162 | + @classmethod |
| 163 | + def bootstrap_median_confidence_interval( |
| 164 | + cls, |
| 165 | + samples: Sequence[float], |
| 166 | + confidence: float = 0.95, |
| 167 | + n_resamples: int = 1000, |
| 168 | + seed: int = 0, |
| 169 | + ) -> tuple[float, float]: |
| 170 | + """Estimate a percentile bootstrap confidence interval for the sample median. |
| 171 | +
|
| 172 | + This uses the standard nonparametric bootstrap: resample the observed |
| 173 | + timings with replacement, compute the median of each resample, then take |
| 174 | + lower and upper quantiles of that bootstrap distribution. The intuition |
| 175 | + is that the empirical sample distribution stands in for the unknown |
| 176 | + underlying timing distribution, so repeated draws from the observed |
| 177 | + samples approximate repeated draws from the process that produced them. |
| 178 | +
|
| 179 | + This does not assume a specific parametric input distribution such as a |
| 180 | + Gaussian. It does assume the timings are a reasonable sample from one |
| 181 | + stable benchmark regime and are approximately exchangeable, which in |
| 182 | + practice means: same workload, after warmup, without strong time-order |
| 183 | + effects such as thermal drift, autotuning phase changes, or one-time |
| 184 | + allocator/startup behavior dominating the run. |
| 185 | +
|
| 186 | + Like any bootstrap interval, this can be unstable with very small sample |
| 187 | + counts. There is no universal minimum, but single-digit samples are weak |
| 188 | + and even low tens should be treated cautiously. For benchmark summaries, |
| 189 | + this is most credible once you have enough steady-state samples that the |
| 190 | + median is no longer moving much when a few points are added or removed. |
| 191 | + """ |
| 192 | + if not 0 < confidence < 1: |
| 193 | + raise ValueError(f"confidence must be in (0, 1), got {confidence}") |
| 194 | + if len(samples) == 0: |
| 195 | + raise ValueError("samples must be non-empty") |
| 196 | + if len(samples) == 1: |
| 197 | + value = float(samples[0]) |
| 198 | + return value, value |
| 199 | + |
| 200 | + rng = random.Random(seed) |
| 201 | + estimates = [ |
| 202 | + statistics.median(rng.choices(samples, k=len(samples))) |
| 203 | + for _ in range(max(1, n_resamples)) |
| 204 | + ] |
| 205 | + alpha = 1.0 - confidence |
| 206 | + return cls.quantile(estimates, alpha / 2), cls.quantile(estimates, 1.0 - alpha / 2) |
| 207 | + |
| 208 | + @classmethod |
| 209 | + def from_samples( |
| 210 | + cls, |
| 211 | + samples_us: Sequence[float], |
| 212 | + confidence: float = 0.95, |
| 213 | + n_resamples: int = 1000, |
| 214 | + seed: int = 0, |
| 215 | + ) -> "CudaBenchmarkStats": |
| 216 | + samples = tuple(float(sample) for sample in samples_us) |
| 217 | + quantiles_us = ( |
| 218 | + cls.quantile(samples, 0.05), |
| 219 | + cls.quantile(samples, 0.50), |
| 220 | + cls.quantile(samples, 0.95), |
| 221 | + ) |
| 222 | + return cls( |
| 223 | + samples_us=samples, |
| 224 | + median_us=quantiles_us[1], |
| 225 | + median_ci_us=cls.bootstrap_median_confidence_interval( |
| 226 | + samples, |
| 227 | + confidence=confidence, |
| 228 | + n_resamples=n_resamples, |
| 229 | + seed=seed, |
| 230 | + ), |
| 231 | + quantiles_us=quantiles_us, |
| 232 | + confidence=confidence, |
| 233 | + ) |
| 234 | + |
| 235 | + @property |
| 236 | + def p05_us(self) -> float: |
| 237 | + return self.quantiles_us[0] |
| 238 | + |
| 239 | + @property |
| 240 | + def p50_us(self) -> float: |
| 241 | + return self.quantiles_us[1] |
| 242 | + |
| 243 | + @property |
| 244 | + def p95_us(self) -> float: |
| 245 | + return self.quantiles_us[2] |
| 246 | + |
| 247 | + |
| 248 | +def benchmark_cuda_function_stats(func: Callable, *args, **kwargs) -> CudaBenchmarkStats: |
| 249 | + """Benchmark a CUDA callable and return median-centered summary stats. |
| 250 | +
|
| 251 | + This collects per-iteration timings from Inductor's GPU benchmarker and |
| 252 | + returns the raw samples, the sample median, a bootstrap confidence interval |
| 253 | + for that median, and `(p05, p50, p95)` sample quantiles. |
| 254 | +
|
| 255 | + Args: |
| 256 | + func: Callable to benchmark. |
| 257 | + *args: Positional arguments forwarded to ``func``. |
| 258 | + **kwargs: Benchmark configuration and keyword arguments forwarded to |
| 259 | + ``func``. The following benchmark-control keys are consumed by this |
| 260 | + helper before calling ``func``: ``NUM_ITERS``, |
| 261 | + ``MEMORY_WARMUP_ITERS``, ``CONFIDENCE``, ``N_RESAMPLES``, ``SEED``, |
| 262 | + and ``IS_VETTED_BENCHMARKING``. |
| 263 | +
|
| 264 | + Returns: |
| 265 | + CudaBenchmarkStats with raw samples, the sample median, a bootstrap |
| 266 | + median confidence interval, and `(p05, p50, p95)` sample quantiles. |
| 267 | +
|
| 268 | + Notes: |
| 269 | + The bootstrap interval assumes the collected timings are representative |
| 270 | + samples from a single steady-state benchmark regime. It is most useful |
| 271 | + after warmup, when samples are not dominated by obvious drift or phase |
| 272 | + changes such as autotuning, thermal throttling, or one-time allocator |
| 273 | + effects. |
| 274 | +
|
| 275 | + Examples: |
| 276 | + Basic usage:: |
| 277 | +
|
| 278 | + stats = benchmark_cuda_function_stats(lambda: kernel(x, y), NUM_ITERS=200) |
| 279 | + print(stats.median_us) |
| 280 | + print(stats.median_ci_us) |
| 281 | + print(stats.quantiles_us) |
| 282 | +
|
| 283 | + With locked clocks:: |
| 284 | +
|
| 285 | + with locked_clocks(): |
| 286 | + stats = benchmark_cuda_function_stats(lambda: kernel(x, y), NUM_ITERS=200) |
| 287 | + """ |
| 288 | + num_iters = kwargs.pop("NUM_ITERS", 100) |
| 289 | + memory_warmup_iters = kwargs.pop("MEMORY_WARMUP_ITERS", 100) |
| 290 | + confidence = kwargs.pop("CONFIDENCE", 0.95) |
| 291 | + n_resamples = kwargs.pop("N_RESAMPLES", 1000) |
| 292 | + seed = kwargs.pop("SEED", 0) |
| 293 | + is_vetted_benchmarking = kwargs.pop("IS_VETTED_BENCHMARKING", False) |
| 294 | + no_args = lambda: func(*args, **kwargs) |
| 295 | + from torch._inductor.runtime.benchmarking import benchmarker |
| 296 | + |
| 297 | + samples_ms = benchmarker.benchmark_gpu( |
| 298 | + no_args, |
| 299 | + benchmark_iters=num_iters, |
| 300 | + memory_warmup_iters=memory_warmup_iters, |
| 301 | + return_mode="all", |
| 302 | + is_vetted_benchmarking=is_vetted_benchmarking, |
| 303 | + ) |
| 304 | + return CudaBenchmarkStats.from_samples( |
| 305 | + (float(sample) * 1e3 for sample in samples_ms), |
| 306 | + confidence=confidence, |
| 307 | + n_resamples=n_resamples, |
| 308 | + seed=seed, |
| 309 | + ) |
| 310 | + |
| 311 | + |
100 | 312 | def benchmark_torch_function_in_microseconds(func: Callable, *args, **kwargs) -> float: |
101 | 313 | lock = kwargs.pop("LOCK_CLOCKS", False) |
102 | 314 | ctx = locked_clocks() if lock else nullcontext() |
|
0 commit comments