给 vLLM 修了个 bug:没有 GPU,我是怎么定位一个崩引擎的竞态问题的

62 阅读4分钟

给 vLLM 修了个 bug:没有 GPU,我是怎么定位一个崩引擎的竞态问题的

先说结论:这篇讲一个我在 vLLM v1 调度器里发现并修复的真实 bug。我本地没有 GPU,全靠读源码 + reporter 的真机日志定位。适合想给开源大项目贡献代码、但又怕 "没环境复现不了" 的朋友。

一个说崩就崩的引擎

有人报:开了 chunked prefill、多个请求并发时,vLLM 0.29.0 的 EngineCore 随机死掉,所有正在服务的请求一起挂,/health 直接 503。

堆栈就一行:

assert request.num_output_placeholders >= 0   # async_scheduler.py:63
AssertionError

一个计数器被减成了负数。没栈、没空指针,就是一个 "本不该发生" 的不变量被打破。


1. 先搞懂这个计数器是干嘛的

num_output_placeholders 是 vLLM v1 异步调度器的记账核心。

调度器每步排 batch 时,并不知道模型会吐几个 token。它的策略是:先预留占位符,等 worker 真把 token 送回来再扣掉。

# 调度时:预留
request.num_output_placeholders += 每步采样数 + 投机token
# 结果回来时:扣掉
request.num_output_placeholders -= len(new_token_ids)
assert request.num_output_placeholders >= 0

正常账本不会负。一旦「送回来的 token 数 > 预留的占位符数」,就 underflow。

2. 从崩溃日志读出真相

reporter 在崩溃点打了日志:

placeholders=-1  new_tokens=1  computed=8448  num_tokens=9119
prefill_chunk=True

三个关键事实:

  • computed=8448 < num_tokens=9119 → 这个请求还在 prefill,根本没开始生成
  • placeholders=0 → 调度器一个占位符都没给它留
  • new_tokens=1 → worker 却真送回来一个 token

调度器源码里有句注释写明契约:还在 prefill 的请求,worker 不应该返回 token。但它返回了。

3. 根因:同一个事实,两边各算一遍

这是整个 bug 最值得品的地方。

调度器判断 "还在不在 prefill",读的是自己这边的状态:

is_prefill_chunk = computed < num_tokens + placeholders

GPU worker 判断 "这个 token 要不要丢",读的是镜像过去的另一份状态,公式还不一样:

discard = computed + scheduled < num_tokens
判断公式读哪边状态
调度器(预留)computed < num_tokens + placeholders调度器自己
Worker(丢弃)computed + scheduled < num_tokensworker 镜像

两边靠 "应该永远一致" 维系,代码里没有任何地方校验。并发下两份副本短暂错位时:worker 以为 prefill 完了、采样个 token 送回来;调度器这边却还认为它在 prefill、占位符是 0。账本当场崩。

4. 没有 GPU,怎么改才对

真正的根因是跨进程、跨设备的状态一致性问题,不是一个小 PR 该重写的。

但 "worker 多送一个 token 就崩整个引擎、连坐所有在线用户" 是绝不该发生的。所以这个 PR 做防御性降级:

if len(new_token_ids) > request.num_output_placeholders:
    logger.warning_once("...丢弃多余 token...")
    new_token_ids = new_token_ids[:request.num_output_placeholders]

两个细节:

  • 为什么是丢 token 而不是补占位符? 因为这个 token 是从一个还没 prefill 完的前缀上采样的,它根本不是合法续写,append 给用户等于流垃圾。丢弃本身就是正确性修复。
  • 为什么用 warning_once ? reporter 实测这条件每步都触发,一次负载打了 44 行日志。按次打 warning 会淹掉生产日志,所以聚合告警、细节走 debug。

测试上我故意写了个 "给每个请求都返回 token" 的 mock—— 它不去复刻 worker 的丢弃逻辑。因为如果 mock 把 worker 的判断也模拟对了,就等于在测试里假设 "两边永远一致",把这个 bug 假设没了。

5. 没 GPU?那就请 reporter 帮你跑

我把 PR 里 "需要 GPU 验证健康路径不误触发" 这格诚实留空,写明 "本机无 GPU,以下只是 CPU 单测结论"。

reporter 看到后,自己在复现机器(Azure H100 NVL 94GB,机密计算模式)上打 patch 跑了一遍,结果回给我:

  • 打 patch 前:~2 秒崩,health 503
  • 打 patch 后:两轮 12 个请求全成功,引擎零重启
  • 42 次丢弃全发生在占位符为 0 时,健康 decode 一次没误触

这份数据我原样写回 PR,标注 "是他的测量,不是我的"。

承认自己环境做不到,比假装做过了,更容易赢得 maintainer 信任。

这个 bug 教会我的四件事

  1. 分布式系统里,两边各维护一份副本,几乎必然是 bug 温床—— 靠 "应该一致" 维系的状态,早晚会错位。
  1. 生产代码里 assert 一个不变量,等于把小问题升级成大事故。一个脏 token,因为一句 assert,变成踢掉所有在线用户。
  1. 没有复现环境 ≠ 不能贡献。读源码定位根因、写 CPU 单测覆盖逻辑、把硬件验证诚实交给 reporter,一样是高质量 PR。
  1. 提交前先查重。我搜了 14 个动这个计数器的 open PR,确认没人在改同一条路径,才动手。

有在给 vLLM / SGLang 这类推理引擎贡献过代码的朋友吗?欢迎评论区交流你们的第一次 PR 经历。