跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 13 章 算子实战 I:逐元素、归一化与 RoPE

/code/13-operators-elementwise-norm-rope/

05-fusion/rmsnorm_rope_quant.py10795 字节
"""第 13 章 · 实战三之二:多算子融合(需要 NVIDIA GPU + TileLang v0.1.15)。

两个 capstone kernel:

  rms_norm_rope_fused        —— RMSNorm + 局部 RoPE 融合,x 只读一遍、y 只写一遍
  rms_norm_rope_quant_fused  —— 再加 per-token FP8 量化:一次读 x,写 fp8 与 scale

融合的收益是可以先在纸上算清楚的(见同章 `code/01-ledger/ledger.py`):
RMSNorm 单独写是「读 x 一遍 → 写 y 一遍」,RoPE 再「读 y 一遍 → 写 y 一遍」,
账本上 4 趟变成了 2 趟。代价是**片上容量**与**寄存器压力**:三件事要共享同一块
fragment 和 shared,tile 尺寸必须缩小,occupancy 可能因此下降。

这两个 kernel 的 `blk_m` 都默认取 1:一行的 N 个 fp32 累加器已经占满一个线程
块的寄存器预算。想加大 `blk_m` 就要按行切 K,或者改用 per-thread partial 归约
(对照 `../tilelang-corpus/12-operator-libraries/raw/TileOPs/src/tileops/kernels/norm/rms_norm.py`)。

本文件只做**静态核对**:API 名逐个对照过 `_vendor/`,`python3 -m py_compile` 通过。
**没有在 GPU 上运行过**,不声明任何性能数字;layout 推导是否通过也必须在真机上
确认(见同目录 README 的「真机验证步骤」)。
"""

from __future__ import annotations

import tilelang
import tilelang.language as T


def _torch_rope_neox(x, cos, sin):
    """本地复制一份 NeoX 旋转(示例目录必须能独立复制出去运行,不跨目录 import)。"""
    import torch

    half = x.shape[-1] // 2
    x_lo = x.float()[..., :half]
    x_hi = x.float()[..., half:]
    c = cos.float()
    s = sin.float()
    y_lo = x_lo * c - x_hi * s
    y_hi = x_hi * c + x_lo * s
    return torch.cat([y_lo, y_hi], dim=-1).to(x.dtype)


def torch_reference_fused(x, weight, cos, sin, eps: float = 1e-6, fp8_max: float = 448.0):
    """RMSNorm → 局部 RoPE → per-token FP8 量化的 PyTorch 参考实现。

    x:      [M, N](M 是 token×head 打平后的行数)
    weight: [N]
    cos/sin:[M, rope_dim // 2]

    返回 (y, y_fp8, scale)。
    """
    import torch

    rstd = torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + eps)
    normed = (x.float() * rstd * weight.float().unsqueeze(0)).to(x.dtype)

    rope_dim = 2 * cos.shape[-1]
    head = normed[..., :-rope_dim]
    tail = normed[..., -rope_dim:]
    y = torch.cat([head, _torch_rope_neox(tail, cos, sin)], dim=-1)

    amax = y.float().abs().amax(dim=-1, keepdim=True).clamp(min=1e-4)
    scale = amax / fp8_max
    y_fp8 = (y.float() / scale).to(torch.float8_e4m3fn)
    return y, y_fp8, scale.squeeze(-1)


# ---------------------------------------------------------------------------
# ① RMSNorm + 局部 RoPE
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[-1])
def rms_norm_rope_fused(
    M: int,
    N: int,
    rope_dim: int,
    eps: float = 1e-6,
    blk_m: int = 1,
    dtype: str = "float16",
    threads: int = 256,
):
    """y = rope(rms_norm(x, w), cos, sin),只有最后 `rope_dim` 列被旋转。

    数据流(都留在片上,中间的 `normed` 不落显存):

        x ──T.copy──▶ shared ──cast──▶ fragment(fp32)
          ──square+reduce_sum──▶ rstd ──mul w[j]──▶ 写回 shared(降回 dtype)
          ──在 shared 上就地做尾部的 RoPE──▶ T.copy 到 Y

    为什么 RoPE 那一步要绕回 shared:NeoX 约定把一行从中间劈成两半,第 `p` 个和
    第 `p + rope_dim/2` 个元素必须同时可见。它们在 fragment 里可能属于不同线程,
    寄存器之间不能直接互相访问;shared 是唯一能让「跨 lane 配对」成立的地方。
    """
    half = rope_dim // 2
    if rope_dim % 2 != 0:
        raise ValueError(f"rope_dim must be even, got {rope_dim}")
    if N < rope_dim:
        raise ValueError(f"needs N >= rope_dim, got N={N}, rope_dim={rope_dim}")
    if M % blk_m != 0:
        raise ValueError(f"needs M % blk_m == 0, got M={M}, blk_m={blk_m}")

    @T.prim_func
    def main(
        X: T.Tensor((M, N), dtype),  # type: ignore[valid-type]
        W: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        Cos: T.Tensor((M, half), dtype),  # type: ignore[valid-type]
        Sin: T.Tensor((M, half), dtype),  # type: ignore[valid-type]
        Y: T.Tensor((M, N), dtype),  # type: ignore[valid-type]
    ):
        with T.Kernel(T.ceildiv(M, blk_m), threads=threads) as bx:
            x_sh = T.alloc_shared((blk_m, N), dtype)
            w_sh = T.alloc_shared((N,), dtype)
            x_f32 = T.alloc_fragment((blk_m, N), "float32")
            sq_f32 = T.alloc_fragment((blk_m, N), "float32")
            sumsq = T.alloc_fragment((blk_m,), "float32")
            rstd = T.alloc_fragment((blk_m,), "float32")

            T.copy(X[bx * blk_m : (bx + 1) * blk_m, :], x_sh)
            T.copy(W, w_sh)

            for i, j in T.Parallel(blk_m, N):
                x_f32[i, j] = T.Cast("float32", x_sh[i, j])
            for i, j in T.Parallel(blk_m, N):
                sq_f32[i, j] = x_f32[i, j] * x_f32[i, j]
            T.reduce_sum(sq_f32, sumsq, dim=1)
            for i in T.Parallel(blk_m):
                rstd[i] = T.rsqrt(sumsq[i] / N + eps)

            # 归一化 + 权重,直接降回 dtype 写进 shared:后面 RoPE 要跨 lane 配对
            for i, j in T.Parallel(blk_m, N):
                x_sh[i, j] = T.Cast(dtype, x_f32[i, j] * rstd[i] * T.Cast("float32", w_sh[j]))

            # 只旋转尾部 rope_dim 列,就地完成
            for i, p in T.Parallel(blk_m, half):
                c = T.Cast("float32", Cos[bx * blk_m + i, p])
                s = T.Cast("float32", Sin[bx * blk_m + i, p])
                lo = T.Cast("float32", x_sh[i, N - rope_dim + p])
                hi = T.Cast("float32", x_sh[i, N - rope_dim + half + p])
                x_sh[i, N - rope_dim + p] = T.Cast(dtype, lo * c - hi * s)
                x_sh[i, N - rope_dim + half + p] = T.Cast(dtype, hi * c + lo * s)

            T.copy(x_sh, Y[bx * blk_m : (bx + 1) * blk_m, :])

    return main


# ---------------------------------------------------------------------------
# ② RMSNorm + RoPE + per-token FP8 量化
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[-3, -2, -1])
def rms_norm_rope_quant_fused(
    M: int,
    N: int,
    rope_dim: int,
    group_size: int = 128,
    eps: float = 1e-6,
    fp8_max: float = 448.0,
    blk_m: int = 1,
    dtype: str = "float16",
    threads: int = 256,
):
    """在 ① 的基础上再做一次 per-token FP8 量化。

    这一步引入**第二次归约**(对旋转后的整行取 amax)。两个归约在同一个 kernel 里,
    所以:

    * 第一次归约(平方和)用 fragment 完成;
    * RoPE 之后,整行已经在 shared 里,第二次归约再把它读回一个 fp32 fragment 求 max。

    账本收益:单独的「量化」算子要再把 `rotated` 读一遍、把 `q` 写一遍;
    融合后这一读一写都省了(见 `code/01-ledger/ledger.py` 的最后一组)。
    """
    half = rope_dim // 2
    if rope_dim % 2 != 0:
        raise ValueError(f"rope_dim must be even, got {rope_dim}")
    if N % group_size != 0:
        raise ValueError(f"needs N % group_size == 0, got N={N}, group_size={group_size}")
    if M % blk_m != 0:
        raise ValueError(f"needs M % blk_m == 0, got M={M}, blk_m={blk_m}")
    n_groups = N // group_size

    @T.prim_func
    def main(
        X: T.Tensor((M, N), dtype),  # type: ignore[valid-type]
        W: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        Cos: T.Tensor((M, half), dtype),  # type: ignore[valid-type]
        Sin: T.Tensor((M, half), dtype),  # type: ignore[valid-type]
        Y: T.Tensor((M, N), dtype),  # type: ignore[valid-type]
        Q: T.Tensor((M, N), "float8_e4m3fn"),  # type: ignore[valid-type]
        Scale: T.Tensor((M, n_groups), "float32"),  # type: ignore[valid-type]
    ):
        with T.Kernel(T.ceildiv(M, blk_m), threads=threads) as bx:
            x_sh = T.alloc_shared((blk_m, N), dtype)
            w_sh = T.alloc_shared((N,), dtype)

            x_f32 = T.alloc_fragment((blk_m, N), "float32")
            sq_f32 = T.alloc_fragment((blk_m, N), "float32")
            sumsq = T.alloc_fragment((blk_m,), "float32")
            rstd = T.alloc_fragment((blk_m,), "float32")

            group_view = T.alloc_fragment((blk_m, n_groups, group_size), "float32")
            amax = T.alloc_fragment((blk_m, n_groups), "float32")
            scale_f32 = T.alloc_fragment((blk_m, n_groups), "float32")
            q_fp8 = T.alloc_fragment((blk_m, N), "float8_e4m3fn")

            T.copy(X[bx * blk_m : (bx + 1) * blk_m, :], x_sh)
            T.copy(W, w_sh)

            # --- 第 1 步:RMSNorm ---
            for i, j in T.Parallel(blk_m, N):
                x_f32[i, j] = T.Cast("float32", x_sh[i, j])
            for i, j in T.Parallel(blk_m, N):
                sq_f32[i, j] = x_f32[i, j] * x_f32[i, j]
            T.reduce_sum(sq_f32, sumsq, dim=1)
            for i in T.Parallel(blk_m):
                rstd[i] = T.rsqrt(sumsq[i] / N + eps)
            for i, j in T.Parallel(blk_m, N):
                x_sh[i, j] = T.Cast(dtype, x_f32[i, j] * rstd[i] * T.Cast("float32", w_sh[j]))

            # --- 第 2 步:尾部 RoPE(就地,跨 lane 配对走 shared) ---
            for i, p in T.Parallel(blk_m, half):
                c = T.Cast("float32", Cos[bx * blk_m + i, p])
                s = T.Cast("float32", Sin[bx * blk_m + i, p])
                lo = T.Cast("float32", x_sh[i, N - rope_dim + p])
                hi = T.Cast("float32", x_sh[i, N - rope_dim + half + p])
                x_sh[i, N - rope_dim + p] = T.Cast(dtype, lo * c - hi * s)
                x_sh[i, N - rope_dim + half + p] = T.Cast(dtype, hi * c + lo * s)

            T.copy(x_sh, Y[bx * blk_m : (bx + 1) * blk_m, :])

            # --- 第 3 步:每 group_size 个元素共享一个 scale ---
            # 「按 group_size 一组取 max」= 把一行 reshape 成 (n_groups, group_size) 后
            # 沿最后一维归约。这里用一个三维 fragment 承载视图,再用 T.reduce_max 降维——
            # 这是 v0.1.15 里 reduce 的常规用法,比手写 read-modify-write 更不容易踩 layout。
            for i, g, k in T.Parallel(blk_m, n_groups, group_size):
                group_view[i, g, k] = T.abs(T.Cast("float32", x_sh[i, g * group_size + k]))
            T.reduce_max(group_view, amax, dim=2, clear=True)
            for i, g in T.Parallel(blk_m, n_groups):
                scale_f32[i, g] = T.max(amax[i, g], 1e-4) / fp8_max

            for i, g, k in T.Parallel(blk_m, n_groups, group_size):
                q_fp8[i, g * group_size + k] = T.Cast(
                    "float8_e4m3fn",
                    T.clamp(
                        T.Cast("float32", x_sh[i, g * group_size + k]) / scale_f32[i, g],
                        -fp8_max,
                        fp8_max,
                    ),
                )
            T.copy(q_fp8, Q[bx * blk_m : (bx + 1) * blk_m, :])

            for i, g in T.Parallel(blk_m, n_groups):
                Scale[bx * blk_m + i, g] = scale_f32[i, g]

    return main


if __name__ == "__main__":
    # 需要 cuda。本机从未执行过。
    import torch

    m, n, rope_dim = 256, 4096, 128
    half = rope_dim // 2
    x = torch.randn(m, n, dtype=torch.float16, device="cuda")
    w = torch.randn(n, dtype=torch.float16, device="cuda")
    angles = torch.randn(m, half, device="cuda")
    cos, sin = angles.cos().half(), angles.sin().half()

    y, y_fp8, scale = torch_reference_fused(x, w, cos, sin)
    kern = rms_norm_rope_fused(m, n, rope_dim, dtype="float16")
    y_tl = kern(x, w, cos, sin)
    torch.testing.assert_close(y_tl, y, rtol=1e-2, atol=1e-2)
    print("rms_norm_rope_fused: correctness passed")

    kern_q = rms_norm_rope_quant_fused(m, n, rope_dim, dtype="float16")
    y_tl_q, q_tl, scale_tl = kern_q(x, w, cos, sin)
    torch.testing.assert_close(scale_tl, scale, rtol=1e-2, atol=1e-2)
    print("rms_norm_rope_quant_fused: correctness passed")

← 浏览本章全部代码