Skip to content
Merged
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
46 changes: 26 additions & 20 deletions examples/hopper_matmul/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
import argparse
import csv
import io
import shutil
import subprocess
import time

VERSION_NAMES = ["v0", "v1", "v2", "v3", "v4", "v5"]
VERSION_NAMES = ["v0", "v1", "v2", "v3", "v4", "v5", "v6"]

VERSION_CLASS = {
"v0": "MatmulTMA",
Expand All @@ -24,6 +25,7 @@
"v3": "MatmulWGMMAV3",
"v4": "MatmulWGMMAV4",
"v5": "MatmulWGMMAV5",
"v6": "MatmulWGMMAV6",
}


Expand Down Expand Up @@ -62,7 +64,8 @@ def _read_ncu_csv(
report_path: str, page: str, metrics: str | None = None
) -> csv.DictReader:
"""Run ncu --import --csv and return a DictReader, skipping the units row."""
cmd = ["/usr/local/cuda/bin/ncu", "--import", report_path, "--csv", "--page", page]
ncu = shutil.which("ncu") or "/usr/local/cuda/bin/ncu"
cmd = [ncu, "--import", report_path, "--csv", "--page", page]
if metrics:
cmd += ["--metrics", metrics]
result = subprocess.run(cmd, capture_output=True, text=True)
Expand Down Expand Up @@ -117,7 +120,7 @@ def parse_ncu_report(report_path: str) -> list[tuple[str, dict]]:


def benchmark_all(versions: list[str], m_size: int, n_size: int, k_size: int):
"""Benchmark all versions using benchmark_func."""
"""Benchmark all versions using benchmark_func (event-loop timing)."""
import math

import pandas
Expand All @@ -127,36 +130,39 @@ def benchmark_all(versions: list[str], m_size: int, n_size: int, k_size: int):
headers = ["version", "latency (ms)", "tflops", "% of cublas"]
rows = []

a = (
torch.rand(m_size, k_size, dtype=torch.float16, device="cuda") - 0.5
) / math.sqrt(k_size)
b = (
torch.rand(n_size, k_size, dtype=torch.float16, device="cuda") - 0.5
) / math.sqrt(k_size)
# Scale only one operand. Scaling both made even grossly permuted outputs
# small enough to pass the old absolute tolerance.
a = torch.randn(m_size, k_size, dtype=torch.float16, device="cuda") / math.sqrt(
k_size
)
b = torch.randn(n_size, k_size, dtype=torch.float16, device="cuda")
c_ref = torch.empty(m_size, n_size, dtype=torch.float16, device="cuda")
c_tilus = torch.empty(m_size, n_size, dtype=torch.float16, device="cuda")

# cuBLAS baseline first, so we can compute % of cublas
cublas_lat = benchmark_func(
lambda: torch.matmul(a, b.T, out=c_ref), warmup=5, repeat=30
)
cublas_tf = 2 * m_size * n_size * k_size / cublas_lat * 1e-9

def tf(ms):
return 2 * m_size * n_size * k_size / ms * 1e-9

cublas_tf = tf(cublas_lat)

for name in versions:
try:
matmul = _load_version(name)()
# warmup + correctness check
matmul(m_size, n_size, k_size, a, b, c_tilus)
torch.cuda.synchronize()
torch.testing.assert_close(c_ref, c_tilus, atol=1e-2, rtol=1e-2)
atol = 5e-2 if name == "v6" else 1e-2
torch.testing.assert_close(c_ref, c_tilus, atol=atol, rtol=1e-2)

latency = benchmark_func(
lambda: matmul(m_size, n_size, k_size, a, b, c_tilus), warmup=5, repeat=30
t = benchmark_func(
lambda: matmul(m_size, n_size, k_size, a, b, c_tilus),
warmup=5,
repeat=30,
)
tf = 2 * m_size * n_size * k_size / latency * 1e-9
pct = tf / cublas_tf * 100.0
rows.append([f"tilus_{name}", latency, tf, pct])
time.sleep(1) # cool down between runs
rows.append([f"tilus_{name}", t, tf(t), tf(t) / cublas_tf * 100.0])
time.sleep(1)
except Exception as e:
print(f" tilus_{name} ERROR: {e}")
rows.append([f"tilus_{name}", float("nan"), float("nan"), float("nan")])
Expand Down Expand Up @@ -228,7 +234,7 @@ def ncu_profile_all(versions: list[str], m_size: int, n_size: int, k_size: int):


def main():
parser = argparse.ArgumentParser(description="Benchmark Hopper matmul V0-V5")
parser = argparse.ArgumentParser(description="Benchmark Hopper matmul V0-V6")
parser.add_argument(
"--ncu",
action="store_true",
Expand Down
9 changes: 6 additions & 3 deletions examples/hopper_matmul/matmul_v0.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,12 @@ def __call__(
self.sync()
phase ^= 1

self.free_shared(sa)
self.free_shared(sb)

# sa/sb are deliberately not freed. The epilogue allocates no shared
# memory, so freeing reclaims nothing -- but it would return those slots
# to the allocator's free list, and the mbarrier allocator (which runs
# after the whole function is emitted) would then be free to place the
# barriers inside a buffer the TMA engine writes throughout the loop
# above, silently corrupting the barrier state.
casted_acc = self.cast(acc, dtype=float16)
gc = self.global_view(c_ptr, dtype=float16, shape=[m_size, n_size])
self.store_global(gc, casted_acc, offsets=[offset_m, offset_n])
Expand Down
9 changes: 6 additions & 3 deletions examples/hopper_matmul/matmul_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,12 @@ def __call__(
self.sync()
phase ^= 1

self.free_shared(sa)
self.free_shared(sb)

# sa/sb are deliberately not freed. The epilogue allocates no shared
# memory, so freeing reclaims nothing -- but it would return those slots
# to the allocator's free list, and the mbarrier allocator (which runs
# after the whole function is emitted) would then be free to place the
# barriers inside a buffer the TMA engine writes throughout the loop
# above, silently corrupting the barrier state.
casted_acc = self.cast(acc, dtype=float16)
gc = self.global_view(c_ptr, dtype=float16, shape=[m_size, n_size])
self.store_global(gc, casted_acc, offsets=[offset_m, offset_n])
Expand Down
9 changes: 6 additions & 3 deletions examples/hopper_matmul/matmul_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,12 @@ def __call__(
)
self.sync()

self.free_shared(sa)
self.free_shared(sb)

# sa/sb are deliberately not freed. The epilogue allocates no shared
# memory, so freeing reclaims nothing -- but it would return those slots
# to the allocator's free list, and the mbarrier allocator (which runs
# after the whole function is emitted) would then be free to place the
# barriers inside a buffer the TMA engine writes throughout the loop
# above, silently corrupting the barrier state.
casted_acc = self.cast(acc, dtype=float16)
gc = self.global_view(c_ptr, dtype=float16, shape=[m_size, n_size])
self.store_global(gc, casted_acc, offsets=[offset_m, offset_n])
Expand Down
156 changes: 87 additions & 69 deletions examples/hopper_matmul/matmul_v4.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# v4: Pipeline class abstraction + tile rasterization for better L2 cache reuse.
# v4: Pipeline abstraction + two WGMMA consumer warp groups.
#
# Changes from v3:
# - Pipeline class encapsulates barrier/phase/stage ring-buffer logic.
# - 1D grid launch with compute_block_coord() maps linear blockIdx to (m, n)
# using a swizzle group so adjacent tiles share B columns → L2 reuse.
# - Split each 128x256 output tile across two consumer warp groups so more
# independent WGMMA work can execute while the producer feeds the pipeline.
# - 1D tile rasterization supports grouped launch order for L2 reuse.

import math

Expand Down Expand Up @@ -66,23 +67,16 @@ def consumer_advance(self):
self.consumer_stage = (self.consumer_stage + 1) % self.num_stages
self.consumer_phase = self.consumer_phase ^ (self.consumer_stage == 0)

def prev_consumer_barrier(self) -> RegisterTensor:
# Use (consumer_stage + num_stages - 1) so num_stages - 1 is evaluated as a
# Python int constant first, ensuring the inner expression is always non-negative
# and avoids C's negative-dividend truncated-modulo behaviour.
prev_stage = (self.consumer_stage + (self.num_stages - 1)) % self.num_stages
return self.empty_barriers[prev_stage]


# Tightened autotune space: drop block_m=128/n=64 (skinny → poor wgmma fill),
# drop block_m=256/n=128 (heavy register pressure), drop num_stages=2 (too
# shallow to hide TMA), drop num_stages=7 (smem-thrashing on 256×256), drop
# swizzle_size=1 (≡ default rasterization, no L2 win). The remaining 60 configs
# are biased toward shapes cuBLAS uses for 8K² fp16 GEMMs.
@tilus.autotune("num_stages", [3, 4, 5, 6])
@tilus.autotune("block_m, block_n", [[128, 128], [128, 256], [256, 256]])
@tilus.autotune("block_k", [16, 32, 64])
@tilus.autotune("swizzle_size", [4, 8])

# v4 stays synchronous (wait_group(0) after every commit) -- overlapping WGMMA
# groups is v5's job. Three stages give the producer enough slack to stay ahead
# of a consumer that drains its MMA every K-tile; two stages do not, and measure
# no faster than v3. Keep the finalized search space to one deterministic H100
# configuration.
@tilus.autotune("num_stages", [3])
@tilus.autotune("block_m, block_n", [[128, 256]])
@tilus.autotune("block_k", [64])
@tilus.autotune("swizzle_size", [4])
class MatmulWGMMAV4(tilus.Script):
def __init__(self, num_stages, block_m, block_n, block_k, swizzle_size):
super().__init__()
Expand Down Expand Up @@ -126,31 +120,43 @@ def __call__(
):
num_stages = self.num_stages
block_m, block_n, block_k = self.block_m, self.block_n, self.block_k
swizzle_size = self.swizzle_size

num_m_blocks = cdiv(m_size, block_m)
num_n_blocks = cdiv(n_size, block_n)
self.attrs.blocks = num_m_blocks * num_n_blocks
self.attrs.warps = 5

m_block, n_block = self.compute_block_coord(
self.blockIdx.x, num_m_blocks, num_n_blocks
)
offset_m: int32 = m_block * block_m
offset_n: int32 = n_block * block_n
self.attrs.warps = 9 # 1 producer + 2 consumer warp groups

offset_m: int32 = 0
offset_n: int32 = 0
if swizzle_size == 1:
# Bypass: plain 2D grid, identical rasterization to v3, with none
# of the swizzle-group address computation below. Resolved at
# trace time (swizzle_size is a compile-time autotune constant),
# so this branch costs nothing when swizzling is used instead.
self.attrs.blocks = [num_m_blocks, num_n_blocks]
offset_m = block_m * self.blockIdx.x
offset_n = block_n * self.blockIdx.y
else:
self.attrs.blocks = num_m_blocks * num_n_blocks
m_block, n_block = self.compute_block_coord(
self.blockIdx.x, num_m_blocks, num_n_blocks
)
offset_m = m_block * block_m
offset_n = n_block * block_n

ga = self.global_view(a_ptr, dtype=float16, shape=[m_size, k_size])
gb = self.global_view(b_ptr, dtype=float16, shape=[n_size, k_size])
sa = self.shared_tensor(dtype=float16, shape=[num_stages, block_m, block_k])
block_m_half = block_m // 2
sa = self.shared_tensor(
dtype=float16, shape=[num_stages, 2, block_m_half, block_k]
)
sb = self.shared_tensor(dtype=float16, shape=[num_stages, block_n, block_k])
acc = self.register_tensor(dtype=float32, shape=[block_m, block_n], init=0.0)
gc = self.global_view(c_ptr, dtype=float16, shape=[m_size, n_size])

# producer_arrive_count=1: single thread does arrive_and_expect_tx
# consumer_arrive_count=128: all 128 consumer threads arrive when done
tma_pipe = Pipeline(
num_stages, producer_arrive_count=1, consumer_arrive_count=128
)
# Each consumer WG elects one thread to release a stage after wait_group(0).
tma_pipe = Pipeline(num_stages, producer_arrive_count=1, consumer_arrive_count=2)

with self.thread_group(thread_begin=128, num_threads=32): # TMA producer warp
with self.thread_group(thread_begin=256, num_threads=32): # TMA producer warp
for offset_k in self.range(0, k_size, block_k, unroll=num_stages):
tma_pipe.producer_acquire()
# Producer warp is already 32 threads — the granularity TMA
Expand All @@ -159,15 +165,22 @@ def __call__(
with self.single_thread():
self.mbarrier.arrive_and_expect_tx(
tma_pipe.producer_barrier(),
transaction_bytes=sa[tma_pipe.producer_stage].nbytes
transaction_bytes=sa[tma_pipe.producer_stage, 0].nbytes
+ sa[tma_pipe.producer_stage, 1].nbytes
+ sb[tma_pipe.producer_stage].nbytes,
)
self.tma.global_to_shared(
src=ga,
dst=sa[tma_pipe.producer_stage],
dst=sa[tma_pipe.producer_stage, 0],
offsets=[offset_m, offset_k],
mbarrier=tma_pipe.producer_barrier(),
)
self.tma.global_to_shared(
src=ga,
dst=sa[tma_pipe.producer_stage, 1],
offsets=[offset_m + block_m_half, offset_k],
mbarrier=tma_pipe.producer_barrier(),
)
self.tma.global_to_shared(
src=gb,
dst=sb[tma_pipe.producer_stage],
Expand All @@ -181,44 +194,49 @@ def __call__(
tma_pipe.producer_acquire()
tma_pipe.producer_advance()

with self.thread_group(thread_begin=0, num_threads=128): # WGMMA consumer
# Prologue: issue first MMA; don't release stage 0 yet (it is
# still being read; we release it in the first main-loop iteration
# after wait_group(1) confirms the MMA is done).
tma_pipe.consumer_acquire()
self.wgmma.fence()
self.wgmma.mma(
sa[tma_pipe.consumer_stage], sb[tma_pipe.consumer_stage].transpose(), acc
with self.thread_group(thread_begin=0, num_threads=128): # consumer WG0
acc0 = self.register_tensor(
dtype=float32, shape=[block_m_half, block_n], init=0.0
)
self.wgmma.commit_group()
tma_pipe.consumer_advance()

# Main loop: issue MMA then call wait_group(1) *after* commit so
# the hardware can pipeline the current and previous groups while
# the TMA wait (consumer_acquire) was overlapping with the prior
# group. wait_group(1) stalls until the *previous* group (n-1) is
# done, then we safely release its stage before advancing.
for offset_k in self.range(block_k, k_size, block_k, unroll=num_stages):
for offset_k in self.range(0, k_size, block_k, unroll=num_stages):
tma_pipe.consumer_acquire()
self.wgmma.fence()
self.wgmma.mma(
sa[tma_pipe.consumer_stage],
sa[tma_pipe.consumer_stage, 0],
sb[tma_pipe.consumer_stage].transpose(),
acc,
acc0,
)
self.wgmma.commit_group()
self.wgmma.wait_group(1)
self.mbarrier.arrive(tma_pipe.prev_consumer_barrier())
self.wgmma.wait_group(0)
with self.single_thread():
self.mbarrier.arrive(tma_pipe.consumer_barrier())
tma_pipe.consumer_advance()
self.store_global(
gc, self.cast(acc0, dtype=float16), offsets=[offset_m, offset_n]
)

# Epilogue: drain the last in-flight MMA group, then release its stage.
self.wgmma.wait_group(0)
self.mbarrier.arrive(tma_pipe.prev_consumer_barrier())

self.sync()
casted_acc = self.cast(acc, dtype=float16)
gc = self.global_view(c_ptr, dtype=float16, shape=[m_size, n_size])
self.store_global(gc, casted_acc, offsets=[offset_m, offset_n])
with self.thread_group(thread_begin=128, num_threads=128): # consumer WG1
acc1 = self.register_tensor(
dtype=float32, shape=[block_m_half, block_n], init=0.0
)
for offset_k in self.range(0, k_size, block_k, unroll=num_stages):
tma_pipe.consumer_acquire()
self.wgmma.fence()
self.wgmma.mma(
sa[tma_pipe.consumer_stage, 1],
sb[tma_pipe.consumer_stage].transpose(),
acc1,
)
self.wgmma.commit_group()
self.wgmma.wait_group(0)
with self.single_thread():
self.mbarrier.arrive(tma_pipe.consumer_barrier())
tma_pipe.consumer_advance()
self.store_global(
gc,
self.cast(acc1, dtype=float16),
offsets=[offset_m + block_m_half, offset_n],
)


def main():
Expand All @@ -234,8 +252,8 @@ def main():
for m, n, k in workloads:
matmul = MatmulWGMMAV4()

a = (torch.rand(m, k, dtype=torch.float16).cuda() - 0.5) / math.sqrt(k)
b = (torch.rand(n, k, dtype=torch.float16).cuda() - 0.5) / math.sqrt(k)
a = torch.randn(m, k, dtype=torch.float16).cuda() / math.sqrt(k)
b = torch.randn(n, k, dtype=torch.float16).cuda()
c_actual = torch.empty(m, n, dtype=torch.float16).cuda()
c_expect = a @ b.T
matmul(m, n, k, a, b, c_actual)
Expand Down
Loading
Loading