计算图基础:静态图与动态图的区别

说到计算图,我脑子里第一个蹦出来的画面就是一张有向无环图。说白了,它就是描述计算过程的骨架。你写一行 y = a + b,计算图里就会有两个节点(a 和 b)指向一个加法节点,再指向 y。就这么简单。

但真正让我对计算图产生敬畏之心的,是几年前调一个分布式训练任务的时候。模型死活跑不对,梯度传着传着就丢了。最后发现,是计算图的拓扑排序出了问题,执行顺序乱了。嗯,从那以后我再也不敢小看这张「图」了。

静态图 vs 动态图:两种哲学

静态图和动态图的区别,其实可以一句话说清楚:图是先建好再跑,还是边跑边建。

静态图(Define-and-Run):你先定义好整个计算流程,然后交给框架去执行。TensorFlow 1.x 就是典型代表。你想想看,这就像写剧本,演员按剧本演,不能即兴发挥。

  • 优点:框架能看到全局,可以做各种优化。比如算子融合、内存复用、甚至自动并行。我在做模型部署时,静态图编译出来的执行文件,性能能提升 30%-50%。
  • 缺点:调试起来很痛苦。你没法在中间打个断点看看某个张量的值。我刚开始用 TensorFlow 1.x 时,经常为了查一个 bug 要写一堆 tf.Print,那叫一个心累。

动态图(Define-by-Run):每执行一行代码,图就动态构建一部分。PyTorch 就是这种模式。这就像即兴话剧,演员边演边编。

  • 优点:调试体验极好。你可以用 Python 的 pdb 在任何地方打断点,打印张量值。我个人习惯用动态图做研究和实验,因为迭代速度快。
  • 缺点:框架看不到全局,优化空间有限。而且每次迭代都要重新建图,有额外开销。

核心区别总结:静态图是「先编译后执行」,动态图是「边解释边执行」。静态图适合部署和生产环境,动态图适合研究和调试。

计算图的数据结构

计算图本质上是一个有向无环图(DAG)。它的数据结构包含两个核心元素:

  • 节点(Node):代表一个操作(Op),比如加法、卷积、矩阵乘法。节点也可以代表变量、常量、占位符。
  • 边(Edge):代表数据流向,也就是张量(Tensor)。边连接两个节点,表示前一个节点的输出是后一个节点的输入。

我习惯用邻接表来存储计算图。每个节点维护一个输入列表和一个输出列表。这样在遍历时效率很高。

// 伪代码:计算图节点结构
class GraphNode {
    string op_type;          // 操作类型:'add', 'conv2d', 'matmul'
    vector<Tensor> inputs;   // 输入张量列表
    vector<Tensor> outputs;  // 输出张量列表
    map<string, Attr> attrs; // 属性:如卷积的 stride, padding
    int node_id;             // 节点唯一标识
}

这里要注意,计算图里还有一种特殊的节点——变量节点。它没有输入,只有输出,代表模型的参数(权重、偏置)。在反向传播时,变量节点会接收梯度,用于更新参数。

小技巧:在实际的编译器实现中,我建议把计算图分成「前向图」和「反向图」来存储。前向图负责推理,反向图负责梯度计算。这样在内存管理和调度上会更清晰。

拓扑排序与执行顺序

有了计算图,怎么执行它?答案就是拓扑排序。

拓扑排序,说白了就是给图中的节点排个队,保证每个节点执行时,它的所有依赖都已经计算完毕。这就像做菜:你得先洗菜、切菜,然后才能下锅炒。

拓扑排序的算法很简单:

  1. 找到所有入度为 0 的节点(没有依赖的节点),加入队列。
  2. 从队列中取出一个节点,执行它。
  3. 将该节点的所有后继节点的入度减 1。
  4. 如果某个后继节点的入度变为 0,加入队列。
  5. 重复步骤 2-4,直到队列为空。

我曾经在一个模型编译器项目中,遇到过拓扑排序死循环的问题。原因是计算图里出现了环——一个节点的输出间接依赖了自己的输出。这在理论上是不允许的,但实际代码中因为某些算子实现不当,确实会出现。嗯,从那以后我每次建图都会加一个环检测。

// 拓扑排序伪代码
vector<int> topological_sort(Graph& graph) {
    vector<int> in_degree(graph.num_nodes(), 0);
    for (auto& node : graph.nodes) {
        for (auto& succ : node.successors) {
            in_degree[succ]++;
        }
    }
    
    queue<int> q;
    for (int i = 0; i < in_degree.size(); i++) {
        if (in_degree[i] == 0) q.push(i);
    }
    
    vector<int> exec_order;
    while (!q.empty()) {
        int node_id = q.front(); q.pop();
        exec_order.push_back(node_id);
        for (auto& succ : graph.nodes[node_id].successors) {
            if (--in_degree[succ] == 0) q.push(succ);
        }
    }
    
    if (exec_order.size() != graph.num_nodes()) {
        // 有环!抛出异常
        throw runtime_error("Graph contains a cycle!");
    }
    return exec_order;
}

执行顺序确定后,框架就可以按照这个顺序依次执行每个节点。这里有个优化点:并行执行。如果两个节点没有依赖关系,它们可以同时执行。比如在同一个 batch 里的两个独立分支计算,就可以并行。

避坑指南:我曾经在写调度器时,天真地以为拓扑排序出来的顺序就是最优执行顺序。后来发现,还要考虑内存带宽、缓存局部性、算子启动开销等因素。拓扑排序只是「合法顺序」,不是「最优顺序」。真正的调度器会在拓扑排序的基础上做各种启发式优化。

用 SVG 画一张计算图结构

下面这张图展示了一个简单的计算图,包含输入、加法、乘法和输出节点。箭头表示数据流向。

Input A Input B Add Output a b a+b ① ② ③ ④

这张图里,Input A 和 Input B 的入度都是 0,所以它们最先执行。Add 节点依赖两个输入,所以排第三。Output 节点依赖 Add,排最后。这就是拓扑排序的结果:①→②→③→④。

静态图与动态图的执行差异

在静态图模式下,拓扑排序是在编译期完成的。框架先建好整张图,排好序,然后一次性执行。这就像拍电影,先写好剧本,排好分镜,再开机。

在动态图模式下,拓扑排序是在运行时动态进行的。每执行一行代码,框架就动态添加节点,并实时计算可执行节点。这就像直播,没有剧本,全靠临场发挥。

特性 静态图 动态图
图构建时机 编译期 运行时
拓扑排序时机 编译期一次完成 每次迭代动态计算
调试体验 差(无法断点) 好(Python 原生调试)
执行性能 高(全局优化) 中(动态开销)
内存优化 好(可复用分析) 一般(动态分配)

我个人觉得,没有绝对的优劣。如果你在做研究,频繁改模型结构,动态图更合适。如果你在做产品,需要极致性能,静态图是更好的选择。现在很多框架都支持两种模式的混合,比如 TensorFlow 2.x 的 @tf.function 装饰器,可以把动态图的一部分转为静态图执行。

一句话总结:计算图是模型编译器的核心数据结构。静态图适合优化和执行,动态图适合开发和调试。拓扑排序保证了执行顺序的合法性,但真正的性能优化还需要更精细的调度策略。

想要更多资料,联系可可