[Kernel][Helion] Add numerics checks to benchmark script (#48968)
Signed-off-by: Shangdi Yu <shangdiy@meta.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shangdi Yu committed
Jul 31, 2026 at 17:16 UTC
b40d859c7b07ae244bcd8c6eecdcdbd9a3afaa07
2 files changed
+339
-10
scripts/benchmark_helion_kernels.py
+249
-10
@@ -5,8 +5,10 @@
5
Benchmark a registered Helion kernel against a baseline.
6
7
For each input case produced by the kernel's registered input generator, this
8
-measures the latency of the Helion kernel and a chosen baseline, then reports
9
-the speedup.
8
+checks the Helion kernel's numerics once against its eager reference, measures
9
+its latency against a chosen performance baseline, then reports the speedup.
10
+Use ``--numerics-with-perf-baseline`` to check numerics against the performance
11
+baseline instead.
12
13
Two baselines are supported (``--baseline``):
14
@@ -30,9 +32,17 @@ Usage:
32
python scripts/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
33
--baseline cuda
34
35
+ # Check numerics against the performance baseline instead of eager
36
+ python scripts/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
37
+ --baseline cuda --numerics-with-perf-baseline
38
+
39
# Disable CUDA graph capture and save results
40
python scripts/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
41
--no-cudagraph --output results.json
42
+
43
+ # Only verify numerics, skipping the timing runs
44
+ python scripts/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
45
+ --numerics-only
46
"""
47
48
import argparse
@@ -43,15 +53,19 @@ import statistics
53
import sys
54
from collections.abc import Callable
55
from dataclasses import asdict, dataclass
56
+from typing import Any
57
58
import torch
59
+from torch.utils._pytree import tree_flatten
60
61
from vllm.triton_utils import triton
62
63
try:
64
+ from helion.autotuner.accuracy import assert_close as helion_assert_close
65
+ from helion.autotuner.accuracy import is_fp8_dtype
66
+
67
from vllm.benchmarks.lib.utils import default_vllm_config
68
from vllm.kernels.helion import get_kernel_by_name, get_registered_kernels
54
- from vllm.kernels.helion.ops import import_all_kernels
69
from vllm.logger import init_logger
70
from vllm.utils.import_utils import has_helion
71
except ImportError as e:
@@ -59,6 +73,27 @@ except ImportError as e:
73
print("Please ensure vLLM is installed and in your Python path")
74
sys.exit(1)
75
76
+
77
+def import_all_kernels() -> None:
78
+ """Trigger Helion op registration, tolerating cross-version name drift.
79
+
80
+ Current vLLM registers every Helion kernel as a side effect of importing
81
+ ``vllm.kernels.helion.ops``; some builds instead expose an explicit importer
82
+ whose name has drifted (``import_all_kernels`` / ``import_all_ops``). Call
83
+ whichever exists; if none does, importing the module already registered
84
+ them.
85
+ """
86
+ try:
87
+ import vllm.kernels.helion.ops as ops
88
+ except ImportError:
89
+ return
90
+ for fn_name in ("import_all_kernels", "import_all_ops"):
91
+ fn = getattr(ops, fn_name, None)
92
+ if callable(fn):
93
+ fn()
94
+ return
95
+
96
+
97
logger = init_logger("vllm.scripts.benchmark_helion_kernels")
98
99
@@ -121,6 +156,21 @@ def print_table(rows: list[Row]) -> None:
156
print(fmt(row))
157
158
159
+def log_versions() -> None:
160
+ """Log torch/helion/triton versions at the head of the output."""
161
+ from importlib.metadata import PackageNotFoundError, version
162
+
163
+ def pkg_version(name: str) -> str:
164
+ try:
165
+ return version(name)
166
+ except PackageNotFoundError:
167
+ return "not installed"
168
+
169
+ logger.info("torch: %s", torch.__version__)
170
+ logger.info("helion: %s", pkg_version("helion"))
171
+ logger.info("triton: %s", getattr(triton, "__version__", pkg_version("triton")))
172
+
173
+
174
def list_kernels() -> None:
175
kernels = get_registered_kernels()
176
@@ -176,12 +226,8 @@ def make_cuda_baseline(kernel_name: str) -> Callable:
226
return cuda_op
227
228
179
-def make_autotune_baseline(kernel_name: str) -> Callable:
180
- """Return the kernel's autotune baseline wrapped in ``torch.compile``.
181
-
182
- The baseline is the native-torch reference the kernel is tuned against,
183
- registered via ``helion_settings.autotune_baseline_fn``.
184
- """
229
+def make_eager_baseline(kernel_name: str) -> Callable:
230
+ """Return the kernel's registered native-torch reference."""
231
wrapper = get_kernel_by_name(kernel_name)
232
settings = wrapper.helion_settings
233
baseline_fn = getattr(settings, "autotune_baseline_fn", None)
@@ -195,6 +241,13 @@ def make_autotune_baseline(kernel_name: str) -> Callable:
241
)
242
sys.exit(1)
243
244
+ return baseline_fn
245
+
246
+
247
+def make_autotune_baseline(kernel_name: str) -> Callable:
248
+ """Return the kernel's autotune baseline wrapped in ``torch.compile``."""
249
+ baseline_fn = make_eager_baseline(kernel_name)
250
+
251
return torch.compile(
252
baseline_fn,
253
fullgraph=True,
@@ -204,6 +257,25 @@ def make_autotune_baseline(kernel_name: str) -> Callable:
257
)
258
259
260
+def make_correctness_baseline(
261
+ kernel_name: str,
262
+ timed_baseline_fn: Callable,
263
+ numerics_with_perf_baseline: bool,
264
+) -> Callable:
265
+ """Choose the numerical reference independently from the timed baseline."""
266
+ if not numerics_with_perf_baseline:
267
+ logger.info(
268
+ "Using the eager reference for '%s' correctness",
269
+ kernel_name,
270
+ )
271
+ return make_eager_baseline(kernel_name)
272
+ logger.info(
273
+ "Using the selected performance baseline for '%s' correctness",
274
+ kernel_name,
275
+ )
276
+ return timed_baseline_fn
277
+
278
+
279
def cleanup_gpu_resources() -> None:
280
try:
281
torch.accelerator.empty_cache()
@@ -227,6 +299,118 @@ def _reduce(times: list[float], return_mode: str) -> float:
299
return _REDUCERS[return_mode](times)
300
301
302
+def _assert_close(actual: object, expected: object, atol: float, rtol: float) -> None:
303
+ """Compare pytrees, allowing the one-ULP FP8 variance used by kernel tests."""
304
+ actual_flat, actual_spec = tree_flatten(actual)
305
+ expected_flat, expected_spec = tree_flatten(expected)
306
+ if actual_spec != expected_spec:
307
+ raise AssertionError(
308
+ f"Output structure mismatch: {actual_spec} != {expected_spec}"
309
+ )
310
+
311
+ for actual_leaf, expected_leaf in zip(actual_flat, expected_flat, strict=True):
312
+ is_fp8 = isinstance(actual_leaf, torch.Tensor) and is_fp8_dtype(
313
+ actual_leaf.dtype
314
+ )
315
+ helion_assert_close(
316
+ actual_leaf,
317
+ expected_leaf,
318
+ atol=1 if is_fp8 else atol,
319
+ rtol=0 if is_fp8 else rtol,
320
+ )
321
+
322
+
323
+def check_correctness(
324
+ kernel: Any,
325
+ baseline_fn: Callable,
326
+ inputs: tuple[Any, ...],
327
+ case: str,
328
+) -> None:
329
+ """Run one numerical comparison on copies separate from benchmark inputs."""
330
+ kernel_inputs = copy.deepcopy(inputs)
331
+ baseline_inputs = copy.deepcopy(inputs)
332
+
333
+ kernel_output = kernel(*kernel_inputs)
334
+ baseline_output = baseline_fn(*baseline_inputs)
335
+
336
+ settings = kernel.helion_settings
337
+ try:
338
+ custom_check = getattr(settings, "autotune_baseline_accuracy_check_fn", None)
339
+ if custom_check is not None:
340
+ custom_check(kernel_output, baseline_output)
341
+ custom_check(kernel_inputs, baseline_inputs)
342
+ return
343
+
344
+ configured_atol = getattr(settings, "autotune_baseline_atol", None)
345
+ configured_rtol = getattr(settings, "autotune_baseline_rtol", None)
346
+ atol = 1e-2 if configured_atol is None else configured_atol
347
+ rtol = 1e-2 if configured_rtol is None else configured_rtol
348
+ _assert_close(
349
+ kernel_output,
350
+ baseline_output,
351
+ atol=atol,
352
+ rtol=rtol,
353
+ )
354
+ _assert_close(
355
+ kernel_inputs,
356
+ baseline_inputs,
357
+ atol=atol,
358
+ rtol=rtol,
359
+ )
360
+ except AssertionError as e:
361
+ raise AssertionError(f"Numerics check failed for case {case}:\n{e}") from e
362
+
363
+
364
+@dataclass
365
+class CorrectnessResult:
366
+ """Outcome of the numerics check for a single shape case."""
367
+
368
+ case: str
369
+ passed: bool
370
+ error: str | None = None
371
+
372
+
373
+def check_kernel_correctness(
374
+ kernel: Any,
375
+ baseline_fn: Callable,
376
+ inputs_dict: dict[Any, tuple[Any, ...]] | None = None,
377
+) -> list[CorrectnessResult]:
378
+ """Run the per-shape numerics check for a kernel, continuing past failures.
379
+
380
+ Runs the same comparison as ``check_correctness`` for every shape case
381
+ produced by the kernel's input generator, but records the outcome per case
382
+ instead of raising on the first mismatch. This lets callers (e.g. a CI gate)
383
+ report every failing shape in one pass rather than aborting early.
384
+
385
+ Args:
386
+ kernel: The Helion kernel wrapper to check.
387
+ baseline_fn: Reference callable sharing the kernel's argument interface.
388
+ inputs_dict: Optional mapping of case key to input tuple. Defaults to
389
+ ``kernel.get_inputs()``.
390
+
391
+ Returns:
392
+ One ``CorrectnessResult`` per shape case, in iteration order. A case that
393
+ raises (compile/run error or numerics mismatch) is marked
394
+ ``passed=False`` with the exception text in ``error``; iteration
395
+ continues regardless. An empty input mapping returns an empty list, which
396
+ the CLI reports as a skipped check.
397
+ """
398
+ if inputs_dict is None:
399
+ inputs_dict = kernel.get_inputs()
400
+
401
+ results: list[CorrectnessResult] = []
402
+ for key, inputs in inputs_dict.items():
403
+ case = str(key)
404
+ try:
405
+ check_correctness(kernel, baseline_fn, inputs, case)
406
+ except Exception as e: # noqa: BLE001 - any failure is recorded, not fatal
407
+ results.append(CorrectnessResult(case=case, passed=False, error=str(e)))
408
+ else:
409
+ results.append(CorrectnessResult(case=case, passed=True))
410
+ cleanup_gpu_resources()
411
+ return results
412
+
413
+
414
def do_bench_cudagraph_l2_clear(
415
fn: Callable, rep: int = 100, return_mode: str = "mean"
416
) -> float:
@@ -298,6 +482,7 @@ def do_bench_cudagraph_l2_clear(
482
def benchmark(
483
kernel_name: str,
484
baseline_fn: Callable,
485
+ correctness_fn: Callable,
486
repeat: int,
487
cudagraph: bool,
488
return_mode: str,
@@ -313,6 +498,9 @@ def benchmark(
498
for key, inputs in inputs_dict.items():
499
logger.info("Benchmarking case %s", key)
500
501
+ check_correctness(kernel, correctness_fn, inputs, str(key))
502
+ logger.info("Numerics check passed for case %s", key)
503
+
504
# Kernels may mutate their inputs in place; give each side its own copy.
505
kernel_inputs = copy.deepcopy(inputs)
506
baseline_inputs = copy.deepcopy(inputs)
@@ -374,7 +562,7 @@ def main() -> None:
562
choices=["cuda", "autotune"],
563
default="autotune",
564
help=(
377
- "Baseline to compare against: 'autotune' uses the kernel's "
565
+ "Performance baseline: 'autotune' uses the kernel's "
566
"autotune_baseline_fn under torch.compile; 'cuda' uses the mapped "
567
"torch.ops._C op (default: autotune)"
568
),
@@ -390,9 +578,24 @@ def main() -> None:
578
type=str,
579
help="Path to save benchmark results as JSON (default: log only)",
580
)
581
+ parser.add_argument(
582
+ "--numerics-only",
583
+ action="store_true",
584
+ help="Only run the per-case numerics check; skip timing and reporting",
585
+ )
586
+ parser.add_argument(
587
+ "--numerics-with-perf-baseline",
588
+ action="store_true",
589
+ help=(
590
+ "Compare numerics against the selected performance baseline instead "
591
+ "of the eager reference"
592
+ ),
593
+ )
594
595
args = parser.parse_args()
596
597
+ log_versions()
598
+
599
import_all_kernels()
600
601
if args.list:
@@ -425,10 +628,43 @@ def main() -> None:
628
baseline_fn = make_cuda_baseline(args.kernel)
629
else:
630
baseline_fn = make_autotune_baseline(args.kernel)
631
+ correctness_fn = make_correctness_baseline(
632
+ args.kernel,
633
+ baseline_fn,
634
+ args.numerics_with_perf_baseline,
635
+ )
636
+
637
+ if args.numerics_only:
638
+ results = check_kernel_correctness(wrapper, correctness_fn)
639
+ if not results:
640
+ logger.warning(
641
+ "No input cases generated for '%s'; skipping numerics check",
642
+ args.kernel,
643
+ )
644
+ return
645
+ for r in results:
646
+ if r.passed:
647
+ logger.info("Numerics check passed for case %s", r.case)
648
+ else:
649
+ logger.error(
650
+ "Numerics check FAILED for case %s: %s", r.case, r.error
651
+ )
652
+ failed = [r for r in results if not r.passed]
653
+ if failed:
654
+ logger.error(
655
+ "%d/%d case(s) failed numerics for '%s'",
656
+ len(failed),
657
+ len(results),
658
+ args.kernel,
659
+ )
660
+ sys.exit(1)
661
+ logger.info("Numerics check passed for all cases of '%s'", args.kernel)
662
+ return
663
664
rows = benchmark(
665
args.kernel,
666
baseline_fn,
667
+ correctness_fn,
668
args.repeat,
669
args.cudagraph,
670
args.return_mode,
@@ -442,6 +678,9 @@ def main() -> None:
678
{
679
"kernel": args.kernel,
680
"baseline": args.baseline,
681
+ "numerics_baseline": (
682
+ args.baseline if args.numerics_with_perf_baseline else "eager"
683
+ ),
684
"cudagraph": args.cudagraph,
685
"repeat": args.repeat,
686
"return_mode": args.return_mode,
tests/kernels/helion/test_benchmark_script.py
new
+90
@@ -0,0 +1,90 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+import torch
6
+
7
+from vllm.utils.import_utils import has_helion
8
+
9
+if not has_helion():
10
+ pytest.skip("Helion is not installed", allow_module_level=True)
11
+
12
+from scripts import benchmark_helion_kernels
13
+
14
+check_correctness = benchmark_helion_kernels.check_correctness
15
+
16
+
17
+class _FakeKernel:
18
+ helion_settings = None
19
+
20
+ def __init__(self, offset: float):
21
+ self.calls = 0
22
+ self.offset = offset
23
+
24
+ def __call__(self, output: torch.Tensor, input: torch.Tensor) -> torch.Tensor:
25
+ self.calls += 1
26
+ output.copy_(input + self.offset)
27
+ return input * 2
28
+
29
+
30
+def test_check_correctness_runs_once_without_mutating_benchmark_inputs():
31
+ kernel = _FakeKernel(offset=1)
32
+ baseline_calls = 0
33
+
34
+ def baseline(output: torch.Tensor, input: torch.Tensor) -> torch.Tensor:
35
+ nonlocal baseline_calls
36
+ baseline_calls += 1
37
+ output.copy_(input + 1)
38
+ return input * 2
39
+
40
+ output = torch.zeros(4)
41
+ inputs = (output, torch.arange(4))
42
+
43
+ check_correctness(kernel, baseline, inputs, "matching")
44
+
45
+ assert kernel.calls == 1
46
+ assert baseline_calls == 1
47
+ torch.testing.assert_close(output, torch.zeros(4))
48
+
49
+
50
+def test_check_correctness_reports_mutated_input_mismatch():
51
+ kernel = _FakeKernel(offset=2)
52
+
53
+ def baseline(output: torch.Tensor, input: torch.Tensor) -> torch.Tensor:
54
+ output.copy_(input + 1)
55
+ return input * 2
56
+
57
+ inputs = (torch.zeros(4), torch.arange(4))
58
+
59
+ with pytest.raises(AssertionError, match="Numerics check failed for case bad"):
60
+ check_correctness(kernel, baseline, inputs, "bad")
61
+
62
+
63
+def test_check_correctness_reports_return_value_mismatch():
64
+ kernel = _FakeKernel(offset=1)
65
+
66
+ def baseline(output: torch.Tensor, input: torch.Tensor) -> torch.Tensor:
67
+ output.copy_(input + 1)
68
+ return input * 3
69
+
70
+ inputs = (torch.zeros(4), torch.arange(4))
71
+
72
+ with pytest.raises(AssertionError, match="Numerics check failed for case bad"):
73
+ check_correctness(kernel, baseline, inputs, "bad")
74
+
75
+
76
+def test_log_versions(monkeypatch):
77
+ messages = []
78
+
79
+ def record(message: str, *args):
80
+ messages.append(message % args)
81
+
82
+ monkeypatch.setattr(benchmark_helion_kernels.logger, "info", record)
83
+
84
+ benchmark_helion_kernels.log_versions()
85
+
86
+ assert [message.split(":", 1)[0] for message in messages] == [
87
+ "torch",
88
+ "helion",
89
+ "triton",
90
+ ]