直接说结论:绝大多数AI训练里GPU利用率低,根本不是GPU不行,而是“GPU在等别人”。等CPU喂数据、等显存分配、等通信同步、等框架内部的一个锁……只是nvidia-smi上面显示了一个惨淡的30%,让你误以为瓶颈在显卡上。
我做过不少训练性能优化,也帮人排过很多“利用率上不去”的现场。有些case花了一下午,最后发现是DataLoader的num_workers设成了1,数据根本喂不饱;有些case调了两天架构,最后发现是NVIDIA驱动没装好,GPU跑在低功耗模式。这类问题看起来五花八门,实际上都逃不出几个固定的坑。
这篇文章就把我排查“GPU利用率低”的完整方法论写出来,核心是六个瓶颈的定位思路,以及一张可以直接照着用的排查清单。内容偏实操,涉及的工具有nvidia-smi、nvidia-smi dmon、Nsight Systems、PyTorch Profiler这些,新手老手都能直接上手排一遍自己的训练脚本。
1. 先别急着优化:搞清楚“利用率低”到底是谁的锅
排查GPU利用率低,最大的坑是一上来就怀疑模型或卡。实际上绝大多数利用率低,问题都不在GPU内部,而在GPU周围的环境和流程上。所以第一步不是优化,而是先界定“GPU利用率低”这个现象到底是怎么产生的。
1.1 三个最常见的“假性低利用率”
先说几个我实际遇到过的例子,帮大家建立一下现场感。
第一个例子,某团队在单卡上训练一个视觉模型,GPU利用率长期在20%~40%之间波动。他们怀疑是模型结构太浅,导致计算量上不去,于是把网络加宽加大,结果利用率更低了。后来我查了一下,发现他们用的是torchvision自带的ImageFolder读图,num_workers=4,每个worker还在做实时JPEG解码,CPU早就满了,GPU每一轮训练都要等200多毫秒才能拿到下一批数据。这属于典型的“GPU等数据”,跟模型计算量一点关系都没有。
第二个例子,另一个团队训练多卡,四张A100看nvidia-smi每张卡的利用率都在95%以上,但训练总吞吐跟单卡相比只提升了1.8倍。他们一直纠结“利用率这么高,为什么加速比不对”,后来用Nsight Systems一测,发现每步训练末尾的梯度AllReduce通信占了将近一半时间。分布式训练里,GPU算得再满,如果算完都在等通信,整体效率依然是低的。这属于“GPU算得快,但同步等得久”,利用率高并不代表效率高。
第三个例子更有意思,有个用户报告“GPU利用率在0和100%之间疯狂跳”,间隔基本是固定的几百毫秒。排查发现,他的验证脚本每100步就跑一次完整的验证集评估,验证过程中模型还在做梯度更新,两个阶段交替占用GPU,导致nvidia-smi显示的利用率像心电图一样。这种问题既不是数据慢,也不是通信慢,而是训练节奏安排不合理。
这三个例子说明,一旦看到“利用率低”,不能只盯着GPU看,要把整个训练链路从头到尾过一遍。GPU只是链路上的一环,它的利用率高低取决于上游是否稳定供数、中间是否畅通、下游是否接收及时。
1.2 区分“算得慢”和“等得久”的金标准
我判断GPU利用率问题的第一原则很简单:GPU利用率低,本质上要么是算不快,要么是等太久。
“算不快”指的是GPU的计算能力本身没有被充分调用。常见原因是kernel太小、算子融合差、精度模式没开对,导致GPU上有大量计算单元闲置。这种情况你去看SM占用率(后面会讲怎么看),会发现在任务运行期间,SM的active warp数远低于理论上限。
“等太久”指的是GPU本身没闲着,但都在等外部资源。比如等CPU把数据从磁盘读出来、等显存分配器给一块能用的空间、等别的卡把梯度传过来。这种情况看SM占用率通常不低,但看nvidia-smi的利用率会发现频繁掉到很低的档位,且周期性地起伏。
区分这两种情况,直接用Nsight Systems或者PyTorch Profiler看时间线最清楚。如果GPU kernel之间的空白间隙很大,说明在等;如果kernel自己执行时间就很长,且内部效率不高,说明是算得慢。如果是前者,排查方向在数据管线、分布式通信、系统配置;如果是后者,排查方向在模型算子、精度模式、计算图优化。
说实话,我见过的case里,冒烟的瓶颈大概六成在数据管线,两成在多卡通信,剩下两成才是纯计算和显存问题。所以下面六个瓶颈的排序,也是按出现的概率从高到低排的。
2. 排查前的准备:一套趁手的工具和能看懂的关键指标
很多人在排查利用率问题时,全程只用一个nvidia-smi,对着右上角的GPU-Util百分比发呆。这个习惯要改,因为nvidia-smi的利用率是采样值,而且它反映的是GPU上有kernel在跑的占比,根本看不出瓶颈细节。
2.1 nvidia-smi之外的几个实用工具
我平时排查GPU利用率问题,工具链基本是下面这几套,按使用顺序排列:
nvidia-smi dmon -s puc:这个命令可以1秒1次地采样GPU利用率、计算单元利用率、显存利用率、功耗等数据,比nvidia-smi的默认输出更适合连续观察波动。nvidia-smi topo -m:查看多卡拓扑。如果是多卡训练,先看卡间走的是NVLink还是PCIe,这决定了通信带宽的上限。nvidia-smi -q -d CLOCK:查看GPU当前实际运行频率。如果频率掉得厉害,可能遇到了功耗墙或温度墙。nsys(Nsight Systems):这是英伟达官方的系统级性能分析工具,能输出整个训练进程的时间线,包括CPU活动、GPU kernel、数据传输、同步等待等。判断“算得慢还是等得久”基本靠它。ncu(Nsight Compute):如果确认瓶颈在GPU计算本身,再用它做kernel级分析,看每个kernel的SM占用、访存带宽、指令混合等细节。- PyTorch自带的
torch.profiler:对PyTorch训练任务来说,直接在训练脚本里加几行代码就能拿到前向、反向、优化器更新各自占了多长时间,是最容易上手的定位手段。
这些工具里,我最推荐先装Nsight Systems,因为它的时间线视图能直观回答“GPU到底在忙还是在等”。如果系统里已经装了CUDA toolkit,一般自带nsys命令,不用额外折腾。
2.2 真正值得盯的几个指标口径
nvidia-smi右上角的GPU利用率只是一个表面指标,我在实际排查中更看重下面几个口径:
第一个是SM Active Warp占有率。可以用ncu或者nsys的GPU Metrics视图看到。它衡量的是每个流式多处理器上活跃的warp占理论可容纳warp的比例。如果这个值低于70%,即便表面利用率是90%,也说明kernel内并行度不够,存在明显的计算浪费。
第二个是显存读写吞吐。用ncu看DRAM Throughput。如果吞吐很高但SM占用很低,典型的高显存带宽需求型任务,优化方向是减少数据搬运;如果吞吐很低且kernel执行时间很长,那就要找代码里的串行依赖或低效访存模式。
第三个是GPU实际运行频率。这个很多人漏看。显卡因为功耗墙或温度墙降频后,名义上利用率是100%,实际算力只有最高频率的六七成。训练速度变慢,但利用率显示却是正常的,属于隐性瓶颈。
第四个是每步训练耗时和它的波动幅度。我会先记录正常情况下的step time,然后看它是否出现过大的毛刺。如果step time周期性地变长,大概率是上游数据供给或验证/检查点任务干扰;如果step time稳定但吞吐比预期低,则是计算或通信效率问题。
我建议每次排查前,先把这三样记录下来:nvidia-smi dmon的1分钟连续采样、训练脚本里打印的step time曲线、以及一次完整的Nsight Systems profile。有了这三样,基本不用靠猜。
3. 六个瓶颈逐一拆解:定位思路与实操手法
下面就是核心部分。我把AI训练GPU利用率低的问题拆成六个瓶颈,每个都给出现象特征、定位方法、以及实践中最有效的解决动作。这个清单是按实际出现频率排序的,越靠前的越值得先排查。
3.1 瓶颈一:数据供给了GPU消费的速度
这是最大的坑,没有之一。单个GPU的训练吞吐可以非常高,比如一张A100跑ResNet-50,理论上一秒钟可以消费上千张224x224的图片。如果数据管线每秒钟只能供给三四百张,GPU就只能干等,利用率自然上不去。
如何判断是不是这个瓶颈?看两个信号:一是nvidia-smi dmon里的GPU利用率出现规律的锯齿状波动;二是nsys时间线里,GPU kernel之间有明显的一段段空白间隙,而且这些空白间隙与CPU上的数据加载活动相对应。
还有一个很实用的土办法:把训练脚本里的数据加载部分单独跑一遍,统计每秒能产出多少batch。如果这个数字比训练时的batch消费速度低,或者勉强持平,那瓶颈几乎可以锁定在供数环节。
解决动作通常按顺序做:
- 检查磁盘类型。如果数据集存在机械硬盘上,换到NVMe SSD往往立刻见效。一个数据集几TB,放在机械盘上读,就算CPU再快也是白搭。
- 用更高效的解码方式。视觉任务常见的是JPEG实时解码,CPU解码一张几百KB的图要烧不少时间。可以用
torchvision的decode函数配合turbojpeg,或者干脆在数据预处理阶段把所有图片解码并缓存成内存映射格式,训练时直接读tensor。 - 减少无效IO。如果你在用一个很大的数据集,并且每个epoch都要重新读一遍,可以评估一下缓存到内存的可行性。很多服务器内存上百GB,够缓存不小比例的数据集。
这里要顺便提一句:数据瓶颈不只是磁盘速度问题,也可能是预处理逻辑太重。比如每次迭代都要做随机裁剪、色彩抖动、归一化,而且这些都是用Python在CPU上算的,那再快的SSD也没用。我的习惯是,把耗时且可并发的预处理放进DataLoader的worker里,并在worker进程里用C++/torchscript实现热点逻辑,或者用NVIDIA DALI把解码和增强搬到GPU上做。
3.2 瓶颈二:数据管线内部配置不合理
有时候磁盘和解码都很快,但GPU利用率依然上不去。这种情况往往是DataLoader的配置出了问题。最常见的问题有三个:num_workers设置过小、pin_memory没开、prefetch_factor没调。
先说num_workers。它的作用是开多个子进程并行加载和预处理数据。如果设成0,那数据加载在主进程里同步执行,GPU每算完一步都要停下来等数据,利用率基本不可能高。如果设成1,在数据集大、预处理重的情况下也常常喂不饱GPU。我自己实践下来,一般配成min(32, CPU核数)附近,再通过实验微调。注意不是越大越好,worker太多会导致CPU上下文切换开销反而增大,甚至把CPU打满,拖慢主训练进程。
再说pin_memory。这个参数的原理是把数据放到页锁定的内存中,这样GPU可以直接通过DMA从主机内存拷贝数据,而不需要经过CPU先做一次页表转换。开起来之后,Host-to-Device的数据拷贝速度能提升不少。在PyTorch里只要给DataLoader传pin_memory=True就行,如果用了to(device, non_blocking=True),效果更明显。
prefetch_factor是一个容易被忽视但是很关键的量。默认情况下PyTorch每个worker会预取2个batch。如果CPU和GPU之间的传输距离远、数据量大,2个batch的缓冲可能不够掩盖加载延迟。可以调成4或者8试试,但要权衡显存占用。
判断这个瓶颈的方法依然靠nsys:在时间线里看数据加载相关的Memcpy(HtoD)操作是否频繁出现,且GPU kernel是否总是在Memcpy之后才开始。如果HtoD拷贝本身就是按照batch逐个进行的,而且拷贝耗时占了5%以上,说明管线没有把传输隐藏好。
我还有一个离线的测试方法:在训练脚本里临时注释掉模型前反向,只保留DataLoader的迭代,统计纯数据加载达到的每秒batch数。如果纯数据加载速率本身就比训练所需速率低,那就该回头调数据管线;如果纯数据加载很快,问题大概率在GPU侧的同步等待上。
3.3 瓶颈三:kernel过小与算子执行效率过低
数据供得上、管线也通了,GPU利用率可能还是低,这时候就要考虑GPU“算不快”的问题了。很多人的模型调用了一堆很小的算子,比如逐元素的激活函数、残差相加、归一化里的小张量操作。每个算子单独看都很快,但它们会引入大量kernel启动开销和kernel间切换开销。GPU的kernel启动一次大约要几微秒,如果几千个小kernel串行执行,光启动开销就占掉了不少时间。
判断方法很直接:用torch.profiler看kernel维度的耗时分布。如果发现大量kernel的执行时间只有几微秒,但数量巨大,总和占比突出,那就是典型的“kernel启动风暴”。另一种情况是单个kernel本身执行时间很长,但SM Active Warp占有率低,说明并行度不足,可能存在大量分支发散或者串行依赖。
解决这个瓶颈主要有三个方向:
一是算子融合。把多个连续的小算子合并成一个大的kernel。PyTorch 2.0之后可以用torch.compile,它对常见模型能自动做算子融合和计算图优化。我实测过不少模型,编译后在A100上大概能拿到1.1到1.5倍的提速,利用率曲线也明显更平滑。如果模型里有大量自定义op,也可以用Triton手写融合kernel。
二是批量操作替代逐元素操作。比如把多个小张量的操作合并成一个大张量的批处理,减少kernel启动次数。NLP任务里常见的padding和mask操作,如果能合并成一个复合kernel,收益很明显。
三是减少同步点。PyTorch里像.item()、.cpu()这类操作会触发设备同步,强制GPU等CPU。如果训练循环里每步都打印loss、甚至每步都执行一次.item(),GPU会在同步点上被反复打断。要改成每隔几百步才同步读取一次loss,或者用torch.no_grad包住统计操作。
还有一个容易被忽略的点:精度模式。如果模型默认跑FP32,即使代码写得再好,计算效率也上不去。现代GPU的FP16/BF16吞吐往往是FP32的两倍以上,配合AMP(自动混合精度)训练,能用更低的内存带宽和更快的tensor core把速度拉起来。开启方式很简单,PyTorch里用torch.cuda.amp.autocast()包裹前向和loss计算就行。很多老模型的瓶颈就卡在FP32上,换到混合精度后利用率直接上了一个台阶。
3.4 瓶颈四:显存分配异常与swap抖动
显存问题是另一个高频瓶颈,而且它表现得很隐蔽。有些任务账面显存够用,但训练过程中频繁出现显存分配等待。GPU显存的分配和释放不像CPU malloc那么轻量,频繁申请和释放大块显存,分配器会耗时,而且会产生显存碎片。
典型现象是:nvidia-smi看到的显存占用率并不高,但训练速度很慢,并且logs里偶尔有CUDA error: out of memory或警告级别的显存不足信息。还有一种情况是用了PyTorch的defrag相关机制或者类似torch.cuda.empty_cache(),显存被反复释放和重新申请,导致GPU计算和显存管理互相竞争资源。
更严重的场景是显存不足导致的自发swap到主机内存。虽然CUDA默认超额申请显存时可能会触发OOM报错,但有些框架或配置允许host memory fallback。一旦发生这种隐式swap,每步训练都要通过PCIe把数据从主机内存拷回显存,速度慢得令人绝望。在Nsight Systems的时间线上,你会看到大段大段的H2D(Host to Device)传输,而GPU kernel执行时间反而很短。
定位方法:
- 首先看
nvidia-smi的显存占用曲线,确认是否存在周期性的显存暴涨暴跌。 - 再看
torch.profiler内存分析部分,定位是哪段代码在反复申请大块显存。 - 最后检查代码里是否频繁调用
torch.cuda.empty_cache(),这个操作虽然在OOM时能救急,但在正常训练中会引入大量显存分配开销,能不用就不用。
解决显存瓶颈一般从几个方向下手:
第一,检查batch size是否设置得过大。很多人以为batch size越大性能越好,但实际上batch size接近显存上限时,PyTorch的缓存分配器会频繁寻找连续的大块显存,失败后还可能触发内部碎片整理,反而拖慢训练。如果batch size导致的显存占用率超过90%,会有明显的性能回退。
第二,改用梯度累积。如果因为显存限制不得不把batch size降低,可以用梯度累积模拟更大的batch。注意梯度累积会影响BatchNorm的统计行为,需要相应处理。
第三,检查是否有大张量的非必要驻留。比如在NLP任务里,attention矩阵中间结果如果没做好算子融合,会在显存中暂存巨大的batch x seq x seq张量。改用内存高效的attention实现,比如PyTorch的scaled_dot_product_attention,能大幅降低显存压力。
3.5 瓶颈五:多卡训练中的通信与同步等待
单卡利用率正常,但只要上多卡,吞吐提升不理想,或者每卡利用率本身不高,问题往往出在通信瓶颈上。分布式训练最大的考验,不是让每张卡都算得快,而是让它们算完之后能快速交换梯度。
先看硬件拓扑。用nvidia-smi topo -m看一下卡间连接,如果是NVLink连接,通信带宽高,梯度同步开销小;如果走的是PCIe switch,带宽和延迟都差一些;更糟的是跨节点走以太网,那通信开销会非常惊人。我在实验室里见过有人直接用四张PCIe互连的卡训练大模型,AllReduce占了每步耗时的一半以上。
再看通信实现方式。PyTorch DDP默认使用NCCL的AllReduce,如果通信的数据量很大,或者卡间拓扑带宽很低,耗时就会很严重。定位方法很简单:在训练脚本里用torch.profiler的profile上下文记录一个完整的step,然后看ncclKernel_AllReduce之类的通信kernel占了多少时间。
如果通信占比过高,下面几个手段可以缓解:
一是梯度压缩。把梯度从FP32压成FP16再通信,能减少一半传输量。更激进的方案是做梯度量化或者稀疏化,但这需要保证模型精度,不适合新手直接上。
二是减少通信频率。可以配合梯度累积,让每K个step才同步一次参数。这等于把通信次数降为原来的1/K。但要注意batch size会变大,需要调整学习率。
三是调整并行策略。如果模型很大,单纯数据并行会把每张卡上的计算量降下来,通信量却不降,这时候应该考虑模型并行或流水线并行。有些场景下ZeRO优化器比DDP更合适,因为它把梯度分片,通信量比AllReduce低。
四是检查NCCL的环境变量。比如NCCL_DEBUG=INFO可以打印出通信链路信息,看它实际走的哪条路径。有时候因为驱动或容器网络配置问题,NCCL没探测到NVLink,而降级到了PCIe或TCP通信,这会导致通信速度雪崩。如果发现这种情况,要检查NCCL_P2P_LEVEL和NCCL_SOCKET_IFNAME设置。
这里要特别注意一个假象:多卡训练时,单卡利用率很高不代表整体效率高。如果通信没隐藏好,每张卡都是“算一会儿、等半天”的节奏,整体训练时长反而可能比单卡还慢。所以多卡场景下,不能只盯着利用率看,要看每步时间与卡数的加速比。
3.6 瓶颈六:系统配置、驱动状态与隐性任务干扰
最后一个瓶颈最容易被忽视,因为它的表象往往不是“利用率低”,而是“机器卡”“风扇狂转”或者“训练速度莫名变慢”。主要包括三类问题:
第一类是驱动和系统状态异常。GPU跑在错误的驱动版本下,可能会出现不兼容或降频。比如驱动认为GPU负载不够高,自动降低显存频率和核心频率。遇到这种问题,先看nvidia-smi -q -d CLOCK输出的当前频率,再对比最大频率,如果差得很多,检查是不是有功耗墙或温度墙限制。还有一种情况是显卡处于P8低功耗状态,原因是驱动或容器没有正确初始化GPU计算模式。可以通过nvidia-smi -q -d PERFORMANCE查看当前performance state,正常训练时应该在P0附近。
第二类是CPU主进程被其他任务抢占。如果训练机器上同时跑着多个进程,尤其是还有别的模型推理、数据预处理、甚至有人在上面跑web服务,CPU主线程会被抢占,导致PyTorch主进程无法及时提交计算任务给GPU。这种情况的表现是GPU利用率整体不高,且CPU的si(软中断)和wa(IO等待)非常高。用top -H看一眼活跃线程,能很快发现是否有别的进程在抢占CPU。
第三类是训练脚本里的隐性同步与额外任务。比如在每个epoch结束后做一次全量验证,验证过程没有关闭梯度计算,也没有限制batch大小,导致GPU在验证和训练两个模式之间来回切换,利用率自然上不去。再比如checkpoint保存,如果你的模型很大,每多少个step就保存一次完整的权重文件,保存过程需要把全部参数从显存拷贝到CPU再写磁盘,这个过程会把GPU计算卡住。我的建议是:验证时明确用torch.no_grad(),并且把验证频率降到不影响训练的级别;checkpoint尽量异步保存,或者减少保存频率。
还有一个小众但很真实的问题:CPU频率模式。有些服务器BIOS默认开了节能模式,CPU主频会比较低。PyTorch主进程和DataLoader子进程都在CPU上跑,CPU主频低会直接拖慢数据供给和指令下发。如果发现CPU主频一直在基础频率附近波动,可以检查一下cpupower frequency-info,必要时切到performance模式。这个操作在云服务器上通常不可用,但在自建机器上很管用。
4. 一套可复制的排查流程:从看到低利用率到定位根因
工具准备和瓶颈清单都有了,接下来是实操流程。我给自己的排查工作制定了一个固定的五步流程,照着走一遍大概一到两个小时,能定位绝大多数利用率问题。
4.1 五步定位法
第一步,先量化现状。写下当前训练任务的配置:单卡还是多卡、batch size、模型参数量、数据集大小、GPU型号。然后跑一个稳定的训练窗口,记录三个基线值:平均step time、nvidia-smi dmon下的平均利用率、以及训练过程中的显存占用峰值。
第二步,跑一次profile。用Nsight Systems或PyTorch Profiler采集3~5个step的完整时间线。重点看两个内容:GPU kernel之间是否有大的间隙,以及CPU和GPU的活动是否有重叠。如果间隙大,进入数据管线排查;如果kernel本身效率低,进入算子排查;如果通信占比高,进入多卡通信排查。
第三步,做数据管线隔离测试。把训练循环里的optimizer.zero_grad() + forward + loss + backward + step注释掉,只保留DataLoader迭代。跑10秒,统计纯数据加载的步进速率,和训练所需速率进行对比。如果纯数据加载速率明显低于训练速率,瓶颈就在数据供给侧,回看瓶颈一和瓶颈二的手段。
第四步,做单卡/多卡对比。如果项目是多卡训练,先用单卡跑同一个batch排,看利用率。如果单卡利用率正常而多卡吞吐异常,大概率是通信相关问题,回看瓶颈五。如果单卡本身利用率就低,问题在数据管线和算子侧,回看瓶颈一到四。
第五步,审查训练脚本的额外操作。看看有没有频繁的.item()、empty_cache、验证、保存checkpoint、tensorboard写入同步日志等操作。这些都可能在不知不觉中打断GPU的连续工作。
下面是整个排查流程里最常用的指令速查表,可以保存下来直接用:
| 用途 | 命令 |
|---|---|
| 连续采样查看GPU利用率和显存 | watch -n 1 nvidia-smi |
| 高频率采样计算/显存利用率 | nvidia-smi dmon -s puc |
| 查看GPU拓扑、NVLink连接 | nvidia-smi topo -m |
| 查看当前频率与功耗状态 | nvidia-smi -q -d CLOCK,PERFORMANCE,POWER |
| 系统级时间线分析 | nsys profile -o output python train.py |
| kernel级性能分析 | ncu --set full python train.py |
| PyTorch训练流程内部分析 | 在训练脚本中加入torch.profiler |
4.2 排查清单:一张表走完六类瓶颈
我把上面六类瓶颈浓缩成一张直接可用的排查清单,包含“现象”“验证方法”“常用解法”三列:
| 瓶颈类别 | 典型现象 | 验证方法 | 常用解法 |
|---|---|---|---|
| 数据供给慢 | 利用率锯齿波动、step time不稳 | 纯DataLoader吞吐测试、nsys看kernel间隙 | 换SSD、预解码缓存、DALI、减少预处理开销 |
| 管线配置差 | 利用率不高但CPU忙、HtoD拷贝频繁 | 检查num_workers/pin_memory/prefetch_factor、torch.profiler看拷贝耗时 | 调大num_workers、开pin_memory、调prefetch_factor |
| kernel效率低 | 利用率不高且step time稳定、SM占用低 | ncu看SM占用、torch.profiler看kernel耗时 | torch.compile、算子融合、AMP混合精度、批量操作 |
| 显存分配异常 | 显存波动大、偶发OOM、速度间歇性变慢 | 内存分析、查看empty_cache调用、Nsight看H2D传输 | 减小batch、梯度累积、禁用empty_cache、优化attention显存 |
| 多卡通信慢 | 单卡利用率高但加速比低、step time包含长通信 | torch.profiler看NCCL kernel、NCCL_DEBUG=INFO看链路 | 梯度压缩、梯度累积、检查NVLink/NCCL配置、调整并行策略 |
| 系统/隐性任务干扰 | 利用率整体偏低、CPU主频低、其他进程抢占 | top看CPU、nvidia-smi看频率/功耗、检查训练脚本 | 修复驱动、关节能、验证/checkpoint异步化、去掉同步点 |
这张表可以当做一个“自检地图”,遇到利用率问题的时候,按照从上到下的顺序逐项排查,一般半小时内能圈定一个大方向。
4.3 一个现场排查案例的复盘
说一个最近帮人排过的真实case,给大家串一下整个流程。
某团队在4张A800上训练一个7B规模的大语言模型,最近加了上下文长度之后,训练速度骤降,GPU利用率只剩20%~40%。团队一开始怀疑是长上下文导致attention显存溢出,于是换了各种attention实现,利用率依然上不去。
我接手后先跑了个nvidia-smi dmon,发现每张卡的利用率确实是锯齿状,高的时候能到90%,低的时候掉到个位数,而且step time从之前的1秒跳到了3秒多。这种锯齿状波动基本指向数据供给或者管线,不太像显存溢出。接着用nsys跑了两个step,时间线上可以看到GPU kernel之间有大段空白,而且空白期间CPU在做memcpy和数据加载,典型的“GPU等数据”。
再往下看,发现DataLoader的num_workers=2,而机器有64个CPU核。把num_workers调到16之后,利用率从30%提升到了70%。但这还没完,利用率到70%之后就不再涨了,再跑一次torch.profiler,发现每一步里有一小段额外耗时来自optimizer.step里的显存分配操作——因为上下文变长后,中间激活值变大了,显存分配器需要反复寻找更大的连续空间。于是顺手把PyTorch的PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True设置加上,让显存分配器按可扩展段管理显存,碎片导致的分配耗时降下来了。最后再开AMP,把训练精度从FP32切到BF16混合精度,利用率稳定在90%上下,step time回到了1秒附近。
这个case典型地体现了排查顺序:先是数据侧、再是计算侧、最后是精度模式。如果一开始就去折腾attention实现,方向就完全错了。
5. 常见问题与踩坑实录
排查做了很多次之后,我总结出一些高频出现的问题和容易被忽略的细节。单列出来,可以帮大家少走弯路。
5.1 六个我实际踩过或反复见到的坑
坑一:只信nvidia-smi的右上角百分比。这个数值是采样值,Sampling interval不同,看到的波动完全不同。而且它只显示“有没有kernel在跑”,不显示“kernel跑得有没有效率”。我见过利用率100%但实际吞吐只有理论一半的情况,原因是功耗墙降频,白忙活。所以一定要用nvidia-smi -q -d CLOCK,PERFORMANCE,POWER看频率和功耗。
坑二:DataLoader的num_workers开得过大导致瓶颈反而更糟。不是越大越好,worker越多CPU上下文切换和内存拷贝越多。尤其当数据本身存在慢速磁盘上时,worker再多也没用,反而会加剧IO混乱。正确做法是先解决磁盘瓶颈,再微调worker数。
坑三:错误地把“验证集评估”和“训练”混在一个进程里跑。验证阶段如果不关梯度,GPU会同时执行两个计算图,导致利用率在低档位和高档位之间反复横跳。训练过程中可以跑验证,但一定要用torch.no_grad()包裹,并限制验证batch的大小,同时尽量压低验证频率。
坑四:无视GPU的功耗和温度状态。跟CPU不一样,GPU的降频非常激进,温度超过某个阈值之后,频率会快速下降。如果你发现利用率很高但速度不快,第一件事要看温度和功耗,而不是怀疑代码。GPU满载长时间跑,温度很容易顶到85℃以上,降频是家常便饭。
坑五:多卡训练里不检查NCCL走的是哪条链路。容器化部署时,NCCL可能因为环境变量没设置好,走了TCP而不是NVLink。一条TCP路径的通信延迟比NVLink高一个数量级,AllReduce会瞬间变成瓶颈。每次上多卡训练前,先跑一遍NCCL_DEBUG=INFO,确认通信链路符合预期。
坑六:容器里显存隔离和性能隔离的问题。有些云平台提供的是MIG(多实例GPU)或者vGPU环境,每块虚拟GPU的性能和显存带宽与物理GPU差距很大。如果你的“GPU利用率低”是发生在云平台上,可能不是训练代码的问题,而是底层虚拟化导致的性能隔离。这时候检查一下你拿到的算力规格,跟物理卡做一次基准对比。
5.2 三个真正值得长期坚持的好习惯
第一,把step time作为第一监控指标。GPU利用率只是间接指标,step time是最终结果。如果step time达标,利用率低不低无所谓;如果step time不达标,利用率再高也没用。建议在训练脚本里打点记录step time,并设置告警阈值。
第二,每次调优只动一个变量。GPU利用率问题的变量很多:数据管线、batch size、精度模式、通信方式、系统配置。如果一次改五个参数,出了问题根本不知道是哪个引起的。我的习惯是,每次只改一个变量,然后跑固定步数对比step time,控制变量法在这种问题上一向很有效。
第三,把性能测试固化成脚本。每次换新环境、换新模型、大规模训练之前,先跑一个固定的性能基准脚本,记录理论吞吐、实际step time、GPU利用率曲线。这样既能建立基线,也能在问题出现时快速对比。我从很早开始就这么干,现在新项目上线前都会先花半小时跑一次基准,后面排查问题能省大量时间。
5.3 一些工具使用上的私房技巧
用nsys的时候,我建议加两个参数:--trace=cuda,nvtx,osrt和--cuda-memory-usage=true。前者能看到CUDA kernel、自定义NVTX标记和系统调用的完整时间线,后者能把显存申请和释放画在时间线下方,一眼看出显存分配是否频繁。--cuda-memory-usage默认是关闭的,新手很容易漏掉。
用PyTorch Profiler的时候,推荐把schedule参数配合wait、warmup、active一起用。比如schedule=torch.profiler.schedule(wait=10, warmup=5, active=3, repeat=1),意思是跳过前10步、预热5步、然后连续记录3步。这样能避免前几个step因为CUDA context初始化导致的数据失真。
另外还有个不太起眼但很实用的小技巧:在训练脚本开头固定随机种子,并且把torch.backends.cudnn.benchmark设为True。benchmark=True会让cuDNN在前几个step自动测试多种卷积算法并选择最快的一种,对视觉模型尤其有效。很多人不知道这个小设置,实测下来对某些模型能带来10%到20%的提速。
6. 分享一点个人经验
今天把六类GPU利用率的瓶颈和排查思路全部梳理了一遍,实际上我每个月都会遇到至少一两次类似的问题。这个领域的经验积累,主要来自一次次被表象迷惑、然后顺着时间线把真相挖出来的经历。
就我个人而言,最想强调的还是那句话:GPU利用率低不是GPU的问题,是整个训练链路的问题。数据、管线、算子、显存、通信、系统配置,任何一个环节拖后腿,都会在GPU利用率上露出马脚。所以拿到“利用率低”这个问题,不要急着改模型、换显卡,先把自己手上的工具链配置齐全,按清单一步步排查。
我也建议每个做AI训练相关工作的朋友,都习惯性地在训练脚本里加一个step time的log,或者在集群监控平台里挂上GPU利用率和温度的自定义指标。这些数据平时看起来没什么用,一旦训练变得异常,它们就是最有价值的诊断线索。
如果今天的排查清单对你有一点点帮助,那这篇文章就没白写。至于更细的kernel优化、通信压缩、并行策略调整,那些是后面再深入的话题,有机会再跟大家细聊。