1. 从算力到带宽:为什么算子融合是推理引擎的必修课
在接触 OpenPPL 的优化器之前,我一度以为深度学习推理引擎的核心竞争力纯粹在于手写汇编级别的 Kernel。真正深入源码之后才发现,算子融合(Operator Fusion)在性能优化中的地位,完全不亚于底层 Kernel 的极致调优,某些场景下甚至更为关键。
先说一个最直观的痛点:访存。举个例子,一个常见的 Conv -> BatchNorm -> ReLU 三段式结构。在没有融合的朴素实现里,Conv 的中间结果要写回显存,BatchNorm 再读出来算一遍,把归一化结果再写回去,ReLU 再读一遍做截断,最后才写回。中间结果存到哪?如果数据量不大,可以留在片上缓存里;但对于大特征图(比如输入 224x224 的卷积),中间结果可能直接打到显存或者内存里。三次反复读写,访存带宽立刻成了瓶颈,算力再强也白搭。
所以,所谓算子融合,本质上是一次经典的“重计算换访存”博弈:把多个算子的计算过程合并到一起,在数据还没被写回主存之前,就尽可能把能做的数学变换全部做完。这类优化,说到底是站在编译器后端视角,对计算图结构执行的优化,而 OpenPPL 的优化器模块,正是这一思路的具体工程化落地。
我刚开始看 OpenPPL 优化器代码时有个错觉:它做的工作也就是把几个相邻算子合并成一个 FusedKernel 而已,似乎并不复杂。但真正读下去才发现,事情远没有那么简单。算子融合在优化器里扮演的角色,类似编译器的优化 pass,需要反复迭代、要做模式匹配、要处理各种 layout 转换、还要在融合收益与 Kernel 负载均衡之间做权衡取舍。这篇文章,就是我梳理 OpenPPL 优化器中算子融合机制的一篇实战笔记。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 优化器里的核心思路:不止是“合并”那么简单
2.1 融合模式的识别与计算图遍历
OpenPPL 优化器收到的最初输入,是一张由框架导出的计算图。这张图里的节点(Node)就是一个个算子(Operator),边(Edge)就是算子之间的数据依赖。优化器要做的第一件事,就是对整张图做深度遍历,识别出哪些相邻节点可以被安全地融合。
这个“安全”很关键。不是任何两个算子挨在一起就能融合的,至少要满足几个硬性约束:
- 数据依赖必须是单纯的一对一关系。如果 A 算子的输出被 B、C 两个算子同时使用(扇出大于 1),直接融合会破坏数据共享。
- 算子必须是“可融合类型”的组合。OpenPPL 里常见的融合模式包括:Conv 加 BatchNorm、Conv 加 ReLU、Conv 加 BatchNorm 加 ReLU、Conv 加 Add 加 ReLU(残差结构)、Gemm 加 ReLU、Gemm 加 Softmax 等。
- 算子必须是同一种执行后端,比如都在 GPU 上执行。分布在 CPU 和 GPU 两端的算子不能直接融合。
- Feature Map 的 Layout 和 Data Type 必须对齐,否则融合之后还得插入格式转换节点,收益会被严重抵消。
形式化一点讲,优化器在遍历计算图时,会维护一个窗口(Windows),窗口里装着当前已经识别出来的一组候选融合算子。窗口一旦初始化,就尝试把后续节点按规则加入;如果新节点不满足融合条件,就把当前窗口闭合,生成一个融合后的算子节点,然后重新开始新一轮匹配。
这里面最微妙的点是融合的“粒度”选择。比如 Conv -> BN -> ReLU 和 Conv -> BN -> ReLU -> Pooling 能不能一起融合?理论上 Pooling 也可以并入,但实际工程里要小心。Pooling 的滑窗逻辑与卷积不同,强行融合有可能导致指令分叉、共享内存占用增高,反而降低吞吐。
我在实际调试 OpenPPL 时有一个经验:融合收益最大的组合,永远是访存密集型算子之间的融合,而计算密集型算子(比如大通道数卷积)融合收益相对有限。因为计算密集场景本身已经把算力吃满,融合只是省下几笔额外的内存读写;访存密集场景则不同,省下的是主存带宽,而带宽恰恰是推理引擎最稀缺的资源。
2.2 为什么要修 BatchNorm 和卷积的“数学账”
很多做过模型部署的朋友都有印象,BatchNorm 层在推理阶段可以被吸收进前一层卷积的参数里。这背后是纯粹的数学等价变换,属于算子融合里最经典也最稳定的收益点。
BatchNorm 的推理计算公式是这样的:
y = (x - mean) / sqrt(var + eps) * gamma + beta
其中 mean 是均值,var 是方差,eps 是防除零的小常数,gamma 和 beta 是可学习参数。BN 层在推理阶段其实是一个逐元素线性变换,可以改写为:
y = alpha * x + beta'
其中:
alpha = gamma / sqrt(var + eps)
beta' = beta - mean * gamma / sqrt(var + eps)
如果前面接的是卷积层,那么卷积的计算是:
conv_out = W * x + b
把 BN 的线性变换代入:
y = alpha * (W * x + b) + beta' = (alpha * W) * x + (alpha * b + beta')
也就是说,新的卷积权重是 alpha * W,新的卷积偏置是 alpha * b + beta'。一次参数重计算,三层结构就地变一层。
这个操作,在 OpenPPL 优化器里被实现为一个专门的 pass,叫 FuseConvBNPass 之类(具体命名不同版本略有差异)。它不产生新的 Kernel,而是在图优化阶段直接改写 Conv 算子的权重和偏置属性,然后把 BatchNorm 节点从计算图中摘除。
有个细节容易踩坑:模型训练时 BN 里的 mean 和 var 是长期滑动平均统计出来的,但在 OpenPPL 的图优化阶段看到的 ONNX 模型里,BN 节点通常带有 training_mode=0 属性,表示推理模式。如果你的模型是从训练脚本直接导出的,没有事先将 BN 层 freeze 成推理模式,ONNX 里可能还带着 training_mode=1,这时候优化器无法直接做融合。所以我一般建议,在导出 ONNX 之前先用 PyTorch 的 model.eval() 切换状态,再做 trace,避免后续在部署阶段被这种细节卡住。
2.3 非凸优化与 Adam 类算法的启发:融合是“组合优化”
写到这里,提一个可能看起来有点跳跃、但实际很有启发的关联点。最近关于优化器的讨论里,Adam 和 AdamW 这类算法很热,因为它们解决的是深度学习中非凸优化问题的收敛性。有意思的是,优化器里的算子融合问题,本质上也是一个组合优化问题:给定一棵计算图,在资源约束下寻找融合收益最大的图变换路径。这个问题同样是 NP-hard 的,所以工程上只能做启发式搜索。
所以你会发现 OpenPPL 优化器并不是一条路走到黑地做全图融合,而是采用了类似迭代式优化(类似不动点迭代)的策略:
- 初始化计算图快照。
- 重复执行多个融合 pass,直到没有新的可融合模式为止。
- 每个 pass 内部按拓扑序遍历节点,做局部最优选择。
这种迭代到不动点的思路,跟求解一个非凸问题时反复迭代更新参数有异曲同工之处。区别在于,非凸优化靠梯度下降逼近局部极小值,而计算图融合靠模式匹配逼近融合上限。虽然没有一个显式的目标函数,但工程上常用的做法是:对每种融合模式设定一个收益预估(比如省下的访存量),优化器按收益从高到低贪心地选择融合路径。
从这个角度看,OpenPPL 优化器并非死板的规则引擎,它内部其实包含了大量的启发式策略。读到对应代码时,我的感受是它像在解一个离散优化问题,只不过求解器不是 Adam,而是模式匹配加贪心策略。
3. 核心融合模式逐个拆解,看 OpenPPL 怎么动刀
3.1 卷积与激活的合并:从三次访存降为一次
先拿最简单的 Conv -> ReLU 开刀。未融合之前,Conv 的输出特征图要写到全局内存,ReLU 再读回来做 max(0, x) 操作。对于一次推理来说,这两次访存可能占整个算子执行时间的百分之三四十。
OpenPPL 的做法很简单:在卷积的 Kernel 实现里,计算完每一个输出元素之后,不直接写回,而是先过一个 ReLU 激活函数,再写回。因为 ReLU 是一个 elementwise 的逐元素操作,完全可以在寄存器层面完成。这样整个特征图只经历一次“算出来 -> 激活 -> 写回”的流程,访存压力直接减半。
但很多人可能会忽略另一个收益:融合 ReLU 后,卷积 Kernel 本身还可以做量化友好优化。在低比特推理里,ReLU 截断相当于把浮点激活的数值范围压到非负区间,这样给后续量化参数的选取提供了更稳定的分布。OpenPPL 在做 INT8 推理优化时,对融合 ReLU 的卷积 Kernel 会格外优先,因为它能进一步减少量化误差。
值得注意的还有激活函数的选择。ReLU 是最简单的,LeakyReLU 稍微复杂一点(需要一次条件判断加一次乘加),Sigmoid/Tanh 则比较昂贵。OpenPPL 的融合规则里,对 Sigmoid 类激活通常也会做融合,但此时 Kernel 内部会启用查表法(Lookup Table)来加速,或者使用向量化的多项式逼近指令。这里面的取舍是 Kernel 开发者需要关注的,但真正拍板决定“哪些激活能融合、以什么代价融合”的,仍然是优化器层。
3.2 卷积与 BatchNorm 与 ReLU 三段合体
三段合体是部署中最常见也最实在的组合。前文已经推导过 BatchNorm 如何吸收进卷积权重,这里再补一个实操细节:OpenPPL 在处理这种三合一融合时,并不是只改权重就完事,还需要把融合结果进一步折叠进卷积 Kernel 的偏置路径里。
在 CUDNN 或者自家 C++ 的卷积 Kernel 里,权重的布局对访存模式影响很大。OpenPPL 的 GPU Kernel 通常选用 NHWC layout(NCHW 在某些情况下也保留),因为通道维放在最右侧,方便利用 Tensor Core 的向量化指令。在做 Conv-BN-ReLU 融合时,优化器会检查原图中 Conv 的输出格式,必要时插入一个 Transpose 节点来调整 layout,确保融合后的 Kernel 能跑在最优的数据排布上。
有的朋友可能会问:Conv -> BN 融合之后,BN 节点不是已经没了吗?为什么还要检查 layout?因为 BN 节点虽然数学上消失了,但在 ONNX 图里它还可能携带输出张量的 shape 和 layout 信息,其他后续节点(比如 Concat 或 Reshape)可能依赖这些信息。优化器需要把这些依赖信息重新绑定到 Conv 节点上,否则计算图检查(graph validation)会直接报错。
还有一个小坑:BatchNorm 的 epsilon 参数在融合时的精度问题。如果你在 ONNX 里把 eps 设成 1e-5,但在融合时用了单精度浮点计算 1 / sqrt(var + eps),中间带来的精度损失在小数值方差场景下可能明显。OpenPPL 内部一般会用 double 来算 alpha 和 beta',再把最终结果 cast 回 float。这是工程细节,但直接影响部署模型的最终精度,尤其是对数值敏感的分割、检测模型。
3.3 残差结构的融合:Add 与 ReLU 一起处理
ResNet 的 Conv -> Add -> ReLU 结构里,Add 操作是把主分支的卷积输出和残差分支的特征逐元素相加。如果 Add 不做融合,Conv 的输出要写回,残差输出也要写回,然后 Add 再读两份数据、算加法、再写回。每一笔都是真金白银的带宽。
OpenPPL 的优化器会尝试将 Add 融合进前一个卷积 Kernel 的“写回”阶段。结构上类似这样:卷积 Kernel 的每个输出元素计算完成后,不直接写回全局内存,而是先从全局内存中把残差分支的对应元素读出来,在寄存器里完成加法,再应用 ReLU,最后写回。这么做的好处很直观:残差分支的数据只需要被读取一次,不需要中间写一份再加一份。
不过,把 Add 融合到卷积里有一个前提:卷积的输出特征图和残差特征图的尺寸、通道数、stride 必须完全一致。如果残差分支存在降采样(比如 stride=2 的卷积或池化),特征图尺寸对不上,融合就变得复杂了。OpenPPL 里面通常只有在特征图完全对齐时才会尝试这种融合,否则会退回到单独执行 Add 算子。
另一个和 Add 相关的优化技巧是:当 Add 后的结果又被 ReLU 吃进去时,可以将 ReLU 的截断语义和加法联动起来。融合 Kernel 在写回之前完成加法并应用 ReLU,这样“一次写回”完成三个阶段的计算,数据流是最短路径。
3.4 GEMM 类算子的融合:矩阵乘法的“外挂”操作
除了卷积,全连接层(Gemm)也是推理图里的常客。Gemm 的本质是矩阵乘法,输出通常接 ReLU、Sigmoid 或 Softmax。OpenPPL 对 Gemm 的融合思路与卷积类似:在矩阵乘法计算完每个输出块之后,趁热在寄存器或共享内存里应用激活。
但 Gemm 的融合还有一个特有优化点:输出块的布局。矩阵乘法通常按 tile 计算,每个 tile 的寄存器里缓存了一小块输出矩阵。如果激活函数是 ReLU,那直接在 tile 上做 max(0, x),然后写回即可。但如果激活函数是 Softmax,就需要在完整的输出行上先求 max,再算 exp 和 sum,最后归一化。OpenPPL 针对 Softmax 融合做了专门的 Kernel 化支持,避免把整个输出矩阵先写回全局内存,再重新读入。
实际使用中,我还遇到过一个情况:某个模型最后的输出层是 Gemm -> Softmax,优化器融合后,推理延迟下降了大约 15% 到 20%。这还是单次融合的收益。如果整张图里有多个类似模式,累积收益能到 30% 以上。
所以我在看 OpenPPL 优化器时最大的感觉是:它做的并不仅仅是“删掉几个中间节点”,而是在精心编排数据的生命周期——尽量让中间产物停留在寄存器、共享内存或 L1 缓存里,而不是动不动就回写全局内存。这种思想贯穿所有融合模式的始终。
4. 实操视角:在 OpenPPL 里看融合前后的计算图变化
4.1 从 ONNX 到 OpenPPL 的转换流程中,优化器插在哪一步
很多人刚接触 OpenPPL 时,分不清它和 ONNX Runtime 这类引擎的区别。OpenPPL 是一个独立的推理引擎,输入模型格式是 ONNX,但它又不像 ONNX Runtime 那样直接解释执行 ONNX 图,而是会先完成一系列转换和优化,生成内部定义的 Runtime 图、Kernel 资源和执行序列,再交由后端执行。
整体流程粗略可以概括为几大步:
- 解析 ONNX 模型,构建原始计算图(Graph)。
- 调用优化器做图优化,其中包括算子融合、常量折叠、layout 转换、冗余节点消除等。
- 为每个算子匹配可用的 Kernel,生成 Kernel 实例集合。
- 调度器根据依赖关系生成执行序列。
- 在目标设备上执行推理。
优化器位于图解析与 Kernel 实例化之间。这意味着,它操作的是“逻辑计算图”,不是设备相关的 Kernel 指令,因此具备很强的跨后端通用性:一套融合规则,既适用于 CUDA 后端,也适用于 CPU 后端(甚至某些自研加速芯片)。
我自己在调试时最常用的办法,就是把 ONNX 模型分别喂给 OpenPPL 优化器前后各 dump 一次图,然后 diff 一下节点数量。一个典型的 ResNet50 模型,未优化前 ONNX 图节点大约在 170 到 180 个之间。经过 OpenPPL 优化器融合之后,节点数常常能压到 90 到 100 个左右,几乎砍半。这减少的并不是“纸面数量”,而是实实在在的执行开销。
4.2 一条真实的融合日志解读
我在实验环境里跑过一个简单的 MobileNetV2 模型,开启 OpenPPL 的 debug 级别日志后,能看到优化器打印出类似下面这种信息(具体格式依版本有所不同):
code复制[FuseConvReLU] fused node_23(Conv) and node_24(Relu), new node id = node_23
[FuseConvBN] fused node_45(Conv) and node_47(BatchNormalization), new node id = node_45
[FuseConvAddReLU] fused node_67(Conv), node_68(Add), node_69(Relu), new node id = node_67
[EliminateDeadNode] removed node_27(Identity)
[ConstantFold] folded node_81(Shape) and node_82(Gather)
看到这些日志,我对该模型的优化情况心里基本有数了。Conv+ReLU 和 Conv+BN 是频率最高的融合,残差结构也被合并,Identity 节点被清除,Shape/Gather 这类元数据相关的常量操作直接算完存下,运行时不再重复计算。
如果你也想在本地复现这个过程,最直接的办法是下载 OpenPPL 源码,用 CMake 配置开启 PPLNN_USE_DEBUG 和 PPLNN_USE_LOG 之类的选项(具体开关以仓库说明为准),然后加载一个 ONNX 模型做一次单次推理。控制台里会输出优化器 pass 的信息,配合一些图可视化工具(比如 Netron),体验更直接。
4.3 Kernel 融合是图融合之后的下一个问题
图优化器完成了算子融合,但真正执行时,还需要“融合后的逻辑算子”落到一个高效的 Kernel 实现上。换言之,图融合是“前端”的事,Kernel 实现是“后端”的事。
OpenPPL 的 Kernel 层针对常见融合模式提供了专门的实现。比如 ConvKernel 内部会接受一个可选的激活参数(fuse_relu 等),在卷积计算主循环里直接执行激活。这里的代码风格和我看过的一些开源推理引擎类似,核心逻辑集中在计算循环里,激活函数通过模板或者内联函数传入,几乎没有任何额外分支开销。
我在调优卷积融合 Kernel 时遇到的问题是这样的:默认的卷积 Kernel 是按输出通道分块的(tile),如果融合了 ReLU 或者 Add,需要额外多分配一块共享内存来暂存残差特征图。对于大 batch、大通道数的模型,共享内存可能不够用。这时候融合反而会导致 Kernel 无法启动,或在 occupancy 上表现不佳。
所以,算子融合并不总是“优”,它是在算力、带宽、共享内存、寄存器压力之间的平衡。OpenPPL 优化器里通常会有成本模型或者启发式开关,让某些融合模式在资源受限时不启用。作为使用方,如果你发现某个 Kernel 融合后性能反而下降,不妨检查一下是不是共享内存或寄存器溢出导致占用率下降。
个人经验是:在推理引擎里,融合策略常需要按“算子形状”做差异化调整。比如一个 3x3 的普通卷积和一个 1x1 的逐点卷积,在融合 ReLU 时的 Kernel 策略可能就不一样。前者计算密度高,融合激活影响相对小;后者访存占比更大,融合激活的收益更明显。
4.4 动手实验:手工实现一个最小融合示例
如果想从原理层面完整体验算子融合,不一定要直接改 OpenPPL 源码。我建议先拿一个小例子,在 NumPy 层面模拟一遍融合过程,理解数学变换后再回到引擎里看代码,会顺畅很多。
假设输入 x 是 [1, 3, 4, 4] 的特征图,卷积层权重 W 是 [2, 3, 3, 3](输出 2 通道,输入 3 通道,3x3 卷积核),偏置 b 是 [2]。BN 层的 gamma 是 [2]、beta 是 [2]、mean 是 [2]、var 是 [2]、eps=1e-5。
先算融合参数:
python复制import numpy as np
alpha = gamma / np.sqrt(var + eps)
beta_combined = beta - mean * alpha
W_fused = W * alpha.reshape(-1, 1, 1, 1)
b_fused = b * alpha + beta_combined
之后用 W_fused 和 b_fused 做一次普通卷积,得到的就是原本 Conv -> BN 的输出,连 BN 计算都省掉了。再加一层 ReLU,直接对卷积输出做 np.maximum(0, x)。
这个最小示例虽然简单,但它完美揭示了算子融合背后最核心的数学原理:把多层线性/非线性变换折叠成更少层,等价输出不变,但计算路径更短、中间结果不落地。
5. 踩坑记录与排查思路,帮你少走弯路
5.1 融合后精度掉点:先检查 BN 的 eps 与计算精度
我遇到过几次模型在 OpenPPL 上推理精度微降的情况,排查了半天发现是 BatchNorm 的 eps 和均值方差精度问题。有些模型导出时,BN 层是 float64 精度,而推理引擎按 float32 读取,导致融合参数出现极小误差。
如果你也遇到融合前后输出差异超过预设阈值,建议:
- 确认 ONNX 里 BN 节点的 eps 与训练时完全一致。
- 在代码里把融合参数
alpha和beta_combined的计算改成 double 精度。 - 对输出逐层对比,定位是哪个融合环节引入偏差。
通常只要融合参数计算精度够,融合前后数学上是严格等价的。但如果底层 Kernel 采用了近似计算方法(比如快速倒数、低精度指令),那就需要对比融合前后的数值差异是否在可接受范围内。
5.2 融合后 Kernel 变慢:可能是共享内存超标
前面提到过,融合 Add 或激活会增加共享内存占用。如果你的 GPU 比较老、共享内存有限,融合后 Kernel 的 occupancy 反而下降,性能不升反降。
处理思路有两种:
- 在 OpenPPL 的配置里关闭对应的融合 pass,让某些算子回退到单独执行。
- 调整 Kernel 的 tile 大小,减少共享内存压力。
如果你是二次开发者,可以在优化器代码里增加条件判断:当输出通道数超过某个阈值时,放弃融合 Add,避免共享内存溢出。这本质上是把融合决策从“无脑规则”变成“带成本模型的规则”,效果会好很多。
5.3 模型中有动态 Shape:注意融合的边界条件
动态 Shape 对算子融合的影响非常隐蔽。如果模型输入尺寸不定,那么某个算子 output 的 tensor shape 在优化器阶段无法确定。OpenPPL 优化器在处理这类节点时,会倾向于保守:融合模式只选择那些 shape 完全确定的算子,不确定的一律跳过。
比如 Reshape -> Conv -> ReLU,如果 Reshape 的 output shape 完全取决于动态 batch size,那么 Conv 和 ReLU 仍然可以融合,因为 ReLU 不依赖具体 shape。但如果想融合一些需要知道特征图空间尺寸的操作(比如 GlobalAveragePool),动态 shape 就会阻止融合。
我的建议是:导出模型时尽量固定 batch size 或者对动态维度打标记,让优化器有更多可融合的空间。另外,多看看 OpenPPL 仓库里关于动态 shape 的支持文档,不同版本支持程度不同。
5.4 多个输出头的模型:注意扇出节点的处理
很多检测模型(比如 YOLO 系列)有多个输出头,一个主干特征的输出会被多个后续分支消费。前文提到过,扇出大于 1 的节点不参与融合,因为融合会破坏数据共享。这种情况下,优化器通常会保留一个独立的中间节点。
这也是为什么融合优化不是万能的。对于扇出节点,OpenPPL 更多依赖 Kernel 层的缓存策略(比如 L2 cache hint)来减少重复读取主存的代价,而不是强行融合。
实际调优时,我发现如果主干特征图的尺寸不大,扇出带来的带宽浪费并不明显。但如果是大特征图(比如 80x80x256),扇出到 3 个分支可能带来接近 3 倍的读带宽开销。这种情况就需要从模型结构层面考虑,或者在推理引擎之外做支路合并优化(例如把多个检测头共享的卷积段先合并计算)。
6. 从优化器的设计哲学看算子融合的艺术
写到这里,我对 OpenPPL 优化器的体会已经很立体了。它涉及的不仅仅是“融合算子”这一个简单动作,而是一整套计算图优化流水线,里面交融了数学等价变换、数据布局决策、访存路径设计和硬件资源约束。
从设计哲学上讲,它做的事情和 Adam 优化器在非凸优化里做的事情很像:用迭代和启发式策略,去追逐一个没有显式解析解的最优方案。只不过 Adam 操作的是模型参数,而 OpenPPL 优化器操作的是计算图结构。前者是数值优化,后者是结构优化。
我个人的感受是,看懂了优化器的算子融合,你就基本看懂了推理引擎优化的骨架。Kernel 层面的优化是“内功”,图优化层面的融合是“招式”。两者齐备,才谈得上在 GPU 上做出接近硬件极限的推理性能。
6.1 融合的上限在哪里:算力与带宽的罗塞塔石碑
到底能省多少时间?这个问题不能一概而论。我实测过一个分割模型,未开启融合时单帧推理 18ms,开启整图融合优化后降到 13ms,差不多 28% 的收益。这个收益基本上全部来自访存量的减少。
反过来,计算极密集的模型(比如大矩阵乘组成的 Transformer 结构),融合收益就会小很多。因为这类模型的主要时间花在矩阵乘法的计算上,elementwise 操作在整个耗时里占比不超过 5% 到 10%。这种情况下,更多精力要放在优化矩阵乘法本身的访存调度和计算分块上,而不是纠结那几个 elementwise 节点拆没拆。
所以,“可不可融合”要看数学等价性,但“值不值得融合”,要回归硬件瓶颈。OpenPPL 优化器里没有一刀切的配置,恰恰是因为它在尝试用工程手段逼近这个“值得”的边界。
6.2 未来方向:自动化的融合策略搜索
算子融合的规则,目前还是以人工经验为主的模式匹配。一个简单的模式,比如 Conv->BN->ReLU,大家都知道要融合。但更复杂的组合(比如 Attention 中的 QKV 投影加多头切分),就需要人工去设计融合策略和对应的 Kernel 实现。
看到最近社区里有很多人在探索编译器和推理引擎结合的方向,比如用 MLIR 或者 TVM 的 AutoTVM 来做融合策略自动搜索。理论上,给定一张计算图和一组硬件资源约束,是可以用搜索算法找到更优的融合方案的。OpenPPL 优化器目前在规则引擎层面做得比较扎实,但要说策略搜索这种重量级能力,确实还处在早期阶段。
我相信未来三到五年,算子融合会从“人工设计规则”逐渐演进到“自动搜索 + 成本模型驱动”。到那时候,类似非凸优化求解器一样,引擎会自动求解“在这个 GPU 上,如何融合算子才能获得最低延迟”这样的问题。到那一天,像 AdamW 之于 Adam 一样,融合策略的“优化算法”也可能迎来一轮新的迭代。
最后的经验之谈
回头看整个过程,我特别想对刚接触推理引擎的朋友说一句:不要在没看优化器之前,就直接跳到 Kernel 层调优。很多时候,你辛辛苦苦把一个卷积 Kernel 调快了 5%,但一个融合策略失误或者一个不必要的中间节点,就能吃掉这 5% 甚至更多。
我在实际使用 OpenPPL 时养成的习惯是:每拿到一个新模型,第一件事就是可视化计算图,先看节点之间的数据流,再逐个梳理哪些节点可以融合、哪些节点是扇出点、哪些节点的 shape 信息不确定。有了这个全局图景,后面看优化器日志也好,调 Kernel 也好,心里都有底。
最后再分享一个小技巧:OpenPPL 里默认的融合规则大概率已经覆盖了绝大多数常见情况,但如果你有特定模型的特殊模式,千万不要怕去改优化器源码。融合 pass 的代码结构通常不复杂,模式匹配逻辑清晰,改起来比想象中容易。加一个自定义融合模式,无非就是三件事:定义匹配条件、定义改写逻辑、定义新节点的 Kernel 映射。做完这三件事,你的模型在 OpenPPL 上就能走出专属于它的最短路径。
