2、计算图基础:计算图的概念、静态图与动态图、计算图的拓扑排序与执行
好,咱们正式开始聊计算图。
说实话,计算图这个概念,是理解整个推理引擎的基石。你想想看,不管多复杂的神经网络,到了底层,其实就是一张图。节点是算子,边是数据流动。我当年刚入行时,总觉得这东西太抽象,直到亲手搭了一个简单的图引擎,才恍然大悟——原来深度学习框架的“灵魂”就在这里。
2.1 计算图的概念
计算图,说白了就是用图结构来表示数学计算过程。
一个计算图由两部分组成:节点(Node)和边(Edge)。
- 节点:代表一个操作,比如加法、乘法、卷积、ReLU等。在推理引擎里,我们通常叫它“算子”(Operator)。
- 边:代表数据(张量)的流向。从一个节点输出,流向下一个节点的输入。
举个例子,一个简单的表达式 z = (a + b) × c,它的计算图长这样:
a ──┐
├──> (+) ──> (×) ──> z
b ──┘ ↑
c ──────────────┘
你看,a和b先进入加法节点,结果再和c一起进入乘法节点,最后输出z。清晰明了。
核心要点:计算图是一种有向无环图(DAG)。为什么必须无环?因为如果有环,数据就会无限循环,永远算不完。嗯,这里要注意,RNN那种“循环”其实是通过时间展开的,展开后依然是无环的。
我在项目中遇到过一个问题:有同事把计算图画成了带环的结构,结果推理时直接栈溢出。排查了半天才发现,是某个算子的输出错误地连回了自己的输入。所以,检查图的连通性,是写图引擎的第一步。
2.2 静态图与动态图
聊完概念,咱们来聊聊两种主流的计算图模式:静态图和动态图。
这个问题,几乎是每个框架使用者都会纠结的点。我个人习惯是:调试用动态图,部署用静态图。
2.2.1 静态图
静态图的特点是:先定义,后执行。
你先把整个网络的结构搭好,搭成一个完整的计算图,然后把这个图“冻结”起来,最后再喂数据进去跑。TensorFlow 1.x 就是典型的静态图模式。
# 伪代码示例:静态图
# 第一步:构建图
a = Placeholder()
b = Placeholder()
c = Placeholder()
z = mul(add(a, b), c)
# 第二步:执行图
result = session.run(z, feed_dict={a: 1, b: 2, c: 3})
静态图的优点很明显:
- 性能好:图是固定的,可以做各种优化,比如算子融合、内存复用。
- 便于部署:图结构确定,可以序列化到文件,拿到任何设备上跑。
但缺点也很要命:
- 调试困难:你不能在中间打印个变量看看值,因为图还没执行。
- 灵活性差:如果你想根据输入动态改变网络结构,静态图基本做不到。
避坑指南:我曾经在静态图里写了一个条件分支,想根据batch size的大小选择不同的计算路径。结果发现,静态图会把所有分支都编译进去,导致内存暴涨。后来我改用动态图,才解决了这个问题。
2.2.2 动态图
动态图正好相反:边定义边执行。
你写一行代码,它就执行一行,同时构建图。PyTorch 就是动态图的代表。
# 伪代码示例:动态图
a = torch.tensor(1)
b = torch.tensor(2)
c = torch.tensor(3)
z = (a + b) * c # 这里直接执行,同时记录计算图
print(z) # 可以随时打印
动态图的优点:
- 调试友好:随时可以打印、打断点,跟写普通Python代码一样。
- 灵活:可以根据数据动态改变网络结构,比如循环次数不固定。
缺点:
- 性能稍差:每次执行都要重新构建图,优化空间有限。
- 部署麻烦:需要带上整个运行时环境。
2.2.3 对比总结
| 特性 | 静态图 | 动态图 |
|---|---|---|
| 构建时机 | 先构建,后执行 | 边构建,边执行 |
| 性能 | 高(可优化) | 中等 |
| 调试难度 | 困难 | 容易 |
| 灵活性 | 低 | 高 |
| 典型框架 | TensorFlow 1.x, ONNX | PyTorch, TensorFlow Eager |
我的建议:如果你在写推理引擎,首选静态图。因为推理阶段不需要动态调整结构,性能才是王道。我们后面要讲的ONNX,就是静态图的一种标准格式。
2.3 计算图的拓扑排序与执行
好,现在图有了,怎么执行它?
你想想看,计算图里节点之间有依赖关系。比如上面的例子,乘法节点必须等加法节点算完才能执行。那计算机怎么知道先算谁、后算谁?
答案就是:拓扑排序。
2.3.1 拓扑排序
拓扑排序,就是把有向无环图中的所有节点,排成一个线性序列。要求是:对于每一条有向边 u → v,在序列中 u 必须出现在 v 之前。
说白了,就是先算依赖,再算被依赖。
算法很简单,我常用的方法是Kahn算法:
# 伪代码:Kahn算法拓扑排序
def topological_sort(graph):
in_degree = {node: 0 for node in graph.nodes}
for node in graph.nodes:
for successor in node.successors:
in_degree[successor] += 1
queue = [node for node in graph.nodes if in_degree[node] == 0]
sorted_nodes = []
while queue:
node = queue.pop(0)
sorted_nodes.append(node)
for successor in node.successors:
in_degree[successor] -= 1
if in_degree[successor] == 0:
queue.append(successor)
if len(sorted_nodes) != len(graph.nodes):
raise Exception("图中存在环!")
return sorted_nodes
这段代码的核心思想:
- 统计每个节点的入度(有多少个前驱节点)。
- 入度为0的节点,说明没有依赖,可以执行。
- 执行完后,把它所有后继节点的入度减1。
- 重复,直到所有节点都被处理。
注意:如果最后还有节点没处理,说明图里有环。我曾经在解析一个ONNX模型时,发现图里有个自环(某个节点的输出连到了自己的输入),拓扑排序直接报错。排查后发现是模型导出时的一个bug。
2.3.2 图执行
有了拓扑排序后的节点列表,执行就简单了:
# 伪代码:计算图执行
def execute_graph(graph, inputs):
# 1. 拓扑排序
sorted_nodes = topological_sort(graph)
# 2. 准备存储中间结果的字典
tensor_dict = {}
for input_name, input_value in inputs.items():
tensor_dict[input_name] = input_value
# 3. 按顺序执行每个节点
for node in sorted_nodes:
# 获取输入张量
input_tensors = [tensor_dict[name] for name in node.input_names]
# 执行算子
output_tensor = node.op(input_tensors)
# 存储输出
tensor_dict[node.output_name] = output_tensor
# 4. 返回最终结果
return tensor_dict[graph.output_name]
你看,整个执行过程其实就是:排序 → 遍历 → 计算 → 传递。
这里有个细节:内存管理。中间结果存在哪里?什么时候可以释放?
我习惯的做法是:当一个节点的所有后继节点都执行完毕后,就可以释放它的输出张量了。这需要维护一个引用计数。嗯,这个后面讲内存优化时会详细展开。
2.4 小结
这一章我们聊了:
- 计算图是深度学习框架的底层数据结构,节点是算子,边是数据流。
- 静态图性能好但灵活差,动态图反之。推理引擎选静态图。
- 拓扑排序是图执行的前提,Kahn算法简单实用。
- 图执行就是按拓扑序依次调用算子,注意内存管理。
下一章,我们会真正动手,用Python写一个最简单的计算图引擎。到时候你会发现,这些概念其实一点都不难。
个人经验:我刚开始写图引擎时,总想一步到位,把优化都做进去。结果代码越写越乱,bug一堆。后来我学乖了:先跑通,再优化。先把最朴素的版本写出来,能正确执行,然后再一点点加优化。这个思路,适用于所有系统开发。