在深度学习开发里,训练慢通常不是单一原因:有时是数据喂得太慢,有时是显存被浪费了,也有时只是把硬件用得不够满。下面这 9 个方法,基本都围绕 PyTorch 和 PyTorch-Lightning 的常见做法展开,偏实战,不追求花哨,但对大多数项目都能直接见效。
PyTorch-Lightning 只是把训练流程包了一层,核心还是 PyTorch。本身不会替你把模型变快,但它把很多参数和训练策略收拢得更清楚,适合拿来说明这些优化点。
1. 先把数据加载做好
训练速度卡住,最常见的地方其实不是 GPU,而是数据读取。比起把数据整成 h5py 或 numpy 再在主进程里慢慢读,直接用 DataLoader 往往更省事,也更容易把吞吐拉起来。图像任务可以直接上 PyTorch 的数据集和 DataLoader,NLP 场景则可以看看 TorchText。
在 PyTorch-Lightning 里,数据管道配置好之后,训练循环就不用自己一层层写了,框架会按你提供的 DataLoader 去跑。
from torch.utils.data import DataLoader
from torchvision.datasets import MNIST
dataset = MNIST(root='./data', train=True, download=True)
loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True)
for batch in loader:
x, y = batch
model.training_step(x, y)
这里 shuffle=True 是为了打乱样本顺序,pin_memory=True 则能让 CPU 到 GPU 的拷贝更顺一点。这个参数经常被忽略,但在 GPU 训练里挺实用。
2. 把 num_workers 调起来
DataLoader 默认是主进程读数据,数据集一大,IO 就会拖后腿。num_workers 的作用就是把加载工作拆到多个进程里,减少等待时间。
# 慢:主进程加载
loader = DataLoader(dataset, batch_size=32, shuffle=True)
# 快:启用 4 个 worker 进程
loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)
一般可以从 4 或 8 开始试,接近 CPU 核心数不一定最优,但比完全不开强很多。别一上来就拉满,worker 太多会和别的任务抢内存、抢磁盘,最后反而抖得更厉害。
3. 批量大小尽量用满显存
batch size 往大调,通常是最直接的提速手段。更大的 batch 能让 GPU 更连续地干活,减少一次次小批次切换带来的浪费。很多时候,模型不是算不过来,而是算得太碎。
不过这一步有明显代价:显存会上去,学习率也往往要跟着改。常见做法是按线性缩放思路去调学习率,但具体还是得结合模型和数据看,不是机械套公式就行。
4. 显存不够时,用梯度累积
如果显存撑不起大 batch,梯度累积比硬拆模型要现实得多。它的思路很简单:先分几次算小 batch,把梯度攒起来,再统一更新一次参数。效果上接近大 batch,代价是单步会慢一点,但总比直接 OOM 强。
optimizer.zero_grad()
scaled_loss = 0
accumulated_steps = 4
for i in range(accumulated_steps):
out = model.forward()
loss = some_loss(out, y) / accumulated_steps
loss.backward()
scaled_loss += loss.item()
optimizer.step()
actual_loss = scaled_loss
PyTorch-Lightning 里就更简单了,直接配 accumulate_grad_batches:
trainer = Trainer(accumulate_grad_batches=4)
trainer.fit(model)
这类优化的好处是不用改模型结构,坏处是训练节奏会变慢一点,但在显存紧张的时候很值。
5. 记录 loss 时别把计算图一起存了
这个坑很隐蔽。很多人为了后面画曲线,直接把 loss append 到列表里,结果把整张计算图也一起留住了,显存回收不了。
# 错误:保留计算图副本
losses.append(loss)
# 正确:仅存储数值
losses.append(loss.item())
.item() 会把标量取成普通 Python 数值,计算图也就断开了。只是记录日志的话,这么做就够了。
6. 单卡训练时,别让 CPU 和 GPU 来回折腾
单 GPU 训练并不等于'把模型丢上去就完了'。模型和输入都要在同一个设备上,传输次数越少越好。Lightning 这类框架会帮你处理一部分,但底层逻辑还是要知道。
model.cuda()
x = x.cuda()
out = model(x)
如果每一步都在频繁搬数据,GPU 很容易空转。torch.cuda.empty_cache() 这种操作只适合你明确知道自己在做什么的时候用,别把它当成常规清理手段,它不会 magically 解决内存问题,反而可能让同步更频繁。
7. 混合精度通常值得开
FP16 的直接收益很明显:显存占用更低,很多新卡上算得也更快。混合精度不是把所有计算都换成 16 位,而是该保留精度的地方保留,能降精度的地方降一点,整体上更平衡。
原生 PyTorch 可以用 AMP:
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
with autocast():
output = model(input)
loss = criterion(output, target)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
在 PyTorch-Lightning 里,通常直接设 precision=16 就能开起来:
trainer = Trainer(precision=16)
trainer.fit(model)
这类优化一般属于'先开了再看效果',成本低,收益往往不差。
8. 多 GPU 时优先考虑分布式训练
多卡训练里,最容易踩坑的是把概念混在一起。DataParallel 能用,但单机多卡下性能和稳定性都不算理想,更多时候我会优先看 DistributedDataParallel。简单说,前者更像把模型复制几份,后者更像让每张卡各干各的,再同步梯度。
分批次训练(DataParallel)
model = DataParallel(model, device_ids=[0, 1, 2, 3])
out = model(x)
Lightning 里可以直接指定多卡:
# 旧写法示意
trainer = Trainer(gpus=[0, 1, 2, 3])
模型分布训练(Model Parallelism)
当单卡放不下模型时,才会考虑把不同模块拆到不同 GPU 上,比如编码器放一张卡、解码器放另一张卡。
self.encoder.cuda(0)
self.decoder.cuda(1)
out = self.decoder(self.encoder(x))
这类方式的代价是实现复杂,调试也麻烦。除非模型确实大到单卡塞不下,否则一般不会优先走这条路。
组合使用
有些场景会把不同模块拆分,再叠加多卡并行,但这已经不是'提速小技巧'了,更像系统设计问题。
9. 分布式多节点训练适合更大的规模
当单机多卡还不够用,就会碰到多节点训练。每个节点上的每张 GPU 都有自己的模型副本,数据切分后各自训练,再通过梯度同步保持一致。DistributedDataParallel 是这类场景里最常见的选择。
def main_process_entrypoint(gpu_nb):
dist.init_process_group("nccl", rank=gpu_nb, world_size=world)
torch.cuda.set_device(gpu_nb)
model = DistributedDataParallel(model, device_ids=[gpu_nb])
# 训练逻辑...
if __name__ == '__main__':
mp.spawn(main_process_entrypoint, nprocs=8)
Lightning 里配置会省很多事:
trainer = Trainer(gpus=8, accelerator='ddp')
trainer.fit(model)
这类方案的收益很大,但前提是你的数据管道、通信和训练逻辑都能跟上。否则卡在同步上,卡再多也不一定快。
结语
如果只挑几个最值得先做的,我会先看数据加载、batch size 和混合精度。这三项通常成本最低,也最容易看见效果。显存紧张就加梯度累积,多卡场景再考虑 DDP。优化训练速度没有统一答案,最后还是要回到模型规模、硬件条件和当前瓶颈上去判断。


