显存省了GPU却闲了?解析LLM训练中的算力利用率瓶颈
1. 最开始的困惑:显存省了,GPU却不忙了
前阵子我调一个 7B 模型的 LoRA 微调脚本,从全参微调改成 LoRA 加梯度检查点之后,显存占用直接从 42GB 掉到不到 12GB,心里还挺爽。按常理说,显存压力小了,单卡 4090 应该能跑更大的 batch,GPU 也该被塞得更满才对。结果打开nvidia-smi一看,GPU-Util 只有 30% 左右,训练吞吐甚至比之前全参微调还慢了不少。
这不是个例。我身边很多做 LLM 训练的朋友都踩过同一脚:显存优化做了一大堆,模型确实塞进单卡了,可 GPU 就是吃不满,一步的耗时反而变长。这篇内容就想把这个反直觉现象掰开揉碎聊清楚,核心讲三个东西:省显存到底省的是什么,GPU 利用率到底怎么衡量,以及当你发现 GPU 空转时应该从哪几个方向去定位和解决。写给你我这种做 LLM 微调、LoRA、分布式训练时天天跟显存和算力打交道的工程人,也写给刚入门、被 `vRAM 限制搞得头大的同学。
先说一个容易被忽视的结论:显存是容量问题,GPU 吃不吃得满是吞吐问题,两者根本不是同一个瓶颈维度。你在仓库里腾出了大量空位,不代表传送带和工人就一定能满负荷运转。接下来的内容全部围绕这个核心展开。
2. 先搞清楚“省显存”到底省的是什么
2.1 显存、带宽、算力,其实是三件不同的事
把 GPU 比作一个加工厂:显存是仓库,显存带宽是传送带,SM(流式多处理器)是里面的工人,Tensor Core 是专门给矩阵乘法用的超级工具。你要训练一个 LLM,得同时满足三件事:模型参数、梯度、优化器状态、激活值这些“物料”能放进仓库;每次前向和反向要读写的“物料”能通过传送带及时送到工人手里;工人和工具得有足够的加工能力把送进来的数据算完。
很多同学只盯着第一个问题,也就是“装不装得下”。于是用 LoRA、梯度检查点、CPU offload 把显存压到很低,觉得万事大吉。可这背后最大的坑在于:你省掉的显存往往对应着“少存中间数据”或者“把数据挪到更慢的存储上”,这个省法很容易让传送带罢工。
举几个实际数字感受一下。一张 RTX 4090 的显存带宽大概 1TB/s 上下,PCIe 4.0 x16 的实际传输带宽大概 32GB/s,两者差了三十多倍。你把一部分参数和优化器状态挪到 CPU 内存,表面上显存是空了,可每次执行算子都要从 PCIe 那头把参数搬回来,传送带直接变成瓶颈,GPU 里的工人大部分时间在等料。这种优化换来的“显存宽裕”,代价是算力整体闲置。
2.2 LLM 训练里显存都花在哪儿了
要理解省显存的各种招数,先得知道显存占用的大头有哪些。分布大致是四块:
- 模型参数本身。7B 模型用 BF16 存,差不多 14GB。
- 优化器状态。用 Adam 的话,fp32 的动量、方差再加主权重,通常是参数量的 2 到 4 倍,这往往是全参微调里最肥的一块。
- 梯度。反向传播过程中要存下来的梯度,量级和参数差不多。
- 激活值。由 batch size、序列长度和网络层数决定,是训练时最容易膨胀的一块。
LoRA 之所以省显存,是因为它冻结原模型权重,只训练插入的小矩阵,优化器状态和梯度规模大幅缩小。梯度检查点省的是激活值:不保存中间结果,反向传播时现算。ZeRO 系列省的是参数/梯度/优化器状态在各个卡之间的重复拷贝。
发现没有?这些招数分别攻击的是显存账单里的不同项目。攻击成功之后,显存数字自然降下来,但计算量和数据搬移量不一定会降,甚至会涨。梯度检查点是最典型的“用算力换显存”,offload 是“用带宽换显存”。换算的时候如果没有把算力和带宽的空闲程度一起考虑,就会看到显存降了、GPU 也闲了。
2.3 显存占用数字和“显卡在工作”之间没有因果关系
nvidia-smi里那一行显存占用,描述的是“仓库里堆了多少货”,完全不代表“传送带和工人忙不忙”。你完全可以构造出一个状态:显存只用了 5GB,但 GPU 上跑着一个巨长的、频繁等显存返回的 kernel,SM 里所有的 warp 都堵在等待队列上,利用率图表看起来一片绿,实际有效计算一个周期都没完成。
反过来也一样:显存已经快爆了,也不代表算力一定满负荷。很多时候一个小模型塞进了单卡,但因为 batch 太小、算子形状不规整,矩阵乘根本喂不饱 Tensor Core,显存倒是存满了,算力照旧闲置。
3. 什么才是真正的“GPU 吃不满”
3.1 GPU-Util 不等于 GPU 在算
先说指标本身。nvidia-smi上报的 GPU-Util,表示的是采样区间内 SM 上存在可执行 warp 的时间比例。可执行 warp 的意思是“有活可以干”,但实际在干的是访存、等待,还是做计算,它不区分。所以在显存/带宽受限的 kernel 里,SM 一直有 warp 在等显存数据,GPU-Util 照样能显示接近 100%,但你真正期望的 Tensor Core 矩阵乘算力可能半点没用上。
这也是为什么很多人看着nvidia-smi觉得“利用率很好”,实际吞吐却低得离谱。真正要看的指标应该分三层:
- 第一层,SM 的整体活跃度,看
sm__throughput.avg.pct_of_peak_sustained_elapsed,对应 SM 里各级流水线被使用的程度。 - 第二层,Tensor Core 利用率,看
tensor__...相关计数器,这层才是 LLM 训练里真正干重活的单元。 - 第三层,HBM 显存带宽利用率,看
dram__throughput.avg.pct_of_peak_sustained_elapsed。
三个指标组合起来,才能判断一个训练过程到底是算力受限、带宽受限,还是压根没调度好。
3.2 算力受限还是带宽受限:一个直觉模型
判断受限很容易,看算力峰值和带宽峰值的比值。假设一张卡的理论算力是 A(FLOPs),显存带宽是 B(byte/s),那它存在一个“平衡点”:
平衡算术强度 = A / B
含义是:如果某个算子平均每访问 1 字节数据能完成的浮点运算次数高于这个比值,它才有可能把算力吃满;如果低于这个比值,它大概率被带宽卡住。RTX 4090 的 BF16 稠密算力大概 80+ TFLOPS,显存带宽约 1TB/s,平衡点大约在 80 FLOP/byte 左右。一个典型的小算子,比如对一个几 MB 的 tensor 做逐元素操作,每字节数据可能只做了个位数到十几的浮点运算,远低于 80,它再怎么调也吃不满算力。
LLM 训练里大量时间其实耗在这类小算子上,尤其是长序列场景下的 normalization、position embedding、mask 操作。它们省显存吗?省。它们能让 GPU 算力吃满吗?基本不能。这就是显存省下来但算力空转的第一类典型状态。
3.3 真正有用的效率指标:MFU,而不是 GPU-Util
做 LLM 训练,建议把目光从“GPU 利用率百分比”挪到“MFU”(Model FLOPs Utilization)。MFU 衡量的是:在给定硬件条件下,你实际完成的浮点运算量占理论峰值的比例,这个指标直接刻画“算力吃满没有”。GPT-3、Llama 这类模型的 MFU 能到 50% 左右已经算调得很不错的,很多简单粗暴的脚本只有 20%、30%。
注意,MFU 和显存占用毫无关系。你把 batch 从 2 提到 8,显存涨一倍,但如果序列长度和 Batch 让矩阵乘形状变得规整,MFU 会涨;反过来你用梯度检查点把显存降下来,MFU 也很可能因为重计算和调度开销而跌。目标应该是让 MFU 更高,而不是让显存更低。
4. 常见的“显存省了、算力闲了”场景拆解
下面这几个场景都是我在实际工程里遇到过的,也是“显存优化特别成功,但 GPU 吃不满”的高发区。
4.1 梯度检查点:用算力换显存,换完调度乱了
梯度检查点又叫 activation checkpointing,做法是前向传播时不保存中间激活值,等反向传播需要时重新跑一段前向把激活值算回来。显存确实省了,代价是把前向的一部分计算重复做了一遍,通常带来 30% 以上的额外计算量。
但真正隐蔽的坑是:额外计算本身会增加 kernel 数量,同时因为不保存中间结果,反向传播里的算子依赖链条变长,GPU 的调度器更难把算子藏住延迟。我实际测过一个 13B 模型,开满全层梯度检查点之后,显存从 38GB 降到 16GB,但单步耗时反而涨了 1.6 倍,SM 活动率从 83% 掉到 60% 左右。也就是说,不是单纯“多算了 30%”,而是调度气泡变多,GPU 有一截时间在空等。
实操建议是:不要无脑开满,只挑激活值占大头的那几类模块开,比如注意力层到 MLP 层之间的边界、长序列下的 attention map。不少框架里可以自定义 checkpoint 策略,粒度细一点,你会发现显存能省五六成,MFU 损失能控制在 10% 以内。
4.2 CPU Offload / NVMe Offload:显存空了,PCIe 成了新瓶颈
ZeRO-Offload 的思想是把优化器状态和部分参数放到 CPU 内存,显卡只保留当前计算要用的切片。单卡显存容量确实不需要很大了,但每个训练 step 都要把参数从 CPU 搬到 GPU,搬完算完还得把梯度搬回去。PCIe 带宽相比显存带宽差一个数量级,于是 GPU 变成了“等外卖”的状态:SM 空转等数据,时钟降频,利用率惨不忍睹。
我记得有次跑一个 30B 模型单机双卡 offload 实验,显存占用每张卡只有 20GB 出头,但 throughput 比预期低了一半还多。后来一查时间轴,GPU 上有大量非常宽的空白气泡,全是 PCIe 搬运造成的等待。要救回来,靠的是通信与计算重叠,比如 ZeRO-Offload 配合分片参数预取,让当前层计算的同时预取下一层参数。但说实话,只要条件允许,Offload 就是最后手段,它不是用来“提速”的,只是让你“跑得动”而已。
4.3 梯度累积:小 batch 省显存,但也把小 kernel 暴露出来了
梯度累积是省显存最粗暴有效的方式之一:显存不够,就把一个大的 batch 拆成多个 micro batch 算,算完把梯度累加在一起再做一次优化器更新。问题在于,micro batch 如果压得太小,比如 batch size 从 32 降到 2,每个 step 里所有矩阵乘的维度都跟着缩小,GPU 上有几千个执行单元,喂进去的矩阵太小,根本填不饱。
另外还有 kernel launch 的开销。PyTorch 每执行一个算子都要经过 CPU 下发指令、GPU 排队执行的过程,单个 kernel 的开销通常在几微秒到几十微秒。当你 batch 小、算子又多时,CPU 下发代码和 GPU 空转的时间占比会明显上升。你以为“多几步而已”,实际是每一步都在用很小的工作量去填同样大的发射开销。
我自己的经验是,梯度累积次数最好不要超过 8 到 16 次。也就是说,如果显存只够 micro batch 为 2,而全局 batch 需要 64,你其实更该考虑调大 micro batch 而不是硬攒 32 个 2 的累计。实在不行,再看能不能通过 vLLM 之类的推理优化思路反过来改训练侧的前向结构——当然这是后话。
4.4 LoRA 微调:参数少不代表计算量少
很多人对 LoRA 有个误解:觉得参数量小,计算量也小。实际上 LoRA 冻结原模型,不代表反向传播不计算原模型的梯度。为更新 LoRA 矩阵,你仍然需要在反向过程中计算“对输入特征的梯度”,这个梯度的形状由隐藏层维度决定,和 LoRA rank 关系不大。所以训练过程的大头计算仍然是那一堆巨大的线性层矩阵乘,LoRA 省下的主要是优化器状态和需要保存的梯度量。
更要命的是,LoRA 层本身往往不是计算热点,反而是显存和带宽热点。因为 rank 通常只有 16 或 64,一个[seq_len, hidden] @ [hidden, rank]的矩阵乘的算术强度很低,很容易变成 memory-bound,也就是“读了大量数据、算不了几笔”。这些 LoRA 小矩阵乘穿插在大模型原始层之间,往往把 GPU 的吞吐节奏搅乱。解决办法不是去掉 LoRA,而是把多个线性层里的小矩阵乘做 kernel 融合,或者用打包技巧让 token 数量变大,同时配合偏置项让矩阵形状对齐。
4.5 MoE 架构:省了专家显存,却让通信和负载不均吃掉收益
MoE(专家混合)模型现在越来越多。它天然“省显存”的地方在于,推理和训练时不需要把所有专家都激活,可以按 token 把计算路由到部分专家。但是省了这部分显存之后,GPU 吃不满的情况往往更严重:一是 all-to-all token dispatch 通信开销很大,GPU 在通信期间基本不干正事;二是不同 token 组落入不同专家的数量可能极不均衡,某个专家排队排满了,其他专家已经算完闲着。
如果做的是 MoE 分布式训练,只看单卡显存占用完全没意义,得看全局的负载分布和通信等待时间。调整 capacity factor、对 token 做更均匀的分组、甚至用 hybrid 专家并行策略,都可能比继续抠显存有效得多。
下面这个表格可以快速对照,看看自己是不是踩了同一个坑:
| 省显存手段 | 主要省的是什么 | 容易变成的新瓶颈 | 最直接的判断方法 |
|---|---|---|---|
| 梯度检查点 | 激活值 | 重计算开销、调度空泡 | Profiler 里看 forward 重算占比 |
| CPU Offload | 参数/优化器状态 | PCIe 带宽,CPU 内存带宽 | 时间轴里出现超大 GPU idle gap |
| 梯度累积 | 激活值(batch减小) | kernel launch、小矩阵乘 | 看 step 里 kernel 平均耗时占比 |
| LoRA/QLoRA | 优化器状态、梯度 | 小矩阵乘的带宽延迟 | 用 Nsight Compute 看某个 LoRA kernel 的 DRAM 占比 |
| MoE | 专家参数/激活 | all-to-all 通信、负载不均 | 看多卡通信时间占比和各卡 SM active 差异 |
5. 用 Profiler 把真正的瓶颈揪出来
5.1 不要猜,先跑 PyTorch Profiler 看全景
遇到 GPU 吃不满,第一件事不是改代码,而是抓数据。PyTorch 自带 profiler 足够做初步判断。最好的做法是让一轮训练跑短一点,比如只跑 5 到 10 个 step,加上 profiling,输出一张按 CUDA time 排序的 kernel 表。
from torch.profiler import profile, ProfilerActivity with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True) as prof: for step in range(5): train_one_step() prof.step() print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=30))看这张表的时候,重点关注三个东西:哪个 kernel 占的 CUDA time 最多;这些 kernel 是偏计算型还是偏访存型;kernel 的整体数量是不是多到离谱。如果排前面的都是很小很小的算子,比如逐元素操作、reshape、normalization 之类的,那八成是算子太碎、显存带宽不够;如果排名靠前的都是大矩阵乘,那瓶颈更可能在数据供给或通信上。
5.2 用 Nsight Systems 看时间轴上的“空白”
Profiler 表格看分布,时间轴看节奏。nsys profile可以清楚看到每个 GPU kernel 在哪段时间执行、CPU 侧在干什么、通信和 kernel 之间有没有巨大的空隙。
nsys profile --trace=cuda,nvtx,osrt -o my_profile python train.py打开时间轴之后,先扫一眼每两个 kernel 之间的空白长度。如果空白动不动就是几十微秒甚至几百微秒,说明 GPU 经常“无活可干”,问题大概率出在数据加载、CPU 下发慢、通信等待或者频繁的 kernel launch。一个容易忽略的点是:Python 侧的数据预处理如果有同步操作,比如频繁.item()、.cpu()、.numpy(),会强制同步 CPU 和 GPU,把流水线彻底打断。训练吞吐低但显存看起来不紧张时,我会第一个去搜代码里有没有这些恶魔级调用。
5.3 用 Nsight Compute 深挖单个热点 kernel
记住一个原则:先优化占比最大的 kernel。找到热点 kernel 之后,再用ncu对单个 kernel 做深挖:
ncu --kernel-name --set full python train.py重点看几个计数器:DRAM 吞吐利用率、SM busy、Tensor Core active、warp stall 原因。我见过很典型的情况:某个 attention 相关的 kernel,DRAM 吞吐到了 80% 以上,但 SM 算力只用了 15%,说明这个 kernel 压根没有在做足够多的运算,纯粹在搬数据。这种时候你去叠更高级的显存节省技巧没意义,正确方向是减少数据搬移量,比如引入 FlashAttention 或者把多个小 kernel 融合成一个。
5.4 一个实操案例:7B LoRA 微调为什么 SM 只有 40%
上个案例具体讲讲。我这边有个 7B LoRA 训练任务,序列长度 4096,micro batch 为 2,梯度累积 8 步,开了部分层梯度检查点。显存占用很健康,只有 15GB,可 SM Active 只有 40%,单 step 耗时 3.2 秒。
用 PyTorch Profiler 先看,排名前几的 kernel 大多是rms_norm、rope、matrix multiplication。点开时间轴发现,每个rms_norm和rope的执行时间只有 5 到 10 微秒,但数量几百个,CPU 下发一个 kernel 的延迟几乎和 kernel 本身执行时间持平。再看 Nsight Compute,那些大 matmul 的 Tensor Core 利用率不低,但整体被大量小 kernel 和 kernel 间空隙稀释了。
当时做了两件事就救回来了。第一,把torch.compile打开,自动把 RMSNorm 和 RoPE 等小算子合并,kernel 数量直接砍掉 40%。第二,把序列 packing 用起来,让每个训练样本尽量填满序列长度,减少 padding token 带来的无效计算。改完之后显存还是 15GB,但 SM Active 到了 72%,单 step 耗时降到 2.1 秒。整轮调优里,我一行显存代码都没改。
6. 把 GPU 真正“喂饱”的实用调优清单
6.1 先调 micro batch,再做梯度累积
显存有余量的时候,第一优先是增大 micro batch,因为大矩阵乘更容易吃满 Tensor Core。经验是,找一个目标隐藏层维度,比如 4096,让 batch size 乘序列长度至少超过 1024,然后在 Profiler 里确认大 matmul kernel 的 Tensor Core 利用率超过 60%。
如果显存不允许,再从 global batch 里拆梯度累积,但累积次数别过头。我的压舱配置是:micro batch 先往上涨,涨到显存占用量到 85% 附近,然后剩下的实际 batch 需求靠梯度累积补齐。记住一句话:梯度累积是填充项,不是首选优化项。
6.2 Kernel Fusion 和 FlashAttention 永远是第一优先级
FlashAttention 这种 IO-aware attention 本质上是把多个“读写显存”的小操作融合成一个“大 kernel”,大幅减少 HBM 访问次数。它能同时做到省显存和提算力利用率,因为它对付的正是 bandwidth-bound 问题。同理,torch.compile能把大量小算子做图融合,带来两个好处:kernel 数量减少、中间变量不落显存。这两个特性叠加,对“显存省了但 GPU 吃不满”的场景几乎一击必中。
6.3 数据管线不能成为隐藏的 GPU 空档
我在好几次 profiling 里发现,GPU 利用率低根本不是因为模型太小,而是 DataLoader 来不及送数据。尤其当你用大模型、长序列的时候,CPU 侧做 tokenize、padding、mask 生成可能比 GPU 算一步还慢。推荐做法:用persistent_workers=True,加num_workers,把预处理尽可能放到dataset外部做批量缓存,另外检查有没有在训练循环里做不必要的.to(device)同步或 Python 层循环。
6.4 分布式训练:通信和计算重叠才是关键
多卡场景下,显存更省了,但 GPU 吃不满的头号原因是梯度同步等待。反向传播算完一层后发起的 allreduce 如果没和下一层计算重叠,每张卡都会等别人。有几个成熟的解决思路:设置梯度分桶减少通信次数;用更快的通信后端把梯度聚合打散;ZeRO-3 场景下把参数预取和计算重叠。开启通信重叠后,多卡训练的 MFU 通常能从 30% 提到 50% 以上。
6.5 别忘了温度和功耗
最后说一个最容易被忽略、也最现实的问题:GPU 过热或者功耗限制导致降频。显存占用不高、SM 看起来也 busy,但时钟频率被压到一半,计算速度自然上不去。跑长任务前先看看:
nvidia-smi -q -d CLOCK nvidia-smi -q -d POWER如果发现 “clocks_event_reasons” 里有hw_slowdown或者sw_thermal_slowdown,不要急着调模型,先把散热、功耗和机房环境处理好。另外,把 GPU lock clock 到稳定值,能显著减少“利用率波动”的观感。
7. 常见问题快查表
| 现象 | 优先怀疑方向 | 快速判断方法 |
|---|---|---|
| 显存空 20GB,GPU-Util 只有 30% | 数据供给慢 / 算子太碎 / 带宽受限 | 用 profiler 看 kernel 间隙和单 kernel DRAM 利用率 |
| 开了梯度检查点后 step 变慢 1.5 倍 | 重计算量偏大 / 调度气泡 | 对比开/关检查点时同一步的 kernel 数量和 forward 占比 |
| micro batch 从 8 降到 2 后吞吐掉了一半 | kernel launch 开销 + 小矩阵乘 | 统计每一步的 kernel 总数和平均执行时长 |
| LoRA 训练时显存很低但吞吐很差 | LoRA 小矩阵乘 bandwidth-bound | 用 Nsight Compute 看 LoRA 相关 kernel 的算术强度 |
| 多卡训练某张卡 SM Active 明显偏低 | 通信等待 / 负载不均 | nsys 里看通信段和 kernel 的空隙 |
| 显存明明够用但训练突然变慢 | 温度降频 / 功耗限制 | nvidia-smi -q -d CLOCK 查降频原因 |
排查顺序我一般固定如下:先确认有没有同步点或 CPU 侧瓶颈,再看大 kernel 的 Tensor Core 利用率,再算一下热点 kernel 的算术强度,最后才回头去动显存相关的配置。很多人一上来就把显存压到极限,跟没找准病灶就急着开刀一样,结果往往是伤上加伤。
我个人现在接手一套 LLM 训练代码,第一反应不是数显存还有多少空,而是跑一个尽可能小的 profiling 热身任务,先把“谁在让 GPU 等”这个问题回答掉。省显存和吃满 GPU 本来就是两场仗,你要先分析清楚自己的训练过程是卡在仓库不够大,还是卡在传送带太慢,又或是工人本身没活干。把这几个变量分开看,很多看似玄学的问题,其实都是一道很直白的算术题。