4、算子融合技术:如何通过PyTorch JIT将多个小算子合并,减少芯片访存开销

大家好,我是你们的老朋友。今天我们来聊聊算子融合。

说实话,这个技术是我在AI芯片领域最欣赏的优化手段之一。为什么?因为它直击痛点——访存开销。你想想看,芯片算力再强,如果数据搬来搬去的时间比计算还长,那再牛的硬件也是白搭。

4.1 什么是算子融合?

简单说,就是把多个连续的小算子合并成一个大的算子。比如一个卷积后面跟着ReLU,再跟着BatchNorm。传统做法是:卷积算完,把中间结果写回显存;ReLU读出来算完,再写回去;BatchNorm再读再写……

这中间有多少次访存?三次写,三次读。而融合之后呢?一次读完,连续算完,一次写回。访存次数直接砍半。

核心思想:减少中间结果的显存读写,让数据尽可能留在片上缓存或寄存器中完成全部计算。

我在项目中遇到过这样一个案例:一个简单的ResNet-50,不做融合时,光中间激活值的读写就占了整个推理时间的40%以上。融合之后,这个比例降到了15%左右。效果立竿见影。

4.2 PyTorch JIT如何实现算子融合?

PyTorch JIT提供了两种方式:torch.jit.tracetorch.jit.script。我个人习惯用trace,因为它对现有代码侵入小。

来看个例子:

import torch
import torch.nn as nn

class SimpleModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = nn.Conv2d(3, 64, 3)
        self.relu = nn.ReLU()
        self.bn = nn.BatchNorm2d(64)
    
    def forward(self, x):
        x = self.conv(x)
        x = self.relu(x)
        x = self.bn(x)
        return x

model = SimpleModel()
example_input = torch.randn(1, 3, 224, 224)

# 使用trace进行JIT编译
traced_model = torch.jit.trace(model, example_input)

# 查看计算图
print(traced_model.graph)

# 开启算子融合优化
traced_model = torch.jit.optimize_for_inference(traced_model)

# 再看优化后的计算图
print(traced_model.graph)

运行之后你会发现,优化前的计算图里,conv、relu、bn是三个独立的节点。优化后,它们被合并成了一个节点。这就是算子融合的魔力。

小提示:torch.jit.optimize_for_inference 不仅做算子融合,还会做常量折叠、死代码消除等优化。建议推理部署时一定要加上这步。

4.3 融合的几种常见模式

在实际工程中,我总结了几种最常见的融合模式:

融合模式 典型组合 访存节省
Conv+激活 Conv2d + ReLU / Sigmoid 减少1次写+1次读
Conv+BN+激活 Conv2d + BatchNorm + ReLU 减少2次写+2次读
逐元素操作链 Add + ReLU / Mul + Add 减少1次写+1次读
降维+激活 GAP + FC + Softmax 减少1次写+1次读

嗯,这里要注意:不是所有算子都能融合。融合的前提是算子之间没有数据依赖冲突,且计算逻辑可以合并成一个kernel。

4.4 避坑指南

我曾经踩过一个坑:在模型里用了自定义的激活函数,结果JIT死活不给我融合。查了半天才发现,自定义算子如果没有注册到JIT的符号表中,它就不会被识别为可融合的算子。

解决办法有两个:

  • 尽量使用PyTorch内置算子,它们天然支持JIT融合
  • 如果必须用自定义算子,记得用 torch.jit.script 装饰一下

警告:JIT融合对动态shape支持不好。如果你的输入尺寸每次都不一样,融合效果可能会打折扣。建议在部署时固定输入尺寸。

4.5 从芯片视角看融合

为什么融合能减少访存开销?说白了,芯片的存储层次是有速度差异的。寄存器最快,L1缓存次之,L2缓存再次之,显存最慢。每次从显存读写数据,都要等几十甚至上百个时钟周期。

融合之后,中间结果可以留在寄存器或L1缓存里。下一个算子直接读,不用再去显存搬数据。这个延迟节省是数量级的。

我画了一张图,帮你理解这个过程:

算子融合前后访存对比 未融合:3次访存 Conv ReLU BN → 写显存 → 写显存 → 写显存 融合后:1次访存 FusedConvReLUBN → 只写1次最终结果 单个算子 融合算子

你看,未融合时每个算子都要写一次显存。融合后,三个算子在一个kernel里连续算完,只写一次最终结果。访存开销从3次降到1次,你说香不香?

4.6 实际部署中的建议

最后,我给大家几个实操建议:

  1. 先用profile工具看看瓶颈:用 torch.profiler 分析模型,找到访存密集的算子序列,优先融合它们
  2. 不要盲目融合所有算子:有些算子融合后反而因为寄存器压力增大导致性能下降。我建议逐个测试
  3. 结合芯片特性做融合:不同芯片的缓存大小、寄存器数量不同。比如在边缘芯片上,融合策略就要更保守一些
  4. 用JIT的debug模式验证:设置 TORCH_JIT_LOG_LEVEL=1 环境变量,可以看到JIT做了哪些优化

我的经验:在NVIDIA GPU上,Conv+BN+ReLU的融合通常能带来1.5-2倍的加速。但在一些移动端芯片上,效果可能只有10-20%。一定要针对你的目标硬件做测试。

好了,算子融合就讲到这里。记住一句话:减少访存,就是减少等待,就是提升性能。下次你在优化模型时,不妨先看看哪些算子可以融合,说不定会有意外惊喜。


公众号:蓝海资料掘金营,微信deep3321