ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

GPU利用率低排查指南:6大瓶颈定位与优化实战

GPU利用率低排查指南:6大瓶颈定位与优化实战

1. GPU利用率低排查的底层逻辑与整体思路

搞AI训练的人,十有八九都经历过这种场景:盯着nvidia-smi里那条GPU利用率曲线,发现它像心电图一样忽高忽低,甚至长时间趴在10%以下,而CPU那边却跑得热火朝天。明明花了大价钱租的卡,结果大部分时间在“摸鱼”,训练一个epoch的时间比预期翻了好几倍。这种问题不是靠“重启试试”能解决的,它背后往往牵扯到数据管道、显存管理、通信开销、算子实现等多个层面的瓶颈。

GPU利用率低,本质上就是GPU在等东西。它可能在等数据从磁盘加载进来,可能在等CPU把下一个batch预处理完,可能在等其他GPU同步梯度,也可能在等一个低效的CUDA kernel执行完毕。排查的核心思路就一句话:找到GPU在等谁,然后解决那个“谁”。听起来简单,但实际操作中,这个“谁”可能藏在PyTorch的DataLoader里,可能藏在NCCL的通信日志里,也可能藏在某个你没注意到的.item()调用里。

这篇文章适合所有在做AI训练时遇到GPU利用率上不去的人——不管你是刚入门的新手,还是已经调过几个月模型的老手。我会把常见的6个瓶颈逐一拆开,给出具体的定位方法和排查清单,每个环节都附上我实际踩过的坑和验证过的操作。你不需要有深厚的CUDA编程经验,但至少要能看懂nvidia-smi的输出,知道PyTorch的DataLoader大概是怎么工作的。

注意:本文讨论的场景是单机多卡或多机多卡的训练环境,推理场景的优化思路有所不同,但部分排查方法仍然通用。

2. 六个典型瓶颈的定位与排查清单

2.1 数据加载瓶颈:GPU在等DataLoader喂饭

这是最常见、也最容易排查的瓶颈。表现是GPU利用率周期性波动,波谷出现在每个step开始的时候,波峰在计算阶段。用nvidia-smi dmon可以看到utilization在0%和90%之间反复横跳。

定位方法很简单:在训练循环里加一个计时器,分别记录数据加载时间和前向反向时间。如果数据加载时间占比超过20%,基本可以确定是数据管道的问题。

import time for batch_idx, (data, target) in enumerate(train_loader): t0 = time.time() data, target = data.cuda(), target.cuda() t1 = time.time() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() t2 = time.time() if batch_idx % 100 == 0: print(f"Data load: {t1-t0:.4f}s, Compute: {t2-t1:.4f}s")

如果Data load时间远大于Compute时间,问题就在数据侧。常见原因有这几个:num_workers设置太小(默认是0,意味着在主进程里加载数据)、磁盘IO太慢(尤其是从网络存储读小文件)、预处理太复杂(比如在__getitem__里做大量CPU计算)、没有使用pin_memory。

排查清单:

检查项推荐值说明
num_workers4-8倍GPU数量太少会阻塞,太多会争抢CPU
pin_memoryTrue加速CPU到GPU的传输
prefetch_factor2-4每个worker预取的batch数
persistent_workersTrue避免每个epoch重建worker
数据格式LMDB/WebDataset小文件多时用打包格式

我自己的经验是,在图像训练任务里,把num_workers从0调到8,GPU利用率能从30%直接拉到85%以上。但要注意,num_workers不是越大越好,超过CPU核心数之后反而会因为进程切换开销导致性能下降。另外,如果你用的是Windows系统,num_workers大于0时要注意if __name__ == '__main__'的保护,否则会报错。

还有一个隐蔽的坑:在__getitem__里用了PIL.Image.open但没有及时关闭文件句柄,导致文件描述符耗尽,DataLoader会莫名其妙地卡住。解决办法是用with语句或者显式调用close()。

2.2 显存瓶颈:batch size被卡死导致GPU吃不饱

显存不够和GPU利用率低看似矛盾,但实际上经常同时出现。因为显存不够,你只能把batch size设得很小,比如从64降到8,结果每个step的计算量太小,GPU还没热身就结束了,利用率自然上不去。

定位方法:用torch.cuda.memory_summary()查看显存分配情况,重点关注allocated和reserved的差值。如果reserved远大于allocated,说明有碎片化问题。

print(torch.cuda.memory_summary(device=None, abbreviated=False))

常见原因包括:模型参数用fp32存储但没用混合精度、中间激活值没有及时释放、优化器状态占用了大量显存(比如Adam会存两份动量)、以及PyTorch的缓存分配器保留了已释放的显存。

排查清单:

  • 开启混合精度训练(torch.cuda.amp),显存占用能降30%-50%
  • 使用梯度累积来模拟大batch,但要注意BatchNorm的统计量问题
  • 检查是否有不必要的张量被引用,比如在循环外累加了loss但没有detach
  • 用torch.cuda.empty_cache()手动释放缓存(但不要频繁调用,会影响性能)
  • 考虑使用ZeRO或FSDP来分片优化器状态

提示:梯度累积虽然能模拟大batch,但它不会减少单次前向的显存占用。如果你的瓶颈是单次前向的激活值太大,梯度累积帮不了你,需要的是梯度检查点(gradient checkpointing)。

我踩过的一个坑是:在验证阶段没有用torch.no_grad(),导致验证集的激活值也被记录下来,显存直接爆掉。加上torch.no_grad()之后,验证阶段显存占用降了60%。

2.3 CPU瓶颈:预处理和Python GIL拖后腿

GPU在等CPU,但等的不是数据加载,而是CPU本身太慢。这种情况在NLP任务里特别常见,因为tokenization往往是在CPU上做的,而且Python的GIL会让多线程预处理变成串行。

定位方法:用htop或top查看CPU利用率。如果训练时CPU利用率接近100%,而GPU利用率很低,那就是CPU瓶颈。更精确的方法是使用py-spy对训练进程做采样分析。

py-spy top --pid <训练进程PID>

常见原因:tokenizer太慢(比如用了Python实现的BPE)、在训练循环里做了大量CPU计算(比如metrics计算)、数据增强用了纯Python实现、以及GIL导致的线程争抢。

排查清单:

  • 把tokenization提前做掉,缓存成二进制文件
  • 用fasttokenizers替代Python tokenizer
  • 把metrics计算移到GPU上,或者用torchmetrics
  • 数据增强用albumentations或kornia,它们底层是C++/CUDA
  • 如果必须用多进程,确保num_workers设置合理,避免进程间通信开销

我遇到过一个典型案例:一个文本分类任务,GPU利用率只有15%,CPU利用率100%。用py-spy一看,70%的时间花在了tokenizer.encode上。把tokenization提前做掉之后,GPU利用率直接拉到80%。

2.4 通信瓶颈:多卡训练时的NCCL等待

多卡训练时,GPU利用率低往往是因为在等NCCL通信。表现是nvidia-smi里GPU利用率不高,但nvidia-smi topo -m显示卡间通信正常。用NCCL_DEBUG=INFO可以看到通信耗时。

定位方法:在训练脚本里加torch.distributed的计时,或者用Nsight Systems做profiling。如果发现all_reduce或broadcast耗时占比很高,那就是通信瓶颈。

NCCL_DEBUG=INFO NCCL_DEBUG_SUBSYS=ALL python train.py

常见原因:梯度同步频率太高(每个step都同步)、模型太大导致通信量大、网络带宽不足(比如用了千兆网而不是InfiniBand)、以及NCCL配置不合理。

排查清单:

检查项推荐做法
梯度同步用梯度累积减少同步频率
通信后端多机用NCCL,单机多卡也用NCCL
网络检查是否走了PCIe而不是NVLink
NCCL参数调整NCCL_ALGO和NCCL_PROTO
重叠用DDP的gradient_as_bucket_view

注意:NCCL_DEBUG=INFO会输出大量日志,建议只在排查时开启,正式训练时关掉。

我自己的经验是,在8卡A100上训练一个BERT-large,如果每个step都做all_reduce,通信时间能占到30%。改成每4个step同步一次之后,通信占比降到8%,GPU利用率从65%提升到88%。

2.5 算子瓶颈:低效CUDA kernel拖慢计算

有些操作在GPU上执行效率很低,比如小张量的逐元素操作、不规则的gather/scatter、以及某些自定义算子。这些操作会让GPU的计算单元利用率很低,虽然nvidia-smi显示GPU利用率高,但实际算力没发挥出来。

定位方法:用Nsight Systems或PyTorch Profiler做kernel级别的分析。重点关注那些执行时间长但占用SM少的kernel。

with torch.profiler.profile( activities=[torch.profiler.ProfilerActivity.CUDA], schedule=torch.profiler.schedule(wait=1, warmup=1, active=3), on_trace_ready=torch.profiler.tensorboard_trace_handler('./log') ) as prof: for step, batch in enumerate(train_loader): train_step(batch) prof.step()

常见原因:用了Python循环逐元素操作、张量形状不规整导致warp divergence、以及没有使用融合算子(比如用torch.add和torch.mul分开做而不是用torch.addcmul)。

排查清单:

  • 把逐元素操作改成向量化操作
  • 用torch.compile自动融合算子
  • 检查是否有不必要的.contiguous()调用
  • 用torch.cuda.nvtx.range_push/pop标记关键区域
  • 考虑用Triton写自定义kernel

我遇到过一个情况:一个模型里用了大量的torch.where做条件选择,每个torch.where都启动一个kernel,导致kernel启动开销比计算本身还大。改成用masked_fill之后,训练速度提升了40%。

2.6 IO瓶颈:磁盘读写和检查点保存

训练过程中要读数据、写日志、保存检查点。如果磁盘IO太慢,GPU就会在等IO。尤其是保存检查点的时候,如果模型很大,写一次可能要几十秒,这期间GPU完全闲置。

定位方法:用iostat或iotop查看磁盘读写速度。如果训练时磁盘利用率接近100%,那就是IO瓶颈。

iostat -x 1

常见原因:从网络存储读数据、检查点保存太频繁、日志写入太频繁、以及数据集没有做缓存。

排查清单:

  • 把数据集拷贝到本地SSD
  • 检查点保存用异步方式,或者只保存模型参数不保存优化器状态
  • 日志用logging的异步handler,或者降低日志级别
  • 用torch.save的_use_new_zipfile_serialization=True加速
  • 考虑用内存文件系统(tmpfs)做临时缓存

我自己的做法是:在训练脚本里加一个后台线程专门负责保存检查点,主线程继续训练。这样保存检查点的时间从原来的30秒降到了几乎为0(对训练的影响)。

3. 实操排查流程与工具链配置

3.1 从nvidia-smi到Nsight Systems的完整工具链

排查GPU利用率低,不能只靠nvidia-smi。它只能告诉你GPU利用率是多少,不能告诉你为什么。完整的工具链应该是:nvidia-smi做初步判断,py-spy看CPU侧,torch.profiler看算子级,Nsight Systems看系统级。

第一步:用nvidia-smi dmon看趋势。

nvidia-smi dmon -s u -d 1

这个命令每秒输出一次GPU利用率,能看出是持续低还是周期性低。持续低通常是CPU或IO瓶颈,周期性低通常是数据加载瓶颈。

第二步:用py-spy看CPU在干什么。

py-spy record -o profile.svg --pid <PID> --duration 60

生成的火焰图能直观看到CPU时间花在哪些函数上。如果大量时间花在DataLoader的__getitem__上,那就是数据加载瓶颈。

第三步:用torch.profiler看GPU算子。

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

这个表格会列出每个算子的CUDA时间,能快速定位到耗时最长的算子。

第四步:用Nsight Systems看系统级时间线。

nsys profile -o report --force-overwrite true python train.py

生成的报告可以用Nsight Systems打开,能看到CPU和GPU的完整时间线,包括kernel执行、内存拷贝、NCCL通信等。

3.2 一个可复用的排查脚本模板

我把上面的工具整合成了一个脚本,你可以直接拿去用。这个脚本会在训练过程中自动记录数据加载时间、计算时间、GPU利用率,并在最后输出一份报告。

import torch import time import subprocess import threading from collections import deque class GPUMonitor: def __init__(self, interval=1.0): self.interval = interval self.utilizations = deque(maxlen=300) self.running = False def start(self): self.running = True self.thread = threading.Thread(target=self._monitor) self.thread.start() def _monitor(self): while self.running: result = subprocess.run( ['nvidia-smi', '--query-gpu=utilization.gpu', '--format=csv,noheader,nounits'], capture_output=True, text=True ) util = int(result.stdout.strip().split('\n')[0]) self.utilizations.append(util) time.sleep(self.interval) def stop(self): self.running = False self.thread.join() def report(self): if not self.utilizations: return "No data" avg = sum(self.utilizations) / len(self.utilizations) low_count = sum(1 for u in self.utilizations if u < 30) return f"Avg GPU util: {avg:.1f}%, Low util ratio: {low_count/len(self.utilizations)*100:.1f}%" # 使用示例 monitor = GPUMonitor() monitor.start() for epoch in range(num_epochs): for batch_idx, (data, target) in enumerate(train_loader): t0 = time.time() data, target = data.cuda(), target.cuda() t1 = time.time() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() optimizer.zero_grad() t2 = time.time() if batch_idx % 50 == 0: print(f"Epoch {epoch}, Batch {batch_idx}, " f"Data: {t1-t0:.4f}s, Compute: {t2-t1:.4f}s, " f"GPU: {monitor.utilizations[-1] if monitor.utilizations else 'N/A'}%") monitor.stop() print(monitor.report())

这个脚本的好处是能同时看到数据加载时间、计算时间和GPU利用率,三者对照就能快速判断瓶颈在哪。

3.3 参数调优的量化计算方法

调参不能靠感觉,要有量化依据。以num_workers为例,最优值可以通过实验确定。我的做法是:固定其他参数,只改num_workers,从0开始每次翻倍,记录每个epoch的时间。

num_workersepoch时间(s)GPU平均利用率
012035%
28555%
46278%
85885%
166083%
326580%

从这张表能看出,num_workers=8是最优的。超过8之后,因为CPU核心数不够,进程切换开销增加,反而变慢了。这个实验花了我大概20分钟,但换来的是训练速度翻倍,非常值得。

对于batch size,可以用“梯度累积+大batch”的方式找到最优组合。比如显存只够batch size=16,但你想用batch size=64,那就累积4步。但要注意,梯度累积会改变BatchNorm的统计量,如果模型里有BN层,需要用SyncBatchNorm或者改用GroupNorm。

4. 常见问题与排查技巧实录

4.1 那些年我踩过的坑:从“GPU利用率0%”到“稳定90%”

第一个坑:num_workers=0。这是PyTorch的默认值,意味着数据加载在主进程里做。我刚开始做深度学习的时候,不知道这个参数,训练了一个星期才发现GPU利用率只有20%。改成8之后,训练时间从3天降到了1天。

第二个坑:在__getitem__里做数据增强。我用的是torchvision.transforms,但有些自定义的增强用了Python循环,导致每个batch要花200ms做增强。后来改成用albumentations,同样的增强逻辑只花20ms。

第三个坑:验证阶段没有用torch.no_grad()。这个坑导致验证阶段显存占用是训练阶段的1.5倍,batch size只能设得很小,验证速度极慢。加上torch.no_grad()之后,验证速度提升了3倍。

第四个坑:检查点保存太频繁。我设置了每100个step保存一次,结果每次保存要花15秒,训练过程中频繁卡顿。改成每1000个step保存一次,并且用后台线程异步保存,卡顿问题解决。

第五个坑:NCCL通信没有重叠。在多卡训练时,梯度同步是阻塞的,GPU在等通信完成。后来用了torch.nn.parallel.DistributedDataParallel的gradient_as_bucket_view=True,通信和计算重叠,GPU利用率从60%提升到85%。

4.2 快速排查清单:5分钟定位瓶颈

如果你不想做复杂的profiling,可以用这个快速排查清单。按顺序检查,通常5分钟内就能定位到瓶颈。

检查项命令/方法判断标准
GPU利用率趋势nvidia-smi dmon -s u持续<30%或周期性波动
CPU利用率htop>90%说明CPU瓶颈
磁盘IOiostat -x 1%util>80%说明IO瓶颈
数据加载时间在训练循环里计时>20%总时间说明数据瓶颈
显存占用torch.cuda.memory_summary()reserved>>allocated说明碎片化
NCCL通信NCCL_DEBUG=INFO通信时间>20%说明通信瓶颈
算子耗时torch.profiler某个算子耗时异常高

这个清单我用了两年多,基本上覆盖了90%以上的场景。剩下的10%通常是硬件问题或者驱动问题,比如GPU降频、PCIe带宽不足等。

4.3 独家避坑技巧:那些文档里不会写的东西

技巧一:用torch.backends.cudnn.benchmark = True。这个设置会让cuDNN自动寻找最优的卷积算法,对于输入尺寸固定的任务,能提升10%-20%的速度。但如果输入尺寸变化很大,反而会变慢,因为每次都要重新搜索。

技巧二:用torch.cuda.set_per_process_memory_fraction限制显存使用。在多卡训练时,如果每张卡都试图占满显存,会导致OOM。设置一个上限,比如0.9,能避免这个问题。

技巧三:用torch.cuda.amp.GradScaler做混合精度训练。但要注意,有些操作在fp16下会溢出,比如exp和log。如果发现loss变成NaN,检查一下是否有这些操作。

技巧四:用torch.nn.utils.clip_grad_norm_做梯度裁剪。混合精度训练时,梯度可能会很大,裁剪能防止梯度爆炸。但裁剪的阈值需要调,太大没效果,太小会减慢收敛。

技巧五:用torch.profiler的record_shapes=True查看张量形状。有时候GPU利用率低是因为张量形状不规整,导致warp divergence。看到形状之后,可以尝试padding到规整的形状。

技巧六:用CUDA_LAUNCH_BLOCKING=1调试CUDA错误。这个环境变量会让CUDA调用同步执行,虽然会减慢速度,但能精确定位到出错的kernel。

技巧七:用torch.autograd.set_detect_anomaly(True)检测梯度异常。如果发现梯度里有NaN,这个设置会告诉你具体是哪个操作产生的。

4.4 不同场景下的排查优先级

不同的训练任务,瓶颈的优先级不一样。根据我的经验,可以按这个顺序排查:

场景首要排查次要排查第三排查
图像分类数据加载显存算子
目标检测数据加载算子显存
NLP微调显存通信数据加载
大模型训练通信显存算子
强化学习CPU数据加载通信

这个表不是绝对的,但能帮你快速缩小排查范围。比如你做的是NLP微调,那先看显存够不够,再看多卡通信有没有问题,最后才看数据加载。

5. 从排查到优化:一个完整的实战案例

5.1 案例背景:8卡A100上的BERT训练

我接手过一个BERT-large的训练任务,8卡A100,预期训练时间3天,但实际跑了5天还没完。nvidia-smi显示GPU利用率只有40%左右,而且波动很大。

第一步:用nvidia-smi dmon看趋势。发现利用率在20%到70%之间周期性波动,周期大概是0.5秒。这个模式很典型,是数据加载瓶颈。

第二步:在训练循环里加计时。Data load时间平均0.3秒,Compute时间平均0.2秒。数据加载时间比计算时间还长,确认是数据瓶颈。

第三步:用py-spy看CPU在干什么。发现大量时间花在tokenizer.encode上。原来这个任务用的是Python实现的BPE tokenizer,速度很慢。

第四步:优化。把tokenization提前做掉,缓存成二进制文件。训练时直接读缓存,不再做tokenization。同时把num_workers从4调到8。

第五步:验证。优化后Data load时间降到0.05秒,Compute时间还是0.2秒。GPU利用率从40%提升到85%,训练时间从5天降到2.5天。

5.2 优化前后的数据对比

指标优化前优化后提升
GPU平均利用率40%85%+112%
Data load时间0.3s0.05s-83%
单epoch时间45min22min-51%
总训练时间5天2.5天-50%
CPU利用率95%60%-37%

这个案例说明,GPU利用率低往往不是GPU本身的问题,而是数据管道的问题。把数据管道优化好,GPU利用率自然就上去了。

5.3 可复用的优化 checklist

基于这个案例,我整理了一个可复用的优化清单。你可以按这个清单逐项检查,通常能解决大部分GPU利用率低的问题。

  • [ ]num_workers是否设置合理(建议4-8倍GPU数量)
  • [ ]pin_memory是否开启
  • [ ]prefetch_factor是否设置(建议2-4)
  • [ ]persistent_workers是否开启
  • [ ] 数据预处理是否提前做掉
  • [ ] 是否使用了混合精度训练
  • [ ] 是否有不必要的.item()或.cpu()调用
  • [ ] 验证阶段是否用了torch.no_grad()
  • [ ] 多卡训练是否用了DDP而不是DP
  • [ ] 梯度同步频率是否合理
  • [ ] 检查点保存是否异步
  • [ ] 是否用了torch.compile或Triton优化算子

这个清单我每次开新项目都会过一遍,能避免大部分性能问题。

5.4 进阶:用Nsight Systems做深度分析

如果你已经排查了上面所有项,GPU利用率还是上不去,那就需要用Nsight Systems做深度分析了。这个工具能看到CPU和GPU的完整时间线,包括每个kernel的执行时间、内存拷贝、NCCL通信等。

nsys profile -o report --force-overwrite true --trace=cuda,nvtx,osrt python train.py

生成的report.nsys-rep文件用Nsight Systems打开,能看到类似这样的时间线:

  • CPU侧:DataLoader的worker进程在做什么
  • GPU侧:每个kernel的执行时间和占用率
  • 通信:NCCL的all_reduce耗时
  • 内存:H2D和D2H的拷贝时间

我通常重点关注三个地方:一是GPU是否有空闲时间段(说明在等什么),二是是否有长耗时的kernel(说明算子效率低),三是NCCL通信是否和计算重叠(说明通信优化空间)。

这个工具的学习曲线有点陡,但一旦掌握,排查性能问题就是降维打击。我建议每个做AI训练的人都花点时间学一下。

6. 写在最后:一些个人体会

排查GPU利用率低这件事,说到底就是找到GPU在等谁。大部分情况下,等的是数据、是CPU、是通信,而不是GPU本身算得慢。我做了这么多年训练优化,真正遇到GPU算力不够的情况少之又少,90%的问题都出在数据管道和通信上。

另外一个体会是:不要过早优化。先把模型跑通,再考虑优化。我见过太多人一开始就纠结于num_workers设多少、要不要用混合精度,结果模型本身还有bug。正确的顺序是:先保证正确性,再保证性能。

最后分享一个我常用的技巧:在训练脚本里加一个--profile参数,开启后自动记录性能数据并生成报告。这样每次训练都能顺便收集性能数据,不用专门去排查。积累一段时间之后,你就能看出哪些优化是有效的,哪些是无效的。

这个内容后续还可以这样扩展:针对特定框架(比如DeepSpeed、Megatron)的优化技巧,或者针对特定硬件(比如昇腾、ROCm)的排查方法。如果你有这方面的需求,可以顺着这个思路继续深挖。

返回列表