这个系列写到现在,前几篇都在教大家怎么把 LLM 训练塞进不大的显存里——梯度检查点、ZeRO、Offload、LoRA 微调,恨不得把每一兆显存都榨干。显存确实省下来了,但紧接着评论区就出现同一个问题:为什么显存占用下去了,GPU 利用率反而也掉下来了?有时候nvidia-smi里只有 20%~30%,显卡像是在摸鱼。
先把结论扔出来:你的卡大概率没有被改坏。省显存这件事,本质上是拿算力、时间、带宽去换容量。当你把显存这个约束挪开,系统的瓶颈就会自动往后平移,落到数据加载、kernel 启动、梯度通信这些环节上。你看到的"GPU 吃不满",不是优化失败了,而是瓶颈转移了。
这篇文章就把"显存省了但 GPU 吃不满"的几种典型原因掰开揉碎,从原理到排查都给一遍。适合手里有单卡或多卡、正在做 LLM 预训练/微调/LoRA、并且被利用率搞到头大的朋友。我不会只丢给你几个工具名字,而是把为什么慢、怎么定位、怎么验证每一步都写清楚。
1. 省显存的技术都不是白拿的:先算清"时间换空间"的账
很多人一开梯度检查点,看到显存掉下来就觉得自己赚了,但 GPU 利用率同步下降后又开始怀疑人生。这里的问题不是显存优化失效,而是你没搞清楚这些技术到底是用什么换显存。
1.1 梯度检查点:你多算了一次前向
梯度检查点(Gradient Checkpointing / Activation Recomputation)的原理,说白了就是"过河拆桥":前向传播的时候不保留每一层的中间激活值,只留下少数几个 checkpoint;反向传播要用某层的激活值算梯度时,再从最近的 checkpoint 重新做一次前向把路铺回来。
账本很清楚:反向阶段每一层都会额外多算一次前向,整体 FLOPs 大约增加 20%~40%,业界一般按 30% 左右估算。如果省下显存后你的 batch size 没变,那每个 step 的计算时间就是实打实变长了——GPU 自然显得"没吃满",甚至比不开检查点之前更慢。
但这里有个非常关键的权衡:如果你把省下来的显存拿去加大 batch size 或序列长度,那么一次前向重算的 30% 开销就被摊薄了,因为大 GEMM 的 Tensor Core 利用率更高、kernel 效率更好,单位 token 成本反而可能下降。
实操上我的建议是:开检查点前先想清楚省出来的显存准备拿来干嘛。如果 batch 已经顶到显卡上限无路可加,那强行开检查点只会换回来更低的吞吐;如果能从 batch 4 加到 8、从序列 2048 加到 4096,这 30% 的额外计算就值得付。
1.2 ZeRO 不是在省钱,是在交通信税
ZeRO 系列是 DeepSpeed 的招牌省显存技术。ZeRO-1 分片优化器状态,ZeRO-2 再分片梯度,ZeRO-3 把参数也分片到各卡。显存确实被摊薄了,但代价是通信量的上升。
ZeRO-1/2 相比普通 DDP 的 all-reduce,会把一次完整的通信拆成更细粒度的 reduce-scatter 和 all-gather,分段数量变多,通信总字节数也略有增加。到了 ZeRO-3,每次迭代前都要先从各卡 all-gather 回完整的参数,反向结束后还要对分片的梯度做 reduce-scatter,等于每步训练都在集群网络上搬模型。
我见过不少小团队在 4 卡 4090 上用万兆以太网跑 ZeRO-3,显存是省得很漂亮,但 GPU 每算一会儿就要停下来等参数从其他卡传过来,利用率长期在 50% 以下。单机多卡用 NVLink 时问题还不明显,一旦跨机走以太网,通信税就立刻变成本文主角。
如果设备不多、网络又不是 NVLink/InfiniBand,我通常建议优先考虑 ZeRO-1 或 ZeRO-2,别一上来就上 ZeRO-3。LoRA 微调场景更没必要全量分片,改用 checkpointing 加更小的 optimizer state,反而能把算力用满。
1.3 Offload 到 CPU:数据要在 PCIe 上来回跑
把优化器状态甚至参数 offload 到 CPU 内存,是"低显存跑模型"话题里最常被提到的手段。它确实能省出大量显存,但你要清楚每一轮迭代发生了什么:反向结束后,梯度要从显存搬到内存,CPU 更新完参数后,新参数又要从内存搬回显存。
显存带宽是 1.5~2TB/s 级别,PCIe 4.0 x16 实际也就 30GB/s 左右,两个数量级的差距。搬一趟数据,GPU 在那儿干等着。如果你的 batch 比较小、每步计算只要几百毫秒,offload 的搬运时间很可能比计算时间还长。
我的经验是,offload 适合两类场景:一是计算步数少、总训练时间不敏感但显存极有限的微调;二是推理场景下的 KV Cache 管理,而不是高频迭代的预训练/全参微调。对于训练来说,offload 基本是"最后手段",能避免尽量避免。
2. 第一个被冤枉的替罪羊:DataLoader 和数据预处理
排除了显存优化本身的算力开销之后,如果 GPU 利用率仍然上不去,下一个要怀疑的往往是数据管线。很多人在这一步就开始怀疑代码写得不对,实际上十次里有大半次是数据供不上。
2.1 检查 GPU 有没有"断粮"
最典型的症状是nvidia-smi里的 GPU-Util 呈现周期性波动:90% 多掉到个位数,再弹回 90%,循环往复。这不是你的训练代码不稳定,而是 DataLoader 在某一刻没把下一个 batch 准备好,GPU 只能空转等数据。
为什么会断粮?GPU 算一个 step 可能只要几百毫秒,而 CPU 侧的数据管线要做的事远比你想象的多:读取文件、反序列化、tokenize、collate、padding,这些加起来可能也需要几百毫秒。两边节奏一旦对不上,GPU 就有空闲。
PyTorch 的 DataLoader 默认情况下每个 worker 在内存里预取 2 个 batch(prefetch_factor=2),对于 4090、A100 这种级别的卡来说,这个预取深度经常不够。如果训练循环里还有明显的 Python 侧逻辑,比如每步打印 loss、执行optimizer.zero_grad()等,CPU 调度的压力会更大,数据断供就越频繁。
这里有个简单自查:在训练循环里记录一次next(iter(data_loader))的耗时。如果这个时间偶尔超过 GPU 算一个 step 的时间,数据断粮就实锤了。
2.2 预处理链路里藏着的 CPU 开销
很多人觉得 tokenizer 很快,因为跑一次 API 调用看起来只要几毫秒。但在 DataLoader worker 里,每个 batch 都要对一批文本做 tokenize,而 HF 的 tokenizer 的 CPU 开销不是零;如果你的数据还是 JSON 格式,json.loads的解析成本也不容小觑;更别提把 batch 里所有样本 padding 到最大长度时,多余的填充 token 会白白消耗 GPU 算力和显存。
一个常见错误是:数据集本身已经是 tokenize 过的 ids,但训练代码里每次迭代还是做一遍 decode 再 re-tokenize,白白浪费 CPU。另一个错误是:明文文本数据直接丢给 DataLoader,让每个 worker 在训练过程中反复做预处理。
2.3 数据管线的优化套路
按性价比排序,我一般建议依次做这几件事:
- 离线预处理:把文本提前 tokenize 成 ids,存成内存映射友好的格式(比如
.bin、.npy或 WebDataset/Parquet)。训练时 DataLoader 只负责切分和搬运,不再碰 tokenizer。 - 长度分桶(bucketing):把相近长度的样本放进同一个 batch,避免 padding 到全 batch 最大长度。这个优化对训练吞吐的影响经常被低估,短样本多一点的话,节省的计算量相当可观。
- 加大 worker 数和预取深度:
num_workers可以往 CPU 核数靠,prefetch_factor调到 4~8,观察是否缓解周期性掉 0。 - 使用 pinned memory 和
non_blocking=True:把 CPU batch 放到页锁定内存,再配合Tensor.to(device, non_blocking=True)复制数据,能让数据传输与 GPU 计算部分重叠。
注意:
prefetch_factor只对num_workers>0生效。如果你还在用默认的num_workers=0,那数据管线是纯串行的,GPU 每算一个 step 就得干等着 CPU 把下一个 batch 备好。
数据管线这一层排查完之后,GPU 利用率往往已经能回到 70% 以上。如果还没达标,那就要往 GPU 内部看了。
3. GPU-Util 100% 不代表算力吃满:利用率要看第二层
这是最容易误导人的地方。很多人用nvidia-smi的 GPU-Util 判断卡有没有满负荷,但 100% 只是一个"时间比例"概念,不是"算力密度"概念。
3.1 nvidia-smi 的 100% 是个时间概念
nvidia-smi的 GPU-Util 统计的是采样周期内是否有 kernel 在 GPU 上执行。只要有一个 kernel 在跑,无论它把 SM 用了几成、Tensor Core 有没有转起来,这段采样时间就会被记为 busy。
打个比方:高速路上只要有一辆车在开,这条路就是"被使用"状态,哪怕车里只装了一行李箱的货。模型训练里大量 kernel 是 Elementwise 操作,比如 LayerNorm、Dropout、残差相加、激活函数,这些 kernel 主要被内存带宽卡住,SM 里的 Tensor Core 其实是闲置的,但nvidia-smi照样打 100%。
所以别再把 GPU-Util 当唯一指标。它只能告诉你 GPU"有没有醒着",不能告诉你"干得有多卖力"。
3.2 用 NCU 看 Tensor Core 的真实占用率
要看算力有没有吃满,得用 NVIDIA 的 Nsight Compute(ncu)。训练脚本里随便挑十来个 step,跑一次完整的 kernel 级分析:
ncu --set full --section SpeedOfLight --launch-count 20 python train.py里面最关键的是 SpeedOfLight 部分的两个百分比:SM 占用率和内存占用率。如果 SM% 接近 100,说明计算单元确实忙;如果内存% 接近 100,说明 kernel 是内存带宽瓶颈;如果两者都不高,而你确认 batch 已经够大,那多半是 kernel 之间的空隙或同步等待造成的。
想看 Tensor Core 是不是真的在干活,可以单独抓这个指标:
ncu --metrics sm__pipe_tensor_cycles_active.avg.pct_of_peak_sustained_elapsed python train.py如果这个数字只有十几个百分点,你基本可以断定:就算 GPU-Util 显示 100%,算力也远没有吃满。
3.3 计算密集还是内存密集:瓶颈维度的判断
判断模型当前的瓶颈维度,直接决定了你该往哪个方向优化。这里做一个粗分类:
- launch-bound:GPU 大部分时间在等 CPU 提交 kernel。特征是 kernel 本身执行时间很短,但 kernel 之间空隙很大。注意,这种情况在
nvidia-smi里往往显示利用率不高,因为空闲时间长。 - memory-bound:SM 空闲,内存总线很忙。特征是用 ncu 看时内存% 高、SM% 低,GPU-Util 却可以接近 100%。
- compute-bound:SM 和 Tensor Core 都很忙。这种状态才是理想状态,优化重心放在算法本身。
LLM 训练里最常见的其实是前两种的混合:算 Transformer 时 GEMM 部分 compute-bound,但 LayerNorm、Dropout、残差、Softmax 这些部分 memory-bound,融合算子能去掉大量内存搬运,这也是torch.compile能带来收益的重要原因。
4. 几千个小 Kernel 的排队效应:CPU 调度成了天花板
如果你已经排除了数据问题,也确认了不是显存优化的正常副作用,但 GPU 仍旧吃不满,那下一个高频嫌疑就是 kernel 发射路径。这个坑很隐蔽,尤其在小模型、小 batch、LoRA 微调这类"单 step 计算量不大"的场景里特别常见。
4.1 每次 launch 都要收 3~10 微秒的"过路费"
在一个训练 step 里,model 的 forward 和 backward 会拆成几百到几千个 kernel。1B 模型一个 step 可能有两三千个 kernel,7B 模型更多。每个 kernel 从 CPU 侧发起,需要经过 PyTorch 调度、CUDA driver、命令队列初始化等固定环节,大概要花 3~10 微秒。
问题就出在这个"固定成本"上。如果 GPU 上单个 kernel 运行时间也是几微秒甚至亚微秒,那整个 step 的时间就被 CPU 的 launch 速度卡住了——GPU 在大部分时间里都在等 CPU 把下一个 kernel 发过来。
这类问题最容易出现在 batch 偏小、单算子计算量不大、模型层数多又碎的场合。你看到 GPU-Util 在 40%~60% 徘徊,但把训练日志打开,每个 step 的 wall time 却并不短,就是典型的"被 CPU 发射速度拖死"。
检查方法很简单:用torch.profiler导出 trace,看 kernel 之间的空隙。如果 CPU time 明显远大于 CUDA time,说明 GPU 在等 CPU;如果一条时间线上 kernel 密密麻麻几乎没空隙,那就不是 launch-bound。
4.2 解法一:torch.compile 算子融合
torch.compile的 inductor 后端会把相邻的 elementwise 算子融合进一个 kernel,顺带干掉大量中间张量的读写。一个 Transformer block 里几十个小 kernel,经常能被融合到只剩几个,训练吞吐的提升在小模型上尤其明显。
我用一个 1.5B 左右的模型做单卡实验,开torch.compile之后每 step 时间缩短了接近 30%,显存占用还略有下降,因为中间激活变少了。
要注意两点:一是首步编译时间会比较长,大概几十秒到几分钟,别把它当成死机;二是如果你的模型里有动态 shape,或者代码里有比较刁钻的 Python 控制流,编译器可能会 graph break,效果大打折扣。训练阶段可以接受用固定序列长度,把动态 shape 尽量压住,torch.compile(dynamic=False)就好。
4.3 解法二:CUDA Graph 绕开 CPU 发射
CUDA Graph 的思路更彻底:先把整个训练 step 里所有 kernel 的发射逻辑捕获成一张图,之后每次迭代只需一次极轻量的 CPU 提交,GPU 就按图把 kernel 依次跑完,CPU launch 开销几乎被清零。
PyTorch 里有torch.cuda.graph上下文管理器,用起来大概长这样:
g = torch.cuda.CUDAGraph() # 先跑一次 warmup,确定显存地址和 shape with torch.cuda.graph(g): output = model(input_batch) # 每次迭代只需要复制输入,然后 replay input_batch.copy_(new_batch) g.replay()但训练场景比这个复杂不少:loss.backward() 和 optimizer.step() 也要在 graph 里捕获,或者至少要把 forward+backward 放进 graph,optimizer 留在外面。而且 CUDA Graph 要求 shape 和显存地址在整个捕获周期内保持不变,所以 batch 必须固定、padding 必须固定,动态 shape 直接触发重新捕获。
如果不介意这些约束,CUDA Graph 在 launch-bound 场景下的收益很大,小模型提升 20%~50% 我都见过;模型越大、单 kernel 时间越长,收益越小,大概在 5%~15% 之间。
4.4 动态 shape 和 MoE 的特殊情况
MoE 模型或多模态模型是 kernel launch 瓶颈的重灾区。MoE 的路由结果是动态的,每个 expert 分到多少 token 每步都变,kernel 的 shape 跟着变,CUDA Graph 很难一把抓。
常见的妥协做法是固定 expert capacity:超出容量的 token 丢弃,宁可损失一点点精度,也要让 kernel shape 稳定,从而把 graph 捕获住。另一个思路是在图内 buffer 上做计算,把动态的 token 数量塞进一个固定大小的 buffer,多余的部分用 mask 掉。
LoRA 微调其实也容易踩这个坑。adapter 的矩阵虽然小,但两个小 GEMM 加几个 elementwise 算子,数量一堆,CPU launch 成本一点没少。遇到这种场景,优先叠加torch.compile,再考虑 CUDA Graph。
5. 多卡的时候,GPU 是在算,还是在等队友?
单卡问题排查完之后,多卡训练还有一个巨大的隐性开销:梯度同步。在这个阶段,GPU 利用率低往往根本不是算力问题,而是每步训练都要等所有卡把梯度对齐。
5.1 All-Reduce 同步点走到台前
数据并行里,每张卡独立计算梯度,但下一轮更新前所有卡必须拿到所有梯度。这个 all-reduce 操作是同步的,所有卡都要等最慢的那张卡完成后才能进入下一个 step。
算一笔账:7B 模型用 bf16 训练,梯度体积就有约 14GB。all-reduce 要在集群网络上把梯度来回归约再广播回去,实际流量通常是梯度体积的数倍。100GbE 以太网有效带宽也就 11~12.5GB/s 左右,光传梯度就要好几秒。而 7B 模型在一个 8 卡集群上,单 step 的计算时间也就几秒,通信占比高得离谱。
NVLink 的单向带宽能到几百 GB/s,单机多卡通常还好;一旦跨机走以太网,通信墙立刻变成主瓶颈。之前提到的 ZeRO-3 会让这个问题雪上加霜,因为参数分片后每步都要 from-scratch 收集参数。
5.2 梯度桶、通信重叠和 bucket_cap_mb
为了解决"先算完 backward 再通信"的零重叠问题,DDP 会把梯度按反向传播的顺序从后往前切成桶(bucket),一边算 backward 一边对已经填满的桶发起通信,让通信和计算重叠。
这里有个值得调的参数:bucket_cap_mb。默认是 25MB,但对大模型来说,桶太多会导致通信被切成大量小段,碎片化严重;桶太大又会推迟通信启动,重叠效果变差。我在 7B 模型上试过把bucket_cap_mb调到 50~100,step time 有可感知的下降。
即便做了通信计算重叠,每个 step 终归还是有同步尾巴——最慢的那张卡的最后一小块梯度必须等所有人到齐。这是数据并行的结构性成本,减不了太多,只能靠提高计算/通信比去摊薄。
5.3 通信与计算的比例:为什么大 batch 更划算
通信总字节量由模型大小决定,单张卡的 GPU 算力再强也省不掉。真正能改的是"一次性同步前算了多少活"。
- 加大 batch size:同样一次 all-reduce,对应的计算量更大,通信占比下降。
- 梯度累积(gradient accumulation):注意它不会减少通信总字节,只是把同步频率降下来,让 GPU 在多个 mini-step 里连续算,再统一同步一次。网络慢、GPU 算力快的小集群上,这是一招很实用的缓兵之计。
- 梯度压缩、梯度稀疏化:通信量下降明显,但是否影响收敛需要单独验证。
我之前在 4 机 8 卡的千兆规模集群上调优时,光是把 batch size 从 8 拉到 32,并配合梯度累积到等效 batch 128,GPU 利用率就从 40% 涨到了 80% 左右。通信量一点没少,但 GPU 每算两秒才同步一次,等待比例大幅下降。
6. 一次完整排查实录:从干瞪眼到定位根因
前面把理论讲完了,如果你已经对着自己的训练日志开始挠头,那我把一次实际排查的完整链路放出来。这套流程我每次遇到"显存省了但 GPU 吃不满"都会走一遍,通常半天内能定位。
6.1 第一步:用 dmon 记录利用率的波动模式
先别急着上 profiler,先用最简单的工具把整体观感拿到:
nvidia-smi dmon -s pcu -d 1盯一分钟,看 GPU 利用率的波动模式:
- 如果 GPU-Util 稳定在低位,几乎没有尖峰,重点怀疑 kernel launch 或通信。
- 如果 GPU-Util 周期性掉 0,再弹回来,重点怀疑数据管线。
- 如果多卡利用率出现"各卡参差不齐"的错位,重点怀疑负载不均或通信同步。
我记得有一次排查一个 22B 模型微调任务,显存只用了 14G 不到,GPU-Util 在 25%~45% 之间抖动,看起来像数据断供,但 dmon 显示 vmem 和 SM 时钟都很稳定,就意识到可能是 kernel 层面的问题。
6.2 第二步:把 step 时间拆开看
在训练循环里插最简单的时间戳,把每个 step 切成数据获取、forward、backward、optimizer 四段:
step_times = [] for step in range(total_steps): t0 = time.time() x, y = next(data_iter) # data_fetch t1 = time.time() out = model(x) # forward t2 = time.time() loss = loss_fn(out, y) loss.backward() # backward t3 = time.time() optimizer.step() optimizer.zero_grad() step_times.append([t1-t0, t2-t1, t3-t2, time.time()-t3])跑 50 个 step 之后看中位数和 p95。如果 data_fetch 的中位数超过了几百毫秒,甚至和 forward 时间一个量级,数据管线就是第一嫌疑。那次排查里,data_fetch 中位数 400ms,forward 才 300ms,前半段时光全耗在等数据上了。
6.3 第三步:用 Profiler 找空隙
时间戳只能定位到"哪个阶段慢",还想再进一步,就得看 kernel 层面。PyTorch 自带的 profiler 足够用了:
from torch.profiler import profile, ProfilerActivity, schedule with profile( activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], schedule=schedule(wait=5, warmup=5, active=10, repeat=1), record_shapes=True ) as prof: for step in range(100): train_step() prof.step() print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=30))看两个东西:CPU time 总量和 CUDA time 总量的比值,以及 kernel 时间线上有没有明显的空洞。如果 CPU time 比 CUDA time 高一大截,就是 launch 瓶颈;如果 CUDA time 总量还不到 wall time 的一半,说明 GPU 有大把时间在等。
要更细的 GPU 时间线,用 Nsight Systems:
nsys profile -o train_profile --stats=true python train.pynsys stats train_profile.nsys-rep能直接看到 GPU 利用率、kernel 数量、CUDA API 等待时间。这个工具基本是我排查这类问题的最后一道大锤。
6.4 第四步:用开关实验锁定真凶
工具定位完,还要用"单变量实验"验证因果,而不是看个热闹。
- 把梯度检查点关掉,重新计时。如果 step time 明显下降、利用率上升,说明是检查点重算造成的算力开销远超预期;此时最优解是检查点保持开启、同时把 batch 加大到显存边界。
- 把 DataLoader worker 数从 4 加到 16,
prefetch_factor调到 8。如果利用率明显提升,数据断供实锤。 - 开
torch.compile或 CUDA Graph。记住每次只改一个变量,否则你永远不知道是不是多个因素叠加出来的。
那次排查最后的结果很典型:把num_workers从 4 加到 16、prefetch_factor调到 8 后,GPU-Util 从 40% 跳到了 70%;再叠加torch.compile,稳定到 85%。显存优化本身没有任何问题,纯粹是省下来的显存没让 GPU 干更多活,反而让它在原地等数据。
| 现象 | 重点检查项 | 常见根因 | 顺手解法 |
|---|---|---|---|
| GPU-Util 周期性掉 0 | data fetch 耗时 | DataLoader 断供 | 加 worker/prefetch、离线 tokenize |
| GPU-Util 低但稳定 | kernel 间空隙 | launch 瓶颈 | torch.compile、CUDA Graph |
| GPU-Util 接近 100% 但训练慢 | NCU SpeedOfLight | memory-bound 小 kernel 多 | 算子融合、减少中间张量 |
| 多卡利用率参差/上不去 | all-reduce 时间 | 通信墙 | 加大 batch、梯度累积、调整 bucket_cap_mb |
| 开检查点后明显变慢 | A/B 开关对比 | 检查点重算开销 | 同时加大 batch,或选择性 checkpointing |
最后聊点排查之外的体会。这套流程里最常用的工具就是nvidia-smi dmon、torch.profiler、nsys这三个,但比工具更重要的是建立"瓶颈转移"的直觉——显存省下来只是第一关,GPU 吃不满才是把系统真正的短板暴露出来。我个人的习惯是,每次只改一个变量,把 step time 的分布(中位数和 p95)记下来,而不是只看平均值;这样你才能分辨某个优化是稳赚还是只是拉平了极端情况。
如果你也遇到过"显存明明没满、GPU 利用率就是上不去"的怪事,不妨把训练日志里的时间分布和 kernel trace 拿出来对照一下,八成能在上面四类原因里找到答案。要是找到的根因不在列表里,欢迎把现象发在评论区,这种奇怪问题往往是最涨经验的。