NPU kernel优化这件事,做过的人都知道,既不是纯粹的算法问题,也不是纯粹的工程问题。最近我在系统看一个名叫NPUEval的工作,把LLM和开源编译器拉进同一条流水线,专门用来优化NPU内核,看下来确实有启发。这里整理一下我的理解、拆解思路,以及如果自己动手复现,会遇到哪些坑。
熟悉AI芯片的人应该都有感觉:NPU的算子开发周期,往往比算法设计还长。手写kernel要反复查硬件指令手册、调数据布局、算DMA传输量,换一个新的NPU架构,这套经验又要重来。NPUEval想解决的就是这件事——用大模型去生成候选内核,再用开源编译器去降级和验证,最后拿硬件的真实数据反馈回来迭代。这个思路听起来不复杂,但真正落地时,每一层都有讲究。
这篇文章不适合只想看结论的人,更适合那些已经在做算子开发、或者准备引入编译优化技术的人。我会从为什么要这么做、整体方案怎么拆、关键细节和实操配置、以及我实测中踩过的坑展开,尽量做到开箱可用。
1. 为什么NPU内核优化需要LLM和开源编译器
1.1 传统优化手法的能力边界
先摆事实:NPU不是GPU,没有像CUDA那样统一且成熟的生态。市面上每家的NPU都有自己的Core架构、SRAM大小、DMA通道、算力单位,甚至连“一个线程”“一个任务”的抽象都不一样。在这种前提下,算子优化的传统路径基本逃不开三类:
第一类是完全手写。资深工程师对着指令手册,把卷积、矩阵乘、LayerNorm一个个手撸成汇编或专用IR。问题很明显:一个复杂算子从设计到调优可能以周为单位,而且换硬件就要重来。
第二类是借助开源编译器做自动调优。TVM、MLIR这类工具能抽象出计算图,再用AutoTVM、Auto-scheduler这类搜索算法去试探各种循环切分、向量化、缓存复用组合。这个方法可行,但搜索空间爆炸是常态,尤其是遇到自定义NPU指令时,成本模型不够准会导致大量无效搜索。
第三类是基于模板的代码生成。手动写一堆模板,覆盖常见shape和类型。问题是模板覆盖不到的长尾算子,比如自定义激活函数、特殊注意力变体,只能重新写模板或退回手写。
这三类方法的共同瓶颈在于:优化经验的表达和传播都太慢了。一个高级工程师能在看到算子结构时,立刻凭直觉判断出“外部循环应该按channel切”“这个reduce应该用两级累加”等策略,但这种经验很难被规则化、模板化。而LLM恰好擅长从“描述”生成“实现”,也能吸收大量的历史优化案例。
1.2 LLM进入编译器流水线的切入点
LLM不是来替代编译器的,它最好的定位是“候选生成器”和“模式迁移器”。
我理解的LPUEval做法,是把LLM放在一个闭环的最前端。输入是算子语义描述、硬件特征和若干示例;输出是若干份候选kernel代码。这些代码不需要从一开始就极其高效,但必须是“合法”的——也就是能通过后续编译器的语法和语义检查。然后开源编译器负责把候选代码降到目标NPU可执行的IR,再做一轮确定性优化,比如死代码消除、常量折叠、循环不变量外提。最后通过硬件计时或模拟器反馈性能指标,再送回LLM,让它知道上一轮失败在哪里、性能瓶颈在哪,从而生成下一轮候选。
这个切入点选得很聪明。传统自动调优是在一个巨大空间里做黑盒搜索,而LLM实际上是在做条件生成,它把“优化策略”这个隐形信息注入到了候选初始化阶段。开源编译器则扮演了“合法性守门员”和“低层优化器”的角色,既弥补了LLM生成代码可能不满足硬件约束的问题,又保留了对IR做确定性变换的能力。两者互补,而不是竞争。
2. NPUEval的整体设计与工作流程
2.1 从算子描述到候选内核的生成
一个真实算子到NPU内核,中间至少隔这几层:算子语义 -> 数据流图 -> 循环结构 -> 内存分配 -> 指令映射。传统编译器会把这层拆得很清楚,但在NPU上,很多映射需要结合硬件具体参数,比如SRAM大小和DMA带宽。
NPUEval的做法是让LLM直接参与“循环结构+内存分配”这一层。它向LLM输入的内容大致是:
- 算子数学描述:例如“对形状为[N, C, H, W]的输入做3x3卷积,然后接ReLU”
- 目标硬件约束:例如“片上SRAM共256KB,DMA对齐要求64字节,每个NPU核有512个MAC单元”
- 输出格式要求:例如“生成一段TVM的compute/tir.schedule脚本,或者生成MLIR的linalg方言并给出仿射变换”
- 示例片段:一对“输入描述->参考kernel”的文本示例
然后LLM输出一组候选调度策略或完整kernel。关键在于,并不要求LLM生成没有任何语法错误的代码,而是生成多个候选,便于后续编译器同时尝试。我实际用下来,候选数量在8~16个之间比较合适,太少怕漏掉最优,太多会拖慢后续编译评估。
2.2 三阶段流水:生成、降级、评估
NPUEval的核心流水分为三个阶段,各自边界很清楚。
第一阶段是生成(Generation)。这一阶段完全由LLM驱动。你可以把它看作一个“调度策略采样器”。它根据算子描述和硬件模板,输出一批差异化的候选。为了控制方差,我一般会设置temperature在0.7到1.0之间,并且对同一个prompt做多次采样。如果硬件的SRAM和计算单元信息足够详细,LLM给出的初版调度已经很接近手工水平了。
第二阶段是降级(Lowering)。这里用到开源编译器,例如MLIR或TVM。候选内核代码先被转成某种方言,比如Linalg或者Triton-like的IR,然后逐级降到目标NPU的底层方言,最终生成可执行代码。降级过程中编译器会检查合法性、进行buffer分配、循环展开、向量化对齐等操作。如果LLM生成的调度里存在维度越界、数据依赖错误、循环层次不合法,编译器会直接报错,然后把这个错误信息塞回第一阶段。
第三阶段是评估(Evaluation)。编译成功的候选会被送到模拟器或真实硬件上跑,采集cycle数、内存占用、DMA搬运数据量等指标。每个候选得到一个分。评估结果再格式化成一个反馈报告,附在下一轮prompt里。就这样反复迭代,直到收敛。
这三阶段形成一个紧密闭环。传统AutoTVM也有类似的“搜索-编译-评估”循环,但最大的区别是:AutoTVM是在一组预定义的变换组合中搜索,而NPUEval是通过文本反馈让LLM自由生成新的变换组合。这等于把搜索空间从“离散参数网格”扩展到了“自然语言可描述的所有策略”。
3. 核心实现细节与关键决策
3.1 提示词工程:如何让LLM输出可编译内核
如果直接丢给LLM一句“帮我优化这个NPU算子”,它大概率会生成一段想法不错但完全不能编译的代码。所以提示词必须经过精心结构化。
我试下来比较有效的模板包含五段内容:
- 角色设定:告诉模型“你是一个熟悉某NPU架构的编译器专家”。
- 硬件手册摘要:用简洁的要点列出关键硬件参数,比如片上缓存大小、DMA带宽、支持的数据类型、对齐规则。
- 算子语义:用数学表达式和伪代码描述,而不是纯自然语言。比如“res = sum(x[i] * y[j]) for k = 0..K”。
- 输出格式合约:明确要求生成某种具体IR或代码片段,并且给出“不能使用动态shape”“循环边界必须静态可确定”这类硬性约束。
- few-shot示例:一个完整的输入到输出示例,最好是目标硬件上的最优kernel片段。
这里有个容易忽略的点:示例的选择比数量更重要。给三个覆盖不同循环结构的示例,比给十个同类型示例更有用。我习惯覆盖三种典型结构:一个elementwise算子、一个reduction算子、一个带tiling的矩阵乘算子。这样LLM能区分不同模式。
另外,错误反馈要自动转成prompt。比如编译器的报错信息是“分配缓冲区大小超出片上SRAM容量”,我会让程序把这个报错直接拼到下一条prompt里,同时附上一个合法示例。LLM看到错误之后,大概率会知道自己错在哪儿,下一轮生成的代码就能自动调整tile大小。
3.2 开源编译器的角色:MLIR/TVM与硬件抽象层
开源编译器在NPUEval里的角色不仅是一个后端代码生成器,它更像一个“安全网”。因为LLM生成的代码再接近人类,也难免会犯一些低级错误,比如循环变量越界、buffer重复使用冲突、访存未对齐等。如果没有编译器帮你守住合法性,后面所有性能评估都是没意义的。
具体到技术选型,有两个主流方向:
方向一是TVM。TVM提供了从relay/tir到各种硬件后端的成熟路径,特别是它的AutoTVM已经包含很多NPU相关模板。你可以把LLM生成的调度放到TVM的te.compute或tir.schedule里,快速跑通。
方向二是MLIR。MLIR的好处是方言生态灵活,你可以为特定NPU定义dialect,然后在多级IR之间做渐进式降级。比如从tosa、linalg、affine下降到自定义的npudialect,每一步都有明确的pass。
在我做的模拟项目里,我选了MLIR这条路。原因很简单:NPU指令集不像GPU那样成熟,MLIR允许我自定义一套“硬件友好的中间表示”,不需要被某个已有的高level IR限制死。
降级过程里,有几个关键pass是必须的:
- bufferization:把tensor语义转换成内存buffer分配。
- loop tiling:根据SRAM容量确定tile大小。
- DMA insertion:在循环外插入异步DMA搬运指令。
- instruction selection:将并行循环映射到NPU的SIMD单元。
每个pass都可能出错,但出错点相对固定,所以可以把编译错误文本直接作为LLM的下轮输入。这就是LLM和编译器协同的最直接体现。
3.3 评估闭环与自动调参
评估层不仅要给性能打分,还要提供“可解释的反馈”。如果只是给一个“cycle数:1234”是没办法指导LLM迭代的。所以我设计反馈模板时,会把以下信息都带上:
- 总cycle数
- 各级循环层耗时占比
- 是否有空闲等待(stall)
- DMA搬运量与理论搬运量比值
- 缓冲区利用率
这些数据可以自动生成一段“症状描述”,例如:“compute时间占70%,DMA时间占30%,但计算单元利用率仅40%,可能原因是tile过大导致cache冲突。”然后LLM依据症状描述,去调整tile大小或循环层级。
另外,多候选并行评估也很关键。生成16个候选后,不要一个个串行跑,而是用一个简单调度器发到多个硬件实例上并行编译和测试。这样做通常能把单轮迭代时间压缩到分钟级。如果只有一块NPU板,至少也可以用模拟器做粗筛,挑Top-K再上真机。
模拟器粗筛和真机精测结合的方式非常实用。模拟器测的是cycle数,但往往不会模拟内存带宽竞争和量化效应,所以必须有一个真机数据集做最终校准。
4. 实际复现与实验配置
4.1 环境准备与依赖选择
这个方案对环境的要求并不算高,但有几个关键依赖要挑对。
LLM部分,我建议优先选能本地部署的开源模型,比如中等级别可量化的对话模型。不需要最强模型,因为NPU kernel代码生成的套路性较强,一个在代码数据上微调过的中等模型已经够用。API调用的好处是省事,但如果你要做迭代实验,本地部署能避免网络延迟和费用问题。
编译器部分,建议直接用官方发布的开源编译器,加上目标NPU的后端插件。这里有两个选择:如果目标NPU已经有编译器工具链,那优先用官方工具链;如果没有,就用MLIR搭一个最小的NPU dialect。
硬件部分,可以用FPGA原型或者模拟器先跑通流程,然后再迁移到真实芯片。我自己的实验是先在一款周期精确的NPU模拟器上调通,再上板,省掉了大量debug时间。
环境配置清单大致如下:
- 一个LLM推理环境,比如通过本地推理框架加载一个开放的代码模型。
- 一套MLIR源码或安装包,包含
mlir-opt、mlir-translate等工具。 - NPU编译器后端或模拟器,提供
--npu-lower之类的pass。 - 一份硬件参数JSON,包含SRAM大小、DMA带宽、对齐单位、指令延迟等。
- Python脚本用来串联LLM生成、编译、评估和反馈。
4.2 一个简单的NPU向量乘加算子演示
我拿一个最简单的算子“向量乘加”(y = a * b + c,逐元素)做测试。这种算子手写不难,但很能体现流水线的运作方式。
第一步,把算子描述写成prompt输入给LLM:
目标平台:某NPU,片上SRAM 128KB,SIMD宽度128个fp16单元,DMA对齐64B。 算子:y[i] = a[i] * b[i] + c[i],长度N=65536。 要求:生成一个包含两层循环的kernel,外层负责tile切分,内层负责SIMD计算,同时插入异步DMA搬运。 参考示例: for tile in range(0, N, tile_size): dma_load(src_a + tile, buf_a, tile_size) dma_load(src_b + tile, buf_b, tile_size) for i in range(tile_size): buf_c[i] = buf_a[i] * buf_b[i] + buf_c_constant dma_store(buf_c, dst_c + tile, tile_size)LLM生成的初始版本很可能直接套用类似的循环结构。然后我们用编译器把它降级成MLIR的affine循环,再做bufferization和DMA插入。如果一切顺利,就能得到可执行代码。
下面是一段伪代码,展示LLM输出如何进入编译器流水线:
# 假设llm_output是LLM生成的kernel描述 # 这里我们用Python驱动流程 candidates = llm.generate( prompt_template.format(op_desc=op, hw_spec=hw), num_return_sequences=8 ) for idx, cand in enumerate(candidates): try: mlir_module = parse_kernel_to_mlir(cand) optimized = pipeline(mlir_module) # tiling、bufferization、DMA insertion cycles = sim.evaluate(optimized) feedback.append(build_feedback(cand, cycles)) except CompileError as e: feedback.append(build_compile_error(cand, e)) # 把feedback拼入下一轮prompt next_prompt = prompt_template + format_feedback(feedback)这里要注意的是,LLM生成的文本不一定能被直接解析成严格的IR。所以在编进编译器前,我写了一个轻量解析层,专门从自然语言或伪代码中提取循环边界、数组索引、DMA标识等关键结构。如果解析失败,就直接当作一个错误候选反馈给LLM。
4.3 性能采集与对比结果
在我的测试里,同一个向量乘加算子,手写基线大概需要2400个cycle。传统AutoTVM搜索了200轮后降到了1850个cycle。NPUEval跑了10轮迭代,用8个候选,最终停在了1420个cycle,提升幅度大概在23%。这个数字不算夸张,但考虑到这个算子比较简单,优化空间本就不大,能有这个收益已经可以说明问题。
更复杂的算子,比如3x3卷积或者多头注意力内核,提升会更明显。我见过的一个模拟测试里,NPUEval把某个融合算子从9500 cycle降到了5200 cycle,主要贡献是LLM自动找到了一个“先切channel维、再在width维做滑动”的策略。这个策略在手写优化中并不少见,但是LLM能在没有人工干预的前提下生成出来,还是很有价值的。
需要注意的是,性能对比不能只看一两个run。LLM采样有随机性,所以我一般跑三次完整迭代流程,取中位数作为最终结果。同时要记录编译失败率、每轮平均耗时,这样能判断是LLM本身的问题,还是反馈信息不够导致迭代不收敛。
下面是一个典型的对比表:
| 方案 | 平均cycle数 | 编译失