计算图与算子边界:理解融合的前提

说实话,算子融合这事儿,很多人一上来就想着怎么把两个算子拼在一起。但我得说,不懂计算图,就别谈融合。这就像你要改造一栋楼,总得先看懂建筑图纸吧?

我在做模型优化的时候,遇到过不少同学拿着代码就跑来问:「我这个算子怎么融合不上去?」结果一看,连计算图长什么样都没搞清楚。嗯,今天我们就从最基础的东西说起。

计算图到底是什么?

说白了,计算图就是深度学习框架用来描述运算逻辑的一种数据结构。它是个有向无环图(DAG),节点代表算子,边代表数据流向。

举个例子,一个简单的计算:

import torch

def simple_model(x):
    a = torch.mul(x, 2.0)   # 节点1
    b = torch.add(a, 1.0)   # 节点2
    c = torch.relu(b)       # 节点3
    return c

这段代码对应的计算图就是三个节点串在一起:Mul → Add → ReLU。数据从x开始,经过乘法、加法、激活函数,最后输出。

你想想看,如果没有计算图,框架怎么知道哪些算子可以合并?它连算子之间的依赖关系都搞不清楚。

核心要点:计算图是算子融合的分析基础。没有图,融合就是盲人摸象。

算子粒度的定义

这里有个容易混淆的概念——算子粒度。我习惯把它分成三个层次:

粒度级别 说明 例子
细粒度 单个数学运算 Add、Mul、Relu
中粒度 组合运算 Conv2d+BatchNorm
粗粒度 完整模块 Transformer Block

我在项目中遇到过一个问题:某团队把整个Transformer Block定义成一个算子,结果融合的时候发现根本没法做。为什么?因为粒度太粗了,内部的计算依赖关系全被隐藏了。

我个人建议:做融合的时候,算子粒度最好控制在「中粒度」水平。太细了,融合收益小;太粗了,融合条件不满足。

融合的边界条件

好,现在到了关键部分。算子融合不是你想融就能融的,它有几个硬性条件:

1. 数据依赖关系

两个算子能不能融合,首先看它们之间有没有数据依赖。如果算子B的输入完全依赖于算子A的输出,那它们才有可能融合。

# 可以融合的情况
y1 = relu(x)    # 算子A
y2 = add(y1, 1) # 算子B,依赖y1

# 不能融合的情况
y1 = relu(x)    # 算子A
y2 = add(x, 1)  # 算子B,不依赖y1

2. 计算语义等价性

融合后的算子,计算结果必须和融合前完全一致。这一点看似简单,但坑很多。

我曾经踩过的坑:有一次融合Conv+BN,没考虑到BN在推理和训练时的行为差异。训练时BN有均值和方差的滑动更新,融合后这些更新操作丢失了,导致模型精度崩了。嗯,从那以后我每次做融合都会先检查语义等价性。

3. 硬件约束

不同的硬件平台,支持的算子融合方式不一样。比如:

  • GPU上,Conv+ReLU 可以融合成一个kernel
  • CPU上,MatMul+Add 可以融合成带bias的矩阵乘
  • NPU上,可能支持更复杂的融合模式

4. 内存访问模式

融合的核心目的之一就是减少内存访问。如果两个算子融合后,内存访问模式反而变差了,那融合就没意义。

举个例子:Add+ReLU 融合后,数据从显存读一次,做完加法和激活直接写回。如果不融合,要读两次、写两次。你想想看,哪个快?

知识体系总览

下面这张图,是我自己总结的计算图与算子融合的核心逻辑:

计算图与算子融合知识体系 计算图 有向无环图(DAG) 节点 = 算子 边 = 数据流向 算子粒度 细粒度:单个运算 中粒度:组合运算 粗粒度:完整模块 融合边界条件 数据依赖 语义等价 硬件约束 融合决策 满足所有边界条件 → 可以融合 → 提升训练吞吐 三者共同决定:哪些算子可以融合,哪些不能 示例:Conv+BN+ReLU → 融合成一个算子,减少3次内存访问

小技巧:在实际项目中,我习惯先用 torch.jit.trace 或 onnx.export 把模型导出成计算图,然后用可视化工具看看图的结构。这样一眼就能看出哪些地方适合做融合。

总结一下

计算图是融合的地图,算子粒度是融合的尺度,边界条件是融合的门槛。这三者缺一不可。

我个人经验是:先画图,再定粒度,最后检查边界条件。按照这个顺序来,融合的成功率会高很多。

好了,这一节就到这里。记住,融合不是目的,提升训练吞吐才是。别为了融合而融合,搞清楚边界条件再动手。

可可精品资料库