「地狱空荡荡」藏在采样器里的 CPU fallback 引发的 72 倍吞吐暴跌

一、引言

一次常规的 256 并发回归压测中,Qwen3.8-27B 在昆仑芯 P800 上的 decode 吞吐停滞在 32 tok/s 量级。同配置权威口径 short 512/512×256 应为 3201.71 output tok/s,稳态峰值可达 4851 tok/s。故障值不足权威值的百分之一。

监控给出的现场更矛盾:GPU 利用率 0%,每个 step 耗时 4.89 秒。计算设备没有在计算,时间却实实在在流走了。吞吐、利用率、单步耗时三个指标互相矛盾,指向一个容易被忽略的方向——CPU 侧的执行与同步。最终定位的结果同样出乎意料:问题不在 GEMM,不在 attention,而在 TopKTopP 采样器里一个不起眼的 exponential_ 算子。地狱空荡荡,魔鬼在采样器里。

二、背景

部署环境如下表所示。

项目配置
模型Qwen3.8-27B
硬件昆仑芯 P800(8×96G)
并行TP=8
框架vllm-kunlun
并发256
权威吞吐3201.71 output tok/s(short 512/512×256)
稳态峰值4851 tok/s

该口径用于评估 decode 阶段在长并发下的服务能力。decode 阶段每生成一个 token 都需要经过一次完整的前向计算与一次采样。TopKTopP 采样位于单步链路的末端,负责把 logits 转换为下一个 token 的选择。它的计算量远小于 GEMM 与 attention,通常不会进入性能分析的视野。

XPU 生态在算子覆盖上与成熟 GPU 生态存在差异。部分算子缺少设备端实现时,框架会选择 CPU fallback 完成计算。CPU fallback 本身不报错,只在性能上留下痕迹。这类痕迹在低并发下不明显,在 256 并发的大 batch 下会被放大到不可忽视的程度。

三、排查:从利用率到单步耗时,再到一个算子

3.1 先看利用率:0% 的 GPU

吞吐异常后,第一项检查是设备利用率。故障期间 GPU 利用率为 0%。设备端若在计算,利用率不可能为 0%。step 时间要么被 CPU 计算吃掉,要么设备在等待 CPU 的同步信号。这条线索排除了大 kernel 的性能退化,把嫌疑指向 CPU 侧。

3.2 再看单步耗时:4.89 秒

第二项检查是单步耗时。故障期间每个 step 耗时 4.89 秒。256 batch 的 decode 单步在设备端采样正常时应在毫秒级。4.89 秒的单步与 0% 的利用率组合在一起,构成 CPU fallback 的典型指纹:设备空闲,CPU 忙,同步点在 step 内反复触发。

3.3 二分算子:锁定 exponential_

第三项检查是逐算子计时。按 step 内各阶段分段统计后,热点收敛到 TopKTopP 采样器。采样器在处理 256 batch × 152K vocab 时,q.exponential_() 走了 CPU fallback。该算子用于生成指数分布噪声,为采样提供随机扰动。故障路径下,每个 step 都要把约 3891 万个元素(256 × 152000)的指数采样交给 CPU 完成,并在计算前后各触发一次设备与主机之间的同步。

根因确认:采样器在没有 top_k/top_p 过滤参数时(k/p 均为 None),没有进入设备端采样路径,指数分布采样退化为 CPU 算子。CPU 计算加同步开销吃掉了全部 step 时间。这一次「背刺」藏在采样器深处,不在 GEMM,不在 attention。

四、修复:把采样路径拉回设备端

修复分两条路径,语义保持一致。

  1. 无 generators 时,一律走设备端 flashinfer_sample。k/p 为 None 时内部补齐 top_k=vocab、top_p=1.0,保证无过滤语义不变,采样全程留在设备端。
  2. generators 分支改用 uniform_ + (-log) 的等价指数噪声,保持统计等价,避免 exponential_ 的 CPU 回退。

top_k=vocab 表示候选集全部保留,等价于不启用 top-k 过滤;top_p=1.0 表示累计概率阈值为 1,等价于不启用 top-p 过滤。两者组合后语义与 k/p 均为 None 一致。generators 分支的替换基于逆变换采样:若 U 服从 (0,1) 均匀分布,则 -ln U 服从参数为 1 的指数分布,uniform_、log_、neg_ 三个设备端算子组合即可得到等价噪声。

示意代码如下。

# 修复前:k/p 均为 None 时走 CPU fallback
if k is None and p is None:
    q.exponential_()          # CPU fallback,触发同步

# 修复后
if generators is None:
    # 无过滤时内部补齐,全程设备端
    flashinfer_sample(q, top_k=vocab, top_p=1.0)
else:
    # uniform_ + (-log) 等价指数噪声,统计等价
    q.uniform_()
    q.log_()
    q.neg_()

修复后同场景恢复 3201.71 output tok/s。对比故障值 32 tok/s,约 72 倍提升。对比口径需要说明:32 是故障值,3201.71 是修复后的权威压测值,72 倍是故障态到正常态的恢复幅度,并非优化后的额外收益。

状态decode 吞吐GPU 利用率单步耗时
故障态32 tok/s0%4.89 s
修复后3201.71 tok/s正常毫秒级
恢复幅度约 72×——

五、方法论:XPU/GPU 推理调优的通用排查范式

本次排查的路径可以抽象为三步,适用于 XPU 与 GPU 推理部署的性能定位。

  1. 先看利用率。GPU 0% 与 step 慢同时出现,优先怀疑 CPU fallback 或同步问题,而不是大 kernel 的性能退化。
  2. 再看单步耗时。单步耗时用于确认热点在设备端还是 CPU 侧。设备空闲而单步慢,指向 CPU 路径;设备忙碌而单步慢,才回到 kernel 层面的分析。
  3. 二分算子。逐算子计时,把热点从整条 step 链路收敛到具体算子。同步点、采样、后处理等小环节需要纳入计时范围。

实践中存在几个常见误区。只盯吞吐不看利用率,容易把 CPU 侧问题误判为设备算力不足。只盯大 kernel,容易漏掉采样与同步环节。把 CPU fallback 当成正确性问题的衍生物而不追查性能影响,会让故障在高并发下反复出现。故障点往往不在 GEMM 或 attention,而在采样、后处理、同步等小环节。一个 exponential_ 的 CPU fallback,足以让 3201 tok/s 的吞吐跌到 32 tok/s。

六、结论

地狱空荡荡,魔鬼在采样器里。性能治理需要把设备端路径当成一条完整链路来审视,任何一处 CPU 回退都可能成为系统瓶颈。修好这个采样器回退后,同场景恢复 3201.71 output tok/s,约 72 倍于故障值。下一次 GPU 利用率为 0% 而 step 慢时,先怀疑那个不起眼的采样器。