
1. 开场被数字骗过几次以后我才开始重新理解 GPU 利用率先把我踩过最典型的坑放在前面某个模型推理服务上线后监控面板上显示 GPU 利用率 70%听起来还不错可实际压测的吞吐数据低得离谱。后来仔细排查才发现这个 70% 来自nvidia-smi里笼统的“设备利用率”它统计的是 GPU 在一段时间内有没有 kernel 在执行而不是计算单元真正忙了多少。说白了GPU 只要每帧都塞了十几个 kernel哪怕每个 kernel 都在等显存数据回流面板上照样能给你刷出接近满负荷的曲线。真正的有效产出却往往只有两三成。做 AI 推理优化第一步就是纠正对“GPU 利用率”的直觉。很多同学一说优化就问“怎么把 GPU 干到 95%”但实际场景里推理服务跑不满 GPU 的原因五花八门——有的卡在 CPU 数据预处理有的卡在 host 与 device 之间的拷贝有的问题出在 batch 太小导致算子启动开销占比过高还有的是模型本身的 arithmetic intensity算术强度太低计算单元根本没活可干。这篇文章我想围绕自己在推理服务调优过程中的完整思路从指标解读、瓶颈分析、算子与显存优化、服务端调度这几个层面把如何系统性地把 AI 模型推理的 GPU 利用率提上去这件事讲透。适合正在做模型部署、推理加速、GPU 服务器性能优化的工程师参考也适合那些刚接触推理框架、想搞清楚性能数字到底怎么看的同学。2. 先把“利用率”这件外衣扒干净三个最容易误读的性能指标2.1 nvidia-smi 里的利用率为什么不能信nvidia-smi的 GPU-Util 列统计的其实是一段时间窗口内有 CUDA kernel 在执行的时间比例单位是“忙碌时间/总时间”。它不关心你跑的 kernel 有没有让流处理器吃饱也不关心显存带宽用满了多少。我们打个比方一辆货车只要在高速公路上跑着哪怕只装了 10% 的货也算“被使用了 100% 的时间”但你绝不会说这辆车利用率高。真正要看的是货舱容积使用了多少、轮胎有没有空转、发动机功率有没有打满——放到 GPU 上对应的是 SM流式多处理器占用率、warp 发射效率、显存带宽占用量。所以在我自己的优化流程里看板第一屏永远不是nvidia-smi的利用率而是 DCGMData Center GPU Manager采集的sm_occupancy、sm_busy、dram_throughput、tensor_pipe_utilization这些细粒度指标。如果sm_busy高但issue_activewarp 调度器活跃占比低说明 SM 的算术流水线大量停摆大概率在等显存如果sm_busy低但 kernel 启动频率很高说明算子太小或者 CPU 侧调度太慢。2.2 三种算力仓位的实际意义我把 GPU 性能表现分成三个层次分别对应三种“利用率”观测层面你能看到的工具理解方式时间占用率nvidia-smi、nvtopGPU 有没有在干活无法衡量效率流水线占用率DCGM sm_busy、issue_active计算单元有多少时间真正在执行指令吞吐完成率Nsight Compute、Nsight Systems单位时间内完成了多少有效浮点或带宽操作三层指标经常互相矛盾。某个模块nvidia-smi显示 90%DCGM 显示 SM busy 只有 40%Nsight Compute 一看是典型的小算子密集模型每个 kernel 只有几十微秒中间有大量 kernel launch 间隙。这时候你去优化模型结构、做算子融合的收益比盲目换更强的 GPU 大得多。2.3 先搞清楚你被哪头“绊住了”计算密集还是访存密集推理优化的第一原则先判断模型的瓶颈类型再决定优化手段。一个简单的方式是算算术强度arithmetic intensity即每个字节数据访问对应多少次浮点运算。对于大语言模型推理在 decode 阶段生成的 token 只有 1 个但需要读取权重矩阵全量参数这导致算术强度非常低整个 kernel 卡在显存带宽上而 prefill 阶段有很长的序列可以并行计算算术强度反而高不少。我在实测中见过把 decode 阶段当 compute-bound 来优化的情况折腾半天算子合并收益微薄。后来用 Nsight Compute 一看瓶颈根本在 DRAM 带宽——三台服务器共享同一条 PCIe 通道、显存频率配置不对、权重还是 FP32 存储这类问题才值得先处理。这篇文章后面所有策略我都会先标出是针对带宽还是计算密集场景避免方向跑偏。3. 看到低利用率之后怎么一步步定位瓶颈从 CPU 到 GPU 的排查链路3.1 第一步分离 CPU 侧与 GPU 侧时间线拿到一个推理服务我不会盯着 GPU 指标看半天而是先做端到端时延分解。最粗暴但有效的方式是日志埋点记录一个请求从进入进程、完成前处理、调用推理接口、拿到输出、到返回响应的五个时间戳。如果“进入进程到调用推理”之间的时间占比超过 20%八成问题在 CPU 侧比如 Python 的 GIL 竞争、Pandas 处理、CV 图像编解码、Tokenization 批次切分等等。这时候 GPU 就算闲置那也不是 GPU 自己的问题。最经典的一次“假 GPU 瓶颈”某图像分类服务GPU 利用率 30%直觉想换更强的卡。结果把预处理逻辑抽出来单独 profiling发现 OpenCV 图像缩放和归一化占了整个请求时长的 45%。CPU 单线程把数据准备好之前GPU 一直是空闲等待状态。换成基于 GPU 的预处理管线例如用 CUDA 张量直接做 resize/normalize再把预处理丢到数据加载子进程里整体吞吐直接翻了 2.5 倍GPU 利用率也自然升到了 90%。所以低利用率的根因经常在 GPU 外面。3.2 第二步核对 host-device 数据搬运开销另一个容易被忽略的头号瓶颈是 H2D/D2H主机到设备、设备到主机拷贝。一个推理请求如果输入是几 MB 的图片或长文本每次请求都要从 CPU 内存拷到显存再从显存拷回结果而拷贝本身用的是 PCIe 带宽。PCIe 4.0 x16 理论带宽约 32GB/s看起来很高但跟显存带宽动辄 1TB/s 以上比还是差一个量级。更致命的是每次拷贝都有固定延迟和同步开销小请求频繁全局同步会把流水线打断。让数据搬运开销呈现在图上最直观的方式是 Nsight Systems 的 timeline 视图你能清楚看到蓝色CPU 侧和绿色GPU 侧之间有多少空隙。我在项目里经常做的一件事是把输入数据先批量拷到 pinned memory固定内存再通过 CUDA Graph 捕获整套推理流程让多个小 kernel 在一个 CUDA Graph 上统一提交减少 launch 间隙和 host 端同步点。这个操作往往能让小请求场景的端到端吞吐提升 30% 到 80%。3.3 第三步用逐算子剖析替“整体感觉”背书确认瓶颈到了 GPU 内部之后我会用 Nsight Compute 做逐算子 profiling。关键指标不是某个 kernel 多快而是achieved occupancy实际驻留 warp 数除以理论最大 warp 数issue stall reasons调度器停顿的原因比如long scoreboard代表在等显存返回mio throttle代表内存排队memory workload analysis看 DRAM 吞吐占峰值带宽的百分比。如果achieved occupancy低问题在资源限制例如寄存器使用过多或者共享内存被 KVCache 之类占满如果long scoreboard占比高说明访存不连续或没充分并行要考虑数据布局如果 DRAM 吞吐已经接近理论带宽的 85% 以上那就无脑量化压缩数据宽度别再动别的。我见过一个 Decoder-only 模型推理 kernel显存带宽已经打到 96% 的理论峰值但 SM 利用率只有 50%优化 group 还在讨论要不要增加 block 数量。加了也白加因为瓶颈是带宽block 加再多也只是排队等数据反而可能损失 L2 命中。这类诊断动作做多了你自然会对“优化方向”产生条件反射先看瓶颈类别再决定是“压体积”还是“调并行”。4. 把重心真正放到推理阶段prefill 与 decode 的利用率差距才是大头4.1 为什么 prefill 阶段 GPU 利用率高decode 阶段跌得惨大语言模型的推理通常分成 prefill预填充和 decode逐 token 生成两个阶段。prefill 阶段要把整个 prompt 一次性喂进去矩阵乘法的输入序列很长每个权重只需读一次、乘很多次天然是计算密集GPU 的流处理器能被喂得很饱。但是 decode 阶段每步只生成一个 token相当于算一个巨大的矩阵乘法只有一行有效数据在流动大部分时间都在等权重从显存搬进计算单元。无论什么尺寸的模型decode 阶段的 SM 利用率达到 40% 已经算相当不错。这也解释了为什么很多团队的 GPU 利用率曲线在首 token 输出前有一个尖峰随后掉到一个很低的平台期。而这个低平台期的长短直接决定了“尾 token 时间”TPOT和“每秒输出 token 数”output tokens/s——恰恰是用户端感知最明显的指标。4.2 用连续批处理continuous batching把 decode 塞满传统静态批处理是“攒够 N 个请求才一起算”问题在于每一个请求的 decode 步长不一致先结束的请求也得陪着慢请求跑完GPU 流水线里出现大量空挡。现在主流推理框架普遍实现的是 continuous batching连续批处理思路每个 step 动态决定哪些请求继续 decode、哪些请求已经完成可以退批再把新请求加进来。这样 batch 里始终有足够多的 token 在跑矩阵乘法把带宽利用率拉高。我在实际部署中最喜欢的方式是 vLLM 风格的 PagedAttention 与 continuous batching 搭配因为显存中的 KVCache 也可以按页分配允许更细粒度的批管理。对于很多需要自研推理服务的团队我的建议是优先复用成熟的连续批处理框架因为自己实现动态 batch 的调度逻辑远比想象中复杂。你需要处理 max tokens 超限、请求优先级、内存预分配策略、pause/resume任何一个角落处理不对线上推理服务就会比学人家造的轮子还难开。4.3 目标批次大小迭代中的黄金数值批次大小不是越大越好。批大小增大吞吐量会先上升但达到某个点之后每增加一个请求都会压低单个请求的实时性而且可能让显存被 KVCache 撑满被迫触发显存回收导致颠簸。我自己在调优时一般用二分法先测 batch1、2、4、8、16、32、64 的吞吐曲线找到吞吐增速的拐点再把拐点附近的值结合延迟预算做交叉验证。比如某次压测batch16 时吞吐还是线性增长batch32 时吞吐只增加了 18%batch64 时吞吐反而掉了 5%排查发现是显存中的 KVCache 开始争用 L2导致带宽命中率下降。最后生产配置锁在 batch24 附近用两级动态批处理策略先按时间窗口攒到最小批再在负载高峰继续追加到阈值。这里多说一句如果用了连续批处理你不需要把“batch size”固定成某个整数更准确的调优对象是“最多允许多少个并发序列同时 decode”。4.4 显存带宽优化权重瘦身和缓存复用对于 decode 阶段这种访存密集瓶颈最直接有效的措施是降低单位 token 需要搬移的字节数。常见路线包括权重量化FP16 降到 INT8甚至 FP8/INT4直接减少显存读取量模型蒸馏和结构化剪枝减少要读取的参数总量权重缓存复用相同的前缀 prompt 复用公共部分的 KVCache例如对话历史避免重复计算。我试过把一个 7B 参数级别的模型从 FP16 转成 W8A8权重 8 比特、激活 8 比特量化后decode 吞吐大约能提升 1.8 倍左右。但这不代表所有情况都这么理想量化在小 batch 下优势明显大 batch 下由于计算密集属性变强压缩带宽带来收益的比例会被计算时间稀释。所以务必要针对性压测别照抄别人的数据。4.5 算子融合在一个真实流程里的效果减少“停顿”比减少“计算”更重要算子融合属于计算侧的优化把多个小 kernel 合并成一个大 kernel减少中间结果的显存读写也减少 kernel launch 的空隙。以标准 Attention 计算为例如果 QK^T 的结果先写进显存、softmax 再读一遍、又乘 V 再写一遍几个来回下来显存流量加倍。使用 FlashAttention 一类的融合实现靠分块和重计算避免中间落盘在小 batch 场景省下的时间非常可观。但我也要提醒一句自研模型推理时很多融合的收益上限受限于架构。如果模型里有一堆逐元素小算子比如 LayerNorm、残差连接、激活融合后可以省掉好几个 kernel 启动如果是大规模 GEMM融合收益相对有限因为瓶颈已经压到计算流水线了。所以我的习惯是先看上层 profile 数据确认哪些算子累计耗时排在前五再决定是否花大力气做融合避免为了融合而融合。5. 服务端与调度层的隐性收益GPU 效率和工程效率同样重要5.1 多请求并发时的 GPU 流Stream管理单卡推理服务经常会犯一个错误所有请求都丢进默认 CUDA Stream所有 kernel 串行排队执行GPU 并行能力白白浪费。正确姿势是把不同请求或者同一请求内部互不依赖的算子放到不同 Stream 上让它们并发执行。特别典型的是“同时跑多路小模型推理”例如同一个容器内同时卸载多个不同输入的模型副本通过多 Stream 并发能吃掉不少空闲 SM。不过 Stream 并发也不是越多越好。Stream 数量增加后调度器的上下文切换开销和 L2 争用会更加明显而且如果两个 kernel 都在抢带宽并发效果会很差。我通常的节奏是先用 2 到 4 个 Stream 做小规模对比测试观察dram_throughput和sm_busy确定资源峰值后再决定继续加还是减。5.2 MIG 切分或多实例部署把碎片化占用变成可控分配当你的 GPU 上同时跑的资源需求差异很大的多个服务时MIG多实例 GPU可能是个不错的隔离方案。MIG 可以把一块大卡切分成多个独立实例每个实例拥有独立的显存带宽和 SM互不干扰。比如一个线上服务只需要 20GB 显存、峰值吞吐不高把它和另外一个需要 40GB 显存的服务放在同一块物理卡上能大幅提升硬件财务上的“利用率”。但 MIG 也有局限切出来的计算容量是固定的如果某个实例的负载一下就涨到 100%另一个实例再空闲也没法把算力借给它。相比之下vGPU 或者单纯容器化共享 调度器限额反而更灵活。我自己的选型偏好是长期稳定、资源画像清晰的服务用 MIG 隔离短期波动大、需要弹性扩缩容的用共享 GPU 加调度策略。5.3 批量调度策略从攒批到优先级抢占推理服务的 GPU 利用率其实和上层调度策略强相关。最简单的是 FCFS先来先服务加攒批窗口窗口内收到的请求攒成一批再一次性送进 GPU。但这个策略有个通病——窗口设大了尾延迟暴涨窗口设小了batch 太小GPU 吃不饱。进阶做法是动态攒批窗口大小随时延分位数动态调整低峰期多等一会攒大 batch高峰期尽快把请求送进去。另一个方向是带优先级的抢占调度短请求优先长请求或者后台批处理让位。我自己在某个在线推理场景中使用的是两队列模型——高优队列只放在线请求低优队列塞离线计算任务。在线请求没占满 GPU 时低优任务自动补位占满时低优任务被挂起。这种“弹性填缝”策略让在线服务的 QoS 几乎不受影响同时整卡的平均 GPU 利用率从 45% 提升到了 85% 左右。调度层的优化很多时候比模型层优化更容易出成果但容易被忽略。5.4 CPU 侧进程绑核与 NUMA 调优推理服务跑到最后CPU 侧同样会成为 GPU 利用率的隐形天花板。尤其在高并发场景下CPU 需要承担请求路由、数据预处理、结果后处理、内存分配等一系列工作如果这些线程在多个 NUMA 节点之间乱跳PCIe 访问延迟会明显增加。用numactl把 GPU 相关进程绑定在同一 NUMA 节点上让显存分配和 CPU 内存分配尽量就近减少跨节点访问实际压测里小请求中位数延迟往往能再降 5% 到 10%。另外如果数据预处理本身计算量大建议用独立进程池而不是线程池。我之前遇到过 Python 线程池因为 GIL 导致预处理阶段多线程无法并行GPU 持续空等的情况改成 multiprocessing 之后数据供给速度上来了GPU 利用率肉眼可见地爬升。这个调整很基础但越基础的问题越容易被错误定位为 GPU 本身不够用。6. 实测复盘一次把推理服务 GPU 利用率从 28% 提到 81% 的完整过程容我把一个印象深刻的案例作为收尾复盘。某内部模拟项目 X是一个基于 7B 参数级模型的文本生成服务单卡部署上线初期表现相当难看nvidia-smi 显示利用率 28%TPOT 高达 90ms用户体验糟糕。我接手后并没有直接动模型结构而是按以下顺序做了四轮调整第一轮端到端 time breakdown。发现 CPU 侧 Tokenization 和特殊规则过滤占掉 35% 的时间于是把相关逻辑重构成预编译缓存并用独立进程做预处理延迟降低约 20%GPU 利用率升到 42%。第二轮调整推理框架的调度参数。启用了连续批处理把静态 batch 改为动态并发序列上限16同时打开 PagedAttention 的预分配策略。GPU 利用率跳到 63%TPOT 降到 48ms。第三轮Nsight Compute 逐算子定位。发现 Attention 相关的 GEMM kernel 大量时间卡在long scoreboard上说明在等显存。将权重从 FP16 量化成 INT8并开启 FlashAttention 融合路径吞吐提升了 56%GPU 利用率来到 76%。第四轮进程绑核与多 Stream。用 numactl 绑定 GPU 和 CPU 到同一 NUMA 节点并让前处理与主推理分别跑在不同 Stream 上进一步把空隙缩小。最终稳定状态GPU 利用率 80% 到 81%TPOT 稳定在 28ms 左右中位首 token 延迟从 310ms 降到了 170ms。这套流程最值得分享的地方不是某个具体操作而是“顺序”本身先把 CPU 侧喂饱再调 batch 调度再碰模型算子最后做系统级绑核。反过来操作通常会浪费大量精力比如模型量化做完了发现在等 CPU 预处理根本跑不出应有的效果。说实话GPU 利用率优化不是一道有固定答案的题不同模型、不同负载形态、不同硬件组合答案完全不一样。但排查路径是高度类似的拆时间、定瓶颈类型、观察细粒度指标、做针对性修改、再回头验证。只要你手里有 DCGM 指标、Nsight 报告和显存带宽数据而不是只盯着一块 nvidia-smi 的数字面板优化方向就不会跑偏。我对团队新人常说的一句话是别问怎么把利用率调高先问你的 GPU 到底在等什么。想清楚这句话一半的优化工作已经完成了。