第 5 讲 · 深度学习框架与自动微分 · Deep Learning Frameworks and Auto Differentiation

Large Language Model Systems · Lecture 05。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 Maps to:CMU 11-868 / 11-968 Large Language Model Systems(Spring 2026,Lei Li),Lecture 5 · Wed 1/28 · Deep Learning Frameworks and Auto Differentiation。 阅读材料:Baydin et al. (2018) Automatic Differentiation in Machine Learning: a Survey(JMLR);Blondel & Roulet (2024) The Elements of Differentiable Programming;Abadi et al. (2016) TensorFlow: A System for Large-Scale Machine Learning(OSDI);原课 HW2 说明(miniTorch:自动微分、网络、训练)。 依据说明:本讲以原课第 5 讲公开的 slides(llmsys-05-dlframework.pdf,57 页)为主线:学习问题与交叉熵、通用的迭代学习算法与梯度下降、计算图与拓扑排序、slides 的例子 f = x₁ + exp(1.5x₁ + 2x₂) 的前向与反向、扇出求和、雅可比矩阵与向量–雅可比积、“构建梯度计算图”的自动微分、有限差分检查、框架的设计原则与 PyTorch / TensorFlow / JAX / NumPy 的对比、TensorFlow 式的占位符 / 变量 / 运算 / 会话、拓扑排序与会话的实现(课上的 mini_tensorflow 练习)。前向模式与对偶数、运算次数与显存的计量、“不按拓扑序”和”沿路径递归”的反例、三个后端的张量框架、静态图与动态图的对照实验、用自己的框架训练 HW2 的网络,都是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:code/lecture05/(6 个脚本 + autodiff.py、tensor.py、mini_tf.py;每个脚本 3 秒以内) 预计阅读时间:4–5 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

学完这一讲,你应该能够:

  1. 从”通用的迭代学习算法”推出梯度下降,说清楚训练神经网络为什么归结为”对任意网络算出每个参数的梯度”。
  2. 把一个表达式画成计算图,用拓扑排序安排计算顺序,并手算反向模式的伴随(adjoint),包括”一个节点被多次使用时梯度相加”。
  3. 区分前向模式和反向模式自动微分,说出它们各自的代价(遍数、运算次数、显存),解释深度学习为什么用反向模式、为什么框架只算向量–雅可比积。
  4. 写出原课 HW2 的 topological_sort 和 backpropagate,解释为什么必须按拓扑序、为什么不能沿路径递归、为什么要用显式栈。
  5. 说出一个深度学习框架的组成:存储与视图、可替换的后端(算子库)、运算的前向 / 反向、自动微分引擎、nn 模块与优化器;区分静态图(先定义后执行)和动态图(边写边算)的取舍。
  6. 用有限差分正确地检查梯度(精度和步长怎么选)。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲只给直觉,后面再展开 Previewed only
导数与链式法则;梯度下降、交叉熵、Adam(前置讲 P3)JAX 的函数变换(grad、jit、vmap)与 XLA 编译(第 12 讲)
张量 = 存储 + 形状 + 步长(P1 §5);第 2–4 讲的四类底层运算与内核算子融合、图编译器(第 10–11 讲);torch.compile
P3 里手写过的文本分类网络的反向传播激活重算等显存优化(大模型训练的几讲)

0.3 本讲地图 Map

§1  学习问题:从"迭代地减小损失"到梯度下降;为什么需要对任意网络求梯度
§2  计算图与拓扑排序:slides 的例子                                       ← 代码 01
§3  自动微分:反向模式的伴随、扇出、雅可比矩阵与 VJP、前向 vs 反向、       ← 代码 01、02、03
    HW2 的 topological_sort 与 backpropagate、梯度检查
§4  把它们拼起来:一个深度学习框架                                         ← 代码 04、05
    框架要满足什么;分层设计;张量与后端;静态图(TensorFlow 1)vs 动态图(PyTorch)
§5  用自己的框架训练 HW2 的网络                                            ← 代码 06
§6  小结   §7  常见误区   §8  练习   §9  延伸阅读   §10 下一讲预告

1. 学习问题 · The learning problem

1.1 从文本分类网络说起

slides 又一次回到了那个影评分类网络(第 2 讲 §1):嵌入查表 → 平均 → 线性 → ReLU → 线性 → softmax。第 2–4 讲关心的是”前向怎样在 GPU 上算得快”;这一讲关心另一半:怎样训练它。

学习问题(slides 第 5 页):给定训练集 {(xn,yn)}n=1N\{(x_n, y_n)\}_{n=1}^N(x、y 都可以是向量),在网络结构固定的前提下,找到参数 θ,使网络对每个训练输入的输出尽量接近对应的 y。分类任务的损失是交叉熵:

L(θ)=1N∑n=1N−log⁡pθ(yn∣xn)\mathcal{L}(\theta) = \frac{1}{N}\sum_{n=1}^{N} -\log p_\theta(y_n \mid x_n)

slides 特意提到,PyTorch 的 CrossEntropyLoss 直接作用在 logits 上(内部把 log-softmax 和负对数似然合在一起算),而不是先算 softmax 再取对数——这是数值稳定性的考虑(P3 §2)。

然后 slides 提出了本讲的核心问题:

loss = nn.CrossEntropyLoss()
output = loss(input_logits, target_labels)
output.backward()        # backward 是怎样实现的?它为什么对任意网络都管用?

1.2 通用的迭代学习算法

slides 第 8–10 页从最一般的形式讲起:要最小化 f(x)f(x),x∈Rdx \in \mathbb{R}^d 未知。迭代地更新 xt+1←xt+Δx_{t+1} \leftarrow x_t + \Delta,使 f(xt+1)<f(xt)f(x_{t+1}) < f(x_t)。Δ 怎么选?一阶泰勒展开:

f(xt+Δx)≈f(xt)+Δx⊤∇f(xt)f(x_t + \Delta x) \approx f(x_t) + \Delta x^\top \nabla f(x_t)

要让右边第二项尽量小(负得最多),Δx 应该和梯度方向相反:Δx=−η∇f(xt)\Delta x = -\eta \nabla f(x_t),η 是学习率。这就是梯度下降。随机梯度下降(SGD)每次只用一小批数据估计梯度:

设置学习率 η,初始参数 θ ← θ₀
for epoch = 1 .. maxEpoch(或直到收敛):
    for 每一批数据:
        total_g = 0
        for 批里的每个 (x, y):
            计算误差 err(f(x; θ), y)
            计算梯度 g = ∂err/∂θ          ← 本讲要解决的就是这一行
            total_g += g
        θ = θ − η · total_g / N

整个算法里,只有”计算梯度”这一行和具体的网络有关。P3 里我们为文本分类网络手写了每一层的反向传播——几十行,而且网络一改就要重写。slides 第 11 页的问题是:怎样对一个”任意的网络”,自动算出损失对每个参数的梯度? 答案分两步:把计算表示成计算图(§2),在图上做自动微分(§3)。


2. 计算图与拓扑排序 · Computation graph

2.1 计算图

slides 第 13 页:神经网络上的计算可以用计算图定义——每个节点表示一个变量或一个运算,有向边把输入连到运算上。slides 的例子(01 号脚本第 1 部分):x1=3,x2=0.5x_1 = 3, x_2 = 0.5,

f=x1+exp⁡(1.5x1+2.0x2)f = x_1 + \exp(1.5 x_1 + 2.0 x_2)
节点定义值
v1x₁3
v2x₂0.5
v31.5 · v14.5
v42.0 · v21.0
v5v3 + v45.5
v6exp(v5)244.691932
v7v1 + v6247.691932

注意 v1 被用了两次(v3 和 v7 都以它为输入):计算图不是一棵树,而是有向无环图(DAG)。这一点在反向时至关重要。

2.2 拓扑排序:按什么顺序算

要计算(slides 第 14 页):1. 对所有节点做拓扑排序;2. 按顺序,用每个节点的输入算出它的值。slides 第 15 页给出的排序方法:把所有节点放进”未处理”队列,反复找一个没有来自未处理节点的输入边的节点,计算它,移到”已处理”队列。01 号脚本照此实现,得到 v1 → v2 → v3 → v4 → v5 → v6 → v7。

为什么这很重要?因为它让框架不需要理解整个函数——只要会算每一种基本运算,按拓扑序依次算下去,任意复杂的表达式都能求值。同样的道理在反向时更加关键。


3. 自动微分 · Auto differentiation

3.1 链式法则与伴随

slides 第 20 页:参数也是变量,也是图里的节点;求损失对参数的梯度,靠的是链式法则:

∂f(g(x))∂x=∂f∂g⋅∂g∂x\frac{\partial f(g(x))}{\partial x} = \frac{\partial f}{\partial g}\cdot\frac{\partial g}{\partial x}

定义每个节点的伴随(adjoint)vˉi=∂f/∂vi\bar v_i = \partial f / \partial v_i。从输出开始,vˉ7=1\bar v_7 = 1,一步步往回传(01 号脚本第 2 部分):

伴随怎么算值
v̄711
v̄6v̄7 · ∂v7/∂v6 = v̄7 · 11
v̄5v̄6 · ∂v6/∂v5 = v̄6 · exp(v5)244.691932
v̄4v̄5 · 1244.691932
v̄3v̄5 · 1244.691932
v̄2v̄4 · 2.0489.383865
v̄1v̄3 · 1.5 + v̄7 · 1368.037898

手算、我们的标量引擎 autodiff.py、PyTorch autograd、解析式(1 + 1.5e^5.5、2e^5.5)四者完全一致:∂f/∂x₁ = 368.0378983963,∂f/∂x₂ = 489.3838645284。

两个要点:

  • 每一步只用到一个节点的局部导数(exp 的导数是它自己,乘以常数的导数是那个常数)和它下游传来的伴随。框架里每种运算只需要实现自己的局部导数。
  • 扇出求和(slides 第 23 页):一个节点有多个使用者时,vˉi=∑j∈使用者vˉj⋅∂vj/∂vi\bar v_i = \sum_{j \in \text{使用者}} \bar v_j \cdot \partial v_j / \partial v_i。v1 有两个使用者(v3 和 v7),所以 v̄1 是两项之和;漏掉 v̄7 · 1 就会得到 367.0379——错。这意味着:一个节点的伴随,要等它所有的使用者都处理完才完整。这就是反向也要按拓扑序(倒过来)的原因。

3.2 向量的情形:雅可比矩阵与 VJP

slides 第 24–26 页把标量推广到向量。y = f(x)(x ∈ Rⁿ、y ∈ Rᵐ)的偏导数排成雅可比矩阵 J=∂y/∂xJ = \partial y/\partial x(m × n,第 i 行是 yᵢ 对所有 x 的导数)。链式法则变成

xˉ=J⊤yˉ\bar x = J^\top \bar y

叫向量–雅可比积(vector-Jacobian product,VJP)。例子:y = Wx 时 J = W,所以 xˉ=W⊤yˉ\bar x = W^\top \bar y——一次矩阵乘法,不需要把 J 写出来。02 号脚本第 4 部分验证了两种情形:y = Wx 时反向得到的 x̄ 与 Wᵀȳ 差 0;逐元素的 sigmoid 雅可比矩阵是对角的,VJP 就是 sˉ⊙σ(1−σ)\bar s \odot \sigma(1-\sigma)(差 2.8e-17)。

为什么框架从不构造雅可比矩阵?看尺寸:一个 4096 维向量的雅可比矩阵要 0.134 GB(float64);一层 Transformer 输出(2048 个 token × 4096 维 = 838 万个数)对输入的雅可比矩阵要 5.6 × 10⁵ GB,而 VJP 只需要一个 67 MB 的向量。每个运算的 backward 实现的正是它自己的 VJP——tensor.py 里每个 Function.backward 都是这样。

3.3 前向模式 vs 反向模式

链式法则可以从两头算起。前向模式和函数值一起往前算:每个中间量带着”对某一个输入的导数”。最优雅的实现是对偶数 a+bεa + b\varepsilon(ε2=0\varepsilon^2 = 0):f(a+bε)=f(a)+f′(a) b εf(a + b\varepsilon) = f(a) + f'(a)\,b\,\varepsilon,把对偶数代进函数,ε 的系数自动就是导数。02 号脚本第 1 部分:对 slides 的函数,前向模式第一遍(x₁ 的 ε = 1)得到 ∂f/∂x₁、第二遍得到 ∂f/∂x₂;反向模式一遍前向 + 一遍反向,两个都得到。

代价对比(02 号脚本第 2 部分,n 个参数的逻辑回归损失,8 个样本):

n前向模式的运算次数(n 遍)反向模式的运算次数(1 遍前向 + 1 遍反向)前向 ÷ 反向
44482242.0
164,8646088.0
6468,6082,14432.0
2561,060,8648,288128.0

一遍前向本身就和 n 成正比;反向模式的总代价约为两遍前向,前向模式要 n 遍——表里的比值正好是 n/2。一个 LLM 有几十亿个参数,前向模式完全不可行。

推广到 Rⁿ → Rᵐ(02 号脚本第 3 部分):前向模式一遍得到雅可比矩阵的一列,要 n 遍;反向模式一遍得到一行,要 m 遍。16 → 1 时反向更省,1 → 16 时前向更省。神经网络训练永远是”参数多、损失一个”,所以框架以反向模式为主;前向模式在 JAX 里也有(jvp),用于方向导数、Hessian–向量积等场合(第 12 讲)。

反向模式的代价:显存。 前向模式的导数跟着值一起走,用完就扔;反向模式要等前向全部算完才开始往回传,所以前向的中间结果(反向要用的那些)必须存下来。02 号脚本第 5 部分统计了一个 MLP(批 32、宽 64)反向需要额外保存的中间结果:每层两份 B × 宽度(矩阵乘法的输入,算权重梯度 Xᵀ·Ȳ 要用;ReLU 的输入,判断哪里放行),共 2·层数 − 1 份,随层数和批大小线性增长。这就是第 1 讲 §5.5 训练显存里”激活值”那一项的来源。推理时不求梯度,就不需要存(PyTorch 的 torch.no_grad());显存不够时,可以只存一部分、反向时重算其余的(激活重算),用时间换显存。

3.4 自动微分 = 为梯度构建计算图

slides 第 28–30 页给自动微分下的定义:不是对每个数据样本显式地往回算导数,而是为每个节点构建计算梯度的图,这张图对任何输入数据都适用。slides 第 30 页画出了 slides 例子的”梯度图”:v̄5 = v̄6 × exp(v5)、v̄1 = v̄3 × 1.5 + v̄7……每个伴随都是由乘法、加法等运算节点算出来的。

这有两种实现风格,本讲都实现了:

  • 符号式(TensorFlow 1、mini_tf.py 的 gradients):真的往图里添加计算梯度的新节点,图建好之后再执行(§4.4)。
  • 基于记录(tape-based,PyTorch、autodiff.py 和 tensor.py):前向时每做一次运算就记下”谁由谁、用哪个函数算出来”(History),反向时沿着记录直接算出梯度的数值。

两者算的是同一个东西(05 号脚本第 3 部分:50 步训练的损失与 PyTorch 差 1.1e-16)。

3.5 HW2 的核心:topological_sort 与 backpropagate

原课 HW2 Problem 1 要求实现两个函数(slides 第 31–33 页标注”对 HW2 很重要”):

def topological_sort(variable):
    """返回计算图的反向拓扑序:输出在最前,每个节点排在它的所有输入之前。
       提示:后序深度优先搜索;访问完一个节点的所有子节点后,把它放到结果的最前面。"""

def backpropagate(variable, deriv):
    """在计算图上做反向传播,把导数累加到叶子节点上。"""

它们只依赖一个很小的”变量协议”:unique_id、is_leaf()、is_constant()、parents、chain_rule(d)(返回每个输入的梯度贡献)、accumulate_derivative(d)。autodiff.py 的实现:

def backpropagate(variable, deriv):
    order = topological_sort(variable)          # 反向拓扑序
    grads = {variable.unique_id: deriv}
    for v in order:                             # 轮到 v 时,它的所有使用者都已处理完
        d = grads.pop(v.unique_id, None)
        if d is None: continue
        if v.is_leaf():
            v.accumulate_derivative(d); continue
        for p, dp in v.chain_rule(d):           # 链式法则:把梯度分给输入
            if not p.is_constant():
                grads[p.unique_id] = grads.get(p.unique_id, 0) + dp     # 扇出求和

03 号脚本检验了它,并用三个反例说明每个设计决定为什么必要:

它对任意图都对。 50 个随机生成的计算图(平均 22.8 个节点、9.7 个被多次使用的值),与中心差分的最大相对误差 3.7e-10(有限差分本身的误差)。

必须按拓扑序。 换成”从输出开始宽度优先、每个节点第一次被碰到就把手里的梯度往回传”:x₁ 作为输出的直接输入很早就被碰到,此时它只收到了来自 v7 的那一份,来自 v3 的那一份还没到——∂f/∂x₁ 被算成 1.0(正确值 368.04)。200 个随机图里 189 个(94%)出错。

不能沿路径递归。 不排序、直接沿每条路径递归往回传,结果是对的——链式法则本来就是”对所有路径求和”——但代价是路径数。每层把上一层用两次的链:

层数路径数拓扑序:chain_rule 调用次数沿路径递归:调用次数
4161245
825624765
1665,53648196,605

拓扑序让每个节点只处理一次(线性),递归会把共享的子图处理很多遍(指数)。真实网络里扇出无处不在:残差连接、注意力里同一个 x 生成 Q、K、V、参数共享。

要用显式栈。 HW2 提示的”后序深度优先”如果直接写成递归,在一条 3000 层的链上会抛 RecursionError(Python 默认递归上限约 1000 层);autodiff.topological_sort 用显式栈,正确地得到 ∂h/∂x = 1.0002³⁰⁰⁰ = 1.8220094911。真实框架(PyTorch 的 C++ autograd 引擎)也用显式的队列和依赖计数,而不是递归。

梯度是累加的。 叶子用 += 累加梯度:同一个 loss 不清零地 backward 两次,w 的梯度从 20 变成 40。所以训练循环每一步都要先清零(optimizer.zero_grad())。累加也有正当用途:显存放不下大批量时,分成几个小批依次 backward、梯度自然累加,最后再更新一次(梯度累积)。常量(数据、标签、requires_grad=False 的张量)不进入计算图,反向时直接跳过。

3.6 怎样检查梯度

slides 第 34 页:用有限差分检查梯度,

∂f(x1,x2)∂x1≈f(x1+h,x2)−f(x1−h,x2)2h\frac{\partial f(x_1, x_2)}{\partial x_1} \approx \frac{f(x_1 + h, x_2) - f(x_1 - h, x_2)}{2h}

并且注意精度:用 double(fp64),h 取 1e-6 左右,通过整个图算两次前向。01 号脚本第 3 部分说明了为什么:

hfloat64 相对误差float32 相对误差
1e-13.7e-033.7e-03
1e-23.7e-052.6e-05
1e-43.7e-091.6e-03
1e-61.5e-106.7e-02
1e-83.6e-08x ± h 在 float32 里与 x 相等

h 太大有截断误差(中心差分是 O(h²)),h 太小有舍入误差(两个几乎相等的数相减,误差约为 机器精度 × |f| / h)。两者此消彼长:float64 的机器精度约 1e-16,最好的 h 在 1e-5 附近;float32 的机器精度约 1e-7,最好的 h 在 1e-2 附近,而且误差下限高得多。有限差分每个参数要算两次前向,只能用来检查,不能用来训练。

slides 的小测是一张 sigmoid 的计算图:v7 = 1 / (1 + exp(−v1·v2))。把它拆成乘、取负、exp、加 1、取倒数五个基本运算,链起来就得到 σ(1 − σ) 的公式(01 号脚本第 4 部分:引擎算出的 ∂v7/∂v1 = −0.26684166 = σ(1−σ)·v2)。框架里不需要写”sigmoid 的导数”——但实际框架仍然会把 sigmoid 做成一个运算:一个节点比五个节点少存中间结果、少调度四次内核,数值也更稳定。基本运算的粒度是框架设计里的一个权衡。


4. 把它们拼起来:一个深度学习框架 · Putting it together

4.1 框架要满足什么

slides 第 37 页列出了深度学习框架(也包括 LLM 的框架)的目标:

  • 表达力强:能描述任意神经网络,支持将来的自定义算子和层;
  • 对机器学习工程师高效:隐藏底层细节(不用写 CUDA),自动微分(不用手推梯度);
  • 在大规模训练和推理中高效:自动扩展到数据和模型的规模,自动利用硬件加速。

slides 第 38 页的对比:

PyTorchTensorFlowJAXNumPy
主要用途深度学习深度学习数值计算与机器学习数值计算
编程范式动态(即时执行)静态(图模式,也支持即时执行)函数变换过程式
自动微分动态计算图静态计算图基于函数的 grad / jit没有
硬件CPU、GPU、TPUCPU、GPU、TPUCPU、GPU、TPU只有 CPU
易用性Pythonic有一定学习曲线Pythonic、函数式非常简单
并行DataParallel / DDPtf.distributepmap无

4.2 分层设计

本讲的 tensor.py 是一个约 800 行(一半以上是注释)的”NumPy 版 miniTorch”,结构就是一个真实框架的缩影:

 nn / optim   : Module、Parameter、Linear、Embedding、SGD、Adam            ← 写模型、训练的人
 Tensor       : 运算符重载、计算图(History)、backward()                 ← 自动微分(复用 autodiff.py 的两个函数)
 Function     : 每个运算的 forward / backward(局部导数 / VJP)
 后端 Ops     : map、zip、reduce、matmul 四类底层运算                      ← 第 2–4 讲的内核在这一层
                PythonOps(纯 Python 循环)/ NumpyOps / SimtOps(第 3 讲的模拟器内核)
 TensorData   : 一维存储 + 形状 + 步长                                      ← P1 §5

每一层只管自己的事:Function 只写局部导数;backpropagate 不知道有哪些运算;模型代码不知道后端是谁。几个细节:

  • 存储与视图(04 号脚本第 1 部分):permute 只交换形状和步长,和原张量共用存储;view 要求连续存放,对转置后的张量直接 view 会报错,要先 contiguous() 拷一份——和 PyTorch 的规则一模一样。
  • 后端可替换(04 号脚本第 2 部分):同一个运算在三个后端上,纯 Python 循环和 NumPy 在 map / zip / reduce 上逐位相同、矩阵乘法差 2.7e-15(BLAS 换了求和顺序),SIMT 模拟器后端(float32)差 1e-7 到 1e-6 量级。一个两层网络的前向,模型代码一行不改、只换后端,输出差 4.8e-08。⏱ NumPy 最快;纯 Python 慢一个数量级左右(张量只有 512 个元素);模拟器再慢一到两个数量级。真实框架的 GPU 后端,就是把这一层换成第 2–4 讲那样的 CUDA 内核——原课 HW1 和 HW2 正是这样分工的。
  • 每个运算的梯度都对(04 号脚本第 3 部分):15 个运算(含广播的加法和乘法、批量矩阵乘法、permute、view、embedding)与 PyTorch 的梯度最大差 8.9e-16。
  • 广播的反向(04 号脚本第 4 部分):偏置 b(形状 (h,))加到 (B, h) 上,前向时 b 被”复制”到每一行,相当于被用了 B 次;扇出求和,所以 b 的梯度是上游梯度沿批求和。tensor.py 在 chain_rule 里统一处理:梯度形状比输入大时自动求和缩回(_sum_to)。
  • Embedding 的反向是 scatter-add:同一个词在一批里出现几次,它那一行的梯度就加几次(np.add.at;P3 §4 讲过 G[ids] += g 的坑)。

4.3 静态图:TensorFlow 1 的设计

slides 第 39 页给出了 TensorFlow 1 的设计原则:

  • 用基本运算组成的数据流图(计算图)表示程序;
  • 延迟执行,分两个阶段:① 定义程序——搭一张带占位符的符号式数据流图;② 在可用的设备上执行(优化过的)程序。

基本组成(slides 第 40–49 页):

组成作用例子
占位符 Placeholder存放输入数据、标签,值在执行时提供x = tf.placeholder(tf.float32, (1, 784))
变量 Variable有状态的节点,值在多次执行之间保留——通常是参数W = tf.Variable(tf.random_uniform((784, 100), -1, 1))
常量 Constant固定的数据
运算 Operation每个网络层的数学运算,要定义前向和反向h = tf.nn.relu(tf.matmul(x, W) + b)
会话 Session执行环境:对节点拓扑排序,依次计算with tf.Session() as s: s.run(...)

mini_tf.py 实现了这一套(对应课上的 mini_tensorflow 练习)。05 号脚本第 1 部分用 slides 的例子:搭完 h = relu(x·W + b) 之后,图里有 6 个节点,一个数都没算;sess.run(h, {x: 数据}) 才按拓扑序算了 6 个节点;sess.run(x·W, …) 只算它依赖的 3 个;没喂 x 时报错。slides 第 52 页描述的会话实现正是这样:占位符从 feed_dict 取值,变量和常量用自己的值,运算节点取输入的值再调用 forward。

符号式求导(05 号脚本第 2 部分):逻辑回归的前向图 22 个节点;tf.gradients(loss, [W, b]) 不计算任何数值,只是按反向拓扑序对每个运算节点调用它的 gradient(),往图里添加了 39 个节点(其中 14 个是处理广播的 sum_to_shape)。会话执行这些节点,得到的梯度与 PyTorch 差 3.5e-17。GradientDescentOptimizer(lr).minimize(loss)(slides 第 48 页)在此基础上再加上”更新变量”的节点,返回一个训练步节点。

4.4 静态图 vs 动态图

05 号脚本第 3 部分用三种方式训练同一个逻辑回归(全批量梯度下降,学习率 0.5,50 步):

步静态图 mini_tf动态图 tensor.pyPyTorch
00.72872208530.72872208530.7287220853
100.27030668090.27030668090.2703066809
490.22369722730.22369722730.2236972273

数学完全相同(最大差 1.1e-16),差别在”什么时候建图、谁来执行”:静态图一共 68 个节点,只建一次,每步执行训练节点时计算其中 54 个;动态图每一步都重新建图(每步 16 个需要梯度的节点,50 步共 800 个,用完即弃)。

两种风格各有所长:

  • 动态图擅长控制流和调试。 05 号脚本第 4 部分:一个最简单的循环网络 ht+1=σ(htw+xt)h_{t+1} = \sigma(h_t w + x_t)。动态图里,循环就是 Python 的 for 循环,长度 3 / 6 / 12 自然建出 10 / 19 / 37 个节点的图,还可以在循环里随便 print;静态图要为每种长度各建一张图(50 / 98 / 194 个节点,含梯度),或者用图内的控制流算子(tf.while_loop、tf.cond),写起来像另一种语言。这是 PyTorch 在研究界流行、TensorFlow 2 改成默认即时执行的主要原因之一。
  • 静态图擅长全局优化。 拿到整张图,框架就能在执行前优化:05 号脚本第 5 部分的常量折叠(6 个运算里 3 个只依赖常量,可以预先算好)只是最简单的一种;更重要的是删掉没用的节点、把相邻的小运算融合成一个内核(第 10–11 讲)、规划显存复用、决定节点放在哪个设备上。TensorFlow 的 Grappler、XLA 编译器(第 12 讲)就是干这个的。

两条路线后来走到了一起:PyTorch 2 的 torch.compile 把动态图的 Python 代码”抓”成图再优化;TensorFlow 2 的 tf.function 和 JAX 的 jit 把函数追踪成图。“写起来像动态图、跑起来像静态图”是今天框架的共同目标。


5. 用自己的框架训练 HW2 的网络 · Training the HW2 network

5.1 自动微分 = 自动写出来的手写反向

06 号脚本第 1 部分把 P3 的文本分类网络(嵌入 16 维、隐藏层 32、2 类 softmax)用 tensor.py 只写前向,然后 loss.backward():损失与 P3 手写版本完全相同,五个参数的梯度最大差 2.1e-17。P3 为这个网络手写了 linear_backward、relu_backward、embedding_backward、平均池化的反向……一共几十行;这里一行反向代码都没写。这就是 slides 说的”对机器学习工程师高效”:网络结构可以随便改,反向自动跟着变。

5.2 HW2 的网络

原课 HW2 Problem 2–3 要求在 miniTorch 上实现:

  • Linear:二维矩阵权重 + 一维偏置;
  • Network:① 对句子长度取平均;② Linear 到 hidden_dim,ReLU,Dropout;③ Linear 到 1 维;④ sigmoid;
  • binary_cross_entropy_loss 和训练循环,在 SST-2 上达到 ≥ 75% 的验证准确率(HW2 用固定的 GloVe 词向量)。

06 号脚本第 2 部分用 tensor.py 实现了同样的网络(嵌入可训练,数据换成 P3 的影评小语料),同时用 PyTorch 写一份,复制同样的初始参数,喂同样的批次、同样的 dropout 掩码,用 Adam(学习率 0.01)训练 8 轮:

轮训练损失(tensor.py)训练损失(PyTorch)验证准确率(两边相同)
10.6692390.6692390.685
40.2626350.2626350.894
70.2185060.2185060.907
80.2192030.2192030.892

320 步里两边损失的最大差 9.0e-15——我们的框架和 PyTorch 做的是同样的计算(Adam 的公式相同、运算顺序略有不同,所以不是逐位相同)。⏱ 在这么小的网络上 tensor.py 慢 3–4 倍:张量这么小,算术几乎不花时间,差距来自每个运算在 Python 里的额外开销(建 History、包装 Tensor、分派到后端)。真实框架把这一层也用 C++ 写了——框架本身的开销在小模型、小批量时会成为瓶颈,这也是 CUDA Graphs、torch.compile 等技术要解决的问题之一。

分句型的验证准确率:single、negated、both 都是 1.000;contrast(“A 好但 B 差”)只有 0.457。和 P3 的结论一致:ReLU 隐藏层能学会否定,但平均嵌入丢掉了词序,转折句的两个从句对调之后输入完全一样,任何词袋模型都做不到。这正是第 6 讲 Transformer(注意力 + 位置编码)要解决的问题。


6. 小结 · Summary

  1. 训练 = 迭代地沿负梯度方向更新参数;唯一和网络有关的一步是算梯度。
  2. 计算图:节点是变量或运算,边是输入关系,一般是 DAG。按拓扑序计算,框架只需要会算每种基本运算。
  3. 反向模式:从输出的伴随 1 开始,每个节点用局部导数把伴随分给输入;一个节点被多次使用时梯度相加,所以必须等它的所有使用者都处理完——反向也按拓扑序。
  4. 向量情形下每个运算的反向是 VJP:x̄ = Jᵀȳ,从不构造雅可比矩阵(太大)。
  5. 前向模式一遍得到对一个输入的导数,反向模式一遍得到一个输出对所有输入的导数;参数多、损失一个,所以用反向模式。代价是要保存前向的中间结果——训练显存里的激活值。
  6. HW2 的 topological_sort(后序 DFS,最好用显式栈)+ backpropagate(按反向拓扑序累加梯度)对任意图都对;不按拓扑序会错,沿路径递归是指数级的。
  7. 梯度检查:中心差分、float64、h 约 1e-6;只用来检查。
  8. 框架 = 存储与视图 + 可替换的后端 + 运算的前向 / 反向 + 自动微分引擎 + nn / 优化器。静态图先定义后执行,便于全局优化;动态图边写边算,便于控制流和调试;今天的框架用编译(torch.compile、jit)把两者结合。

7. 常见误区 · Common pitfalls

  • “反向传播就是对每个参数手推一个公式。” 框架只知道每种基本运算的局部导数;整个网络的梯度由链式法则在图上自动拼出来。
  • “计算图是一棵树,每个节点的梯度只有一个来源。” 计算图是 DAG,扇出的节点要把所有使用者传来的梯度相加。
  • “从输出开始随便按什么顺序往回传都行。” 必须保证轮到一个节点时它的所有使用者都处理完了(反向拓扑序);宽度优先、“第一次碰到就传”会漏掉贡献。
  • “沿每条路径递归往回传,结果对就行。” 结果对,代价是路径数(指数级)。
  • “反向模式的代价和参数个数无关。” 和参数个数无关的是遍数;一遍前向本身的代价仍然随网络大小增长。反向模式真正的额外代价是存中间结果的显存。
  • “框架会算出每一层的雅可比矩阵。” 只算向量–雅可比积;一层 Transformer 的雅可比矩阵根本存不下。
  • “有限差分用 float32、h 越小越准。” h 太小会被舍入误差淹没;要用 float64、h 约 1e-6。
  • “backward 两次没关系。” 叶子的梯度是累加的,每一步都要先清零(除非有意做梯度累积)。
  • “偏置的梯度和输出梯度形状一样。” 广播过的输入,梯度要沿被广播的维度求和缩回原形状。
  • “静态图过时了,动态图全面胜出。” 两者各有所长;今天的主流是用编译把动态图的代码转成图来优化。

8. 练习 · Exercises

  1. f(x) = (x − 3)²,从 x₀ = 0 出发、学习率 η = 0.1 做一步梯度下降,x₁ 是多少?
  2. 画出 f = (x₁ + x₂) · exp(x₁) 的计算图,在 x₁ = 1、x₂ = 2 处用反向模式算出 ∂f/∂x₁ 和 ∂f/∂x₂。
  3. f = x · x · x 写成 v2 = v1 · v1、v3 = v2 · v1。在 x = 2 处用伴随算出 ∂f/∂x,并指出哪里用到了”扇出求和”。
  4. y = W x,W = [[1, 2], [3, 4]],上游梯度 ȳ = [1, −1]。x̄ 是多少?
  5. 函数 f: R¹⁰⁰⁰ → R 和 g: R → R¹⁰⁰⁰,求完整的雅可比矩阵,前向模式和反向模式各要几遍?
  6. 用 slides 的例子解释:为什么”从输出开始宽度优先、第一次碰到就往回传”会把 ∂f/∂x₁ 算成 1?
  7. 中心差分的误差约为 C·h² + ε·|f| / h(ε 是机器精度)。估算 float64(ε ≈ 1.1e-16)和 float32(ε ≈ 6e-8)下最好的 h 的量级(取 C、|f| 为 1 的量级),与 01 号脚本的表对照。
  8. x 的形状 (3, 2),b 的形状 (2,),y = x + b,上游梯度 G = [[1, 2], [3, 4], [5, 6]]。b 的梯度是多少?
  9. 一个 10 层、宽 4096 的 MLP(每层 Linear + ReLU),批大小 512,float32。按 02 号脚本第 5 部分的规律,反向要额外保存多少字节的中间结果?
  10. 下面两种场合,你更倾向静态图还是动态图?为什么?(a) 研究一个每个样本循环次数不同的新模型;(b) 把一个训练好的固定模型部署到手机上。
  11. 05 号脚本里,mini_tf 的整张图有 68 个节点,执行训练步时只计算了 54 个。没被计算的 14 个是什么?
  12. Embedding 的输入下标 ids = [[3, 1, 3]],上游梯度的三行分别是 g₀、g₁、g₂。嵌入矩阵哪几行的梯度不为 0?各是多少?

参考答案

  1. f’(x) = 2(x − 3),f’(0) = −6,x₁ = 0 − 0.1 × (−6) = 0.6。
  2. 节点:v1 = x₁ = 1,v2 = x₂ = 2,v3 = v1 + v2 = 3,v4 = exp(v1) = e,v5 = v3 · v4 = 3e。伴随:v̄5 = 1;v̄3 = v4 = e,v̄4 = v3 = 3;v̄2 = v̄3 = e ≈ 2.718;v̄1 = v̄3 · 1 + v̄4 · exp(v1) = e + 3e = 4e ≈ 10.873(v1 有两个使用者:v3 和 v4)。
  3. v1 = 2,v2 = 4,v3 = 8。v̄3 = 1;v̄2 = v̄3 · v1 = 2;v̄1 = v̄3 · v2(来自 v3 的第二个输入)+ v̄2 · v1(来自 v2 的第一个输入)+ v̄2 · v1(来自 v2 的第二个输入)= 4 + 4 + 4 = 12 = 3x²。v1 被用了三次(v2 用两次、v3 用一次),三份贡献相加。
  4. x̄ = Wᵀȳ = [[1, 3], [2, 4]] · [1, −1] = [−2, −2]。
  5. f:前向 1000 遍,反向 1 遍(前向 1 遍 + 反向 1 遍)。g:前向 1 遍,反向 1000 遍(前向 1 遍 + 反向 1000 遍)。
  6. 输出 v7 = v1 + v6,宽度优先时 v1 作为 v7 的直接输入在第二层就被碰到,此时它只收到 v̄7 · 1 = 1;它的另一份梯度要经 v6 → v5 → v3 才传过来,到达时 v1 已经”处理过了”,被丢掉。所以得到 1 而不是 1 + 1.5e^5.5。
  7. 对 h 求导令其为 0:h ≈ (ε / 2C)^{1/3} ~ ε^{1/3}。float64:(1.1e-16)^{1/3} ≈ 5e-6;float32:(6e-8)^{1/3} ≈ 4e-3。与表一致:float64 在 1e-6 附近最好,float32 在 1e-2 附近最好。
  8. b 的梯度 = G 沿第 0 维求和 = [9, 12];x 的梯度 = G。
  9. 每层两份 B × 宽度,减去输入本身一份:(2 × 10 − 1) × 512 × 4096 × 4 字节 = 19 × 8,388,608 ≈ 159 MB(≈ 152 MiB)。
  10. (a) 动态图:控制流就是普通的 Python 循环,每个样本的图可以不同,调试方便。(b) 静态图(或把动态图编译 / 导出成图):结构固定,可以事先做常量折叠、算子融合、显存规划,部署时不需要 Python 解释器。
  11. loss 本身的两个节点(乘 1/n、取负):训练步只需要梯度,而梯度从常数 1 开始往回传,不需要 loss 的数值;另外 12 个是对占位符 x、y 和常量求导时由各运算的 gradient() 顺手建出来、但没有任何节点使用的梯度节点(例如对 x 的 matmul 梯度)。会话只计算训练步依赖的节点,所以它们不会被执行。
  12. 第 3 行和第 1 行:第 3 行的梯度 = g₀ + g₂(这个词出现了两次),第 1 行 = g₁,其余行为 0。

9. 延伸阅读 · Further reading

  • Baydin, Pearlmutter, Radul & Siskind (2018) Automatic Differentiation in Machine Learning: a Survey(JMLR):前向 / 反向模式、对偶数、实现方式(运算符重载 vs 源码变换)的全面综述。
  • Blondel & Roulet (2024) The Elements of Differentiable Programming(arXiv 2403.14606):从可微编程的角度系统讲计算图、JVP / VJP、反向模式的显存与检查点。
  • Abadi et al. (2016) TensorFlow: A System for Large-Scale Machine Learning(OSDI):数据流图、延迟执行、设备放置的设计动机。
  • Paszke et al. (2019) PyTorch: An Imperative Style, High-Performance Deep Learning Library(NeurIPS):动态图(即时执行)为什么能同时做到好用和高效,以及 C++ autograd 引擎、缓存分配器的设计。
  • 原课 HW2 与 miniTorch(https://minitorch.github.io/ ):本讲 autodiff.py / tensor.py 的原型——Sasha Rush 为教学写的迷你框架,模块划分与这里一致。
  • Ansel et al. (2024) PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation(ASPLOS):torch.compile 怎样把动态图”抓”成图。

10. 下一讲预告 · Next lecture

第 1–5 讲搭好了底座:GPU 怎样执行、内核怎样写快、框架怎样自动求导。第 6 讲进入第二单元——Transformer。§5 的实验留下了一个问题:平均嵌入丢掉了词序,转折句只能猜。原课第 6 讲从这里出发:注意力机制怎样让每个 token 按内容去”看”其他 token,多头注意力、位置编码、残差连接、LayerNorm、前馈层各自解决什么问题,编码器–解码器和只有解码器的结构有什么区别(第 1 讲 §3.4 的三类语言模型)。对应原课 HW3:在 miniTorch 上实现多头注意力、Transformer 层和 Decoder-only 语言模型。我们会用本讲的 tensor.py 把它们一块块搭起来,并逐层与 PyTorch 对照。

← 返回课程目录