ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Nsight Systems实战:GPU利用率从50%拉到90%

Nsight Systems实战:GPU利用率从50%拉到90%

先给你描述一个场景:你在跑一个开源大模型的微调,nvidia-smi -l 1一刷,GPU利用率只在40%到50%之间来回打转。你第一反应是显卡不行,或者代码写得稀烂,可等真想去定位,却发现连个切入点都没有。这个现象我太熟了——GPU利用率低,往往不是GPU自己偷懒,而是整个训练链路里某个看不见的环节在拖后腿。想把它揪出来,靠一张会跳数字的仪表盘远远不够,必须用专业的AI Profiling工具,而且要零侵入的那种:不改一行训练代码、不重新编译模型,就能把整个训练过程从头到尾解剖一遍。

这篇文章会围绕一次真实排障展开,讲讲我怎么利用这类工具把GPU利用率从不到50%拉到90%以上。涉及的工具、命令、判读思路我都会写得尽量具体,方便你直接复现。

1. 先搞清楚:nvidia-smi里的“GPU利用率”到底代表什么

1.1 那个百分比真的代表“GPU在忙”吗

先说结论:nvidia-smi里的利用率,指的是在采样周期内GPU上有kernel正在执行的SM时间占比。它衡量的是“有没有活干”,而不是“算力单元被用了多少”。打个比方,一条自动化食品流水线,机器每小时能处理1000个汉堡,但上游供货商每5分钟才送来一批半成品,机器大部分时间其实在空转。这时候你去看设备状态,看到的就是“机器利用率很低”,但问题压根不在机器本身。

这个区别在深度学习场景里尤其坑。训练一个模型时,GPU要干的活包括:读取数据、做数据增强、搬数据进显存、执行前向反向、做梯度同步、更新参数。这些环节里只有“执行前向反向”真正用到了GPU算力,其他环节很多是在CPU上完成的。CPU一旦慢了,GPU自然就像流水线上的机器一样等米下锅,利用率掉下去非常正常。

1.2 我见过最典型的“假低利用率”案例

之前我在调一个视觉模型的微调脚本,训练日志里每步输出时间是1.6秒,GPU利用率只有34%。当时我的第一反应是batch size太小,于是把batch从32调到64,结果显存直接爆了。后来细查才发现,数据增强在CPU端做得太重——每次喂给GPU之前,都要在Python里做随机裁剪、旋转、归一化,这串操作走的是纯CPU逻辑,每步要花1.1秒。GPU真正干活只需要0.5秒,剩下的时间全在干等。

这种情况你用任何“调大batch”的直觉去优化都没用,因为瓶颈不在显存和GPU算力,而在数据管线的吞吐。想验证这个判断,最直接的方式就是用Profiling工具把CPU和GPU的时间线放在一起看,看看GPU的空档是不是正好对应着CPU的高耗时操作。

1.3 不看复杂Profiler之前,先做这四个快速检查

当然,不是所有问题都值得立刻上重型工具。在打开Profiler之前,我一般会先做一轮快速检查,把嫌疑范围缩到最小:

  • 用watch -n 0.5 nvidia-smi观察利用率波动周期,看是否和训练step对齐。如果利用率呈现“高一下、低一下”的锯齿状,大概率是每步之间有周期性等待。
  • 看CPU总占用率。如果训练时CPU占用已经拉到很满,多半数据预处理或者Python逻辑是瓶颈。
  • 在训练循环里打印每次step的耗时,以及DataLoader取一批数据的时间。两步一对比,数据加载是否成为瓶颈一目了然。
  • 粗略数一下每步触发的kernel数量。如果你的模型很简单,但每步有几千个kernel调用,那很可能大量时间浪费在kernel启动开销上。

这些检查花不了十分钟,但能帮你决定下一步该用哪一类Profiling工具,以及重点看什么指标。

2. 零侵入Profiling工具怎么选:我为什么盯上Nsight Systems

2.1 “零侵入”的真正价值是保住现场

市面上能看GPU状态的工具不少,但绝大多数要么只看表面指标,要么要求你改代码。零侵入工具的意义在于:你在训练跑了一半、问题正在发生的时候就能介入,不打断任务,不改变程序行为,拿到的是真实现场而不是“为了分析而分析”的表演数据。

我自己主力用的是NVIDIA Nsight Systems(命令名是nsys)。它对运行中的进程做系统级采样,能看到CPU函数调用栈、CUDA API调用、GPU kernel执行、内存拷贝、NCCL通信等几乎全部关键事件,而且不需要在代码里插入任何探针。nsys可以直接启动一个训练脚本,也可以对一个已经跑起来的进程做后台采样,这就是它“零侵入”的含义。

与之相对的是PyTorch自带的torch.profiler,它需要在训练循环里包一个with语句,严格来说算轻侵入。但它也有不可替代的价值:能直接输出“哪一行PyTorch算子最耗时”的汇总表,对定位框架层面的问题非常高效。

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

2.2 我的标准组合:nsys看全局,ncu查内核,PyTorch Profiler做汇总

很多人拿到Nsight Systems之后会犯一个错误:试图用它分析每一个kernel。实际上nsys的优势在宏观时间线,它能告诉你瓶颈在哪个阶段、GPU在等谁;但要深挖某个kernel为什么慢,还得用Nsight Compute(命令名是ncu),后者能给出SM占用率、显存带宽、指令混合之类的微架构指标。

我常用的搭配是:

  • nsys先跑一轮完整的训练,哪怕几百步也行,得到整个时间线。
  • 从时间线里找出最可疑的段落,比如GPU利用率长期为0的空档,或者某个耗时极长的kernel。
  • 针对性的用ncu去分析那个kernel,看是访存受限、计算受限还是占用率太低。
  • 如果问题看起来和具体算子相关,再用torch.profiler跑几步,拿到按算子聚合的耗时表。

这套流程看起来多了一步,但实际排查效率比直接猜高很多。因为绝大多数GPU利用率问题根本不是某个kernel太慢,而是整个训练流程里CPU、GPU、数据管线之间的配合出了问题。这类问题只有nsys这种全局时间线工具才能一眼看穿。

2.3 使用前提和容易忽略的版本坑

这些工具对运行环境有一些硬性要求,使用前最好先确认:

项目要求
NVIDIA驱动建议驱动版本较新,至少支持CUDA 11.8以上
CUDA Toolkit与驱动版本配套,nsys和ncu会随CUDA Toolkit一起安装
容器环境如果训练跑在Docker里,需要给容器加--cap-add SYS_ADMIN --cap-add SYS_PTRACE,或直接加--privileged
权限云平台上的共享GPU实例如果没开放性能计数器权限,ncu可能采不到部分指标,但nsys一般还能用

另外注意一点:不同版本的nsys生成的文件后缀不一样,早期版本是.qdstrm,新版是.nsys-rep。用UI打开旧版报告有时候会提示不兼容,建议统一用最新工具链。这个坑我踩过,别等分析到一半才去折腾格式转换。

3. 一次真实排障:从“GPU只有50%”到拿到根因的完整链路

3.1 现象与最初的三条怀疑

有次我在微调一个7B参数的大模型,单机8卡DDP,跑的是分布式训练。nvidia-smi一刷,每张卡利用率大致在45%到55%之间,而且波动很厉害,有时瞬间掉到20%以下。训练速度勉强能接受,但直觉告诉我远没发挥出硬件潜力。

当时我列了三条怀疑:

  • 数据加载跟不上。模型大了,每步要处理的token数量也多,DataLoader如果没调好很容易成为瓶颈。
  • 并行策略有问题。虽然用的是DDP,但某些加在模型外面的逻辑可能破坏了计算和通信的重叠。
  • CPU端隐藏同步太多。训练脚本里如果频繁调用item()、.cpu()这类同步操作,GPU会被迫停下来等CPU。

这三条都有可能,但光靠猜没法推进。我决定在下一个训练run里直接挂nsys。

3.2 用nsys抓时间线:把猜测变成证据

执行的分析命令很简单:

nsys profile --trace=cuda,nvtx,osrt --output=finetune_7b python train.py

--trace=cuda,nvtx,osrt分别表示采样CUDA活动、NVTX标记(模型自己打的阶段标记)和操作系统运行时函数。--output指定报告文件名。跑完训练后,nsys会生成一个报告文件,我习惯直接在Nsight Systems的图形界面里打开。

如果你在无图形界面的服务器上工作,也可以先导出成文本摘要:

nsys stats finetune_7b.nsys-rep

这个命令会输出一份每个CUDA函数和kernel的调用次数、总耗时、平均耗时的统计表,虽然不如时间线直观,但定位问题也够用了。

3.3 从报告里读出的三个关键信号

打开时间线后,我看到了三条非常清晰的信号。

第一条:GPU Stream上每个step的开头都有一段大约0.8秒的空档。空档期间GPU完全没有kernel在执行,但CPU线程此时正高度活跃。点开这份时间线里对应的CPU线程,能看到大量时间花在DataLoader的__iter__和collate_fn上。这说明在每步训练前,GPU都在等CPU把数据准备好,两者根本没有重叠。

第二条:CPU侧出现了大量cudaMemcpyAsync调用,而且每次执行时间都不短。翻到代码里一看,训练脚本在每步结尾都有一次指标验证,把计算好的logits从GPU复制回CPU,再做一个torch.max之类的操作。这一步虽然“看起来只是一行代码”,但它涉及一次完整的GPU到CPU数据搬运,每步搬的数据量还不小。数据在总线上来回折腾,时间就被吃掉了。

第三条:kernel的平均启动间隔偏高。进一步看nsys stats的输出,我注意到CPU提交kernel的时间开销在整个step时间里占了不小的比例。这不是说某个算子慢,而是脚本里散落着大量细碎的Python操作,比如对loss.item()的情况没有批量处理,每个item()都会触发一次GPU同步。太多零碎同步点堆在一起,GPU利用率的“锯齿”就是这么来的。

3.4 修复方案与实测效果

针对这三条信号,我做了三处改动:

  • 把每步都做的指标验证改成每50步做一次,并把logits从GPU搬回CPU时改用更小的抽样Tensor,不做全量搬运。这一步直接削掉了每步末尾的大段传输时间。
  • 调大DataLoader的num_workers,打开pin_memory=True,同时把prefetch_factor从默认的2提高到4。这样数据加载和GPU计算能重叠起来,GPU不再空等。
  • 清理训练循环里的零碎同步操作,把多次item()调用合并成一次.cpu()批量取值,尽量减少CPU和GPU之间的同步点。

改完之后,同一步的训练耗时从1.6秒降到0.9秒左右,nvidia-smi显示的利用率稳定在88%到93%之间。整个训练任务的时间差不多缩短了40%。但要注意,这里改动的每一处都不是我凭空猜出来的,而是nsys时间线上白纸黑字写着的证据。

4. 定位之后:数据搬运、小算子、通信等待三类瓶颈的对症下药

4.1 数据与内存搬运占主导的画像

这类问题在Profiler里最典型的信号是:GPU利用率呈周期性锯齿状,空闲时段恰好和CPU侧的数据处理高度重合,而且cudaMemcpy系列操作的耗时占比很高。

处理方法优先级从高到低:

  • 让数据加载和GPU计算重叠。DataLoader的num_workers、prefetch_factor、pin_memory这三个参数先调起来。
  • 减少CPU到GPU的碎片化拷贝。多个小Tensor最好拼成大块一次拷贝,尤其避免在训练循环里频繁调用tensor.cpu()这类反向搬运。
  • 如果数据增强实在太重,考虑把增强逻辑挪到GPU上用张量操作实现,或者直接用DALI这类GPU端数据管线。

判断数据瓶颈够不够严重,可以做一个快速估算。假设每步要训练128条样本,每条样本在CPU端预处理要花2毫秒,那么每步的纯数据准备时间就是256毫秒。如果GPU计算本身只需要150毫秒,那剩下的106毫秒就是纯等待。重叠起来之后,每步时间理论上可以变成max(计算时间, 数据准备时间) = 256毫秒,而不是两者相加的406毫秒。很多“GPU利用率低”的案例,一算这个账就清楚了。

4.2 小算子与同步点过多的画像

这类问题的特征也很明显:kernel调用数量多到上万,但单个kernel执行时间非常短,经常只有几十微秒。这种碎片化的kernel执行会让GPU频繁在启动和退出的状态之间切换,算力根本没有机会满载。

PyTorch在不同版本里的执行方式差异很大。频繁的item()、tolist()、用Python的for循环逐元素操作,都会制造同步点。优化这类问题,优先做三件事:

  • 给训练循环里的热路径“排毒”。统计所有同步操作的次数,把非必要的item()调用删除,或者延迟到累积到一批再取。
  • 用torch.compile或torch.jit.script把细碎的Python算子融合成更高效的CUDA kernel,减少启动次数。
  • 批量操作替代逐元素操作。比如计算准确率时不要用Python循环遍历batch,而是直接用torch.eq之类的向量化操作。

4.3 多卡通信在等待的画像

多卡训练里还有个独特现象:GPU利用率看着不低,但吞吐也提不上去。此时去nsys时间线里看NCCL通信段,会发现每步结束时有一个长长的通信尾迹,GPU在等待其他卡发来的梯度。

处理思路一般是:

  • 检查DDP的bucket_cap_mb设置,适当调小bucket可以让通信和反向计算更早重叠。
  • 尝试梯度累积,降低同步频率,但注意这会增加显存占用,要配合梯度剪裁。
  • 如果框架支持,打开通信计算重叠选项,比如PyTorch DDP里的static_graph=True在某些场景下能显著减少通信等待。
  • 更在意单机训练效率的话,可以考虑用zero_grad(set_to_none=True)减少不必要的显存清零操作。

这类问题在不同框架、不同网络结构下差异很大,没有万能参数。但排查路径是一致的:先在时间线上确认通信占用了多少时间,再决定要不要动通信策略。不要一上来就去调NCCL的环境变量,那样很容易把本来正常的行为改出问题。

5. 零侵入分析时容易踩的坑和我留下的习惯

5.1 Profiling过程本身会污染性能数据

nsys采样时的开销虽然不大,但依然会给训练过程带来额外负担。我实测过,开--trace=cuda,nvtx,osrt跑训练,测出来的step time会比正常训练高出10%到20%。所以千万不能把Profiling过程中测得的绝对耗时当成最终性能指标,那只能用来横向比较不同阶段和不同kernel之间的相对关系。

如果要和优化后的数据做对比,我习惯在Profiling结束之后,正常启动一次训练,重新测一遍真正的step time。

5.2 容器和云环境里,权限比工具本身更麻烦

在Docker容器里用nsys和ncu,最常遇到的就是权限不足,表现为采样到的数据不完整,或者ncu直接报错。Docker启动时最好提前加上:

docker run --gpus all --cap-add SYS_ADMIN --cap-add SYS_PTRACE --ipc=host ...

如果你是租用的云GPU实例,管理不了宿主机的驱动与权限,那ncu的硬件计数器大概率用不了。这种情况就退回到PyTorch Profiler,虽然侵入性大一点,但拿到的算子级汇总数据足够解决大部分问题了。

5.3 别把“GPU利用率”当成唯一指标

最后说一个观念上的坑。GPU利用率高不代表系统性能好。一个训练系统如果利用率很高,但同步点频繁、kernel调度稀碎,它依然有可能是低效的。反过来,利用率不高也不一定代表GPU没吃饱。

我判断一个训练系统健不健康,通常会同时看四个数:GPU利用率、step time、CPU端等待时间、每步kernel数量。这四个数结合起来才能还原全貌。以后如果你的GPU利用率又莫名其妙掉下去,先别急着怀疑显卡,打开nsys看一眼时间线,看看GPU在等谁。多数情况下,它等的是CPU,等的是数据,等的是通信,而不是显卡自己跑不动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进