去年冬天我接手了一个推理服务的性能优化,模型本身没改动,端到端延迟却比预期高了将近一倍。排查到最后发现,问题不在框架、不在量化、也不在内存拷贝,而是几个核心算子用的 schedule 是照搬开源示例的默认参数——分块尺寸、向量化宽度、并行粒度全是"别人机器上跑得通"的值,放到我这块 CPU 上完全不合适。这件事让我彻底转向了 AutoTVM 这套基于 Python API 的自动调优方案:它做的事很朴素,就是把"该用多大的分块""循环怎么拆""什么时候向量化"这些原本靠经验和反复试错决定的参数,交给一个搜索器去自动跑分数。AutoTVM 的 Python API 就是这套机制暴露给使用者的全部入口,搞清楚它,你才能把任意一个自己写的 te 算子接进调优流程,而不是只能调官方预置好的那几个模板。
这篇内容面向的是已经能用 TVM 写出基本 schedule、但还没系统用过 AutoTVM 的开发者,也适合那些用过一次apply_history_best却始终没搞清楚日志里到底存了什么的老手。我会从模板的定义方式一直讲到测量后端、调优器选型、日志复用和最后的收益验证,中间夹着我自己踩出来的坑。
1. AutoTVM 在整条编译链里到底替谁干活
很多人第一次接触 AutoTVM,会把它理解成"自动写 schedule 的工具",这个理解偏差挺大,容易导致后面用错方向。AutoTVM 并不会帮你决定用不用te.compute、要不要做算子融合、要不要做 reduction 拆分,这些结构性决策仍然要人来做。它真正负责的是:在你已经搭好的 schedule 骨架里,把那些数值型的选择(分块因子、展开因子、并行轴位置、向量化宽度)作为搜索变量,通过实际测量执行时间来打分,找出你这块硬件上最快的那组取值。
这个定位决定了使用 AutoTVM 的正确姿势是"先有模板,再谈调优"。模板质量差,搜索空间设计得不合理,再强的搜索器也救不回来。我见过有人把整个 schedule 结构都做成条件分支塞进搜索空间,结果空间大到 10 的十几次方,跑一晚上只能采到边角料,这种情况还不如老老实实手工调几个关键参数。
1.1 手写 schedule 卡在哪个环节
手写 schedule 的问题不在于写不出来,而在于没有反馈闭环。你按经验写一组参数,编译、运行、测时间,如果不好就再改一组。这个循环单次成本可能是几分钟,而值得尝试的组合动辄几百上千种。更麻烦的是,参数之间是耦合的:你把tile_x从 4 调到 8,最优的tile_y可能也跟着变了,手工调本质上是在一个高维非凸空间里做坐标下降,很容易停在局部最优点上。
AutoTVM 干的事就是把这个循环自动化,并且用代价模型来引导搜索——它不需要把每个点都测一遍,而是根据已经测过的点去预测哪些区域更可能有收益,优先采样那些区域。对 XGBoost 这类基于树的代价模型来说,通常测几百个点就能收敛到接近最优的配置,相比穷举的上万点节省了一个数量级。
还有一层隐性价值:调优日志是可复用的资产。同一块硬件、同一个算子形状,调一次可以反复用。这在多模型、多批次上线的场景里非常划算。
1.2 Task、ConfigSpace、Tuner 三件套之间的依赖关系
抛开代码,AutoTVM 的 Python API 在概念上只有三个东西,理清它们的关系,后面所有 API 调用都是顺理成章的事。
Task是"一个待调优的计算任务",由算子名、参数(形状、数据类型)和目标平台三者唯一确定。它包含信息:这是什么算子、在什么形状下、编译到哪个 target。同一个算子在不同 shape 下是不同的 Task,不同 target 下也是不同的 Task。
ConfigSpace是"这个任务允许调哪些旋钮、每个旋钮取值是什么"。它是在模板函数内部通过cfg.define_split、cfg.define_knob这些调用动态构建出来的,是模板的副产品。也就是说,搜索空间不是单独定义的,而是定义模板时顺手长出来的。
Tuner是"搜索器",它拿着 Task 和 ConfigSpace,反复挑一个候选配置,交给测量后端跑一遍拿到时间,然后根据结果更新自己的策略,再挑下一个。
这三者的关系可以类比成:Task 是考题,ConfigSpace 是选项集合,Tuner 是答题策略。测量后端(measure_option)是判卷老师,日志是答题卡。这一整套流程在代码上的入口就是autotvm.task.create()加上tuner.tune()。
2. 模板函数怎么写:把可调旋钮嵌进 te 算子里
模板函数是整件事的地基,写错了后面全白费。它的契约很简单:接收一组算子参数(形状、dtype 等),返回(schedule, [输入张量列表])二元组。函数内部既要用te把计算定义出来,又要通过autotvm.get_config()拿到配置对象,在构建 schedule 的过程中把配置项应用到具体的循环上。
这里最容易混淆的一点是:同一个模板函数会以两种身份被调用。调优阶段,get_config()返回的是一个ConfigSpace对象,cfg["tile_x"].apply(...)这种调用会往搜索空间里注册一个可调项,并且随便取一个值让 schedule 能建起来;应用阶段,get_config()返回的是一个ConfigEntity,里面装的是从日志里读出来的具体数值,apply就是老老实实把那个值用上。理解这个双身份机制,你就明白为什么模板函数里绝对不能有副作用,也不能依赖任何外部可变状态。
2.1 一份能跑通的矩阵乘模板骨架
下面这段是我常用的最小可用模板,矩阵乘法,两个可调项:分块因子和一个展开因子。参数不是最优的,但结构完整,可以直接改。
import tvm from tvm import te, autotvm @autotvm.template("tutorial.matmul") def matmul(M, N, K, dtype="float32"): A = te.placeholder((M, K), dtype=dtype, name="A") B = te.placeholder((K, N), dtype=dtype, name="B") k = te.reduce_axis((0, K), name="k") C = te.compute( (M, 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() y, x = s[C].op.axis # 注册一个二维拆分:把 y 轴拆成 yo, yi,x 轴拆成 xo, xi cfg.define_split("tile_yx", y, num_outputs=2) cfg.define_split("tile_xx", x, num_outputs=2) # 注册一个离散取值:内层循环展开多少 cfg.define_knob("unroll", [1, 2, 4, 8]) yo, yi = cfg["tile_yx"].apply(s, C, y) xo, xi = cfg["tile_xx"].apply(s, C, x) s[C].reorder(yo, xo, yi, xi) s[C].unroll(xi, cfg["unroll"].val) s[C].vectorize(xi) return s, [A, B, C]几个细节值得单独说。第一,define_split的第一个参数是旋钮名,第二个是要拆的那个迭代变量,不是维度下标。很多人一开始会传x这个 Python 元组变量里的某个位置,写错了会直接抛类型错误。第二,apply的返回值才是拆出来的新轴,必须用返回值去reorder,不能用原来的y、x。第三,cfg["unroll"].val在调优阶段取的是候选值之一,在应用阶段取的是日志里的值,两处代码完全一致,不需要条件分支。
2.2 define_split 与 define_knob:哪些参数值得暴露出去
搜索空间的大小直接决定调优时间,而空间大小等于各个旋钮取值个数的乘积。上面那份模板里,define_split默认会把所有能整除的因子都枚举出来,对于 1024 这种高度合数的维度,单个define_split就可能产生几十个候选,两个一乘就是上千,再乘上unroll的 4 个取值,总空间就到几千了。
我一般遵循三条经验:其一,只暴露真正影响性能的轴,比如 reduction 轴的拆分对矩阵乘影响很大,而 batch 维如果只有 1,拆它毫无意义,直接写死。其二,用define_knob手工给候选值,不要图省事让define_split全量枚举,比如分块因子只给[1, 2, 4, 8, 16]这几档,空间立刻降一个数量级。其三,候选值要贴合硬件,向量化宽度给到 8 而你的平台只支持 4 路 SIMD,那一半候选值纯属浪费测量次数。
define_split还有一个filter参数,可以传一个 lambdas 去剔除不合理的组合:
cfg.define_split("tile_yx", y, num_outputs=2, filter=lambda x: x.size[0] <= 64 and x.size[1] <= 16)这个filter里的x是一个候选拆分方案,x.size是拆出来的各级因子。用它来排除"外层因子过大导致缓存装不下"或者"内层因子过大导致寄存器溢出"的明显坏解,能省掉大量无效测量。我通常会给内层因子设一个上限,因为内层因子直接决定寄存器压力和向量化宽度,超出硬件能力的解测都不用测。
另外别忘了cfg.add_flop(...),把算子的浮点运算次数告诉 AutoTVM,这样日志和进度条里就能显示 GFLOPS,调优过程中你能一眼看出当前最优配置的性能水平,比只看毫秒数直观得多。矩阵乘的 flop 就是2 * M * N * K。
3. 测量后端怎么挑:本机跑与远程跑的真实差别
模板写完,下一步是告诉 AutoTVM "怎么编译、在哪跑、跑几次"。这一整套配置通过autotvm.measure_option传进去,由两个组件组成:builder 负责把 schedule 编译成可执行模块,runner 负责执行并计时。API 上看起来只是两个参数,实际选型时踩的坑最多。
最基础的写法长这样:
measure_option = autotvm.measure_option( builder=autotvm.LocalBuilder(timeout=10), runner=autotvm.LocalRunner(number=10, repeat=3, min_repeat_ms=500, timeout=10), )这段代码的含义是:在本地编译,在本地跑,每次测量内部执行 10 遍取平均,整个测量重复 3 次取最小值,每次测量至少耗时 500 毫秒。
3.1 LocalRunner:最短路径与三个必调参数
本机测量适合调优目标和调优环境是同一台机器的情况,比如给服务器 CPU 调优,或者给本机的 GPU 调优。它的优点是零配置、启动快,缺点是测量精度受系统噪声影响大,尤其是共享机器上。
number、repeat、min_repeat_ms这三个参数是控制精度的核心,很多人直接抄默认值,结果调出来的配置在不同时间跑出来的分数差异很大,其实问题出在测量本身不稳。
number是单次测量内部的执行次数,取平均。它的作用是摊薄单次调用的计时误差,因为time.time()或者 CUDA event 的精度有限,算子跑一次可能只有几十微秒,直接测一次误差能到 20% 以上。repeat是整个测量重复的次数,取最小值,它的作用是规避系统噪声带来的偶发慢速,比如某次测量恰好遇到调度抖动或缓存被换出,取最小值就把这种偶发因素过滤掉了。min_repeat_ms是一个自适应的兜底:如果一次测量总耗时不到这个值,runner 会自动加大内部执行次数,直到超过为止,这样能保证测量的信噪比。
我的经验取值是:CPU 上调优,number=10、repeat=3、min_repeat_ms=500;GPU 上调优,number=10、repeat=1、min_repeat_ms=1000,因为 GPU 单次 kernel 太短,靠min_repeat_ms自动放大执行次数比手动设number更靠谱。如果调优时间预算紧张,可以降到min_repeat_ms=200,但代价是选出来的配置可能不是真正的最优,只是"在噪声里看起来最优"的那一个。
还有一点容易忽略:LocalRunner默认在 CPU 上跑,如果你的 target 是 GPU,必须显式传device参数,或者在环境变量里配好。不传的话它会拿 CPU 的计时去给 GPU 的 kernel 打分,结果完全失真,而且不报错,这是最阴险的一类问题。
3.2 RPCRunner:交叉编译链路和 device_key 的对应关系
真正上生产的场景,调优机往往不是目标设备。给嵌入式板子或者手机调优,只能交叉编译之后把产物推过去跑,这时候要用RPCRunner。
它的工作方式是:目标设备上跑一个 RPC server,调优机上跑一个 RPC tracker,调优程序通过 tracker 找到设备、把编译好的模块推过去、远程执行并取回耗时。配置上需要三方信息对齐:
measure_option = autotvm.measure_option( builder=autotvm.LocalBuilder( n_parallel=4, build_func="ndk", # 交叉编译工具链 ), runner=autotvm.RPCRunner( "device_key_xxx", # 必须与设备端 server 的 key 一致 host="127.0.0.1", port=9190, number=10, repeat=3, timeout=60, ), )device_key是三处必须完全一致的字符串:目标设备启动 server 时传的 key、调优程序里RPCRunner的 key、以及 target 字符串里的-device=参数(如果你的 target 是这种写法)。任何一处不一致,表现都是"连接超时"或者"找不到设备",而不是给出明确的配置错误,排查起来很耗时间。
build_func这一项决定用哪套工具链编译。可选值包括"default"(本地编译器)、"ndk"(用环境变量里的TVM_NDK_CC指定的交叉编译器)、"cc"(通用,靠 target 里的-mtriple指定架构)。我一般用"cc",配合完整的 target 字符串,比如"llvm -mtriple=aarch64-linux-android -mattr=+neon",这样 target 里的架构信息和编译器的架构信息只在一个地方维护,不容易错配。
n_parallel是并行编译的进程数,调大能显著减少编译等待时间,但要看你调优机的内存是否扛得住,每次编译都要加载完整的 TVM 运行时。
4. Tuner 选型与调优预算分配
模板和测量后端就位后,最后一步是选搜索器。AutoTVM 提供的那几个 tuner 在 API 上几乎可以互换,差别全在搜索行为上,选错会导致"该收敛的时候在瞎逛"或者"过早收敛到坏解"。
4.1 随机、网格、XGBoost 三种搜索行为的差异
| Tuner | 搜索行为 | 适合场景 | 缺点 |
|---|---|---|---|
RandomTuner | 在空间内均匀随机采样 | 空间小、想快速摸底 | 不看历史结果,收敛慢 |
GridSearchTuner | 按顺序穷举所有组合 | 空间极小(几十个点以内) | 组合爆炸后完全不可用 |
XGBTuner | 用梯度提升树做代价预测,引导采样 | 空间大、trial 预算有限 | 冷启动阶段偏慢,前几十个点近似随机 |
GridSearchTuner的实际用途比想象中少,它更适合做验证实验:比如你想确认某个新加的旋钮到底有没有用,用网格把它的取值全跑一遍,成本可控,结论明确。
RandomTuner在小空间里表现不比 XGBoost 差,而且实现简单、没有额外依赖。如果你的搜索空间只有一两百个点,直接用随机就够了,没必要上模型。
XGBTuner是默认选择,实测下来几百个 trial 通常能收敛到接近最优。它内部区分kind="knob"和kind="curve"两种模式:前者把每个旋钮当成离散类别特征,后者利用拆分因子的数值连续性做更细的建模。对于大量使用define_split的模板,kind="curve"往往效果更好。
tuner = autotvm.tuner.XGBTuner(task, kind="curve")还有一点:XGBoost 的代价模型是按 log(cost)建模的,也就是它对相对误差更敏感,这对调优来说是好事,因为性能优化关注的通常也是相对提升。
4.2 number、repeat、timeout 与 early stopping 的组合
tuner.tune()的参数里,除了n_trial和measure_option,还有两个是控制搜索节奏的:
tuner.tune( n_trial=1000, measure_option=measure_option, callbacks=[ autotvm.callback.progress_bar(1000), autotvm.callback.log_to_file("matmul_tune.log"), ], early_stopping=200, )n_trial是试验总次数上限。注意每次 trial 是一次"采样 + 测量",实际编译和执行的总时间等于 trial 数乘以单次测量耗时再加编译时间。一千个 trial 在 CPU 上可能要跑几个小时,时间预算要从这里倒推。
early_stopping是"连续多少次没有刷新最优值就停"。这个参数设得太大浪费预算,设得太小容易过早停在一个次优解上。我一般设成n_trial的 1/5 到 1/3,比如一千个 trial 配 200 到 300 的 early stopping。如果搜索空间本身很平滑(比如只有一个旋钮),可以更激进一些,因为这类空间里一旦找到局部最优,附近基本就是全局最优。
callbacks里的进度条是可选的,但强烈建议保留,不是为了好看,而是进度条上会显示当前最优记录的耗时和 GFLOPS,你能实时判断"还在明显下降"还是"已经一动不动了",这比调优结束后再看日志要直观得多。
5. 日志是调优的资产:读写、续调和合并
调优跑一次成本不低,日志的价值不在跑完那一刻,而在于后续所有复用。很多人调优时随手把日志扔在临时目录,模型上线后想排查某个算子为什么变慢,发现原始调优记录早没了,只能重跑。我自己维护一个目录结构,按"硬件型号 / 算子名 / shape 哈希"分层存日志,一套硬件上跑过的日志基本可以一直用下去。
5.1 record 的四元素结果字段
日志的每一行是一个 JSON 对象,解开看大概是这个结构:
{ "i": [["tutorial.matmul", 1024, 1024, 1024, "float32"], "llvm -keys=cpu ..."], "c": {"tile_yx": [[4, 8], [16, 2]], "tile_xx": [[2, 4], [10, 4]], "unroll": [1, 8]}, "r": [[0.00123], 0, 0.045, 1690000000.0], "v": 0.4 }i是 task 标识,包含算子名、参数列表和 target 字符串,这也是为什么 target 变了日志就用不上——匹配是全字段匹配。c是配置,每个旋钮的值记录成[当前值在候选列表中的下标, 候选列表长度],这种紧凑格式省空间。r是结果,四个元素分别是:
- 第一个:测得的执行耗时(秒),是
number次执行的平均值、repeat次的最小值 - 第二个:错误码,0 表示正常,非 0 表示编译失败或运行失败
- 第三个:这次测量的总耗时(秒),包含编译和执行,用于统计调优成本
- 第四个:时间戳
理解r里第一个值是什么尤其重要,因为它不是"跑一次的时间",而是统计之后的值。你如果在别处手工计时做对比,数字对不上是正常的,比较的基准不一样。
autotvm.record.load_from_file()会把这些 JSON 解析成(state, config)的列表,state里有task、cost、error_no等属性,你可以写脚本自己做筛选:比如剔除 error_no 非 0 的记录、按 cost 排序取前十个看看它们的配置有没有共性。这种手工分析在大空间调优之后特别有用,能帮你发现"某个旋钮的某个取值区间普遍表现好"这类规律,进而收缩搜索空间重调,比盲目加 trial 有效得多。
5.2 多进程调优的日志竞争
log_to_file默认是追加模式,这个默认值意味着你可以安全地分批调优、追加到同一个日志。我常用这个特性把长时间的调优拆成几个晚上的批次,中间机器要用就中断,第二天接着跑,日志自动累积。
但多进程并发写同一个日志文件时要注意:每一行是独立写入的,通常不会出现半行撕裂(因为单行长度远小于缓冲区大小且是追加写),但如果进了不同的文件系统或者用了某些容器挂载方式,理论上存在风险。更稳妥的做法是每个进程写各自独立的日志文件,调优结束后用cat合并成一个,日志格式是逐行 JSON,合并天然安全。
续调还有个技巧:如果某个 task 的日志里已经有几千条记录,重新调优时 tuner 并不会自动读旧日志继续,你需要先把旧日志里的记录加载进 tuner 的 history。XGBTuner提供了一个内部的 history 容器,可以通过遍历旧记录手动灌进去,但更简单的做法是:把旧日志当成一次"预采集",直接用旧日志跑apply_history_best看效果,如果已经够用就不再调;如果不够用,就在同一个日志文件上继续追加,调优结束后整体重新选最优即可——因为apply_history_best是在整个日志里全局取最优,追加的内容会自动参与比较,不需要特殊处理。
6. 把最优配置接回构建:apply_history_best 的作用域
调优跑完,日志里躺着几千条记录,最终的落地代码只需要一行上下文管理器:
with autotvm.apply_history_best("matmul_tune.log"): with tvm.transform.PassContext(opt_level=3): lib = tvm.build(s, [A, B, C], target="llvm")这一行的效果是:在这个with块内部的所有tvm.build或relay.build调用,遇到模板函数时不再走get_config()的调优分支,而是走应用分支,把日志里该 task 的最优配置注入进去。
6.1 with 块内部发生了什么
apply_history_best的实现是一个上下文管理器,进入时把当前线程的 dispatch context 换成一个从日志构建的DispatchContext,退出时恢复原样。这个 context 里维护着"task key → 最优 config"的映射表。
关键在于匹配规则:日志里的 task 标识是(算子名, 参数元组, target 字符串),构建时用同样的规则生成查询 key,三者全等才算命中。这意味着:
- 算子名必须完全一致,
@autotvm.template("tutorial.matmul")里的字符串就是 key - 参数的类型和顺序必须一致,
(1024, 1024, 1024, "float32")和(1024, 1024, 1024)算不同的 key - target 字符串必须一致,多一个空格或者少一个
-mattr参数,都算不同的 key
命中失败时不会报错,静默走模板里的get_config()默认路径,用一个随机的候选配置。这是最坑的一点:你满心以为调优生效了,实际测出来性能没变,原因是 key 没对上。
我一般会在with块里加一句断言式的调试输出,把命中的 task 数打出来:
from tvm.autotvm import task as _task with autotvm.apply_history_best("matmul_tune.log"): ctx = autotvm.DispatchContext.current for k, v in ctx.query_imported_fallback.items(): print("hit:", k) # 再进行 build不同版本的内部属性名可能不同,但思路是通用的:构建前先确认命中了,再谈性能。
6.2 验证调优是否真的生效的三个办法
第一,看编译产物。调优生效前后,生成的函数体(lib.get_source()或者反汇编)里循环结构会明显不同,分块和展开会体现在代码里。这是最直接的证据。
第二,做 A/B 计时。同一份代码,一次不加with,一次加上,用足够多次的执行取平均值对比。差异在 5% 以内的话,说明调优收益不明显,可能是这个算子的瓶颈不在 schedule 参数上,而在内存带宽或者算法选择上,这时候继续调参是徒劳的。
第三,看端到端。单算子调优的收益不等于端到端收益,算子在整体耗时里占的比重决定了上限。我遇到过一个算子调优提升 40%,但它在推理流程里只占 5% 的时间,端到端只提升了 2%。所以在投入大量调优时间之前,先做性能剖析,找出真正的大头,把调优预算花在占比最高的几个算子上。
7. 踩坑实录:模板污染、target 错配与缓存假象
写到这里,API 的主链路已经完整了。但真正让我吃亏的都不是 API 用法,而是几个看起来无关紧要的实现细节。
7.1 模板函数里藏了可变全局对象
有一次我为了复用几个中间结果,在模块顶层定义了一个列表,在模板函数里往里 append。表现是:调优阶段跑得好好的,测量数据也正常,但应用阶段生成的代码和日志里的最优配置对不上。排查了很久才发现,模板函数在一个进程里会被调用非常多次,每次调用都往那个列表里塞东西,导致 schedule 的构建过程依赖了之前调用的状态。
AutoTVM 的模板函数必须是纯函数:同样的输入,必须产出同样的 schedule 结构,唯一的变量是get_config()给出的配置值。任何跨调用的状态——全局变量、类的实例属性、闭包捕获的列表——都会污染结果。我后来的做法是,模板函数里所有中间变量都在函数内部新建,需要共享的东西通过参数传进去,宁可多写几行也不留隐患。
还有一个变体:用lru_cache缓存模板函数的编译结果。这个做法在调优阶段是灾难性的,因为调优就是要用不同配置反复构建同一个算子,缓存直接让所有配置返回同一份结果,实测数据全是假的。如果你确实想加速,缓存的粒度应该是"编译产物",而且要在配置层面做 key,不是简单的参数 key。
7.2 target 与物理设备不一致的隐蔽失败
生成 task 时的 target 字符串,必须和实际运行时的硬件严格对应,包括 CPU 特性。我犯过一次错:target 写成"llvm"(不带任何-mattr),但实际机器支持 AVX2。结果是编译出来的代码只用了 SSE 级别的指令,调优器在一个错误的指令集假设下找最优配置,找出来的"最优"在启用 AVX2 之后完全不是最优——搜索空间里那些向量化宽度为 8 的候选,因为编译器生成不出对应指令而全部被判为慢速,实际上它们才是真正该选的。
正确的写法是把机器特性显式写进 target:
target = "llvm -mcpu=skylake-avx512" # 具体型号 # 或者 target = "llvm -mattr=+avx2,+fma" # 特性列表用具体型号(-mcpu)比手工列特性更省心,因为型号里隐含了一整套特性组合。但对跨机器分发的产物,用-mattr更可控,你可以明确知道用了哪些指令,避免在不支持的机器上崩溃。
还要注意 target 字符串会进日志的匹配 key,调优时用的 target 和部署时用的 target 必须完全一致。我现在的做法是把它定义成一个模块级常量,调优脚本和构建脚本从同一个地方导入,从源头上杜绝不一致。
还有一个更隐蔽的场景:在容器里调优,容器的 CPU 特性和宿主机不一致,尤其是通过 cgroup 限制或者做了 CPU 亲和性绑定之后,暴露给编译器的特性列表可能变窄。这种情况下的调优结果拿到宿主机上用,可能反而变慢。我现在调优前会先跑一个探测脚本,打印当前进程可见的 CPU 特性,确认和部署环境一致再开跑。
最后说一个关于调优预算的心得:AutoTVM 的搜索效率在 trial 数达到几百之后边际收益急剧下降,继续加 trial 大概率只是在噪声里打转。与其把一千个 trial 加到五千,不如把搜索空间的候选值重新审视一遍,砍掉明显不合理的挡位,或者针对性地给某个关键旋钮更细的取值粒度。我在实际项目里最有效的一次优化,是把某个旋钮的候选值从均分的 8 个改成贴合缓存行大小的 5 个,trial 数减半,最终选出来的配置反而快了 6%。