📑 本页目录(点开跳转)
02 · autograd:图是什么时候建的,又是什么时候没的
⏱ 80 分钟 | ⭐ 实测:第二次 backward() 直接报 "Trying to backward through the graph a second time" —— 图默认用完就扔
🎯 一句话
你从来没有「定义」过一张计算图 —— 图是前向的时候顺手记下来的,backward() 走完一遍就被扔掉了。
本章要讲清四件事:图是什么时候建的(每次前向、每次都是新的)、图上存了什么(实测一次前向为反向存下 44.0 MB)、
图是什么时候没的(backward() 一走完就释放)、以及为什么梯度是累加而不是覆盖。
⚠️ 本章不讲 detach / no_grad 怎么用,那是第 04 章。这里只讲图本身。
🧩 一、图不是你画的,是算出来的
第 01 章说过,说明书上第六个字段 requires_grad 一开,「接下来对它做的每一步都要被记账」。
记账的方式就是:每一次运算,都在结果张量上挂一个 grad_fn。
import torch
x = torch.tensor([2.0], requires_grad=True) # 叶子:你自己造的
y = x * 3 # 中间结果
z = y.sum() # 标量
print("x is_leaf", x.is_leaf, " requires_grad", x.requires_grad, " grad_fn", x.grad_fn)
print("y is_leaf", y.is_leaf, " requires_grad", y.requires_grad, " grad_fn", y.grad_fn)
print("z grad_fn", z.grad_fn)
print("y 的上游:", y.grad_fn.next_functions) # ⭐ AccumulateGrad 就是「把梯度攒到 x.grad 上」
实测输出:
| 变量 | is_leaf | requires_grad | grad_fn |
|---|---|---|---|
| x | True | True | None |
| y | False | True | <MulBackward0 object at 0x000001C128091AE0> |
其余节点与上游连接(保留地址原文)
- z grad_fn <SumBackward0 object at 0x000001C1296599C0>
- y 的上游: ((<AccumulateGrad object at 0x000001C1296C9480>, 0), (None, 0))
(内存地址每次跑都不一样,⚠️ 别对着地址找不同。)
⭐ 三个角色,一次分清:
| 角色 | 怎么认 | 它是什么 |
|---|---|---|
| 叶子(leaf) | is_leaf == True、grad_fn is None |
⭐ 你自己造的张量:模型参数、输入数据。只有它会拿到 .grad |
| 中间结果 | is_leaf == False、有 grad_fn |
运算的产物。requires_grad 是继承来的,不是你设的 |
grad_fn 节点 |
MulBackward0 / SumBackward0 / AccumulateGrad |
⭐ 真正的「图」。每个节点知道两件事:怎么反着算,和上游是谁(next_functions) |
⭐ AccumulateGrad 这个名字本身就是答案 —— 它不叫 SetGrad,叫「累加梯度」。
第四节会看到这个命名有多要命。
⭐ 图是从输出往回连的:z.grad_fn 是 SumBackward0,它的上游是 MulBackward0,
再往上是 AccumulateGrad(终点,把梯度写进 x.grad)。
⚠️ x 自己不在图上 —— 图上是那个 AccumulateGrad 节点,它持有对 x 的引用。
⭐
requires_grad是「或」传播的:只要输入里有一个要梯度,结果就要梯度。 所以在一个网络里,从第一个带参数的层往后,每一个中间结果都在图上。
🧩 二、图是动态的:每次前向都是一张新图
这是 PyTorch 和早期静态图框架最大的区别,也是它调试起来舒服的原因:
import torch
def f(t, flag):
return t * 2 if flag else t ** 3 # ⭐ 同一段代码,两条不同的路
for flag in (True, False):
t = torch.tensor([2.0], requires_grad=True)
out = f(t, flag)
out.backward()
print("flag =", flag, " grad_fn =", type(out.grad_fn).__name__,
" grad =", t.grad.item())
实测输出:
对照
flag = True grad_fn = MulBackward0 grad = 2.0
flag = False grad_fn = PowBackward0 grad = 12.0
⭐ 同一个函数,两次跑出了两张不同的图。 你可以在 forward 里写 if、写 while、
写「循环次数取决于输入内容」,autograd 只是跟着你实际走过的那条路记账。
三个直接后果:
| 后果 | 说明 |
|---|---|
⭐ 可以用 print 和断点调试 |
图就是 Python 执行流本身,pdb 停在哪你就看到哪 |
| ⭐ 动态结构是免费的 | 变长序列、按内容决定的分支、递归结构,都不需要特殊 API |
| ⚠️ 代价:每次前向都要重建一遍 | 建图有开销,而且图不能提前优化。torch.compile 想解决的正是这一条(第 10 章) |
⚠️ 一个容易记反的点:动态不等于「便宜」。图是每一步真的建出来的对象, 第七节会量给你看它有多大。
🧩 三、backward() 走完,图就没了
import torch
a = torch.tensor([3.0], requires_grad=True)
loss = a * a
loss.backward()
print("第一次 backward,a.grad =", a.grad.tolist()) # 2a = 6
try:
loss.backward() # ⚠️ 同一张图第二次
except RuntimeError as e:
print("第二次 backward ->", e)
实测输出(⭐ 报错是真实原文):
操作步骤
⭐ 报错原文里其实把机制说完了,拆开看:
| 原文片段 | 意思 |
|---|---|
Saved intermediate values of the graph are freed |
⭐ 被释放的不是图的结构,是节点为了反向而存下来的那些中间张量 |
when you call .backward() or autograd.grad() |
释放发生在反向走完的时候,不是别的时候 |
Specify retain_graph=True |
修法之一 |
⚠️ 为什么默认要扔:因为那些中间张量很占地方(第七节实测 44.0 MB)。 一次训练迭代里,前向存下来的东西反向用完就再也用不到了,留着就是白占显存。 ⭐ 默认释放是对的设计,不是缺陷。
retain_graph=True 是让它留着:
import torch
b = torch.tensor([3.0], requires_grad=True)
loss = b * b
loss.backward(retain_graph=True) # ⭐ 留着图
print("第一次后 b.grad =", b.grad.tolist())
loss.backward() # 最后一次不用留
print("第二次后 b.grad =", b.grad.tolist()) # ⚠️ 12 不是 6 —— 梯度是累加的
b.grad = None # 或者 b.grad.zero_()
loss2 = b * b
loss2.backward()
print("清零后重算 =", b.grad.tolist())
实测输出:
要点
第一次后 b.grad = [6.0]
第二次后 b.grad = [12.0]
清零后重算 = [6.0]
⚠️⚠️ 注意第二行的 12.0。 两次 backward() 求的是同一个导数,答案当然都是 6 ——
但 b.grad 变成了 12。这直接引出下一节。
🧩 四、梯度是累加的,不是覆盖的
⭐ 这是 optimizer.zero_grad() 存在的唯一原因,也是那个节点叫 AccumulateGrad 的原因。
backward() 做的事是 x.grad += 算出来的梯度,不是 x.grad = 算出来的梯度。
| 你以为 | 实际 |
|---|---|
backward() 把 .grad 写成这次的梯度 |
⭐ backward() 把这次的梯度加到 .grad 上 |
| 不清零只是「不干净」 | ⚠️ 不清零 = 这一步的梯度是历史所有步的和,训练直接废 |
ML 基础 15 章 的六个高频 Bug 里第一条就是「忘了 optimizer.zero_grad() → 训练完全不收敛」。
⭐ 机制就是这一行。
那为什么要设计成累加? 因为累加让两件事变得不需要额外 API:
| 场景 | 累加带来的好处 |
|---|---|
| 梯度累积(小显存模拟大 batch) | ⭐ 只要连着 backward() 几次再 step() 一次就行,不需要任何特殊支持 |
| 一个参数被多条路径用到 | 比如共享 embedding、多任务多个 loss —— 各条路的梯度自动加起来,这正是链式法则要的 |
⚠️ optimizer.zero_grad() 和 model.zero_grad() 不完全等价:
前者只清这个优化器管着的参数。如果你有两个优化器管不同部分,只清一个会漏。
⚠️ 清零的写法:现在的 zero_grad() 默认是 set_to_none=True,也就是把 .grad 直接设成 None
而不是填 0。⭐ 所以训练循环里读 p.grad 之前要先判空,不然会撞上 NoneType 而不是一个全 0 张量。
🧩 五、只有叶子会拿到 .grad
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x * 3
z = y.sum()
z.backward()
print("x.grad =", x.grad) # 叶子,有值
print("y.grad =", y.grad) # ⚠️ 中间结果,是 None(还会打一条警告)
x2 = torch.tensor([2.0], requires_grad=True)
y2 = x2 * 3
y2.retain_grad() # ⭐ 显式要求留下来
y2.sum().backward()
print("retain_grad 之后 y2.grad =", y2.grad)
实测输出(⭐ 警告是真实原文,为省地方只留了关键的前半句):
要点
UserWarning: The .grad attribute of a Tensor that is not a leaf Tensor is being accessed. Its .grad
attribute won't be populated during autograd.backward(). If you indeed want the .grad field to be
populated for a non-leaf Tensor, use .retain_grad() on the non-leaf Tensor.
x.grad = tensor([3.])
y.grad = None
retain_grad 之后 y2.grad = tensor([1.])
⚠️ 注意这是 UserWarning 不是报错 —— 程序照跑,y.grad 就是 None。
💀 如果你写了个「梯度监控」在打印中间层的 .grad,它会一路打印 None 而不报错。
⭐ 为什么中间结果的 .grad 默认不留:一次前向有成千上万个中间张量,
把它们的梯度全存下来,显存会直接翻倍,而 99% 的场景根本不看。
| 你想做的事 | 正确写法 |
|---|---|
| 看某个中间张量的梯度 | ⭐ t.retain_grad()(在前向之后、反向之前调用) |
| 看某一层的输入/输出梯度 | module.register_full_backward_hook(...) |
| 看参数的梯度 | 参数是叶子,⭐ 直接 p.grad 就有,不需要任何额外操作 |
⚠️ 别把 retain_grad(留中间结果的 .grad)和 retain_graph(留图不释放)搞混。
名字差一个字母,做的事完全不同。
| 全名 | 干什么 | 代价 | |
|---|---|---|---|
retain_grad() |
retain gradient | 让一个中间张量保留它的 .grad |
多存一个和它同样大的张量 |
retain_graph=True |
retain graph | 让整张图的中间存量不被释放 | ⚠️ 整张图的存量都不释放(实测量级见下一节) |
🛑 读到这里可以停 —— 前半章讲完了(约 30 分钟)。 后半章还有:
backward()要一个标量 · 图到底占多少地方(实测) · 什么时候真的需要retain_graph·autograd.grad:要梯度但不写进.grad回来的时候不用重读,直接从下一节接着看就行。
🧩 六、backward() 要一个标量
import torch
c = torch.tensor([1.0, 2.0], requires_grad=True)
out = c * 2 # 输出是长度 2 的向量,不是标量
try:
out.backward()
except RuntimeError as e:
print("非标量 backward ->", e)
out2 = c * 2
out2.backward(torch.tensor([1.0, 1.0])) # ⭐ 手动给「上游传下来的梯度」
print("给了 gradient 之后 c.grad =", c.grad.tolist())
实测输出:
关键信息
⭐ 为什么必须是标量:backward() 要从「输出对输出的导数」开始往回推,标量的话这个起点显然是 1.0,
它可以默认填上。向量输出的话,d(向量)/d(向量) 是一个雅可比矩阵,PyTorch 不知道你想要哪个方向,
所以要你自己给一个权重向量(out.backward(v) 算的是 v 和雅可比的乘积)。
⚠️ 实践中撞到这条,99% 是你忘了求 loss 的均值或和:
| 你写的 | 结果 |
|---|---|
loss = (pred - y) ** 2 |
⚠️ 是一个 batch 长度的向量 → 报这个错 |
loss = ((pred - y) ** 2).mean() |
⭐ 标量,正常 |
⚠️ 别用 backward(torch.ones_like(out)) 去「绕过」这个报错 —— 那等价于 out.sum().backward(),
也就是把 batch 内的损失求和而不是求平均。⭐ 后果是有效学习率跟着 batch size 变,
换个 batch size 训练行为就变了,而且不报错。
🧩 七、图到底占多少地方(实测)
反向传播需要「前向时的一些中间值」。到底存了多少?可以用 saved_tensors_hooks 数出来:
import torch
import torch.nn as nn
def measure(x_needs_grad):
tot = {"n": 0, "b": 0}
def pack(t): # ⭐ 每有一个张量被「存给反向用」,这里就被调一次
tot["n"] += 1
tot["b"] += t.numel() * t.element_size()
return t
torch.manual_seed(0)
net = nn.Sequential(*[nn.Linear(1024, 1024) for _ in range(8)])
x = torch.randn(512, 1024, requires_grad=x_needs_grad)
with torch.autograd.graph.saved_tensors_hooks(pack, lambda t: t):
net(x)
return tot["n"], tot["b"] / 1024 / 1024
n1, m1 = measure(False)
print(f"输入不要梯度:存了 {n1} 个张量、{m1:.1f} MB")
n2, m2 = measure(True)
print(f"输入要梯度 :存了 {n2} 个张量、{m2:.1f} MB") # ⭐ 多出来的正是第一层的权重
net = nn.Sequential(*[nn.Linear(1024, 1024) for _ in range(8)])
print("对比:模型参数本身",
round(sum(p.numel() for p in net.parameters()) * 4 / 1024 / 1024, 1), "MB")
实测输出:
要点
输入不要梯度:存了 15 个张量、44.0 MB
输入要梯度 :存了 16 个张量、48.0 MB
对比:模型参数本身 32.0 MB
⭐ 对着账算一遍(8 层 Linear(1024, 1024),batch 512,全是 float32):
| 存了什么 | 一个多大 | 几个 | 小计 |
|---|---|---|---|
| 每层的输入(算权重梯度要用) | 512×1024×4 = 2 MB | 8 | 16 MB |
| 每层的权重(算输入梯度要用) | 1024×1024×4 = 4 MB | ⭐ 7 | 28 MB |
| 合计 | 15 | 44 MB |
⭐ 为什么权重只存了 7 个而不是 8 个:第一层的权重不用存 —— 因为输入 x 不要梯度,
反向不需要再往 x 回传,那一步的乘法就不必做,权重也就不必留。
把 x 改成 requires_grad=True,立刻变成 16 个 / 48.0 MB,多出来的正好是 4 MB(一个权重)。
⭐ 三条能直接用上的结论:
- 「图」不是抽象概念,它有明确的字节数。 这个例子里图存的 44 MB 比模型参数本身的 32 MB 还多。
- ⭐ 图的大小和 batch size 成正比,参数不是。 表里 16 MB 那一行跟着 512 走,28 MB 那一行不跟。 这就是「OOM 了先把 batch 调小」为什么有效。
- ⭐ 少一段需要梯度的路,就少存一批东西。 上面 15 vs 16 是最小的例子;
把第一层参数冻住(
requires_grad_(False))省得更多 —— 这是第 04 章「冻结 backbone」的机制。
⭐ 这三条在站内的下一步:「怎么用更少的显存换更多的 batch」是 AI 基础设施 09 的正题 (梯度检查点、offload、累加,各省多少各花多少)。这里只负责说清「存的是什么」。
🧩 八、什么时候真的需要 retain_graph
⚠️ 报错原文建议你 Specify retain_graph=True,但多数情况下那是错的修法。先看两个假需求:
假需求 ①:在循环里累加 loss,最后一起 backward
import torch
w = torch.tensor([1.0], requires_grad=True)
total = torch.zeros(1)
for i in range(5):
total = total + w * i # ⚠️ total 一直挂在图上
depth = 0
node = total.grad_fn
while node is not None and node.next_functions:
depth += 1
node = node.next_functions[0][0]
print("累加 5 步之后,从 total 往回数的节点层数 =", depth) # ⚠️ 跟步数一起长
run = 0.0
for i in range(5):
run += (w * i).item() # ⭐ .item() 把值取出来,图当场断掉
print("用 .item() 累加,run 的类型是", type(run).__name__, "值 =", run)
实测输出:
要点
累加 5 步之后,从 total 往回数的节点层数 = 5
用 .item() 累加,run 的类型是 float 值 = 10.0
⭐ 节点层数跟着步数一起长。 训练循环里的典型写法长这样,跑一遍看深度怎么涨:
import torch
import torch.nn as nn
def depth_of(t): # 从 t 往回数图有多少层
d, node = 0, t.grad_fn
while node is not None and node.next_functions:
d += 1
node = node.next_functions[0][0]
return d
torch.manual_seed(0)
model = nn.Linear(4, 1)
batches = [(torch.randn(8, 4), torch.randn(8, 1)) for _ in range(4)]
total_loss = 0.0
for xb, yb in batches:
loss = nn.functional.mse_loss(model(xb), yb)
total_loss += loss # 💀 就是这一行:loss 是张量
print(" 这一步之后 total_loss 的图深度 =", depth_of(total_loss))
print("total_loss 现在是", type(total_loss).__name__, "requires_grad =", total_loss.requires_grad)
total_ok = 0.0
for xb, yb in batches:
loss = nn.functional.mse_loss(model(xb), yb)
total_ok += loss.item() # ⭐ 修法:把值取出来
print("正确写法下 total_ok 是", type(total_ok).__name__)
实测输出:
对照
这一步之后 total_loss 的图深度 = 3
这一步之后 total_loss 的图深度 = 4
这一步之后 total_loss 的图深度 = 5
这一步之后 total_loss 的图深度 = 6
total_loss 现在是 Tensor requires_grad = True
正确写法下 total_ok 是 float
⚠️ 一个 batch 涨一层:3 → 4 → 5 → 6。total_loss 从 float 变成了挂在图上的张量,
图和它存的中间张量一路不释放 —— 症状是「训练跑着跑着显存越来越大」,
而且第二个 batch 的 loss.backward() 就可能报「a second time」。
⭐ 修法不是 retain_graph=True,是把值取出来:total_loss += loss.item()。
(ML 基础 15 章 的模板写的正是 total += loss.item() * len(xb)。)
假需求 ②:想对同一个 loss 反传两次
多半是你的代码结构错了。⭐ 正确做法是重新前向一次,得到一张新图。
真需求:三种
| 场景 | 为什么必须 |
|---|---|
| ⭐ 二阶导 / 高阶导(MAML、梯度惩罚、WGAN-GP) | 要对「梯度」再求一次梯度,第一次反向的图必须留着 |
| 一张图上有多个 loss 要分别反传到不同参数 | 比如 GAN 里从同一个生成结果同时反传到 D 和 G(⚠️ 更常见的写法是用 detach,见第 04 章) |
| 截断 BPTT 里跨段共享的那部分 | 序列被切段,段与段之间有共享的隐状态 |
⚠️ retain_graph=True 的代价就是第七节量出来的那个数(这个例子里 44 MB 不释放)。
⭐ 只在最后一次反向时省掉它,像第三节那段代码那样:前面几次带 retain_graph=True,最后一次不带。
🛑 读到这里可以停 —— 已经读了约 55 分钟。 最后一段还有(约 23 分钟):
autograd.grad:要梯度但不写进.grad· 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🧩 九、autograd.grad:要梯度但不写进 .grad
import torch
x = torch.tensor([3.0], requires_grad=True)
f = x ** 3
(g,) = torch.autograd.grad(f, x, create_graph=True) # ⭐ 要二阶就得 create_graph
print("一阶 3x^2 在 x=3 处 =", g.item())
(g2,) = torch.autograd.grad(g, x)
print("二阶 6x 在 x=3 处 =", g2.item())
print("x.grad 还是", x.grad, " ← autograd.grad 不往 .grad 里写")
实测输出:
对照
一阶 3x^2 在 x=3 处 = 27.0
二阶 6x 在 x=3 处 = 18.0
x.grad 还是 None ← autograd.grad 不往 .grad 里写
⭐ 两个函数的分工:
loss.backward() |
torch.autograd.grad(out, inputs) |
|
|---|---|---|
| 结果去哪 | ⭐ 累加进每个叶子的 .grad |
⭐ 作为返回值给你,不碰任何 .grad |
| 适合 | 训练循环(优化器就是读 .grad 的) |
只想要梯度这个值:梯度惩罚、可视化、二阶 |
| 副作用 | 会和别处的累加混在一起 | ⭐ 没有副作用 |
⭐ create_graph=True 是「让这次反向本身也被记账」 —— 只有这样,算出来的梯度 g 才带着 grad_fn,
才能对它再求一次导。⚠️ 它隐含了 retain_graph=True,显存开销更大,别随手加。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 01 · 张量到底是什么 | ⭐ requires_grad 是说明书上的第六个字段,这一章是它展开的样子 |
| 03 · 三种就地操作报错 | 本章第七节说图「存下了每层的输入和权重」—— 你把那些张量就地改掉,就是下一章的三种报错 |
| 04 · detach 与 no_grad | 本章讲图怎么建,下一步是三种「不让它建」的办法,以及它们断的不是一个东西 |
| 09 · 自己写一个 autograd 算子 | ⭐ 本章的 grad_fn 节点你也可以自己写一个 —— 那时才真正明白「前向存什么、反向还什么」 |
| ML 基础 15 · PyTorch 实战手册 | 「忘了 zero_grad() 训练完全不收敛」的机制就是第四节的累加;那份模板里 total += loss.item() 的 .item() 就是第八节的修法 |
| ML 基础 08 · 反向传播 | 链式法则本身的数学。⭐ 本章讲的是「框架怎么把它自动化」,不重复推导 |
| AI 基础设施 09 · 显存优化全家桶 | ⭐ 第七节量出「图存了 44 MB」之后的下一步:梯度检查点、offload、累加各省多少 |
| ML 基础 18 · 挑战项目 A 手搓 mini-torch | 读完这一章再去做那个项目,你要造的正是 grad_fn 和 AccumulateGrad |
✅ 检查点
- 图是什么时候建的?「叶子」「中间结果」「
grad_fn节点」三个角色分别怎么认? AccumulateGrad这个节点名说明了什么?它导致训练循环里必须写哪一行?- 第二次对同一个 loss
backward()的报错原文关键词是什么?被释放的到底是什么? - 实测那段代码里,
loss.backward(retain_graph=True)跑两次之后b.grad是多少?为什么不是 6? - 中间结果的
.grad为什么是None?这是报错还是警告?想看它该怎么办? retain_grad()和retain_graph=True的区别是什么?grad can be implicitly created only for scalar outputs通常是因为什么?为什么不该用backward(torch.ones_like(out))绕过?- 实测里一次前向为反向存下了多少个张量、多少 MB?把账拆开是哪两部分?为什么权重只存了 7 个而不是 8 个?
- 「训练跑着跑着显存越来越大」的典型写法是哪一行?正确的修法是什么(不是
retain_graph)? loss.backward()和torch.autograd.grad(...)的结果分别去了哪里?create_graph=True是干什么的?
👀 答案
- 图是前向的时候顺手记下来的,不是你定义的:每一次运算在结果张量上挂一个
grad_fn。叶子:is_leaf==True、grad_fn is None,你自己造的(参数、输入),只有它会拿到.grad;中间结果:is_leaf==False、有grad_fn,requires_grad是继承来的;grad_fn节点:真正的图,每个节点知道「怎么反着算」和「上游是谁(next_functions)」。 - 它叫「累加梯度」不叫「设置梯度」——
backward()做的是x.grad += ...而不是=。所以训练循环里每一步必须optimizer.zero_grad(),否则这一步的梯度是历史所有步的和,训练完全不收敛。 Trying to backward through the graph a second time。被释放的不是图的结构,是节点为反向存下来的中间张量(原文:Saved intermediate values of the graph are freed)。默认释放是对的设计 —— 那些东西很占地方(实测 44.0 MB)。- 12.0。两次求的是同一个导数(都是 6),但梯度是累加的,所以
.grad变成 6+6=12。清零(b.grad = None)后重算才是 6.0。 - 因为一次前向有成千上万个中间张量,全存梯度会让显存翻倍而 99% 用不上。⚠️ 这是
UserWarning不是报错,程序照跑、值就是None——💀 所以写「梯度监控」打印中间层.grad会一路打印None而不报错。想看就在前向之后、反向之前调t.retain_grad()(实测之后y2.grad = tensor([1.]))。 retain_grad()是让一个中间张量保留它的.grad,代价是多存一个同样大的张量;retain_graph=True是让整张图的中间存量不被释放,代价是那一整批(本例 44 MB)都不释放。名字差一个字母,做的事完全不同。- 99% 是忘了对 loss 求
.mean()或.sum(),(pred-y)2是一个 batch 长度的向量。⚠️ 不该用backward(torch.ones_like(out))绕过,因为那等价于求和而不是求平均 ——后果是有效学习率跟着 batch size 变**,换个 batch size 训练行为就变,而且不报错。 - 15 个张量、44.0 MB(8 层
Linear(1024,1024)、batch 512、float32)。拆开是:每层的输入 2 MB × 8 = 16 MB(算权重梯度要用)+ 每层的权重 4 MB × 7 = 28 MB(算输入梯度要用)。第一层权重不用存,因为输入x不要梯度、反向不需要再往回传;把x改成requires_grad=True立刻变成 16 个 / 48.0 MB。顺带两条:图的 44 MB 比模型参数本身的 32 MB 还多;图的大小和 batch size 成正比而参数不是,这就是「OOM 先调小 batch」为什么有效。 total_loss += loss(loss 是张量)。⚠️ 它让total_loss挂在图上、每个 batch 叠一层,图一路不释放。修法是total_loss += loss.item()把值取出来,不是retain_graph=True。实测「累加 5 步之后从 total 往回数的节点层数 = 5」。loss.backward()把梯度累加进每个叶子的.grad(优化器就是读.grad的);torch.autograd.grad(...)把梯度作为返回值给你、不碰任何.grad(实测之后x.grad还是None),没有副作用。create_graph=True是让这次反向本身也被记账,这样算出的梯度才带grad_fn、才能再求一次导(实测一阶 27.0、二阶 18.0);⚠️ 它隐含retain_graph=True,显存开销更大。
🛑 可以停在这里
⚡ 走神救援
⭐ 图不是你定义的,是前向时顺手记下来的——每一次运算在结果上挂一个
grad_fn。三个角色:叶子(你自己造的参数和输入,⭐ 只有它拿得到.grad)、中间结果、grad_fn节点。⭐ 终点那个节点叫
AccumulateGrad,名字本身就是答案:它累加不是覆盖——这就是optimizer.zero_grad()存在的唯一原因,忘了就「训练完全不收敛」。图是动态的:同一个函数走不同分支就建出不同的图,所以能用
torch.compile治的就是这条)。⭐
backward()走完图就没了,而释放的不是结构是中间张量——实测一次前向为反向存下的量比模型参数本身还多。⭐ 图的大小随 batch size 走、参数不随,这就是 OOM 先调小 batch 的机制。⚠️ 只有叶子有
.grad:中间结果是None并且只打一条 UserWarning 不是报错——所以梯度监控会一路打印None而你以为一切正常。要看就retain_grad();⚠️ 别把它和retain_graph=True(留整张图)搞混。
backward()要标量,⚠️ 别用backward(torch.ones_like(out))绕过——那等于求和,有效学习率会跟着 batch size 变。⭐ 最值钱的一条:报错建议的
retain_graph=True多半是错的修法。真正的病常常是total_loss += loss(累加的是张量),图会跟着步数一起长——修法是加.item()。
下一节 👉 03-三种就地操作报错.md