跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

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

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

03-norm/norm_kernels.py10861 字节
"""第 13 章 · 实战二:LayerNorm / RMSNorm 的三级实现(需要 NVIDIA GPU + TileLang v0.1.15)。

四个 kernel:

  rms_norm_naive_row_per_block —— 一个 block 一行,先读一遍求平方和,再读一遍归一化
  rms_norm                     —— 一行留在 fragment 里,读一遍、写一遍(对照官方示例)
  rms_norm_splitk              —— 行太长放不进 fragment 时分 K 段:两趟读,第二趟倒序走
  layer_norm_fwd               —— 两遍归约(sum 与 sum of squares)的标准 LayerNorm

对照的官方示例:
  _vendor/tilelang-v0.1.15/examples/norm/rms_norm.py
  _vendor/tilelang-v0.1.15/examples/norm/layernorm.py

本文件只做**静态核对**:API 名逐个对照过 `_vendor/`,`python3 -m py_compile` 通过。
**没有在 GPU 上运行过**,不声明任何性能数字。运行方式见同目录 README.md。
"""

from __future__ import annotations

import tilelang
import tilelang.language as T


def torch_reference_rms_norm(x, weight=None, eps: float = 1e-6):
    """PyTorch 参考实现(小数据量下对照用)。

        rstd = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps)
        y = x * rstd * (weight if weight is not None else 1)
    """
    import torch

    rstd = torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + eps)
    y = x.float() * rstd
    if weight is not None:
        y = y * weight.float().unsqueeze(0)
    return y.to(x.dtype)


def torch_reference_layer_norm(x, gamma, beta, eps: float = 1e-5):
    """PyTorch 参考实现:直接调用 F.layer_norm 最省事。"""
    import torch
    import torch.nn.functional as F

    return F.layer_norm(x, (x.shape[-1],), gamma, beta, eps=eps)


# ---------------------------------------------------------------------------
# ① 朴素:一个 block 一行,读两遍 x
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[1])
def rms_norm_naive_row_per_block(N: int, D: int, eps: float = 1e-6, dtype: str = "float32", threads: int = 128):
    """y = x * rsqrt(mean(x^2) + eps)。

    每一行分给一个 block:先把整行读进 shared,求平方和,再把整行读出来归一化。
    x 在账本上被读了**两遍**——这正是 §3.2 里「先能用」的版本,也是优化要打掉的
    第一笔开销。
    """
    if D % threads != 0:
        raise ValueError(f"needs D % threads == 0, got D={D}, threads={threads}")

    @T.prim_func
    def main(X: T.Tensor((N, D), dtype), Y: T.Tensor((N, D), dtype)):  # type: ignore[valid-type]
        with T.Kernel(N, threads=threads) as bx:
            x_sh = T.alloc_shared((D,), dtype)
            sq = T.alloc_fragment((D,), "float32")
            acc = T.alloc_fragment((1,), "float32")

            T.copy(X[bx, :], x_sh)
            for j in T.Parallel(D):
                sq[j] = T.Cast("float32", x_sh[j]) * T.Cast("float32", x_sh[j])
            T.reduce_sum(sq, acc, dim=0)
            for j in T.Parallel(D):
                Y[bx, j] = T.Cast(dtype, T.Cast("float32", x_sh[j]) * T.rsqrt(acc[0] / D + eps))

    return main


# ---------------------------------------------------------------------------
# ② 一行留在寄存器里:读一遍、写一遍
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[1], pass_configs={"tl.disable_tma_lower": True})
def rms_norm(M: int, N: int, blk_m: int = 1, eps: float = 1e-6, dtype: str = "float", threads: int = 128):
    """对照 `examples/norm/rms_norm.py` 的结构:整行放进 fragment,自己乘回自己。

    数据流:`global → fragment`(一遍)→ 平方 → 归约 → 乘 rstd → `fragment → global`。
    中间结果始终在寄存器里,没有第二遍 global 读。

    注意 `pass_configs`:官方示例用 `tl.disable_tma_lower` 关掉 TMA,只让这一对
    普通的 load/store 走 SIMT 路径;v0.1.15 里这个键已经标记为 deprecated
    (`tilelang/transform/pass_config.py:141,352-355`),推荐改成逐次
    `T.copy(..., disable_tma=True)`。
    """
    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), Y: T.Tensor((M, N), dtype)):  # type: ignore[valid-type]
        with T.Kernel(T.ceildiv(M, blk_m), threads=threads) as bx:
            x_local = T.alloc_fragment((blk_m, N), dtype)
            sq_local = T.alloc_fragment((blk_m, N), dtype)
            sumsq = T.alloc_fragment((blk_m,), dtype)

            T.copy(X[bx * blk_m : (bx + 1) * blk_m, :], x_local)
            for i, j in T.Parallel(blk_m, N):
                sq_local[i, j] = x_local[i, j] * x_local[i, j]
            T.reduce_sum(sq_local, sumsq, dim=1)
            for i in T.Parallel(blk_m):
                sumsq[i] = T.rsqrt(sumsq[i] / N + eps)
            for i, j in T.Parallel(blk_m, N):
                x_local[i, j] = x_local[i, j] * sumsq[i]
            T.copy(x_local, Y[bx * blk_m : (bx + 1) * blk_m, :])

    return main


# ---------------------------------------------------------------------------
# ③ 行太长时的分 K 两趟写法
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[1], pass_configs={"tl.disable_tma_lower": True})
def rms_norm_splitk(
    M: int,
    N: int,
    blk_m: int = 1,
    blk_k: int = 512,
    eps: float = 1e-6,
    dtype: str = "float",
    threads: int = 128,
):
    """N 太大、整行放不进 fragment 时:把行切成 `num_k_step` 段。

    结构与 `examples/norm/rms_norm.py` 的 `rms_norm_splitk` 一致:

    * 第一趟只累加平方和(`A_local` 只留当前段的累加结果);
    * 第二趟倒序从最后一段往前处理,刚读过的数据还在 cache 里。

    两趟仍然读了两遍 x,但每趟的 tile 小得多,占用片上存储少;代价是 x 被读两遍。
    什么时候值得:见正文 §3.2「先能用,再用好」里的取舍讨论。
    """
    if N % blk_k != 0:
        raise ValueError(f"needs N % blk_k == 0, got N={N}, blk_k={blk_k}")

    @T.prim_func
    def main(X: T.Tensor((M, N), dtype), 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, blk_k), dtype)
            x_local = T.alloc_fragment((blk_m, blk_k), dtype)
            sumsq = T.alloc_fragment((blk_m,), dtype)
            num_k_step = T.ceildiv(N, blk_k)

            T.clear(x_local)
            for k in T.Serial(num_k_step):
                T.copy(X[bx * blk_m : (bx + 1) * blk_m, k * blk_k : (k + 1) * blk_k], x_sh)
                for i, j in T.Parallel(blk_m, blk_k):
                    x_local[i, j] += x_sh[i, j] * x_sh[i, j]
            T.reduce_sum(x_local, sumsq, dim=1)
            for i in T.Parallel(blk_m):
                sumsq[i] = T.rsqrt(sumsq[i] / N + eps)

            for k in T.Serial(num_k_step):
                # 倒序:上一趟刚读过的尾段还在 cache 里
                T.copy(
                    X[bx * blk_m : (bx + 1) * blk_m, (num_k_step - 1 - k) * blk_k : (num_k_step - k) * blk_k],
                    x_sh,
                )
                for i, j in T.Parallel(blk_m, blk_k):
                    x_sh[i, j] *= sumsq[i]
                T.copy(
                    x_sh,
                    Y[bx * blk_m : (bx + 1) * blk_m, (num_k_step - 1 - k) * blk_k : (num_k_step - k) * blk_k],
                )

    return main


# ---------------------------------------------------------------------------
# ④ LayerNorm:两遍归约 + 仿射
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[-3, -2, -1])
def layer_norm_fwd(
    N: int,
    D: int,
    eps: float = 1e-5,
    blk_m: int = 1,
    threads: int = 256,
    in_dtype: str = "bfloat16",
    out_dtype: str = "bfloat16",
):
    """LayerNorm 前向,结构对照 `examples/norm/layernorm.py`。

    三个输出:`Y`、`Mean`、`Rstd`(反传要用)。用 float32 累加,读完一遍就
    把 mean / rstd 都算出来,全程只读一遍 x。
    """
    if N % blk_m != 0:
        raise ValueError(f"needs N % blk_m == 0, got N={N}, blk_m={blk_m}")
    accum_dtype = "float"

    @T.prim_func
    def main(
        X: T.Tensor((N, D), in_dtype),  # type: ignore[valid-type]
        gamma: T.Tensor((D,), in_dtype),  # type: ignore[valid-type]
        beta: T.Tensor((D,), in_dtype),  # type: ignore[valid-type]
        Y: T.Tensor((N, D), out_dtype),  # type: ignore[valid-type]
        Mean: T.Tensor((N,), accum_dtype),  # type: ignore[valid-type]
        Rstd: T.Tensor((N,), accum_dtype),  # type: ignore[valid-type]
    ):
        with T.Kernel(T.ceildiv(N, blk_m), threads=threads) as bx:
            x_sh = T.alloc_shared((blk_m, D), in_dtype)
            g_sh = T.alloc_shared((D,), in_dtype)
            b_sh = T.alloc_shared((D,), in_dtype)
            x_local = T.alloc_fragment((blk_m, D), accum_dtype)
            xsq_local = T.alloc_fragment((blk_m, D), accum_dtype)
            sum_row = T.alloc_fragment((blk_m,), accum_dtype)
            sumsq_row = T.alloc_fragment((blk_m,), accum_dtype)
            mean_row = T.alloc_fragment((blk_m,), accum_dtype)
            rstd_row = T.alloc_fragment((blk_m,), accum_dtype)

            T.copy(X[bx * blk_m : (bx + 1) * blk_m, :], x_sh)
            T.copy(gamma, g_sh)
            T.copy(beta, b_sh)
            for i, j in T.Parallel(blk_m, D):
                x_local[i, j] = T.Cast(accum_dtype, x_sh[i, j])
            for i, j in T.Parallel(blk_m, D):
                xsq_local[i, j] = x_local[i, j] * x_local[i, j]
            T.reduce_sum(x_local, sum_row, dim=1)
            T.reduce_sum(xsq_local, sumsq_row, dim=1)

            inv_d = T.float32(1.0) / T.Cast(accum_dtype, D)
            for i in T.Parallel(blk_m):
                mean_row[i] = sum_row[i] * inv_d
                rstd_row[i] = T.rsqrt(sumsq_row[i] * inv_d - mean_row[i] * mean_row[i] + T.Cast(accum_dtype, eps))
                Mean[bx * blk_m + i] = mean_row[i]
                Rstd[bx * blk_m + i] = rstd_row[i]

            for i, j in T.Parallel(blk_m, D):
                x_sh[i, j] = T.Cast(
                    out_dtype,
                    (x_local[i, j] - mean_row[i]) * rstd_row[i] * T.Cast(accum_dtype, g_sh[j])
                    + T.Cast(accum_dtype, b_sh[j]),
                )
            T.copy(x_sh, Y[bx * blk_m : (bx + 1) * blk_m, :])

    return main


# ---------------------------------------------------------------------------
# 可选:把它接进 torch.autograd(只给出接线方式,不是完整实现)
# ---------------------------------------------------------------------------


def make_layer_norm_fn():
    """展示如何把一个 TileLang kernel 包成 `torch.autograd.Function`。

    这里故意**不**给出反传 kernel:本章的四个 kernel 都是前向。真正的反传实现见
    `_vendor/tilelang-v0.1.15/examples/norm/layernorm.py` 的 `_layernorm_bwd`
    (它用 `T.atomic_add` 跨 block 累加 dgamma / dbeta)。
    """
    import torch

    class LayerNormFn(torch.autograd.Function):
        @staticmethod
        def forward(ctx, x, gamma, beta, eps):  # type: ignore[override]
            n, d = x.shape
            kernel = layer_norm_fwd(n, d, eps=eps, in_dtype="bfloat16", out_dtype="bfloat16")
            y, mean, rstd = kernel(x, gamma, beta)
            ctx.save_for_backward(x, gamma, mean, rstd)
            ctx.eps = eps
            return y

        @staticmethod
        def backward(ctx, dy):  # type: ignore[override]
            raise NotImplementedError("see examples/norm/layernorm.py for the backward kernel")

    return LayerNormFn


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

    n, d = 1024, 1024
    x = torch.randn(n, d, dtype=torch.float32, device="cuda")
    out = rms_norm(n, d, blk_m=1, dtype="float32")(x)
    torch.testing.assert_close(out, torch_reference_rms_norm(x, eps=1e-6), rtol=1e-2, atol=1e-2)
    print("rms_norm: correctness passed")

← 浏览本章全部代码