显存省了GPU-Util却掉到50%?大模型训练变慢的四大暗雷与排查优化实战 1. 从显存爆了到GPU 摸鱼一个典型的训练变慢现场1.1 复现一下同一步训练显存降了一半util 也降了一半这个 LLM Training Lab 系列聊到第 10 期前面几期都在解决一个很现实的物理问题怎么在一张只有 24G、甚至 12G 显存的卡上把 70 亿参数的大模型塞进去正常训练。LoRA、梯度检查点、8bit 优化器、CPU offload方法我挨个写过然后评论区就开始出现同一个新问题显存确实省下来了可 GPU-Util 却从 90% 掉到 50%为什么吃不满我自己在本地复现过这个案例基本就是大家留言里的标准描述。模型是一个 7B 参数的 decoder-only 模型FP16 精度AdamW 优化器单卡 A100 40G。全参数微调时显存顶得很死batch size 只能开到 2即便这样显存也到 36G 左右但 nvidia-smi 里 GPU-Util 长期在 90% 上下跳动。后来我按常规思路做了三件事base 模型换成 LoRA 微调、每层开 gradient checkpointing、优化器状态用分片方式管理。效果很明显显存从 36G 直接降到 16Gbatch size 从 2 提到 4 还有富余看起来一切完美。结果真跑起来就傻眼了step 时间从 2.1 秒涨到 2.6 秒GPU-Util 在 50% 到 60% 之间反复横跳。显存少了那么多理论上计算量也没增加GPU 为什么反而开始摸鱼这个现象不是玄学背后是一套非常清晰的资源权衡逻辑只是很少有人把它和省显存放在一起讲。这篇文章就把我踩过的坑、完整的排查过程以及最终修改方案都梳理一遍。1.2 GPU-Util 到底在统计什么占满时间不等于算满算力很多人对 GPU-Util 有个误解以为它是算力利用率是 GPU 压榨得有多满。实际上 nvidia-smi 里的 utilization.gpu本质上是统计一个采样周期内 GPU 计算引擎有多少时间处于有 kernel 在执行的状态。注意它统计的是发动机点火的时间比例不是油门踩到底的比例。一个 kernel 哪怕只把一个 SM 上的几个 warp 排满只要它在跑这个指标可能都会记作 busy。反过来如果你的工作负载里 kernel 本身很小一个 kernel 从启动到退出只花几十微秒而 CPU 端调度下一个 kernel 也要几十微秒GPU 引擎在调度间隙没有活干util 就会明显掉下去。这也正好解释了为什么显存省了但 util 低了你的负载从一堆又大又长的稠密矩阵乘法变成了数量更多、单个尺寸更小的 kernel发动机依然在点着火但点火间隙变多了。显存和 GPU 算力是两个独立维度的资源。显存是仓库算力是生产线。你把仓库清空了一大半不代表生产线就被迫满负荷运转。只有让新的货物也流进仓库生产线才会重新忙碌起来。1.3 省显存是手段不是目的先回答省出来给谁用这里有一个我反复强调、但总被忽略的基础判断省下来的显存如果没有任何新东西去用那你只是让显存空闲并不能换来吞吐提升。梯度检查点、offload、ZeRO 这些技术本质上是拿时间换空间显存是省了但每一步的额外开销是实打实存在的。只有当省出来的显存让你能做更大 batch、更长序列、更大模型的时候这些开销才可能被摊薄甚至赚回来。所以在排查GPU 吃不满之前先把省显存用的哪些时间债盘清楚。接下来逐个拆开讲这几个就是问题的根源。2. 四个为省显存放下的暗雷重算、搬运、通信与启动开销2.1 梯度检查点多算一遍前向换来激活释放梯度检查点gradient checkpointing的原理是不存中间激活要用时现场重新算。对于一个大 transformer 层反向传播需要用到前向时的激活值如果这些激活值不留在显存里就得在前向阶段保留输入等反向跑到这一层时再做一次完整的前向来重建激活。这笔账可以用 FLOPs 估算。一个标准的前向加反向训练过程假设模型参数量为 N每步处理 token 数为 T全量训练的理论计算量大约为 6 × N × T。开了梯度检查点之后反向阶段需要额外重算一次等价的前向而前向大约占总计算量的三分之一到四成所以整体 FLOPs 会从 6 × N × T 涨到接近 8 × N × T也就是多出来约 30%-35% 的纯计算量。这里就会遇到一个很反直觉的现象如果显存没有成为限制因素你给 batch size 保持不变纯用梯度检查点去省显存那么训练不可能变快只会变慢。那些多出来的前向重算会让 GPU 排队算一些原本可以不用算的东西。哪怕 GPU-Util 显示 100%你的有效吞吐也在下降。那为什么还要用梯度检查点答案很简单只有当你因为显存限制无法增大 batch、无法拉长序列时它才值得。举个例子你原本 batch2 就 OOM开了检查点后能跑 batch8虽然单个 token 的计算量多了 30%但 batch 从 2 涨到 8整体吞吐是大幅翻倍的。如果开了检查点之后你还是跑 batch2那就纯粹是亏本买卖。2.2 Activation/Weight offload把 HBM 的活儿塞给 PCIeoffload 的做法和重算不同它是把中间激活或者参数直接搬出 GPU放到 CPU 内存等要用的时候再搬回来。显存确实腾出来了但搬运数据这件事本身要花时间而且时间成本可能是数量级的差距。来看带宽这件事GPU 里的 HBM 显存带宽动辄 2TB/s 起步A100 这种卡甚至到 2TB/s 以上而 PCIe 4.0 x16 的实测单向带宽一般在 30GB/s 左右CPU 端的 DDR4 双通道内存带宽也只有 40GB/s 上下。把数据从 GPU 显存挪到 CPU 内存再挪回来等于把一条高速公路上的车流硬生生改道到一条乡间小路上。想象一下你原本在厨房里有个大冰箱取食材三步就到。offload 相当于把冰箱搬到楼下的储藏室每次做饭都要下楼拿一趟菜。确实厨房宽敞了但每做一道菜的时间全花在上下楼上。放到训练场景里如果每一步需要搬运的激活量达到几百 MB光搬运就要几十毫秒而一个 7B 模型单层前向可能只需要几毫秒GPU 的大部分时间就是在干等数据。我见过不少人在显存不够时一上来就开 CPU offload结果训练速度掉一个数量级。这种做法只适合我先跑通、不追求速度的调试阶段或者模型实在塞不下、但又必须在本机完成一个完整跑通的场景。真要追求性能宁可牺牲 batch size 也不建议常态化开启。2.3 ZeRO 与 MoE显存分摊之后通信账单来了ZeRO 系列优化的核心是把显存里重复存的东西分摊到多张卡上。ZeRO-1 只切优化器状态ZeRO-2 把梯度也切了ZeRO-3 连参数都一起切。显存确实省得很彻底但通信量也在同步上涨。以 ZeRO-3 为例每次前向都要先做一次参数的 all-gather把分布在各卡上的参数聚合到当前卡反向计算梯度时每算完一段就需要一次 reduce-scatter把梯度同步并切分到各卡。想象一个团队从每人保存一份完整资料变成每人只保存一部分但要一起工作于是每次开会前都要互相传阅资料、散会后还要把各自的笔记汇总分发。开会通信的时间就是你省下储物柜显存换来的代价。多卡训练里还有另一个被低估的场景MoE 架构。很多人问MoE 架构到底要不要把所有参数放进显存很遗憾训练场景下大多数实现还是需要主要参数驻留显存只是 expert 部分可以按需调度。就算你真的省下了 expert 的显存MoE 的路由本身会引入 all-to-all 通信token 要在不同卡之间重新排列。一旦路由不均衡某些卡空转、某些卡忙死GPU-Util 照样会被拖垮。你会发现显存是省了但排队等通信的时间反而变长了。2.4 LoRA 和 Kernel 启动计算量变少固定开销开始唱主角回到最开头的那个案例LoRA 表面上是压显存最有效的手段之一因为它把可训练参数从 7B 压缩到几千万优化器状态和梯度都大幅缩小。但注意LoRA 微调时 base 模型的前向仍然要完整跑一遍反向也仍然需要计算输入梯度真正省掉的是 base 模型各层的权重梯度。这带来一个很微妙的变化单个算子的计算量变小了训练循环里的 kernel 数量却没有同比例减少。GPU 执行一个 kernel 是有固定成本的kernel 启动、寄存器分配、上下文切换每一步都有开销。而 Python 端的调度开销更大每一次算子调用都要做张量元数据检查、autograd 图管理、CUDA 启动调用。对比一下当一个 kernel 要跑 500 微秒时CPU 花 50 微秒去调度它这个开销可以被占满的计算隐藏掉当一个 kernel 只跑 50 微秒时CPU 还是花 50 微秒调度GPU 就开始出现大量空闲窗口。这就是为什么计算越薄启动开销占比越高。显存省下来之后如果你的 batch size 没有涨起来kernel 普遍变小变薄GPU 自然就吃不满了。3. GPU 吃不满的排查链路别被一个数字骗了3.1 先分三桶CPU bound、kernel bound、communication bound遇到 GPU-Util 低我建议先别动任何代码先分类。90% 的情况可以归进三个桶里CPU bound、kernel bound、communication bound。CPU bound 的表现非常典型GPU-Util 在某个低水位线上波动但 CPU 占用率非常高甚至多个核心满负荷。这时候 GPU 是在等 CPU 把数据送进来罪魁祸首基本是 DataLoader、tokenizer、Python 里的繁重预处理或者你在训练循环里写了太多的同步操作。kernel bound 的表现是 GPU-Util 不算太低但也没有达到预期CPU 占用并不高。这说明 GPU 引擎本身在干活只是活干得太碎、太没效率。比如 batch size 太小让单个矩阵乘法的规模不够或者动态 padding 让大量算力消耗在无效的 padding token 上。communication bound 主要出现在多卡环境。观察方法很简单在多卡训练时GPU-Util 会周期性地断崖式下跌然后恢复再下跌。这个节奏往往对应梯度同步的 all-reduce 周期。计算和通信没有充分重叠GPU 在通信阶段就是干等。3.2 三分钟能上手的监控组合nvidia-smi、py-spy 和一个小脚本先上一组快速命令每一条都有明确的定位用途。# 每 5 秒刷新一次看 util、显存、功耗和 SM 时钟 nvidia-smi --query-gpuutilization.gpu,memory.used,power.draw,clocks.sm --formatcsv -l 5 # 更细的 dmon 视图能看更多引擎状态 nvidia-smi dmon -s puct -d 5如果 CPU 占用高下一步用 py-spy 直接看 Python 调用栈py-spy dump --pid python进程IDpy-spy 能非常快地抓到当前 Python 代码停在哪一行。我记得第一次在训练卡慢时抓它栈顶停在dataset.__getitem__里那一刻我就明白瓶颈不在 GPU而是在数据管线里反复做 tokenizer 的 encodeCPU 把 GPU 饿着了。还有一个不动代码的压测方法单独把 DataLoader 拿出来不调用模型只测它每 batch 需要多长时间。如果 DataLoader 取一个 batch 就要 1 秒而 GPU 每处理一个 batch 只要 0.3 秒那是给 GPU 喂食的传送带根本跟不上。3.3 用 Timeline 看谁在等谁以及一份快速判断表上面这些指标确定了哪一类瓶颈但定位到具体位置还需要 timeline。我最常用的两个工具是 PyTorch 自带的torch.profiler和 NVIDIA Nsight Systems。torch.profiler能输出整个训练 step 的 operator 分解告诉你时间到底花在哪个算子上是 CPU 侧等待还是 CUDA 侧等待。Nsight Systems 则更适合看宏观时间线能很直观地看到 GPU kernel 之间的空隙以及 CPU 在做什么。用 timeline 的核心思路只有一个谁在等谁。如果是 CPU 先准备数据再启动 kernel你会看到 kernel 之间的大段空隙也就是GPU 等 CPU。如果是 GPU 上的 kernel 执行时间本身很长但 util 又不高那往往是 kernel 内并行度不够或者访存和计算没有重叠。为了方便快速对照我整理了一份排查表你看到的现场常见根源60 秒确认方法GPU-Util 反复横跳CPU 多核占满CPU bound数据供应跟不上top、py-spy dumpGPU-Util 稳定在低值CPU 空闲kernel 本身太小或并行度不足ncu --set speed-of-light看 kernel 效率多卡 util 周期跌破均值通信未隐藏all-reduce 等待Nsight Systems 看通信段单卡显存充足但 util 很低batch 太小或动态 shape先把 batch 翻倍试一次这张表能覆盖 80% 的初判场景。拿到结论之后再去做专项优化会比盲目开torch.compile或者乱调 NCCL 参数靠谱得多。4. 把欠的时间还回去数据、kernel、通信与同步的专项优化4.1 数据管线的改造把 token 预处理挡在训练循环外面如果你的排查结论是 CPU bound第一件事不是加num_workers而是先问自己训练循环里到底有多少 CPU 活在跑很多人在数据集中直接做 tokenizer 的 encode导致每个 epoch 都要在 CPU 上重新分词一遍。一个几十 GB 的语料分词本身就要吃掉大量 CPU 时间但 GPU 对这个过程毫无感知。正确的做法是把 tokenize 放到离线阶段。先把原始语料一次性切成 token id保存成内存友好、读取高效的二进制格式比如 Arrow、MDS 或者简单的npy序列训练时只做查表读取。这一步能极大缩短 CPU 侧的时间债务。如果确实需要实时预处理DataLoader 的配置也值得认真调一次。一个我验过多次的起点配置长这样DataLoader( dataset, batch_size8, num_workers8, prefetch_factor4, pin_memoryTrue, persistent_workersTrue, drop_lastTrue, )num_workers建议取 CPU 物理核心数的一半到三分之二不要无脑拉满否则 worker 之间的调度也会成为 CPU 新瓶颈。pin_memoryTrue让数据直接固定到页锁定内存能降低 H2D 拷贝开销。persistent_workersTrue避免每个 epoch 重建 worker在小数据集复跑时收益尤其明显。4.2 让单个 Kernel 更有肉batch、算子融合与 CUDA Graphskernel bound 的优化思路很直接让 GPU 上有足够大的矩阵乘法可算并减少 kernel 之间的间隙。先理解 GPU 的并行模型一句话版本GPU 以 warp32 个线程为最小调度单位以 CTA/block 为单位把计算块分给 SM。如果一个 batch 里矩阵乘法的行数和列数太小grid 就只有几个 block大量 SM 是空着的。这也是为什么 CPU 调度成本不变时batch 越大单个 kernel 越有肉GPU 越容易吃满。所以 kernel bound 时的第一条路永远是在显存允许范围内把 batch size 提上去或者把序列长度提上去。第二个动作是算子融合。FlashAttention 这类 kernel 的主要收益不只是省显存它还把 attention 里的多次内存读改写合成了一次让整个 kernel 在时间上更紧凑。PyTorch 2.x 的torch.compile也是一种融合手段它会把一组小算子合成一个大的 CUDA kernelmodel torch.compile(model, modereduce-overhead)reduce-overhead模式从用法上最省心背后会用 CUDA Graph 把整段模型执行录制成图大幅降低 kernel 启动和 Python 调度开销。需要注意的是如果你的数据 shape 是动态的torch.compile的收益会大打折扣因为它需要为不同 shape 重新编译反而可能出现负优化。所以我一般会建议先把训练数据的长度按最大长度截齐保证 shape 固定再开torch.compile。另外还有一个 NLP 特有的隐性浪费——padding。很多人为了省事把整个 batch 的样本都 pad 到最长长度但 GPU 在计算那些 padding token 时并没有任何它在偷懒的感知那些 padding 计算白占算力MFU 却掉得厉害。这里推荐做分桶先把训练数据按长度排序每个 batch 只由长度接近的样本组成能最大化有效 token 占比长期收益比盲目调一两个超参数大得多。4.3 多卡环境下把通信藏进计算里多卡吃不满通信等待往往是元凶。DDP 在设计上已经做了计算和通信的部分重叠反向传播时梯度一旦产生allreduce 会立刻跟着发起而不是等全部反向算完才开始。但实际效果受很多因素影响第一步要检查的往往不是算法问题而是 DataLoader 是否在所有 rank 上都一致。多卡训练里有个常见坑分布式模式下只有主进程在准备数据或者各个 rank 的 shuffle 结果不一致导致一个 iteration 结束时有的卡早在那闲着有的卡还在算。先用DistributedSampler保证每个 rank 的数据分片确定再谈其他优化。DDP 本身也有一个可调参数bucket_cap_mb。DDP 会把梯度按桶分组每个桶累积到一定大小再触发通信。桶太大通信粒度粗尾部等待时间会长桶太小通信次数增加来回握手也费时间。对于中小模型可以从默认的 25MB 往 50-100MB 方向试观察 step 时间的变化曲线即可ddp_model DistributedDataParallel( model, bucket_cap_mb50, gradient_as_bucket_viewTrue, )gradient_as_bucket_viewTrue能直接让梯度视图指向通信桶省掉一次梯度的额外拷贝。如果用的是 ZeRO 或 FSDP那就要重点确认是否开了通信和计算的 overlap。FSDP 的sharding_strategy、forward_prefetch、backward_prefetch这些参数直接影响参数 gather 是否提前进行。我不会给死配置因为不同集群的网络拓扑差别太大但你可以打开TORCH_LOGSdistributed或 NCCL 的调试日志看看通信段占比再决定往哪个方向调。4.4 别小看同步和日志eval、print 与垃圾回收的隐性打断很多 GPU 吃不满的案子里真正的问题不在哪一个大项而是无数零散的同步点把 GPU 的时间切得七零八落。第一刀切在 eval。训练循环里如果每个 epoch 跑一遍完整 evalGPU 在 eval 阶段其实也在算但 eval 一般 batch 更大、没有反向util 反而可能高。真正的坑在有些实现会把 eval 写成一个 barrier 式的同步点所有卡必须等 eval 结果聚合完才开始下一步这个等待窗口 GPU 是空的。第二刀切在日志。每 step 都 print 一次 loss甚至在多卡场景下每个 rank 都 print你会在终端看到大量重复输出。print 本身在 Python 里是有 GIL 和 IO 开销的一旦触发同步GPU 就会被卡住。我的习惯是每 N 步打一次并且只在 rank 0 上打。WB 这类工具如果用默认同步模式每 step 的 commit 也可能拖慢建议把上传的间隔调长或者直接关掉 step 级别的指标上传。第三刀切在 Python 垃圾回收。训练循环里每步产生海量中间张量频繁的 GC 扫描会让 Python 进程卡顿进而让 GPU 断粮。虽然现代深度学习框架的 CPython 引用计数能回收大部分临时对象但循环引用和 CUDA 缓存的反复分配仍然会产生周期性停顿。可以在训练开始前对加载好的高质量对象做一次冻结import gc gc.freeze()也可以调整 GC 阈值降低触发频率。还有一个容易被忽略的设置是 CUDA 内存分配器export PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True这个配置能减少显存碎片和分配抖动在 batch size 反复波动或长序列训练里稳定性的提升相当明显。5. 真正的目标不是 GPU-Util 100%而是有效吞吐5.1 用 MFU 代替 GPU-Util 作为效率基线前面讲了一堆排查和优化手段但如果你把 GPU-Util 直接当成训练的 KPI很可能一开始就把方向带偏了。一个 padding 到 70% 的 batchGPU-Util 照样能跑 100%可有效算力利用率可能不到三成。做 LLM 训练的人更常用 MFUModel FLOPs Utilization来评估。它的公式不复杂理论计算量除以 GPU 峰值算力再除以实际耗时。一个 7B 模型batch8、seq2048也就是每步处理 16384 个 token前向加反向的训练 FLOPs 大约为 6 × 7e9 × 16384约等于 690 TFLOPS。假设单卡 A100 的 FP16 稠密峰值算力是 312 TFLOPS理想情况下这个 step 应该跑 690 / 312 ≈ 2.2 秒。如果实测单步 5.5 秒MFU 约 40%这个数字其实已经算不错了。如果你实测单步 8 秒以上那就别再看 GPU-Util 了应该回到前面几步查数据、查通信、查 kernel。低显存场景下的单卡训练MFU 二三十是很常见的事不用一上来就背没吃满的心理包袱。优化的目标应该是在固定显存和硬件条件下把有效吞吐每秒处理的有效 token 数拉高而不是把某一个显示排面上的数字拉满。5.2 省显存后第一件事试更大的 batch而不是看着显存发呆回到最开头的那个案例。显存从 36G 降到 16G 之后最优解不是继续地看着空闲显存发呆而是第一时间把 batch size 翻倍、再翻倍观察两个东西step 时间的变化和每秒 token 吞吐的变化。有一组经验数字很值得参考如果 batch 翻倍后step 时间只增加了 30% 左右说明之前确实处于显存受限状态省显存的技术在给你创造更大的算力利用空间这笔买卖赚了。如果 batch 翻倍后 step 时间几乎也翻倍说明训练已经进入计算受限或通信受限的状态省出来的显存暂时没有用武之地你也可以考虑把模型规模或序列长度往上抬而不是继续堆 batch。我自己在实践中的体会是梯度检查点开与不开取决于你是否真的需要那个显存额度。跑短序列、小 batch 时激活本来就不大开检查点纯粹赔时间跑长序列比如 8K 以上时激活是显存大户检查点的收益才真正体现出来。每一次空间换时间的技术都要配合空间被重新利用的动作才算是完成一次完整优化。5.3 我的调试清单与几句实话最后把整套思路压成一份我自己的调试清单方便你复现时直接走流程先记录当前基线固定 seed、固定 batch、固定步数跑 50 步测 time per step 和有效 tokens/s。用 GPU-Util 和 CPU 占用做初判归类到 CPU bound、kernel bound 或 communication bound。开torch.profiler或 Nsight Systems 看 timeline确认谁在等谁别急着调参数。数据管线优先优化离线 tokenize、分桶、提升 DataLoader worker 数量。kernel 层面固定 shape、加大 batch、考虑torch.compile和 FlashAttention。多卡环境确认 DistributedSampler、调 DDP bucket、检查通信是否与计算重叠。每改一个变量都回到第 1 步重新测只保留真正让有效吞吐上升的改动。关于显存省下来了为什么 GPU 还是吃不满我的最终回答其实是一句话省显存的技术不创造算力它只是帮你把资源的边界挪一挪。如果挪边界的同时没有让更大的 batch、更长的序列或更大的模型流进来GPU 吃不满是必然的不是你调参调错了。把 GPU-Util 从 60% 调回 90% 很容易难的是想清楚那 90% 在算什么。