2、计算图基础:计算图的概念、静态图与动态图、计算图的拓扑排序与执行

好,咱们正式开始聊计算图。

说实话,计算图这个概念,是理解整个推理引擎的基石。你想想看,不管多复杂的神经网络,到了底层,其实就是一张图。节点是算子,边是数据流动。我当年刚入行时,总觉得这东西太抽象,直到亲手搭了一个简单的图引擎,才恍然大悟——原来深度学习框架的“灵魂”就在这里。

2.1 计算图的概念

计算图,说白了就是用图结构来表示数学计算过程。

一个计算图由两部分组成:节点(Node)边(Edge)

  • 节点:代表一个操作,比如加法、乘法、卷积、ReLU等。在推理引擎里,我们通常叫它“算子”(Operator)。
  • :代表数据(张量)的流向。从一个节点输出,流向下一个节点的输入。

举个例子,一个简单的表达式 z = (a + b) × c,它的计算图长这样:

    a ──┐
         ├──> (+) ──> (×) ──> z
    b ──┘           ↑
    c ──────────────┘

你看,ab先进入加法节点,结果再和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一堆。后来我学乖了:先跑通,再优化。先把最朴素的版本写出来,能正确执行,然后再一点点加优化。这个思路,适用于所有系统开发。