显存优化后GPU利用率仍低?LLM训练瓶颈排查指南
1. 显存空出来了,GPU 利用率却趴在 30% 不动
显存优化这件事,做过 LLM 训练的人多少都经历过一个阶段:一开始 OOM 报错,于是上梯度检查点、上 ZeRO、上 8-bit 优化器、上 LoRA,折腾一圈之后nvidia-smi里的显存占用终于从 98% 降到了 60%,心里一块石头落地。结果跑起来一看,GPU 利用率(utilization.gpu)还是只有 30% 到 40%,power.draw也上不去,训练速度几乎没变。
这个现象在 LLM Training Lab 系列里被单独拎出来讲,是因为它太典型了。显存和算力是两回事,显存省下来不等于算力被喂饱。很多人把"显存不够"当成训练慢的唯一瓶颈,实际上当显存不再是瓶颈之后,真正的瓶颈才浮出水面——而它往往藏在数据管线、通信、kernel 启动开销或者 batch 结构里。
这篇内容适合两类人:一类是正在做 LLM 微调或预训练、已经解决了 OOM 但发现吞吐上不去的工程师;另一类是刚接触 PyTorch 分布式训练、想搞清楚"为什么我的卡在摸鱼"的开发者。我会把显存和 GPU 利用率之间的关系拆开讲,给出可复现的排查路径,以及几个我在实际项目里踩过的坑。核心关键词围绕 LLM、GPU、显存、PyTorch、训练这几个方向展开,不跑题。
先说一个反直觉的结论:显存占用下降之后 GPU 利用率不升反降,是正常现象,甚至是可以预期的。原因在于,你为了省显存做的那些操作,很多本身就在增加计算步骤或者串行等待。梯度检查点就是最典型的例子——它用一次额外的前向计算换掉存储的激活值,计算量上去了,显存下来了,但每一步的耗时变长了。如果这时候数据供给跟不上,GPU 就会在"等数据"和"算一下"之间反复横跳,利用率自然上不去。
所以排查这件事,不能只盯着显存看,得把整条流水线摊开。
2. 先搞清楚 GPU 利用率这个数字到底在说什么
2.1 utilization.gpu 的统计口径
nvidia-smi里的utilization.gpu指的是"在过去采样周期内,GPU 上有至少一个 kernel 在执行的时间占比"。注意,它衡量的是有没有 kernel 在跑,而不是算力被用了多少。这意味着两件事:
第一,一个只用了 1% SM 的 kernel 和一个打满所有 SM 的 kernel,在utilization.gpu上可能都显示 100%。第二,如果 kernel 之间存在空隙(gap),哪怕每个 kernel 本身很重,利用率也会被拉低。
这就解释了为什么有时候你看到利用率 100%,但训练速度还是很慢——因为跑的都是些小 kernel,或者 kernel 之间在等通信。反过来,利用率 30% 也不一定代表 GPU 在闲着,可能是采样窗口刚好卡在了 kernel 间隙上。
我一般会同时看三个指标:utilization.gpu、utilization.memory(显存带宽占用)和power.draw(功耗)。如果功耗远低于 TDP,那基本可以确认 GPU 没吃饱;如果功耗接近 TDP 但利用率低,那可能是采样问题或者 kernel 太碎。
2.2 用 PyTorch Profiler 看真实的时间分布
nvidia-smi只能给你一个粗粒度的数字,真正定位问题得靠 profiler。PyTorch 自带的torch.profiler就够用:
import torch from torch.profiler import profile, ProfilerActivity with profile( activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], schedule=torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1), on_trace_ready=torch.profiler.tensorboard_trace_handler('./log'), record_shapes=True, profile_memory=True, ) as prof: for step, batch in enumerate(dataloader): train_step(batch) prof.step() if step >= 6: break跑完之后用 TensorBoard 打开./log,重点看三样东西:CUDA kernel 的时间线、CPU 侧的 op 时间、以及DataLoader相关的等待。如果时间线上出现大段空白,那就是 GPU 在等;如果 kernel 密密麻麻但每个都很短,那是启动开销在拖后腿。
提示:profiler 本身有开销,别在正式训练时一直开着。用
schedule参数控制采样窗口,跑几十步拿到代表性数据就关掉。
2.3 一个容易被忽略的指标:SM 占用率与 occupancy
utilization.gpu高不代表 SM 被充分利用。真正的算力利用率要看 SM occupancy 和每个 SM 上活跃的 warp 数量。这个用 Nsight Compute(ncu)才能看到。如果 occupancy 很低,说明每个 SM 上并行执行的线程块不够,延迟掩盖不住,算力自然上不去。
不过对 LLM 训练来说,大部分时间花在矩阵乘和 attention 上,这些 kernel 的 occupancy 通常不是问题。真正的问题往往在 kernel 之间的空隙,也就是"GPU 在等谁"。
3. 显存优化手段本身如何拖慢训练
3.1 梯度检查点的计算换存储代价
梯度检查点(gradient checkpointing)的原理是不保存中间激活值,反向传播时重新计算一遍前向。省显存的效果立竿见影,通常能省 50% 到 70% 的激活显存,但代价是前向计算量增加约 33%(因为要重算)。
如果你的模型本来计算就不是瓶颈,那这个代价可以接受;但如果模型已经接近算力上限,梯度检查点会让每一步变慢。更关键的是,重算的前向是串行插入到反向传播里的,会打乱原本可以重叠的计算流,导致 kernel 之间的并行度下降。
我在一个 7B 模型的 LoRA 微调里做过对比:开梯度检查点,显存从 38GB 降到 22GB,但每步耗时从 0.42s 涨到 0.58s,GPU 利用率从 78% 掉到 61%。省下来的显存并没有换来速度,反而因为重算让 GPU 更忙但更碎。
3.2 ZeRO 各阶段的通信开销
ZeRO-1 切优化器状态,ZeRO-2 再切梯度,ZeRO-3 连参数都切。每往上一级,显存省得更多,但通信量也更大。ZeRO-3 在每个前向和反向都要 all-gather 参数,通信量是 ZeRO-1 的好几倍。
如果卡间带宽不够(比如 PCIe 而不是 NVLink),通信就会成为瓶颈。这时候你会看到 GPU 利用率低,但nvidia-smi里显存占用也不高——因为 GPU 在等通信,不是在算。
判断方法很简单:看nvidia-smi的utilization.gpu和网络/PCIe 带宽。如果通信带宽打满而 GPU 利用率低,那就是通信瓶颈。这时候要么降 ZeRO stage,要么换更快的互联,要么用梯度累积把通信摊薄。
3.3 8-bit 优化器的量化反量化开销
8-bit Adam 这类优化器把优化器状态从 FP32 压到 INT8,显存省了 4 倍。但每次更新都要做量化(quantize)和反量化(dequantize),这些操作虽然不重,但会引入额外的 kernel。如果 batch 很小,这些额外 kernel 的启动开销占比就会很高。
实测下来,8-bit 优化器在 batch size 较大时几乎无感,但在小 batch 场景下会让每步耗时增加 5% 到 10%。所以如果你的显存已经够用,没必要为了省而省。
4. 数据管线:最隐蔽的 GPU 空转来源
4.1 DataLoader 的 num_workers 与 prefetch
GPU 利用率上不去,十有八九是数据供给跟不上。DataLoader的num_workers设小了,CPU 预处理速度跟不上 GPU 消费速度,GPU 就会周期性空转。
一个经验值:num_workers设成 CPU 物理核心数的 1 到 2 倍。但这不是绝对的,取决于你的数据预处理有多重。如果预处理里有大量 Python 逻辑(比如 tokenize、动态 padding),那 CPU 很容易成为瓶颈。
dataloader = DataLoader( dataset, batch_size=32, num_workers=8, pin_memory=True, prefetch_factor=4, persistent_workers=True, )pin_memory=True让数据放在锁页内存里,H2D(host to device)拷贝更快;prefetch_factor控制每个 worker 预取多少 batch;persistent_workers=True避免每个 epoch 重建 worker 进程。这三个参数配合好,能显著减少 GPU 等数据的时间。
4.2 动态 padding 与变长序列的坑
LLM 训练里序列长度往往不固定。如果每个 batch 都按最长序列 padding,短序列就会浪费大量计算。更糟的是,如果 padding 比例波动大,每个 batch 的实际计算量差异很大,GPU 利用率就会忽高忽低。
解决办法是用长度分桶(length bucketing)或者动态 padding 到 batch 内最大长度。但动态 padding 会让每个 batch 的 shape 不同,触发 CUDA 的 kernel 重新编译(如果开了torch.compile)或者内存重分配,反而可能引入新的开销。
我一般会做长度分桶,把长度相近的样本放一起,然后固定几个长度档位(比如 512、1024、2048),这样既减少 padding 浪费,又保持 shape 稳定。
4.3 用 CUDA Stream 做数据拷贝与计算的重叠
PyTorch 默认在默认流上做 H2D 拷贝,会和计算串行。如果数据量大,拷贝时间不可忽略。可以用单独的 CUDA stream 做异步拷贝:
stream = torch.cuda.Stream() with torch.cuda.stream(stream): batch = batch.to('cuda', non_blocking=True) torch.cuda.current_stream().wait_stream(stream)这样拷贝和上一步的计算可以重叠。不过要注意同步点,别让数据竞争。这个技巧在数据量大、拷贝频繁的场景下收益明显。
5. 通信与并行策略里的隐性等待
5.1 DDP 的梯度 all-reduce 时机
DDP(DistributedDataParallel)在反向传播过程中做梯度 all-reduce。默认情况下,它是按 bucket 分组的,每个 bucket 的梯度算完就触发一次 all-reduce,试图和后续的反向计算重叠。
但如果 bucket 设得太大,all-reduce 会等到很多梯度都算完才开始,重叠效果差;设得太小,通信次数多,启动开销大。bucket_cap_mb默认是 25MB,一般不用改,但在大模型上可以适当调大。
更关键的是,如果反向传播本身很快(比如小模型),all-reduce 来不及和计算重叠,GPU 就会在通信时空转。这时候可以考虑用梯度累积,把多个 micro-batch 的梯度攒起来再通信,摊薄通信开销。
5.2 张量并行与流水线并行的气泡
张量并行(TP)在每个 layer 内部切分,通信频繁但量小;流水线并行(PP)按 layer 切分,通信量小但有"气泡"(bubble)——也就是流水线填充和排空阶段 GPU 空闲。
PP 的气泡比例大致是(p-1)/(m+p-1),其中 p 是流水线级数,m 是 micro-batch 数。如果 m 不够大,气泡会吃掉大量算力。比如 p=4、m=4 时,气泡比例是 3/7,接近 43% 的时间 GPU 在等。
解决办法是增大 micro-batch 数,或者用 interleaved schedule 把气泡填得更满。但 micro-batch 数受显存限制,这就又回到了显存和算力的权衡。
5.3 通信与计算重叠的实操检查
判断通信是否是瓶颈,可以在 profiler 里看 NCCL kernel 的占比。如果 NCCL 相关 kernel 占了总时间的 20% 以上,那通信就是重要瓶颈。这时候可以考虑:
- 降 ZeRO stage,减少通信量
- 用梯度累积,减少通信频率
- 检查是否用了 NVLink,PCIe 带宽差很多
- 调整 bucket 大小,改善重叠
6. 小 batch 与 kernel 启动开销的放大效应
6.1 为什么小 batch 下 GPU 利用率天然低
每个 CUDA kernel 的启动都有固定开销,大概几微秒。如果 kernel 本身执行时间只有几十微秒,启动开销占比就很高。小 batch 下,每个 kernel 的计算量小,执行时间短,启动开销的占比就被放大。
LLM 训练里,如果 batch size 太小,矩阵乘的维度不够大,GPU 的并行度用不满,SM 占用率低,利用率自然上不去。这也是为什么大 batch 训练通常效率更高——不是因为算法更好,而是因为硬件利用率更高。
6.2 梯度累积:用小 batch 模拟大 batch
显存不够上大 batch,但又想提高 GPU 利用率,梯度累积是标准解法。它把 N 个小 batch 的梯度累加,等效于一个大 batch,但显存占用还是小 batch 的水平。
optimizer.zero_grad() for i, batch in enumerate(dataloader): outputs = model(batch) loss = criterion(outputs, batch.labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意 loss 要除以累积步数,否则梯度会放大。梯度累积不减少计算量,但能让每个 kernel 的 batch 维度更大,提高利用率。不过它也有代价:累积期间不能更新参数,如果累积步数太多,收敛行为会和真正的大 batch 有差异。
6.3 torch.compile 与 kernel 融合
torch.compile能把多个小 kernel 融合成一个大 kernel,减少启动开销。在 LLM 训练里,它对 element-wise 操作(如 LayerNorm、激活函数)的融合效果明显。
但torch.compile有编译时间,而且遇到动态 shape 会反复编译。如果序列长度变化频繁,编译开销可能超过收益。我一般会先用mode="reduce-overhead"试试,如果编译时间可接受再用。
7. 一套可复现的排查流程
7.1 第一步:确认瓶颈在计算、通信还是数据
先跑 100 步,用 profiler 抓数据,然后按下面的表判断:
| 现象 | 可能瓶颈 | 验证方法 |
|---|---|---|
| CUDA kernel 间有大段空白 | 数据供给不足 | 看 DataLoader 时间占比 |
| NCCL kernel 占比高 | 通信瓶颈 | 看 NCCL 时间占比 |
| kernel 密集但都很短 | 启动开销 | 看平均 kernel 时长 |
| 功耗低、利用率低 | GPU 没吃饱 | 看 power.draw |
| 显存高、利用率高但慢 | 计算瓶颈 | 看 SM occupancy |
7.2 第二步:针对性优化
数据瓶颈就调num_workers、prefetch_factor、pin_memory;通信瓶颈就降 ZeRO stage、加梯度累积、检查互联;启动开销就上torch.compile、增大 batch;计算瓶颈就考虑换更高效的 attention 实现(如 FlashAttention)。
7.3 第三步:验证优化效果
每次只改一个变量,跑同样的步数,对比每步耗时和 GPU 利用率。别一次改一堆,否则出了问题不知道是哪个改动导致的。
8. 几个我踩过的坑和对应的经验
第一个坑:盲目开梯度检查点。有次为了省显存开了检查点,结果训练速度掉了 30%,后来发现显存其实够用,只是当时没算清楚。经验是先用torch.cuda.memory_summary()看清楚显存到底花在哪,再决定要不要省。
第二个坑:num_workers 设太大。以为 worker 越多越好,结果 CPU 内存爆了,而且 worker 之间抢锁,反而更慢。经验是num_workers从 4 开始试,逐步加,观察 CPU 利用率和内存。
第三个坑:忽略 pin_memory。数据量大的时候,H2D 拷贝能占 10% 以上的时间。开了pin_memory=True之后,拷贝和计算重叠,利用率明显提升。
第四个坑:ZeRO-3 用在单机上。单机多卡没有 NVLink 的话,ZeRO-3 的通信开销能把收益吃光。经验是单机优先用 ZeRO-1 或 ZeRO-2,多机再考虑 ZeRO-3。
第五个坑:profiler 一直开着。profiler 本身有开销,一直开着会让训练变慢,而且日志巨大。经验是用schedule采样,跑几十步就关。
9. 显存和算力的平衡点在哪
显存优化和算力利用本质上是矛盾的:省显存的手段大多要增加计算或通信。找到平衡点的关键是先定位真正的瓶颈,而不是无脑省显存。
如果显存是瓶颈,那就省显存,接受一定的速度损失;如果显存已经不是瓶颈,那就别再省了,把精力放在数据管线、通信重叠和 kernel 效率上。
我的一般做法是:先把显存压到刚好不 OOM 的程度(留 10% 到 15% 余量),然后在这个基础上优化吞吐。显存余量太小会导致碎片化 OOM,太大则浪费了可以换速度的空间。
具体到参数上,我会这样配:梯度检查点只在激活显存占比超过 40% 时开;ZeRO stage 根据卡数和互联选,单机 NVLink 用 ZeRO-2,单机 PCIe 用 ZeRO-1;8-bit 优化器只在显存真的紧张时用;num_workers设成 CPU 核心数的一半到一倍,根据 CPU 利用率调。
最后分享一个判断小技巧:如果nvidia-smi里 GPU 利用率在 30% 到 60% 之间波动,而且波动有周期性,那基本是数据供给问题;如果稳定在某个低值不动,那更可能是通信或 kernel 启动开销。周期性波动看数据,稳定低值看通信,这个经验帮我省了不少排查时间。