[claudesquad] update from 'multi-request-2' on 13 Jan 26 02:01 CST
This commit is contained in:
419
findings.md
419
findings.md
@@ -1,169 +1,288 @@
|
||||
# Findings: Torch Distributed Port Conflict
|
||||
# Findings: nanovllm 多请求状态污染分析
|
||||
|
||||
## Problem Analysis
|
||||
## 重要说明
|
||||
|
||||
### Issue Summary
|
||||
创建多个 LLM 实例时出现端口冲突 (EADDRINUSE),导致第二个实例无法启动。
|
||||
**nanovllm offload 模式不支持 batch**,只能单个 request 顺序执行。问题出在**请求切换**(前一个 request 完成后,开始下一个 request)时状态清理不完整。
|
||||
|
||||
### Root Cause Deep Dive
|
||||
---
|
||||
|
||||
#### 1. 资源绑定位置
|
||||
## 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
|
||||
# nanovllm/engine/model_runner.py:30-32
|
||||
import os
|
||||
port = os.environ.get("NANOVLLM_DIST_PORT", "2333")
|
||||
dist.init_process_group("nccl", f"tcp://localhost:{port}", world_size=self.world_size, rank=rank)
|
||||
# model_runner.py:757, 880
|
||||
assert len(seqs) == 1, "Layer-wise offload only supports single sequence"
|
||||
```
|
||||
|
||||
- 默认端口 **2333**,可通过 `NANOVLLM_DIST_PORT` 环境变量配置
|
||||
- `init_process_group()` 绑定 TCP 端口用于进程间通信
|
||||
- 端口绑定持续到 `destroy_process_group()` 被调用
|
||||
### 2.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
|
||||
# nanovllm/engine/llm_engine.py:37
|
||||
atexit.register(self.exit)
|
||||
seq_A = Sequence(...) # id(seq_A) = 0x7f1234567890
|
||||
del seq_A # 对象被释放,但字典中 key 保留
|
||||
|
||||
# nanovllm/engine/llm_engine.py:39-43
|
||||
def exit(self):
|
||||
self.model_runner.call("exit")
|
||||
del self.model_runner
|
||||
for p in self.ps:
|
||||
p.join()
|
||||
|
||||
# nanovllm/engine/model_runner.py:66-78
|
||||
def exit(self):
|
||||
# ... cleanup code ...
|
||||
dist.destroy_process_group()
|
||||
```
|
||||
|
||||
**关键问题**: `atexit` 只在 **Python 解释器退出** 时触发,而非对象被删除时!
|
||||
|
||||
#### 3. 问题时间线
|
||||
```
|
||||
1. 创建 LLM #1
|
||||
├── init_process_group() 绑定端口 2333 ✓
|
||||
└── atexit.register(self.exit) 注册
|
||||
|
||||
2. LLM #1 超出作用域或被 del
|
||||
├── Python GC 回收对象内存
|
||||
├── atexit handler 未触发(进程未退出)
|
||||
├── Worker 进程仍在运行
|
||||
└── 端口 2333 仍被占用 ❌
|
||||
|
||||
3. 创建 LLM #2
|
||||
├── init_process_group() 尝试绑定端口 2333
|
||||
└── EADDRINUSE 错误 ❌
|
||||
|
||||
4. 程序退出(此时 atexit 才运行)
|
||||
└── 为时已晚 - 已经崩溃
|
||||
seq_B = Sequence(...) # id(seq_B) 可能 = 0x7f1234567890(相同地址)
|
||||
# _decode_start_pos[id(seq_B)] 返回 seq_A 的旧值!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solution Analysis
|
||||
## 3. 状态污染机制分析
|
||||
|
||||
### 方案对比
|
||||
### 3.1 decode buffer 污染路径
|
||||
|
||||
| 方案 | 可靠性 | 向后兼容 | 实现复杂度 | 推荐度 |
|
||||
|------|--------|----------|------------|--------|
|
||||
| `close()` 方法 | 最高 | 是 | 低 | ★★★★★ |
|
||||
| `__del__` 方法 | 中等 | 是 | 低 | ★★★☆☆ |
|
||||
| 端口检测重试 | 中等 | 是 | 低 | ★★★☆☆ |
|
||||
| Context Manager | 最高 | 需要代码修改 | 低 | ★★★★☆ |
|
||||
| 动态端口 | 低 | 是 | 低 | ★★☆☆☆ |
|
||||
|
||||
### 为什么选择三层防护
|
||||
|
||||
1. **Layer 1: close()** - 用户显式控制,最可靠
|
||||
2. **Layer 2: __del__** - 自动清理,覆盖大部分场景
|
||||
3. **Layer 3: 端口检测** - 最后防线,提供清晰错误信息
|
||||
|
||||
### `__del__` 的限制
|
||||
|
||||
Python 的 `__del__` 不保证被调用:
|
||||
- 循环引用时可能不触发
|
||||
- 解释器关闭时可能无法访问依赖模块
|
||||
- 不应依赖于 `__del__` 进行关键资源清理
|
||||
|
||||
但作为**额外防护层**是有价值的,因为:
|
||||
- 大多数情况下会被调用
|
||||
- 比没有好
|
||||
- 不影响其他清理机制
|
||||
|
||||
---
|
||||
|
||||
## Code Structure Analysis
|
||||
|
||||
### LLMEngine 生命周期
|
||||
```
|
||||
__init__()
|
||||
├── 创建 worker 进程 (self.ps)
|
||||
├── 创建 ModelRunner (self.model_runner)
|
||||
├── 注册 atexit handler
|
||||
└── 设置 scheduler, tokenizer
|
||||
|
||||
close() [新增]
|
||||
├── 检查 _closed 标志(幂等)
|
||||
├── 注销 atexit handler
|
||||
├── 调用 model_runner.exit()
|
||||
├── join worker 进程
|
||||
└── 设置 _closed = True
|
||||
|
||||
__del__() [新增]
|
||||
└── 调用 close()(忽略异常)
|
||||
|
||||
__enter__/__exit__() [新增]
|
||||
└── Context manager 支持
|
||||
```
|
||||
|
||||
### ModelRunner 资源
|
||||
```
|
||||
__init__()
|
||||
├── torch.distributed 初始化(绑定端口)
|
||||
├── 模型加载
|
||||
├── KV cache 分配
|
||||
├── CUDA graph 捕获(可选)
|
||||
└── SharedMemory 创建(多GPU)
|
||||
|
||||
exit()
|
||||
├── SharedMemory 清理
|
||||
├── CUDA graph 清理
|
||||
└── dist.destroy_process_group()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| `__del__` 不被调用 | 中 - 端口泄漏 | Layer 3 端口检测提供清晰错误 |
|
||||
| close() 重复调用 | 低 | `_closed` 标志保证幂等 |
|
||||
| atexit 双重调用 | 低 | 注销机制防止 |
|
||||
| 子进程残留 | 高 | join() 确保子进程退出 |
|
||||
| CUDA 资源泄漏 | 中 | ModelRunner.exit() 清理 |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### atexit.unregister 兼容性
|
||||
- Python 3.7+ 支持
|
||||
- 需要传入同一个函数对象
|
||||
- 使用 `self._atexit_handler` 而非 `self.exit` 以便正确注销
|
||||
|
||||
### 端口检测方法
|
||||
**污染写入** (`run_layerwise_offload_decode:1010-1013`):
|
||||
```python
|
||||
def _check_port_available(port: int, host: str = "localhost") -> bool:
|
||||
"""使用 socket connect_ex 检测端口是否被占用."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
result = s.connect_ex((host, port))
|
||||
return result != 0 # 0 = connected = port in use
|
||||
except Exception:
|
||||
return True # 假设可用
|
||||
# 每次 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])
|
||||
```
|
||||
|
||||
**注意**: 这种检测存在 TOCTOU (Time-of-check to time-of-use) 竞争条件,但对于我们的用例足够了。
|
||||
**污染读取** (`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 |
|
||||
|
||||
Reference in New Issue
Block a user