📑 本页目录(点开跳转)
- 🎯 一句话
- 🧩 一、先说清什么算「就地」
- 🧩 二、版本计数器:它怎么发现你改了
- 🧩 三、形态①:叶子被就地改(报在你写的那一行)
- 🧩 四、形态②:叶子的视图被就地改(报的是另一句话)
- 🧩 五、形态③:反向要用的中间结果被就地改(报在 backward() 那一行)
- 🧩 六、为什么 x * 2 不报错、sigmoid 报错
- 🧩 七、💀 形态④:根本不报错的那种
- 🧩 八、no_grad 和 detach 都拦不住版本计数器
- 🧩 九、那还要不要用就地操作
- 🧩 十、nn.ReLU(inplace=True) 什么时候安全
- 🧩 十一、撞上了怎么定位、怎么改
- 🔗 这一章连到哪里
- ✅ 检查点
- 🛑 可以停在这里
03 · 就地操作:三种报错,三个不同的原因
⏱ 106 分钟 | ⚠️ 实测:三种形态的报错原文完全不同,而最贵的那一种根本不报错 —— 只是悄悄算错
🎯 一句话
就地操作省下的是一次内存分配,付出的代价是「autograd 记的账可能对不上」。 PyTorch 用一个叫版本计数器的东西来抓这件事 —— 它能抓住三种形态,抓不住第四种。 本章把这四种形态各跑一遍,报错原文逐字抄下来,然后告诉你分别该怎么改。
⭐ 本章存在的理由:ML 基础 11 章的「梯度断流四来源」里,第四条只写了一句
「原地操作(in-place):x += 1 有时会破坏计算图,改成 x = x + 1」。
这一章就是把那个「有时」拆开 —— 什么时候会、什么时候不会、什么时候会但不报错。
🧩 一、先说清什么算「就地」
判据只有一条:结果写回原来那块内存,还是写进新分配的一块。
import torch
a = torch.tensor([1.0, 2.0])
b = a + 1 # 非就地:结果放进新分配的一块内存
print("a + 1 和 a 同一块内存吗:", b.data_ptr() == a.data_ptr())
before = a.data_ptr()
a.add_(1) # ⭐ 下划线后缀 = 就地
print("a.add_(1) 之后 a 还是原来那块吗:", a.data_ptr() == before, " a =", a.tolist())
x = torch.tensor([1.0, 2.0])
y = x
y += 1 # ⭐ += 也是就地
print("y += 1 之后 x =", x.tolist(), " y is x:", y is x)
p = torch.tensor([1.0, 2.0])
q = p
q = q + 1 # 不是就地:q 被重新绑到了新对象上
print("q = q + 1 之后 p =", p.tolist(), " q is p:", q is p)
s = torch.tensor([1.0, 2.0, 3.0])
s[0] = 99 # ⭐ 下标赋值也是就地
print("s[0] = 99 之后 s =", s.tolist())
实测输出:
对照
a + 1 和 a 同一块内存吗: False
a.add_(1) 之后 a 还是原来那块吗: True a = [2.0, 3.0]
y += 1 之后 x = [2.0, 3.0] y is x: True
q = q + 1 之后 p = [1.0, 2.0] q is p: False
s[0] = 99 之后 s = [99.0, 2.0, 3.0]
⭐ 三种写法都是就地,别只盯着下划线:
| 写法 | 就地吗 | 说明 |
|---|---|---|
t.add_(1)、t.mul_(2)、t.zero_()、t.clamp_(0) |
⭐ 是 | 下划线后缀是 PyTorch 的统一约定 |
t += 1、t *= 2、t -= lr * g |
⭐ 是 | Python 的 __iadd__,⚠️ 最容易被忽略的一种 |
t[0] = 99、t[0:2] = v、t[mask] = 0 |
⭐ 是 | 下标赋值写的就是原来那块内存 |
t = t + 1 |
不是 | 造了个新张量,把名字重新绑上去 |
torch.relu_(t)、F.relu(t, inplace=True) |
⭐ 是 | 函数名带下划线、或者带 inplace=True 参数 |
⚠️ y += 1 和 y = y + 1 在纯 Python 的数字上没区别,在张量上是两件事。
上面 y += 1 把 x 也改掉了(它们本来就是同一个对象),q = q + 1 没有。
💀 这是「我明明没动那个变量,它怎么变了」的头号来源。
🧩 二、版本计数器:它怎么发现你改了
PyTorch 给每一块张量存储挂了一个整数,每被就地改一次就加一:
import torch
t = torch.tensor([1.0, 2.0])
print("刚建出来 _version =", t._version)
t += 1
print("+= 之后 _version =", t._version)
t.mul_(2)
print("mul_ 之后 _version =", t._version)
u = t * 3
print("非就地的产物 _version =", u._version, " 而 t 仍然是", t._version)
x = torch.tensor([1.0, 2.0, 3.0])
v = x[0:2] # 视图:和 x 共享同一块内存
v += 1
print("改视图之后 x._version =", x._version, " v._version =", v._version)
d = x.detach() # ⚠️ detach 出来的也共享同一个计数器
d.mul_(2)
print("改 detach 之后 x._version =", x._version)
实测输出:
| 操作 / 状态 | 版本观察 |
|---|---|
| 刚建出来 | 0 |
| += 之后 | 1 |
| mul_ 之后 | 2 |
| 非就地的产物 | 0 而 t 仍然是 2 |
| 改视图 | x._version = 1 v._version = 1 |
| 改 detach | x._version = 2 |
⭐ 整套机制就三句话:
- 前向的时候,
grad_fn节点把要用的张量存下来,顺手记下它当时的版本号。 - 反向的时候,节点拿出存的张量,对一下版本号。
- 对不上就报错 —— 因为存的那个值已经不是它当初存的那个了。
⭐ 计数器是跟着内存走的,不是跟着变量名走的。上面实测:改 x[0:2] 这个视图,
x 自己的版本号也从 0 变成 1;改 x.detach() 出来的张量,x 的版本号变成 2。
⚠️ 所以「我改的是另一个变量」不是免罪理由,第八节会看到这一条坑死人。
⭐ 为什么是版本号而不是拷贝一份:拷贝就没有省内存这件事了。 版本号只多占 8 个字节,而 02 章第七节量过, 一次前向为反向存下的东西是 44.0 MB 量级 —— 用 8 字节换 44 MB 的正确性检查,这买卖太划算了。
🧩 三、形态①:叶子被就地改(报在你写的那一行)
import torch
x = torch.tensor([1.0, 2.0], requires_grad=True)
try:
x += 1
except RuntimeError as e:
print("形态① ->", e)
y = torch.tensor([1.0, 2.0]) # 不要梯度,随便改
y += 1
print("不要梯度的张量:", y.tolist())
z = torch.tensor([1.0, 2.0], requires_grad=True)
with torch.no_grad(): # ⭐ 优化器就是这么更新参数的
z -= 0.5
print("no_grad 里改叶子:", z.tolist(), " is_leaf =", z.is_leaf,
" requires_grad =", z.requires_grad)
实测输出(⭐ 报错是真实原文):
操作步骤
⭐ 关键词:a leaf Variable that requires grad。
| 特征 | 值 |
|---|---|
| 什么时候报 | ⭐ 就在你写那一行,立刻报,不用等到 backward() |
| 为什么报 | 叶子是 AccumulateGrad 的目标(见 02 章第一节)。你在建图期间改它,autograd 无法保证梯度还对得上 |
| 例外 | ⭐ 包在 torch.no_grad() 里就允许 —— 实测 z 从 [1,2] 变成 [0.5,1.5],而且仍然是叶子、仍然 requires_grad=True |
⭐ 那个例外正是优化器的工作方式:optimizer.step() 内部就是在 no_grad 下对参数做就地减法。
第九节会把这两行手写出来。
🧩 四、形态②:叶子的视图被就地改(报的是另一句话)
import torch
x = torch.zeros(3, requires_grad=True)
try:
x[0] = 1.0 # 下标赋值:先取视图,再往视图里写
except RuntimeError as e:
print("x[0] = 1.0 ->", e)
w = torch.zeros(3, requires_grad=True)
try:
w[0:2] += 1
except RuntimeError as e:
print("w[0:2] += 1 ->", e)
# ⭐ 正确写法:非就地地造一个新张量
m = torch.zeros(3, requires_grad=True)
out = m + torch.tensor([1.0, 0.0, 0.0])
print("非就地版本 ->", out.tolist(), " grad_fn =", type(out.grad_fn).__name__)
实测输出:
结果对照
⭐ 关键词多了三个字:a view of a leaf Variable。
⚠️⚠️ 注意 x[0] = 1.0 报的是「视图」那一句,不是形态①那一句。
因为 x[0] = v 在底层是两步:先取出 x[0] 这个视图,再往视图里写。
⭐ 这也是第 01 章那句「切片改的只是说明书,底下还是同一块内存」的直接后果 ——
你以为你在改 x 的一个元素,autograd 看到的是「有人在改一个视图」。
| 你想做的事 | ❌ 会撞形态② | ⭐ 改成 |
|---|---|---|
| 给参数的某几维赋初值 | w[0] = 1.0 |
with torch.no_grad(): w[0] = 1.0 |
| 把某些位置置零(掩码) | x[mask] = 0 |
x = x * (~mask) 或 x = x.masked_fill(mask, 0) |
| 拼一个带梯度的向量 | 先 zeros 再逐位赋值 |
⭐ 各段分别算好,最后 torch.cat / torch.stack |
⭐ 最后一行那个模式值得单独记:很多人写「先开一个 zeros(n, requires_grad=True),然后一位一位填」,
这在 NumPy 里是标准写法,在 PyTorch 里每一次赋值都撞形态②。
正确的做法是先把各段算出来,最后拼。
🧩 五、形态③:反向要用的中间结果被就地改(报在 backward() 那一行)
import torch
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = torch.sigmoid(x)
y += 1 # 💀 sigmoid 的输出正是它反向要用的东西
try:
y.sum().backward() # ⚠️ 报错发生在这里,不在上面那行
except RuntimeError as e:
print("形态③ ->", e)
实测输出(⭐ 一字未改):
操作步骤
⭐ 这条报错信息量最大,逐段拆:
| 原文片段 | 它在告诉你 |
|---|---|
one of the variables needed for gradient computation |
有一个反向要用的张量出问题了 |
[torch.FloatTensor [2]] |
⭐ 它的形状是 [2] —— 在真实网络里靠这个定位是哪一层 |
which is output 0 of Sigmoid |
⭐ 是哪个算子的第几个输出 —— 定位的关键 |
is at version 1; expected version 0 |
⭐ 第二节那个计数器:存的时候是 0,取的时候变成 1 了 |
Hint: ... set_detect_anomaly(True, ...) |
第十一节讲怎么用 |
⚠️⚠️ 报错的位置和出错的位置差了一整段:错是 y += 1 犯的,报是 backward() 报的。
💀 在真实训练循环里,这意味着报错栈顶是 loss.backward() 那一行,而真凶在几十行之前的 forward 里。
第十一节的 set_detect_anomaly 就是干这个的。
🧩 六、为什么 x * 2 不报错、sigmoid 报错
同样是「就地改中间结果」,有的炸有的不炸。这不是玄学,判据很硬:
import torch
def try_one(name, f):
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = f(x)
y.mul_(2) # 就地改中间结果
try:
y.sum().backward()
print(f"{name:14s} 没报错,x.grad = {x.grad.tolist()}")
except RuntimeError as e:
print(f"{name:14s} 报错:{str(e)[:88]}")
try_one("x * 2", lambda t: t * 2)
try_one("x ** 2", lambda t: t ** 2)
try_one("torch.exp", torch.exp)
try_one("torch.sigmoid", torch.sigmoid)
try_one("torch.tanh", torch.tanh)
try_one("torch.relu", torch.relu)
实测输出:
对照
x * 2 没报错,x.grad = [4.0, 4.0]
x ** 2 没报错,x.grad = [4.0, 8.0]
torch.exp 报错:one of the variables needed for gradient computation has been modified by an inplace ope
torch.sigmoid 报错:one of the variables needed for gradient computation has been modified by an inplace ope
torch.tanh 报错:one of the variables needed for gradient computation has been modified by an inplace ope
torch.relu 报错:one of the variables needed for gradient computation has been modified by an inplace ope
⭐ 判据是一句话:这个算子的反向,要不要用它自己的【输出】。
| 算子 | 反向公式 | 要存什么 | 输出被就地改 |
|---|---|---|---|
y = 2x |
dx = 2 · dy |
⭐ 什么都不用存(常数 2 是编译期就知道的) | 不炸 |
y = x² |
dx = 2x · dy |
存输入 x |
不炸 |
y = eˣ |
⭐ dx = y · dy |
存输出 y |
💀 炸 |
y = sigmoid(x) |
⭐ dx = y(1−y) · dy |
存输出 y |
💀 炸 |
y = tanh(x) |
⭐ dx = (1−y²) · dy |
存输出 y |
💀 炸 |
y = relu(x) |
dx = dy · [y > 0] |
存输出 y(只要知道正负) |
💀 炸 |
⭐ 指数族全都存输出,因为它们的导数能用输出本身表达(e^x 的导数就是 e^x),
这样能少存一份 —— 是个非常划算的优化,代价就是输出不能被动。
⚠️ 别把「这次没报错」当成安全。x * 2 那一行 x.grad = [4.0, 4.0] 是对的
(y 被 mul_(2) 变成了 4x,导数确实是 4)—— 它只是恰好不需要那个输出而已。
💀 换一个激活函数,同一段代码就炸;而报错会出现在你以为跟这次改动无关的地方。
🛑 读到这里可以停 —— 前半章讲完了(约 37 分钟)。 后半章还有:💀 形态④:根本不报错的那种 ·
no_grad和detach都拦不住版本计数器 · 那还要不要用就地操作 ·nn.ReLU(inplace=True)什么时候安全 · 撞上了怎么定位、怎么改 回来的时候不用重读,直接从下一节接着看就行。
🧩 七、💀 形态④:根本不报错的那种
前三种都有版本计数器兜着。这一种没有 —— 它是本章最贵的一节。
破法一:.data 绕过版本计数器
import torch
x = torch.tensor([2.0], requires_grad=True)
y = torch.sigmoid(x)
y.sum().backward()
print("正确的 x.grad =", x.grad.item())
x2 = torch.tensor([2.0], requires_grad=True)
y2 = torch.sigmoid(x2)
y2.data.mul_(0) # 💀 .data 不碰版本计数器
y2.sum().backward()
print("用 .data 改过之后 x.grad =", x2.grad.item(), " ← 没有任何报错")
print("y2 的版本号仍然是", y2._version)
实测输出:
对照
正确的 x.grad = 0.10499362647533417
用 .data 改过之后 x.grad = 0.0 ← 没有任何报错
y2 的版本号仍然是 0
💀💀 梯度从 0.10499362647533417 变成了 0.0,版本号还停在 0,程序一声不吭跑完。
在真实训练里,这表现为「loss 不降但也不报错」—— 而 ML 基础 11 章
排查「梯度断流」的第一条查的就是 .detach() / .data。⭐ 这就是它排第一的原因。
⭐ .data 是什么:它是 detach() 的老写法,但比 detach() 更危险 ——
detach() 共享版本计数器(第八节实测会报错),.data 不共享。
| 写法 | 共享内存 | 共享版本计数器 | 结论 |
|---|---|---|---|
y.detach() |
是 | ⭐ 是 | 就地改它 → 会报错,你还有救 |
y.data |
是 | 💀 否 | 就地改它 → 静默算错 |
⭐ 一句话结论:现代 PyTorch 代码里不应该出现 .data。 要断图用 detach()(第 04 章),
要改参数用 with torch.no_grad():。⚠️ 老教程和老仓库里 .data 满地都是,抄的时候要换掉。
破法二:前向的值被悄悄改掉(autograd 根本不知情)
版本计数器只管「反向要用的张量」。⚠️ 你自己还要用的张量,它一概不管。
import torch
import torch.nn as nn
torch.manual_seed(0)
x = torch.randn(1, 4)
backbone = nn.Linear(4, 4)
feat = backbone(x) # 你打算留着做别的用(比如算一个辅助 loss)
before = feat.detach().clone()
h = torch.relu_(feat) # ⚠️ 就地版 relu
print("relu 之前的 feat:", [round(v, 4) for v in before[0].tolist()])
print("relu 之后的 feat:", [round(v, 4) for v in feat[0].detach().tolist()])
print("feat 和 h 是同一个对象吗:", feat is h)
实测输出:
要点
relu 之前的 feat: [0.0856, -0.1264, 1.2841, 0.1311]
relu 之后的 feat: [0.0856, 0.0, 1.2841, 0.1311]
feat 和 h 是同一个对象吗: True
⚠️ 第二个数从 -0.1264 变成了 0.0,而 feat 这个名字你根本没碰过。
torch.relu_ 返回的就是 feat 自己(feat is h 为 True)。
💀 接下来你拿 feat 去算辅助 loss / 存特征 / 做可视化,用到的全是 relu 之后的值,
数值全错、一行报错没有。
这类事故的三个典型现场:
| 现场 | 怎么发生的 |
|---|---|
| 多任务 / 辅助头 | 主干输出 feat 被就地激活改掉,辅助头拿到的已经不是原始特征 |
| 存特征做检索 / 可视化 | 把 feat 存进列表,后面某一层就地改了它,列表里的东西跟着变 |
⭐ torch.from_numpy 之后就地改 |
第 01 章实测过它们共享内存 —— 就地改 tensor,原来的 numpy 数组跟着变 |
⭐ 通用防线一句话:要留着用的张量,先 .clone()。(上面代码里 before 就是这么来的。)
🧩 八、no_grad 和 detach 都拦不住版本计数器
很多人以为「包一层 no_grad 就不记账了」,或者「先 detach 再改就安全了」。两个都不成立:
import torch
x = torch.tensor([2.0], requires_grad=True)
y = torch.sigmoid(x)
with torch.no_grad(): # ⚠️ 以为这样就「不记账」了
y.mul_(0)
try:
y.sum().backward()
except RuntimeError as e:
print("no_grad 里改 ->", str(e)[:96])
x2 = torch.tensor([2.0], requires_grad=True)
y2 = torch.sigmoid(x2)
y2.detach().mul_(0) # ⚠️ detach 出来的和 y2 是同一块内存、同一个计数器
try:
y2.sum().backward()
except RuntimeError as e:
print("detach 之后改 ->", str(e)[:96])
实测输出:
关键信息
⭐ 两个都照样报形态③。原因分别是:
| 你以为 | 实际 |
|---|---|
no_grad 让这次修改「不被记账」 |
⭐ no_grad 只管不新建图,⚠️ 版本号照加。图是之前建好的,它记的账不会因为你后来进了 no_grad 就作废 |
detach() 出来的是「另一个张量」 |
⭐ 它只是另一个 Python 对象,⚠️ 同一块内存、同一个版本计数器(第二节实测过) |
💀 这两条合起来解释了一个常见的错误修法:撞上形态③之后,
有人把那一行包进 no_grad、或者加个 detach(),然后发现还是报同样的错,就以为报错信息在骗人。
⭐ 它没骗你 —— 它抓的是「这块内存的内容变了」,跟你用什么姿势改的无关。
⭐ 正确的修法只有一个方向:别改它。 把 y.mul_(0) 换成 y2 = y * 0。
⚠️ 这三个词的区别是下一章的正题。 这里只需要记住一条:它们都不是「就地操作的免罪符」。
🧩 九、那还要不要用就地操作
要。而且你每一次训练迭代都在用,只是没意识到。
它省的到底是什么
import torch
import torch.nn as nn
x = torch.randn(4, 8)
out1 = nn.ReLU(inplace=False)(x)
print("inplace=False 输出和输入同一块内存吗:", out1.data_ptr() == x.data_ptr())
x2 = torch.randn(4, 8)
out2 = nn.ReLU(inplace=True)(x2)
print("inplace=True 输出和输入同一块内存吗:", out2.data_ptr() == x2.data_ptr())
one = torch.randn(256, 1024)
print("一个 256x1024 的 float32 张量 =",
one.numel() * one.element_size() / 1024 / 1024, "MB")
实测输出:
算一算
inplace=False 输出和输入同一块内存吗: False
inplace=True 输出和输入同一块内存吗: True
一个 256x1024 的 float32 张量 = 1.0 MB
⭐ 省的是「输出那一份新分配的内存」。按上面这个尺寸算账:batch 256、宽度 1024 的激活值一份是 1.0 MB,
一个 6 层的网络里每层一个 ReLU,inplace=True 就少分配 6 份、也就是 6 MB。
⚠️ 这是按张量大小算出来的账,不是实测峰值 —— 真实峰值还受分配器复用影响。
🗓️ 未实跑 —— 需要 GPU:显存上的实际峰值差异要用 torch.cuda.max_memory_allocated() 才能量,本机没有卡。
⚠️ 它不省「反向存下来的那一份」。02 章第七节量的那 44.0 MB 是 grad_fn 存的东西,
inplace=True 之后 ReLU 存的仍然是它的输出(只不过那块内存和输入共用)。
⭐ 所以别指望 inplace=True 解决 OOM —— 真正的大头在 AI 基础设施 09。
训练循环里你已经在用的两处
import torch
w = torch.tensor([1.0], requires_grad=True)
(w * w).backward()
with torch.no_grad():
w -= 0.1 * w.grad # ⭐ 就地:w 还是原来那个叶子
print("就地更新后 :", w.tolist(), " is_leaf =", w.is_leaf, " grad_fn =", w.grad_fn)
v = torch.tensor([1.0], requires_grad=True)
opt = torch.optim.SGD([v], lr=0.1) # 优化器记住的是「这个对象」
(v * v).backward()
v = v - 0.1 * v.grad # ⚠️ 非就地:名字被重新绑到了新对象上
print("非就地更新后:", v.tolist(), " is_leaf =", v.is_leaf,
" grad_fn =", type(v.grad_fn).__name__)
print("优化器手里那个还是:", opt.param_groups[0]["params"][0].tolist())
实测输出:
对照
就地更新后 : [0.800000011920929] is_leaf = True grad_fn = None
非就地更新后: [0.800000011920929] is_leaf = False grad_fn = SubBackward0
优化器手里那个还是: [1.0]
⭐ 两个数值都是 0.8,但后果天差地别:
就地(w -= ...) |
非就地(v = v - ...) |
|
|---|---|---|
| 还是叶子吗 | ⭐ 是(grad_fn = None) |
❌ 不是(grad_fn = SubBackward0) |
下一轮 .grad |
正常累加 | 💀 它不是叶子了,拿不到 .grad(02 章第五节) |
| 优化器看得到吗 | ⭐ 看得到,它持有的就是这个对象 | 💀 看不到 —— 实测优化器手里那个还是 [1.0],一步都没更新 |
⭐ 这就是为什么 optimizer.step() 必须就地改参数:优化器在构造时记下的是那些张量对象,
你要是换了个新对象,它手里那份就成了孤儿。
⚠️ 另一处更常见的就地是 optimizer.zero_grad() / p.grad.zero_() —— 清零也是就地写。
🛑 第二个休息点 —— 中段讲完了(约 26 分钟)。 最后一段还有:
nn.ReLU(inplace=True)什么时候安全 · 撞上了怎么定位、怎么改 这一章确实长,分三次读完全没问题 —— 回来直接从下一节接着看。
🛑 读到这里可以停 —— 已经读了约 64 分钟。 最后一段还有(约 20 分钟):
nn.ReLU(inplace=True)什么时候安全 · 撞上了怎么定位、怎么改 回来的时候不用重读,直接从下一节接着看就行。
🧩 十、nn.ReLU(inplace=True) 什么时候安全
⭐ 先说站内现状:inplace=True 这个写法在立项时全站 392 页 HTML 里命中 0 次(旧板块至今仍是 0) ——
也就是说你在站内任何一页抄到的代码都不带它。但它在外面的开源实现里(torchvision 的 ResNet 就是)遍地都是,
所以你迟早会抄到。
import torch
import torch.nn as nn
torch.manual_seed(0)
x = torch.randn(2, 4, requires_grad=True)
ok = nn.Sequential(nn.Linear(4, 4), nn.ReLU(inplace=True), nn.Linear(4, 1))
ok(x).sum().backward()
print("Linear 后面接 inplace ReLU:跑通,x.grad 形状 =", tuple(x.grad.shape))
x2 = torch.tensor([1.0, -2.0], requires_grad=True)
bad = nn.Sequential(nn.Sigmoid(), nn.ReLU(inplace=True))
try:
bad(x2).sum().backward()
except RuntimeError as e:
print("Sigmoid 后面接 inplace ReLU ->", str(e)[:96])
实测输出:
关键信息
⭐ 判据还是第六节那一条,只不过要看的是【前一个算子】:
| 前一个是谁 | 它的反向要用自己的输出吗 | ReLU(inplace=True) |
|---|---|---|
Linear / Conv2d |
❌ 不要(要的是输入和权重,见 02 章第七节的账) | ⭐ 安全,这就是 ResNet 里能这么写的原因 |
BatchNorm |
❌ 不要它的最终输出 | ⭐ 一般安全 |
Sigmoid / Tanh / Exp |
⭐ 要 | 💀 炸(实测) |
另一个 ReLU |
⭐ 要 | 💀 炸 |
⚠️ 还有一种情况报错抓不到:前一个算子的输出你自己还要用(第七节破法二那种)。
⭐ 典型是残差连接:out = x + f(x) 之后,x 还要参与别的分支时,
一个就地激活会把 x 一起改掉 —— autograd 可能不报错,值已经错了。
⭐ 给一条能直接执行的规矩:
inplace=True只在两种情况下用:① 你就是在复现一个明确写了它的开源实现; ② 你确实 OOM 了、并且量过它有用。⚠️ 其余一律不写 —— 它省的那点内存不值得你花半天定位一个静默的数值错。
🧩 十一、撞上了怎么定位、怎么改
定位:set_detect_anomaly 会告诉你是 forward 的哪一行
import torch
with torch.autograd.set_detect_anomaly(True):
a = torch.tensor([1.0, 2.0], requires_grad=True)
b = torch.sigmoid(a)
b += 1
try:
b.sum().backward()
except RuntimeError as e:
print("最终报错:", str(e)[:70])
实测输出(⭐ 前半段是打到 stderr 的 UserWarning,路径按你的机器变):
对照
UserWarning: Error detected in SigmoidBackward0. Traceback of forward call that caused the error:
File " · \b12.py", line 5, in <module>
b = torch.sigmoid(a)
(Triggered internally at · \python_anomaly_mode.cpp:127.)
最终报错: one of the variables needed for gradient computation has been modified
⭐ 它多给了一个东西:那个出问题的算子是在 forward 的哪一行造出来的(line 5, in <module>)。
在几百行的模型里,这一句能省掉一小时。
⚠️ 两个使用注意:
| 注意 | 说明 |
|---|---|
| ⭐ 只在调试时开 | 它让反向变慢(每个节点都要多记一份 forward 栈),别留在训练脚本里 |
| ⚠️ 报的名字和报错原文对不上 | 这里 warning 说 SigmoidBackward0,RuntimeError 说 output 0 of Sigmoid。⭐ 是同一个东西,一个是反向节点的类名,一个是前向算子名 |
四条修法,按优先级
| 优先级 | 修法 | 什么时候用 |
|---|---|---|
| ⭐ 1 | 改成非就地:y += 1 → y = y + 1,y.mul_(2) → y = y * 2 |
⭐ 默认选它。 多分配一块内存换正确性,绝大多数情况这个代价可以忽略 |
| 2 | inplace=True 改成 False |
撞上的是激活层 |
| 3 | .clone() 一份再改 |
你确实需要一个可改的副本(比如要留着原值) |
| ⭐ 4 | 包 with torch.no_grad(): |
⚠️ 只对形态①②有效(改叶子/改叶子的视图),第八节实测对形态③无效 |
⚠️ 不在这张表里的两个「修法」:
- ❌ 加
.data—— 它只是让报错消失,第七节实测梯度从0.10499362647533417变成0.0。💀 这是把报错换成了静默的错误答案。 - ❌ 加
retain_graph=True—— 那治的是另一个病(图被释放),跟版本号对不上没关系。
📋 四种形态反查表
| 报错原文关键词 | 形态 | 谁被改了 | 报在哪一行 | 首选修法 |
|---|---|---|---|---|
a leaf Variable that requires grad |
① | 叶子本身(参数、输入) | ⭐ 就是你写的那行 | 包 no_grad,或改非就地 |
a view of a leaf Variable that requires grad |
② | 叶子的视图(x[0]=、切片) |
⭐ 就是你写的那行 | masked_fill / cat 拼,或包 no_grad |
one of the variables needed ... output 0 of Sigmoid ... version 1; expected version 0 |
③ | 反向要用的中间结果 | ⚠️ backward() 那行 |
改非就地;开 set_detect_anomaly 定位 |
| ⚠️ 没有报错 | ④ | .data 改的 / 你自己还要用的张量 |
💀 不报 | 删掉 .data;要留着用的先 .clone() |
⭐ 另外两条不属于「三种形态」但常撞上的:
| 报错原文 | 什么时候 |
|---|---|
Output 0 of Unbind is a view and is being modified inplace. This view is the output of a function that returns multiple views. |
对 unbind / split / chunk 出来的视图就地改 |
unsupported operation: more than one element of the written-to tensor refers to a single memory location. Please clone() the tensor before performing the operation. |
对 expand / broadcast_to 出来的张量就地改 —— ⭐ 那里多个下标指向同一块内存,报错自己就把修法(clone())写出来了 |
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 01 · 张量到底是什么 | ⭐ 形态②的根源在那一章:「切片改的只是说明书,底下还是同一块内存」。第七节 from_numpy 共享内存的实测也在那里 |
| 02 · autograd 怎么建图 | ⭐ 第七节量的「一次前向为反向存下 44.0 MB」,正是本章第六节说的「存下来的输出」。第九节「非就地更新之后拿不到 .grad」的机制在它第五节 |
| 04 · detach / no_grad / requires_grad | ⭐ 本章第八节实测这两个都拦不住版本计数器;下一章讲它们各自到底断了什么、为什么不一样 |
| 05 · nn.Module 内部 | 第九节说「优化器持有的是那些张量对象」—— 参数是怎么被找到并交给优化器的,在那一章 |
| ML 基础 11 · 训练调试手册 | ⭐ 那里「梯度断流四来源」的第四条只说「x += 1 有时会破坏计算图」,本章就是那个「有时」的完整展开;第一条「.detach() / .data」的机制在第七节 |
| ML 基础 09 · 优化器与学习率 | 第九节手写的那两行 SGD 更新对应那里的优化器公式;本章补的是「为什么那一步必须就地」 |
| AI 基础设施 09 · 显存优化全家桶 | ⭐ 第九节说 inplace=True 省的只是激活值那一份、不解决 OOM;真正的省法(梯度检查点、offload)在那一章 |
✅ 检查点
- 除了下划线后缀,还有哪两类写法也是就地操作?
y += 1和y = y + 1在张量上的区别是什么? - 版本计数器的三步机制是什么?它是跟着变量名走还是跟着内存走?实测里改
x的一个视图之后,x._version变成了几? - 形态①的报错原文关键词是什么?它报在哪一行?唯一的例外是什么,为什么那个例外很重要?
x[0] = 1.0(x是要梯度的叶子)报的是形态①还是形态②?为什么?- 形态③的报错原文里,哪两段是定位用的关键信息?为什么说「报错的位置和出错的位置差了一整段」?
- 同样是就地改中间结果,为什么
x * 2不报错而torch.sigmoid(x)报错?判据是什么? - 💀 形态④「不报错的那种」有哪两个破法?实测里
.data让梯度从多少变成了多少? no_grad和detach为什么拦不住版本计数器?分别是什么原因?inplace=True省的是什么、不省的是什么?为什么Linear后面接它安全、Sigmoid后面接它会炸?- 为什么
optimizer.step()必须就地改参数?实测里非就地更新之后,优化器手里那个参数是多少? set_detect_anomaly多给了什么信息?为什么不能留在训练脚本里?- 四条修法的优先级是什么?哪两个「修法」是绝对不能用的?
👀 答案
t += 1这类复合赋值(Python 的__iadd__)和t[0] = 99这类下标赋值。判据只有一条:结果写回原来那块内存,还是写进新分配的一块。实测y = x; y += 1之后x也变成了[2.0, 3.0]、y is x是True;而q = p; q = q + 1之后p还是[1.0, 2.0]、q is p是False。💀 前者是「我明明没动那个变量,它怎么变了」的头号来源。- ① 前向时
grad_fn存下要用的张量并记下它当时的版本号;② 反向时取出来对一下版本号;③ 对不上就报错。跟着内存走,不跟着变量名走 —— 实测改x[0:2]这个视图之后x._version变成 1,再改x.detach()出来的张量,x._version变成 2。 a leaf Variable that requires grad is being used in an in-place operation.就报在你写那一行,不用等到backward()。例外是包在torch.no_grad()里就允许 —— 实测z从[1,2]变成[0.5, 1.5],而且仍然是叶子、仍然requires_grad=True。这个例外正是optimizer.step()的工作方式。- 形态②,报的是
a view of a leaf Variable that requires grad ...。因为x[0] = v底层是两步:先取出x[0]这个视图,再往视图里写。这是 01 章「切片改的只是说明书」的直接后果 —— 你以为在改元素,autograd 看到的是有人在改一个视图。 [torch.FloatTensor [2]](出问题张量的形状)和which is output 0 of Sigmoid(哪个算子的第几个输出)。⚠️ 错是y += 1犯的、报是backward()报的 —— 💀 在真实训练循环里报错栈顶是loss.backward(),真凶在几十行之前的 forward 里。- 判据是:这个算子的反向要不要用它自己的【输出】。
y = 2x的反向dx = 2·dy什么都不用存、y = x²存的是输入,所以不炸(实测x.grad分别是[4.0, 4.0]和[4.0, 8.0]);而exp(dx = y·dy)、sigmoid(dx = y(1−y)·dy)、tanh(dx = (1−y²)·dy)、relu(看y > 0)都存输出,实测四个全炸。⚠️ 「这次没报错」不等于安全 —— 换个激活函数同一段代码就炸。 - 破法一
.data:它共享内存但不共享版本计数器,实测梯度从0.10499362647533417变成0.0、y2._version仍是 0、一行报错没有。破法二:前向的值被悄悄改掉 —— 版本计数器只管「反向要用的张量」,你自己还要用的它一概不管;实测torch.relu_(feat)把feat从[0.0856, -0.1264, 1.2841, 0.1311]改成[0.0856, 0.0, 1.2841, 0.1311],而feat is h为True。防线:要留着用的张量先.clone();现代代码里不应该出现.data。 - 实测两个都照样报形态③。
no_grad只管不新建图,版本号照加 —— 图是之前建好的,不会因为你后来进了no_grad就作废;detach()只是另一个 Python 对象,同一块内存、同一个版本计数器。💀 所以「撞上形态③就加个detach或包个no_grad」是无效修法,正确方向只有「别改它」。 - 省的是输出那一份新分配的内存(实测
inplace=True时输出和输入data_ptr相同、False时不同;按 batch 256 × 宽 1024 算一份是 1.0 MB,6 层就是 6 MB)。⚠️ 不省反向存下来的那一份(02 章那 44.0 MB),所以别指望它解决 OOM。Linear/Conv2d的反向要的是输入和权重、不要自己的输出,所以安全(ResNet 就这么写);Sigmoid的反向要它自己的输出,所以炸(实测)。 - 因为优化器在构造时记下的是那些张量对象。实测非就地更新之后:新的
v变成了非叶子(grad_fn = SubBackward0)、拿不到.grad,而优化器手里那个还是[1.0],一步都没更新。两种写法的数值都是 0.8,后果天差地别。⚠️ 另一处就地是zero_grad()/p.grad.zero_()。 - 它多给那个出问题的算子是在 forward 的哪一行造出来的(实测 warning 里指到
line 5, in <module>的b = torch.sigmoid(a))。⚠️ 它让反向变慢(每个节点都多记一份 forward 栈),只在调试时开。另注意 warning 说SigmoidBackward0、RuntimeError说output 0 of Sigmoid,是同一个东西。 - 1 改成非就地(默认选它)→ 2
inplace=True改False→ 3.clone()一份再改 → 4 包no_grad(⚠️ 只对形态①②有效)。💀 绝对不能用的两个:❌ 加.data(把报错换成静默的错误答案,实测梯度变0.0)、❌ 加retain_graph=True(那治的是图被释放,跟版本号对不上没关系)。
🛑 可以停在这里
⚡ 走神救援
⭐ 就地操作省的是一次内存分配,付的是「autograd 记的账可能对不上」。
抓它的机制是版本计数器:前向存下张量并记下版本号,反向时对一下,对不上就报错。⭐ 计数器跟着内存走,不跟着变量名走——所以改视图、改
.detach()出来的对象,都会让原张量的版本号往上跳。四种形态:叶子被改(报在你写的那一行;⭐ 例外是包在
no_grad里就允许——那个例外正是optimizer.step()的工作方式);叶子的视图被改(⚠️x[0] = 1.0报的是这一条,因为下标赋值底层是「先取视图再往视图里写」);反向要用的中间结果被改(⚠️ 报在backward()那一行,真凶在几十行之前的前向里)。⭐ 为什么
x * 2不炸而sigmoid炸:判据是这个算子的反向要不要用它自己的输出——2x什么都不存,而exp/sigmoid/tanh/relu都存输出。💀 第四种根本不报错:
.data共享内存但不共享版本计数器,梯度会悄悄变成 0 而版本号纹丝不动;另一种是你还要用的张量被就地改掉,值全错、零报错。⭐ 防线:要留着用的先.clone(),现代代码里不该出现.data。⚠️
no_grad和detach都拦不住计数器:前者只管不新建图、版本号照加,后者只是另一个 Python 对象、同一块内存同一个计数器。那还要不要用就地?要——
optimizer.step()和zero_grad()就是;非就地更新参数会让它变成非叶子,优化器手里那个一步都不会动。
下一节 👉 04-detach与no_grad.md