跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 08 章从会用到敢用30 分钟15 习题8 自测题

语言基础

把第 7 章的生成源码往回拆:内核定义与网格、dtype 与内存作用域、T.Parallel/循环/控制流各自对应 IR 里什么、代价是什么,以及 Python 兼容性的边界在哪。

本章进度状态 未读完习题 0 / 15自测 未作答

学习目标

读完本章,你应当能够:

  1. 说清 T.Kernel 的每一个参数:位置参数是块网格沿 x/y/z 的 extent,返回值 是该轴的块索引;threads= 是每块的 SIMT 线程数(省略时 SIMT 后端默认 128); 并能写出 1 维、2 维、3 维网格以及块内取索引的三种写法。
  2. 说出 v0.1.15 支持的数据类型家族(bool / 有符号与无符号整数 / float16、 bfloat16、float32、float64 / float8 八种 / float6 两种 / float4 两种存储模型 / tfloat32),以及 'float16'、T.float16、torch.float16 三种写法为什么会 归一到同一个类型;并能正确写出 fp8 / fp4 的 dtype。
  3. 按「谁看得到这份数据」「它活多久」两条标准,在 T.alloc_shared / alloc_fragment / alloc_local / alloc_var 之间做出选择, 并解释为什么 alloc_fragment 写成块形状却落在寄存器里;同时认得昇腾侧 T.alloc_l1 / alloc_l0a / alloc_l0b / alloc_l0c 与 UB 的分配方式。
  4. 说出 T.serial / T.unroll / T.vectorized / T.Parallel / T.Pipelined 各自解决什么问题,并能复述 T.Parallel 的默认维度映射规则 (lane → thread → 每线程串行复制)。
  5. 在 if/else、T.if_then_else、T.Select、while 之间做出正确选择,并说出 控制流对向量化与软流水造成的两种典型代价。
  6. 判断一段 Python 代码在 kernel 里能不能写:知道哪些语法会被 trace 期展开、 哪些会变成设备循环、哪些直接不可用;并知道「编译期值」与「设备值」混用是 最常见的坑。

前置知识

  • 第 3 章:tile / dataflow / schedule 三层解耦,以及「数据流留给人、调度空间 交给编译器」这条主线。
  • 第 6 章:grid → block → warp → thread、NV 的 shared memory、昇腾的 UB / L1 / L0A / L0B / L0C 与 MTE 路径。本章会直接使用这些机器图,不再重复。
  • 第 7 章:装好 TileLang,写出并跑通第一个 kernel,会打印生成的 CUDA 源码。
  • 会用 Python;写过 PyTorch;不需要写过 CUDA。

本章的验证边界。 写作环境是 macOS(Apple Silicon)、Python 3.11, 没有 NVIDIA GPU,也没有昇腾 NPU(见 版本锁定)。 本章所有需要加速器的代码示例都只做到「仅静态核对」:python3 -m py_compile 通过,且每个 API 名与签名都在 _vendor/tilelang-v0.1.15/ 里逐条读过。 凡是在本机真正运行过的脚本(都是纯标准库的小模型),会明确写成 「CPU 逻辑验证」。本章不含任何实测性能数字。


1. 问题的由来:kernel 里到底要"表达"什么

在 PyTorch 里写 y = x * 2,你表达的是数学意图。写 TileLang kernel 时, 你要表达的是一组工程事实——这些事实在 PyTorch 里由框架替你决定,在 kernel 里必须由你说清楚:

  1. 这次 launch 有多少个块、每块多少线程。 决定硬件怎么把工作铺开,也决定 你能用多少并行度。
  2. 每个数的位宽与语义。 fp16 和 bf16 都占 2 字节,但一个截尾数、一个截指数; fp8 有八种拼法,选错了不是"精度差一点",而是数值范围完全不同。
  3. 每份数据住在哪一级存储、谁能看见它。 shared 是块内共享的暂存区, fragment 名义上也是块形状但实际切到寄存器,alloc_var 只是一个标量。
  4. 哪些迭代是并行的、哪些是顺序的、哪些要流水。 同一段循环写成 T.Parallel 和写成 T.serial 是两种完全不同的硬件行为。
  5. 什么条件下走哪条路。 边界处理、稀疏分支、提前退出。
  6. 哪些是 Python,哪些是设备代码。 这是 TileLang 作为「Python 嵌入 DSL」 独有的边界问题:同一个 with 块里混着两种语义的东西,分不清就会写出 「看起来对、编译不出来」或者更糟的「编译出来但语义错了」的代码。

第 7 章你已经跑通过第一个 kernel,看过它生成的 CUDA 源码。本章要做的是把那份 源码往回拆:每个语言构造对应到 IR 里的什么、代价是什么、什么时候该用哪个。

阅读本章时请始终记住第 4 章的那条管线:你写的 Python 先被解析成 TIR, 再经过一系列 pass 变成具体后端的代码。本章每一节都会给出「你写的」和 「编译器看到的」两侧。


2. 概念与模型:六个构件

把整章压成一张表。一个 kernel 里必须显式表达的东西,只有六类:

构件 语言表面 落到 IR 里是 本章
定义与网格 with T.Kernel(gx, gy, ...) as (bx, by) thread-binding frame(blockIdx.x/y/z + threadIdx.x/y/z) §3
数据类型 'float16' / T.bfloat16 / torch.float32 tvm.DataType §4
buffer 与作用域 T.alloc_shared / alloc_fragment / alloc_local / alloc_var Buffer + storage_scope §5
迭代 T.serial / T.unroll / T.vectorized / T.Parallel / T.Pipelined For 节点,ForKind 不同 §6
控制流 if/else、x if c else y、T.if_then_else、while IfThenElse 语句 / if_then_else 表达式 / While §7
Python 边界 哪些语法能用、哪些会展开、哪些不行 trace 期求值 vs 运行期 TIR §8

结构关系见 图:01-kernel-anatomy。

一条贯穿全章的分界线:trace 期的 Python 与运行期的 TIR。 你在 kernel 体里写的每一行,要么在构造 IR 的时候(trace 期)就被求值掉, 要么变成设备上真正执行的 TIR 语句。for k in [0, 1, 2] 是第一种(trace 期 把循环体复制三份),for i in T.serial(4) 与 for i in range(4) 都是第二种; if True: 是第一种,if i < N:(其中 i 是 TIR 变量)是第二种。 本章 §6–§8 的所有坑,几乎都能归到「我以为是这一种,其实是那一种」。


一个 TileLang kernel 的六个构件构件语言表面(你写的)落到 IR 里是后文位置1. 定义与网格with T.Kernel(gx, gy) as (bx, by)thread-binding frame(blockIdx + threadIdx)§32. 数据类型'float16' / T.bfloat16 / torch.float16同一个 tvm.DataType(dtype 规范化)§43. buffer 与作用域T.alloc_shared / fragment / local / varBuffer + storage_scope§54. 迭代T.serial / unroll / vectorized / Parallel / PipelinedFor 节点,ForKind 不同§65. 控制流if/else、T.if_then_else、whileIfThenElse 语句 / if_then_else 表达式 / While§76. Python 边界哪些语法能用、哪些会展开、哪些不行只留 trace 期能确定的部分§8
六类必须显式表达的东西,以及它们各自落到 IR 的什么位置;右边是后文各节的位置。
  • 你写的是块(tile)级的意图,不是线程级的指令。
  • 类型、内存作用域、迭代、控制流这四件事必须显式写出来。
  • trace 期的 Python 与运行期的 TIR 是两种东西,见 §7 与 §8。

3. kernel 定义与网格:T.Kernel

T.Kernel 的网格与索引T.Kernel(gx, gy, gz, threads=128)as (bx, by, bz)bx = blockIdx.x范围 0..gx-1by = blockIdx.y范围 0..gy-1bz = blockIdx.z范围 0..gz-1threads=128blockDim.x;省略时 SIMT 默认 128一次 launch = gx*gy*gz 个块
位置参数按顺序对应 x / y / z 三个轴;省略 threads= 时 SIMT 后端默认 128 个线程。
  • x 变得最快:bx = pid % gx,by = (pid // gx) % gy,bz = pid // (gx*gy)。
  • 写 T.Kernel(ceildiv(N, BN), ceildiv(M, BM)) 时,x 轴吃 N(列方向),y 轴吃 M(行方向)。
  • 昇腾方言只接受一维核网格 T.Kernel(num_cores),没有 threads=,多给一维会直接报错。
  • 越界部分由 LegalizeSafeMemoryAccess 自动插守卫。

3.1 参数含义

T.Kernel 的定义在 _vendor/tilelang-v0.1.15/tilelang/cuda/language/kernel.py:13-55:

def Kernel(
    *blocks: int | tirx.PrimExpr,
    threads: int | list[int] | tuple[int, ...] | None = None,
    prelude: str | None = None,
    cluster_dims: int | tuple[int, int, int] | list[int] | None = None,
) -> KernelLaunchFrame:

四个参数各自的含义:

  • *blocks(位置参数,1–3 个):块网格沿每个轴的 extent,对应 gridDim.(x|y|z)。它返回的绑定就是这个轴上的块索引。顺序就是轴的顺序: T.Kernel(gx, gy) 的 gx 是 x 轴,gy 是 y 轴。
  • threads=(关键字):每块的线程数。可以是一个整数(写进 blockDim.x), 也可以是长度 ≤ 3 的列表/元组(blockDim.(x|y|z))。省略时,SIMT 后端用 DEFAULT_SIMT_THREADS = 128(tilelang/transform/__init__.py:201)。
  • prelude=:注入到生成代码之前的源码(比如 #include 或辅助函数)。
  • cluster_dims=:CUDA 的线程块簇(SM90+)。2 或 (2,1,1) 表示 2-CTA 簇。T.ClusterKernel 是要求必须给 cluster_dims 的同款 launch。

这四条不是「随便什么关键字都能塞」:每个后端方言用自己 Kernel 的关键字 参数声明它认识哪些注解,其它一律拒绝。CUDA 方言认 threads / prelude / cluster_dims;ROCm 与 Metal 认 threads / prelude;CPU 只认 prelude。

3.2 网格是"块"的网格,不是"线程"的网格

这是从 PyTorch 走过来最容易带错的观念。T.Kernel 里的一切默认是块级的:

with T.Kernel(T.ceildiv(N, 128), threads=128) as bx:
    for i in T.Parallel(128):        # 不是 "threadIdx.x 线程做第 i 件事"
        C[bx * 128 + i] = A[bx * 128 + i] + B[bx * 128 + i]

T.Parallel(128) 的 128 是逻辑迭代数,不是「128 个线程各做一次」—— 虽然在这个例子里结果恰好一样。编译器的布局推导会决定把这 128 次迭代切成 「哪几条 lane、哪些线程、每线程几次」。所以你不需要、通常也不应该手写 threadIdx。真需要线程级行为时,CUDA 方言提供 T.get_thread_binding(dim=0)(tilelang/language/kernel.py:587), 而昇腾侧要先开一个 T.SimtVF(threads=...) 区域(见 3.5)。

3.3 最小示例:一维网格

code/01-kernel-grid/kernel_grid.py 的第一个函数:

@tilelang.jit
def elementwise_scale(N, block=256, dtype="float32"):
    @T.prim_func
    def kernel(X: T.Tensor((N,), dtype), Y: T.Tensor((N,), dtype)):
        with T.Kernel(T.ceildiv(N, block), threads=block) as bx:   # ①
            for i in T.Parallel(block):                            # ②
                gi = bx * block + i                                # ③
                Y[gi] = X[gi] * 2.0                                # ④
    return kernel

逐行:

  • ① T.ceildiv(N, block) 是 x 轴的块数——上取整,所以最后一轮可能盖住 一部分越界位置(由后面的守卫处理)。threads=block 把每块线程数定死成 block;as bx 拿到 x 轴块索引,等价于 blockIdx.x。
  • ② T.Parallel(block) 声明「这一块要做的 block 件事」。它是块级意图, 映射由编译器做。
  • ③ 全局下标 = 块号 × 每块元素数 + 块内偏移。这就是所有 elementwise kernel 的通用公式。
  • ④ 直接写全局内存。TileLang 会为 gi < N 自动插边界守卫 (LegalizeSafeMemoryAccess),所以这里省掉了显式 if。

3.4 二维与三维网格

二维网格就是把 N 方向给 x、M 方向给 y,这是 examples/quickstart.py 的写法:

with T.Kernel(T.ceildiv(N, BN), T.ceildiv(M, BM), threads=128) as (bx, by):
    T.copy(A[by * BM, ko * BK], A_s)
    T.copy(B[ko * BK, bx * BN], B_s)

返回的是列表(多个轴时),可以解包;只有一个轴时返回单个 Var,写成 as bx 或 as (bx,) 都行。这个行为写在 tilelang/language/kernel.py:88-96 的 _normalize_bindings 里。

块索引的分解遵循「x 变得最快」:

grid = (gx, gy, gz) 时,一次 launch 的线性块号 p 分解为
    bx = p % gx
    by = (p // gx) % gy
    bz = p // (gx * gy)

这不是 TileLang 自己的约定,而是 CUDA 的 blockIdx 顺序。TileLang 只是在 src/ir.cc:236-262 里给每个轴建一个标签为 blockIdx.x/y/z 的绑定帧。

图见 图:02-grid-mapping;本目录 code/01-kernel-grid/grid_index_model.py(CPU 逻辑验证)把上表算了出来: M=1000, N=700, BM=256, BN=128 → grid=(6,4)、24 个块、M 方向多 24 行、 N 方向多 68 列。

3.5 昇腾侧:一维核网格 + 核内线程域

昇腾方言的 Kernel 是另一个形状 (tilelang/ascend/language/kernel.py:139-172):

def Kernel(*blocks, prelude=None) -> KernelLaunchFrame:
    if len(blocks) != 1:
        raise ValueError(f"Ascend targets a 1-D core grid: T.Kernel(N) takes exactly one grid extent. ...")
  • 没有 threads=:NPU 的核网格是一维的,核内线程域要显式开。
  • 多维网格直接报错,而不是被悄悄压平。
  • 线程域用 with T.SimtVF(threads=128):(真实的 threadIdx 作用域)或 with T.SimdVF():(寄存器级 SIMD,没有线程维)打开。

这解释了第 6 章那张机器图在语言层的落点:NV 是「块网格 + 块内隐式线程」, 昇腾是「核网格 + 核内显式线程域」。

3.6 块内取索引的三种写法

with T.Kernel(N, M, B, threads=(64, 2)) as (bx, by, bz):
    tx, ty, tz = T.get_thread_bindings()   # ① threadIdx.x / y / z(固定三个)
    n_blk_x = T.get_block_extent(0)    # ② 该轴的块数
    bx2 = T.get_block_binding(0)       # ③ 等价于 bx
  • ① T.get_thread_binding(dim) / T.get_thread_bindings():线程索引。 返回的永远是三个(x、y、z),threads=(64, 2) 时第三个是 1。 注意一个限制——T.Kernel 没写 threads= 时,trace 期不知道线程数, 此刻调 get_thread_extent() 会抛 "The thread extent is not known at trace time"(kernel.py:215-226)。
  • ② T.get_block_extent(dim):该轴的网格 extent;没启动的轴返回 1 (kernel.py:199-208)。
  • ③ T.get_block_binding(dim):该轴的块索引 Var,和 as 绑定拿到的是同一个 东西。

threads=(64, 2) 这种按维度的写法,会在 _normalize_threads (kernel.py:99-135)里补齐成三维:[64, 2] → [64, 2, 1]。


4. 数据类型

数据类型家族(v0.1.15)normalizetilelang.language 上的 dtype 符号共 173 个名字(dtypes.py::_all_dtypes)7 个规范别名short/int/long/uint/half/float/double无向量形bool · int4 · float4_e2m1_unpacked整数int8/16/32/64 · uint8/16/32/64浮点float16 · float32 · float64bfloat16只有 bfloat16x2,没有 x4 及以上tfloat32有 x2…x64低精度家族float8(8 种)e3m4 · e4m3 · e4m3fn · e4m3fnuz · e5m2 · e8m0fnu …float6(2 种)e2m3fn · e3m2fnfloat4(2 种存储模型)e2m1fn 打包 4 bit;e2m1_unpacked 8 bit 槽同一个 tvm.DataType字符串 / TileLang 对象 / 框架 dtype 三种写法归一
173 个名字分成别名、整数、浮点、bfloat16、tfloat32 与低精度家族,最后都归一到一个 DataType。
  • 向量形(lane):多数类型有 x2…x64;bfloat16 只有 x2;bool / int4 / unpacked fp4 没有。
  • fp8 的八个拼法不是一回事:e4m3fn 是 CUDA 默认、e4m3fnuz 是 ROCm 默认,e8m0fnu 只有指数。
  • fp4 两种存储模型:打包(4 bit,全局张量)与 SMEM 解包(8 bit 槽)。
  • 混合精度时累加 dtype 必须显式给;A/B 的 dtype 只允许在 f8f6f4 家族内混。

4.1 三种写法,一个类型

docs/programming_guides/type_system.md:7-11 列出了三种写法,它们等价:

dtype = "float16"           # 字符串
dtype = T.float16           # TileLang dtype 对象
dtype = torch.float16       # 框架 dtype

归一化在 tilelang/language/dtypes.py 里做:字符串走 _CANONICAL_TO_DISPLAY_STR(:105),把 TVM 的规范名换成显示名 (float → float32、half → float16、int → int32、long → int64、 short → int16、double → float64、uint → uint32);torch / numpy 的 dtype 走 _TORCH_DTYPE_TO_STR / _NUMPY_DTYPE_TO_STR 映射表;最后都进 dtype.__new__(:245-253)得到同一个 tvm.DataType。

这条规则的实际好处:你不用为了「传参」和「写 buffer」记两套名字。 torch.randn(..., dtype=torch.bfloat16) 得到的张量,直接喂给 T.Tensor((N,), T.bfloat16) 的 kernel 就行;第 7 章的 JIT 会用张量的实际 dtype 去推断目标与类型。

4.2 家族清单

v0.1.15 里 tilelang.language 导出的 dtype 名字一共 173 个 (dtypes.py:891 的 __all__ 由 _all_dtypes 展开)。按家族分:

家族 成员 向量形(lane)
布尔 bool 无
有符号整数 int4、int8、int16、int32、int64 int8/16/32/64 有 x2…x64;int4 无
无符号整数 uint8、uint16、uint32、uint64 各有 x2…x64
浮点 float16、bfloat16、float32、float64 float16/32/64 有 x2…x64;bfloat16 只有 x2
张量浮点 tfloat32(custom[tfloat32]) x2…x64
float8 float8_e3m4、float8_e4m3、float8_e4m3b11fnuz、float8_e4m3fn、float8_e4m3fnuz、float8_e5m2、float8_e5m2fnuz、float8_e8m0fnu 各有 x2…x64
float6 float6_e2m3fn、float6_e3m2fn 各有 x2…x64
float4 float4_e2m1fn(打包)、float4_e2m1_unpacked(SMEM) float4_e2m1fn 有 x2…x64;unpacked 无

外加 7 个规范别名:short、int、long、uint、half、float、 double。

这张表不是抄文档抄来的:code/02-dtypes/dtype_families.py --diff 会去读 dtypes.py 的 _all_dtypes,逐条比对。当前输出是 exact match(173 条)。 图见 图:03-dtype-families。

注意 bfloat16 的特殊性。 它只有 bfloat16 和 bfloat16x2 两个名字, 没有 bfloat16x4 及以上。如果你下意识地写 T.bfloat16x8,那是错的。 需要「一次处理 8 个 bf16」时,只能靠循环或展开表达,或者改用 float8(如果数值范围允许)。

4.3 fp8:八个拼法不是一回事

fp8 是"8 位浮点"的统称,E4M3 与 E5M2 的数值范围完全不同;同一份 E4M3 在不同 平台还有不同的 NaN 规则。列出八个成员时,要同时说清「指数/尾数怎么分」和 「NaN / 溢出怎么处理」:

dtype 指数 / 尾数 备注
float8_e3m4 3 / 4 早期格式
float8_e4m3 4 / 3 抽象 E4M3,NaN 规则由后端决定
float8_e4m3b11fnuz 4 / 3 bias-11 的 FNUZ 变体
float8_e4m3fn 4 / 3 有限值 + NaN,CUDA 侧的默认拼法
float8_e4m3fnuz 4 / 3 FNUZ,ROCm 侧的默认拼法
float8_e5m2 5 / 2 指数范围更大,精度更低
float8_e5m2fnuz 5 / 2 FNUZ 变体
float8_e8m0fnu 8 / 0 只有指数,用作 MX 的块 scale 因子

不想背这张表也可以:tilelang/language/fp8.py 提供了 determine_fp8_type(fp8_format="e4m3"),它按运行时平台返回正确的拼法—— CUDA 返回 float8_e4m3fn,ROCm 返回 float8_e4m3fnuz,gfx950 例外地回到 OCP 拼法。

in_dtype = T.float8_e4m3fn
accum_dtype = T.float32        # 累加 dtype 必须显式给

混合精度时累加 dtype 永远是显式的。 type_system.md:38-39 把这条写在 "Notes" 里:要 fp16 输入、fp32 累加,就把 accum_dtype 写成 T.float32, 不要指望推导。

4.4 fp4:两种存储模型

FP4 E2M1 在 v0.1.15 里有两个 dtype,区别不在数值,在存储:

dtype bits 用法
T.float4_e2m1fn 4 打包:一字节两个 FP4 值,全局张量与打包 SMEM 往返用它,占一半空间
T.float4_e2m1_unpacked 8 解包:一个 FP4 值占一个字节槽,只用于 SMEM/TMA,配对 tcgen05 的 kind::f8f6f4 / mxf8f6f4 混合精度路径

两者的 docstring 写在 dtypes.py:268-304。is_float4() 对两者都返回 True; is_float4_e2m1fn() / is_float4_e2m1_unpacked() 才区分它们。用错模型的 典型症状是「访存对不上」或者「TMA 描述符对不上」——因为它们对应不同的 CU_TENSOR_MAP_DATA_TYPE_*。

还有一个实际约束:PyTorch 没有 float4_e2m1fn 这个 dtype,所以 as_torch() 会退到 float4_e2m1fn_x2(打包版)或 int8 (dtypes.py:187-236)。传张量进去时要注意这一点。

4.5 混合 A/B dtype 的硬约束

dtypes.py:319-340 的 validate_gemm_ab_dtypes 规定了一条硬约束:

A 与 B 的 dtype 不同时:
  - 只有「f8f6f4 家族」之间允许混用(且调用方要显式 opt-in)
  - 其它任何组合都会抛 ValueError

所以 T.gemm 里 A 用 fp8、B 用 fp16 是不允许的;要混精度就在 fp8 家族内部 混(比如 fp8 × fp4),或者先转成同一类型。


5. buffer 与内存作用域

storage scope 与分配函数分配函数默认 scope谁看得到 / 用途T.alloc_sharedshared.dyn块内共享的片上暂存区,放 tile 的暂存副本T.alloc_fragmentlocal.fragment逻辑上是块形状,物理上切到每线程寄存器T.alloc_locallocal每线程私有、可以自己下标的小数组T.alloc_varlocal.var单元素标量(用的时候写 scale[0])T.alloc_globalglobal跨块工作区(少见,优先从 Torch 侧传入)T.alloc_tmemshared.tmemBlackwell 的 TMEM 累加器T.alloc_barriershared.barriermbarrier,参数是 arrive_count
同一个高层概念换一个 scope 就是换一种物理存储;选择依据是「谁看得到」和「活多久」。
  • fragment 写成块形状却落在寄存器:128×128 fp32 = 64 KiB = 128 个 fp32 寄存器 / 线程。
  • 昇腾侧:alloc_shared 落到 UB,alloc_l1 / l0a / l0b / l0c 落到 L1 与各级 Cube 缓冲。
  • 主库 v0.1.15 没有 alloc_ub,UB 用 alloc_shared;alloc_ub 是适配器仓库的拼法。
  • 两侧 scope 名不同(shared.dyn vs shared、shared.l0a vs wmma.matrix_a),片段不能互相搬。

5.1 四个常用分配函数

tilelang/language/allocate.py 里的签名(括号里是默认 scope):

API 默认 scope 语义 行号
T.alloc_shared(shape, dtype) shared.dyn 块内共享的片上暂存区 :49
T.alloc_fragment(shape, dtype) local.fragment 逻辑上是块形状,物理上切到每线程寄存器 :81
T.alloc_local(shape, dtype) local 每线程私有、普通下标空间 :67
T.alloc_var(dtype, init=...) local.var 单元素标量 :103

还有三个不常用但要知道的:

  • T.alloc_global(shape, dtype)(:171):后端直接分配的工作区 (cudaMalloc 风格)。docstring 明确建议优先把工作区从 Torch 侧传进来, 这个 API 主要用于测试和特定场景。
  • T.alloc_tmem(shape, dtype)(:254):Blackwell 的 Tensor Memory, scope shared.tmem。约束写在 docstring 里:列数必须是 2 的幂且 ≥ 32, 分配与释放必须由同一个 warp 执行,只有 TCGEN5.MMA 和特定的 TMEM 载入/存储 指令能碰它。
  • T.alloc_barrier(arrive_count)(:194):mbarrier,scope shared.barrier。

5.2 怎么选

选择依据只有两条:谁看得到这份数据,以及它要活多久。

放一个 tile 的暂存副本,块内所有线程共享         → alloc_shared
放一个"块形状"的累加器 / 中间结果                → alloc_fragment
放一个每线程私有、需要自己下标的小数组            → alloc_local
放一个标量:计数器、标志、临时值                 → alloc_var
放一块跨块的工作区(少见)                       → alloc_global
放 Blackwell 的 TMEM 累加器                      → alloc_tmem

最小示例(code/03-memory-scopes/alloc_scopes.py):

with T.Kernel(T.ceildiv(N, BN), T.ceildiv(M, BM), threads=128) as (bx, by):
    A_s = T.alloc_shared((BM, BK), "float16")     # ①
    B_s = T.alloc_shared((BK, BN), "float16")     # ①
    C_f = T.alloc_fragment((BM, BN), "float32")   # ②
    scratch = T.alloc_local((BK,), "float32")     # ③
    scale = T.alloc_var("float32", init=0.5)      # ④
    T.clear(C_f)
    for ko in T.Pipelined(T.ceildiv(K, BK), num_stages=3):
        T.copy(A[by * BM, ko * BK], A_s)
        T.copy(B[ko * BK, bx * BN], B_s)
        T.gemm(A_s, B_s, C_f)
    for i, j in T.Parallel(BM, BN):
        C_f[i, j] = C_f[i, j] * scale[0]          # ⑤
    T.copy(C_f, C[by * BM, bx * BN])
  • ① A/B 是「搬上来、反复用」的 tile,需要块内共享 → alloc_shared。
  • ② C 是累加器。写成 (BM, BN) 的块形状,但 local.fragment 意味着 布局推导会把它切成每线程的寄存器片段。
  • ③ alloc_local 是一个每线程私有的小数组,索引空间普通(scratch[k])。
  • ④ T.alloc_var 只分配一个元素。docstring 里的例子给了五种写法, 常用的是 T.alloc_var("float32", init=0.5) 或 T.alloc_var("int32", 0)。用的时候是 scale[0]——它仍然是一个 buffer, 只是长度为 1。
  • ⑤ 注意 scale[0] 而不是 scale。

5.3 为什么 fragment 写成块形状却落在寄存器里

docs/get_started/overview.md:82-84 把这件事讲得最清楚:alloc_fragment 分配 的是整个线程块的寄存器文件,TileLang 用布局推导(LayoutInference)推一个 T.Fragment 对象,决定「这块寄存器怎么分给每个线程」。

所以下面这段算术是成立的(code/03-memory-scopes/scope_footprint.py 现算):

C_f = T.alloc_fragment((128, 128), "float32")     # 逻辑上 128×128
128 × 128 × 4 B = 64 KiB
64 KiB / 128 线程 = 512 B/线程 = 128 个 fp32 寄存器/线程

128 个寄存器/线程是个很大的数字(NV 每线程最多 255 个 32-bit 寄存器, 见第 6 章)。这就是为什么 BM × BN 不能随便放大:它直接吃掉 occupancy。 这也是为什么第 10 章要专门讲 layout——fragment 的布局不是装饰,它决定 寄存器压力与后续 T.copy 的合并方式。

5.4 一个特例:bool buffer 在 CUDA 侧会被改写 scope

if dtype == "bool":
    scope = "shared"      # 不是 shared.dyn

allocate.py:60-62 的注释解释了原因:smem 合并 pass 目前处理不了 bool 类型, 所以 T.alloc_shared(..., "bool") 会落到静态 shared 上。昇腾方言故意覆盖 了这个函数,去掉了这个 workaround——它的 docstring 写着「Ascend UB handles bool buffers in the requested scope directly」 (tilelang/ascend/language/allocate.py:15-24)。这是第 6 章说过的 「同一个高层概念在两台上落点不同,且被显式承认」的又一个例子。

5.5 昇腾侧:UB / L1 / L0A / L0B / L0C

主库 v0.1.15 的昇腾方言有五个分配函数 (tilelang/ascend/language/allocate.py):

A_l1 = T.alloc_l1((BM, BK), "bfloat16")       # scope shared.l1   (:26)
B_l1 = T.alloc_l1((BN, BK), "bfloat16")       # scope shared.l1
C_l0c = T.alloc_l0c((BM, BN), "float32")      # scope shared.l0c  (:55)
C_ub = T.alloc_shared((BM // 2, BN), "float32")  # UB,scope shared.dyn (:15)

alloc_l0a(:37)与 alloc_l0b(:46)的 docstring 各写了一句关键的话: 「Layout is deferred to the consuming gemm operation, which infers K-major or MN-major from its transpose flags.」 也就是说,L0A/L0B 的物理排布不是你在 分配时定的,而是由消费它的 T.gemm 的 transpose_A / transpose_B 反推。 这是第 21 章布局推导要处理的核心问题,本章只需要记住「别自己假设 L0 的排布」。

alloc_l0c 还会额外挂一个 make_ascend_l0c_layout 的布局注解(:55-66), 因为 L0C 是固定布局。

5.6 一个必须记住的版本差异:alloc_ub 在哪

主库 v0.1.15 里没有 T.alloc_ub。 UB 就用 T.alloc_shared 分配—— 昇腾后端 README 的原话是:

「Use T.alloc_shared for Unified Buffer storage, T.alloc_l1 for L1 storage, and T.alloc_l0a, T.alloc_l0b, or T.alloc_l0c for Cube buffers.」 —— _vendor/tilelang-v0.1.15/tilelang/ascend/README.md:138-139

alloc_ub 是适配器仓库的拼法。_vendor/tilelang-ascend-v0.1.2.000/ 的 tilelang/language/allocate.py:140-157 里同时有 alloc_L1 / alloc_L0A / alloc_L0B / alloc_L0C / alloc_ub(注意大驼峰), scope 名也不同:

适配器仓库                            主库 v0.1.15 的 ascend 方言
────────────────────────────────     ──────────────────────────────
T.alloc_ub    → "shared.ub"           (无)UB 用 T.alloc_shared → "shared.dyn"
T.alloc_L1    → "shared.l1"           T.alloc_l1  → "shared.l1"
T.alloc_L0A   → "wmma.matrix_a"       T.alloc_l0a → "shared.l0a"
T.alloc_L0B   → "wmma.matrix_b"       T.alloc_l0b → "shared.l0b"
T.alloc_L0C   → "wmma.accumulator"    T.alloc_l0c → "shared.l0c"

两套拼法不能互换。写昇腾代码之前先确认自己用的是哪棵树;本书统一以 _vendor/tilelang-v0.1.15/ 为准,出现适配器仓库的拼法时会显式标注。


6. 并行与循环

五种循环构造子你在 kernel 里写的迭代T.serial普通设备循环,ForKind = SerialT.unroll请求展开,ForKind = UnrolledT.vectorized把一段迭代合成一条向量操作T.Parallel嵌套并行循环,线程映射交给布局推导T.Pipelined生产者—消费者软流水(num_stages)谁执行默认单个线程顺序走完,除非外层有 T.Parallel 在分活优化目标摊掉循环开销、把访存合并成更宽的请求优化目标一条向量指令处理 extent 个元素,寄存器复用编译器决定lane → thread → 每线程串行复制编译器决定buffer 开几份、copy 与 compute 怎么重叠
五种迭代构造子各自的语义与优化目标;大写版本只是小写版的别名。
  • for i in range(n) 在 kernel 里会变成 T.serial(n),不是展开。
  • for x in [0, 1, 2] 或 enumerate(...) 是 trace 期展开,不产生设备循环。
  • T.serial 与 T.Serial 是同一个东西,unroll / vectorized 同理。
T.Parallel 的布局推导与校验给了没给否是T.Parallel(e0, …, e_{k-1})有没有 loop_layout= ?用你给的 FragmentInputDim 必须等于嵌套深度 k,且只能挂在最外层布局推导自己推一个并把它挂到最外层循环上校验通过?编译错误最外层缺布局 / 内层非法挂布局 / InputDim 不匹配PartitionLoop + VectorizeLoop落地成真实的线程与向量访问
T.Parallel 的 extents 是逻辑迭代空间;谁当 lane、谁当 thread 由布局推导决定。
  • 默认映射:行优先展平 → 按 vector_size 切 lane → % num_thread 轮转到线程 → 剩下变串行。
  • 同一份工作,nest 形状不同时每线程次数相同,但归属不同,访存会不会合并也不同。
  • T.copy 在 lowering 时先展开成 T.Parallel,再由流水 pass 决定属哪一级、开几份 buffer。

6.1 五种循环构造子

tilelang/language/loop.py 里定义了它们:

构造子 签名(节选) 语义
T.serial(start, stop=None, step=None) :189 普通设备循环,ForKind = Serial
T.unroll(start, stop=None, step=None, *, explicit=False) :230 请求展开;explicit=True 要求真的展开
T.vectorized(start, stop=None) :322 把一段迭代合成一条向量操作
T.Parallel(*extents, coalesced_width=None, loop_layout=None) :13 嵌套并行循环,线程映射由布局推导决定
T.Pipelined(start, stop=None, num_stages=0, order=None, stage=None) :112 生产者—消费者软流水

大写版本 T.Serial / T.Unroll / T.Vectorized 只是小写版的别名 (:297、:309、:349),用大写强调「这是 tile 级循环」。

6.2 语义差异:谁执行、为了什么

T.serial      谁执行:默认单个线程顺序走完;除非被外层 T.Parallel 分活
              目的:表达"必须按序"的迭代(累加、扫描、K 方向归约)

T.unroll      谁执行:同 serial,但请求编译器展开
              目的:摊掉循环开销、把连续访存合并成更宽的请求
              额外:CUDA dialect 支持 unroll_factor=N → #pragma unroll N

T.vectorized  谁执行:一段迭代被合成一条向量指令
              目的:一个 request 搬多个元素,寄存器复用

T.Parallel    谁执行:编译器决定(lane → thread → 每线程串行)
              目的:表达"这些迭代之间没有依赖"

T.Pipelined   谁执行:外层是循环,"谁"由循环体里的语句决定
              目的:让 copy 和 compute 在时间上重叠

最小示例(code/04-loops/loops_demo.py):

base = bx * BLK                             # trace 期算出的块内起点
acc = T.alloc_var("float32", init=0.0)     # ①
for i in T.serial(BLK):                    # ②
    acc[0] += X[base + i]
for i in T.vectorized(4):                  # ③
    Y[base + i] = X[base + i] + acc[0]
for i in T.unroll(4):                      # ④
    Y[base + 4 + i] = X[base + 4 + i] * 2.0
for i, j in T.Parallel(BM, BN):            # ⑤
    Y[by * BM + i, bx * BN + j] = X[by * BM + i, bx * BN + j]
  • ① 归约的累加器用一个标量 acc(alloc_var),所以它必须是串行的。
  • ② T.serial 表达「一次加一个」。这条依赖链本身不能并行。
  • ③ T.vectorized(4) 要求「这 4 次迭代拼成一条向量操作」。它的前提是 X[base..base+3] 在内存里连续、且对齐。
  • ④ T.unroll 只是请求展开;能不能展开由后端决定。需要保证时用 explicit=True,或者 CUDA 方言的 unroll_factor=N。
  • ⑤ T.Parallel 把线程映射全部交给编译器——这是最常用、也最该优先用的 一种。

6.3 T.Parallel 的维度映射规则

这是本章技术含量最高的一节。T.Parallel 的 extents 是逻辑迭代空间, 不是「哪个维度给线程」的声明。 默认映射由 src/transform/loop_partition.cc 的 PlanLoopPartition 决定 (:248-271):

对 k 维 T.Parallel(e0, e1, ..., e_{k-1}):

  flattened  = e0*(e1*(...*i0 + i1)...) + i_{k-1}     # 行优先展平
  access_idx = flattened // vector_size                # 先按 lane 分组
  thread     = access_idx % num_thread                 # 再轮转到线程
  local_idx  = (access_idx // num_thread) * vector_size
               + (flattened % vector_size)             # 剩下的变每线程串行

  快 ────────────────────────────────────────────────→ 慢
  │  lane(连续,可向量化) │ thread(相邻,可合并) │ 每线程串行复制 │

三个结论:

  1. 最内层是最快的。行优先展平意味着 i_{k-1} 变得最快,所以最内层的 连续取值会落进同一组 lane → 可以被向量化。
  2. 线程是中间那一层。lane 分组之后按 % num_thread 轮转,所以相邻的 线程拿到相邻的分组 → 访存可以合并。
  3. 剩下的变串行复制。当总迭代数超过 num_thread × vector_size 时, 多出来的部分变成每个线程的串行循环。

code/04-loops/loop_mapping.py(CPU 逻辑验证)把这个公式实现了一遍。跑 --compare 会看到一件反直觉的事:

threads=8, vector_size=2, 总共 64 次迭代

T.Parallel(8, 8)      per-thread=8    thread 0 owns (0,0) (0,1) (2,0) (2,1) ...
T.Parallel(2, 32)     per-thread=8    thread 0 owns (0,0) (0,1) (0,16) (0,17) ...
T.Parallel(4, 4, 4)   per-thread=8    thread 0 owns (0,0,0) (0,0,1) (1,0,0) (1,0,1) ...

每线程的工作量完全相同,但归属不同。 所以「nest 形状无所谓,反正次数 一样」是错的:形状决定了 thread 0 读的是哪几个逻辑下标,也就决定了 load 会不会合并。

6.4 什么时候必须自己给布局

不写 loop_layout= 时,布局推导会自己推一个并挂在最外层循环上。需要自己 给的时候,loop_layout= 接的是一个 T.Fragment,并且有硬约束 (tilelang/language/loop.py:40-79):

  • 嵌套深度为 k 时,Fragment 的 InputDim 必须等于 k;
  • 布局注解只能挂在最外层并行循环上,内层挂会报错;
  • 每个并行循环在布局推导之后都必须被某个布局覆盖,漏了也是编译错误。
layout = T.Fragment([BM, BN], forward_thread_fn=lambda i, j: (i * BN + j) % 128)
for i, j in T.Parallel(BM, BN, loop_layout=layout):
    ...

另一个 hint 是 coalesced_width=,它告诉向量化规划器「期望的合并宽度」, 用于向量化可行性判断(loop.py:22-30)。

6.5 T.Pipelined:把 copy 和 compute 叠起来

for ko in T.Pipelined(T.ceildiv(K, BK), num_stages=3):
    T.copy(A[by * BM, ko * BK], A_s)     # 生产者
    T.copy(B[ko * BK, bx * BN], B_s)     # 生产者
    T.gemm(A_s, B_s, C_f)                # 消费者

num_stages 是「生产者和消费者之间最多用几份 buffer」。它的代价是片上 容量:第 5 章算过,(128,256) fp32 开两份就放不进示例里的 192 KiB UB。

想手工排调度就用 order / stage(两个长度相同的列表,按顺序给循环体里的 可调度语句编号)。这里有一条容易踩的规则(loop.py:139-168):本地别名 产生的可重放标量 Bind 不算可调度语句,不占槽位。 例如

for i in T.Pipelined(n, order=[1, 0], stage=[0, 1]):
    base: T.int32 = i * block          # 可重放,不占槽位
    T.copy(A[base], shared)            # slot 0
    T.copy(shared, B[base])            # slot 1

只写了两个 slot,而不是三个。如果这个 Bind 读的 buffer 恰好也被流水写, 它就不再可重放,会变回一个调度语句(因为它带了流水依赖)。


7. 控制流

控制流的三种写法和两种代价我想表达什么?「满足条件才执行」if / elif / else → IfThenElse 语句「取一个值,只碰其中一边」T.if_then_else:只求值被选中的分支「取一个值,两边都安全」T.Select:两边都会求值可放在 T.Parallel 体内也可能被流水划分看见越界访存的正确写法用短路求值保护这一边纯数值选择不要用它做越界保护
语句分支用 if/else,取值分支用 T.if_then_else(只求值一边),T.Select 两边都会求值。
  • 代价一:每次迭代都带谓词会挡住向量化,退化成带条件的标量循环。
  • 代价二:把守卫拿掉、尾部另起一次 launch,fast path 才能自由向量化。
  • T.all_of / T.any_of 接的是 buffer,不是条件列表;多条件要用 and / or(文档落后于源码)。
  • 常量恒真的 while 会被编译期拒绝;break / continue 在设备循环里可用。

7.1 if / elif / else 与三元表达式

标准 Python 的 if/elif/else 在 kernel 里可用,条件是 TIR 表达式 (docs/programming_guides/control_flow.md:15-40):

for i in T.Parallel(BLK):
    gi = bx * BLK + i
    if gi < N:                      # ① TIR 条件 → 设备侧分支
        Y[gi] = X[gi]
    else:
        Y[gi] = 0.0

Y[by, 0] = S[by] if by < M else 0.0   # ② 三元 → if_then_else 表达式
  • ① 条件是 TIR 表达式,会变成设备上真正的分支。
  • ② x if cond else y 构造的是 if_then_else,是值不是语句。

编译期常量会被折叠。 条件是纯 Python 的 bool 时 (比如 if USE_FAST_PATH: 里 USE_FAST_PATH = True),trace 期就定下来了, 不会在设备上留下分支(control_flow.md:19-21)。这也意味着:从 Python 常量 推导出来的 if 可以放心用来「按参数开关一段实现」。

7.2 T.if_then_else 与 T.Select:不是一回事

这两个都是「取值」,但求值方式不同。tilelang/language/tir/ir.py:172-179 的 Select docstring 把区别写死了:

「Select evaluates both value expressions. Use if_then_else when a branch must guard side effects or out-of-bounds memory accesses.」

# 安全:条件为假时,X[gi] 这个 load 根本不会发生
Y[gi] = T.if_then_else(gi < N, X[gi], 0.0)

# 危险:两个操作数都会被求值,gi >= N 时 X[gi] 照样被读
Y[gi] = T.Select(gi < N, X[gi], 0.0)

规则:做越界保护、或者一边有副作用时,只能用 T.if_then_else; 两边都安全、只想按条件选值时,T.Select 更接近硬件 select,通常更省。

7.3 T.all_of / T.any_of 的一个陷阱

docs/programming_guides/control_flow.md:33-38 给的例子是(同一份文件在 :141 又用了一次):

if T.all_of(i < M, j < N):     # ← 这个写法在 v0.1.15 里是错的

但 v0.1.15 的实现在 tilelang/language/logical.py:12, 51:

def any_of(buffer: BufferLikeType) -> tirx.PrimExpr: ...
def all_of(buffer: BufferLikeType) -> tirx.PrimExpr: ...

它们接受的是一个缓冲区或缓冲区切片,语义是「这个切片里是否至少有一个 / 全部元素为真」。对应测试的用法是 T.all_of(BlockMask[by, bx, k, :]) (testing/python/language/test_tilelang_language_all_of.py:56)。

多个条件的与,用 and。 Python 的 and / or / not 作用在 TIR 表达式上 时会降级成 tir.And / tir.Or / tir.Not (tilelang/language/eager/builder.py:820-846):

if (i < M) and (j < N):
    ...

这是本章发现的一处文档落后于源码的地方,请以源码为准。

7.4 while、break、continue

while i[0] < BLK:                # 条件必须是 TIR 表达式
    if X[i[0]] < 0.0:
        i[0] += 1
        continue                 # 跳过本次
    Y[i[0]] = X[i[0]]
    i[0] += 1
  • while 条件是 TIR 表达式;常量恒真会被编译期拒绝,防止写出死循环 (control_flow.md:112-116)。
  • break / continue 在 T.serial / T.unroll / T.Parallel / while 里 都可用。有一个限制要注意:trace 期的 Python 循环里,只有编译期条件才支持 break / continue;要按运行期的值跳转,就用 T.serial (python_compatibility.md:42-48)。
  • 设备侧的 T.loop_break()(tilelang/language/customize.py:163)是 break 的等价内建,某些场景下更明确。

7.5 控制流对 layout 与流水的两种代价

代价一:守卫会挡住向量化。 如果每次迭代都带一个谓词,向量化器没法把它 合成一个宽的、对齐的访存请求,只能退化成带条件的标量循环。 docs/deeplearning_operators/elementwise.md:113-135 给了一个生成代码的例子: N=2047、threads=256、num_per_thread=8 时,生成的是

#pragma unroll
for (int i = 0; i < 8; ++i) {
  if (((i * 256) + ((int)threadIdx.x)) < 2047) {
    C[...] = A[...] + B[...];
  }
}

没有向量化,也没有合并访存。同一份文档紧接着指出:除了尾部那一组, 其它线程本来可以走更优化的代码。 这正是 LegalizeSafeMemoryAccess 自动插守卫的代价,也是「默认安全」与「默认快」之间的取舍。

代价二:分支会切开流水。 T.Pipelined 的 order / stage 是按循环体里的 可调度语句编号的。一段 if 会把循环体切成多个基本块,Bind 的可重放性 也可能因此改变(见 6.5)。写流水循环时,尽量让循环体的结构稳定:把 数据相关的分支留在 T.Parallel 内部,而不是让整个流水循环分叉。


8. Python 兼容性边界

Python 在 kernel 里的可用边界直接可用for i in range(n) → 设备侧 T.serial;list / tuple / enumerate / zip → trace 期展开;while(TIR 条件);if / elif / else;break / continue;a[i] 与切片、负索引;+ - * / % 与增强赋值;列表/字典/集合推导式可用可用但有限制with 只认 T.Kernel 这类 DSL 上下文;assert 要用 T.device_assert 或 T.assert;print() 只在 Python 表达式上有效,设备侧用 T.print()有限制不可用kernel 里不能 def / class(要复用就写 @T.macro);不能 a = b = c 链式赋值;没有 len()(用 buffer.shape[dim]);没有 type() / isinstance()不可用
同一个 with 块里混着 Python 语义与设备语义;分清「编译期值」与「设备值」是关键。
  • 编译期值:N(@jit 工厂的参数)、A.shape[0];设备值:T.dynamic、bx、X[i]。
  • 对设备值用 Python 的 if / for / len 会出错;对编译期值写 T.serial 会把该展开的东西变成设备循环。
  • Python list 不是设备 buffer:用运行期下标去索引 list 不会生成设备侧查表。

8.1 一张表

docs/programming_guides/python_compatibility.md 是这一节的权威来源。 归纳成三类:

类别 构造 说明
直接可用 for i in range(n) / range(a,b,s) 映射成 T.serial
直接可用 for x in list/tuple/enumerate/zip trace 期展开,必须有限
直接可用 while、if/elif/else、x if c else y、break/continue
直接可用 a[i]、a[i,j,k]、a[i:j]、a[-1] 切片会构造 BufferRegion
直接可用 x = expr、+ - * / %、+= 等增强赋值
直接可用 列表/字典/集合推导式 边界必须在 trace 期可求值;推导变量不外泄
有限制 with 只认 T.Kernel / T.ws 这类 DSL 上下文
有限制 assert 用 T.device_assert 或 T.assert
有限制 print() 只在 Python 表达式上有效;设备侧用 T.print()
不可用 函数与类 kernel 里不能有 def / class;要复用写 @T.macro
不可用 a = b = c 链式赋值,拆开写
不可用 len() 用 buffer.shape[dim]
不可用 type()、isinstance()

自查工具:code/06-python-boundary/py_compat_scan.py(CPU 逻辑验证)把这张表 做成了一个 AST 扫描器,能在编译之前先把显然越界的构造挑出来。

8.2 @T.macro:kernel 里的"函数"

kernel 里不能写函数,但可以写宏:

@T.macro
def load_tile(src, dst, r, c):
    T.copy(src[r, c], dst)

T.macro 在编译期把宏体内联到调用点,语义上接近 CUDA 的 __device__ 函数(python_compatibility.md:89-91)。v0.1.15 里它同时挂在 lazy 解析器 (tilelang/language/parser/entry.py:105)与 eager builder (tilelang/language/eager/builder.py:1075)上,两种前端都能用。

8.3 最容易搞错的一件事:编译期值 vs 设备值

N              Python int(来自 @jit 工厂的参数)  →  编译期常量
A.shape[0]     从 buffer 形状读出来的符号          →  既可用于 trace,也可进 TIR
T.dynamic("K") 显式声明的符号变量                  →  设备值(TIR Var)
bx             T.Kernel 返回的块索引               →  设备值(TIR Var)
X[i]           buffer load                         →  设备值(TIR 表达式)

两种典型错误:

  • 把设备值当 Python 值用:写 if bx > 4: 是没问题的(bx 是 TIR Var, > 会构造 TIR 表达式),但写 len(X) 或 X.shape 去当 Python 的整数用, 就会掉进 trace 期的坑。
  • 把应该展开的东西写成设备循环:Python 的 range 在 for 语句里会变成 设备侧的 T.serial,包括边界是动态的时候(python_compatibility.md:32-37)。 「我只想展开 4 次」和「我要一个 4 次的设备循环」在写法上要分清: 循环一个具体的 Python 列表是展开,range 是设备循环。
for k in [0, 1, 2]:        # 展开:trace 期把循环体复制 3 份
    ...
for k in range(3):         # 设备循环:相当于 T.serial(3)
    ...
for k in T.unroll(3):      # 请求展开的设备循环
    ...

还有一个更隐蔽的点:Python list 不是设备 buffer。 用运行期变量去索引 Python list 不会生成设备侧查表,要用 buffer 或直接把下标算出来 (python_compatibility.md:68-70)。

图见 图:08-python-boundary。


9. 类型系统与隐式转换的坑

第 4 节讲了「有哪些类型」,这一节讲「编译器会替你转什么、不会替你转什么」。

① 整数字面量与浮点混用。 T.max(C_f[i, j], 0) 是 examples/quickstart.py 的写法:0 是 Python int,C_f 是 fp32 的 fragment。 这类「字面量与 buffer 元素」的混用会被接受并做类型提升。但两个不同 dtype 的 buffer 直接运算就未必,尤其在有 lanes(向量形)的时候。遇到"类型不匹配"的 报错,先把操作数显式转成同一个 dtype 再看。

② bool 的存储。 CUDA 侧 T.alloc_shared(..., "bool") 会落到静态 shared(见 5.4);bool 也没有向量形。用 uint8 做掩码在很多场景下更省心 (control_flow_demo.py 的 buffer_predicates 就是这么写的)。

③ int4 / float4 是打包的。 int4 与 float4_e2m1fn 的 bits 是 4, 也就是一字节存两个值。as_torch() 会退到 int8 / float4_e2m1fn_x2 (dtypes.py:187-236)。做指针算术或算 buffer 大小时如果按 1 字节/元素算, 会差一倍。

④ float8_e4m3 与 float8_e4m3fn 是两个不同的类型。 前者是抽象 E4M3, as_torch() 会按平台在 float8_e4m3fn(CUDA)与 float8_e4m3fnuz(ROCm) 之间切换(dtypes.py:187-206)。同一段代码在两个平台上会得到不同的物理类型—— 这正是 fp8.py 的 determine_fp8_type() 存在的理由。

⑤ 累加 dtype 必须显式。 见 4.3。fp16 输入 + fp16 累积在 K 很大时会明显 损失精度;用 fp32 累积是默认推荐(第 27 章会量化讨论这一点)。

⑥ 混合 A/B dtype 只允许 f8f6f4 家族内部。 见 4.5。

⑦ 字符串别名会被改写。 "half" 就是 "float16"、"float" 就是 "float32"(dtypes.py:105-113)。这本身没问题,但如果你在日志里看到 float32 而代码里写的是 "float",不要以为是编译器换了类型。


10. 实现机制:编译器把这些语义落到哪里

这一节把前面各节的语言表面接回第 4 章那条编译管线。

① T.Kernel → MaterializeKernelLaunch。 launch_kernel 先在 trace 期做三件归一化:块绑定 (_normalize_bindings,kernel.py:88-96)、线程数 (_normalize_threads,:99-135,补成三维)、簇维度 (_normalize_cluster_dims,:138-151,[1,1,1] 视为没给)。 然后 DEFAULT_SIMT_THREADS = 128(transform/__init__.py:201)在 pass 阶段补上默认线程数。这个 pass 还决定「这个后端有没有线程维度」: lower_thread_binding=True(CUDA/ROCm/Metal)把线程占位符绑成 threadIdx.* 作用域;False(CPU、Ascend)丢掉它们,body 里引用线程索引 会被拒绝。昇腾因此把线程域做成 T.SimtVF 自己的作用域。

② T.alloc_* → Buffer + storage_scope。 每个分配函数最终都调到 T.sblock_alloc_buffer(shape, dtype, scope=...) (allocate.py:49-193)。scope 是唯一区分它们的东西: shared.dyn / local.fragment / local / local.var / global / shared.tmem / shared.barrier。同一个 API 换一个 scope 就是换一种物理存储。

③ T.Parallel → PlanLoopPartition + LowerParallelLoop。 布局推导先给每个并行循环找一个 Fragment 布局(没给就自己推), PlanLoopPartition(loop_partition.cc:248-271)按 6.3 的公式算出映射, LowerParallelLoop(:324-350)再分两步落地:先 PartitionLoop 按布局切分, 再 VectorizeLoop 按访存分析决定向量宽度(1 就是标量)。最后 PragmaUnrollLoop 处理展开 pragma。

④ 类型归一化 → DataType。 三个入口(字符串 / TileLang 对象 / 框架 dtype)都走到 dtype.__new__(dtypes.py:245-253),产出同一个 tvm.DataType。之后 validate_gemm_ab_dtypes(:319-340)之类的检查才有统一的比较基准。

⑤ 布局推导是"所有并行循环都必须被覆盖"的。 ParallelLoopLayoutValidator 在 tl.transform.LayoutInference 里检查: 每个并行循环最终都要有布局注解,且必须在最外层(loop.py:40-58)。 这条规则是第 10、21 章的主角,本章只要求你知道「T.Parallel 不是免费的, 它背后有一次布局推导」。


11. 进阶与陷阱

  1. 以为 threads= 可以随便省。 省略时默认 128,看起来无害;但一旦 kernel 体里要用 T.get_thread_extent(),trace 期就会报 "The thread extent is not known at trace time"(kernel.py:215-226)。 需要线程数就显式给。

  2. 以为 T.Parallel 的 extents 直接对应线程维度。 不是。它先展平, 再按 lane → thread → 串行 切。要控制映射就写 loop_layout=。

  3. 用 T.Select 做越界保护。 两边都会求值,越界访问照样发生。用 T.if_then_else。

  4. 忽略 T.alloc_fragment 的寄存器代价。 128×128 fp32 就是 128 寄存器/线程。BM × BN 是 occupancy 的第一杀手。

  5. 把 bfloat16x8 当成合法 dtype。 bf16 只有 x2。

  6. 把适配器仓库的 alloc_ub / alloc_L1 当成主库 API。 主库 v0.1.15 没有 alloc_ub;scope 名也不一样。

  7. 在流水循环里放会分叉的 if。 order / stage 是按可调度语句编号的, 分叉会改变编号与 Bind 的可重放性。

  8. 把 Python 的 range 当成"展开"。 在 for 语句里它会变成设备侧的 T.serial。要展开就循环一个具体的 Python 序列。

  9. 相信文档里的 T.all_of(i < M, j < N)。 v0.1.15 的 all_of 接的是 buffer。多条件用 and。

  10. 忘了 fp4 有两种存储模型。 float4_e2m1fn 是打包的(4 bit), float4_e2m1_unpacked 是 SMEM 专用的(8 bit 槽)。算大小、配 TMA 描述符时选错就会对不上。


12. 本章小结

  • 六个构件:网格(T.Kernel)、dtype、buffer/作用域、迭代、控制流、 Python 边界。前五个直接决定 IR 的形状,第六个决定「哪些东西根本进不了 IR」。
  • T.Kernel:位置参数按顺序对应 x/y/z 的块数,返回块索引;threads= 是 每块线程数(默认 128);prelude / cluster_dims 是后端注解。块内默认是 块级世界,线程级要用 T.get_thread_binding()(NV)或 T.SimtVF(昇腾)。
  • dtype:173 个名字,'float16' / T.float16 / torch.float16 三种写法 归一到一个 DataType。fp8 有八个拼法,fp4 有两种存储模型,bf16 只有 x2。 混合精度时累加 dtype 必须显式;A/B dtype 只允许在 f8f6f4 家族内混。
  • buffer 与作用域:alloc_shared(块内共享暂存)、alloc_fragment(块形状 逻辑 / 寄存器物理)、alloc_local(每线程私有数组)、alloc_var(标量)。 昇腾侧还多出 UB / L1 / L0A / L0B / L0C,且主库用 alloc_shared 分配 UB。
  • 迭代:T.serial 顺序、T.unroll 展开、T.vectorized 向量化、 T.Parallel 交给编译器、T.Pipelined 软流水。T.Parallel 的默认映射是 展平 → lane → thread → 每线程串行,所以 nest 的形状决定访存能否合并。
  • 控制流:if/else 是语句,T.if_then_else 只求值被选中的一边, T.Select 两边都求值。守卫会挡住向量化,分支会切开流水。 T.all_of / T.any_of 接的是 buffer,不是条件列表。
  • Python 边界:range 在 for 里是设备循环,具体序列是 trace 期展开; 没有函数/类(用 @T.macro)、没有链式赋值、没有 len()。 分清「编译期值」与「设备值」是这一节最关键的一件事。

下一章(第 9 章)会在这些构件之上讲核心原语:T.copy、T.gemm、 T.reduce_*、T.atomic_add、T.fill、T.tile.*——每一个的语义、参数与 降级行为,以及它们各自会展开成什么样的 T.Parallel / T.serial。

延伸阅读

  • 想回到编译管线本身:第 4 章。
  • 想回到两台机器的硬件图(shared / UB / L1 / L0 的物理动机):第 6 章。
  • 想按存储作用域写第一个程序:第 7 章。
  • 想深入布局推导与 T.Fragment:第 10 章、第 21 章。
  • 想深入软流水与多缓冲:第 11 章、第 22 章。
  • 想把低精度用对:第 27 章。
  • 本章引用的全部原始资料与核验状态:见 本章引用。
  • 想亲手复算网格、dtype、作用域与循环映射:code/ 下六个示例。

习题

15 题 · 答案在提交后展开

先在纸上或输入框里写下你的答案,再展开参考答案对照。客观判断请自己打勾:这三颗按钮 只影响本机保存的学习进度。

E1概念难度 ★无需硬件概念题 不需要硬件

with T.Kernel(A, B, threads=128) as (bx, by):

  1. A 和 B 分别对应哪个网格维度?它们写进哪个 launch 参数?
  2. bx / by 分别等价于 CUDA 里的什么?
  3. 如果把 threads=128 删掉会发生什么?默认值是多少,写在哪一行?
  4. 为什么写成 as (bx, by) 和写成 as (bx,) 在只有一个位置参数时都能工作? 请指出实现这一点的函数名与文件位置。

E2概念难度 ★无需硬件概念题 不需要硬件
  1. 下面三种写法得到的 dtype 是同一个吗?为什么?

    d = "float16"
    d = T.float16
    d = torch.float16
    
  2. 说出 v0.1.15 里 float8 的全部八个成员名,并为每一个写出「指数位数 / 尾数 位数」。

  3. float4_e2m1fn 与 float4_e2m1_unpacked 的 bits 分别是多少?为什么同一个 「FP4 E2M1」会有两种 dtype?

  4. T.bfloat16x8 合法吗?给出理由。


E3代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

读 code/01-kernel-grid/kernel_grid.py 的 two_dim_threads(不需要运行):

with T.Kernel(M, threads=(64, 2)) as bx:
    tx, ty = T.get_thread_bindings()
    Y[bx, ty * 64 + tx] = X[bx, ty * 64 + tx]
  1. threads=(64, 2) 会让 blockDim.x / blockDim.y / blockDim.z 分别是多少? 补齐三维这一步在哪个函数里做?给出文件位置。
  2. 上面这段代码有一处会立刻报错,是哪一处?T.get_thread_bindings() 实际返回 几个值、顺序是什么?应该怎么改?
  3. ty * 64 + tx 覆盖的下标范围是什么?要让这个 kernel 不越界,对 N 有什么 要求?
  4. 如果改成 with T.Kernel(M, N) as (bx, by):(不加 threads=),这段代码还能 用吗?为什么?

E4概念难度 ★★无需硬件概念题 不需要硬件

为下面四份数据各选一个分配函数,并说明理由(用「谁看得到」「活多久」两条):

  1. 一个 (128, 32) 的 fp16 A 矩阵 tile,在 K 方向的循环里被反复读;
  2. 一个 (128, 128) 的 fp32 累加器;
  3. 一个每线程私有、长度 32 的 fp32 临时数组;
  4. 一个用来记「当前块是否还要继续算」的 int32 标志。

再回答:为什么把累加器写成 T.alloc_shared 通常是错的?


E5代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

读 code/03-memory-scopes/scope_footprint.py 的输出(不需要运行):

  C fragment (fp32, per block)       (128, 128)   float32   x1  =    64 KiB
  C tile in UB (fp32, 2-stage)       (128, 256)   float32   x2  =   256 KiB   cap 192 KiB  DOES NOT FIT
  1. 第一行的 64 KiB 是怎么算出来的?请写式子。
  2. 如果把 threads=128 补上,这块 fragment 平均每个线程占多少字节、多少个 fp32 寄存器?请写式子,并说明为什么这个数字要警惕。
  3. 第二行要放得下,有哪些改法?(至少给两个,并说明各自的代价。)
  4. 脚本里的 196608 是什么口径?直接用这个数当「昇腾 UB 有多大」合适吗? 正确做法是什么?

E6概念难度 ★★无需硬件概念题 不需要硬件

对下面五种需求,各选一个循环构造子(T.serial / T.unroll / T.vectorized / T.Parallel / T.Pipelined):

  1. 「把 128 个元素各乘 2,彼此没有依赖」;
  2. 「沿 K 方向把 32 个数累加到同一个标量上」;
  3. 「把 A 的 tile 从 GM 搬到 shared,同时上一个 K 块的 GEMM 还在算」;
  4. 「希望这 8 次迭代拼成一条 ld.global.v4」;
  5. 「一个每块只走 4 次、开销想摊掉的小循环」。

再回答:T.serial 和 T.Parallel 在「谁执行」这件事上的默认差异是什么?


E7代码阅读难度 ★★★无需硬件代码阅读题 不需要硬件

读 code/04-loops/loop_mapping.py 的输出(不需要运行):

$ python3 loop_mapping.py --extents 4 4 --threads 4 --vector 2
  index          flat  thread  slot
  (0, 0)            0       0     0
  (0, 1)            1       0     1
  (0, 2)            2       1     0
  ...
$ python3 loop_mapping.py --compare
  T.Parallel(8, 8)      per-thread=8    thread 0 owns (0,0) (0,1) (2,0) (2,1) ...
  T.Parallel(2, 32)     per-thread=8    thread 0 owns (0,0) (0,1) (0,16) (0,17) ...
  1. 手算验证 (0, 2) 落在 thread=1, slot=0:写出 flattened、access_idx、 thread、local_idx 四步。
  2. 为什么 T.Parallel(8, 8) 与 T.Parallel(2, 32) 的「每线程次数」一样, 但 thread 0 拿到的下标不同?
  3. 这段映射公式的原始出处在哪里?给出文件与行号区间。
  4. 假设 Y[by*BM+i, bx*BN+j] 里的行是连续的、X 是行主序二维张量。 T.Parallel(BM, BN) 与 T.Parallel(BN, BM) 哪个更可能合并访存?为什么?

E8动手难度 ★★无需硬件动手题 不需要硬件

在本机运行 code/04-loops/loop_mapping.py:

  1. 默认参数跑一遍,记录每个迭代点 → (thread, slot) 的表;
  2. 跑 --compare,记录五种 nest 形状的 thread 0 归属;
  3. 自己构造一组 --extents / --threads / --vector,使得有些线程完全分不到 工作,并解释为什么(提示:总迭代数 < threads 会发生什么);
  4. 把 vector_size 改成 1,观察 lane 那一层退化成什么。

E9动手难度 ★★无需硬件动手题 不需要硬件

在本机运行 code/01-kernel-grid/grid_index_model.py:

  1. 记录 M=1000, N=700, BM=256, BN=128 时的网格形状、块数与尾部 padding;
  2. 把 x_is_n 从 True 改成 False(或直接改脚本),说明 thread 0/pid 0 之后的块顺序发生了什么变化;
  3. 用脚本里的 linear_to_block 解释:为什么「x 变得最快」意味着按线性块号 走一遍会先扫完一行?
  4. 新增一个 grid = (3, 2, 2) 的例子,手工验证 pid=7 分解成 (bx, by, bz)。

E10概念难度 ★★无需硬件概念题 不需要硬件
Y[gi] = T.if_then_else(gi < N, X[gi], 0.0)      # A
Y[gi] = T.Select(gi < N, X[gi], 0.0)            # B
  1. A 与 B 在求值行为上有什么区别?引用 _vendor/tilelang-v0.1.15/ 里的 原始说明(文件 + 行号)。

  2. 当 gi >= N 时,A 会读 X[gi] 吗?B 呢?

  3. 在什么情况下 B 反而更合适?

  4. 下面这三段代码,哪一段会把 C 里的值乘 2、哪一段不会?为什么?

    # P
    for i in T.Parallel(128):
        C[i] = C[i] * 2
    # Q
    for i in T.Parallel(128):
        if i < 64:
            C[i] = C[i] * 2
    # R
    x = 3 if 1 > 2 else 4
    for i in T.Parallel(128):
        C[i] = C[i] * x
    

E11代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

读 _vendor/tilelang-v0.1.15/tilelang/language/logical.py:12-87(不需要运行):

def any_of(buffer: BufferLikeType) -> tirx.PrimExpr: ...
def all_of(buffer: BufferLikeType) -> tirx.PrimExpr: ...
  1. T.all_of(buffer) 的语义是什么?它接受的是什么类型?
  2. docs/programming_guides/control_flow.md:33-38 里的 if T.all_of(i < M, j < N): 与源码一致吗?如果不一致,正确的写法是什么?
  3. 请到 testing/python/language/test_tilelang_language_all_of.py 里找一个 正确的调用例子,并抄下来。
  4. 这条题想说明什么工程习惯?请用一句话概括。

E12概念难度 ★★无需硬件概念题 不需要硬件

判断下面每一小段代码在 kernel 里能不能写;能写的说明它会变成什么,不能写的 说明该改成什么:

  1. for i in range(N): (N 是 Python int)
  2. for i in range(N): (N 是 T.dynamic("N") 出来的 Var)
  3. for k in [0, 1, 2]:
  4. n = len(X)
  5. a = b = 0
  6. def helper(x): return x + 1
  7. assert i < N
  8. print("hello") / T.print(C, msg="C:")
  9. scale_idx_table = [j * 2 for j in range(16)],然后 for idx, val in enumerate(scale_idx_table): out[idx] = val

E13动手难度 ★★无需硬件动手题 不需要硬件

在本机运行 code/06-python-boundary/py_compat_scan.py:

  1. --self-test 的输出是什么?几项通过?
  2. 默认(不带参数)时会报出哪些 BAD?它们各自的原因是什么?
  3. 把 py_compat_scan.py 指向本章的 code/05-control-flow/control_flow_demo.py(一条命令),记录输出里 BAD 与 WARN 的数量,并解释:这里的 BAD/WARN 是真的越界,还是这个 教学模型的假阳性?
  4. 用一句话说明这个脚本不能替代什么。

E14综合难度 ★★★无需硬件综合题 不需要硬件

下面是一段「看起来对」的 kernel,请找出至少四处问题,并逐条给出修法。 (提示:网格、dtype、作用域、控制流、Python 边界各有一处。)

@tilelang.jit
def bad(M, N, K, BM=128, BN=128, BK=32):
    @T.prim_func
    def kernel(A: T.Tensor((M, K), "float8_e4m3"),
               B: T.Tensor((K, N), "float16"),
               C: T.Tensor((M, N), "float16")):
        with T.Kernel(T.ceildiv(N, BN), threads=128) as bx:
            A_s = T.alloc_shared((BM, BK), "float8_e4m3")
            B_s = T.alloc_shared((BK, BN), "float16")
            C_f = T.alloc_fragment((BM, BN), T.bfloat16x8)
            mask = T.alloc_var("float32", 1.0)
            n = len(A)
            for ko in T.Pipelined(T.ceildiv(K, BK), num_stages=3):
                a = b = ko * BK
                T.copy(A[0, a], A_s)
                T.copy(B[a, bx * BN], B_s)
                T.gemm(A_s, B_s, C_f)
                if T.all_of(mask[0] > 0.0, ko < 4):
                    C_f[0, 0] = C_f[0, 0] * mask[0]
            T.copy(C_f, C[0, bx * BN])
    return kernel

E15动手难度 ★★★需要加速器动手题 需要硬件

(可选,只有有加速器的读者需要做。本章写作环境既无 GPU 也无 NPU,故本题未在 本机验证。)

在 NVIDIA GPU 机器上:

  1. 把 code/05-control-flow/control_flow_demo.py 的 guard_costs_vectorization 与 guard_free_full_blocks 都编译到 N=2047,打印两者的 CUDA 源码;
  2. 回答:哪一份出现了向量化访存(如 float4)?哪一份只有标量 + 谓词? 这与第 7.5 节的说法一致吗?
  3. 把 num_per_thread 从 8 改成 4、16,观察向量化是否出现变化;
  4. 明确记录你的验证级别是「在真实硬件上运行过」,并写清 GPU 型号、CUDA 版本、 TileLang 版本(必须是 v0.1.15)。

在昇腾 NPU 机器上(二选一):

  1. 跑通 code/03-memory-scopes/alloc_scopes.py 里的 ascend_scope_showcase (需要把 T.copy 的目标尺寸调成合法值),用 GetCoreMemSize 记录该设备 真实的 UB / L1 / L0C 容量;
  2. 与 code/03-memory-scopes/scope_footprint.py 里的 192 KiB 对照,说明那是不是 该设备的真实值。

章节自测

8 题 · 提交后本地评分

单选题。提交后立即给出分数与逐题解析;重做会清空选择,但会保留上一次的成绩记录。

  1. 1. 下面关于 T.Kernel 的说法,哪一句是对的?

  2. 2. 关于 dtype,下面哪一句符合本章的说明?

  3. 3. 同一份 E4M3 在 tilelang 里有多个拼法,下面哪句是对的?

  4. 4. 写 C_f = T.alloc_fragment((128, 128), "float32"),本章给出的寄存器账是?

  5. 5. 在主干 v0.1.15 的昇腾方言里,UB(Unified Buffer)应该怎么分配?

  6. 6. 关于 T.Parallel 的默认维度映射,下面哪句是对的?

  7. 7. 要在 kernel 里做越界保护,例如只在该下标合法时才读取 X[gi],应该怎么写?

  8. 8. 文档里写的 if T.all_of(i < M, j < N) 在 v0.1.15 里为什么是错的?

本章代码

17 个文件

正文里提到的每个示例都能在这里直接阅读(带语法高亮),也可以整章浏览。

浏览本章全部代码 →

本章引用

源码链接指向锁定 tag

本章写到的每个源码位置与外部资料都在下面列出:源码链接指向锁定 tag 的 GitHub 文件, 资料链接指向原文。查看全部资料索引 →

展开引用清单

第 08 章 参考资料

本章所有结论都基于下表的本地资料或锁定版本源码。版本以 版本锁定 为准:TileLang v0.1.15 (commit a35f8ddf45eba16c21211ec8822d56ce5363036f)、TileLang-Ascend TileLang-ascend-v0.1.2.000-release(commit 89a9364e47a341fffdd65ed94786b1d4f9b6a78c)。 文中不出现任何含糊的版本表述(一律写具体 tag)。

写作环境:macOS (Apple Silicon)、Python 3.11;没有 NVIDIA GPU,也没有昇腾 NPU。 因此本章没有任何本机实测的运行结果或性能数字。

本章把来源分成三类:

  • 锁定版本源码 / 官方文档快照(_vendor/tilelang-v0.1.15/):只用于 「这个 API / 语义在 v0.1.15 里存不存在、签名是什么、文档怎么说」。 在工作树上用 rg / sed 定位到定义处并完整读过;不代表运行验证。
  • 适配器仓库(_vendor/tilelang-ascend-v0.1.2.000/):只在讨论 alloc_ub / alloc_L1 等拼法差异时引用,与主库分开标注。
  • corpus 资料(资料库索引):官方文档的镜像快照。本章引用的 那几份与 _vendor/ 里同名文件内容一致,差异只在部分非 ASCII 字符的 编码(已 diff 验过:只有连字符、emoji 一类的 mojibake 行不同)。

锁定的源码引用(_vendor/tilelang-v0.1.15/)

id 引用位置 用途 核验方式与状态
S1 tilelang/cuda/language/kernel.py:13-55 CUDA 方言 T.Kernel(*blocks, threads=, prelude=, cluster_dims=) 的签名与 docstring 已核验(2026-10-01):逐行阅读
S2 tilelang/language/kernel.py:88-151, 200-268, 392-427, 587-611 _normalize_bindings / _normalize_threads / _normalize_cluster_dims、launch_kernel、T.Kernel/T.ClusterKernel、get_block_binding 与 get_thread_binding 已核验(2026-10-01)
S3 src/ir.cc:236-262 位置参数 → blockIdx.x/y/z 的绑定帧;变量名 bx/by/bz 已核验(2026-10-01)
S4 tilelang/transform/__init__.py:201-232 DEFAULT_SIMT_THREADS = 128 与 MaterializeKernelLaunch 的参数语义(含「Ascend 不降线程域」的说明) 已核验(2026-10-01)
S5 tilelang/ascend/language/kernel.py:139-172 昇腾 T.Kernel 是一维核网格、拒绝多维、无 threads= 已核验(2026-10-01)
S6 tilelang/ascend/language/frame.py:107, 179 T.SimtVF(threads=..., latency=0) 与 T.SimdVF(latency=0) 已核验(2026-10-01)
S7 tilelang/language/dtypes.py:105, 120-142, 187-253, 262-317, 319-347, 633-711, 891 _CANONICAL_TO_DISPLAY_STR、_STR_TO_TVM_DTYPE_CALL、as_torch()、fp4 家族判定、validate_gemm_ab_dtypes、全部 dtype 对象、__all__ 已核验(2026-10-01):并用 code/02-dtypes/dtype_families.py --diff 与 _all_dtypes 做过集合比对,结果 exact match(173 条)
S8 tilelang/language/fp8.py:1-49 determine_fp8_type / determine_torch_fp8_type 的平台选择逻辑 已核验(2026-10-01)
S9 tilelang/language/allocate.py:49-193, 254-289 alloc_shared(含 bool workaround :60-62)、alloc_local、alloc_fragment、alloc_var、alloc_global、alloc_barrier、alloc_tmem 已核验(2026-10-01)
S10 tilelang/ascend/language/allocate.py:15-80, 103-125 昇腾 alloc_shared(UB)、alloc_l1、alloc_l0a、alloc_l0b、alloc_l0c、alloc_l0a_sf、alloc_l0b_sf 已核验(2026-10-01)
S11 tilelang/language/loop.py:13-357 Parallel / Persistent / Pipelined / serial / unroll / vectorized 与三个大写别名;order/stage 与可重放 Bind 的规则 已核验(2026-10-01)
S12 tilelang/cuda/language/loop.py:16-79 CUDA 方言的 Parallel(prefer_async=) 与 unroll(unroll_factor=) 已核验(2026-10-01)
S13 src/transform/loop_partition.cc:112-350 PartitionLoop、LoopPartitioner::Partition(:248-271 的映射公式)、PlanLoopPartition、LowerParallelLoop(:324-350) 已核验(2026-10-01)
S14 tilelang/language/tir/ir.py:172-186, 228 Select / min / max 的 docstring 与 if_then_else 的导出 已核验(2026-10-01)
S15 tilelang/language/logical.py:12-87 any_of(buffer) / all_of(buffer) 的真实签名(与 control_flow.md 的例子不一致) 已核验(2026-10-01)
S16 testing/python/language/test_tilelang_language_all_of.py:56, 105 T.all_of(<buffer slice>) 的正确用法 已核验(2026-10-01)
S17 tilelang/language/eager/builder.py:115-128, 812-855, 1075 unwrap_cond、boolop / ifexp 对 PrimExpr 的处理、eager 侧 macro 已核验(2026-10-01)
S18 tilelang/language/parser/entry.py:105 解析器侧 T.macro 已核验(2026-10-01)
S19 tilelang/language/customize.py:163-169 T.loop_break() 已核验(2026-10-01)
S20 资料库索引 昇腾 Quick Start、T.alloc_l1/l0c/alloc_shared、T.SimtVF、T.dual_copy、「Use T.alloc_shared for Unified Buffer storage」(:138)、「Synchronization: None」对照表(:151) 已核验(2026-10-01):未在 NPU 上运行
S21 docs/programming_guides/type_system.md:1-41 dtype 三种写法、常见标量类型、fp8/6/4 家族、lane 打包、累加 dtype 建议 已核验(2026-10-01)
S22 docs/programming_guides/python_compatibility.md:1-101 Python 兼容性总表(控制流 / 数据访问 / 赋值 / 函数与类 / 语句与内建) 已核验(2026-10-01)
S23 docs/programming_guides/control_flow.md:1-145 条件、循环、while、break/continue、LegalizeSafeMemoryAccess 的说明;T.all_of(i<M, j<N) 的过时例子在 :33-38(:141 又用了一次) 已核验(2026-10-01):已与 logical.py / 测试对照,确认不一致
S24 docs/programming_guides/language_basics.md:26-172 @T.prim_func、T.Tensor、T.Kernel 的注解规则、T.dyn / T.dynamic、内存作用域概述、向量加与 tiled GEMM 骨架 已核验(2026-10-01)
S25 docs/get_started/overview.md:76-90 「tile 是一等公民」、T.alloc_shared / T.alloc_fragment 的物理含义与 Layout Inference 的作用 已核验(2026-10-01)
S26 docs/deeplearning_operators/elementwise.md:28-135 「块级而非线程级」的解释;N=2047 时守卫阻止向量化的生成代码示例 已核验(2026-10-01)
S27 docs/tutorials/debug_tools_for_tilelang.md:52 「T.copy 先展开成 T.Parallel,再由 LowerTileOP 继续下降」 已核验(2026-10-01)
S28 examples/quickstart.py:1-60 GEMM+ReLU 骨架、T.Kernel(ceildiv(N,BN), ceildiv(M,BM))、T.max(C_local[i,j], 0) 的整数字面量用法 已核验(2026-10-01):API 逐个比对;未运行

「已核验」的含义:在锁定版本的工作树上用 rg / sed 定位到定义处,完整读过 签名与 docstring;表中的行号区间与文件内容一致。这不是运行验证。


适配器仓库引用(_vendor/tilelang-ascend-v0.1.2.000/)

id 引用位置 用途 核验方式与状态
A1 tilelang/language/allocate.py:126-157 scope 对照注释(shared / shared.l1 / shared.ub / wmma.matrix_a / wmma.matrix_b / wmma.accumulator)与 alloc_L1 / alloc_L0A / alloc_L0B / alloc_L0C / alloc_ub 的定义 已核验(2026-10-01):文件存在,逐行阅读;未在 NPU 上运行
A2 tilelang/language/__init__.py:47-51, 212-213 适配器仓库把上述五个函数导出到 T.,并在报错信息里提示可用名字 已核验(2026-10-01)

注意:主库 v0.1.15 的 tilelang/ascend/language/ 里没有 alloc_ub。 本章在正文与图上都把两套拼法分开列出,避免读者混用。


corpus 资料引用(资料库索引)

抓取日期:2026-09-30(Asia/Shanghai),见 corpus 的 资料库索引。 下表几份都是「官网 _sources 的原始 Markdown 快照」,与 _vendor/ 同名文件 内容一致(差异仅在部分非 ASCII 字符的编码)。

id 本地路径 与 _vendor/ 对应 核验状态
R1 TileLang 官方文档 · 资料清单 docs/programming_guides/type_system.md 已核验(2026-10-01):diff 仅编码差异
R2 TileLang 官方文档 · 资料清单 docs/programming_guides/python_compatibility.md 已核验(2026-10-01):同上
R3 TileLang 官方文档 · 资料清单 docs/programming_guides/control_flow.md 已核验(2026-10-01):同上
R4 TileLang 官方文档 · 资料清单 docs/programming_guides/language_basics.md 已核验(2026-10-01):同上
R5 TileLang 官方文档 · 资料清单 docs/deeplearning_operators/elementwise.md 已核验(2026-10-01)
R6 TileLang 官方文档 · 资料清单 docs/tutorials/debug_tools_for_tilelang.md 已核验(2026-10-01)
R7 TileLang 主干仓库 · 资料清单 tile-ai/tilelang 的完整 clone 已核验(2026-10-01):作为 _vendor/tilelang-v0.1.15/ 之外的旁证路径;本章结论一律以 _vendor/ 为准

上游链接(可达性未核验)

本章没有引用任何上游 URL 作为唯一依据:所有结论都落在 _vendor/ 或 corpus 的本地文件里。这里只列出与本章内容对应、未核验的上游页面, 供读者按需自行确认。

主题 上游 URL 核验状态
Python 兼容性 https://tilelang.com/programming_guides/python_compatibility.html 未核验(未抓取)
类型系统 https://tilelang.com/programming_guides/type_system.html 未核验(未抓取)
控制流 https://tilelang.com/programming_guides/control_flow.html 未核验(未抓取)
快速上手(language basics) https://tilelang.com/programming_guides/language_basics.html 未核验(未抓取)

本章的代码示例与验证级别

示例 验证级别 说明
code/01-kernel-grid/grid_index_model.py CPU 逻辑验证 在写作机上实际运行过,输出抄进正文与 README
code/01-kernel-grid/kernel_grid.py 仅静态核对 py_compile 通过 + API 逐个核对;未运行
code/02-dtypes/dtype_families.py CPU 逻辑验证 实际运行过;--diff 与 vendor 的 _all_dtypes 完全一致(173 条)
code/02-dtypes/fp8_fp4_exprs.py 仅静态核对 py_compile 通过 + dtype 符号与 helper 逐个核对;未运行
code/03-memory-scopes/scope_footprint.py CPU 逻辑验证 实际运行过,输出抄进正文与 README
code/03-memory-scopes/alloc_scopes.py 仅静态核对 py_compile 通过 + alloc_* 签名逐个核对;未运行
code/04-loops/loop_mapping.py CPU 逻辑验证 实际运行过;复现 loop_partition.cc 的映射公式
code/04-loops/loops_demo.py 仅静态核对 py_compile 通过 + 循环构造子签名逐个核对;未运行
code/05-control-flow/control_flow_demo.py 仅静态核对 py_compile 通过 + 控制流 API 逐个核对;未运行
code/06-python-boundary/py_compat_scan.py CPU 逻辑验证 实际运行过;--self-test 5/5 通过

本章发现的文档 / 源码不一致

以下两条是写作过程中发现的上游文档与锁定版本源码不一致之处。本章按 「以源码为准 + 显式提示」处理,建议在 audit/ 里登记,必要时向上游反馈。

位置 文档怎么说 v0.1.15 源码实际是什么 本章处理
docs/programming_guides/control_flow.md:33-38(又见 :141) if T.all_of(i < M, j < N): logical.py:12,51 的 any_of / all_of 接受 buffer / buffer 切片;测试也按缓冲区调用(test_tilelang_language_all_of.py:56) 正文 §7.3 显式指出,并给出 and 的替代写法
docs/programming_guides/control_flow.md:33 「Short-circuit boolean ops are supported.」 eager/builder.py:820-846 把 and/or/not 降级为 tir.And / tir.Or / tir.Not,不是求值短路 正文只写「and/or 可用并降级成 And/Or/Not」,不声称短路语义

未采用 / 待补资料

下面这些在 corpus 与 _vendor/ 内都没有可靠出处,本章因此没有给出具体 数字或结论。这是「不知道就写不知道」的落实,不是遗漏。

缺什么 为什么需要 建议来源
各型号昇腾 UB / L1 / L0A / L0B / L0C 的官方容量 正文 §5 与 scope_footprint.py 只能拿「192 KiB」当示例值 各型号《Ascend C 算子开发指南》架构规格章节,或真实设备上的 GetCoreMemSize 实测
NV 各代 shared memory / 寄存器文件在具体 tile 形状下的 occupancy 曲线 正文 §5.3 只能给出「128 寄存器/线程」这一个算术结论 NVIDIA Occupancy Calculator / tuning guide,或真实设备上的 occupancy API
T.Parallel 的 vector_size 由谁、按什么规则选定的第一手说明 正文 §6.3 只给出了映射公式,vector_size 的来源要靠 VectorizeLoop 的访存分析推断 上游 src/transform/loop_partition.cc 的 VectorizeLoop 注释,或维护者的 issue 答复
float8_e4m3 与 float8_e4m3fn 在昇腾后端上如何映射 正文 §4.3 只覆盖了 CUDA / ROCm 两条路线 昇腾后端文档或真实设备上的 dtype round-trip 实验
NVIDIA 与 Ascend 两棵树上 T.gemm 参数集合的完整差异 本章只用到 transpose_B / clear_accum 两个参数 第 9 章(核心原语)会展开,本章不重复

引用规则提醒

  • 源码引用一律写成 _vendor/tilelang-v0.1.15/<相对路径> 或 _vendor/tilelang-ascend-v0.1.2.000/<相对路径>,并给出行号。
  • 资料引用一律写成 资料库索引。
  • 「这个 API 在不在 v0.1.15 里」这类判断,一律以 _vendor/tilelang-v0.1.15/ 的实际文件为准;本章的 alloc_ub 结论就是这样得出的 (ls 与 rg 都确认主库 tilelang/ascend/language/ 下没有该名字)。

本章共 15 个二级小节;右上角目录在桌面端会跟随滚动高亮。