这期LLM Training Lab想聊一个特别常见的现象:显存终于省下来了,GPU利用率却上不去。
背景是前段时间我拿手头一张8GB显存的卡试做一次大模型微调。为了把模型塞进去,我把能开的“省显存大招”都开了:4bit量化、gradient checkpointing、梯度累积、混合精度,最后显存占用稳定在6GB左右,模型总算跑起来了。按常理说,显存还剩了2GB,应该有余力把batch调大,训练速度也应该有明显提升。可实际看nvidia-smi,GPU-Util长期在20%~40%之间跳,温度、功耗也都不高,显卡几乎是在“摸鱼”。
这期就专门拆一下:为什么显存省下来了,GPU还是吃不满?省显存和利用算力这两件事,到底在哪里脱节了。既然是Lab系列,我把排查思路和操作记录完整写下来,适合正在做LLM训练/微调、被GPU利用率低困扰的读者参考。
1. 先纠正一个误区:显存占用和GPU利用率是两套指标
1.1 显存是仓库,计算单元才是工厂
显存(VRAM)本质上是存放数据的仓库:模型权重、梯度、优化器状态、激活值、CUDA上下文都要占地方。LLM训练时显存开销可以粗略估算:
- 模型参数:bf16时每参数2字节。
- 梯度:通常和参数同精度,每参数2字节。
- 优化器状态:以AdamW为例,一般是fp32的一阶矩和二阶矩,每参数4B加4B。
- 主权重(master weight):有时会保留fp32副本,每参数4B。
- 激活值:这部分往往才是大头,与batch size、序列长度、模型层数直接相关。
所以全参数训练一个7B模型,光是参数、梯度、优化器状态加起来就要大几十GB,8GB卡根本不可能。QLoRA的做法是把模型量化到4bit,只训练少量adapter参数,配合gradient checkpointing牺牲重计算换显存,才能把显存压到6GB这个级别。这里的关键是:省显存的思路大多数是“拿时间换空间”或“拿精度换空间”,而不是让GPU跑得更快。
1.2 GPU-Util到底显示的是什么
nvidia-smi里那个百分比GPU-Util,很多人以为是“GPU算力利用率”,其实它只是“在采样窗口内,SM上有kernel在执行的时间占比”。也就是说,只要有一个很小的kernel在跑,GPU-Util就可能飙到100%;反过来,如果kernel运行间隙很长,即便每个kernel本身已经把SM算力打满,从nvidia-smi看整体利用率也会掉下来。
真实地看GPU忙不忙,应该组合看三个维度:GPU-Util、SM内部的活动(比如nvidia-smi dmon里的SM字段)、以及显存带宽占用。单看GPU-Util一个数字,经常会被误导。
明白了这两点,就能理解为什么“显存省下来了”和“GPU吃不满”常常同时出现。显存压下来之后,你还必须处理另一条链路:数据能不能喂饱、计算kernel够不够大、算子之间有没有间隔、通信要不要等待。下面按最常见的瓶颈逐个拆。
2. 显存是省了,GPU却在摸鱼的六个常见原因
2.1 CPU产数据的速度跟不上GPU消化的速度
这是“显存省了但GPU利用率低”场景里最常见的真凶,我碰到的十次里有八次是这个问题。
把数据管线想成流水线:GPU是高速加工设备,CPU是原料供应商。你用gradient checkpointing省下来的显存,只是让模型能塞进卡里,并没有解决“CPU一秒钟只能生产200个token、GPU一秒钟能吃2000个token”的矛盾。当CPU还在做tokenize、padding、collate的时候,GPU只能干等。
我当时用DataLoader默认配置(num_workers=0),在主进程里实时处理文本。模型前向反向一次只要0.2秒,但CPU准备下一个batch要花0.6秒。于是nvidia-smi里GPU-Util就呈现出典型的“每隔几百毫秒跳一下”的锯齿形曲线。排查方式很简单:把profile放到训练循环里看CPU time和GPU time的分布,或者先跑一个纯CPU的数据集迭代计时,对比GPU上的step耗时。解决方法也直接:num_workers开起来、pin_memory=True、prefetch_factor调大,如果有条件把预处理结果缓存成二进制文件,训练时不再实时tokenize。
2.2 Batch太小,kernel“喂不饱”GPU
省显存最常见的副作用是batch变小。很多人为了把模型塞进显存,micro-batch从16一路降到4甚至1。显存确实下来了,但每个CUDA kernel的并行规模也跟着变小了。
GPU上跑一个矩阵乘法,本质是把一个大矩阵切成很多小块分给不同的SM执行。如果batch=1、sequence length又不长,矩阵乘法的规模可能只有768×768之类。这种尺寸的GEMM对几十个SM的卡来说太小了,很多SM根本分不到活干,空在那里等。
更重要的是,batch太小会导致kernel之间出现大量空隙。kernel启动、同步、内存搬运的时间是固定的,计算时间却变小了,于是GPU大部分时间在“排队”而不是“干活”。梯度累积可以在不大幅增加显存的前提下等效地增大batch,但要注意梯度累积不会让单个kernel变大,真正的计算kernel还是按照micro-batch的尺寸来切,所以它对提高kernel效率帮助有限。要真正把kernel喂饱,还是得在显存允许范围内把micro-batch调大,同时配合gradient checkpointing去压低激活值峰值。
2.3 小算子太多,启动开销比干活还多
再往下看profile,你会发现LLM训练里充斥着成千上万个小kernel:LayerNorm、Dropout、残差相加、GELU、Sigmoid、Softmax……这些算子每个的计算量都很小,但都要经历“CPU发命令、GPU执行、CPU再发下一条命令”的完整流程。一次kernel launch的开销大约是3~10微秒,训练一个step如果有几千个kernel,启动开销加起来就是几十毫秒。
这事看着不起眼,在显存紧张时会被进一步放大。比如你用gradient checkpointing,每个被checkpoint的层在前向时省下了激活值,反向时会重新算一次前向,等价于把模型整体前向计算量提高了30%~50%。多出来的这些计算,就是由大量小kernel组成的,它们让GPU“看起来很忙”,但真正用来推进梯度的“有效算力”并没有增加多少,训练总时间反而可能更长。
所以,省显存不是免费的。在使用省显存手段前,先搞清楚自己是不是真的需要。如果显存还够用,就不要贸然开gradient checkpointing;如果开了,就要接受它带来的额外kernel开销,并想办法用torch.compile或flash attention这类融合算子把这些零碎kernel合并掉。
2.4 多卡训练时,梯度同步把时间吃掉了
如果你不是单卡训练,而是用DDP跑多卡,那还需要把通信时间纳入考量。DDP的流程是:每张卡各自前向反向算出梯度,然后在所有卡之间做一次AllReduce把梯度同步,再各自更新参数。AllReduce的通信量约等于模型参数总量,和batch大小没有关系。
换句话说,你把batch从16降到4,计算时间可能缩到原来的四分之一,但通信时间一点没少。于是当计算速度变快时,通信时间在总时间里的占比反而高了。这时候GPU-Util会呈现一种奇怪的现象:前向反向时GPU很忙,AllReduce时GPU闲置,整体利用率可能只有50%左右。
解决思路:降低梯度同步频率。比如先做几个micro-batch的梯度累积,再统一通讯一次;或者用Deepspeed/FSDP这类框架,把通信拆成更细粒度的片段,与前向反向重叠执行。FSDP在反向过程中逐层做ReduceScatter,可以把一部分通信时间“藏”在计算后面,让GPU不至于干等。单卡阶段理解这个瓶颈,后面上多卡时能省很多调试时间。
2.5 显存带宽成了新瓶颈
显存省下来了,不代表数据搬运量真的降下来了。很多LLM算子其实不是“算不动”,而是“搬不动”,典型的比如LayerNorm、Softmax、flash attention里的部分路径。它们对每个元素只做几回合运算,却要把整个矩阵从HBM搬到SM里。HBM带宽是有限的,一张游戏卡的显存带宽大概两三百GB/s,数据中心卡在2TB/s这个量级。当计算密度(每字节数据对应的计算次数)很低时,GPU的流处理器干不了多少活,大部分时间在等数据从显存搬过来。
这就是为什么有时候你看到GPU-Util是100%,实际吞吐量却依然上不去——SM可能被内存等待拖住,但nvidia-smi看到的还是“有kernel在执行”。这类瓶颈普通工具看不出来,需要用ncu(Nsight Compute)去对比“内存吞吐”和“计算吞吐”两个指标。
解决办法就是做算子融合,把多次往返显存的操作合并成一次。flash attention的核心工作之一就是让attention计算不把中间的注意力矩阵写回显存,而是留在SM的寄存器里继续算,这能极大地缓解带宽瓶颈。现代LLM训练里,用融合版的attention、融合版的LayerNorm,收益往往比单纯加大batch更明显。
2.6 并行策略产生的气泡与同步等待
再往上层看,如果你的训练方案里有模型并行,还会遇到“气泡”问题。比如pipeline并行,把模型按层切到不同GPU上,每个GPU只负责其中几层。理想情况是所有GPU同时工作,但现实是前一个Stage的数据没算完,后一个Stage只能空等。即使加了micro-batch去填气泡,气泡也不可能完全消除。
类似的现象也出现在异构环境里。比如笔记本上同时有Intel核显和NVIDIA独显的环境,PyTorch默认跑在NVIDIA卡上,但如果某些算子被坏习惯放到了CPU或其他设备上(比如把数据搬回CPU再搬回来),就会出现莫名其妙的同步等待。在混合精度训练里,一次不经意的.cpu()或.item()调用,在Python代码里可能只是打印一个loss,却会导致整个CUDA流强制同步,GPU所有计算停下来等CPU,利用率瞬间掉到0。
这六个问题经常混在一起出现,所以排查时不要只盯一个指标,要把眼光放在训练循环的整体时间线上。
3. 定位瓶颈的工具箱:别猜,用数据说话
3.1 用nvidia-smi和nvtop先看个大概
第一步永远是先看实时状态,命令也就两三个:
- nvidia-smi:看显存占用、GPU-Util、功耗、温度。
- nvidia-smi dmon -d 1:以1秒为间隔刷新,能看到SM占用、显存带宽占用等更细的指标,可以发现“GPU-Util高但显存带宽打满”的情况。
- nvtop:如果系统里有,比smi直观很多,像htop一样看GPU各卡状态。
单看GPU-Util一条曲线没有意义,最好同时记录功耗、温度和带宽。如果GPU-Util只有30%但功耗已经拉到额定值,那说明算力本身没有真正占满,可能是kernel太小或者内存等待;如果功耗很低、显存带宽很低、GPU-Util也低,那多半是CPU或通信在拖后腿。功耗可以作为“算力有没有真正干活”的旁证。
3.2 PyTorch Profiler:看清每个算子的耗时
光看全局图还不能定位到具体算子。用PyTorch自带的profiler拿一张算子耗时表最直接:
import torch from torch.profiler import profile, ProfilerActivity model.train() with profile( activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], schedule=torch.profiler.schedule(wait=1, warmup=1, active=3), record_shapes=True, profile_memory=True ) as prof: for step, batch in enumerate(dataloader): loss = model(batch) loss.backward() optimizer.step() prof.step() if step > 5: break print(prof.key_averages().table( sort_by="cuda_time_total", row_limit=30 ))看输出的表格时,我的经验是优先看两类指标:一是Self CUDA Time(算子自己在GPU上的耗时)最高的几个,二是调用次数(# of Calls)特别多的几个。
如果Top耗时几乎被“cudaMemcpyAsync”和“void atomicAdd”这类占满,多半是数据搬运和减小kernel的问题;如果被大量LeakyReLU、MaskedFill这种小算子占满,那就应该上算子融合或torch.compile。还可以用prof.export_chrome_trace导出文件,拖进chrome://tracing去看GPU与CPU的时间线,能一眼看出GPU是不是在等CPU。
3.3 Nsight Systems看全局事件流
如果需要更精细的定位,NVIDIA官方的Nsight Systems是标准答案,命令很简单:
nsys profile -o trace python train.py跑完后用Nsight Systems打开生成的.qdrep文件,时间线上会清晰标出CPU活动、GPU kernel、CUDA API调用、内存拷贝这些事件。这个工具的精髓是看“重叠”情况:理想状态下CPU的活动应该和GPU kernel紧密重叠,CPU提前准备好下一个batch的数据;如果看到GPU kernel之间有大段空白而CPU线程也没在跑,要么是数据没到,要么是同步点在作怪。
我的建议排序是:先nvidia-smi看现象,再用PyTorch Profiler拿到算子级分布,最后用Nsight Systems看整体时间线。不要一上来就上重型工具,先确认问题在哪一层。
4. 对症下药:把GPU利用率拉起来的具体操作
4.1 数据管线改造:从源头保证GPU“断不了粮”
数据管线是性价比最高的优化点,大部分低利用率问题在这一步就能解决。
第一刀:预处理缓存放磁盘。文本类数据实时tokenize非常慢,T5-style的span corruption这类预处理在CPU上做起来更是灾难。把tokenize结果以二进制(比如.npy或webdataset格式)预先跑一遍,训练时直接读token id,这一步往往能带来几倍吞吐提升。
第二刀:DataLoader参数拉满。num_workers设成4~8(不要超过CPU物理核数),prefetch_factor设为2或更高,pin_memory=True。pin_memory的作用是让数据页锁定在内存里,CPU到GPU拷贝时可以走DMA通道,省掉一次内存页复制。注意,num_workers太高反而会因为进程间通信抢CPU,需要实测。
第三刀:把collate_fn里的Python逻辑降到最低。padding、mask构造最好在预处理阶段完成,而不是每个batch实时做。collate_fn里一旦出现python for循环或list comprehension,时间就会不可控地变长。
我当时把tokenize缓存后,GPU-Util从35%直接升到70%。这个提升几乎不花钱,却常常被忽略。
4.2 在显存限制下重新设计batch与梯度累积
batch size调整要分两个层次想清楚:名义batch size决定梯度和收敛质量,micro-batch size决定kernel效率和显存占用。
梯度累积的价值是让“名义batch”变大,但它不会消灭micro-batch kernel太小的问题。所以在单卡显存紧张时,正确的姿势是:
- 先把micro-batch调到显存能承受的上限,比如原来batch=8会爆显存,那就用batch=4。
- 如果batch=4时GPU-Util依然不高,再判断是kernel太小的问题,还是CPU喂不上的问题。
- 只有梯度累积解决了“多步更新一次”的需求之后,才把accumulation steps设为一个合适的数字。
另外注意,梯度累积积累的是梯度,不是前向结果。每个micro-batch都要完整跑一次前向反向,所以GPU的计算总量并没有减少。某些不成熟的实现会在每个micro-batch结束后调用optimizer.zero_grad()和loss.backward(),这些操作频率过高也会带来额外同步。正确做法是只在真正需要更新的时候做zero_grad和step。
如果显存还有余量,最优先做的永远是直接增大micro-batch,因为只要kernel变大,GPU-Util大概率就会上去。这也是为什么很多人说“先用大batch跑通,再谈省显存”。
4.3 用fusion和torch.compile把碎片kernel合并
省显存带来的额外小kernel,可以通过算子融合“还回去”。
torch.compile是上线最快的一招。对训练脚本改动极小,只需要在model外面包一层:
model = torch.compile(model, mode="reduce-overhead")mode="reduce-overhead"会把小kernel合并成更大的kernel,同时减少python解释开销。实测下来,一些以LayerNorm、激活函数居多的模型能拿到20%~30%的加速。缺点是编译阶段会占一些显存和CPU时间,第一次运行可能会等比较久,需要先跑几个warmup。
再进阶一步是替换手写的高开销算子。比如attention部分不要自己for循环写softmax和矩阵乘,直接用flash-attention的融合实现;LayerNorm如果用的是PyTorch原生,可以替换成apex的FusedLayerNorm,虽然提升幅度看模型大小,但在kernel很碎的场景下收益明显。
最后要提醒的是:torch.compile和gradient checkpointing是可以叠用的,但你得先跑一次profile确认新增的kernel overhead是不是已经变小了。如果编译后依然是碎片kernel主导,那问题可能出在数据管线上。
4.4 多卡场景下的通信与重叠优化
多卡DDP时,通信占比可以通过几个手段降低:
第一,梯度累积但不立即通信。把梯度累积步数调大,让多步梯度攒起来再做一次AllReduce。例如accumulation_steps=4,那么通信次数直接变成原来的四分之一。代价是优化器更新的频率降低,收敛行为会略有变化,需要照着等效batch的直觉来调学习率。
第二,使用通信算子与计算的重叠。DeepSpeed的ZeRO-1/ZeRO-2、FSDP都把梯度分片和参数分发设计成异步的,让通信和计算交错进行。如果现在还是裸写DDP,先换成FSDP再对比一次,通常通信占比能下降不少。注意FSDP默认会把参数切成很多分片,每个分片都要做all-gather,如果模型层数很多且分片粒度太小,通信次数反而会增多,所以FSDP的sharding策略和bucket size要调。
第三,梯度压缩。通信带宽不足时,可以用1-bit Adam、TopK梯度压缩这类方案,业界有验证在特定模型上可以保持收敛。但对新手来说不建议优先尝试,因为调参复杂度高。
多GPU利用率不高很多时候不是卡的问题,是你在每个step都在同步、都在等待别人。先让单卡利用率跑起来,再谈多卡扩展。
5. 常见问题与排查技巧实录
5.1 显存占用很低,GPU-Util却是0%
遇到过好几次这种场景:loss在下降,显存也稳定,但GPU-Util一直显示0%。排查下来通常有两种可能:
- 训练循环里有.cpu()或.item()导致的设备同步。比如每个step把loss打印出来,或者每个step做一次指标计算。这类操作会强制CPU和GPU同步,一旦同步点密集,GPU就一直在等CPU,利用率自然为0。
- 算子被放到CPU上执行了。检查一下模型里有没有混用numpy、有没有某些算子因为dtype不支持被派发到CPU。用torch.profiler看一下CPU算子占比就能确认。
解决方式:loss统计改成每N步打印一次,用平均值而不是单值;检查模型内部有没有隐性dtype转换。
5.2 GPU-Util在0和100之间剧烈跳动
这种锯齿形曲线大多是数据供给不稳定。先看是不是磁盘IO太慢(比如从网络盘读数据)、是不是每个epoch都在做全量shuffle导致缓存失效、是不是DataLoader的num_workers太少、是不是实时decoding图像或音频,而不是预处理好的token。
如果排除了数据管线,再看训练脚本里有没有周期性操作:checkpoint保存、验证集评估、梯度累积清零。保存checkpoint时把模型参数搬到CPU序列化,大模型一次可能卡住几秒。优化思路是异步保存:拷贝参数到CPU内存后,让GPU继续算,由后台线程负责写磁盘。
5.3 判断该不该上gradient checkpointing
简单给个参考:
| 场景 | 建议 |
|---|---|
| 显存充足,batch可以调大 | 不要开checkpointing,直接大batch |
| 显存刚好够,但batch很小 | 先试checkpointing,配合增加batch |
| 开了checkpointing后GPU-Util反而更低 | 大概率是数据管线或kernel融合问题 |
| 显存仍然爆 | 才考虑量化、LoRA这类更重的手段 |
这里有一条我的原则:显存省下来之后,一定要能看到“吞吐量上升”才算是有效省显存。判断标准很简单:同样的数据量,每秒处理的样本数(samples/s)有没有变多。如果显存省了但samples/s反而跌了,那就是省错了地方。
5.4 我的检查清单
每次新跑一个训练任务,我都会花十分钟把下面的清单过一遍,省掉不少弯路:
- nvidia-smi观察GPU-Util、功耗、显存带宽,先确认现象。
- 用PyTorch Profiler跑3~5个step,看Top耗时算子和调用次数。
- 检查训练循环里有没有.item()、.cpu()这样的隐性同步。
- 确认DataLoader的num_workers、pin_memory、prefetch参数合理。
- 确认micro-batch在显存上限的80%左右,而不是保守地只用了50%。
- 如果开了gradient checkpointing,用profile确认重计算没有带来过多kernel开销。
- 多卡时统计通信时间占比,看all-reduce是否在高频触发。
系列写到第10期,我自己的感触挺深:大模型训练的调优,本质上就是在显存、算力、数据供给之间找平衡,而多数新手的问题不是不会调,而是太相信单一指标。显存够用只是底线,GPU利用率只是一个中间指标,真正该盯的是每秒能处理多少个样本、每个step要多久。
如果你也在做类似的事,我建议把这一套排查流程、常用配置命令、踩过的坑都记到自己的笔记或者顺手建一个小型的本地知识库里去。下次换模型、换显卡、换数据集时,先翻记录再动手,比从零开始猜要快得多。先把一个任务跑成“显存没问题、利用率够高、吞吐稳定”,再去折腾更花哨的优化手段,这是最稳的路。