Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions transformer_engine/pytorch/gemm_autotune.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved.
# License for AMD contributions = MIT. See LICENSE for more information

"""Opt-in on-the-fly autotuning for dense GEMM (bf16 + mxfp8, forward + backward).

Second consumer of the pure :mod:`kernel_router` (after ``grouped_gemm_autotune``).
When ``NVTE_AUTOTUNE_KERNELS=1`` (ROCm only), each dense GEMM issued through
``general_gemm`` -- forward (TN), dgrad (NN), wgrad (NT) -- picks the fastest
backend for its shape+layout by measuring the candidates once and caching the
winner. Two regimes have a real choice: bf16 races the C++ default (hipBLASLt)
against the Triton kernel (``NVTE_USE_GEMM_TRITON=1``); mxfp8 races HipKittens (the
C++ default for mxfp8) against hipBLASLt (``NVTE_ROCM_USE_HIPBLASLT_MXFP8=1``). Both
toggles are read per call, so -- unlike the grouped mxfp8 path -- there is no
process-static to freeze the choice. fp8/fp32 have a single backend and are left
to ``general_gemm`` unchanged.

The op-agnostic switches (``NVTE_AUTOTUNE_KERNELS`` / ``NVTE_AUTOTUNE_KERNELS_VERBOSE``)
and the low-level runtime glue (env toggling, the do_bench timer, the input-format
classifier) are shared with ``grouped_gemm_autotune``.

Scope / safety:

* Only plain GEMMs are routed: calls with a comm-overlap communicator (``ub``) or
an output quantizer are delegated to ``general_gemm`` unchanged -- the former
because measuring would double the collective, the latter because repeated
measurement would corrupt the output quantizer's amax. (mxfp8 quantizes the
*inputs*; the output is bf16 with no quantizer, so mxfp8 is amax-safe.)
* Timing is side-effect-free: candidates are measured into a fresh output
(``out=None``, ``accumulate=False``) so re-running them never touches the real
output or a fused ``main_grad``. Only the winner is run once with the real args.
* For mxfp8, when HipKittens does not support a shape the C++ path falls back to
hipBLASLt internally (per call, deterministic), so the ``hipkittens`` candidate
degrades to a tie with ``hipblaslt`` rather than mis-measuring.
* Unlike grouped GEMM, dense token count (M = seq*mbs) is fixed within a run, so
the key uses exact M/N/K -- no coarse token bin is needed.
* Off by default; ``NVTE_AUTOTUNE_KERNELS_VERBOSE=1`` adds per-call selection logging.
"""
from __future__ import annotations

from dataclasses import dataclass

from .cpp_extensions import general_gemm
from .kernel_router import AutotuneRouter

# Shared, torch-dependent runtime glue (pending extraction into a common module).
from .grouped_gemm_autotune import (
_FLOAT16_DTYPES,
_autotune_enabled,
_do_bench_ms,
_env,
_quant_classes,
_verbose,
)

_GEMM_TRITON_ENV = "NVTE_USE_GEMM_TRITON"
_HIPBLASLT_MXFP8_ENV = "NVTE_ROCM_USE_HIPBLASLT_MXFP8"


@dataclass(frozen=True)
class GemmKey:
"""GPU-free key a dense-GEMM selection is cached on. M/N/K are exact (dense
token count is stable within a run), so no coarse binning is needed."""

m: int
n: int
k: int
out_dtype: str
layout: str
in_format: str


def _in_format(operand, out_dtype):
"""Classify the GEMM's input precision as ``bf16`` / ``fp8`` / ``mxfp8`` from one
operand, or ``None`` if unsupported. Mirrors the grouped classifier but takes a
single tensor (dense operands are tensors, not lists)."""
if out_dtype not in _FLOAT16_DTYPES:
return None
fp8_types, mxfp8_type, storage_type = _quant_classes()
if not isinstance(operand, storage_type):
return "bf16" if getattr(operand, "dtype", None) in _FLOAT16_DTYPES else None
q = getattr(operand, "_quantizer", None)
if isinstance(q, mxfp8_type):
return "mxfp8"
if isinstance(q, fp8_types):
return "fp8"
return None


def _mnk(A, B, layout):
"""Logical GEMM dims from the operands, per TE's column-major BLAS convention
(see ``triton_kernels/gemm/gemm_wrapper.py``): ``m = A0 if transa else A1``,
``k = A1 if transa else A0``, ``n = B1 if transb else B0``."""
transa = layout[0] == "T"
transb = layout[1] == "T"
m = A.size(0) if transa else A.size(1)
k = A.size(1) if transa else A.size(0)
n = B.size(1) if transb else B.size(0)
return m, n, k


_last_log: str | None = None


def _log_selection(sel) -> None:
"""One line per call: cache miss, the key, what was tried, and the winner.
Consecutive identical lines are collapsed; cache hits are silent."""
global _last_log
if sel.from_cache:
return
key = sel.key
ks = (
f"M={key.m} N={key.n} K={key.k} in_format={key.in_format} "
f"layout={key.layout} out_dtype={key.out_dtype}"
)
tried = []
for r in sel.reports:
if not r.available:
tried.append(f"{r.name}=unavailable")
elif r.error:
tried.append(f"{r.name}=ERROR({r.error})")
elif r.time_ms is None:
tried.append(f"{r.name}=rejected")
else:
tried.append(f"{r.name}={r.time_ms:.4f}ms")
line = f"[te-autotune] cache MISS [{ks}] tried: {', '.join(tried)} -> selected {sel.winner}"
if line != _last_log:
print(line, flush=True)
_last_log = line


@dataclass
class _GemmCall:
"""Everything needed to (re)issue one ``general_gemm`` call. ``kwargs`` is the
full original keyword set (out, accumulate, layout, bias, ...)."""

A: object
B: object
kwargs: dict


class _GemmBackend:
"""A dense-GEMM backend reached through ``general_gemm``, selected by transiently
toggling ``NVTE_USE_GEMM_TRITON``."""

def __init__(self, name, env, *, formats):
self.name = name
self._env = dict(env)
self._formats = frozenset(formats)

def available(self, key: GemmKey) -> bool:
return key.in_format in self._formats

def prepare(self, call: _GemmCall):
# Side-effect-free timing: fresh output, accumulate off, no comm-overlap
# extra output. Only the winner is run for real via run_real().
env = self._env
mkwargs = dict(call.kwargs)
mkwargs["out"] = None
mkwargs["accumulate"] = False
mkwargs.pop("extra_output", None)

def run():
with _env(**env):
general_gemm(call.A, call.B, **mkwargs)

return run

def run_real(self, call: _GemmCall):
with _env(**self._env):
return general_gemm(call.A, call.B, **call.kwargs)


_router: AutotuneRouter | None = None
_backends: dict = {}


def _get_router() -> AutotuneRouter:
global _router, _backends
if _router is None:
# Each candidate sets both toggles explicitly so it selects the same backend
# regardless of format: bf16 ignores the mxfp8 toggle and vice versa.
candidates = [
_GemmBackend(
"hipblaslt",
{_GEMM_TRITON_ENV: None, _HIPBLASLT_MXFP8_ENV: "1"},
formats=("bf16", "mxfp8"),
),
_GemmBackend(
"triton",
{_GEMM_TRITON_ENV: "1", _HIPBLASLT_MXFP8_ENV: None},
formats=("bf16",),
),
_GemmBackend(
"hipkittens",
{_GEMM_TRITON_ENV: None, _HIPBLASLT_MXFP8_ENV: None},
formats=("mxfp8",),
),
]
_backends = {c.name: c for c in candidates}
_router = AutotuneRouter(
candidates=candidates,
timer=_do_bench_ms,
verifier=None,
default="hipblaslt",
)
return _router


def _eligible(A, B, kwargs):
"""Return the input format if this dense GEMM should be autotuned, else None.
Excludes disabled autotune, comm-overlap (``ub``), and output-quantized calls.
bf16 (hipBLASLt vs Triton) and mxfp8 (hipBLASLt vs HipKittens) each have two
backends; other formats have one and are left to ``general_gemm``."""
if not _autotune_enabled():
return None
if kwargs.get("ub") is not None:
return None
if kwargs.get("quantization_params") is not None:
return None
fmt = _in_format(A, kwargs.get("out_dtype"))
return fmt if fmt in ("bf16", "mxfp8") else None


def autotuned_gemm(A, B, **kwargs):
"""Drop-in for :func:`general_gemm`.

With ``NVTE_AUTOTUNE_KERNELS=1`` and an autotunable dense GEMM (bf16 or mxfp8
inputs, no comm-overlap, unquantized output), selects the fastest backend
(hipBLASLt / Triton for bf16, hipBLASLt / HipKittens for mxfp8) for this
shape+layout and runs it. Otherwise delegates to ``general_gemm`` unchanged,
returning its 4-tuple result.
"""
fmt = _eligible(A, B, kwargs)
if fmt is None:
return general_gemm(A, B, **kwargs)

layout = kwargs.get("layout", "TN")
m, n, k = _mnk(A, B, layout)
key = GemmKey(
m=m, n=n, k=k, out_dtype=str(kwargs.get("out_dtype")), layout=layout, in_format=fmt
)
call = _GemmCall(A=A, B=B, kwargs=kwargs)
sel = _get_router().select(key, call)
if _verbose():
_log_selection(sel)
# select() measures into scratch on a miss and returns only a name on a hit;
# run the chosen backend once with the real args to produce the result.
return _backends[sel.winner].run_real(call)
Loading