Skip to content

Commit c6af6c4

Browse files
committed
ci benchmarker
1 parent fe44787 commit c6af6c4

3 files changed

Lines changed: 292 additions & 19 deletions

File tree

test/test_benchmark_stats.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import pytest
2+
3+
from transformer_nuggets.utils.benchmark import (
4+
CudaBenchmarkStats,
5+
benchmark_cuda_function_stats,
6+
)
7+
8+
9+
def test_benchmark_cuda_function_stats_uses_all_samples(monkeypatch):
10+
class FakeBenchmarker:
11+
def benchmark_gpu(self, fn, **kwargs):
12+
fn()
13+
assert kwargs["benchmark_iters"] == 7
14+
assert kwargs["memory_warmup_iters"] == 11
15+
assert kwargs["return_mode"] == "all"
16+
return [0.010, 0.012, 0.011]
17+
18+
monkeypatch.setattr("torch._inductor.runtime.benchmarking.benchmarker", FakeBenchmarker())
19+
20+
called = {"count": 0}
21+
22+
def fn():
23+
called["count"] += 1
24+
25+
stats = benchmark_cuda_function_stats(
26+
fn,
27+
NUM_ITERS=7,
28+
MEMORY_WARMUP_ITERS=11,
29+
CONFIDENCE=0.90,
30+
N_RESAMPLES=200,
31+
SEED=0,
32+
)
33+
34+
assert called["count"] == 1
35+
assert isinstance(stats, CudaBenchmarkStats)
36+
assert stats.samples_us == pytest.approx((10.0, 12.0, 11.0))
37+
assert stats.quantiles_us == pytest.approx((10.1, 11.0, 11.9))
38+
assert stats.p05_us == pytest.approx(10.1)
39+
assert stats.p50_us == pytest.approx(11.0)
40+
assert stats.p95_us == pytest.approx(11.9)
41+
assert stats.median_us == pytest.approx(11.0)
42+
assert stats.median_ci_us[0] <= stats.median_us <= stats.median_ci_us[1]
43+
assert stats.confidence == pytest.approx(0.90)
44+
45+
46+
def test_benchmark_cuda_function_stats_singleton(monkeypatch):
47+
class FakeBenchmarker:
48+
def benchmark_gpu(self, fn, **kwargs):
49+
fn()
50+
return [0.010]
51+
52+
monkeypatch.setattr("torch._inductor.runtime.benchmarking.benchmarker", FakeBenchmarker())
53+
54+
stats = benchmark_cuda_function_stats(lambda: None)
55+
56+
assert stats.samples_us == pytest.approx((10.0,))
57+
assert stats.quantiles_us == pytest.approx((10.0, 10.0, 10.0))
58+
assert stats.median_us == pytest.approx(10.0)
59+
assert stats.median_ci_us == pytest.approx((10.0, 10.0))

transformer_nuggets/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
benchmark_torch_function_in_microseconds,
33
benchmark_cuda_function_in_microseconds,
44
benchmark_cuda_function_in_microseconds_triton,
5+
benchmark_cuda_function_stats,
56
locked_clocks,
67
max_memory_usage,
78
cuda_memory_usage,
89
profile_function,
910
ProfileConfig,
11+
CudaBenchmarkStats,
1012
save_memory_snapshot,
1113
profiler,
1214
attach_oom_observer,

transformer_nuggets/utils/benchmark.py

Lines changed: 231 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import ctypes
2+
import ctypes.util
13
import logging
4+
import os
25
import random
6+
import statistics
37
from contextlib import contextmanager, nullcontext
48
from dataclasses import dataclass, field
59
from pathlib import Path
610
from typing import Literal
7-
from collections.abc import Callable
11+
from collections.abc import Callable, Sequence
812

913
import torch
1014
import torch.utils.benchmark as benchmark
@@ -18,41 +22,69 @@
1822
logger.addHandler(logging.NullHandler())
1923

2024

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+
2156
@contextmanager
2257
def locked_clocks(device: int = 0, clock_mhz: int | None = None):
2358
"""Lock GPU SM clocks for stable benchmarking.
2459
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.
2762
2863
Args:
2964
device: CUDA device index.
3065
clock_mhz: SM clock frequency in MHz. If None, locks to the GPU's max SM clock.
3166
"""
3267
import subprocess
3368

69+
if os.geteuid() != 0:
70+
raise RuntimeError("Requires root to lock GPU clocks")
71+
3472
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")
4781

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}"])
5183
logger.info(f"Locked GPU {device} SM clocks to {clock_mhz} MHz")
5284
try:
5385
yield clock_mhz
5486
finally:
55-
subprocess.call(["sudo", "nvidia-smi", "-i", str(device), "-rgc"])
87+
subprocess.call(["nvidia-smi", "-i", str(device), "-rgc"])
5688
logger.info(f"Reset GPU {device} SM clocks")
5789

5890

@@ -97,6 +129,186 @@ class ProfileConfig:
97129
row_limit: int = 10
98130

99131

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+
100312
def benchmark_torch_function_in_microseconds(func: Callable, *args, **kwargs) -> float:
101313
lock = kwargs.pop("LOCK_CLOCKS", False)
102314
ctx = locked_clocks() if lock else nullcontext()

0 commit comments

Comments
 (0)