定位与全貌

TileLang 解决什么问题,它在 GPU 软件栈里的位置。

TileLang 是一种用 Python 语法写 GPU kernel 的领域专用语言,编译器把 tile 级程序降到 TIRX(TileLang 维护的 TVM TIR 分叉,在 3rdparty/tvm 子模块里),再生成 CUDA、ROCm、Metal 或 LLVM 的设备代码。编程模型只有一句话:程序描述一个 thread block 内对 tile 的搬运与矩阵乘,编译器负责映射到线程、同步和硬件指令。程序员不写线程索引,不插 __syncthreads,不选张量核指令。

适用范围是块级算子:GEMM、dequant GEMM、FlashAttention、线性注意力、MoE。仓库 examples/ 目录里有 DeepSeek MLA、稀疏注意力、block-scaled GEMM 的完整实现。

后端支持

后端target 名硬件状态
NVIDIA CUDAcudaSM70–SM120主力,TMA / WGMMA / TMEM 按架构启用
AMD ROCmhipCDNA / RDNA,含 gfx942、gfx950支持
Apple MetalmetalApple silicon支持
LLVM CPUllvm主机 CPU实验
CuTe DSL / WebGPUcutedsl / webgpuNVIDIA / WebGPU 运行时实验

不指定 target 时按当前环境自动探测 CUDA、HIP、Metal。预编译 wheel 覆盖 Linux x86-64/AArch64、Windows x86-64、macOS arm64。没有 NVIDIA 卡也能学:Mac 上自动走 Metal 后端,CPU 上可显式 target="llvm",前 9 章的代码两种环境都能跑。

定位与选型

2026 年写 GPU kernel 的主流选择有四个,TileLang 的位置:

抽象层级强项代价
Tritonblock 级 DSL生态最大,调试工具链成熟,教程多自定义 layout 与指令级控制弱,backend 基本只有 CUDA
CUTLASS / CuTeC++ 模板库性能上限最高,NVIDIA 官方维护学习曲线陡,编译慢,迭代以小时计
ThunderKittens研究级 tile 库接近手写的性能,论文复现快API 不稳定,无向后兼容承诺
TileLangtile 级 DSLlayout/流水线/同步由编译器推导,Python 迭代速度,多后端快速演进的研究编译器,部分 API 会变(见下表)

控制流主导、单元素级别的逻辑不适合任何 tile 级 DSL,直接写 CUDA 或用 PyTorch 算子。

阅读前提与章节稳定性

阅读前提只有一条:会 Python。不需要 CUDA 经验,第 3 章补齐全部硬件前提。下表标出版本敏感的章节,版本对应 commit 见页脚:

章节稳定性原因
第 1–9 章(入门与 GEMM 主线)稳定核心原语与推导方法变化最慢
第 13、14 章(TMA、warp specialization)随架构演进而扩展Hopper 专属,Blackwell 路径仍在加
第 15 章(手写 MMA)随架构演进每代指令集不同
第 17–20 章(编译管线、pass、后端、carver)最易变pass 名与顺序、注册表是内部实现
第 23 章(昇腾)生态仓追踪核对自独立仓 tilelang-ascend,版本声明过期最快

与手写 CUDA 的分工

手写 CUDA C++TileLang
程序员写什么线程索引、共享内存分配、同步、MMA 指令tile 的 copy 与 gemm、循环结构
编译器做什么nvcc 优化指令级线程映射、layout 推导、流水线、同步、指令选择
输出cubinCUDA C++ 源码 → cubin(可查看生成的源码)

安装与第一个 kernel

装好环境,读懂官方 quickstart 的每一行。

安装

# 终端
pip install tilelang
python -c "import tilelang; print(tilelang.__version__)"

要求 Python ≥ 3.10。运行 kernel 需要 GPU 与 PyTorch:NVIDIA 直接用 CUDA 版 torch;AMD 先装 ROCm 版 torch,tilelang 复用同一 Linux wheel;Mac 上自动走 Metal 后端。编译缓存在 ~/.tilelang/cache。ccache 只加速 tilelang C++ 扩展的构建,JIT 的设备端编译走 nvcc/NVRTC,不经 ccache。

第一个 kernel:向量加法

import torch
import tilelang
import tilelang.language as T


@tilelang.jit
def vector_add(A, B, block_M: int = 128):
    M = T.const("M")
    A: T.Tensor((M,), T.float32)
    B: T.Tensor((M,), T.float32)
    C = T.empty((M,), T.float32)

    with T.Kernel(T.ceildiv(M, block_M), threads=128) as (bx,):
        for i in T.Parallel(block_M):
            C[bx * block_M + i] = A[bx * block_M + i] + B[bx * block_M + i]

    return C


M = 1024
a = torch.randn(M, device="cuda")
b = torch.randn(M, device="cuda")
c = vector_add(a, b)
torch.testing.assert_close(c, a + b)
print("vector add passed.")

逐行

代码含义
@tilelang.jit装饰器。首次调用时按输入形状和编译期参数特化编译,之后按特化缓存复用。
T.const("M")声明符号维度,编译期由实际形状绑定。
A: T.Tensor((M,), ...)参数注解:形状与 dtype,写进编译后函数的签名。
T.Kernel(n, threads=128) as (bx,)启动 n 个 block 的网格,每 block 128 线程,bx 是 block 索引。函数体就是一个 block 的程序,网格里每个 block 跑同一份代码,靠 bx 区分自己负责哪段数据。
for i in T.Parallel(block_M)数据并行循环,128 个迭代点映射到 128 个线程,第 4 章展开。
return C输出张量由 T.empty 在 kernel 内分配,作为返回值交回 Python。

跑起来应看到 vector add passed.。两个动手实验:

  • 把加法改成 ReLU:T.max(A[...] + B[...], 0)
  • 把 block_M 改成 64,网格从 8 个 block 变成 16 个。

M 不整除 block_M 时越界迭代要加守卫:T.if_then_else(idx < M, value, 0.0),第 9 章的 causal mask 用的就是它。

kernel = vector_add.compile(M=1024)
print(kernel.get_kernel_source())   # 生成的 CUDA C++ 源码

第 4 章讲执行模型与存储层级,第 5 章走读 GEMM。

M=1000、block_M=128 时网格有多少个 block?最后一个 block 处理多少个有效元素?

⌈1000/128⌉ = 8 个 block。最后一个 block 处理 1000 − 7×128 = 104 个有效元素,其余 24 个迭代越界,必须用守卫跳过或置零,否则写越界。

GPU 硬件入门

SM、warp、张量核、存储层级、occupancy、合并访存:第 5 章推导需要的全部硬件前提。

层级:grid、block、warp、thread

GPU 是一组 SM 的复制:A100 有 108 个 SM,每个 SM 独立调度自己的线程。kernel 启动时生成一个 grid,里面是若干 block。block 是逻辑概念:kernel 函数体就是一个 block 的程序,shared、barrier 都以 block 为界。硬件按顺序把 block 一个个分发到空闲 SM,一个 block 只在一个 SM 上跑完,不迁移;一个 SM 同时驻留多个 block——驻留不是排队等待,所有驻留 block 的 warp 混在同一个调度池里,每周期被挑中发射,并发执行。

到了 SM 才拆成 warp:32 个线程为一组,锁步执行同一条指令(SIMT),warp 是硬件调度单位。warp 内线程走不同分支时两条路径串行执行,叫分支发散,是 GPU 编程的基本损耗来源。一个 block 的 128 个线程 = 4 个 warp。

图 3.1 · 并行的四层:SM、block、调度器、warp
GPU:108 个 SM 并行 分配 block SM:同时驻留多个 block block A block B 放大一个 warp 32 线程锁步 (SIMT)
并行有四层:108 个 SM 同时跑;一个 SM 同时驻留多个 block(图中画 2 个,A100 上限 32 个);4 个调度器每周期发射 4 个 warp;一个 warp 内 32 线程锁步。一个 block 最多 1024 线程(=32 warp),本教程取 128(=4 warp)。
图 3.2 · 发射与执行:串行发,并发跑
发 w0~w3 发 w4~w7 发 w8~w11 发:4 个 warp / 周期(= 128 线程) 执行:几百周期 / 条 → 第 1 轮没跑完,几十轮已发出 在飞数 = 发射速率 × 延迟 → occupancy
每周期发 4 个 warp(= 128 线程),每条执行几百周期;第 1 轮没跑完,几十轮已发出,任意时刻几十个 warp 同时在飞。block 分配同理:按顺序一个个发,上百个同时在跑。
层级是什么数量关系(A100)
grid一次启动的所有 block由 kernel 的网格决定
block协作线程组,驻留一个 SM每 SM 上限 32 个,实际被四项预算里最紧的卡住(见 occupancy)
warp32 线程,SM 的调度单位128 线程的 block = 4 个 warp;每 SM 最多 64 个 warp
thread持有私有寄存器每线程最多 255 个寄存器

发射与执行:串行发,并发跑

一个 SM 有 4 个 warp 调度器,每周期最多发 4 个 warp = 128 线程。1024 线程的 block = 32 个 warp,发满一轮(给每个 warp 发一条指令)最少 8 个周期;本教程取 128 线程 = 4 个 warp,一轮 1 个周期。

一轮只是一条指令:kernel 有上千条指令,要发成千上万轮。发一条 1 个周期,执行要几十到几百个周期(访存约 500 周期),所以第 1 条还在执行,后面几百条已经发出。在飞数 = 发射速率 × 延迟——GPU 的并行就是用在飞的数量摊薄单个的延迟:block 按顺序一个个发,但任意时刻几十上百个 block 和 warp 同时在跑。要掩盖几百周期的访存,就得有足够多的 warp 驻留,这就是 occupancy(见后)。

存储层级

图 3.3 · A100 的存储层级与一个 SM 的内部结构
SM ×108 warp 调度器 ×4 每周期挑 4 个 warp 发射 CUDA core ×64 标量/向量 FMA 张量核 mma.m16n8k16 寄存器堆 256 KB 65536×4B,线程私有 shared memory ≤164 KB block 内共享,≈180 GB/s,约 30 周期 片外 L2 40 MB · 全片共享 比 HBM 快一个量级 HBM 40–80 GB ≈2 TB/s · 约 500 周期 越靠上容量越小、越快
层级容量带宽 / 延迟谁管
寄存器256 KB / SM单周期访问,喂张量核编译器分配,线程私有
shared memory≤164 KB / SM≈180 GB/s / SM,约 30 周期T.alloc_shared,block 内共享
L240 MB / 全片比 HBM 快一个量级硬件缓存,软件可提示优先级
HBM40–80 GB / 卡≈2 TB/s,约 500 周期kernel 的输入输出住在这里

注意 shared 的带宽口径:180 GB/s 是单个 SM 的,19 TB/s 是 108 个 SM 的全片聚合,两个数差 100 倍,引用时必须带口径。第 5 章算的是单 SM 账,用 180 GB/s 这一档。

张量核与 CUDA core

CUDA core 做标量 FMA,一周期一次。张量核是专用矩阵引擎:A100 的 mma.m16n8k16 一条指令完成 16×8×16 个乘加。整机峰值:fp16 张量核 312 TFLOP/s,fp32 CUDA core 19.5 TFLOP/s,同样的芯片,喂给张量核快 16 倍。这就是 GEMM kernel 追求张量核路径、输入用 fp16/bf16 的原因。tf32、int8 是同一条引擎的其他档位,fp8 要等 Hopper。

occupancy:用并发掩盖延迟

HBM 访问约 500 周期。SM 不等它:一个 warp 等数据时,调度器切到另一个就绪的 warp。能掩盖多少延迟,取决于 SM 上驻留了多少 warp:

资源每 SM 上限(A100)
warp 槽64 个 warp(2048 线程)
block 槽32 个 block
shared memory164 KB
寄存器65536 个 32 位寄存器

occupancy = 实际驻留 warp 数 ÷ 64。驻留数取四项预算里最紧的那个,32 只是 block 数上限;block 形状不一时,大块占完剩下的预算装不下别的形状,会留空洞,装不满。每多驻留一个 block,就多占一份 shared 和寄存器,所以 tile 越大、流水线越深,能同时驻留的 block 越少。第 5 章第 4 步算的就是这笔账。occupancy 够用即可,低到驻留 block 装不满所有 SM 时一定亏。

合并访存

warp 的 32 个线程同一周期各发一个地址,硬件把它们合并成尽量少的 128 B 事务。地址连续:32 个 fp32 = 128 B,一次事务。地址散开:最坏 32 次。合并访存是 GPU 带宽的前提,必须先满足。第 5 章 block_K 取 32 个 fp16(=64 B);carver 搜索从满足合并访存的 64 个 fp16(=128 B)起步,让一次拷贝正好凑满一个事务。

这些概念在第 5 章各就其位:

推导步骤依据的硬件概念
指令形状定粒度张量核
warp 网格定 block 形状warp
block_K 取大合并访存
num_stages 换 occupancy存储层级与延迟隐藏
上限验算shared 与寄存器容量
一个 128×128 的 fp32 C fragment 由 128 个线程分担,每线程平均持有多少个元素?占多少寄存器?离每线程 255 寄存器的上限多远?

128×128÷128 = 128 个元素/线程,fp32 每元素 1 个寄存器,占 128 个。255 − 128 = 127 个寄存器要装下 A/B fragment、循环变量和地址,余量不大——这就是大 tile 会寄存器溢出的原因,第 21 章展开。

执行模型与存储层级

T.Kernel 对应什么,数据放在哪,为什么 fragment 不需要程序员管线程。

网格与线程

GPU 概念规模(典型)TileLang 里是谁
grid几十到几千个 blockT.Kernel(*blocks) 的 extents,1–3 维
thread block≤ 1024 线程threads=,整数或 (x,y,z) 元组,必须为正
warp32 线程,调度单位不显式出现;T.gemm 的 warp 策略由 policy 控制
threadT.Parallel 的迭代点;T.get_thread_bindings() 取索引

核心心智模型:kernel 函数体是一个 block 的程序,网格里每个 block 跑同一份代码,靠 block 索引区分自己负责哪块数据。quickstart 里 by * block_M 是这个 block 负责的 C tile 行起点,bx * block_N 是列起点。

三级存储

层级硬件容量 / SM带宽(量级)生命周期分配 API
globalHBM整卡 40–80 GB≈ 1.5–2 TB/s整个 kernel参数张量 / T.empty
shared片上 SRAM164–228 KB(A100/H100)≈ 180 GB/s / SM(全片聚合 ≈ 19 TB/s)block 内T.alloc_shared
fragment寄存器堆256 KB(65536×4B)单周期访问,带宽远超 shared线程私有T.alloc_fragment

T.alloc_shared((m, n), dtype) 的 scope 是 shared.dyn,动态共享内存。T.alloc_fragment 的 scope 是 local.fragment:它在 IR 里是一整块 tile,编译器的 Layout Inference Pass 推导每个线程持有哪些元素、以什么 stride 访问,程序员面对的始终是整块 tile 的下标。术语对照:CUDA 文档里的 CTA 就是 thread block;prologue/epilogue 指流水线的首尾段,第 6 章。

数据在三级之间的移动由 T.copy 表达:global ↔ shared、shared ↔ fragment、fragment ↔ global 都可以。标准路径里 T.gemm 的输入在 shared、累加器在 fragment;wgmma 的 A 操作数也可以在寄存器,第 9 章 FlashAttention 的第二个 T.gemm(softmax 概率矩阵乘 V)就是这个变体。

一个 (128, 32) 的 fp16 shared tile 占多少字节?只算 shared,A100 一个 SM 最多驻留几个用它的 block?

128×32×2 B = 8 KB。164 ÷ 8 = 20 个(block 槽上限 32,不先卡住)。这就是第 5 章 occupancy 三道门槛里的 shared 那道。

GEMM 走读与参数推导

同一个 kernel:先逐段拆开,再把五个超参数从硬件约束里推出来。

import tilelang
import tilelang.language as T


@tilelang.jit
def matmul(A, B, block_M, block_N, block_K, dtype=T.float16, accum_dtype=T.float32):
    M, N, K = T.const("M, N, K")

    A: T.Tensor((M, K), dtype)
    B: T.Tensor((K, N), dtype)
    C = T.empty((M, N), dtype)

    with T.Kernel(T.ceildiv(N, block_N), T.ceildiv(M, block_M), threads=128) as (bx, by):
        A_shared = T.alloc_shared((block_M, block_K), dtype)
        B_shared = T.alloc_shared((block_K, block_N), dtype)
        C_local = T.alloc_fragment((block_M, block_N), accum_dtype)

        T.clear(C_local)
        for k in T.Pipelined(T.ceildiv(K, block_K), num_stages=3):
            T.copy(A[by * block_M, k * block_K], A_shared)
            T.copy(B[k * block_K, bx * block_N], B_shared)
            T.gemm(A_shared, B_shared, C_local)

        T.copy(C_local, C[by * block_M, bx * block_N])

    return C

结构

网格是 ⌈N/b_N⌉ × ⌈M/b_M⌉ 个 block,bx 索引 N 方向、by 索引 M 方向。每个 block 负责 C 的一个 b_M×b_N tile,K 维切成 ⌈K/b_K⌉ 个条带:循环体三行,搬 A 条带到 shared、搬 B 条带到 shared、做一次 tile 乘积累加;循环结束把 C_local 写回 global。输入 fp16、累加 fp32:张量核 fp16 吞吐最高,fp32 累加保精度。

签名里的 T.constT.Tensor 注解和第 2 章的 vector_add 同义:符号维度编译期绑定,形状与 dtype 写进特化签名,换形状或 dtype 产生新特化。

分块可视化

图 5.1 · C 的每个 tile 由一个 block 计算,K 维条带循环累加
点击 C 中任意 tile 选择一个 block,然后播放 K 循环。

五个参数从哪里来

kernel 签名里有五个编译期参数:block_M、block_N、block_K、threads、num_stages,都能从硬件约束推出来。

1 · 指令形状定最小粒度

A100 的 fp16 张量核指令是 mma.m16n8k16:一条指令算 16×8 的输出块,K 方向一次走 16。编译器把两条拼成 16×16,micro tile 就此定为 16×16×16。block_M、block_N、block_K 都必须是 16 的倍数,int8 输入时 K 方向换成 32。H100 的 wgmma 是 m64n256k16,同一规则,最小粒度大一号。

2 · warp 网格定 block 形状与线程数

一个 warp 32 个线程,GEMM 模板里负责 C 的 64×64 子块。warp tile 取 64×64 是模板约定,让 warp 数接近 sm_partition=4;carver 也会搜其他切法。block 是 warp 的二维网格:2×2 个 warp → block 128×128,threads = 4×32 = 128。threads 必须是 32 的倍数。carver 打分时让 warp 数尽量接近 sm_partition=4,即每 SM 的调度分组数,128 线程正好 4 个 warp。warp 的切法由 GemmWarpPolicy 决定,三档语义见第 15 章表。

3 · block_K 在 shared 预算内取大

block_K 不进 C 的寄存器片段,只花 shared。它越大,K 循环次数越少,流水线首尾损耗摊得越薄。carver 的做法:先取满足合并访存的最小值,即一次 128 B 事务装 64 个 fp16;再在 shared 预算内逐步放大。基础例子取 32,persistent 例子取 64,差别只是预算。

4 · num_stages 用 occupancy 换延迟隐藏

流水线每深一级,A/B 的 shared 占用线性增加。每个 block 的 shared 账单:

$$S = (b_M b_K + b_K b_N) \times 2\,\text{B} \times \text{num\_stages}$$

代入 128×128×32、stages=3:S = 48 KB,A100 每 block 上限 164 KB。每 SM 能驻留的 block 数由三道门槛取小:

$$\text{block\_per\_SM} = \min\!\left(\left\lfloor\frac{2\times\text{smem\_cap}}{S}\right\rfloor,\ \left\lfloor\frac{\text{reg\_cap}}{\text{reg\_usage}}\right\rfloor,\ 4\right)$$

reg_usage 是 carver 的寄存器代理值:2·max(prod(tile)·bits/32),对所有节点取最大,C 累加器是 fp32,主导项是 2·bM·bN = 32768。smem 侧给 6,寄存器侧给 2(65536÷32768),分组封顶 4,取 2。stages 加到 8:S = 128 KB,smem 侧也降到 2,block_per_SM 不变。

取值规则:在 block_per_SM 不低于目标值的前提下尽量深,大 tile 目标取 1、普通取 2。3 是保守默认,工程上对 [2, 3, 4] 实测取最优。

5 · 上限:为什么不直接用 256×256

算术强度随 tile 增大而升高:

$$I = \frac{b_M b_N}{b_M + b_N}\ \text{FLOP/B}$$

128×128 给 64 FLOP/B,256×256 给 128。A100 的 ridge point 约 156 FLOP/B(312 TFLOP 峰值 ÷ 2 TB/s 带宽),这个 kernel 仍受带宽影响,增大 tile 能继续提强度。但大 tile 先撞资源上限:

配置shared 账单寄存器代理值block_per_SM结论
128×128×32,stages=348 KB327682可行域上沿
256×256×64,stages=3192 KB > 164 KB131072 = 全 SM 的两倍0不可行
128×256×64,stages=3144 KB655361persistent 用它换强度

128×128 是可行域的上沿,真正卡住的是寄存器——shared 只用了 48 KB。persistent 例子选 128×256×64,用 occupancy 换 tile 强度(I = 85 对 64),persistent 循环保证 SM 不空转(第 12 章)。

换一台设备,重算一遍

常量变了,数就变了,算法不变:

设备shared / block寄存器 / SMSM 或 CUridge参数怎么变
A100(sm80)164 KB65536108≈156128×128×32,stages=3,threads=128;mma.m16n8k16 加 cp.async
H100(sm90)228 KB65536132≈295同样的 tile 可以加深 stages 或加大 bK;TMA 负责多维异步搬运,第 13 章;wgmma 单指令做到 64×256,warp 分工随之变大
MI300X(gfx942)64 KB32768304≈246LDS 只有 64 KB:128×128×32、stages=3 已占 48 KB,stages=4 即到顶;128×256×32、stages=3 要 72 KB,shared 先超限,寄存器代理值 65536 也超 32768 的上限;gfx942 没有软件流水线,stages 被剥掉
MI350(gfx950)160 KB32768LDS 回到 A100 量级,流水线在 gfx950 上恢复启用

carver 把这张表写进了 arch 对象:CUDA 查驱动拿 shared 容量,reg_cap=65536 写死,CDNA 为 32768。

上面五步在 carver 里对应一个 best-first 搜索:候选是每维的因子加 2 的幂,约束是 shared 与寄存器两道上限,目标函数 (traffic+1)·num_wave。第 20 章拆开它。

参数验算器

图 5.2 · 改任何一个数,看哪条约束先断
真实占用以 Nsight Compute 为准。wave 数按 M=N=K=4096 估算。

参数跑出启动失败或性能异常时,第 22 章的故障手册按症状索引了原因与修法。

为什么 C tile 用 fragment 而不是 shared?

累加器每轮 K 迭代都要被 T.gemm 读写,fragment 在寄存器里,延迟最低;shared 只用于搬运中转。反过来,A/B 条带必须先到 shared,因为 T.gemm 的输入约定在 shared,且 shared 能被 block 内所有线程复用。

在 MI300X 上直接照搬 A100 的 128×128×32、stages=3,要改什么?

能跑,shared 账单 48 KB ≤ 64 KB,但 stages=4 即到顶;想上 128×256×32 会先撞 shared 上限(72 KB > 64 KB),寄存器代理值 65536 也超 32768 的上限;gfx942 上 num_stages 被编译器剥掉,写了也不生效。真正要改的是预期:这台设备的瓶颈在 LDS 容量,与 HBM 带宽无关。

软件流水线

T.Pipelined 做了什么,num_stages 的准确含义,代价是什么。

问题:global → shared 的一次拷贝有几百周期延迟。若循环体严格按拷贝 A、拷贝 B、算的顺序执行,张量核在拷贝期间空闲。

T.Pipelined(extent, num_stages=N) 让编译器把循环体重排:拷贝排在流水线前段、计算排在后段,迭代错位执行——算第 k 块时,第 k+1 到 k+N−1 块已经在搬运。稳态下每轮耗时从 $T_{\text{copy}} + T_{\text{gemm}}$ 降到约 $\max(T_{\text{copy}}, T_{\text{gemm}})$。

num_stages 的准确定义:生产者与消费者之间的 buffer 版本数。默认 0,关闭流水线。手动用 order/stage 调度时,深度由 max(stage)+1 推出。

时间线

图 6.1 · 稳态调度:同一时间槽内,拷贝与计算属于不同迭代

编译器做了什么

两个编译 pass 完成重排。PipelinePlanning 把循环体语句分类:copy 是生产者,排到 stage 0;gemm 等计算是消费者,排到 stage N。InjectSoftwarePipeline 把循环改写成展开的 prologue、稳态循环、展开的 epilogue 三段,跨 stage 的 shared buffer 多版本化。

在 SM80 以上,同 dtype 的 global → shared 拷贝会降成 cp.async 异步指令,配 cp.async.commit_groupcp.async.wait_group<N>;TMA 拷贝走 mbarrier 路径。程序员写的还是三行顺序代码。

代价与约束

说明
shared 内存A/B buffer 按 num_stages 多版本化,深度 ×2 的占用,直接影响 occupancy
循环种类必须是 kSerial 循环;T.Pipelined 产出的就是
可流水语句只有带副作用的语句(copy、fill、gemm、reduce、store、atomic)进流水线;标量 bind 与 buffer 声明不占 stage
依赖方向对同一 buffer,生产者 stage ≤ 消费者 stage
ROCmgfx950 以外的目标会剥掉 num_stages 回退成普通循环
128×128×32 在 A100 上 stages 从 3 加到 8,block_per_SM 变多少?用第 5 章的数字算。

不变。stages=3:S=48 KB,smem 侧给 6,寄存器侧给 2,取 2。stages=8:S=128 KB,smem 侧降到 2,寄存器侧还是 2,取 2。寄存器侧先卡住,这个区间内加深 stages 不再降 occupancy,只赚延迟隐藏。

手算:A100 上 256×128×64、stages=3 可行吗?先断在哪道约束?

smem:3×(256×64 + 64×128)×2B = 144 KB ≤ 164 KB,smem 侧给 2(⌊328/144⌋=2)。寄存器:2×256×128 = 65536,恰好顶到 65536 上限,寄存器侧给 1。occupancy = 1,先断在寄存器这道约束。这个配置把 tile 加大到了极限,换来了更高的单 tile 强度,代价是没有任何占用冗余。

布局与存储体冲突

两种 swizzle 的区别:网格级为 L2,shared 级为 bank。

网格级 swizzle:T.use_swizzle

T.use_swizzle(panel_size=10, enable=True) 改变 block 到 SM 的派发顺序,让相邻 block 复用同一片 L2 缓存数据。它影响的是 grid 调度,不涉及 shared 内存布局。官方 examples/quickstart.py 的注释里提到的就是它。第 21 章的案例会看到:大 GEMM 的 B 面板能驻留 L2,全靠派发顺序保住复用。

Shared 级 swizzle:消除 bank conflict

shared 内存分成 32 个 bank,一个 warp 的 32 个线程同时访问同一 bank 时被串行化。shared 级 swizzle 把元素在 bank 间重排,让 warp 的访问落在不同 bank。它的三档粒度(32B/64B/128B)、XOR 公式、bank 映射对照图和手写时机都在第 16 章。

自动调优

@autotune 的工作方式:搜什么、怎么搜、结果存哪。

调优对象是 kernel 的编译期参数:block_M/N/K、num_stages、threads。这些值改变生成的代码,不改变数学语义,所以可以穷举。

装饰器用法

import itertools
from tilelang.autotuner import autotune


def get_configs():
    iter_params = dict(
        block_M=[64, 128, 256], block_N=[64, 128, 256],
        block_K=[32, 64], num_stages=[0, 1, 2, 3],
        threads=[128, 256],
    )
    return [dict(zip(iter_params, v)) for v in itertools.product(*iter_params.values())]


@autotune(configs=get_configs(), warmup=10, rep=10)
@tilelang.jit
def matmul(A, B, block_M=128, block_N=128, block_K=32, num_stages=3, threads=128):
    ...

configs 是 dict 列表,key 必须与 kernel 的编译期参数一一对应,多余的 key 直接报错。上例是 3×3×2×4×2 = 144 个配置。

网格的取值范围就是第 5 章推导的可行域。

参数取值理由
block_M / block_N16 的倍数在 shared 与寄存器预算内
num_stages0–30/1 是无流水线基线,2/3 常用,再深 occupancy 先断
threads128、256对应 4 与 8 个 warp

可行域之外的配置编译出来也跑不满,搜了也是白搜。

流程

步骤做法
编译每个 config 编译成一个 kernel,线程池并行
计时do_bench(warmup, rep),后端可选 event(默认)、cupti、cudagraph
正确性给了 ref_prog 就逐配置对拍,默认 rtol/atol 1e-2
选优测量延迟最小者胜;early_stop 让估算延迟已超过当前最优值乘以因子的配置跳过大计时

用 carver 替代手写网格

arch = CUDA("cuda") if torch.version.hip is None else CDNA("hip")
template = MatmulTemplate(
    M=M, N=N, K=K,
    in_dtype=T.float16, out_dtype=T.float16, accum_dtype=T.float32,
).with_arch(arch)
hints = template.recommend_hints(topk=20)
configs = [{"block_M": h.block[0], "block_N": h.block[1], ...} for h in hints]

手写网格覆盖广但大部分配置注定无效;carver 先按硬件约束过滤,只给可行域里打分最高的一批。两者产出同一种 dict 列表。

缓存

调优结果有两级缓存:进程内 dict 加磁盘上 ~/.tilelang/cache 的 autotuner 命名空间。key 是 tilelang 版本、函数源码、configs、编译与计时参数的 sha256。同一特化第二次调用直接命中,不重新搜。用了回调形式的 ref/supply/check 程序时缓存关闭,因为回调没有稳定标识。

为什么搜 threads=256 可能比 threads=128 更慢?

threads 影响每 block 的寄存器与 shared 摊薄量,进而影响 occupancy:256 线程可能让每 SM 驻留 block 数下降,wave 数变少,调度器无法掩盖延迟。线程数与 tile 形状、流水线深度耦合,这正是要搜的原因。

给第 2 章的 vector_add 包一层 autotune,搜 block_M ∈ {64, 128, 256},M=1024。你预期谁赢?

大概率打平,或在噪声里交替领先。M=1024 时整个 kernel 的计算量是 1024 次加法,launch 开销和计时噪声主导,三个配置的差异被淹没。这个练习的教训:autotune 的结果要在目标问题规模上看,小问题上的最优配置不可复现,也没有意义。

FlashAttention

在线 softmax 的数学,以及官方 MHA 前向 kernel 怎么实现它。

本章是初级篇密度最高的一章:8 个 fragment、在线 softmax 递推、causal mask。第一遍可以只读递推与步进器,kernel 细节等到第 10 章(归约)之后再回来。

问题

注意力的分数矩阵 $S = QK^\top$ 大小是 seq × seq。序列长到几万时,显式存 S 再做 softmax 的 HBM 读写成为瓶颈。FlashAttention 的做法是不实例化 S:按 K/V 的块流式计算,用在线 softmax 维护递推状态。

在线 softmax

对一行分数,处理到第 $j$ 个块时维护三个量:行最大值 $m^{(j)}$、分母 $\ell^{(j)}$、未归一化输出 $\tilde O^{(j)}$。递推:

$$m^{(j)} = \max\!\bigl(m^{(j-1)},\ \mathrm{rowmax}(S_j)\bigr)$$ $$\ell^{(j)} = e^{(m^{(j-1)}-m^{(j)})/\sqrt{d}}\,\ell^{(j-1)} + \sum_i e^{(S_{j,i}-m^{(j)})/\sqrt{d}}$$ $$\tilde O^{(j)} = e^{(m^{(j-1)}-m^{(j)})/\sqrt{d}}\,\tilde O^{(j-1)} + e^{(S_j-m^{(j)})/\sqrt{d}}\,V_j$$

全部块处理完后归一化:$O = \tilde O^{(J)}/\ell^{(J)}$。新块的最大值可能更大,所以旧结果要乘回缩放因子 $e^{(m^{(j-1)}-m^{(j)})/\sqrt{d}}$。注意 $m$ 是原始分数尺度的最大值,该缩放因子和求和项一样带 $1/\sqrt{d}$。

实现把底数换成 2:$e^{x/\sqrt{d}} = 2^{\alpha x}$,其中 $\alpha = \log_2 e / \sqrt{d} = 1.44269504/\sqrt{d}$,于是递推里所有指数都用 T.exp2(α·) 计算,硬件上比 exp 快。下面 kernel 里的 scale 就是 $\alpha$。

递推过程

图 9.1 · 单查询、4 个键、每块 2 个,逐步看 m、ℓ、Õ 的变化

kernel 结构

@tilelang.jit(out_idx=[3])
def flashattn(batch, heads, seq_len, dim, is_causal,
              block_M=64, block_N=64, num_stages=1, threads=128):
    scale = (1.0 / dim) ** 0.5 * 1.44269504  # 1/sqrt(d) * log2(e)
    shape = [batch, seq_len, heads, dim]
    dtype = T.float16
    accum_dtype = T.float32

    @T.prim_func
    def main(Q: T.Tensor(shape, dtype), K: T.Tensor(shape, dtype),
             V: T.Tensor(shape, dtype), Output: T.Tensor(shape, dtype)):
        with T.Kernel(T.ceildiv(seq_len, block_M), heads, batch, threads=threads) as (bx, by, bz):
            Q_shared = T.alloc_shared([block_M, 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([block_M, dim], dtype)
            acc_s = T.alloc_fragment([block_M, block_N], accum_dtype)
            acc_s_cast = T.alloc_fragment([block_M, block_N], dtype)
            acc_o = T.alloc_fragment([block_M, dim], accum_dtype)
            scores_max = T.alloc_fragment([block_M], accum_dtype)
            scores_max_prev = T.alloc_fragment([block_M], accum_dtype)
            scores_scale = T.alloc_fragment([block_M], accum_dtype)
            scores_sum = T.alloc_fragment([block_M], accum_dtype)
            logsum = T.alloc_fragment([block_M], accum_dtype)

            T.copy(Q[bz, bx * block_M:(bx + 1) * block_M, by, :], Q_shared)
            T.fill(acc_o, 0)
            T.fill(logsum, 0)
            T.fill(scores_max, -T.infinity(accum_dtype))

            loop_range = (
                T.min(T.ceildiv(seq_len, block_N),
                      T.ceildiv((bx + 1) * block_M, block_N))
                if is_causal else T.ceildiv(seq_len, block_N))

            for k in T.Pipelined(loop_range, num_stages=num_stages):
                T.copy(K[bz, k * block_N:(k + 1) * block_N, by, :], K_shared)
                if is_causal:
                    for i, j in T.Parallel(block_M, block_N):
                        acc_s[i, j] = T.if_then_else(
                            bx * block_M + i >= k * block_N + j, 0,
                            -T.infinity(acc_s.dtype))
                T.gemm(Q_shared, K_shared, acc_s, transpose_B=True,
                       policy=T.GemmWarpPolicy.FullRow)

                T.copy(scores_max, scores_max_prev)
                T.fill(scores_max, -T.infinity(accum_dtype))
                T.reduce_max(acc_s, scores_max, dim=1, clear=False)
                for i in T.Parallel(block_M):
                    scores_max[i] = T.max(scores_max[i], scores_max_prev[i])
                for i in T.Parallel(block_M):
                    scores_scale[i] = T.exp2(scores_max_prev[i] * scale - scores_max[i] * scale)
                for i, j in T.Parallel(block_M, 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(block_M):
                    logsum[i] = logsum[i] * scores_scale[i] + scores_sum[i]
                T.copy(acc_s, acc_s_cast)

                for i, j in T.Parallel(block_M, dim):
                    acc_o[i, j] *= scores_scale[i]

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

            for i, j in T.Parallel(block_M, dim):
                acc_o[i, j] /= logsum[i]
            T.copy(acc_o, O_shared)
            T.copy(O_shared, Output[bz, bx * block_M:(bx + 1) * block_M, by, :])

    return main

逐段

上面省略了非因果模式的尾部 mask 分支与 pass_configs,完整代码在 examples/flash_attention/example_mha_fwd_bshd.py。

out_idx=[3] 指定返回值在参数列表里的位置:main 的第 4 个参数 Output(索引 3)是输出,其余是输入,@jit 据此生成调用包装。

代码对应递推式
网格 (⌈seq/b_M⌉, heads, batch)一个 block 负责一个 batch、一个 head、一段 Q 的所有 K/V 块
Q tile 循环外拷贝一次Q 是被遍历的一方,常驻 shared
T.gemm(Q, K, acc_s, transpose_B=True)$S_j = Q_j K_j^\top$,FullRow 让每个 warp 算完整行
reduce_max + scores_scale$m^{(j)}$ 与 $e^{(m^{(j-1)}-m^{(j)})/\sqrt{d}}$
acc_o *= scores_scale 再累加旧输出乘回缩放,再加上 $P_j V_j$
logsum = logsum * scale + sum$\ell^{(j)}$ 递推
结尾 acc_o /= logsum$O = \tilde O / \ell$

所有累加器是 fp32,只有喂给张量核的 P 矩阵 cast 回 fp16。fp16 的最小正规数约 6×10⁻⁵,次正规区间到 6×10⁻⁸ 为止,更小的值在 cast 时变 0,长序列里小概率踩到;对精度敏感的场景保 fp32 P 或换 bf16,第 21 章数值节展开。

causal 模式下循环在对角块截断,对角块内部把严格上三角置 −inf,对角块之后的 K 块根本不访问。

为什么第二个 T.gemm 之前要把 acc_s cast 成 fp16?

张量核的 P·V 路径吃 fp16 输入:Ampere/Hopper 的张量核没有 fp32 输入的 MMA 指令,约束在指令集,fp32 输入只能走 SIMT FMA 回退(或先 cast),慢一个量级。softmax 后的概率值范围在 [0,1],cast 到 fp16 的精度损失通常可接受,而累加仍在 fp32 的 acc_o 里。

把 scale 里的 1.44269504 去掉、T.exp2 换回 T.exp,数学上等价吗?为什么实现不这么写?

等价,差的只是换底公式:2^(1.44269504·x) = e^x。不这么写是因为硬件:GPU 的 SFU 对 exp2 有原生近似指令(ex2.approx),exp 要多步乘法和近似拼出来,在线 softmax 的热循环里每个元素都省一次。

归约、扫描与原子

中级篇起点:跨线程汇总数据的三类原语,各自的语义与代价。

T.reduce_*:tile 级归约

T.reduce_sum / reduce_max / reduce_min(buf, out, dim=-1, clear=True) 是宏,没有返回值,结果写入 out。out 的形状必须是输入去掉 dim 维,或该维保留为 1,不匹配直接 ValueError。clear=True(默认)先把 out 初始化为单位元再归约;clear=False 把结果累加到 out 已有值上。clear=False 在实现上会先把 out 拷进临时 fragment 再归约,因为 warp 级归约的广播语义会让旧值被同 warp 的 32 个线程各加一次,不能直接在 out 上算。batch 参数控制批量 AllReduce:batch>1 时每 batch 个输出共享一对 barrier,barrier 总数降为 1/batch,batch 必须整除每线程输出元素数。

def fragment_reduce(
    x: T.Tensor((rows, width), "float32"),
    out: T.Tensor((rows, width), "float32"),
) -> None:
    with T.Kernel(1, threads=128):
        x_frag = T.alloc_fragment((rows, width), "float32")
        sum_frag = T.alloc_fragment((rows,), "float32")
        for i, j in T.Parallel(rows, width):
            x_frag[i, j] = x[i, j]
        T.reduce_sum(x_frag, sum_frag, dim=1)
        for i, j in T.Parallel(rows, width):
            out[i, j] = x_frag[i, j] / sum_frag[i]

reduce_sum 的 out 形状是输入去掉 dim 维:(rows, width) 去掉 dim=1 得 (rows,)。

warp 级与扫描

原语语义
T.warp_reduce_sum/max/min(v)寄存器级 shuffle 归约:每线程传入一个标量,调用后 warp 内所有线程拿到同一个结果,有返回值
T.cumsum / T.cummax(src, dst=None, reverse=False)前缀扫描;dst=None 原地,dst 形状必须与 src 逐维一致;fragment 输入先 stage 到 shared 再扫再拷回
图 10.1 · warp shuffle 归约:32 个线程的值经 5 轮异或折叠成 1 个(图为 8 线程 3 轮)
线程持有的值
每轮每个线程与相距 2r 的线程交换并合并,r 从 0 到 4。warp_reduce_* 直接发 shuffle 指令,不经过 shared。

原子操作

T.atomic_add / atomic_max / atomic_min(dst, v) 用于多个线程写同一全局地址的场景。标量地址走 extern atomic 路径;带形状的 buffer 走 tile-region 路径,编译器可自动向量化成 atomic_addx2/x4。

return_prev 取回旧值;memory_order 只在 CUDA 上生效。典型需求来自两类 kernel:stream-K GEMM 里多个 split 累加同一 C 元素;attention backward 里多个 query block 累加同一份 dQ/dK/dV。一个 tile 能由一个 block 独立算完时不需要原子,原子是跨 block 聚合的手段。

alloc_reducer:延迟归约句柄

T.alloc_reducer(shape, dtype, op) 声明一个一等归约句柄,op 取 sum/max/min/bitand/bitor/bitxor。三段式使用:reducer_init 开始一轮归约(epoch),循环里 reducer_update(acc[indices], value) 贡献,finalize_reducer(acc, dst) 写出。物理存储与跨线程通信由编译器规划,普通读写、clear、别名在编译期被拒绝。

acc = T.alloc_reducer((rows,), "float32", op="sum")
T.reducer_init(acc)
for i, j in T.Parallel(rows, width):
    T.reducer_update(acc[i], x_frag[i, j])
dst = T.alloc_fragment((rows,), "float32")
T.finalize_reducer(acc, dst)

与 reduce_sum 的区别:reduce_sum 要求输入已经在一个 fragment 里、一次归约完;alloc_reducer 的贡献可以散在多层循环里,最后统一合并,attention backward 里跨 query block 累加 dK/dV 就是这个形状。

warp_reduce_sum 和 reduce_sum 都能求和,什么时候用哪个?

数据已经在每个线程的寄存器里、且只要一个标量结果,用 warp_reduce_sum,一次 shuffle 树完成。数据是 tile 级 buffer、要按某一维归约成一个 out buffer,用 reduce_sum,编译器负责 fragment/shared 之间的搬运和 barrier。前者是指令级原语,后者是 tile 级宏。

动态形状与变长序列

一个编译好的 kernel 服务任意形状,以及 varlen attention 的真实做法。

方式编译行为适用
T.dynamic("m")保留为生成 kernel 的运行时标量参数,一个二进制服务任意值一个 kernel 要跑很多种形状,重编译不可接受
T.const("M") 或普通 int 参数编译期 bake 进 PrimFunc,每个值一个特化、触发一次重编译形状集合小,要全特化的性能

T.dynamic 的机制

T.dynamic("m") 返回一个自由 TIR 变量。它出现在哪里都按普通符号用:张量注解、T.empty、网格 extents、循环边界。调用方从不传它——launch adapter 在每次调用时从实际张量的 shape 或 stride 读出值,追加为 kernel 的隐式参数。读不到来源时抛 TypeError。

@tilelang.jit
def matmul_dynamic_mnk(
    A, B, block_M, block_N, block_K, trans_A, trans_B,
    in_dtype, out_dtype, accum_dtype, num_stages, threads,
):
    M = T.dynamic("m")
    N = T.dynamic("n")
    K = T.dynamic("k")

    A: T.Tensor((M, K), in_dtype)
    B: T.Tensor((K, N), in_dtype)
    C = T.empty((M, N), out_dtype)

    with T.Kernel(T.ceildiv(N, block_N), T.ceildiv(M, block_M), threads=threads) as (bx, by):
        A_shared = T.alloc_shared((block_M, block_K), in_dtype)
        B_shared = T.alloc_shared((block_K, block_N), in_dtype)
        C_local = T.alloc_fragment((block_M, block_N), accum_dtype)
        T.clear(C_local)
        for k in T.Pipelined(T.ceildiv(K, block_K), num_stages=num_stages):
            T.copy(A[by * block_M, k * block_K], A_shared)
            T.copy(B[k * block_K, bx * block_N], B_shared)
            T.gemm(A_shared, B_shared, C_local)
        T.copy(C_local, C[by * block_M, bx * block_N])

    return C

编译一次,之后 M/N/K 任意变化都走同一个二进制。注意 block_M 这些仍是编译期参数——dynamic 的是问题维度,tile 形状保持特化

变长序列:varlen attention

varlen attention 用另一种模式:把变长打包成定长。输入是去掉 padding 的 packed 张量 [UQ, heads, dim],外加两个索引张量 cu_seqlens_q/k(每个请求的起止偏移,shape [batch+1])和一个运行时标量 max_seqlen_q。

网格按 max_seqlen_q 划分,块内从 cu_seqlens 读出当前请求的起止,算出实际长度,越界位置 mask 掉,写回时再按长度守卫。UQ/UKV(打包后的总 token 数)仍然是编译期 bake 的 int 参数。

把 T.const 换成 T.dynamic,性能会变吗?

可能变。T.const 在编译期就是已知整数,循环边界、网格大小、tile 下标全部按常量优化;T.dynamic 是运行时参数,编译器能做的特化更少(例如无法按已知 K 展开 K 循环)。代价换的是不重编译。形状固定且调用频繁时用 const,形状多变时用 dynamic。

Persistent kernel

让固定数量的 block 驻留 SM、循环领取多个 tile,消灭尾部 wave。

问题:wave quantization

普通 kernel 的 grid 等于 tile 总数。SM 以 wave 为单位消化 block:一个 wave 是全机 SM 同时跑一轮。tile 总数不是 SM 整数倍时,最后一个 wave 只有部分 SM 有活,其余空闲。

图 12.1 · 19 个 tile、8 个 SM:第三个 wave 只有 3 个 SM 在干活
真实尺寸同理:M=N=1536、block=128 时 144 个 tile,A100 的 108 个 SM 上第二个 wave 只有 36 个 SM 在干活,整体利用率 67%——tile 总数越少、SM 越多,尾部浪费占比越大。

做法

grid 固定为 SM 数量,每个 block 驻留整个 kernel 生命周期,循环领取多个 tile:

sm_num = driver.get_num_sms()
m_blocks = T.ceildiv(M, block_M)
n_blocks = T.ceildiv(N, block_N)

with T.Kernel(sm_num, threads=threads) as (block_id):
    A_shared = T.alloc_shared((block_M, block_K), dtype)
    B_shared = T.alloc_shared((block_K, block_N), dtype)
    C_local = T.alloc_fragment((block_M, block_N), accum_dtype)
    C_shared = T.alloc_shared((block_M, block_N), dtype)

    for bx, by in T.Persistent([m_blocks, n_blocks], sm_num, block_id):
        T.clear(C_local)
        for k in T.Pipelined(T.ceildiv(K, block_K), num_stages=num_stages):
            T.copy(A[bx * block_M, k * block_K], A_shared)
            T.copy(B[k * block_K, by * block_N], B_shared)
            T.gemm(A_shared, B_shared, C_local)
        T.copy(C_local, C_shared)
        T.copy(C_shared, C[bx * block_M, by * block_N])

T.Persistent([m_blocks, n_blocks], sm_num, block_id) 每轮产出一对 (bx, by)。编译器按 group_size=8 做面板 swizzle 保 L2 局部性,越界迭代由 in_range 守卫跳过。每个 block 处理 ⌈total/SM⌉ 或 ⌊total/SM⌋ 个 tile,负载差最多 1。迭代次数按 8 对齐向上取整,比 ⌈total/SM⌉ 多,多出的迭代是空转守卫,属于预期行为。想精确控制可以走手动路径:wave 循环加 tile_id = sm_num * w + block_id,自己解码 bx/by。

这个例子里 C 经 C_shared 中转写回。这是写法选择:规则允许 fragment 直接 T.copy 到 global,第 2 章 vector_add 和官方 quickstart 都直接写回。中转通常是为了配合向量化拷贝或绕开特定 lowering 限制。

和 split-K / stream-K 的分工

persistent 解决 wave 量化。另一类问题是 K 维很大、M×N 的 tile 数太少,SM 喂不饱:split-K 把 K 维切开分给多个 block 算部分和再原子累加(第 10 章),stream-K 进一步按工作量比例切分消除 split 间的负载差。参考实现是 examples/gemm_splitk 和 examples/gemm_streamk。两者正交,stream-K 的调度网格本身就常做成 persistent。

不想手写 T.Persistent 循环,语言层有封装好的 T.PersistentTileScheduler(tilelang/language/tile_schedule.py:93),面板 swizzle 与越界守卫都在里面。

什么形状下 persistent kernel 收益最大?

tile 总数少、SM 多的时候。大 M、大 N 的 GEMM 动辄几千个 tile,尾部 wave 占比很小;小 batch 的 decode、窄矩阵乘法可能只有几十个 tile,尾部浪费能到 30% 以上。另一个收益场景是 kernel 启动开销占比高的小算子:驻留后启动只发生一次。

手算:4096³ GEMM、block=128、A100 的 108 个 SM,非 persistent 的 wave 利用率是多少?persistent 能提到多少?

grid = 32×32 = 1024 个 tile,⌈1024/108⌉ = 10 个 wave,最后一波只有 52 个 block。利用率 = 1024/(108×10) ≈ 95%。persistent 后 108 个 block 各领 9 或 10 个 tile,负载差 1,利用率接近 100%——只赚约 5 个百分点。

TMA 与异步拷贝

Hopper 之后的搬运引擎,以及三条拷贝路径的选择。

本章的 T.tma_copy 与 mbarrier 是 Hopper(sm_90)及以后特性,Ampere 及更老硬件只有前两条路径。

三条路径

T.copyT.async_copyT.tma_copy
底层指令编译器选:SIMT 循环 / cp.async / TMA仅 cp.async + commit_group仅 TMA(cp.async.bulk.tensor)
同步语义同步,自动插 wait发射后不管,无自动 wait分裂相位:只发 expect_tx + load,用户自管 barrier
multicast不支持不支持cluster_mask 一次读广播到多个 CTA
回退约束不满足时回退普通拷贝;debug 构建打 warning(DLOG),release 无声不适用不可回退,约束不满足直接编译报错
适用默认Ampere,细粒度异步重叠Hopper+,大 tile / 多维 stride / multicast

TMA 胜出的四种情形

  • 大 tile:128×128 fp16 = 32 KB,TMA 由一个 leader 线程发一条指令;cp.async 需要全 warp 逐 16B 发两千多条。
  • 多维 stride:descriptor 编码最多 5 维的 box 形状、stride、swizzle、越界填充;cp.async 只能搬连续段。
  • cluster multicast:一次 DRAM 读广播到 cluster 内多个 CTA 的 shared。
  • 硬件事务计数:mbarrier 的 expect_tx 原生集成,拷贝完成自动 arrive。

同步模型

T.tma_copy 的 load 只发 expect_tx + tma_load,不发 wait,barrier 必填。消费侧用 T.mbarrier_wait_parity 按 parity 等待;store 侧发 tma_store + arrive,攒一批后显式 T.tma_store_wait

图 13.1 · 双缓冲下的 barrier 握手:producer 等 empty、发 full;consumer 等 full、发 empty
箭头是 full barrier 的 arrive→wait:loader 发完 bi 块就 arrive,compute 等到后算 bi 块;同一时刻两组 warp 各干各的。parity 随迭代翻转,同一 mbarrier 交替保护两个 buffer 版本。T.Pipelined 集成时编译器把 barrier 按 stage 多版本化,每 stage 一个 mbarrier。

descriptor 生命周期

用户不创建 descriptor。编译器在 copy lowering 时从 buffer + region 生成,结构相同的 descriptor 做 CSE 合并成一个,每个 kernel 只初始化一次并提升到循环外。循环里直接写 T.tma_copy 即可,外提由编译器完成。

mbars = T.alloc_barrier([128] * 4)   # 两个 full + 两个 empty
# producer 角色(loader warps)
for bi in T.serial(block_count):
    T.mbarrier_wait_parity(mbarrier=mbars[bi % 2 + 2], parity=(((bi & 3) >> 1) ^ 1))
    T.tma_copy(K[bz, by, k:k + block_N, :], K_shared[bi % 2, :, :], barrier=mbars[bi % 2])
    T.mbarrier_arrive(mbarrier=mbars[bi % 2])

# consumer 角色(compute warps)
for bi in T.serial(block_count):
    T.mbarrier_wait_parity(mbarrier=mbars[bi % 2], parity=((bi & 3) >> 1))
    T.gemm(Q_shared, K_shared[bi % 2, :, :], acc_s, transpose_B=True,
           policy=T.GemmWarpPolicy.FullRow)
    T.mbarrier_arrive(mbarrier=mbars[bi % 2 + 2])

节选自 examples/minference/example_vertical_slash_sparse_attn.py,简化为双缓冲 K 搬运:full barrier 表示数据已到,empty barrier 表示 buffer 已用完。

为什么 T.tma_copy 不允许回退,T.copy 却可以?

契约不同。T.tma_copy 的同步是用户自管的:barrier 必填、没有隐式 wait。如果悄悄回退成普通拷贝,用户写的 mbarrier 握手就失去意义,程序会死锁或读到未完成的数据——静默回退比报错危险。T.copy 的语义是完整同步的,用什么指令实现都不改变语义,所以可以自动选路;它的回退提示是 DLOG(WARNING),只在 debug 构建打印,release wheel 里无声,别依赖日志发现回退,要看生成的源码。

Warp specialization

把 block 内的 warp 分成固定角色:一部分专门搬,一部分专门算。

本章的 TMA/mbarrier 路径面向 Hopper 及以后;Ampere 上的 ws 例子用 cp.async 代替 TMA,结构相同。

概念

软件流水线(第 6 章)让同一批 warp 先后做两件事,warp specialization 让两组 warp 在同一时刻分别搬运和计算:loader warp 持续发 TMA/cp.async,compute warp 持续跑张量核,两组通过 mbarrier 握手

图 14.1 · 256 线程分成两个 warp group:ws(1) 搬运,ws(0) 计算
warp group = 128 线程。loader 把 K 维第 ko 块搬进 A_shared[ko%2]、B_shared[ko%2],同时 compute 在算第 ko−1 块。寄存器池共享:loader 用不到的寄存器通过 setmaxnreg 划给 compute。

两种 API

帧 API声明式 schedule API
入口with T.ws(0): / with T.ws(1):算子标 annotations={T.WSID: "id"},再 T.annotate_ws_schedule
角色代码手写每个角色的分支写直线 kernel,WSRole/WSPipeline/WSSync 描述分工
重写者无,就是条件分支MaterializeWSSchedule pass 重写成 per-role 代码

WSPipeline(name, buffers, depth) 是一对 full/empty mbarrier,保护 depth 个多版本 buffer:producer 等 empty、arrive full;consumer 等 full、arrive empty。

WSRole(name, warps_lo, warps_hi, max_nreg) 里的 max_nreg 是寄存器预算:loader 用不到的寄存器通过 setmaxnreg 划给 compute(如 40 vs 224),SM 寄存器池是共享的。num_warps 必须是 4 的倍数,setmaxnreg 按 warp group 生效。

num_stages = 2
with T.Kernel(T.ceildiv(N, block_N), T.ceildiv(M, block_M), threads=256) as (bx, by):
    A_shared = T.alloc_shared((num_stages, block_M, block_K), dtype)
    B_shared = T.alloc_shared((num_stages, block_K, block_N), dtype)
    C_local = T.alloc_fragment((block_M, block_N), accum_dtype)
    mbars = T.alloc_barrier([128, 128] * num_stages)

    for ko in T.Pipelined(T.ceildiv(K, block_K), num_stages=num_stages):
        with T.ws(1):   # loader warps
            T.mbarrier_wait_parity(mbarrier=mbars[ko % num_stages + num_stages],
                                   parity=((ko // num_stages) % num_stages) ^ 1)
            T.tma_copy(A[by * block_M:(by + 1) * block_M, ko * block_K:(ko + 1) * block_K],
                       A_shared[ko % num_stages, :, :], barrier=mbars[ko % num_stages])
            T.tma_copy(B[ko * block_K:(ko + 1) * block_K, bx * block_N:(bx + 1) * block_N],
                       B_shared[ko % num_stages, :, :], barrier=mbars[ko % num_stages])
            T.mbarrier_arrive(mbarrier=mbars[ko % num_stages])
        with T.ws(0):   # compute warps
            T.mbarrier_wait_parity(mbarrier=mbars[ko % num_stages],
                                   parity=(ko // num_stages) % num_stages)
            T.gemm(A_shared[ko % num_stages, :, :], B_shared[ko % num_stages, :, :], C_local)
            T.mbarrier_arrive(mbarrier=mbars[ko % num_stages + num_stages])

    with T.ws(0):
        T.copy(C_local, C[by * block_M, bx * block_N])

K 是符号表达式,进不了 Python range,K 循环用 TIR 层的 T.Pipelined。这里 num_stages 与 buffer 深度一致,角色之间的重叠由 ws 分工提供,循环展开只负责多版本 buffer 的索引节奏。

指令 lowering 分两代:

指令commit
Hopperproducer 用 cp.async.bulk.tensor 带 mbarrier 事务计数;consumer 用 wgmma,T.wgmma_gemm 发射、T.wait_wgmma 配 fence/commit_group 收口——
Blackwell(sm100)换成 tcgen05 指令族(T.tcgen05_gemmtcgen05.commit.mbarrier.arrive

两代的 barrier 握手结构相同,换的是指令。

warp specialization 和第 6 章的软件流水线冲突吗?

不冲突,是两个维度的重叠,且经常叠加。软件流水线让同一批 warp 的迭代 k 与 k+1 重叠;warp specialization 让不同 warp 在同一时刻做搬运和计算。上面的例子就是两者叠加:ws 分角色,角色内部再用 num_stages=2 多版本 buffer。先加流水线,不够再加 ws。

手写 MMA 指令

T.gemm 覆盖不到时,直接操作 ldmatrix / mma / stmatrix。

什么时候需要 drop 到这一层

T.gemm 按架构生成标准路径:Ampere 的 mma.sync、Hopper 的 wgmma、Blackwell 的 tcgen05。需要手写的场景:INT4 等特殊 dtype、非标准累加布局、把多条指令与自定义访存模式交织。代价是线程到 fragment 元素的映射要自己负责(用 emitter 时由它代算,见下)。

例子的结构

参数含义
micro_size_x/y/k16 / 16 / 16单条 mma 指令的微 tile(int8 时 k=32)
warp_row/col_tiles64 / 64每个 warp 负责的 tile 大小
block_row/col_warps2 / 2warp 网格,共 128 线程
block_M/N/K128 / 128 / 32由上面三组推出
图 15.1 · 数据路径:global → shared(swizzle)→ ldmatrix → 寄存器 → mma → stmatrix → shared → global
global A/B shared
swizzle 布局
ldmatrix 寄存器 fragment mma × N stmatrix shared C(4D 微块坐标) global C
A/B 用 T.Parallel 显式搬进 shared,因为要控制 swizzle 布局,T.copy 做不到;C 的 shared 形状是 4 维:(block_M//16, block_N//16, 16, 16),按微块坐标存放。
for ko in T.Pipelined((K // block_K), num_stages=stage):
    for i, k in T.Parallel(block_M, block_K):
        A_shared[i, k] = A[by * block_M + i, ko * block_K + k]
    for j, k in T.Parallel(block_N, block_K):
        B_shared[j, k] = B[bx * block_N + j, ko * block_K + k]

    for ki in T.serial(0, (block_K // micro_size_k)):
        mma_emitter.ldmatrix_a(A_local, A_shared, ki)
        mma_emitter.ldmatrix_b(B_local, B_shared, ki)
        mma_emitter.mma(A_local, B_local, C_local)

mma_emitter.stmatrix(C_local, C_shared)
for i, j in T.Parallel(block_M, block_N):
    C[by * block_M + i, bx * block_N + j] = C_shared[
        i // micro_size_x, j // micro_size_y,
        i % micro_size_x, j % micro_size_y,
    ]

核心循环,节选自 examples/gemm/example_gemm_intrinsics.py。

TensorCoreIntrinEmitter 是代码生成器:ldmatrix_a/b 发 ldmatrix 指令把微 tile 从 shared 拉进寄存器,mma 发 mma.sync,stmatrix 把结果写回 shared。emitter 替你算线程到元素的映射,你只描述 warp 网格和微 tile 大小。ldmatrix 要求 shared 基址 16B 对齐,swizzle 工厂函数生成的布局天然满足,手写 shared 布局时要自己保证这个对齐。

GemmWarpPolicy

语义
Square(默认)warp 按方形网格切分 C tile
FullRow每个 warp 负责完整的若干行——行归约(softmax 的分母)天然落在一个 warp 内,FlashAttention 用它
FullCol每个 warp 负责完整的若干列
C 的 shared 为什么是 4 维形状 (block_M//16, block_N//16, 16, 16)?

因为 stmatrix 一次写一个 16×16 微块,写出的坐标天然分两层:微块网格与微块内偏移,前两维选微块,后两维是微块内的行列。写回 global 时用整除取前两维、取模取后两维(例子最后 4 行)。如果压成 2 维,stmatrix 的写出地址和 swizzle 都得重新手算。

把 warp_row_tiles 从 64 改成 128(其余不变),block_M 和每线程寄存器压力怎么变?

block_M = block_row_warps × warp_row_tiles = 2×128 = 256(原来 128),block_N 不变。每个 warp 的 C fragment 从 64×64 = 4096 元素涨到 128×64 = 8192,寄存器压力翻倍;每 block 的 mma 指令数也翻倍。这就是第 5 章寄存器约束的微观来源。

布局代数与 swizzle 深入

Layout 的 (shape, stride) 代数,swizzle 的三档粒度与自动识别。

Layout 代数

TileLang 的布局对象是 CuTe 风格的 (shape, stride) 对,带一组代数运算:

运算作用
composition两个布局复合,坐标变换嵌套
logical_divide / logical_product把一维逻辑坐标切成多维 / 合并多维
tiled_product / blocked_product按 tile 或 block 组合两个布局
complement求补布局:线程没覆盖到的元素怎么排
right_inverse / left_inverse逆布局,从地址反查坐标

ComposedLayout = swizzle + offset + layout,是带 swizzle 的完整 shared 布局,可与 tilelang Layout 互转。举一个 logical_divide 的具体用途。stmatrix 要求 C 的 shared 按 16×16 微块存放(第 15 章):逻辑坐标 (i, j) 要拆成 (i//16, j//16, i%16, j%16) 四维。手写这个拆分,每个访问点都要重复整除取模;用布局代数,logical_divide 一次把 (block_M, block_N) 布局切成微块网格与微块内坐标两层,映射由布局对象携带,所有访问点共享同一份变换。swizzle、逆布局、补布局同理。

这套代数来自 CuTe(CUTLASS 3.x 的布局系统),TileLang 的 Layout 与 CuTe Layout 同构,CuTe 教程的概念可以直接迁移。

Swizzle 三档

模式事务宽度WGMMA descriptorbase 对齐用途
SWIZZLE_32B32 Blayout_type=3256 B普通访存
SWIZZLE_64B64 Blayout_type=2512 BWGMMA
SWIZZLE_128B128 Blayout_type=11024 BTMA

XOR 本身是同一族:把行组位异或进列位,区别只是一次事务覆盖多宽。工厂函数对应三档:make_quarter/half/full_bank_swizzled_layout,另有 make_swizzled_layout(通用,TMA-aware)和 volta/wgmma/tcgen05 专用变体。

图 16.1 · 8×8 tile 的 bank 映射:swizzle 前同列同 bank,swizzle 后每行错开
颜色是 bank 编号。左:一个 warp 访问一列时 8 个线程挤在同一个 bank,串行 8 次。右:xor8x8 后同一列落在 8 个不同 bank,一次事务完成。

自动识别与手写时机

DetectSwizzleMode 从结构上识别一个布局属于哪档 swizzle(条件:stride % 8 == 0 加对应连续性整除)。TMA/WGMMA descriptor 生成靠它决定硬件 swizzle 字段——你注解的布局会被编译器反查出来,不需要再声明一次模式

走 T.gemm 标准路径时布局由编译器按架构自动选(Ampere、Hopper、SM100 各有实现)。需要手写的场景:手写 ldmatrix(第 15 章)、转置访存、TMA 搬运的目标布局。

def make_swizzle_layout(shared_buf):
    dtype = shared_buf.dtype
    shape = shared_buf.shape

    can_swizzle = shape[-1] * DataType(dtype).bits == 512
    if not can_swizzle:
        return T.Layout(shape, lambda *args: args)

    def transform_func(i, j):
        return list(get_swizzle_layout(i, j, shape[-1], dtype))

    return T.Layout(shape, transform_func)

# 用法
T.annotate_layout({
    A_shared: make_swizzle_layout(A_shared),
    B_shared: make_swizzle_layout(B_shared),
})

条件 shape[-1] * bits == 512:行宽恰好 512 bit(32 个 fp16)时套 64B swizzle 的 8×4 模式;行宽 ≥128 B 才够 128B 档;不满足条件退化成恒等布局。

swizzle 让访问变快了,为什么不默认给所有 shared buffer 都开?

两个原因。第一,标准 swizzle 模板只覆盖行宽 512 bit 的 tile(上面代码的 can_swizzle 条件),其他形状套不上。第二,swizzle 是为 GEMM 的 ldmatrix 与列访问设计的;归约、转置、逐元素 kernel 里,访问模式本来就不撞 bank,套 swizzle 只多做地址计算、没有收益。编译器按已知访问模式选布局,访问模式未知时恒等布局是安全默认。

编译流水线

从 PrimFunc 到 cubin 经过了什么,每个 pass 负责什么。

总览

图 17.1 · 编译阶段与每阶段的关键 pass
Python 函数 PrimFunc (TIRX) PreLowerSemanticCheck PipelinePlanningInjectSoftwarePipelineLayoutInferenceLowerTileOp 存储/循环 lowerSplitHostDevice device IR codegen CUDA C++ nvcc / NVRTC cubin
host/device 拆分是物理的:calling_conv == DEVICE_KERNEL_LAUNCH 的函数进 device 部分,其余进 host。

关键 pass

pass职责
PreLowerSemanticCheck后端无关校验:嵌套循环、Parallel 内局部索引、fragment 循环三类检查器
PipelinePlanning + InjectSoftwarePipeline第 6 章的流水线重排,在 layout 推导之前完成
LayoutInference推导每个 fragment 的逐线程布局,是 TileLang 的核心 pass
LowerTileOptile 级算子降成内建函数:T.copy 选 SIMT/cp.async/TMA 路径,T.gemm 选 mma/wgmma/tcgen05 路径
LowerHopperIntrinTMA descriptor 生成与 CSE;mbarrier 多版本化是另一个 pass(MultiVersionBufferRewriter)的事
PlanAndUpdateBufferAllocationLocation / StorageRewritebuffer 作用域规划与存储重写
SplitHostDevice + LowerDeviceKernelLaunch按 calling_conv 拆分,给 kernel 函数盖 DEVICE_KERNEL_LAUNCH 章
LowerIntrin / HoistBroadcastValuesdevice 侧 codegen 前的最后清理

两个设计事实

路径选择在 pipeline 阶段完成,codegen 只负责打印。T.gemm 变成 wgmma 还是 mma,由 LowerTileOp 及之前的 pass 决定;codegen 把内建函数映射成 tl:: 模板调用(如 ptx_cp_async → tl::cp_async_gs<N>ptx_wgmma_ss → tl::wgmma_ss<...>),按用到的指令族包含对应模板头。

pass 顺序是显式的。CUDA 流水线是 tilelang/cuda/pipeline.py 里一个 Python 函数,每个 pass 的调用顺序可读、可单步。加新 pass 就是在这个函数里插一行。

编译缓存

@jit 的编译产物按源码与编译参数的哈希缓存到 ~/.tilelang/cache(环境变量 TILELANG_CACHE_DIR 可改),同一 kernel 第二次调用直接加载 cubin。改源码或换 target 会换哈希,不用手动清;怀疑缓存损坏时删目录即可。

想确认 T.gemm 被降成了 wgmma 还是 mma,看哪里?

两个地方。快速确认看 get_kernel_source() 的 CUDA 源码:wgmma 会出现 tl::wgmma_ss 模板调用。要定位是哪个 pass 做的选择,看 LowerTileOp 之后的 IR(第 18 章的 IR Lower Trace),那里 tile op 已经变成内建函数,指令族已确定。

Pass 工具箱与 IR 调试

编译器行为不符合预期时,按什么顺序查。

工具

工具用法看什么
IR dumppass_configs={tilelang.PassConfigKey.TL_ENABLE_DUMP_IR: True, tilelang.PassConfigKey.TL_DUMP_IR_DIR: "./dump_ir"}每个 pass 前后的完整 TIR
IR Lower Trace环境变量 TL_LOWER_TRACE=terminal|html|both每次编译的 per-pass 前后对比 + codegen 结果,按会话组织
Pass Visualizerpython -m tilelang.tools.pass_visualizer.viewer kernel.py --out report.html每个 pass 的 SBlock 结构树(读写、alloc、注解),自包含 HTML
pass 计时tilelang.PassConfigKey.TL_PASS_PROFILE: True编译时间花在哪个 pass
AST 打印tilelang.PassConfigKey.TL_AST_PRINT_ENABLE: TruePreLowerSemanticCheck 阶段的 TIR AST
布局可视化tilelang.PassConfigKey.TL_LAYOUT_VISUALIZATION_ENABLE: TrueLayoutInference 推出的 fragment 布局图
源码拦截register_cuda_postproc_callback截获最终 CUDA 源码字符串,程序化检查
T.printkernel 内 T.print(...)运行时设备端值

pass_configs 是正式入口

开关通过 @jit 的 pass_configs 参数传入:PassConfigKey → 值的字典,编译时读取,不依赖全局状态。开关和 kernel 定义写在一起,复现实验时不会漏掉环境差异。完整键列表在 tilelang/transform/pass_config.py。

调试顺序

  1. 正确性:先 assert_allclose 对拍参考实现,确认是数值错还是编译错。
  2. 生成代码:get_kernel_source() 看 CUDA 源码。没有 cp.async → LowerTileOp 的注入条件没满足(global→shared、同 dtype、SM80+);指令族不对 → 看 T.gemm 的 policy 与架构。
  3. 定位 pass:TL_LOWER_TRACE=html 拿到每个 pass 的前后 IR,二分找到第一个改坏的 pass。
  4. 运行时值:编译产物看起来对但结果错,T.print 打印中间 fragment。
  5. 编译慢:pass_profile 找耗时 pass,常见嫌疑是 UnrollLoop 和 LayoutInference。
@tilelang.jit(pass_configs={
    tilelang.PassConfigKey.TL_ENABLE_DUMP_IR: True,
    tilelang.PassConfigKey.TL_DUMP_IR_DIR: "./dump_ir",
})
def matmul(...):
    ...

kernel = matmul.compile(M=1024, N=1024, K=1024,
                        block_M=128, block_N=128, block_K=32)
# ./dump_ir 下每个 pass 前后各一份 TIR 文本

两个真实调试路径

pass 二分:生成的 CUDA 源码里 T.gemm 降成了 mma 而机器是 H100。TL_LOWER_TRACE=html 拿到每个 pass 前后的 IR,从后往前二分:LowerTileOp 之后的 IR 里已经是 mma 内建函数,说明选择发生在这个 pass;再查它的输入 target,arch 停在 sm_80——target 检测没拿到 GPU。修 target 标记,wgmma 出现。

自动最小化:编译器在某个 pass 崩溃时,手工缩 kernel 很慢。tilelang/autodd.py 是仓库自带的 delta 调试器:喂一个触发报错的 Python 源文件,它自动删减语句直到剩下最小触发例,__freeze__ 上下文可以保护不能删的关键行。examples/autodd/ 里有现成例子(tilelang_buggy.py 是触发例,tilelang_minimized_expected.py 是期望输出)。

编译器在某个 pass 崩溃,手工缩 kernel 很慢,仓库里有什么工具?

tilelang/autodd.py 是 delta 调试器:喂一个触发报错的 Python 源文件,自动删减语句到最小触发例,__freeze__ 上下文保护不能删的关键行。

后端与 codegen

后端注册表怎么工作,CUDA codegen 实际做什么,Metal/LLVM 的差异。

注册表模型

图 19.1 · target 字符串到 codegen 的解析路径
target="cuda" determine_target 按 target.kind 查索引
多个后端共享 kind 时用 supports_target 谓词
BackendModule PassPipelineDeviceCodegenexecution backends
CUDA 与 CuTeDSL 都注册了 cuda kind,靠谓词区分;解析到 0 个或多个后端直接报错。BackendContext 不可变,驱动 lower/codegen 全程。

CUDA codegen 做什么

CodeGenTileLangCUDA 是一个打印器:VisitExpr 遇到内建函数调用就映射成 tl:: 模板调用,映射表是一张大的 case 分支。Finish() 阶段按用到的指令族(need_wgmma_instruction_h_、need_tcgen05mma_instruction_h_ 等旗标)包含对应的模板头文件。

生成的 CUDA C++ 经 nvcc(-std=c++20,带模板与 CUTLASS 头路径)编成 cubin,二进制本身还有一层 CUDABinaryCache。

内建函数打印成
ptx_cp_asynctl::cp_async_gs<N>
ptx_wgmma_ss / rstl::wgmma_ss<AType,BType,CType,M,N,K,...>
ptx_tcgen05_mma_ss / tstl::tcgen05mma_ss / tcgen05mma_ws_ss

其他后端

后端codegen 方式
Metal直接发 MSL:simdgroup_matrix 与 Metal 4 cooperative tensor 两条路径,无模板头中间层
LLVM复用上游 TVM 的 target.build.llvm;c kind 用自研 C 源码 codegen
CuTe DSL与 CUDA 共享 kind,走 NVIDIA CUTLASS CuTe DSL

什么代码能跨后端

跨后端的边界画在内建函数上。只用 T.copy、T.gemm、T.reduce_* 这类标准内建函数的 kernel 可以跨 CUDA/ROCm/Metal:每个后端的 pipeline 负责把同一个内建函数降成自己的指令。手写 ptx_* 内建函数(第 15 章)、T.tma_copy、wgmma/tcgen05 这类指令级原语把 kernel 钉死在 CUDA 上。判断标准具体可查:kernel 里出现的每个内建函数,目标后端的 pipeline 有没有对应 lowering——没有就编译报错,不会静默生成错代码。

加一个后端

契约是一个 BackendModule:声明拥有的 target_kinds、每个 kind 一个 PassPipeline 和一个 DeviceCodegen、执行后端策略。在 tilelang/backend/ 注册后,前端 DSL 与大部分 pass 不用改——后端差异被压缩在 pipeline 尾部的内建函数选择和 codegen 的打印表里。仓库的 .agents/skills 里有 backend integration skill 描述完整流程。

生成的 CUDA 源码里出现了一个不存在的 tl:: 模板,bug 在哪一层?

在 codegen 的打印表,两种具体可能。一是 pipeline 发的内建函数名和 codegen 的 case 分支对不上——映射表是手写的大分支,加内建函数时漏改一边就会打出未定义符号。二是模板头文件没被包含:Finish() 按 need_wgmma_instruction_h_ 这类旗标决定 include 哪个头,旗标没置上,模板声明就不在编译单元里。查映射表和头文件包含两处,不用怀疑 nvcc 之前的 pass。

Carver 内部

tile 形状推荐器的搜索算法:三个角色、一个目标函数、两道约束。

三个角色

角色实现职责
ArchTileDevice(CUDA/CDNA/RDNA/CPU)硬件常量:smem 容量、reg_cap(CUDA 65536,CDNA/RDNA 32768)、SM 数、事务宽度、带宽
TemplateBaseTemplate 子类(Matmul/GEMV/FlashAttention/...)把算子建成 PrimFunc 节点 DAG,提供 get_hardware_aware_configs
PolicyDefaultPolicy / TensorCorePolicy搜 tile 形状;TensorCorePolicy 覆盖 mma/wmma 的推理与打分

搜索算法

图 20.1 · best-first 搜索循环
基础 tile
最小冗余
取队首 每维扩展到下一个因子 smem_cost ≤ smem_cap ?reg_usage ≤ reg_cap ? 打分入队
prio=(traffic+1)·num_wave
候选 = 每维的因子加 2 的幂;优先队列按 prio 升序;访问 2000 个 tile 封顶。

目标函数与约束

traffic 是单输出 tile 的 DRAM 字节估计:沿节点 DAG 传播输入 tile,全局读写按事务粒度向上取整。num_wave = ⌈grid 大小 / (block_per_SM × SM 数)⌉。乘积估计全 kernel 的 DRAM 流量。

两道约束:smem_cost 由 BestFit 分配器在节点 DAG 上模拟(buffer 在最后消费者之后释放),不超 smem_cap;reg_usage = 2·max(prod(tile)·bits/32),不超 65536。

bits 取节点自身 dtype,2 是 default.py:564 的硬编码系数:本章 GEMM 用 fp32 累加器,代入 bits=32 得 2·bM·bN;carver 默认模板 accum_dtype=float16,代理值减半。occupancy 是三者取小:

$$\text{block\_per\_SM} = \min\!\left(\left\lfloor\frac{\text{max\_smem\_usage}}{\text{smem\_cost}}\right\rfloor,\ \left\lfloor\frac{\text{reg\_cap}}{\text{reg\_usage}}\right\rfloor,\ 4\right)$$

输出 Hint 包含 block/thread/warp 划分、pipeline_stage、vectorize 等字段,可直接映射成 autotune 的 config dict。

这套搜索是第 5 章手算推导的自动化:候选生成对应第 1、2 步的粒度约束,两道硬约束对应第 4、5 步的容量验算,目标函数里的 num_wave 对应 occupancy 与 wave 数。手算能判断一个配置可不可行,carver 能在可行域里批量打分,autotune 最后实测选优。

加一个模板

BaseTemplate 子类加 dataclass 字段装算子参数,实现 initialize_function 建 PrimFunc,get_hardware_aware_configs 通常一行调 get_roller_hints_from_func,在 carver/template/__init__.py 导出。张量核算子还要经 matmul_analysis 打 tensorcore_config 标签,Policy 才会选 TensorCorePolicy。

carver 是独立的:JIT 不调用 carver,只有 L2 缓存大小查询用了 carver.arch.driver。推荐结果要经过 autotune 实测才作数。

目标函数为什么是 (traffic+1)·num_wave,而不是直接最小化 traffic?

单看 traffic 会选偏大的 tile:tile 越大重复搬运越少,但 grid 里 block 数变少,wave 数上升,尾部浪费和调度灵活性变差。单看 num_wave 会选超大 tile,smem/reg 直接超限。乘积同时惩罚两者:traffic 估单 tile 成本,num_wave 估全机利用。+1 让零流量 tile(纯计算)的 prio 不为 0,不至于被最小堆排到队首。

性能取证

一个慢 kernel 怎么归因:三层假设、每层的测量工具、一个算给你看的案例。

三层归因

性能问题只有三个可能的来源,按从粗到细的顺序排除:

问题判据
算法层算术强度低于 ridge point,kernel 受带宽限制第 5 章的 I 公式对照设备 ridge
占用层SM 没填满:occupancy 低或 wave 数不是整数第 5 章验算器输出的 block_per_SM 与 waves
微结构层SM 填满了但单 SM 效率低:bank conflict、寄存器溢出、访存喂不饱张量核Nsight Compute 的指标

算法层不对,微结构层怎么调都到不了峰值;占用层不满,先补 occupancy;前两层都过了才进微结构层。

测量工具

工具给什么什么时候用
do_bench(backend="event")CUDA event 计时,默认快速对比配置,autotune 用它
do_bench(backend="cupti")CUPTI 内核级计时,剥掉 launch 开销小 kernel 的 event 计时被 launch 干扰时
do_bench(backend="cudagraph")CUDA Graph 重放计时,带分位数消除 launch 抖动,测稳态延迟
Nsight Computeoccupancy、bank conflict、寄存器溢出、warp stall 原因微结构层归因
get_kernel_source()生成的 CUDA 源码确认指令族;出现 STL/LDL 到 local 即寄存器溢出
TL_LOWER_TRACE=html每个 pass 前后的 IR怀疑编译器选错路径时,第 18 章

ncu 实操

微结构层归因的标准动作:ncu --set full -o report python bench.py 采一次,GUI 里按顺序看三页。

ncu 页看什么
Speed Of LightSM 利用率与内存利用率谁低,决定往哪个方向挖
Warp Statestall 原因分布:Long Scoreboard 等访存、Short Scoreboard 等寄存器依赖、Barrier 等同步
Source指标落到具体源码行,溢出和 bank conflict 在这定位

命令行快速看 occupancy:ncu --metrics sm__warps_active.avg.pct_of_peak_sustained_active python bench.py

两个高频微结构问题

寄存器溢出。fragment 超过寄存器预算时,编译器把溢出部分放到 local memory,片上延迟变成 HBM 延迟。症状是 ncu 的 Register Spill Stores/Loads 非零。修法:减 block_M×block_N,C 片段是最大头;加 threads 摊薄每线程片段但降 occupancy。减 num_stages 不解决溢出:stages 花的是 shared,溢出发生在寄存器

wave 量化。wave 数不是整数时,最后一个 wave 的 SM 空转,第 12 章的 persistent kernel 消掉的就是这个尾部。

L2 命中靠 T.use_swizzle 改派发顺序,第 7 章;Hopper 上还有 cluster,多个 SM 共享一次 HBM 读取。cluster 用 T.ClusterKernel(cluster_dims=(m, n)) 声明,与 T.Kernel 是两个入口;跨 SM 的 shared 访问走 cluster barrier。

案例:4096³ fp16 在 A100 上

总计算量 2×4096³ = 1.37×10¹¹ FLOP,峰值时间 0.44 ms。整体强度 1365 FLOP/B,远在 ridge 156 之上;但单 tile 强度只有 64 FLOP/B,按这个数 kernel 永远受带宽限制,这与 cuBLAS 级实现能到峰值 65–90% 矛盾。

差在 L2 复用:B 的一个面板 4096×128×2B = 1 MB,A100 L2 有 40 MB,同一块面板被沿 M 方向的 32 个 block 重复读取,除第一次外都命中 L2;A 面板沿 N 方向同理。理想复用下总流量趋近 3×4096²×2B = 96 MB,按 tile 各读一次记账是 2 GB,差 21 倍,真实命中在两者之间,由 L2 容量和派发顺序决定。

tile 强度只是下界,越过 ridge 靠 L2 复用:它假设每次片外读取都到 HBM,越过 ridge 需要的复用增益只有 156/64 ≈ 2.4 倍,相当于 32 个读取者里约 19 个命中 L2。优化顺序是先保 L2 命中——swizzle 派发(第 7 章)、persistent(第 12 章)、cluster——再谈增大 tile。

Roofline 计算器

图 21.1 · 输入实测延迟,看自己在 roofline 的哪一段

数值

性能取证偶尔会和数值问题纠缠。fp16 路径的精度损失有两类:第 9 章的 P cast 下溢是一类,另一类是累加顺序——归约树的结合顺序不同舍入就不同,对拍时 10⁻² 量级的相对差不代表 kernel 错。敏感场景把累加器保 fp32(TileLang 的 accum_dtype 默认就是 fp32),或自己实现补偿求和。

判断数值问题还是编译问题有个粗判据:同一 kernel 对 fp32 参考实现,误差随序列长度线性增长是舍入,阶跃式出错是编译或索引 bug

T.reduce_* 的 nan_propagate 参数控制 NaN 传播语义,对拍时两边要设成一致。

ncu 显示 occupancy 100%,性能只有峰值的 40%,先查什么?

微结构层。occupancy 满只说明 SM 上驻留了足够的 block,不说明 SM 在干活。查 warp stall 的原因分布:bank conflict、寄存器溢出、张量核占比低说明被访存喂不饱。算法层和占用层已经排除了。

4096³ fp16 在 A100 上实测 0.6 ms,用图 21.1 的计算器算峰值占比,判断该先查哪层。

2×4096³/0.6 ms ≈ 229 TFLOP/s,是 312 峰值的 73%,已进峰值区间。tile 强度 64 低于 ridge 156,但 B 面板 1 MB 能放进 40 MB L2,复用足够越过 ridge——所以算法层不是瓶颈,occupancy 若也正常,剩余差距查微结构层:warp stall 分布、张量核占比。

CUDA 硬件故障手册

按报错信息和性能症状索引:每条给出原因和修法。

编译与启动期报错

症状原因修法
FATAL: Invalid TMA descriptor argumentsTMA 描述符参数不合法:形状秩不匹配、基址未按 16B 对齐、swizzle 与行宽冲突查 global tensor 最后一维对齐;T.tma_copy 的约束失败是致命的,设计上不回退普通拷贝
FATAL: T.tma_copy() cannot fall back to normal copytma_copy 强制走 TMA,设备或形状不满足约束改用 T.copy,让编译器按架构自动选 TMA、cp.async 或普通拷贝
FATAL: T.wgmma_gemm() requires Hopper WGMMA loweringtarget arch 低于 sm90,或 dtype、形状不满足 wgmma 约束查 target 检测是否落到默认 arch;A100 用 mma 路径
启动失败:too many resources requested每 block 寄存器超 255/线程,或 shared 超设备 opt-in 上限减 tile;shared 用 shared.dyn 作用域走动态上限
生成代码里没有 cp.async注入条件没满足:非 global→shared、dtype 不一致、arch 低于 sm80对照第 6 章的三个条件逐条查

前三条 FATAL 的出处:src/cuda/runtime.cc:544、src/cuda/op/copy.cc:2125、src/cuda/op/gemm.cc:147,报错文本以仓库为准。

结果错误(不报错)

症状原因修法
对拍误差随尺寸线性增长fp16 舍入累积累加器保 fp32;第 21 章数值节
误差阶跃式出现或大面积错索引错:dynamic 维度守卫缺失、把符号值当 Python int 用T.print 打块坐标和下标;先在小尺寸上对拍
只有部分形状错尾块:M/N/K 不被 tile 整除时越界迭代没 mask查 T.Persistent 的 in_range 守卫与 T.Parallel 边界
多次运行结果不一致原子加顺序非确定,或累加器没初始化T.clear 累加器;原子归约的逐次差异是预期行为

性能症状

症状原因修法
ncu: Bank Conflicts 高warp 内线程撞同一 bank给 shared buffer 注解 swizzle 布局,第 7、16 章;走 T.gemm 标准路径时编译器自动选
ncu: Spill Stores/Loads 非零fragment 超寄存器预算减 block_M×block_N;减 stages 没用,理由见第 21 章
ncu: Occupancy 低三道门槛之一卡住用第 5 章验算器定位是 shared、寄存器还是分组卡住
性能随矩阵尺寸周期性掉坑wave 量化persistent kernel,第 12 章;或调 tile 让 wave 接近整数
L2 Hit Rate 低block 派发顺序与数据复用不匹配T.use_swizzle(panel_size=10);Hopper 用 cluster 让多 SM 共享读取
小 kernel 计时波动大launch 开销淹没执行时间do_bench(backend="cudagraph")

架构代际差异

架构指令路径注意
sm70/sm75(V100/T4)mma 或 FMA 回退无 cp.async,流水线靠普通拷贝;部分 dtype 组合不支持张量核
sm80/sm89(A100/Ada)mma + cp.async本教程大部分例子的默认假设
sm90(H100)wgmma + TMA,可加 warp specializationshared 上限 228 KB;wgmma 要求 target 带 sm_90 标记
sm100(B200)tcgen05 + TMEMtilelang/cuda/op/gemm/gemm_tcgen05.py,2SM 协作 kernel
sm120(Blackwell 消费级)gemm_mma_sm120 专用路径指令集与 sm100 不同

2:4 稀疏张量核

T.gemm_sp 走 2:4 结构化稀疏路径:权重每 4 个元素至少 2 个为零,配 metadata 描述非零位置,张量核吞吐翻倍。约束在数据侧:权重必须先压缩成 2:4 结构并生成 metadata,普通稠密矩阵用不了。

在 MI300X 上 kernel 结果正确,但把 block_K 从 32 加到 64 就启动失败,最可能的原因?

shared 超限。block_K 加倍让 A/B buffer 翻倍:128×128×64、stages=3 要 96 KB,MI300X 的 LDS 只有 64 KB。用第 5 章验算器代入新参数看 shared 行;修法是减 stages 或减小 tile。

昇腾与多后端

后端全景,昇腾 NPU 的硬件模型与 DSL 映射,什么代码能跨后端。

后端全景

后端target硬件支持级别
NVIDIA CUDAcudaSM70 到 SM120主力,发版 wheel 与 CI 覆盖
AMD ROCm/HIPhipCDNA/RDNA,含 gfx942/gfx950 路径支持,Linux wheel 内置;CI 跑 gfx942,gfx950 未覆盖
Apple MetalmetalApple silicon支持;Metal 4 cooperative tensor 在 M5 上可用
LLVM CPUllvm主机 CPU实验,源码编译 USE_LLVM=ON
CuTe DSL / WebGPUcutedsl / webgpuNVIDIA GPU / WebGPU 运行时实验
华为昇腾Ascend C / NPU IR昇腾 A2、A3生态仓 tilelang-ascend,独立发布
沐曦、摩尔线程、海光、日出maca / musa / hcu / tang各自国产卡生态仓,独立发布,需各自软件栈

昇腾的硬件模型

昇腾 AI Core 与 CUDA SM 的结构不同:

CUDA 概念昇腾对应说明
SMAI Core = Cube 核 + Vector 核 + Scalar 核一个 Core 里两种计算核:Cube 算矩阵,Vector 算逐元素,分工由硬件固定
shared memoryL1 buffer(Cube 侧)/ Unified Buffer(Vector 侧)没有全核统一的 shared,两种计算核各有自己的片上缓存
寄存器、fragmentL0A / L0B / L0CCube 核的输入与累加缓存
block 内线程协作无对应昇腾编程模型按核间分工组织,无线程协作概念
__syncthreadsset_flag / wait_flagCube 与 Vector 通过 flag 同步,数据经 global memory 或 L2 交换

tilelang-ascend 的映射

生态仓 tilelang-ascend 把同一套 DSL 降到昇腾,已在 A2、A3 上验证,依赖 CANN ≥ 8.3.RC1 与 torch-npu ≥ 2.6.0.RC1。映射规则:

TileLang昇腾
T.alloc_sharedL1 / Unified Buffer
T.alloc_fragmentL0A / L0B / L0C
T.gemmT.gemm_v0,跑在 Cube 核
T.Parallel 逐元素自动向量化成 Vector 指令
T.reduce_max 等归约Vector 核归约
T.Kernel(..., is_npu=True)NPU kernel 入口;threads 只接受 1 或 2,表示 Cube 与 Vector 的比例
T.Scope("C") / T.Scope("V")显式划分 Cube 段与 Vector 段;T.set_cross_flag / T.wait_cross_flag 跨核同步

同步与复用有编译器自动模式:

环境变量作用
TL_ASCEND_AUTO_SYNC核内同步
TL_ASCEND_AUTO_CV_COMBINE / TL_ASCEND_AUTO_CV_SYNC自动插跨核 flag
TL_ASCEND_MEMORY_PLANNING自动复用 buffer

设备端调试用 T.printf 与 T.dump_tensor。

两条编译路线

路线分支方式
AscendC & PTOascendc_pto降到昇腾 C 语言,CANN 工具链编译
AscendNPU IRnpuir降到 NPU IR;另有 MLIR 路线的 tilelang-mlir-ascend

已知边界:

  • T.Pipelined 不支持嵌套;
  • threads 只取 1 或 2;
  • Cube 与 Vector 之间交换数据走 global memory 或 L2,没有 shared 直连。

新算子的验收信号是测试 harness 打印 "Kernel Output Match!"——这只代表对拍通过,性能要单独测。

昇腾与四个国产后端由生态仓独立发布,版本节奏与主仓不同步,映射表和环境变量会随版本变。本节基于 2026-09 的主仓与 tilelang-ascend;动手前先核对应生态仓的 README 与版本说明。

什么代码能跨后端

跨后端的边界画在内建函数上,逐内建函数的判断方法见第 19 章。昇腾这一侧的特殊之处是 Cube/Vector 分工替代了 warp 角色,映射见上表。

加后端的契约是 BackendModule(第 19 章),完整移植流程在仓库 .agents/skills/tilelang-backend。五个生态后端都是按这条契约接的。

同一个 FlashAttention kernel 从 A100 搬到昇腾,要改什么?

结构不用改:T.copy、T.gemm、T.reduce_max、online softmax 的递归都是原语。要改的是硬件假设:warp specialization 没有了,昇腾用 Cube/Vector 分工替代;TMA 没有了,搬运走 L1/UB;FullRow warp policy 没有对应物,Cube/Vector 比例用 threads=1|2 表达;num_stages 保留但不能嵌套。换 target、按映射表换分配原语、重跑 autotune;第 5 章的推导在新硬件常量上重来一遍。

源码地图与进阶路径

教程到此为止。之后的学习单位是源码与 PR。

仓库地图

路径内容
tilelang/language/前端 DSL:原语定义、循环帧、kernel 帧、eager builder
tilelang/cuda/CUDA 方言、CUDA pass pipeline、TMA/ws 相关语言层
tilelang/engine/lower 编排、语义检查、编译产物
tilelang/backend/后端注册表、target 检测
tilelang/jit/ + tilelang/cache/特化、磁盘缓存、执行适配器(tvm_ffi/nvrtc/cython/torch)
tilelang/carver/ + tilelang/autotuner/tile 推荐、配置搜索
src/transform/顶层 41 个 C++ pass,连同子目录共 44 个:pipeline、layout、lower_tile_op、split_host_device 等
src/cuda/codegen/CUDA 打印器与 tl:: 模板
src/layout/ + tilelang/layout/布局 C++ 实现与 Python 包装
3rdparty/tvm/定制 TVM(TIRX、FFI),子模块

进阶路径

  1. 改例子:examples/ 里挑一个算子,改 tile 形状、dtype、加 epilogue,用 assert_allclose 守住正确性。
  2. 读 pass:从 PipelinePlanning 和 InjectSoftwarePipeline 开始,它们输入输出清晰(第 6 章已有概念),用 TL_LOWER_TRACE 对照真实 IR。
  3. 加模板:给 carver 加一个新算子模板(第 20 章),理解节点 DAG 与 traffic 传播。
  4. 加 pass:在 src/transform 写一个 IR 变换,注册进 cuda/pipeline.py,配 testing/python/transform 的测试。
  5. 加后端:按 BackendModule 契约接一个新 target。

测试与社区

测试在 testing/python/,按 language、transform、backend、carver、cuda、jit 等子系统分目录,pytest 直接跑。新特性配测试是 CONTRIBUTING.md 的硬要求。练习材料有 tilelang-puzzles(十个递进练习),工具有 tilelang-lsp(buffer 形状与布局的 inlay hint)。

之后遇到问题,第一手材料是源码、IR Lower Trace 和 examples/,第二手是 docs 与 Discord。

附录 · 语言原语速查

写 kernel 真正用到的约 30 个原语,按职责分组。

用法分两层:主干原语(Kernel 框架、分配、搬运、计算、循环)要记,架构相关内建函数用到再查源码。

Kernel 框架与符号

原语作用
@tilelang.jit把 Python 函数变成 JIT kernel:首次调用时特化编译,按参数缓存
@T.prim_func另一种入口:函数直接是 PrimFunc,参数显式声明 Tensor
T.Kernel(*blocks, threads=)启动网格,解包出 blockIdx;1–3 维
T.const("M, N, K")声明编译期符号维度
T.Tensor(shape, dtype)参数注解;T.empty 分配输出张量

分配

原语作用
T.alloc_shared(shape, dtype)片上共享内存 tile
T.alloc_fragment(shape, dtype)寄存器 tile,逐线程布局由编译器推导
T.alloc_local / T.alloc_barrier线程私有标量/数组;命名 barrier(pipeline 与 TMA 用)

搬运与计算

原语作用
T.copy(src, dst)tile 级拷贝,自动并行化并插同步;支持 global↔shared↔fragment
T.async_copy / T.tma_copy显式异步拷贝;Hopper+ 的 TMA 描述符搬运
T.gemm(A, B, C, transpose_B=False, policy=)C += A @ B,映射张量核;policy 如 T.GemmWarpPolicy.FullRow 规定 warp 分工
T.gemm_sp / T.mma_gemm_blockscaled2:4 稀疏矩阵乘;SM120 的 MXFP4 block-scale 矩阵乘;MXFP8 的 block-scale 在 T.tcgen05_gemm_blockscaled(SM100)
T.fill(buf, v) / T.clear(buf)填充;清零。clear 是普通函数调用,立即执行

循环

原语作用
T.Parallel(*extents)数据并行循环,迭代点映射到线程
T.serial(extent)普通顺序循环
T.Pipelined(extent, num_stages=)软件流水线循环,默认 num_stages=0 即关闭(第 6 章)
T.unroll / T.vectorized完全展开;向量化访存

归约与原子

原语作用
T.reduce_sum / reduce_max / reduce_min(buf, out, dim=)tile 归约。是宏,结果写入 out 参数,返回 None
T.warp_reduce_sumwarp 级归约,返回标量
T.atomic_add / atomic_max / atomic_min(dst, v)全局或 shared 地址的原子操作

表达式与数学

原语作用
T.ceildiv(a, b)向上取整除法,算网格大小用
T.if_then_else(cond, a, b)惰性分支选择;T.Select 两分支都求值,按需选用
T.exp / exp2 / log / sqrt / rsqrt / tanh / sigmoid / erf / max / min / clamp逐元素数学函数,编译成设备内建函数
T.infinity(dtype) / T.castinf 常量;dtype 转换

布局、注解与调试

原语作用
T.use_swizzle(panel_size=, enable=)网格级 swizzle,改变 block 派发顺序以提高 L2 命中(第 7 章)
T.annotate_layout({buf: layout})给 shared buffer 指定布局,如 bank-conflict-free 的 swizzle 布局
T.Layout / T.Fragment / T.GemmWarpPolicy布局对象、寄存器分布、gemm 的 warp 分工策略
T.print设备端打印,调试用

完整 API 有 300 多个导出,上面是写绝大多数 kernel 需要的部分。其余是架构相关内建函数,用到时查 tilelang/language/tilelang/cuda/language/ 的源码。

本教程基于 tilelang 主仓 commit c63a2e70(2026-09)核对。API 与文件名以该版本为准;后续版本的差异查 CHANGELOG 与对应源码。