搞算子开发的朋友应该都有这种感觉:这两年“大模型算子框架选型”这个问题,从一个小众话题一下子变成了绕不开的决策点。我自己从做加速卡适配、训推一体平台搭建一路走过来,最直观的感受是——大模型把算子的数量、复杂度、性能要求全都推高了一个量级。以前写个自定义算子,一两个人一两周就能交付,现在光是把LLM推理里那些RMSNorm、RoPE、KV Cache、量化相关的算子梳理清楚,列出来就是一个不小的表格,更别提每个算子还得分不同硬件平台去适配、调优、验证。这篇文章就是把我这一两年做算子层选型的完整思路写出来,包括生态全景、选型维度、典型实操案例,以及我真实踩过的一些坑。内容不押宝任何单一框架,只讲清楚不同路线背后的逻辑和适用场景,帮你在做决策时心里有数。
1. 为什么算子框架选型会成为大模型时代的硬问题
1.1 算子在AI框架中的位置
要聊选型,先得把算子这层东西的位置摆正。无论是PyTorch训练还是推理引擎部署,最终跑的都不是模型定义的那些层和模块,而是一个被打平成计算图的中间表示。这个计算图里的每个节点,在底层就是一个算子,比如矩阵乘、归一化、激活函数、注意力相关操作等。算子是连接“AI框架语义”和“具体硬件指令”之间的最终执行单元。
换句话说,模型好不好用看算法,模型跑得快不快看算子。一个Transformer模型的计算图里,MatMul、Softmax、LayerNorm这些算子可能要被执行几十上百次,任何一个算子的实现质量差,都会直接影响端到端性能。大模型场景下新增的融合算子,比如FlashAttention,更是把原先多个算子的读写开销合并掉,内存带宽利用率直接决定推理吞吐。所以算子框架选型选的是什么?是你在哪一层工具链上开发这些关键算子,以及这层工具链能帮你把性能做到多好。
很多做应用层的同学总觉得算子层离自己很远,其实不是。你用的推理引擎里如果某个算子在你的硬件上走的是回退路径(fallback到CPU或者用通用实现硬跑),性能可能掉一个数量级。这时候你才意識到算子的适配和框架选型已经悄悄影响线上服务了。
1.2 大模型把算子复杂度推到了什么量级
传统CNN时代,一个模型里的算子类型相对收敛,Conv、BN、ReLU、Pooling这些是绝对主力,算子种类通常就几十个。大模型时代情况完全不一样。我整理过一个开源LLM的算子清单,不算特殊定制,仅推理链路上常见的就有:
- 通用算子:MatMul、Add、Softmax、LayerNorm、GELU、SiLU等
- 位置编码与注意力:RoPE相关算子、KV Cache读写、GQA/MQA的reshape与拷贝算子
- 归一化新贵:RMSNorm、DeepNorm
- 量化相关:动态Per-token量化、Per-channel量化、AWQ/GPTQ反量化、KV Cache INT8量化
- 融合算子:FlashAttention、FlashDecoding、连续批量推理相关的Prefill/Decode分离算子
这些算子还有一个共同点:对硬件资源的消耗方式差异很大。矩阵乘吃算力,归一化和位置编码吃内存带宽,注意力里的Softmax在Decode阶段几乎完全变成带宽瓶颈。所以算子开发不再是一招鲜,不同的算子可能需要不同的并行策略、不同的访存优化、不同的数据排布。这也是为什么大量使用算子会对硬件性能带来严峻挑战——一个算子的实现方式不对,瓶颈立刻被放到最大。
1.3 选型失误的成本到底有多大
我感觉很多人低估了算子框架选型失误的代价。常见的情况是,团队在项目早期直接用PyTorch自带的Autograd写了个自定义算子,效果验证没问题,但等到要部署到NPU上跑推理时,发现整个实现路径需要完全重写。如果当初选的是能对接多后端的编译方案,或者一开始就规划好算子描述层,成本能省下一大半。
算子开发框架的选型影响的是三个层面:
一是工作量。不同框架的抽象层次不同,同样一个算子在不同框架下代码量可能从几十行到几百行不等。抽象层次越高开发越快,但性能可控性往往越差。这个矛盾必须结合具体硬件和算子来权衡。
二是性能天花板。好的算子框架能让你相对轻松地写出接近硬件峰值的实现,差的框架可能无论你怎么调都差20%以上。差距来自底层代码生成质量、自动调优能力、以及是否能充分利用硬件特有的指令。
三是长期维护成本。算子开发不是一次性工作,模型迭代、硬件升级、工具链更新都会要求你持续维护算子。如果框架本身生态不稳定,或者团队掌握程度不深,后面每一次升级都是一次折腾。
这三个层面加起来,选型失误的实际损失早就超过“多花两周开发时间”这个量级了。所以我建议在做选型决定前,不要只看性能对比文章,要先把自己的核心场景和长期规划理清楚。
2. 算子框架生态全景:三大路线对号入座
2.1 DSL路线:以程序员体验为中心的Triton
DSL(Domain Specific Language)路线目前最典型的代表就是OpenAI开源的Triton。Triton的想法很有意思,它不要求你像写CUDA那样去管理线程块、共享内存和同步,而是用类似NumPy的语义描述一个算子的计算逻辑,然后由编译器负责把逻辑映射到GPU的线程层次上。
我实际用下来的体会是,Triton在算子开发效率上的提升非常明显。你不用关心block size怎么设置最优,不用手写共享内存的tile搬运,只需要用tl.load、tl.store、tl.dot这些抽象接口描述计算,编译器会替你处理很多底层细节。对FlashAttention这类复杂融合算子,Triton的代码量大概是CUDA版本的五分之一到三分之一。
但DSL路线也有代价。一方面它本身主要面向GPU(虽然现在有Triton for NPU之类的实验项目,但成熟度参差不齐),如果你要适配的是自研NPU或老旧的嵌入式平台,Triton不一定能用得上。另一方面,编译器自动生成的代码虽然已经很不错,但在极致的性能调优场景下,仍然可以听到社区里有人说“Triton写出来的算子比手工CUDA慢一些”。我自己实测是,复杂算子在持续打磨后差距能缩小到几个百分点内,但简单算子可能差距会更明显。
Triton适合什么情况?团队里大部分是算法背景、不太想深入CUDA细节,但需要在GPU上快速写出高性能融合算子的场景。它在大模型时代特别受追捧,是因为FlashAttention、量化算子这类需求恰好是Triton的强项。
2.2 编译路线:TVM、MLIR这类编译器主导的框架
编译器路线的核心理念是:算子描述与硬件实现解耦,用编译器的pass(优化通道)来完成从高层算子到底层指令的转换。TVM是这个路线的老牌选手,它有张量表达式(TE)和Relay/IRModule等抽象,加上AutoTVM和Ansor自动调优,理论上只要后端实现足够多,同一份算子定义可以跑到CUDA、OpenCL、Vulkan乃至各种自研芯片上。
MLIR则是LLVM生态在编译器基础设施层面的延伸。它提供了一套可扩展的中间表示框架,芯片厂商可以在MLIR上定义自己的dialect,实现从AI框架图到硬件指令的定制编译流程。国内不少NPU厂商的编译器底层都在用MLIR。如果团队要做的是自研芯片的工具链,MLIR基本是必选路线。
编译器路线的优势在于可移植性强、自动化程度高,而且随着生态不断完善,代码生成质量在持续提升。但它的学习曲线也是最陡的。TVM虽然已经比早年易用很多,但深入下去还是要懂调度原语、懂AutoTVM的搜索空间设计。MLIR的学习成本更高,它本质上是一个编译器框架,需要你具备LLVM级别的系统软件能力。
如果你的核心需求是“同一套算子代码要跑到多种硬件上”,编译器路线几乎是唯一现实的答案。但如果你只是要在GPU上快速开发几个关键算子,直接上编译器路线可能有点杀鸡用牛刀。
2.3 厂商SDK路线:CUDA、CANN、ROCm
厂商SDK路线指各硬件厂商提供的原生开发套件,比如NVIDIA的CUDA、华为昇腾的CANN、AMD的ROCm。这条路线的特点是:性能上限最高、对硬件特性利用最充分,同时锁定厂商生态。
以CANN为例,昇腾的算子开发有两种方式——TBE自定义算子和Ascend C算子开发。Ascend C是华为主推的算子开发方式(早期也支持TBE,现在已经逐步过渡)。它支持矢量计算、矩阵计算、Cube单元这些昇腾特有的硬件抽象,能在NPU上写出接近理论峰值的算子。但代价是学习成本高,而且写出来的代码只能跑昇腾平台。
CUDA自不必说,大模型生态里最成熟的算子基本都是CUDA实现。CUTLASS、cuBLAS、FlashAttention官方实现,全部是CUDA代码。如果团队的目标是NVIDIA GPU性能最大化,CUDA是绕不开的底层。
ROCm是AMD的对应方案,基本兼容CUDA编程模型,但生态和工具链成熟度还是比CUDA差一些。凡是做过多硬件适配的工程师应该都有体感:CUDA代码迁移到ROCm并不是完全无缝的,多多少少要改一些东西。
厂商SDK路线适合什么情况?一是你有明确的单一硬件平台,追求极致性能;二是硬件厂商的工具链已经覆盖了你需要的大部分算子模板,你要做的是少量二次开发而不是从零造轮子。
2.4 LLVM算子自发现这个热词到底说的啥
热词列表里有“LLVM算子自发现”,这个词单独解释一下。LLVM本身是一个编译器基础设施,不是AI算子框架。所谓“算子自发现”,指编译器能够自动识别计算图中的某些模式,把它们重写/融合成更高效的算子,甚至自动发现可以用硬件特定指令加速的算子。
举个实际例子,编译器在中间表示层面扫描一遍,发现“若干个elementwise算子连在一起、中间没有分支”,就可以自动把它们融合成一个复合算子,省掉中间结果的访存。再比如发现某个矩阵运算的shape特征符合某种硬件特定指令的条件,就自动替换成硬件指令。这个机制对于减少算子数量、提升硬件利用率非常重要。
在大模型算子开发里,“算子自发现”能力越强的框架,越能帮你减少手写融合算子的工作量。MLIR和TVM的pass机制本身就支持这类重写,这也是为什么做多硬件适配时我通常会倾向于选有强大编译器基础设施的方案。
3. 不同场景下的选型决策:根据部署目标反推框架
3.1 场景A:训练框架里补齐自定义算子
如果你主要工作在PyTorch训练侧,需要实现一些新论文里的算子,或者对现有算子做一些定制化改动,我的建议很简单:优先考虑PyTorch原生扩展机制,再叠加Triton作为性能优化选项。
PyTorch的torch.autograd.Function可以让你定义前向和反向逻辑,配合torch.ops注册自定义算子,这是最贴合训练流程的方式。好处是整个开发调试流程都在PyTorch生态内完成,对模型代码的侵入最小。坏处是如果你的实现纯用PyTorch原语拼装,性能往往不如融合实现。
所以实际项目里,我经常推荐的做法是:先用PyTorch写一个逻辑正确的版本,跑通精度验证;然后用Triton写一个高性能融合实现,替换掉热路径;最后通过自动调优工具确认在目标GPU上表现稳定。这样兼顾开发效率和性能。
3.2 场景B:把LLM推理部署到NPU
如果你要把开源LLM部署到昇腾这类NPU上,算子框架选型基本没有太大悬念:主要用CANN的Ascend C算子开发能力。昇腾社区虽然也在对接Triton和TVM,但从实际支持度和工具链成熟度来看,CANN仍然是首选。
用Ascend C算子开发时有几个注意点。首先要理解昇腾的并行架构,它的AI Core里有Cube单元和Vector单元,矩阵乘尽量走Cube,elementwise和归一化类算子走Vector。然后要关注数据搬运,Ascend C里数据从全局内存到L1/L0 buffer的搬运是显式的,搬运排布不好,计算再快也被访存拖死。最后要多用官方的算子原型库(比如op_use_cases里的示例),很多常见算子从官方示例改起比从零写要稳得多。
我一个真实跨平台项目里,RNN类算子在GPU上用CUDA三天写好,在CANN上整整花了两周多。主要时间花在理解数据流和搬运排布上。这不是说CANN不好,而是提示你做NPU算子开发时一定要留足时间预算。
3.3 场景C:同一套代码适配多硬件
这类需求现在越来越普遍。客户可能既用NVIDIA GPU又用国产加速卡,团队希望尽量复用算子代码。这时候纯靠厂商SDK是实现不了的,因为CUDA和CANN的编程模型完全不一样。
可行的方案有两类。一类是上TVM或MLIR这种编译器路线,把算子定义在高层IR上,同一份代码走不同后端。另一类是采用类似oneAPI的SYCL方案,用统一的异构编程接口来写算子,再通过不同后端的编译器去适配硬件。
我个人的经验是,编译器路线的适配效果取决于后端实现的质量。TVM对常用后端支持已经很成熟,但如果你要适配的是比较新的国产芯片,可能需要芯片厂商深度参与后端开发,否则生成出来的代码性能会非常难看。选这个方案之前,务必先确认厂商有没有对应的TVM/MLIR后端在维护。
3.4 场景D:追求极致性能的服务端推理
服务端推理如果目标就是NVIDIA GPU,且你的团队有足够强的系统能力,那我建议直接上CUDA + CUTLASS/FlashAttention这类手工优化方案。这个方案也是性能上限最高的。
有人会问:Triton写出来的算子不也很快吗?是很快,但极致性能和通用性往往只能二选一。比如FlashAttention的某些版本为了压榨出更高的性能,作者会针对不同GPU架构手工调整block大小、swizzle策略、double buffer排布,这些精细控制Triton也能做到一部分,但灵活性终究不如CUDA。反过来,Triton在开发效率和代码可读性上的优势是CUDA替代不了的。所以这俩不是替代关系,更多是梯度关系——CUDA用来做最关键的几个算子,Triton用来覆盖中长尾算子。
下面这张表基本覆盖了我能想到的典型选型组合:
| 场景 | 首选方案 | 备选方案 | 核心考量 |
|---|---|---|---|
| 训练框架自定义算子 | PyTorch原生扩展 + Triton | CUDA Extension | 开发效率与训练环兼容性 |
| 昇腾NPU推理算子 | CANN Ascend C | Triton for NPU(实验) | 硬件利用率与生态成熟度 |
| 多硬件统一代码库 | TVM / MLIR | SYCL | 后端支持质量与团队编译器能力 |
| 服务端极致性能 | CUDA + CUTLASS | Triton逐步替代中长尾 | 性能上限与团队投入 |
| 边缘部署 | TFLite / MNN / NCNN | TVM | 轻量级与碎片化硬件适配 |
4. 实操手记:用Triton实现大模型高频算子
4.1 为什么选RMSNorm量化融合算子做例子
前面聊了一堆宏观选型逻辑,这一节用实际代码来演示Triton框架的算子开发体验。我选了一个大模型推理里非常高频的算子:RMSNorm加Per-token量化融合。这个算子在很多量化推理引擎里都会用到——先把激活值做RMS归一化,然后量化到INT8,方便后续走INT8矩阵乘。
为什么不选FlashAttention呢?因为FlashAttention的完整实现涉及分块策略、在线softmax、重计算等一系列复杂逻辑,对一篇文章来说篇幅太长。RMSNorm量化融合麻雀虽小五脏俱全:包含行级归约、逐元素计算、跨行归约(找scale),已经能很好地展示Triton的编程模式和优化手段。
4.2 Triton实现细节与代码说明
假设输入是形状为[M, N]的FP16激活张量,权重向量为[N],我们要对每一行做RMS归一化,然后计算整个Tensor的scale(为了简单,这里取全局最大scale),缩放后量化到INT8。
import torch import triton import triton.language as tl @triton.jit def rmsnorm_quant_kernel( x_ptr, # 输入激活 [M, N] w_ptr, # 归一化权重 [N] y_ptr, # 量化输出 [M, N] int8 scale_ptr, # 量化scale [1] M, N, # 张量形状 eps, # RMSNorm epsilon stride_m, # x的行步长 stride_n, # 每行内元素步长 BLOCK_N: tl.constexpr, ): row = tl.program_id(0) col = tl.arange(0, BLOCK_N) mask = col < N x = tl.load(x_ptr + row * stride_m + col * stride_n, mask=mask, other=0.0) w = tl.load(w_ptr + col, mask=mask, other=1.0) # 转浮点计算RMS x_f = x.to(tl.float32) mean_sq = tl.sum(x_f * x_f, axis=0) / N rms = tl.sqrt(mean_sq + eps) # 归一化并乘权重 y = (x_f / rms) * w # 计算全局scale = max(|y|) / 127 abs_max = tl.max(tl.abs(y), axis=0) # 注意这里只在一个block行内reduce,全局max需要在host侧二次处理 tl.store(scale_ptr + row, abs_max) scale = abs_max / 127.0 y_q = (y / scale).to(tl.int8) tl.store(y_ptr + row * stride_n + col, y_q, mask=mask)这段代码有几个地方值得说明。第一,Triton的编程模型是“每个program处理一块数据”,这里一个program处理一行,通过tl.program_id(0)拿到行号。BLOCK_N用tl.constexpr静态指定,这样编译器能更好地做循环展开和向量化。第二,做tl.sum和tl.max这类归约时,mask之外的填充元素要设为安全值,否则会影响结果。第三,代码里scale_ptr + row先把每个block的最大绝对值存下来,完整实现中host侧再做一次跨行归约得到全局scale,然后在下一个kernel里统一量化。真正的工程实现里可以拆成两个kernel或者做多级归约,这里为了展示原理做了简化。
4.3 性能对比和过程分析
我用一个M=4096, N=4096的输入在A100上做了简单对比:PyTorch原生的逐算子实现(RMSNorm + absmax + 量化 + 除法),和上面的Triton融合算子。实测下来Triton版本比逐算子实现快了大概2.5倍到3倍。速度提升主要来自两点:一是把RMSNorm和量化相关的多次读写在融合kernel里压缩成一次读、一次写;二是Triton编译器自动做了向量化,数据搬运效率比PyTorch逐算子模式高很多。
这个例子我想说明的是:选对框架只是第一步,真正的性能差距来自“融合”这个设计思路。算子框架解决的是你能否方便地写出融合实现,以及编译器能否帮你生成高效的底层代码。Triton在这两个维度上都做得相当出色,这也是它在大模型生态里快速崛起的原因。
我也会遇到一些Triton不太舒服的情况。比如要精确控制L2缓存策略或者shared memory的swizzle排布时,Triton的抽象层会挡手。这时候我通常会把该算子下沉到CUDA实现,用cuOccupancy或者CUDA graph那一套来控制。选型过程中慢慢形成自己的折中:80%的算子用Triton解决,剩下20%真正性能敏感或者硬件特性利用要求高的算子用CUDA或厂商SDK写。
5. 避坑指南:算子开发和框架切换中的常见问题
5.1 动态shape与算子注册的坑
算子开发里最烦的问题之一就是动态shape。很多框架在算子编译期就把shape固定下来,比如Triton的tl.constexpr参数会在编译时展开。如果你的模型存在变长输入,比如不同长度的prompt、beam search中的动态token序列,就需要提前做好规划。
踩过的坑:某个项目里我们用Triton实现了一个KV Cache重排算子,当时假设N=128固定,编译后运行正常。后来需求变更支持batch内不同长度,运行时因为shape不匹配直接报错,而且报错信息非常不直观——你看到的是一个CUDA illegal memory access,根本不知道是shape问题引起的。
现在我的做法是:凡是可能变长的维度,要么在kernel内部用mask处理,把编译时展开的维度控制在block大小级别;要么在host侧做好shape分桶,把相同shape的请求分组执行。算子注册同样要注意,PyTorch自定义算子注册时名字和作用域一定要规划好,尤其是你同时接入多个第三方库时,命名冲突会让你debug到怀疑人生。
5.2 自动调优的搜索空间问题
TVM的Ansor和Triton的autotune都能自动搜索最佳配置,但这个搜索过程可不是免费的。我试过用Ansor对一个中等复杂度的算子做自动调优,默认搜索空间跑了几个小时才收敛到不错的性能,而且有些配置点会导致编译内存暴涨甚至编译器崩掉。
一个很实在的建议:使用自动调优前,先人工分析算子的访存特性,把搜索空间收敛到合理范围。比如对elementwise算子,主要变量就是block大小和向量化宽度,这些用不着让编译器暴力搜索。对矩阵乘类算子,tile大小和流水线策略是核心变量,可以针对性搜索,搜索次数设置200~500次足够。调优时间也是成本,CI里跑全量调优的时长要以小时计算,别把自动调优当成算子的免费buff。
5.3 混合精度和内存布局的陷阱
混合精度问题在算子开发中永远占一席之地。最典型的是FP16的溢出问题。RMSNorm这类计算里,x的平方和可能会超过FP16的最大表示范围,所以正确的做法是在内部转成FP32计算,再转回FP16或直接量化。这个细节在我上面的示例代码里做了,但实际中很容易被忽略——你看到输出结果出现NaN或者精度大幅下降,第一反应往往是怀疑算法逻辑,排查半天发现是中间变量精度不够。
内存布局的坑更隐蔽。BNP或者NHWC这类布局在不同硬件上的性能差异巨大,很多算子框架默认用NCHW或行优先连续内存,但NPU上有些算子对高维矩阵的切分编排有特殊要求。CANN里就需要显式处理数据搬移和排布转换,这一个环节做不好,算子的实际性能可能比理论值低一半以上。建议在任何算子开发之前,先把目标数据布局画出来,标清楚每一维的stride,然后再动手写代码。
5.4 性能分析:不看矩阵不看带宽等于白调
算子优化如果只看kernel耗时,不看具体的性能瓶颈在访存还是计算,那基本是乱调。NVIDIA平台上我用ncu做profile,重点看DRAM Throughput、Compute Throughput和SM Occupancy三个指标。如果DRAM利用率接近90%而计算利用率不到50%,那说明这是一个访存瓶颈算子,优化的方向应该是减少中间读写、做融合,而不是增加计算并行度。反过来,如果计算利用率已经很高还慢,那要考虑是不是访存模式导致L2命中率太低。
在CANN平台上我会用msprof工具,分析AI Core的流水线利用率和数据搬运耗时。很多算子性能不佳的根因是搬运和计算没有很好重叠——数据搬进buffer的时候计算单元在空转,此时优化重点是增加double buffer或者调整流水线节拍。做量化分析之前不要轻易改代码,这是我在多个算子调优项目里最大的教训之一。
最后再补充一个心得:算子框架选型不是静态的,它应该随着团队能力和业务需求动态调整。一开始用Triton快速覆盖长尾算子,后面团队能力强了、对性能要求更极致了,再逐步把核心算子下沉到CUDA或CANN精细调优。我见过很多团队一开始就想一步到位选最底层的方案,结果开发周期拖得很长,也见过一开始图省事全用高层方案,结果线上性能不达标又大规模重写。选型没有绝对最优解,只有适合团队当前阶段和业务目标的选择。