计算图基础:从概念到执行
各位同学,今天我们来聊聊计算图。这东西听起来有点抽象,但说白了,它就是深度学习框架的「骨架」。我刚开始接触推理引擎时,总觉得计算图是个理论概念,直到有一次在优化一个模型时,发现算子顺序调一下,性能差了30%——嗯,从那以后我再也不敢小看它了。
一、计算图是什么?
计算图,本质上是一个有向图。节点代表操作(比如卷积、ReLU、矩阵乘法),边代表数据流动(也就是张量)。你想想看,一个神经网络前向传播的过程,其实就是数据沿着这些边,从一个节点流向下一个节点。
核心要点:计算图将复杂的数学运算拆解成一系列原子操作,每个操作都可以独立优化和执行。
举个例子,一个简单的表达式 z = (x + y) * w,在计算图中会被拆成两个节点:一个加法节点和一个乘法节点。数据从 x、y 流入加法节点,结果再和 w 一起流入乘法节点,最终输出 z。
// 伪代码表示
Node add = Add(x, y);
Node mul = Mul(add, w);
我在项目中遇到过一个问题:有些框架会把多个小操作合并成一个「融合算子」,比如把 Conv + BN + ReLU 合并成一个节点。这样做的好处是减少内存读写,提升推理速度。但代价是计算图变得不那么「透明」,调试起来更麻烦。
二、静态图 vs 动态图
这是面试高频题,也是实际工程中必须做的选择。我个人的习惯是:训练阶段用动态图,部署阶段用静态图。为什么?
| 特性 | 静态图 | 动态图 |
|---|---|---|
| 定义方式 | 先构建图,再执行 | 边执行边构建 |
| 灵活性 | 低(图结构固定) | 高(可动态改变) |
| 性能 | 高(可全局优化) | 较低(每次重新构建) |
| 调试难度 | 高(错误定位困难) | 低(逐行执行) |
| 典型框架 | TensorFlow 1.x, ONNX | PyTorch, TensorFlow Eager |
静态图就像写一个完整的剧本,演员按剧本演。动态图则像即兴表演,演到哪算哪。你想想看,静态图能提前知道整个计算流程,所以可以做很多优化:内存复用、算子融合、执行顺序调整。而动态图每次执行都可能不同,优化空间就小很多。
个人经验:我曾经把一个 PyTorch 模型转成 ONNX(静态图)部署,推理速度提升了 2.3 倍。原因就是静态图允许我们把多个小算子合并,减少了大量的 kernel launch 开销。
但动态图也有它的优势。我记得有一次调试一个复杂的 Transformer 模型,如果用的是静态图,光定位一个维度不对的错误就得花半天。动态图直接打印中间结果,几分钟就搞定了。
三、计算图的拓扑排序
有了计算图,怎么执行它?答案就是拓扑排序。说白了,就是给所有节点排个序,保证每个节点执行时,它的所有输入都已经准备好了。
拓扑排序的算法很简单:
- 找到所有入度为 0 的节点(没有依赖的节点)
- 把这些节点加入执行队列
- 移除这些节点,更新剩余节点的入度
- 重复直到所有节点都被处理
// 拓扑排序伪代码
vector<Node*> topological_sort(Graph& graph) {
vector<Node*> result;
queue<Node*> q;
// 找到所有入度为0的节点
for (auto& node : graph.nodes) {
if (node.in_degree == 0) {
q.push(node);
}
}
while (!q.empty()) {
Node* cur = q.front(); q.pop();
result.push_back(cur);
for (auto& next : cur.outputs) {
next.in_degree--;
if (next.in_degree == 0) {
q.push(next);
}
}
}
return result;
}
注意:如果图中存在环,拓扑排序就无法完成。这在推理引擎中是个严重错误,意味着计算图有循环依赖。我曾经在解析一个 ONNX 模型时遇到过这种情况,最后发现是模型导出时出了问题。
四、计算图的执行
有了拓扑排序后的执行顺序,接下来就是真正的计算了。这里有几个关键点:
- 内存分配:每个节点的输出需要分配内存。静态图可以提前计算好最大内存需求,一次性分配。
- 算子调度:按拓扑顺序依次调用每个算子的 kernel 函数。
- 同步与异步:GPU 执行时,要注意同步点。有些算子可以异步执行,提升吞吐量。
我建议你在实现推理引擎时,把执行器设计成「流水线」模式。什么意思呢?就是内存分配、数据加载、kernel 执行可以重叠进行。比如 GPU 在执行第 3 个算子时,CPU 已经在准备第 4 个算子的数据了。
// 简化的执行器伪代码
class Executor {
void run(Graph& graph) {
auto order = topological_sort(graph);
MemoryPool pool(graph.get_max_memory());
for (auto* node : order) {
// 分配输出内存
auto* output = pool.allocate(node->output_size);
// 执行算子
node->kernel(node->inputs, output);
// 释放不再需要的输入
for (auto* input : node->inputs) {
if (input->ref_count-- == 0) {
pool.deallocate(input);
}
}
}
}
};
避坑指南:我曾经在实现内存池时,忘记考虑「内存碎片」问题。结果模型跑着跑着就 OOM 了,但实际内存使用量只有总容量的 60%。后来加了个内存整理机制才解决。
五、知识体系总览
下面这张图总结了计算图的核心知识体系,你可以把它当作本章的「思维导图」:
这张图把计算图的四个核心维度串起来了:概念是基础,静态/动态图是设计选择,拓扑排序是执行前提,执行引擎是落地实现。每个维度都有它的坑和技巧,后面我们会逐一深入。
一句话总结:计算图就是深度学习框架的「中间表示」,它把模型从「数学描述」变成了「可执行的指令序列」。理解它,你才能真正掌握推理引擎的优化之道。
公众号:蓝海资料掘金营,微信deep3321