☰
GPU利用率低?六大瓶颈排查与优化实战
2026/11/2 9:00:20 网站建设 项目流程

1. GPU利用率的真实含义与观测姿势

先说一个最容易被忽略的事实:nvidia-smi里那个GPU-Util,并不是大家理解的“GPU做了多少算力”的百分比。它的官方定义是“在采样周期内,SM(流式多处理器)上有至少一个warp处于活跃状态的时间比例”。换句话说,它只告诉你GPU在这段时间里“有没有活干”,但完全没有告诉你“干的活有多重”。

这里有个很经典的例子:如果模型里全是逐个tensor做乘法、加法这类轻量级操作,SM确实不停在跑,Util可能显示90%以上,但实际算力吞吐低得可怜。反过来,如果数据管线卡住了,GPU大部分时间真的在等数据,Util就会忽高忽低甚至长期趴在一个低值。这两种情况,单看Util数值都会让人误判方向。所以,排查GPU利用率低的第一步,不是改代码,而是把“这个数字到底是什么”搞清楚。

我的习惯是,接到“利用率低”的问题,先不做任何参数调整,先用三个工具同时采样几分钟:

nvidia-smi dmon -c 180 -s dmpu -d 1 htop iostat -x 1

dmon -s dmpu会输出SM使用率、显存使用率、显存带宽、功耗和PCIe读写带宽,这是比smi整屏信息更稳定的采样方式。同时开htop看CPU侧线程是否满载,iostat看磁盘IO是否异常。这套组合能快速判断一个问题:瓶颈到底在GPU这边,还是在GPU前面。

另一个容易被误读的指标是功耗。假设你用的是350W功耗上限的卡,跑训练时功耗只有100W出头,那基本可以确定GPU没有在真正发力,问题大概率出在数据供给或kernel调度。反过来,如果Util不高但功耗已经拉到300W以上,那说明确实有大量计算在跑,只是细碎到无法撑满SM——这种情况常见于小算子风暴。

再加上nvidia-smi -q -d PERF里的P状态。正常情况下满载训练应该是P0,要是看到P2甚至P8,说明驱动层面觉得没有高负载需求,这本身就是一条重要线索。笔记本平台尤其要留意:电源策略、散热、混合显卡模式都可能把GPU锁在低功耗档位,这属于后面要讲到的硬件层瓶颈。

提示:nvidia-smi的Util是最容易看到的指标,也是最容易误导人的指标。正式排查前,先记录五到十分钟的dmon采样和功耗数据,后续定位会省很多事。

2. 六个瓶颈逐个拆解:从数据供给到小算子开销

2.1 数据供给瓶颈:CPU管道没有喂饱GPU

这个瓶颈在中小规模训练里出现的频率非常高。现象非常典型:GPU利用率不高,但CPU侧有几个核心长时间跑满,step时间长短不一,整体吞吐远低于预期。

原理并不复杂。训练循环是“拉动式”的——GPU每次forward/backward都要等当前batch准备好,而batch的准备恰恰在CPU上完成:读文件、解码图片/文本、数据增强、collate成tensor、再拷到显存。任何一个环节慢,都会让GPU处在等待状态。DataLoader虽然默认是多进程加载,但有几个地方特别容易漏:

  • 数据变换逻辑写得太重,比如每张图都做大量随机裁剪、颜色抖动,这些计算都在CPU worker里完成;
  • 数据集存储在远端文件系统或机械盘上,IO延迟高,iostat能看到明显的util堆积;
  • 主进程的collate逻辑里有自定义Python代码,虽然数据加载并行化了,但汇总这一步仍然卡在主进程;
  • Windows下多进程数据加载有特殊的限制,经常出现num_workers设了等于没设的情况,因为if __name__ == "__main__"保护缺失或者spawn模式导致大量重复初始化。

排查方法很简单:先看CPU使用率,再看torch.profiler的CPU侧时间线。我用得最多的是临时把DataLoader换成固定数据、直接喂同一个batch循环跑几步,如果GPU利用率立刻上去了,基本可以锁定是数据侧的问题。

修复方向的优先级我通常这样排:

  • 调整num_workers到物理核数的一半左右,开启persistent_workers=True和prefetch_factor=2或4;
  • 设置pin_memory=True,减少host到device拷贝的阻塞;
  • 重计算型transform挪到GPU上执行,比如用torchvision.transforms.v2里支持GPU的版本,或者把部分预处理放进模型forward里;
  • 如果是大文件流式读取,优先考虑WebDataset这类流式工具,避免每个epoch都做大量随机IO。

2.2 显存与批大小策略:GPU不是饿死,而是吃不饱

这一类问题有个迷惑性:nvidia-smi里Util可能并不低,但统计到的有效吞吐却明显低于显卡的标称算力。这时候往往不是GPU在等数据,而是计算粒度本身就不够大。

核心变量是batch size。训练时的并行效率高度依赖一次喂进去的数据量。batch太小,单个kernel的算力需求不足以把SM占满,kernel启动延迟和内存访问开销在单步耗时里的占比会被无限放大。如果模型本身又小,这种情况会更加明显——不是GPU不行,而是训练负载本身就喂不饱它。

另一个容易忽略的点是显存碎片和动态shape。PyTorch的缓存分配器会在检测到新的shape时重新分配显存块,动态shape频繁出现时,分配器会不断做碎片整理和重新分配,这一来一回消耗的时间非常可观。表面上看问题像是显存不够,实际是分配策略在拖累吞吐。

排查动作有两个。第一,跑一个batch size梯度扫描:把batch从32翻到64、128,记录每秒样本数和Util变化。如果batch翻倍后吞吐明显提升,说明瓶颈在并行粒度。第二,用torch.cuda.memory_summary()查看显存分配状况,如果segment数量特别多、剩余空闲块零散,说明碎片问题已经比较严重。

常见的修复手段包括:

  • 固定模型输入shape,对变长数据做统一padding,有musk就让kernel忽略padding部分;
  • 打开混合精度训练,模型显存和带宽压力直接腰斩;
  • 必要时使用梯度检查点(gradient checkpointing)节省显存,把省下来的空间让给更大的batch;
  • 如果确定shape在训练过程中不变,直接上torch.cuda.graphs把forward和backward录制成CUDA Graph,减少重复的内存分配和kernel提交。

注意:batch size不是越大越好。一次性把显存用到95%以上,反而容易因显存碎片或OOM触发缓存清理,导致后续步骤变慢。留出20%到30%的余量通常是更稳的选择。

2.3 小算子启动开销:GPU利用率跳水的隐形元凶

这个瓶颈在基于Python的深度学习框架里非常普遍,尤其是模型结构复杂、算子粒度细的时候。现象是:Util的数值像心电图一样跳来跳去,一会儿80%一会儿10%,采样频率稍高一点就能看到大量归零时刻。功耗不低,但整体吞吐不匹配。

原因在于GPU计算是异步流水线机制:CPU端负责把kernel一个接一个提交到GPU,GPU负责排队执行。如果模型里全是很小很轻的operator——比如逐点加法、逐元素乘法、token级的归一化——每个kernel的运行时间只有几微秒,但CPU提交kernel本身有固定开销,GPU在切换和等待指令流时就会出现大量空闲空隙。

我在Nsight Systems时间线里见过最典型的情况:绿色CPU活动非常密集,但GPU时间线上全是细条状的kernel,每个kernel之间的间隔比kernel自身长度还大。这种情况下,Util低下不是数据问题,而是“启动开销”在吃性能。

定位方式很直接。用torch.profiler跑一次完整迭代:

from torch.profiler import profile, ProfilerActivity with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: train_step() print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=30))

然后重点看两个数:kernel总数和单个kernel的平均执行时间。如果总数上万,平均执行时间不到几十微秒,基本可以判定为小算子风暴。修复方向从轻到重:

  • 关掉训练循环里显式的torch.cuda.synchronize()、.item()、print等同步操作,这些调用会强制CPU等待GPU,破坏流水线;
  • 用torch.compile开启默认的算子融合,让编译器把相邻的小算子合并成更大的kernel;
  • 如果模型shape固定,用CUDA Graph把所有操作一次性录制;
  • 生产环境还有更彻底的方案,比如用TensorRT或自己写融合算子,但训练场景一般到compile和CUDA Graph这层就够了。

2.4 多卡分布式同步气泡:为什么利用率永远到不了90%

多卡训练的“利用率低”和单卡完全是两个故事。最典型的表现是:单卡跑得好好的,一上torch.nn.DistributedDataParallel,每张卡的Util就开始呈周期性下跌——每跑一小段时间就集体掉到50%以下,然后恢复,再掉。这个周期性低值,正是梯度同步在“放气”。

数据并行的机制是:每张卡独立算完自己的batch,然后在每个step末尾做一次全体参数的梯度all-reduce。all-reduce是同步操作,所有卡必须等最慢的那张算完才能开始通信,通信期间所有卡的GPU都在等待。卡越多,通信量越大,气泡越大,这也是“千卡利用率难上70%”的核心原因之一。

排查时先看是不是负载不均。用nvidia-smi dmon -c 100 -s m同时采集多卡SM利用率,如果各卡之间差距超过10%,说明某张卡的数据量或计算量与其他卡不一致。常见原因包括:数据采样器没有做均匀切分、batch size设置不一致、或者其中一张卡上还跑着别的任务。

再看通信层。把NCCL_DEBUG=INFO打开,查看allreduce实际耗时和通信路径。单机多卡要看是否走NVLink和PCIe P2P,跨机要看网卡规格和拓扑。云上租用GPU时尤其要注意其他租户的抢占和共享带宽,有时候问题不在你的训练代码,而在网络邻居。

解决方向按优先级排列:

  • 确保每张卡的batch数据和计算负载完全对称;
  • 使用支持通信与计算重叠的框架或设置,让梯度聚合和下一轮的forward同时进行;
  • 多机场景下合理设置NCCL_SOCKET_IFNAME和NCCL_P2P_DISABLE,避免走了低带宽通道;
  • 如果通信占比实在太高,考虑梯度累积减少通信频率,或者改用ZeRO/张量并行等策略降低单次通信量。

2.5 环境与硬件状态:驱动报错43与降频造成的假瓶颈

这类问题最让人头疼,因为它的现象和软件瓶颈长得很像,但根因在驱动或硬件。典型情况包括:训练过程中某一步突然Util归零,日志里出现GPU crash dump triggered;Windows设备管理器里显卡报错误代码43;或者长期高温导致PERF状态下降,性能跑不到标称值的一半。

Windows上错误43很常见,本质是驱动与设备之间的通信出了问题。可能是最新驱动和当前硬件不兼容,也可能是系统更新把配置搞乱了,甚至笔记本双显卡(集显+独显)模式下独显被错误禁用。遇到这种情况,先看设备管理器状态,再回滚驱动或者用DDU彻底卸载重装,基本能解决大半。

Linux下更多是kernel日志在说话:dmesg -T | grep -i nvrm能看到NVRM模块的报错信息。如果是超频、供电不稳或者显存故障,日志里会有针对性提示。这里要提醒一句:有些问题在nvidia-smi里完全看不出异常,但训练就是慢,这时候一定结合温度和功耗判断。笔记本用户尤其常见:插电跑和拔电跑完全是两个效果,电源管理策略把GPU Lock在了低功耗挡。

排查命令如下:

nvidia-smi -q -d PERFORMANCE nvidia-smi -q -d POWER nvidia-smi -q -d TEMPERATURE dmesg -T | grep -i nvrm

看到Perf不是P0、功耗上限被压得很低、或者温度长期贴着温度墙,先解决环境和散热问题,再考虑优化训练代码。这个顺序很重要,因为软件层面的优化做得再好,硬件不配合也白搭。

2.6 模型形态与执行调度:动态shape和验证穿插的拖累

最后一个瓶颈来自训练脚本本身的结构设计,很多人会把它误判成数据问题。现象是:GPU利用率按固定节奏周期性波动,比如每训练50步就掉一次,掉完恢复,循环往复。这种规律本身就说明问题不在随机IO,而在某个固定动作。

常见的调度型瓶颈有几种:

  • 训练中频繁做模型验证(eval),每N步切到eval模式,跑完整的验证集,这段时间GPU利用率自然低;
  • 周期性保存checkpoint,保存过程要同步设备状态、写大文件,主线程被阻塞;
  • 日志和指标打印过于频繁,且打印前执行了.item()隐式同步;
  • 模型输入是动态shape,每次batch的序列长度不一,导致kernel并行度忽高忽低,CUDA缓存分配器必须频繁调整。

排查时先用一个笨办法:记录每步耗时,看低利用率那个点的具体位置。如果是固定步数间隔,翻代码找对应位置,十有八九就是验证或保存逻辑。如果是torch.profiler里看到CPU和GPU时间线有大段空白,则在空白处附近找同步点。

修复方案很直接:验证频率降低,或者放到单独进程中异步执行;checkpoint保存改成异步落盘;日志输出挪到独立线程;输入统一padding固定shape。这些都是工程习惯问题,但影响往往比想象中大得多。

3. 一次真实卡在某个瓶颈的完整排查复盘

3.1 现象初判:从“util低”到“先看功耗”的转变

有一次我帮人调一个多模态小模型微调任务。训练脚本跑得很顺,loss在稳定下降,但用户说“显卡利用率只有30%到40%,是不是模型结构写错了”。我登录机器后做的第一件事不是看代码,而是跑了3分钟的dmon采集。

采样结果很明确:SM利用率均值在35%左右,显存占用只有20%,功耗只有90W出头,而这张卡的功耗上限是300W。这四个指标结合起来,基本可以排除“模型写错导致GPU算不满”的方向——因为在那种情况下功耗通常会高一些。功耗低、SM低、显存占用低,第一嫌疑就是数据没喂上来。

我又开了一个htop窗口,发现确实有四个CPU核在满负载跑。这个迹象进一步指向数据预处理管线。

3.2 三线定位:数据、算子、调度逐个排除

我按数据侧优先的原则处理。先把num_workers从4提到8,开启persistent_workers=True和pin_memory=True,重新跑了一轮。Util从35%提升到了接近60%。有所改善,但离“跑满”还差得远,这说明数据管线只是问题的一部分,不是全部。

接着我用torch.profiler开了整轮迭代的trace,重点看GPU侧的kernel分布。结果出来之后,问题非常清晰:这轮迭代里总共提交了大约两万三千个kernel,其中大量kernel的执行时间只有个位数微秒。Nsight Systems的时间线里,GPU的kernel条之间全是空隙,CPU一直在忙着重放轻量级操作。这正是小算子启动开销的典型画像。

然后我又检查了模型的输入处理逻辑,发现每个batch的序列长度都不固定,导致模型里的attention计算shape也在变。这进一步加重了批处理的不稳定性。到这里,瓶颈就被拆成了三个部分:数据管线占一部分、小算子启动开销占一部分、动态shape带来的额外调度压力占一部分。

3.3 一次有效的修复序列:参数调整、compile、图模式

我按成本从低到高的顺序依次处理。第一步,固定输入shape,把所有样本padding到统一长度,同时优化DataLoader参数。这一步让Util稳定到了65%左右,step耗时也变得规律了。

第二步,开启torch.compile,把模型中重复的小算子做融合。这一步效果显著,Util直接跳到85%左右。第三步,既然shape已经固定了,我把整个训练step录制成了CUDA Graph,进一步消除kernel启动的开销。最终Util稳定在了90%以上,训练吞吐比调整前提升了2.3倍。

这次复盘给我最大的体会是:排查不要过早下结论。如果我一开始只盯着数据管线,可能提升到60%就停手了。只有把数据、算子、调度三个维度都看一遍,才能找到真正的组合瓶颈。

4. 可以直接抄的排查清单与监控建议

4.1 六类瓶颈速查表

排查时不一定非要按顺序跑,但把这张表贴在旁边会很省事:

瓶颈类型关键现象核心观测手段常见修复方向
数据供给Util低但CPU忙,step时间起伏dmon + htop + iostat + profiler CPU侧workers/pin_memory/GPU transform/流式数据
显存与批大小Util不低但吞吐低,偶尔OOMmemory_summary + batch扫描固定shape/混合精度/gradient ckpt/CUDA Graph
小算子启动Util跳动,kernel多而短torch.profiler / Nsight Systemstorch.compile/算子融合/减少同步/CUDA Graph
分布式同步多卡Util周期性跌,各卡不均dmon多卡 + NCCL_DEBUG负载均衡/通信计算重叠/NCCL参数
环境与硬件突然归零、错误43、crash dump、降频dmesg / 事件查看 / POWER查询重装驱动/电源策略/散热与供电
形态调度Util按固定节奏周期跌step耗时记录 + 全迭代trace降验证频率/异步保存/固定shape

这张表解决的是“往哪个方向查”的问题。具体到每一步操作,还是建议回到前面对应小节里的命令和思路。

4.2 启动训练前就值得记录的基线脚本

我每次到一个新环境跑训练,第一件事不是直接启动完整任务,而是先跑几个分钟级的基线和硬件体检。这套东西不复杂,但如果每次都跳过,遇到问题后再补就会浪费大量时间:

# 1. GPU状态采样,看SM/功耗/温度 nvidia-smi dmon -c 180 -s dmpu # 2. 确认驱动与CUDA计算能力匹配 nvidia-smi nvcc -V # 3. 查内核日志中的GPU异常 dmesg -T | grep -i nvrm # 4. 训练一轮后用profiler记录时间线 nsys profile --trace=cuda,cudnn,cublas -o trace_%p python train.py

这套命令跑完,你至少能回答三个问题:硬件状态是否健康、驱动是否匹配、训练过程的时间花在哪。

4.3 一点个人经验:先记基线,再动参数

我见过太多人接到“利用率低”的问题,第一反应就是调num_workers或者换batch size。这些操作不是没有用,而是没有基线对比时,你根本不知道改完是变好了还是变坏了。我自己的习惯是,先固定跑10分钟,记录Util均值、功耗均值、step耗时分布,然后一次只改一个变量,再跑10分钟对照。

这个办法虽然看起来笨,但能避免一个典型的陷阱:同时改了两三个地方,性能确实上去了,但你永远不知道哪一步起作用了,下次碰到同类问题还是两眼一抹黑。

最后再分享一个小技巧:如果你的卡支持nvidia-smi dmon里的PCIe读写字段,观察这个值也很有价值。如果训练时显存带宽和PCIe读写都不高,但Util不稳定,大概率还是kernel启动的问题;如果PCIe长时间高占用,则要检查是否频繁做host和device之间的数据拷贝。把这个维度加进你的日常监控里,很多问题会更快暴露出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询