训练慢的时候绝大多数人的第一反应是改代码换 batch size、改优化器、拆模型结构、加 num_workers甚至有人直接把 PyTorch 版本升级一遍。这些操作不是没有用而是顺序错了。我在 AI Infra 这行干了十多年处理过不下几十个训练变慢的案例真正的问题往往不在你盯着的那段代码里而在数据管线、GPU 利用率、Kernel 调度和通信链路这些更底层的地方。所以这一章我先不提优化技巧专门讲讲怎么做一次系统性的 GPU 性能体检。1. 慢的感觉不可靠先定义清楚你要优化的那个慢很多人跑训练时判断慢的方式很粗暴看了一眼 nvidia-smiGPU 利用率 60%觉得不对劲或者训练一个 epoch 比昨天多花了 20 分钟就觉得代码坏了。但这些感觉基本不能用来指导优化因为慢这个字在不同场景下指向完全不同的东西。1.1 训练慢的三种误解吞吐、收敛、卡顿先梳理一下日常说的训练慢通常包含三种完全不同的语义。第一种是吞吐量低单位时间内处理的数据量上不去。这种情况通常表现为 GPU 利用率不高、step time 偏长、每秒处理的样本数明显低于预期。这是性能工程最常处理的问题也是本章体检方案的核心目标。第二种是收敛慢loss 下降速度不如预期。这类问题往往和模型结构、学习率、数据分布有关GPU 利用率可能一直很高但模型就是学不动。性能体检工具也能看到一些端倪比如某些 Kernel 耗时异常、mixed precision 配置不对导致精度溢出但根因不在硬件资源利用率上。第三种是卡顿训练过程断断续续表现为周期性停顿、GPU 利用率像锯齿一样波动。常见原因是数据加载跟不上、CPU 预处理过重、或者 checkpoint 保存和验证集评估插进了训练主循环里。所以拿到问题先别急着改代码问自己一句你说的慢到底是哪种慢这个问题的答案决定了你要看哪些指标。1.2 为什么GPU 利用率低于 90%这个判断经常误导人nvidia-smi 里的 GPU-Util 是很多人的第一判断依据但实际上这个数字的参考价值非常有限。它代表的是采样周期内 GPU 上至少有一个 SMStreaming Multiprocessor在执行 kernel 的时间百分比而不是 SM 的真正计算密度。打个比方你问流水线工人今天有没有干活他说有我在工位上站了一天但他可能只是在等零件真正动手组装的时间没多少。GPU-Util 就是那个在工位上的数字它无法区分 GPU 是在满负荷跑大型矩阵乘法还是在一遍遍启动一个耗时极短但空转严重的小 kernel。我见过一个真实案例某个模型训练时 GPU-Util 显示 98%看起来一切正常但 step time 依然比理论值慢了好几倍。用 profiler 一查发现绝大部分时间花在几百个微小 kernel 的启动和切换上GPU 实际上是忙碌地空转。所以性能体检的第一步就是要抛开对 GPU-Util 单一指标的依赖。1.3 体检的第一步把慢锚定成可测量的数字在动任何工具之前先记录一组基线数据。这些数据不需要很高级nvidia-smi 和日志就能搞定当前 step times/step取最近 100 个 step 的中位数和 P95吞吐量samples/sGPU-Util、显存占用、显存温度、功耗瓦数CPU 利用率、内存占用DataLoader 是否出现明显的等待间隙看日志时间戳间隔是否均匀记录基线之后再开始逐层排查。这一步非常关键因为性能优化最怕的就是改完了感觉快了没有基线数据你无法判断改动到底带来了多少收益甚至可能把本来就正常的东西改坏。2. 性能体检的四张表从 GPU 全局到 Kernel 内部逐层下钻我习惯把性能体检分成四个层次每一层对应不同的工具和不同的关注点。不是每次都要全部跑一遍但你必须知道每一层解决什么问题才能根据症状快速定位到正确的层次。2.1 第一层nvidia-smi/dcgm 看全局资源水位nvidia-smi 是最基础的工具主要看四样东西GPU-Util全局利用率用来判断 GPU 是不是完全没被喂饱显存占用排除显存不够导致的频繁 swap 或 out of memory温度与功耗如果温度超过 83°C 或者功耗持续贴在 TDP 上限附近可能存在散热降频这个在长时间训练里非常常见多卡场景下的卡间利用率能初步判断是否存在通信不均nvidia-smi 的局限在于它只能看到 有没有在干看不到 在干什么。如果第一层看到 GPU-Util 长期较低说明瓶颈大概率在 GPU 之外数据加载、CPU 预处理、通信等如果 GPU-Util 很高但训练依然慢那就需要往下钻。补充一个小技巧用nvidia-smi dmon -s pucvmet可以按 1 秒粒度持续采样拿到比默认输出更详细的 SM、显存控制器、编码器、温度、功耗状态。如果是长时间训练建议把 dmon 输出重定向到文件训练结束后再分析。2.2 第二层PyTorch Profiler 看 CPU/GPU 耗时分解接下来用 PyTorch Profiler 把 operator 级别的耗时拆出来。这一层解决的核心问题是一个 step 的时间里CPU 在忙什么GPU 在忙什么谁在等谁。使用方式很简单from torch.profiler import profile, ProfilerActivity with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapesTrue, profile_memoryTrue, with_stackTrue, ) as prof: for i, (x, y) in enumerate(train_loader): loss model(x, y) loss.backward() optimizer.step() if i 50: # 不要 profile 整个 epoch采样 50 个 step 就够了 break print(prof.key_averages().table( sort_bycuda_time_total, row_limit30, ))输出表里重点关注三列Self CPU Time Total某个算子在 CPU 上的总耗时数据搬运、格式转换等算子的 CPU 时间特别高时代表预处理或数据传输存在瓶颈CUDA Time Total某个算子在 GPU 上的执行时间时间占比最高的算子就是你需要优化的目标CUDA Time Avg和 调用次数如果某个算子被调用了成千上万次且单次耗时很短说明模型里有大量小算子kernel 启动开销会非常可观跑完以后先回答一个问题一个 step 的总耗时里CPU 侧耗时和 GPU 侧耗时哪个更大如果 CPU 侧总耗时远大于 GPU 侧例如 CPU 1.5 秒GPU 0.6 秒说明 GPU 在大量空闲等待 CPU 喂数据。如果 GPU 侧远大于 CPU 侧则瓶颈在 GPU 计算本身需要做 kernel 级分析。2.3 第三层nsys 看时间线里的空隙和重叠PyTorch Profiler 能告诉你哪个算子最耗时但它把单个 step 的粒度打散了。有些问题它在算子层面看不出来比如 CPU 数据预处理和 GPU 计算是否真正重叠、kernel 和 kernel 之间有没有大量空隙、通信和计算是否被串行执行。这时候要上 Nsight Systemsnsys。nsys 的命令行用法nsys profile --tracecuda,nvtx,osrt --outputtrain_profile -w true python train.py跑完后用 Nsight Systems 打开生成的.nsys-rep文件最核心的是看 CUDA 时间线上的空隙。一个理想状态的训练循环应该是CPU 预处理当前 batch 的同时GPU 在算上一个 batch两条时间线紧密重叠几乎没有空隙。但很多时候你看到的是GPU 算完了等了几十毫秒CPU 才把下一个 batch 送过来或者每个 kernel 之间有几微秒到几十微秒的空隙连起来就是可观的浪费。nsys 还能直观看到数据加载的耗时风格。如果 DataLoader 相关的 CPU 时间线长时间持续高占用并且 GPU 时间线周期性空白基本可以实锤数据加载瓶颈。如果 CPU 时间线很清闲但 GPU kernel 之间依然有空隙那问题可能出在 kernel 启动开销或者框架调度上。2.4 第四层ncu 看单个 Kernel 的算术强度与访存效率当瓶颈确认在 GPU 内部之后就要用 Nsight Computencu针对具体的 kernel 做微观分析。这一层的目的是回答GPU 明明在干活但干的效率高不高ncu 的使用方式比较特殊它建议在单独的进程里只 profile 你关心的 kernelncu --set full --kernel-name regex_of_kernel --launch-count 3 python train.py重点看三块Compute (SM) ThroughputSM 中计算单元的利用率Memory Throughput显存带宽利用率Duration和Occupancy单个 kernel 的耗时与 SM 占用率从这三个指标可以反推 kernel 的属性。如果 Memory Throughput 远高于 Compute Throughput说明这个 kernel 是访存密集型瓶颈在显存带宽怎么优化计算指令都没用核心方向是减少数据搬运、提高数据复用。反过来则是计算密集型方向是提升算术强度或使用 Tensor Core。常见的情况是两者都不高比如 SM Throughput 只有 30%Memory Throughput 只有 20%但 kernel 耗时很长。这种往往是在等显存延迟、bank conflict 严重、或者 occupancy 太低导致隐藏不了缓存未命中需要进一步看 Memory Workload Analysis 和 Warp State Statistics 才能定位。3. 一份典型体检报告怎么看常见瓶颈的特征与根因工具是手段最终目的是搞清楚病根。这一节我把实际工作里最常见的几类瓶颈列出来每类瓶颈对应什么特征、什么根因、体检报告上长什么样照着对照基本上能快速缩小排查范围。3.1 数据加载瓶颈GPU 饿肚子利用率锯齿特征最好辨认GPU-Util 曲线像锯齿一样来回震荡高的时候 95%低的时候跌到 20%。nvidia-smi 里显存占用稳定但 SM 利用率波动很大。nsys 时间线上GPU kernel 执行一段后出现明显空白空白与 CPU 的数据加载时间线对齐。PyTorch Profiler 里dataloader相关等待时间特别长。根因通常是三类磁盘 IO 太慢、CPU 预处理过重、DataLoader 的 worker 数量不足。注意区分这三种根因磁盘 IO 慢的话 CPU 利用率并不高因为 CPU 在等磁盘预处理过重的话所有 CPU 核都在满负荷运转worker 数量不足则整机 CPU 利用率不高但每个 worker 都在忙。体检阶段不急着改先用一个最简单实验确认把 DataLoader 换成随机生成的 fake tensor 数据完全跳过真实数据如果 step time 大幅下降就说明数据侧确实是瓶颈。接下来再细查是 IO 还是预处理。3.2 Kernel 太小太密启动开销吃掉算力这个瓶颈最大的迷惑性在于 nvidia-smi 利用率表现非常正常——GPU-Util 可能是 95% 以上功耗也接近满载但 step time 就是明显偏慢。PyTorch Profiler 表里能看到大量self_cuda_time_total很小但调用次数极高的算子比如aten::add、aten::mul、aten::relu可能一个 step 里调用了几千次。nsys 的 CUDA 时间线上能看到细密的 kernel 分布kernel 间空隙非常多。根因是模型里充满了逐元素的细碎操作比如频繁对 tensor 做标量加减乘除、大量逐层 ReLU 和 Dropout、或者在 Python 层面逐层手动实现了某些本可以融合的计算。GPU 每个 kernel 无论多小启动都需要固定开销通常几微秒到几十微秒几千个 kernel 叠加之后启动开销就变成了主要瓶颈。这里有个反常识的地方很多人以为 GPU 利用率高就代表效率高实际上非常忙碌地频繁切换小任务和专注地跑一个大任务在利用率上可能表现一样但吞吐差距巨大。3.3 访存瓶颈算不过来 vs 等数据过来如果 ncu 显示 Memory Throughput 接近 80% 以上而 Compute Throughput 只有 20~30%说明这个 kernel 被显存带宽卡住了。特征是最耗时的算子往往是 embedding、attention 里的 gather/scatter、或者某些没做 chunk 处理的全连接层且改成 fp16/bf16 后提速非常明显。根因通常是数据复用率低。比如大矩阵乘里面如果某个维度特别大而循环块切分不合适每算一个结果都要从显存重新拉数据或者模型里有大量 reshape、transpose、permute 操作导致显存访问模式变成了零星随机访问完全打不到显存带宽的峰值。这种情况下即使算子本身计算量不大耗时也会很高因为 GPU 绝大多数时间在等数据伸手递过来。3.4 多卡通信瓶颈梯度同步拖后腿多卡训练里常见但 pyTorch 默认不打印的一类问题。特征很典型单卡跑得很好DDP 一上step time 从 1.0 秒变成 1.8 秒而且 GPU-Util 每轮出现周期性下跌。nsys 里能看到 NCCL 的 AllReduce 时间线通信期间 GPU 计算基本暂停。根因主要分成两种。第一种是网络中低速连接比如用 TCP 而不是 RDMA/InfiniBand 做卡间互联梯度同步耗时会随卡数线性上涨。第二种是梯度通信和计算没有重叠PyTorch DDP 默认在 backward 结束时等待所有梯度的 AllReduce 完成如果模型层次很浅或者梯度大小不均匀通信的空窗期就会非常明显。体检时在 nsys 里搜索NCCL或ncclKernel直接看通信耗时占比就能判断是不是需要换通信后端、开启梯度压缩或者改用梯度异步。4. 体检之后不要急着改代码先排优先级拿到体检结果之后很多人容易犯的毛病是看到瓶颈就直接动手改改完也不对比基线然后陷入越改越乱的泥潭。性能优化打的是性价比仗同样的时间投入正确的优先级能带来数倍的收益错误的优先级可能只带来 5% 的提升。4.1 性价比排序管线、配置、算子、算法我自己的经验是把优化手段按改动成本从低到高、对系统影响从外到内来排优先级数据与管线配置调 num_workers、pin_memory、prefetch_factor、换数据格式比如从 JPEG 换成 LMDB 或 TFRecord、把数据预处理改到 GPU 上或用 DALI。这些改动通常只是配置项和几行代码但数据加载瓶颈带来的提升往往非常可观。框架与运行配置开启混合精度AMP、设置torch.backends.cudnn.benchmark True、调整 batch size、修改优化器的分组策略等。不改变模型结构收益稳定且可控。算子融合与 Kernel 重写把多个逐元素算子合并成一个、写自定义 CUDA kernel、引入 FlashAttention 这类现成的融合算子。改动成本较高需要更多工程和测试投入适合瓶颈已经定位到具体 kernel 且前面的手段都试过的情况。模型结构或算法级修改改 loss 计算方式、调整模块设计、减少某些层的计算。这属于最后手段因为会直接影响模型效果需要重新做实验验证风险和成本都很高。这个排序的核心逻辑是先用低成本手段解决系统级瓶颈再考虑是否值得为计算瓶颈付出更高的工程成本。4.2 低成本的优先级调整示例以数据加载瓶颈为例体检报告显示 CPU 预处理过重、GPU 大量空闲。这种情况下的低成本调整顺序是先加 workerDataLoader(..., num_workers8, pin_memoryTrue)。注意不是 worker 越多越好worker 超过 CPU 物理核数一半以后收益会迅速衰减反而增加进程切换开销。再把预处理操作尽量合并成向量化操作避免 for 循环里逐张图做 numpy 操作。或者把整个预处理函数从 gpu 机器挪出去提前做成离线缓存训练时只做轻量级读取。如果显存带宽是瓶颈低成本手段是先把数据精度降下来比如从 fp32 换成 fp16/bf16访存量直接减半收益立竿见影。很多时候这一步就能解决问题不需要去动算子实现。4.3 什么情况下才值得改模型结构/自己写算子我自己判断要不要动算子级优化的标准很简单瓶颈是否已经通过低成本手段压缩到只剩计算本身。也就是说数据加载已经最优、精度已经是低精度、batch size 已经调整过、配置项已经挖掘干净此时 profiler 里某个 kernel 依然是耗时大头并且它的理论上限用 ncu 算出的峰值可达性能与实测差距明显这时候才值得投入做算子融合或者手写 CUDA kernel。另外还有一种情况值得做算子级优化这个模型/算子会被长期反复使用比如整个团队未来半年都在跑这个模型。一次性的算子优化可能花三天但换来的是后面每次训练提速 20%~30%这笔账才划算。如果只是跑一两周的一次性实验与其花时间优化算子不如直接把资源规格往上提一档。5. 我实际做性能体检时踩过的坑最后分享几个我在实际体检过程中踩过的坑。这些坑在文档里不会写但碰上一次就能白折腾一整天。5.1 在容器里看 nvidia-smi 的误区容器里运行训练时nvidia-smi输出的显存和利用率和宿主机上看到的可能不一致。某些版本的容器运行时只映射了 GPU 设备但 MIGMulti-Instance GPU或者显存隔离策略可能导致显示的值比宿主机上的少一大截。如果你在容器里看到某个 GPU 利用率总是低得不正常先到宿主机上对比一下同样命令的输出再判断是不是真的有问题。5.2 小 batch 诊断 vs 大 batch 诊断要分开有一个早期踩过的坑是用小 batch比如 batch size2做 profiler然后拿这套结论去优化大 batch 训练。小 batch 下 CPU 启动开销、kernel 调度开销被放大得非常明显此时 PyTorch Profiler 里占比最高的可能是一堆在 大 batch 下根本不算问题的细碎算子。反过来大 batch 下数据加载的前期处理时间没有被摊薄更容易暴露访存和显存占用问题。体检时尽量用和真实训练一致的 batch size或者至少做两轮分析对照一轮 small batch 和一轮真实 batch。5.3 性能体检要留基线改一行记一笔这个算是我最想强调的一点。性能体检不是为了出一张报告就完事而是为后续优化建立可对照的基准。建议在项目里建一个简单的优化日志表格记录每次改动的参数、日期、step time、GPU-Util、显存占用、CPU 占用。这看起来麻烦但在模型跑了两周之后如果有人问这个配置当时为什么这么调你能翻出日志回答而不是靠记忆猜。另外还有一个实际操作的小建议性能体检时跑的训练步数不要太少。有些人为了省时间只跑 5 个 step 就开始看 profiling 数据但加载预热、显存分配、cudnn benchmark 搜索这些都会在前几十个 step 内发生5 个 step 的数据噪音太大。我习惯先跑 50 个 step 做 warm up再取中间 30 个 step 的 profiling 数据作为分析样本。性能体检这件事看起来不如改个炫酷的模型结构有成就感但它才是性能工程的真正起点。把慢这个抽象感觉一步步变成可量化的指标再根据指标排序优化动作绝大多数训练性能问题都能在这个流程里找到答案。下一章我会继续展开具体瓶颈案例的优化实操包括数据管线重构和算子融合的完整过程。