第4章 梯度与计算图:计算图原理、梯度存储机制、retain_graph与detach的妙用

说到深度学习,绕不开的就是梯度。而梯度的背后,是计算图在默默工作。

我刚开始接触PyTorch时,对计算图的理解很模糊。只知道调backward()就能算梯度,至于它怎么算的、内存怎么管理的,完全没概念。直到有一次,我训练一个稍复杂的模型,显存直接爆了。排查半天,发现是计算图没释放。嗯,从那以后,我开始认真研究这玩意儿。

计算图:到底是个啥?

说白了,计算图就是记录你所有运算过程的一张“流程图”。

你想想看,我们做前向传播时,每个张量是怎么来的?

  • a = torch.tensor([2.0], requires_grad=True) — 这是叶子节点
  • b = a * 3 — 这是运算结果
  • c = b ** 2 — 继续运算

PyTorch会在背后偷偷记下:a → 乘法 → b → 平方 → c。这就是一张有向无环图(DAG)。

我习惯把计算图想象成“案发现场重建”。前向传播时,每个操作都留下线索。反向传播时,就顺着这些线索往回走,用链式法则算出每个节点的梯度。

核心要点:计算图只在requires_grad=True的张量参与运算时才会构建。如果你用torch.no_grad()包裹,就不会建图,自然也不会有梯度。

下面这张图,展示了计算图的基本结构:

计算图结构示例 a (叶子节点) requires_grad=True 乘法操作 b = a * 3 平方操作 c = b ** 2 损失函数 loss = c.mean() 梯度 ∂loss/∂b grad_fn: PowBackward 梯度 ∂loss/∂a grad_fn: MulBackward 前向传播 反向传播(梯度)

梯度存储机制:显存里的隐形杀手

每次调用backward(),PyTorch都会计算梯度,然后默认把梯度累加到张量的.grad属性上。

但这里有个坑:计算图本身也会占用显存。

为什么?因为PyTorch需要保存中间结果,才能在反向传播时计算梯度。比如上面的例子,计算c对b的梯度需要b的值,计算b对a的梯度需要a的值。这些中间结果,都保存在计算图里。

我曾经踩过的坑:训练一个GAN,生成器和判别器交替训练。每次调用backward()后,计算图还留在内存里。迭代了几百步,显存从8GB涨到12GB,最后直接OOM。排查了半天,才发现是计算图没释放。

默认情况下,每次backward()执行完后,计算图会被释放(free)。但如果你需要多次反向传播,就得用retain_graph=True。

retain_graph:什么时候用?

retain_graph,顾名思义,就是保留计算图。

什么时候需要?最常见的就是GAN训练:

# 生成器前向
fake = generator(z)
# 判别器判断真假
d_real = discriminator(real)
d_fake = discriminator(fake.detach())

# 判别器损失
d_loss = d_real_loss(d_real, real_labels) + d_fake_loss(d_fake, fake_labels)
d_loss.backward()  # 第一次反向传播
optimizer_d.step()

# 生成器损失
g_loss = g_loss(discriminator(fake), real_labels)
g_loss.backward()  # 第二次反向传播,需要保留计算图
optimizer_g.step()

等等,上面这段代码其实有问题。第一次backward()后,计算图就被释放了。第二次backward()时,discriminator(fake)的计算图已经没了。

正确的做法:

d_loss.backward(retain_graph=True)  # 保留计算图
optimizer_d.step()

g_loss.backward()  # 这次可以正常释放了
optimizer_g.step()

我的建议:能用detach就别用retain_graph。retain_graph会让计算图一直留在内存里,显存压力很大。我一般只在必须多次反向传播时才用,用完马上释放。

detach:切断计算图的利器

detach()的作用很简单:从计算图中分离出一个张量。分离后的张量不再需要梯度,也不会参与反向传播。

我举个例子:

x = torch.tensor([2.0], requires_grad=True)
y = x * 3
z = y.detach()  # z不再需要梯度
w = z ** 2

w.backward()  # 梯度只传到y,不会传到x
print(x.grad)  # None

detach在强化学习中特别常用。比如PPO算法,旧策略采样的数据需要detach掉,不让梯度流回去。

还有一个经典场景:特征提取 + 分类器微调。

# 预训练的特征提取器
features = backbone(inputs)
# detach掉,只训练分类器
features = features.detach()
outputs = classifier(features)
loss = criterion(outputs, labels)
loss.backward()

这样,backward()时梯度只更新分类器,不会动backbone的参数。既省显存,又防止特征提取器被破坏。

核心区别总结:

方法 作用 显存影响 典型场景
retain_graph=True 保留计算图,允许多次backward 增加显存占用 GAN、多任务学习
detach() 切断张量与计算图的连接 减少显存占用 特征提取、强化学习
torch.no_grad() 上下文管理器,整个代码块不建图 大幅减少显存 推理、评估

实战避坑指南

我总结几个常见的坑,都是我在项目中真实遇到过的:

  1. 梯度累加时忘记清零:每次backward()后,梯度会累加到.grad上。如果你做梯度累加(gradient accumulation),记得手动zero_grad()。不然梯度会越加越大。
  2. in-place操作破坏计算图:比如x.add_(1),这种操作会修改原始张量,导致计算图出错。PyTorch会报错:RuntimeError: a leaf Variable that requires grad has been used in an in-place operation.
  3. 循环中创建计算图不释放:如果你在循环里反复创建计算图,又不及时释放,显存会持续增长。我习惯在循环末尾显式调用del variable,或者用with torch.no_grad()包裹不需要梯度的部分。
  4. 多GPU时计算图复制:使用DataParallel时,每个GPU上都会复制一份计算图。显存占用直接翻倍。我建议用DistributedDataParallel,效率更高,显存也更省。

一个小技巧:调试时可以用torchviz库可视化计算图。安装后调用make_dot(loss)就能生成计算图PDF。我第一次看到自己模型的计算图时,才发现有些分支根本不需要梯度,白白浪费了显存。

嗯,关于计算图和梯度,今天就聊这么多。记住一句话:计算图是梯度的载体,也是显存的消耗者。用好retain_graph和detach,你就能在显存和功能之间找到平衡。

想要更多资料,联系可可