4、单卡基线建立:单GPU性能摸底、CPU瓶颈排查、数据加载管线优化、混合精度对性能的影响
各位同学,咱们今天聊点实在的。
做集群训练之前,第一件事是什么?不是调参,不是改模型,而是先把单卡跑明白。我见过太多团队,集群一跑就崩,回头查了半天,发现单卡上就有瓶颈。说白了,单卡基线没立住,后面全是空中楼阁。
4.1 单GPU性能摸底:先看看你的卡到底有多快
拿到一张新卡,我习惯先做三件事:
- 跑一个纯计算测试:比如矩阵乘法,看看理论算力能到多少。
- 跑一个简单模型:比如ResNet-50,看看实际吞吐。
- 记录GPU利用率:用
nvidia-smi或者nvtop盯着看。
为什么要摸底?因为很多问题一开始就埋下了。我记得有一次,新到的A100跑起来只有理论性能的60%,查了半天发现是PCIe链路降速了——插槽没插紧。这种低级错误,不摸底根本发现不了。
核心指标:
- GPU利用率(越高越好,理想值>95%)
- 显存带宽利用率(用
nvidia-smi dmon看) - 实际吞吐(samples/sec)
这里给个简单的测试脚本,我经常用:
import torch
import time
model = torch.hub.load('pytorch/vision:v0.10.0', 'resnet50', pretrained=False).cuda()
input_tensor = torch.randn(64, 3, 224, 224).cuda()
# warm up
for _ in range(10):
model(input_tensor)
# benchmark
torch.cuda.synchronize()
start = time.time()
for _ in range(100):
model(input_tensor)
torch.cuda.synchronize()
end = time.time()
print(f"Throughput: {100 * 64 / (end - start):.2f} samples/sec")
嗯,这里要注意:torch.cuda.synchronize()不能省。我见过有人忘了加,结果测出来的吞吐虚高30%,一上真实训练就露馅。
4.2 CPU瓶颈排查:别让CPU拖了GPU后腿
很多人觉得训练慢就是GPU不行。其实不然。我遇到过最离谱的一次,GPU利用率只有40%,但CPU已经100%了——数据加载线程在疯狂抢资源。
排查CPU瓶颈,我一般按这个顺序来:
- 看CPU利用率:
htop或者top,注意看每个核的负载。 - 看I/O等待:
iostat -x 1,如果%iowait高,说明磁盘太慢。 - 看数据加载线程:PyTorch的DataLoader默认用多进程,但进程数设多了反而会争CPU。
我的经验:DataLoader的num_workers不是越大越好。我一般设成CPU核心数的一半,或者直接用psutil.cpu_count() // 2。曾经有个项目设了32个worker,结果CPU上下文切换开销比数据加载还大,降成8个反而快了20%。
还有一个容易被忽略的点:内存带宽。如果CPU和GPU共享内存带宽(比如某些服务器架构),CPU大量读写数据时,GPU的显存访问也会受影响。用perf stat -e instructions,cycles,cache-misses可以看缓存命中率,如果cache-miss太高,说明数据局部性不好。
4.3 数据加载管线优化:让GPU永远有活干
理想情况下,GPU应该像一台永不停歇的机器。但现实中,数据加载经常成为瓶颈。我把它叫做「GPU等饭」问题——模型算得快,但数据送不过来。
优化数据加载管线,我总结了几个关键点:
- 使用内存映射文件:对于大数据集,用
mmap方式加载,避免重复读取磁盘。 - 预处理流水线化:把数据增强、归一化等操作放到DataLoader的worker里做,别在GPU上做。
- 使用NVIDIA DALI:如果数据量特别大,DALI可以把预处理放到GPU上,效率极高。
举个例子,我之前做视频训练,每帧都要做随机裁剪和颜色抖动。用PyTorch原生DataLoader,GPU利用率只有60%。换成DALI后,直接飙到98%。
避坑指南:我曾经在数据加载里用了torchvision.transforms.RandomResizedCrop,结果发现它每次都在CPU上做随机数生成,导致worker之间频繁争抢随机数种子。后来改成在每个worker里独立初始化随机种子,问题解决。
这里给一个优化后的DataLoader配置示例:
from torch.utils.data import DataLoader
import torchvision.transforms as T
transform = T.Compose([
T.RandomResizedCrop(224),
T.RandomHorizontalFlip(),
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
dataloader = DataLoader(
dataset,
batch_size=256,
num_workers=8, # 根据CPU核心数调整
prefetch_factor=4, # 预取批次数量
pin_memory=True, # 锁页内存,加速CPU->GPU传输
persistent_workers=True # 复用worker进程,减少创建开销
)
注意pin_memory=True这个参数。它把数据固定在物理内存里,GPU可以直接通过DMA访问,省去了CPU到GPU的拷贝时间。我实测过,开这个参数能提升5-10%的吞吐。
4.4 混合精度对性能的影响:FP16不是银弹
混合精度训练(AMP)现在很流行。说白了,就是用FP16做计算,用FP32做累加,既快又准。但我要泼点冷水:不是所有模型都适合AMP。
我遇到过的情况:
- CV模型:ResNet、EfficientNet这些,AMP基本无损,速度提升1.5-2倍。
- NLP模型:BERT、GPT系列,AMP效果也很好,但要注意loss scaling。
- 小模型:比如MobileNet,AMP反而可能变慢——因为FP16的矩阵乘法在小尺寸上没优势。
为什么会这样?因为GPU的FP16算力虽然高,但需要足够大的矩阵才能发挥出来。小模型的计算量太小,反而被FP16的精度转换开销拖累了。
我建议的做法是:
- 先用FP32跑一遍,记录基线。
- 开启AMP,对比速度和精度。
- 如果精度下降超过1%,考虑使用
torch.cuda.amp.GradScaler的dynamic scaling。
AMP代码示例:
scaler = torch.cuda.amp.GradScaler()
for data, target in dataloader:
optimizer.zero_grad()
with torch.cuda.amp.autocast():
output = model(data)
loss = loss_fn(output, target)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
嗯,这里有个坑:GradScaler的update()一定要在step()之后调用。我曾经写反了顺序,结果梯度一直不更新,模型训了三天loss纹丝不动——那叫一个崩溃。
4.5 本章小结:单卡基线检查清单
好了,咱们把这一章的内容串一下。建立单卡基线,其实就是回答四个问题:
| 检查项 | 目标值 | 常见问题 |
|---|---|---|
| GPU利用率 | >95% | CPU瓶颈、数据加载慢 |
| CPU利用率 | <80%(非满载) | worker数过多、I/O等待 |
| 数据加载时间 | <训练时间的10% | 磁盘慢、预处理开销大 |
| AMP精度损失 | <1% | loss scaling不当、小模型 |
我个人习惯,每次换新环境或者新模型,都会先跑一遍这个清单。花不了多少时间,但能省下后面排查问题的无数个小时。
最后送大家一句话:单卡跑不顺,集群跑不动。把基础打牢,后面的事情自然水到渠成。
实用工具推荐:
nvidia-smi dmon:实时监控GPU指标perf stat:CPU性能计数器torch.utils.bottleneck:PyTorch内置性能分析工具
想要更多资料,联系可可