跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 18 章 端到端 II:推理与部署

/code/18-e2e-inference-serving/

06-tilelang-decode-attention/decode_attention_splitkv.py9892 字节
#!/usr/bin/env python3
"""第 18 章 · decode attention(split-KV / flash decoding)的 TileLang 骨架。

验证级别:**仅静态核对**。

* 本机没有 NVIDIA GPU,也没有昇腾 NPU,本文件**从未被编译或运行过**。
* 它存在的目的是把「decode attention 需要哪些张量、两次 launch 之间要传
  什么」写清楚,让读者能对照锁定版本的官方示例逐行读。
* 结构与 API 名对照 `_vendor/tilelang-v0.1.15/examples/flash_decoding/`:
  `example_mha_inference.py`(split + combine 两个 kernel)、
  `example_gqa_decode.py`(GQA 的 `cur_kv_head` 与逐 split 的 lse)、
  `example_gqa_decode_varlen_logits.py`(varlen 的 `cu_seqlens_k`)。

与官方示例的差别(都是为了把教学要点凸出来):

1. 只做 **decode**(`seqlen_q = 1`),query 方向没有分块;
2. KV 用 **varlen** 表示(`cu_seqlens_k`),不假设每个 batch 一样长;
3. 省掉 autotune 装饰器与 TMA 细节,只留数据流;
4. split 的边界用「按比例切」而不是「按 floor 等分」,避免短序列被切出空段。

在有卡机器上如何验证:见本目录 `README.md`。
"""

from __future__ import annotations

import tilelang
import tilelang.language as T


@tilelang.jit(out_idx=[6])
def gqa_decode_splitkv(
    batch: int,
    heads: int,
    kv_heads: int,
    total_kv: int,
    max_kv: int,
    dim: int,
    num_splits: int = 8,
    block_N: int = 128,       # 每个 split 一次搬多少 KV
    block_H: int = 64,        # 一次处理多少个 query head
    num_stages: int = 2,
    threads: int = 128,
    dtype: str = "float16",
    accum_dtype: str = "float32",
):
    """一次 decode step 的 GQA attention:split 段 + combine 段。

    参数
    ----
    batch / heads / kv_heads
        形状。``heads // kv_heads`` 是 GQA 的 group 数,也就是「一份 KV 被
        几个 query head 复用」——Qwen2.5-7B 是 28 // 4 = 7。
    total_kv
        varlen 打包后的 KV 总长度(整个 batch 拼在一起)。
    max_kv
        batch 里最长的 KV 长度,**编译期常量**:grid 与中间张量的形状都要
        用到它。不同 max_kv 会触发重新编译(第 7 章的 JIT 缓存)。
    num_splits
        KV 方向切几段。decode 时只有 1 个 query token,``batch × heads``
        个并行块常常喂不饱机器,靠这一维把并行度乘上去。

    约束
    ----
    ``num_splits`` 必须满足「每个 split 至少覆盖一个 KV」,
    即 ``num_splits <= min_kv``;否则会有 split 拿到空区间、epilogue 里
    出现 ``0 / 0``。真实框架在调度层保证这一点(不满足就退化成 num_splits=1)。
    """
    scale = (1.0 / dim) ** 0.5 * 1.44269504  # log2(e):把 exp 变成 exp2
    kv_group_num = heads // kv_heads
    valid_block_H = min(block_H, kv_group_num)

    shape_q = [batch, heads, dim]
    shape_kv = [total_kv, kv_heads, dim]
    shape_o = [batch, heads, dim]
    part_shape = [batch, heads, num_splits, dim]
    #: 逐 (batch, split) 的 log-sum-exp;q_len = 1 时它是标量
    lse_shape = [batch, num_splits, heads]

    @T.prim_func
    def gqa_decode_splitkv_kernel(
        Q: T.Tensor(shape_q, dtype),
        K: T.Tensor(shape_kv, dtype),
        V: T.Tensor(shape_kv, dtype),
        cu_seqlens_k: T.Tensor([batch + 1], T.int32),
        Output_partial: T.Tensor(part_shape, dtype),
        Lse: T.Tensor(lse_shape, dtype),
        Output: T.Tensor(shape_o, dtype),
    ):
        # ---------------- split 段 ----------------
        with T.Kernel(batch, heads // valid_block_H, num_splits,
                      threads=threads) as (bid, hid, sid):
            Q_shared = T.alloc_shared([valid_block_H, dim], dtype)
            K_shared = T.alloc_shared([block_N, dim], dtype)
            V_shared = T.alloc_shared([block_N, dim], dtype)
            O_shared = T.alloc_shared([valid_block_H, dim], dtype)
            acc_s = T.alloc_fragment([valid_block_H, block_N], accum_dtype)
            acc_s_cast = T.alloc_fragment([valid_block_H, block_N], dtype)
            acc_o = T.alloc_fragment([valid_block_H, dim], accum_dtype)
            scores_max = T.alloc_fragment([valid_block_H], accum_dtype)
            scores_max_prev = T.alloc_fragment([valid_block_H], accum_dtype)
            scores_scale = T.alloc_fragment([valid_block_H], accum_dtype)
            scores_sum = T.alloc_fragment([valid_block_H], accum_dtype)
            logsum = T.alloc_fragment([valid_block_H], accum_dtype)

            # varlen:本序列的 KV 区间由前缀和给出
            cur_start_k = cu_seqlens_k[bid]
            cur_end_k = cu_seqlens_k[bid + 1]
            cur_seqlen_k = cur_end_k - cur_start_k
            # 第 hid 个块的第 0 个 query head 落在哪个 KV head 上
            cur_kv_head = (hid * valid_block_H) // kv_group_num

            T.copy(Q[bid, hid * valid_block_H:
                     hid * valid_block_H + valid_block_H, :], Q_shared)
            T.fill(acc_o, 0)
            T.fill(logsum, 0)
            T.fill(scores_max, -T.infinity(accum_dtype))

            # 按比例切:sid 负责 [split_start, split_end)
            split_start = cur_seqlen_k * sid // num_splits
            split_end = cur_seqlen_k * (sid + 1) // num_splits
            split_len = T.ceildiv(split_end - split_start, block_N)
            for k in T.Pipelined(split_len, num_stages=num_stages):
                T.copy(
                    K[cur_start_k + split_start + k * block_N:
                      cur_start_k + split_start + (k + 1) * block_N,
                      cur_kv_head, :],
                    K_shared,
                )
                T.clear(acc_s)
                T.gemm(Q_shared, K_shared, acc_s, transpose_B=True,
                       policy=T.GemmWarpPolicy.FullRow)
                # 越界(本 split 之外)的位置记 -inf,不参与 softmax
                for i, j in T.Parallel(valid_block_H, block_N):
                    acc_s[i, j] = T.if_then_else(
                        split_start + k * block_N + j < split_end,
                        acc_s[i, j],
                        -T.infinity(accum_dtype),
                    )
                # online softmax(第 15 章 2.3 的递推,逐项对应)
                T.copy(scores_max, scores_max_prev)
                T.reduce_max(acc_s, scores_max, dim=1, clear=False)
                for i in T.Parallel(valid_block_H):
                    scores_scale[i] = T.exp2(
                        scores_max_prev[i] * scale - scores_max[i] * scale)
                for i, j in T.Parallel(valid_block_H, block_N):
                    acc_s[i, j] = T.exp2(acc_s[i, j] * scale
                                         - scores_max[i] * scale)
                T.reduce_sum(acc_s, scores_sum, dim=1)
                for i in T.Parallel(valid_block_H):
                    logsum[i] = logsum[i] * scores_scale[i] + scores_sum[i]
                T.copy(acc_s, acc_s_cast)
                for i, j in T.Parallel(valid_block_H, dim):
                    acc_o[i, j] *= scores_scale[i]

                T.copy(
                    V[cur_start_k + split_start + k * block_N:
                      cur_start_k + split_start + (k + 1) * block_N,
                      cur_kv_head, :],
                    V_shared,
                )
                T.gemm(acc_s_cast, V_shared, acc_o,
                       policy=T.GemmWarpPolicy.FullRow)

            # 写出「本段已归一化的输出」与「本段的 lse」
            for i, j in T.Parallel(valid_block_H, dim):
                acc_o[i, j] /= logsum[i]
            for i in T.Parallel(valid_block_H):
                # combine 段用 log2 域比较,所以先转成 log2 + 缩放后的最大值
                logsum[i] = T.log2(logsum[i]) + scores_max[i] * scale
            for i in T.Parallel(valid_block_H):
                Lse[bid, sid, hid * valid_block_H + i] = logsum[i]
            T.copy(acc_o, O_shared)
            T.copy(O_shared, Output_partial[bid, hid * valid_block_H:
                                            (hid + 1) * valid_block_H, sid, :])

        # ---------------- combine 段 ----------------
        with T.Kernel(batch, heads // valid_block_H, threads=threads) as (bid, hid):
            po_local = T.alloc_fragment([valid_block_H, dim], dtype)
            o_accum_local = T.alloc_fragment([valid_block_H, dim], accum_dtype)
            o_shared = T.alloc_shared([valid_block_H, dim], dtype)
            lse_local = T.alloc_fragment([num_splits, valid_block_H], dtype)
            lse_local_split = T.alloc_fragment([valid_block_H], accum_dtype)
            lse_logsum_local = T.alloc_fragment([valid_block_H], accum_dtype)
            lse_max_local = T.alloc_fragment([valid_block_H], accum_dtype)
            scale_local = T.alloc_fragment([valid_block_H], accum_dtype)

            T.clear(lse_logsum_local)
            T.clear(o_accum_local)
            for k, j in T.Parallel(num_splits, valid_block_H):
                lse_local[k, j] = Lse[bid, k, hid * valid_block_H + j]
            T.reduce_max(lse_local, lse_max_local, dim=0, clear=True)
            for k in T.serial(num_splits):
                for j in T.Parallel(valid_block_H):
                    # 先减最大值再累加,和 online softmax 是同一个技巧
                    lse_logsum_local[j] += T.exp2(lse_local[k, j]
                                                  - lse_max_local[j])
            for j in T.Parallel(valid_block_H):
                lse_logsum_local[j] = T.log2(lse_logsum_local[j]) + lse_max_local[j]

            for k in T.serial(num_splits):
                T.copy(Output_partial[bid, hid * valid_block_H:
                                      (hid + 1) * valid_block_H, k, :], po_local)
                for j in T.Parallel(valid_block_H):
                    # 权重 = exp(lse_s − logsumexp(lse)) = ℓ_s / Σ_s' ℓ_s'
                    scale_local[j] = T.exp2(lse_local[k, j] - lse_logsum_local[j])
                for i, j in T.Parallel(valid_block_H, dim):
                    o_accum_local[i, j] += po_local[i, j] * scale_local[j]
            T.copy(o_accum_local, o_shared)
            T.copy(o_shared, Output[bid, hid * valid_block_H:
                                    (hid + 1) * valid_block_H, :])

    return gqa_decode_splitkv_kernel


#: 与 `example_mha_inference.py` 的 `num_split = 4` 同一量级;
#: `example_gqa_decode.py` 的 autotune 空间里 num_split ∈ {1, 2, 4, 8}。
DEFAULT_SHAPE = {
    "batch": 8,
    "heads": 28,
    "kv_heads": 4,
    "total_kv": 8 * 8192,
    "max_kv": 8192,
    "dim": 128,
    "num_splits": 8,
}


def build(**overrides: int):
    """编译入口;在有卡的机器上调用它。"""
    cfg = dict(DEFAULT_SHAPE)
    cfg.update(overrides)
    return gqa_decode_splitkv(**cfg)


if __name__ == "__main__":
    raise SystemExit(
        "本文件只做静态核对:请在装有 NVIDIA GPU + TileLang v0.1.15 的机器上\n"
        "参照 code/06-tilelang-decode-attention/README.md 运行。"
    )

← 浏览本章全部代码