跳转至

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() 时,引擎会执行以下步骤:

  1. loss 张量开始,沿着计算图反向遍历所有 Function 节点。

  2. 对于每一个 Function,调用其预定义的 backward() 方法,利用链式法则计算该操作的输入梯度。

  3. 将这些输入梯度传播到更早的节点,并累加到这些节点的 .grad 属性中。

  4. 最终,所有 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。这在模型评估或推理时非常有用,能显著减少显存占用和计算开销。

微调示例:冻结预训练模型的卷积层,只训练最后的分类头。

for param in model.backbone.parameters():
    param.requires_grad = False
# 之后只有分类头的参数会接收梯度更新

叶子节点和非叶子节点在梯度保留上有何区别?

  • 叶子节点:直接由用户创建的、没有 grad_fn 的张量(例如 nn.Parametertorch.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_graphbackward() 的一个参数。默认 False,反向传播完成后,计算图会被销毁,以释放内存。如果需要在一次反向传播后再次对同一个计算图执行 backward()(例如计算高阶梯度,或者多个损失共享部分网络),就必须设置 retain_graph=True

必须使用的场景:

  1. 计算高阶导数:例如 torch.autograd.gradbackward() 两次。

  2. 多个损失函数共享同一个子网络:如果你有 loss1loss2,它们都依赖于同一个中间输出,但需要分别反向传播。此时对 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 的技术。核心步骤:

  1. 对于每个 mini-batch,执行前向传播、计算 loss。

  2. 调用 loss.backward(),计算梯度。此时梯度会累加到叶子节点的 .grad 属性上。

  3. 重复 1-2 步(多个 mini-batch),不进行 optimizer.step()optimizer.zero_grad()

  4. 当累积了 K 个 mini-batch 的梯度后,调用 optimizer.step() 更新参数。

  5. 调用 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() 之前调用;否则上一次迭代的梯度会污染本次迭代。


忘记清零梯度会导致什么后果?

严重后果:

  1. 梯度污染:当前 batch 的梯度会与之前所有 batch 的梯度加和,导致参数更新混乱。

  2. 训练不稳定/发散:尤其在训练的后期,梯度量级可能很大,叠加后更容易发生梯度爆炸。

  3. 错误的学习动态:模型无法学到当前 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()ba 共享同一块存储,修改 b 的 in‑place 会影响 abrequires_gradFalse,从 ab 的梯度完全阻断。它常用于阻止梯度回流到原始计算图。

  • 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():,以获得最佳的推理速度和内存效率。

model.eval()
with torch.inference_mode():  # 推理模式
    output = model(input_data)

如何在局部重新启用梯度计算?

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_valx.add_(1) 等操作,会破坏计算图历史。

  • 索引赋值:x[idx] = val 在反向传播时可能无法正确传递梯度(除非使用 index_put 等特定实现)。

  • 直接使用 Python 原生控制流:只要操作本身是可微的PyTorch操作,iffor 不破坏可微性。

处理:对于不可导操作,PyTorch 会尝试给出一个近似梯度,或者直接报错。在自定义 Function 时,你需要手动为不可导操作定义一个合理的反向梯度(例如直通估计 STE)。


对于不可导操作,PyTorch会如何处理梯度?

PyTorch的自动求导引擎遇到不可导操作时,通常会有以下行为:

  • 抛出异常:如果该操作没有实现 backward,反向传播时会报错。

  • 自动处理:例如 x > 0 返回布尔值,其 requires_gradFalse,不参与梯度图。

  • 提供近似梯度:如 nn.ReLU 在 x=0 处的梯度为0,这是人为定义的子梯度。

  • 直通估计(STE):在自定义函数中,常见的做法是在反向时直接将梯度原样传递,认为前向的不可导操作近似为单位变换。例如,对于 round(),可以定义 backward 为返回与输入相同的梯度。


自定义 torch.autograd.Function 需要实现哪两个方法?

需要实现 forwardbackward 两个静态方法。

  • forward(ctx, *args):执行前向运算,接受张量参数,返回输出张量。ctx 对象用于保存反向传播所需的信息。

  • backward(ctx, *grad_outputs):接收梯度输出(与 forward 输出形状相同),根据 ctx 中保存的中间数据,计算并返回相对于 forward 各个输入的梯度。


在自定义 Function 中,ctx 对象有什么用?如何保存中间结果?

ctx 是一个上下文管理对象,用于在 forwardbackward 之间传递和保存信息。

  • 保存: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 的输入或输出,不能保存中间计算产生的临时张量(除非也将其作为额外输出)。正确保存数据是自定义高效、正确反向传播的关键。