算子融合:提升推理效率的核心技术 - 第2节

一、计算图:深度学习模型的骨架

做推理加速这些年,我越来越觉得计算图就像模型的骨架。没有它,你根本不知道模型在干什么。

说白了,计算图就是一个有向无环图(DAG)。节点是算子,边是数据流动的方向。数据从输入节点流入,经过一系列算子处理,最终到达输出节点。

计算图的核心要素:

  • 节点(Node):代表一个算子操作,比如卷积、ReLU、矩阵乘法
  • 边(Edge):代表张量数据的流向,也就是中间结果
  • 拓扑结构:决定了算子的执行顺序

举个例子,一个简单的两层神经网络:

# 伪代码展示计算图结构
input → Conv2D → ReLU → MaxPool → Conv2D → ReLU → Flatten → Dense → Softmax → output

每个箭头就是一条边,每个操作就是一个节点。数据从左到右流动,这就是前向传播。

我在项目中遇到过一个问题:同样的模型,在不同框架上跑出来的性能差很多。后来发现,是计算图的构建方式不同导致的。有的框架是静态图,有的框架是动态图,这直接影响后续的算子融合策略。

静态图 vs 动态图

特性 静态图 动态图
构建时机 执行前构建完整图 运行时逐行构建
优化空间 大,可全局优化 小,只能局部优化
调试难度 较难 容易
典型框架 TensorFlow 1.x, ONNX PyTorch, TensorFlow Eager

我个人习惯用静态图做推理部署。为什么?因为静态图给了你全局优化的机会。你可以看到整个计算流程,然后决定哪些算子可以合并,哪些可以消除。

二、算子:计算图的最小执行单元

算子是什么?就是计算图里的每个节点。它定义了一个具体的数学运算。

我经常跟团队说:算子就是深度学习模型的「原子操作」。你想想看,不管多复杂的模型,拆到最底层,就是一堆算子的组合。

算子的分类

按功能来分,算子大致可以分为这几类:

  • 计算密集型算子:卷积、矩阵乘法、全连接。这类算子计算量大,访存比高,是性能瓶颈的主要来源。
  • 激活函数算子:ReLU、Sigmoid、Tanh。计算量不大,但数量多,频繁调用。
  • 池化算子:MaxPool、AvgPool。用于降维,计算简单。
  • 归一化算子:BatchNorm、LayerNorm。训练和推理行为不同,推理时通常可以融合。
  • 形状变换算子:Reshape、Transpose、Squeeze。不涉及计算,只改变张量形状。
  • 逐元素算子:Add、Mul、Div。两个张量对应元素做运算。

我的经验之谈:

做算子融合时,优先关注计算密集型算子和它周围的激活函数、归一化算子。这些组合的融合收益最大。我曾经把一个Conv+BN+ReLU融合成一个算子,推理速度提升了30%。

算子的属性

每个算子都有自己的一组属性。比如卷积算子:

Conv2D(
    kernel_size=3,    # 卷积核大小
    stride=1,         # 步长
    padding='same',   # 填充方式
    dilation=1,       # 膨胀系数
    groups=1,         # 分组数
    in_channels=64,   # 输入通道数
    out_channels=128  # 输出通道数
)

这些属性决定了算子的计算量和内存访问模式。做融合时,必须考虑这些属性是否兼容。

三、计算图与算子的关系:整体与局部

计算图和算子的关系,说白了就是整体和局部的关系。计算图是宏观视角,算子是微观视角。

我画了一张图来展示它们的关系:

计算图与算子的关系 计算图(宏观视角) Input → Conv → ReLU → Pool → FC → Output 算子(微观视角) Conv2D 算子 kernel=3x3, stride=1 in=64, out=128 ReLU 算子 逐元素操作 f(x)=max(0,x) MaxPool 算子 pool=2x2, stride=2 取最大值 计算图定义「做什么」,算子定义「怎么做」

从这张图可以看得很清楚:计算图描述了数据流动的路径,而每个节点背后都是一个具体的算子实现。

为什么算子融合能加速?

原因其实很简单。你想想看,每个算子执行时,都要经历:

  1. 从内存读取输入数据
  2. 执行计算
  3. 把结果写回内存

如果两个算子分开执行,中间结果就要写回内存再读出来。这个读写操作的开销,有时候比计算本身还大。

算子融合的核心思想:

把多个连续的算子合并成一个,减少中间结果的读写次数。说白了,就是让数据在寄存器里多待一会儿,别老往内存里跑。

举个例子,Conv + ReLU 融合:

// 融合前:两次内存访问
conv_output = conv2d(input, weight)  // 写回内存
output = relu(conv_output)           // 从内存读取

// 融合后:一次内存访问
output = fused_conv_relu(input, weight)  // 计算完直接输出

我曾经在一个项目中,把整个 ResNet-50 的 Conv+BN+ReLU 全部融合,推理速度提升了将近 40%。这就是算子融合的魅力。

计算图对算子融合的影响

计算图的结构决定了哪些算子可以融合。一般来说,满足以下条件的算子可以考虑融合:

  • 连续执行:在计算图中是相邻节点
  • 数据依赖:前一个算子的输出是后一个算子的唯一输入
  • 计算模式匹配:比如卷积后面跟激活函数,这是最常见的融合模式

注意:

不是所有相邻算子都适合融合。比如 Conv + Softmax,这两个算子的计算模式差异太大,融合后反而可能变慢。我刚开始做融合时犯过这个错,后来学乖了,先做性能分析再决定。

嗯,到这里,计算图和算子的基本概念就讲完了。记住一句话:计算图是蓝图,算子是砖块。算子融合,就是把这些砖块砌得更紧凑,让数据流动得更快。

一句话总结:

理解计算图的结构,熟悉算子的特性,你才能知道哪些算子可以融合,哪些不能。这是做推理加速的第一步,也是最关键的一步。

可可精品资料库