# Findings: nanovllm 多请求状态污染分析 ## 重要说明 **nanovllm offload 模式不支持 batch**,只能单个 request 顺序执行。问题出在**请求切换**(前一个 request 完成后,开始下一个 request)时状态清理不完整。 --- ## 1. 代码架构发现 ### 1.1 请求生命周期 (顺序执行) **关键**: offload 模式下,每次只处理**一个 request**,不是 batch。 ``` LLMEngine.generate() [llm_engine.py:114-151] ├── Observer.complete_reset() # 重置性能统计 ├── for prompt in prompts: │ └── add_request(prompt, sp) # 添加到 scheduler 队列 ├── while not is_finished(): │ ├── scheduler.schedule() # 获取下一个序列 (offload 模式: 1个) │ ├── model_runner.call("run", seqs, is_prefill) # 执行单个请求 │ └── scheduler.postprocess(seqs, token_ids) │ └── if seq.is_finished: │ └── kvcache_manager.deallocate(seq) # 释放资源 ← 问题点 │ └── [开始处理下一个请求] # ← 状态切换 └── return outputs ``` **请求切换流程**: ``` Request A (prefill) → Request A (decode × N) → Request A 完成 ↓ deallocate(A) ← 状态清理不完整! ↓ Request B (prefill) → Request B 读取到 A 的残留状态 → 错误输出 ``` ### 1.2 OffloadEngine 状态清单 **位置**: `nanovllm/kvcache/offload_engine.py:40-145` | 成员变量 | 类型 | Shape | 生命周期 | |----------|------|-------|----------| | `layer_k_cache` | GPU Tensor | [num_buffers, max_seq_len, kv_heads, head_dim] | 整个引擎 | | `layer_v_cache` | GPU Tensor | [num_buffers, max_seq_len, kv_heads, head_dim] | 整个引擎 | | `decode_k_buffer` | GPU Tensor | [num_layers, block_size, kv_heads, head_dim] | 整个引擎 | | `decode_v_buffer` | GPU Tensor | [num_layers, block_size, kv_heads, head_dim] | 整个引擎 | | `k_cache_cpu` | CPU Tensor (pinned) | [num_layers, num_cpu_blocks, block_size, kv_heads, head_dim] | 整个引擎 | | `v_cache_cpu` | CPU Tensor (pinned) | [num_layers, num_cpu_blocks, block_size, kv_heads, head_dim] | 整个引擎 | | `compute_stream` | CUDA Stream | - | 整个引擎 | | `prefill_offload_streams` | List[CUDA Stream] | num_layers | 整个引擎 | | `prefill_offload_events` | List[CUDA Event] | num_layers | 整个引擎 | | `layer_load_streams` | List[CUDA Stream] | num_buffers | 整个引擎 | | `buffer_load_events` | List[CUDA Event] | num_buffers | 整个引擎 | | `buffer_compute_done_events` | List[CUDA Event] | num_buffers | 整个引擎 | **关键发现**: - **没有 reset() 方法** - **没有任何清理逻辑** - 所有 tensor 在初始化时 `torch.zeros()` 后永不清零 ### 1.3 HybridKVCacheManager 状态清单 **位置**: `nanovllm/kvcache/hybrid_manager.py` | 成员变量 | 作用 | 清理方式 | |----------|------|----------| | `logical_blocks` | 逻辑块列表 | `block.reset()` in deallocate | | `free_logical_ids` | 空闲逻辑块队列 | deallocate 归还 | | `free_cpu_blocks` | 空闲 CPU 块队列 | deallocate 归还 | | `cpu_block_to_logical` | CPU 块→逻辑块映射 | deallocate 删除 | | `prefilled_blocks` | 已 prefill 的块集合 | deallocate 中 discard | | `_decode_start_pos` | 序列→decode起始位置 | `clear_decode_tracking()` | | `_prefill_len` | 序列→prefill长度 | `clear_decode_tracking()` | **关键发现**: - `deallocate()` 没有调用 `clear_decode_tracking()`! - `_decode_start_pos` 和 `_prefill_len` 使用 `id(seq)` 作为 key - Python 对象 ID 可能在不同请求间重用 --- ## 2. 请求切换机制分析 ### 2.1 offload 模式的单 request 限制 代码中明确限制: ```python # model_runner.py:757, 880 assert len(seqs) == 1, "Layer-wise offload only supports single sequence" ``` ### 2.2 请求切换时序 ``` 时间 → ┌─────────────────────────────────────────────────────────────────┐ │ Request A: [prefill] → [decode] → [decode] → ... → [完成] │ └─────────────────────────────────────────────────────────────────┘ ↓ deallocate(seq_A) - blocks 释放 ✓ - tracking 字典未清理 ✗ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ Request B: [prefill] → [decode] → ... │ │ ↑ │ │ 如果 id(seq_B) == id(seq_A),读到 A 的残留状态! │ └─────────────────────────────────────────────────────────────────┘ ``` ### 2.3 Python 对象 ID 重用 Python 的内存管理会重用已释放对象的内存地址,导致: ```python seq_A = Sequence(...) # id(seq_A) = 0x7f1234567890 del seq_A # 对象被释放,但字典中 key 保留 seq_B = Sequence(...) # id(seq_B) 可能 = 0x7f1234567890(相同地址) # _decode_start_pos[id(seq_B)] 返回 seq_A 的旧值! ``` --- ## 3. 状态污染机制分析 ### 3.1 decode buffer 污染路径 **污染写入** (`run_layerwise_offload_decode:1010-1013`): ```python # 每次 decode step,将当前 token 的 KV 存入 decode buffer offload_engine.decode_k_buffer[layer_id, pos_in_block].copy_(ring_k[context_len]) offload_engine.decode_v_buffer[layer_id, pos_in_block].copy_(ring_v[context_len]) ``` **污染读取** (`run_layerwise_offload_decode:969-976`): ```python # 如果有之前的 decode tokens,从 decode buffer 读取 if num_prev_decode_tokens > 0: k_decode_prev, v_decode_prev = offload_engine.get_decode_kv( layer_id, decode_start_pos, pos_in_block ) ring_k[total_prefill_tokens:total_prefill_tokens + num_prev_decode_tokens].copy_(k_decode_prev) ``` **问题场景**: 1. 请求 A 的 decode 阶段在 `decode_k_buffer[layer, 0:N]` 写入 KV 2. 请求 A 完成,buffer 数据保留 3. 请求 B 开始,如果其 `decode_start_pos` 被错误计算为非零 4. 请求 B 会读取请求 A 的旧数据 ### 3.2 decode_start_pos 计算逻辑 **位置**: `hybrid_manager.py:485-505` ```python def get_decode_start_pos(self, seq: Sequence) -> int: seq_id = id(seq) # Python 对象 ID if seq_id not in self._decode_start_pos: # 第一次调用 - 计算起始位置 prefill_len = len(seq) - 1 # 当前长度减去新 token self._decode_start_pos[seq_id] = prefill_len % self._block_size return self._decode_start_pos[seq_id] ``` **问题**: - 如果新请求的 `id(seq)` 恰好等于旧请求的 `id(seq)`(Python 内存重用) - `_decode_start_pos` 中可能存在旧的值 - 会返回错误的 decode 起始位置 ### 3.3 clear_decode_tracking 未被调用 **位置**: `hybrid_manager.py:538-549` ```python def clear_decode_tracking(self, seq: Sequence) -> None: seq_id = id(seq) self._decode_start_pos.pop(seq_id, None) self._prefill_len.pop(seq_id, None) ``` **问题**: - 这个方法在 `deallocate()` 中**没有被调用**! - 查看 `deallocate()` (218-244 行),没有 `clear_decode_tracking()` 调用 - 这导致旧请求的 tracking 数据残留 --- ## 3. 失败模式分析 ### 3.1 观察到的失败模式 从测试结果: | Sample | Expected | Output | Status | |--------|----------|--------|--------| | 0 | 8930103 | `: 8930103.` | PASS (第一个请求) | | 1 | 4194548 | `: 419 multiplication of 4548.` | **FAIL** | | 2 | 8231838 | `:ное 8231838.` | PASS | Sample 1 的输出 "419 multiplication of 4548" 显示数字被"拆分"了。 **可能原因**: 1. 在某个 decode step,attention 计算使用了错误的 KV 2. 模型"看到"了旧请求的部分 context 3. 导致生成逻辑出错 ### 3.2 为什么第一个请求总是成功? 1. 第一个请求时,所有 buffer 都是零初始化 2. `decode_start_pos` 字典为空,正确计算 3. 没有残留数据干扰 ### 3.3 为什么后续请求可能成功? 某些请求可能成功因为: 1. `id(seq)` 没有与之前的请求冲突 2. `pos_in_block` 不重叠,没读到旧数据 3. 或者旧数据恰好对结果影响不大 --- ## 4. 修复方向 ### 4.1 必须修复: deallocate 时清理状态 ```python # hybrid_manager.py: deallocate() def deallocate(self, seq: Sequence) -> None: # ... 现有逻辑 ... # 添加: 清理 decode tracking self.clear_decode_tracking(seq) # 添加: 通知 offload engine 清理 if self.offload_engine is not None: self.offload_engine.on_sequence_finished() ``` ### 4.2 必须修复: OffloadEngine 添加清理方法 ```python # offload_engine.py def on_sequence_finished(self): """请求完成时的清理""" # 清零 decode buffer self.decode_k_buffer.zero_() self.decode_v_buffer.zero_() ``` ### 4.3 可选: 更激进的清理 ```python def reset_all(self): """完全重置状态""" self.decode_k_buffer.zero_() self.decode_v_buffer.zero_() self.layer_k_cache.zero_() self.layer_v_cache.zero_() # 重置 CUDA events for event in self.buffer_compute_done_events: event.record() ``` --- ## 5. 待验证假设 | 假设 | 验证方法 | 优先级 | |------|----------|--------| | decode_buffer 残留导致污染 | 在第二个请求开始时检查 buffer 是否为零 | 高 | | _decode_start_pos 字典残留 | 打印 deallocate 前后的字典内容 | 高 | | id(seq) 重用导致错误 | 打印每个请求的 seq id | 中 | | ring buffer 残留 | 检查每次 decode 前 ring buffer 内容 | 低 | --- ## 6. 参考代码位置 | 功能 | 文件 | 行号 | |------|------|------| | OffloadEngine 初始化 | offload_engine.py | 40-145 | | deallocate | hybrid_manager.py | 218-244 | | clear_decode_tracking | hybrid_manager.py | 538-549 | | get_decode_start_pos | hybrid_manager.py | 485-505 | | run_layerwise_offload_decode | model_runner.py | 867-1057 | | decode buffer 写入 | model_runner.py | 1010-1013 | | decode buffer 读取 | model_runner.py | 969-976 |