🏠 总目录📚 本教程 03 · 三种就地操作报错 ← →
📑 本页目录(点开跳转)

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

⭐ 整套机制就三句话:

  1. 前向的时候,grad_fn 节点把要用的张量存下来,顺手记下它当时的版本号。
  2. 反向的时候,节点拿出存的张量,对一下版本号。
  3. 对不上就报错 —— 因为存的那个值已经不是它当初存的那个了。

⭐ 计数器是跟着内存走的,不是跟着变量名走的。上面实测:改 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 is being used in an in-place operation.
不要梯度的张量: [2.0, 3.0]
no_grad 里改叶子: [0.5, 1.5] is_leaf = True requires_grad = True

⭐ 关键词: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__)

实测输出:

结果对照

x[0] = 1.0 -> a view of a leaf Variable that requires grad is being used in an in-place operation.
w[0:2] += 1 -> a view of a leaf Variable that requires grad is being used in an in-place operation.
非就地版本 -> [1.0, 0.0, 0.0] grad_fn = AddBackward0

⭐ 关键词多了三个字: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 has been modified by an inplace operation: [torch.FloatTensor [2]], which is output 0 of Sigmoid, is at version 1; expected version 0 instead. Hint: enable anomaly detection to find the operation that failed to compute its gradient, with torch.autograd.set_detect_anomaly(True, check_nan=False).

⭐ 这条报错信息量最大,逐段拆:

原文片段 它在告诉你
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 里改 -> one of the variables needed for gradient computation has been modified by an inplace operation:
detach 之后改 -> one of the variables needed for gradient computation has been modified by an inplace operation:

⭐ 两个都照样报形态③。原因分别是:

你以为 实际
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])

实测输出:

关键信息

Linear 后面接 inplace ReLU:跑通,x.grad 形状 = (2, 4)
Sigmoid 后面接 inplace ReLU -> one of the variables needed for gradient computation has been modified by an inplace operation:

⭐ 判据还是第六节那一条,只不过要看的是【前一个算子】:

前一个是谁 它的反向要用自己的输出吗 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(): ⚠️ 只对形态①②有效(改叶子/改叶子的视图),第八节实测对形态③无效

⚠️ 不在这张表里的两个「修法」:

📋 四种形态反查表

报错原文关键词 形态 谁被改了 报在哪一行 首选修法
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)在那一章

✅ 检查点

  1. 除了下划线后缀,还有哪两类写法也是就地操作?y += 1 和 y = y + 1 在张量上的区别是什么?
  2. 版本计数器的三步机制是什么?它是跟着变量名走还是跟着内存走?实测里改 x 的一个视图之后,x._version 变成了几?
  3. 形态①的报错原文关键词是什么?它报在哪一行?唯一的例外是什么,为什么那个例外很重要?
  4. x[0] = 1.0(x 是要梯度的叶子)报的是形态①还是形态②?为什么?
  5. 形态③的报错原文里,哪两段是定位用的关键信息?为什么说「报错的位置和出错的位置差了一整段」?
  6. 同样是就地改中间结果,为什么 x * 2 不报错而 torch.sigmoid(x) 报错?判据是什么?
  7. 💀 形态④「不报错的那种」有哪两个破法?实测里 .data 让梯度从多少变成了多少?
  8. no_grad 和 detach 为什么拦不住版本计数器?分别是什么原因?
  9. inplace=True 省的是什么、不省的是什么?为什么 Linear 后面接它安全、Sigmoid 后面接它会炸?
  10. 为什么 optimizer.step() 必须就地改参数?实测里非就地更新之后,优化器手里那个参数是多少?
  11. set_detect_anomaly 多给了什么信息?为什么不能留在训练脚本里?
  12. 四条修法的优先级是什么?哪两个「修法」是绝对不能用的?
👀 答案
  1. 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。💀 前者是「我明明没动那个变量,它怎么变了」的头号来源。
  2. ① 前向时 grad_fn 存下要用的张量并记下它当时的版本号;② 反向时取出来对一下版本号;③ 对不上就报错。跟着内存走,不跟着变量名走 —— 实测改 x[0:2] 这个视图之后 x._version 变成 1,再改 x.detach() 出来的张量,x._version 变成 2。
  3. 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() 的工作方式。
  4. 形态②,报的是 a view of a leaf Variable that requires grad ...。因为 x[0] = v 底层是两步:先取出 x[0] 这个视图,再往视图里写。这是 01 章「切片改的只是说明书」的直接后果 —— 你以为在改元素,autograd 看到的是有人在改一个视图。
  5. [torch.FloatTensor [2]](出问题张量的形状)和 which is output 0 of Sigmoid(哪个算子的第几个输出)。⚠️ 错是 y += 1 犯的、报是 backward() 报的 —— 💀 在真实训练循环里报错栈顶是 loss.backward(),真凶在几十行之前的 forward 里。
  6. 判据是:这个算子的反向要不要用它自己的【输出】。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)都存输出,实测四个全炸。⚠️ 「这次没报错」不等于安全 —— 换个激活函数同一段代码就炸。
  7. 破法一 .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。
  8. 实测两个都照样报形态③。no_grad 只管不新建图,版本号照加 —— 图是之前建好的,不会因为你后来进了 no_grad 就作废;detach() 只是另一个 Python 对象,同一块内存、同一个版本计数器。💀 所以「撞上形态③就加个 detach 或包个 no_grad」是无效修法,正确方向只有「别改它」。
  9. 省的是输出那一份新分配的内存(实测 inplace=True 时输出和输入 data_ptr 相同、False 时不同;按 batch 256 × 宽 1024 算一份是 1.0 MB,6 层就是 6 MB)。⚠️ 不省反向存下来的那一份(02 章那 44.0 MB),所以别指望它解决 OOM。Linear/Conv2d 的反向要的是输入和权重、不要自己的输出,所以安全(ResNet 就这么写);Sigmoid 的反向要它自己的输出,所以炸(实测)。
  10. 因为优化器在构造时记下的是那些张量对象。实测非就地更新之后:新的 v 变成了非叶子(grad_fn = SubBackward0)、拿不到 .grad,而优化器手里那个还是 [1.0],一步都没更新。两种写法的数值都是 0.8,后果天差地别。⚠️ 另一处就地是 zero_grad() / p.grad.zero_()。
  11. 它多给那个出问题的算子是在 forward 的哪一行造出来的(实测 warning 里指到 line 5, in <module> 的 b = torch.sigmoid(a))。⚠️ 它让反向变慢(每个节点都多记一份 forward 栈),只在调试时开。另注意 warning 说 SigmoidBackward0、RuntimeError 说 output 0 of Sigmoid,是同一个东西。
  12. 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

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