给 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_tokens | worker 镜像 |
两边靠 "应该永远一致" 维系,代码里没有任何地方校验。并发下两份副本短暂错位时: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 教会我的四件事
- 分布式系统里,两边各维护一份副本,几乎必然是 bug 温床—— 靠 "应该一致" 维系的状态,早晚会错位。
- 生产代码里 assert 一个不变量,等于把小问题升级成大事故。一个脏 token,因为一句 assert,变成踢掉所有在线用户。
- 没有复现环境 ≠ 不能贡献。读源码定位根因、写 CPU 单测覆盖逻辑、把硬件验证诚实交给 reporter,一样是高质量 PR。
- 提交前先查重。我搜了 14 个动这个计数器的 open PR,确认没人在改同一条路径,才动手。
有在给 vLLM / SGLang 这类推理引擎贡献过代码的朋友吗?欢迎评论区交流你们的第一次 PR 经历。