定位与全貌
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 CUDA | cuda | SM70–SM120 | 主力,TMA / WGMMA / TMEM 按架构启用 |
| AMD ROCm | hip | CDNA / RDNA,含 gfx942、gfx950 | 支持 |
| Apple Metal | metal | Apple silicon | 支持 |
| LLVM CPU | llvm | 主机 CPU | 实验 |
| CuTe DSL / WebGPU | cutedsl / webgpu | NVIDIA / 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 的位置:
| 抽象层级 | 强项 | 代价 | |
|---|---|---|---|
| Triton | block 级 DSL | 生态最大,调试工具链成熟,教程多 | 自定义 layout 与指令级控制弱,backend 基本只有 CUDA |
| CUTLASS / CuTe | C++ 模板库 | 性能上限最高,NVIDIA 官方维护 | 学习曲线陡,编译慢,迭代以小时计 |
| ThunderKittens | 研究级 tile 库 | 接近手写的性能,论文复现快 | API 不稳定,无向后兼容承诺 |
| TileLang | tile 级 DSL | layout/流水线/同步由编译器推导,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 推导、流水线、同步、指令选择 |
| 输出 | cubin | CUDA 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。
| 层级 | 是什么 | 数量关系(A100) |
|---|---|---|
| grid | 一次启动的所有 block | 由 kernel 的网格决定 |
| block | 协作线程组,驻留一个 SM | 每 SM 上限 32 个,实际被四项预算里最紧的卡住(见 occupancy) |
| warp | 32 线程,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(见后)。
存储层级
| 层级 | 容量 | 带宽 / 延迟 | 谁管 |
|---|---|---|---|
| 寄存器 | 256 KB / SM | 单周期访问,喂张量核 | 编译器分配,线程私有 |
| shared memory | ≤164 KB / SM | ≈180 GB/s / SM,约 30 周期 | T.alloc_shared,block 内共享 |
| L2 | 40 MB / 全片 | 比 HBM 快一个量级 | 硬件缓存,软件可提示优先级 |
| HBM | 40–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 memory | 164 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 | 几十到几千个 block | T.Kernel(*blocks) 的 extents,1–3 维 |
| thread block | ≤ 1024 线程 | threads=,整数或 (x,y,z) 元组,必须为正 |
| warp | 32 线程,调度单位 | 不显式出现;T.gemm 的 warp 策略由 policy 控制 |
| thread | — | T.Parallel 的迭代点;T.get_thread_bindings() 取索引 |
核心心智模型:kernel 函数体是一个 block 的程序,网格里每个 block 跑同一份代码,靠 block 索引区分自己负责哪块数据。quickstart 里 by * block_M 是这个 block 负责的 C tile 行起点,bx * block_N 是列起点。
三级存储
| 层级 | 硬件 | 容量 / SM | 带宽(量级) | 生命周期 | 分配 API |
|---|---|---|---|---|---|
| global | HBM | 整卡 40–80 GB | ≈ 1.5–2 TB/s | 整个 kernel | 参数张量 / T.empty |
| shared | 片上 SRAM | 164–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.const 与 T.Tensor 注解和第 2 章的 vector_add 同义:符号维度编译期绑定,形状与 dtype 写进特化签名,换形状或 dtype 产生新特化。
分块可视化
五个参数从哪里来
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=3 | 48 KB | 32768 | 2 | 可行域上沿 |
| 256×256×64,stages=3 | 192 KB > 164 KB | 131072 = 全 SM 的两倍 | 0 | 不可行 |
| 128×256×64,stages=3 | 144 KB | 65536 | 1 | persistent 用它换强度 |
128×128 是可行域的上沿,真正卡住的是寄存器——shared 只用了 48 KB。persistent 例子选 128×256×64,用 occupancy 换 tile 强度(I = 85 对 64),persistent 循环保证 SM 不空转(第 12 章)。
换一台设备,重算一遍
常量变了,数就变了,算法不变:
| 设备 | shared / block | 寄存器 / SM | SM 或 CU | ridge | 参数怎么变 |
|---|---|---|---|---|---|
| A100(sm80) | 164 KB | 65536 | 108 | ≈156 | 128×128×32,stages=3,threads=128;mma.m16n8k16 加 cp.async |
| H100(sm90) | 228 KB | 65536 | 132 | ≈295 | 同样的 tile 可以加深 stages 或加大 bK;TMA 负责多维异步搬运,第 13 章;wgmma 单指令做到 64×256,warp 分工随之变大 |
| MI300X(gfx942) | 64 KB | 32768 | 304 | ≈246 | LDS 只有 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 KB | 32768 | — | — | LDS 回到 A100 量级,流水线在 gfx950 上恢复启用 |
carver 把这张表写进了 arch 对象:CUDA 查驱动拿 shared 容量,reg_cap=65536 写死,CDNA 为 32768。
上面五步在 carver 里对应一个 best-first 搜索:候选是每维的因子加 2 的幂,约束是 shared 与寄存器两道上限,目标函数 (traffic+1)·num_wave。第 20 章拆开它。
参数验算器
参数跑出启动失败或性能异常时,第 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}})$。
时间线
编译器做了什么
两个编译 pass 完成重排。PipelinePlanning 把循环体语句分类:copy 是生产者,排到 stage 0;gemm 等计算是消费者,排到 stage N。InjectSoftwarePipeline 把循环改写成展开的 prologue、稳态循环、展开的 epilogue 三段,跨 stage 的 shared buffer 多版本化。
在 SM80 以上,同 dtype 的 global → shared 拷贝会降成 cp.async 异步指令,配 cp.async.commit_group 与 cp.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 |
| ROCm | gfx950 以外的目标会剥掉 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_N | 16 的倍数 | 在 shared 与寄存器预算内 |
| num_stages | 0–3 | 0/1 是无流水线基线,2/3 常用,再深 occupancy 先断 |
| threads | 128、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 怎么实现它。
问题
注意力的分数矩阵 $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$。
递推过程
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 再扫再拷回 |
原子操作
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 有活,其余空闲。
做法
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.copy | T.async_copy | T.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。
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 分成固定角色:一部分专门搬,一部分专门算。
概念
软件流水线(第 6 章)让同一批 warp 先后做两件事,warp specialization 让两组 warp 在同一时刻分别搬运和计算:loader warp 持续发 TMA/cp.async,compute warp 持续跑张量核,两组通过 mbarrier 握手。
两种 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 |
|---|---|---|
| Hopper | producer 用 cp.async.bulk.tensor 带 mbarrier 事务计数;consumer 用 wgmma,T.wgmma_gemm 发射、T.wait_wgmma 配 fence/commit_group 收口 | —— |
| Blackwell(sm100) | 换成 tcgen05 指令族(T.tcgen05_gemm) | tcgen05.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/k | 16 / 16 / 16 | 单条 mma 指令的微 tile(int8 时 k=32) |
| warp_row/col_tiles | 64 / 64 | 每个 warp 负责的 tile 大小 |
| block_row/col_warps | 2 / 2 | warp 网格,共 128 线程 |
| block_M/N/K | 128 / 128 / 32 | 由上面三组推出 |
swizzle 布局→ ldmatrix→ 寄存器 fragment→ mma × N→ stmatrix→ shared C(4D 微块坐标)→ global C
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 descriptor | base 对齐 | 用途 |
|---|---|---|---|---|
| SWIZZLE_32B | 32 B | layout_type=3 | 256 B | 普通访存 |
| SWIZZLE_64B | 64 B | layout_type=2 | 512 B | WGMMA |
| SWIZZLE_128B | 128 B | layout_type=1 | 1024 B | TMA |
XOR 本身是同一族:把行组位异或进列位,区别只是一次事务覆盖多宽。工厂函数对应三档:make_quarter/half/full_bank_swizzled_layout,另有 make_swizzled_layout(通用,TMA-aware)和 volta/wgmma/tcgen05 专用变体。
自动识别与手写时机
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 负责什么。
总览
关键 pass
| pass | 职责 |
|---|---|
| PreLowerSemanticCheck | 后端无关校验:嵌套循环、Parallel 内局部索引、fragment 循环三类检查器 |
| PipelinePlanning + InjectSoftwarePipeline | 第 6 章的流水线重排,在 layout 推导之前完成 |
| LayoutInference | 推导每个 fragment 的逐线程布局,是 TileLang 的核心 pass |
| LowerTileOp | tile 级算子降成内建函数:T.copy 选 SIMT/cp.async/TMA 路径,T.gemm 选 mma/wgmma/tcgen05 路径 |
| LowerHopperIntrin | TMA descriptor 生成与 CSE;mbarrier 多版本化是另一个 pass(MultiVersionBufferRewriter)的事 |
| PlanAndUpdateBufferAllocationLocation / StorageRewrite | buffer 作用域规划与存储重写 |
| SplitHostDevice + LowerDeviceKernelLaunch | 按 calling_conv 拆分,给 kernel 函数盖 DEVICE_KERNEL_LAUNCH 章 |
| LowerIntrin / HoistBroadcastValues | device 侧 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 dump | pass_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 Visualizer | python -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: True | PreLowerSemanticCheck 阶段的 TIR AST |
| 布局可视化 | tilelang.PassConfigKey.TL_LAYOUT_VISUALIZATION_ENABLE: True | LayoutInference 推出的 fragment 布局图 |
| 源码拦截 | register_cuda_postproc_callback | 截获最终 CUDA 源码字符串,程序化检查 |
| T.print | kernel 内 T.print(...) | 运行时设备端值 |
pass_configs 是正式入口
开关通过 @jit 的 pass_configs 参数传入:PassConfigKey → 值的字典,编译时读取,不依赖全局状态。开关和 kernel 定义写在一起,复现实验时不会漏掉环境差异。完整键列表在 tilelang/transform/pass_config.py。
调试顺序
- 正确性:先 assert_allclose 对拍参考实现,确认是数值错还是编译错。
- 生成代码:get_kernel_source() 看 CUDA 源码。没有 cp.async → LowerTileOp 的注入条件没满足(global→shared、同 dtype、SM80+);指令族不对 → 看 T.gemm 的 policy 与架构。
- 定位 pass:TL_LOWER_TRACE=html 拿到每个 pass 的前后 IR,二分找到第一个改坏的 pass。
- 运行时值:编译产物看起来对但结果错,T.print 打印中间 fragment。
- 编译慢: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 的差异。
注册表模型
多个后端共享 kind 时用 supports_target 谓词→ BackendModule→ PassPipelineDeviceCodegenexecution backends
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_async | tl::cp_async_gs<N> |
| ptx_wgmma_ss / rs | tl::wgmma_ss<AType,BType,CType,M,N,K,...> |
| ptx_tcgen05_mma_ss / ts | tl::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 形状推荐器的搜索算法:三个角色、一个目标函数、两道约束。
三个角色
| 角色 | 实现 | 职责 |
|---|---|---|
| Arch | TileDevice(CUDA/CDNA/RDNA/CPU) | 硬件常量:smem 容量、reg_cap(CUDA 65536,CDNA/RDNA 32768)、SM 数、事务宽度、带宽 |
| Template | BaseTemplate 子类(Matmul/GEMV/FlashAttention/...) | 把算子建成 PrimFunc 节点 DAG,提供 get_hardware_aware_configs |
| Policy | DefaultPolicy / TensorCorePolicy | 搜 tile 形状;TensorCorePolicy 覆盖 mma/wmma 的推理与打分 |
搜索算法
最小冗余→ 取队首→ 每维扩展到下一个因子→ smem_cost ≤ smem_cap ?reg_usage ≤ reg_cap ? → 打分入队
prio=(traffic+1)·num_wave
目标函数与约束
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 Compute | occupancy、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 Light | SM 利用率与内存利用率谁低,决定往哪个方向挖 |
| Warp State | stall 原因分布: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 计算器
数值
性能取证偶尔会和数值问题纠缠。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 arguments | TMA 描述符参数不合法:形状秩不匹配、基址未按 16B 对齐、swizzle 与行宽冲突 | 查 global tensor 最后一维对齐;T.tma_copy 的约束失败是致命的,设计上不回退普通拷贝 |
| FATAL: T.tma_copy() cannot fall back to normal copy | tma_copy 强制走 TMA,设备或形状不满足约束 | 改用 T.copy,让编译器按架构自动选 TMA、cp.async 或普通拷贝 |
| FATAL: T.wgmma_gemm() requires Hopper WGMMA lowering | target 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 specialization | shared 上限 228 KB;wgmma 要求 target 带 sm_90 标记 |
| sm100(B200) | tcgen05 + TMEM | tilelang/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 CUDA | cuda | SM70 到 SM120 | 主力,发版 wheel 与 CI 覆盖 |
| AMD ROCm/HIP | hip | CDNA/RDNA,含 gfx942/gfx950 路径 | 支持,Linux wheel 内置;CI 跑 gfx942,gfx950 未覆盖 |
| Apple Metal | metal | Apple silicon | 支持;Metal 4 cooperative tensor 在 M5 上可用 |
| LLVM CPU | llvm | 主机 CPU | 实验,源码编译 USE_LLVM=ON |
| CuTe DSL / WebGPU | cutedsl / webgpu | NVIDIA GPU / WebGPU 运行时 | 实验 |
| 华为昇腾 | Ascend C / NPU IR | 昇腾 A2、A3 | 生态仓 tilelang-ascend,独立发布 |
| 沐曦、摩尔线程、海光、日出 | maca / musa / hcu / tang | 各自国产卡 | 生态仓,独立发布,需各自软件栈 |
昇腾的硬件模型
昇腾 AI Core 与 CUDA SM 的结构不同:
| CUDA 概念 | 昇腾对应 | 说明 |
|---|---|---|
| SM | AI Core = Cube 核 + Vector 核 + Scalar 核 | 一个 Core 里两种计算核:Cube 算矩阵,Vector 算逐元素,分工由硬件固定 |
| shared memory | L1 buffer(Cube 侧)/ Unified Buffer(Vector 侧) | 没有全核统一的 shared,两种计算核各有自己的片上缓存 |
| 寄存器、fragment | L0A / L0B / L0C | Cube 核的输入与累加缓存 |
| block 内线程协作 | 无对应 | 昇腾编程模型按核间分工组织,无线程协作概念 |
| __syncthreads | set_flag / wait_flag | Cube 与 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_shared | L1 / Unified Buffer |
| T.alloc_fragment | L0A / L0B / L0C |
| T.gemm | T.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 & PTO | ascendc_pto | 降到昇腾 C 语言,CANN 工具链编译 |
| AscendNPU IR | npuir | 降到 NPU IR;另有 MLIR 路线的 tilelang-mlir-ascend |
已知边界:
T.Pipelined不支持嵌套;- threads 只取 1 或 2;
- Cube 与 Vector 之间交换数据走 global memory 或 L2,没有 shared 直连。
新算子的验收信号是测试 harness 打印 "Kernel Output Match!"——这只代表对拍通过,性能要单独测。
什么代码能跨后端
跨后端的边界画在内建函数上,逐内建函数的判断方法见第 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),子模块 |
进阶路径
- 改例子:examples/ 里挑一个算子,改 tile 形状、dtype、加 epilogue,用 assert_allclose 守住正确性。
- 读 pass:从 PipelinePlanning 和 InjectSoftwarePipeline 开始,它们输入输出清晰(第 6 章已有概念),用 TL_LOWER_TRACE 对照真实 IR。
- 加模板:给 carver 加一个新算子模板(第 20 章),理解节点 DAG 与 traffic 传播。
- 加 pass:在 src/transform 写一个 IR 变换,注册进 cuda/pipeline.py,配 testing/python/transform 的测试。
- 加后端:按 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_blockscaled | 2: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_sum 等 | warp 级归约,返回标量 |
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.cast | inf 常量;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 与对应源码。