2、计算图构建:静态图与动态图的区别、TensorFlow/PyTorch中的图构建机制、图捕获与追踪
聊到神经网络编译器,绕不开的第一个坎就是计算图。说白了,计算图就是你把神经网络“画”出来的一张蓝图。这张蓝图怎么画、什么时候画,直接决定了编译器能帮你优化到什么程度。
我刚开始接触深度学习框架时,其实没太在意这个事。反正写个模型,跑就完了。直到有一次,我部署一个模型到嵌入式设备上,发现推理速度慢得离谱。排查了半天,才发现是图构建方式没选对,导致编译器根本没法做有效的内存复用和算子融合。嗯,从那以后,我再也不敢轻视计算图的构建方式了。
2.1 静态图 vs 动态图:核心差异
先看一个最直观的区别。静态图,就是你先把整个计算流程定义好,然后交给框架去执行。动态图,则是边定义边执行,像写普通程序一样。
我习惯用一个比喻来解释:静态图像是你先画好完整的施工图纸,再让工人去盖楼。动态图则是你一边盖一边改图纸,灵活但效率低。
| 对比维度 | 静态图 | 动态图 |
|---|---|---|
| 构建时机 | 执行前完成 | 执行时构建 |
| 调试难度 | 较高,无法逐行断点 | 较低,Python原生调试 |
| 优化空间 | 大,编译器可全局分析 | 小,图结构动态变化 |
| 控制流 | 需特殊算子支持 | 原生Python控制流 |
| 典型框架 | TensorFlow 1.x, JAX | PyTorch, TensorFlow Eager |
你想想看,静态图为什么优化空间大?因为编译器拿到的是完整的图结构。它可以做算子融合、内存复用、常量折叠这些操作。动态图呢?每次执行图都可能变,编译器只能做局部优化。
核心观点:静态图是“先编译后执行”,动态图是“边解释边执行”。编译器更喜欢静态图,因为能看到全局。
2.2 TensorFlow中的图构建机制
TensorFlow 1.x 是典型的静态图框架。我记得刚接触时,写个简单的加法都要先定义图,再开Session跑。代码长这样:
import tensorflow as tf
# 构建图阶段
a = tf.constant(2)
b = tf.constant(3)
c = tf.add(a, b)
# 执行图阶段
with tf.Session() as sess:
result = sess.run(c)
print(result) # 输出 5
这种模式有个问题:调试起来很痛苦。你不能在 tf.add 那行打断点,因为那行只是定义了一个节点,还没真正计算。我曾在调试一个复杂的RNN模型时,花了整整两天才定位到一个维度错误。原因就是图构建时没有报错,执行时才炸。
到了TensorFlow 2.x,引入了Eager Execution,默认变成了动态图模式。写法跟PyTorch很像了:
import tensorflow as tf
# 直接执行,不用Session
a = tf.constant(2)
b = tf.constant(3)
c = tf.add(a, b)
print(c.numpy()) # 输出 5
但TensorFlow 2.x并没有完全放弃静态图。它提供了 @tf.function 装饰器,可以把一段Python代码自动转换成静态图。这就是所谓的“图捕获”。
个人经验:我建议在模型训练时用动态图模式,方便调试。部署推理时再用 @tf.function 转成静态图,获得性能提升。别在开发阶段过早优化,得不偿失。
2.3 PyTorch中的图构建机制
PyTorch从一开始就选择了动态图路线。它的图构建是隐式的,你写的前向传播代码,就是图本身。每次执行,都会动态构建一个新的计算图。
import torch
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x * 2
z = y.sum()
z.backward() # 自动求导,基于动态构建的图
print(x.grad) # 输出 tensor([2., 2.])
PyTorch的图构建机制,核心是 autograd 引擎。每次你执行一个张量操作,它都会在后台记录一个 Function 对象,形成一个有向无环图(DAG)。这个图只在当前前向传播中有效,下一次执行会重新构建。
为什么会这样设计?说白了,PyTorch的设计哲学是“让用户感觉不到图的存在”。你写模型就像写普通的Python程序,控制流、循环、条件判断都可以直接用Python原生的。这对研究人员非常友好。
注意:PyTorch的动态图虽然灵活,但每次前向传播都重新建图,会带来额外的开销。如果你的模型结构固定不变,可以考虑用 torch.jit.trace 或 torch.jit.script 将模型转成静态图,提升推理性能。
2.4 图捕获与追踪技术
图捕获,就是把动态的Python代码“冻结”成静态的计算图。这是连接动态图灵活性和静态图性能的关键桥梁。
目前主流的图捕获方式有两种:
- Tracing(追踪):用一组示例输入跑一次模型,记录下所有执行过的操作,生成静态图。优点是简单,缺点是无法处理动态控制流。
- Scripting(脚本化):解析Python的AST(抽象语法树),将控制流也转换成图的一部分。优点是能处理动态控制流,缺点是支持的操作有限。
我在项目中遇到过一个问题:用 torch.jit.trace 追踪一个包含 if-else 的模型,结果导出的静态图只保留了 if 分支,因为示例输入只走了那个分支。部署时遇到不同的输入,直接跑错了分支。
后来我改用 torch.jit.script,虽然写代码时要注意一些限制(比如不能用某些Python特性),但至少保证了控制流的正确性。
import torch
# 用 Scripting 方式捕获带控制流的模型
@torch.jit.script
def my_model(x, use_bias: bool):
y = x * 2
if use_bias:
y = y + 1
return y
# 导出为 TorchScript
traced_model = torch.jit.script(my_model)
traced_model.save("model.pt")
TensorFlow这边,@tf.function 的图捕获机制更复杂一些。它会追踪函数的执行轨迹,遇到控制流时,会自动创建 tf.cond 或 tf.while_loop 等图内算子。但有个坑:如果函数内部使用了全局变量或Python副作用,图捕获可能会出错。
避坑指南:我曾经在 @tf.function 内部使用了一个Python列表来累积中间结果,结果发现每次调用列表都是空的。原因是在图捕获模式下,Python列表不会被保留在图中。正确的做法是用 tf.TensorArray 替代。
2.5 知识体系总览
下面这张图,是我梳理的计算图构建的核心知识脉络。你可以把它当作一个思维导图来看:
这张图把静态图、动态图和图捕获三者的关系梳理清楚了。你可以看到,无论是TensorFlow还是PyTorch,最终都在往“动态开发 + 静态优化”这个方向靠拢。说白了,框架们也在互相学习。
我个人觉得,理解计算图的构建方式,是学好神经网络编译器的第一道门槛。你不需要记住每个API的细节,但一定要明白:图是怎么来的?什么时候能优化?什么时候不能?搞清楚了这些,后面学算子融合、内存规划这些高级话题,就会轻松很多。