Files
wehub-resource-sync 26446540fa
Lint / lint (push) Waiting to run
CI / MacOS (push) Waiting to run
CI / Windows (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:36:25 +08:00

804 lines
28 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import argparse
import os
import re
import subprocess
import sys
import time
from collections.abc import Mapping
from enum import Enum
import numpy as np
import torch
import triton.profiler as proton
import tvm_ffi
import tvm
from tvm.script import tirx as T
from tvm.support import nvcc
def is_running_under_pytest():
"""Check if the code is being executed within a pytest session."""
return "PYTEST_CURRENT_TEST" in os.environ
def setup():
parser = argparse.ArgumentParser()
parser.add_argument("--dump-ptx", type=str, help="Dump PTX code to specified file")
parser.add_argument("--dump-source", action="store_true", help="Dump source code")
args = parser.parse_args()
if args.dump_ptx:
@tvm_ffi.register_global_func("tvm_callback_cuda_compile", override=True)
def tvm_callback_cuda_compile(code, target):
ptx = nvcc.compile_cuda(code, target_format="ptx")
with open(args.dump_ptx, "w", encoding="utf-8") as f:
f.write(ptx.decode())
return ptx
return args
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
# proton-viewer -m avg_time/us prints average kernel time in microseconds (see
# triton/profiler/viewer.py avg_time_factor_dict). Store microseconds as-is.
PROTON_AVG_TIME_METRIC = "avg_time/us"
def _parse_proton_tree(text, *, kernel: str = ""):
"""Parse proton-viewer tree output into {impl: time_us}.
Accepts ALL depth-1 nodes (no KNOWN_IMPLS filter). For each depth-1 impl,
takes the slowest depth-2 child kernel time.
Tree numbers are microseconds when ProtonContext uses avg_time/us.
Returns (impl_times, baseline_errors) where:
impl_times: {str: float} — impl name to avg time in microseconds
baseline_errors: {str: str} — impl name to error message
"""
_ = kernel # kept for callers; unit does not depend on workload
impl = None
results = {}
baseline_errors = {}
for raw in text.splitlines():
line = _ANSI_RE.sub("", raw).rstrip()
if not line:
continue
if line.startswith("BASELINE_ERROR:"):
parts = line.split(":", 2)
if len(parts) >= 3:
baseline_errors[parts[1].strip()] = parts[2].strip()
continue
# Depth-1 impl header: starts with tree drawing chars
if line and line[0] in "\u251c\u2514": # ├ └
parts = line.split("\u2500", 1)[-1].split() # split on ─
if len(parts) >= 2:
impl = parts[1]
else:
impl = None
continue
# Depth-2 kernel: contains tree drawing chars at deeper indent
if impl and ("\u251c\u2500" in line or "\u2514\u2500" in line): # ├─ └─
parts = line.split("\u2500", 1)[-1].split()
if len(parts) >= 2:
name = parts[1]
if (
"vectorized_elementwise_kernel" in name
or "elementwise_kernel_with_index" in name
):
continue
try:
t = float(parts[0])
results[impl] = max(results.get(impl, 0), t)
except ValueError:
pass
return results, baseline_errors
class ProtonContext:
"""Context manager for Proton profiling sessions.
Always captures proton-viewer output and parses impl times so that
get_impl_times() / get_baseline_errors() work after exiting the context.
The proton tree is printed to **stdout** by default (visible on screen
when running kernels interactively). When the environment variable
``TIRX_BENCH_JSON=1`` is set (done automatically by ``--json`` mode),
the tree goes to **stderr** instead so it does not corrupt the JSON on
stdout.
"""
def __init__(
self,
name="kernel",
hook="triton",
debug=False,
nsight=False,
metric=PROTON_AVG_TIME_METRIC,
kernel="",
):
self.name = name
self.hook = hook
self.debug = debug
self.nsight = nsight
self.metric = metric
self.kernel = kernel
self._impl_times = {}
self._baseline_errors = {}
def __enter__(self):
if not is_running_under_pytest() and not self.debug and not self.nsight:
proton.start(self.name, hook=self.hook)
proton.deactivate()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if not is_running_under_pytest() and not self.debug and not self.nsight:
proton.finalize()
hatchet = f"{self.name}.hatchet"
result = subprocess.run(
["proton-viewer", "-m", self.metric, hatchet],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
self._impl_times, self._baseline_errors = _parse_proton_tree(
result.stdout, kernel=self.kernel
)
out = sys.stderr if os.environ.get("TIRX_BENCH_JSON") else sys.stdout
print(f"# proton {PROTON_AVG_TIME_METRIC} (microseconds)\n", file=out, end="")
print(result.stdout, file=out, end="")
else:
print(
f"proton-viewer failed (rc={result.returncode}): {result.stderr}",
file=sys.stderr,
)
if os.path.exists(hatchet):
os.remove(hatchet)
def get_impl_times(self):
"""Return {impl_name: avg_time_us} parsed from proton-viewer output."""
return dict(self._impl_times)
def get_baseline_errors(self):
"""Return {impl_name: error_message} from BASELINE_ERROR lines."""
return dict(self._baseline_errors)
def _get_l2_cache_bytes():
"""Query L2 cache size from the current CUDA device, fallback to 128MB."""
try:
props = torch.cuda.get_device_properties(torch.cuda.current_device())
if hasattr(props, "l2_cache_size") and props.l2_cache_size > 0:
return props.l2_cache_size
except Exception:
pass
return 128 * 1024 * 1024 # 128MB default (B200)
def _tensor_bytes(args, _seen=None):
"""Sum the byte size of all torch/tvm tensors in a nested value."""
if _seen is None:
_seen = set()
total = 0
if isinstance(args, list | tuple):
for a in args:
total += _tensor_bytes(a, _seen)
elif isinstance(args, Mapping):
for a in args.values():
total += _tensor_bytes(a, _seen)
elif isinstance(args, torch.Tensor):
key = ("torch", args.device.type, args.device.index, int(args.data_ptr()))
if key not in _seen:
_seen.add(key)
total += args.nelement() * args.element_size()
elif hasattr(args, "numpy"): # tvm.runtime.NDArray
try:
key = ("tvm", int(args.handle.value))
except Exception:
key = ("tvm", id(args))
if key not in _seen:
_seen.add(key)
try:
total += int(np.prod(args.shape)) * np.dtype(str(args.dtype)).itemsize
except Exception:
total += args.numpy().nbytes
return total
def tensor_bytes(*values):
"""Return unique torch/tvm tensor bytes for kernel-owned byte accounting.
The benchmark driver does not use this implicitly. Kernel benchmark
factories may call it when their invocation footprint is exactly the set of
tensors in ``values``.
"""
if len(values) == 1:
return _tensor_bytes(values[0])
return _tensor_bytes(values)
def _compute_group_count(input_bytes, l2_bytes=None):
"""Return TK-style input-group count from one invocation's byte footprint."""
if input_bytes <= 0:
return 1
if l2_bytes is None:
l2_bytes = _get_l2_cache_bytes()
threshold = l2_bytes * 3
if input_bytes >= threshold:
return 1
return int(threshold // input_bytes) + 1
def _make_bench_input(input_factory):
value = input_factory()
if not isinstance(value, tuple) or len(value) != 2:
raise TypeError("input_factory must return (case, input_bytes)")
case, input_bytes = value
try:
input_bytes = int(input_bytes)
except (TypeError, ValueError) as err:
raise TypeError("input_factory input_bytes must be an integer") from err
if input_bytes < 0:
raise ValueError("input_factory input_bytes must be non-negative")
return case, input_bytes
def prepare_input_groups(input_factory, l2_bytes=None):
"""Materialize TK-style input groups from a single-group factory.
``input_factory`` must return ``(case, input_bytes)``. ``case`` is passed
back to every benchmark function unchanged. ``input_bytes`` defines one
invocation's L2-eviction footprint and is intentionally owned by the kernel
benchmark harness instead of inferred here.
"""
if not callable(input_factory):
raise TypeError("input_factory must be callable")
if l2_bytes is None:
l2_bytes = _get_l2_cache_bytes()
sample, input_bytes = _make_bench_input(input_factory)
num_groups = _compute_group_count(input_bytes, l2_bytes)
groups = [sample]
for _ in range(num_groups - 1):
case, _ = _make_bench_input(input_factory)
groups.append(case)
return groups, {
"num_groups": num_groups,
"input_bytes": input_bytes,
"l2_bytes": l2_bytes,
"l2_eviction_factor": 3,
"flush_l2": False,
}
def _bench_event_groups(funcs, groups, warmup, repeat, cooldown_s):
num_groups = len(groups)
results = {}
for idx, (name, func) in enumerate(funcs.items()):
if idx > 0:
time.sleep(cooldown_s)
for i in range(warmup):
func(groups[i % num_groups])
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
torch.cuda.synchronize()
start_event.record()
for i in range(repeat):
func(groups[i % num_groups])
end_event.record()
torch.cuda.synchronize()
results[name] = start_event.elapsed_time(end_event) / repeat * 1000.0
time.sleep(cooldown_s)
return results
def _bench_proton_groups(
funcs, groups, warmup, repeat, cooldown_s, proton_name, debug, nsight, *, kernel=""
):
num_groups = len(groups)
with ProtonContext(proton_name, debug=debug, nsight=nsight, kernel=kernel) as ctx:
for idx, (name, func) in enumerate(funcs.items()):
if idx > 0:
time.sleep(cooldown_s)
for i in range(warmup):
func(groups[i % num_groups])
torch.cuda.synchronize()
if not is_running_under_pytest() and not debug and not nsight:
proton.activate()
with proton.scope(name, metrics={}):
for i in range(repeat):
func(groups[i % num_groups])
proton.deactivate()
else:
for i in range(repeat):
func(groups[i % num_groups])
torch.cuda.synchronize()
time.sleep(cooldown_s)
return ctx.get_impl_times(), ctx.get_baseline_errors()
def _flush_l2_legacy(flush_l2_size):
if flush_l2_size > 0:
torch.empty(flush_l2_size, dtype=torch.int, device="cuda").zero_()
def _bench_legacy_callable(func, warmup, repeat, proton_name, debug, nsight, flush_l2_size):
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
def timed_loop():
start_event.record()
for _ in range(repeat):
_flush_l2_legacy(flush_l2_size)
func()
end_event.record()
for _ in range(warmup):
_flush_l2_legacy(flush_l2_size)
func()
torch.cuda.synchronize()
if not is_running_under_pytest() and not debug and not nsight:
proton.activate()
with proton.scope(proton_name, metrics={}):
timed_loop()
proton.deactivate()
else:
timed_loop()
torch.cuda.synchronize()
return start_event.elapsed_time(end_event) / repeat * 1000.0
# Labels identifying our own kernel (vs external reference impls). Must match
# OUR_IMPLS in bench_suite's ratio_diff.py. Used by the TIRX_BENCH_IMPLS filter.
OURS_IMPLS = frozenset({"tir", "tirx"})
def bench_impls_mode():
"""Current impl-selection mode: 'all' (default), 'ours', or 'baseline'.
Set via the ``TIRX_BENCH_IMPLS`` env var (by bench_suite ``run.py --impls``).
A kernel's ``run_bench`` can use this to skip *building/warming* reference
impls (e.g. flashinfer autotune, deepgemm/cublas ext setup) that ``bench``'s
filter alone cannot avoid, since that setup runs before ``bench`` is called.
"""
mode = os.environ.get("TIRX_BENCH_IMPLS", "all").lower()
if mode not in {"all", "ours", "baseline"}:
raise ValueError(f"TIRX_BENCH_IMPLS must be 'all', 'ours', or 'baseline', got {mode!r}")
return mode
def bench_only_ours():
"""True when only our own kernel should be benched (reference setup skippable)."""
return bench_impls_mode() == "ours"
def filter_impls(funcs):
"""Filter a ``{label: callable}`` impl map per the current ``bench_impls_mode``.
Call this right after building ``funcs`` so any subsequent
``if "<ref>" in funcs:`` reference-setup blocks are skipped in 'ours' mode.
"""
mode = bench_impls_mode()
if mode == "ours":
return {n: f for n, f in funcs.items() if n in OURS_IMPLS}
if mode == "baseline":
return {n: f for n, f in funcs.items() if n not in OURS_IMPLS}
return funcs
def bench(
funcs,
input_factory=None,
warmup=500,
repeat=100,
cooldown_s=1.0,
timer="proton",
proton_name="kernel",
l2_bytes=None,
debug=False,
nsight=False,
flush_l2_size=int(8e8 // 4),
references=None,
rounds=1,
round_cooldown_s=1.0,
validate_case=None,
):
"""Benchmark implementations with a factory-owned input footprint.
This is the single TIRx benchmark API. It follows the ThunderKittens-style
multi-input protocol for L2 eviction and supports either Proton/CUPTI or
CUDA-event timing. The benchmark driver never infers which tensors belong
to a workload; ``input_factory`` owns that definition by returning
``(case, input_bytes)``.
Parameters
----------
funcs : dict[str, callable]
Map of implementation name to callable. Each callable receives one
``case`` returned by ``input_factory``. This should hold only *our*
kernel(s); external baselines go in ``references``.
references : dict[str, callable], optional
Map of reference-impl name to a no-arg *builder* that does the heavy
import/setup and returns the run callable. Builders run lazily and only
when that impl will actually be benched (skipped entirely under
``--impls ours``); a builder that raises is recorded as a
``BASELINE_ERROR`` instead of failing the workload.
input_factory : callable
Factory returning ``(case, input_bytes)`` for one benchmark group.
warmup : int
Number of untimed warmup iterations per implementation.
repeat : int
Number of timed iterations per round.
cooldown_s : float
Seconds to sleep between impls for thermal cooldown.
rounds : int
Independent benchmark rounds (compile + inputs once; each round runs
warmup + repeat for every selected impl).
round_cooldown_s : float
Seconds to sleep between rounds (ignored when ``rounds == 1``).
validate_case : callable, optional
Called once on the first prepared ``case`` (after ``prepare_input_groups``,
before warmup/repeat rounds). Under bench_suite, ``run_kernel_bench`` holds
the per-GPU lock for the whole ``run_bench()`` call.
timer : {"event", "proton"}
Timing backend.
Returns
-------
dict
``{"impls": {name: us}, "round_samples": {name: [us, ...]}, ...}``.
Times are stored in microseconds (same unit as pinned bench_suite baselines).
"""
if repeat <= 0:
raise ValueError("repeat must be positive")
if warmup < 0:
raise ValueError("warmup must be non-negative")
if rounds < 1:
raise ValueError("rounds must be >= 1")
if round_cooldown_s < 0:
raise ValueError("round_cooldown_s must be non-negative")
if timer not in {"event", "proton"}:
raise ValueError(f"unsupported timer {timer!r}; expected event or proton")
if callable(funcs) and input_factory is None:
return _bench_legacy_callable(
funcs,
warmup=warmup,
repeat=repeat,
proton_name=proton_name,
debug=debug,
nsight=nsight,
flush_l2_size=flush_l2_size,
)
if input_factory is None:
raise TypeError("input_factory is required when funcs is a mapping")
if not isinstance(funcs, Mapping) or not funcs:
raise TypeError("funcs must be a non-empty mapping of name to callable")
for name, func in funcs.items():
if not isinstance(name, str):
raise TypeError("func names must be strings")
if not callable(func):
raise TypeError(f"funcs[{name!r}] must be callable")
# Select impls for this run. ``funcs`` holds our own kernel(s); external
# baselines are passed as ``references`` (name -> no-arg builder). A builder
# is invoked here ONLY when its impl will actually be benched, so --impls
# ours skips all reference setup, --impls baseline skips ours, and a builder
# that fails is recorded as a BASELINE_ERROR rather than failing the
# workload. Legacy kernels that put references directly in ``funcs`` are
# still handled by the label filter (filter_impls) below.
funcs = filter_impls(funcs)
build_errors: dict[str, str] = {}
if bench_impls_mode() != "ours":
for ref_name, builder in (references or {}).items():
if not isinstance(ref_name, str) or not callable(builder):
raise TypeError("references must map a name to a no-arg builder callable")
try:
ref_fn = builder()
except Exception as e:
build_errors[ref_name] = str(e)
print(f"BASELINE_ERROR: {ref_name}: {e}", file=sys.stderr)
continue
if ref_fn is None:
continue
if not callable(ref_fn):
raise TypeError(f"references[{ref_name!r}] builder must return a callable")
funcs = {**funcs, ref_name: ref_fn}
if not funcs:
# Nothing to bench in this mode (e.g. 'ours' on a kernel with no tir
# impl, or 'baseline' with no references). Return an empty-but-valid
# result so the workload is a no-op.
return {
"impls": {},
"round_samples": {},
"errors": build_errors,
"timer": timer,
"benchmark_protocol": {
"warmup": warmup,
"repeat": repeat,
"cooldown_s": cooldown_s,
"rounds": rounds,
"round_cooldown_s": round_cooldown_s,
"order": [],
},
}
inputs, protocol = prepare_input_groups(input_factory, l2_bytes=l2_bytes)
num_groups = len(inputs)
if num_groups == 0:
return {
"impls": {},
"round_samples": {},
"errors": build_errors,
"timer": timer,
"benchmark_protocol": {
**protocol,
"warmup": warmup,
"repeat": repeat,
"cooldown_s": cooldown_s,
"rounds": rounds,
"round_cooldown_s": round_cooldown_s,
"order": list(funcs.keys()),
},
}
if validate_case is not None:
validate_case(inputs[0])
errors = dict(build_errors)
round_samples: dict[str, list[float]] = {}
for round_idx in range(rounds):
if round_idx > 0:
time.sleep(round_cooldown_s)
if timer == "event":
impls = _bench_event_groups(funcs, inputs, warmup, repeat, cooldown_s)
proton_errors = {}
else:
impls, proton_errors = _bench_proton_groups(
funcs,
inputs,
warmup,
repeat,
cooldown_s,
proton_name,
debug,
nsight,
kernel=proton_name,
)
errors.update(proton_errors)
for impl, sec in impls.items():
round_samples.setdefault(impl, []).append(sec)
if not round_samples:
aggregated = {}
else:
import statistics
aggregated = {impl: statistics.mean(samples) for impl, samples in round_samples.items()}
return {
"impls": aggregated,
"round_samples": round_samples,
"errors": errors,
"timer": timer,
"benchmark_protocol": {
**protocol,
"warmup": warmup,
"repeat": repeat,
"cooldown_s": cooldown_s,
"rounds": rounds,
"round_cooldown_s": round_cooldown_s,
"order": list(funcs.keys()),
},
}
# utils for tg4perfetto profiler, adapted from https://github.com/flashinfer-ai/flashinfer
class EventType(Enum):
kBegin = 0
kEnd = 1
kInstant = 2
kFinalize = 3
def decode_tag(tag, num_groups):
block_group_tag = tag >> 12
event_idx = (tag >> 2) & 0x3FF
event_type = tag & 0x3
return (block_group_tag // num_groups, block_group_tag % num_groups, event_idx, event_type)
def export_to_perfetto_trace(
profiler_buffer: np.ndarray, file_name: str, event_type_names: list[str]
) -> None:
if is_running_under_pytest():
return
import torch
# pip install git+https://github.com/ihavnoid/tg4perfetto.git
from tg4perfetto import TraceGenerator
profiler_buffer_host = torch.tensor(profiler_buffer)
num_blocks, num_groups = profiler_buffer_host[:1].view(dtype=torch.int32)
num_blocks = int(num_blocks)
num_groups = int(num_groups)
tgen = TraceGenerator(file_name)
tid_map = {}
track_map = {}
finish_idx = set()
for block_idx in range(num_blocks):
pid = tgen.create_group(f"block_{block_idx}")
for group_idx in range(num_groups):
tid = pid.create_group(f"group_{group_idx}")
tid_map[(block_idx, group_idx)] = tid
for i in range(1, len(profiler_buffer_host)):
if profiler_buffer_host[i] == 0:
continue
tag, timestamp = profiler_buffer_host[i : i + 1].view(dtype=torch.uint32)
tag = int(tag)
timestamp = int(timestamp)
block_idx, group_idx, event_idx, event_type = decode_tag(tag, num_groups)
if event_type == EventType.kFinalize.value:
finish_idx.add((block_idx, group_idx))
if len(finish_idx) == num_blocks * num_groups:
break
else:
if (block_idx, group_idx) in finish_idx:
continue
event = event_type_names[event_idx]
tid = tid_map[(block_idx, group_idx)]
if (block_idx, group_idx, event_idx) in track_map:
track = track_map[(block_idx, group_idx, event_idx)]
else:
track = tid.create_track()
track_map[(block_idx, group_idx, event_idx)] = track
if event_type == EventType.kBegin.value:
track.open(timestamp, event)
elif event_type == EventType.kEnd.value:
track.close(timestamp)
elif event_type == EventType.kInstant.value:
track.instant(timestamp, event)
tgen.flush()
@T.meta_class
class CudaProfiler:
"""A lightweight wrapper around T.timer_* CUDA intrinsics.
Stores repeated arguments used by timer_init/start/end/finalize so users can
call concise methods in kernels. Intended to mirror Pipeline/TileScheduler helpers.
When ``profiler_enabled`` is False (or a false-y Expr), calls to
``init/start/end/finalize`` become no-ops. This allows constructing a
profiler unconditionally and eliminating external ``if PROFILER_ON:`` guards.
"""
def __init__(
self,
profiler_buffer: T.Buffer,
write_stride: int,
num_groups: int,
default_leader: None | tvm.tirx.Expr | bool = None,
profiler_enabled: bool | tvm.tirx.Expr = True,
):
self.buffer = profiler_buffer
self.write_stride = write_stride
self.num_groups = num_groups
self.default_leader = default_leader
# Accept either a Python bool or a Expr; normalize simple bools to T.bool
# so we can use it uniformly inside macros for conditional emission.
if isinstance(profiler_enabled, bool | np.bool_):
self.profiler_enabled = T.bool(bool(profiler_enabled))
else:
# Assume Expr-like input; use as-is
self.profiler_enabled = profiler_enabled # type: ignore[assignment]
self.profiler_tag = T.alloc_buffer([1], "uint64", scope="local", align=8)
self.profiler_write_offset = T.alloc_buffer([1], "uint32", scope="local", align=8)
def _leader(self, leader: None | tvm.tirx.Expr | bool):
if leader is not None:
if isinstance(leader, bool | np.bool_):
return T.bool(bool(leader))
return leader
if self.default_leader is not None:
return self.default_leader
return T.bool(True)
@T.inline
def init(self, group_id: tvm.tirx.Expr):
if self.profiler_enabled:
T.cuda.timer_init(
self.buffer.data,
self.profiler_tag.data,
self.profiler_write_offset.data,
self.num_groups,
group_id,
)
@T.inline
def start(self, event_type: Enum, leader: None | tvm.tirx.Expr | bool = None):
if self.profiler_enabled:
T.cuda.timer_start(
event_type,
self.buffer.data,
self.profiler_tag.data,
self.profiler_write_offset.data,
self.write_stride,
self._leader(leader),
)
@T.inline
def end(self, event_type: Enum, leader: None | tvm.tirx.Expr | bool = None):
if self.profiler_enabled:
T.cuda.timer_end(
event_type,
self.buffer.data,
self.profiler_tag.data,
self.profiler_write_offset.data,
self.write_stride,
self._leader(leader),
)
@T.inline
def finalize(self, leader: None | tvm.tirx.Expr | bool = None):
if self.profiler_enabled:
T.cuda.timer_finalize(
self.buffer.data,
self.profiler_tag.data,
self.profiler_write_offset.data,
self.write_stride,
self._leader(leader),
)