很多刚上手 PyTorch 的人,在写完loss.backward()之后都会愣一下:梯度到底存到哪了?为什么我第二次 backward 就报错了?为什么有的张量.grad是 None,有的却是实打实的数值?这些问题看起来零碎,其实都指向同一件事——PyTorch 的自动微分机制到底是怎么运转的。张量的梯度计算是 PyTorch 整个训练体系的发动机,搞不清它,后面写优化器、写自定义损失、做梯度裁剪、调学习率,全是照葫芦画瓢,出了问题根本无从下手。
这篇内容面向的是已经能跑通一个简单训练脚本、但对梯度机制还停留在“会调用 API”层面的读者。我会从张量为什么需要梯度讲起,把计算图、叶子节点、梯度累加、detach与no_grad的区别、梯度检查这些容易混淆的点一个个拆开,配上可以直接复现的代码和实测结果。看完之后,你应该能对着任意一段 PyTorch 代码,准确说出每个张量的梯度会流向哪里、什么时候被清空、为什么有时候需要手动干预。下面这些内容都是我在实际调试和带人过程中反复验证过的,踩过的坑也会一并写出来。
1. 梯度这件事,先搞清楚张量在记录什么
1.1 requires_grad 不是开关,而是“记账标记”
很多人把requires_grad=True理解成一个“开启梯度计算”的开关,这个理解不算错,但太浅了。更准确的说法是:它给这个张量打上了一个“记账标记”,从此之后,所有以它为输入的运算,PyTorch 都会在后台记一笔账,把运算关系记录下来,形成一张有向无环图,也就是常说的计算图。
import torch x = torch.tensor([2.0, 3.0], requires_grad=True) y = x * x + 1 print(y.requires_grad) # True print(y.grad_fn) # <AddBackward0 object>注意y.grad_fn这个属性。只要一个张量的requires_grad是 True,并且它不是用户直接创建的(而是运算产生的),它就一定带一个grad_fn,指向产生它的那个运算的反向函数。这个grad_fn就是计算图上的一个节点,反向传播时 PyTorch 正是顺着这些节点一路往回走的。
这里有个特别容易被忽略的点:只有浮点类型和复数类型的张量才能设置requires_grad=True。整数张量不行,会直接报错。原因是梯度本质上是导数,整数没有连续可导的概念。我见过有人用torch.tensor([1, 2, 3], requires_grad=True)然后困惑为什么报错,其实就是类型问题,加上dtype=torch.float32就好了。
1.2 计算图是动态搭出来的,不是提前定义好的
和某些静态图框架不同,PyTorch 的计算图是在前向计算的过程中一边算一边搭的。你每执行一行涉及需要梯度的张量的运算,图就长出一个节点。这意味着两件事:第一,图的结构完全取决于你代码的实际执行路径,if分支走哪边,图就是哪样;第二,前向算完,图就在那了,反向传播就是沿着这张已经搭好的图往回走。
a = torch.tensor(1.0, requires_grad=True) b = a * 2 c = b ** 2 # 此时图的结构: a -> MulBackward -> b -> PowBackward -> c c.backward() print(a.grad) # 8.0,因为 dc/da = 2 * (2a) = 4a = 4我特意把中间变量b写出来,是想强调:图是跟着变量走的。如果你写成c = (a * 2) ** 2,中间那个b就不存在了,但图照样会记录乘法节点和幂运算节点,只是你拿不到中间变量的引用了。这个区别在做梯度调试的时候很关键——想检查中间层梯度,就得把中间结果赋给变量留住。
1.3 叶子节点:梯度最终落脚的地方
计算图里的张量分两类:叶子节点和非叶子节点。用户直接创建的、requires_grad=True的张量就是叶子节点,比如模型里的权重参数;由运算产生的张量是非叶子节点,比如中间激活值。默认情况下,反向传播只会把梯度累加到叶子节点的.grad上,非叶子节点的梯度算完就丢了。
w = torch.tensor([1.0, 2.0], requires_grad=True) # 叶子 h = w * 3 # 非叶子 loss = h.sum() loss.backward() print(w.grad) # tensor([3., 3.]),叶子有梯度 print(h.grad) # None,非叶子默认不保留这个设计不是偷懒,而是出于内存考虑。一个深层网络里中间激活值成千上万,如果每个都保留梯度,显存瞬间就爆了。真要看非叶子节点的梯度,得手动调用h.retain_grad(),这个后面会细说。理解“梯度只落在叶子节点”这一条,能解释掉一大半“为什么我的.grad是 None”的困惑。
2. backward 到底做了什么,梯度又是怎么累加的
2.1 一次 backward 的完整链路
调用loss.backward()时,PyTorch 做的事情可以拆成几步:从loss这个标量出发,沿着grad_fn链反向遍历计算图;每到一个节点,就调用该节点对应的反向函数,用链式法则把上游传来的梯度乘以本地的导数,得到传给下游的梯度;一直传到叶子节点,把结果累加到它们的.grad属性上。
这里有个硬性约束:backward()默认只能对标量调用。如果你的 loss 是个向量,直接 backward 会报 “grad can be implicitly created only for scalar outputs”。解决办法有两个,要么先loss.sum()或loss.mean()变成标量,要么给backward()传一个和输出同形状的gradient参数,相当于指定每个输出分量的权重。
v = torch.tensor([1.0, 2.0, 3.0], requires_grad=True) out = v * 2 # out.backward() # 报错 out.backward(torch.tensor([1.0, 0.0, 0.0])) # 只对第一个分量求导 print(v.grad) # tensor([2., 0., 0.])传gradient参数这个用法在写自定义损失或者多任务学习时很有用,它等价于给输出乘上一个权重向量再求和。理解这一点,你就明白为什么标量能直接 backward——标量相当于天然带了一个权重 1。
2.2 梯度是累加的,不是覆盖的
这是新手最容易栽的坑,没有之一。PyTorch 的.grad属性是累加语义,每次 backward 都会把新算出来的梯度加到已有的值上,而不是替换掉。
x = torch.tensor(1.0, requires_grad=True) y = x ** 2 y.backward() print(x.grad) # 2.0 y = x ** 2 y.backward() print(x.grad) # 4.0,不是 2.0,累加了为什么会这样设计?因为在某些场景下累加是有意义的,比如一个参数在多个地方被用到(参数共享),一次 backward 里它的梯度本来就该是各路径梯度之和。但放到常规的训练循环里,如果你不在每轮迭代开始时清零梯度,上一轮的梯度就会污染这一轮,模型直接训飞。
所以标准训练循环里一定有这一行:
optimizer.zero_grad()它的作用就是把优化器管理的所有参数的.grad置零(或者置为 None,取决于set_to_none参数)。我个人的习惯是把它放在loss.backward()之前、前向计算之后,逻辑上更顺:先清账,再算新账。放的位置其实不影响结果,只要在 backward 之前就行,但保持固定位置能避免自己看花眼。
2.3 第二次 backward 报错的真正原因
跑过这段代码的人应该都见过这个报错:
RuntimeError: Trying to backward through the graph a second time...原因在于,反向传播走完一遍之后,为了省内存,PyTorch 会把计算图里保存的中间结果释放掉。你第二次对同一个 loss 调 backward,图已经残缺了,自然走不通。
x = torch.tensor(2.0, requires_grad=True) y = x ** 3 y.backward() print(x.grad) # 12.0 # y.backward() # 报错,图已被释放要重复利用同一张图,得在第一次 backward 时加retain_graph=True:
y.backward(retain_graph=True) y.backward() print(x.grad) # 24.0,两次累加但我要提醒一句:retain_graph=True不是随便加的。它会让中间结果不被释放,显存占用上升。真正需要它的场景其实不多,主要是同一张图要算多次梯度(比如某些 GAN 的训练技巧、或者对同一 loss 求高阶导)。如果你只是想在训练循环里反复 backward,那正确做法是每轮重新做前向计算,而不是保留旧图。
3. detach、no_grad 和 inference_mode:三个容易混的家伙
3.1 detach 是“剪断”计算图
detach()返回一个和原张量共享数据、但脱离计算图的新张量。它不再有grad_fn,requires_grad也变成 False。用它参与后续运算,不会在图上留下任何痕迹。
a = torch.tensor([1.0, 2.0], requires_grad=True) b = a * 2 c = b.detach() * 3 print(c.requires_grad) # False print(c.grad_fn) # None这个操作在几个场景下是刚需。一是你想拿某个中间结果做记录或打印,不希望它牵扯进梯度计算;二是实现某些自定义逻辑,比如目标网络(target network)的更新,需要把参数值拷过来但不建立梯度关系;三是做梯度截断,把某条路径的梯度人为切断。
有个细节值得说:detach()出来的张量和原张量共享底层存储。也就是说,如果你原地修改了 detach 后的张量,原张量的数据也会变。这个特性有时候会被利用来做一些原地操作,但也容易埋雷,改之前想清楚。
3.2 no_grad 是“整段关灯”
torch.no_grad()是一个上下文管理器,它管辖范围内的所有运算都不记录梯度,不管张量的requires_grad是什么。
with torch.no_grad(): y = a * 2 print(y.requires_grad) # False它和detach()的区别在于作用范围:detach()作用于单个张量,no_grad()作用于一段代码。推理阶段、评估阶段、参数手动更新,都该包在no_grad()里。原因很实在——不记录梯度能省下大量显存和计算,推理速度也会快一些。
我见过有人在评估循环里忘了加no_grad(),结果显存一路涨,跑到一半 OOM。排查半天以为是模型太大,其实是评估时还在默默搭图。这种问题加一行上下文就解决了。
3.3 inference_mode 比 no_grad 更彻底
较新版本的 PyTorch 提供了torch.inference_mode(),它比no_grad()更激进:不仅不记录梯度,连版本计数等推理用不上的元信息都不维护,因此开销更低。
with torch.inference_mode(): y = a * 2代价是它产生的张量不能再参与需要梯度的运算,也不能做某些原地操作。纯推理场景下优先用它,如果代码里还有需要梯度的逻辑混着,那就退回no_grad()。我一般的判断标准很简单:这段代码只做前向、结果只用来出预测,就用inference_mode;如果拿不准,用no_grad更保险。
下面这张表把三者的差异摆在一起,方便对照:
| 特性 | detach() | no_grad() | inference_mode() |
|---|---|---|---|
| 作用对象 | 单个张量 | 代码块 | 代码块 |
| 是否共享存储 | 是 | 不涉及 | 不涉及 |
| 开销 | 低 | 中 | 最低 |
| 产生张量能否再求导 | 否 | 否 | 否 |
| 典型场景 | 切断单条路径 | 评估、手动更新参数 | 纯推理部署 |
4. 非叶子节点的梯度,以及 retain_grad 的正确用法
4.1 为什么默认不保留中间梯度
前面提过,非叶子节点的.grad默认是 None。这个设计的核心动机是内存。设想一个 50 层的网络,每层激活值都是几 MB 的张量,如果每个都保留梯度,显存需求会翻好几倍。而训练时我们真正需要的只是参数(叶子节点)的梯度,中间激活的梯度算完即用、用完即弃,是最经济的做法。
但调试的时候,我们恰恰想看中间层的梯度是否正常——梯度消失、梯度爆炸,往往就发生在中间某几层。这时候retain_grad()就派上用场了。
4.2 retain_grad 的实操与代价
w = torch.tensor([1.0, 2.0], requires_grad=True) h = w * 3 h.retain_grad() # 关键一步,必须在 backward 之前调用 loss = h.sum() loss.backward() print(h.grad) # tensor([1., 1.])调用顺序很重要:retain_grad()必须在反向传播之前执行,因为它是给这个张量打标记,告诉引擎“算到我的时候把梯度留下来”。backward 之后再调就没意义了。
代价是显存。每保留一个中间张量的梯度,就多占一份和它同形状的存储。所以我的建议是:调试阶段针对性地对少数几个关心的层调retain_grad(),定位完问题就删掉,别留在正式训练代码里。曾经有人图省事给所有中间变量都加了retain_grad(),结果显存直接翻倍,训练 batch size 被迫砍半,得不偿失。
4.3 用 hook 做更灵活的梯度观测
如果只是想观测梯度、不想改张量的保留行为,注册 hook 是更优雅的方式。register_hook可以在某个张量的梯度算出来时插入一个回调,拿到梯度值做记录或修改。
grads = {} def save_grad(name): def hook(grad): grads[name] = grad return hook h = w * 3 h.register_hook(save_grad('h')) loss = h.sum() loss.backward() print(grads['h']) # tensor([1., 1.])hook 的好处是不改变计算图本身的行为,观测完自动失效(如果不手动移除的话会一直挂着,这点要注意)。做梯度监控、画梯度分布图的时候,这套机制比retain_grad更干净。不过 hook 里别做太重的操作,它是在反向传播过程中同步执行的,写复杂逻辑会拖慢训练。
5. 梯度检查与常见异常排查
5.1 用 gradcheck 验证自定义函数的梯度
如果你自己实现了某个运算的反向传播(通过继承torch.autograd.Function),那必须验证梯度算得对不对。PyTorch 提供了torch.autograd.gradcheck,它用数值微分(有限差分)来对比你解析计算的梯度。
from torch.autograd import gradcheck class MyFunc(torch.autograd.Function): @staticmethod def forward(ctx, x): return x ** 2 @staticmethod def backward(ctx, grad_out): x, = ctx.saved_tensors return grad_out * 2 * x f = MyFunc.apply x = torch.randn(3, dtype=torch.double, requires_grad=True) print(gradcheck(f, (x,))) # True 表示梯度正确注意这里必须用double类型,因为数值微分对精度敏感,float32 的误差容易导致误判。gradcheck 跑起来比较慢,只在开发自定义算子时用,别塞进训练循环。
5.2 梯度为 None 的几种典型情况
排查梯度问题时,.grad是 None 是最常见的现象。我整理了几种高频原因,按出现频率排序:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 参数 .grad 为 None | 参数没设 requires_grad | 检查p.requires_grad |
| 参数 .grad 为 None | 参数没参与前向计算 | 打印计算图或用 hook 确认 |
| 参数 .grad 为 None | 被 detach 或包在 no_grad 里 | 检查上下文和 detach 调用 |
| 中间张量 .grad 为 None | 非叶子节点默认不保留 | 调retain_grad() |
| 梯度全为 0 | 激活函数饱和或路径被切断 | 检查激活值分布 |
其中“参数没参与前向计算”这条最隐蔽。比如你定义了一个分支网络,某个 batch 里这个分支没被走到,那它的参数这一轮就没有梯度,.grad保持 None 或旧值。这种情况在条件计算、MoE 结构里很常见,排查时得结合前向逻辑一起看。
5.3 梯度爆炸与消失的快速定位
梯度爆炸表现为 loss 突然变成 NaN 或者参数值飞掉,梯度消失表现为深层参数几乎不更新。定位手段是打印各层梯度的范数:
total_norm = 0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.data.norm(2) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 print(f"grad norm: {total_norm}")如果这个范数在训练中持续增大到几百上千,基本就是爆炸了,可以考虑梯度裁剪torch.nn.utils.clip_grad_norm_。如果它一直很小甚至趋近于零,那要检查是不是网络太深、激活函数选得不对,或者初始化有问题。我习惯在训练脚本里每隔若干步打印一次这个范数,成本很低,但出问题时能第一时间发现苗头。
6. 几个实战中反复踩到的坑
6.1 在 loss 上做 in-place 操作
对需要梯度的张量做原地操作(比如x += 1、x.relu_()),很容易触发 “a leaf Variable that requires grad is being used in an in-place operation” 或者版本计数相关的报错。原因是原地修改会破坏反向传播所需的前向值。解决办法是改用非原地版本,x = x + 1、x = x.relu()。这个坑在写自定义损失时特别容易踩,因为很多人习惯用+=累加。
6.2 优化器更新参数时忘了 no_grad
手动更新参数(不通过优化器)时,如果直接p -= lr * p.grad,会报错,因为这行运算本身会被记录进计算图。正确写法是包在no_grad()里:
with torch.no_grad(): for p in model.parameters(): p -= lr * p.grad用优化器的话它内部已经处理好了,不用操心。但自己写更新逻辑时,这个上下文不能漏。
6.3 混合精度下的梯度缩放
用混合精度训练时,梯度可能因为 float16 表示范围小而下溢成 0。标准做法是配一个 GradScaler 做梯度缩放,backward 时用scaler.scale(loss).backward(),更新前scaler.unscale_(optimizer)再裁剪,最后scaler.step(optimizer)和scaler.update()。这套流程顺序不能乱,乱了梯度要么没缩放要么没还原,训练效果直接崩。我见过有人把scaler.step和optimizer.step混着调,结果参数更新了两次,loss 曲线诡异得很。
6.4 多卡训练下的梯度同步
数据并行训练时,各卡各自算梯度,然后做 all-reduce 求平均。如果你在单卡上调试好的梯度裁剪逻辑直接搬到多卡,要注意裁剪应该发生在梯度同步之后,否则每张卡按自己的局部梯度裁剪,同步后的整体梯度范数就不受控了。这个细节在分布式训练里很关键,但单卡调试时完全暴露不出来。
7. 把梯度机制用进日常调试
7.1 一个可复用的梯度健康检查函数
我把前面零散的手段整合成一个函数,训练时定期调用,能覆盖大部分梯度异常:
def check_gradients(model, step): report = {} for name, p in model.named_parameters(): if p.grad is None: report[name] = "None" else: report[name] = round(p.grad.norm(2).item(), 6) print(f"[step {step}] {report}")输出里如果某个参数长期是 None,说明它没被训练到;如果某个范数异常大,就是爆炸信号。这个函数本身开销不大,但信息量足够定位问题。我一般在前几百步每 50 步调一次,稳定后拉长间隔。
7.2 理解梯度流,比记住 API 更重要
写到这里我想强调一个观点:PyTorch 的梯度 API 就那么几个,backward、zero_grad、detach、no_grad、retain_grad,背下来不难。真正拉开差距的是你能不能在心里画出梯度流——给定一段代码,说出每个张量的梯度从哪来、到哪去、在哪被截断、在哪被累加。这个能力不是靠记 API 得来的,是靠反复调试、反复看.grad和grad_fn练出来的。
我个人的习惯是,遇到任何梯度相关的疑惑,第一反应是打印grad_fn和requires_grad,把计算图在脑子里重建一遍。大部分“莫名其妙”的报错,打印两行就能定位。比如那个经典的“backward second time”报错,只要看一眼 loss 的grad_fn还在不在,就知道图是不是被释放了。
7.3 关于性能的一点经验
最后分享一个容易被忽视的点:梯度计算本身是有成本的。前向时保存中间结果、反向时逐节点求导,这些都要时间和显存。所以推理、评估、特征提取这些不需要梯度的场景,务必用no_grad或inference_mode包起来。我实测过一个中等规模的模型,评估阶段加上no_grad后显存占用降了将近四成,吞吐也提升了一截。这个优化几乎零成本,但很多人写评估代码时就是会忘。
另外,zero_grad(set_to_none=True)比默认的置零方式略快,因为它省去了写零的操作,直接把.grad设成 None,下次 backward 时再分配。较新版本的 PyTorch 默认就是set_to_none=True,如果你用的是老版本,可以手动打开这个选项,积少成多也是收益。
梯度计算这套机制,初看是一堆 API,用熟了会发现它其实是一套很自洽的设计:用计算图记录运算、用叶子节点收拢梯度、用累加语义支持参数共享、用释放机制控制内存。把这几个核心概念串起来,剩下的都是细节。真正吃透之后,你写训练代码会从“照着模板填”变成“知道每一步为什么这么写”,遇到问题也能自己顺着梯度流排查,而不是到处搜报错信息。