Files
AstrAI/csrc/bench/benchmark_gemm.py
T
ViperEkura 1798474316 perf: rebuild decode gemm dispatch around shape-driven tile configs
- split-K removed entirely: tiled kernel walks K in one pass, no partials/semas workspace, no memset, single launch per call
- skinny GEMM (M<=8) dispatch table replaces the hand-written switch
- shape-driven four-family table replaces plan_gemm: wide-N (n>=4096) default {16,64,64,3,128} with BM=32 at M>16; narrow-N deep-K rings {16,32,256,2,64} while the grid fits one wave, {16,32,128,2,64} past it
- narrow-N is K-serial: widening the grid measurably does nothing (BN 64->32 ties, doubled m_tiles tie, kv at 4 blocks ties q/o at 24); deeper K chunks win until 72KB smem forces one CTA per SM and past one wave the 2-wave quantization loses to BK=128
- launch-check macros in common/launch.cuh; smem opt-in for the 72KB/60KB rings
- rename kernels/bf16_*.cu to gemm.cu/swiglu.cu; module names unchanged
- Python gate: lm_head (N>32768) falls back to cuBLAS, band narrows to M<=32
- drop the stale per-op benchmark narratives; fold the live numbers into cuda_kernels.md

Benchmark: NVIDIA L20 (sm_89, 92 SMs), CUDA 12.8, bf16, L2-thrash weight rotation, per-call medians at M=16: q/o 9.5us, kv 8.6us, gate/up 33.3us, down 33.7us (down -29% vs prior default). End-to-end 1B decode (gen 128, 3 trials, tokens/s vs cuBLAS): B=1 260 vs 252, B=8 1660 vs 1446, B=16 2464 vs 2437, B=32 3620 vs 3690. Prior split-K dispatch measured B=16 2243 / B=32 3393.
2026-09-04 22:41:39 +08:00

334 lines
10 KiB
Python

"""Benchmark decode-time linear shapes before enabling custom GEMM dispatch.
The benchmark deliberately calls ``torch.nn.functional.linear`` directly. It
establishes the per-architecture cuBLAS baseline that later GEMM primitives and
dispatch decisions must beat.
"""
from __future__ import annotations
import json
import math
import statistics
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
import click
import torch
import torch.nn.functional as F
@dataclass(frozen=True)
class LinearShape:
name: str
n: int
k: int
DEFAULT_SHAPES = (
LinearShape("q_proj", 1536, 1536),
LinearShape("k_proj", 256, 1536),
LinearShape("v_proj", 256, 1536),
LinearShape("attn_out", 1536, 1536),
LinearShape("mlp_up", 6912, 1536),
LinearShape("mlp_gate", 6912, 1536),
LinearShape("mlp_down", 1536, 6912),
LinearShape("lm_head", 100000, 1536),
)
DTYPES = {"bfloat16": torch.bfloat16, "float16": torch.float16}
def parse_positive_ints(value: str) -> tuple[int, ...]:
"""Parse a comma-separated, duplicate-free list of positive integers."""
try:
values = tuple(dict.fromkeys(int(item.strip()) for item in value.split(",")))
except ValueError as exc:
raise click.BadParameter("expected comma-separated integers") from exc
if not values or any(item <= 0 for item in values):
raise click.BadParameter("values must be positive integers")
return values
def parse_shape(value: str) -> LinearShape:
"""Parse NAME:N:K into a benchmark shape."""
parts = value.split(":")
if len(parts) != 3 or not parts[0]:
raise click.BadParameter("shape must use NAME:N:K")
try:
n, k = (int(item) for item in parts[1:])
except ValueError as exc:
raise click.BadParameter("N and K must be integers") from exc
if n <= 0 or k <= 0:
raise click.BadParameter("N and K must be positive")
return LinearShape(parts[0], n, k)
def estimate_io_bytes(
m: int, n: int, k: int, element_size: int, *, has_bias: bool
) -> int:
"""Estimate bytes touched once by Y[M,N] = X[M,K] @ W[N,K].T."""
elements = m * k + n * k + m * n
if has_bias:
elements += n
return elements * element_size
def percentile(values: Iterable[float], quantile: float) -> float:
ordered = sorted(values)
if not ordered:
raise ValueError("percentile requires at least one sample")
rank = (len(ordered) - 1) * quantile
lower = math.floor(rank)
upper = math.ceil(rank)
if lower == upper:
return ordered[lower]
fraction = rank - lower
return ordered[lower] * (1 - fraction) + ordered[upper] * fraction
def summarize_latency(samples_ms: list[float]) -> dict[str, float]:
return {
"median_ms": statistics.median(samples_ms),
"p90_ms": percentile(samples_ms, 0.90),
"p99_ms": percentile(samples_ms, 0.99),
"min_ms": min(samples_ms),
"max_ms": max(samples_ms),
}
def measure_cuda_ms(
operation: Callable[[], torch.Tensor], *, warmup: int, iterations: int, trials: int
) -> list[float]:
for _ in range(warmup):
operation()
torch.cuda.synchronize()
samples = []
for _ in range(trials):
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(iterations):
operation()
end.record()
end.synchronize()
samples.append(start.elapsed_time(end) / iterations)
return samples
def count_cuda_kernels(
operation: Callable[[], torch.Tensor], repeats: int = 5
) -> float:
"""Profile a few calls and return the average device events per call."""
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA,
],
acc_events=True,
) as profile:
for _ in range(repeats):
operation()
torch.cuda.synchronize()
device_type = torch.autograd.DeviceType.CUDA
events = [event for event in profile.events() if event.device_type == device_type]
return len(events) / repeats
def capture_linear(
x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor | None
) -> tuple[torch.cuda.CUDAGraph, torch.Tensor]:
for _ in range(3):
F.linear(x, weight, bias)
torch.cuda.synchronize()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
output = F.linear(x, weight, bias)
return graph, output
def benchmark_case(
shape: LinearShape,
m: int,
*,
dtype: torch.dtype,
mode: str,
bias_enabled: bool,
warmup: int,
iterations: int,
trials: int,
) -> dict[str, object]:
x = torch.randn((m, shape.k), device="cuda", dtype=dtype)
weight = torch.randn((shape.n, shape.k), device="cuda", dtype=dtype)
bias = torch.randn(shape.n, device="cuda", dtype=dtype) if bias_enabled else None
graph = None
graph_output = None
if mode == "graph":
graph, graph_output = capture_linear(x, weight, bias)
def operation() -> torch.Tensor:
graph.replay()
return graph_output
else:
def operation() -> torch.Tensor:
return F.linear(x, weight, bias)
samples_ms = measure_cuda_ms(
operation, warmup=warmup, iterations=iterations, trials=trials
)
latency = summarize_latency(samples_ms)
io_bytes = estimate_io_bytes(
m, shape.n, shape.k, x.element_size(), has_bias=bias is not None
)
median_seconds = latency["median_ms"] / 1000
result: dict[str, object] = {
"name": shape.name,
"m": m,
"n": shape.n,
"k": shape.k,
"mode": mode,
"bias": bias is not None,
"estimated_io_bytes": io_bytes,
"effective_bandwidth_gbps": io_bytes / median_seconds / 1e9,
"cuda_kernel_launches_per_call": count_cuda_kernels(operation),
**latency,
"samples_ms": samples_ms,
}
return result
def render_markdown(payload: dict[str, object]) -> str:
metadata = payload["metadata"]
assert isinstance(metadata, dict)
results = payload["results"]
assert isinstance(results, list)
lines = [
"# Decode linear baseline",
"",
f"- GPU: {metadata['gpu_name']}",
f"- Compute capability: {metadata['compute_capability']}",
f"- PyTorch / CUDA: {metadata['torch_version']} / {metadata['cuda_version']}",
f"- Dtype: {metadata['dtype']}",
"",
"| Layer | M | N | K | Mode | Median (ms) | p99 (ms) | GB/s | CUDA kernels/call |",
"|---|---:|---:|---:|---|---:|---:|---:|---:|",
]
for item in results:
assert isinstance(item, dict)
lines.append(
"| {name} | {m} | {n} | {k} | {mode} | {median_ms:.4f} | "
"{p99_ms:.4f} | {effective_bandwidth_gbps:.1f} | "
"{cuda_kernel_launches_per_call:.2f} |".format(**item)
)
lines.append("")
return "\n".join(lines)
def device_metadata(dtype_name: str) -> dict[str, object]:
props = torch.cuda.get_device_properties(0)
return {
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"gpu_name": props.name,
"compute_capability": f"{props.major}.{props.minor}",
"total_memory_bytes": props.total_memory,
"torch_version": torch.__version__,
"cuda_version": torch.version.cuda,
"dtype": dtype_name,
}
@click.command(help=__doc__)
@click.option("--output", type=click.Path(path_type=Path), required=True)
@click.option("--markdown-output", type=click.Path(path_type=Path))
@click.option("--m-values", default="1,2,4,8,16,32", show_default=True)
@click.option(
"--shape",
"shape_values",
multiple=True,
help="Override defaults with repeatable NAME:N:K shapes.",
)
@click.option("--dtype", type=click.Choice(tuple(DTYPES)), default="bfloat16")
@click.option("--mode", type=click.Choice(("eager", "graph", "both")), default="both")
@click.option("--bias/--no-bias", default=False)
@click.option("--warmup", type=click.IntRange(min=1), default=10, show_default=True)
@click.option(
"--iterations", type=click.IntRange(min=1), default=100, show_default=True
)
@click.option("--trials", type=click.IntRange(min=1), default=20, show_default=True)
@click.option("--seed", type=int, default=0, show_default=True)
def benchmark_command(
output: Path,
markdown_output: Path | None,
m_values: str,
shape_values: tuple[str, ...],
dtype: str,
mode: str,
bias: bool,
warmup: int,
iterations: int,
trials: int,
seed: int,
) -> None:
if not torch.cuda.is_available():
raise click.ClickException("CUDA is required")
parsed_m = parse_positive_ints(m_values)
shapes = tuple(parse_shape(item) for item in shape_values) or DEFAULT_SHAPES
modes = ("eager", "graph") if mode == "both" else (mode,)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
results = []
for shape in shapes:
for m in parsed_m:
for current_mode in modes:
click.echo(
f"{shape.name}: M={m} N={shape.n} K={shape.k} {current_mode}"
)
results.append(
benchmark_case(
shape,
m,
dtype=DTYPES[dtype],
mode=current_mode,
bias_enabled=bias,
warmup=warmup,
iterations=iterations,
trials=trials,
)
)
payload: dict[str, object] = {
"schema_version": 1,
"metadata": device_metadata(dtype),
"parameters": {
"m_values": list(parsed_m),
"shapes": [asdict(shape) for shape in shapes],
"modes": list(modes),
"bias": bias,
"warmup": warmup,
"iterations": iterations,
"trials": trials,
"seed": seed,
},
"results": results,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
if markdown_output is not None:
markdown_output.parent.mkdir(parents=True, exist_ok=True)
markdown_output.write_text(render_markdown(payload), encoding="utf-8")
if __name__ == "__main__":
benchmark_command()