契机
生产环境中的vLLM推理服务突然假死:进程存活、显存正常占用、健康检查端点返回正常,但所有推理请求全部超时。GPU-util为0,CPU拉到100,极端环境出现,特指高并发
环境说明
-
推理框架:vLLM-v0.23.0
-
模型:多模态大模型
-
驱动:Driver Version: 575.57.08
-
cuda:release 12.9, V12.9.86
-
启动参数:
VLLM_LOGGING_LEVEL=DEBUG \ PYTHONUNBUFFERED=1 \ CUDA_VISIBLE_DEVICES=x \ nohup /home/conda/envs/vllm/bin/vllm serve /home/models/xxxx \ --served-model-name xxxx \ --host 0.0.0.0 \ --port 20100 \ --trust-remote-code \ --enable-prefix-caching \ --gpu-memory-utilization 0.9 \ --uvicorn-log-level debug \ --log-error-stack \ --enable-log-requests \ --enable-log-outputs \ --enable-request-id-headers \ --max-log-len 2000 \ --enable-logging-iteration-details \ > vllm_debug.log 2>&1 & -
运行时长:服务已稳定运行数天(约 100 万次迭代),突发假死
这里多说一嘴,由于我是12.9的驱动所以装vllm相当吃力,生产环境又不敢升级驱动,如果你刚好也是12.x驱动,一定要求https://vllm.ai/找安装方法,并且安装过程需要把加速源全部禁用,安装过程中观察下载连接和地址,一定不要从镜像下载!
pip install vllm --extra-index-url https://wheels.vllm.ai/0.24.0/cu129 --extra-index-url https://download.pytorch.org/whl/cu129 --index-strategy unsafe-best-match
故障现象
| 检测项 | 状态 | 备注 |
|---|---|---|
| 进程 PID | 存活 | EngineCore |
| GPU 显存 | 正常 | 正常 |
| GPU 利用率 | 0% | 异常 |
| CPU 利用率 | 136% | EngineCore 进程,异常高 |
/health 端点 |
200 OK | 正常返回 |
/v1/models 端点 |
200 OK | 模型列表正常 |
/v1/chat/completions |
超时 | 15s+ 无响应 |
诊断过程
外部观测
yaml
# 1. GPU状态
nvidia-smi
# GPU : 显存 xxxxMiB / 利用率 0%
# 2. 进程CPU占用
top -H -p 3791245
# EngineCore 子进程 CPU 136%,远超正常值
# 3. 健康检查
curl -s http://0.0.0.0:20100/health
# 返回 OK
# 4. 推理请求
curl -s --max-time 15 http://0.0.0.0:20100/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{...}'
# 超时,无响应
服务处于假死状态------http层存活但调度引擎冻结
debug日志分析
yaml
# 5. 查找最后一次正常推理
grep -a "generation tokens" debug.log | tail -20
# 最后一次有实际生成吞吐:00:30:42,之后全部归零
# 6. 查找EngineCore最后一条日志
grep -a "EngineCore" debug.log | tail -10
# 最后一条:00:30:33 "EngineCore waiting for work"
# 之后 EngineCore 再无任何输出
EngineCore在00:30:33之后完全静默,主循环停止运转
Metrics端点
yaml
# 7. 查看vLLM内部指标
curl -s http://0.0.0.0:30000/metrics | grep -E "running|waiting|queue"
# vllm:num_requests_running: 0
# vllm:num_requests_waiting: 0
请求已被APIServer接收(日志中可见 "Added request"),但EngineCore的调度器显示 Running: 0, Waiting: 0 ------ 请求在到达调度器之前就消失了。
py-spy抓栈
yaml
# 8. 安装 py-spy(不依赖 ptrace,通过 /proc 读取)
pip install py-spy
# 9. 抓取 EngineCore 进程的 Python 调用栈
py-spy dump --pid 3794291 --locals
#Thread-2持有GIL,在popitem中死循环。由于CPython的GIL机制,该线程阻塞了EngineCore所有其他Python线程
Thread 3821499 (active+gil): "Thread-2 (process_input_sockets)"
popitem (vllm/utils/cache.py:196) !!卡在这里!!
__setitem__ (cachetools/__init__.py:82)
__setitem__ (cachetools/__init__.py:294)
get_and_update_item (vllm/multimodal/cache.py:662)
get_and_update_features (vllm/multimodal/cache.py:607)
preprocess_add_request (vllm/v1/engine/core.py:829)
process_input_sockets (vllm/v1/engine/core.py:1528)
strace确认
yaml
# 10. strace跟踪EngineCore子进程
strace -p 3794291 -f -e trace=futex -c
# 3 秒内仅 8 次 futex 调用(微秒级)
# 其余时间全部在用户态Python代码中空转
确认纯用户态 CPU 消耗,没有系统调用阻塞,是Python死循环
源码分析
yaml
# cachetools/__init__.py:82
class Cache:
def __setitem__(self, key, value):
...
while self.__currsize + size > self.maxsize:
self.popitem() # ← 调用 vllm 重写的 popitem
# vllm/utils/cache.py:196
def popitem(self, remove_pinned=False):
lru_key = next(key for key in self.order if key not in self.pinned_items)
value = self.pop(lru_key) # ← pop 一个不在实际缓存中的 key
return (lru_key, value)
# 问题:pop() 对不存在的 key 返回 None,currsize 没有减少
# 回到 cachetools 的 while 循环:currsize + size > maxsize 仍为真
# → 再次 popitem → 再次失败 → 死循环
刚好去搜索日志中的异常
yaml
grep -a "AssertionError\|Expected a cached" debug.log
#找到报错日志
(EngineCore pid=3794291) ERROR 06-29 00:28:41 [v1/engine/core.py:1616]
AssertionError: Expected a cached item for mm_hash='a874ce...4739d0'
结论:
- touch() 方法(cache.py#L120-L124)对不存在的 key 将其写入内部 LRU 顺序链表 __order,但不写入实际缓存数据 _Cache__data
- get_and_update_item()(cache.py#L660)在 assert 失败时,setitem 未被执行,但 touch() 已写入的幽灵 key 残留在 __order 中
- 后续任意请求触发缓存写入 → setitem 进入驱逐循环
- popitem() 从 __order 取出幽灵 key → pop() 找不到实际数据 → currsize 不减 → 死循环
验证复盘
yaml
# vLLM GitHub Issue #43941
# 标题: "Bug: Infinite loop in EngineCore during multimodal cache eviction"
# 状态: 已关闭,合入 PR #43595
# 修复版本: v0.24.0
完全匹配的已知 Bug,与我们的 py-spy 堆栈、日志、时间线一致
时间线:
00:28:41 多模态缓存 AssertionError 触发
└→ touch() 已将幽灵 key 写入 __order
└→ assert 失败,__setitem__ 未执行,幽灵 key 残留
00:28:41 引擎继续处理其他请求(幽灵 key 暂不影响)
00:30:33 某请求触发缓存写入 → __setitem__ 进入驱逐循环
└→ popitem() 取出幽灵 key
└→ pop() 返回 None,currsize 未减少
└→ while 循环条件永真 → 死循环
└→ Thread-2 持有 GIL,EngineCore 完全冻结
00:30:33+ 所有后续请求均失败,客户端超时
| 位置 | 代码 | 问题 |
|---|---|---|
| cache.py#L120-L124 | touch() 的 except 分支 |
将不存在的 key 写入 __order,产生幽灵 key |
| cache.py#L196-L208 | popitem() 驱逐逻辑 |
未检查 key 是否真实存在于缓存中 |
| cache.py#L658-L664 | get_and_update_item() |
touch() 和 __setitem__ 之间非原子,异常导致不一致 |
修复方案
本着不升级vllm的方案,我直接去改conda环境中包的源码
touch-不存在的key不操作
# 原代码(L120-L124)
def touch(self, key: _K) -> None:
try:
self._LRUCache__order.move_to_end(key)
except KeyError:
self._LRUCache__order[key] = None # ← 幽灵 key 来源
# 修复后
def touch(self, key: _K) -> None:
if key in self:
self._LRUCache__order.move_to_end(key)
popitem---跳过并清理幽灵key
# 原代码(L196-L208)
def popitem(self, remove_pinned: bool = False):
if not remove_pinned:
lru_key = next(
(key for key in self.order if key not in self.pinned_items),
ALL_PINNED_SENTINEL,
)
if lru_key is ALL_PINNED_SENTINEL:
raise RuntimeError(...)
else:
lru_key = next(iter(self.order))
value = self.pop(cast(_K, lru_key))
return (lru_key, value)
# 修复后
def popitem(self, remove_pinned: bool = False):
while True:
if not remove_pinned:
lru_key = next(
(key for key in self.order if key not in self.pinned_items),
ALL_PINNED_SENTINEL,
)
if lru_key is ALL_PINNED_SENTINEL:
raise RuntimeError(...)
else:
lru_key = next(iter(self.order))
if lru_key in self: # ← 仅驱逐真实存在的数据
value = self.pop(cast(_K, lru_key))
return (lru_key, value)
self._LRUCache__order.pop(lru_key, None) # ← 清理幽灵 key
两层防御:改动1从源头杜绝幽灵key产生,改动2确保即使有其他路径产生幽灵key,驱逐时也不会死循环
对应上游PR:vllm-project/vllm#43595,已合入v24
复现路径
- vLLM v0.23.0 或更早版本
- 使用多模态模型(视觉-语言模型),启用prefix caching
- 在缓存接近容量上限时,发送一条触发了多模态hash不存在于缓存的请求
yaml
# 1. 启动 vLLM 服务(使用 v0.23.0)
vllm serve <多模态模型路径> \
--host 0.0.0.0 --port 20100 \
--enable-prefix-caching \
--gpu-memory-utilization 0.9
# 2. 发送大量多模态请求填满缓存
for i in $(seq 1 500); do
curl -s http://0.0.0.0:20100/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"...","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"video_url","video_url":"<video_path>"}]}]}' &
done
# 3. 发送触发幽灵 key 的请求
# 需满足:mm_hash 在 cache 的 __order 中但不在 _Cache__data 中
# 实际场景中较难精确控制,高并发下概率触发
# 4. 验证假死
curl -s --max-time 10 http://0.0.0.0:20100/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"...","messages":[{"role":"user","content":"hello"}]}'
# 预期:超时
# 5. 验证根因
py-spy dump --pid <EngineCore_pid> --locals
# 预期:Thread-2 卡在 vllm/utils/cache.py popitem()
单元测试如下
yaml
# 模拟 touch() 产生的幽灵 key
from vllm.utils.cache import LRUCache
cache = LRUCache(capacity=100, getsizeof=lambda x: 10)
cache["real_key"] = "real_value"
# 模拟 touch() 对不存在的 key 写入 __order
cache._LRUCache__order["ghost_key"] = None
# 填满缓存触发驱逐
for i in range(9):
cache[f"key_{i}"] = f"value_{i}"
# 此时再次 setitem 会触发 popitem()
# 如果 popitem 没有防御性检查,将死循环
try:
cache["new_key"] = "new_value"
except Exception:
pass # 在修复前会死循环
总结
