计算图基础:静态图与动态图的区别
说到计算图,我脑子里第一个蹦出来的画面就是一张有向无环图。说白了,它就是描述计算过程的骨架。你写一行 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; // 节点唯一标识
}
这里要注意,计算图里还有一种特殊的节点——变量节点。它没有输入,只有输出,代表模型的参数(权重、偏置)。在反向传播时,变量节点会接收梯度,用于更新参数。
小技巧:在实际的编译器实现中,我建议把计算图分成「前向图」和「反向图」来存储。前向图负责推理,反向图负责梯度计算。这样在内存管理和调度上会更清晰。
拓扑排序与执行顺序
有了计算图,怎么执行它?答案就是拓扑排序。
拓扑排序,说白了就是给图中的节点排个队,保证每个节点执行时,它的所有依赖都已经计算完毕。这就像做菜:你得先洗菜、切菜,然后才能下锅炒。
拓扑排序的算法很简单:
- 找到所有入度为 0 的节点(没有依赖的节点),加入队列。
- 从队列中取出一个节点,执行它。
- 将该节点的所有后继节点的入度减 1。
- 如果某个后继节点的入度变为 0,加入队列。
- 重复步骤 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 的入度都是 0,所以它们最先执行。Add 节点依赖两个输入,所以排第三。Output 节点依赖 Add,排最后。这就是拓扑排序的结果:①→②→③→④。
静态图与动态图的执行差异
在静态图模式下,拓扑排序是在编译期完成的。框架先建好整张图,排好序,然后一次性执行。这就像拍电影,先写好剧本,排好分镜,再开机。
在动态图模式下,拓扑排序是在运行时动态进行的。每执行一行代码,框架就动态添加节点,并实时计算可执行节点。这就像直播,没有剧本,全靠临场发挥。
| 特性 | 静态图 | 动态图 |
|---|---|---|
| 图构建时机 | 编译期 | 运行时 |
| 拓扑排序时机 | 编译期一次完成 | 每次迭代动态计算 |
| 调试体验 | 差(无法断点) | 好(Python 原生调试) |
| 执行性能 | 高(全局优化) | 中(动态开销) |
| 内存优化 | 好(可复用分析) | 一般(动态分配) |
我个人觉得,没有绝对的优劣。如果你在做研究,频繁改模型结构,动态图更合适。如果你在做产品,需要极致性能,静态图是更好的选择。现在很多框架都支持两种模式的混合,比如 TensorFlow 2.x 的 @tf.function 装饰器,可以把动态图的一部分转为静态图执行。
一句话总结:计算图是模型编译器的核心数据结构。静态图适合优化和执行,动态图适合开发和调试。拓扑排序保证了执行顺序的合法性,但真正的性能优化还需要更精细的调度策略。
想要更多资料,联系可可