计算图基础:从概念到实战

各位同学,今天我们来聊聊计算图。说实话,这玩意儿是推理引擎的基石,搞不懂它,后面那些花里胡哨的融合优化基本就是空中楼阁。我当年刚入行时,也在这上面栽过跟头——有一次手写了一个复杂的网络,结果图结构没设计好,推理速度比乌龟还慢。嗯,从那以后,我对计算图就格外上心了。

一、计算图到底是什么?

说白了,计算图就是一种有向图。节点是操作(比如卷积、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 是业界标准之一
  • 图的数据结构选型直接影响优化效率和执行性能

下一章,我们会深入算子融合的具体技术。到时候你会发现,今天讲的图结构知识,就是融合优化的「地图」。没有地图,你连路都找不到,更别说优化了。

嗯,今天就到这里。有问题随时交流。