计算图基础:从概念到实战
各位同学,今天我们来聊聊计算图。说实话,这玩意儿是推理引擎的基石,搞不懂它,后面那些花里胡哨的融合优化基本就是空中楼阁。我当年刚入行时,也在这上面栽过跟头——有一次手写了一个复杂的网络,结果图结构没设计好,推理速度比乌龟还慢。嗯,从那以后,我对计算图就格外上心了。
一、计算图到底是什么?
说白了,计算图就是一种有向图。节点是操作(比如卷积、ReLU、加法),边是数据流(张量)。你想想看,一个神经网络的前向传播,不就是一堆操作按顺序执行吗?计算图就是把这个过程画出来。
举个例子,一个简单的表达式 z = (x + y) * w,用计算图表示就是:
x ──┐
├──> (+) ──> (*) ──> z
y ──┘ ↑
w
节点 (+) 和 (*) 是操作,边是数据流向。就这么简单。
核心要点:计算图把「计算」和「数据」分离了。操作只管怎么算,数据只管往哪流。这种解耦,是后面所有优化的前提。
二、静态图 vs 动态图:两种哲学
这里有个经典问题:静态图和动态图,到底选哪个?我个人的经验是——看场景。
静态图(Graph First)
先定义完整的图,再执行。TensorFlow 1.x 就是典型。优点很明显:
- 全局优化空间大:整个图都摆在你面前,算子融合、内存复用随便搞
- 部署友好:图结构固定,可以序列化成文件,直接扔到手机上跑
- 性能稳定:没有运行时动态分支,执行路径是确定的
但缺点也让人头疼:调试困难。我记得有一次,图里某个节点的shape对不上,报错信息绕了三大圈才定位到问题。你想想看,写代码时看不到中间结果,全靠脑补,多痛苦。
动态图(Define-by-Run)
边定义边执行,PyTorch 就是代表。它的好处是:
- 调试直观:可以随时 print 中间结果,跟写普通 Python 一样
- 灵活性高:if-else、循环这些控制流随便用,图结构可以动态变化
- 上手快:不用学什么 Graph API,直接写 forward 函数就行
但代价呢?性能优化受限。你没法提前知道整个图长什么样,很多跨算子优化就做不了。我曾经在一个动态图模型上做算子融合,结果因为图结构每次都不一样,融合策略根本没法固化。
| 特性 | 静态图 | 动态图 |
|---|---|---|
| 定义方式 | 先建图,后执行 | 边执行边建图 |
| 调试难度 | 高(看不到中间值) | 低(随时 print) |
| 优化空间 | 大(全局分析) | 小(局部优化) |
| 典型框架 | TensorFlow 1.x, ONNX | PyTorch, TensorFlow Eager |
我的建议:训练阶段用动态图,方便调试;部署阶段转成静态图,追求性能。现在 PyTorch 的 torchscript 和 TensorFlow 的 tf.function 都能做这个转换,算是两全其美了。
三、计算图的表示方法:IR 登场
图建好了,怎么在内存里表示它?这就引出了 IR(Intermediate Representation,中间表示)。IR 是推理引擎的「通用语言」,所有优化都在 IR 层面做。
常见的 IR 形式有几种:
- 图 IR:直接用节点和边的数据结构表示。比如 ONNX 的 protobuf 格式,每个节点有 op_type、inputs、outputs、attributes。
- 线性 IR:把图展开成指令序列。比如 TVM 的 Relay IR,既有图结构,又有类型信息。
- SSA 形式:静态单赋值,每个变量只赋值一次。这种形式特别适合做数据流分析。
我个人比较喜欢图 IR,因为它直观。你看 ONNX 的节点定义:
// 伪代码,ONNX 的 NodeProto 结构
message NodeProto {
repeated string input = 1; // 输入张量名
repeated string output = 2; // 输出张量名
string op_type = 3; // 操作类型,比如 "Conv"
string name = 4; // 节点名
repeated AttributeProto attribute = 5; // 属性,比如卷积的 kernel_size
}
每个节点知道自己从哪来(input)、到哪去(output)、干什么(op_type)。整个图就是这些节点的集合,再加上一个 GraphProto 来管理所有节点和初始张量。
避坑指南:我曾经在解析 ONNX 模型时,发现某个节点的 input 名字在图中找不到对应的 producer。后来查了半天,原来是那个输入是 graph input(模型的外部输入),不是某个节点的输出。嗯,处理 IR 时一定要区分清楚「图输入」和「中间张量」。
四、图的数据结构:怎么存才高效?
IR 是逻辑表示,但真正在引擎里跑的时候,我们需要高效的数据结构来操作图。常见的实现方式有:
邻接表
每个节点维护一个列表,存它的前驱和后继。比如:
class Node {
string op_type;
vector<Node*> inputs; // 前驱节点
vector<Node*> outputs; // 后继节点
// 其他属性...
};
这种结构做拓扑排序、DFS 遍历都很方便。但要注意,如果图很大(比如几百个节点),频繁的指针操作会有性能开销。
索引数组
把所有节点放在一个数组里,用索引代替指针。比如:
struct Node {
int op_type; // 操作类型枚举
vector<int> inputs; // 前驱节点索引
vector<int> outputs; // 后继节点索引
};
vector<Node> nodes; // 所有节点连续存储
这种方式缓存友好,遍历速度快。我在做图优化时特别喜欢用这种结构,因为可以批量处理节点,不用一个个指针跳来跳去。
DAG 的拓扑排序
计算图通常是有向无环图(DAG)。拓扑排序后,我们可以得到一个线性执行顺序。比如:
// 拓扑排序结果示例
exec_order = [input, conv, relu, pool, fc, softmax, output]
这个顺序保证了每个节点执行时,它的所有输入都已经计算好了。我建议在引擎初始化时就做一次拓扑排序,后面执行时直接按顺序跑就行,省得每次都要检查依赖。
实战经验:有一次我优化一个 50 层的 ResNet,发现拓扑排序后,某些节点的执行顺序可以微调,从而减少内存峰值。比如把两个不依赖的卷积节点并行执行,虽然单次推理时间没变,但内存占用降了 30%。这就是图数据结构带来的优化空间。
五、总结与思考
好了,今天的内容就到这里。我们来捋一捋:
- 计算图是推理引擎的骨架,节点是操作,边是数据流
- 静态图适合部署优化,动态图适合训练调试,现在两者在融合
- IR 是图的「通用语言」,ONNX 是业界标准之一
- 图的数据结构选型直接影响优化效率和执行性能
下一章,我们会深入算子融合的具体技术。到时候你会发现,今天讲的图结构知识,就是融合优化的「地图」。没有地图,你连路都找不到,更别说优化了。
嗯,今天就到这里。有问题随时交流。