"反向传播"这个名字在深度学习里被用得太久,很多人已经意识不到它其实是一个更大学科的特例:自动微分(automatic differentiation, AD)。AD 有两种基本模式——前向(forward mode)与反向(reverse mode),它们的差别不是实现细节,而是乘法结合顺序的差别,进而决定复杂度按输入维数还是输出维数计费。深度学习的损失是标量、参数以亿计,这个极端不对称的形状让答案毫无悬念。这篇把这笔账算清楚,顺带解释 PyTorch 里 tape、grad_fn 这些概念对应什么。
自动微分不是数值微分,也不是符号微分
先划清边界。数值微分用有限差分 $(f(x+h)-f(x-h))/2h$ 逼近导数,精度受截断与舍入误差夹击,且每个输入维度要两次求值——只配做梯度检验,不配做训练。符号微分(SymPy、Mathematica 那一套)把表达式树展开成闭式再求导,遇到几千行的程序(循环、条件、分支)直接爆炸。
自动微分的定位在两者之间:它把程序拆成初等运算的计算图,每个节点(加、乘、sin、矩阵乘)的局部雅可比已知,然后用链式法则把这些局部雅可比乘起来。结果在浮点精度内是精确的(不是逼近),代价与前向求值同阶。所有深度学习框架的 autograd 都是这个思路。
前向模式:带着种子向量向右走
前向模式给每个中间变量 $v$ 配一个"切向" $\dot v=\frac{\partial v}{\partial x_j}$(对某个选定的输入分量 $x_j$ 的偏导),与求值同步前推。每个初等运算的切向更新规则就是该运算的导数规则,例如:
$$ u=a+b:\ \dot u=\dot a+\dot b;\qquad u=ab:\ \dot u=\dot a\,b+a\,\dot b;\qquad u=\sin a:\ \dot u=\cos a\cdot\dot a. $$
一次前向模式扫描(称为一个 forward pass,种子取 $\dot x_j=1$ 其余为零)算出的是雅可比的一列——所有输出对第 $j$ 个输入的偏导。对 $f:\mathbb{R}^n\to\mathbb{R}^m$,要拿全雅可比就得跑 $n$ 次:
$$ \text{cost}_{\text{fwd}}=O\big(n\cdot\text{cost}(f)\big). $$
前向模式有个优雅的实现技巧:对偶数(dual numbers)。给每个数配一个无穷小分量 $a+b\varepsilon$($\varepsilon^2=0$),初等运算在对偶数上照常定义,算完 $f$ 后 $\varepsilon$ 的系数自动就是导数。JAX 的 jax.jacfwd 底层就是这个结构。实现虽美,复杂度公式不会因实现技巧改变:按列计费,输入多少维就扫多少遍。
反向模式:从标量损失倒着走
反向模式分两遍。第一遍正常前向求值,同时把每个中间变量的值和算子记录在磁带(tape,也叫 Wengert list)上;第二遍从输出端出发,给每个中间变量 $v$ 维护伴随量 $\bar v=\partial\ell/\partial v$,按磁带逆序回推。每个节点做的是上一篇讲的 VJP:设节点 $u=\phi(v_1,\dots,v_k)$,则
$$ \bar v_i \;\mathrel{+}= \; \left(\frac{\partial u}{\partial v_i}\right)^\top \bar u . $$
反向扫完一遍磁带,得到的是雅可比(这里是梯度,因为输出是标量)的一整行——全部输入对这一个输出的偏导。对 $f:\mathbb{R}^n\to\mathbb{R}^m$,拿全雅可比要跑 $m$ 次反向:
$$ \text{cost}_{\text{rev}}=O\big(m\cdot\text{cost}(f)\big). $$
两种模式的复杂度公式摆在一起,选择就是纯粹的算术:训练时 $n$(参数量)$=10^9$、$m$(损失个数)$=1$。前向模式要跑十亿次前向,反向模式只跑一次。这不是"快几倍"的优化,是"可行与不可行"的分界线。
这个不对称还有理论保证撑腰:Baur–Strassen 定理(1983)证明,任意标量函数的全部偏导可以在不超过单次求值约 3–5 倍算术操作内算完。反向传播就是这个定理的构造性实现。换句话说,梯度几乎和前向一样便宜是数学事实,不是工程巧合。
一个三层小例子的完整 tape
抽象完了看个具体的。设 $\ell=\sin(x_1 x_2)+x_3$,输入 $x_1=2,x_2=3,x_3=1$。前向时磁带记录:
v1 = x1 * x2 = 6 # 局部导数: ∂v1/∂x1 = x2, ∂v1/∂x2 = x1
v2 = sin(v1) = sin(6) # 局部导数: ∂v2/∂v1 = cos(v1)
v3 = v2 + x3 = sin(6) + 1 # 局部导数: ∂v3/∂v2 = 1, ∂v3/∂x3 = 1
反向时从 $\bar v_3=1$ 出发沿磁带逆序累加:
$$ \bar v_2=1\cdot 1=1,\quad \bar x_3=1,\quad \bar v_1=\cos(6)\cdot 1,\quad \bar x_1=\cos(6)\cdot x_2=3\cos6,\quad \bar x_2=\cos(6)\cdot x_1=2\cos6. $$
对照解析梯度 $\nabla\ell=(x_2\cos(x_1x_2),\,x_1\cos(x_1x_2),\,1)$,逐项吻合。这个例子里每一步都是标量 VJP,把 $\cos v_1$、$x_2$ 这类局部导数换成矩阵版本的 $W^\top$、$\sigma'\odot$,就是神经网络的反向传播——机制一模一样,只是节点的"局部导数规则"升级成了矩阵版本。
用数字感受一下:一个 MLP 的两种模式代价
把抽象公式落成数字。一个 4 层 MLP,隐藏维 4096,参数量约 $5\times10^7$,batch 内一次前向 FLOPs 约 $2\times\text{batch}\times5\times10^7$。损失是标量。
| 前向模式 | 反向模式 | |
|---|---|---|
| 拿全梯度所需扫描次数 | $n=5\times10^7$ 次 | $m=1$ 次 |
| 单次扫描的常数开销 | $\approx 1\times$ 前向 | $\approx 2\sim3\times$ 前向 |
| 总代价估算 | $5\times10^7$ 次前向 | $3$ 次前向当量 |
| 中间量存储 | $O(1)$(可滚动覆盖) | $O(L)$(磁带) |
五千万倍对三倍——这个对比值得贴在工位上。反向模式多付的 2~3 倍常数来自每个节点要同时算局部导数并做累加(矩阵乘层是 2 倍:一次 GEMM 前向对应反向两次 GEMM,注意力那篇的 FLOPs 账就是这么来的),但跟 $n/m$ 的结构性差距比,常数完全可以忽略。
混合模式与检查点的定量公式
纯前向、纯反向只是两个极端,工程上还有混合策略。最常用的是梯度检查点:把 $L$ 层分成 $\sqrt L$ 段,只缓存每段边界的激活,反向时进入某段先从边界重算该段前向。算笔账:存储从 $L$ 份激活降到 $2\sqrt L$ 份(边界 $\sqrt L$ 份 + 段内重算时临时 $\sqrt L$ 份),最优分段数由 $\min_k(k+L/k)$ 给出,$k=\sqrt L$;额外计算恰好是约一次全量前向(每段重算一遍),即训练一步从"前向 + 2×前向"涨到"前向 + 3×前向",多花约 33%。Megatron-LM 的 --recompute-granularity full 就是这个方案,7B 以上模型训练几乎必开。
另一个角落里的混合是海森-向量积(HVP):先反向得到梯度 $\bar v=J^\top e$($e$ 为单位向量),再对"梯度与向量 $u$ 的内积"做一次前向模式,得到 $Hu$。$O(\text{cost})$ 拿到海森的一列而不实例化海森,二阶优化分析和sharpness 度量里全靠它,torch.autograd.functional.hvp 直接可用。
tape 的代价:内存才是反向模式的账单
反向模式近乎免费的只有 FLOPs,内存并不免费:第二遍回推要用第一遍缓存的中间值(上面例子里 $\cos(v_1)$ 需要 $v_1$)。$L$ 层网络的激活存储是 $O(L)$,对几十层的 Transformer 来说激活显存远超参数显存——上一节的检查点公式正是为这条边界准备的。除了重计算,另一条路线是 reversible 架构(RevNet):让每层激活可从下一层反解出来,理论存储 $O(1)$,代价是架构受约束。这些都是"前向便宜、存储贵"这个不对称性的直接后果。
PyTorch 里这套结构的痕迹随处可见:每个 requires_grad=True 的张量挂着一个 grad_fn(就是磁带上的节点类型),loss.backward() 触发沿 tape 的逆序遍历,torch.no_grad() 就是停止录带。知道了反向模式的内存模型,"detach() 为什么能省显存"、"为什么 inference 要包 no_grad"这类问题全部有了统一答案。
前向模式亲手实现一次:对偶数二十行
前向模式在深度学习里用得少,但实现对偶数只需要一个类,写完对它的理解就落到实处了:
class Dual:
def __init__(self, val, der=0.0):
self.val, self.der = val, der
def __add__(self, o):
o = o if isinstance(o, Dual) else Dual(o)
return Dual(self.val + o.val, self.der + o.der)
__radd__ = __add__
def __mul__(self, o):
o = o if isinstance(o, Dual) else Dual(o)
return Dual(self.val * o.val, self.der * o.val + self.val * o.der)
__rmul__ = __mul__
import math
def sin(d): return Dual(math.sin(d.val), math.cos(d.val) * d.der)
def f(x1, x2, x3): return sin(x1 * x2) + x3 # 上一节的例子
r = f(Dual(2, 1), Dual(3, 0), Dual(1, 0)) # 种子: ∂/∂x1
print(r.val, r.der) # sin(6)+1, 3·cos(6) —— 与解析解一致
每次调用算的是一列雅可比:换一个输入方向就要整图重跑一遍。这就是 $O(n\cdot\text{cost})$ 的具体形态——代码里没有磁带、没有回传,只有切向随前向同步传播。JAX 的 jax.jvp 是同一机制的向量化版本;而 jax.jacrev/jacfwd 的命名直接暴露了两种模式各自"按行/按列组装雅可比"的本质。
什么时候反而该用前向模式
反向模式不是普适最优。当 $n\ll m$——输入少、输出多——前向模式按 $n$ 计费反而便宜,典型场景是少量设计参数驱动的大规模物理仿真、以及计算海森-向量积(forward-over-reverse 的组合技巧)。深度学习内部也有角落用到前向模式:超梯度(hypergradient,对学习率等超参求导)里 $n$ 是几个超参数、$m$ 是全部模型参数,JAX 用户用 jax.jacfwd 做这件事正是按形状选模式的例子。工具无优劣,复杂度公式说了算。
回到主线:标量损失 + 海量参数的形状,让反向传播成为深度学习唯一的可行解,这个选择是结构性而非历史偶然。理解了 VJP 链与 tape,下一篇就可以用这套语言干净地推 softmax + 交叉熵的著名梯度 $p-y$。
顺带一提框架层面的分歧:PyTorch 是动态磁带(define-by-run,每次前向重新录带,控制流随意写),经典 TensorFlow 1.x 是静态图(先建图后执行,磁带在编译期固定)。两者数学上是同一个 reverse mode,差别只在磁带何时建立。JAX 走第三条路:jax.grad 对纯函数先线性化(linearize)出前向轨迹再转置(transpose)出 VJP 链,把"录带"变成了函数变换。理解了 tape 与 VJP,三个框架的 autograd 文档读起来只是同一概念的三套术语。