计算图与算子边界:理解融合的前提
说实话,算子融合这事儿,很多人一上来就想着怎么把两个算子拼在一起。但我得说,不懂计算图,就别谈融合。这就像你要改造一栋楼,总得先看懂建筑图纸吧?
我在做模型优化的时候,遇到过不少同学拿着代码就跑来问:「我这个算子怎么融合不上去?」结果一看,连计算图长什么样都没搞清楚。嗯,今天我们就从最基础的东西说起。
计算图到底是什么?
说白了,计算图就是深度学习框架用来描述运算逻辑的一种数据结构。它是个有向无环图(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 融合后,数据从显存读一次,做完加法和激活直接写回。如果不融合,要读两次、写两次。你想想看,哪个快?
知识体系总览
下面这张图,是我自己总结的计算图与算子融合的核心逻辑:
小技巧:在实际项目中,我习惯先用 torch.jit.trace 或 onnx.export 把模型导出成计算图,然后用可视化工具看看图的结构。这样一眼就能看出哪些地方适合做融合。
总结一下
计算图是融合的地图,算子粒度是融合的尺度,边界条件是融合的门槛。这三者缺一不可。
我个人经验是:先画图,再定粒度,最后检查边界条件。按照这个顺序来,融合的成功率会高很多。
好了,这一节就到这里。记住,融合不是目的,提升训练吞吐才是。别为了融合而融合,搞清楚边界条件再动手。