如果你只是把 PyTorch 当一个“能跑就行”的黑盒来用,训练效率大概率是被浪费的。我自己这些年几乎每天都跟 PyTorch 打交道,最大的体会是:框架本身不会限制你,真正拉后腿的往往是数据管线、默认的 eager 执行方式,以及显存不够时只会干瞪眼的三个问题。这篇文章就把我从“GPU 利用率不到 40%”到现在每分钟吞吐量翻倍的完整调优经验整理出来,聚焦四条主线——先找瓶颈、再调数据管线、要不要上 torch.compile、显存吃紧怎么救,最后给一套可以直接落地复用的调优流程。适合那些正在跑 PyTorch 训练、但感觉 GPU 一直在摸鱼,或者想系统做一次训练提速的开发者。整个调优链路不依赖特殊硬件,普通单卡机器也能明显看到收益。
1. 先定位瓶颈:一个训练脚本到底慢在哪里
1.1 判断瓶颈的两个粗筛方法
很多人一上来就把 num_workers 调大、把模型改成半精度,结果要么收益很小,要么直接跑挂。其实训练慢这个问题,百分之九十都能归到几个固定环节,先搞清楚慢在哪一步,后面的操作才有意义。我惯用的第一个粗筛办法非常土:拿 nvidia-smi 盯着 GPU-Util 以及 Volatile GPU-Util(老版本卡驱动)来回刷新。如果 GPU 利用率持续很低,比如一直在 20%~40% 之间跳动,说明 GPU 大部分时间在等数据;如果利用率接近 90% 以上,但整体训练吞吐还是上不去,那瓶颈就要看算子效率和显存带宽了。
另一个更有效的粗筛方法是“手动短路数据管线”。把 DataLoader 换成直接从内存反复取同一个 batch,或者干脆忽略 batch 直接喂固定形状的随机张量,跑几个 iteration 看看每秒能跑多少。如果这种“假数据”情况下训练速度明显变快,那问题基本出在数据侧;如果速度依旧和原来差不多,说明数据加载没问题,时间花在了模型前向反向、CPU 与 GPU 同步或者梯度更新上。
1.2 用 torch.profiler 代替瞎猜
前面两种方法只能定位到粗粒度“数据侧”还是“模型侧”,但真要搞清楚是哪一行代码在拖时间,还是得用 PyTorch 自带的 profiler。我最常用的是如下这个小片段:
import torch from torch.profiler import profile, ProfilerActivity, record_function def train_one_step(model, data, optimizer, loss_fn): x, y = data x, y = x.cuda(), y.cuda() with torch.profiler.profile( activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True, profile_memory=True, ) as prof: with record_function("forward"): y_pred = model(x) loss = loss_fn(y_pred, y) with record_function("backward"): optimizer.zero_grad() loss.backward() optimizer.step() print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))你用这个 profile 跑一轮迭代,表格里会按 CUDA 时间总量排序,哪些算子在 GPU 上耗时最长一目了然。很多时候我发现耗时最高的并不是卷积或者 Transformer 的注意力,而是to(cuda)的拷贝,或者DataLoader内部的collate函数没有做好类型转换。还有一种诡异情况是 CPU 端的optimizer.step()和 GPU 计算相互等待,造成比较高的同步开销,这种一般会在表里出现频繁的cpu_self_time高的行。
1.3 常见瓶颈的五种典型症状
在调优之前,我建议你先对着这份症状清单判断自己属于哪一类,思路会更清楚:
| 症状 | 大概率瓶颈位置 | 初步方案 |
|---|---|---|
| GPU 利用率低、GPU 内存占用很高 | 数据加载或 CPU 预处理过慢 | 调 DataLoader、缓存数据 |
| GPU 利用率忽高忽低、呈锯齿状 | 每个 batch 前有同步等待或数据处理抖动 | 增加 prefetch、持久化 worker |
| GPU 利用率很高但整体耗时大 | 模型算子效率、显存带宽受限 | torch.compile、算子融合、混合精度 |
| 训练中途突然卡一下、然后又恢复 | 内存碎片、日志或验证阶段干扰 | 排除 checkpoint 与日志同步 |
| 显存溢出但模型本身不大 | 中间激活值过多或 batch 尺寸设置不当 | 激活重计算、梯度累积、AMP |
不要跳过这一步直接调参,这是我不止一次踩過的坑。之前有一次为了把吞吐提上去,我连续调了三天 DataLoader,后来 profiling 才发现光是把某个自定义 Dataset 里反复调用的np.load换成内存缓存,收益就比折腾 worker 数高得多。所以先定位,再动手,这个顺序值得固执一点。
2. 数据管线提速清单:从进程数到内存映射
2.1 四个起手参数:num_workers、persistent_workers、prefetch_factor、pin_memory
数据管线这块,PyTorch 的 DataLoader 本身参数不多,但每一个都值得按自己的环境单独试一遍。我建议你先用一套安全配置测基线:num_workers设为本机 CPU 逻辑核心数的一半,persistent_workers=True,prefetch_factor=4,pin_memory=True。这四个参数的含义和取舍如下:
num_workers:数据加载子进程数。并不是越大越好,设成和逻辑核数相同甚至更高,反而容易造成进程频繁切换、内存带宽争抢,形成 CPU 死锁般的现象。我给大多数单机 8 核 CPU 用的值是 4,32 核机器一般从 12 到 16 起测。persistent_workers=True:让子进程在迭代结束后不销毁,下一次 epoch 接着用。省掉反复 fork 的启动开销,对训练数据量大、epoch 多的场景收益非常明显。prefetch_factor=4:每个 worker 预取样本数。默认值2常常不够覆盖 GPU 计算和 CPU 加载之间的速度差,调大以后相当于给数据管线加缓冲。pin_memory=True:在 GPU 训练中几乎应该无条件开启,它把数据放到页锁定内存,to(cuda)的拷贝可以走更快的高带宽 DMA 路径,不再是普通可分页内存那样每次拷贝都被 CPU 调度“卡一下”。
我贴一个实际可用的配置片段:
from torch.utils.data import DataLoader from dataset import MyDataset train_loader = DataLoader( MyDataset(...), batch_size=64, shuffle=True, num_workers=4, pin_memory=True, persistent_workers=True, prefetch_factor=4, )这四个参数调完,很多场景下吞吐能直接提升约 30%~60%,一分钱不花。
2.2 随机小文件读取是最容易翻车的地方
如果你的训练样本是成千上万张小图片或者小文本片段,随机读取的 IO 开销很容易变成瓶颈。磁盘随机读比顺序读慢一个数量级,一万个小文件来回跳,机械盘基本没救,SSD 也会被频繁的系统调用拖慢。我常用的几个方案按性价比排序如下:
第一,把数据做成内存映射或者直接打包。比如将全部样本打包成一个大.npy文件,训练时用np.load(..., mmap_mode='r')把整个文件映射到内存里,Dataset 取样本时只做索引切片,不再发生真实磁盘 IO。像这种改造,省掉的不只是读取时间,连文件系统的 inode 查询开销都没了。
第二,如果不想大改数据格式,可以在第一次 epoch 时把样本读取后的预处理结果(比如标准化后的张量)缓存到本机内存,或写到/dev/shm临时目录。有一个容易忽略的点:缓存文件路径不要用普通磁盘目录,否则每个 epoch 还是会把缓存读回内存,白忙一趟。
第三,自定义 Dataset 时尽量避免在__getitem__里做重量级解码。例如图像解码最好提前做,或者用支持随机访问的数据格式(比如以 zip 容器顺序存储)。__getitem__每一行代码都会乘上 batch 大小,然后还要再叠加 prefetch 缓冲,小操作也会被放大成大开销。
2.3 一个关于 worker 数与死锁的踩坑记录
这里想专门分享一次踩坑,因为实在太典型。有次我在 32 核 CPU 的机器上调数据加载,看到网上说num_workers设为核心数,我就照做了。结果训练到一半进程卡住,GPU 利用率掉到 0,CPU 长时间满载。后来排查发现问题是:数据预处理里用了一个 C 扩展库,它自身内部有全局解释器锁(GIL)竞争,worker 数量一多,这些子进程之间互相争抢内存带宽,每个进程都在空转,反而把系统调度拖崩溃。换成num_workers=16之后立刻恢复。
这个经历给我的教训是:粗暴地把 worker 数设为 CPU 核心数,不一定是提效法宝。训练进程本身、数据增强、日志和验证进程都需要 CPU 资源,worker 数超过某个阈值后,每个 worker 分到的内存带宽和 L3 缓存都会下降,整体速度不升反降。如果你没有条件一个个测,那就从“物理核心数的一半”开始跑,观察 GPU 利用率和总耗时,再逐步增加。
另外,如果你在 Windows 下开发,且用了大量 worker,有时会看到子进程反复启动或者报BrokenPipeError,这通常需要在代码入口加一个if __name__ == "__main__":保护,把训练逻辑放进这个块里,能规避很多平台差异问题。
3. torch.compile 能把 eager 模式压榨到什么程度
3.1 compile 到底在做什么:算子融合与 CUDA Graph
PyTorch 默认的运行模式是 eager,每一条 Python 算子在执行时都会启动一个 kernel、读写一遍中间结果,图层面的通用优化很难做上去。torch.compile的思路是把模型先变成一张中间计算图,再做图优化,最后生成更高效的执行方案。它背后有几个关键机制,用大白话讲就是:
- 算子融合:把一组相邻且可以合并的算子(比如
conv + bias + relu)合并成一个 GPU kernel,减少中间张量的显存读写次数。 - 表达重写:在框架内部把 Python 层面的动态控制流转成更紧凑的静态逻辑,减少 Python 解释器反复介入。
- CUDA Graph:把一整个前向加反向捕获成一张图,一次性发射到 GPU,大幅降低 kernel 启动次数。这部分对训练步骤短、模型层多的小 batch 场景特别有用。
使用方式也很直接:
import torch model = MyModel().cuda() model = torch.compile(model, mode="default") for epoch in range(epochs): for x, y in train_loader: x, y = x.cuda(), y.cuda() optimizer.zero_grad() loss = loss_fn(model(x), y) loss.backward() optimizer.step()就这么一行替换,在不少模型上能让训练速度提升 20% 到 40%。需要注意一点,torch.compile在第一次运行模型时会有一段明显的编译期预热,看起来像“卡死”,实际上是在做图捕获和代码生成。不要把这段当成死机,多跑几个 iteration 后速度会趋于正常。
3.2 不同模型的实际收益差距
torch.compile 的收益不是均匀的,极端情况下不同模型差距很大。我把自己在某个测试环境上实测过的几类模型的提速结果整理成下面的参考表,硬件是单张消费级显卡,PyTorch 版本相对较新:
| 模型类型 | 典型结构 | 实测提速幅度 | 说明 |
|---|---|---|---|
| 小型 CNN | 3~5 层卷积 | 10%~20% | 计算图简单,融合空间有限 |
| 中大型 CNN | ResNet 风格、带 BN | 20%~30% | 卷积和归一化算子融合收益明显 |
| Transformer Encoder | 12 层左右 | 30%~50% | 注意力线性层融合、kernel 启动减少 |
| 带动态控制流模型 | if-else、自定义循环 | 可能负优化 | 动态 shape 导致反复重编译 |
如果你模型很小,或者结构非常简单,torch.compile的收益不一定大,反而会带来第一次编译的时间和显存额外开销。所以不用盲目期待它“一定更快”,最好拿自己的模型做一个对比实验。
3.3 踩过的坑:动态 shape、第三方算子与回退
compile 看起来一键提速,实际上有几个非常典型的坑,我几乎每个都踩过一遍。
第一个是动态 shape。如果你的输入宽高或者序列长度在不同 batch 之间会变,模型可能会被反复重新编译,那不但没提速,反而不断在编译与执行之间切换。解决思路是尽量固定输入维度,或者在 dataset 预处理阶段做统一 resize / padding。不过有一些新版本 PyTorch 对动态 shape 支持得更好了,会做一个比较模糊的形状推断,但我个人建议核心训练数据还是保持静态形状更稳妥。
第二个是第三方自定义算子。模型里如果用了不是 PyTorch 官方库的 CUDA 扩展或某些自定义 autograd.Function,torch.compile有时候能处理,有时候会直接回退到 eager 模式,也就是相当于没有任何加速效果。我的经验是先用一个小脚本打印编译后的模型类型,确认它到底是编译成功还是回退:
compiled_model = torch.compile(model) first_result = compiled_model(torch.randn(4, 3, 224, 224).cuda()) # 多跑几轮后再检查输出类型 for _ in range(3): compiled_model(torch.randn(4, 3, 224, 224).cuda()) print(compiled_model)如果发现回退,别急着骂框架,先检查自己是不是用了自定义算子。很多情况下把自定义 CUDA kernel 换成官方 API 组合,反而能吃到编译优化红利。
第三个是显存开销。编译模式往往会生成更多中间缓冲,显存占用会比 eager 模式高一些。如果你的模型在 eager 模式下已经接近显存上限,那上torch.compile很可能直接 OOM。这种情况下先做显存优化(下文第四部分),再考虑编译。
3.4 什么情况下不建议用 compile
如果你的模型本身很小,或者训练循环里有大量 Python 逻辑操作张量的 shape、索引,torch.compile不一定好使。还有一类情况是调试阶段,比如想打印中间梯度、想在具体某个层断点检查,compile 出来的图会把这些信息藏起来,排查问题很不方便。我的建议是:调试阶段用 eager,稳定训练时再尝试 compile。如果你有多个 GPU 或者多机训练,还要注意版本兼容问题,因为编译后的图在底层如果涉及分布式通信逻辑,某些老版本会有冲突,新版本相对成熟很多。
4. 显存吃紧时的三件套:AMP、梯度累积与激活重计算
4.1 混合精度不是简单的 half()
显存不够的时候,很多人第一反应是把模型参数直接转成half,这其实非常危险。half精度在反向传播时容易梯度下溢,尤其是你的 loss 数值很小或者梯度范数接近零时,直接变成 0,训练直接停滞。PyTorch 官方推荐的混合精度方案是autocast + GradScaler:前向计算时只把部分算子(卷积、矩阵乘)切成半精度,反向传播时维护一个动态 scale 因子,防止梯度溢出。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4) for x, y in train_loader: x, y = x.cuda(), y.cuda() optimizer.zero_grad() with autocast(): loss = loss_fn(model(x), y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()这样做的收益不只是省显存,半精度的 FP16 算子计算在多数现代显卡上比 FP32 快接近一倍,显存占用能减少 30%~50% 不等。要注意的是scaler.step(optimizer)前不要自己手动clip_grad_norm_,正确做法是先对梯度做 scale 后的裁剪,或者直接传scaler.unscale_后再 clip,否则容易把梯度搞乱。这是一个很细节、很常见的坑。
4.2 梯度累积实现“伪大batch”
如果模型在逻辑上想用大 batch(比如 128),但单卡显存只能塞下 32,梯度累积就是把 4 个小 batch 的梯度加在一起,再更新一次参数。实现不复杂:
accumulation_steps = 4 optimizer.zero_grad() for step, (x, y) in enumerate(train_loader): x, y = x.cuda(), y.cuda() loss = loss_fn(model(x), y) / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()这里有两点得留意。第一,loss 除以accumulation_steps是为了让梯度累加后的大小等同于单次大 batch 的平均梯度,否则有效学习率会变大,模型容易震荡。第二,如果模型中用了 BatchNorm,小 batch 累积模拟大 batch 时,BN 的统计量仍然按每个小 batch 单独计算,这跟真正的大 batch 不完全一致,可能影响精度。替代方案是换用 SyncBatchNorm,或者调整 BN 的 momentum 参数让统计值更平滑。
梯度累积最大的好处是几乎不增加额外显存,只付出训练时间上略微增多的前向次数(因为小 batch 多,计算效率略低),但对显存捉襟见肘的模型来说,这是最优先级的方案。
4.3 激活重计算的取舍
第三步是激活重计算,PyTorch 里面对应的 API 是torch.utils.checkpoint。它的思路特别朴素:反向传播需要用到前向中间激活值,常规做法是全部存在显存里;而 checkpoint 的意思是不存,等反向传播用到了,临时再把这个中间结果重新算一遍。用时间换显存,显存占用能大幅下降,但训练耗时通常会增加 20%~40%。
from torch.utils.checkpoint import checkpoint class MyTransformerBlock(nn.Module): def forward(self, x): return checkpoint(self._forward_impl, x, use_reentrant=False) def _forward_impl(self, x): # 内部做多头注意力和 FFN,显存大头就在这些中间激活值 ...常见的做法不是对整层用 checkpoint,而是挑出中间显存最高的几个大张量层,比如 Transformer 的每一个注意力块。我在实践中的经验是:优先把 checkpoint 加在激活张量形状最大、层数最深的模块上,不要无脑给所有层加,否则训练时间会拖得很明显。你完全可以先跑几步,用torch.profiler看哪几个层显存峰值最高,然后精准替换。
4.4 显存优化组合拳的先后顺序
这三件套的正确出场顺序,我强烈建议按下面的流程走,不要跳步:
- 优先开 AMP(autocast + GradScaler),这是性价比最高的,显存下降明显,速度反而更快。
- 如果还超显存,用梯度累积把有效 batch 撑起来,避免损失模型效果。
- 最后才用激活重计算,因为这个会显著拖慢训练速度,但显存释放最多。
- 如果显存依然紧张,再考虑把
torch.compile(..., mode="reduce-overhead")关掉,或者用max-autotune模式下重新分配显存策略。
这套组合拳打下来,我见过把原来 12GB 显存都不够跑的模型,压到 8GB 显存卡上稳定训练。但代价也有:训练时间比原来的 eager 模式长一些,梯度累积和激活重计算叠加在一起尤其明显。所以它是“能不能跑起来”和“跑得爽不爽”的权衡,你的目标应该是满足显存限制的前提下,尽量保留加速手段。
5. 一套可复现的调优验证流程:指标怎么定,收益怎么量化
5.1 先记录基线,再动手
我见过太多人调优的时候不记录起始状态,调完发现“好像快了,但不知道具体快了多少”。正确的做法是先把基线数据记下来,固定三个指标:单 iteration 平均耗时、GPU 峰值显存、每秒处理的样本数(throughput)。如果还要比较精度一致性,就把 val loss 也一起记录。流程上,我会写一个极简的 benchmark 脚本,在同样的 epoch 数、同样的随机种子下测 3 次取平均,这样得到的数字才有对比意义。
实际操作中,不同批次之间训练速度会因为预热、任务调度、显存频率自动调整而产生波动,所以不要拿一次的结果下结论。我用一个最朴素的公式估算吞吐:total_samples / total_time。这样即便 batch size 改了,也能用统一的吞吐值比较优劣。
5.2 调优顺序与预期收益
我给一个自用很久的调优顺序,基本是经验总结:
- 优先修数据管线(worker、pin_memory、prefetch、缓存策略),预期收益 30%~60%,而且几乎不改变模型精度。
- 然后开 AMP,预期收益 20%~50%(速度提升同时显存下降)。
- 如果想保留大有效 batch,再上梯度累积。
- 模型结构比较复杂时尝试 torch.compile,先跑一个 epoch 对比收益,再决定去留。
- 最后才做激活重计算这种“损失训练时间换显存”的操作。
比如我最近在一个推荐模型训练任务上,初始 GPU 利用率只有 35%,单 iteration 约 2.1 秒。经过“数据缓存 + 持久化 worker + pin_memory + AMP”之后,单 iteration 降到 0.9 秒,再上 torch.compile 后降到 0.72 秒。等价的吞吐从每秒约 400 样本提升到 1150 样本,整整翻了接近三倍。这种收益没有哪一步是魔法,都是一点一点抠出来的。
5.3 一个常见问题排查速查表
调优过程中总会遇到一些偶发问题,我把最常遇到的几类整理成一张速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| GPU 利用率持续 0% | 数据加载阻塞、模型卡死 | 检查数据管线、进程是否存活 |
| num_workers 调大反而更慢 | 内存带宽争抢、GIL、子进程调度开销 | 调小 worker、检查预处理是否太重 |
| 开了 pin_memory 后速度没变 | 数据可能本来就以页锁定内存存在 | 用to(device, non_blocking=True) |
| AMP 后 loss 变成 nan | 学习率过大或 scale 不稳定 | 检查 GradScaler 状态、适当降低学习率 |
| compile 后显存增加 | 编译中间缓冲变多 | 先做显存优化再 compile |
| 梯度累积后精度变差 | BN 统计量错位或没有等分 loss | 调 momentum、处理 loss scale |
这张表不用背下来,真遇到问题再回来查,比硬记强得多。
5.4 我建议的默认配置模板
最后给一套我自己的默认配置模板,你可以当成调优起点,不要直接当作最终答案。环境是单机多卡或单卡都可,PyTorch 版本保持更新:
train_loader = DataLoader( dataset, batch_size=64, shuffle=True, num_workers=6, pin_memory=True, persistent_workers=True, prefetch_factor=4, drop_last=True, ) model.cuda() model = torch.compile(model, mode="default") scaler = torch.cuda.amp.GradScaler()每次开启一个新训练任务,我会先跑一个 30 iteration 的短测试,打印出吞吐和显存峰值,记录到实验日志里,作为这一轮的 baseline。后续不管调什么,都拿这个 baseline 来说话。这也是我想强调的最后一点:调优这件事,最怕的不是你不会调,而是调完说不清到底哪一步起了作用。养成记录 baseline 的习惯,你的优化思路会越来越清晰。