训练一慢,绝大多数人的第一反应是改代码。跑得慢就换更小的模型,loss 不降就动学习率,显存不够就调 batch size,再不行就怀疑是框架 bug,把主干代码翻个底朝天。我在 AI Infra 相关的工作里见过太多这样的场景:一顿操作猛如虎,速度没快多少,还引入了新问题。所以我想先说一句:训练慢,先别急着改代码,先做一次性能体检。
所谓性能体检,指的是一套标准化的排查动作:在不改变训练逻辑的前提下,用 nvidia-smi、Nsight Systems、torch.profiler 这类工具,把 GPU 算力、显存带宽、数据加载、多卡通信这几个环节全部测一遍,搞清楚时间到底花在哪。这个系列聊 GPU 性能工程,第一件事不是教你怎么优化 kernel,而是先教会你怎么给训练任务做体检。因为只有先定位瓶颈,后面的优化才不是玄学。
1. 为什么“先做性能体检”是训练优化里最值钱的一件事
1.1 改代码之前,先回答一个最基础的问题:慢在哪里?
训练系统本质上是一条流水线。GPU 只是其中一段,它前面连着 CPU 数据预处理,后面连着磁盘或网络存储,如果是多卡或多机训练,中间还夹着通信调度。你看到的“训练慢”只是流水线末端的结果,但时间到底消耗在哪一环,光靠看代码是看不出来的。
我自己就犯过这类错误。有一次跑一个视觉模型的微调任务,总感觉 GPU 在偷懒,于是花了一整天去优化模型结构、调整算子实现,结果性能几乎没变化。后来用 Profiling 工具才发现,问题根本不在模型代码:数据增强逻辑全写在 CPU 上,DataLoader 的 worker 又开得不够,GPU 大部分时间是在空转等数据。方向错了,再努力也是白费。
所以“先做性能体检”的第一层意义,就是强制你把问题域收敛。训练慢可能来自四个方面:算力不够、访存受限、数据供不上、通信在等。这四个方向的解决手段完全不同,改代码前必须先把问题归类。
1.2 性能体检的本质:让数据替你做决策
体检不是玄学,它产出的是一组可对比的数字:GPU 利用率、SM 活跃度、显存带宽利用率、DataLoader 耗时、NCCL 通信耗时、端到端吞吐。有了这些数字,你后续的所有优化都变成了“假设-验证”:认为瓶颈在数据增强,那就动数据增强;认为瓶颈是 kernel 太碎,就做算子融合或 batch 调整。
你可以把性能优化当成一次严格的 A/B 测试。先记录基线值,然后只改一个变量,再看指标是否变化。如果改了之后吞吐没有提升,说明瓶颈不在这里,果断回滚,换下一个假设。这样既不会做无用功,也不会把一个本来正常的模块改坏。
这也是为什么我一直强调:性能工程先讲度量,再讲优化。没有度量的优化,本质上是撞运气。
1.3 一次体检能省下多少时间
举一个很常见的例子。某个训练脚本在单卡上表现尚可,但换到 8 卡后吞吐居然只提升了不到 2 倍。团队的第一反应是模型并行策略有问题,准备大改。但体检后发现,问题出在每次 epoch 结束时都会执行一次全量评估,同时多个 rank 会在 checkpoint 落盘时产生同步等待,GPU 基本是在轮流闲着。改掉这两个调度问题后,8 卡吞吐立刻翻了接近一倍。
另一个更典型的场景:DataLoader 的 num_workers 设置不合理。这个是老生常谈,但每次都能看到。很多人用的是默认值 0,意味着数据加载在主进程里串行执行,GPU 每算完一个 batch 都要干等。性能体检只需要看一眼“GPU 利用率的波动周期”和“CPU 等待占比”,就能确认这个瓶颈,改几行配置就能跑满。
一次性能体检的成本通常在一两个小时以内,但它能避免后面几周甚至一个月的错误优化。这笔账怎么算都划算。
2. 性能体检的指标体系和工具清单
2.1 训练任务里需要盯住的核心指标
给训练任务做体检,首先要知道该量什么。我的习惯是把指标分成四个环节,每个环节都至少要有一个代表性指标。
| 类别 | 核心指标 | 说明 |
|---|---|---|
| 算力侧 | GPU 利用率 / SM Active / Kernel 时间占比 | GPU 有没有在干活,干得是不是正事 |
| 访存侧 | 显存带宽利用率 / L2 命中率 / 显存占用 | 是不是访存把计算卡住了 |
| 数据侧 | DataLoader 耗时 / CPU 占用 / Worker 数量 | GPU 是不是在等数据 |
| 通信侧 | NCCL 耗时 / 同步等待 / 总线带宽 | 多卡协同是不是出现了通信瓶颈 |
| 端到端 | samples/sec、step 耗时、epoch 耗时 | 最终的吞吐表现 |
这里要特别解释一个常见的误区:GPU 利用率高不等于效率高。nvidia-smi 里的 GPU-Util 通常表示采样周期内 GPU 上有 kernel 在执行的比例,哪怕这个 kernel 正卡在锁等待或访存延滞上,Util 也可能显示很高。所以体检时不能只看这一项,还要配合 SM Active、Memory Throughput、Kernel 时间占比等指标一起看。
2.2 用对工具:从一行命令到全链路 Profiling
工具选型也分层次,不是一上来就用最重的 Profiling 工具。
第一层是命令行快检。nvidia-smi dmon -s pum -d 1可以每秒输出 GPU 利用率、显存占用、功耗和温度,适合先快速判断 GPU 是否被“饿着”或“过热降频”。nvidia-smi pmon -s u -d 1可以看到每个进程的显存和 SM 使用情况。想检查多卡拓扑,用nvidia-smi topo -m,能看 GPU 之间是 NVLink 还是 PCIe 连接。
第二层是集群监控。如果任务跑在 Kubernetes 或大规模 GPU 集群上,可以用 DCGM Exporter 配合 Prometheus 采集 GPU 指标。DCGM 能拿到比 nvidia-smi 更细的数据,比如 SM 占用、NVLink 错误、显存温度等。这个适合把性能体检常态化,而不是每次手工命令行。
第三层是系统级 Profiling。nsys profile能记录 CPU 和 GPU 的事件时间线,看到每个 kernel、每个 memcpy、每个 DataLoader 调用在时间轴上的分布。PyTorch 用户还可以直接用torch.profiler,对训练代码的侵入更小。
第四层是 kernel 级分析。ncu也就是 Nsight Compute,会逐个 kernel 分析计算吞吐、访存吞吐、占用率、指令瓶颈等。这个用在已经确认瓶颈在 GPU 内部、需要细抠算子优化的时候。
我通常的建议是:先命令行看现象,再 nsys/torch.profiler 看时间线,最后 ncu 看具体 kernel。跳过前面直接上 ncu,很容易在错误的环节浪费几个小时。
2.3 指标联动的门道:怎么读出瓶颈类型
单独一个指标说明不了问题,指标组合在一起才能定位瓶颈类型。这里给几组常见的组合判断。
| 看到的组合 | 可能的瓶颈 |
|---|---|
| GPU Util 高 + SM Active 高 + 带宽利用率高 | 算力或访存接近饱和,需要算法级优化 |
| GPU Util 高 + SM Active 低 + Kernel 数量巨大 | kernel 太碎,启动开销成了瓶颈 |
| GPU Util 波动 + DataLoader 等待占比高 | CPU 数据供给不足,先查数据管线 |
| GPU Util 高 + 端到端吞吐仍然低 | 可能存在同步等待或通信开销 |
| Memory Throughput 高 + Compute Throughput 低 | 访存密集型瓶颈,考虑内存访问优化 |
这种联动分析才是性能体检的核心。指标不是用来汇报的,而是用来做诊断的。读懂了这些组合,你基本上能判断出该往哪个方向继续挖。
3. 实操流程:给训练任务做一次标准性能体检
3.1 第一步:定基线和体检参数
任何一次体检,都要从“固定条件”开始。训练性能受随机影响很大,如果不固定条件,两次测量之间的差距可能超过了优化带来的收益,数据就没法作为决策依据。
我自己的做法是:
- 固定随机种子,包括 Python、NumPy、PyTorch 和 CUDA 相关的随机状态;
- 固定 batch size、输入尺寸、迭代步数;
- 固定环境变量,比如
CUDA_VISIBLE_DEVICES; - 测速时关闭不必要的日志和调试模式,尤其是
NCCL_DEBUG=INFO这种会明显拖慢通信的设置; - 先跑 20 到 30 个 step 做预热,等 cuDNN heuristic 选择、显存分配和数据缓存都稳定之后,再记录 50 到 100 个 step;
- 每个配置至少重复 3 次,取中位数或平均值。
这样得到的基线才具备可对比性。基线值建议记在一个固定的模板里,内容包括环境版本、命令、指标数据和备注。没有基线,后面改代码后的“变快”和“变慢”都无法量化。
3.2 第二步:单卡专项体检,先把“计算效率”看清楚
单卡体检的目标,是确认 GPU 本身的计算效率是否正常。我一般先用 PyTorch Profiler 跑一段,直接看时间都花在哪里。
import torch from torch.profiler import profile, ProfilerActivity model = ... data_loader = ... optimizer = ... def train_step(batch): optimizer.zero_grad() loss = model(batch) loss.backward() optimizer.step() return loss with profile( activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], schedule=torch.profiler.schedule(wait=5, warmup=5, active=10, repeat=1), on_trace_ready=torch.profiler.tensorboard_trace_handler("./logs") ) as prof: for step, batch in enumerate(data_loader): loss = train_step(batch) prof.step()跑完之后,在 TensorBoard 的 Profiler 页面里可以看几个关键视图:GPU Kernel 时间、CPU 时间、DataLoader 时间。如果发现 DataLoader 等待占比很高,说明瓶颈在数据侧;如果发现 CPU 时间远高于 GPU 时间,说明可能在 kernel 启动或 Python 逻辑层有开销。
如果 GPU 核心里有问题,再用 Nsight Compute 看特定 kernel。
ncu --set full -o kernel_profile python train.py --profile-steps 30重点关注 Compute Throughput 和 Memory Throughput。如果 Compute 接近 90% 以上,这个 kernel 基本没有优化空间;如果 Memory 达到 80% 以上而 Compute 只有 30%,这就是访存瓶颈,要往内存访问模式、算子融合方向考虑。
需要注意,ncu 本身会带来较大的性能开销,也会改变运行节奏,不要在一次完整训练上跑,只需要采样少量代表性 step。
3.3 第三步:数据管线与 CPU 侧检查
单卡 GPU 利用率上不去,大概率是数据没供上。这里要专门做一次“纯数据加载”实验,把模型和训练逻辑全部摘掉,只看 DataLoader 的耗时。
常见做法是写一个单独脚本,只跑for batch in loader: pass,统计每步平均耗时,再和训练时的每步 GPU 计算耗时对比。如果纯数据加载的时间已经接近或超过 GPU 计算时间,那就说明数据管线需要优化。
优化的顺序通常是这样:
- 检查 num_workers。建议从 4 或 8 开始试,并不是越大越好,因为 worker 进程太多也会带来内存膨胀和进程切换开销;
- 开启
pin_memory=True,可以加快 CPU 到 GPU 的拷贝; - 检查预处理逻辑是否过重。图像解码、Resize、增强这些操作如果大量集中在 CPU 上,考虑缓存或少量减少;
- 检查文件系统。如果数据集在远端网络存储上,可能因为 IO 延迟拖慢加载,可以先把小规模数据拷贝到本地 SSD 试试;
- 关注 CPU 总核数和容器 CPU 配额。如果容器的 CPU limit 只有 4 核,但 num_workers 设置了 16,反而会产生大量上下文切换。
判断数据管线的瓶颈,还有一个很实用的办法:把整个数据集尽可能放到内存或本地高速盘上,如果吞吐立刻大幅提升,就说明瓶颈在 IO 或外设访问。这个实验做起来很快,但能帮你直接锁定方向。
3.4 第四步:多卡扩展性体检
多卡场景要多做一件事:算扩展效率。先测单卡吞吐,再测 N 卡吞吐,然后计算 scale 效率。
scaling_efficiency = N卡吞吐 / (单卡吞吐 * N) * 100%如果效率低于 80%,就需要查通信环节。首先看 GPU 间的物理拓扑。
nvidia-smi topo -m如果 GPU 之间是 PCIe 而非 NVLink,通信带宽会差不少;如果跨 NUMA 节点,还可能造成额外的拷贝开销。多机场景还要关注网卡和 GPU 的亲和关系,最理想的情况是每个 GPU 都能就近访问自己的网卡,而不是绕路。
然后可以用 nccl-tests 快速测一下当前环境里的 AllReduce 带宽。
mpirun -np 8 ./build/all_reduce_perf -b 8M -e 512M -f 2 -g 1看输出的busbw是否接近该卡型的理论值。如果低得离谱,可能是 NCCL 通信库选路有问题,也可能是被其他任务抢占。
在多卡 Profiling 的时候,nsys profile里的时间线能看到 GPU kernel 之间是否有大段空白,这些空白往往就是 collective 同步等待。如果通信占比不小,可以考虑梯度累积、通信压缩、或者调整同步频率等方式,但具体方案要结合模型的收敛性来看,不能盲目套用。
多卡体检经常被忽略的一点是负载不均衡。比如某些 rank 的数据量天生比其他 rank 大,或者有 rank 在额外执行评估、打日志、保存 checkpoint,都会拖慢整个训练。体检时要特别留意每个 rank 的 GPU 利用率是否接近一致。
3.5 一次真实体检的现场记录
举一个比较典型的案例。一个 8 卡 A100 的视觉模型训练任务,单卡吞吐是 200 samples/s,8 卡却只有 700 samples/s,扩展效率只有 44%。团队一开始怀疑是多卡通信问题,准备调 NCCL 参数。
我接手后先跑了nvidia-smi dmon,发现 GPU 利用率不是稳定在高位,而是像心电图一样在 20% 和 90% 之间剧烈波动,波峰之间间隔很规律。这个现象基本排除了通信瓶颈,更像是数据供给不稳定。
然后用nsys profile看了 50 个 step 的时间线,发现 GPU kernel 之间有大段空白,空白处对应的 CPU 栈明显是图像解码和随机增强。再打开htop,发现机器 CPU 只有 16 个物理核,但训练脚本给每个 GPU 配了 4 个 DataLoader worker,总共 32 个进程在抢 16 个核。
优化方案很简单:把每个 GPU 的 num_workers 降到 2,开启 pin_memory,把部分过重的增强操作从 CPU 挪到 GPU 上执行。改完后 GPU 利用率稳定在 90% 以上,8 卡吞吐提升到 1300 samples/s,扩展效率也回到 80% 以上。
这个案例其实没有什么高深技巧,但如果没有性能体检,就很难把“8 卡吞吐低”归因到 CPU 核数不足,而不是通信问题。
4. 常见瓶颈识别与排查技巧
4.1 GPU 利用率上不去或者忽高忽低
这是训练中最常见的现象。看到 GPU Util 只有 30% 或者上下乱跳,先别怀疑 GPU 坏了,按照这个顺序排查:
- 看是否处于预热阶段。训练刚开始的几十步,cuDNN 在做自动调优、显存在分配,利用率低是正常的;
- 看数据管线。单独跑 DataLoader,对比纯加载时间和 GPU 计算时间;
- 看 CPU 核数和 worker 数量。worker 太多导致争抢,也会让利用率波动;
- 看是否有评估或 checkpoint 逻辑穿插在训练循环里,这些操作会周期性打断 GPU 计算;
- 看是否有其他进程抢占 GPU 或 CPU,包括同机其他容器造成的资源争抢。
有一个很实用的判断方法:把 batch size 临时调小,如果 GPU 利用率反而上升了,说明 GPU 本身没问题,是数据供应跟不上。这时候要优化的就是数据管线,而不是模型。
4.2 显存占用高但算力在闲置
另一个常见场景是 nvidia-smi 里显存占用已经接近满,但 GPU 利用率很低。很多人会立刻想到“显存泄漏”,于是反复调 batch size。但显存占用高和计算闲置同时出现,更常见的原因是计算在等待数据或等待同步。
如果显存占用持续增长而利用率始终低位,也有可能是程序中有缓存未正确释放,比如每次迭代都把某个 tensor 对象保存到了 list 里。这种内存泄漏用nvidia-smi的显存变化曲线就能看出来。torch.cuda.empty_cache()只能清理 PyTorch 的显存缓存块,解决不了真正的对象泄漏,还是要从代码里找哪里把中间结果留住了。
4.3 单卡能跑满,多卡以后反而变慢
单卡利用率很高,说明模型计算和数据管线都没大问题。多卡变慢,优先怀疑通信和同步。
第一步看扩展效率。如果 8 卡只有 4 倍多的吞吐,那基本能确定瓶颈在通信。第二步看迭代周期内是否有明显的同步等待。比如在 nsys 时间线上,如果每个 step 的末尾都有一段所有 GPU 都在等的区间,那多半是在等 AllReduce 或梯度同步。第三步检查 NCCL 版本和网络类型,老版本 NCCL 对某些新拓扑支持不佳,升级版本经常能直接解决一部分问题。
还有一种容易被忽略的情况:负载不均衡。某个 rank 因为数据切分不均或额外任务,完成一个 step 比别的 rank 慢,就会拖累所有 rank 一起等待。体检时如果把多卡的日志按 rank 分开看,谁在“拖后腿”一目了然。
4.4 环境与容器层面的隐藏变量
性能体检不仅要看应用层,还要看环境层。很多时候训练变慢,是环境变了而不是代码变了。
容器里如果限制了 CPU quota,比如只给 4 核,那么不管你怎么调 num_workers 都是白费。NUMA 不对齐也会影响性能,GPU 和 CPU 跨 NUMA 节点时要走更远的路径,P2P 拷贝会慢不少。
对于共享 GPU 或启用了 MIG、vGPU 的场景,nvidia-smi 显示的数字可能只代表你分到的切片,不代表整卡的真实状态。这时候还要留意同卡上的邻居任务是否在打满显存和带宽,导致你的任务被挤到慢速通道。
另外,驱动版本、CUDA 版本、cuDNN 版本的匹配程度也会影响性能。升级驱动后没有重新编译依赖库,或者换了容器镜像但底层 CUDA 变了,都可能造成几个百分点的性能波动。体检报告的“环境信息”一栏要记录这些,否则数据无法复现。
5. 出报告、排优先级、把体检变成习惯
5.1 把体检数据整理成一张可汇报的表
拿到一堆指标之后,不能只是“看一眼,觉得还行”。我习惯把体检结果整理成一张结构化的表,方便自己分析,也方便同步给团队。
| 项目 | 结果 | 判断 | 证据 |
|---|---|---|---|
| 单卡吞吐 | 1800 samples/s | 基线 | 3 次重复取均值 |
| GPU Utili | 75% | 中高 | nvidia-smi dmon |
| DataLoader wait | 42% | 瓶颈 | torch.profiler 时间线 |
| NCCL time | 8% | 健康 | nsys trace |
| 多卡扩展效率 | 0.52 | 低 | 8卡吞吐/单卡理论值 |
| 推荐动作 | 降低 worker 争抢、pin_memory、检查 CPU 配额 | 高优先 | 实验验证后吞吐翻倍 |
表格的核心作用是强迫你写清楚“判断依据”,每一条结论都能回溯到某个工具的输出。这样即使过了几个月再回来看,也能明白当时为什么做这个决定,而不是靠记忆。
5.2 优先级怎么排:先解决“存在感最强”的瓶颈
体检发现的问题通常不止一个,这时候别贪心,一次只改一个变量。优先级排序我的经验是:
先处理只改配置、不动代码就能生效的点,比如 num_workers、pin_memory、cudnn.benchmark、环境变量。这些改动风险极低,验证成本也低。如果改完吞吐有明显提升,立刻就能节省后续所有实验的时间。
再处理需要改训练逻辑的点,比如数据预处理、模型算子、显存管理、通信同步方式。这些需要结合正确性一起验证,不能只看性能。最后才是更重的重构,比如换模型并行策略、做算子融合,这时候性能体检的基线就变成了评估重构是否成功的标尺。
一次改一个变量还有个额外好处:你能准确知道每个变量贡献了多少性能,而不是把几个改动混在一起,最后根本说不清是谁起的作用。
5.3 体检不是一次性动作,而是训练运维的日常
性能体检不应该是出了问题才做。换环境、升级驱动、改模型结构、调整分布式配置,任何一个环节都可能让训练性能产生波动。我个人的做法是:任何训练脚本跑第一个完整 step 时,都会顺手挂一次torch.profiler或者nsys,把这个版本的性能基线记录下来。
如果团队有条件,还可以把性能基线做成 CI 检查或定时任务。比如每天早上跑一个固定的小模型 benchmark,看吞吐是不是相比历史版本有回退。这相当于给 GPU 集群做常态化监控,能在用户发现“训练变慢”之前先发现问题。
踩过几次坑之后,我发现性能工程最关键的并不是掌握多少优化技巧,而是养成“先量化再优化”的习惯。训练慢,先别急着改代码。先做一次性能体检,让数据告诉你答案,这才是节省所有人时间的最快方式。