[Perf] ~2x decode throughput regression for structured outputs since #45424: apply_grammar_bitmask staging rewrite (bisected to commit, file

该问题通常出现在 vLLM v0.24.0 / v0.25.1 上运行 xgrammar 结构化输出(guided JSON)时,引擎每步在 V1/V2 runner 里重新分配并 pin 一份 12.6 MB 级别的 bitmask staging buffer,导致含结构化输出的 batch 解

快速结论:该问题通常出现在 vLLM v0.24.0 / v0.25.1 上运行 xgrammar 结构化输出(guided JSON)时,引擎每步在 V1/V2 runner 里重新分配并 pin 一份 12.6 MB 级别的 bitmask staging buffer,导致含结构化输出的 batch 解码吞吐下降(仓库 A100 环境约 2x,容器受限 CPU 环境下可放大到 5.4x)。优先排查 apply_grammar_bitmask staging 路径与 CPU 线程数是否与 cgroup quota 匹配。

适用环境:Issue 已确认:vLLM v0.23.0(基线正常)vs v0.24.0 / v0.25.1(回归);A100 80GB PCIe;torch 2.11.0 (cu13);Python 3.12;Gemma-4-26B-A4B-IT bf16、max_num_seqs=64、ngram spec decode k=5、CUDA graphs、chunked prefill 8192、TRITON_ATTN;离线 in-process LLM engine。独立复现环境:KServe Pods、1×B300、TP=1,gemma-4-31B-it(bf16 与 NVFP4)、gemma-4-12B-it bf16,v0.25.1 生产 traffic、单一 JSON schema、temperature=0。

最快修复方案:Issue 评论中已验证的可用 workaround 是在 v0.25.1 上设置 OMP_NUM_THREADS 等于 cgroup CPU 配额(例如 cpu.max = 1200000 100000 时设为 12),可在不回滚任何代码的情况下消除回归(guided p50 从 7.49s 回到 1.07s,decode 从 115 回到 738 tok/s)。若无法调整线程数,回滚到 v0.23.0 也可立即恢复(评论中实测 guided 回到 1.66s)。

注意事项:OMP_NUM_THREADS 只缓解症状,并未修复 per-step pinned 分配本身;回滚 v0.23.0 会丢失后续版本改动。Issue 提到正式修复方向是 PR #49919,另有 #49150 / #49168 仅覆盖 V1 runner,V2 runner 需单独 patch。未验证版本上这些方案是否生效需自行复测。

问题场景

在 vLLM v0.24.0 / v0.25.1 上跑 every-request guided JSON(output_format=json_schema → xgrammar 后端)时,端到端 decode 吞吐相比 v0.23.0 明显下降。仓库作者的离线 harness(A100 80GB、Gemma-4-26B-A4B-IT bf16、max_num_seqs=64、ngram spec decode k=5、CUDA graphs、全部请求为结构化输出)在 2000 请求 drain 上从 513s 掉到 985s;生产语料(14,985 请求)从 v0.21.0 的 3810s 掉到 v0.24.0 的 7581s。独立复现(KServe、1×B300、TP=1、gemma-4-31B-it / gemma-4-12B-it)显示在并发 ≤1 时 guided ≈ free,只在 concurrency 8+ 时崩塌;换 warm grammar cache 同样慢,说明不是 grammar 编译耗时,而是每步 decode 路径问题。

报错原文

[Perf] ~2x decode throughput regression for structured outputs since #45424: apply_grammar_bitmask staging rewrite (bisected to commit, file, and hunk)

Issue 中同时给出的关键回归数据:

| build                              | drain time | rate       |
|------------------------------------|------------|------------|
| 8dd1b702f (parent commit)          | 513 s      | 233 req/min|
| 7df3d7dad (culprit)                | 985 s      | 121 req/min|
| 7df3d7dad + ONLY structured_output/utils.py reverted | 534 s | 224 req/min |
| v0.24.0 release                    | 7581 s vs 3810 s on 15k corpus | 2.0x slower |
| host cores | torch threads        | free p50 | guided p50      | guided out-tok/s |
|------------|----------------------|----------|-----------------|------------------|
| 40         | 40                   | 1.41s    | 2.18s (+55%)    | 384              |
| 172        | 172                  | 1.12s    | 7.49s (+569%)   | 115              |
| 172        | 12 (OMP_NUM_THREADS) | 1.11s    | 1.07s           | 738              |

原因分析

回归被 bisect 到 commit 7df3d7dada840c68b85b26b79de7f59f676d58e3「[Core] Ensure memory is pinned prior to async h2d copy」(#45424),并进一步定位到单一文件 vllm/v1/structured_output/utils.py 中的 apply_grammar_bitmask

该 commit 把原来的 numpy staging:

sorted_bitmask = np.full(
    shape=(logits.shape[0], grammar_bitmask.shape[1]), fill_value=-1,
    dtype=grammar_bitmask.dtype,
)
grammar_bitmask = torch.from_numpy(sorted_bitmask).to(logits.device, non_blocking=True)

改成基于 pinned tensor 的 staging:

sorted_bitmask_tensor = torch.full(
    (logits.shape[0], grammar_bitmask.shape[1]), -1,
    dtype=torch.from_numpy(grammar_bitmask[:0]).dtype,
    pin_memory=PIN_MEMORY,
)
sorted_bitmask = sorted_bitmask_tensor.numpy()
grammar_bitmask = sorted_bitmask_tensor.to(logits.device, non_blocking=True)

对 262k vocab、max_num_seqs=64、k=5 ngram 推测,这相当于每个 engine step 新分配一份约 (384 × 8192) int32 ≈ 12.6 MB(通常还会被 pin)的 host tensor,并把 reorder loop 写进 pinned buffer 的 numpy view,步末再释放。所有含结构化输出请求的 batch 每一步都会走这条路径。Issue 正文中有一行关键对照:7df3d7dad + PIN_MEMORY forced False 仍是 985s / 121 req/min,说明代价来自 staging 重写本身,而不只是 pin 标志;仅恢复 staging block(保留同一函数里 index_tensor via async_tensor_h2d 的改动)即可完全恢复 514s / 233 req/min。

V2 runner 路径在同一时间窗引入了同类改动,位于 vllm/v1/worker/gpu/buffer_utils.py

# v0.23.0
#   Copy directly to GPU — explicit pin_memory() causes sporadic stalls
#   under high concurrency due to CUDA driver contention. The driver
#   handles the transfer efficiently without manual pinning.
return out.copy_(x, non_blocking=True)

# v0.25.1 (#45424)
# pin_memory() is no-op if the memory is already pinned.
pinned = x.pin_memory()
return out.copy_(pinned, non_blocking=True)

评论指出该注释原本来自 #37006「[V1] Remove pin_memory() in async_copy_to_gpu to fix sporadic stalls」,其目的正是移除这次调用,#45424 又把它加回来,属于对已合入修复的静默回退。「no-op if already pinned」的假设在此路径上不成立:grammar_bitmask 是 scheduler 每步新建的 np.ndarraypin_memory() 就是一次真实分配加拷贝。该调用只在 guided 路径生效(apply_grammar_bitmaskif not grammar_req_ids 时提前返回),所以 free decoding 不受影响。

至于严重程度差异:torch.get_num_threads() 取自宿主核心数、忽略 cgroup CPU quota,在 172 核 / 12-CPU quota(cpu.max = 1200000 100000)的节点上相当于 14.3x 超额订阅,per-step pinned memcpy 展开到这些线程上持续触发 CFS 限流。这解释了同一个 commit 在不同部署下表现为 2x 到 5.4x 的差异。

环境排查

  • vLLM 版本:确认是否处于 v0.24.0 / v0.25.1;若为 v0.23.0 则不属于本回归窗口。
  • 请求特征:是否 every request 使用 guided JSON(output_format=json_schema)或其它 xgrammar 结构化输出;free decoding 是否不受影响。
  • 并发级别:回归是否只在 concurrency 8+ 出现,concurrency 1 是否接近 free 基线。
  • Runner 路径:确认模型走 V1 还是 V2 runner(例如 gemma-4-31B-it 在 v0.23.0 → v0.25.1 间因 V2 eligibility 检查变化从 V1 迁移到 V2);V2 对应 vllm/v1/worker/gpu/buffer_utils.py,V1 对应 vllm/v1/structured_output/utils.py
  • CPU 线程与 cgroup 配额:检查宿主核心数与容器 cpu.max,对比 torch.get_num_threads() 是否远超 quota。
  • Python 3.12 / torch 2.11.0 (cu13) / CUDA 环境是否与 Issue 一致;A100 80GB PCIe 或 B300 等硬件均可复现。
  • 模型与量化:Gemma-4-26B-A4B-IT bf16、gemma-4-31B-it(bf16 / NVFP4)、gemma-4-12B-it bf16 均在 Issue 中出现。
  • spec decode / CUDA graphs:Issue 指出 regression 在 CUDA graphs on/off、甚至 0.25.1 无 ngram 时同样存在,可据此排除这些因素。

解决步骤

  1. 先确认症状是否匹配:在相同 harness / 相同 batch 占用下比较 free decoding 与 guided JSON 的 p50,若 guided 只在 concurrency 8+ 崩塌而 free 基本不变,则与本 Issue 吻合。
  2. 可优先尝试 workaround:在 v0.25.1 部署上设置 OMP_NUM_THREADS 等于容器 CPU quota(评论示例为 12)。评论中该设置把 guided p50 从 7.49s 降到 1.07s,decode 从 115 提升到 738 tok/s;在生产 gemma-4-31B bf16 部署上 guided p50 从 6.10s 降到 1.72s,decode 从 117 升到 398 tok/s,且不再改动其他配置。
  3. 若不能调整线程数,评论中验证过的替代是回滚镜像到 v0.23.0(参数不变),guided 立即恢复到 1.66s。
  4. 如需代码级修复:关注 PR #49919(Issue 作者预期由此修复);该 PR 与 #49150 / #49168 互补,后两者只改 V1 的 vllm/v1/structured_output/utils.py
  5. 若你的模型走 V2 runner,注意 #49150 / #49168 不覆盖 V2;评论中已另开 PR 针对 V2 路径,把两次 per-step copy 都经由预分配的 CpuGpuBuffer staging,同时保留 #45424 要求的「源必须 pinned」属性并去掉 per-step 分配。在未合入前,V2 用户只能依赖 OMP_NUM_THREADS 或回滚版本。
  6. 若自行打补丁,方向是恢复 numpy staging,或保留一个按 (max_logits_rows, bitmask_width) 预分配并可复用的 pinned staging buffer,而不是每步新分配;不要只把 PIN_MEMORY 强制为 False,Issue 已验证这一项不恢复吞吐。

验证方法

使用与基线相同的 harness、相同 batch 占用、相同模型与请求集,分别跑 v0.23.0 基线、v0.25.1(未处理)和 v0.25.1(应用 workaround 后),对比 guided JSON 的 p50 与 decode out-tok/s。Issue 中的判据是:guided p50 回到与 free 大致持平(评论里为 1.07s vs 1.11s),或 2000 请求 drain 时间回到 513s / 233 req/min 量级。另需确认请求质量未受影响(Issue 复现中 300/300 valid JSON、无 finish_reason=length),因为该问题只影响耗时、不影响输出正确性。

参考来源

vllm-project/vllm #49013

相关 PR / Issue:#49919#49150#49168#47047(可能相关,未确认)。

GamsGo AI

AI 工具推荐

想把多个 AI 模型放在一个入口?

GamsGo AI 集成 ChatGPT、DeepSeek、Gemini、Claude、Midjourney、Veo 等常用模型,适合写作、绘图、视频和日常 AI 工作流。

了解 GamsGo AI

推广链接:通过此链接购买,我可能获得佣金,不影响你的价格。

这个方案解决了吗?

celebrityanime
celebrityanime
文章: 22950

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注