1339 lines
50 KiB
Python
1339 lines
50 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025 - 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
|
|
#
|
|
# Use of this software is governed by the terms and conditions of the
|
|
# NVIDIA End User License Agreement (EULA), available at:
|
|
# https://docs.nvidia.com/cutlass/latest/media/docs/pythonDSL/license.html
|
|
#
|
|
# Any use, reproduction, disclosure, or distribution of this software
|
|
# and related documentation outside the scope permitted by the EULA
|
|
# is strictly prohibited.
|
|
|
|
import functools
|
|
import inspect
|
|
import logging
|
|
import os
|
|
from itertools import product
|
|
from time import time
|
|
from typing import Type, Union, Callable, Optional, Dict, List, Any
|
|
|
|
import cuda.bindings.driver as cuda_driver
|
|
import cuda.bindings.runtime as cuda_runtime
|
|
|
|
from cutlass.cutlass_dsl import Constexpr, CuTeDSL, T, dsl_user_op, const_expr
|
|
|
|
from .typing import Numeric, Int8, Boolean, Tensor, Layout, Shape
|
|
|
|
from . import nvgpu
|
|
from .core import recast_layout, make_layout, composition, get, rank, size
|
|
from .tuple import elem_less
|
|
from .tensor import (
|
|
make_rmem_tensor,
|
|
recast_tensor,
|
|
make_identity_tensor,
|
|
TensorSSA,
|
|
_Tensor,
|
|
)
|
|
from .atom import make_copy_atom
|
|
from .algorithm import copy
|
|
from .core import zipped_divide
|
|
from .runtime import from_dlpack
|
|
|
|
from cutlass._mlir.dialects import builtin, cf, nvvm, vector
|
|
|
|
|
|
@dsl_user_op
|
|
def assert_(cond, msg=None, *, loc=None, ip=None):
|
|
cf.assert_(Boolean(cond).ir_value(), msg if msg else "", loc=loc, ip=ip)
|
|
|
|
|
|
################################################
|
|
# Runtime Assertion Helper Utilities For Testing
|
|
################################################
|
|
|
|
|
|
class AssertionError(RuntimeError):
|
|
"""Custom assertion error for runtime assertions."""
|
|
|
|
pass
|
|
|
|
|
|
class Assertion:
|
|
"""Base class for runtime assertion."""
|
|
|
|
pass
|
|
|
|
|
|
class _CompileTimeAssertion(Assertion):
|
|
"""Compile-time assertion helper that tracks assertion results during execution.
|
|
|
|
This assertion is used internally when RuntimeAssertion is passed through
|
|
JIT compilation. It stores assertion results in a tensor and provides compile-time
|
|
tracking of assertion results.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
tensor: Tensor,
|
|
num_assertions: int = 1,
|
|
msgs=None,
|
|
device=None,
|
|
disable: bool = False,
|
|
init_value: bool = False,
|
|
used_indices: set = None,
|
|
):
|
|
"""Initialize _CompileTimeAssertion.
|
|
|
|
:param tensor: Tensor to store assertion results
|
|
:param num_assertions: Number of assertions to support
|
|
:param msgs: List of assertion messages
|
|
:param device: Device to run assertions on
|
|
:param disable: If True, assertions are disabled
|
|
:param init_value: Initial value for assertion tensor
|
|
:param used_indices: Set of used assertion indices
|
|
"""
|
|
if msgs is None:
|
|
msgs = []
|
|
self._tensor = tensor
|
|
self._num_assertions = num_assertions
|
|
self._device = device
|
|
self._disable = disable
|
|
self._msgs = msgs
|
|
self._init_value = init_value
|
|
self._used_indices = used_indices
|
|
|
|
def __new_from_mlir_values__(self, values):
|
|
if self._disable:
|
|
return _CompileTimeAssertion(
|
|
None,
|
|
self._num_assertions,
|
|
self._msgs,
|
|
self._device,
|
|
self._disable,
|
|
self._init_value,
|
|
self._used_indices,
|
|
)
|
|
return _CompileTimeAssertion(
|
|
_Tensor(values[0], dtype=Boolean),
|
|
self._num_assertions,
|
|
self._msgs,
|
|
self._device,
|
|
self._disable,
|
|
self._init_value,
|
|
self._used_indices,
|
|
)
|
|
|
|
def __extract_mlir_values__(self):
|
|
if self._disable:
|
|
return []
|
|
return self._tensor.__extract_mlir_values__()
|
|
|
|
@dsl_user_op
|
|
@CuTeDSL.jit
|
|
def store(self, idx: Constexpr, pred: Boolean, msg: str = "", *, loc=None, ip=None):
|
|
"""Assert a predicate condition.
|
|
|
|
:param idx: Assertion index
|
|
:type idx: int
|
|
:param pred: Predicate condition to assert
|
|
:type pred: Boolean
|
|
:param msg: Assertion message
|
|
:type msg: str, optional
|
|
:param loc: MLIR location information for debugging, defaults to None
|
|
:type loc: optional
|
|
:param ip: MLIR insertion point for code generation, defaults to None
|
|
:type ip: optional
|
|
"""
|
|
if const_expr(self._disable):
|
|
return
|
|
if const_expr(not isinstance(idx, int)):
|
|
raise ValueError(f"expects idx to be 'int', but got {type(idx)}")
|
|
if const_expr(idx >= self._num_assertions):
|
|
raise ValueError(f"please increase the number of assertions!!!")
|
|
if const_expr(self._init_value is True):
|
|
self._tensor[idx] = pred and self._tensor[idx]
|
|
else:
|
|
self._tensor[idx] = pred
|
|
self._msgs[idx] = f"{msg}\nAt {loc}"
|
|
self._used_indices.add(idx)
|
|
|
|
def __enter__(self):
|
|
"""Enter context manager."""
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
"""Exit context manager and verify assertions if no exception occurred."""
|
|
# Only verify if there was no exception in the with block
|
|
if exc_type is None and not self._disable:
|
|
# _CompileTimeAssertion doesn't have verify method as it's checked at compile time
|
|
pass
|
|
return False # Don't suppress exceptions
|
|
|
|
|
|
class RuntimeAssertion(Assertion):
|
|
"""Runtime assertion helper that verifies conditions at runtime.
|
|
```python
|
|
There are two modes to use RuntimeAssertion:
|
|
1. Manual mode - explicitly call verify():
|
|
```python
|
|
@cute.jit
|
|
def jit_func(assertions: Assertion):
|
|
assertions.store(0, pred, "assertion failed")
|
|
assertions = cute.testing.RuntimeAssertion(num_assertions=1)
|
|
jit_func(assertions)
|
|
assertions.verify()
|
|
```
|
|
|
|
2. Context manager mode - automatically verifies on exit:
|
|
```python
|
|
with cute.testing.RuntimeAssertion(num_assertions=1) as assertions:
|
|
jit_func(assertions)
|
|
# verify() is called automatically after the with block
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
num_assertions: int = 1,
|
|
device=None,
|
|
disable: bool = False,
|
|
init_value: bool = False,
|
|
):
|
|
"""Initialize _RuntimeAssertion.
|
|
|
|
:param num_assertions: Number of assertions to support
|
|
:param device: Device to run assertions on (None for CPU, "cuda" for GPU)
|
|
:param disable: If True, assertions are disabled
|
|
:param init_value: Initial value for assertion tensor
|
|
"""
|
|
self._num_assertions = num_assertions
|
|
self._device = device
|
|
self._disable = disable
|
|
self._msgs = [""] * num_assertions
|
|
self._init_value = init_value
|
|
self._used_indices = set()
|
|
if self._disable:
|
|
return
|
|
import torch
|
|
|
|
self._torch_tensor = torch.full(
|
|
(self._num_assertions,),
|
|
device=self._device,
|
|
dtype=torch.bool,
|
|
fill_value=init_value,
|
|
)
|
|
self._tensor = from_dlpack(self._torch_tensor)
|
|
|
|
def __c_pointers__(self):
|
|
"""Get C pointers for passing to JIT functions."""
|
|
if self._disable:
|
|
return []
|
|
return self._tensor.__c_pointers__()
|
|
|
|
def __get_mlir_types__(self):
|
|
"""Get MLIR types for code generation."""
|
|
if self._disable:
|
|
return []
|
|
return self._tensor.__get_mlir_types__()
|
|
|
|
def __new_from_mlir_values__(self, values):
|
|
"""Create new instance from MLIR values (for JIT compilation)."""
|
|
if self._disable:
|
|
return _CompileTimeAssertion(
|
|
None,
|
|
self._num_assertions,
|
|
self._msgs,
|
|
self._device,
|
|
self._disable,
|
|
self._init_value,
|
|
self._used_indices,
|
|
)
|
|
return _CompileTimeAssertion(
|
|
_Tensor(values[0], dtype=Boolean),
|
|
self._num_assertions,
|
|
self._msgs,
|
|
self._device,
|
|
self._disable,
|
|
self._init_value,
|
|
self._used_indices,
|
|
)
|
|
|
|
def verify(self):
|
|
"""Verify all assertions have passed."""
|
|
if self._disable:
|
|
return
|
|
import torch
|
|
|
|
if self._device is not None:
|
|
torch.cuda.synchronize()
|
|
false_indices = torch.where(self._torch_tensor == False)[0].tolist()
|
|
valid_indices = [idx for idx in false_indices if idx in self._used_indices]
|
|
if len(valid_indices) > 0:
|
|
# emit the first assertion error.
|
|
raise AssertionError(self._msgs[valid_indices[0]])
|
|
|
|
def __enter__(self):
|
|
"""Enter the context manager, returns self for use in 'with' statement."""
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
"""Exit the context manager, automatically calls verify()."""
|
|
if exc_type is None:
|
|
# Only verify if no exception occurred in the with block
|
|
self.verify()
|
|
# Return False to propagate any exception that occurred
|
|
return False
|
|
|
|
|
|
def _maybe_recast_tensor_from_f4(src: Tensor, tv_layout: Layout):
|
|
if src.element_type.width == 4:
|
|
tv_layout = recast_layout(8, 4, tv_layout)
|
|
src = recast_tensor(src, dtype=Int8)
|
|
return src, tv_layout
|
|
|
|
|
|
def _maybe_recast_to_f4(input: TensorSSA, dtype: Type[Numeric]):
|
|
"""Conditionally recasts the tensor to 4-bit type if the destination type is 4-bit.
|
|
|
|
:param input: The input tensor to recast.
|
|
:param dtype: The target numeric type to potentially recast to.
|
|
:raises TypeError: If dtype is not a subclass of Numeric.
|
|
:return: A new tensor recast to 4-bit if dtype is 4-bit, otherwise returns self unchanged.
|
|
"""
|
|
if not inspect.isclass(dtype) or not issubclass(dtype, Numeric):
|
|
raise TypeError(f"dst_ty must be a type of Numeric, but got {dtype}")
|
|
|
|
if dtype.width == 4:
|
|
recast_shape = recast_layout(4, 8, make_layout(input.shape)).shape
|
|
i4_vec = vector.bitcast(
|
|
T.vector(input.type.shape[0] * 2, T.i(4)), input.maybe_downcast()
|
|
)
|
|
res_vect = builtin.unrealized_conversion_cast(
|
|
[T.vector(i4_vec.type.shape[0], dtype.mlir_type)], [i4_vec]
|
|
)
|
|
return TensorSSA(res_vect, recast_shape, dtype)
|
|
return input
|
|
|
|
|
|
def _maybe_recast_from_f4(input: TensorSSA, src_dtype: Type[Numeric]):
|
|
"""Conditionally recasts the tensor from 4-bit type if the source type is 4-bit.
|
|
|
|
:param input: The input tensor to recast.
|
|
:param src_dtype: The source numeric type to potentially recast from.
|
|
:raises TypeError: If src_dtype is not a subclass of Numeric.
|
|
:return: A new tensor recast from 4-bit if src_dtype is 4-bit, otherwise returns self unchanged.
|
|
"""
|
|
if not inspect.isclass(src_dtype) or not issubclass(src_dtype, Numeric):
|
|
raise TypeError(f"src_ty must be a type of Numeric, but got {src_dtype}")
|
|
|
|
if src_dtype.width == 4:
|
|
recast_shape = recast_layout(8, 4, make_layout(input.shape)).shape
|
|
i4_vec = builtin.unrealized_conversion_cast(
|
|
[T.vector(input.type.shape[0], T.i(4))], [input.maybe_downcast()]
|
|
)
|
|
res_vect = vector.bitcast(T.vector(i4_vec.type.shape[0] // 2, T.i8()), i4_vec)
|
|
return TensorSSA(res_vect, recast_shape, Int8)
|
|
return input
|
|
|
|
|
|
@CuTeDSL.kernel
|
|
def _convert_kernel(
|
|
gSrc: Tensor,
|
|
gDst: Tensor,
|
|
cSrc: Tensor,
|
|
src_tv_layout: Layout,
|
|
dst_tv_layout: Layout,
|
|
src_shape: Shape,
|
|
src_ty,
|
|
dst_ty,
|
|
):
|
|
tidx = nvvm.read_ptx_sreg_tid_x(T.i32())
|
|
bidx = nvvm.read_ptx_sreg_ctaid_x(T.i32())
|
|
|
|
cta_coord = (None, bidx)
|
|
# logical idx -> address
|
|
ctaSrc = gSrc[cta_coord] # (...,TileV,...)
|
|
ctaDst = gDst[cta_coord] # (...,TileV,...)
|
|
ctaCSrc = cSrc[cta_coord] # (...,TileV,...)
|
|
# print(f"ctaSrc = {ctaSrc.type}")
|
|
|
|
# compose with CTA TV layout
|
|
# tid, vid -> address
|
|
tidfrgSrc = composition(ctaSrc, src_tv_layout) # (T,V)
|
|
tidfrgDst = composition(ctaDst, dst_tv_layout) # (T,V)
|
|
tidfrgCSrc = composition(ctaCSrc, src_tv_layout) # (T,V)
|
|
# print(f"tidfrgSrc = {tidfrgSrc.type}")
|
|
|
|
# slice for threads
|
|
thr_coord = (tidx, None)
|
|
thrSrc = tidfrgSrc[thr_coord] # (V)
|
|
thrDst = tidfrgDst[thr_coord] # (V)
|
|
thrCSrc = tidfrgCSrc[thr_coord] # (V)
|
|
# print(f"thrSrc = {thrSrc.type}")
|
|
|
|
# predicate
|
|
if elem_less(thrCSrc[0], src_shape):
|
|
# allocate fragments for gmem->rmem
|
|
frgSrc = make_rmem_tensor(
|
|
get(src_tv_layout, mode=[1]), gSrc.element_type
|
|
) # (V)
|
|
frgDst = make_rmem_tensor(
|
|
get(dst_tv_layout, mode=[1]), gDst.element_type
|
|
) # (V)
|
|
# print(f"frgSrc = {frgSrc.type}")
|
|
|
|
# Move data to reg address space
|
|
copy_atom_load = make_copy_atom(nvgpu.CopyUniversalOp(), gSrc.element_type)
|
|
copy(copy_atom_load, thrSrc, frgSrc)
|
|
|
|
vec_src = frgSrc.load()
|
|
vec_src = _maybe_recast_to_f4(vec_src, src_ty)
|
|
vec_dst = vec_src.to(dst_ty)
|
|
vec_dst = _maybe_recast_from_f4(vec_dst, dst_ty)
|
|
frgDst.store(vec_dst)
|
|
|
|
# Copy the results back to c
|
|
copy_atom_stg = make_copy_atom(nvgpu.CopyUniversalOp(), gDst.element_type)
|
|
copy(copy_atom_stg, frgDst, thrDst)
|
|
|
|
|
|
@CuTeDSL.jit(preprocess=False)
|
|
def _convert(
|
|
src: Tensor,
|
|
dst: Tensor,
|
|
leading_mode: Constexpr,
|
|
elem_per_copy: Constexpr,
|
|
):
|
|
# Step 1. figure proper tv_layout
|
|
src_ty = src.element_type
|
|
dst_ty = dst.element_type
|
|
|
|
tv_layout = make_layout((128, elem_per_copy), stride=(elem_per_copy, 1))
|
|
|
|
# Step 2. maybe recast from f4 tensor
|
|
src, src_tv_layout = _maybe_recast_tensor_from_f4(src, tv_layout)
|
|
dst, dst_tv_layout = _maybe_recast_tensor_from_f4(dst, tv_layout)
|
|
src_shape = src.shape
|
|
# predicate tensor
|
|
idA = make_identity_tensor(src.shape)
|
|
|
|
# Step 3. select a proper tiling pattern as (...,TileV, ...)
|
|
src_cta_tiler = [
|
|
1,
|
|
] * rank(src.layout)
|
|
src_cta_tiler[leading_mode] = size(src_tv_layout) # (...,TileV,...)
|
|
dst_cta_tiler = [
|
|
1,
|
|
] * rank(dst.layout)
|
|
dst_cta_tiler[leading_mode] = size(dst_tv_layout) # (...,TileV,...)
|
|
|
|
# Step 4. partition input and output tensor by cta tiler.
|
|
gS = zipped_divide(src, tuple(src_cta_tiler)) # ((...,TileV,...),(...,RestV,...))
|
|
cS = zipped_divide(idA, tuple(src_cta_tiler)) # ((...,TileV,...),(...,RestV,...))
|
|
gD = zipped_divide(dst, tuple(dst_cta_tiler)) # ((...,TileV,...),(...,RestV,...))
|
|
# print(f"{gS.type=}")
|
|
|
|
_convert_kernel(
|
|
gS,
|
|
gD,
|
|
cS,
|
|
src_tv_layout,
|
|
dst_tv_layout,
|
|
src_shape,
|
|
src_ty,
|
|
dst_ty,
|
|
).launch(
|
|
grid=[size(gS, mode=[1]), 1, 1],
|
|
block=[size(src_tv_layout, mode=[0]), 1, 1],
|
|
)
|
|
|
|
|
|
# Converts from src tensor to dst tensor, their logical shape are required to be the same.
|
|
# And when src or dst dtype is narrow precision(Float4E2M1FN/Float8E8M0FNU/Float8E4M3FN), the shape of
|
|
# their leading dimension should be 4(fp8)/8(fp4) element align. (nvgpu.cvt_fptrunc/cvt_fpext
|
|
# needs 32-bits aligned input/output)
|
|
def convert(src: Tensor, dst: Tensor):
|
|
assert len(src.shape) == len(dst.shape), (
|
|
"Shape of src and dst tensors should be the same rank."
|
|
)
|
|
# find leading mode
|
|
leading_mode = [
|
|
idx
|
|
for idx, (shape, stride) in enumerate(zip(src.shape, src.stride))
|
|
if shape > 1 and stride == 1
|
|
]
|
|
if len(leading_mode) != 1:
|
|
raise ValueError(f"Leading mode should be unique, but got {leading_mode}")
|
|
leading_mode = leading_mode[0]
|
|
|
|
elem_per_copy = 2
|
|
|
|
if src.element_type.width == 4 or dst.element_type.width == 4:
|
|
elem_per_copy = 8
|
|
elif src.element_type.width == 8 or dst.element_type.width == 8:
|
|
elem_per_copy = 4
|
|
assert (
|
|
src.shape[leading_mode] % elem_per_copy == 0
|
|
and dst.shape[leading_mode] % elem_per_copy == 0
|
|
)
|
|
|
|
_convert(src, dst, leading_mode, elem_per_copy)
|
|
|
|
|
|
#########################################
|
|
# Testing utilities
|
|
#########################################
|
|
|
|
|
|
def sample_pytest(rand_cfg=None):
|
|
"""
|
|
Decorator to randomly sample pytest parametrized tests.
|
|
rand_cfg: Tuple[int, float] - (random_seed, sample_ratio)
|
|
Sampling is disabled when:
|
|
- A specific test is selected (via -k or direct test path)
|
|
- Not running under pytest
|
|
"""
|
|
import functools
|
|
import os
|
|
import random
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
seed, sample_ratio = rand_cfg
|
|
random.seed(seed)
|
|
|
|
def decorator(func):
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
if rand_cfg is not None and "PYTEST_CURRENT_TEST" in os.environ:
|
|
# Check if test was explicitly selected like ::test_name[param1-param2-...]
|
|
if "-k" in sys.argv or any(".py::" in arg for arg in sys.argv):
|
|
# Test was explicitly selected, don't skip
|
|
return func(*args, **kwargs)
|
|
|
|
if random.uniform(0.0, 1.0) > sample_ratio:
|
|
pytest.skip(f"Randomly skipped (sampling ratio: {sample_ratio})")
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
#########################################
|
|
# Benchmarking utilities
|
|
#########################################
|
|
|
|
|
|
class JitArguments:
|
|
"""
|
|
A type to hold both args and kwargs for passing to a kernel while benchmarking.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
self.references = list()
|
|
|
|
def add_to_scope(self, references: Any) -> None:
|
|
"""
|
|
Keeps references to external variables (e.g., Torch tensors when taking a view)
|
|
in the scope of the lifetime of the JitArguments object.
|
|
"""
|
|
self.references.extend(references)
|
|
|
|
|
|
def _cuda_success(
|
|
err: Union[tuple, cuda_runtime.cudaError_t, cuda_driver.CUresult], message: str
|
|
):
|
|
"""
|
|
Helper function to check CUDA API errors.
|
|
"""
|
|
if isinstance(err, tuple):
|
|
_cuda_success(err[0], message)
|
|
elif isinstance(err, cuda_runtime.cudaError_t):
|
|
error_message = cuda_runtime.cudaGetErrorString(err)[1].decode("utf-8")
|
|
if err != cuda_runtime.cudaError_t.cudaSuccess:
|
|
raise RuntimeError(f"{message} : {error_message}")
|
|
elif isinstance(err, cuda_driver.CUresult):
|
|
if err != cuda_driver.CUresult.CUDA_SUCCESS:
|
|
error_message = cuda_driver.cuGetErrorString(err)[1].decode("utf-8")
|
|
raise RuntimeError(f"{message} : {error_message}")
|
|
else:
|
|
raise TypeError(
|
|
f"{err} is an unexpected type : it should be a cudaError_t or CUresult"
|
|
)
|
|
|
|
|
|
def _does_kernel_use_stream(
|
|
kernel: Callable, stream: cuda_driver.CUstream, *args, **kwargs
|
|
):
|
|
"""
|
|
This function checks if the kernel uses the provided non-default stream.
|
|
It does this by capturing the stream and then checking if any kernels were launched.
|
|
:param kernel: The kernel to check
|
|
:type kernel: Callable
|
|
:param stream: The stream to check
|
|
:type stream: cuda_driver.CUstream
|
|
:return: True if the kernel uses the stream, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
|
|
assert int(stream) != int(cuda_driver.CUstream_flags.CU_STREAM_DEFAULT), (
|
|
"Stream must be a non-default stream"
|
|
)
|
|
|
|
err = cuda_runtime.cudaStreamBeginCapture(
|
|
stream, cuda_runtime.cudaStreamCaptureMode.cudaStreamCaptureModeThreadLocal
|
|
)
|
|
_cuda_success(err, "Error on stream capture")
|
|
|
|
kernel(*args, **kwargs)
|
|
|
|
err, graph = cuda_runtime.cudaStreamEndCapture(stream)
|
|
_cuda_success(err, "Error on stream capture")
|
|
|
|
# Get number of nodes in warmup graph to check it matches what is expected
|
|
err, _, num_nodes = cuda_runtime.cudaGraphGetNodes(graph)
|
|
_cuda_success(err, "Error on querying graph")
|
|
return num_nodes > 0
|
|
|
|
|
|
def benchmark(
|
|
callable: Callable,
|
|
*,
|
|
warmup_iterations: int = 10,
|
|
iterations: int = 100,
|
|
stream: Optional[cuda_driver.CUstream] = None,
|
|
kernel_arguments: Optional[JitArguments] = None,
|
|
workspace_generator: Optional[Callable[[], JitArguments]] = None,
|
|
workspace_count: int = 1,
|
|
use_cuda_graphs: bool = False,
|
|
) -> float:
|
|
"""Benchmarks a callable function with the specified parameters.
|
|
|
|
For example,
|
|
.. code-block:: python
|
|
|
|
from cutlass.cute.testing import benchmark
|
|
|
|
@cute.jit
|
|
def user_function(a: cute.Tensor, b: cute.Tensor, c: cute.Tensor, stream: cuda_driver.CUstream):
|
|
# contents of the function
|
|
pass
|
|
|
|
time_us = benchmark(user_function, kernel_arguments=JitArguments(a, b, c, stream)
|
|
warmup_iterations=10, iterations=100
|
|
stream=stream)
|
|
|
|
To prevent skewing results by repeately accessing the L2 cache, use the workspace_count and workspace_generator
|
|
parameters to cycle through a number of different workspaces.
|
|
|
|
.. code-block:: python
|
|
|
|
from cutlass.cute.testing import benchmark
|
|
|
|
@cute.jit
|
|
def user_function(a: cute.Tensor, b: cute.Tensor, c: cute.Tensor):
|
|
# contents of the function
|
|
pass
|
|
|
|
def workspace_generator():
|
|
# create a, b, and c
|
|
return JitArguments(a, b, c)
|
|
|
|
time_us = benchmark(user_function,
|
|
workspace_generator=workspace_generator,
|
|
workspace_count=10,
|
|
warmup_iterations=10000,
|
|
iterations=1000)
|
|
|
|
To benchmark you may always configure the function being profiled (callable), the warmup iterations, and
|
|
the number of profiling iterations.
|
|
|
|
Whenever the kernel being benchmarked runs in a non-default stream, the stream must be provided through the stream parameter.
|
|
|
|
To use CUDA graphs, the callable must be a compiled @cute.jit annotated function.
|
|
When using CUDA graphs, the kernel must be launched in a non-default stream.
|
|
|
|
:param callable: The function to benchmark. For jit function, it must be compiled functions.
|
|
:type callable: Callable
|
|
:param warmup_iterations: Number of warmup iterations, defaults to 10
|
|
:type warmup_iterations: int, optional
|
|
:param iterations: Number of benchmark iterations, defaults to 100
|
|
:type iterations: int, optional
|
|
:param stream: Stream kernel is launched in, defaults to CUDA stream default
|
|
:type stream: CUstream, None
|
|
:param kernel_arguments: Kernel arguments to launch callable with, defaults to None
|
|
:type kernel_arguments: JitArguments, None
|
|
:param workspace_generator: Function that returns kernel arguments, defaults to None
|
|
:type workspace_generator: Callable
|
|
:param workspace_count: Number of workspaces (arguments) to loop through, looping through enough workspaces will keep the L2 cache cold
|
|
:type workspace_count: int, optional
|
|
:param use_cuda_graphs: Whether to use cuda graphs, defaults to False
|
|
:type use_cuda_graphs: bool, optional
|
|
|
|
:return: The benchmark time in microseconds
|
|
:rtype: float
|
|
"""
|
|
|
|
import cutlass.base_dsl.jit_executor as jit_executor
|
|
import cutlass.cutlass_dsl.cuda_jit_executor as cuda_jit_executor
|
|
|
|
if stream is None:
|
|
stream = cuda_driver.CUstream(cuda_driver.CUstream_flags.CU_STREAM_DEFAULT)
|
|
|
|
if workspace_count < 1:
|
|
raise ValueError("workspace_count must be at least 1")
|
|
|
|
time_us = float("nan")
|
|
if workspace_generator == None:
|
|
# If no workspace generator is provided, we need a single workspace
|
|
if workspace_count != 1:
|
|
raise ValueError("Need a single workspace if not providing a generator")
|
|
|
|
# If no workspace generator is provided, we need a kernel_argument
|
|
if kernel_arguments == None:
|
|
raise ValueError(
|
|
"Please pass a kernel argument if not providing a generator"
|
|
)
|
|
workspace_generator = lambda: kernel_arguments
|
|
|
|
workspaces = [workspace_generator() for _ in range(workspace_count)]
|
|
|
|
for workspace in workspaces:
|
|
if type(workspace) != JitArguments:
|
|
raise TypeError(
|
|
"workspace_generator and/or kernel_arguments should use JitArguments type"
|
|
)
|
|
|
|
def _loop_and_call_kernel(iterations: int, workspace_index: int = 0):
|
|
for _ in range(iterations):
|
|
current_workspace = workspaces[workspace_index]
|
|
callable(*current_workspace.args, **current_workspace.kwargs)
|
|
workspace_index = (workspace_index + 1) % workspace_count
|
|
return workspace_index
|
|
|
|
# Create CUDA events for timing
|
|
err, start_event = cuda_driver.cuEventCreate(
|
|
cuda_driver.CUevent_flags.CU_EVENT_DEFAULT
|
|
)
|
|
_cuda_success(err, "Error on creating event")
|
|
err, end_event = cuda_driver.cuEventCreate(
|
|
cuda_driver.CUevent_flags.CU_EVENT_DEFAULT
|
|
)
|
|
_cuda_success(err, "Error on creating event")
|
|
|
|
elapsed_time = float("nan")
|
|
|
|
if use_cuda_graphs:
|
|
# Check if the stream is a non-default stream
|
|
if int(stream) == int(cuda_driver.CUstream_flags.CU_STREAM_DEFAULT):
|
|
raise ValueError(
|
|
"Measuring with CUDA Graphs requires executing in a non-default stream"
|
|
)
|
|
|
|
workspace_index = 0
|
|
|
|
# Capture warmup graph
|
|
err = cuda_runtime.cudaStreamBeginCapture(
|
|
stream, cuda_runtime.cudaStreamCaptureMode.cudaStreamCaptureModeThreadLocal
|
|
)
|
|
_cuda_success(err, "Error on stream capture")
|
|
|
|
workspace_index = _loop_and_call_kernel(warmup_iterations)
|
|
err, gwarm = cuda_runtime.cudaStreamEndCapture(stream)
|
|
_cuda_success(err, "Error on stream capture")
|
|
|
|
# Get number of nodes in warmup graph to check it matches what is expected
|
|
err, _, num_nodes = cuda_runtime.cudaGraphGetNodes(gwarm)
|
|
_cuda_success(err, "Error on querying graph")
|
|
# Assertion is >= since we may launch multiple kernels in one host function
|
|
if num_nodes < warmup_iterations:
|
|
raise ValueError(
|
|
"CUDA stream passed to benchmark does not match the stream the kernel was launched in"
|
|
)
|
|
|
|
# Capture profiling graph
|
|
err = cuda_runtime.cudaStreamBeginCapture(
|
|
stream, cuda_runtime.cudaStreamCaptureMode.cudaStreamCaptureModeThreadLocal
|
|
)
|
|
_cuda_success(err, "Error on stream capture")
|
|
_loop_and_call_kernel(iterations, workspace_index)
|
|
err, gprofile = cuda_runtime.cudaStreamEndCapture(stream)
|
|
_cuda_success(err, "Error on stream capture")
|
|
|
|
# Instantiate graphs
|
|
err, gwarm = cuda_runtime.cudaGraphInstantiate(gwarm, 0)
|
|
_cuda_success(err, "Error on graph instantiation")
|
|
err, gprofile = cuda_runtime.cudaGraphInstantiate(gprofile, 0)
|
|
_cuda_success(err, "Error on graph instantiation")
|
|
|
|
# Launch warmup graph
|
|
err = cuda_runtime.cudaGraphLaunch(gwarm, stream)
|
|
_cuda_success(err, "Error on graph launch")
|
|
|
|
# Record start time
|
|
err = cuda_driver.cuEventRecord(start_event, stream)
|
|
_cuda_success(err, "Error on recording event")
|
|
|
|
# Launch profiling graph
|
|
err = cuda_runtime.cudaGraphLaunch(gprofile, stream)
|
|
_cuda_success(err, "Error on graph launch")
|
|
|
|
# Record end time
|
|
err = cuda_driver.cuEventRecord(end_event, stream)
|
|
_cuda_success(err, "Error on recording event")
|
|
err = cuda_driver.cuEventSynchronize(end_event)
|
|
_cuda_success(err, "Error on synchronizing event")
|
|
|
|
# Get elapsed time
|
|
err, elapsed_time = cuda_driver.cuEventElapsedTime(start_event, end_event)
|
|
_cuda_success(err, "Error on querying event")
|
|
|
|
# Destroy graphs
|
|
err = cuda_runtime.cudaGraphExecDestroy(gwarm)
|
|
_cuda_success(err, "Error on destroying graph")
|
|
err = cuda_runtime.cudaGraphExecDestroy(gprofile)
|
|
_cuda_success(err, "Error on destroying graph")
|
|
|
|
else:
|
|
if int(stream) != int(
|
|
cuda_driver.CUstream_flags.CU_STREAM_DEFAULT
|
|
) and not _does_kernel_use_stream(
|
|
callable, stream, *workspaces[0].args, **workspaces[0].kwargs
|
|
):
|
|
raise ValueError(
|
|
"CUDA stream passed to benchmark does not match the stream the kernel was launched in"
|
|
)
|
|
|
|
# Not using graphs
|
|
# Warmup
|
|
workspace_index = _loop_and_call_kernel(warmup_iterations)
|
|
# Record start event
|
|
err = cuda_driver.cuEventRecord(start_event, stream)
|
|
_cuda_success(err, "Error on recording event")
|
|
_loop_and_call_kernel(iterations, workspace_index)
|
|
# Record end event
|
|
err = cuda_driver.cuEventRecord(end_event, stream)
|
|
_cuda_success(err, "Error on recording event")
|
|
# Synchronize end event
|
|
err = cuda_driver.cuEventSynchronize(end_event)
|
|
_cuda_success(err, "Error on synchronizing event")
|
|
err, elapsed_time = cuda_driver.cuEventElapsedTime(start_event, end_event)
|
|
_cuda_success(err, "Error on querying event")
|
|
|
|
# Destroy events
|
|
err = cuda_driver.cuEventDestroy(start_event)
|
|
_cuda_success(err, "Error on destroying event")
|
|
err = cuda_driver.cuEventDestroy(end_event)
|
|
_cuda_success(err, "Error on destroying event")
|
|
|
|
return elapsed_time / iterations * 1e3
|
|
|
|
|
|
def get_workspace_count(
|
|
one_workspace_bytes: int, warmup_iterations: int, iterations: int
|
|
) -> int:
|
|
"""Calculate the number of workspaces needed to fill L2 cache.
|
|
|
|
:param one_workspace_bytes: Size of one workspace in bytes
|
|
:type one_workspace_bytes: int
|
|
:param warmup_iterations: Number of warmup iterations
|
|
:type warmup_iterations: int
|
|
:param iterations: Number of iterations
|
|
:type iterations: int
|
|
:return: Number of workspaces needed
|
|
:rtype: int
|
|
"""
|
|
from cutlass.utils import HardwareInfo
|
|
|
|
num_l2_cache_bytes = HardwareInfo().get_l2_cache_size_in_bytes()
|
|
num_workspaces = (num_l2_cache_bytes * 3) // one_workspace_bytes + 1
|
|
num_iters = warmup_iterations + iterations
|
|
return num_iters if num_iters < num_workspaces else num_workspaces
|
|
|
|
|
|
#########################################
|
|
# Autotuning/Tuning utilities
|
|
#########################################
|
|
|
|
|
|
def _benchmark_for_autotune(
|
|
callable: Callable,
|
|
*args,
|
|
warmup_iterations: int,
|
|
iterations: int,
|
|
use_cold_l2: bool,
|
|
print_verbose: bool,
|
|
current_stream: Optional[cuda_driver.CUstream] = None,
|
|
**kwargs,
|
|
) -> float:
|
|
"""Benchmarks a callable function with the specified parameters.
|
|
|
|
This function differs from the benchmark function in that it is used for autotuning. In this case we
|
|
do not loop through workspaces to keep the L2 cache cold. Instead we rely on writing to an L2 cache sized address to keep the L2 cache cold.
|
|
|
|
The primary reason for doing this is that we do not have information on how to generate the workspaces for the kernel when autotuning.
|
|
We also do not have information on how much memory the workspaces take up.
|
|
|
|
This benchmarking is done as a close approximation of the actual runtime of the kernel in an E2E system,
|
|
where we may have clock throttling, a warm cache, or other factors that could affect the runtime of the kernel.
|
|
|
|
:param callable: The function to benchmark
|
|
:type callable: Callable
|
|
:param args: Arguments to pass to the callable function
|
|
:param warmup_iterations: Number of warmup iterations, defaults to 10
|
|
:type warmup_iterations: int, optional
|
|
:param iterations: Number of benchmark iterations, defaults to 100
|
|
:type iterations: int, optional
|
|
:param use_cold_l2: Whether to clear L2 cache between runs, defaults to True
|
|
:type use_cold_l2: bool, optional
|
|
:param print_verbose: Whether to print verbose output, defaults to False
|
|
:type print_verbose: bool, optional
|
|
:param current_stream: Stream to benchmark in, defaults to CUDA stream default
|
|
:type current_stream: CUstream, None
|
|
:param kwargs: Additional keyword arguments to pass to the callable function
|
|
|
|
:return: The benchmark time in microseconds
|
|
:rtype: float
|
|
"""
|
|
if current_stream is None:
|
|
current_stream = cuda_driver.CUstream(
|
|
cuda_driver.CUstream_flags.CU_STREAM_DEFAULT
|
|
)
|
|
|
|
if int(current_stream) != int(
|
|
cuda_driver.CUstream(cuda_driver.CUstream_flags.CU_STREAM_DEFAULT)
|
|
) and not _does_kernel_use_stream(callable, current_stream, *args, **kwargs):
|
|
raise ValueError(f"Incorrect stream passed to kernel: {current_stream}")
|
|
|
|
if use_cold_l2:
|
|
from cutlass.utils import HardwareInfo
|
|
|
|
# use memset to clear L2 cache
|
|
hardware_info = HardwareInfo()
|
|
num_l2_cache_bytes = hardware_info.get_l2_cache_size_in_bytes()
|
|
err, cache_ptr = cuda_driver.cuMemAlloc(int(num_l2_cache_bytes))
|
|
_cuda_success(err, "Error on allocating memory")
|
|
|
|
# Create CUDA events for timing
|
|
err, start_event = cuda_driver.cuEventCreate(
|
|
cuda_driver.CUevent_flags.CU_EVENT_DEFAULT
|
|
)
|
|
_cuda_success(err, "Error on creating event")
|
|
err, end_event = cuda_driver.cuEventCreate(
|
|
cuda_driver.CUevent_flags.CU_EVENT_DEFAULT
|
|
)
|
|
_cuda_success(err, "Error on creating event")
|
|
try:
|
|
# warmup
|
|
for _ in range(warmup_iterations):
|
|
callable(*args, **kwargs)
|
|
|
|
time = 0
|
|
execution_time_ms = []
|
|
for _ in range(iterations):
|
|
if use_cold_l2:
|
|
# clear L2 cache by memset to zero for every run
|
|
err = cuda_driver.cuMemsetD32Async(
|
|
cache_ptr, 0, int(num_l2_cache_bytes // 4), current_stream
|
|
)
|
|
_cuda_success(err, "Error on memset")
|
|
err = cuda_driver.cuEventRecord(start_event, current_stream)
|
|
_cuda_success(err, "Error on recording event")
|
|
callable(*args, **kwargs)
|
|
err = cuda_driver.cuEventRecord(end_event, current_stream)
|
|
_cuda_success(err, "Error on recording event")
|
|
err = cuda_driver.cuEventSynchronize(end_event)
|
|
_cuda_success(err, "Error on synchronizing event")
|
|
err, elapsed_time = cuda_driver.cuEventElapsedTime(start_event, end_event)
|
|
_cuda_success(err, "Error on querying event")
|
|
execution_time_ms.append(elapsed_time)
|
|
# unit: us
|
|
time_us = sum(execution_time_ms) * 1e3 / len(execution_time_ms)
|
|
except Exception as e:
|
|
print(f"This config execution error: {e}")
|
|
time_us = float("inf")
|
|
if print_verbose:
|
|
print(f"Execution time: {time_us:.4f} us")
|
|
|
|
if use_cold_l2:
|
|
err = cuda_driver.cuMemFree(cache_ptr)
|
|
_cuda_success(err, "Error on freeing memory")
|
|
err = cuda_driver.cuEventDestroy(start_event)
|
|
_cuda_success(err, "Error on destroying event")
|
|
err = cuda_driver.cuEventDestroy(end_event)
|
|
_cuda_success(err, "Error on destroying event")
|
|
return time_us
|
|
|
|
|
|
class autotune_jit:
|
|
"""Auto-tuning tool supporting both dictionary and parameterized decorator styles.
|
|
The autotune_jit class can be used as a decorator or a function.
|
|
When used as a decorator, it will automatically tune the function based on the parameters.
|
|
When used as a function, it will return a decorator that can be used to decorate a function.
|
|
For example:
|
|
.. code-block:: python
|
|
|
|
@autotune_jit(params_dict={'param1': [1, 2, 3], 'param2': [4, 5, 6]}, update_on_change=['param3'])
|
|
@cute.jit
|
|
def user_function(param1=1, param2=2, param3=3):
|
|
# contents of the function
|
|
pass
|
|
|
|
The function will be automatically tuned over all combinations of param1 and param2 whenever param3 changes .
|
|
For non-specified parameters, the default value in user_function will be used (e.g., `param3` in `user_function`).
|
|
.. code-block:: python
|
|
user_function(a, b, c) # Autotunes code
|
|
user_function(a, b, c) # This call pulls the best kernel from cache
|
|
|
|
Known Limitations:
|
|
- Only supports functions that are decorated with cute.jit
|
|
- If the function which is decorated with cute.jit is call method of a class, and the class has internal state that
|
|
is used as constexpr arguments in the function, the autotuner will not be able to find the best configuration.
|
|
|
|
Note: The autotuner has the same semantics as cute.compile. If the function is compiled, but global variables are changed,
|
|
the autotuner will not recompile the kernel.
|
|
"""
|
|
|
|
logger = None
|
|
|
|
@classmethod
|
|
def _initialize_logger(cls):
|
|
"""Ensure the logger is initialized"""
|
|
if cls.logger is None:
|
|
cls.logger = logging.getLogger(__name__ + "_Autotune")
|
|
if not cls.logger.handlers:
|
|
handler = logging.StreamHandler()
|
|
formatter = logging.Formatter(
|
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
handler.setFormatter(formatter)
|
|
cls.logger.addHandler(handler)
|
|
if (
|
|
os.environ.get("CUTE_DSL_LOG_AUTOTUNE") is not None
|
|
and os.environ.get("CUTE_DSL_LOG_AUTOTUNE") != "0"
|
|
):
|
|
cls.logger.setLevel(logging.INFO)
|
|
|
|
@classmethod
|
|
def _create_tuning_wrapper(
|
|
cls, func, warmup_iterations, iterations, autotune_update_params
|
|
):
|
|
"""Create a wrapper function that performs auto-tuning
|
|
|
|
Args:
|
|
func: Original function
|
|
|
|
Returns:
|
|
Decorated wrapper function
|
|
"""
|
|
from cutlass.cute import compile
|
|
|
|
# Initialize autotune parameters
|
|
if not hasattr(func, "_autotune_params"):
|
|
func._original_func = func
|
|
func._autotune_params = {}
|
|
func._autotune_update_params = autotune_update_params
|
|
func._best_kernel = dict()
|
|
func._best_config = dict()
|
|
|
|
# Create wrapper function for auto-tuning
|
|
@functools.wraps(func)
|
|
def tuning_wrapper(*args, **kwargs):
|
|
parameters = inspect.signature(func._original_func).parameters.keys()
|
|
tuning_key = list()
|
|
for param_name in func._autotune_update_params:
|
|
if param_name in kwargs.keys():
|
|
tuning_key.append(kwargs[param_name])
|
|
else:
|
|
index = list(parameters).index(param_name)
|
|
if index < len(args):
|
|
tuning_key.append(args[index])
|
|
tuning_key = tuple(tuning_key)
|
|
if tuning_key in func._best_kernel.keys():
|
|
cls.logger.info(
|
|
f"Using cached best configuration: {func._best_config[tuning_key]}"
|
|
)
|
|
return func._best_kernel[tuning_key](*args, **kwargs)
|
|
|
|
# Get all parameter configurations
|
|
params_dict = func._autotune_params
|
|
keys = list(params_dict.keys())
|
|
values = list(params_dict.values())
|
|
|
|
min_time = float("inf")
|
|
|
|
best_kernel = None
|
|
# Record start time
|
|
start = time()
|
|
|
|
# Iterate through all possible configuration combinations
|
|
for config_values in product(*values):
|
|
# Build current configuration
|
|
current_config = dict(zip(keys, config_values))
|
|
cls.logger.info(f"Tuning configuration: {current_config}")
|
|
|
|
try:
|
|
# Call the original function, using current configuration to replace default parameters
|
|
# For example, if current_config contains "cluster_shape_mn": (2, 1)
|
|
# It will override func's default parameter value
|
|
merged_kwargs = {**kwargs, **current_config}
|
|
compiled_func = compile(
|
|
func._original_func, *args, **merged_kwargs
|
|
)
|
|
|
|
# Detect which constexpr arguments we need to remove from args and merged_kwargs
|
|
# This is done because after compiling our function signature will change, removing all constexpr arguments.
|
|
indexes_to_remove = list()
|
|
for arg in compiled_func.args_spec.get_constexpr_args():
|
|
if arg["argument_name"] in merged_kwargs:
|
|
del merged_kwargs[arg["argument_name"]]
|
|
elif arg["argument_index"] is not None:
|
|
indexes_to_remove.append(arg["argument_index"])
|
|
if arg["argument_name"] not in func._autotune_update_params:
|
|
# Handle the case where the programmer avoided autotuning over constexpr values, and
|
|
# recompile in that case
|
|
func._autotune_update_params.append(
|
|
arg["argument_name"]
|
|
)
|
|
|
|
# Remove constexpr arguments from args
|
|
args_no_constexpr = list(args)
|
|
for index in sorted(indexes_to_remove, reverse=True):
|
|
del args_no_constexpr[index]
|
|
|
|
# Benchmark the compiled function
|
|
cur_time = _benchmark_for_autotune(
|
|
compiled_func,
|
|
*args_no_constexpr,
|
|
warmup_iterations=warmup_iterations,
|
|
iterations=iterations,
|
|
use_cold_l2=True,
|
|
print_verbose=False,
|
|
**merged_kwargs,
|
|
)
|
|
|
|
cls.logger.info(f" Execution time: {cur_time} us")
|
|
|
|
# Update best results
|
|
if cur_time < min_time:
|
|
min_time = cur_time
|
|
best_kernel = compiled_func
|
|
best_config = current_config
|
|
|
|
except NotImplementedError as e:
|
|
cls.logger.info(
|
|
f" Encountered unimplemented error, abort execution: {e}"
|
|
)
|
|
raise e
|
|
except (ValueError, TypeError) as e:
|
|
cls.logger.info(f" Configuration parameter skipping: {e}")
|
|
raise e
|
|
continue
|
|
except Exception as e:
|
|
cls.logger.info(f" Execution error skipping: {e}")
|
|
raise e
|
|
continue
|
|
|
|
end = time()
|
|
tuning_time = end - start
|
|
|
|
if best_kernel is None:
|
|
raise ValueError("No best kernel found")
|
|
|
|
cls.logger.info(
|
|
f"Best configuration: {best_config}, execution time: {min_time} us"
|
|
)
|
|
cls.logger.info(f"Total tuning time: {tuning_time} s")
|
|
func._best_kernel[tuning_key] = best_kernel
|
|
func._best_config[tuning_key] = best_config
|
|
return best_kernel(*args, **kwargs)
|
|
|
|
# Append autotune wrapper to not conflict with the jit kernel names
|
|
tuning_wrapper.__name__ = func.__name__ + "_autotune_wrapper"
|
|
tuning_wrapper.__qualname__ = func.__qualname__ + "_autotune_wrapper"
|
|
|
|
return tuning_wrapper
|
|
|
|
return func # If already has a wrapper, return the original function
|
|
|
|
def __init__(
|
|
self,
|
|
params_dict: Dict[str, List[Any]] = None,
|
|
update_on_change: List[str] = None,
|
|
warmup_iterations=10,
|
|
iterations=100,
|
|
):
|
|
"""Initialize the autotune_jit decorator.
|
|
|
|
:param params_dict: Dictionary containing parameter names and their possible values
|
|
:type params_dict: Dict[str, List[Any]], optional
|
|
:param update_on_change: Whether to retune when the parameters changes, defaults to None
|
|
:type update_on_change: bool, optional
|
|
:param warmup_iterations: Number of warmup iterations, defaults to 100
|
|
:type warmup_iterations: int, optional
|
|
:param iterations: Number of benchmark iterations, defaults to 100
|
|
:type iterations: int, optional
|
|
"""
|
|
# Initialize logger
|
|
self._initialize_logger()
|
|
|
|
# Save parameter dictionary
|
|
self.params_dict = params_dict or {}
|
|
self.update_on_change = update_on_change or list()
|
|
|
|
# Save iterations
|
|
self.warmup_iterations = warmup_iterations
|
|
self.iterations = iterations
|
|
|
|
def __call__(self, func):
|
|
"""Called when class instance is used as a decorator.
|
|
|
|
:param func: Function to be decorated
|
|
:type func: Callable
|
|
:return: Decorated function
|
|
:rtype: Callable
|
|
"""
|
|
# Create wrapper function
|
|
decorated_func = self._create_tuning_wrapper(
|
|
func, self.warmup_iterations, self.iterations, self.update_on_change
|
|
)
|
|
|
|
# Use the wrapper if it exists, otherwise use the original function
|
|
result_func = (
|
|
decorated_func if hasattr(decorated_func, "_autotune_params") else func
|
|
)
|
|
|
|
# Add parameters from the dictionary to the function's autotune parameters
|
|
for param_name, param_values in self.params_dict.items():
|
|
result_func._autotune_params[param_name] = param_values
|
|
|
|
return result_func
|
|
|
|
|
|
def tune(
|
|
func: Callable[[Any], Callable[[], Any]],
|
|
params_dict: Dict[str, List[Any]] = None,
|
|
kernel_arguments: JitArguments = JitArguments(),
|
|
warmup_iterations=10,
|
|
iterations=100,
|
|
stream: Optional[cuda_driver.CUstream] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Tuning tool to suport arbitrary functions. The user must provide a function that returns a callable, which
|
|
takes no arguments to be tuned over.
|
|
Best practice is to return a jit function that is compiled with cute.compile for optimal performance.
|
|
For example:
|
|
.. code-block:: python
|
|
|
|
def user_function(param1=1, param2=2, param3=3) -> Callable[[], Any]:
|
|
# contents of the function
|
|
return lambda : compiled_func(param1, param2, param3)
|
|
|
|
config = tune(user_function, params_dict={'param1': [1, 2, 3], 'param2': [4, 5, 6]}, update_on_change=['param3'])
|
|
|
|
:param func: Function to be tuned, note that errors raised in the function will be ignored and the next configuration will be tried.
|
|
:type func: Callable[[Any], Callable[[], Any]]
|
|
:param params_dict: Dictionary containing parameter names and their possible values
|
|
:type params_dict: Dict[str, List[Any]], optional
|
|
:param kernel_arguments: Kernel arguments to launch callable with, defaults to JitArguments()
|
|
:type kernel_arguments: JitArguments, optional
|
|
:param warmup_iterations: Number of warmup iterations, defaults to 10
|
|
:type warmup_iterations: int, optional
|
|
:param iterations: Number of benchmark iterations, defaults to 100
|
|
:type iterations: int, optional
|
|
:param stream: Stream kernel is launched in, defaults to CUDA stream default
|
|
:type stream: CUstream, None
|
|
:return: Best configuration
|
|
:rtype: Dict[str, Any]
|
|
"""
|
|
logger = logging.getLogger(__name__ + "_Autotune")
|
|
if not logger.handlers:
|
|
handler = logging.StreamHandler()
|
|
formatter = logging.Formatter(
|
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
handler.setFormatter(formatter)
|
|
logger.addHandler(handler)
|
|
if (
|
|
os.environ.get("CUTE_DSL_LOG_AUTOTUNE") is not None
|
|
and os.environ.get("CUTE_DSL_LOG_AUTOTUNE") != "0"
|
|
):
|
|
logger.setLevel(logging.INFO)
|
|
|
|
if stream is None:
|
|
stream = cuda_driver.CUstream(cuda_driver.CUstream_flags.CU_STREAM_DEFAULT)
|
|
|
|
# Get all parameter configurations
|
|
keys = list(params_dict.keys())
|
|
values = list(params_dict.values())
|
|
|
|
min_time = float("inf")
|
|
|
|
best_config = None
|
|
# Record start time
|
|
start = time()
|
|
|
|
# Iterate through all possible configuration combinations
|
|
for config_values in product(*values):
|
|
# Build current configuration
|
|
current_config = dict(zip(keys, config_values))
|
|
logger.info(f"Tuning configuration: {current_config}")
|
|
|
|
try:
|
|
merged_kwargs = {**kernel_arguments.kwargs, **current_config}
|
|
|
|
compiled_func = func(*kernel_arguments.args, **merged_kwargs)
|
|
# Benchmark the compiled function
|
|
cur_time = _benchmark_for_autotune(
|
|
compiled_func,
|
|
warmup_iterations=warmup_iterations,
|
|
iterations=iterations,
|
|
use_cold_l2=True,
|
|
print_verbose=False,
|
|
current_stream=stream,
|
|
)
|
|
|
|
logger.info(f" Execution time: {cur_time} us")
|
|
|
|
# Update best results
|
|
if cur_time < min_time:
|
|
min_time = cur_time
|
|
best_config = current_config
|
|
|
|
except NotImplementedError as e:
|
|
logger.info(f" Encountered unimplemented error, abort execution: {e}")
|
|
raise e
|
|
except (ValueError, TypeError, CantImplementError) as e:
|
|
logger.info(f" Configuration parameter skipping: {e}")
|
|
continue
|
|
except Exception as e:
|
|
logger.info(f" Execution error skipping: {e}")
|
|
continue
|
|
|
|
end = time()
|
|
tuning_time = end - start
|
|
|
|
if best_config is None:
|
|
raise ValueError("No best kernel found")
|
|
|
|
logger.info(f"Best configuration: {best_config}, execution time: {min_time} us")
|
|
logger.info(f"Total tuning time: {tuning_time} s")
|
|
return best_config
|
|
|
|
|
|
class CantImplementError(Exception):
|
|
"""Exception raised when a function is not implemented."""
|
|
|
|
def __init__(self, message=None):
|
|
self.message = message or "The current config is invalid/unsupported"
|
|
super().__init__(self.message)
|
|
|
|
def __str__(self):
|
|
return self.message
|
|
|
|
def __repr__(self):
|
|
return self.message
|