尧图精选

显存省了GPU却吃不满?LLM训练GPU利用率低下的根因与优化

🕒 发布时间:2026/10/2 15:23:28 📁 来源:尧图网络
1. 显存和 GPU 利用率之间的真实关系1.1 一个让很多人困惑的现象显存省下来了GPU 还是吃不满——这个问题我在带新人的时候几乎每个月都会被问到。场景通常是这样的你拿到一张 8GB 或 12GB 显存的卡模型加载进去之后发现显存占用只有 60% 甚至更低心里一阵窃喜觉得还有余量可以加大 batch size。结果把 batch size 翻倍之后训练速度几乎没变GPU 利用率nvidia-smi里的Volatile GPU-Util依然在 30% 到 50% 之间晃荡。再翻倍显存快满了速度还是没起来。这时候很多人会开始怀疑人生是不是 PyTorch 没装好是不是 CUDA 版本不对是不是这张卡本身就不行先给一个结论让你安心绝大多数情况下这不是环境问题而是数据供给和计算调度的问题。显存只是“仓库”GPU 计算单元是“流水线”。仓库空着一大半不代表流水线就能跑满——流水线要等原料送过来原料送不过来仓库再大也没用。1.2 显存、算力、带宽是三件不同的事要理解这个问题得先把三个概念拆开显存容量决定你能装多大的模型、多大的 batch、多长的序列。它是个“空间”指标。显存带宽决定数据在显存和计算单元之间搬运的速度。它是个“搬运”指标单位是 GB/s。计算吞吐FLOPS决定 GPU 每秒能做多少次浮点运算。它是个“加工”指标。训练一个 LLM 的每一步本质上是从显存读数据 → 送进计算单元算 → 把结果写回显存。如果计算单元算得飞快但数据搬运跟不上计算单元就会空转等待这就是所谓的memory-bound访存受限。反过来如果数据搬运很快但计算单元算不过来就是compute-bound计算受限。大模型训练里很多操作其实是 memory-bound 的比如 LayerNorm、Softmax、逐元素的激活函数、优化器更新。真正 compute-bound 的是矩阵乘法GEMM。所以你会看到一个反直觉的现象显存省下来了但如果省下来的是“计算所需的数据搬运量”GPU 反而更吃不满。1.3 为什么“省显存”常常和“吃不满 GPU”同时出现省显存的手段很多都会间接降低 GPU 的计算密度。举几个最常见的梯度累积Gradient Accumulation把大 batch 拆成多个小 batch 分步算。显存省了但每一步的计算量变小了kernel 启动开销占比上升GPU 利用率下降。梯度检查点Gradient Checkpointing用计算换显存前向要多算一遍。显存省了但总计算量增加单步时间变长如果数据加载跟不上利用率照样上不去。混合精度 量化把 FP32 换成 FP16/BF16 甚至 INT8。显存省了但低精度运算在某些卡上吞吐并不线性提升而且反量化本身也要开销。ZeRO / FSDP 分片把优化器状态、梯度、参数切分到多卡。单卡显存省了但卡间通信量暴增通信等待期间 GPU 就是闲着的。所以“显存省下来了”和“GPU 吃不满”经常是同一套操作的两种表现它们不是矛盾而是因果。2. 定位瓶颈先搞清楚 GPU 到底在等什么2.1 用 nvidia-smi 看利用率只是第一步nvidia-smi的GPU-Util是个采样值它表示在采样周期内有百分之多少的时间 GPU 上至少有一个 kernel 在跑。注意是“至少有一个 kernel 在跑”不是“计算单元被充分利用”。一个只用了 1% 计算单元的 kernel 也会让这个数字显示为 100%。所以看到GPU-Util低说明 GPU 有大量时间处于空闲但看到GPU-Util高也不代表计算单元被榨干了。要看得更细得用nvidia-smi dmon或者nvidia-smi --query-gpuutilization.gpu,utilization.memory,power.draw,clocks.sm --formatcsv -l 1持续观察。我一般会同时盯这几个指标指标含义偏低说明什么utilization.gpu计算单元活跃时间占比GPU 在等数据或等调度utilization.memory显存控制器活跃时间占比显存带宽没吃满可能是计算受限或数据没到位power.draw实时功耗远低于 TDP说明 GPU 没在全力干活clocks.smSM 核心频率降频了可能是功耗墙或温度墙clocks.mem显存频率显存降频带宽直接打折如果power.draw只有 TDP 的一半clocks.sm也没跑满那基本可以确定 GPU 在“摸鱼”。2.2 用 PyTorch Profiler 找到真正的空转点nvidia-smi只能告诉你“GPU 闲”不能告诉你“为什么闲”。这时候要上torch.profiler。下面是我常用的一个最小可用模板import torch from torch.profiler import profile, ProfilerActivity, schedule model ... optimizer ... dataloader ... # 预热避免第一次编译和 cudnn benchmark 干扰 for _ in range(3): for batch in dataloader: ... with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], scheduleschedule(wait1, warmup1, active3, repeat1), on_trace_readytorch.profiler.tensorboard_trace_handler(./log), record_shapesTrue, profile_memoryTrue, with_stackFalse, ) as prof: for step, batch in enumerate(dataloader): if step 10: break with prof.step(): outputs model(**batch) loss outputs.loss loss.backward() optimizer.step() optimizer.zero_grad()跑完之后用 TensorBoard 打开./log重点看三样东西CUDA kernel 之间的空隙如果两个 kernel 之间有大段空白说明 CPU 侧调度跟不上或者数据没准备好。cudaMemcpy和cudaMemset的占比如果这两个占比很高说明数据搬运开销大可能是 pinned memory 没用对或者 host-to-device 拷贝太频繁。DataLoader相关的时间如果enumerate(dataloader)本身耗时很长那瓶颈在数据加载不在 GPU。2.3 一个快速判断瓶颈的决策流程我总结了一个不用 profiler 也能快速判断的土办法适合现场排查第一步把DataLoader的num_workers设成 0跑几步看速度。如果速度明显变慢说明数据加载是瓶颈之一。第二步把 batch 里的数据预先放到 GPU 上用一个固定的 tensor 反复喂跳过 DataLoader。如果速度立刻上来了那 100% 是数据供给问题。第三步如果跳过 DataLoader 还是慢把模型换成一个纯矩阵乘法的玩具模型比如几个nn.Linear堆叠看能不能吃满。如果玩具模型能吃满说明你的模型里有大量 memory-bound 的小算子。第四步如果玩具模型也吃不满检查torch.backends.cudnn.benchmark是否开启以及输入尺寸是否是 8 的倍数Tensor Core 对对齐敏感。这套流程走下来基本能在十分钟内定位到问题大类。3. 数据供给最容易被忽视的 GPU 空转元凶3.1 DataLoader 的 num_workers 不是越大越好很多人一听数据加载慢第一反应是把num_workers从 4 调到 16。结果发现速度没变甚至更慢了。原因在于CPU 核心数有限num_workers超过物理核心数之后进程切换开销反而拖慢整体。每个 worker 都要复制一份数据索引数据集特别大的时候worker 启动本身就要好几秒。GIL 和 Python 对象序列化worker 和主进程之间通过队列传数据传的是 Python 对象序列化和反序列化都要时间。我的经验值是num_workers min(8, os.cpu_count() // 2)然后配合persistent_workersTrue和prefetch_factor2。persistent_workers让 worker 在 epoch 之间不销毁重建prefetch_factor让每个 worker 提前准备几个 batch。dataloader DataLoader( dataset, batch_size32, shuffleTrue, num_workers8, pin_memoryTrue, persistent_workersTrue, prefetch_factor2, drop_lastTrue, )pin_memoryTrue这个参数特别关键。它让数据放在页锁定内存pinned memory里host-to-device 拷贝可以用 DMA 直接搬速度比普通内存快好几倍而且不占用 CPU。很多人省显存的时候忘了这个结果 GPU 一直在等拷贝。3.2 数据预处理要尽量前置我见过太多项目在__getitem__里做 resize、normalize、tokenize每个 batch 都要现算。这些操作在 CPU 上做GPU 就在旁边干等。正确的做法是能离线做的就离线做把 tokenize、图像 resize 这些一次性预处理完存成二进制格式比如.npy、.arrow、.bin训练时直接读。必须在线的用 GPU 做比如随机裁剪、随机遮挡这些可以在 GPU 上用torchvision.transforms.v2或者自己写 kernel 做。tokenize 用快速分词器HuggingFace 的tokenizers库Rust 实现比纯 Python 快一个数量级AutoTokenizer.from_pretrained(..., use_fastTrue)默认就是快的。3.3 用 CUDA Stream 让拷贝和计算重叠即使数据准备好了host-to-device 拷贝和计算默认是在同一个 stream 上串行的。PyTorch 提供了torch.cuda.Stream让你把拷贝放到另一个 stream和计算重叠stream torch.cuda.Stream() next_batch None for batch in dataloader: with torch.cuda.stream(stream): batch {k: v.cuda(non_blockingTrue) for k, v in batch.items()} torch.cuda.current_stream().wait_stream(stream) # 此时 batch 已经在 GPU 上可以开始计算 ...non_blockingTrue配合pin_memoryTrue才能真正实现异步拷贝。如果内存没 pinnon_blocking是无效的这一点很多人不知道。4. 计算图与算子层面的优化4.1 小算子太多kernel launch 开销吃掉利用率LLM 里有很多逐元素操作x bias、x * mask、gelu(x)、dropout(x)。每个操作在 PyTorch 里都是一个独立的 CUDA kernel。kernel launch 本身有开销几微秒到几十微秒如果每个 kernel 只算几微秒那 launch 开销占比就很高GPU 大量时间花在“启动下一个 kernel”上。解决办法有三个层次算子融合把多个逐元素操作合并成一个 kernel。PyTorch 2.0 的torch.compile会自动做这件事实测在 LLM 上能提升 20% 到 40% 的吞吐。用 fused optimizer比如apex.optimizers.FusedAdam或者torch.optim.AdamW(fusedTrue)把优化器更新融合成少量 kernel。手写 Triton kernel对特别热点的算子用 Triton 写融合 kernel控制力最强但门槛也最高。# 开启 torch.compile一行代码换 20% 以上吞吐 model torch.compile(model, modemax-autotune)modemax-autotune会让编译器花更多时间搜索最优 kernel 配置适合训练这种长时间运行的任务。第一次编译会慢但之后每一步都快。4.2 矩阵乘法的形状要对齐Tensor Core 对矩阵维度有对齐要求。以 FP16 为例Tensor Core 的 MMA 指令通常要求 K 维度是 8 或 16 的倍数。如果你的 hidden size 是 4096那没问题如果是 4097那最后一个维度会走 fallback 路径速度直接掉一半。所以模型设计时hidden size、intermediate size、vocab size 尽量取 128 的倍数。这不是玄学是硬件对齐要求。我见过一个项目把 vocab size 设成 50257GPT-2 的原始值结果 embedding 层的矩阵乘法一直跑不满改成 50304128 的倍数之后速度提升了 15%。4.3 注意 attention 的实现方式标准的多头注意力实现里softmax(QK^T / sqrt(d)) V会产生一个[batch, heads, seq_len, seq_len]的中间矩阵。seq_len 一长这个矩阵就爆炸而且它是 memory-bound 的。现在主流做法是用FlashAttention它把整个 attention 计算融合成一个 kernel不显式生成那个大矩阵既省显存又快。PyTorch 2.0 之后可以用torch.nn.functional.scaled_dot_product_attention它会自动选择 FlashAttention 或 memory-efficient attention 后端import torch.nn.functional as F # 自动选择最优 attention 后端 out F.scaled_dot_product_attention(q, k, v, attn_maskmask, is_causalTrue)如果你还在手写 attention强烈建议换成这个。实测在 seq_len2048 时FlashAttention 比朴素实现快 2 到 3 倍显存省 5 到 10 倍。5. 并行策略与通信开销5.1 数据并行下的梯度同步是隐形杀手用DistributedDataParallelDDP做数据并行时每个 step 结束都要做一次 all-reduce 同步梯度。如果模型大、卡多、网络慢这个同步时间可能比计算时间还长。这时候 GPU 就是在等通信利用率自然上不去。判断方法很简单在 profiler 里看ncclAllReduce的耗时占比。如果超过 20%说明通信是瓶颈。优化手段梯度分桶gradient bucketingDDP 默认会把梯度分成多个 bucket边算边通信。调大bucket_cap_mb可以让通信更连续但会增加显存峰值。通信与计算重叠用torch.distributed.algorithms.ddp_comm_hooks里的fp16_compress_hook把梯度压缩成 FP16 再通信带宽减半。梯度累积配合 no_sync在累积的前几步用model.no_sync()跳过梯度同步只在最后一步同步通信次数直接除以累积步数。for i, batch in enumerate(dataloader): with model.no_sync() if i % accum_steps ! 0 else contextlib.nullcontext(): loss model(**batch).loss / accum_steps loss.backward() if i % accum_steps 0: optimizer.step() optimizer.zero_grad()5.2 ZeRO 阶段选择要匹配硬件ZeRO 有三个阶段阶段 1 切优化器状态阶段 2 再切梯度阶段 3 再切参数。切得越狠显存省得越多但通信量也越大。在单机多卡、NVLink 互联的场景下阶段 2 通常是性价比最高的。阶段 3 在每层前向和反向都要 all-gather 参数通信频繁如果卡间带宽不够GPU 就会大量时间花在等参数上。我的建议是先用阶段 2显存不够再上阶段 3同时监控通信占比。如果阶段 3 的通信占比超过 30%那说明你的卡间带宽撑不住要么减少卡数要么换更高效的并行方案比如张量并行 流水线并行。5.3 流水线并行的 bubble 问题流水线并行把模型按层切到不同卡上micro-batch 像流水线一样流过。但流水线的启动和排空阶段有些卡是闲着的这就是 bubble。bubble 占比约等于(p - 1) / (m p - 1)其中 p 是流水线阶段数m 是 micro-batch 数。要降低 bubble就得增大 m。但 m 受显存限制micro-batch 越多同时驻留的激活越多。所以流水线并行和显存是矛盾的。实践中p 一般不超过 8m 至少是 p 的 4 倍bubble 才能压到 20% 以下。6. 常见问题速查与避坑清单6.1 问题速查表现象可能原因排查方法解决方向GPU-Util 低power 低数据加载慢跳过 DataLoader 测试加 worker、pin_memory、预处理前置GPU-Util 低power 高通信等待profiler 看 nccl 占比梯度压缩、no_sync、减少卡数GPU-Util 高但速度慢小算子多profiler 看 kernel 数量torch.compile、算子融合显存够但 batch 上不去激活峰值高看 profile_memory梯度检查点、FlashAttention多卡加速比低通信瓶颈看 all-reduce 耗时换并行策略、检查网络拓扑第一次 step 特别慢编译和 benchmark正常现象预热几步再计时6.2 几个我踩过的坑坑一torch.cuda.synchronize()放错位置。有人为了计时准确在每个 step 前后都加synchronize()结果把异步执行的优势全毁了。正确做法是只在计时区间的两端加中间不要加。坑二loss.item()每个 step 都调。item()会强制同步把 GPU 流水线打断。要记录 loss 就攒几个 step 再记或者用loss.detach()存 tensor最后统一转 CPU。坑三optimizer.zero_grad()默认是set_to_noneFalse。这会把梯度清零而不是释放显存占用更高。改成optimizer.zero_grad(set_to_noneTrue)显存能省一点速度也略快。坑四忘了开 TF32。在 Ampere 及以后的卡上torch.backends.cuda.matmul.allow_tf32 True和torch.backends.cudnn.allow_tf32 True能让 FP32 矩阵乘法走 TF32 路径速度提升明显精度损失很小。默认是关的很多人不知道要手动开。坑五DataLoader 的drop_lastFalse。最后一个 batch 如果特别小比如只剩 1 个样本会导致这一步的计算量骤降GPU 利用率出现周期性掉坑。训练时建议drop_lastTrue。6.3 一个可以直接抄的配置模板把上面这些整合起来我给一个 LLM 训练的起步配置适合单机 8 卡、每卡 24GB 以上的场景import os import torch from torch.utils.data import DataLoader # 基础开关 torch.backends.cuda.matmul.allow_tf32 True torch.backends.cudnn.allow_tf32 True torch.backends.cudnn.benchmark True # DataLoader dataloader DataLoader( dataset, batch_size8, shuffleTrue, num_workersmin(8, os.cpu_count() // 2), pin_memoryTrue, persistent_workersTrue, prefetch_factor2, drop_lastTrue, ) # 模型 model torch.compile(model, modemax-autotune) # 优化器 optimizer torch.optim.AdamW( model.parameters(), lr1e-4, fusedTrue, ) # 训练循环 accum_steps 4 for i, batch in enumerate(dataloader): batch {k: v.cuda(non_blockingTrue) for k, v in batch.items()} ctx model.no_sync() if i % accum_steps ! 0 else contextlib.nullcontext() with ctx: loss model(**batch).loss / accum_steps loss.backward() if (i 1) % accum_steps 0: optimizer.step() optimizer.zero_grad(set_to_noneTrue)这套配置不是万能的但能覆盖大部分“显存省了 GPU 却吃不满”的场景。真正调优还是要靠 profiler 数据说话不要凭感觉调参。7. 关于硬件本身的一些现实约束7.1 笔记本 GPU 的功耗墙和散热墙如果你用的是笔记本上的移动版 GPU比如 RTX 4060 Laptop那要接受一个现实它的 TDP 通常只有 60W 到 115W而且散热受限。跑几分钟之后温度上来GPU 会主动降频保护自己。这时候nvidia-smi里会看到clocks.sm从 2000 MHz 掉到 1000 MHz 左右利用率数字可能还是很高但实际算力已经腰斩。这不是软件问题是物理限制。能做的只有垫高笔记本改善进风、限制功耗墙到稳定值比如nvidia-smi -pl 80、接受它跑不快的事实。别指望移动卡能跑出桌面卡的吞吐。7.2 显存带宽比容量更影响速度同样是 8GB 显存GDDR6 和 GDDR6X 的带宽差很多LPDDR5 和 HBM 差更多。带宽决定了数据搬运速度而 LLM 训练里大量操作是 memory-bound 的。所以一张带宽高的卡即使容量小一点训练速度也可能更快。选卡的时候除了看显存容量一定要看显存带宽GB/s和显存位宽bit。这两个指标对训练速度的影响很多时候比容量更大。7.3 多卡不等于线性加速两张卡跑同一个任务速度不是两倍。Amdahl 定律告诉我们加速比受串行部分限制。通信、同步、数据加载这些串行环节不消失加速比就上不去。实践中2 卡加速比 1.7 到 1.9 是正常的4 卡 3.2 到 3.6 是正常的8 卡能到 6 以上就算调得不错了。如果发现加速比远低于这个范围先查通信再查数据加载最后查负载均衡。别一上来就怀疑硬件坏了。8. 我个人的一些实操体会调了这么多项目我最大的体会是GPU 利用率低九成以上不是 GPU 的问题是喂数据的方式和算子的组织方式有问题。显存省下来只是第一步真正决定速度的是数据能不能及时送到、算子能不能高效执行、多卡之间能不能少等待。我一般会按这个顺序排查先看 DataLoader 能不能喂饱再看 profiler 里 kernel 之间有没有空隙再看通信占比最后才考虑换硬件。这个顺序能解决 90% 的“GPU 吃不满”问题。还有一个反直觉的点有时候故意多占一点显存反而能让 GPU 跑得更满。比如把 batch size 调大、把 prefetch 调多、把 bucket 调大这些都会增加显存占用但能减少等待提升利用率。省显存和省时间很多时候是矛盾的要看你当前的目标是什么。如果目标是跑得快那就别太抠显存如果目标是跑得起来那就先保显存速度慢点就慢点。最后分享一个小技巧在训练脚本开头加一行torch.cuda.set_per_process_memory_fraction(0.95)把显存使用限制在 95%留一点余量给 CUDA context 和碎片能避免一些莫名其妙的 OOM。这个在显存吃紧的时候特别有用。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →