对应 Maps to:CMU 15-779(Fall 2025,Zhihao Jia),Lecture 9 · Fri 9/26 · ML Compilers Part 2: Kernel Autotuning。 必读论文:T. Chen, L. Zheng, E. Yan, Z. Jiang, T. Moreau, L. Ceze, C. Guestrin, A. Krishnamurthy, Learning to Optimize Tensor Programs(NeurIPS 2018,即 AutoTVM);L. Zheng, C. Jia, M. Sun, Z. Wu, C. H. Yu, A. Haj-Ali, Y. Wang, J. Yang, D. Zhuo, K. Sen, J. E. Gonzalez, I. Stoica, Ansor: Generating High-Performance Tensor Programs for Deep Learning(OSDI 2020)。选读:N. Vasilache et al., Tensor Comprehensions: Framework-Agnostic High-Performance Machine Learning Abstractions(2018)。 依据说明:本讲以原课 slides
09-ML-compilers-part-2.pdf的主线组织,三个部分依次是:
triton.autotune:模板 + 搜索;- AutoTVM:模板 + 学习的代价模型;
- Ansor:草图生成 + 标注搜索。
其中 TVM 的硬件感知搜索空间、张量化和学习型程序优化器几页注明取自 Tianqi Chen 的 slides。此外还讲到模板的问题、Ansor 的概览、程序采样、性能微调、消融实验,以及三者的比较表。论文里的数字只做转述。“假想 GPU”(HypoGPU)和它的隐藏性能模型、迷你 XGBoost、各种调优器的实现,以及全部实验,都是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:
code/lecture09/(4 个脚本 + 共享模块tuning.py;只需 numpy;01 号约 4 秒,其余 45 秒–1.5 分钟) 预计阅读时间:3–4 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标
学完这一讲,你应该能够:
-
把”给一个算子找最快的实现”写成一个优化问题,说出它和普通超参数搜索的三点不同。
-
用数字说明搜索空间的”地形”:好配置有多稀少、旋钮之间怎样相互影响、为什么不能穷举,也不能一个旋钮一个旋钮地分别调。
-
解释 Triton 的
@triton.autotune怎么工作、key的含义,以及”配置表由人写”带来的局限。 -
讲清楚 AutoTVM 的三个组件:
- 梯度提升树代价模型:用什么特征、为什么用排序损失;
- 模拟退火探索 + ε-贪心;
- 迁移学习:为什么要用”程序结构”而不是”旋钮”作特征。
-
讲清楚 Ansor 的三个组件:
- 草图生成规则;
- 随机标注;
- 在完整程序上做进化搜索,以及为什么不在半成品上剪枝。
并能解读它的消融实验和任务调度器。
-
比较 Triton autotune、AutoTVM、Ansor 在”搜索空间从哪里来”和”怎样搜索”两个维度上的差别。
0.2 先修与本讲范围
| 默认你已经会 | 本讲只给直觉,后面再展开 |
|---|---|
| 第 8 讲:Triton 的按块编程、TVM 的计算定义与调度原语 | 计算图级的改写与超优化(第 10–11 讲) |
| 第 6 讲:多级流水线、Little 定律 | 硬件和调度的联合搜索、动态形状的调优(延伸阅读) |
| 第 3–4 讲:占用率、波次、寄存器、共享内存 | 神经网络代价模型(TreeGRU、Transformer 代价模型)的细节 |
| 基本的机器学习概念:训练集、损失函数、过拟合 | 贝叶斯优化 |
0.3 本讲地图
§1 问题:在几十亿个等价程序里找最快的 ← 代码 01(演示 4)
§2 Triton 的 @triton.autotune:配置表 + 逐个实测 ← 代码 01(演示 1–3、5)
§3 TVM 的硬件感知搜索空间
§4 AutoTVM:学习的代价模型 + 模拟退火 + 迁移学习 ← 代码 02
§5 Ansor:草图生成 + 随机标注 + 进化搜索 ← 代码 03
§6 任务调度器:调优时间先给谁 ← 代码 04
§7 三者比较
§8 论文精读:AutoTVM;Ansor
§9 小结 §10 常见误区 §11 练习 §12 延伸阅读 §13 下一讲预告
0.4 关于”假想 GPU”
自动调优的实验需要一个真实耗时未知、只能实测的函数。本机没有 GPU,所以代码 tuning.py 造了一块”假想 GPU”(HypoGPU)。
它的规格量级取自 A100:
- 108 个 SM;
- 312 TFLOP/s FP16;
- 2 TB/s 显存带宽;
- 每块最多 163 KB 共享内存。
它有一个隐藏的性能模型,综合了前几讲讲过的效应:
- Tensor Core 吞吐、共享内存带宽、L2 带宽;
- 访存延迟与流水线级数(第 6 讲的 Little 定律);
- 寄存器溢出与占用率(第 3 讲);
- 波次量化;
- GROUP_M 对显存流量的影响(第 8 讲);
- split-K 的额外归约。
最后再乘一个由配置决定、只会让程序变慢的”说不清的扰动”,代表那些谁也没建模的细节。
调优器不许看这个模型,只能调用 Measurer.measure “上机实测”:每测一个配置记 1 秒编译时间,结果带 1.5% 的随机噪声。这和真实情况一样——AutoTVM 论文把问题描述为”我们不知道 f(x) 的解析式,只能在硬件上跑实验来查询它”。
需要强调:HypoGPU 的模型是为了制造一个”有结构、有交互、有噪声”的地形而设计的,它不预测任何真实 GPU 的性能。讲义里比较的是调优方法(测多少次能找到多好的配置),真实系统的性能数字全部引自论文。
1. 问题:在几十亿个等价程序里找最快的 · The tuning problem
1.1 直觉
第 8 讲的 TVM 把”算什么”(计算定义)和”怎么算”(调度)分开:同一个 ,可以有成千上万种循环组织方式。它们的结果相同,速度却能差几十倍。Triton 也一样:同一个矩阵乘法内核,BLOCK_M、BLOCK_N、BLOCK_K、num_warps、num_stages、GROUP_M 的不同组合速度差别很大。
自动调优(auto-tuning)就是让机器替人选。
1.2 形式化
AutoTVM 论文 §2 把问题写成:
- :一个张量算子的计算定义(index expression);
- :它的调度空间;
- :编译器, 是按调度 生成的低层程序;
- :程序在硬件上的真实耗时。
目标是
关键在于: 没有解析式,只能实测。
论文指出这个问题和传统的超参数搜索有三点不同:
- 实验相对便宜:编译、运行一个程序只要几秒(超参数搜索一次实验要几小时甚至几天)。所以可以收集上千条数据;但代价模型本身也必须足够快,否则还不如直接测。
- 有领域结构:程序有循环、访存、复用这些结构,可以据此设计模型,而不是把它当黑盒。
- 有大量相似的任务:一个网络里有很多形状不同的同类算子,适合迁移学习。
1.3 地形:好配置极少,旋钮相互影响(代码 01 演示 4)
tuning.py 的空间有 7 个旋钮,取值个数分别是 5 × 5 × 5 × 5 × 7 × 6 × 6,共 157,500 个配置。
在”方阵 4096³”上的地形:
| 统计 | 结果 |
|---|---|
| 编译期就能判定非法(共享内存放不下或 warp 分块太小) | 56,052 个(36%) |
| 合法配置 | 101,448 个 |
| 合法配置相对最优的慢速比:中位数 | 5.08× |
| 10% 分位 / 90% 分位 / 99% 分位 | 2.20× / 17.40× / 110× |
| 离最优 5% 以内的 | 24 个(0.024%) |
| 离最优 10% 以内的 | 255 个(0.25%) |
随手挑一个,中位数要慢 5 倍;随机挑 1000 个,碰到”5% 以内”那 24 个的概率只有约 22%(练习 5)。
更麻烦的是旋钮之间的交互。下表固定其余旋钮为最优值,只变 BLOCK_K 和 num_stages(单位:相对最优的慢速比,× 表示非法):
| BK \ stages | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 16 | 2.15 | 1.36 | 1.09 | 1.16 | 1.12 | 1.18 | 1.15 |
| 32 | 1.60 | 1.00 | 1.07 | 1.11 | 1.12 | 1.12 | 2.13 |
| 64 | 1.34 | 1.06 | 1.13 | 2.08 | 2.09 | 2.03 | × |
| 128 | 1.66 | 2.03 | 2.10 | × | × | × | × |
| 256 | 4.19 | × | × | × | × | × | × |
最好的级数随 BLOCK_K 变化:16→3、32→2、64→2、128→1。原因有两个:
- BLOCK_K 小时,每步算得少,要更多级数据同时在路上才能掩盖访存延迟(第 6 讲的 Little 定律,练习 1);
- BLOCK_K 大时,每级缓冲区大,级数多了要么放不下,要么占用率下降。
所以不能一个旋钮一个旋钮地分别调:“先找最好的 BK,再找最好的 stages”会错过组合最优。
穷举不可能:把 10 万个合法配置全测一遍,只算编译就要 28 小时。AutoTVM 论文说单个 GPU 算子的空间”可以达到几十亿”。
2. Triton 的 @triton.autotune:配置表 + 逐个实测 · Autotuning in Triton
2.1 用法
原课 slides 第 3–4 页的写法(转述):
@triton.autotune(
configs=[triton.Config({'BLOCK_SIZE_M': BM, 'BLOCK_SIZE_N': BN, 'BLOCK_SIZE_K': BK, 'GROUP_SIZE_M': 8},
num_stages=s, num_warps=w)
for BM in [128] for BN in [128, 256] for BK in [64, 128]
for s in [2, 3, 4] for w in [4, 8]],
key=['M', 'N', 'K'], # M、N、K 任何一个变了,就把所有配置重新测一遍
)
@triton.jit
def matmul_kernel(a_ptr, b_ptr, c_ptr, M, N, K, BLOCK_SIZE_M: tl.constexpr, ...):
...
两个要点:
- 第一次用某个 key 值(例如某个 (M, N, K))调用内核时,Triton 会把列表里的每个配置都编译、运行、计时,挑出最快的并缓存起来,以后同样的 key 直接用。
- slides 的评价是:Python 注解很好用,但”把所有配置枚举一遍很耗时”。
调优之所以安全,前提是配置只改变怎么算,不改变算什么。代码 01 演示 1 用第 8 讲的 minitriton 按 4 个不同的配置(包括 split-K = 4)真的算了一遍 200×300×100 的矩阵乘法:与 float64 参考的误差都是 3.8e-4,也就是 FP16 输出的舍入量级。配置之间只是加法顺序不同,舍入后偶尔差一个最小单位。
2.2 slides 上的 24 个配置在 HypoGPU 上(代码 01 演示 2)
先看合法性:24 个配置里有 12 个放不下 163 KB 的共享内存,需要 192–384 KB。例如 BN = 256、BK = 128、4 级流水 = 4 × (128·128 + 128·256) × 2 字节 = 384 KB。真 Triton 遇到这种配置会报 out of resources,autotune 把它记为无穷慢。
再看在 5 种形状上的结果:
| 形状 | 24 个里最好的 | 耗时 | 整个空间的最优 | 耗时 | 差距 |
|---|---|---|---|---|---|
| 方阵 4096³ | 128×128×128 w4 s2 | 496.9 µs | 64×128×32 w2 s2 | 464.4 µs | 1.07× |
| 预填充 QKV(512×12288×4096) | 128×128×128 w4 s2 | 202.8 µs | 128×128×32 w4 s4 | 199.6 µs | 1.02× |
| 解码 batch 16(16×4096×4096) | 128×128×128 w8 s2 | 53.8 µs | 16×64×256 w2 s4 | 22.0 µs | 2.45× |
| 小矩阵 512³ | 128×128×128 w4 s2 | 10.9 µs | 64×64×128 w4 s4 | 6.9 µs | 1.58× |
| 权重梯度 wgrad(512×512×32768) | 128×128×64 w4 s3 | 388.0 µs | 128×64×64 w2 s2 k16 | 85.9 µs | 4.51× |
这张配置表是为大矩阵写的:在方阵和预填充上已经很好。但它在两个形状上表现很差:
- 解码只有 16 行,BLOCK_M = 128 的块有 88% 是填充,白白多算 8 倍。最优配置用 BLOCK_M = 16。
- 权重梯度(,K 是 token 数)的输出只有 512×512。128×128 的块只有 16 个,108 个 SM 大多闲着。最优配置把 K 切成 16 段并行(split-K),而配置表里根本没有这个选项。
表里没有的配置,autotune 永远找不到。 搜索空间是程序员手写的。
2.3 key 的意义:最优配置不能跨形状照搬(演示 3)
把每个形状的最优配置拿到其他形状上运行,慢速比从 1.06× 到 6.66×,中位数 2.35×。没有一个配置在所有形状上都好,所以 key 变了就要重新调。
2.4 动态形状(演示 5)
LLM 解码时,一个 batch 里的 token 数 M 每一步都在变。M 从 1 变到 256 时,最优的 BLOCK_M 一路跟着变(16、16、16、16、16、32、64、128、64);如果一直用 M = 16 调出来的配置,在 M = 256 时慢 3.05 倍。
如果 key 里包含 M,每出现一个新的 M 都要现场测一遍配置表(约 25 秒),这在推理服务里不可接受。常见的折中有两种:
- 把 M 归到几个桶里(例如 2 的幂),离线调好;
- 把调优结果缓存下来。
2.5 小结
@triton.autotune 简单、可靠(每个候选都真的测过),但有两个问题:
- 搜索空间(配置表)要人写,写漏了的结构永远找不到;
- 搜索方法是穷举,空间稍大就测不起。
接下来的两篇论文分别解决”怎样少测几次”(AutoTVM)和”空间从哪里来”(Ansor)。
3. TVM 的搜索空间:硬件感知 · Hardware-aware search space
原课 slides 用 Tianqi Chen 的几页图说明 TVM 的搜索空间来自哪里。第 8 讲的调度原语(split、reorder、tile、数据布局)只是 CPU 上的一部分;面向 GPU 和加速器,TVM 的空间还包括:
- 多级分块:每个循环轴切成好几层,分别对应线程块、线程、寄存器。
- 线程绑定:哪个循环绑到
blockIdx/threadIdx。 - 内存层次:在共享内存里缓存一块输入(
cache_read),对应第 4 讲的共享内存分块。 - 展开和向量化:循环展开、向量化访存的标注。
- 张量化(tensorization):把一小块循环整体替换成硬件的张量指令,例如 Tensor Core 的矩阵乘加、加速器的 GEMM 单元。它的难点是:每种硬件的张量指令语义不同,要先把指令的语义声明成一个小的计算定义,编译器才能在程序里识别出能被替换的部分。
把这些选择组合起来,一个 GPU 算子就有”几十亿种可能的优化选择”(slides 的说法)。AutoTVM 论文的空间包括”每个循环轴上的多级分块、循环顺序、GPU 上的共享内存缓存,以及展开、向量化等标注”,规模可以达到几十亿。
我们的 7 个旋钮是对这个空间的大幅简化。Triton 版本的空间小得多,因为块内的线程分工交给了编译器(第 8 讲)。
4. AutoTVM:学习的代价模型 · Learning to optimize tensor programs
4.1 框架
最朴素的程序优化器是”生成候选 → 编译 → 实测 → 挑最快”。slides 指出它的问题:每次实验要几秒,实测太贵。
AutoTVM 在中间加了一个统计代价模型 (论文图 2):
┌───────────── 训练数据 D = {(e, s, c)} ←──────────┐
↓ │
探索模块 ──用 f̂ 打分──→ 挑一批候选 ──编译、实测──→ 真实耗时 c ────────┘
(模拟退火) (b 个)
论文 Algorithm 1 的循环如下:
D ← ∅
while 实测次数 < 上限:
Q ← 用 f̂ 当"能量",跑并行模拟退火,收集候选
S ← 从 Q 里挑 (1−ε)·b 个(可选:兼顾多样性)
S ← S ∪ {随机挑 ε·b 个}
对 S 里每个候选:编译、实测,把 (e, s, c) 加进 D
用 D 更新 f̂
预测一个配置只要几微秒,所以可以用模型在空间里”走”很多步,只把最有希望的那一小批拿去实测。slides 的说法是”靠学习适应硬件,在毫秒级做出预测”。
代码 02 演示 4(⏱ 本机)里:
- 用 512 条数据训练一次模型约 0.1 秒;
- 预测一个配置约 1–2 µs;
- 模拟的实测每个配置约 1.4 秒(编译 1 秒 + 运行 100 遍)。
两者相差约 10⁶ 倍。
4.2 代价模型:梯度提升树(给初学者的解释)
AutoTVM 的第一种模型是梯度提升树(gradient boosted trees,GBT),实现用的是 XGBoost。第二种是 TreeGRU,一个直接读程序语法树的神经网络。代码里自己写了一个迷你版 GBDT,下面按它解释。
决策树:一连串”是 / 否”问题,例如”共享内存 > 64 KB 吗?""K 方向步数 > 128 吗?“,每个叶子给一个预测值。树的问法(用哪个特征、在哪里切)是从数据里学出来的:每次挑一个能让两边的数据”最整齐”的切分。
提升(boosting):一棵浅树很粗糙。提升的做法是一棵接一棵地加树,每棵新树专门去修正前面所有树加起来的误差:
XGBoost 的二阶公式:每加一棵树,对每个样本算损失函数对当前预测的一阶导数 和二阶导数 。一个节点切分成左右两半的”增益”是
其中 、 是节点里所有样本的 、 之和, 是正则化系数。叶子的值取 ,相当于牛顿法走一步。
tuning.py 的 GBDT 就是这几行公式,再加上”直方图分桶”:把每个特征离散成至多 32 个桶,找切分点时只要对桶求和。
为什么 AutoTVM 选树模型?
- 训练和预测都很快,在 CPU 上就够;
- 对特征的尺度不敏感;
- 能自然地表达”如果共享内存超过某个值就很慢”这种阈值关系。
论文也指出它的代价:树模型依赖精心设计的特征。TreeGRU 不需要特征工程,但训练和预测慢,要用 GPU 加速。
4.3 特征:旋钮本身,还是程序的结构?(代码 02 演示 2)
最简单的特征是 7 个旋钮本身(取 log2)。另一种是程序特征:编译器从生成的程序里就能算出来、不需要知道硬件时间模型的 18 个量,例如:
- 线程块个数、每块线程数、共享内存大小、每线程的累加器个数;
- warp 分块大小、每步算多少 FLOP、每步搬多少字节、分块的算术强度;
- K 方向走几步、流水线级数、填充效率、有效的分组大小、split-K;
- 形状本身。
在 3000 个没见过的配置上测排序质量(Spearman ρ,1 = 预测排序与真实排序完全一致):
| 训练样本 | 方阵:旋钮特征 | 方阵:程序特征 | wgrad:旋钮特征 | wgrad:程序特征 |
|---|---|---|---|---|
| 64 | 0.67 | 0.84 | 0.34 | 0.77 |
| 256 | 0.85 | 0.93 | 0.75 | 0.91 |
| 1024 | 0.90 | 0.96 | 0.90 | 0.95 |
(排序目标;回归目标的数字见代码输出。)
程序特征在样本少的时候优势最大:它把”硬件真正关心的量”直接算了出来,模型不用再从 7 个旋钮的组合里自己”发现”它们。AutoTVM 的特征也是从低层循环程序里提取的:每层循环的长度、访存量、数据复用率,以及展开、向量化、线程绑定等标注。论文还把每层循环的特征向量看成一个”点的集合”,提取”循环长度 vs 访存量”之类的关系特征(context relation features),使特征不依赖具体的循环嵌套模式。
4.4 训练目标:排序还是回归?
调优只关心谁更快,不关心某个程序到底是 1.23 ms 还是 1.25 ms。AutoTVM 因此提供了排序损失(rank loss):
只要两个程序的预测顺序和真实顺序一致,损失就小;预测的绝对值不重要。论文图 5 显示,排序目标”比回归好或持平”,所以默认用排序。
代码里的 GBDT(objective="rank") 实现了这个损失:每个样本随机配 32 个对手,而不是枚举所有的对。在代码 02 演示 2 的 12 个设置里,排序目标的 ρ 有 10 个不低于回归目标。recall@30(模型挑的前 30 个里有几个真在前 30)则波动很大:在 3000 个配置里精确挑出前 30 名,比整体排序难得多。
4.5 探索:并行模拟退火 + ε-贪心
有了模型,也不能把整个空间都预测一遍:几十亿个配置,即使一个只要 1 微秒也要几十分钟,何况要反复做。AutoTVM 用模拟退火(simulated annealing)在空间里”走”:
- 开 128 条马尔可夫链,每条链在一个配置上;
- 每一步给每条链提一个”邻居”(随机改一个旋钮,换成相邻的取值);
- 如果模型预测邻居更快,就移过去;如果更慢,以概率 也移过去。 是预测吞吐的下降, 是”温度”,从高到低慢慢降。这让链能跳出局部最优。
- 把所有链走过的状态按预测排序,挑最好的 个没测过的。
论文还有三个细节:
- 链的状态在两批之间保留,不是每次重新开始;
- 每批随机挑 个()保证探索,否则模型可能永远不去看它”以为很差”的区域;
- 可以兼顾多样性:在前 个里挑一个子集,最大化”预测得分 + 覆盖了多少种不同的旋钮取值”(一个子模函数,用贪心近似求解)。论文发现大多数情况下多样性没有影响,有一个工作负载上有帮助,于是默认打开。
论文也试了贝叶斯优化常用的不确定性采集函数(EI、UCB,用 bootstrap 估计不确定性),发现没有帮助。
4.6 搜索效果(代码 02 演示 1)
三种策略各跑 5 次(不同随机种子),表中是”选中配置达到最优的百分比”的中位数:
| 方阵 4096³ | n=64 | 128 | 256 | 512 | 1024 | 到 95% 要测 |
|---|---|---|---|---|---|---|
| 网格搜索(字典序) | 19.2% | 19.3% | 19.3% | 19.3% | 19.1% | 没达到 |
| 随机搜索 | 62.0% | 84.7% | 87.4% | 92.5% | 92.8% | 没达到 |
| AutoTVM | 86.9% | 92.9% | 99.7% | 99.7% | 100.0% | 129 次 |
| 权重梯度 wgrad | n=64 | 128 | 256 | 512 | 1024 | 到 95% 要测 |
|---|---|---|---|---|---|---|
| 网格搜索 | 25.4% | 27.4% | 27.8% | 27.8% | 27.8% | 没达到 |
| 随机搜索 | 76.1% | 86.2% | 86.2% | 89.8% | 89.8% | 5 次里 4 次没达到 |
| AutoTVM | 78.7% | 86.4% | 93.0% | 97.6% | 98.9% | 257 次 |
三点读法:
- 网格搜索按字典序走,前 1024 个配置全是 BLOCK_M = 16 的小块。在大空间里按固定顺序枚举,是最差的策略。
- 随机搜索的问题在 §1.3 已经算过:好配置只占 0.02%,撞上要靠运气。
- AutoTVM 的第一批(64 个)也是随机的,所以和随机搜索差不多;从第二批开始,模型把实测集中到有希望的区域,差距迅速拉开。
AutoTVM 论文图 4(TITAN X 上的 ResNet-18 卷积)也是这个形状:GBT 和 TreeGRU 都比遗传算法和随机搜索收敛得快、结果好,找到的算子比随机搜索快约 2 倍。论文特别指出:在一般的超参数调优文献里,基于模型的方法往往只和随机搜索打平,这里能明显胜出,靠的是领域相关的建模。
曲线偶尔不单调(例如从 99.7% 变成 99.6%),是因为测量有噪声:新测到的配置可能”测得快、其实慢”,被选中后真实耗时反而变大。§6.4 会专门讨论。
4.7 迁移学习(代码 02 演示 3)
一个网络里有很多形状不同的矩阵乘法 / 卷积。每个都从零调起,浪费了之前测过的数据。AutoTVM 的做法是把模型拆成两部分:
- 用之前所有工作负载的历史数据 训练;
- 只用当前任务的数据,学”全局模型在这里错在哪”。
能迁移的前提是跨任务不变的表示:
- “旋钮”不是这样的表示。同一个 BLOCK_M = 128,在 M = 16 和 M = 4096 上意义完全不同;而且换一个搜索空间,旋钮就变了。
- “低层循环程序的特征”(循环长度、访存量、复用率)在不同形状之间含义相同。
代码 02 演示 3 先在 4 个源形状(方阵、FFN 上投影、小矩阵、解码)上各调 512 次,用这些数据训练全局模型,然后在 3 个目标形状上每批测 16 个:
| 目标(程序特征) | n=16 | 32 | 64 | 128 | 256 | |
|---|---|---|---|---|---|---|
| 预填充 QKV | 不迁移 | 61.1% | 71.3% | 79.6% | 97.4% | 99.6% |
| 迁移 | 100.0% | 100.0% | 99.8% | 99.8% | 98.7% | |
| FFN 下投影 | 不迁移 | 75.3% | 81.8% | 91.4% | 91.4% | 94.5% |
| 迁移 | 73.2% | 82.9% | 84.1% | 91.8% | 95.1% | |
| 权重梯度 wgrad | 不迁移 | 63.8% | 69.5% | 78.9% | 90.0% | 90.0% |
| 迁移 | 47.9% | 58.1% | 77.6% | 88.2% | 96.5% |
迁移学习的效果取决于源任务和目标任务有多像:
- 预填充 QKV 和源任务里的”FFN 上投影""方阵”很像,迁移之后第一批 16 个就找到了最优配置。AutoTVM 论文报告迁移学习把优化加速 2–10 倍,指的就是这种情况。
- FFN 下投影(K = 11008)和 wgrad 的最优配置都用 split-K,而 4 个源任务的最优配置都不用。全局模型从没见过”split-K 很有用”的数据。在 wgrad 上,前 64 次迁移都不如不迁移,要等本地模型学会,到 n = 256 才反超。
- 用旋钮特征做迁移,第一批反而很差(预填充 QKV 只有 28.6%)。这正是论文强调”不变的表示”的原因。
5. Ansor:不写模板 · Generating the search space automatically
5.1 模板的问题
slides 第 26 页指出 TVM 和 Triton 共同的问题:搜索空间由手写的模板定义。这带来两个后果:
- 不是全自动:每个新算子、每种新硬件都要专家写模板,工作量巨大。Ansor 论文提到 TVM 代码库里这类模板已经超过 1.5 万行,而且随着新算子、新硬件还在增长。
- 空间有限:模板没写到的结构永远搜不到,结果不是最优。
另一类做法是 Halide auto-scheduler 式的顺序构建(sequential construction):
- 把程序的生成拆成一串固定顺序的决策(先决定这个节点怎样分块,再决定那个节点……);
- 每一步用代价模型给不完整的程序打分,只保留前 k 个(束搜索,beam search)。
它不需要模板,但 Ansor 论文指出三个问题:
- 代价模型是在完整程序上训练的,给半成品打分不准(§5.5);
- 固定的决策顺序限制了空间,例如加缓存节点、rfactor 这类会改变图结构的变换,很难排进固定的顺序;
- 空间越大,决策链越长,累积误差越大。
5.2 层次化的搜索空间:草图 + 标注
Ansor 的回答是把空间分成两层(slides 第 29 页):
- 草图(sketch):程序的高层结构,包括哪些循环分块、分几层、哪些节点融合、要不要加缓存、要不要拆归约。一个计算图只有少数几个草图。
- 标注(annotation):几十亿种低层细节,包括分块大小、并行、展开、向量化。
采样过程是:计算图 → 基于规则的草图生成 → 若干草图 → 随机标注 → 完整程序。之后再对完整程序做微调(§5.6)。
5.3 草图生成规则(代码 03 演示 1)
Ansor 从计算图的输出往输入逐个节点处理,对每个节点检查一组派生规则的条件,满足就应用(论文表 1):
| 规则 | 条件 | 做什么 |
|---|---|---|
| 1 跳过 | 节点不能直接内联 | 什么也不做,处理下一个节点 |
| 2 总是内联 | 节点是简单的逐元素运算,能内联 | 内联进消费者 |
| 3 多级分块 | 节点有数据复用(如矩阵乘法、卷积) | 按固定的分块结构展开循环 |
| 4 多级分块 + 融合消费者 | 有数据复用,且后面跟着可融合的逐元素运算 | 分块,并把消费者融合进来 |
| 5 加缓存写 | 有数据复用,但没有可融合的消费者 | 加一个缓存阶段(寄存器累加、最后写回) |
| 6 归约分解(rfactor) | 空间并行度少、归约很长 | 把归约拆成几段并行,再加一个归约节点 |
多级分块的结构:
- CPU 上是 “SSRSRS”:S 是空间循环的一层分块,R 是归约循环的一层分块。矩阵乘法的 3 层循环 因此展开成 10 层。
- GPU 上改成 “SSSRRSRS”:前三层空间分块分别绑定到 blockIdx、虚拟线程(用来减少 bank 冲突)和 threadIdx。GPU 上还加了两条规则:插入缓存节点以使用共享内存(类似规则 5),和跨线程归约(类似规则 6)。
tuning.py 的 generate_sketches 只实现了规则 3、4、5、6 针对矩阵乘法的子集:
- 方阵 C = A·B:有数据复用、没有消费者 → 规则 3 + 规则 5,1 个草图。
- C = relu(A·B):有可融合的消费者 → 规则 4,relu 融合进写回,1 个草图。
- 权重梯度 dW = Xᵀ·dY(512×512,K = 32768):输出很小而归约很长,规则 6 也满足 → 2 个草图,多出来的是”split-K + 第二个归约内核”。
最后这个正是代码 01 里 slides 的配置表缺少、因而慢了 4.5 倍的结构。草图里所有数字(分块大小、级数、split 段数)都留空。用 minitriton 执行 rfactor 草图的程序(96×80,K = 2048),split-K = 1、4、16 的误差都是 3.6e-4:拆开归约只改变加法的分组。
5.4 随机标注(代码 03 演示 2)
给草图的每个空随机填一个合法的值,就得到一个完整程序。Ansor 均匀地采样,目的是覆盖整个空间,给每个结构一个被选中的机会。
但随机标注的程序大多很差:1000 个随机程序的中位数只达到最优的 31%(方阵)和 28%(wgrad)。所以采样只是起点,好程序要靠微调。
5.5 为什么不在半成品上剪枝(代码 03 演示 3)
这是 Ansor 论文 §2 的核心论据(论文图 3)。代码里重现了这个实验:
- 在 2000 个完整程序上训练代价模型;
- 对另外 1000 个程序,只保留前 k 个决策(split-K → BM → BN → BK → num_warps → num_stages → GROUP_M),其余旋钮填”还没做这个变换”的默认值;
- 看模型能不能分辨它们最终的快慢。
| 已决定的旋钮数 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7(完整) |
|---|---|---|---|---|---|---|---|---|
| 方阵:成对准确率 | 50% | 50% | 60% | 61% | 62% | 83% | 94% | 95% |
| 方阵:recall@10 | 0% | 0% | 0% | 0% | 0% | 20% | 40% | 40% |
| wgrad:成对准确率 | 50% | 63% | 67% | 69% | 71% | 78% | 92% | 92% |
成对准确率是”随便取两个程序,模型判断谁快,判断对的比例”,50% 等于瞎猜。只决定了一半旋钮时,模型几乎是在瞎猜;recall@10 在程序完整之前几乎是 0。
原因很简单:模型只能在完整程序上训练,因为只有完整程序能编译、能测出标签。它不知道”BM = 128、其余待定”的程序最后能有多快——那取决于还没做的决定。论文的两条曲线也是从 50% 和 0% 出发,随着程序变完整而迅速上升。
5.6 性能微调:进化搜索 + 学习的代价模型
Ansor 的微调是进化搜索(evolutionary search):
- 初始种群 = 随机采样的程序 + 之前实测最好的程序;
- 每一代按”适应度 ∝ 代价模型预测的吞吐”选父代,对选中的父代做下列操作之一,产生子代:
| 操作 | 论文的含义 | 代码里的对应 |
|---|---|---|
| 分块大小变异 | 把一层分块除以一个因子、乘到另一层(保持乘积不变,所以总是合法) | BM / BN / BK 乘或除以 2 |
| 并行变异 | 改变并行的粒度(融合相邻循环或再切分) | 改 GROUP_M / SPLIT_K |
| pragma 变异 | 改编译器 pragma,例如 auto_unroll_max_step=N | 改 num_stages / num_warps |
| 计算位置变异 | 改变一个不分块的节点(如卷积的 padding)在哪一层循环里算 | (矩阵乘法里没有) |
| 节点交叉 | 程序的”基因”是它的改写步骤;按计算图的节点从两个父代各取一部分改写步骤 | 按”分块 / pragma / rfactor”三组旋钮各取一方 |
几代之后,挑预测最好的一小批去实测,再用实测数据重新训练模型。
Ansor 的代价模型也是梯度提升树,但有三点不同:
- 它预测程序里每个最内层语句的得分再加起来;
- 吞吐按每个计算图归一化到 [0, 1];
- 损失函数是以吞吐为权重的平方误差,让模型更在意快的程序。
每次都重新训练(数据通常少于 3 万条,训练很快)。代码里
AnsorTuner用的就是”以吞吐为权重的回归”。
进化操作是针对张量程序专门设计的,能处理有复杂依赖的空间;它们可以乱序修改程序,不受顺序构建的限制。
5.7 消融实验(代码 03 演示 4)
Ansor 论文图 7 用 ResNet-50 最后一个卷积(batch 16)比较了 4 个变体,每条曲线是 5 次的中位数:
- “Ansor(完整)”;
- “束搜索”(采样时用代价模型剪掉半成品,不微调);
- “不微调”(只靠随机采样);
- “有限空间”(把空间限制成类似手写模板:限制分块层数、最内层分块大小和计算位置)。
结论(slides 第 35 页)有三条:
- 最重要的因素是搜索空间;
- 微调能改进搜索结果;
- 能用约十分之一的时间达到 AutoTVM 的性能。
我们的模拟(每个变体测 1024 次,5 次运行的中位数):
| 变体 | 方阵 n=128 | 方阵 n=1024 | wgrad n=256 | wgrad n=1024 |
|---|---|---|---|---|
| Ansor(完整) | 95.0% | 99.8% | 90.5% | 99.0% |
| 束搜索(给半成品打分剪枝) | 94.8% | 99.7% | 76.6% | 97.0% |
| 不微调(只随机采样) | 99.1% | 100.0% | 92.7% | 97.2% |
| 有限空间(无 rfactor 草图) | 95.0% | 99.8% | 55.0% | 56.6% |
- 搜索空间最重要:有限空间在 wgrad 上停在 56.6%,最好的程序根本不在空间里,算法再好也没用。在不需要 rfactor 的方阵上,它和完整的 Ansor 一样。
- 束搜索在 wgrad 上前 256 次只有 76.6%:它按 §5.5 那种不准的分数剪枝,早期把有希望的结构剪掉了;数据多了以后才追上来。
- “不微调”在我们的模拟里并不差,在方阵上甚至更快到 99%。这和论文不一致。原因是我们的空间只有 15.75 万个配置,每轮随机采 1,280 个、再让代价模型挑,已经覆盖得很密。Ansor 的真实空间大几个数量级,随机样本很难落到好程序附近,“在好程序的邻居里找”的进化搜索价值才显现出来。我们没有为了复现论文去缩小采样数,这一条在代码 README 里如实记录。
6. 任务调度器:调优时间先给谁 · Task scheduler
6.1 问题
一个网络被划分成很多子图,每个子图的调优是一个任务。Ansor 论文举的例子是 ResNet-50,划分后有 29 个不同的子图。有些任务不值得多花时间:
- 它不是瓶颈(在端到端延迟里占比很小);
- 或者调了也快不了多少。
平均分配(轮流)会把时间浪费在这些任务上。
6.2 梯度近似
设任务 已经分到 个时间单位(一个单位 = 给它跑一轮调优),目前最好的延迟是 ,它在网络里出现 次。端到端延迟
调度器每次把下一个单位给 最大的任务。导数没法直接算,论文用下式近似:
- 第一项向后看:最近 个单位里实际降了多少。
- 第二项是乐观的猜测,有两种:
- “再花一样多的时间,也许还能降这么多”();
- “如果它能达到相似任务 里最好的吞吐 ,还能降多少”( 是任务 的浮点运算量)。
此外,开始时先轮流热身一轮,之后以概率 随机挑任务。论文的目标函数还可以换成”多个网络的总延迟""满足延迟要求即可""对参考延迟的几何平均加速”等(论文表 2)。
6.3 一个玩具网络上的实验(代码 04)
网络由三部分组成,共 11 个矩阵乘法任务:
- 一个 7B 规模解码器的预填充(512 个 token,32 层);
- 一个解码步(batch 16);
- 挂在 4 个投影上的 LoRA 适配器。
按真实最优计算,端到端是 35.75 ms,其中最大的 4 个任务(预填充 FFN 上、QKV、FFN 下、解码 FFN 上)占 75%。LoRA 和解码任务的算术强度只有 15–16 FLOP/字节,是访存受限的;预填充是 410–449,是计算受限的(HypoGPU 的拐点约 156)。
一共 44 个时间单位,每个单位实测 64 个程序,5 次运行的中位数:
| 策略 | 分配(预 QKV / 预 O / 预上 / 预下 / LM head / LoRA-A / LoRA-B / 解 QKV / 解 O / 解上 / 解下) | 第 15 个单位 | 第 44 个单位 |
|---|---|---|---|
| 轮流 | 4 / 4 / 4 / 4 / 4 / 4 / 4 / 4 / 4 / 4 / 4 | 96.0% | 97.6% |
| 梯度(相似 = 全部任务) | 3 / 2 / 5 / 3 / 1 / 1 / 1 / 1 / 1 / 25 / 1 | 95.1% | 96.6% |
| 梯度(相似 = 强度接近,本讲义的改动) | 7 / 3 / 12 / 7 / 1 / 2 / 1 / 2 / 1 / 4 / 2 | 95.1% | 97.7% |
结果和预期不一样。照公式、把所有矩阵乘法都当成”相似任务”时,调度器把 25/44 个单位给了”解码 FFN 上投影”,结果比轮流还差。
问题出在公式第二项的假设:它认为任务 能达到相似任务里最高的 FLOP/s。解码任务的算术强度只有 16,是访存受限的,永远到不了预填充那样的 FLOP/s;于是调度器一直”乐观地”以为它还能快很多。
把”相似”改成”算术强度相差不到 4 倍”(这是本讲义的改动,不是论文的做法)之后,时间主要给了贡献最大的几个预填充任务,分配合理了。但端到端只有 97.7%,和轮流(97.6%)几乎一样。
为什么在这里收益很小?
- 在 HypoGPU 上,每个任务调两轮就有 95% 左右,之后收益递减;
- 测量噪声让”多测”不一定”更好”(§6.4)。
Ansor 论文表 3 报告,在真实网络上(Intel CPU,batch 1)Ansor 达到 AutoTVM 性能所需的实测次数少 2.7–16.5 倍(ResNet-50:21,220 vs 6,403;MobileNet-V2:31,272 vs 1,892),墙钟时间少 3.3–88.6 倍。那是任务调度、微调和更大的空间加在一起的效果;论文图 10 的消融里,带任务调度器的完整 Ansor 在搜索效率和最终性能上都最好,因为它优先调优了瓶颈子图(例如含 3×3 卷积的子图)。
教训:启发式公式背后的假设,要和工作负载相符。
6.4 测量噪声的”赢家诅咒”(代码 04 演示 3)
调优器选的是”实测最快”的配置。在几百个候选里取最小值,挑出来的往往是噪声刚好往下偏的那个。在一次轮流调度的结果里:
- 11 个任务的”实测”全都比”真实”快,平均快 2.4%;
- 测得越多,这个偏差越大。§6.3 里分到更多时间的任务不一定更好,这是原因之一。
补救办法是对最终候选多测几次:
- 把实测最快的 5 个各重测 5 次、取平均后重选,6 个任务变好(最多改善 1.0%),没有变差的;
- 剩下的差距主要不是噪声,而是没搜到。例如 LoRA A 选中的配置慢 23%,重测也救不回来。
7. 三者比较 · Triton vs AutoTVM vs Ansor
slides 最后一页的比较表:
| 搜索空间 | 搜索算法 | |
|---|---|---|
triton.autotune | 手写的配置表 | 逐个实测(穷举) |
| AutoTVM | 手写的模板 | 学习的代价模型 |
| Ansor | 草图生成 + 随机标注 | 学习的代价模型(+ 进化搜索) |
代码 03 演示 5 把三者放在同一个 HypoGPU 上比较(Triton 用 slides 的 24 个配置;AutoTVM 在”多级分块、没有 rfactor”的模板空间里测 1024 次;Ansor 测 1024 次;3 次运行的中位数):
| 形状 | Triton(24 个配置) | AutoTVM(模板空间) | Ansor(草图空间) |
|---|---|---|---|
| 方阵 4096³ | 93.5% | 99.7% | 99.7% |
| 解码 batch 16 | 40.8% | 99.0% | 98.4% |
| 小矩阵 512³ | 63.2% | 99.9% | 99.6% |
| 权重梯度 wgrad | 22.1% | 56.3% | 99.0% |
差别正好落在表的两列上:
- Triton 的配置表:在它设计时考虑的大矩阵上不错,在其他形状上很差。
- AutoTVM:模板空间比配置表大得多,又有代价模型,前三个形状都在 99% 左右;但 wgrad 需要模板里没有的 split-K,停在 56%。
- Ansor:草图规则自动加上了 rfactor。
注意这里的”模板空间”是为对比设计的,不代表 TVM 真实的矩阵乘法模板:TVM 也为小 batch 写过带跨线程归约的专门模板。论点是一般性的——模板写到哪里,空间就到哪里。
三者各有用途:
- Triton autotune 最简单,适合”内核作者心里已经有几个候选”的情况;
- AutoTVM / Ansor 适合要为很多算子、很多硬件生成代码的编译器。
TVM 后来的 MetaSchedule 把 Ansor 的思路和”可以由人写的调度规则”统一了起来(延伸阅读)。
8. 论文精读 · Paper reading
8.1 Learning to Optimize Tensor Programs(Chen et al., NeurIPS 2018,AutoTVM)
1. 问题:深度学习依赖手工优化的算子库(如 cuDNN),只覆盖少数服务器 GPU。新硬件、新算子、算子融合产生的新组合都缺少高性能实现。为每种硬件手写库需要巨大的工程量。
2. 想法:
- 把”为一个算子找最快的程序”形式化为在调度空间 上最小化真实耗时 ,而 只能实测(§1.2)。
- 用学习的统计代价模型(梯度提升树或 TreeGRU)预测程序的耗时,引导搜索:
- 特征取自低层循环程序;
- 用排序损失训练;
- 探索用并行模拟退火,加 ε-贪心和多样性。
- 用跨工作负载不变的表示(循环程序的上下文关系特征)做迁移学习:全局模型 + 本地模型。
3. 验证:
- 组件评估:ResNet-18 的卷积,TITAN X 上。
- 代价模型比遗传算法和随机搜索收敛快,找到的算子比随机搜索快约 2 倍;
- 排序目标比回归好或持平;
- 多样性大多无影响;
- 不确定性采集函数没有帮助;
- 迁移学习把优化加速 2–10 倍。
- 端到端:在低功耗 CPU、移动 GPU、服务器 GPU 上与手工优化的库有竞争力,比现有框架快 1.2–3.8 倍。
4. 从系统的角度看:AutoTVM 把”写高性能算子库”变成了”写搜索空间 + 让机器学硬件”。这里最关键的系统洞察是用便宜的预测代替昂贵的实测:几微秒对几秒,相差约 10⁶ 倍。它和第 8 讲的 TVM 论文一起,确立了”调度空间 + 代价模型 + 搜索”的 ML 编译器范式。
5. 局限:
- 搜索空间仍由人写的模板定义(Ansor 要解决的问题);
- 每个新形状都要调,调一个网络要几小时;
- 代价模型依赖特征工程。
8.2 Ansor: Generating High-Performance Tensor Programs for Deep Learning(Zheng et al., OSDI 2020)
1. 问题:现有方法要么依赖手写模板(TVM、FlexTensor),空间有限;要么按顺序构建程序、用代价模型对不完整的程序激进剪枝(Halide auto-scheduler),因为模型对半成品打分不准而错过好程序。两者都会漏掉很多有效的优化组合。
2. 想法:
- 层次化的搜索空间:用几条派生规则自动生成草图(高层结构),再随机标注低层细节。不需要模板,并且均匀地采样完整程序,给每个结构一个被选中的机会。
- 在完整程序上微调:进化搜索(分块大小、并行、pragma、计算位置的变异,加上按节点的交叉),以学习的代价模型为适应度,避开对半成品打分。
- 任务调度器:用梯度近似把调优时间分给网络里最能降低端到端延迟的子图。
3. 验证:
- 单算子(10 种算子 × 4 种形状 × 2 种 batch,Intel CPU)上,在所有设置下都是最好或并列最好,比其他搜索框架快 1.1–22.5 倍。论文解释说,多数算子的最优程序在其他框架的搜索空间之外;例如矩阵 2-范数(NRM)的大幅加速来自把归约循环并行化——正是我们 wgrad 例子里 rfactor 的作用。
- 子图(卷积 + BN + ReLU;转置 + 批量矩阵乘法 + softmax;CPU 和 V100)上,比手工库和其他搜索框架快 1.1–14.2 倍。
- 端到端:在 Intel CPU、ARM CPU、NVIDIA GPU 上分别最多快 3.8 倍、2.6 倍、1.7 倍。
- 达到 AutoTVM 的性能所需的实测次数少一个数量级(表 3)。
- 消融实验表明搜索空间是最重要的因素(§5.7)。
4. 从系统的角度看:Ansor 的核心贡献是把”搜索空间”本身也自动化了。它的两条论据都有实验支持:
- 草图生成说明,空间可以由规则自动推出,而不必手写;
- 图 3 说明,代价模型只在完整程序上可靠,所以应该”先采样完整程序、再微调”,而不是”边构建边剪枝”。
它是 TVM 里 auto-scheduler 的基础,后来发展为 MetaSchedule。
5. 局限:
- 规则覆盖不到的结构(例如 Tensor Core 这种特殊硬件指令)仍要人写规则;
- 针对静态形状,动态形状要为每个形状调;
- 调一个网络仍需上千次实测;
- 只在张量程序层面优化,计算图的结构不变——第 10–11 讲会看到,改写计算图本身也能带来很大的收益。
8.3 两篇论文之间的联系
两篇论文分工明确:
- AutoTVM 回答”怎样搜”:用学习的代价模型把实测集中在有希望的地方,并用迁移学习复用历史数据;
- Ansor 回答”在哪里搜”:用规则自动生成更大的空间,并坚持在完整程序上搜索。
Ansor 也沿用了 AutoTVM 的代价模型思想(梯度提升树)。两者的共同前提是”实测便宜到可以做上千次、但贵到不能做几百万次”。
它们和本课程的暗线一致,都是把专家的手工技巧变成搜索空间 + 代价模型 + 正确性保证:
- 这里的正确性靠”调度不改变计算定义”来保证;
- 第 10 讲的 TASO 把同样的思路用到计算图的改写上,正确性要另外验证。
9. 小结 · Summary
-
自动调优:在调度空间里找最快的程序,真实耗时只能实测。和超参数搜索相比:实验便宜、有结构、相似任务多。
-
地形很难:HypoGPU 上好配置只占 0.02%,中位数的配置慢 5 倍,旋钮之间有交互,穷举要 28 小时(真实空间几十亿)。
-
Triton
@triton.autotune:在手写的配置表里逐个实测,key 变了就重调。简单可靠,但表里没有的结构永远找不到(wgrad 慢 4.5 倍),动态形状下代价高。 -
AutoTVM:
- 学习的代价模型(梯度提升树,特征取自程序结构,排序损失);
- 模拟退火 + ε-贪心;
- 迁移学习。
在 HypoGPU 上测 256 次就到 99.7%,随机搜索测 1024 次只有 92.8%。迁移学习对相似的形状很有效,对结构不同的形状反而可能拖后腿。
-
Ansor:
- 规则生成草图(多级分块、融合、缓存写、rfactor);
- 随机标注保证覆盖;
- 进化搜索在完整程序上微调——因为代价模型给半成品打分不准,只决定一半旋钮时成对准确率约 62%,接近瞎猜。
消融实验:搜索空间最重要。
-
任务调度器按梯度近似分配调优时间。它的乐观假设在访存受限的任务上会失效;测量噪声带来”赢家诅咒”,最终候选应该重测。
10. 常见误区 · Common misconceptions
- “自动调优可能改变计算结果。” 不会。配置只改变循环的组织和加法的顺序(浮点舍入差异除外)。代码 01 演示 1 里 4 个配置的误差都是 FP16 的舍入量级。split-K 也只是改变加法的分组。
- “一个旋钮一个旋钮地调就行。” 旋钮之间有交互:最好的 num_stages 随 BLOCK_K 变化(§1.3)。坐标轮换式的调法会停在组合次优的地方。
- “调好一次,所有形状都能用。” 跨形状照搬最优配置,中位数慢 2.35 倍,最多 6.66 倍。这就是 autotune 的
key。 - “代价模型要准确预测耗时。” 只要排序对就够了,所以 AutoTVM 默认用排序损失。我们的实验里排序目标在 10/12 个设置里不差于回归。
- “迁移学习总是有帮助。” 只有源任务和目标任务相似时才有帮助。wgrad 需要 split-K、源任务都不用,迁移在前 64 次反而更差。
- “束搜索每步保留前 k 个,总不会比随机差。” 剪枝依据的是代价模型对半成品的打分,而它接近瞎猜(§5.5)。早期剪掉的结构再也回不来。
- “测得越多,选出来的越好。” 有测量噪声时,“实测最快”系统性地偏乐观(赢家诅咒,§6.4)。要对最终候选重测。
- “Ansor 比 AutoTVM 好是因为进化搜索。” 消融实验表明最重要的是搜索空间。在我们的小空间里,“不微调”甚至和完整的 Ansor 一样好。
11. 练习 · Exercises(含参考答案)
练习 1 (Little 定律)在 HypoGPU 上,64×128 的块、num_warps = 2,一个块只能用 4 个 Tensor Core 子分区中的 2 个。每一步(沿 K 走 BLOCK_K):
- Tensor Core 时间 = 周期;
- 一次读取的延迟 ≈ 500 + 搬运时间,搬运时间 = 周期。
估算 BK = 16 和 BK = 32 时至少要几级流水,并和 §1.3 的表对照。
答案:
- BK = 16:计算 256 周期,延迟 500 + 128 = 628 周期。要让延迟被掩盖,需要 (级数 − 1) × 256 ≥ 628,即级数 ≥ 3.5。同一个 SM 上还有别的块帮忙掩盖延迟,所以 3 级就接近最好(表中 3 级 1.09,2 级 1.36)。
- BK = 32:计算 512 周期,延迟 500 + 256 = 756 周期,需要级数 ≥ 2.5;同样有别的块帮忙,2 级最好(表中 2 级 1.00)。
结论:BK 越小,每步的计算越短,要掩盖同样的延迟就要更多级。
练习 2 (XGBoost 增益)一个节点里有 4 个样本,一阶梯度 ,二阶梯度都是 1,。按”前两个 | 后两个”切分,增益是多少?叶子值各是多少?
答案:
- 不切分时 ,得分 。
- 左边 ,得分 ;右边 ,得分 3。增益 = 3 + 3 − 0 = 6。
- 叶子值:左 ,右 。
平方损失时 :左边两个样本的预测比目标低,所以叶子值为正,把预测往上拉。
练习 3 (排序 vs 回归)三个程序的真实耗时是 1.0、1.1、5.0 ms。模型 A 预测 1.05、1.04、4.9;模型 B 预测 0.5、0.8、2.0。哪个模型的平方误差小?哪个对调优更有用?
答案:
- A 的平方误差很小(约 0.0007 + 0.0036 + 0.01),B 的很大。
- 但 A 把前两个的顺序弄反了,会让调优器选到 1.1 ms 的程序;B 的顺序完全正确,会选到 1.0 ms 的程序。
调优只需要顺序,所以排序损失更合适。
练习 4 (模拟退火)当前配置的预测吞吐是 0.80,邻居是 0.78,温度 。移到邻居的概率是多少?温度降到 0.005 呢?
答案:;。温度高时经常接受变差的移动、能跳出局部最优;温度低时几乎只往好的方向走。
练习 5 (随机搜索要测多少次)HypoGPU 方阵上离最优 5% 以内的配置占合法配置的 0.024%。随机测 1024 次至少碰到一个的概率是多少?要有 95% 的把握,要测多少次?
答案:
- ;
- 次。
对照代码 02:随机搜索 1024 次后中位数只有 92.8%。AutoTVM 不靠撞运气,而是让模型告诉它往哪里走。
练习 6 (草图规则)对下面两个计算图,按 §5.3 的规则表说出会生成哪些草图:(a) , 是 4096×4096, 是 4096×4096;(b) 对一个 16×1,000,000 的矩阵按行求和。
答案:
- (a) 加 bias 和 relu 是可以内联的逐元素运算(规则 2),合并成矩阵乘法的消费者。矩阵乘法有数据复用、又有可融合的消费者 → 规则 4(多级分块 + 融合消费者)。输出很大、归约不算长,不满足 rfactor 的条件。共 1 个草图。
- (b) 按行求和没有数据复用(每个元素只读一次),不做多级分块(规则 1 跳过)。输出只有 16 个、归约长达 100 万,满足规则 6 → 生成 rfactor 草图,把每行的求和拆给很多线程并行,再归约(GPU 上就是”跨线程归约”规则)。
练习 7 (任务调度)两个任务:任务 1 出现 32 次,当前 µs,已分到 个单位,最近 2 个单位从 205 降到 200;任务 2 出现 128 次, µs,,没有历史。取 ,第二项只用 。下一个单位给谁?
答案:
- 任务 1: µs/单位。
- 任务 2: µs/单位。
给任务 1。注意任务 2 的 在 时等于”再调一轮延迟就降到 0”,非常乐观;如果再用”相似任务的最高吞吐”那一项,对访存受限的任务会更乐观(§6.3)。
练习 8 (开放)推理服务里 M 每步都在变(§2.4)。设计一个调优方案:离线调哪些 M?在线遇到新的 M 怎么办?代价模型能起什么作用?
参考思路:
- 离线:按 2 的幂(或按实际流量里的 M 分布)分桶,每个桶调一次;用迁移学习让后面的桶调得更快。
- 在线:遇到新的 M 时,用代价模型在几个桶的最优配置里挑一个预测最快的,不实测,或者只实测前 2–3 个;把结果缓存。
- 进一步:把 M 当作特征直接训练一个跨形状的代价模型。代码 02 的程序特征里已经包含了 log2 M。
12. 延伸阅读 · Further reading
- T. Chen, C. Guestrin, XGBoost: A Scalable Tree Boosting System(KDD 2016)——AutoTVM 和 Ansor 代价模型的底座。
- N. Vasilache et al., Tensor Comprehensions(2018)——多面体编译 + 遗传算法自动调优,本讲的选读。
- A. Adams et al., Learning to Optimize Halide with Tree Search and Random Programs(SIGGRAPH 2019)——Ansor 对比的”顺序构建 + 束搜索”。
- S. Zheng et al., FlexTensor: An Automatic Schedule Exploration and Optimization Framework for Tensor Computation on Heterogeneous System(ASPLOS 2020)。
- L. Zheng et al., TenSet: A Large-scale Program Performance Dataset for Learned Tensor Compilers(NeurIPS 2021 Datasets and Benchmarks)——用大规模数据集预训练代价模型。
- J. Shao et al., Tensor Program Optimization with Probabilistic Programs(NeurIPS 2022,MetaSchedule)——TVM 新一代的调度搜索框架。
- Triton 官方文档
triton.autotune、triton.Config(以及prune_configs_by这类剪枝配置的接口)。
13. 下一讲预告 · Next lecture
本讲的调优都在一个算子内部:计算定义固定,只改变怎么算。但很多优化需要改变计算图本身,例如:
- 把两个共享输入的矩阵乘法拼成一个;
- 把 1×1 卷积”放大”成 3×3 再和旁边的 3×3 卷积合并(第 2 讲手工做过)。
手写这种改写规则,TensorFlow 用了 5 万多行代码,还容易出错。
第 10 讲《计算图级优化》介绍两种自动化的做法:
- TASO:枚举小图,用”指纹”找出等价的图对,形式化验证后作为改写规则;再做允许暂时变差的回溯搜索。
- PET:允许”部分等价”的变换,再用自动生成的修正内核把不等价的那一小块算回来。
我们会在小矩阵和卷积上真的跑一遍”枚举 → 指纹 → 验证 → 搜索”,看机器怎样自己发现第 2 讲手写的那些规则。