Skip to content
Merged
22 changes: 22 additions & 0 deletions benchmarks/microbenchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ python benchmark_gemm.py --csv --csv-samples gemm_samples.csv
The samples CSV contains one row per timing sample with columns for all
benchmark parameters plus `label`, `sample_idx`, and `time_ms`.

### Rotating input buffers

By default each benchmark cycles its inputs through a ring of buffers whose
total footprint exceeds the **last-level cache**, so back-to-back kernel
launches touch different memory (closer to a cold-cache, steady-state workload)
instead of reading data still resident in cache and reporting optimistic
numbers. This matches the `--rotating` option of `hipblaslt-bench`, which
likewise takes a rotating memory budget in MB. Pass `--no-rotating` to instead
time a single cached input buffer:

```bash
python benchmark_gemm.py # rotate, auto-size the ring past the LLC
python benchmark_casting.py --rotating 512 # rotate within a 512 MB budget
python benchmark_gemm.py --no-rotating # single cached input buffer
```

Rotation is **on by default**. Passing `--rotating MB` sets the rotating memory
budget in megabytes (the ring holds enough buffers to span it); omitting the
value auto-sizes the ring to ~2x a conservative 256 MB last-level cache (the AMD
Infinity Cache; see `utils.py::_last_level_cache_bytes`). `--no-rotating`
disables rotation entirely.

## Shared configuration

Common benchmark settings live in `utils.py`.
Expand Down
18 changes: 12 additions & 6 deletions benchmarks/microbenchmarks/benchmark_casting.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from utils import (
MODEL_HIDDEN_SIZES, M_SIZE_LIST,
time_func, compute_gbps, make_metric_record, run_benchmarks,
make_input, rotating,
)

TE_FP8_E4M3 = tex.DType.kFloat8E4M3
Expand Down Expand Up @@ -62,14 +63,19 @@ def bench_cast(Case, M, hidden_size, direction, fp8_dtype, dtype_str):
quantizer = Float8Quantizer(scale, amax, fp8_dtype)

if direction == "quantize":
x = torch.randn(M, hidden_size, dtype=torch.bfloat16, device=device)
out = quantizer(x)
cast_func = lambda: quantizer.quantize(x, out=out)
next_x = make_input((M, hidden_size), torch.bfloat16, device=device)
out = quantizer(next_x())
cast_func = lambda: quantizer.quantize(next_x(), out=out)
total_bytes = numel * (2 + 1) # BF16 read + FP8 write
else:
x = torch.randn(M, hidden_size, dtype=torch.bfloat16, device=device)
fp8_tensor = quantizer(x)
cast_func = lambda: fp8_tensor.dequantize()
# Rotate a ring of FP8 tensors (bytes can't be inferred, so hint numel).
next_fp8 = rotating(
lambda: quantizer(
torch.randn(M, hidden_size, dtype=torch.bfloat16, device=device)
),
bytes_per_buffer=numel, # FP8 ~ 1 byte/element
)
cast_func = lambda: next_fp8().dequantize()
total_bytes = numel * (1 + 2) # FP8 read + BF16 write

ms, measurement = time_func(cast_func, method="blocked")
Expand Down
10 changes: 6 additions & 4 deletions benchmarks/microbenchmarks/benchmark_gemm.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from utils import (
generate_gemm_test_cases,
time_func, compute_tflops, make_forward_backward_metric_records, run_benchmarks,
make_input,
)

BENCHMARK_LABEL = "GEMM"
Expand All @@ -20,16 +21,17 @@ def bench_gemm(Case, M, N, K, dtype):
device = "cuda"

linear = te.Linear(K, N, bias=False).to(device=device, dtype=dtype)
x = torch.randn(M, K, dtype=dtype, device=device, requires_grad=True)
next_x = make_input((M, K), dtype, device=device, requires_grad=True)

fwd_func = lambda: linear(x)
fwd_func = lambda: linear(next_x())
out = fwd_func()
grad_out = torch.randn_like(out)

def fwd_bwd_func():
out = linear(x)
xb = next_x()
out = linear(xb)
out.backward(grad_out)
x.grad = None
xb.grad = None
linear.weight.grad = None

fwd_bwd_func()
Expand Down
10 changes: 6 additions & 4 deletions benchmarks/microbenchmarks/benchmark_gemm_fp8.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from utils import (
generate_gemm_test_cases,
time_func, compute_tflops, make_forward_backward_metric_records, run_benchmarks,
make_input,
)

RECIPES = {
Expand All @@ -36,18 +37,19 @@ def bench_fp8_gemm(Case, M, N, K, dtype):
device = "cuda"

linear = te.Linear(K, N, bias=False).to(device=device, dtype=dtype)
x = torch.randn(M, K, dtype=dtype, device=device, requires_grad=True)
next_x = make_input((M, K), dtype, device=device, requires_grad=True)
grad_out = torch.randn(M, N, dtype=dtype, device=device)

def fwd_func():
with te.fp8_autocast(enabled=True, fp8_recipe=FP8_RECIPE):
return linear(x)
return linear(next_x())

def fwd_bwd_func():
xb = next_x()
with te.fp8_autocast(enabled=True, fp8_recipe=FP8_RECIPE):
out = linear(x)
out = linear(xb)
out.backward(grad_out)
x.grad = None
xb.grad = None
linear.weight.grad = None

fwd_flops = 2 * M * N * K
Expand Down
12 changes: 8 additions & 4 deletions benchmarks/microbenchmarks/benchmark_grouped_gemm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
compute_tflops,
make_forward_backward_metric_records,
run_benchmarks,
make_input,
)

BENCHMARK_LABEL = "Grouped GEMM"
Expand Down Expand Up @@ -114,18 +115,21 @@ def bench_grouped_gemm(Case, B, M, N, K, dtype):
params_dtype=dtype,
device=device,
)
x = torch.randn((sum_M, K), dtype=dtype, device=device, requires_grad=True)
# Rotate the activation buffer (on by default) so back-to-back grouped GEMMs
# read different memory; GroupedLinear splits it internally per m_splits.
next_x = make_input((sum_M, K), dtype, device=device, requires_grad=True)

def fwd_func_te():
return grouped_linear(x, m_splits, m_splits_tensor=m_splits_tensor)
return grouped_linear(next_x(), m_splits, m_splits_tensor=m_splits_tensor)

out_te = fwd_func_te()
grad_out = torch.randn_like(out_te)

def fwd_bwd_func_te():
out = grouped_linear(x, m_splits, m_splits_tensor=m_splits_tensor)
xb = next_x()
out = grouped_linear(xb, m_splits, m_splits_tensor=m_splits_tensor)
out.backward(grad_out)
x.grad = None
xb.grad = None
for param in grouped_linear.parameters():
param.grad = None

Expand Down
12 changes: 7 additions & 5 deletions benchmarks/microbenchmarks/benchmark_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from utils import (
DTYPE_LIST, MODEL_HIDDEN_SIZES, M_SIZE_LIST,
time_func, compute_gbps, make_forward_backward_metric_records, run_benchmarks,
make_input,
)

NORM_TYPES = [
Expand Down Expand Up @@ -49,22 +50,23 @@ def bench_norm(Case, M, hidden_size, norm_name, norm_cls, dtype):
device = "cuda"

norm = norm_cls(hidden_size).to(device=device, dtype=dtype)
x = torch.randn(M, hidden_size, dtype=dtype, device=device, requires_grad=True)
next_x = make_input((M, hidden_size), dtype, device=device, requires_grad=True)

fwd_func = lambda: norm(x)
fwd_func = lambda: norm(next_x())
out = fwd_func()
grad_out = torch.randn_like(out)

def fwd_bwd_func():
out = norm(x)
xb = next_x()
out = norm(xb)
out.backward(grad_out)
x.grad = None
xb.grad = None
for p in norm.parameters():
p.grad = None

fwd_bwd_func()

elem_bytes = x.element_size()
elem_bytes = torch.empty(0, dtype=dtype).element_size()
fwd_bytes = 2 * M * hidden_size * elem_bytes # read x, write y
bwd_bytes = 4 * M * hidden_size * elem_bytes # read grad+x+y, write grad_x

Expand Down
38 changes: 38 additions & 0 deletions benchmarks/microbenchmarks/run_benchmarks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/bash
###############################################################################
# Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved.
#
# See LICENSE for license information.
###############################################################################
# Run every microbenchmark.
#
# ./run_all.sh # defaults (rotation on)
# ./run_all.sh --rotating 512 # rotate within a 512 MB budget
# ./run_all.sh --no-rotating # disable input rotation
# ./run_all.sh --csv # also write per-benchmark CSVs
#
# Set PYTHON to pick a specific interpreter (default: python).

shopt -s nullglob

cd "$(dirname "$0")"
PYTHON="${PYTHON:-python}"

failed=()
for bench in benchmark_*.py; do
echo
echo "############################################################"
echo "# ${bench} $*"
echo "############################################################"
if ! "$PYTHON" "$bench" "$@"; then
echo "!!! ${bench} FAILED" >&2
failed+=("$bench")
fi
done

echo
if (( ${#failed[@]} )); then
echo "FAILED: ${failed[*]}" >&2
exit 1
fi
echo "All benchmarks completed."
135 changes: 135 additions & 0 deletions benchmarks/microbenchmarks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"""Shared utilities for microbenchmarks: model configs, timing, throughput, runner."""

import argparse
import itertools
import math
import torch
import torch.utils.benchmark as benchmark

Expand Down Expand Up @@ -106,6 +108,112 @@ def time_func(fn, method="adaptive", min_run_time=DEFAULT_MIN_RUN_TIME_SECONDS):
return m.mean * 1e3, m


# ---------------------------------------------------------------------------
# Rotating input buffers (on by default; disable via --no-rotating)
# ---------------------------------------------------------------------------
# Benchmark inputs are cycled through a ring of buffers so that back-to-back
# kernel launches read different input memory and don't benefit from artificial
# cache residency. Populated by run_benchmarks() from the parsed CLI args.
_ROTATE_BUFFERS = True
_ROTATE_MB = 0 # rotation memory budget in MB; 0 => auto-size to exceed the LLC
# Ceiling on the rotation ring size. hipBLASLt-bench caps its rotating block
# count at the iteration count (max(cold_iters, iters)) so it never allocates a
# buffer it won't revisit. torch.utils.benchmark picks the iteration count
# adaptively, so there is no fixed value to cap against; we instead bound the
# ring at a fixed maximum (mirroring hipBLASLt's default cold_iters of 1000).
# With the auto budget this ceiling is never reached; it only guards a very
# large explicit --rotating budget on a small buffer, which would otherwise
# allocate a copy per few MB up to the whole budget.
_ROTATE_MAX_BUFFERS = 1000


def _last_level_cache_bytes():
"""Bytes of the last-level cache that buffer rotation must exceed.

HIP reports ``L2_cache_size`` as the small per-XCD L2 (e.g. 4 MB on gfx950),
but the real last-level cache is the much larger AMD Infinity Cache.

Actual last-level/Infinity Cache sizes:
- gfx942 / gfx950: 256 MB
- gfx1250: 192 MB

We use 256 MB for all devices: a slightly oversized ring is harmless (it
only allocates a little more memory) and avoids per-arch probing.
"""
return 256 * 1024 * 1024


def _rotation_count(bytes_per_buffer, cache_mult=2.0, min_buffers=2):
"""Number of buffers so the rotation ring spans the requested memory budget.

With an explicit ``--rotating MB`` the budget is that many megabytes; when
omitted it is *cache_mult* x the last-level cache (the ~256 MB AMD Infinity
Cache), so a buffer is evicted before it is reused. The ring is floored at
*min_buffers* (so enabling rotation always rotates) and capped at
``_ROTATE_MAX_BUFFERS`` (the adaptive-timer analog of hipBLASLt-bench capping
its block count at the iteration count, so a huge budget on a small buffer
can't allocate an unbounded ring).
"""
if bytes_per_buffer <= 0:
return min_buffers
if _ROTATE_MB and _ROTATE_MB > 0:
budget = _ROTATE_MB * 1024 * 1024
else:
cache = _last_level_cache_bytes()
if not cache:
return min_buffers
budget = cache_mult * cache
count = math.ceil(budget / bytes_per_buffer)
if _ROTATE_MAX_BUFFERS and _ROTATE_MAX_BUFFERS > 0:
count = min(count, _ROTATE_MAX_BUFFERS)
return max(min_buffers, count)


def _tensor_nbytes(t):
"""Byte size of a torch tensor, or 0 if it can't be determined."""
numel = getattr(t, "numel", None)
element_size = getattr(t, "element_size", None)
if callable(numel) and callable(element_size):
return int(numel()) * int(element_size())
return 0


def rotating(build, *, bytes_per_buffer=None):
"""Return a zero-arg callable yielding an input buffer to time.

Rotation is on by default: it builds a ring of ``build()`` buffers (spanning
the ``--rotating MB`` budget, or ~2x the last-level cache when the size is
omitted) and returns the next one on each call. With ``--no-rotating`` it
returns a single cached buffer from ``build()`` on every call, matching the
original single-buffer behavior.

``build`` is a zero-arg callable returning one fresh buffer.
``bytes_per_buffer`` overrides the auto-sizing hint for buffers whose byte
size can't be inferred (e.g. FP8 tensors).
"""
first = build()
if not _ROTATE_BUFFERS:
return lambda: first
nbytes = bytes_per_buffer if bytes_per_buffer is not None else _tensor_nbytes(first)
count = _rotation_count(nbytes)
buffers = [first] + [build() for _ in range(max(0, count - 1))]
ring = itertools.cycle(buffers)
return lambda: next(ring)


def make_input(shape, dtype, *, device="cuda", requires_grad=False):
"""Rotation-aware input: a zero-arg callable returning a ``randn`` tensor.

Honors ``--rotating`` (see :func:`rotating`); on by default, so it returns
the next tensor in the ring each call (``--no-rotating`` for a single one).
"""
return rotating(
lambda: torch.randn(
*shape, dtype=dtype, device=device, requires_grad=requires_grad
)
)


# ---------------------------------------------------------------------------
# Throughput helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -275,6 +383,26 @@ def make_parser(**kwargs):
"--csv-samples is ignored in this mode."
),
)
rotating_group = parser.add_mutually_exclusive_group()
rotating_group.add_argument(
"--rotating", nargs="?", type=int, const=0, default=None, metavar="MB",
help=(
"Rotate benchmark inputs through a ring of buffers so back-to-back "
"launches touch different memory (avoids artificial cache "
"residency), like hipBLASLt-bench --rotating. Optionally pass the "
"rotating memory budget in MB; omit it to auto-size the ring to "
"exceed the last-level cache (the 256 MB Infinity Cache on "
"gfx942/gfx950, not just L2). On by default; disable with "
"--no-rotating."
),
)
rotating_group.add_argument(
"--no-rotating", action="store_true", default=False,
help=(
"Disable input buffer rotation (see --rotating) and time a single "
"cached input buffer."
),
)
return parser


Expand Down Expand Up @@ -334,6 +462,13 @@ def run_benchmarks(test_cases, bench_fn, param_columns, default_csv=None,
if args is None:
args = make_parser().parse_args()

global _ROTATE_BUFFERS, _ROTATE_MB
_rotating = getattr(args, "rotating", None)
if _rotating is not None and _rotating < 0:
raise ValueError("--rotating expects a non-negative size in MB")
_ROTATE_BUFFERS = not getattr(args, "no_rotating", False)
_ROTATE_MB = _rotating or 0

if args.kernel_profile:
from torch.profiler import profile, ProfilerActivity

Expand Down