2. 计算图基础:计算图的概念、静态图与动态图、计算图在编译器中的作用

2.1 计算图到底是什么?

先说说我自己的理解。计算图,说白了就是把数学运算画成一张图。

你想想看,一个神经网络模型,无非就是一堆矩阵乘法、卷积、激活函数堆在一起。计算图就是把这些操作拆开,每个操作变成一个节点,数据流向变成边。嗯,就这么简单。

举个例子,一个简单的表达式 z = (a + b) × c,用计算图表示就是:

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

每个圆圈是一个操作节点,箭头是数据流向。我在项目中遇到过不少新人,一上来就写几百行的 forward 函数,结果调试起来一头雾水。要是先画个计算图,问题早定位了。

核心要点:计算图 = 节点(算子)+ 边(数据依赖)

  • 节点:加法、乘法、卷积、ReLU 等操作
  • 边:张量(Tensor)在节点间流动
  • 方向:从输入到输出,前向传播的方向

2.2 静态图 vs 动态图

这是面试高频题,也是实际开发中必须做的选择。我两种都写过,说说我的感受。

静态图(Define-and-Run)

先定义好整个计算图,再喂数据执行。TensorFlow 1.x 就是典型代表。

import tensorflow as tf

# 先定义图结构
a = tf.placeholder(tf.float32)
b = tf.placeholder(tf.float32)
c = tf.constant(2.0)
z = tf.multiply(tf.add(a, b), c)

# 再创建会话执行
with tf.Session() as sess:
    result = sess.run(z, feed_dict={a: 1.0, b: 2.0})
    print(result)  # 输出 6.0

静态图的优点很明显:

  • 性能好:图结构固定,编译器可以做全局优化
  • 内存可控:提前知道所有中间结果的大小
  • 便于部署:导出的计算图就是完整的推理逻辑

但缺点也让人头疼:

  • 调试困难:你不能在中间打断点看数据
  • 灵活性差:if-else 这种控制流需要特殊处理
  • 学习成本高:新手往往搞不清 placeholder 和 session

我曾经踩过的坑:在 TensorFlow 1.x 里写了一个带条件分支的网络,结果发现静态图不支持动态的 if-else。最后只能用 tf.cond 重写,折腾了一整天。所以后来我特别关注框架对控制流的支持程度。

动态图(Define-by-Run)

边定义边执行,PyTorch 就是这种风格。代码写起来就像写普通的 Python 程序。

import torch

a = torch.tensor(1.0, requires_grad=True)
b = torch.tensor(2.0, requires_grad=True)
c = torch.tensor(2.0)

z = (a + b) * c
print(z)  # 输出 tensor(6.0, grad_fn=<MulBackward0>)

动态图的优势:

  • 直觉友好:代码执行顺序就是计算顺序
  • 调试方便:随时可以 print 中间结果
  • 灵活性强:原生支持 Python 控制流

代价呢?

  • 性能开销:每次执行都要重新构建图
  • 优化受限:编译器看不到全局结构
  • 部署复杂:需要保留完整的运行时环境
对比维度 静态图 动态图
构建时机 执行前 执行时
调试体验
执行性能
内存优化 容易 困难
控制流支持 需特殊处理 原生支持
典型框架 TensorFlow 1.x, Caffe PyTorch, TensorFlow Eager

我的建议:做研究原型用动态图,快速迭代;做产品部署用静态图,追求性能。现在很多框架支持 JIT 编译,比如 PyTorch 的 torch.jit.trace,可以先把动态图转成静态图再优化,两全其美。

2.3 计算图在编译器中的作用

好了,现在聊聊编译器视角。计算图对编译器来说意味着什么?

我个人习惯把编译器的工作分成三步:

  1. 前端解析:把模型代码转成计算图 IR
  2. 中间优化:在计算图上做各种变换
  3. 后端生成:把优化后的图映射到目标硬件

计算图就是整个编译过程的「通用语言」。没有它,编译器根本没法理解你的模型。

计算图带来的优化机会

有了计算图,编译器就能做很多「人眼看不出」的优化:

  • 算子融合:把 Conv + BN + ReLU 合并成一个算子,减少内存读写
  • 常量折叠:提前算好那些不变的子表达式
  • 死代码消除:删掉那些计算结果不会被用到的节点
  • 内存规划:复用中间张量的内存空间

举个例子,算子融合的效果:

# 优化前:三个独立算子
input ──> Conv ──> BN ──> ReLU ──> output
         (写内存)  (读/写)  (读/写)

# 优化后:融合成一个算子
input ──> FusedConvBNReLU ──> output
         (只写一次内存)

我在项目中遇到过,一个简单的 Conv-BN-ReLU 融合,推理速度能提升 30% 以上。这就是计算图的价值——让编译器看到全局,做人类手工难以完成的优化。

计算图的表示形式

编译器内部怎么存计算图?常见的有三种:

  • 邻接表:每个节点记录它的输入节点列表
  • 拓扑排序列表:按执行顺序排列节点
  • SSA 形式:静态单赋值,每个变量只赋值一次

我个人比较喜欢 SSA 形式,因为它天然适合做数据流分析。TVM 的 Relay IR 就是基于 SSA 的。

一句话总结:计算图是深度学习编译器的「中间人」。它把前端模型和后端硬件解耦,让优化变得系统化、自动化。

2.4 本章知识体系

下面这张图是我自己画的,把本章的核心逻辑串起来了:

计算图知识体系 计算图概念 节点=算子,边=数据流 静态图 vs 动态图 Define-and-Run vs Define-by-Run 编译器作用 优化与代码生成 静态图特点 • 性能高,全局优化 • 内存可提前规划 • 调试困难 动态图特点 • 灵活易调试 • 原生控制流 • 性能开销大 编译器优化技术 • 算子融合(Conv+BN+ReLU) • 常量折叠、死代码消除 • 内存规划与复用 计算图 = 深度学习编译器的核心 IR

嗯,这张图把本章的核心逻辑都串起来了。从概念出发,到两种模式的对比,再到编译器如何利用计算图做优化,一条线走下来。

一个小技巧:如果你刚开始接触计算图,建议先用 PyTorch 写几个小网络,然后用 torchviz 把计算图可视化出来。亲眼看到图的结构,比看文档理解快得多。


公众号:蓝海资料掘金营,微信deep3321