一块 A100 的利用率长期显示 92%,团队以为"算力用满了"。而实际吞吐只有同规格机器的三分之一——因为那 92% 里有一半是"等数据":GPU 在做空转的等待循环,驱动统计成"忙碌"。
GPU 利用率是个被高估的指标。它统计的是"核有没有在跑",而不是"跑的是不是有用的计算"。这个指标的来源也值得知道:它采样的粒度通常在几百毫秒,所以"瞬时满载"与"持续满载"在看板上是同一个数——而这两者对吞吐的含义完全不同。
而后者要靠别的数才能看出来。
1. 三种"看起来很高"的低效
| 类型 | 特征 |
|---|---|
| 数据饥饿 | 利用率高但与吞吐不匹配 |
| 小批量 | 每步算得快但步数多 |
| 显存受限 | 频繁换入换出 |
第一项最典型:**批处理把数据从 CPU 拷到 GPU 的时间算作"忙碌"**第三项(显存受限)还有一个"隐性"形态:显存够用但带宽被打满,此时计算单元大部分时间在等数据,利用率高而吞吐低,表现与数据饥饿几乎一致。
,而这段时间算力是闲置的。判定方式是看"每秒处理的样本数",而不是利用率。
第三项表现为"利用率高但延迟抖动大":显存不够时驱动会把部分数据换出,实际是在搬运而不是计算。
2. 三个必须一起看的指标
| 指标 | 说明 |
|---|---|
| 吞吐(samples/s 或 tokens/s) | 真实产出 |
| 显存占用与峰值 | 是否贴着上限 |
| 计算单元利用率(SM/Tensor Core) | 真正在算的比例 |
第一项(吞吐)的定法要固定口径:tokens/s 与 samples/s 不能混用,输入长度不同时同一个 tokens/s 对应的算力消耗也不同。所以报告里必须写"平均输入长度"。
第二项要区分"平均"与"峰值":平均 60% 而峰值 98% 意味着已经没有余量,任何并发增加都会 OOM。
第三项比"整体利用率"细一层:Tensor Core 利用率低而整体利用率高,说明大部分时间在做非矩阵运算(如数据搬运、控制逻辑)。
// 一次看清三项(NVIDIA)
nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv -l 1
// 更细:SM 占用与显存带宽(需要 profiler)
ncu --metrics sm__throughput.avg.pct_of_peak_sustained_elapsed,dram__throughput.avg.pct_of_peak_sustained_elapsed -c 1 python infer.py
3. 数据饥饿的识别与修复
| 现象 | 修复方向 |
|---|---|
| GPU 忙但吞吐低 | 增加预取、加 worker |
| CPU 单核打满 | 数据解码是瓶颈 |
| 磁盘 IO 断续 | 数据本地化或缓存 |
| 每步之间有间隔 | 用 pinned memory + 异步拷贝 |
第四项是最容易见效的技术点:普通内存到显存是同步拷贝,会阻塞计算第一项(加 worker)有个上限:worker 数超过 CPU 核数后收益迅速下降,而每个 worker 都会复制一份数据预处理状态,内存可能成为新瓶颈。
;用 pinned(page-locked)内存配异步流,能把拷贝与计算重叠。
// 异步拷贝:让数据搬运与计算重叠
loader = DataLoader(ds, batch_size=64, num_workers=8,
pin_memory=True, # 用 pinned 内存
prefetch_factor=4) # 提前准备若干批
for batch in loader:
x = batch.to("cuda", non_blocking=True) # 异步 H2D
y = model(x)
// 关键:num_workers 与 prefetch 不足时,GPU 就会等数据
4. 小批量的两笔账
| 维度 | 小批量 | 大批量 |
|---|---|---|
| 单步延迟 | 低 | 高 |
| 吞吐 | 低(核没喂满) | 高 |
| 显存 | 低 | 高 |
| 数值稳定性 | 差(梯度噪声大) | 好 |
推理场景常用"小批量 + 高并发"来压延迟,代价是吞吐上不去,因为每步的固定开销(kernel 启动、同步)被摊薄得不够。
第三项(数值稳定性)常被忽略:批量小到个位数时,训练中的批归一化统计会非常不稳定,表现为 loss 抖动。所以"小批量提延迟"通常只用于推理,不用于训练。
判定方式是算"每步耗时 / 批量":如果这个值随批量增大而明显下降,说明固定开销占比高,批量还有提升空间。
还有一个"服务形态"层面的低效:把多个小模型塞进同一块卡,看起来利用率高,实际每个模型都分不到足够的显存做批处理,于是三者的吞吐都不如"一块卡跑一个"。
// 三种部署形态的取舍
1 卡 1 模型:批处理空间足,延迟低,但卡数需求高
1 卡 N 模型:卡省了,但每模型 batch 受限,总吞吐可能更低
N 卡 1 模型(张量并行):单请求快,但通信开销与卡数成正比
// 判据:算总吞吐(所有模型 tokens/s 之和)而不是看利用率
5. 显存受限的表现
| 现象 | 说明 |
|---|---|
| 峰值贴近上限 | 无余量 |
| 偶发 OOM | 长序列或大 batch 触发 |
| 吞吐随并发不增 | 已到显存墙 |
| 延迟抖动 | 换入换出 |
第二项的偶发性最难查:同样代码在 99% 请求下正常,1% 的长输入触发 OOM第四项(延迟抖动)的判据是"抖动是否与输入长度相关":相关则是显存换入换出,不相关则多半是排队或其它进程争抢。
,而错误信息只写"out of memory",不写"因为这条输入太长"。
缓解手段是"输入长度上限 + 动态批处理",而不是"把显存加满"——加显存只是把墙往后推,墙还在。
// 三个约束比"加显存"更有效
serving:
max_input_tokens: 4096 # 硬上限,超限即拒绝
max_batch_tokens: 16384 # 按 token 数而非请求数分批
kv_cache_budget_ratio: 0.6 # 给 KV cache 的显存比例
// 效果:OOM 从"偶发"变成"可预测的拒绝"
监控上还有一个"看不见"的部分:CPU 侧的准备时间。分词、图像解码、请求路由都发生在 GPU 之外,而这些时间在 GPU 利用率上完全不可见——于是"GPU 利用率 92%、端到端延迟很高"这类现象,答案常常在 CPU 那一侧。
// 端到端拆解:把时间分到 GPU 内外
// 总延迟 = 排队 + 预处理(CPU)+ 计算(GPU)+ 后处理(CPU)
// 若预处理占比 > 30%,优化 GPU 无用
pidstat -u -t -p $(pgrep -f server) 1 5 # 看 worker 线程的 CPU 时间
6. 排错:五种"GPU 很忙但慢"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 利用率 95% 吞吐只有 1/3 | 数据饥饿 | 加 worker/prefetch |
| 利用率随并发不升 | 显存墙 | 限制长度或分批 |
| 延迟高但吞吐正常 | 排队而非计算 | 看队列而非 GPU |
| 单卡快、多卡慢 | 通信开销 | 检查并行策略 |
| 时快时慢 | 其他进程争抢 | 隔离或限额 |
第四项(多卡变慢)要看"扩展效率":8 卡跑出 5 卡的效果(62%)算正常,跑出 2 卡的效果说明通信是瓶颈。而通信瓶颈往往来自"每步数据量小、同步次数多"。
第五项在多租户环境里非常普遍:同一块卡上有别人的任务,利用率显示的是卡的总利用率,而你的任务只拿到一部分。
7. 边界:什么情况利用率低是正常的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 小模型 + 低延迟要求 | 核喂不满是设计取舍 | 接受 |
| 稀疏/不规则计算 | 有分支与空转 | 接受 |
| 单请求在线推理 | 批量小 | 提高并发而非批量 |
| 训练的小 step | 通信占比高 | 看扩展效率 |
判断口径一句话:每秒产出的 token 或样本数,是否与这块卡的规格相称。最后一条与"成本"有关的边界:利用率不是省钱的指标,吞吐/成本才是。同样一块卡,利用率 60% 但吞吐是别人的两倍,那就是更省的——所以优化目标应当写成"每美元多少 token",而不是"利用率多少"。
相称就别看利用率;不相称,利用率再高也是假象。
8. 自测
- GPU 利用率统计的是什么?为什么"92% 但吞吐只有 1/3"是常见现象?
- 三个必须一起看的指标分别是什么?为什么"平均显存 60% 而峰值 98%"意味着没余量?
- 数据饥饿的四种现象里,哪一种最容易被误判为"计算慢"?pinned memory 解决了什么?
- 小批量的两笔账怎么算?"每步耗时 / 批量"这个比值说明什么?
- 偶发 OOM 为什么难查?为什么"加显存"不是解法?
- 判断"利用率低是否正常"的那个问题是什么?