KernelBench hard · H100
Paged Attention Tencent Hy3
12.3%geomean peak fraction across shapes
agent session3h 23mtotal wall3h 24mcheck32sbenchmark13soutput tokens—gpu-lock wait3sgpu-lock held42sregimememory
Per-shape vs governing ceilingeach shape graded against whichever binds — bf16 compute or HBM bandwidth
8×32×8×128×1024×160.223 ms7.4%0.15 TB/s · 7% of 2.0 TB/s HBM · also 1 TFLOPS (0% of compute)
32×32×8×128×2048×160.361 ms36.5%0.75 TB/s · 37% of 2.0 TB/s HBM · also 3 TFLOPS (0% of compute)
4×64×8×128×4096×160.236 ms14.0%0.29 TB/s · 14% of 2.0 TB/s HBM · also 2 TFLOPS (0% of compute)
16×32×8×128×1535×160.245 ms20.2%0.41 TB/s · 20% of 2.0 TB/s HBM · also 2 TFLOPS (0% of compute)
8×16×4×64×2000×160.222 ms3.6%0.07 TB/s · 4% of 2.0 TB/s HBM · also 0 TFLOPS (0% of compute)
compute-bound memory-bound · bar + right column = official fraction of the ceiling (the geomean input)
geomean(7.4% · 36.5% · 14.0% · 20.2% · 3.6%) = 12.3%
Kernel source (redacted)
"""Custom Triton paged-attention decode kernel (memory-bound, single-query).
Design
------
Decode is bandwidth-bound: the KV cache must be streamed end-to-end once. We
launch ONE program per (batch element, KV head) and walk the pages of sequence
b via block_table, reading each KV token exactly once and running a FlashAttention
style *online softmax* so K and V are consumed in a single streaming pass. A
single (b, hkv) program owns the group of G = num_heads / num_kv_heads query
heads that share that KV head, so KV is fetched exactly once regardless of the
GQA ratio (matching problem.yaml's minimal bytes_formula).
To hide memory latency when the batch is small, each sequence is optionally
split across several programs (FlashDecoding): kernel 1 produces, per (b, hkv,
split), a *partial* (running max, normalizer, weighted sum) over its page range;
kernel 2 combines the partials with the online-softmax combine rule. This keeps
the GPU's SMs saturated even for batch=4 / long context.
KV cache layout: (num_blocks, page_size, num_kv_heads, 2 * head_dim) with the
last dim packing [K | V], so a single gather pulls both halves.
Triton notes (learned the hard way on this build):
* A tl.dot whose contraction dimension is not stride-1 contiguous in memory
HANGS on Hopper. The token dimension is strided (stride 2048) in this KV
layout, so the V aggregation is done as a broadcast-multiply + sum over
tokens (reads V contiguously per token) instead of a tl.dot.
* A combined (row & col) store mask HANGS on this build; a row-only mask is
used (BLOCK_D == D always, so no column masking is needed).
* The MMA requires the leading (query-head) dimension to be a multiple of 16,
so BLOCK_G is rounded up to 16 and padding rows are reset every step.
"""
import math
import torch
import torch.nn as nn
import triton
import triton.language as tl
@triton.jit
def _decode_split_kernel(
Q, KV, BLOCK_TABLE, M_PART, L_PART, A_PART, SEQ_LENS,
# kv strides
stride_kv_b, stride_kv_p, stride_kv_h, stride_kv_d,
# q strides
stride_q_b, stride_q_h, stride_q_d,
# partial strides: (B, Hkv, num_splits, ...)
stride_m_b, stride_m_h, stride_m_s, stride_m_g,
stride_l_b, stride_l_h, stride_l_s, stride_l_g,
stride_a_b, stride_a_h, stride_a_s, stride_a_g, stride_a_d,
stride_bt_b, stride_bt_p,
scale,
B, HKV, G, P, D, num_splits, pages_per_split, num_pages,
BLOCK_D: tl.constexpr,
BLOCK_G: tl.constexpr,
GROUP: tl.constexpr,
PAGE: tl.constexpr,
NEG_INF: tl.constexpr,
):
pid_b = tl.program_id(0)
pid_hkv = tl.program_id(1)
pid_split = tl.program_id(2)
seqlen = tl.load(SEQ_LENS + pid_b).to(tl.int32)
num_pages = num_pages # python int passed in
start = pid_split * pages_per_split
end = start + pages_per_split
if end > num_pages:
end = num_pages
offs_g = tl.arange(0, BLOCK_G)
offs_d = tl.arange(0, BLOCK_D)
keep = offs_g < GROUP
# Load Q for the G query heads mapped to this KV head.
q_ptr = Q + pid_b * stride_q_b + pid_hkv * GROUP * stride_q_h
Qg = tl.load(q_ptr + offs_g[:, None] * stride_q_h + offs_d[None, :] * stride_q_d,
mask=(offs_g[:, None] < GROUP), other=0.0).to(tl.float32)
m = tl.full((BLOCK_G,), NEG_INF, dtype=tl.float32)
lsum = tl.zeros((BLOCK_G,), dtype=tl.float32)
acc = tl.zeros((BLOCK_G, BLOCK_D), dtype=tl.float32)
offs_p = tl.arange(0, PAGE)
for page in range(start, end):
blk = tl.load(BLOCK_TABLE + pid_b * stride_bt_b + page * stride_bt_p).to(tl.int32)
kv_base = KV + blk * stride_kv_b + pid_hkv * stride_kv_h
k_ptrs = kv_base + offs_p[None, :] * stride_kv_p + offs_d[:, None] * stride_kv_d
KT = tl.load(k_ptrs).to(tl.float32) # (D, PAGE)
scores = tl.dot(Qg, KT) * scale # (BLOCK_G, PAGE)
tok = page * PAGE + offs_p
valid = tok < seqlen
scores = tl.where(valid[None, :], scores, NEG_INF)
m_new = tl.maximum(m, tl.max(scores, axis=1)) # (BLOCK_G,)
p = tl.exp(scores - m_new[:, None]) # (BLOCK_G, PAGE)
corr = tl.exp(m - m_new) # (BLOCK_G,)
v_ptrs = kv_base + D * stride_kv_d + offs_p[:, None] * stride_kv_p + offs_d[None, :] * stride_kv_d
Vp = tl.load(v_ptrs).to(tl.float32) # (PAGE, D)
p = tl.where(tok[None, :] < seqlen, p, 0.0) # (BLOCK_G, PAGE)
acc_corr = acc * corr[:, None] + tl.sum(p[:, :, None] * Vp[None, :, :], axis=1)
acc = acc_corr
lsum = corr * lsum + tl.sum(p, axis=1)
m = m_new
# Reset padding rows every step so they never leak into shared vectors.
acc = tl.where(keep[:, None], acc, 0.0)
lsum = tl.where(keep, lsum, 0.0)
m = tl.where(keep, m, NEG_INF)
# Write partials at [b, hkv, split, g].
m_ptr = M_PART + pid_b * stride_m_b + pid_hkv * stride_m_h + pid_split * stride_m_s
l_ptr = L_PART + pid_b * stride_l_b + pid_hkv * stride_l_h + pid_split * stride_l_s
a_ptr = A_PART + pid_b * stride_a_b + pid_hkv * stride_a_h + pid_split * stride_a_s
tl.store(m_ptr + offs_g * stride_m_g, m.to(tl.float32))
tl.store(l_ptr + offs_g * stride_l_g, lsum.to(tl.float32))
tl.store(a_ptr + offs_g[:, None] * stride_a_g + offs_d[None, :] * stride_a_d,
acc.to(tl.float32))
@triton.jit
def _combine_kernel(
M_PART, L_PART, A_PART, OUT,
stride_m_b, stride_m_h, stride_m_s, stride_m_g,
stride_l_b, stride_l_h, stride_l_s, stride_l_g,
stride_a_b, stride_a_h, stride_a_s, stride_a_g, stride_a_d,
stride_o_b, stride_o_h, stride_o_d,
B, HKV, G, D, num_splits,
BLOCK_D: tl.constexpr,
BLOCK_G: tl.constexpr,
GROUP: tl.constexpr,
NEG_INF: tl.constexpr,
):
pid_b = tl.program_id(0)
pid_hkv = tl.program_id(1)
offs_g = tl.arange(0, BLOCK_G)
offs_d = tl.arange(0, BLOCK_D)
keep = offs_g < GROUP
m = tl.full((BLOCK_G,), NEG_INF, dtype=tl.float32)
lsum = tl.zeros((BLOCK_G,), dtype=tl.float32)
acc = tl.zeros((BLOCK_G, BLOCK_D), dtype=tl.float32)
for s in range(0, num_splits):
m_s = tl.load(M_PART + pid_b * stride_m_b + pid_hkv * stride_m_h + s * stride_m_s
+ offs_g * stride_m_g).to(tl.float32)
l_s = tl.load(L_PART + pid_b * stride_l_b + pid_hkv * stride_l_h + s * stride_l_s
+ offs_g * stride_l_g).to(tl.float32)
a_s = tl.load(A_PART + pid_b * stride_a_b + pid_hkv * stride_a_h + s * stride_a_s
+ offs_g[:, None] * stride_a_g + offs_d[None, :] * stride_a_d).to(tl.float32)
# Treat empty partials (l_s == 0, i.e. m_s at sentinel) as no-op.
m_s = tl.where(l_s > 0, m_s, m)
m_new = tl.maximum(m, m_s)
w_g = tl.exp(m - m_new)
w_s = tl.exp(m_s - m_new)
acc = acc * w_g[:, None] + a_s * w_s[:, None]
lsum = lsum * w_g + l_s * w_s
m = m_new
out = acc / lsum[:, None]
o_ptr = OUT + pid_b * stride_o_b + pid_hkv * GROUP * stride_o_h
tl.store(o_ptr + offs_g[:, None] * stride_o_h + offs_d[None, :] * stride_o_d,
out.to(tl.bfloat16), mask=(offs_g[:, None] < GROUP))
class Model(nn.Module):
def __init__(self, batch, num_heads, num_kv_heads, head_dim, seq_len, page_size):
super().__init__()
assert num_heads % num_kv_heads == 0
self.batch = batch
self.num_heads = num_heads
self.num_kv_heads = num_kv_heads
self.head_dim = head_dim
self.seq_len = seq_len
self.page_size = page_size
self.group_size = num_heads // num_kv_heads
self.scale = 1.0 / math.sqrt(head_dim)
self.register_buffer("_dummy", torch.zeros(1, dtype=torch.bfloat16), persistent=False)
def forward(self, query, kv_cache, block_table, seq_lens):
B, H, D = query.shape
Hkv = self.num_kv_heads
G = self.group_size
P = self.page_size
BLOCK_G = ((G + 15) // 16) * 16
out = torch.empty(B, H, D, dtype=query.dtype, device=query.device)
num_pages = int(((seq_lens.max() + P - 1) // P).item())
# FlashDecoding: aim for ~512 concurrent programs to hide memory latency.
target = 512
num_splits = max(1, min((target + B * Hkv - 1) // (B * Hkv), num_pages))
pages_per_split = (num_pages + num_splits - 1) // num_splits
# Partial buffers (B, Hkv, num_splits, BLOCK_G, [D]).
m_part = torch.empty(B, Hkv, num_splits, BLOCK_G, dtype=torch.float32, device=query.device)
l_part = torch.empty(B, Hkv, num_splits, BLOCK_G, dtype=torch.float32, device=query.device)
a_part = torch.empty(B, Hkv, num_splits, BLOCK_G, D, dtype=torch.float32, device=query.device)
_decode_split_kernel[(B, Hkv, num_splits)](
query, kv_cache, block_table, m_part, l_part, a_part, seq_lens,
kv_cache.stride(0), kv_cache.stride(1), kv_cache.stride(2), kv_cache.stride(3),
query.stride(0), query.stride(1), query.stride(2),
m_part.stride(0), m_part.stride(1), m_part.stride(2), m_part.stride(3),
l_part.stride(0), l_part.stride(1), l_part.stride(2), l_part.stride(3),
a_part.stride(0), a_part.stride(1), a_part.stride(2), a_part.stride(3), a_part.stride(4),
block_table.stride(0), block_table.stride(1),
self.scale,
B, Hkv, G, P, D, num_splits, pages_per_split, num_pages,
BLOCK_D=D, BLOCK_G=BLOCK_G, GROUP=G, PAGE=P, NEG_INF=-3.0e4,
)
_combine_kernel[(B, Hkv)](
m_part, l_part, a_part, out,
m_part.stride(0), m_part.stride(1), m_part.stride(2), m_part.stride(3),
l_part.stride(0), l_part.stride(1), l_part.stride(2), l_part.stride(3),
a_part.stride(0), a_part.stride(1), a_part.stride(2), a_part.stride(3), a_part.stride(4),
out.stride(0), out.stride(1), out.stride(2),
B, Hkv, G, D, num_splits,
BLOCK_D=D, BLOCK_G=BLOCK_G, GROUP=G, NEG_INF=-3.0e4,
)
return out
def get_inputs():
B = BATCH
H = NUM_HEADS
Hkv = NUM_KV_HEADS
D = HEAD_DIM
L = SEQ_LEN
P = PAGE_SIZE
pages_per_seq = (L + P - 1) // P
total_pages = max(B * pages_per_seq + 8, 64)
query = torch.randn(B, H, D, dtype=torch.bfloat16) * 0.1
kv_cache = torch.randn(total_pages, P, Hkv, 2 * D, dtype=torch.bfloat16) * 0.1
perm = torch.randperm(total_pages)[: B * pages_per_seq].reshape(B, pages_per_seq).int()
block_table = perm.contiguous()
seq_lens = torch.full((B,), L, dtype=torch.int32)
return [query, kv_cache, block_table, seq_lens]
def get_init_inputs():
return [BATCH, NUM_HEADS, NUM_KV_HEADS, HEAD_DIM, SEQ_LEN, PAGE_SIZE]
# --- Shape knobs (overridden by check.py / benchmark.py from shapes.py) ----
BATCH = 8
NUM_HEADS = 32
NUM_KV_HEADS = 8
HEAD_DIM = 128
SEQ_LEN = 1024
PAGE_SIZE = 16
20260709_090201_hy3_hy3_03_paged_attention