GPU利用率81%空转,135M小模型推理性能为何只有48 token/s?

发布时间:2026/11/4 13:17:57
GPU利用率81%空转,135M小模型推理性能为何只有48 token/s?

1. 先算账:1.35 亿参数的模型,理论解码速度到底该是多少

看到 "1.35 亿参数,每秒只解码 48 个 token,GPU 81% 时间空转" 这组数据,我的第一反应不是硬件坏了,也不是模型写崩了,而是:这一定是踩中了某种典型的部署性能陷阱。1.35 亿参数也就是 135M,放在今天的生成式模型里属于相当轻量的一档,按道理推理不该慢成这样。

先做个粗算,让你对"正常水平"有个概念。假设这里是一个标准的 decoder-only Transformer,135M 参数大约对应 12 层、hidden size 768、FFN 维度 3072 左右,词表按 50k 算。模型权重以 FP16 存储,总大小大约是:

  • 参数总量 1.35e8,FP16 每个参数 2 字节,权重占 270MB 左右。
  • 再加 KV cache,按 2048 上下文、12 层、单头 64 维、12 个注意力头来估,每 token 每层要存 2 组向量(K 和 V),也就是 2 × 768 × 2 字节 = 3KB 左右,2048 个 token 也就 6MB,相比权重完全可以忽略。

推理时每生成一个新 token,最朴素的做法是把整份权重从显存读一遍参与矩阵乘。那么速度下限完全由显存带宽决定。拿常见的 RTX 4090 举例,显存带宽约 1TB/s,270MB / 1TB/s ≈ 0.27ms 读完全部权重,也就是理论上每秒至少能解码 3700 个 token。A100 80G 带宽更高,能到 7000+ token/s。即便考虑激活值读写、softmax、采样、kernel 切换这些额外开销,一个 135M 模型在 4090 上跑到 1500~3000 token/s 也不是什么夸张的事。

所以你看到 48 token/s 的时候,问题根本不在"模型太大",而在别的地方。48 比理论下限低了差不多两个数量级。这不是带宽不够,也不是算力不够,是根本没有让 GPU 有效干活的调度链路。换句话说,不是卡在"计算慢",是卡在"等待"和"空转"上。

这也是小模型推理和大模型推理一个非常微妙的差异:大模型动辄几百 GB 权重,GPU 光搬权重就能吃饱,反而是"带宽受限"的典型场景,性能曲线很直观。而 135M 这种小模型,权重小到 GPU 还没来得及发力就已经读完了,此时 kernel 启动开销、Python 循环开销、CPU 和 GPU 之间的同步,每一项都可能成为比算力更大的瓶颈。

2. GPU 利用率 81% 空转,不等于显卡在偷懒

很多人一看到 GPU 利用率只有 20%、30%,第一反应是"显卡在摸鱼"。但这里的数据更反直觉:GPU 有 81% 的时间在空转,换个说法是 GPU 确实一直在被调用,但大部分时间没有实际的计算 warp 在执行。这比单纯的"利用率低"更值得警惕——它说明你的程序一直在让 GPU 做一些事,但每件事都小得可怜,GPU 处于"启动一下、干一秒、等半天、再启动一下"的状态。

要理解这个,得先搞清楚 GPU 利用率这个指标到底在看什么。任务管理器里的 GPU 利用率,通常统计的是某个时间窗口内 GPU 引擎(计算单元、显存控制器)有活动的时间占比。如果一个 kernel 很小,只启动了 10 微秒,这 10 微秒会被算作"忙",但真正满负荷跑满所有 SM 了吗?没有。一个 768×768 的矩阵乘,在 4090 上可能只激活了不到 10% 的 SM,其余 SM 全程围观。这时从利用率看可能是 30%,但实际有效计算量微乎其微。

还有一种典型情况是 GPU 绝大部分时间在等数据。PyTorch 默认的 eager 模式执行时,PyTorch 框架本身在 CPU 端有大量 Python 层逻辑,每生成一个 token 都要经历"拼输入 → 前向传播 → 采样 → 把新 token 拼回缓存 → 下一次前向",这些逻辑大部分跑在 CPU 上。GPU 把活干完只需要 1ms,但 CPU 准备下一次调用花了 20ms,那 GPU 就有 95% 的时间在空转等待。你从日志里看到的就是"GPU 一直在被调用,却啥也没干"。

第三个原因藏在 kernel 数量上。一个未经优化的 Transformer 前向,一组注意力 + FFN 可能需要几十个甚至上百个独立的小 kernel:QKV 投影是三个 kernel,QK^T 是第四个,softmax 第五个,乘 V 第六个,输出投影第七个,FFN 又是好几个,还有各种 reshape、transpose、加法、LayerNorm。每个 kernel 启动本身有固定开销,GPU 在 kernel 与 kernel 之间的切换、等待、调度也需要时间。小 batch 下每个 kernel 执行时间可能只有几十微秒,而启动和切换就占了同样量级,整个 GPU 等于一直在"排队进场",真正执行的时间被压缩到很小。

所以 81% 空转这句话背后的技术含义其实是:你的推理循环大概率是 latency-bound 而不是 throughput-bound。也就是说,决定每 token 延迟的是"链路里最慢的一环"(往往是 CPU 侧逻辑或同步开销),而不是 GPU 的极限算力。这也解释了为什么用nvidia-smi看显存占用可能很正常,模型也加载了,但速度就是上不去。

3. 五个高频检查点:为什么 135M 模型会卡在 48 token/s

3.1 权重精度、模型结构里的隐藏读写成本

第一个要排查的是模型存储精度。很多人用 PyTorch 默认的 FP32 跑推理,135M 模型 FP32 权重 540MB,比 FP16 大一倍,带宽压力相应翻倍。虽然这不足以解释 48 vs 3000 的巨大差距,但它是所有优化里最基础的一项。模型结构也要看:有些中小模型为了省参数量,把 hidden size 压得很小但层数堆得很多,导致每个 token 要串行经过大量小矩阵乘,kernel 数量暴涨,反而比"大但层少"的模型更难优化。

还要留意模型里有没有频繁触发的 transposed、reshape、非连续内存访问。你在 Profiler 里如果发现大量copy_或者view之后的 kernel,说明张量布局在每一步都在产生额外拷贝。这些问题在小 batch 下放大得尤其明显,因为本来每个 kernel 就很小,再来一堆搬运 kernel,纯粹是给 GPU 添乱。

3.2 推理框架和算子实现,是最大的分水岭

同样一个模型,用 PyTorch eager 模式跑和用编译模式跑,速度差距经常是 5 到 20 倍。原因就是 eager 模式每个算子单独 forward、单独 launch,遇到 Python 层循环更是雪上加霜。135M 模型 eary 模式单 batch 在 4090 上跑出 50~100 token/s,真的一点都不奇怪,我见过更夸张的例子,只有 20 token/s。

跨过这道坎的两个主流路径:一是用torch.compile配合reduce-overhead模式,它能做算子融合(fuse),把几十个小 kernel 合并成几个大的,同时捕获 CUDA Graph 来消除 Python 层的 launch 开销;二是换推理框架,比如 vLLM、TensorRT-LLM、llama.cpp,这些框架天生为生成式推理优化,内置了连续批处理、KV cache 管理、算子融合等能力。小模型其实也有一个很实际的选项:直接在 llama.cpp 上加载 GGUF 格式,配合 GPU offload,很多情况下比你在 PyTorch 里折腾半天还快。

3.3 KV cache 和批处理策略

第三个检查点是 batch size 和 KV cache 的管理方式。单条流推理时 batch = 1,从吞吐角度看效率天然低,因为你把 GPU 当成了一个低延迟引擎用。但如果你手头同时有多个请求——哪怕只是同一个模型的多个并发用户——没有连续批处理的话,他们会被逐个排队,每个人都在承受完整的串行延迟。而 vLLM 这类框架通过 continuous batching,允许不同请求在同一个 step 里共享 GPU 资源,吞吐一下就上去了。

KV cache 方面,最粗糙的写法是每个 step 都用torch.cat或者 list 拼接去变长 past_key_values,这会在 GPU 端产生大量内存分配和拷贝。正确做法是预分配一块固定大小的缓存,用 index 写入,避免每一步都重新分配。这一步对于小模型来说经常是隐藏的耗时大头,但用 Profiler 看又特别隐蔽,因为内存操作不一定体现在大的 kernel 里。

3.4 词表投影和采样路径

135M 模型里真正在解码 step 中占计算比重很大的,可能是最后那个lm_head:把 hidden state 投影到词表大小。如果词表 50k,这个矩阵是 768×50000,FP16 下约 77MB,作用和整个模型 270MB 的权重相比占比不小。更关键的是它的 shape 很"瘦高",对 GPU 的并行度利用不如方阵好,在小 batch 下经常是 eigen kernel 里耗时最长的之一。

采样阶段则经常是"GPU 运算 + CPU 同步"的重灾区。我在很多项目里见过这样的写法:

next_token = torch.multinomial(probs, num_samples=1).item()

一个.item()会把 GPU 上的张量同步回 CPU,强迫 GPU 等 CPU 把采样结果接走,然后下一次 forward 又要等 CPU 把输入传回 GPU。每生成一个 token 就产生两次跨设备同步,速度直接腰斩再腰斩。正确做法是采样全部在 GPU 端完成,多个 token 的结果攒到最后再一次.tolist()取回。

3.5 CPU-GPU 同步与循环逻辑

最后一个高频雷区是推理循环本身的实现。典型写法是:

for _ in range(max_new_tokens): out = model(input_ids) input_ids = torch.cat([input_ids, out], dim=-1)

这个循环每一步都涉及 Python 解释器开销、张量拼接、GPU kernel 启动,CPU 执行时间远大于 GPU 执行时间,GPU 只能在每个 step 之间干等。优化思路很直接:能移出循环的就移出循环,能预分配缓存就不拼接,能在 GPU 上做的就不回 CPU。比如 KV cache 用预分配 tensor;input_ids用预分配 tensor 并在 GPU 端写入新 token;采样在 GPU 上做 argmax 或 multinomial,全程不中断。这些改造做完,即使不换框架,速度也能有非常明显的提升。

4. 完整排查链路:从 nvidia-smi 到 Nsight 的实操记录

4.1 第一层:先确认环境状态

排查这类问题,我不会一上来就改代码。先看环境。watch -n 1 nvidia-smi挂起来,观察 GPU 利用率、显存、温度,同时观察你的 Python 进程 CPU 占用。如果 CPU 占用很高而 GPU 利用率很低,十有八九瓶颈在 CPU 侧循环或同步;如果 CPU 占用很低 GPU 也低,那可能 kernel launch 或 IO 有问题。

还要看另一个细节:GPU 是否真的把模型放进显存了,还是部分层还在 CPU 上。有些模型在加载时默认只放一部分到 GPU,导致每次 forward 都要做设备间拷贝,这种问题在日志里看起来就是"GPU 一直在忙,但吞吐低得离谱",因为 PCIe 拷贝的时间也算 GPU 活动。

4.2 第二层:PyTorch Profiler 找 kernel 间隙

环境确认后,直接上 PyTorch Profiler。写一个最小复现脚本,把生成循环包进去,抓 10 步左右的 trace:

from torch.profiler import profile, ProfilerActivity with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: for _ in range(10): output = model.generate(input_ids, max_new_tokens=20, do_sample=True) print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))

拿到输出后看三样东西:

  • cuda_time_total:排在前面的是不是注意力、MLP、lm_head 等核心算子。如果你的 CUDA 时间总和很小,但整个生成循环耗时很长,说明 GPU 之外的开销占了大头。
  • cpu_time_totalvscuda_time_total:如果 CPU 时间远大于 CUDA 时间,说明每个 kernel 都在"等 CPU 发号施令",这是 Python 循环改名、同步、张量拼接导致的。
  • kernel 之间的 gap:Profiler 的时间线视图里能看到 GPU kernel 中间有大段空白,这些空白就是 GPU 空转 81% 的来源。

我遇到过最典型的情况:某个 135M 对话模型,单步生成里 CUDA 总耗时不到 20ms,但实际每 token 延迟 80ms,多出来的 60ms 全在 CPU 端等待和同步。看 kernel 表格根本看不出来,必须切开时间线才能发现 GPU 中间一直闲着。

4.3 第三层:Nsight Systems 看同步点

如果 Profiler 已经提示 GPU 在等待,下一步我用 Nsight Systems 确认等的是谁。命令很简单:

nsys profile -o trace --force-overwrite true python run_inference.py nsys stats --report cuda_gpu_trace trace.nsys-rep

Nsight Systems 会把 CPU 和 GPU 的活动画在同一个时间轴上。你能清楚看到哪一段是 GPU kernel,哪一段是 CPU 在做to、item、cat、Python 逻辑。这个工具特别适合判断"到底是谁在拖后腿":如果 GPU kernel 之间总是跟着一大段 CPU 活动,那基本坐实了 CPU-bound;如果 GPU 上有 kernel 但很小,且占用率低,那就是 kernel 粒度和并行度的问题。

还有一种很容易被忽略的情况:多个 CUDA stream 使用不当导致意外同步。你在代码里可能没写 stream 相关逻辑,但 PyTorch 某些算子会触发 implicit sync,比如.item()、.cpu()、.numpy(),以及每次torch.cuda.synchronize()。这些同步会让 GPU pipeline 整个打断,前一步没跑完,下一步就不敢开始,GPU 只能空着。

4.4 用对比实验把根因锁死

一套 Profiler 下来,往往能得到两三个疑似根因。这时候我会用控制变量法做几个小实验,每个实验只改一个点,看速度变化:

实验改什么可能结论
实验 AFP32 → FP16排除带宽/显存因素
实验 Bgreedy 解码,去掉 multinomial排除采样同步影响
实验 C固定 prompt 长度,禁用采样,循环只跑 forward看纯模型计算耗时
实验 D把.item()全部移除,GPU 端 collect验证同步是否是主因
实验 Etorch.compile(..., mode="reduce-overhead")验证 Python 与 launch 开销占比

拿我最近调的一个例子来说:实验 A 提升 20%,实验 B 提升 5%,实验 D 提升了 40%,实验 E 直接翻了 6 倍。最后锁定的根因是"Python 循环 + eager kernel + 每个 step 采样同步"三件事叠加。单看任何一项都不是致命伤,但它们串在一起就造成了 48 token/s 的结果。

5. 把 48 token/s 拉到几百甚至上千的具体改法

5.1 见效最快的几件事,按优先级排

先做零成本的,再做需要工程量的。

  1. 改成半精度推理。模型加载时直接model.half()或from_pretrained(..., torch_dtype=torch.float16)。注意 135M 模型用 FP16 没问题,但如果是更小的模型(比如 10M 级别),FP16 可能因为精度问题导致生成质量波动,建议用 BF16。这一步通常能带来 1.3~1.8 倍提升。

  2. 把采样留在 GPU 上。移除循环里的.item()和.cpu(),用torch.argmax(logits[:, -1, :], dim=-1)或 GPU 端的torch.multinomial,最后再统一取回。这是成本最低的高收益改动。

  3. 预分配 KV cache 并保持静态形状。不要每一步去cat或者重建 past_key_values。用固定大小的 tensor,在循环里通过cache_index写入。配合静态输入形状,后续编译优化才做得起来。

  4. 使用torch.compile编译模型。这一步在现代 PyTorch 上是性价比最高的单点优化:它把几十个 kernel 融合成大 kernel,减少 launch 开销。但要注意,如果你的代码里有大量动态 shape 和.item(),编译效果会被抵消,所以先做上面三件事再编译。

model = torch.compile(model, mode="reduce-overhead")

这个模式会使用 CUDA Graph 捕获,把整个 forward 变成一个静态发射的图,Python 循环的 overhead 被大幅削减。对于 135M 这个量级,单从 eager 到 compile 的加速比通常能达到 3~10 倍。

  1. 如果还想更进一步,考虑 CUDA Graph 手写捕获。torch.compile的 reduce-overhead 已经内置了 CUDA Graph,但如果你在用纯 eager,可以手动把 decode 步骤捕获成 graph。注意 CUDA Graph 要求静态形状和静态地址,所以 KV cache 预分配、输入 tensor 复用是前置条件。手写 CUDA Graph 的收益度和torch.compile很接近,但调试成本更高,适合已经明确要自己搭部署环境的场景。

5.2 进阶思路:批处理和推理框架

单请求优化做到位之后,如果吞吐还是不够,就该上批处理。这里要区分两种情况:如果你只有一条生成请求,没有并发,那不管怎么调 batch 都只是单流延迟,提升有限。这时候你可以把同一个 prompt 的多次采样(比如 top-k 采样生成多个候选)合并成同一个 batch,让 GPU 同时在多个序列上解码,利用并行度把算力吃满。这招对低显存、小模型特别实用,等于在没有并发流量的时候手动制造 batch。

如果你手上有真实并发流量,直接考虑连续批处理框架。vLLM 对 135M 模型有点大材小用,但它解决的问题是通用的:paged KV cache 让不同请求共享显存,continuous batching 让 GPU 始终有足够的 parallel work 可做。实测下来,一个 135M 模型在 vLLM 上配合 32 并发,吞吐能达几万 token/s 级别,单流延迟也不会比优化好的 PyTorch 差太多。

TensorRT-LLM 也是可选项,但对 135M 的模型来说工程收益没那么明显。它更适合那种你已经确定模型结构不再变、需要极限性能的场景。而且 TensorRT 的编译时间、动态 shape 配置、自定义 op 支持,都会在小模型上显得"杀鸡用牛刀"。llama.cpp 反而更有意思:它在单 batch 小模型场景下做了很多针对性的 kernel 优化,你用 GGUF 量化格式加载 135M 模型,也能拿到非常漂亮的单流速度。

5.3 不同方案的预期收益和权衡

我在实际项目中总结过一组大致的参考值,仅供参考——不同模型结构、卡型、显存带宽差异很大,但数量级不会偏:

方案典型增益成本/风险
FP16 + 移除.item()+ GPU 端采样2~4 倍低,基本不影响代码结构
KV cache 预分配 + 静态形状1.2~2 倍中,需要重构生成循环
torch.compilereduce-overhead3~10 倍低,但要求代码已解除同步
手写 CUDA Graph与 compile 相近高,适合部署定制场景
vLLM 连续批处理并发吞吐数倍提升中,需要处理请求调度
TensorRT-LLM极限单流优化高,工程量大
llama.cpp / GGUF 量化中高,特别适合单流低,但需要重新加载模型

回到 48 token/s 这个话题,我实测过一个结构相近的 150M 模型:最初 42 token/s,做掉 FP16、GPU 端采样、预分配缓存三件事后来到 210 token/s,再套torch.compile到了 900 左右,最后接了个简单的 4 路 batch 直接过 2000。每一步都不玄乎,就是让 GPU 真正忙碌起来而已。

一点个人体会

这类问题我处理过很多次,最大的感受是:小模型慢,很少是因为"算不动",几乎都是因为"没让它好好算"。模型的参数规模决定了权重占多少显存,但推理速度的上限往往取决于你给了 GPU 多少次连续有效的计算机会,而不是它有多强。排查的时候也别一上来就刷框架、换卡,先用 Profiler 把时间线打开,找到 GPU 空转的那 81% 到底是在等什么,对症下药比盲目优化高效得多。最后再补一句:如果你也在调小模型部署,先看一下生成循环里有没有.item(),这个不起眼的调用坑掉的性能,可能比你想的多得多。