2、计算图基础:计算图的概念、静态图与动态图、计算图与算子融合的关系
2.1 计算图到底是什么?
说实话,我刚入行那会儿,觉得“计算图”这词挺唬人的。后来自己手写了一个简单的神经网络,才明白它就是个“流水线图纸”。
计算图,说白了就是用图结构来描述数学运算。每个节点是一个操作(比如矩阵乘法、ReLU激活),每条边是数据流动的方向(也就是张量)。你想想看,一个神经网络前向传播的过程,其实就是数据沿着这张图一路跑下去。
核心要点:计算图 = 节点(算子)+ 边(数据依赖)。它不存具体数值,只存“谁依赖谁”的关系。
举个例子,一个简单的表达式 z = (a + b) × c,它的计算图长这样:
你看,数据从 a、b 流入加法节点,结果再和 c 一起流入乘法节点,最后输出 z。这就是一张最简单的计算图。
2.2 静态图 vs 动态图:两种哲学
这里有个经典问题:静态图和动态图到底选哪个?我记得刚接触 PyTorch 时,觉得动态图太爽了,写起来跟普通 Python 代码一样。后来去优化一个推理服务,才发现静态图在性能上确实有它的优势。
| 对比维度 | 静态图(如 TensorFlow 1.x) | 动态图(如 PyTorch) |
|---|---|---|
| 构建方式 | 先定义图,再执行 | 边定义边执行 |
| 调试难度 | 较难,不能直接打断点 | 容易,跟普通代码一样 |
| 性能优化 | 容易做全局优化 | 优化空间有限 |
| 灵活性 | 低,控制流受限 | 高,支持任意 Python 逻辑 |
| 典型框架 | TensorFlow 1.x, Caffe | PyTorch, TensorFlow Eager |
我的经验:做研究、快速原型用动态图;做部署、追求极致性能用静态图。现在很多框架已经能两者兼顾了,比如 PyTorch 的 torch.jit.trace 可以把动态图转成静态图。
为什么会这样?静态图在运行前就能看到整个计算流程,可以做算子融合、内存复用等优化。动态图虽然灵活,但每次执行都要重新构建图,优化器没法提前做全局规划。
2.3 计算图与算子融合的关系
好,重点来了。算子融合和计算图到底什么关系?
我个人习惯把计算图想象成一张“菜谱”。每个步骤(算子)都写得很清楚:先切菜,再炒菜,最后装盘。算子融合就是把这些步骤合并——比如把“切菜”和“炒菜”合并成一个“切炒一体”的操作。
在深度学习里,最常见的融合是 Conv + BN + ReLU。你看这张图:
融合前,数据要经过三次读写:Conv 写中间结果、BN 读再写、ReLU 读再写。融合后,这三个操作合并成一个 kernel,中间结果直接留在寄存器里,不用来回访存。
注意:不是所有算子都能融合。融合的前提是这些算子在计算图上必须是“顺序依赖”的,中间不能有分支。我曾经踩过一个坑,把两个有数据依赖的算子强行融合,结果精度全乱了。
算子融合的本质,就是利用计算图的拓扑结构,把多个连续的、无分支的节点合并成一个。这样做的好处很明显:
- 减少访存:中间结果不用写回显存,直接在片上搞定
- 减少 kernel 启动开销:原来要启动 3 次 GPU kernel,现在 1 次搞定
- 利于编译器优化:融合后的算子可以做更多局部优化
2.4 实战中的避坑指南
我曾经在一个项目里,为了追求极致性能,把整个 ResNet 的残差块都融合成一个算子。结果呢?训练速度没提升多少,反向传播的梯度计算却变得极其复杂,调试了整整两天。
这里给大家几个建议:
- 先分析计算图的热点:用 profiling 工具看看哪些算子耗时最多,优先融合这些
- 保持反向传播的可导性:融合后的算子必须能正确计算梯度,否则训练会崩
- 注意数值精度:融合操作可能会改变中间结果的精度,尤其是混合精度训练时
- 不要过度融合:融合太狠会导致显存碎片化,反而降低性能
一句话总结:计算图是算子融合的地图。你只有把地图看懂了,才知道哪些路可以合并,哪些路必须分开走。
嗯,关于计算图的基础就聊这么多。下一节我们会深入具体的融合策略,包括横向融合、纵向融合,以及它们在 Transformer 模型中的实际应用。