如果只用一句话概括 PyTorch / TensorFlow 的本质:它们是在张量计算之上,自动构建计算图,并用链式法则自动求梯度的系统。训练神经网络看起来是调用 loss.backward() 和 optimizer.step(),但底层真正发生的是:前向阶段记录依赖关系,反向阶段沿图逆序传播梯度,同时在显存、计算量和调度开销之间做工程权衡。
这篇文章对应 Week 1 的学习目标,只聚焦 CMU 10-414 中最核心的四块:Computation Graph、Backpropagation、Automatic Differentiation、Memory Optimization。最后我会写一个只支持 matmul、relu、softmax、cross_entropy 的 mini autograd,用最少代码把深度学习框架的骨架讲清楚。
1. 为什么先学 Autograd
深度学习框架表面上提供了很多能力:GPU 张量、神经网络层、优化器、数据加载、分布式训练、模型导出。但如果把这些能力拆到底,核心闭环只有四步:
1 | 参数 W 初始化 |
其中最重要的是 backward。因为模型训练不是手写每个参数的求导公式,而是让框架自动求导。例如一个两层 MLP:
1 | logits = relu(x @ w1) @ w2 |
框架需要知道:
logits是怎么由x、w1、w2算出来的;loss对logits的梯度是多少;relu、matmul、cross_entropy各自的局部导数是什么;- 多条路径汇合时,梯度如何累加;
- 哪些中间激活要保存,哪些可以反向时重新计算。
所以学 Autograd,本质是在学 PyTorch / TensorFlow 的“训练引擎”。
2. Computation Graph:计算图是什么
计算图是一个有向无环图。节点代表数据或操作,边代表依赖关系。
以这段代码为例:
1 | z = x @ w |
可以拆成:
1 | x ----\ |
这里有两类对象:
- Tensor / Value 节点:保存真实数据,例如
x、w、z、h、loss。 - Op / Function 节点:保存操作规则,例如
matmul、relu、cross_entropy。
动态图框架 PyTorch 通常在 Python 执行前向代码时即时建图。你写 z = x @ w,框架马上生成一个新 Tensor,并让这个 Tensor 记住:它来自 x、w,创建它的操作是 matmul。
静态图框架早期 TensorFlow 1.x 则先定义图,再统一编译执行。静态图更利于全局优化,动态图更符合 Python 调试习惯。今天 PyTorch 2.x 通过 torch.compile 又把动态图捕获为可优化图,本质上是在易用性和编译优化之间折中。
3. Forward Graph:前向图保存什么
前向阶段不只是算数值,还要为反向传播留线索。一个 Tensor 通常至少需要这些字段:
1 | class Tensor: |
例如:
1 | z = x @ w |
会创建一个新 Tensor z。它的 parents 是 (x, w),它的 backward_fn 会记录矩阵乘法的导数规则:
1 | z = x @ w |
这里的“上游梯度”就是 loss 对 z 的梯度,通常写作 dL/dz。反向函数负责把 dL/dz 变成 dL/dx 和 dL/dw。
4. Backpropagation:反向传播的本质
反向传播不是神秘算法,它就是链式法则在计算图上的系统化应用。
假设:
1 | x -> z -> h -> loss |
那么:
1 | dloss/dx = dloss/dh * dh/dz * dz/dx |
如果图里有分叉和汇合,例如:
1 | -> a -> |
那么 x 的梯度需要把所有路径的贡献加起来:
1 | dloss/dx = dloss/da * da/dx + dloss/db * db/dx |
这就是 Autograd 里 grad += contribution 的原因。一个参数可能被多次使用,或者一个中间结果可能影响多个后续节点,梯度天然需要累加。
反向传播的执行顺序必须是拓扑逆序:先算离 loss 最近的节点,再算更早的节点。因为某个节点只有收齐所有下游贡献,才能继续把梯度传给它的父节点。
5. Automatic Differentiation:自动微分不是数值微分
常见的求导方法有三种:
| 方法 | 思路 | 问题 |
|---|---|---|
| 手动求导 | 人写每层公式 | 模型一复杂就不可维护 |
| 数值微分 | 用 (f(x+eps)-f(x))/eps 近似 |
慢且有数值误差 |
| 自动微分 | 把程序拆成基本算子并套链式法则 | 框架主流方案 |
自动微分不是符号求导。它不会把整个程序化简成一个数学表达式,而是在程序实际执行时记录每一步基本操作,然后对每个基本操作调用已知的局部反向规则。
自动微分主要有两种模式:
- Forward-mode AD:从输入往输出推导导数,适合输入维度小、输出维度大的场景。
- Reverse-mode AD:从输出往输入反传梯度,适合深度学习,因为 loss 通常是一个标量,而参数量巨大。
神经网络训练几乎都用 reverse-mode AD。一次 backward 就能得到所有参数对同一个标量 loss 的梯度。
6. 四个核心算子的反向公式
下面是 mini autograd 要支持的四个算子。
6.1 MatMul
前向:
1 | C = A @ B |
形状:
1 | A: [N, D] |
反向:
1 | dA = dC @ B.T |
直觉:矩阵乘法把 A 的每一行和 B 的每一列做内积。上游梯度 dC 告诉我们每个输出元素对 loss 的影响,再乘回另一个输入,就得到当前输入的梯度。
6.2 ReLU
前向:
1 | relu(x) = max(x, 0) |
反向:
1 | dx = dout * (x > 0) |
如果前向时 x <= 0,ReLU 输出被截断为 0,局部导数为 0;如果 x > 0,ReLU 是恒等映射,局部导数为 1。
6.3 Softmax
前向:
1 | softmax(x_i) = exp(x_i) / sum_j exp(x_j) |
实际实现必须做数值稳定:
1 | shifted = x - max(x) |
如果不减最大值,exp(1000) 很容易溢出。
Softmax 的完整 Jacobian 是:
1 | ∂s_i/∂x_j = s_i * (1(i=j) - s_j) |
在代码里我们通常不显式构造 [C, C] 的 Jacobian,而是直接写向量-Jacobian 乘积:
1 | dx = s * (dout - sum(dout * s)) |
6.4 Cross Entropy
对于分类任务,标签 y 是类别 id,预测 p 是 softmax 概率:
1 | loss = -log(p_y) |
如果 batch size 是 N:
1 | loss = mean_i -log(p[i, y_i]) |
对 softmax 概率的梯度:
1 | dp[i, y_i] = -1 / p[i, y_i] / N |
工程里通常会把 softmax + cross_entropy 融合成一个更稳定的 cross_entropy_with_logits:
1 | dlogits = (softmax(logits) - one_hot(y)) / N |
这也是 PyTorch 中 torch.nn.CrossEntropyLoss 接收 logits 而不是 softmax 后概率的原因。
7. Mini Autograd 完整代码
这个实现只依赖 NumPy,代码目标不是功能完整,而是把 autograd 的核心结构讲清楚。
1 | import numpy as np |
8. 每个结构体和函数细讲
8.1 Tensor.data
data 是真实数值。这里用 np.ndarray 保存。真实框架里,Tensor 不只保存数据,还要保存设备、dtype、stride、storage、layout 等信息。例如 PyTorch Tensor 可能在 CPU 或 CUDA 上,可能是 float32、float16、bfloat16,也可能是非连续内存视图。
8.2 Tensor.requires_grad
requires_grad 表示是否需要追踪梯度。输入数据通常不需要梯度,模型参数需要梯度。
1 | x = Tensor([[1, 2]], requires_grad=False) |
如果一个操作的任意父节点需要梯度,那么输出也需要梯度:
1 | requires_grad=self.requires_grad or other.requires_grad |
这就是梯度追踪在图上传播的方式。
8.3 Tensor.grad
grad 保存当前 Tensor 的梯度,也就是 dLoss/dTensor。注意它不是局部导数,而是最终 loss 对这个 Tensor 的总导数。
为什么初始是 None 而不是 0?因为这样可以区分“还没算过梯度”和“梯度确实为 0”。真实训练中每个 step 前都要清空梯度,否则梯度会跨 batch 累加。
1 | w.zero_grad() |
PyTorch 也是默认累加梯度,所以训练循环里必须写 optimizer.zero_grad()。
8.4 Tensor.parents
parents 保存当前 Tensor 的输入依赖。例如 z = x @ w,那么 z.parents = (x, w)。反向传播构建拓扑排序时要沿着 parents 一直追溯到叶子节点。
叶子节点一般是用户直接创建的 Tensor,例如输入和参数。中间节点是由操作产生的 Tensor。
8.5 Tensor.op
op 只是为了调试展示。真实框架里会有更复杂的 grad_fn 或 Function 对象,里面保存算子类型、反向规则、上下文缓存等。
例如 PyTorch 里:
1 | z = x @ w |
你会看到类似 MmBackward 的对象。
8.6 Tensor._backward
_backward 是 mini autograd 的核心。每个操作在前向时创建输出 Tensor,并给输出 Tensor 塞一个闭包函数。这个闭包知道:
- 当前操作的输入是谁;
- 当前操作的输出是谁;
- 输出的梯度
out.grad如何转成输入的梯度。
例如 matmul:
1 | def _backward(): |
闭包会捕获 self、other、out。这就是动态图框架非常自然的地方:Python 执行到哪里,反向函数就记录到哪里。
8.7 ensure_tensor
ensure_tensor 负责把普通数字或数组包装成 Tensor。这样以后可以支持:
1 | x @ np_array |
真实框架中类似逻辑会更复杂,因为要处理 dtype promotion、device 对齐、广播规则等。
8.8 backward
backward 做三件事:
- 如果当前 Tensor 是标量 loss,则默认上游梯度为 1;
- 从 loss 出发 DFS 构建拓扑序;
- 逆拓扑序调用每个 Tensor 的
_backward。
关键代码:
1 | for tensor in reversed(topo): |
为什么要反过来?因为 topo 是从叶子到 loss 的顺序,反向传播必须从 loss 回到叶子。
8.9 _accumulate_grad
梯度必须累加:
1 | self.grad = self.grad + grad |
如果一个 Tensor 影响 loss 的路径不止一条,每条路径都会贡献一部分梯度。Autograd 的正确性依赖累加,而不是覆盖。
9. 跑一个最小训练例子
下面构造一个两层分类模型:
1 | np.random.seed(0) |
完整过程是:
1 | x @ w1 |
这个例子虽然小,但已经包含了深度学习训练最重要的机制。
10. 梯度流:为什么会消失或爆炸
梯度流指梯度从 loss 往前层传播的过程。每经过一个操作,梯度都会乘上局部导数。
如果很多局部导数小于 1,梯度会越来越小,形成梯度消失;如果很多局部导数大于 1,梯度会越来越大,形成梯度爆炸。
1 | dL/dx = dL/dh_n * dh_n/dh_{n-1} * ... * dh_1/dx |
这解释了很多网络设计:
- ReLU 比 sigmoid 更常用,因为正区间导数是 1,更利于梯度通过;
- ResNet 用残差连接,让梯度可以沿 identity path 直接传播;
- LayerNorm / BatchNorm 缓解激活分布漂移;
- 合理初始化让前向激活和反向梯度保持稳定尺度;
- 梯度裁剪可以防止 RNN / Transformer 中的梯度爆炸。
理解梯度流之后,很多训练技巧不再是经验魔法,而是为了让链式法则的乘积更稳定。
11. Memory Optimization:显存到底花在哪里
训练时显存主要来自:
- 参数:模型权重;
- 梯度:每个参数对应一份梯度;
- 优化器状态:Adam 会保存一阶、二阶动量,通常是参数量的 2 倍;
- 激活值:前向中间结果,反向需要用;
- 临时 buffer:算子执行过程中的 workspace。
很多人以为显存主要被参数占用,但在大 batch、长序列、深网络里,激活值经常非常可观。因为反向传播需要前向时的中间结果。例如 ReLU backward 要知道前向输入是否大于 0,matmul backward 要知道另一个输入矩阵。
12. Activation Checkpoint:用计算换显存
Activation checkpoint 的核心思想:不要保存所有中间激活,只保存少量 checkpoint;反向传播时,把缺失的中间激活重新算一遍。
普通训练:
1 | forward: 保存 a1, a2, a3, a4 |
Checkpoint:
1 | forward: 只保存 a0, a4 |
代价很清楚:
| 方案 | 显存 | 计算 |
|---|---|---|
| 保存所有激活 | 高 | 低 |
| Activation checkpoint | 低 | 高 |
这就是 memory vs compute tradeoff。训练大模型时,如果显存是瓶颈,宁愿多算一点,也要把 batch size、sequence length 或模型规模撑起来。
PyTorch 中常用:
1 | from torch.utils.checkpoint import checkpoint |
使用 checkpoint 时要注意:
- 被 checkpoint 的函数最好是纯函数,不要依赖会变化的外部状态;
- dropout 等随机操作需要正确处理 RNG 状态;
- 不是所有层都值得 checkpoint,通常选显存占用大的 block;
- checkpoint 会增加训练时间,不是免费优化。
13. PyTorch 和 TensorFlow 本质上做了什么
当你写 PyTorch:
1 | loss = model(x).softmax(dim=-1).log().mean() |
框架做的事情和 mini autograd 没有本质区别,只是工程复杂度高很多:
- Tensor 支持 CPU / GPU / 多种 dtype;
- 算子调用高性能 kernel,例如 cuBLAS、cuDNN、Triton;
- Autograd engine 处理拓扑调度、并行反传、线程安全;
- View / inplace / broadcasting 有复杂的梯度语义;
- 编译器会做图捕获、融合、常量折叠、内存规划;
- 分布式训练会插入通信算子,例如 all-reduce;
- 混合精度会处理 loss scaling 和 fp16/bf16 数值稳定。
但心智模型仍然是:
1 | Tensor + Op + Graph + Chain Rule + Scheduler + Memory Planner |
14. Mini Autograd 的限制
这个 mini 版本故意省略了很多真实框架能力:
- 不支持 broadcasting 的梯度反推;
- 不支持 view、reshape、transpose 的复杂共享内存语义;
- 不支持 inplace 修改检测;
- 不支持 GPU;
- 不支持高阶梯度;
- 不支持动态图释放和显存复用;
softmax和cross_entropy分开实现,数值稳定性不如融合版。
但它已经完整覆盖了深度学习框架最核心的训练闭环。
15. Week 1 学完应该掌握什么
学完这一周,不要求背公式,而是要能解释清楚这些问题:
- 前向计算时,框架为什么要记录计算图?
loss.backward()为什么要按拓扑逆序执行?- 多条路径指向同一个 Tensor 时,梯度为什么要累加?
matmul、relu、softmax、cross_entropy的反向公式是什么?- 为什么深度学习训练主要用 reverse-mode AD?
- 为什么训练比推理更耗显存?
- Activation checkpoint 为什么能省显存,代价是什么?
- PyTorch 的动态图和 TensorFlow 的静态图各有什么取舍?
如果这些问题都能讲顺,基本就理解了深度学习框架的本质。
16. 最后总结
Autograd 的核心非常朴素:前向时记录图,反向时套链式法则。复杂的是工程化:如何让它在 GPU 上快、在大模型上省显存、在动态图里易调试、在编译图里可优化。
Mini autograd 的意义不是替代 PyTorch,而是帮我们建立底层直觉。只要理解了 Tensor 如何保存 parents 和 _backward,理解了拓扑逆序和梯度累加,再看 PyTorch、TensorFlow、JAX 的设计,就不会只停留在 API 层,而能真正理解它们为什么这样工作。
补充:完整可运行代码已放在 /downloads/code/mini_autograd.py,可以直接下载运行。