GPU利用率低?六大瓶颈排查清单与优化实战
前段时间我在排查一个训练任务,loss掉得很慢,跑了好几天还在高位震荡。打开nvidia-smi一看,GPU利用率只有20%出头,显存占用倒是很高,但SM(流处理器)大部分时间在闲着。最让人头疼的是,这任务看起来还在跑,就是进度条纹丝不动,整个团队都在等我给个说法。
这种情况如果你也遇到过,那几乎可以确定问题不在模型本身,而在“模型外面”——数据供给、CPU预处理、内核调度、显存管理、多卡通信、资源竞争,这六个环节随便卡一个,GPU都会饿到干等。做AI训练的人每天盯GPU利用率,但利用率低这件事,从来不是只看GPU就能解决的。这篇文章我会把自己实际排查时用到的思路、命令、工具和避坑经验整理成一份清单,帮助你一步步定位瓶颈到底在哪里,而不是瞎调BatchSize或盲目加卡。
因为实际排查GPU利用率低,关键不是“把数值调高”,而是搞清楚“GPU的时钟周期到底花在了哪里”。一个真正可用的排查思路是:从GPU观察到的现象出发,沿数据流方向逐一排除,最后用Profile工具确认根因。下面直接进入正题。
1. 先用NVIDIA工具定位“时间去哪了”——利用率指标别只盯着一个数
1.1 nvidia-smi显示的数字到底是什么意思
很多人的第一反应是打开终端跑一句nvidia-smi,看到Utilization只有20%就开始怀疑GPU坏了。但这里有个常见的误解:nvidia-smi显示的GPU-Util,指的是一段时间内GPU上有计算指令执行的百分比,它不区分是在跑你的核心业务逻辑还是在空转刷显存。举个例子,如果你的代码里频繁调用一次很小的kernel,GPU可能一直在执行指令,nvidia-smi显示利用率不低,但实际上大量时间浪费在了内核启动和同步等待上。
更要命的是,nvidia-smi默认是每1秒采样一次,实时性还可以,但看不到单个kernel的长短和间隔。想定位瓶颈,第一步就要养成熟练查看这些工具的习惯:不仅是nvidia-smi,还要会用nvtop这种按进程实时展示SM、显存、编码器占用率的工具。当然,它们只能告诉你“GPU忙不忙”,无法回答“GPU忙得值不值”。
真正要判断“值不值”,必须看到时间线上的数据流:CPU在做什么、数据传输用了多久、kernel执行时长和间隔。这也是为什么我建议所有跑训练的人都应该掌握NVIDIA Nsight Systems的基础用法,它是定位瓶颈的第一利器。
1.2 用Nsight Systems给训练任务拍一张“时间轴照片”
Nsight Systems(通常简称nsys)能记录CPU、GPU、CUDA API、内存拷贝、内核执行等事件,生成一张时间线。我第一次用的时候,看到时间线上CPU侧一大片蓝色等待、GPU侧零星几个小尖峰,整个问题瞬间清晰了,根本不用猜。
常用命令是这样的:
# 截取训练前几百个iteration分析 nsys profile --trace=cuda,nvtx,osrt --output=profile_report -f true \ python train.py --max-steps 200跑完之后会生成一个.nsys-rep文件,用Nsight Systems GUI打开。重点看两个东西:
- CPU侧的CUDA API调用时长。如果
cudaMemcpy、cudaLaunchKernel之间的间隔特别长,说明数据准备或Python调度卡住了。 - GPU侧每个kernel的执行时长和相邻kernel之间有没有明显空隙。如果kernel间空隙远大于kernel本身,说明GPU在等CPU喂数据或等同步。
如果你连GUI都不想装,也可以用nsys stats快速输出汇总统计:
nsys stats --report=cuda_gpu_trace --format=csv profile_report.nsys-rep然后按kernel总时长排序,基本能锁定是哪个算子占用最多。不过要注意:Nsight Systems会显著拖慢训练速度,我只建议在测试场景或前几十个iteration做,不要在正式训练时开。
1.3 从时间线快速判断瓶颈类型
我总结过一套简单的判断方法,非常直观,分享给你:
| 时间线特征 | 可能的瓶颈 |
|---|---|
| CPU侧密集,GPU侧稀疏 | 数据加载、Python预处理、GIL锁 |
| GPU侧有kernel,但kernel间空隙巨大 | CPU指令发射慢、数据拷贝慢、等待同步 |
| 单个kernel执行时间长,且利用率高 | 算子本身计算密集,需考虑算子优化或换更快的实现 |
| 多GPU训练中GPU之间互相等待 | 通信瓶颈、负载不均、AllReduce开销 |
| 显存占用高但SM占用低 | BatchSize过大导致频繁换页,或模型内含大量稀疏/空白计算 |
从这一步开始,你就不再是盲目猜了。接下来围绕最常见的六个瓶颈,一点一点拆解。
2. 瓶颈一:数据供给跟不上——Dataset和DataLoader是首个嫌疑人
2.1 为什么数据加载会成为GPU饿肚子的根源
训练循环的本质是:CPU把数据从磁盘或内存取出来,做预处理,然后拷到GPU显存,GPU算完,再取下一批。如果一批数据要500毫秒才能准备好,而GPU算一批只要100毫秒,那GPU有80%的时间都在空转,利用率自然上不去。尤其是现在图像、视频、大语言模型的token序列越来越大,一个样本几MB甚至几十MB,磁盘IO和预处理很快就成了短板。
我自己踩过最典型的坑:数据集存在机械硬盘上,一个epoch要读取上千个文件,DataLoader的num_workers已经调到16,但GPU利用率始终上不去。后来用iostat -x 1一看,磁盘util接近100%,avgqu-sz排队长度惊人。换成NVMe固态盘之后,同样的训练配置,GPU利用率从45%直接跳到80%以上。你说问题在模型吗?根本不在。
2.2 数据瓶颈的排查命令与指标
排查数据供给瓶颈,比想象中简单。只需要开几个终端窗口,同时监控这几个指标:
# 实时看磁盘IO,重点看 %util、await、avgqu-sz iostat -x 1 # 看整体IO吞吐和CPU上下文切换 dstat -d -c -i 1 # 看进程实际状态,如果有大量D状态进程说明在等待IO top -d 1如果你用的是PyTorch,还可以在代码里给Dataset做更精细的记录:
- 单独测量单个样本读取时间,
- 单独测量预处理时间,
- 分别打印出来。
很多时候瓶颈不在于磁盘而在预处理逻辑。比如我见过有人每次迭代都对大图做JPEG解码、随机裁剪、翻转、归一化,这些全在CPU主线程里做,GPU当然等你。这时候要用torch.utils.data.DataLoader里的num_workers和prefetch_factor把预处理并行化。
一个实用的判断标准:如果nvidia-smi显示GPU利用率低,同时top显示多个python子进程CPU占用接近100%,那数据管线大概率有并行度不够的问题。如果磁盘%util长期100%,那就是存储系统扛不住了。
2.3 数据管线优化手段和实操经验
优化数据管线,优先级我建议这样排:
- 把数据集放在NVMe固态盘或内存文件系统里,别用机械硬盘或网络盘(网络文件系统尤其坑,随机小文件读取性能极差)。
- 使用
DataLoader时设置合理num_workers,一般建议等于CPU物理核数的一半到全部,但别贪多,太多反而会导致频繁切换上下文。 - 用
prefetch_factor预取多批数据,默认是2,可以适当调大。 - 如果数据读取不需要实时,最好把预处理结果落盘成缓存格式(例如TFRecord、WebDataset、或者pickle打包),减少重复解码。
- 做实验时用合成数据(random tensor)跑一遍逻辑。如果合成数据下GPU利用率很高,而真实数据下很低,铁定是供给链路的问题。
有一次我就是这么干的,用torch.utils.data.DataLoader加载一堆随机Tensor跑,GPU利用率94%,换真实数据只有60%,立刻定位到是数据侧。后来检查发现是数据集中有个字段每次都要从数据库查询,执行一次要200ms,把它改成启动时预载到内存后,问题直接消失。这类问题如果只看GPU利用率根本发现不了。
3. 瓶颈二:CPU预处理和GPU之间的“流水线错位”
3.1 num_workers的真相和常见误区
数据管线优化时很多人喜欢把num_workers调到很大,以为越大越快。但实际上这个参数控制的是“有多少个独立的进程同时去加载和预处理数据”,不是“越多越好”。如果你的预处理本身很轻量,开太多进程反而会因为进程间通信、内存拷贝的代价拖慢整体速度。我见过一个反直觉的案例,把num_workers从8调到12,GPU利用率不升反降,因为每个子进程都在抢占CPU资源,主进程反而拿不到足够的CPU来执行模型调度。
正确做法是结合你自己的CPU核数和预处理复杂度来定。一个可行的起步点:物理核数的1到2倍,然后再观察调整。同时也要注意pin_memory=True是否开启,这个参数可以把CPU内存中的数据页锁定,加速CPU到GPU的拷贝,很多时候打开它就能提升几个百分点的利用率。
3.2 prefetch、pin_memory和流水线深度
想象一条流水线:读磁盘、预处理、传GPU、计算,每一步都需要时间。如果每一步单独跑,总耗时就是四段相加。但如果把它们放到不同进程/线程里重叠执行,总耗时就能缩短到最慢的那一段。DataLoader的prefetch_factor控制“预先加载多少批数据到内存”,其实就是加深流水线。默认2可能不够,调成4或8可以缓解短时突发IO导致的等待。
pin_memory=True的作用类似于为GPU传输专门准备了一条“直通车道”。正常CPU内存可以被换页,而锁页内存不会被换出,因此host到device的拷贝能跳过一部分地址转换开销。虽然它不是万能的,但成本几乎为零,建议默认打开。
还有一点容易被忽略:如果你的预处理里用了不兼容多进程的对象,例如lambda函数或某些自定义类没有实现序列化,PyTorch会退化成主进程加载——也就是num_workers全无效。我在代码里碰到过因为Dataset里持有threading.Lock导致多进程worker全部卡住,GPU利用率掉到接近0。遇到这种问题,建议在worker_init_fn里显式初始化,避免把不可pickle的对象通过继承传到子进程。
3.3 实战案例:CPU占用低但GPU等待高
有一次现场排查,GPU利用率只有30%,但CPU占用也才40%,两边看起来都不忙,这就非常奇怪了。后来用nsys看时间线,发现GPU侧kernel执行正常,但每次kernel之间都有一段“神秘间隔”,而且CPU侧的cudaMemcpyAsync调用迟迟不返回。再往下查,发现代码里用了tensor.item()去同步获取一个标量,每步都触发了一次GPU到CPU的同步拷贝,等于强制把流水线打断了。
这种问题非常隐蔽,因为tensor.item()在脚本里只是一个取值操作,但它背后意味着设备到主机的同步等待。解决的方法也很简单:把需要的标量放到GPU上用torch.Tensor操作,或者聚合到一定步数后再同步取值。排查同步等待有一个小技巧:在Nsight timeline里搜索Sync或cudaDeviceSynchronize标记,一旦频繁出现,基本就是在“假并行”。
4. 瓶颈三:内核启动与GPU利用率虚高——你的“利用率”可能不是有效计算
4.1 内核启动开销:为什么小算子堆起来的模型特别慢
GPU和CPU不一样,CPU喜欢顺序执行,GPU则适合大规模并行。但每次把kernel发射到GPU上,都有固定开销,包括命令解析、上下文切换、依赖检查,通常约为5到10微秒。单个kernel如果执行时间只有几微秒,那启动开销占比就非常惊人。
典型例子是小模型或推理场景,或者模型中存在大量element-wise操作,例如逐点激活、归一化、dropout mask生成。这类算子本身计算量不大,但数量极多,GPU在一层一层地调度中浪费时间。表现就是时间线上几十个短kernel连在一起,每个都很短,但间隔接近kernel自身长度。这种场景下的GPU利用率数字可能显示80%,但实际“有效FLOPs”可能只有一两成。
要判断这类问题,最简单的方式是在Nsight Systems里看kernel平均时长。如果大量kernel时长小于5微秒,就要考虑算子融合了。
4.2 用Nsight Compute分析kernel内部的资源利用率
Nsight Systems告诉你kernel占了多少时间,Nsight Compute(ncu)则能钻进单个kernel内部,看SM的占用率、warp占有率、内存流水线、分支发散等情况。用法大致是:
# 指定只profiler一个kernel,用kernel name或序号 ncu --set full --launch-count 1 -k regex:matmul python train.py重点看SM Busy和Memory Throughput两个指标:
- 如果SM很忙但Memory很闲,通常说明计算密度高,可以尝试更高效的算法或降低精度。
- 如果Memory很忙但SM较闲,说明访存受限,要考虑合并访问、数据结构紧凑化。
- 如果两者都很低,那可能是warp占有率不够,也就是并行线程太少,无法填满GPU延迟隐藏的能力。
这里必须提一个核心概念:GPU利用率低,很多时候不是“不忙”,而是“忙在等待上”。每个SM有最多可驻留的warp数量,如果你的线程块太大或寄存器占用过多,能驻留的warp变少,当部分warp在等待显存返回时,没有足够多的其他warp顶上,SM就空转了。幸好现代深度学习框架中这种问题相对少,但自定义算子或极端模型的层里很容易出现。
4.3 减少内核启动开销:算子融合、自动混合精度与cuDNN benchmark
解决小算子爆炸和启动开销,最直接的手段是算子融合。把多个相邻的逐元素操作合成一个kernel,比如把add和relu合并成bias_relu,可以由编译器自动完成,也可以手写CUDA实现。框架层面最省事的做法是打开torch.compile(PyTorch 2.0+),它会在后台做kernel fusion,效果常常立竿见影。
另外,我建议训练时固定开启torch.backends.cudnn.benchmark = True,这会让cuDNN在第一次迭代时尝试多种卷积算法,选出最快的一个缓存下来。如果你的输入尺寸固定,这个设置几乎百利无一害;但要注意如果输入尺寸频繁变化,benchmark本身会增加额外开销,这时反而要关掉。
混合精度和TF32也是优化利器。在Ampere及以上架构的GPU上,TF32能显著加快矩阵运算,对很多模型精度影响很小。而FP16/BF16混合精度则是目前训练大模型的主流做法,不仅省显存,还能让GPU利用率更饱满。有不少人跑大模型训练利用率低,其实是因为默认用了FP32,显存带宽撞壁,SM大部分时间在等数据。换成BF16混合精度后,利用率立刻提升。
关于“利用率虚高”,我再分享一个经验:有些框架(比如TensorFlow老版本)会把“GPU在执行kernel的时间占比”记为利用率,即使kernel效率极低。所以排查时不要轻信面板上的数字,一定要结合loss下降速度来评估。如果loss下降慢、kernel内部效率也不高,那就要从计算密度和访存模式入手了。
5. 瓶颈四:显存不够导致的频繁换页与BatchSize误调
5.1 OOM与显存溢出带来的“假训练”
显存问题看似简单,实际坑很多。最直接的情况是显存溢出(OOM),代码直接报错退出,这个反而好排查。麻烦的是显存没爆,但接近满载时,GPU驱动为了给新数据腾空间,可能把旧数据挪到主机内存,或者执行缓慢的分配/释放。我的经验是:如果nvidia-smi里显存占用长期在95%以上,或者训练过程中显存数字在周期性大跳,一定要警惕换页和碎片化。
一个更隐蔽的场景:你用了CUDA的unified memory或自定义了显存分配策略,比如PyTorch的torch.cuda.memory_stats()可以看allocated_bytes.all.peak。我把这个值打出来之后发现,模型权重和优化器状态占了一大半,可用空间就剩一点,每次分配新张量都触发cudaMalloc,cudaMalloc本身非常慢,直接拖低GPU利用率。
5.2 BatchSize和GPU利用率的关系
很多人一看到GPU利用率低,第一反应是调大BatchSize。大方向没错,但不绝对。在数据管线正常的前提下,调大BatchSize能让每个GPU iteration处理更多数据,计算密度更高,利用率随之提升。但BatchSize过大也会带来副作用:显存不够、优化收敛变慢、batch内平均梯度不代表真实分布。
我的建议是:先观察“BatchSize增加后,单iteration的总耗时如何变化”。如果耗时近似线性增加,说明计算密集型主导,可以继续加大;如果耗时增加不明显,说明瓶颈可能在数据管线或CPU侧,加大BatchSize并不能解决问题。一个简单的测量方法:
# 在训练脚本里统计每个iteration耗时 python -c " import torch, time # ... dummy model ... for i in range(20): t0=time.time() loss.backward() optimizer.step() print((time.time()-t0)*1000) "用这个方法,你可以快速建立“BatchSize-耗时”曲线。如果你发现从batch=32调到batch=128,耗时只从100ms涨到120ms,说明GPU计算还远未饱和,主瓶颈在别处。反之,如果耗时从100ms涨到350ms,说明算力基本吃满了,利用率没问题,问题可能在于模型收敛策略。
5.3 梯度累积的正确用法和常见误区
梯度累积可以帮你突破“显存不够导致batch太小”的限制。比如你想用batch=512,但单卡显存只能塞下batch=64,那就可以每8步做一次优化器更新,等效batch=512。这在分布式训练中也常用。
但务必注意:梯度累积不等于每一小步的GPU利用率都会提高。它在逻辑上等价于把大的batch拆成多个小batch串行处理,如果小batch的计算密度本身不够,梯度累积只是缓解显存限制,并不能解决利用率低的问题。而且梯度累积会引入额外的同步点,如果每次累积后都调用optimizer.zero_grad(),也会打断kernel流水线。建议用optimizer.zero_grad(set_to_none=True),比zero_grad()更省显存和带宽。
如果显存足够,我通常不建议过度使用梯度累积。对单卡训练而言,有更干净的方法:把BatchSize加大到显存允许的上限,同时适当降低batch内样本的解析规模(比如小图、短序列),利用率自然就上去了。
6. 瓶颈五:多卡训练时的通信与负载不均
6.1 AllReduce带来的等待:GPU在等谁?
一旦进入多卡分布式训练,GPU利用率低的原因又多了一类:通信等待。最典型的是同步SGD中,每张卡算完梯度后必须全局求平均,这个过程就是AllReduce。假设8张卡,每张卡算完的时间都不一样,快卡必须等慢卡到了才能一起AllReduce。快卡算完了但没事干,GPU利用率显示下降。再加上网络传输本身耗时,通信占比可能远高于你的预期。
先说个判断经验:如果单卡训练时GPU利用率90%,多卡训练只有60%,那基本就是负载均衡或通信开销导致。先用nvidia-smi topo -m查看GPU之间的拓扑连接方式,是NVLink、PCIe还是通过主机内存走QPI/UPI,这决定了通信带宽上限。建议做一次NCCL的快速基准测试,用官方脚本nccl-tests里的all_reduce_perf来测单机多卡的实际通信带宽:
# 在安装了NCCL的容器里运行 mpirun -np 8 ./build/all_reduce_perf -b 128M -e 1G -f 2 -g 1如果带宽远低于理论值,优先看PCIe链路和NVLink是否降速。NVIDIA的nvidia-smi topo -m会输出一张拓扑表,如果两张卡都在同一个PCIe Switch下,通信会快很多;如果跨NUMA节点,通信要走CPU,慢很多。
6.2 通信占比与梯度压缩、梯度累积的取舍
判定通信是不是瓶颈,可以比较每个iteration里“通信时间”和“计算时间”。NCCL在训练日志里通常会有平均值,或者你用nsys也能看到ncclKernel的时间占比。如果通信时间超过总时间的30%,就值得优化。
优化方向有几种:一是用梯度压缩/稀疏化减少通信量;二是用梯度累积,让通信频率降低——比如本机每4个micro-batch才做一次AllReduce;三是调整NCCL的环境变量,例如NCCL_P2P_LEVEL、NCCL_BUFFSIZE,在某些硬件组合下能明显改善;四是用分桶通信,把梯度的AllReduce和反向计算重叠。PyTorch DDP默认已经把梯度和反向计算重叠了一部分,但如果你的网络层很多、每层梯度都小,通信次数会爆炸,可以设置bucket_cap_mb合并小张量。
另一个容易被忽视的点:多机训练时,网卡质量直接决定上限。如果只有千兆网卡,数据传输慢到让人崩溃;至少需要25GbE或InfiniBand。如果只能走TCP,那一定要打开NCCL的NCCL_SOCKET_IFNAME指定正确的网卡,否则它可能走了错误的网络路径。
6.3 负载不均的问题:有些卡就是“偷懒”
即使通信带宽没问题,多卡利用率低也可能是各卡负载不均。常见的负载不均来源有:数据采样不均衡(有些worker拿到了特别长的样本,需要计算时间更久)、机器里有其他任务抢占资源、或者使用了非同步模式的Async SGD。
检查方法也很简单:在多卡训练时分别看每张卡的GPU利用率。如果是PyTorch DDP,通常每张卡利用率应该非常接近。如果某张卡长期低于其他卡,多半是它的CPU数据供给或者通信出了问题。可以用:
# 每0.5秒刷新一次,带进程信息 watch -n 0.5 nvidia-smi或者写一个小脚本,采样每张卡当前的sm利用率并画图。我排查过一个案例,4卡训练中第3张卡一直只有40%,另外三张卡95%。后来发现负责给第3张卡feed数据的CPU核心被别的任务占满了,数据生成速度跟不上。把num_workers分散到其他NUMA节点CPU上后,立刻恢复到90%以上。
6.4 多卡调试的另一个经验:设置环境变量是真的有讲究
NCCL在不同硬件拓扑下的行为差异很大,我建议在跑大规模训练前先用nccl-tests在不同环境变量组合下各跑一遍,记录最优参数。比较常见的组合:
export NCCL_P2P_DISABLE=0 export NCCL_IB_DISABLE=0 export NCCL_P2P_LEVEL=PXB export NCCL_BUFFSIZE=16777216但请记住,这些参数没有普适最优解。有一回我们为了提升通信,强制NCCL_P2P_LEVEL=NVLink,结果因为某些节点GPU没有被NVLink连接,反而走了更慢的路径,性能降低一倍。所以任何环境变量修改都要用基准测试验证,不要人云亦云。
7. 瓶颈六:资源竞争与后台杂讯——容易被忽略的“邻居噪音”
7.1 CPU核心被抢占、内存带宽不够
GPU利用率低,但CPU侧检查了数据管线没问题,通信也没问题,这时候你就要把视野放到“整个机器”上了。最典型的敌人是共享机器上的其他任务。如果你在用公有云或内部K8s集群,别的容器可能会抢CPU、占内存带宽、打满磁盘IO。有时你自己没申请那么多资源,但宿主机的其他进程也会干扰你。
排查命令:
# 看整体负载和核心使用 uptime top -E k # 看内存带宽压力(需要perf工具) perf stat -e uncore_imc/data_reads/,uncore_imc/data_writes/ -a sleep 10 # 看NUMA均衡 numastat -p <pid>如果整体负载很高,而你的进程CPU占用也不小,但GPU就是等,那就可能是内存带宽撞墙了。现代CPU核心越多,但内存带宽是共享的。数据加载进程如果大量占用内存带宽,GPU把数据从CPU内存搬到显存的速度就会受影响。
7.2 多任务共享GPU时的利用率假象
还有一种情况:同一张GPU上跑了多个训练任务。从nvidia-smi看,单张卡利用率100%,但你的任务实际只分到了一部分SM。NVIDIA的MIG技术会做物理隔离,但默认情况下多进程是共享GPU的,每个任务能用的SM数量不固定。如果你的利用率显示低,先检查是不是有其他任务占用了这块卡。
用nvidia-smi --query-compute-apps=pid,used_memory,utilization.gpu --format=csv可以查看当前GPU上有哪些进程在跑,以及各自占的算力和显存。如果你发现自己任务显存占了几GB,但utilization.gpu里都是别的进程,那就说明你在“捡漏”式地共享GPU,性能波动非常大。
这个问题在团队共用训练服务器时尤其常见。我的建议是:
- 用
CUDA_VISIBLE_DEVICES明确指定自己的GPU编号,别让它自动选。 - 如果有多张卡,可以按任务类型划分:训练任务独占卡,推理任务用小号卡或MIG切片。
- 如果不得不共享,至少用
torch.cuda.set_per_process_memory_fraction限制自己的显存上限,防止一山不容二虎导致OOM。
7.3 排查资源竞争的系统级命令速览
这里我给一份适合在排查阶段轮流敲的命令清单:
| 命令 | 查看内容 | 适用场景 |
|---|---|---|
top -d 1 -c | 进程CPU、内存占用 | 找CPU吃满的进程 |
htop | 更友好的CPU/内存视图 | 快速确认NUMA核使用 |
iostat -x 1 | 磁盘IO等待 | 数据盘慢 |
dstat -c -d -m -n 1 | 整体系统资源 | 多维度概览 |
free -h | 内存余量 | 内存不足导致swap |
vmstat 1 | 进程、内存、IO、CPU调度 | 定位系统级饿死 |
nvidia-smi -l 1 | GPU实时状态 | 看GPU利用率波动 |
nsys/ncu | 内核与时间线 | 精确定位kernel问题 |
另外,不要忽略内核日志。如果GPU设备出现nvidia-smi里的“ERR!”状态,或者dmesg里有NVML报错,那可能是驱动或硬件层面的问题。这种故障会让GPU利用率显示异常,甚至拒绝执行kernel。遇到过几次后,我的习惯是任何一次训练前先跑一个简单的CUDA向量加法脚本,确保驱动和显卡通信正常。
8. 一份可直接抄的GPU利用率排查清单(含命令速查)
8.1 一页纸排查流程:从现象到根源
我把整个排查思路浓缩成六个步骤,按顺序走,基本能覆盖90%的利用率低问题:
- 确认“利用率低”是真的低:看
nvidia-smi、nvtop、loss下降速度,排除利用率虚高。 - 判断是单卡还是多卡:单卡先看时间线,多卡先看每卡利用率差异和通信。
- 用Nsight Systems截取一小段训练时间线,区分CPU等待、GPU执行、通信、拷贝四大块。
- 针对时间线特征,按前面五个瓶颈依次排查:数据供给→CPU预处理/同步→kernel效率→显存占用→通信与负载。
- 用Nsight Compute深入有嫌疑的kernel,看SM Occupancy和Memory Throughput。
- 修复后重新采集时间线,对比优化前后同一阶段的时间开销。
这套流程听起来很繁琐,但实际上手很快。对绝大多数情况,第3步的时间线已经能给出80%的结论,后面所有操作都只是验证。
8.2 常用命令速查表(按瓶颈分类)
| 瓶颈类型 | 核心命令 | 关键指标 |
|---|---|---|
| 数据供给 | iostat -x 1、dstat -d | 磁盘%util、await、avgqu-sz |
| CPU预处理 | top、htop、vmstat | 每个进程CPU%、上下文切换 |
| 内核启动 | nsys stats --report=cuda_gpu_trace | kernel平均时长、间隔 |
| 显存问题 | nvidia-smi、torch.cuda.memory_stats() | 显存峰值、碎片、OOM |
| 多卡通信 | nccl-tests/all_reduce_perf、nvidia-smi topo -m | AllReduce带宽、通信耗时占比 |
| 资源竞争 | top、free -h、dmesg -T | 负载、内存、GPU错误 |
8.3 我的几个压箱底习惯
排查GPU利用率这件事,其实考验的不是会不会敲命令,而是对“整个数据流”有没有直觉。这里把我个人压箱底的几个习惯分享给你,不一定适合所有人,但至少给了不少同事帮助:
- 在训练脚本里每隔一定步数打印一次GPU利用率和iteration耗时,做成日志。训练完再回看,能发现很多“当时没注意”的规律。
- 做任何大数据集实验前,先跑100步随机数据,建立该模型在“无供给瓶颈”时的GPU利用率基线。没有基线,后面所有排查都无从谈起。
- 修改参数时一次只改一个变量,改完必须重新测时间。同时改num_workers、batchsize、混合精度,出了问题你根本不知道谁导致的。
- 遇到莫名其妙的利用率跳变,先怀疑“定时任务”。我遇到过好几次训练利用率周期性掉到0,最后发现是系统每小时跑一次
updatedb,磁盘IO爆炸,把数据管线全部卡住了。排查这类问题,vmstat里wa列会告诉你答案。 - 看GPU利用率时,同时看一眼
nvidia-smi里的Volatile GPU-Util(在部分新版驱动中已改叫utilization.gpu)和Temperature。如果温度高到降频,利用率再高也是白搭,性能其实在打折。
最后想多说一句:很多人把GPU利用率低归结为“代码写得不行”,但实际排查下来,大部分问题都出在数据管线和系统环境上。如果你手里的模型本来就有大量小算子,算子融合和降精度带来的收益往往比换更大显存的卡更直接。这就像一个熟练的机修工,不会看到转速低就猛踩油门,而是先检查是不是进气管堵了、是不是离合打滑了。对应到GPU训练,你的进气管就是整个数据链路,离合就是通信和内核调度。
以后遇到“GPU利用率只有20%”的情况,别急着发帖求助,先对着这份清单过一遍。你会发现,很多瓶颈根本不需要改模型结构,只需要调整几个参数,就能把利用率拉到80%以上。这个过程本身,就是做AI工程最有意思的部分。