FEATURED · 精选文章

LLVM IR性能排序驱动的自动调优迁移学习策略

发布时间 / 2026/9/17 9:38:37
来源 / 创域科博编辑部
栏目 / 资讯中心
LLVM IR性能排序驱动的自动调优迁移学习策略 做自动调优autotuning的人大概率都经历过这种崩溃一个矩阵乘法 kernel在 A100 上调到飞起换到 MI250 上全得重来。跑几百次编译加运行一跑一晚上出来一张几十项的配置表下次换台机器又要重新开始。迁移学习Transfer-Learning这几年一直被当成解药但真正落地时你会发现怎么选源任务、怎么判断两个程序长得像不像比调参本身还难。这篇文章要聊的就是一个很巧的思路——把编译中产生的 LLVM IR 当作“指纹”先训练一个轻量模型对 IR 做性能排序Performance Ranking再用排序结果去指导迁移学习的源选择与初始化让新一轮调优的起步配置更接近全局最优省掉大量无效编译运行。这个方法的核心在于把“性能预测”转化成“性能排序”。传统预测模型要回答“这个配置跑多快”这在跨任务、跨机器时误差很大而排序模型只需要回答“这个配置是否比那个配置快”相对顺序比绝对数值稳定得多也更容易迁移。我最早是在一个循环分块内核调优项目里试了这个思路实测下来起步配置的搜索效率提高了 30%-50%而且代码量和复杂度远低于我预想。下面我把整个方案的原理、实现、踩坑过程都展开讲讲。1. 自动调优为什么又慢又贵先拆清楚问题1.1 一次“普通调优”实际上在烧多少钱自动调优的本质是在一个巨大的配置空间里去搜出最优编译参数和循环变换策略。以最常见的循环分块loop tiling加向量化为例分块大小可以从 16 到 512 选向量宽度有 4/8/16展开因子又有 2/4/8再加上循环交换、循环融合等二选项组合数轻松破 10^8。一个 autotuner 每次评估一个配置需要完整编译一次代码再运行几十次取性能中位数一次评估的代价从几秒到几分钟不等。搜几千轮一晚上就没了算力成本拉满结果还只对当前硬件和编译器组合有效。这里有个特别容易被忽略的浪费大量运行发生在“前 200 轮”的无序探索里。随机搜索或者贝叶斯优化的初始点基本都是从空间里随机撒点前几百轮都是在探路。如果告诉你“根据历史经验这类程序在分块 128、向量 8 时的表现大概率不错”你就能跳过大多数“听起来合理但实际很拉垮”的配置。1.2 迁移学习已有的解法为什么还不够把之前任务学到的知识搬到新任务上迁移学习听起来就是天然解药。已有的做法大概分三类一是直接把源任务的最优配置做冷启动warm start这是最简单也最脆弱的——源程序结构和目标差得远时这个配置可能比随机点还差二是用历史数据训练一个回归模型预测新任务上的绝对运行时间再喂给贝叶斯优化做先验这种方案受机器型号、编译器版本影响剧烈同一个源码在不同机器上的最优配置完全不同用绝对运行时间做标签模型学到的很多是“机器噪声”三是做多任务联合优化把新任务和所有旧任务一起训练一个代理模型这个效果最好但复杂度和计算开销都高小团队很难落地。这些方法共通的短板在于它们都在回答“这个配置在新任务上能跑多快”而“跑多快”这个标签本身就不稳定。同样的优化配置在 CPU 上快了 1.5 倍在 GPU 上可能反而慢了 0.8 倍。目标函数都不稳定学出来的模型自然一迁就歪。1.3 换个思路如果要回答的是“谁比谁快”呢我最早看到“性能排序”这个思路时第一反应是“这能准吗”后来仔细想了下它的鲁棒性恰好是它能用的原因。运行时间这类绝对标签受实验环境波动影响很大今天跑和明天跑能差 5%但同一个任务上配置 A 比配置 B 快这个相对顺序在绝大多数情况下是稳定不变的。不同机器之间绝对性能不可比但“这个程序像不像某个已调过的程序”这件事是可比的。这就是 LLVM IR Performance Ranking 的核心逻辑不直接预测新任务的性能数值而是学习一个“打分函数”给定一个 kernel 的 IR 特征和一个候选配置输出一个排序分数。这个分数不追求绝对准确只追求能把好配置排在坏配置前面。因为排序信号是跨任务、跨机器都能获得的迁移起来比回归模型方便得多。2. 核心思路为什么是 LLVM IR以及“排序”到底比“预测”好在哪2.1 程序相似度怎么衡量源码、AST、IR 的取舍要判断“新程序和历史哪个程序更像”第一步得给程序做一个可计算的表示。最自然的想法是用源码文本或 AST但这两者都有问题。源码文本对注释、变量命名、代码布局敏感两个逻辑一模一样的 kernel一个用了无符号整数另一个用了有符号整数文本相似度就很低AST 稍微好点但仍然停留在语法层两个通过不同写法表述同一循环结构的程序AST 的差异可能很大。更关键的是源码和 AST 都不直接反映编译器真正做了什么优化——同一个循环开不开向量化性能差 5 倍这在源码层面几乎看不出来。LLVM IR 站在了一个很好的位置上。它在语法层之下、机器码之上目标无关但与优化直接相关。IR 保留了循环结构、基本块划分、函数调用、访存操作、算术指令类型——这些恰好是决定性能调优策略的核心信息同时也稳定地折叠了语法层的噪声。实践里还有一个额外的好处同一个程序经过不同优化级别编译产生的 IR 既有关联又有差异天然适合做迁移学习里的“域适应”样本。2.2 为什么“排序模型”比“回归模型”更适合跨任务迁移我们用生活里的例子类比一下。预测一个人能跑多快影响因素太多年龄、状态、装备、风速绝对速度很难跨场地比较但“甲比乙跑得快”这个顺序在一个相对稳定的条件下是比较可靠的。跨到另一个场地虽然大家速度都变了但排序可能依然差不多。LLVM IR 性能排序就是这个道理。具体到实现上回归模型要学的映射是(IR特征, 配置) - 运行时间这个目标函数在不同硬件、不同编译版本上分布差异很大很容易学成“记性模型”——记住了训练集的机器表现却学不会迁移。排序模型学的是(IR特征, 配置A, 配置B) - A是否优于B在构造训练数据时把“同一个 kernel 在不同配置下的性能对”作为样本模型学到的是配置之间的相对优势模式这个模式在不同目标机器上比绝对时间稳定得多。实测中我在三台不同微架构的 CPU 上做验证回归模型的迁移误差达到 20%-40%而排序模型的 top-20 配置识别准确率有 65%-75%显著更可用。2.3 用 IR “指纹”选源任务比用元特征选源任务靠谱在哪传统迁移学习里选源任务常用的做法是给每个任务算一组元特征问题规模、并行度、访存密度等然后在新任务和候选源任务之间算元特征距离。这个方法的问题在于元特征是我主观定义的不同的人定义不同而且往往覆盖不到真正影响性能的因素。比如两个 kernel一个矩阵乘一个矩阵加规模并行度都差不多但优化策略天差地别。IR 特征是由编译器自动生成的高维表示它不依赖人的先验定义。更妙的是IR 特征天然编码了“编译器在做什么”的信息循环嵌套多深、基本块多少、访存指令占比多少、有没有向量化机会这些都是编译器优化时真正在看的东西。用它做相似度计算选出来的源任务在优化策略上往往真的相似。我在实验里遇到最典型的一个例子历史库里有高斯消元和一个自定义的图像变换 kernel两者源码完全不同但 IR 特征距离很近把前者的最优配置迁移过去后搜索第一轮就超过了随机搜到 200 轮时的性能。这就是机器人能自动找到的“隐藏相似性”。3. 系统设计拆解从 IR 到排序模型再到迁移接入3.1 整体流程五步走环环相扣这套方案可以拆成五个模块IR 提取、特征化、排序模型训练、源任务选择、迁移调优接入。第一步对历史库里的每个 kernel用 clang 生成多个优化级别下的 LLVM IR第二步把 IR 转成向量特征这一步决定了整个方案的天花板第三步用历史调优数据构造 pairwise 样本训练排序模型第四步新 kernel 来了提取 IR 特征用训练好的模型对所有历史任务计算排序分数选出最像的 Top-K 个源任务第五步把这 K 个源任务的最优配置作为新任务调优的初始点或以先验形式注入搜索器。这里多说一句排序模型不只是给“选源”用的它本身就能当轻量级代理模型用。在调优初期先用排序模型对候选配置打分只挑得分高的子集去实际编译运行能再省一批运行时间。相当于一个没有任何训练开销的“免费的预筛器”。3.2 LLVM IR 特征化不要一上来就用高维操作码直方图刚开始做 IR 特征时最容易犯的错误是把 LLVM IR 里 200 多个操作码全做一个直方图形成一个几千维的稀疏向量。这样做的直接后果是维度太高、样本太少排序模型训练稀碎过拟合到训练集的 benchmark 上。我更推荐的做法是把特征分三层设计。第一层是全局结构特征包括基本块数量、函数调用数量、循环嵌套数量、最大循环深度、内存操作指令占比、整数/浮点计算比例。这几项是粗粒度但极其稳定的相似度信号计算代价低跨版本稳定性好。第二层是细粒度操作码分布但不直接用完整直方图而是按功能聚成几十类算术类、访存类、控制流类、向量类等既能降低维度又保留优化相关信息。第三层是强化特征比如把循环体特征单独抽取出来关注循环内访存指令密度、是否有展开标记、循环携带依赖的大致形态。这三层拼起来大概 200-400 维排序模型训练起来非常稳。3.3 排序模型怎么训练pairwise 样本构造是关键训练排序模型样本构造比模型选型重要得多。我常用的做法是对每个历史 task跑完 autotuning 后把 config 按真实性能排序取前 20% 的配置作为“好配置”后 20% 作为“坏配置”然后在好配置和坏配置之间随机配对构造(IR特征, config_good, config_bad)的正样本对——好配置必须排在坏配置前面。注意不要只拿 best config 当正样本因为只用一个点没有泛化能力把前 20% 都当正样本模型能学到“这一类 kernel 下哪些配置区间普遍较好”的规律。模型选型上XGBoost 的 LambdaRank 是最省心的起点效果稳定、训练快、调参空间小。如果工程上想上线到在线系统用一个两层的 MLP 网络也能达到接近的效果。我自己的经验几千个样本对就足够让 XGBoost 学到有区分度的分数了主要前提是样本对要跨 kernel、跨配置空间分布不能全是从某一个 kernel 里采的。3.4 迁移学习怎么接入不同 autotuner 的三种接法接入方式取决于你用的 autotuner 框架。如果是 OpenTuner 这类基于进化的调优器最简单的方式是提供初始种群initial population把源任务迁移来的配置塞进前几代种群中。实测比随机初始化的种群收敛快很多。如果是基于贝叶斯优化的框架例如 GPTune 或自研的 SMBO可以把排序模型给出的最相似 Top-K 源任务的性能观测作为先验观测样本喂给高斯过程让 GP 的初始均值就落在合理区域。如果你的框架支持自定义搜索空间剪枝还有一个更激进的玩法先用排序模型对配置空间做粗筛把得分低的区域直接剪枝掉搜索空间缩到原来的 1/5 至 1/10搜索自然就快了。4. 实操环节从零搭一个最小可复现版本4.1 环境准备与工具链我用的是 Ubuntu 22.04 LLVM 16 Python 3.10autotuner 用 OpenTuner 做演示。工具链核心其实就三件clang 用来生成 IRllvm-stat 或者自己写 IR parser 用来提特征XGBoost 用来训练排序模型。具体安装不再赘述但有一个容易被坑的点一定要固定 LLVM 版本。不同大版本之间的 IR 会有细微差异如果历史库里的 IR 是用 LLVM 14 生成的新 kernel 用 LLVM 16 生成特征分布可能偏移排序模型表现直接下降。4.2 生成 IR 并提取特征我用下面的命令生成多个优化级别的 IRclang -S -emit-llvm -O0 -Xclang -disable-llvm-passes -o kernel_o0.ll kernel.c clang -S -emit-llvm -O2 -o kernel_o2.ll kernel.c clang -S -emit-llvm -O3 -o kernel_o3.ll kernel.c这里的细节是O0 的 IR 保留了完整的未优化结构O3 的 IR 已经经过了大量 pass 变换两者分别代表“程序怎么写”和“编译器会怎么优化”。如果只用一个级别特征会丢失一半信息。提取特征时我建议直接用 llvm 的 C API 写一个统计 pass比自己解析 ll 文件正则匹配要稳得多。特征输出格式我习惯用 JSON一组 kernel 对应一个特征向量数组。4.3 训练排序模型一份可以直接跑的最小代码下面是一个用 XGBoost LambdaRank 训练排序模型的最小示例。输入是三张表kernel 特征、配置参数、真实性能。输出是一个排序模型文件。import xgboost as xgb import numpy as np import json from sklearn.model_selection import train_test_split # 特征数据准备 # X 的每一行是一个 (kernel_idx, config_param) 拼接向量 # y 的每一行是该配置对应的真实性能 # group 是每个 kernel 对应的样本数LambdaRank 需要按组计算排序 with open(trainset.json) as f: data json.load(f) X np.array(data[features]) # shape: (n_samples, n_features) y np.array(data[performance]) # shape: (n_samples,) groups data[groups] # list, 每个 kernel 的样本数量 # 训练/验证切分保持组结构 train_idx, val_idx train_test_split( np.arange(len(y)), test_size0.2, random_state42 ) # 注意这里为了演示简化为按行切分实际使用时应按 kernel 切分 # 否则同一个 kernel 会泄漏到验证集里 dtrain xgb.DMatrix(X[train_idx], labely[train_idx]) dval xgb.DMatrix(X[val_idx], labely[val_idx]) dtrain.set_group(groups[: len(train_idx)]) dval.set_group(groups[: len(val_idx)]) params { objective: rank:pairwise, eval_metric: ndcg, eta: 0.05, max_depth: 6, subsample: 0.8, colsample_bytree: 0.8, seed: 42, } xgb_model xgb.train( params, dtrain, num_boost_round500, evals[(dval, val)], early_stopping_rounds50 ) xgb_model.save_model(rank_model.json)几个关键细节。第一rank:pairwise是 XGBoost 内置的 pairwise 排序目标它会自动把样本按 group 进行两两比较。第二set_group这一行不能漏这是告诉模型哪些样本属于同一个排序组同一个 kernel否则模型会以为所有样本都来自同一个任务。第三按 kernel 切分训练/验证集是非常重要的一步我见过有人按行随机切分结果同一个 kernel 的特征既在训练集又在验证集NDCG 虚高到 0.95一上真实新 kernel 就掉到 0.4。4.4 把排序模型接进 OpenTuner初始种群注入法OpenTuner 的核心接口是OpenTunerBenchmark和manipulator。要让迁移配置生效最简单的是在seed_config里注入初始点。下面是伪代码import opentuner from opentuner.search.manipulator import ConfigurationManipulator class MyBenchmark(opentuner.measurer.Measurer): def __init__(self, kernel_ir_feature, rank_model, source_configs): self.kernel_feature kernel_ir_feature self.rank_model rank_model self.source_configs source_configs # 由排序模型筛出的 Top-K 源配置 def __call__(self, cfg): # 编译运行返回性能 return compile_and_run(self.ir_path, cfg) def seed_configs(manipulator, source_configs): 把迁移来的配置作为初始种群注入 seeds [] for c in source_configs: cfg manipulator.config_from_dict(c) seeds.append(cfg) return seeds接好之后调优前几轮就会从这些迁移配置开始搜索而不是随机撒点。实际能省多少我在四个 kernel 上测过搜索到相同性能所需轮数平均减少了 30%-50%其中结构相似度高的 kernel 减少得最夸张比如两个不同尺寸的 2D 卷积第一轮迁移配置就比随机搜索的最好性能还好。5. 实测效果与收益边界什么时候好用什么时候会崩5.1 收益最明显的三类场景根据我的实验和看到的公开数据这套方案在以下场景收益最明显。第一类是结构化强的循环密集型 kernel比如卷积、矩阵运算、图像处理——这类程序循环结构规整IR 特征表现力强排序模型的信号清晰。第二类是调优目标机器与历史机器属于同一微架构家族的情况此时性能排序在机器间高度一致迁移效果最好。第三类是搜索空间较大的场景空间越大随机探索的开销越高一个好的初始点带来的收益越显著排序模型帮你在搜索开始前就避开大量“看起来合理实际很差”的区域。5.2 容易翻车的场景与失败模式也要泼几盆冷水。第一个容易翻车的情况是 GPU kernel 调优。GPU 的最终性能高度依赖后端的寄存器分配、共享内存调度等环节这些信息在 LLVM IR 层几乎是不可见的排序模型能学到的信号很弱。如果硬要用建议把特征换成 NVVM IR 或加入显式访存分析特征但整体提升有限。第二个是编译器版本跨度太大的问题我从 LLVM 14 的模型拿去给 LLVM 17 的新 kernel 用效果明显变差——IR 指令集和 pass 策略都变了。解决方法是历史库持续更新定期用新版本编译器重生成 IR或者训练时同时混入多版本 IR 做数据增强。第三类是 benchmark 分布太窄历史库全是浮点计算内核突然来了个字符串处理 kernel排序模型给出的 Top-K 源其实都不像硬迁移反而产生负迁移。解决办法是设一个相似度阈值低于阈值就不迁移直接走随机贝叶斯。6. 常见问题与排查技巧实录6.1 用着用着发现排序模型 NDCG 很高但迁移效果就是不好这个问题我踩过很深的坑。NDCG 衡量的是排序模型在验证集上的表现但如果验证集的划分方式不对NDCG 会虚高。比如同一个 kernel 的不同配置既出现在训练集又出现在验证集模型记住了配置组合NDCG 看着 0.9一到真正的新 kernel 就现原形。正确的做法是验证集必须整 kernel 划分训练时完全没见过这个 kernel 的 IR 特征。我后来把验证集里的 kernel 数量从 8 个加到 20 个NDCG 从虚高的 0.9 降到真实的 0.65但这个 0.65 反而能精准预测迁移效果。6.2 排序模型输出的分数在多个源任务之间没法比较刚开始做源选择时我直接用排序模型对“不同历史任务的 IR配置”打分数然后选分数最高的任务。结果发现分数在不同任务间分布差异很大某类任务分数普遍偏高就总是被选中。原因是排序模型只在组内做比较组间的分数 offset 是任意的。解决方案是不要直接比绝对分数而是用分数对每个源任务内部做归一化或者干脆只取每个任务的 top 配置列表再用 IR 相似度距离做二次筛选两种方式结合更稳健。6.3 迁移配置可能把调优器带到局部最优注入迁移配置有一个副作用如果迁移配置本身不是全局最优而且排序模型误导了搜索器搜索器可能在它附近反复探索后续的变异全部围绕这个种子进行容易陷入局部最优。我处理的办法是注入迁移种子后仍然保证初始种群里有 20%-30% 的纯随机配置让搜索器保持多样性。可以再加一个时间衰减的策略前 50 轮用迁移种子做参考50 轮以后迁移种子的影响权重逐渐降到 0把主导权交还给搜索器自己的采样策略。6.4 快速排查清单我把日常排查的问题整理成一张表遇到效果不好的时候按顺序排查现象可能原因排查方法排序模型 NDCG 高但迁移无效训练/验证集划分泄漏按 kernel 整组切分重新训练新 kernel 排序分数不稳定IR 特征提取差异LLVM 版本/优化级别不一致固定 LLVM 版本统一优化级别组合迁移后性能反而差选出了不相似的源任务检查 IR 特征相似度阈值低于阈值回退随机搜索搜索中后期收敛慢迁移种子过度引导降低迁移种子权重增加随机种群比例换了新机器完全失效排序模型在机器间泛化不足为不同微架构系列各训一个模型或用排序归一化特征7. 一点扩展的玩法排序模型不只是“选源”工具除了最直接的源任务选择排序模型还能当“搜索空间预筛器”用。我在另一个项目里试过调优前先用排序模型对随机采样的 5000 个配置打分取 top 100 作为候选池再跑真实编译运行。这个做法的收益在于排序模型是纯计算推理单次打分微秒级5000 次打分在几秒钟内完成等于用一次模型推理换掉了 100 次真实编译运行。在配置空间极大、单次编译运行成本很高的场景下这个预筛机制的性价比相当可观。还有一个有趣的方向是把排序模型在线化。正常调优流程结束后新产生的真实性能数据可以增量式地回流到排序模型里让它越用越准。配合每周一次的定时增量训练模型能跟着历史库的覆盖范围一起成长。我实际跑下来增量训练 3 轮后新 kernel 的 top-20 识别准确率从 65% 提到了 72% 左右虽然不再像最初那样惊艳但在长期运营的调优服务里是实实在在的积累优势。对于想试这套方案的团队我的建议是先别急着做大规模系统拿 5-10 个历史 kernel、一个排序模型、一个 autotuner 的最小闭环跑通验证“排序选源 初始点注入”的效果。大概率你会遇到和我类似的惊喜迁移学习不再是黑盒撞大运而是每一步都有明确的依据和执行路径。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻