
我最近在群里看到一个问题特别典型为了跑LLM把INT4量化、梯度检查点、甚至offload全用上之后显存占用率是满意了结果打开nvidia-smi一看GPU利用率只有百分之二三十。第一反应是显卡坏了或者驱动抽风查了一圈硬件没问题最后才发现是自己对显存和算力之间的关系想得太简单。这一篇是LLM Training Lab的第10篇专门拆一拆“显存省下来了为什么GPU还是吃不满”这件事适用场景是低显存显卡上的训练、推理和量化部署。下面讲的都是我自己实际跑过、用profiler验证过的东西希望能帮你少走几个月的弯路。1. 先理清关系显存省了算力为什么反而使不上劲1.1 显存管“放”SM管“算”两个维度经常互相打架很多人一开始会把显存和GPU算力当成一回事觉得显存大了GPU就快显存小了GPU就慢。实际上这是两个完全独立的维度。显存负责存放模型权重、KV cache、激活值它就是一个仓库真正干活的车间是GPU里的SM流式多处理器它们负责从仓库把数据搬进临时工作台寄存器、共享内存做完矩阵乘、归一化、激活函数再把结果写回仓库。那问题就来了仓库里堆满了数据不代表车间在满负荷运转。GPU的算力要发挥出来前提是数据能源源不断地从显存送到SM手上。如果数据搬运的速度跟不上计算的速度SM就只能在那等数据这时候算力再高也没用。这个现象在计算机体系结构里有个非常关键的概念叫算术强度Arithmetic Intensity通俗说就是“每从内存搬1字节数据进来能顺带完成多少次浮点运算”。算术强度高属于计算主导compute-boundGPU的浮点单元能一直满着算术强度低属于带宽主导memory-boundGPU大部分时间在等内存数据。LLM里的绝大部分算子——注意力、归一化、逐元素操作——都是典型的带宽主导这也是为什么很多人把模型塞进显卡之后验算力发现GPU根本没在努力干活。这里有个需要记住的结论显存优化解决的是“放不放得下”的问题但它改变不了“数据流动快不快”的问题。如果你优化的方式增加了数据搬运的负担那GPU吃不满就是必然结果。1.2 每一种“省显存”的手段背后都有算力或带宽的代价先看最常用的权重量化。INT4、INT8量化把权重从FP16的2字节压到0.5或1字节显存占用立减。但量化只是改了存储格式GPU的GeMM计算核心一般还是按FP16或FP32来算。所以每次计算拿到的是量化权重先要经过一步反量化dequant把它还原成高精度数据再做矩阵乘。这一步看着不起眼实际上对带宽和指令数都有影响。好的kernel实现会把反量化和矩阵乘融合在一起在寄存器里解完直接算写回主存的次数很少但很多框架的朴素实现是“先从显存读量化权重解成FP16矩阵再重新读一遍做GeMM”等于每个token要把整层权重多读一遍。省下来的空间又通过带宽给赔回去了。再看梯度检查点Gradient Checkpointing。训练时为了省显存不再把每一层的激活值都留到反向传播时使用而是到反向的时候重新算一遍前向。显存占用确实下去了但总浮点运算量大约增加三分之一。如果你的GPU本来就是算力吃满的状态比如大batch、长序列的预填阶段吞吐量就会肉眼可见地下降如果GPU本身就在等内存带宽增加的这部分算力反而是“白饶”的利用率倒是真能上来但收益有限。最极端的是offload把权重或KV cache搬到CPU内存甚至NVMe硬盘上。这种方案显存数字能降得非常好看但代价是数据通道完全换了一根。PCIe 4.0 x16的双向带宽大概32GB/s而一块RTX 4060 Laptop的显存带宽是272GB/s左右差了接近一个数量级。每次需要参数的时候都要从内存经PCIe搬到显存GPU就蹲在那等数据。这种情况下面GPU利用率低不是因为卡不好而是因为整个计算过程被数据搬运卡死了。所以我把这个问题的本质总结成一句话显存优化的本质是拿数据流动的成本换存储空间。你把权重压得更紧凑、让激活值不落显存、把数据放到更远的设备上这些操作都让GPU需要花更多时间等数据。显存数字好看了利用率自然上不去。2. GPU被“架空”的四个典型原因2.1 kernel启动开销GPU在干等指令GPU和CPU不一样它不能一条指令一条指令地执行普通的循环而是要把任务组织成一个一个的kernel提交到设备端。每个kernel启动都有固定开销哪怕是最简单的空kernel从主机端提交到设备端执行也要几微秒的延迟。而LLM的一个前向推理里往往包含上千个kernel每个层都有矩阵乘、归一化、残差、激活如果框架没有做算子融合这些kernel就会一个个排着队来SM在两个kernel之间只能干等着。这种“空隙”是很多人用nvidia-smi看不到的只有用profiler看时间线才能发现GPU的时间片全被启动开销吃掉了。对应的解法就是算子融合和CUDA Graph。把多个小kernel合并成一个大kernel减少启动次数或者把整个推理流程用CUDA Graph固化成一张图一次性提交。我在4080上测试过一个简单的推理流程不带任何显存优化开启CUDA Graph之后GPU利用率能从30%提到60%以上这在短序列、小batch的场景下收益尤其明显。2.2 memory-boundSM没闲着但它一直在等内存这个状态是排查中最迷惑人的。nvidia-smi里显示的GPU Util定义是过去采样时间内SM上有活跃warp的时间占比而不是浮点单元实际忙碌的比例。SM上有warp在跑不代表它在做浮点运算它可能是在等显存回包。等数据的这段期间SM状态也算“活跃”于是利用率看起来有六七十实际算力吞吐却低得感人。这就是典型的带宽主导。LLM的decode阶段就是重灾区batch size只有1的时候生成下一个token需要把整个权重矩阵按顺序过一遍每读一个权重只做很少的乘加运算。内存总线被数据读取挤满SM的浮点单元却闲得发慌。这时候你去看带宽利用率大概率是满的算力利用率反而不高。2.3 并行度不足大模型也怕小batch虽然LLM参数多看起来应该能轻松打满GPU但单次请求的batch很小或者序列里实际参与计算的有效长度特别短矩阵乘法的shape就太小了。GPU调度线程块是按“波次”来的如果一次矩阵乘只需要很少的线程块很多SM根本轮不到活干。低显存部署场景下这个问题特别严重因为显存限制了batch size你只能塞下很小的batchSM注定了空一半。这也是为什么同一个模型在服务器上跑能吃到95%利用率搬到8G显卡上就只有30%——不是显卡烂是显存限制下的并行度不够用了。MoE架构还有一个额外的坑路由不均匀。所有专家虽然都加载在显存里但每次只激活一部分。如果几个热门专家被反复调用其他专家一直闲置那一小部分专家所在的SM在排队干活剩下的SM全部空转。你从整体上看GPU利用率很低但热点专家那块区域的SM利用率可能已经爆了。2.4 prefill和decode同一个模型、两种命运这也是一个容易被忽视的坑。LLM推理分两个阶段prefill阶段处理用户输入的提示词整个序列并行计算矩阵乘法的shape又大又规整属于典型的计算主导GPU利用率通常会冲到90%以上。一旦进入decode阶段开始一个token一个token地生成batch收缩、计算变成瘦高的矩阵乘每一轮都要把全部权重读一遍就变成了带宽主导。所以你看遍历时GPU利用率是锯齿形的跑到prefill阶段冲到90%一进decode阶段又掉到20%这个真不是故障而是负载特性变了。如果总利用率上不去但模型生成没卡顿大概率是因为对话场景里decode占了绝大多数时间。这时候你去调高prefill阶段的并行度是没有用的重点应该放在decode阶段的带宽优化和batching上。3. 用profiler一步定位瓶颈3.1 先用nvidia-smi给自己一个方向很多人习惯在nvidia-smi里盯utilization.gpu但这只是初筛。我建议第一眼先看power.draw。GPU利用率低但功耗也低比如4060 Laptop满载大概115W实际只有40W说明SM确实没在疯跑大概率是数据搬运或者启动开销卡住了如果利用率低但功耗高说明SM在等数据但仍然在维持高频状态这种情况更倾向于内存带宽瓶颈。真正要做到精确定位还是得靠profiler。先养成一个习惯用查询模式把关键指标一次性拿到nvidia-smi --query-gpuutilization.gpu,utilization.memory,power.draw,clocks.sm,clocks.mem --formatcsv -l 1utilization.memory这一列很关键。如果显存带宽利用率很高而SM利用率很低基本就是带宽瓶颈如果两者都低大概率是kernel启动空档或者CPU侧数据处理拖了后腿。3.2 Nsight Systems先看时间线上的空洞NVIDIA Nsight Systemsnsys是最适合第一步深入排查的工具。它能看到整个时间线上kernel的分布、CPU活动、内存拷贝、CUDA API调用。我的习惯是先跑一条完整流程记住重点关注时间线里有没有大段空白。如果是kernel之间有大段空白说明问题在启动开销如果空白集中在每个token生成的开始处大概率是CPU侧数据处理或者同步等待比如在Python里频繁调.cpu()、做.tolist()、动态改变shape这些操作都会强制同步。命令很简单nsys profile --tracecuda,nvtx -o my_trace python run.py建议在代码里给关键阶段手动打上NVTX标记否则时间线上一片kernel名看着累死。PyTorch配合torch.cuda.nvtx很简单torch.cuda.nvtx.range_push(decode_iter) # ... decode 逻辑 torch.cuda.nvtx.range_pop()这样一眼就能看出prefill和decode分别占了多久以及两个阶段各自的GPU利用率分布。3.3 Nsight Compute判断一个kernel到底瓶颈在哪Nsight Systems告诉你“时间花在哪”Nsight Computencu告诉你“这个kernel为什么这么慢”。ncu是逐kernel做深度分析的可以看SM吞吐、显存吞吐、warp占用率这些硬指标。我常用的命令是ncu --metrics sm__throughput.avg.pct_of_peak_sustained_elapsed,gpu__compute_memory_throughput.avg.pct_of_peak_sustained_elapsed,sm__warps_active.avg.pct_of_peak_sustained_active python run.py判断逻辑很简单sm__throughput接近95%而memory吞吐还有大量余量算力瓶颈这个kernel没什么好调的memory吞吐接近满载而sm吞吐还有余量带宽瓶颈SM在等数据两个都很低要么并行度不够wave不饱满要么在等依赖比如上一个kernel的结果没出来。需要注意ncu不同版本的指标名可能略有差异如果报错先跑一下ncu --query-metrics | grep sm__throughput确认完整名称。ncu跑起来比正常程序要慢很多因为它要打断每个kernel做分析所以一般先拿nsys做整体切片再挑几个耗时最长的kernel用ncu单点分析不要一上来直接对整个流程跑ncu。3.4 PyTorch Profiler快速粗筛如果觉得装nsys、ncu太麻烦PyTorch自带的profiler也能做一版粗筛with torch.profiler.profile( activities[torch.profiler.ProfilerActivity.CUDA], record_shapesTrue ) as prof: run_inference() print(prof.key_averages().table(sort_bycuda_time_total, row_limit20))关键是看kernel数量里有没有大量elementwise、view、copy、fill这类小kernel。如果GeMM的总耗时不高但小kernel的数量动辄几百上千说明算子融合做得不到位这会直接表现为CPU-GPU之间的启动空隙跟前面kernel launch瓶颈是同一个链条。还建议把record_shapesTrue开起来目标是检查某些层的输入shape是否在动态变化动态shape会引发kernel重编译也会导致利用率波动。以上定位思路可以汇总成一张表症状最可能的瓶颈第一检查手段对策方向利用率低、功耗也低kernel启动 / CPU侧同步nsys看kernel间隙CUDA Graph、算子融合、去掉Python干预利用率低、功耗中高、显存带宽占用高内存带宽主导ncu看memory吞吐增大batch、序列packing、缩短数据读取量利用率低、SM warp占有率也低并行度不足ncu看warps_active增大batch、调整MoE路由、预热warmup利用率周期性波动prefill/decode负载差异NVTX分阶段标记分阶段单独优化decode或prefill4. 实战8GB显卡跑INT4量化模型把利用率从20%拉到70%4.1 案例背景与初始状态我手里有一台RTX 4060 Laptop显存只有8GB正好对应“低显存运行模型”这个常见场景。我跑的是一个14B级开源模型的INT4量化版本权重约7GB加上KV cache和激活值总占用大概7.2GB勉强塞进显存。一开始是照着网上教程最朴素的写法写的每次decode一个token就在Python里做.cpu()取出结果、切片、修改input_ids再调用下一轮。nvidia-smi一查显存占用7.2GBGPU-Util只有28%功耗只有45W。第一反应是量化后的kernel太低了于是直接打开ncu想看GeMM的反量化开销结果发现dram带宽利用率才60%浮点吞吐更是低得可怜完全不是反量化导致的。接着上nsys看时间线真相立刻明了decode kernel之间全是大段大段的空白一个token生成完GPU要等几百毫秒才等到下一个kernel。问题根本不在量化kernel而在Python侧那堆数据处理逻辑每次.cpu()和动态shape操作都会触发CPU-GPU同步。GPU核弹级算力在推理里只跑了不到三成时间剩下的时间全在等主机的业余选手。4.2 一轮有效优化把CPU环节从关键路径上拿掉优化思路非常明确让GPU一次性把该干的活都干完CPU只在开始时下发任务、结束时收结果中间完全不做任何干预。具体改动是这几个关键点把输入token序列预分配成固定最大长度比如2048的缓冲区无论当前生成到第几个token都只更新缓冲区前缀生成过程完全在GPU上进行用logits[:, -1, :]直接在张量上取最后一个位置的logits不再把结果搬回CPU或转成Python list所有历史token保存在预分配的GPU张量里只在最后一步一次性转成Python数组。再配合torch.compile把模型编译一次顺便把固定shape的decode步固化成CUDA Graph。这里有个容易出事的地方CUDA Graph对动态shape极度敏感我第一次试着把graph套上去的时候直接报错最后发现是因为我虽然固定了输入buffer长度但模型内部有几个跳过逻辑还在根据序列长度动态跳变。把这些判断全部改成“按最大长度计算、无效位置置掩码”之后graph才成功跑起来。优化完再查同一份日志GPU Util从28%升到了78%功耗从45W提升到接近90W生成速度从每token约420毫秒降到110毫秒左右吞吐翻了接近4倍。没用任何花哨算子就是让GPU把时间花在真正的计算上。4.3 另一种情况如果瓶颈是显存带宽怎么处理同样场景下还遇到过另一台机器用nsys看没有大段空白kernel之间接得很密但整体GPU利用率也只有35%。这就不是启动开销的问题了。ncu一查decode阶段的gpu__compute_memory_throughput接近满载sm__throughput只有20%上下。这是一个非常标准的带宽瓶颈每个token生成都要把7GB权重从头到尾读一遍batch size又只有1数据读取已经占满了显存带宽SM根本没有足够的计算密度去参与。这种场景下最直接有效的办法就是增大batch size。注意增大batch并不会增加太多的数据读取量因为权重读一遍之后可以被多个token共享但计算密度上来了SM就能真正忙碌起来。代价是decode的延迟会上升因为一个batch里的所有token要一起排队、一起算、一起返回。如果要在延迟和吞吐之间找平衡可以尝试连续批处理continuous batching让新请求在batch间隙无缝插入。这个方向比去抠单个kernel的反量化实现要划算得多。5. 常见问题排查速查5.1 高频现象与对应排查动作整理下来这类“显存省了、GPU却没吃满”的问题有几种高频模式差不多覆盖了90%的场景。现象典型原因排查方式首选对策显存占用低但GPU利用率只有10%~20%功耗也低kernel启动间隙 / CPU侧同步等待nsys看时间线空白CUDA Graph、torch.compile、避免频繁.cpu()量化后推理速度不升反降反量化实现未融合权重被重复读取ncu看GeMM的memory吞吐换W8A8或配置QAT、使用优化kernel库、开FUSED dequant开了梯度检查点训练吞吐明显下降反向重算增加计算量原瓶颈是算力对比开启前后的FLOPs减少checkpoint层级、选择性checkpoint关键层多卡机器利用率低单卡还好通信占比过高等待AllReducensys看通信kernel梯度融合、梯度压缩、提升通信频率或换高速互联decode阶段利用率周期性跳水prefill与decode负载特性不同NVTX分阶段标记时间占比针对decode单独调batch、用连续批处理MoE模型利用率低且分布不均路由不均匀导致部分专家过载看各专家kernel耗时分布改用负载均衡loss、动态路由策略5.2 容易再踩的三个坑第一不要迷信utilization.gpu这个数字。它只是“SM上有活跃warp的时间占比”一个等待内存回包的warp也算活跃。所以GPU利用率低不一定是坏事有些带宽瓶颈下即便算力完全没跑满利用率数字也有50%。真要判断性能有没有提升直接看token/s或者训练吞吐更靠谱。第二显存利用率高不代表显存带宽高。nvidia-smi里的utilization.memory反映的是显存读写操作的忙闲比例不是实际吞吐。要确认带宽是否打满还是要看ncu里的dram吞吐百分比。第三调参之前先确认自己是不是在正确的device上。很多入门踩坑案例是模型放在GPU上但输入预处理或post-processing用的张量在CPU上导致反复隐式拷贝又引发CPU-GPU同步。这类问题用nsys一眼就能看到不必怀疑模型本身。我自己真实操作下来的体会是显存数字只是“空间”维度GPU利用率是“时间”维度这两个维度经常打架。排查顺序目前固定为四步先看power draw排除SM完全空转再用nvidia-smi看显存带宽占用和功耗的匹配关系接着上nsys找时间线上的大空档最后用ncu单点确认瓶颈类型。这套流程下来绝大多数“显存省了、GPU却吃不满”的案例都能在半天之内定位清楚。如果你实在没时间做全套profiling那先试一个高性价比的动作把固定shape的decode循环用CUDA Graph固化配合torch.compile在低显存笔记本上这个是收益最明显的一步我甚至见过单纯这一步就让吞吐翻倍的场景。