AutoTVM 这套东西,我是被逼着学的。当时手里一个算子,手写 Schedule 调了两天,性能卡在某个数值上不去,换了几种切分方式都不理想,最后是靠猜和试凑才勉强交差。那种感觉很不舒服——你不知道自己离最优解还有多远,也不知道再试下去有没有意义。后来接触到 TVM 的 Python API,发现 AutoTVM 把这件"靠经验和手感"的事情,变成了一套可以参数化、可以搜索、可以复现的流程。用它的 Python API 写完模板函数,配好测量方式,跑一个晚上,日志文件里就躺着一组实测出来的配置,直接apply_history_best套回去就能用。这篇文章面向的是已经能看懂 TVM 基础 Schedule、但被调优效率卡住的同学,也照顾刚上手、想先跑通一遍完整闭环的新手。我会把整个链路的原理、参数怎么定、坑在哪里,按我自己的实操顺序捋一遍。
1. 手写 Schedule 调到崩溃之后,我把活交给了 AutoTVM
1.1 手工调优的天花板到底在哪
手写 Schedule 的本质是:你根据对硬件和算子的理解,选一个切分方案,然后编译、跑、看耗时,不满意就改。这个循环里有两个隐藏成本,很多人一开始意识不到。第一个是搜索空间的组合爆炸。以一个 1024 的三维切分为例,i、j、k 三个轴各自可以切出不同的因子,还要考虑 reorder 的顺序、是否向量化、是否绑定线程,组合数量轻松上万。你手工试的,往往只是其中十几个点,而且大概率集中在"你熟悉的那一小片区域"。第二个是测量本身的噪声。同一个配置跑两次,耗时可能差 5% 到 15%,尤其在有其他负载的机器上。手工调的时候人很容易被噪声误导,把一次侥幸跑得快的配置当成好配置,结果换台机器或者换个数据规模就崩了。
AutoTVM 解决的正是这两个问题:它用一套声明式的配置空间把"可能的 Schedule"枚举出来,用统一的测量协议保证每次测量的条件一致,再用一个成本模型去预测哪些配置值得试,把有限的机器时间花在更有希望的点上。它不能替你想清楚算子该怎么写,但能替你把"已经写好的那套模板里,哪个参数组合最好"这件事系统化地做完。
1.2 把"经验"翻译成参数:AutoTVM 的搜索空间抽象
理解 AutoTVM 最关键的一步,是搞清楚"模板函数(template)"和"配置(config)"这两者的关系。你写的模板函数是一个带参数的 Schedule 生成器,但它不接收具体的数值,而是通过autotvm.get_config()拿到一个配置对象,然后用cfg.define_split、cfg.define_knob这类方法去声明"我这里有一个旋钮,它的取值范围是这些"。在调优阶段,这个配置对象是空的待填状态,AutoTVM 会不断给它灌入不同的取值;在应用阶段,apply_history_best会把它变成一个带具体值的配置,模板函数据此生成真正的最优 Schedule。
这个设计的巧妙之处在于,你只写一遍代码,就能同时服务于"搜索"和"部署"两个场景。我见过有人写两套代码,一套用来调优、一套用来上线,结果两边逻辑慢慢漂移,调出来的配置套不上去。用@autotvm.template装饰器加get_config()的标准写法,这个坑就自动避开了。还有一个细节:模板函数最后必须返回(schedule, arg_bufs)这个二元组,arg_bufs是输入输出张量的列表,顺序要和后面 build 时一致,搞错了会在测量阶段报参数不匹配。
1.3 和自动调度器的分工:什么时候该用谁
TVM 生态里还有一类自动调度器(auto-scheduler),它不需要你写 Schedule,只要给出计算描述,它自己从零构造搜索空间。这两者的定位差别很大,用错了会白费功夫。我的判断标准是这样的:如果你的算子结构比较常规,比如矩阵乘、卷积、池化这类,而且你手头已经有一个跑得还行的 Schedule 骨架,那用 AutoTVM 更划算,因为搜索空间被你的模板约束住了,收敛快,通常几百到上千次 trial 就能摸到不错的解。反过来,如果算子很怪,比如带复杂 index 变换的算子,你自己都不知道该怎么切,那自动调度器更合适,代价是搜索空间大、调优时间长。
还有一个现实因素:AutoTVM 的模板是你手写的,可控性强,出了问题容易定位;自动调度器的搜索过程更像黑盒,调不出来的时候排查成本高。我在项目里的实际做法是,先用自动调度器跑一个参考性能,看看这个算子大概能到什么水平,然后再用 AutoTVM 在自己写的模板上精调,两者对比一下,差距太大就说明我的模板设计有问题,这时候该改的是模板,而不是加大 trial 数量硬搜。
2. 环境准备与第一个能跑通的调优任务
2.1 版本对齐与依赖清单
AutoTVM 位于 TVM 主仓库的python/tvm/autotvm目录下,随 TVM 一起安装,不需要单独装包。但有一个依赖必须补上:成本模型默认用 XGBoost,如果没有装,XGBTuner会直接报错,退化成随机搜索。装法就一句pip install xgboost,注意版本不要太老,太老的版本在模型序列化和多线程预测上有过一些问题。
我把常用依赖列一下,避免你一个个试:
| 组件 | 作用 | 缺失后果 |
|---|---|---|
| tvm(含 autotvm) | 主体框架 | 无法导入 |
| xgboost | 成本模型后端 | XGBTuner 不可用,只能随机搜索 |
| numpy | 张量数据构造 | 基本跑不起来 |
| 目标硬件的编译工具链 | 编译生成的代码 | LocalBuilder 阶段报编译错误 |
版本对齐这块有个经验:TVM 的 Python 包和编译出来的 libtvm 必须是同一份源码编出来的,混用不同版本的 wheel 和本地库,最常见的结果是导入成功但一调用tvm.build就崩。我习惯直接在源码目录下pip install -e python,这样源码改动和 Python 包始终同步,调试也方便打日志。
2.2 写出一个带搜索空间的模板函数
拿矩阵乘法当例子,因为它结构清晰,调优效果也直观。下面这段是我常用的模板骨架,你可以直接改成自己的算子:
import tvm from tvm import te, autotvm @autotvm.template("tutorial/matmul") def matmul(N): A = te.placeholder((N, N), name="A") B = te.placeholder((N, N), name="B") k = te.reduce_axis((0, N), name="k") C = te.compute( (N, N), lambda i, j: te.sum(A[i, k] * B[k, j], axis=k), name="C", ) s = te.create_schedule(C.op) cfg = autotvm.get_config() cfg.define_split("tile_i", N, num_outputs=2, max_factor=128) cfg.define_split("tile_j", N, num_outputs=2, max_factor=128) cfg.define_split("tile_k", N, num_outputs=2, max_factor=64) i, j = C.op.axis ko = C.op.reduce_axis[0] i_o, i_i = cfg["tile_i"].apply(s, C, i) j_o, j_i = cfg["tile_j"].apply(s, C, j) k_o, k_i = cfg["tile_k"].apply(s, C, ko) s[C].reorder(i_o, j_o, k_o, i_i, j_i, k_i) return s, [A, B, C]这里define_split声明的是"把某个轴切成外层和内层两段,切分因子不超过 max_factor"。注意 apply 的返回值顺序就是外层轴和内层轴,reorder 的时候外层循环要排在内层前面,写反了性能会差一个数量级,而且不容易发现,因为代码照样能编译通过。
max_factor的选择有讲究。设得太大,搜索空间里会出现大量超过寄存器容量的切分,编译出来性能极差甚至编译失败;设得太小,又可能把真正的好配置排除在外。我的经验值是:对于 32 位浮点的 CPU 场景,内层因子从 8 到 64 之间开始试;如果是 GPU 共享内存场景,要把共享内存容量算进去,比如每个 block 用 48KB 共享内存,那tile_i * tile_j * 4 字节不能超过这个数,超了编译阶段就会报出来。
2.3 从 tune 到 apply 的完整闭环
模板写好之后,创建任务、配置测量、启动调优,三步走:
import logging import sys from tvm import autotvm logging.getLogger("autotvm").setLevel(logging.DEBUG) logging.getLogger("autotvm").addHandler(logging.StreamHandler(sys.stdout)) N = 1024 task = autotvm.task.create( "tutorial/matmul", args=(N,), target="llvm -mcpu=skylake-avx512", ) print(task.config_space) measure_option = autotvm.measure_option( builder=autotvm.LocalBuilder(n_parallel=1), runner=autotvm.LocalRunner(number=10, repeat=3, min_repeat_ms=500, timeout=10), ) tuner = autotvm.tuner.XGBTuner(task) tuner.tune( n_trial=800, measure_option=measure_option, callbacks=[ autotvm.callback.log_to_file("matmul.log"), autotvm.callback.progress_bar(800), ], )调完之后,用日志回放最优配置:
with autotvm.apply_history_best("matmul.log"): with tvm.target.Target("llvm -mcpu=skylake-avx512"): s, arg_bufs = matmul(N) func = tvm.build(s, arg_bufs, name="matmul")apply_history_best是个上下文管理器,它会按当前的 target 和 task 名去日志里找匹配的最优记录,匹配逻辑是基于 dispatch context 的,所以 target 字符串必须和调优时写的一模一样。我踩过一次这个坑:调优时写llvm -mcpu=skylake-avx512,应用时写llvm,结果它没匹配上,悄悄用了默认配置,性能自然上不去,而且不报任何错。后来我养成了一个习惯,把 target 字符串提成常量,两边引用同一个变量。
LocalRunner的三个参数值得单独说一下。number=10表示一次测量里把这段代码跑 10 遍,repeat=3表示整个测量重复 3 轮,AutoTVM 会从这三轮里取一个统计值作为这个配置的成绩。min_repeat_ms=500的意思是如果跑一遍太快(比如只有几微秒),就自动增加循环次数,让单次测量至少持续半秒,这样计时器的精度误差就被摊薄了。这个参数在调小算子上极其重要,我见过有人调一个 32x32 的小矩阵乘,不设min_repeat_ms,测出来的耗时全是噪声,调出来的配置基本是随机的。
3. 核心机制拆解:config space、成本模型和 runner 各管什么
3.1 config space 与 sketch、annotation 的三层关系
很多人第一次看define_split会以为参数空间就是"因子取哪些值",其实不止。AutoTVM 里搜索空间的构造分三层:knob(最底层的旋钮,比如枚举哪些整数)、annotation(给轴打标记,比如"这个轴要切成两段")、sketch(一系列 annotation 的组合,形成整体的切分骨架)。define_split生成的是带(factor, order)的配置实体,order 表示切开之后内外层谁在前,所以即使 factor 相同,不同 order 也会产生不同的配置。
一层层叠加之后,配置空间的总量会比你直觉大很多。我习惯在task.create之后先print(task.config_space)看一眼,或者用len(task.config_space)拿数量。如果数量超过几万,说明max_factor放得太松,得收紧;如果只有几十,那搜索几乎没意义,说明约束太死。比较合适的量级是几百到几千,XGBTuner 在这个规模下,一千次 trial 通常能摸到接近最优的区域。
这里有个容易忽略的点:define_split只会生成能整除的因子,如果 N 是质数,那可用因子就只有 1 和 N,搜索空间会塌缩。所以在设计模板时,尽量让参与切分的维度尺寸是 2 的幂,或者至少在模板里加兜底逻辑,遇到不能整除的情况走另一条分支。我在处理不规整尺寸时,会额外定义一个cfg.define_knob("use_pad", [0, 1]),让调优过程自己决定要不要补零,这比写死一个方案更灵活。
3.2 XGBoost 成本模型是怎么"学会"预测的
调优前几十次,XGBTuner 的行为和随机搜索没区别,因为模型还没数据。每测完一批配置,它会把"配置向量 → 实测耗时"这些样本喂给 XGBoost 训练一个回归模型,然后拿这个模型去预测剩下那些没测过的配置,挑预测值最低的若干个去实际测。默认每 10 次测量更新一次模型,这个间隔能通过plan_size之类的参数间接影响,也可以自己在回调里干预。
理解这一点之后,很多现象就说得通了。比如你发现前 100 次 trial 耗时波动很大,那是随机探索阶段,正常;跑到 300 次以后提升变慢,那是模型已经把高潜力区域探得差不多了。再比如,如果测量噪声很大,模型学到的是"噪声 + 真实性能"的混合信号,它会反复推荐那些碰巧测出好成绩的配置,反而降低效率。所以把测量质量做扎实,比把 trial 数量加大更有效。我一般的做法是先把number和repeat调够,确认同一配置连续测三次的波动在 3% 以内,再开始正式调优。
模型的一个限制是它只能在你给的配置空间内做选择,不会外推。如果你的模板设计本身就把最优解排除在外了,模型再聪明也没用。这就是我在第 1.3 节说的,模板设计才是上限,调优只是把这个上限逼近。
3.3 runner 和 RPC tracker 在链路里的真实位置
本机调优用LocalBuilder+LocalRunner就够了,但一旦要调远程设备,比如一块开发板或者一台手机,链路就变成三段:控制端负责跑搜索算法和成本模型,tracker 进程负责撮合控制端和设备端,设备端跑一个 RPC server 等着接任务。启动顺序不能错,先起 tracker,再起设备端 server,最后跑调优脚本。
tracker 的启动命令:
python -m tvm.exec.rpc_tracker --host=0.0.0.0 --port=9190设备端的启动命令,需要把编译好的运行库和交叉编译产物一起推上去:
python -m tvm.exec.rpc_server --tracker=127.0.0.1:9190 --key=mydev控制端连接并申请设备:
from tvm import rpc tracker = rpc.connect_tracker("127.0.0.1", 9190) remote = tracker.request(key="mydev", priority=0, session_timeout=120) measure_option = autotvm.measure_option( builder=autotvm.LocalBuilder( n_parallel=1, build_func=tvm.build, ), runner=autotvm.RPCRunner( key="mydev", host="127.0.0.1", port=9190, number=10, repeat=3, timeout=30, ), )session_timeout这个参数要设得比单次测量的最长耗时长一些,否则跑到一半会话被回收,报的错还很难懂,通常是一句含糊的连接断开。RPCRunner的timeout也要相应放大,远程设备的编译和上传本身就慢,超时设太紧会把正常的测量误判为失败,AutoTVM 会把这个配置记为 error 然后跳过,你以为它测过了,其实没有。
4. 调优参数怎么定:trial 数、早停与并行
4.1 trial 数量的估算与时间预算控制
n_trial定多少,取决于三个量:单次测量的平均耗时、你的时间预算、以及这个算子在整体网络里的重要程度。单次耗时可以先跑 10 次 trial 测一下,日志里能看到每条记录的耗时。假设一次测量平均 2 秒(含编译),你有一晚上 8 小时,那就是 14400 秒,理论上能跑 7000 次,但实际要打七折到八折,因为编译失败的配置也会占时间,而且有些配置编译特别慢。
我的经验配比是这样的:核心算子(占端到端时间 30% 以上)给 1000 到 2000 次;次要算子 500 次左右;边缘算子 100 到 200 次就够,甚至直接用默认配置。如果所有算子一视同仁地给 2000 次,时间全浪费在收益很小的算子上,整体性能提升反而不如集中火力调那几个大头。
AutoTVM 的 tuner 没有内置的早停开关,但可以自己做。我常用的做法是分批调,每批 100 次,每批结束后读一遍日志,找出当前最优成绩,如果连续三批最优成绩没有改善超过 1%,就停下来。这段逻辑不复杂,用一个循环包住tuner.tune就行:
import os from tvm import autotvm best = float("inf") stale = 0 for round_id in range(20): tuner.tune( n_trial=100, measure_option=measure_option, callbacks=[autotvm.callback.log_to_file("matmul.log")], ) records = autotvm.record.load_from_file("matmul.log") cur = min(r.cost for r in records if r.cost[0] > 0) if cur < best * 0.99: best = cur stale = 0 else: stale += 1 if stale >= 3: break注意r.cost[0]可能是负数,表示这个配置测量失败,过滤掉再取最小值,否则会把失败记录当成最好成绩。
4.2 断点续调与日志文件管理
日志文件是整个调优过程的唯一产出物,弄丢了就得重来。AutoTVM 的日志是纯文本追加写,每行一条记录,所以断点续调的方式很简单:用同一份日志文件重新跑tuner.tune,它会先加载已有记录,成本模型接着之前的状态继续学。我一般会固定日志命名规则,比如算子名_target缩写_日期.log,一个算子一个文件,别混在一起写。混写的问题在于应用阶段apply_history_best要扫描整个文件,文件太大会明显拖慢构建速度,而且不同 target 的记录混在一起,匹配逻辑虽然能过滤,但排查问题的时候很难看清。
还有一点:log_to_file的回调是追加模式,重复跑同一个实验不会覆盖旧数据,这既是优点也是坑。如果你改了模板函数,旧日志里那些配置的语义已经变了,继续用会导致应用阶段拿到一个不匹配的配置,性能莫名其妙地差。我改了模板之后第一件事就是mv 旧日志 旧日志.bak,重新开始,这个习惯救过我好几次。
日志里的每条记录包含 target 的 keys、完整的配置向量和测量结果。想快速看看哪些配置效果最好,可以用autotvm.record.load_from_file读出来排序,也可以直接按行解析,格式是固定的。我写过一个几行的小脚本,把日志里前 10 名的配置打出来,人工看一眼切分因子有什么规律,往往能反过来帮你改进模板。
4.3 多设备并行与任务切分
一台机器跑调优,CPU 往往跑不满,因为测量过程是串行的:编译、上传、运行、回收结果。要提速有两个方向。第一个是把LocalBuilder的n_parallel调大,允许多个配置并行编译,这对编译时间占比高的场景效果明显。第二个是接多台设备,每台设备起一个 RPC server,用不同的 key,然后开多个调优进程,每个进程连一个 key,各自写各自的日志文件,最后合并。
合并日志有个小技巧:直接把多个文件cat到一起就行,因为格式是行独立的,apply_history_best会自己在里面找最优。不过要注意不同设备的性能基线不一样,把手机和开发板的日志混在一起,应用阶段会选出一个"在某台设备上最好"的配置,套到另一台设备上可能很差。所以合并的前提是这些设备的架构和主频接近,否则还是分开管理。
多进程调优还有一个隐患:如果多个进程共用一个设备,RPC 的请求会排队,反而比单进程更慢,而且session_timeout容易被挤爆。我吃过这个亏,一开始以为通道越多越好,开了 8 个进程连同一块板子,结果大量请求超时,报错信息还是连接层的,查了半天才发现是资源争抢。正确做法是设备数大于等于进程数。
5. 踩坑实录:常见报错与性能异常排查
5.1 编译失败与超时的三类根因
调优过程中最常见的不是崩溃,而是"沉默的失败":某个配置编译不过,AutoTVM 记一条 error 就跳过了,你的日志里会看到负数耗时。追根溯源,大概三类原因。第一类是切分因子不合理,比如某个轴根本不能被这个因子整除,define_split在生成阶段已经过滤了大部分非法因子,但如果你用了define_knob手工指定切分,就要自己保证合法性。第二类是资源超限,GPU 上是共享内存和寄存器,CPU 上是寄存器溢出导致编译极慢。第三类是目标平台不支持某些指令,比如在不支持 AVX512 的机器上用-mcpu=skylake-avx512,编译阶段直接报错。
排查的顺序我一般是这样的:先单独拿出一个失败配置,手动构造配置对象,走一遍tvm.build,看完整报错。这一步比在调优循环里看日志高效得多,因为调优时的报错信息经常被截断或者吞掉。如果手动编译能过,那就是测量环节的问题,重点看 RPC 连接和超时;如果手动编译也过不了,那就是模板或 target 参数的问题,看一下生成的 CUDA 或者 C 源码,往往一眼就能看出越界在哪。
超时的排查稍微麻烦一点,因为它不一定是配置的问题。远程设备的网络抖动、设备端进程被其他任务占满、甚至是设备端存储空间满了导致上传失败,都会表现为超时。我习惯在调优前先手动跑一次remote.upload加remote.run,确认这条链路通畅再开始,能省掉大量无效 trial。
5.2 调优完了反而更慢
这个问题我遇到过两次,原因完全不同。第一次是 target 字符串不一致,前面说过,apply_history_best匹配不上就静默用默认配置,性能当然不如手写的。第二次是测量环境的差异:调优时机器空闲,应用时机器上有其他负载,那些在空闲状态下测出来"最优"的配置,恰恰是对缓存敏感、抗干扰能力差的配置,一有竞争就掉得厉害。
还有一类更隐蔽的情况:调优是在单算子层面做的,这个算子的性能确实提升了 30%,但端到端没变,因为整个网络的瓶颈根本不在这个算子上。这时候该做的是先 profile 整个网络,找出真正占大头的部分,别一头扎进单算子调优。我一般会先用图级别的分析把各算子的耗时占比排出来,只调前 5 个,剩下的一律用默认。
如果是 GPU 场景,还要注意融合的问题。调优时是按单算子测的,但实际部署时相邻算子可能被融合成一个 kernel,融合之后的最优配置和单独测的最优配置未必一致。这个矛盾目前没有完美的解法,我的做法是对那些确定不会被融合的算子(比如归约类)放心调,对可能融合的算子只做温和调优,别把参数推到极端。
5.3 日志与缓存相关的坑
AutoTVM 依赖 TVM 的编译缓存,默认在用户目录下的.tvm里。缓存命中能大幅加速调优,但也带来一个坑:你改了模板代码,缓存里还是旧的编译结果,测出来的性能和你的新代码对不上。我遇到过一次,改完模板性能没变化,查了两个小时才发现是缓存。清理方式就是删掉缓存目录,或者在环境变量里指定一个新的缓存路径,每次实验用不同的路径,实验之间互相隔离。
另一个坑是日志文件被同时写。多个进程写同一个日志文件,虽然追加写本身是原子的,但内容会交错,apply_history_best的解析逻辑是按行处理,混在一起一般能读,但调试的时候极其痛苦。一个进程一个日志文件,这是硬规矩,别为了省事合在一起。
最后提醒一句关于use_transfer_learning之类的迁移学习功能。它能拿别的相似 task 的调优结果来加速当前 task 的搜索,在算子结构相似的时候确实有用。但如果两个 task 的实际硬件特性差很远,迁移过来的先验反而会把搜索带偏。我一般只在确认两个 task 是同架构、同算子家族的情况下才开这个开关。
| 现象 | 高概率原因 | 快速验证方式 |
|---|---|---|
| 日志里大量负数耗时 | 配置编译失败或被跳过 | 单配置手动 build 看完整报错 |
| 应用后性能无明显提升 | target 不匹配或缓存未更新 | 打印匹配到的配置向量 |
| 调优过程中频繁超时 | RPC 会话超时或设备争抢 | 单次手动上传运行测试 |
| 前 100 次 trial 波动巨大 | 随机探索阶段,属正常 | 看 300 次以后是否收敛 |
| 修改模板后结果不变 | 编译缓存命中旧产物 | 换缓存目录重跑 |
6. 最后分享几个我总结的实操习惯
调优这件事,工具只占一半,另一半是流程纪律。我现在开一个新算子之前,一定先把模板函数跑通一次默认配置,确认它能编译、能跑、结果数值正确,然后再进调优环节。这个顺序看着啰嗦,但能挡掉一大类问题:模板里轴顺序写反了、arg_bufs 顺序不对、输出张量没分配,这些错误如果在调优循环里才暴露,你会在几百条 error 记录里翻找半天。
第二个习惯是每次调优前先固定随机种子,把n_trial设小一点跑一遍基线,记录下这次基线的耗时和最好成绩。硬件环境会变,同样的脚本今天跑和下周跑,结果可能差 10%,没有这个基线,你根本判断不了某次"性能提升"是真实的还是环境波动。
第三个习惯是给每个调优任务写一段简短备注,放在日志文件同目录的 README 里,记清楚这次调优的 target 字符串、模板的版本、trial 数量、以及最后选中的配置大概是什么切分方式。半年后回头看,没有这段备注的日志基本等于废纸,因为你不知道它对应哪版代码。
最后一个:别把调优当成一次性任务。算子的数据规模和输入形状会变,硬件的频率和负载也会变,一份调优日志用一两年就该重新跑。我现在的做法是把调优脚本纳入构建流程,在算子实现变更时自动触发一次小规模重调,规模不用大,两三百次 trial 就够,主要目的是发现"模板改动是否让历史配置失效"这件事。这套流程跑下来,比我早期手工试凑的效率高了不止一个量级,也终于不用再靠猜来结束一场调优了。