PyTorch 面试八股¶
PyTorch动态计算图是如何构建和释放的?¶
PyTorch 采用动态计算图(Define-by-Run)机制,计算图在每次前向传播时动态构建,在反向传播完成后立即释放。
构建过程:
-
当你对一个设置了
requires_grad=True的张量执行操作(如+,*,mm等),PyTorch 会创建一个对应的Function对象,记录该操作的输入、输出以及反向传播所需的梯度计算逻辑。 -
这个
Function对象被链接到输入和输出的Tensor上,形成一条有向无环图(DAG)。图中的节点是张量(数据)和Function(操作),边表示数据流向。 -
每执行一个新操作,计算图就动态增长。因为是动态的,所以图可以随 Python 控制流(如
if-else,for循环)变化而改变形状,非常灵活。
释放过程:
-
当调用
.backward()时,自动求导引擎沿着计算图反向遍历,依次计算每个Function的梯度,并将梯度写入参与的张量的.grad属性中。 -
反向传播完成后,中间张量(非叶子节点)的梯度以及部分计算图结构通常会被释放,以节省内存。这也是为什么多次调用
.backward()时,若不设置retain_graph=True,图会被销毁而无法再次反向传播。
叶子节点:直接由用户创建的、没有历史操作的张量(如 w = torch.randn(3, requires_grad=True))是叶子节点。计算图的“输入”通常是叶子节点,它们的梯度在反向传播后被保留在 .grad 中,用于优化器更新。
2. requires_grad=True 在计算图中起什么作用?¶
requires_grad 是张量的一个属性,它决定了该张量是否需要被追踪梯度,以及是否参与计算图的构建。
-
当一个张量的
requires_grad=True时,所有依赖它的操作所产生的新张量都会自动具有requires_grad=True(除非显式指定为False),并且这些操作都会被注册到计算图中。 -
在反向传播时,只有
requires_grad=True的叶子张量才会被计算并累积梯度(存入.grad)。 -
它相当于告诉 PyTorch:“我希望优化这个张量,请记录所有依赖于它的计算,以便后续自动求导。”
实用场景:
-
模型参数:
nn.Parameter内部将张量的requires_grad自动设为True,因此所有模型参数默认需要梯度。 -
冻结部分网络:在微调时,将某些层设置为
requires_grad=False,可以冻结这些层,只更新其余部分,既节省显存又防止遗忘。 -
输入数据:通常将输入数据的
requires_grad保持为False,因为不需要优化输入,这也能避免构建不必要的计算图。
3. backward() 函数调用后具体计算了哪些梯度?¶
backward() 是自动求导引擎的入口。当调用 loss.backward() 时,引擎会执行以下步骤:
-
从
loss张量开始,沿着计算图反向遍历所有Function节点。 -
对于每一个
Function,调用其预定义的backward()方法,利用链式法则计算该操作的输入梯度。 -
将这些输入梯度传播到更早的节点,并累加到这些节点的
.grad属性中。 -
最终,所有
requires_grad=True的叶子张量都会得到一个.grad,其值等于损失函数对该叶子张量的梯度。
注意:
-
backward()默认只对标量损失工作。如果要对非标量张量调用backward(),必须提供一个与调用张量形状相同的gradient参数,作为外部梯度。 -
中间张量(非叶子)的梯度通常被自动释放,除非特别指定
retain_grad()。
如何只计算部分张量的梯度而忽略其他?¶
可以通过设置张量的 requires_grad 属性来控制。
-
在创建张量时:
x = torch.randn(3, requires_grad=False)。 -
对已有张量:
x.requires_grad_(False)(就地操作)或x = x.detach()(创建一个共享数据但不需要梯度的新张量)。 -
在上下文管理器中临时禁用:使用
torch.no_grad()包裹代码块。在此块内的所有操作都不会构建计算图,即使输入张量requires_grad=True。这在模型评估或推理时非常有用,能显著减少显存占用和计算开销。
微调示例:冻结预训练模型的卷积层,只训练最后的分类头。
叶子节点和非叶子节点在梯度保留上有何区别?¶
-
叶子节点:直接由用户创建的、没有 grad_fn 的张量(例如
nn.Parameter或torch.randn(..., requires_grad=True))。当反向传播完成后,它们的梯度被保留在.grad属性中,这是优化器更新参数的基础。 -
非叶子节点:计算图中的中间结果,例如
y = x * 2中的y。默认情况下,反向传播完成后,非叶子节点的梯度会被立即释放,以节省内存。
为什么这样设计?
在深度学习中,我们通常只关心叶子节点(模型参数)的梯度,而中间节点成千上万,如果全部保留会占用大量显存。因此,PyTorch 默认仅保留叶子节点的梯度。
为什么非叶子节点默认不保留 .grad?如何强制保留?¶
如上所述,这是为了节省内存。中间张量的数量往往极其庞大,保留它们的梯度会迅速耗尽 GPU 显存。
强制保留的方法:
-
在计算之前,对期望保留梯度的非叶子张量调用
.retain_grad()。这样在反向传播后,其.grad不会被自动释放,可以用于调试或可视化。 -
但是,除非有特殊分析需求,否则应避免在常规训练中使用,以免造成内存泄漏。
x = torch.randn(3, requires_grad=True)
y = x * 2
y.retain_grad() # 强制保留 y 的梯度
loss = y.sum()
loss.backward()
print(y.grad) # tensor([1., 1., 1.])
7. retain_graph=True 的作用是什么?什么场景下必须使用?¶
retain_graph 是 backward() 的一个参数。默认 False,反向传播完成后,计算图会被销毁,以释放内存。如果需要在一次反向传播后再次对同一个计算图执行 backward()(例如计算高阶梯度,或者多个损失共享部分网络),就必须设置 retain_graph=True。
必须使用的场景:
-
计算高阶导数:例如
torch.autograd.grad或backward()两次。 -
多个损失函数共享同一个子网络:如果你有
loss1和loss2,它们都依赖于同一个中间输出,但需要分别反向传播。此时对loss1.backward(retain_graph=True),然后对loss2.backward()。
注意:滥用 retain_graph=True 会导致计算图一直占用内存,如果循环中频繁调用会造成显存泄漏。一般只在必要时使用,且任务结束后及时释放(例如手动删除张量或进入下一轮迭代)。
如何对一个张量多次调用 backward?有什么注意事项?¶
可以对一个张量或其不同的损失多次调用 backward(),但需要根据情况设置参数。
-
情况1:同一计算图,多次反向传播(如高阶梯度)。必须设置
retain_graph=True。 -
情况2:不同的计算图,或图已重建。例如在训练循环中,每次迭代都是全新的前向传播,构建新的计算图,因此不需要
retain_graph。 -
梯度累积:如果对一个张量多次
backward(),每次的梯度会累加到叶子节点的.grad中。这常用于实现梯度累积(模拟大 batch)。
注意事项:
-
如果不设置
retain_graph=True而试图第二次backward(),会抛出RuntimeError,提示计算图已被释放。 -
如果多次反向传播之间未清零梯度(
optimizer.zero_grad()),梯度会累加,这可能是期望的(梯度累积)或不期望的(导致梯度爆炸)。
梯度累积是如何实现的?它解决了什么问题?¶
实现原理:
梯度累积是一种在显存受限时模拟大 batch size 的技术。核心步骤:
-
对于每个 mini-batch,执行前向传播、计算 loss。
-
调用
loss.backward(),计算梯度。此时梯度会累加到叶子节点的.grad属性上。 -
重复 1-2 步(多个 mini-batch),不进行
optimizer.step()和optimizer.zero_grad()。 -
当累积了 K 个 mini-batch 的梯度后,调用
optimizer.step()更新参数。 -
调用
optimizer.zero_grad()清空梯度,准备下一轮累积。
解决的问题:
-
突破显存限制:允许在单张 GPU 上用很小的 micro-batch 进行训练,却能获得大 batch 的收敛效果。
-
稳定训练:某些模型需要大 batch 才能收敛,而硬件无法支持时,梯度累积是唯一选择。
-
均衡计算与通信:在分布式训练中,有时也通过梯度累积减少通信频率。
代码示例:
accumulation_steps = 4
for i, data in enumerate(dataloader):
loss = model(data) / accumulation_steps # 注意 loss 缩放
loss.backward()
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
关键:因为每次 backward() 的梯度会相加,所以需要对 loss 进行缩放(除以累积步数),以保证更新的量级与使用真实大 batch 时一致。这是因为 loss.backward() 计算的是梯度对 batch 的累加,我们需保持梯度期望不变。
为什么每次 backward 前需要 optimizer.zero_grad()?¶
PyTorch 的梯度累积机制决定了,如果不手动清零,每次 backward() 计算的梯度会累加到已有的 .grad 中。
-
在训练循环的每一次迭代中,我们处理一个新的 mini-batch。如果不清零,当前 batch 的梯度会与之前所有 batch 的梯度叠加,导致模型参数更新使用了错误的方向和大小。
-
这会导致模型无法收敛,甚至梯度爆炸。
optimizer.zero_grad() 实际上遍历了优化器中的所有参数组,将每个参数的 .grad 设置为 None 或填充零。将梯度设为 None 比填充零更高效,因为它避免了显式归零操作,PyTorch 的自动求导系统在下次赋值时会直接分配新内存。
使用技巧:
-
如果希望进行梯度累积,应在累积期间不调用
zero_grad(),而是在累积完成后、optimizer.step()更新参数之前才清零。 -
务必确保
zero_grad()在backward()之前调用,而不是在step()之前调用;否则上一次迭代的梯度会污染本次迭代。
忘记清零梯度会导致什么后果?¶
严重后果:
-
梯度污染:当前 batch 的梯度会与之前所有 batch 的梯度加和,导致参数更新混乱。
-
训练不稳定/发散:尤其在训练的后期,梯度量级可能很大,叠加后更容易发生梯度爆炸。
-
错误的学习动态:模型无法学到当前 batch 的真实信息,而是被历史信息干扰,使损失曲线剧烈震荡或无法下降。
示例:
# 错误示例:忘记 zero_grad()
for data in dataloader:
loss = model(data)
loss.backward()
optimizer.step() # 每次都在累积梯度,导致参数更新完全错误
解决方案:在每次 backward() 前(或 optimizer.step() 后)调用 optimizer.zero_grad()。对于梯度累积,严格在累积步数结束时调用 step(),然后立即清零。
因此,“清零梯度”是深度学习训练循环中必须遵守的铁律。它与梯度累积的概念是统一的:累积是在多次 backward 之间不清零,但最终更新后必须清零。
如何查看计算图中某个中间张量的梯度?¶
默认情况下,反向传播完成后,计算图中的非叶子节点(中间张量)的梯度会被立即释放以节省内存。要查看它的梯度,必须在计算前调用 .retain_grad() 方法显式指示PyTorch保留该张量的梯度。
x = torch.randn(3, requires_grad=True)
w = torch.randn(3, requires_grad=True)
y = x * w # y 是非叶子节点,中间张量
y.retain_grad() # 强制保留 y 的梯度
loss = y.sum()
loss.backward()
print(y.grad) # 可以正常查看,输出 tensor([1., 1., 1.])
注意事项:
-
使用
retain_grad()会占用额外显存,通常仅用于调试或特定可视化场景,不应在常规训练中滥用。 -
如果不调用
retain_grad(),y.grad将为None。
detach() 和 clone() 的区别有哪些?分别用于什么场景?¶
两者都用于从当前计算图中分离张量,但行为和用途不同。
核心区别:
-
b = a.detach():b与a共享同一块存储,修改b的 in‑place 会影响a。b的requires_grad为False,从a到b的梯度完全阻断。它常用于阻止梯度回流到原始计算图。 -
c = a.clone():c拥有全新的存储,与a完全独立。默认情况下,clone()会保留梯度流(如果原张量需要梯度)。若想切断梯度,需调用a.clone().detach()。
场景示例:
-
detach():在强化学习的Actor‑Critic算法中,Critic网络更新时,需要用到Actor的输出但不希望梯度从Critic流回Actor。此时可以actor_output.detach()。 -
clone():当你需要修改一个张量,但又想保留原始版本进行反向传播时(例如某些训练技巧),可以copy = tensor.clone()。
torch.no_grad() 和 torch.inference_mode() 在使用和性能上有什么不同?¶
两者都用于禁用梯度计算,但 torch.inference_mode() 是更高效、更专注的版本,专门为推理场景设计。
使用建议:
-
在训练循环中的验证阶段,使用
with torch.no_grad():。 -
在模型部署/线上推理的代码中,优先使用
with torch.inference_mode():,以获得最佳的推理速度和内存效率。
如何在局部重新启用梯度计算?¶
在 torch.no_grad() 或 torch.inference_mode() 包裹的代码块内,可以通过嵌套一个 torch.enable_grad() 上下文,临时重新启用梯度计算。
x = torch.randn(3, requires_grad=True)
with torch.no_grad():
# 此处梯度计算被禁用
y = x * 2 # y.requires_grad == False
with torch.enable_grad():
# 局部重新启用梯度
z = x * 3 # z.requires_grad == True
loss = z.sum()
loss.backward()
print(x.grad) # 正常计算并保留了梯度
应用:某些自定义层或复杂的推理逻辑中,可能大部分前向传播不需要梯度,但其中一小部分需要根据输入动态调整并需要梯度信号。
怎样计算高阶梯度?create_graph 参数的作用是什么?¶
计算二阶导数:需要让第一次反向传播的梯度也参与计算图构建,以便对其进行求导。这通过在 backward() 或 torch.autograd.grad() 中设置 create_graph=True 实现。
create_graph=True 的作用是:构建一个计算图来追踪梯度计算本身。这样,第一次求导产生的梯度张量也是一个 requires_grad=True 的计算图节点,可以对它再次调用 backward() 计算二阶导数。
示例:计算二阶导
x = torch.randn(3, requires_grad=True)
y = x ** 3
# 一阶导
grad1 = torch.autograd.grad(y, x, create_graph=True)[0] # grad1 = 3x^2,且保留了计算图
# 二阶导(对 grad1 求和再反向传播)
grad2 = torch.autograd.grad(grad1.sum(), x)[0] # grad2 = 6x
如何对网络参数求二阶导数?举例说明。¶
对网络参数求二阶导数常用于实现某些元学习算法(如MAML)、模型正则化(如梯度惩罚)或影响函数计算。
示例:计算参数的二阶导
model = nn.Linear(2, 1)
x = torch.randn(1, 2)
y = model(x)
# 保留计算图以计算一阶梯度的梯度
grad_params = torch.autograd.grad(y, model.parameters(), create_graph=True)
# grad_params 是一个元组,每个元素是每个参数的一阶梯度,且它们需要grad
# 计算二阶导(例如对所有权重一阶导的平方和求二阶导)
loss = sum(g.norm() for g in grad_params) # 一阶导范数
loss.backward() # 此时 model.parameters() 的 .grad 即为二阶导
torch.autograd.grad 和 .backward() 在功能上有何异同?¶
-
loss.backward():直接计算梯度并累加到叶子节点的.grad属性中。它自动遍历整个图,不需要指定输出和输入。适合标准训练。 -
torch.autograd.grad(outputs, inputs):返回计算出的梯度列表,不会修改叶子节点的.grad。它需要明确指定输出和输入,更灵活,可用于高阶导数和非标量输出的情况。
示例对比:
x = torch.randn(3, requires_grad=True)
y = x * 2
loss = y.sum()
# 使用 backward()
loss.backward()
print(x.grad) # tensor([2., 2., 2.])
x.grad = None # 清空梯度
# 使用 autograd.grad
grads = torch.autograd.grad(loss, x)
print(grads[0]) # tensor([2., 2., 2.])
print(x.grad) # None,x.grad 未被修改
如何获取模型参数相对于某个中间变量的梯度?¶
使用 torch.autograd.grad() 并指定 inputs 为模型参数,outputs 为中间变量。这允许你分析某个中间表示对模型参数的敏感性。
x = torch.randn(1, 10)
model = nn.Linear(10, 5)
mid = model(x) # 中间变量
# 计算模型权重和偏置相对于 mid 的每个元素的梯度(Jacobian)
for i in range(mid.size(-1)):
grads = torch.autograd.grad(mid[0, i], model.parameters(), retain_graph=True)
print(f"Feature {i}: weight grad shape {grads[0].shape}")
这在特征可视化、可解释性研究(如Grad‑CAM)中非常常用。
什么是计算图的可微性?哪些操作会导致不可导?¶
可微性是指一个函数在给定点处存在导数,其计算图中所有操作必须支持链式法则的梯度传递。PyTorch的自动求导引擎要求图中的每一个 Function 都实现了 backward() 方法。
导致不可导的常见操作:
-
非连续操作:如
x.round()、x.int()、x == y(布尔运算),这些操作在数学上梯度为零或未定义(阶跃函数)。 -
原位修改:对需要梯度的叶子张量进行
x[0] = new_val或x.add_(1)等操作,会破坏计算图历史。 -
索引赋值:
x[idx] = val在反向传播时可能无法正确传递梯度(除非使用index_put等特定实现)。 -
直接使用 Python 原生控制流:只要操作本身是可微的PyTorch操作,
if和for不破坏可微性。
处理:对于不可导操作,PyTorch 会尝试给出一个近似梯度,或者直接报错。在自定义 Function 时,你需要手动为不可导操作定义一个合理的反向梯度(例如直通估计 STE)。
对于不可导操作,PyTorch会如何处理梯度?¶
PyTorch的自动求导引擎遇到不可导操作时,通常会有以下行为:
-
抛出异常:如果该操作没有实现
backward,反向传播时会报错。 -
自动处理:例如
x > 0返回布尔值,其requires_grad为False,不参与梯度图。 -
提供近似梯度:如
nn.ReLU在 x=0 处的梯度为0,这是人为定义的子梯度。 -
直通估计(STE):在自定义函数中,常见的做法是在反向时直接将梯度原样传递,认为前向的不可导操作近似为单位变换。例如,对于
round(),可以定义backward为返回与输入相同的梯度。
自定义 torch.autograd.Function 需要实现哪两个方法?¶
需要实现 forward 和 backward 两个静态方法。
-
forward(ctx, *args):执行前向运算,接受张量参数,返回输出张量。ctx对象用于保存反向传播所需的信息。 -
backward(ctx, *grad_outputs):接收梯度输出(与forward输出形状相同),根据ctx中保存的中间数据,计算并返回相对于forward各个输入的梯度。
在自定义 Function 中,ctx 对象有什么用?如何保存中间结果?¶
ctx 是一个上下文管理对象,用于在 forward 和 backward 之间传递和保存信息。
-
保存:
ctx.save_for_backward(*tensors)将前向传播中的中间结果(非标量张量)保存下来,供反向传播使用。这些张量在反向时会自动取出,并从计算图中安全地分离。 -
存储其他信息:你可以将任意Python对象(如整数、列表)直接赋值给
ctx的属性(如ctx.my_val = 10)。
class MyLinear(nn.Function):
@staticmethod
def forward(ctx, input, weight, bias=None):
ctx.save_for_backward(input, weight) # 保存反向所需数据
output = input.mm(weight.t())
if bias is not None:
output += bias
return output
@staticmethod
def backward(ctx, grad_output):
input, weight = ctx.saved_tensors # 取出保存的张量
grad_input = grad_weight = grad_bias = None
if ctx.needs_input_grad[0]:
grad_input = grad_output.mm(weight)
if ctx.needs_input_grad[1]:
grad_weight = grad_output.t().mm(input)
return grad_input, grad_weight, grad_bias
注意:save_for_backward 只能保存张量,且要求它们是 forward 的输入或输出,不能保存中间计算产生的临时张量(除非也将其作为额外输出)。正确保存数据是自定义高效、正确反向传播的关键。