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瓶颈,我一般按这个顺序来:

  1. 看CPU利用率:htop或者top,注意看每个核的负载。
  2. 看I/O等待:iostat -x 1,如果%iowait高,说明磁盘太慢。
  3. 看数据加载线程: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的精度转换开销拖累了。

我建议的做法是:

  1. 先用FP32跑一遍,记录基线。
  2. 开启AMP,对比速度和精度。
  3. 如果精度下降超过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不当、小模型

我个人习惯,每次换新环境或者新模型,都会先跑一遍这个清单。花不了多少时间,但能省下后面排查问题的无数个小时。

最后送大家一句话:单卡跑不顺,集群跑不动。把基础打牢,后面的事情自然水到渠成。

单卡基线建立流程 GPU性能摸底 计算吞吐、利用率 CPU瓶颈排查 负载、I/O、缓存 数据加载优化 DALI、预取、pin_memory 混合精度测试 FP16 vs FP32对比 ✅ 单卡基线建立 每个步骤都需要记录数据,形成可复现的基线报告

实用工具推荐:

  • nvidia-smi dmon:实时监控GPU指标
  • perf stat:CPU性能计数器
  • torch.utils.bottleneck:PyTorch内置性能分析工具

想要更多资料,联系可可