Bug 原理
根因机制
reset_cache() 用于清空 offloading scheduler 的所有 cache 状态(通常在引擎重启、重配置或需要驱逐所有 stored blocks 时调用)。它做了三件事:
_jobs.clear()— 清空 scheduler 侧所有 in-flight job 的状态记录_stale_job_threshold = _job_counter— 标记一个阈值,之后到达的 worker 完成响应如果 job_id < 阈值,则视为”过时响应”并丢弃- 但遗漏了
status.transfer_jobs.clear()— 每个请求的RequestOffloadState中的transfer_jobs(set[int])没有被清空
后果链(为什么请求会永久卡死)
reset_cache() 调用
│
├─ _jobs.clear() → scheduler 侧 job 状态清空
├─ _stale_job_threshold → 旧 job_id 被标记为过时
└─ status.transfer_jobs → ❌ 未清空,残留旧 job_id
│
▼
后续调度步骤调用 get_num_new_matched_tokens()
│
├─ if req_status.transfer_jobs: → 永远为 True(残留 job_id 永远在 set 里)
├─ return None, False → 请求被 defer
└─ 请求移入 step_skipped_waiting → 每步都被跳过
│
▼
请求永久卡死:
- 永远不会被调度
- 永远不会完成
- request_finished() 永远不会被调用
- _req_status 永远不释放(内存泄漏)
- _build_load_jobs 中 assert not transfer_jobs → AssertionError
为什么这些残留 job_id 不会自然消失
正常情况下,transfer_jobs 中的 job_id 通过 update_connector_output() 路径移除:worker 完成传输后报告,scheduler 从 transfer_jobs 中 discard(job_id)。
但 reset_cache() 之后:
- 旧 job 的 worker 响应因为
job_id < _stale_job_threshold被丢弃,永远到不了 completion handler - 所以
transfer_jobs中的旧 job_id 永远不会被移除 - 这些 job_id 成为”幽灵状态”——不在
_jobs里,但存在于每个请求的transfer_jobs里
修复
一行代码:status.transfer_jobs.clear(),在 reset_cache() 中遍历存活的请求时清空其 transfer_jobs。
OffloadingConnectorScheduler 实现机制
角色定位
OffloadingConnectorScheduler(OCS)是 vLLM scheduler 侧的 offloading 管理器,位于 vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py。它负责:
- 加载决策:决定哪些 token 可以从外部存储(CPU 内存、磁盘、远程节点)加载回 GPU
- 存储决策:决定哪些 block 需要 store 到外部存储
- 传输跟踪:跟踪所有 in-flight 的加载/存储 job
核心数据结构
OffloadingConnectorScheduler
├── _req_status: dict[ReqId, RequestOffloadState] # 每个请求的 offloading 状态
│ └── RequestOffloadState
│ ├── transfer_jobs: set[int] # 请求当前 in-flight 的 job ID 集合
│ ├── group_states: tuple[...] # 每个 KV cache group 的状态(hash keys、block IDs 等)
│ ├── num_locally_computed_tokens: int # GPU 本地计算的 token 数
│ └── max_offload_tokens: int|None # 该请求最多 offload 的 token 上限
│
├── _jobs: dict[int, TransferJobStatus] # 所有活跃 job 的状态
│ └── TransferJobStatus
│ ├── req_id: ReqId # 属于哪个请求
│ ├── pending_count: int # 还有几个 worker 未完成(0 = job 完成)
│ ├── keys: set[OffloadKey] # 这个 job 覆盖的 offload key
│ └── is_store: bool # 是 store 还是 load job
│
├── _job_counter: int # job ID 生成器(单调递增)
├── _stale_job_threshold: int # 区分"旧 job"和"新 job"的阈值
├── _blocks_being_loaded: set[OffloadKey]|None # 正在加载的 blocks(避免重复加载)
└── manager: OffloadingManager # 实际执行 lookup/load/store 的后端抽象层
关键流程
1. get_num_new_matched_tokens() — 决定可加载的 token 数
输入:request, num_computed_tokens(GPU 已计算的 token 数)
输出:(num_new_tokens, is_async)
步骤:
1. 检查 transfer_jobs 是否为空
- 非空 → return None, False(请求被 defer,等待传输完成)
2. 调用 _lookup() 向后端查询哪些 blocks 命中
3. 返回可加载的 token 数
2. _lookup() — 向后端查询命中情况
- 遍历所有 KV cache group(full-attention 优先,sliding-window 后)
- 对每个 group 调用
manager.lookup(key, req_context) - 返回
LookupResult.HIT / HIT_PENDING / RETRY / MISS - 可能需要多轮收敛迭代(不同 group 的 hit 范围互相影响)
3. update_connector_output() — worker 完成报告
输入:worker 报告的 KVConnectorOutput
处理:
1. 遍历完成的 job_id
2. 从 transfer_jobs 中移除该 job_id
3. 调用 manager.complete_load/store() 通知后端
4. 如果是最后一个 worker(pending_count == 0)→ job 真正完成
4. reset_cache() — 清空所有 cache 状态
调用场景:引擎重启、重配置、驱逐所有 stored blocks
步骤:
1. 断言没有正在进行的调度步骤
2. 标记所有现有 job 为"需要 flush"
3. 删除已完成的请求(_req_status 中 is_finished 的)
4. 调用 manager.reset_cache() 清空后端
5. 重置存活的请求的 store 进度(next_stored_block_idx = 0)
6. ✅ 清空 transfer_jobs(PR #48102 的修复)
7. 更新 _stale_job_threshold,清空 _jobs 和 _block_id_to_pending_jobs
请求状态机
[新请求]
│ on_new_request()
▼
[无传输] ─────────────────────────────┐
│ │
│ get_num_new_matched_tokens() │ transfer_jobs 清空后
│ 发现可加载 token │ 回到此状态
▼ │
[加载传输中] │
│ transfer_jobs = {load_job_id} │
│ │
│ update_connector_output() │
│ 加载完成 │
▼ │
[无传输] ─────────────────────────────┘
│
│ 需要 store(decode 产生新 blocks)
▼
[存储传输中]
│ transfer_jobs = {store_job_id_1, store_job_id_2, ...}
│
│ update_connector_output()
│ 所有 store 完成
▼
[无传输]
为什么这个 bug 是”可达的”
reset_cache()是正常运维操作(引擎重启、cache 驱逐等)- 此时存在未完成的请求是正常场景(正在 decoding 的请求不会被强制终止)
- 这些请求的
transfer_jobs如果不被清空,就会成为永久卡死的根因 - 这不是理论风险,而是在
reset_cache()被调用后必然触发的 bug