写在前面:Pytorch 内容繁杂,实验中用到的技巧与机制若不及时记录,容易遗忘并导致重复造轮子。本文档用于记录 Pytorch 的相关技巧与机制,将持续更新,方便后续查阅。
动态计算图
缘起
在一次实验日志中,发现一个固定的 CNN 模块推理时间存在较大波动。排查后发现是数据流水线卡顿或 GPU 内存管理抖动所致,但也因此注意到了 Pytorch 的动态计算图机制。
Pytorch 在每次前向传播时都会重新构建计算图,这意味着每次前向传播都会产生额外开销,尤其在模型结构复杂或输入数据变化较大时,可能导致推理时间波动。
具体而言:
- 每次前向传播,即便模型结构未变,Pytorch 仍会依据当前输入数据和模型结构重建计算图。
- 若输入数据形状或模型结构发生变化,计算图会重新构建,进而引发性能波动。
可能导致性能波动的情况
1. 条件分支结构
模型中包含数据依赖的条件分支(如 if 语句)时,不同前向传播可能执行不同计算路径,导致计算图结构变化。
class BadEncoder(nn.Module):
def forward(self, x):
if x.sum() > 0: # 数据依赖的条件
x = self.heavy_conv(x)
else:
x = self.light_conv(x)
return x
2. 变长序列
处理变长序列(如 RNN 或 Transformer)时,每次输入长度可能不同,导致计算图结构变化。
class BadRNN(nn.Module):
def forward(self, x):
for i in range(x.size(1)): # 依赖输入序列长度
x = self.rnn_cell(x[:, i, :], x)
return x
3. 其他情况
- 动态形状
- 随机性操作
- 稀疏操作
解决方案
- 使用静态计算图:若模型结构固定,可借助 TorchScript 或 ONNX 转换为静态计算图,避免每次前向传播都重新构建。
- Padding + Mask:对于变长序列,采用填充至统一长度,并利用 mask 标识有效部分,保持计算图结构一致。
模型训练/推理速度分析与优化
背景
修改模型与训练代码后发现速度明显下降。排查发现数据加载耗时过长,遂将 DataLoader 的在线预处理改为离线处理。但模型推理速度仍慢于基线,后续需进一步优化。
基本概念
1. CUDA 异步机制
CPU 向 GPU 发送指令后会立即继续执行下一行代码。若不强制同步,使用 time.time() 测量的仅仅是“CPU 发送指令的时间”。正确测量需使用 torch.cuda.synchronize() 强制同步。
2. 参数量、FLOPs、计算瓶颈与访存瓶颈
若 train_loop 运行缓慢,可能属于:
- 计算瓶颈
- 访存瓶颈
定位问题
- 使用
htop观察 CPU 核心占用率:是否单核饱和或所有核闲置。 - 使用
watch -n 1 nvidia-smi观察 GPU 的GPU-Util与Power Usage:若利用率在 0% 与 100% 间剧烈波动,通常是数据加载跟不上。
两种瓶颈也可能同时存在。
优化方向
数据加载(访存)问题
- 线程相关:增大
num_workers以充分利用多核 CPU。 - 预处理复杂:尽量将预处理逻辑离线完成,减少在线预处理时间。
- Pin Memory:设置
pin_memory=True。
计算瓶颈问题
针对计算图优化,推荐使用 PyTorch Profiler。示例代码如下:
import torch
import torch.nn as nn
import torch.optim as optim
from torch.profiler import profile, record_function, ProfilerActivity, tensorboard_trace_handler
class SimpleModel(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(1000, 1000)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(1000, 10)
def forward(self, x):
return self.fc2(self.relu(self.fc1(x)))
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleModel().to(device)
optimizer = optim.SGD(model.parameters(), lr=0.01)
criterion = nn.CrossEntropyLoss()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1),
on_trace_ready=tensorboard_trace_handler('./log/profiler_result'),
record_shapes=True,
profile_memory=True,
with_stack=True
) as prof:
for i in range(10):
inputs = torch.randn(32, 1000).to(device)
labels = torch.randint(0, 10, (32,)).to(device)
with record_function("model_forward"):
outputs = model(inputs)
with record_function("loss_backward"):
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
with record_function("optimizer_step"):
optimizer.step()
prof.step()
# 按 GPU 总耗时排序
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))
# 按 CPU 总耗时排序
print(prof.key_averages().table(sort_by="cpu_time_total", row_limit=10))

上图为一个简单的 PyTorch Profiler 分析示例。第一个表展示各操作符(
aten::*)的耗时与内存变化,第二个表更侧重模型定义层次。
Profiler 输出关键列说明
| 列名 | 含义 |
|---|---|
| Self CPU % | 该函数自身消耗的 CPU 时间占比(不含调用的子函数) |
| Self CPU | 自身 CPU 时间的绝对值 |
| CPU total % | 该函数 + 所有子函数的 CPU 时间占比 |
| CPU time avg | 平均每次调用的 CPU 时间 |
| CPU Mem | 该函数及子函数的 CPU 内存净变化(正=分配,负=释放) |
| Self CPU Mem | 仅该函数自身的 CPU 内存变化 |
| CUDA Mem | 该函数及子函数的 GPU 内存净变化 |
| Self CUDA Mem | 仅该函数自身的 GPU 内存变化 |
| # of Calls | 调用次数 |
示例分析(因 WSL2 CUPTI 限制未看到 CUDA 层开销,但思路一致)
第一张表(操作符级)
CUDA Mem出现负数(如 -375.75 KB),表示释放 GPU 显存。在前向+反向传播中,这通常意味着中间结果被释放或显存复用,属于正常现象。- 主要耗时在
aten::to(数据类型转换或设备拷贝)和aten::randn(CPU 生成随机数再拷贝到 GPU),建议直接在 GPU 上生成随机数或提前生成张量。
第二张表(逻辑分组)
| 逻辑模块 | Self CPU % | 说明 |
|---|---|---|
| loss_backward | 24.47% | 反向传播占用大量 CPU 控制逻辑(非 GPU 计算) |
| aten::to | 22.53% | 数据类型/设备转换问题 |
| Autograd (AddmmBackward0) | 0.59% (Self) / 4.42% (Total) | 矩阵乘法反向,Self 低但 Total 高,说明调用了底层 CUDA 核函数 |
| model_forward | 2.07% | 前向传播 CPU 调度开销很小,说明大部分时间在 GPU |
| optimizer_step | 0.45% | 优化器步进调度开销不高 |
优化建议
- 避免
aten::to频繁调用:直接在 GPU 上生成数据或复用已有张量。 - 减少 CPU 端随机数生成:使用 GPU 上的随机生成器。
- 降低反向传播 CPU 调度开销:考虑混合精度训练(如 Apex)、梯度累积、算子融合或转为静态图(TorchScript/ONNX)。
- 显存复用正常:前向传播后的显存释放是正常复用机制,无需担心。
后续将分别针对训练侧和推理侧专门撰写优化文章。
注意事项
- 推理速度不同于训练速度:推理无反向传播开销。
- 需进行 Warm-up 后再测量真实推理耗时。
- ONNX、TensorRT 等端侧静态图部署是另一类优化策略,后续可展开讨论。
DDP过程中冻结参数训练导致DDP规约梯度失败
问题描述
起因依旧是论文中DDP的代码,在训练过程中需要冻结部分参数进行训练,和NanoVLM中的训练方式类似,但是实现方式不同,NanoVLM中视觉模型和语言模型以及Connect模块是分开训练的,而我的论文代码中是一起训练的,并且冻结部分不会一直冻结,在5个epoch后会解冻继续训练,所以我再forward函数中使用了分支来实现冻结参数的训练,伪代码大概是下边这样:
def forward(self, x, epoch):
x = layer1(x)
if epoch < 5:
# 冻结部分参数
with torch.no_grad():
x = self.layer2(x)
else:
# 正常训练
x = self.layer2(x)
return x
这样会导致DDP产生如下报错,模型中存在参数在某些 forward 过程中未被用于计算 loss,导致梯度同步失败。:
RuntimeError: Expected to have finished reduction in the prior iteration before starting a new one. This error indicates that your module has parameters that were not used in producing loss. You can enable unused parameter detection by (1) passing the keyword argument `find_unused_parameters=True` to `torch.nn.parallel.DistributedDataParallel`; (2) making sure all `forward` function outputs participate in calculating loss. If you already have done the above two steps, then the distributed data parallel module wasn't able to locate the output tensors in the return value of your module's `forward` function. Please include the loss function and the structure of the return value of `forward` of your module when reporting this issue (e.g. list, dict, iterable).
实际上,DDP期望所有模型参数都要参与 loss 的计算(即都有梯度),但实际运行时,某些参数被“闲置”了。这通常发生在:
- 模型中存在条件分支,导致部分参数在某些前向传播中未被使用。
- 某些模块的输出没有传给 loss 函数,或者被 detach 了。
解决方法
有一个比较简单的解决方法是在初始化 DDP 时,设置 find_unused_parameters=True,这样 DDP 会自动检测哪些参数未被使用,并跳过这些参数的梯度同步。
model = torch.nn.parallel.DistributedDataParallel(model, find_unused_parameters=True)
但需要注意的是,启用 find_unused_parameters=True 会增加一定的开销,因为 DDP 需要额外的通信来检测未使用的参数。
因此,如果可以通过调整模型结构或训练流程来确保所有参数都参与计算,则更推荐这种方式,这就是第二种方法,第二种方法有很多实现方式,归根到原理就是你需要确保参数都有梯度,你可以通过分离冻结参数的模块进行训练,或者在 forward 函数中确保所有参数都参与计算(即使是被冻结的参数也要参与计算,但不更新它们的梯度,或者手动设置梯度为零)。
# forward 函数中确保所有参数都参与计算
def forward(self, x, epoch):
x = self.layer1(x)
x = self.layer2(x) # 始终正常前向,不断开梯度
x = self.layer3(x)
return x
# 冻结 layer2 的参数
optimizer = torch.optim.Adam([
{'params': model.module.layer2.parameters(), 'lr': 0.0}, # 前5个epoch不更新
{'params': model.module.layer1.parameters()},
{'params': model.module.layer3.parameters()},
], lr=1e-3)
# epoch 5 后更新 layer2 的参数
for param_group in optimizer.param_groups:
if param_group['params'][0] is model.module.layer2.parameters().__next__(): # 简单判断
param_group['lr'] = 1e-3
还有很多方式能实现这个目标,关键是要确保所有参数都参与计算,并且通过手动调整优化器或者冻结参数的梯度来保证梯度不是None但是也不更新这些参数。
当然,还有一种方式是训练开始前手动设置冻结参数的 requires_grad=False,这样这些参数就不会参与梯度计算,也不会引发 DDP 的同步问题,但是如果后续需要解冻这些参数进行训练,就需要重新设置 requires_grad=True,并且重新初始化 DDP,这样会比较麻烦。
# 冻结参数
for param in model.layer2.parameters():
param.requires_grad = False
# 训练前设置 DDP
model = torch.nn.parallel.DistributedDataParallel(model)
# 5 epoch 后解冻参数
for param in model.layer2.parameters():
param.requires_grad = True
# 重新初始化 DDP
model = torch.nn.parallel.DistributedDataParallel(model)
总之,解决这个问题的关键是确保所有参数都参与计算,或者正确设置 DDP 的参数来处理未使用的参数。
还有一个不太相关的小点,DDP本质是对raw_model进行封装,所以raw_model在每个进程的训练过程一直存在,可以通过model.module来访问原始模型的属性和方法,也可以直接调用raw_model的forward函数来进行前向传播。
这个可以用在一些特殊的训练需求中,例如,在对输入进行某种干扰之后,调用模型的forward得到结果进行自监督,这时候使用raw_model.forward可以避免一些DDP的限制,因为这时候DDP不会管理这次前向的信息。