出现问题的场景
vLLM 0.18+ 启用了 KV Cache Offloading:当 GPU 内存不足时,部分 KV blocks 被卸载到 CPU。当请求重新到达时,需要从 CPU 加载回 GPU。
当 50 个并发请求共享同一个 10,000-token prefix,且 blocks 正在从 CPU 加载时,scheduler 的 _lookup() 对后续请求返回 None,导致它们全部被放回等待队列,等第一个 load 完成才能继续。
性能影响(2× L40S, Qwen3-8B, concurrency=50):
- TTFT 膨胀 12 倍(11.46s vs 0.90s)
- 吞吐量下降 34%
解决的方案
核心改动
在 OffloadingConnectorScheduler._lookup() 中:
# 旧逻辑:发现 blocks 正在加载 → 延迟整个请求
if any(key in self._blocks_being_loaded for key in offload_keys):
return None
# 新逻辑:引入压力感知门控
if any(key in self._blocks_being_loaded for key in offload_keys):
if self._gpu_kv_cache_under_pressure:
return None # GPU 有压力 → 等待 load 完成
else:
return 0 # GPU 无压力 → 跳过 CPU hit,直接 prefill
压力标志的实现
- 设置:在
update_state_after_alloc()中,当num_external_tokens > 0时设为True - 重置:在
build_connector_meta()开头设为False(每个 schedule step)
作者的意图
- GPU 缓存充裕时:blocks 被 offload 到 CPU 只是备份,GPU 里还有 → 不需要等 load,直接 prefill
- GPU 缓存不够时:blocks 已从 GPU evict → 等 load 完成能避免重复计算
问题分析
1. 压力判断不严谨
num_external_tokens > 0 的含义是 “有请求需要从 CPU 加载”,不等于 “GPU 内存不够了”。
Blocks 可能既在 GPU 里,也被 offload 到 CPU 做备份。此时”需要 CPU 加载”不意味着 GPU 一定不够用。
2. 重置时机与调度时序脱节
调用顺序:
schedule() 开始
→ 循环处理 Request 1-N
→ _lookup() ← 决策点,检查压力标志
→ allocate_slots()
→ update_state_after_alloc() ← 设置压力标志
→ build_connector_meta() ← 重置压力标志
问题:build_connector_meta() 在每个 step 末尾重置压力标志,而下一个 step 开头又是 False。这意味着:
Step 1: 压力=False (reset from Step 0)
Request 1: 压力=False → return 0 → 放行 → update 后压力=True
Request 2-50: 压力=True → return None → 全部延迟
build_connector_meta → 压力=False
Step 2: 压力=False (刚被重置)
Request 2: 压力=False → return 0 → 放行 → update 后压力=True
Request 3-50: 压力=True → return None → 全部延迟
本质上每个 step 只能放行一个请求,只是从”全等”改成了”一个一个放”。
3. 并发 prefill 制造恶性循环
当压力标志为 False 时,多个请求被放行并执行 prefill:
Step 1: 压力=False
50 个请求全部被放行 → 同时 prefill → 分配大量 GPU blocks
→ GPU 内存耗尽 → 触发 eviction
→ 更多 blocks 被存到 CPU → blocks_being_loaded 堆积
→ 下一轮 convoy 更严重
这形成了一个 “放行 → 耗尽 → convoy → 重置 → 又放行” 的震荡循环。每次放行都加剧 GPU 内存压力,导致更多 blocks 被 evict 到 CPU。