KernelZero:让大模型自举进化,实现算子优化自动化
做算子优化的朋友应该都有体会:调一个 kernel,比训一个模型还累。同一个算子,换一张卡、换一个编译器版本、换一种数据形状,性能就可能翻车;同一段优化经验,从 CUDA 抄到 CANN,从 GPU 搬到 NPU,很多都得推倒重来。更麻烦的是,能写好算子的人本来就少,每个算子都要人肉调优,模型迭代的速度反而被这些底层算子卡住了脖子。
这两年大模型写代码的能力有目共睹,于是不少人动了心思:能不能让大模型来写算子?我自己试过不少方案,发现一个很致命的问题——模型会写常规业务代码,但写算子这种"正确性和性能都极其苛刻"的代码,几乎找不到可用的训练样本。网上能搜集到的优化算子实现,大多是编译产物、汇编级代码、平台相关代码,清洗出来根本没法训练。后来我换了个思路:既然缺数据,那就让模型自己造数据,自己出题,自己解题,自己评分,在一个闭环里持续进化写算子的能力。这个方案,就是 KernelZero。
KernelZero 这个名字有两层含义:一是"Kernel from Zero",从零开始写算子内核,不走手工调优的老路;二是"Zero Annotation",零人工标注。整个系统不需要人类专家去标注一份算子代码,也不需要手工整理训练集,所有题目、答案、评分全部由模型与评测环境自举生成。这篇文章我就把这套方案的设计思路、核心细节、实操过程和踩过的坑完整拆给你看。
1. 为什么算子优化成了 AI 的"卡脖子"环节,以及"自己出题"为什么是破局点
1.1 算子的本质:从拉普拉斯算子说起
很多人第一次听到"算子"这两个字,是在数学课上。拉普拉斯算子就是最典型的例子:它是一个二阶微分算子,用公式写出来是 Δf = ∂²f/∂x² + ∂²f/∂y²,在图像处理里可以当成边缘检测的卷积核来用。但从数学公式到一个能在 GPU 上跑得飞快的实现,中间隔着的不是一两步,而是一整套硬件映射工程。
在 AI 框架里,算子被定义为"对张量执行的一个基本计算单元",矩阵乘法(GEMM)、卷积(Convolution)、归一化(BatchNorm、LayerNorm)、激活函数(ReLU、SiLU)、Softmax、注意力计算,这些全都是算子。一个大模型的计算过程,本质上是成百上千个算子按照特定顺序组合执行。算子的实现质量直接影响整个模型的训练和推理速度,所以近些年业界对"大量使用算子对硬件性能的挑战"越来越重视——算子越多、越复杂,性能优化的压力就越大。
难点在于:同一个算子在不同硬件上有完全不同的写法。GPU 上要考虑线程层级、共享内存、寄存器分配、访存合并;NPU 上要考虑 AI 核心的流水线、数据搬运、计算单元匹配;CPU 上要考虑 SIMD 向量化、缓存友好性。一个在 A100 上表现优异的 GEMM 实现,搬到昇腾上可能直接退化到理论峰值的 20%。这就是为什么像 CUDA、CANN 这类算子开发工具链一直在做抽象简化,但最终写出来的高性能算子,依然极度依赖专家的领域知识和手工调优经验。
1.2 手工优化与大模型写算子的双重困境
手工优化现在面临三个问题。第一是人力缺口,能同时吃透硬件架构、编译原理和并行计算的人本来就不多,而 AI 模型迭代又催生了对新算子的持续需求。第二是迁移成本,每出现一款新硬件,所有核心算子都要重新适配一遍,这种重复劳动非常消磨人。第三是经验不可沉淀,很多优化技巧散落在资深工程师的大脑中,换一个人就换一套做法,团队能力无法稳定复用。
大模型写代码看似是解药,但真正动手实测过的人会告诉你:远没那么简单。模型生成普通业务代码正确率可以很高,但算子代码需要同时满足两个苛刻条件——数值正确和性能达标。数值正确要求模型对浮点计算的行为有精确理解,性能达标要求模型懂并行编程模型、懂内存层级、懂指令调度。更麻烦的是,这类高质量样本极度稀缺。GitHub 上大部分算子代码要么调用了现成库、要么是特定平台的生成物,没法作为训练语料。就算硬着头皮用这些数据去微调模型,模型学到的也只是"调用库"而不是"写实现"。
所以 KernelZero 的思路是:不依赖外部高质量数据,让模型通过自我对抗的方式不断生成题目和解法,由评测环境给出客观反馈,再把这些反馈变成新一轮的训练信号。这和 AlphaGo Zero 的自对弈思路很相似,核心逻辑是——如果评测是可靠的,那么大量生成的尝试本身就会筛选出越来越好的结果。这个思路最大的好处,是把算子数据稀缺的问题,变成了一个算力换数据的问题。而算力是可以堆的,高质量人工数据却很难堆。
2. KernelZero 的整体架构:出题器、解题器、裁判器三件套
2.1 自举闭环的核心思想:像考试一样训练模型
KernelZero 整个系统可以类比成一个"学校":出题器是老师,负责生成题目;解题器是学生,负责写算子实现;裁判器是阅卷系统,负责评分和反馈。三者循环起来就形成了一个不依赖外部输入的进化系统。
这里面最关键的设计决策是:为什么不让模型直接拿真实模型里的算子来练?原因有两方面。一方面,真实模型中的算子类型有限,覆盖不到那些"冷门但重要"的算子,比如一些特殊归一化、自定义融合算子;另一方面,很多真实算子的题目描述过于复杂,模型一开始根本无从下手,就像让小学生直接做高考压轴题,只会打击学习信心,没法形成有效的梯度信号。所以 KernelZero 坚持从原子算子、简单组合开始,让题目难度和模型当前能力动态匹配。
整个闭环的数据流是这样走的:出题器生成一个算子任务描述(包括数学定义、输入输出约束、性能目标);解题器尝试生成 8~16 个候选实现;裁判器对候选实现做正确性验证和性能基准测试,过滤出正确且高效的结果;然后这些结果连同题目一起被写入训练样本池,用于下一轮对解题器的微调。同时,失败的实现会被记录到"困难样本库",出题器会参考这些失败记录调整后续出题的难度和方向。每一轮完整循环,都相当于一次结构化的"考试+补课"。
2.2 三模块的具体分工与设计取舍
出题器(Generator)的职责是生成"题目"。题目不能是简单的自然语言描述,而必须是形式化、可验证、可打分的任务规格。一个合格的题面通常包含四部分:算子的数学定义、输入张量的形状与数据类型约束、参考行为说明(允许调用哪些底层原语、禁止调用哪些高层库)、性能目标(比如"在 A100 上吞吐不低于 cuDNN 参考实现的 80%")。
解题器(Solver)是核心生成模型。它接收题面,输出一份可编译的算子代码。在训练初期,解题器可能只会生成一些朴素实现,比如用两层 for 循环写一个卷积,性能很差但至少正确。随着训练推进,模型会逐渐学会使用更高级的并行策略、向量化、缓存分块等技巧。这里有一个重要的设计约束:解题器只能使用评测环境允许的底层编程接口(比如 CUDA 核心语法或者 Triton DSL),不允许直接调用 cuDNN、CUB 这类封装库。否则模型会找出最简单粗暴的作弊路径——调用一个高性能库函数,那就丧失了"写算子"的意义。
裁判器(Judge)承担着整条链路里最重的责任。它需要同时保证"评判的公平性"和"反馈的有效性"。公平性是指不被模型的取巧手段绕过,比如模型硬编码特定输入的输出结果;有效性是指打分能区分不同实现之间的差距。裁判器的具体设计会在下一章展开,这里先给出一个最核心的原则:裁判器必须要有多样化的校验手段,任何单一校验方式都可能在长期的自举训练中被模型找到漏洞。
3. 题目怎么出才有效:题型模板、难度自适与课程学习
3.1 四种题面生成方式:从模板到动态派生
我实际测试下来,题面生成有四种有效方式,各有优劣。
第一种是模板化枚举。把原子算子按"类别、形状、约束"组合出大量题面。比如逐元素算子(Elementwise)模板:输入形状从 [32, 32] 到 [2048, 2048] 随机采样,支持的数据类型在 FP16、FP32、BF16 中随机选择,操作类型从 ReLU、SiLU、GELU 等激活函数中随机选择。这种方式的优势是简单可控,特别适合训练早期帮模型建立基础能力;缺点是题型单一,模型很快会"背题"。
第二种是计算图组合。从常见模型结构(如一个 ResNet 块、一个 Transformer 块)中抽取子图,把子图作为复合算子的题目,要求模型把多个原子操作融合成单个 kernel。这种方式训练出来的能力直接对生产有价值,因为真实场景里融合算子正是性能优化的重点。比如 conv + bn + relu 三段融合,就是一个经典的复合算子题目。
第三种是自然语言翻译式出题。让出题器先把一个计算图用自然语言描述出来,再让解题器根据这段自然语言写实现。这种方式的题面更贴近真实开发场景,模型的泛化性更好,因为题目不再是"模板化的规格单",而是一段像需求文档一样的文字。缺点是自然语言描述容易产生歧义,裁判器需要额外的约束来消除歧义,比如强制规定输入布局、维度顺序等。
第四种是失败衍生题。当解题器在某道题上反复失败时,出题器会分析失败模式,把原题拆解成更小的子题。比如一个 GEMM 写不出性能达标的版本,就拆成"纯内存布局迁移的 copy kernel"和"无分块的朴素 GEMM"两个子题,逐个击破后再合并回原题。这种做法非常有效,因为它是在针对模型的真实薄弱点出题,而不是随机撒网。
3.2 动态难度调节:像游戏匹配机制一样控制训练节奏
自举训练最大的风险之一是难度失控。题目太难,模型长期解不出,会产生大量无效采样,浪费算力;题目太简单,模型又学不到新东西,能力提升停滞。我的做法是引入一个基于成功率的动态难度调节机制,原理类似游戏里的动态难度匹配——玩家表现好就提高敌人强度,表现差就降低敌人强度。
具体实现时,出题器维护一张难度表,每个算子类别、每个形状区间、每个性能目标档位都对应一个权重。当解题器在某个难度档位上的成功率连续 N 轮大于 70%,出题器就把这个档位的权重提高,让更难档位的题目更频繁地出现;当成功率连续 N 轮低于 20%,就降低高档位权重,增加低档位题目。我这里 N 取的是 3 轮,太短会导致难度剧烈震荡,太长又反应迟钝。
实际效果方面,我大约在 5000 轮迭代后,让模型在常见算子上的平均首次正确率从 12% 提升到了 78% 左右。注意这是"首次正确率"——模型第一次看到题面就能写出正确实现的比例。这个指标比"多轮重试后的最终成功率"更能反映真实能力。如果只看最终成功率,早期模型也能靠反复试错达到 40%,但那并不代表它真的学会了。
3.3 裁判器的三层评测:正确性、性能、鲁棒性
裁判器是整个系统公平性的最后一道防线,绝不能简单用一个torch.allclose就完事。我把它设计成三层评测。
第一层是数值正确性评测。每个题面生成时会绑定一份参考实现。参考实现的来源有三种:对于纯数学定义的算子,用 NumPy 或 PyTorch 的高层 API 写出参考实现;对于需要严格验证的算子(比如整数运算、位运算类),额外用 CPU 端实现做交叉验证;对于复杂融合算子,用多种独立实现互相对比,剔除有异常的实现后再做黄金值对齐。比较策略上,不是简单地看绝对误差,而是结合atol和rtol双阈值判断,同时对不同浮点类型设置不同的容忍度:FP16 的误差容忍度要明显高于 FP32。
第二层是性能评测。性能目标不能只看端到端时间,因为模型写出来的 kernel 可能正确但完全没有做任何优化,端到端时间可能比参考实现慢 50 倍。我采用的指标是:单算子吞吐(throughput),即每秒处理的数据量,再与参考实现的吞吐做比值。性能合格线设置为参考实现的 60%,如果低于这个值,即使结果正确也判为不合格。这里要注意,性能评测必须在与训练环境一致且独占的硬件上进行,避免其他进程干扰导致误判。
第三层是鲁棒性评测。一些模型生成的狡猾实现会硬编码特定的数据值。比如题目里输入是随机数,模型就硬写一个 if 判断:"如果第一个元素等于 0.12345 就返回某个固定值"。这种实现在固定输入下能通过数值校验,但换一批输入就完全错误。我的对策是把输入随机化做多轮校验,每轮使用不同的随机种子和数据范围,同时随机改变输入的实际形状,防止模型对形状条件做分支硬编码。鲁棒性评测通过率也是最终得分的一个权重项,防止模型走捷径。
4. 实操过程:从 0 到 1 搭一个 KernelZero 训练循环
4.1 技术栈与模型选型:不是越大越好
实操第一步是选型。生成模型我强烈建议选代码能力温和的中等规模模型,而不是追求最大的参数规模。我在这套系统里先用的是基于 Qwen2.5-Coder-7B 的版本,后来也试过 DeepSeek-Coder-6.7B。为什么不用 70B 级别的模型?两个原因:一是自举训练需要高频采样、大量的推理请求,参数量太大,推理成本会高到无法承受;二是算子在正确性上的要求很特殊,大模型的"能力泛化"反而容易让它在不熟悉的算子领域过度自信,生成一堆表面正确但运行时崩溃的代码。
算子实现的后端语言我选了 Triton 作为主目标,CUDA 作为进阶目标。Triton 的好处是 DSL 层级的表达能力——模型不需要手动管理线程块和共享内存,只需要写"计算逻辑",编译器负责映射到 GPU。这大幅降低了生成难度,是训练早期的理想选择。当模型在 Triton 上已经稳定之后,再引入 CUDA C++ 考题,逼迫模型学习更底层的线程协作和内存管理。
推理加速用 vLLM 做 batch 采样,一次可以并行生成 8~16 个候选实现。评测环境的参考实现基于 PyTorch,编译校验用 Triton 自带的 JIT 机制。整个训练循环的代码骨架并不复杂,我把它简化后给你看。
4.2 核心训练循环的代码骨架与数据流
下面这个简化版的训练循环可以参考。它去掉了很多工程细节,但保留了完整的主干逻辑:
import random from typing import List, Dict class KernelZeroLoop: def __init__(self, solver, generator, judge, sampler): self.solver = solver # 生成模型 self.generator = generator # 出题器 self.judge = judge # 裁判器 self.sampler = sampler # 批量采样器 self.success_pool = [] # 成功样本池 self.fail_pool = [] # 困难样本池 def run_epoch(self, num_rounds: int = 100): for round_id in range(num_rounds): # 1. 出题:根据当前难度权重采样题目 problem = self.generator.sample_problem( difficulty_weights=self.generator.current_weights ) # 2. 解题:批量生成候选实现 candidates = self.sampler.generate_candidates( prompt=problem.to_prompt(), num_candidates=random.choice([8, 12, 16]) ) # 3. 评测:三层评测逐层过滤 passed = [] for code in candidates: v_result = self.judge.numerical_check(code, problem) if not v_result.ok: self.fail_pool.append((problem, code, v_result.reason)) continue p_result = self.judge.perf_check(code, problem) if not p_result.meets_threshold(0.6): self.fail_pool.append((problem, code, f"perf:{p_result.speedup:.2f}")) continue r_result = self.judge.robust_check(code, problem) if not r_result.pass_robust: self.fail_pool.append((problem, code, "robust_fail")) continue passed.append((problem, code, p_result.speedup)) # 4. 反馈:成功样本入池,更新难度权重 for item in passed: self.success_pool.append(item) self.generator.update_weights( problem=problem, success_rate=len(passed) / len(candidates) ) # 5. 定期微调:每 50 轮用池子里的数据做一次增量训练 if round_id % 50 == 0: self.finetune_solver(rerank=True) def finetune_solver(self, rerank: bool = True): # 用成功样本做 SFT,同时混入 10% 的历史样本防止遗忘 sft_data = self.build_sft_dataset( success_pool=self.success_pool[-2000:], history_pool=self.success_pool[:-2000], mix_ratio=0.1 ) self.solver.finetune(sft_data, lr=1e-5, loora_rank=32)这里有两个细节值得专门说明。
第一个是"按轮次微调"的节奏。我使用每 50 轮一次增量微调,而不是每轮结束都更新模型权重。原因是每轮产生的样本数量太少、噪声太大,直接更新模型会让参数反复震荡,训练不稳定。攒到 50 轮的量(大约 500~1000 条成功样本)再做 SFT 微调,信号要清晰得多。
第二个是"重排序"(rerank)步骤。微调之后,当前模型会变强,但历史样本里有些当时"过难"的题目,现在也许能解出来了。我每隔几轮会拿最近积累的困难样本,让新模型重新尝试一遍,把新解出来的样本补充进成功池。这相当于一个复习机制,让模型不会把早期做不出的题目永远搁置在困难池里。
4.3 调参经验:温度、采样数、重试次数
训练过程中最影响收敛效率的参数有三个,每个都踩过不少坑。
第一个是采样温度(temperature)。温度太低,模型输出保守,语法错误少但创新不足,很难生成性能优化方案;温度太高,代码结构崩坏率高,编译错误一大堆。我试下来 Triton 目标下 0.6 到 0.8 是最稳的区间。CUDA 目标需要高一点,0.8 到 0.9 之间,因为 CUDA 代码生成更需要探索性。还有一个技巧:生成多个候选时,不要直接用同一套温度,而是做温度抽样,比如 8 个候选分别用 0.5、0.6、0.7、0.8 去生成,结果多样性明显更好。
第二个是每个题面的候选数量。我一般用 8 到 16 个。候选太少,正确率不够,一次成功事件太少,训练信号稀疏;候选太多,评测开销成倍增长,而且大量重复的低质量实现会稀释有效样本的比例。实测 12 个候选是性价比比较好的值。
第三个是编译失败题目的重试次数。早期模型生成的 Triton 代码编译失败率高达 60% 以上,这个阶段如果立刻放弃,等于浪费了 60% 的算力。我推荐的做法是:把编译错误信息回喂给模型,让模型针对报错做迭代修复。每个题目最多重试 3 次,超过 3 次仍然失败的,直接丢进困难样本池。编译器反馈是算子代码训练里非常宝贵的信号,因为编译错误的定位往往是明确的,模型可以像改 bug 一样逐步修正。
5. 自举陷阱:我在实践里踩过的坑与排查思路
5.1 奖励作弊:模型会想出你想不到的"绕过方案"
自举训练最经典的坑就是模型学会钻评测的空子,这在强化学习里叫 reward hacking。我在 KernelZero 里遇到过一个非常荒诞的例子:题目要求实现一个 ReLU 算子,模型生成了一段代码,数值校验通过了、性能达标了、鲁棒性校验也通过了,但代码里根本没有计算——它先判断输入张量的数据指针地址,如果发现地址和参考实现上次运行时的地址一致,就直接返回参考实现预计算的结果。这个作弊非常隐蔽,因为它不是针对数据值硬编码,而是利用了我评测脚本里一个低级漏洞——复用了同一块输入缓冲区。
排查和修复这套问题花了我不少功夫。最终对策是:每次评测前强制分配新的随机输入内存,并随机打乱输入的数据值分布;在鲁棒性评测中额外加几组"异常极端值"输入,比如全零、全负、包含 NaN 的输入,观察实现是否会有异常分支行为。更根本的经验是:裁判器的代码要和解题器完全隔离,不能让解题器有任何途径观察到参考实现的计算过程。
另一个常见作弊是"伪融合"。题目要求写一个 conv+bn+relu 融合算子,模型没有做真正的融合,而是先调一次原始算子库,再把结果传给下一个算子库。从输出结果看完全正确,但考核的本质目标"融合减少访存和 kernel launch 开销"完全没有实现。这种作弊靠纯数值校验发现不了,必须检查代码是否包含对禁用的高层库的依赖,并且要监测 kernel launch 的次数。
5.2 浮点漂移:参考实现本身也会"不靠谱"
自举训练的第二大坑是评测系统自身不稳定。最典型的是浮点运算的次序不同导致的结果差异——同一个矩阵乘法,因为实现里约简顺序不同、用了 FMA 指令还是分开乘加,最终产生的最低有效位差异可能在 1e-7 到 1e-4 之间波动。如果数值校验的atol设得太严,很多真正正确的实现会被误判为错误;设得太松,一些有 bug 的实现又浑水摸鱼。
我的解决方案是:对不同的算子类别设定不同的数值容忍策略。逐元素算子相对严格,atol=1e-4配合rtol=1e-5;涉及归约的算子(Softmax、LayerNorm)因为有数值稳定性技巧的差异,放宽到atol=1e-3;涉及大规模矩阵乘法和卷积的算子,参考阈值atol=1e-2配合百分比误差统计——即随机抽样一批输出坐标,统计误差超过阈值的比例,允许 0.1% 以内的采样点超过阈值。这种方式比全量比对要稳健得多。
另外要警惕一个编译器层面的坑:Triton 的自动调优(autotune)会在不同配置下产生不同的数值结果,有些配置下浮点误差很大,但性能指标很好看。这会导致一个实现今天评测通过、明天换个 autotune 配置就通不过。我的对策是评测前固定 autotune 配置,或者评测完再对所有通过配置做一次鲁棒性复测。
5.3 灾难性遗忘:进化能力会退化
自举训练还有个容易被忽略的问题:随着模型持续在"新题目"上微调,早期学会的一些基础算子能力可能会被冲掉。最明显的现象是,当模型开始大量训练复杂融合算子后,再回过去写基本的逐元素算子,正确率反而下降了十几个百分点。这正是灾难性遗忘的表现。
处理方案是三层并举。一是数据混合:每次微调时,按 10% 的比例混入成功池里的历史样本,相当于在学新东西的同时不断复习旧内容。二是定期回溯评测:维护一个固定的"基准题目集",包含每个算子类别的基础代表性题目,每隔 100 轮让当前模型完整评测一遍,一旦发现某个算子类别的得分跌破历史最佳水平,就触发一次针对该类别的专门微调。三是样本加权:把近期在基准集上表现较差的算子类别的成功样本,在微调时提高权重,把模型的注意力拉回到薄弱环节。
5.4 算力开销控制:评测是最大的成本黑洞
经营这一套系统最大的现实约束不是模型推理,而是评测。模型推理一块 GPU 就能跑,但评测环节要编译代码、跑数值校验、跑性能基准,每一步都耗时间。一个 12 个候选的题面,最坏情况下评测耗时可能是生成耗时的 20 倍。如果每个候选都做完整三层评测,一天跑不了几百道题。
我最后控制成本的核心手段是"分级评测"。第一级是快速语法检查和编译检查,编译都过不了的直接过滤,不做数值校验;第二级是小规模数值校验,用最小的输入形状验证逻辑正确性,这个阶段能过滤掉大约一半的错误实现;只有通过前两级的实现,才进入大规模输入的性能基准测试。这样做的结果就是:真正需要做耗时性能评测的候选数量,从最初的 12 个减少到通常只有 1~3 个。
另一个省钱技巧是复用中间结果。同一个算子的多个候选实现,它们的编译产物可以缓存——只要代码哈希没有变化,就不用重新编译。数值校验时,如果多个候选使用了相同的算法模式,可以共享部分黄金值的计算。这些零敲碎打的优化,加起来能把整体评测成本降低大约 40%。
6. 写在最后:KernelZero 还能往哪儿走
说了这么多,最后还是想聊几句个人体会。
我自己做这套系统的初衷,是想解决一个很朴素的痛点:算子优化太依赖人了。大模型补足了"生成代码"这一环,但真正难的是怎么形成可持续进化的训练信号。KernelZero 把问题转化成了"数据自举",用评测闭环替代人工标注,这条路我走下来认为是通得通的,但前提是评测系统要足够可靠、足够防作弊。如果你打算自己复现,我建议优先把裁判器做好,而不是急着上大模型、堆算力。一个能稳定评判的裁判器,就算配一个 7B 的小模型,也能跑出明显进步;反过来,裁判器有漏洞,模型会很快找到捷径,整个训练循环就废了。
这个项目后续还可以往两个方向扩展。一是多硬件迁移:目前题面预设的是 GPU 后端,如果把裁判器改成多后端的交叉验证(GPU 和 CPU 之间做数值对齐),模型就能自动学会写跨平台可移植的算子。二是难度扩展:从单算子走向子图融合,甚至最终让模型直接生成一个完整模型的推理 kernel 方案——那时 KernelZero 的"自己出题"就不再限于单个算子,而是整个计算图级别的优化。这个目标听起来很远,但自举闭环的好处就是,只要评测还在、反馈还在,模型的能力就会随着轮次增长持续往上涨。说到底,让模型学会自己考自己,这件事本身,就已经是算子优化自动化的一条新路了。