很多人第一次接触“大模型显存优化”的时候,都有同一个错觉:显存降下来了,GPU 就能跑得更快、吃得更满。毕竟显存是算力发挥的前提,省下来的显存不就能塞下更大的 batch、做更长序列的推理吗?
但真正上手之后,你大概率会撞上本 Lab 标题里描述的现象:显存确实降了,可 GPU 利用率反而更难看,甚至更低了。我见过不少人在这上面兜圈子,以为是驱动、固件或者框架版本的问题,折腾半天才发现,省显存和吃满 GPU 是两条互相牵扯、甚至有时彼此对立的路线。
这一篇就专门聊聊这个反直觉的现象:为什么显存省下来了,GPU 却吃不饱?我会把背后最常见的几类瓶颈拆开,包括 kernel 启动开销、内存带宽与计算强度的关系、数据加载中断、以及多卡通信对算力的吞噬,最后给出一套可以直接照做的排查清单。内容偏实操,适合已经跑通 LoRA 微调或者 vLLM 推理、正在做性能优化的人。
1. 先复述一下现象:省了的显存,到底去哪了
1.1 你以为的“省显存”,省的是哪一层
只要聊到 LLM 的显存优化,大家第一反应就是量化、梯度检查点、FlashAttention、PagedAttention 这些手段。它们的共同点是:把模型权重、激活值、KV cache 在显存里的占用压下去。量化是用更少的 bit 表示权重和激活,梯度检查点是用“重计算”换激活存储,FlashAttention 则利用分块计算避免把完整注意力矩阵写进显存。
但这些优化动作,本质上改变的是“存储”,不是“计算”。模型的前向计算依然要做同样的矩阵乘法、同样的注意力打分,反向传播依然要算同样的梯度。省显存只是让你在物理容量上腾出了空间,并没有消除这些计算本身。你腾出来的空间,如果只是空在那里,GPU 的算力自然也不会因为“空间变大”而被自动填满。
更关键的是,这些优化手段中有一部分会引入额外的计算代价。典型就是梯度检查点:激活不清除、留到反向时重新算一遍,省了显存,但用 GPU 的闲置算力做了两遍前向。如果你的 GPU 本来算力就紧凑,那这个“用时间换空间”的策略会把计算负载拉满,但跑到我们看到的是另一回事——如果我告诉你很多情况下,省完显存后 GPU 的 SM(流式多处理器)其实在大量空转呢?
1.2 GPU 利用率这个数字,很多人的读法是错的
先解决一个常见误区:很多人一打开nvidia-smi看到Volatile GPU-Util,哦,100%,就以为 GPU 吃满了;看到 50% 就觉得“没吃满,性能浪费了”。这个指标在深度学习场景下的参考价值比你想象中小得多。它统计的其实是“采样时间内至少有一个 kernel 在执行的 SM 占比”。换句话说,只要 GPU 在断断续续地干活,每个采样点都撞上一个正在执行的 kernel,这个数值就能冲到接近 100%。
但它完全不反映“计算单元有多忙”。一个 kernel 若只用了 1 个 SM 在跑,其余 100 多个 SM 空闲,nvidia-smi的利用率也可能显示成 100%。更不用说,这个数字对 memory-bound 的算子会非常误导——带宽已经打满了,SM 上却有一堆周期在等待访存返回,可利用率照样高。所以你观察到的“GPU 吃不满”,大概率不是nvidia-smi的百分比下降,而是实际的训练吞吐(样本/秒)或推理延迟(token/秒)低于预期,同时利用率的数字忽高忽低。
用更准确一点的工具去看,比如用nsys或者torch.profiler抓 kernel 时间线,你会发现真正的景象是两种:要么 GPU 在跑大量极短的 kernel,几乎每时每刻都在“启停”;要么在跑一个跑得非常慢的 kernel,但线程利用率低得可怜。这两类问题涉及完全不同的优化方向,下面分头说。
2. 瓶颈之一:你被 kernel 启动开销拖死了
2.1 每次调用的固定代价,比你想象的贵得多
做 LLM 训练的时候,模型会被编译成一层一层的算子,每个算子对应一次 GPU kernel 的启动。这个启动本身有固定开销:首先 CPU 要把 kernel 描述提交到命令队列,经过驱动层、GPU 前端,然后 GPU 把计算块调度到 SM 上。一次启动的固定延迟在微秒量级,现代 GPU 上大概 3~10 微秒不等。
听起来不贵?但问题在于,现代 LLM 结构里存在大量“小算子”。做一次 LayerNorm 要启动几个 kernel,做一次残差连接又要一个,做一次激活函数又是一个。拿一个 7B 参数的模型来说,一次完整的 batch 前向+反向,可能要启动上千个 kernel。如果平均每次只执行 10 微秒,其中 5 微秒在启动、只有 5 微秒在真正算,那么哪怕 GPU 看起来不断在跑 kernel,有效算力也就打了对折。
显存优化在这里插了一脚。比如你用梯度检查点之后,反向传播时会额外重放一次前向计算,这意味着同样的架构要重复启动一遍完整的前向 kernel 序列。kernel 启动次数翻倍,启动开销自然成为更大的负担。而基于 vLLM 这类推理引擎做 PagedAttention 时,每次解码生成一个 token,也会重新执行一小撮 kernel,如果调度做不好、预热不足,启动开销占比会非常夸张。
2.2 变小的 batch 反而放大了 kernel 开销
显存优化节省的空间被拿来增大 batch size 时,第二阶段会……这里先不跑太远。回到根本:一次 kernel 执行的联合效应近似用“启动固定开销 + 数据量相关的线性开销”来描述。当 batch size 变大时,kernel 内的计算量线性增长,但每个 kernel 的固定启动开销不变,所以启动开销在总时间里的占比会下降,这从数学上就是“摊薄成本”。
但如果你在做低显存设备上的推理,batch 很可能被压到很小,甚至等于 1。此时每个 kernel 干的活更少,固定开销占比反而飙升。举个例子,两次 kernel 调用,第一次要算 1000 个 token 的注意力,第二次只算 1 个 token,启动开销相同,但第二次的有效计算密度低了好几个量级。显存优化往往鼓励你把请求拆得更碎、把缓冲池控制得更小,这在多用户推理场景下虽然保证了公平性,但对吞吐是纯粹的伤害。
2.3 kernel 融合,是省显存之后的必修课
理解了上面的逻辑,解决方案就浮出来了:想办法提高单次 kernel 的“含计算量”,把多个小算子合成一个大算子,这就是 kernel fusion(算子融合)。FlashAttention 就是典型的融合:把注意力中的 QK^T、softmax、PV 分块融合进一个 kernel,既省掉了中间矩阵的显存读写,又减少了 kernel 启动次数。LayerNorm + 残差 + 激活函数也可以合并成单个融合 kernel,业界框架里一般叫bias_norm_act之类的复合算子。
所以我每次在做显存优化的时候,都会顺带问一句:这套代码里有没有没做融合的通用算子序列?如果你发现前向计算里有大段x = self.norm(x)、x = residual + x、x = gelu(x)这样的三步曲,且三个操作各是各的 kernel,建议优先合并。Ten 上如果基础模型用的 PyTorch 原生 ops,可以用torch.compile的 inductor 后端去自动融合;如果用的是纯手工推理引擎或者自定义 CUDA 逻辑,就得手动设计复合 kernel。省钱不减性能,靠的从来不是单点优化,而是组合拳。
3. 瓶颈之二:算力没用上,带宽先撞墙了
3.1 理解算力强度和 Roofline
显存降下来了,GPU 吃不满,第二个高频原因是:你的算子根本不是 compute-bound(计算密集),而是 memory-bound(访存密集)。学过体系结构的人都知道 Roofline 模型,简单说就是拿“计算量/访存量”这个比值(算术强度)去和硬件平台的“峰值算力/峰值带宽”这个比值做比较。如果算术强度低于拐点,性能上限就是带宽,你堆再多 SM 算力也没用,因为大部分周期都在等数据从显存搬进寄存器。
LLM 里大量算子的算术强度其实低得可怜。比如 LayerNorm:对每个元素做减均值、除方差、缩放,读一次、写一次,中间几乎没有多少乘加。词嵌入查表(embedding lookup)更夸张,本质上就是从显存的一片区域里“抓”几行数据,算力参与度约等于零。位置编码(RoPE)里的旋转部分也类似,单个元素只做几个乘加,但每轮都要把整个 token 的向量从 HBM 读进 cache。
这些算子的共同特点就是:它们依赖的是显存带宽,而不是 SM 的 FLOPS。你做了显存优化,比如把权重从 FP16 量化到 INT8,理论上带宽压力能降低一半,因为搬的字节数少了;但如果同时模型结构没变、访存模式没变,带宽仍可能是瓶颈,SM 依然在等待,利用率照样上不去。
3.2 为什么显存省了,带宽瓶颈反而变明显
这里有一个很关键的心智模型:显存优化,比如激活检查点,实际上把“要存的激活”推迟到反向阶段重新计算。这意味着前向阶段多跑一次,多一次从权重到激活的访存路径;反向阶段还要再访存一次权重来计算梯度。整体上,它的访存次数可能不降反升。如果原本模型已经接近带宽上限,梯度检查点就是给带宽压力火上浇油。
另外一个例子是量化推理。你满心欢喜把权重压到 INT4/INT8,省了一半以上的显存带宽需求,结果却发现 GPU 利用率和总吞吐并没有翻倍。原因很简单:推理过程要搬的不仅有权重,还有激活、KV cache。KV cache 随序列长度线性增长,当上下文特别长时,KV cache 的读写会占据绝大部分带宽。你只把权重变小了,却忽视了 KV 搬运量——这就好比你把行李箱做小了,但托运的货物本身还是那么多。
3.3 实测:用带宽命中的时间去算一次理论上限
如果你想验证自己的算子是不是带宽瓶颈,方法很直接:用torch.cuda.nvrtc随便写一个只做拷贝的 kernel,或者干脆用torch.add这种 HBM 密集算子,测一下它跑一批数据的时间,和你的实际算子进行对比。如果在相同数据量下,你的算子耗时接近纯拷贝算子,那基本可以断言是带宽限制而不是算力限制。
遇到这种局面,优化的方向不是“减少算子数”,而是“减少字节搬运量”。FlashAttention 的省显存只是附加福利,它真正的杀手锏是减少 HBM 访问量——本来要写回显存的大注意力矩阵现在留在片上 SRAM 里,直接省掉一次大往返。RoPE、RMSNorm 这些中间 kernel,也可以尝试融合进主 GEMM(例如 Q、K 的乘法和 RoPE 一起算,业界叫“QKV + RoPE 融合”),把多轮访存压缩成一轮。当你的算法能压缩 HBM 通行量时,GPU 的 SM 才会真正忙起来。
4. 瓶颈之三:CPU 管线断供,GPU 在等饭来
4.1 多阶段流水线:GPU 只是其中一环
显存省下来了、算子也优化了,GPU 还是吃不满,还有一个高频原因在 GPU 之外:数据供应不上。训练一个模型,数据路径不是“硬盘直接进显存”,而是:存储(磁盘/对象存储)→ CPU 内存 → 数据预处理(解码、tokenize、增广、padding)→ 进入 GPU 显存 → 模型计算。任何一个环节只要慢于 GPU 的消耗速度,GPU 就得空转等待。
你会发现,显存优化和这条流水线有着隐秘的交互关系。为了省显存,你往往会把 batch size 调小。Batch 小了,意味着每个 iteration 里 GPU 计算消耗的样本数量也少了,对前级数据管线的压力更小——听起来是好事,对不对?但反过来看,小 batch 让 GPU 快速算完一个 iteration,CPU 侧刚准备下一批数据的时间窗就变得更短。如果数据加载做到 8 个 worker 也要 200ms 才能准备一个 batch,而 GPU 算一个 batch 只要 100ms,那么 GPU 每轮就有 100ms 在干等。你调小 batch 的本意是省显存、换更稳的显存预算,实际却把流水线的稀有度暴露出来了。
4.2 用数据卡顿识别这个问题
怎么自查数据侧是不是短板?最原始但有效的方法:先跑一个“空模型”。把模型改成直接return loss或者只做y = x.sum(),训练循环和数据加载保持不变,然后统计每步耗时。此时 GPU 几乎不干活,测得的时间基本等于数据准备时间。再跑正常训练,如果正常训练每步耗时远大于数据准备耗时,恭喜你,GPU 侧是主要瓶颈;如果两者很接近,那数据加载就去掉了至少一半的时间。
另外,PyTorch 的DataLoader里num_workers、prefetch_factor、pin_memory几个参数算得上经典调试三板斧。pin_memory=True能把 host 内存锁定,让 CPU 到 GPU 的 DMA 拷贝更快;prefetch_factor决定后台预取多少个 batch,调大些能给 GPU 多留缓冲。需要注意的是,num_workers不是越大越好,过多 worker 会导致 CPU 争抢、进程切换开销上升,反而把延迟拉高。你在 8 核机器上无脑开 32 个 worker,大概率会更慢。
4.3 显存省了以后,你多出来的空间可以这么用
如果数据加载确实是短板,显存优化省下来的那部分空间正好可以用来做“超前缓冲”:把下一批甚至下几批的数据提前搬进显存里的 pinned buffer / 常量缓存。业界有一种做法是“torch data pipeline 里搞双重缓冲”,一个 stream 做 H2D 拷贝,另一个 stream 做计算,用 CUDA event 做同步。这和显存管理是天然搭配——你的显存有冗余,才能做这种双 stream 流水线,也才能掩盖 CPU 侧的延迟。
换句话说,显存优化不是在真空中进行的。省下来的显存如果只用来增加 batch size,可能加剧流水线饥饿;但省下来的空间如果用来做预取和双缓冲,反而能直接修补 CPU 管线的短板。同样是显存下降,应用方式不同,对 GPU 利用率的影响可能是两个极端。
5. 多卡场景:通信和调度才是隐形杀手
5.1 集合通信如何吃掉显存优化的红利
很多人刚开始从单卡迁移到多卡时,都会疑惑:为什么每张卡看起来利用率都不高,整体吞吐也没有线性增长?答案通常在于通信。分布式训练中,反向传播之后所有 rank 要同步梯度,用的是一组集合通信操作(AllReduce 也就是“规约与广播”)通过 NCCL 完成。AllReduce 要求在每张卡之间搬移完整的梯度数据,这个搬运量和参数量成正比。
显存优化在这里有正反两面。正面是:你用梯度检查点或 ZeRO 之类的策略,把单卡上的显存需求降下来了,那么每张卡就能用更大的 batch——实际是每卡 batch size 放大,梯度更新频率变低,通信次数变少,通信占比自然下降。反面则来自 MoE 或者管道并行这类结构:MoE 架构的 token 要把部分 token 路由到别的卡上的 expert,产生 All-to-All 通信;稠密的通信频率和数据量并不因为你的显存优化而减少,只和并行策略与 batch 大小挂钩。
5.2 怎么确认通信在拖后腿
最简单的观测方法:把NCCL_DEBUG=INFO打开跑一次,看通信和计算的时间重叠情况。还有更直观的:对比单卡跑同一批次的时间T1与多卡跑相同总批次的时间Tn,如果Tn ≈ T1 / n,说明通信隐藏得不错;如果Tn明显大于T1 / n,先别急着调显存,看看通信是不是主因。
现代的 PyTorch 分布式训练中,通信与计算的重叠主要靠反向传播阶段的梯度桶(bucket)机制实现。PyTorch 会为梯度分桶,每算完一个桶,就立刻对桶发起 AllReduce,从而让通信与剩余计算重叠。如果你用的是自定义的训练循环,一定要确保梯度桶设置合理(model.register_comm_hook或者在 DDP 下调整bucket_cap_mb),否则通信通常会排队等待,变成纯串行。这个桶大小其实是个调优点,桶太大通信粒度粗、提前发起的能力弱;桶太小则通信次数过多,单次内部利用率低。
另外,网络拓扑也好,NVLink 也罢,跨节点走 InfiniBand/RoCE 的延迟和带宽都远低于卡间 NVLink。跨节点训练时,通信量的占比会骤然上升,显存优化之后的“省显存换更大 batch”策略反倒能对冲跨节点通信开销,因为 batch 变大,梯度同步次数变少。如果你在做多机训练且发现瓶颈在通信,可以考虑梯度累积来放大等效 batch size,而不是简单增加并行卡数。
6. 实操排查清单:四个命令找出真凶
6.1 一套顺手可用的 Profiling 组合
排查这类问题,我建议按以下顺序操作:
先看整体时间分布。用nsys profile -o trace_file --stats=true python train.py抓一次 trace,然后在报告里搜CUDA kernel的总时长和耗时占比。如果 kernel 总时长仅占训练总时长的 60% 以下,说明有大量时间被 CPU 侧的非计算逻辑占掉了,优先查数据加载和 Python 开销;如果在 90% 以上,说明 GPU 侧确实忙,再往下看 kernel 的类型和单个 kernel 的时长分布。
再抓单个 kernel 的效率指标。用ncu --set full --target-processes all python train.py(NVIDIA Nsight Compute),找到耗时最高的 Top 10 kernel,看三项指标:Compute (SM) Throughput、Memory Throughput、以及 kernel 的duration。如果 kernel 的 Memory Throughput 远高于 SM Throughput,基本就是访存瓶颈;如果 SM Throughput 也不高,但是 kernel 耗时极短,考虑启动开销与并行度不足。
然后是 PyTorch 侧总览。直接torch.profiler.profile(activities=[torch.profiler.ProfilerActivity.CUDA, torch.profiler.ProfilerActivity.CPU], schedule=...)跑几十个 step,打印key_averages()表。这里要区分CPU time和CUDA time。若 CPU time ≫ CUDA time,说明 Python 调度或数据预处理是瓶颈;若 CUDA time 集中在某些特定算子,再回过去用 Nsight Compute 看细节。
最后是通信检查。分布式场景加NCCL_DEBUG=INFO结合时间轴判断通信与计算重叠度。如果通信阻塞时间占整个迭代的 30% 以上,那基本锁定了通信瓶颈。
6.2 常见场景速查
| 现象 | 最可能原因 | 快速验证 | 首要对策 |
|---|---|---|---|
| GPU 利用率波动大,某个 kernel 吃满但整体吞吐低 | Kernel 启动开销过大或者算子碎片化 | nsys 看 kernel 数量与平均时长 | 用 torch.compile / FlashAttention 融合算子 |
nvidia-smi显示利用率频繁跳变,但 SM 线程利用率不高 | 访存瓶颈 | ncu 看 Memory Throughput 高于 SM Throughput | 减少 HBM 访问:融合、量化、增量解码优化 |
| CPU 占用率拉满,GPU 等待 | 数据预处理 / tokenizer 过慢 | 空模型跑一个 step 对比耗时 | 调大 num_workers、prefetch_factor、pin_memory |
| 通信等待占比高 | 多卡梯度同步 | NCCL_DEBUG、通信时间统计 | 增大 batch、梯度累积、调桶大小 |
| 显存优化后反向变慢 | 激活重计算引入前向重放 | profiler 对比原始版本 | 检查 inference 端用 KV cache,训练端考虑选择性 checkpoint |
7. 从“省显存”转向“端到端吞吐优化”的两个落地方案
7.1 推理场景:显存优化 + 请求调度优化
在 LLM 推理服务(vLLM、TensorRT-LLM 等)里,省显存不只是为了“能跑下模型”,更是为了容纳更大的并发批处理。PagedAttention 已经证明了显存碎片化的价值,但真正让 GPU 吃满的,是 vLLM 里的 continuous batching。简单说,任务不是一个一个串行跑,而是多个请求互相穿插:在生成阶段,空闲的显存空隙立刻分配给新请求的 prefill,整个 GPU 的算力始终被请求流填满。如果你做显存优化后只是把“单请求的 KV cache”调小了,却没有开启 continuous batching,那并发度没有提升,GPU 照样吃不满。
正确的姿势是:显存优化 → 提高 KV cache 可用容量 → 交互式调度器把更多请求放进同一个 batch → 提升算术强度与 SM 利用率。这里的因果链很容易被打断,比如调度器如果只能在 decode 阶段插入新请求,那么在 prefill 阶段 GPU 可能仍有空档。必要时可以查服务端日志里的num_running_seq与num_waiting_reqs,如果差不多每个 batch 都排得满满当当,GPU 利用率大概率会很好看。
7.2 训练场景:用显存冗余换取流水线深度
同理,在训练场景里,省下来的显存应当被主动用于增强流水线:增加并行 transformer layer 的 chunk 数(如果你用 pipeline parallelism 的话)、加大的并行 AC(activation offload 到 CPU)策略、或者干脆开大 batch,并配合 CUDA graph 捕获完整计算图,把 kernel 启动开销压缩到最低。
CUDA graph 是个经常被忽略的工具:它能把一组 CUDA kernel 捕获成一个图,然后一次启动。在数据加载、算子融合已经优化的前提下,CUDA graph 可以大幅压缩 CPU 侧的发射时间,让小算子密集的 LLM 计算结构也稳定地把 GPU 吃满。在 PyTorch 2.x 里,torch.compile的mode="reduce-overhead"底层就干了 CUDA graph 捕获这回事。实测下来,对解码阶段的小 batch 推理,这个模式能把吞吐提高 20%~40%,代价只是显存占用多一点点,而这对我们“显存已经省下来”的场景刚刚好。
老话说“省显存不是终点”,我更愿意把它看作一个“腾挪空间”的动作。空间腾出来了,得想办法用成吞吐量:你能加大 batch,就去加大 batch 以摊薄 kernel 启动和通信成本;你能开流水线并行,就去把流水线的气泡填掉;你能塞更多并发请求,就去把 continuous batching 的队列写满。显存是资源,不是指标。
最后分享一个排查时的个人习惯:每次做完显存优化,我都会老老实实跑一次 profiler,且固定同一组 batch size、序列长度、迭代次数,保证旧版和新版在相同条件下对比。很多“优化后变慢”的假象,其实是优化前用了大 batch、优化后不小心把 batch 调小了,导致所有固定开销的比例都变高。控制好变量,再用本 Lab 的清单逐层排查,大概率很快能找到那个吃掉你 GPU 收益的元凶。