☰
AI训练GPU利用率低?六大瓶颈定位与性能优化实战
2026/10/3 5:47:03 网站建设 项目流程

接手过的AI训练性能优化任务里,十有八九第一句话都是“GPU利用率才30%,是不是卡坏了”。可真等我掏出Nsight Systems和PyTorch Profiler跑完一轮,坏的基本都不是显卡——数据管线饿了、CPU忙晕了、多卡通信内耗、kernel调度浪费、显存OOM滚雪球,随便挑一个出来都能让GPU饿肚子。GPU利用率从来不是病根,它只是病状,是计算链路里某个环节堵了之后表现在外的结果。这篇东西就是从我实际排过的一堆案例里整理出来的,把AI训练中GPU利用率低的6个主要瓶颈逐个拆开,讲清楚每个瓶颈长什么样、怎么定位、怎么修。适合正在被性能问题折磨的训练工程师,也适合刚入门想系统性给训练提速的算法朋友。

1. 先别急着改代码:你盯着的“利用率”指标可能本身就错了

很多朋友做性能排查第一步就是打开nvidia-smi盯着GPU-Util看,数值低了心里发慌,数值高了以为万事大吉。这里有个非常容易踩的认知坑:nvidia-smi里的 utilization,统计的是“采样周期内有没有kernel在跑”,只要GPU上一个kernel都没闲着,它就会给你显示接近100%,哪怕这个kernel本身只有个位数SM在工作。换句话说,这个数字回答的是“GPU忙不忙”,回答不了“GPU干得有没有效率”。

1.1 GPU利用率、SM占用率、显存带宽占用是三件事

我见过一个真实的NLP训练场景,nvidia-smi长期显示95%以上,但每秒钟处理的token数量就是上不去。后来用Nsight Compute看内核级指标,发现SM Active Warp Occupancy平均只有不到20%,绝大多数SM都在空转等内存回包。这就是典型的“显存带宽受限”,数据搬运速度跟不上计算消费速度,GPU看似在忙,实际上是在排队等数据。

排查的时候至少要看三组指标:

指标含义工具
GPU-UtilGPU是否有kernel在跑nvidia-smi
SM占用率SM上活跃warp的比例Nsight Compute
DRAM吞吐实际访问显存带宽占理论比值Nsight Compute
Tensor Core使用率计算是否打到了Tensor Core上Nsight Compute

如果只看第一行,后面三个全蒙在鼓里,那你的排查方向从一开始就是歪的。

1.2 真正的排查基线:先抓一条完整的step trace

不管怀疑哪个环节,我建议动手第一件事都是抓一条完整的训练step trace。用Nsight Systems跑大概几十个step,然后看GPU timeline上的彩色条带。此时不需要去分析每一个细节,只看三个大问题:

  • GPU计算条带上有没有明显的空洞(gap)?空洞里面是什么颜色,是内存拷贝(Memcpy)还是通信(Communication)?
  • CPU和GPU之间是不是在交替干活,而不是流水线重叠?
  • 有没有周期性出现的规律,比如每个step尾部固定有一段长空闲?

这一步相当于给系统拍个CT,后续所有瓶颈定位都基于这张图展开。跳过这一步直接改batch size或者换显卡,大概率是瞎折腾。

2. 瓶颈一:数据管线饿了,GPU大部分时间在“等菜”

这是单机训练里最常见、也最容易确认的瓶颈。特征是GPU利用率呈现非常规律的周期性锯齿波:利用率冲到80%、90%,然后突然跌到20%甚至0,过一会儿又冲回来。这个“爬升—跌落”的节奏,基本就是GPU在“干活—等下一批数据”之间反复横跳。

2.1 怎么确认是数据问题而不是计算问题

最土也最有效的方法,是直接测量dataloader的耗时。在训练循环里单独提一桶数据:

import time loader = torch.utils.data.DataLoader(...) start = time.perf_counter() batch = next(iter(loader)) print(f"dataloader single batch cost: {time.perf_counter() - start:.3f}s")

不要只测一次,多测几个batch看方差。如果单次取batch的耗时和GPU计算一个batch的耗时处于同一量级,数据管线基本就是瓶颈了。

用Nsight Systems看会更直观:GPU timeline上如果出现大段“等待数据”产生的空洞,且空洞前紧跟着一个cudaMemcpyAsync或者Memcpy标签,那数据加载、预处理、从CPU拷贝到GPU这条链路一定有一环卡住了。

2.2 数据饥饿的常见原因和对应解法

我把踩过的坑按概率排一下:

  • 磁盘读取慢。尤其HDF5、TFRecord这种大文件随机读场景,一旦训练数据分布在多个小文件里,磁盘寻道时间会直接把取数时间拉爆。解法是提前把数据洗成顺序读的大文件,或者转成内存映射格式(mmap),让OS帮忙做页缓存。
  • worker数量不够。默认的num_workers经常是0,也就是主进程纯串行干活,GPU不饿才怪。经验法则是每个GPU至少配4到8个worker,同时数据不会频繁跨节点复制的情况下让worker数接近CPU物理核数的一半。
  • 预处理太重。图像解码、缩放、随机裁剪如果全在CPU侧做,哪怕是20个worker都顶不住。解法是把能离线做的全部离线做掉,训练时只做必要的在线增强。
  • pin_memory=False导致多一次内存拷贝。开成True之后数据从CPU锁页内存到GPU是走DMA直传的,能省掉一次手动拷贝,对吞吐有实打实的帮助。

一个完整的推荐配置长这样:

DataLoader( dataset, batch_size=bs, num_workers=8, prefetch_factor=4, persistent_workers=True, pin_memory=True, )

persistent_workers=True这个选项值得单独说一下——它保证worker进程在epoch切换时不会重建,省掉每轮重新fork的开销,对那种频繁换epoch的训练非常友好。

2.3 分布式训练里数据问题更容易被放大

多机训练时,如果数据放在共享文件系统(比如网络挂载盘)上,每个rank都在跨网络读数据,延迟和带宽都会被放大。我见过几回把数据拷到各节点的本地NVMe之后,整体吞吐直接涨了30%以上的案例。分布式的原则是“数据跟着机器走”,尽量让每个rank从本地盘消费自己的分片,跨节点只做梯度聚合,不做数据传输。

3. 瓶颈二:CPU成了隐形短板,喂饭的人自己忙晕了

数据管线问题解决了以后,GPU可能已经吃上饭了,但下一层瓶颈往往冒出来:CPU侧处理不过来,GPU计算完一个batch之后还是要等CPU把下一个batch的前处理做完。这种场景和单纯的数据饥饿不一样——数据饥饿是“饭还没做好”,CPU瓶颈是“饭做好了但厨房只有一口锅,出菜速度跟不上”。

3.1 典型特征:CPU所有核全部打满,GPU经常小幅空转

外观上看,nvidia-smi的利用率可能不低,但训练吞吐就是上不去。这时候去开个新终端跑htop,会看到CPU使用率几乎顶到100%。这类问题在NLP领域尤其诡异:你以为瓶颈在GPU算力,实际上一大半CPU时间全花在tokenizer上了。

定位方法是用py-spy对训练进程做一次采样:

py-spy dump --pid <训练进程pid>

采几下就能看到Python侧的热点函数。我印象最深的几次,热点要么是transformers的tokenizer,要么是自定义collate_fn里逐样本做的Python循环,要么是一堆numpy/PIL调用。

3.2 CPU瓶颈怎么治:能离线就离线,能上GPU就上GPU

核心思路只有一个:CPU只做“非做不可”的事,把可计算的东西挪走。

第一,tokenizer是重灾区。如果训练数据允许,尽量在数据预处理阶段就把文本转成token id序列,存成二进制格式,训练时直接读id,不要每次在线去跑tokenizer。省下来的CPU时间经常是数量级的差距。

第二,数据增强尽量搬到GPU上做。CV场景里,NVIDIA DALI是最正统的方案,它能把解码、裁剪、翻转、颜色抖动全部做成GPU算子流水线。不想引入DALI的话,PyTorch 2.x也有一部分增强算子支持在GPU tensor上跑,虽然覆盖面有限,但至少比CPU侧做要快得多。

第三,collate_fn的写法要极度克制。别在里边写复杂的Python循环、频繁做list拼接,尽量直接操作tensor。举个例子,如果你要做定长padding,直接预分配一个[batch, max_len]的tensor,把每个序列copy_进去,比循环里torch.stack一堆变长tensor快得多。

3.3 一个容易忽略的细节:CPU和GPU的流水线重叠

就算CPU处理一个batch只需要很短的时间,如果代码写法是“取数据→等数据→训练→再取数据”这种串行模式,CPU和GPU也永远在互相等待。正确做法是让取数和训练并行起来:dataloader多开几个prefetch,训练循环里预取下一个batch,让CPU处理当前batch的同时GPU在算上一个batch。prefetch_factor就是干这个用的,别把它设成1,那跟没有预取区别不大。

4. 瓶颈三:显存OOM引发的滚雪球效应,比你想的更隐蔽

很多人觉得显存溢出是“崩了才算”的事故,但更常见的是OOM没有直接崩,而是通过一系列操作把训练效率逐步拖垮,GPU利用率随之肉眼可见地往下掉。这类瓶颈有点像慢性病,难发现,但杀伤力不小。

4.1 OOM不崩的几种常见“并发症”

第一种是激进地调小batch size。OOM之后第一反应就是batch_size从32改成8,改完确实不崩了,但GPU上跑的矩阵乘法变成了小矩阵,Tensor Core根本喂不饱,利用率直接从90%掉到50%。这种情况你换什么显卡都没用,问题出在batch太小,每张卡的算力根本没有被填满。

第二种是开了activation checkpointing之后没算账。梯度检查点能省显存,但代价是反向传播时要重新计算一遍前向激活。有些模型重计算的量极大,开完以后整个训练时间多了20%到30%,而GPU利用率看着还行,实际上是在做重复劳动。

第三种是显存碎片。显存没有爆,但分配不到连续的大块内存,PyTorch的缓存分配器反复做碎片整理和废弃内存释放,间接拖慢训练。这种情况看nvidia-smi只能看到“已用显存还有剩余”,但一申请大块就失败。

4.2 怎么定位显存相关的隐性损耗

如果怀疑显存问题,第一步先把torch.cuda.memory_summary()打印出来看一眼:

print(torch.cuda.memory_summary())

这里能看到当前reserved、allocated、free的分布情况,还能看到有没有大量碎片区域。如果reserved远大于allocated,说明缓存池里有大量闲置但零碎的内存,可以考虑在合理时机调低PYTORCH_CUDA_ALLOC_CONF里的max_split_size_mb参数,或者开启expandable_segments(这个在PyTorch 2.x上效果明显)。

export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True

另外,看一眼训练循环里是否频繁执行torch.cuda.empty_cache(),这玩意儿没事别调,因为它会清空整个缓存池,下一次申请就会重新向驱动要内存,反而更慢。

4.3 正确的省显存姿势:别牺牲计算形状

处理OOM的正确思路,是优先降低“内存占用”而不是“计算形状”。按优先级排是这样的:

  • 混合精度/bf16:显存直接砍半,且对大批量训练几乎无损,这是性价比最高的一步。
  • 优化器状态offload:把Adam的动量、方差放一部分到CPU内存,需要的时候再换回来。
  • 激活重计算:只对特定重计算代价低的层开启,不要无脑全开。
  • 梯度累积:用累积来模拟大batch,但要记住累积并不会让单次计算的效率变高,它只是“折中方案”。如果能在不OOM的前提下直接增大batch,优先直接增大batch。

5. 瓶颈四:多卡通信内耗,AllReduce把训练红利吃回去

单卡跑得不错,一上分布式多卡,吞吐不线性增长,甚至卡数翻倍后时间变化不大——这种情况多半是通信瓶颈。数据并行里的AllReduce同步操作,是所有卡算完梯度之后互相交换数据,这个过程的通信量跟模型本身的大小强相关,跟batch大小关系不大。所以在“模型很大、单卡batch较小”的场景里,通信耗时占step总耗时的比例会急剧上升,GPU利用率自然被拉低。

5.1 先算一笔账:通信到底要花多长时间

数据并行下,每次梯度同步要传输的字节数大约等于模型参数量乘2(因为AllReduce是reduce加broadcast两个方向,虽然实际NCCL用ring算法分摊了,但总量依然是两边),再加上N张卡之间的拓扑开销。粗算一下:一个10B参数的模型,单次AllReduce至少要传20GB数据量。假设跨节点是100Gbps的网络,单次通信的理论下限就是1.6秒。如果你的训练step计算时间只有2秒,那么通信就已经吃掉了接近一半时间,利用率不可能高得起来。

5.2 定位通信瓶颈的三板斧

第一步,打开NCCL的调试日志:

export NCCL_DEBUG=INFO export NCCL_DEBUG_SUBSYS=INIT,GRAPH,TUNING

日志里能看到NCCL实际选择了什么网络类型、走的是IB还是TCP、有没有检测到正确的拓扑。很多问题是这里暴露的:比如明明机器上有InfiniBand,但因为没设NCCL_IB_DISABLE=0,实际走了以太网TCP,通信带宽直接差一个量级。

第二步,用Nsight Systems看通信和计算的overlap。理想情况下,AllReduce应该和反向传播的后半段时间重叠,GPU timeline上通信块是夹在计算块里的。如果你看到的是“计算→通信→等待→计算→通信”,说明重叠没做起来,整个过程是串行的。

第三步,统计通信耗时占比。nsys里直接看Communication和Compute两个类别的时间比例。如果Communication占比超过30%,通信就是主要瓶颈。

5.3 提升多卡利用率的实操配置

通信优化往这几个方向调:

  • 确保节点内部走NVLink,节点之间走IB。用nvidia-smi topo -m看一眼GPU亲和性,确认卡间拓扑。
  • 设置合理的网卡绑定:export NCCL_SOCKET_IFNAME=ib0,指定通信走真正的IB网卡,而不是默认的eth0。
  • 大模型梯度分片:用DeepSpeed ZeRO或者PyTorch FSDP替代普通DDP。DDP每次同步全量梯度,FSDP只同步当前进程负责的那一片,通信量可以降到原来的1/N。
  • 增大batch、减少通信次数。梯度累积做满一个“大step”再同步一次,牺牲一点收敛灵活性,换取通信占比显著下降。这是最粗暴也最有效的办法。

通信问题是多卡利用率低的核心元凶之一,而且很容易被误判成“代码效率低”或者“GPU算力不足”。排查时务必先确认通信层,再动上层代码。

6. 瓶颈五:kernel调度开销,小算子堆积的“细碎损耗”

有些场景指标看着很诡异:GPU利用率不算低,Nsight里也没有大片空洞,可训练速度就是达不到理论上限。这种“看起来忙、实际上没干多少活”的状态,多半是kernel启动开销太频繁,GPU被一堆细碎的小算子占满了时间片。

6.1 诊断方法:看kernel数量和不均匀度

用torch.profiler抓一个标准的step,然后看表格里的kernel summary:

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))

重点不看最前面的耗时大户,而是看整个step总共启动了多少个kernel、平均每个kernel的耗时。如果一次step里有几万个kernel,其中大量kernel的GPU执行时间只有几微秒,那说明计算密度太低,调度开销已经占了大头。

Nsight Systems里同样能看到“一小段一小段”紧密排列的超短kernel,中间还有肉眼可见的launch间隔,这种就是纯粹的启动开销型瓶颈。

6.2 为什么会有这么多小kernel

根因在于PyTorch的算子粒度太细。一个简单的transformer block里就有几十个元素级操作,比如x + bias、gelu、dropout、layer_norm,每个都是一个独立kernel。这些kernel每个都只做很少的计算,数据刚从显存读上来用完就扔,再启动下一个再从显存读一遍。带宽没少吃,有效计算没多少。

6.3 解法三件套:融合、编译、CUDA Graph

第一选择是算子融合。把多个逐元素操作合并成一个自定义融合kernel,比如“矩阵乘+偏置+GELU”这个组合可以一口气写完。不想手写kernel的话,用现成的FlashAttention、LayerNorm Fused Kernel、Adam Fused,都能直接把常用热点替掉。

第二选择是上torch.compile。PyTorch 2.x的inductor能做自动算子融合,在某些模型上能白捡20%以上的性能,尤其是在大量小算子堆积的模型结构里。代价是编译时间和动态shape不友好,但训练场景里结构基本固定,性价比很高。

model = torch.compile(model, mode="reduce-overhead")

第三选择是CUDA Graph。如果训练循环结构固定、没有动态分支,可以把整个训练step捕获成一张图,之后每次重放。CUDA Graph能把几千次kernel启动开销压到接近零,在batch较小、kernel数量多的场景收益尤其明显。PyTorch 2.x里torch.compile(mode="reduce-overhead")内部就是在用CUDA Graph,这也是这个mode名字的由来。

6.4 顺手检查一下同步点

训练循环里藏着的隐式同步点也会制造类似假象。比如每步调用loss.item()、打印loss、在Python端反复执行.cpu()、给某个list追加标量、甚至调用torch.cuda.synchronize(),都会强制等待GPU跑完当前所有kernel再做下一步分发,流水线立刻变成串行。这些代码在单机小batch时看不出毛病,批量一上来就拖后腿。养成习惯:训练循环里尽量不触发同步,指标记录能用tensorboard直接喂的话就不要挪到CPU。

7. 瓶颈六:模型结构的算术强度硬伤,换什么卡都救不回来

最后这个瓶颈有点特殊,它跟代码没多大关系,纯粹是模型结构本身的计算特性决定了上限。我接过一个项目,两个模型在同一个GPU上跑,一个利用率90%以上,一个死活只有60%。代码套路一点没变,数据管线也没问题,查到最后发现是模型里头大量使用了低算术强度的算子,GPU的HBM带宽成了天花板。

7.1 如何理解“算术强度”

算术强度(Arithmetic Intensity)定义是“总浮点运算次数/总数据搬运字节数”。GPU有一个“机器平衡点”,当计算强度低于这个平衡点时,无论SM算力多强,都会被内存带宽卡住,利用率没法上来。

举例说明:一个标准attention计算里,Q@K^T和V@P是大矩阵乘,算术强度高,GPU跑得很顺。但softmax、mask、dropout这一类算子纯粹是“读一遍、存一遍”,算术强度很低,完全靠带宽堆。如果模型里这类低强度算子占比大,哪怕代码写得完美,利用率也会被拖住。

7.2 哪些模型结构最容易踩“算术强度”坑

  • 小batch下的Transformer层:batch=8、seq_len=128的时候,很多矩阵的M维很小,Tensor Core吃饱的条件不满足。
  • GQA/MQA里的KV cache操作:除了减少显存,它对带宽的改善其实也是关键动机,因为多头注意力的KV数据搬运量极大。
  • 长序列模型中的prefill阶段:序列很长时,attention矩阵本身很大,但中间算子IO密集,带宽有限,就是跑不快。
  • 过度使用elementwise激活函数和逐位mask的层。

7.3 提升算术强度的实操手段

第一,尽量让矩阵乘法维度“变大且规整”。多条样本做sequence packing,把长度相近的样本拼在一个batch里减少padding浪费;padding长度尽量对齐到8或16的倍数,让矩阵乘法更容易命中Tensor Core。

第二,用更低精度。BF16/FP8并不只是“显存减半”,同样的HBM带宽下位宽减半意味着可以搬运两倍的数据量,算术强度直接翻倍。这也是如今主流大模型训练全部切到BF16的核心原因之一。

第三,替换IO密集型算子的实现方式。FlashAttention之所以重要,不单单是省显存,而是它把attention的中间矩阵留在SM片上,不来回倒腾全局显存,这本质上就是在提高算术强度。对长序列、大模型来说,这可能是提升GPU利用率最有效的一招。

第四,如果模型是自定义的、结构极其稀疏(比如某些MOE和稀疏注意力),检查一下是否因为稀疏导致了实际有效计算量太少,把“利用率”压了下来。这种情况下单纯堆算力没有意义,需要从模型设计层面调整计算密度。

8. 把上面这套办法沉淀成一份可照做的排查清单

逐项排查是一件挺烦的事,为了不让自己每次从头摸索,我整理了一份按时间顺序推进的排查清单。你照着这个顺序走一遍,绝大多数利用率问题都能在半天内定位到根因。

8.1 标准排查流程

第一步,记录基线(10分钟)。明确GPU型号、驱动和CUDA版本、框架版本、模型参数量、batch size、显存占用、单step时间。没有基线后面所有对比都无从谈起。

第二步,抓一次Nsight Systems trace(30分钟)。看timeline上的空洞、颜色分布、周期规律。这一步能把问题锁定在“数据侧”“计算侧”“通信侧”三大区域之一。

第三步,查数据管线和CPU热点(30-60分钟)。测量dataloader单batch耗时,用py-spy看CPU热点。排查tokenizer、collate、worker数、pin_memory这些点。

第四步,查通信(多卡场景必做,30分钟)。打开NCCL_DEBUG看后端、拓扑、走网类型;用nsys统计Communication时间占比;检查DDP/FSDP的选择是否合理。

第五步,查kernel和compute端(60分钟)。用torch.profiler看step内kernel数量、平均耗时、耗时大户排名;检查是否有大量微秒级小kernel;检查是否存在隐式同步点。

第六步,查显存和模型结构(30分钟)。看memory_summary有没有碎片、reserved/allocated比例、是否开了不必要的gradient checkpointing;估算模型算数强度,确认是否带宽受限。

8.2 六个瓶颈快速对照表

瓶颈典型现象核心指标首选定位工具首选修复
数据管线饥饿利用率锯齿波,周期性暴跌dataloader耗时/GPU计算耗时Nsight Systems、代码打点本地NVMe缓存、加大num_workers、prefetch
CPU隐性瓶颈CPU全核打满,吞吐上不去CPU热点函数py-spy离线tokenize、增强上GPU、精简collate
显存OOM滚雪球batch被调小、显存碎片allocated/reserved、OOM日志memory_summarybf16、优化器offload、合理梯度累积
多卡通信内耗卡数增加加速比很差Communication占比NCCL_DEBUG、Nsight SystemsFSDP/ZeRO、大batch、指定IB网卡
kernel调度浪费kernel多且短、利用率高但吞吐低kernel数量/平均耗时torch.profiler算子融合、torch.compile、CUDA Graph
模型算术强度硬伤同卡同配置不同模型利用率差异大算术强度/DRAM吞吐Nsight ComputeFlashAttention、BF16/FP8、sequence packing

按我的经验,六个瓶颈里前四个覆盖了绝大多数真实问题,尤其是数据管线饥饿和多卡通信内耗,加起来能占我接到案例的七成。但这不代表后两个不重要——随着模型越来越大、算子越来越稠密,kernel调度和算术强度的问题会越来越常见。

最后说句实在话:性能排查不是玄学,本质上是把“时间到底花在哪了”这个问题查清楚。只要你能把Nsight和profiler这类工具用熟,并且遇事先分层定位而不是急着改参数,GPU利用率基本都能救回来。有些坑踩过一次之后就会有肌肉记忆,比如我现在只要看到训练时CPU风扇狂转,脑子里第一反应就是tokenizer又成了瓶颈。希望这篇清单能帮你少走点弯路。

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

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

立即咨询