🏠 总目录📚 本教程 02 · autograd 怎么建图 ← →
📑 本页目录(点开跳转)

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_leafrequires_gradgrad_fn
xTrueTrueNone
yFalseTrue<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)

实测输出(⭐ 报错是真实原文):

操作步骤

第一次 backward,a.grad = [6.0]
第二次 backward -> Trying to backward through the graph a second time (or directly access saved tensors after they have already been freed). Saved intermediate values of the graph are freed when you call .backward() or autograd.grad(). Specify retain_graph=True if you need to backward through the graph a second time or if you need to access saved tensors after calling backward.

⭐ 报错原文里其实把机制说完了,拆开看:

原文片段 意思
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 -> grad can be implicitly created only for scalar outputs
给了 gradient 之后 c.grad = [2.0, 2.0]

⭐ 为什么必须是标量: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(一个权重)。

⭐ 三条能直接用上的结论:

  1. 「图」不是抽象概念,它有明确的字节数。 这个例子里图存的 44 MB 比模型参数本身的 32 MB 还多。
  2. ⭐ 图的大小和 batch size 成正比,参数不是。 表里 16 MB 那一行跟着 512 走,28 MB 那一行不跟。 这就是「OOM 了先把 batch 调小」为什么有效。
  3. ⭐ 少一段需要梯度的路,就少存一批东西。 上面 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

✅ 检查点

  1. 图是什么时候建的?「叶子」「中间结果」「grad_fn 节点」三个角色分别怎么认?
  2. AccumulateGrad 这个节点名说明了什么?它导致训练循环里必须写哪一行?
  3. 第二次对同一个 loss backward() 的报错原文关键词是什么?被释放的到底是什么?
  4. 实测那段代码里,loss.backward(retain_graph=True) 跑两次之后 b.grad 是多少?为什么不是 6?
  5. 中间结果的 .grad 为什么是 None?这是报错还是警告?想看它该怎么办?
  6. retain_grad() 和 retain_graph=True 的区别是什么?
  7. grad can be implicitly created only for scalar outputs 通常是因为什么?为什么不该用 backward(torch.ones_like(out)) 绕过?
  8. 实测里一次前向为反向存下了多少个张量、多少 MB?把账拆开是哪两部分?为什么权重只存了 7 个而不是 8 个?
  9. 「训练跑着跑着显存越来越大」的典型写法是哪一行?正确的修法是什么(不是 retain_graph)?
  10. loss.backward() 和 torch.autograd.grad(...) 的结果分别去了哪里?create_graph=True 是干什么的?
👀 答案
  1. 图是前向的时候顺手记下来的,不是你定义的:每一次运算在结果张量上挂一个 grad_fn。叶子:is_leaf==True、grad_fn is None,你自己造的(参数、输入),只有它会拿到 .grad;中间结果:is_leaf==False、有 grad_fn,requires_grad 是继承来的;grad_fn 节点:真正的图,每个节点知道「怎么反着算」和「上游是谁(next_functions)」。
  2. 它叫「累加梯度」不叫「设置梯度」——backward() 做的是 x.grad += ... 而不是 =。所以训练循环里每一步必须 optimizer.zero_grad(),否则这一步的梯度是历史所有步的和,训练完全不收敛。
  3. Trying to backward through the graph a second time。被释放的不是图的结构,是节点为反向存下来的中间张量(原文:Saved intermediate values of the graph are freed)。默认释放是对的设计 —— 那些东西很占地方(实测 44.0 MB)。
  4. 12.0。两次求的是同一个导数(都是 6),但梯度是累加的,所以 .grad 变成 6+6=12。清零(b.grad = None)后重算才是 6.0。
  5. 因为一次前向有成千上万个中间张量,全存梯度会让显存翻倍而 99% 用不上。⚠️ 这是 UserWarning 不是报错,程序照跑、值就是 None ——💀 所以写「梯度监控」打印中间层 .grad 会一路打印 None 而不报错。想看就在前向之后、反向之前调 t.retain_grad()(实测之后 y2.grad = tensor([1.]))。
  6. retain_grad() 是让一个中间张量保留它的 .grad,代价是多存一个同样大的张量;retain_graph=True 是让整张图的中间存量不被释放,代价是那一整批(本例 44 MB)都不释放。名字差一个字母,做的事完全不同。
  7. 99% 是忘了对 loss 求 .mean() 或 .sum(),(pred-y)2 是一个 batch 长度的向量。⚠️ 不该用 backward(torch.ones_like(out)) 绕过,因为那等价于求和而不是求平均 ——后果是有效学习率跟着 batch size 变**,换个 batch size 训练行为就变,而且不报错。
  8. 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」为什么有效。
  9. total_loss += loss(loss 是张量)。⚠️ 它让 total_loss 挂在图上、每个 batch 叠一层,图一路不释放。修法是 total_loss += loss.item() 把值取出来,不是 retain_graph=True。实测「累加 5 步之后从 total 往回数的节点层数 = 5」。
  10. 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() 存在的唯一原因,忘了就「训练完全不收敛」。

图是动态的:同一个函数走不同分支就建出不同的图,所以能用 print 和断点调试、变长结构免费;⚠️ 代价是每次都要重建、无法提前优化(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

打卡记录保存在你的浏览器里,首页能看到总进度