跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

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

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

02-elementwise/elementwise_variants.py8686 字节
"""第 13 章 · 实战一:逐元素算子的四个版本(需要 NVIDIA GPU + TileLang v0.1.15)。

这四个 kernel 是同一件事的四级实现,用来对照「编译产物里的访存宽度」:

  add_flat          —— 一个 block 处理 threads 个元素,每线程 1 个元素
  add_thread_vec    —— 显式写出「每线程 num_per_thread 个元素」,相邻元素同线程
  add_register_vec  —— 先用 T.copy 把两块数据搬进 fragment,再算,再写回
  scale_cast        —— 乘标量 + dtype 转换(cast 类逐元素算子)
  per_token_cast_fp8—— 「cast」的进阶形态:分组的量化转换(cast 前先做一次归约)

对照的官方示例:
  _vendor/tilelang-v0.1.15/examples/elementwise/example_elementwise_add.py
  _vendor/tilelang-v0.1.15/examples/cast/example_per_token_cast_to_fp8.py

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

from __future__ import annotations

import tilelang
import tilelang.language as T


def _check_1d(n: int, block: int) -> None:
    if n <= 0 or block <= 0:
        raise ValueError(f"n and block must be positive, got n={n}, block={block}")


# ---------------------------------------------------------------------------
# ① 朴素:一整块交给一个 block,每线程一个元素
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[2])
def add_flat(N: int, threads: int = 256, dtype: str = "float32"):
    """y = x + b,每线程 1 个元素。

    这里**故意不写边界判断**:`T.Kernel` 的网格是 `T.ceildiv(N, threads)`,
    尾块越界时由 TileLang 的 safe-memory-legalize 过程插入谓词。代价是向量化
    被谓词挡住(见正文 §5.1)。
    """
    _check_1d(N, threads)

    @T.prim_func
    def main(
        A: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        B: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        C: T.Tensor((N,), dtype),  # type: ignore[valid-type]
    ):
        with T.Kernel(T.ceildiv(N, threads), threads=threads) as bx:
            for i in T.Parallel(threads):
                idx = bx * threads + i
                C[idx] = A[idx] + B[idx]

    return main


# ---------------------------------------------------------------------------
# ② 每线程 NUM_PER_THREAD 个元素,并且明确「相邻元素属于同一线程」
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[2])
def add_thread_vec(N: int, threads: int = 256, num_per_thread: int = 8, dtype: str = "float32"):
    """y = x + b,每线程一串连续的 `num_per_thread` 个元素。

    索引写成 `(bx * threads + i) * num_per_thread + j`:最内层 `j` 连续。
    这样最内层循环可以直接被向量化成一次 128-bit 访存(fp32 是 4 个一组,
    fp16/bf16 是 8 个一组)。这是官方 elementwise 文档推荐的那种「显式告诉
    编译器每线程算几个」的写法。
    """
    block = threads * num_per_thread
    _check_1d(N, block)

    @T.prim_func
    def main(
        A: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        B: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        C: T.Tensor((N,), dtype),  # type: ignore[valid-type]
    ):
        with T.Kernel(T.ceildiv(N, block), threads=threads) as bx:
            for i, j in T.Parallel(threads, num_per_thread):
                idx = (bx * threads + i) * num_per_thread + j
                C[idx] = A[idx] + B[idx]

    return main


# ---------------------------------------------------------------------------
# ③ 用 T.copy 显式分两段:global → fragment,算,fragment → global
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[2])
def add_register_vec(N: int, threads: int = 256, num_per_thread: int = 8, dtype: str = "float32"):
    """y = x + b,先把数据搬进寄存器片段再算。

    要求 `N % (threads * num_per_thread) == 0`:`T.copy` 的 region 是整块搬运,
    这里不做尾块处理,这样能把「纯对齐、可整向量化」这一档单独拿出来看。
    与 ② 的差别是**数据流方向**:② 是边读边算边写,③ 是先把整个 tile 读进
    fragment 再算。当一份输入要被用多次时,③ 省下的就是重复的 global 读。
    """
    block = threads * num_per_thread
    _check_1d(N, block)
    if N % block != 0:
        raise ValueError(f"this variant needs N % (threads * num_per_thread) == 0, got N={N}, block={block}")

    @T.prim_func
    def main(
        A: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        B: T.Tensor((N,), dtype),  # type: ignore[valid-type]
        C: T.Tensor((N,), dtype),  # type: ignore[valid-type]
    ):
        with T.Kernel(N // block, threads=threads) as bx:
            a_frag = T.alloc_fragment((block,), dtype)
            b_frag = T.alloc_fragment((block,), dtype)
            c_frag = T.alloc_fragment((block,), dtype)

            start = bx * block
            T.copy(A[start : start + block], a_frag)
            T.copy(B[start : start + block], b_frag)
            for i in T.Parallel(block):
                c_frag[i] = a_frag[i] + b_frag[i]
            T.copy(c_frag, C[start : start + block])

    return main


# ---------------------------------------------------------------------------
# ④ 乘标量 + dtype 转换:y = cast(x * alpha)
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[2])
def scale_cast(
    M: int,
    N: int,
    alpha: float = 0.5,
    in_dtype: str = "float32",
    out_dtype: str = "float16",
    block_M: int = 8,
    block_N: int = 256,
    threads: int = 128,
):
    """y = cast(x * alpha),二维视角。

    cast 是逐元素的,但**跨 dtype 的 cast 会限制向量宽度**:一次向量化的 cast
    要求源与目标的 lane 数都能被同一个宽度整除。fp32→fp16 时源侧最多 4 lane
    (128-bit / 4 byte),所以这条路径的访存宽度上限比同 dtype 的 add 低。
    """
    if M % block_M != 0 or N % block_N != 0:
        raise ValueError(f"needs M % {block_M} == 0 and N % {block_N} == 0, got M={M}, N={N}")

    @T.prim_func
    def main(
        X: T.Tensor((M, N), in_dtype),  # type: ignore[valid-type]
        Y: T.Tensor((M, N), out_dtype),  # type: ignore[valid-type]
    ):
        with T.Kernel(T.ceildiv(N, block_N), T.ceildiv(M, block_M), threads=threads) as (bx, by):
            for i, j in T.Parallel(block_M, block_N):
                Y[by * block_M + i, bx * block_N + j] = T.Cast(
                    out_dtype, X[by * block_M + i, bx * block_N + j] * alpha
                )

    return main


# ---------------------------------------------------------------------------
# ⑤ per-token / per-group FP8 量化:cast 之前先做一次归约
# ---------------------------------------------------------------------------


@tilelang.jit(out_idx=[1, 2])
def per_token_cast_to_fp8(
    M: int,
    N: int,
    blk_m: int = 8,
    group_size: int = 128,
    threads: int = 128,
    dtype: str = "float32",
):
    """分组量化:y_fp8 = clamp(x / s), s = max(|x|, 1e-4) / 448。

    这是官方 `examples/cast/example_per_token_cast_to_fp8.py` 的结构(去掉了一些
    工程细节):**逐元素算子一旦带上「按组求 amax」就变成「归约 + 逐元素」**,
    这正是第 4 节讲的归约模式,也是把它和后面的归一化放在同一章讲的原因。
    """
    if N % group_size != 0:
        raise ValueError(f"needs N % group_size == 0, got N={N}, group_size={group_size}")
    fp8_min, fp8_max = -448.0, 448.0

    @T.prim_func
    def main(
        X: T.Tensor((M, N), dtype),  # type: ignore[valid-type]
        X_fp8: T.Tensor((M, N), "float8_e4m3fn"),  # type: ignore[valid-type]
        X_scale: T.Tensor((M, T.ceildiv(N, group_size)), dtype),  # type: ignore[valid-type]
    ):
        with T.Kernel(T.ceildiv(M, blk_m), T.ceildiv(N, group_size), threads=threads) as (bx, by):
            x_local = T.alloc_fragment((blk_m, group_size), dtype)
            amax_local = T.alloc_fragment((blk_m,), dtype)
            scale_local = T.alloc_fragment((blk_m,), dtype)
            q_local = T.alloc_fragment((blk_m, group_size), dtype)
            q_fp8 = T.alloc_fragment((blk_m, group_size), "float8_e4m3fn")

            T.copy(
                X[bx * blk_m : (bx + 1) * blk_m, by * group_size : (by + 1) * group_size],
                x_local,
            )
            T.reduce_absmax(x_local, amax_local, dim=1)
            for i in T.Parallel(blk_m):
                amax_local[i] = T.max(amax_local[i], 1e-4)
                scale_local[i] = amax_local[i] / fp8_max
            for i, j in T.Parallel(blk_m, group_size):
                q_local[i, j] = T.clamp(x_local[i, j] / scale_local[i], fp8_min, fp8_max)
            T.copy(q_local, q_fp8)

            for i in T.Parallel(blk_m):
                X_scale[bx * blk_m + i, by] = scale_local[i]
            T.copy(
                q_fp8,
                X_fp8[bx * blk_m : (bx + 1) * blk_m, by * group_size : (by + 1) * group_size],
            )

    return main


if __name__ == "__main__":
    # 只有真正有 GPU + TileLang 时才走到这里;本机从未执行过。
    import torch

    n = 1 << 20
    a = torch.randn(n, dtype=torch.float32, device="cuda")
    b = torch.randn(n, dtype=torch.float32, device="cuda")
    for name, kern in (
        ("add_flat", add_flat(n, threads=256, dtype="float32")),
        ("add_thread_vec", add_thread_vec(n, threads=256, num_per_thread=8, dtype="float32")),
        ("add_register_vec", add_register_vec(n, threads=256, num_per_thread=8, dtype="float32")),
    ):
        out = kern(a, b)
        torch.testing.assert_close(out, a + b, rtol=1e-5, atol=1e-5)
        print(f"{name}: correctness passed")

← 浏览本章全部代码