kernelbench.com

KernelBench hard · H100

Paged Attention LongCat 2.0

24.2%geomean peak fraction across shapes

manually audited: clean

harnesslongcat-claudeagent session3h 34mtotal wall3h 35mcheck29sbenchmark32soutput tokens327,942cost$44.22gpu-lock wait31sgpu-lock held30sregimememory

Per-shape vs governing ceilingeach shape graded against whichever binds — bf16 compute or HBM bandwidth

8×32×8×128×1024×160.094 ms17.6%0.36 TB/s · 18% of 2.0 TB/s HBM · also 1 TFLOPS (0% of compute)
32×32×8×128×2048×160.226 ms58.4%1.19 TB/s · 58% of 2.0 TB/s HBM · also 5 TFLOPS (1% of compute)
4×64×8×128×4096×160.128 ms25.8%0.53 TB/s · 26% of 2.0 TB/s HBM · also 4 TFLOPS (1% of compute)
16×32×8×128×1535×160.141 ms35.2%0.72 TB/s · 35% of 2.0 TB/s HBM · also 3 TFLOPS (0% of compute)
8×16×4×64×2000×160.091 ms8.9%0.18 TB/s · 9% of 2.0 TB/s HBM · also 1 TFLOPS (0% of compute)

compute-bound memory-bound · bar + right column = official fraction of the ceiling (the geomean input)

geomean(17.6% · 58.4% · 25.8% · 35.2% · 8.9%) = 24.2%

Kernel source (redacted)
"""Triton paged-attention decode kernel for SM90 Hopper (H100 PCIe).

Single-query decode with GQA, memory-bound.  Two kernels, launched as a group:

  phase1  grid = (batch, num_kv_heads, K_SPLIT)
      Each block owns one kv_head and the GROUP_SIZE = num_heads // num_kv_heads
      query heads that share it, and processes a *contiguous chunk* of the paged
      KV sequence.  KV for the kv_head is streamed page-by-page and reused across
      the whole group, so every KV byte is loaded exactly once.  Online softmax
      (FlashAttention-style running m/l) folds the softmax into the stream, and a
      per-token mask handles the predicated tail page.  The partial (m, l, acc)
      state is written to a workspace.

  phase2  grid = (batch, num_kv_heads)
      Reduces the K_SPLIT partial states into the final output with the standard
      online-softmax merge.

Splitting the sequence (instead of launching one block that loops over all pages
serially) raises the thread-block count from batch*num_kv_heads to
batch*num_kv_heads*K_SPLIT, which is what saturates the 132 SMs of the H100 for
the small-batch shapes.  K_SPLIT is chosen per-shape to land the grid in the
~256-512 block range without making the reduction expensive.

The KV cache packs [K | V] on its last dim; each page loads K and V as separate
(page_size, head_dim) tiles with a constant offset.
"""
import math

import torch
import torch.nn as nn
import triton
import triton.language as tl

# --- 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


@triton.jit
def _phase1_kernel(
    q_ptr, kv_ptr, block_table_ptr, seq_lens_ptr,
    wm_ptr, wl_ptr, wa_ptr,                     # workspace: m, l, acc
    stride_q_b, stride_q_h, stride_q_d,
    stride_kv_b, stride_kv_p, stride_kv_h, stride_kv_d,
    stride_bt_b, stride_bt_m,
    GROUP_SIZE: tl.constexpr,
    HEAD_DIM: tl.constexpr,
    PAGE_SIZE: tl.constexpr,
    MAX_BLOCKS: tl.constexpr,
    NUM_KV_HEADS: tl.constexpr,
    K_SPLIT: tl.constexpr,
    scale,
    num_warps: tl.constexpr = 4,
):
    pid_b = tl.program_id(0)
    pid_kv = tl.program_id(1)
    pid_s = tl.program_id(2)

    seq_len = tl.load(seq_lens_ptr + pid_b)
    num_pages = (seq_len + PAGE_SIZE - 1) // PAGE_SIZE
    pages_per_split = (num_pages + K_SPLIT - 1) // K_SPLIT
    page_start = pid_s * pages_per_split
    page_end = tl.minimum(page_start + pages_per_split, num_pages)

    q_head_start = pid_kv * GROUP_SIZE

    # Load the group's queries: (G, D), contiguous along the head dim.
    offs_g = tl.arange(0, GROUP_SIZE)[:, None]   # (G, 1)
    offs_d = tl.arange(0, HEAD_DIM)[None, :]     # (1, D)
    q_mask = (q_head_start + offs_g) < (pid_kv * GROUP_SIZE + GROUP_SIZE)
    q = tl.load(
        q_ptr + pid_b * stride_q_b + (q_head_start + offs_g) * stride_q_h + offs_d,
        mask=q_mask,
        other=0.0,
    ).to(tl.float32)  # (G, D)

    # Running online-softmax state, per query head in the group.
    m = tl.full((GROUP_SIZE,), -1e30, dtype=tl.float32)
    l = tl.zeros((GROUP_SIZE,), dtype=tl.float32)
    acc = tl.zeros((GROUP_SIZE, HEAD_DIM), dtype=tl.float32)

    offs_p = tl.arange(0, PAGE_SIZE)[:, None]    # (P, 1)
    offs_dp = tl.arange(0, HEAD_DIM)[None, :]    # (1, D)
    offs_p1d = tl.arange(0, PAGE_SIZE)           # (P,)

    for page_idx in range(page_start, page_end):
        valid_page = page_idx < num_pages

        page = tl.load(block_table_ptr + pid_b * stride_bt_b + page_idx * stride_bt_m)
        page = tl.where(valid_page, page, 0)

        base = page * stride_kv_b + pid_kv * stride_kv_h
        k = tl.load(
            kv_ptr + base + offs_p * stride_kv_p + offs_dp,
            mask=valid_page,
            other=0.0,
        ).to(tl.float32)  # (P, D)
        v = tl.load(
            kv_ptr + base + offs_p * stride_kv_p + offs_dp + HEAD_DIM,
            mask=valid_page,
            other=0.0,
        ).to(tl.float32)  # (P, D)

        scores = tl.dot(q, k.T) * scale  # (G, P)

        token = page_idx * PAGE_SIZE + offs_p1d  # (P,)
        mask = token < seq_len
        scores = tl.where(mask, scores, -1e30)

        new_m = tl.maximum(m, tl.max(scores, axis=1))          # (G,)
        exp_scores = tl.exp(scores - new_m[:, None])            # (G, P)
        exp_scores = tl.where(mask, exp_scores, 0.0)
        new_l = l * tl.exp(m - new_m) + tl.sum(exp_scores, axis=1)  # (G,)
        acc = acc * tl.exp(m - new_m)[:, None] + tl.dot(exp_scores, v)  # (G, D)
        m = new_m
        l = new_l

    # Write partial state to workspace.  Layout: (B, Hkv, K_SPLIT, G) and
    # (B, Hkv, K_SPLIT, G, D), flattened.
    split_idx = (pid_b * NUM_KV_HEADS + pid_kv) * K_SPLIT + pid_s
    base_m = split_idx * GROUP_SIZE
    offs_g1d = tl.arange(0, GROUP_SIZE)
    gmask = offs_g1d < GROUP_SIZE
    tl.store(wm_ptr + base_m + offs_g1d, m, mask=gmask)
    tl.store(wl_ptr + base_m + offs_g1d, l, mask=gmask)
    base_a = split_idx * (GROUP_SIZE * HEAD_DIM)
    offs_gd = tl.arange(0, GROUP_SIZE * HEAD_DIM)
    tl.store(wa_ptr + base_a + offs_gd, acc.reshape(GROUP_SIZE * HEAD_DIM),
             mask=offs_gd < GROUP_SIZE * HEAD_DIM)


@triton.jit
def _phase2_kernel(
    wm_ptr, wl_ptr, wa_ptr, out_ptr,
    stride_o_b, stride_o_h, stride_o_d,
    GROUP_SIZE: tl.constexpr,
    HEAD_DIM: tl.constexpr,
    NUM_KV_HEADS: tl.constexpr,
    K_SPLIT: tl.constexpr,
    num_warps: tl.constexpr = 4,
):
    pid_b = tl.program_id(0)
    pid_kv = tl.program_id(1)

    q_head_start = pid_kv * GROUP_SIZE

    offs_g = tl.arange(0, GROUP_SIZE)[:, None]   # (G, 1)
    offs_d = tl.arange(0, HEAD_DIM)[None, :]     # (1, D)
    q_mask = (q_head_start + offs_g) < (pid_kv * GROUP_SIZE + GROUP_SIZE)

    # Merge the K_SPLIT partial (m, l, acc) states with online-softmax reduction.
    m = tl.full((GROUP_SIZE,), -1e30, dtype=tl.float32)
    l = tl.zeros((GROUP_SIZE,), dtype=tl.float32)
    acc = tl.zeros((GROUP_SIZE, HEAD_DIM), dtype=tl.float32)

    offs_g1d = tl.arange(0, GROUP_SIZE)
    offs_gd = tl.arange(0, GROUP_SIZE * HEAD_DIM)
    gmask = offs_g1d < GROUP_SIZE
    gdmask = offs_gd < GROUP_SIZE * HEAD_DIM

    for s in range(K_SPLIT):
        split_idx = (pid_b * NUM_KV_HEADS + pid_kv) * K_SPLIT + s
        base_m = split_idx * GROUP_SIZE
        ms = tl.load(wm_ptr + base_m + offs_g1d, mask=gmask, other=-1e30)
        ls = tl.load(wl_ptr + base_m + offs_g1d, mask=gmask, other=0.0)
        base_a = split_idx * (GROUP_SIZE * HEAD_DIM)
        accs = tl.load(wa_ptr + base_a + offs_gd, mask=gdmask, other=0.0).reshape(GROUP_SIZE, HEAD_DIM)

        new_m = tl.maximum(m, ms)
        acc = acc * tl.exp(m - new_m)[:, None] + accs * tl.exp(ms - new_m)[:, None]
        l = l * tl.exp(m - new_m) + ls * tl.exp(ms - new_m)
        m = new_m

    out = (acc / l[:, None]).to(tl.bfloat16)
    tl.store(
        out_ptr + pid_b * stride_o_b + (q_head_start + offs_g) * stride_o_h + offs_d,
        out,
        mask=q_mask,
    )


class Model(nn.Module):
    """Single-query paged attention decode (Triton split-K kernel)."""

    def __init__(
        self,
        batch: int,
        num_heads: int,
        num_kv_heads: int,
        head_dim: int,
        seq_len: int,
        page_size: int,
    ):
        super().__init__()
        assert num_heads % num_kv_heads == 0, "num_heads must be a multiple of num_kv_heads (GQA)"
        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: torch.Tensor,
        kv_cache: torch.Tensor,
        block_table: torch.Tensor,
        seq_lens: torch.Tensor,
    ) -> torch.Tensor:
        B, H, D = query.shape
        Hkv = self.num_kv_heads
        G = self.group_size
        P = self.page_size

        out = torch.empty(B, H, D, dtype=query.dtype, device=query.device)
        max_blocks = block_table.shape[1]

        # Choose K_SPLIT to land the phase1 grid in the ~256-512 block range so the
        # GPU stays full for small batches, without oversplitting large ones.
        base = B * Hkv
        num_pages = (int(seq_lens.max().item()) + P - 1) // P
        # Target a grid large enough to saturate the 132 SMs of the H100.
        # The optimum scales with how register-heavy each block is (G*D), so a
        # single desired-count heuristic lands near the per-shape optimum across
        # the whole sweep; the rest of the shapes sit on a flat plateau.
        desired = 640
        k_split = max(1, min(num_pages, (desired + base - 1) // base))
        # Keep the reduction cheap.
        k_split = min(k_split, 20)

        wm = torch.empty(B, Hkv, k_split, G, dtype=torch.float32, device=query.device)
        wl = torch.empty(B, Hkv, k_split, G, dtype=torch.float32, device=query.device)
        wa = torch.empty(B, Hkv, k_split, G, D, dtype=torch.float32, device=query.device)

        grid1 = (B, Hkv, k_split)
        _phase1_kernel[grid1](
            query, kv_cache, block_table, seq_lens,
            wm, wl, wa,
            query.stride(0), query.stride(1), query.stride(2),
            kv_cache.stride(0), kv_cache.stride(1), kv_cache.stride(2), kv_cache.stride(3),
            block_table.stride(0), block_table.stride(1),
            GROUP_SIZE=G, HEAD_DIM=D, PAGE_SIZE=P, MAX_BLOCKS=max_blocks,
            NUM_KV_HEADS=Hkv, K_SPLIT=k_split, scale=self.scale,
        )

        grid2 = (B, Hkv)
        _phase2_kernel[grid2](
            wm, wl, wa, out,
            out.stride(0), out.stride(1), out.stride(2),
            GROUP_SIZE=G, HEAD_DIM=D, NUM_KV_HEADS=Hkv, K_SPLIT=k_split,
        )
        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]

20260707_075525_longcat-claude_LongCat-2.0_03_paged_attention