main
py 696 lines 22.5 KB
Raw
1 #!/usr/bin/env python3
2 # SPDX-License-Identifier: Apache-2.0
3 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4 """Benchmark a registered Helion kernel against a baseline.
5
6 For each input case produced by the kernel's registered input generator, this
7 checks the Helion kernel's numerics once against its eager reference, measures
8 its latency against a chosen performance baseline, then reports the speedup.
9 Use ``--numerics-with-perf-baseline`` to check numerics against the performance
10 baseline instead.
11
12 Two baselines are supported (``--baseline``):
13
14 - ``autotune`` (default): the kernel's autotuning baseline
15 (``helion_settings.autotune_baseline_fn``), wrapped in ``torch.compile``
16 (inductor). This is the native-torch reference used by kernel autotuning
17 and correctness unit tests.
18 - ``cuda``: the corresponding hand-written CUDA op (``torch.ops._C.*``). The
19 mapping from Helion kernel name to CUDA op lives in ``CUDA_BASELINE_OPS``
20 below. Every Helion kernel shares the same argument interface as its CUDA
21 counterpart, so inputs are forwarded verbatim.
22
23 Usage:
24 # List available kernels
25 python tools/benchmark_helion_kernels.py --list
26
27 # Benchmark a kernel against the autotune baseline (default)
28 python tools/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant
29
30 # Benchmark against the CUDA baseline
31 python tools/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
32 --baseline cuda
33
34 # Check numerics against the performance baseline instead of eager
35 python tools/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
36 --baseline cuda --numerics-with-perf-baseline
37
38 # Disable CUDA graph capture and save results
39 python tools/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
40 --no-cudagraph --output results.json
41
42 # Only verify numerics, skipping the timing runs
43 python tools/benchmark_helion_kernels.py --kernel per_token_group_fp8_quant \\
44 --numerics-only
45 """
46
47 import argparse
48 import copy
49 import gc
50 import json
51 import statistics
52 import sys
53 from collections.abc import Callable
54 from dataclasses import asdict, dataclass
55 from typing import Any
56
57 import torch
58 from torch.utils._pytree import tree_flatten
59
60 from vllm.triton_utils import triton
61
62 try:
63 from helion.autotuner.accuracy import assert_close as helion_assert_close
64 from helion.autotuner.accuracy import is_fp8_dtype
65
66 from vllm.benchmarks.lib.utils import default_vllm_config
67 from vllm.kernels.helion import get_kernel_by_name, get_registered_kernels
68 from vllm.logger import init_logger
69 from vllm.utils.import_utils import has_helion
70 except ImportError as e:
71 print(f"Error importing vLLM: {e}")
72 print("Please ensure vLLM is installed and in your Python path")
73 sys.exit(1)
74
75
76 def import_all_kernels() -> None:
77 """Trigger Helion op registration, tolerating cross-version name drift.
78
79 Current vLLM registers every Helion kernel as a side effect of importing
80 ``vllm.kernels.helion.ops``; some builds instead expose an explicit importer
81 whose name has drifted (``import_all_kernels`` / ``import_all_ops``). Call
82 whichever exists; if none does, importing the module already registered
83 them.
84 """
85 try:
86 import vllm.kernels.helion.ops as ops
87 except ImportError:
88 return
89 for fn_name in ("import_all_kernels", "import_all_ops"):
90 fn = getattr(ops, fn_name, None)
91 if callable(fn):
92 fn()
93 return
94
95
96 logger = init_logger("vllm.scripts.benchmark_helion_kernels")
97
98
99 # Maps a Helion kernel name to the CUDA op (attribute on ``torch.ops._C``) that
100 # implements the same operation. Helion kernels share the CUDA op's argument
101 # interface, so the kernel's input tuple is forwarded verbatim. Add an entry
102 # here when introducing a new kernel whose baseline should be the CUDA op.
103 CUDA_BASELINE_OPS: dict[str, str] = {
104 "dynamic_per_token_scaled_fp8_quant": "dynamic_per_token_scaled_fp8_quant",
105 "fused_qk_norm_rope": "fused_qk_norm_rope",
106 "per_token_group_fp8_quant": "per_token_group_fp8_quant",
107 "rms_norm_dynamic_per_token_quant": "rms_norm_dynamic_per_token_quant",
108 "rms_norm_per_block_quant": "rms_norm_per_block_quant",
109 "silu_and_mul_per_block_quant": "silu_and_mul_per_block_quant",
110 "scaled_mm": "cutlass_scaled_mm",
111 }
112
113 # torch.compile options for the torch baseline, mirroring how these kernels are
114 # compiled inside vLLM.
115 _TORCH_COMPILE_OPTIONS: dict[str, bool] = {
116 "enable_auto_functionalized_v2": False,
117 "size_asserts": False,
118 "alignment_asserts": False,
119 "scalar_asserts": False,
120 "combo_kernels": True,
121 "benchmark_combo_kernel": True,
122 }
123
124
125 @dataclass
126 class Row:
127 case: str
128 baseline_ms: float
129 kernel_ms: float
130 speedup_x: float
131
132
133 def print_table(rows: list[Row]) -> None:
134 headers = ["case", "baseline_ms", "kernel_ms", "speedup(x)"]
135
136 data = [
137 [
138 r.case,
139 f"{r.baseline_ms:.3f}",
140 f"{r.kernel_ms:.3f}",
141 f"{r.speedup_x:.3f}",
142 ]
143 for r in rows
144 ]
145
146 cols = list(zip(*([headers] + data)))
147 widths = [max(len(cell) for cell in col) for col in cols]
148
149 def fmt(row: list[str]) -> str:
150 return " | ".join(cell.ljust(w) for cell, w in zip(row, widths))
151
152 print(fmt(headers))
153 print("-+-".join("-" * w for w in widths))
154 for row in data:
155 print(fmt(row))
156
157
158 def log_versions() -> None:
159 """Log torch/helion/triton versions at the head of the output."""
160 from importlib.metadata import PackageNotFoundError, version
161
162 def pkg_version(name: str) -> str:
163 try:
164 return version(name)
165 except PackageNotFoundError:
166 return "not installed"
167
168 logger.info("torch: %s", torch.__version__)
169 logger.info("helion: %s", pkg_version("helion"))
170 logger.info("triton: %s", getattr(triton, "__version__", pkg_version("triton")))
171
172
173 def list_kernels() -> None:
174 kernels = get_registered_kernels()
175
176 if not kernels:
177 print("No Helion kernels found in registry.")
178 return
179
180 print("Available Helion kernels:")
181 print("=" * 50)
182 for name in sorted(kernels.keys()):
183 cuda = CUDA_BASELINE_OPS.get(name)
184 suffix = "" if cuda else " (no CUDA baseline mapping)"
185 print(f" {name}{suffix}")
186 print(f"\nTotal: {len(kernels)} kernels")
187
188
189 def check_requirements() -> bool:
190 if not torch.accelerator.is_available():
191 logger.error("CUDA is not available. Helion benchmarking requires GPU.")
192 return False
193 if not has_helion():
194 logger.error("Helion is not installed. Please install Helion package.")
195 return False
196 return True
197
198
199 def make_cuda_baseline(kernel_name: str) -> Callable:
200 """Return a callable invoking the CUDA op mapped to ``kernel_name``.
201
202 The Helion kernel and its CUDA op share the same argument interface, so the
203 input tuple is forwarded verbatim.
204 """
205 cuda_op_name = CUDA_BASELINE_OPS.get(kernel_name)
206 if cuda_op_name is None:
207 logger.error(
208 "No CUDA baseline mapping for kernel '%s'. Add an entry to "
209 "CUDA_BASELINE_OPS in %s (mapping the kernel name to its "
210 "torch.ops._C.<op> name), or benchmark with --baseline torch.",
211 kernel_name,
212 __file__,
213 )
214 sys.exit(1)
215
216 cuda_op = getattr(torch.ops._C, cuda_op_name, None)
217 if cuda_op is None:
218 logger.error(
219 "torch.ops._C.%s is not available. Ensure the vLLM C extension is "
220 "built and loaded.",
221 cuda_op_name,
222 )
223 sys.exit(1)
224
225 return cuda_op
226
227
228 def make_eager_baseline(kernel_name: str) -> Callable:
229 """Return the kernel's registered native-torch reference."""
230 wrapper = get_kernel_by_name(kernel_name)
231 settings = wrapper.helion_settings
232 baseline_fn = getattr(settings, "autotune_baseline_fn", None)
233 if baseline_fn is None:
234 logger.error(
235 "Kernel '%s' has no autotune_baseline_fn in its helion_settings, so "
236 "the 'autotune' baseline is unavailable. Register one via "
237 "register_kernel(..., helion_settings=helion.Settings("
238 "autotune_baseline_fn=...)), or benchmark with --baseline cuda.",
239 kernel_name,
240 )
241 sys.exit(1)
242
243 return baseline_fn
244
245
246 def make_autotune_baseline(kernel_name: str) -> Callable:
247 """Return the kernel's autotune baseline wrapped in ``torch.compile``."""
248 baseline_fn = make_eager_baseline(kernel_name)
249
250 return torch.compile(
251 baseline_fn,
252 fullgraph=True,
253 dynamic=False,
254 backend="inductor",
255 options=_TORCH_COMPILE_OPTIONS,
256 )
257
258
259 def make_correctness_baseline(
260 kernel_name: str,
261 timed_baseline_fn: Callable,
262 numerics_with_perf_baseline: bool,
263 ) -> Callable:
264 """Choose the numerical reference independently from the timed baseline."""
265 if not numerics_with_perf_baseline:
266 logger.info(
267 "Using the eager reference for '%s' correctness",
268 kernel_name,
269 )
270 return make_eager_baseline(kernel_name)
271 logger.info(
272 "Using the selected performance baseline for '%s' correctness",
273 kernel_name,
274 )
275 return timed_baseline_fn
276
277
278 def cleanup_gpu_resources() -> None:
279 try:
280 torch.accelerator.empty_cache()
281 gc.collect()
282 if hasattr(torch, "_dynamo"):
283 torch._dynamo.reset()
284 torch.accelerator.synchronize()
285 except Exception as e:
286 logger.warning("Failed to cleanup GPU resources: %s", e)
287
288
289 _REDUCERS: dict[str, Callable[[list[float]], float]] = {
290 "min": min,
291 "max": max,
292 "mean": statistics.fmean,
293 "median": statistics.median,
294 }
295
296
297 def _reduce(times: list[float], return_mode: str) -> float:
298 return _REDUCERS[return_mode](times)
299
300
301 def _assert_close(actual: object, expected: object, atol: float, rtol: float) -> None:
302 """Compare pytrees, allowing the one-ULP FP8 variance used by kernel tests."""
303 actual_flat, actual_spec = tree_flatten(actual)
304 expected_flat, expected_spec = tree_flatten(expected)
305 if actual_spec != expected_spec:
306 raise AssertionError(
307 f"Output structure mismatch: {actual_spec} != {expected_spec}"
308 )
309
310 for actual_leaf, expected_leaf in zip(actual_flat, expected_flat, strict=True):
311 is_fp8 = isinstance(actual_leaf, torch.Tensor) and is_fp8_dtype(
312 actual_leaf.dtype
313 )
314 helion_assert_close(
315 actual_leaf,
316 expected_leaf,
317 atol=1 if is_fp8 else atol,
318 rtol=0 if is_fp8 else rtol,
319 )
320
321
322 def check_correctness(
323 kernel: Any,
324 baseline_fn: Callable,
325 inputs: tuple[Any, ...],
326 case: str,
327 ) -> None:
328 """Run one numerical comparison on copies separate from benchmark inputs."""
329 kernel_inputs = copy.deepcopy(inputs)
330 baseline_inputs = copy.deepcopy(inputs)
331
332 kernel_output = kernel(*kernel_inputs)
333 baseline_output = baseline_fn(*baseline_inputs)
334
335 settings = kernel.helion_settings
336 try:
337 custom_check = getattr(settings, "autotune_baseline_accuracy_check_fn", None)
338 if custom_check is not None:
339 custom_check(kernel_output, baseline_output)
340 custom_check(kernel_inputs, baseline_inputs)
341 return
342
343 configured_atol = getattr(settings, "autotune_baseline_atol", None)
344 configured_rtol = getattr(settings, "autotune_baseline_rtol", None)
345 atol = 1e-2 if configured_atol is None else configured_atol
346 rtol = 1e-2 if configured_rtol is None else configured_rtol
347 _assert_close(
348 kernel_output,
349 baseline_output,
350 atol=atol,
351 rtol=rtol,
352 )
353 _assert_close(
354 kernel_inputs,
355 baseline_inputs,
356 atol=atol,
357 rtol=rtol,
358 )
359 except AssertionError as e:
360 raise AssertionError(f"Numerics check failed for case {case}:\n{e}") from e
361
362
363 @dataclass
364 class CorrectnessResult:
365 """Outcome of the numerics check for a single shape case."""
366
367 case: str
368 passed: bool
369 error: str | None = None
370
371
372 def check_kernel_correctness(
373 kernel: Any,
374 baseline_fn: Callable,
375 inputs_dict: dict[Any, tuple[Any, ...]] | None = None,
376 ) -> list[CorrectnessResult]:
377 """Run the per-shape numerics check for a kernel, continuing past failures.
378
379 Runs the same comparison as ``check_correctness`` for every shape case
380 produced by the kernel's input generator, but records the outcome per case
381 instead of raising on the first mismatch. This lets callers (e.g. a CI gate)
382 report every failing shape in one pass rather than aborting early.
383
384 Args:
385 kernel: The Helion kernel wrapper to check.
386 baseline_fn: Reference callable sharing the kernel's argument interface.
387 inputs_dict: Optional mapping of case key to input tuple. Defaults to
388 ``kernel.get_inputs()``.
389
390 Returns:
391 One ``CorrectnessResult`` per shape case, in iteration order. A case that
392 raises (compile/run error or numerics mismatch) is marked
393 ``passed=False`` with the exception text in ``error``; iteration
394 continues regardless. An empty input mapping returns an empty list, which
395 the CLI reports as a skipped check.
396
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:
417 """CUDA-graph benchmark that flushes the L2 cache before every call.
418
419 ``triton.testing.do_bench_cudagraph`` captures back-to-back kernel launches
420 with a warm L2 cache, which over-estimates performance for memory-bound
421 kernels. This clears L2 (via triton's benchmark cache buffer) before each
422 call and subtracts the isolated cache-clear cost from the measured time.
423
424 Adapted from tritonbench's ``_do_bench_cudagraph_with_cache_clear`` using
425 only triton/torch primitives so no extra dependency is introduced.
426 """
427 cache = triton.runtime.driver.active.get_empty_cache_for_benchmark()
428 clear_cache = cache.zero_
429
430 s = torch.Stream()
431 with s:
432 clear_cache()
433 fn()
434
435 start_event = torch.Event(enable_timing=True)
436 end_event = torch.Event(enable_timing=True)
437 start_event.record()
438 for _ in range(5):
439 clear_cache()
440 fn()
441 end_event.record()
442 torch.accelerator.synchronize()
443 estimate_ms = start_event.elapsed_time(end_event) / 5
444 n_repeat = 1000 if estimate_ms == 0 else max(1, int(rep / estimate_ms))
445
446 graph = torch.cuda.CUDAGraph()
447 with torch.cuda.graph(graph):
448 for _ in range(n_repeat):
449 clear_cache()
450 fn()
451
452 clear_graph = torch.cuda.CUDAGraph()
453 with torch.cuda.graph(clear_graph):
454 for _ in range(n_repeat):
455 clear_cache()
456 torch.accelerator.synchronize()
457
458 kernel_times = []
459 for _ in range(10):
460 start_event = torch.Event(enable_timing=True)
461 end_event = torch.Event(enable_timing=True)
462 start_event.record()
463 clear_graph.replay()
464 end_event.record()
465 torch.accelerator.synchronize()
466 clear_ms = start_event.elapsed_time(end_event) / n_repeat
467
468 start_event = torch.Event(enable_timing=True)
469 end_event = torch.Event(enable_timing=True)
470 start_event.record()
471 graph.replay()
472 end_event.record()
473 torch.accelerator.synchronize()
474 total_ms = start_event.elapsed_time(end_event) / n_repeat
475
476 kernel_times.append(total_ms - clear_ms)
477
478 return _reduce(kernel_times, return_mode)
479
480
481 @torch.inference_mode()
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,
489 ) -> list[Row]:
490 kernel = get_kernel_by_name(kernel_name)
491 # do_bench already flushes L2 per call; do_bench_cudagraph does not, so use
492 # the cache-clearing variant to avoid warm-L2 over-estimates.
493 benchmark_fn = do_bench_cudagraph_l2_clear if cudagraph else triton.testing.do_bench
494
495 inputs_dict = kernel.get_inputs()
496 rows: list[Row] = []
497
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)
507
508 kernel_latency = benchmark_fn(
509 lambda kernel_inputs=kernel_inputs: kernel(*kernel_inputs),
510 rep=repeat,
511 return_mode=return_mode,
512 )
513 baseline_latency = benchmark_fn(
514 lambda baseline_inputs=baseline_inputs: baseline_fn(*baseline_inputs),
515 rep=repeat,
516 return_mode=return_mode,
517 )
518
519 rows.append(
520 Row(
521 case=str(key),
522 baseline_ms=baseline_latency,
523 kernel_ms=kernel_latency,
524 speedup_x=baseline_latency / kernel_latency,
525 )
526 )
527 cleanup_gpu_resources()
528
529 return rows
530
531
532 def main() -> None:
533 parser = argparse.ArgumentParser(
534 description="Benchmark a Helion kernel against a baseline",
535 formatter_class=argparse.RawDescriptionHelpFormatter,
536 epilog=__doc__.split("Usage:")[1] if "Usage:" in __doc__ else "",
537 )
538 parser.add_argument(
539 "--list",
540 action="store_true",
541 help="List available Helion kernels and exit",
542 )
543 parser.add_argument(
544 "--kernel",
545 type=str,
546 help="Name of the single Helion kernel to benchmark",
547 )
548 parser.add_argument(
549 "--repeat",
550 type=int,
551 default=100,
552 help="Number of benchmark repetitions (default: 100)",
553 )
554 parser.add_argument(
555 "--no-cudagraph",
556 dest="cudagraph",
557 action="store_false",
558 help="Disable CUDA graph mode (enabled by default)",
559 )
560 parser.add_argument(
561 "--baseline",
562 choices=["cuda", "autotune"],
563 default="autotune",
564 help=(
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 ),
569 )
570 parser.add_argument(
571 "--return-mode",
572 choices=["min", "max", "mean", "median"],
573 default="mean",
574 help="Statistic to report from the benchmark samples (default: mean)",
575 )
576 parser.add_argument(
577 "--output",
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:
602 list_kernels()
603 return
604
605 if not args.kernel:
606 parser.error("--kernel is required (or use --list to see available kernels)")
607
608 kernels = get_registered_kernels()
609 if args.kernel not in kernels:
610 logger.error("Kernel '%s' not found in registry.", args.kernel)
611 logger.error("Available kernels: %s", sorted(kernels.keys()))
612 sys.exit(1)
613
614 wrapper = kernels[args.kernel]
615 if wrapper._disabled:
616 logger.error(
617 "Kernel '%s' is disabled: %s",
618 args.kernel,
619 wrapper._disabled_reason,
620 )
621 sys.exit(1)
622
623 if not check_requirements():
624 sys.exit(1)
625
626 with default_vllm_config():
627 if args.baseline == "cuda":
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,
671 )
672
673 print_table(rows)
674
675 if args.output:
676 with open(args.output, "w") as f:
677 json.dump(
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,
687 "results": [asdict(r) for r in rows],
688 },
689 f,
690 indent=2,
691 )
692 logger.info("Saved results to %s", args.output)
693
694
695 if __name__ == "__main__":
696 main()