第 9 讲 · ML 编译器(二):内核自动调优 · ML Compilers Part 2: Kernel Auto-tuning

Advanced Topics in Machine Learning Systems (LLM Edition) · Lecture 09。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 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 学习目标

学完这一讲,你应该能够:

  1. 把”给一个算子找最快的实现”写成一个优化问题,说出它和普通超参数搜索的三点不同。

  2. 用数字说明搜索空间的”地形”:好配置有多稀少、旋钮之间怎样相互影响、为什么不能穷举,也不能一个旋钮一个旋钮地分别调。

  3. 解释 Triton 的 @triton.autotune 怎么工作、key 的含义,以及”配置表由人写”带来的局限。

  4. 讲清楚 AutoTVM 的三个组件:

    • 梯度提升树代价模型:用什么特征、为什么用排序损失;
    • 模拟退火探索 + ε-贪心;
    • 迁移学习:为什么要用”程序结构”而不是”旋钮”作特征。
  5. 讲清楚 Ansor 的三个组件:

    • 草图生成规则;
    • 随机标注;
    • 在完整程序上做进化搜索,以及为什么不在半成品上剪枝。

    并能解读它的消融实验和任务调度器。

  6. 比较 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 把”算什么”(计算定义)和”怎么算”(调度)分开:同一个 C=ABC = AB,可以有成千上万种循环组织方式。它们的结果相同,速度却能差几十倍。Triton 也一样:同一个矩阵乘法内核,BLOCK_M、BLOCK_N、BLOCK_K、num_warps、num_stages、GROUP_M 的不同组合速度差别很大。

自动调优(auto-tuning)就是让机器替人选。

1.2 形式化

AutoTVM 论文 §2 把问题写成:

  • ee:一个张量算子的计算定义(index expression);
  • Se\mathcal{S}_e:它的调度空间;
  • gg:编译器,x=g(e,s)x = g(e, s) 是按调度 ss 生成的低层程序;
  • f(x)f(x):程序在硬件上的真实耗时。

目标是

s⋆=arg⁡min⁡s∈Sef(g(e,s)).s^\star = \arg\min_{s \in \mathcal{S}_e} f\big(g(e, s)\big).

关键在于:ff 没有解析式,只能实测。

论文指出这个问题和传统的超参数搜索有三点不同:

  1. 实验相对便宜:编译、运行一个程序只要几秒(超参数搜索一次实验要几小时甚至几天)。所以可以收集上千条数据;但代价模型本身也必须足够快,否则还不如直接测。
  2. 有领域结构:程序有循环、访存、复用这些结构,可以据此设计模型,而不是把它当黑盒。
  3. 有大量相似的任务:一个网络里有很多形状不同的同类算子,适合迁移学习。

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 \ stages1234567
162.151.361.091.161.121.181.15
321.601.001.071.111.121.122.13
641.341.061.132.082.092.03×
1281.662.032.10××××
2564.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 s2496.9 µs64×128×32 w2 s2464.4 µs1.07×
预填充 QKV(512×12288×4096)128×128×128 w4 s2202.8 µs128×128×32 w4 s4199.6 µs1.02×
解码 batch 16(16×4096×4096)128×128×128 w8 s253.8 µs16×64×256 w2 s422.0 µs2.45×
小矩阵 512³128×128×128 w4 s210.9 µs64×64×128 w4 s46.9 µs1.58×
权重梯度 wgrad(512×512×32768)128×128×64 w4 s3388.0 µs128×64×64 w2 s2 k1685.9 µs4.51×

这张配置表是为大矩阵写的:在方阵和预填充上已经很好。但它在两个形状上表现很差:

  • 解码只有 16 行,BLOCK_M = 128 的块有 88% 是填充,白白多算 8 倍。最优配置用 BLOCK_M = 16。
  • 权重梯度(dW=X⊤dYdW = X^\top dY,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 在中间加了一个统计代价模型 f^\hat f(论文图 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):一棵浅树很粗糙。提升的做法是一棵接一棵地加树,每棵新树专门去修正前面所有树加起来的误差:

F(x)=∑t=1Tη⋅treet(x)F(x) = \sum_{t=1}^{T} \eta \cdot \text{tree}_t(x)

XGBoost 的二阶公式:每加一棵树,对每个样本算损失函数对当前预测的一阶导数 gig_i 和二阶导数 hih_i。一个节点切分成左右两半的”增益”是

gain=GL2HL+λ+GR2HR+λ−G2H+λ,\text{gain} = \frac{G_L^2}{H_L + \lambda} + \frac{G_R^2}{H_R + \lambda} - \frac{G^2}{H + \lambda},

其中 GG、HH 是节点里所有样本的 gg、hh 之和,λ\lambda 是正则化系数。叶子的值取 −G/(H+λ)-G/(H + \lambda),相当于牛顿法走一步。

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:程序特征
640.670.840.340.77
2560.850.930.750.91
10240.900.960.900.95

(排序目标;回归目标的数字见代码输出。)

程序特征在样本少的时候优势最大:它把”硬件真正关心的量”直接算了出来,模型不用再从 7 个旋钮的组合里自己”发现”它们。AutoTVM 的特征也是从低层循环程序里提取的:每层循环的长度、访存量、数据复用率,以及展开、向量化、线程绑定等标注。论文还把每层循环的特征向量看成一个”点的集合”,提取”循环长度 vs 访存量”之类的关系特征(context relation features),使特征不依赖具体的循环嵌套模式。

4.4 训练目标:排序还是回归?

调优只关心谁更快,不关心某个程序到底是 1.23 ms 还是 1.25 ms。AutoTVM 因此提供了排序损失(rank loss):

∑i,jlog⁡(1+e−sign⁡(ci−cj) (f^(xi)−f^(xj)))\sum_{i,j} \log\Big(1 + e^{-\operatorname{sign}(c_i - c_j)\,(\hat f(x_i) - \hat f(x_j))}\Big)

只要两个程序的预测顺序和真实顺序一致,损失就小;预测的绝对值不重要。论文图 5 显示,排序目标”比回归好或持平”,所以默认用排序。

代码里的 GBDT(objective="rank") 实现了这个损失:每个样本随机配 32 个对手,而不是枚举所有的对。在代码 02 演示 2 的 12 个设置里,排序目标的 ρ 有 10 个不低于回归目标。recall@30(模型挑的前 30 个里有几个真在前 30)则波动很大:在 3000 个配置里精确挑出前 30 名,比整体排序难得多。

4.5 探索:并行模拟退火 + ε-贪心

有了模型,也不能把整个空间都预测一遍:几十亿个配置,即使一个只要 1 微秒也要几十分钟,何况要反复做。AutoTVM 用模拟退火(simulated annealing)在空间里”走”:

  1. 开 128 条马尔可夫链,每条链在一个配置上;
  2. 每一步给每条链提一个”邻居”(随机改一个旋钮,换成相邻的取值);
  3. 如果模型预测邻居更快,就移过去;如果更慢,以概率 eΔ/Te^{\Delta/T} 也移过去。Δ<0\Delta < 0 是预测吞吐的下降,TT 是”温度”,从高到低慢慢降。这让链能跳出局部最优。
  4. 把所有链走过的状态按预测排序,挑最好的 (1−ε)b(1-\varepsilon)b 个没测过的。

论文还有三个细节:

  • 链的状态在两批之间保留,不是每次重新开始;
  • 每批随机挑 εb\varepsilon b 个(ε=0.05\varepsilon = 0.05)保证探索,否则模型可能永远不去看它”以为很差”的区域;
  • 可以兼顾多样性:在前 λb\lambda b 个里挑一个子集,最大化”预测得分 + 覆盖了多少种不同的旋钮取值”(一个子模函数,用贪心近似求解)。论文发现大多数情况下多样性没有影响,有一个工作负载上有帮助,于是默认打开。

论文也试了贝叶斯优化常用的不确定性采集函数(EI、UCB,用 bootstrap 估计不确定性),发现没有帮助。

4.6 搜索效果(代码 02 演示 1)

三种策略各跑 5 次(不同随机种子),表中是”选中配置达到最优的百分比”的中位数:

方阵 4096³n=641282565121024到 95% 要测
网格搜索(字典序)19.2%19.3%19.3%19.3%19.1%没达到
随机搜索62.0%84.7%87.4%92.5%92.8%没达到
AutoTVM86.9%92.9%99.7%99.7%100.0%129 次
权重梯度 wgradn=641282565121024到 95% 要测
网格搜索25.4%27.4%27.8%27.8%27.8%没达到
随机搜索76.1%86.2%86.2%89.8%89.8%5 次里 4 次没达到
AutoTVM78.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 的做法是把模型拆成两部分:

f^(x)=f^(global)(x)+f^(local)(x),\hat f(x) = \hat f^{(\text{global})}(x) + \hat f^{(\text{local})}(x),

  • f^(global)\hat f^{(\text{global})} 用之前所有工作负载的历史数据 D′D' 训练;
  • f^(local)\hat f^{(\text{local})} 只用当前任务的数据,学”全局模型在这里错在哪”。

能迁移的前提是跨任务不变的表示:

  • “旋钮”不是这样的表示。同一个 BLOCK_M = 128,在 M = 16 和 M = 4096 上意义完全不同;而且换一个搜索空间,旋钮就变了。
  • “低层循环程序的特征”(循环长度、访存量、复用率)在不同形状之间含义相同。

代码 02 演示 3 先在 4 个源形状(方阵、FFN 上投影、小矩阵、解码)上各调 512 次,用这些数据训练全局模型,然后在 3 个目标形状上每批测 16 个:

目标(程序特征)n=163264128256
预填充 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 共同的问题:搜索空间由手写的模板定义。这带来两个后果:

  1. 不是全自动:每个新算子、每种新硬件都要专家写模板,工作量巨大。Ansor 论文提到 TVM 代码库里这类模板已经超过 1.5 万行,而且随着新算子、新硬件还在增长。
  2. 空间有限:模板没写到的结构永远搜不到,结果不是最优。

另一类做法是 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 层循环 (i,j,k)(i, j, k) 因此展开成 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)。代码里重现了这个实验:

  1. 在 2000 个完整程序上训练代价模型;
  2. 对另外 1000 个程序,只保留前 k 个决策(split-K → BM → BN → BK → num_warps → num_stages → GROUP_M),其余旋钮填”还没做这个变换”的默认值;
  3. 看模型能不能分辨它们最终的快慢。
已决定的旋钮数01234567(完整)
方阵:成对准确率50%50%60%61%62%83%94%95%
方阵:recall@100%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=1024wgrad n=256wgrad 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 梯度近似

设任务 ii 已经分到 tit_i 个时间单位(一个单位 = 给它跑一轮调优),目前最好的延迟是 gi(ti)g_i(t_i),它在网络里出现 wiw_i 次。端到端延迟

f=∑iwi gi(ti).f = \sum_i w_i\, g_i(t_i).

调度器每次把下一个单位给 ∣∂f/∂ti∣\left|\partial f / \partial t_i\right| 最大的任务。导数没法直接算,论文用下式近似:

∂f∂ti≈wi(αgi(ti)−gi(ti−Δt)Δt+(1−α)min⁡ ⁣(−gi(ti)ti,  βCimax⁡k∈N(i)Vk−gi(ti)))\frac{\partial f}{\partial t_i} \approx w_i \left( \alpha \frac{g_i(t_i) - g_i(t_i - \Delta t)}{\Delta t} + (1-\alpha) \min\!\Big(-\frac{g_i(t_i)}{t_i},\; \beta \frac{C_i}{\max_{k \in N(i)} V_k} - g_i(t_i)\Big) \right)

  • 第一项向后看:最近 Δt\Delta t 个单位里实际降了多少。
  • 第二项是乐观的猜测,有两种:
    • “再花一样多的时间,也许还能降这么多”(−gi/ti-g_i/t_i);
    • “如果它能达到相似任务 N(i)N(i) 里最好的吞吐 VkV_k,还能降多少”(CiC_i 是任务 ii 的浮点运算量)。

此外,开始时先轮流热身一轮,之后以概率 ε\varepsilon 随机挑任务。论文的目标函数还可以换成”多个网络的总延迟""满足延迟要求即可""对参考延迟的几何平均加速”等(论文表 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 / 496.0%97.6%
梯度(相似 = 全部任务)3 / 2 / 5 / 3 / 1 / 1 / 1 / 1 / 1 / 25 / 195.1%96.6%
梯度(相似 = 强度接近,本讲义的改动)7 / 3 / 12 / 7 / 1 / 2 / 1 / 2 / 1 / 4 / 295.1%97.7%

结果和预期不一样。照公式、把所有矩阵乘法都当成”相似任务”时,调度器把 25/44 个单位给了”解码 FFN 上投影”,结果比轮流还差。

问题出在公式第二项的假设:它认为任务 ii 能达到相似任务里最高的 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 1640.8%99.0%98.4%
小矩阵 512³63.2%99.9%99.6%
权重梯度 wgrad22.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. 想法:

  • 把”为一个算子找最快的程序”形式化为在调度空间 Se\mathcal{S}_e 上最小化真实耗时 ff,而 ff 只能实测(§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

  1. 自动调优:在调度空间里找最快的程序,真实耗时只能实测。和超参数搜索相比:实验便宜、有结构、相似任务多。

  2. 地形很难:HypoGPU 上好配置只占 0.02%,中位数的配置慢 5 倍,旋钮之间有交互,穷举要 28 小时(真实空间几十亿)。

  3. Triton @triton.autotune:在手写的配置表里逐个实测,key 变了就重调。简单可靠,但表里没有的结构永远找不到(wgrad 慢 4.5 倍),动态形状下代价高。

  4. AutoTVM:

    • 学习的代价模型(梯度提升树,特征取自程序结构,排序损失);
    • 模拟退火 + ε-贪心;
    • 迁移学习。

    在 HypoGPU 上测 256 次就到 99.7%,随机搜索测 1024 次只有 92.8%。迁移学习对相似的形状很有效,对结构不同的形状反而可能拖后腿。

  5. Ansor:

    • 规则生成草图(多级分块、融合、缓存写、rfactor);
    • 随机标注保证覆盖;
    • 进化搜索在完整程序上微调——因为代价模型给半成品打分不准,只决定一半旋钮时成对准确率约 62%,接近瞎猜。

    消融实验:搜索空间最重要。

  6. 任务调度器按梯度近似分配调优时间。它的乐观假设在访存受限的任务上会失效;测量噪声带来”赢家诅咒”,最终候选应该重测。


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 时间 = 2⋅64⋅128⋅BK/2048×22 \cdot 64 \cdot 128 \cdot BK / 2048 \times 2 周期;
  • 一次读取的延迟 ≈ 500 + 搬运时间,搬运时间 = (64+128)⋅BK⋅2/48(64+128) \cdot BK \cdot 2 / 48 周期。

估算 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 个样本,一阶梯度 g=(−2,−1,1,2)g = (-2, -1, 1, 2),二阶梯度都是 1,λ=1\lambda = 1。按”前两个 | 后两个”切分,增益是多少?叶子值各是多少?

答案:

  • 不切分时 G=0,H=4G = 0, H = 4,得分 02/5=00^2/5 = 0。
  • 左边 GL=−3,HL=2G_L = -3, H_L = 2,得分 9/3=39/3 = 3;右边 GR=3,HR=2G_R = 3, H_R = 2,得分 3。增益 = 3 + 3 − 0 = 6。
  • 叶子值:左 −GL/(HL+λ)=1-G_L/(H_L+λ) = 1,右 −1-1。

平方损失时 g=F−yg = F - y:左边两个样本的预测比目标低,所以叶子值为正,把预测往上拉。

练习 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,温度 T=0.05T = 0.05。移到邻居的概率是多少?温度降到 0.005 呢?

答案:e−0.02/0.05=e−0.4≈0.67e^{-0.02/0.05} = e^{-0.4} \approx 0.67;e−0.02/0.005=e−4≈0.018e^{-0.02/0.005} = e^{-4} \approx 0.018。温度高时经常接受变差的移动、能跳出局部最优;温度低时几乎只往好的方向走。

练习 5 (随机搜索要测多少次)HypoGPU 方阵上离最优 5% 以内的配置占合法配置的 0.024%。随机测 1024 次至少碰到一个的概率是多少?要有 95% 的把握,要测多少次?

答案:

  • 1−(1−0.00024)1024≈1−e−0.246≈22%1 - (1 - 0.00024)^{1024} \approx 1 - e^{-0.246} \approx 22\%;
  • n=ln⁡0.05/ln⁡(1−0.00024)≈12,500n = \ln 0.05 / \ln(1 - 0.00024) \approx 12{,}500 次。

对照代码 02:随机搜索 1024 次后中位数只有 92.8%。AutoTVM 不靠撞运气,而是让模型告诉它往哪里走。

练习 6 (草图规则)对下面两个计算图,按 §5.3 的规则表说出会生成哪些草图:(a) C=relu(AB+bias)C = \text{relu}(A B + \text{bias}),AA 是 4096×4096,BB 是 4096×4096;(b) 对一个 16×1,000,000 的矩阵按行求和。

答案:

  • (a) 加 bias 和 relu 是可以内联的逐元素运算(规则 2),合并成矩阵乘法的消费者。矩阵乘法有数据复用、又有可融合的消费者 → 规则 4(多级分块 + 融合消费者)。输出很大、归约不算长,不满足 rfactor 的条件。共 1 个草图。
  • (b) 按行求和没有数据复用(每个元素只读一次),不做多级分块(规则 1 跳过)。输出只有 16 个、归约长达 100 万,满足规则 6 → 生成 rfactor 草图,把每行的求和拆给很多线程并行,再归约(GPU 上就是”跨线程归约”规则)。

练习 7 (任务调度)两个任务:任务 1 出现 32 次,当前 g1=200g_1 = 200 µs,已分到 t1=4t_1 = 4 个单位,最近 2 个单位从 205 降到 200;任务 2 出现 128 次,g2=9g_2 = 9 µs,t2=1t_2 = 1,没有历史。取 α=0.2\alpha = 0.2,第二项只用 −g/t-g/t。下一个单位给谁?

答案:

  • 任务 1:32×(0.2×(200−205)/2+0.8×(−200/4))=32×(−0.5−40)=−129632 \times (0.2 \times (200-205)/2 + 0.8 \times (-200/4)) = 32 \times (-0.5 - 40) = -1296 µs/单位。
  • 任务 2:128×(0+0.8×(−9/1))=−921.6128 \times (0 + 0.8 \times (-9/1)) = -921.6 µs/单位。

给任务 1。注意任务 2 的 −g/t-g/t 在 t=1t = 1 时等于”再调一轮延迟就降到 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 讲手写的那些规则。

← 返回课程目录