
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], scheduletorch.profiler.schedule(wait1, warmup1, active3, repeat1), on_trace_readytorch.profiler.tensorboard_trace_handler(./log), record_shapesTrue, profile_memoryTrue, ) 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 占用率与 occupancyutilization.gpu高不代表 SM 被充分利用。真正的算力利用率要看 SM occupancy 和每个 SM 上活跃的 warp 数量。这个用 Nsight Computencu才能看到。如果 occupancy 很低说明每个 SM 上并行执行的线程块不够延迟掩盖不住算力自然上不去。不过对 LLM 训练来说大部分时间花在矩阵乘和 attention 上这些 kernel 的 occupancy 通常不是问题。真正的问题往往在 kernel 之间的空隙也就是GPU 在等谁。3. 显存优化手段本身如何拖慢训练3.1 梯度检查点的计算换存储代价梯度检查点gradient checkpointing的原理是不保存中间激活值反向传播时重新计算一遍前向。省显存的效果立竿见影通常能省 50% 到 70% 的激活显存但代价是前向计算量增加约 33%因为要重算。如果你的模型本来计算就不是瓶颈那这个代价可以接受但如果模型已经接近算力上限梯度检查点会让每一步变慢。更关键的是重算的前向是串行插入到反向传播里的会打乱原本可以重叠的计算流导致 kernel 之间的并行度下降。我在一个 7B 模型的 LoRA 微调里做过对比开梯度检查点显存从 38GB 降到 22GB但每步耗时从 0.42s 涨到 0.58sGPU 利用率从 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 与 prefetchGPU 利用率上不去十有八九是数据供给跟不上。DataLoader的num_workers设小了CPU 预处理速度跟不上 GPU 消费速度GPU 就会周期性空转。一个经验值num_workers设成 CPU 物理核心数的 1 到 2 倍。但这不是绝对的取决于你的数据预处理有多重。如果预处理里有大量 Python 逻辑比如 tokenize、动态 padding那 CPU 很容易成为瓶颈。dataloader DataLoader( dataset, batch_size32, num_workers8, pin_memoryTrue, prefetch_factor4, persistent_workersTrue, )pin_memoryTrue让数据放在锁页内存里H2Dhost to device拷贝更快prefetch_factor控制每个 worker 预取多少 batchpersistent_workersTrue避免每个 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_blockingTrue) torch.cuda.current_stream().wait_stream(stream)这样拷贝和上一步的计算可以重叠。不过要注意同步点别让数据竞争。这个技巧在数据量大、拷贝频繁的场景下收益明显。5. 通信与并行策略里的隐性等待5.1 DDP 的梯度 all-reduce 时机DDPDistributedDataParallel在反向传播过程中做梯度 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)/(mp-1)其中 p 是流水线级数m 是 micro-batch 数。如果 m 不够大气泡会吃掉大量算力。比如 p4、m4 时气泡比例是 3/7接近 43% 的时间 GPU 在等。解决办法是增大 micro-batch 数或者用 interleaved schedule 把气泡填得更满。但 micro-batch 数受显存限制这就又回到了显存和算力的权衡。5.3 通信与计算重叠的实操检查判断通信是否是瓶颈可以在 profiler 里看 NCCL kernel 的占比。如果 NCCL 相关 kernel 占了总时间的 20% 以上那通信就是重要瓶颈。这时候可以考虑降 ZeRO stage减少通信量用梯度累积减少通信频率检查是否用了 NVLinkPCIe 带宽差很多调整 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 会反复编译。如果序列长度变化频繁编译开销可能超过收益。我一般会先用modereduce-overhead试试如果编译时间可接受再用。7. 一套可复现的排查流程7.1 第一步确认瓶颈在计算、通信还是数据先跑 100 步用 profiler 抓数据然后按下面的表判断现象可能瓶颈验证方法CUDA kernel 间有大段空白数据供给不足看 DataLoader 时间占比NCCL kernel 占比高通信瓶颈看 NCCL 时间占比kernel 密集但都很短启动开销看平均 kernel 时长功耗低、利用率低GPU 没吃饱看 power.draw显存高、利用率高但慢计算瓶颈看 SM occupancy7.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_memoryTrue之后拷贝和计算重叠利用率明显提升。第四个坑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-18-bit 优化器只在显存真的紧张时用num_workers设成 CPU 核心数的一半到一倍根据 CPU 利用率调。最后分享一个判断小技巧如果nvidia-smi里 GPU 利用率在 30% 到 60% 之间波动而且波动有周期性那基本是数据供给问题如果稳定在某个低值不动那更可能是通信或 kernel 启动开销。周期性波动看数据稳定低值看通信这个经验帮我省了不少排查时间。