Files
agentic-pd-hybrid/docs/D_TO_P_SYNC_CONTRACT_ZH.md
Gahow Wang fd37eda367 docs(design): D->P sync interface contract + 4-phase rollout
Companion to BLOCK_LEVEL_EVICTION_DESIGN_ZH. Specifies the
three-layer contract (mooncake / SGLang / agentic-pd-hybrid)
that the empty feat/d-to-p-sync branch is meant to fill.

Contents:
- §1 staleness budget β as a first-class system parameter,
      with recommended default (page_size .. 4096 tokens)
- §2.1 mooncake double-role API: KVRole enum extension,
      DecodeKVSender / PrefillKVReceiver class shapes,
      independent bootstrap channel
- §2.2 SGLang RadixCache.insert_external signature with
      five concrete design decisions (re-mapping policy,
      failure handling, lock_ref discipline, evict
      interaction, multi-P backup view)
- §2.3 agentic-pd-hybrid CLI flags, DirectSessionState
      additions, hook points in _invoke_session_direct
      and _invoke_kvcache_seeded_router
- §3 candidate Theorem 4 (reseed_cost upper bound under
      staleness budget β)
- §4 P1..P4 rollout with validation criteria per phase
- §5 five enumerated risks + mitigation
- §6 explicit decoupling: block-level eviction first,
      then D->P sync; do NOT bundle in one PR

Makes the feat/d-to-p-sync branch actionable for the next
collaborator without GPU until P2 microbench phase.
2026-05-12 23:50:39 +08:00

248 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D→P 增量 KV 同步 — 接口契约与 rollout 计划
**日期**2026-05-12
**前置**[RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md](RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md)(缺口定位)+ [BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md)(前置条件)
**性质**:跨层接口契约 + staleness budget 形式化 + 分阶段 rollout
**Status**:草案。`feat/d-to-p-sync` 分支当前为空,本文是该分支应当首先 land 的设计文档
---
## 0. TL;DR
reseed 慢路径的 50% 时间在 P 重 prefill**修复 transfer 段(启 RDMA只能解一半**。彻底消除长尾的唯一办法是让 P 端 backup 增量跟上 D 端的 append
> D 在 direct-to-D 路径上完成一个 turn → 异步把新 commit 的 KV block 推回 P 端 radix → 下次 reseed 时 P 端 radix 命中完整 prefix无需 re-prefill仅一次 P→D transfer。
本文给出三层mooncake / SGLang / agentic-pd-hybrid的接口契约、一个 **staleness budget β** 的形式化定义,以及四阶段 rollout 计划,让该工作可以与 block-level eviction 解耦推进。
---
## 1. Staleness Budget β —— 形式化定义
设 D 上 session `s` 的 committed prefix 长度为 `L_D(s, t)`time `t` 的瞬时值P 上同 session 的 backup prefix 长度为 `L_P(s, t)`
```
staleness(s, t) := L_D(s, t) - L_P(s, t) ≥ 0
```
**Staleness budget β** 是系统承诺维持的上界:
```
∀ s, ∀ t : staleness(s, t) ≤ β
```
直观:β 越小 → reseed 命中 P 端 backup 的可能越高 → reseed 退化为单次 P→D transfer + ≤ β tokens 的 re-prefill。
- **β = 0**完全同步D 每 commit 一块就阻塞等 P ack。延迟成本高不推荐。
- **β = ∞**当前状态P 端 backup 永远 seed-time 静态快照)。
- **β = 一个 page24 tokens**:单 block sync。理论最优粒度但 D 端每次 append 都触发一次 D→P RPC。
- **β = O(append_len)(典型 1K4K**:批量 sync。推荐起点把同 turn 的 decode 输出聚合后整批推送。
- **β = O(turn_size)(典型 ~50K**:粗粒度 sync。失效 reseed bypass仅减少 transfer。不可取。
→ rollout 推荐 β = `max(page_size, min(committed_in_turn, β_max))``β_max` 默认 4096。
---
## 2. 三层接口契约
### 2.1 Mooncake 层:双角色化
**当前状态**(详见 [RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md](RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md) §3
- `MooncakeKVManager` 在初始化时按 `disaggregation_mode ∈ {PREFILL, DECODE}` 强角色化。
- `MooncakeKVSender` 仅在 PREFILL 模式实例化,`MooncakeKVReceiver` 仅在 DECODE 模式实例化。
- `add_transfer_request` 含硬约束 `assert disaggregation_mode == PREFILL`
**目标接口**
```python
# third_party/sglang/python/sglang/srt/disaggregation/base/conn.py
class BaseKVManager:
roles: set[KVRole] # 替换原单值字段,允许 {PREFILL, DECODE}
class KVRole(Enum):
PREFILL = "prefill"
DECODE = "decode"
PREFILL_BACKUP_RECEIVER = "prefill_backup_receiver" # 新P 端接收 D→P sync
DECODE_BACKUP_SENDER = "decode_backup_sender" # 新D 端发送 D→P sync
```
**新增类**(实现层 ~400 LOC
| 类 | 角色 | 关键方法 |
|---|---|---|
| `DecodeKVSender` | D 端把 append 后的新 KV block 推回 P | `enqueue_sync(session_id, kv_blocks, target_p)` 异步入队,返回 `sync_id` |
| `PrefillKVReceiver` | P 端接收 D→P sync 包 | `recv_loop()` 后台线程;每个包触发 callback 注入 radix tree |
**Bootstrap channel**:需要独立于现有 P→D 通道的第二个 bootstrap socket避免 buffer pointer 协商冲突)。配置:
- 默认 disable由 ServerArgs flag `--enable-d2p-sync` 开启
- 新增 port range `BOOTSTRAP_D2P_PORT_BASE = 22000`
### 2.2 SGLang 层Radix 多生产者扩展
**当前状态**P 端 radix 假设单生产者(本 worker 模型输出)。`RadixCache.cache_finished_req` 内部直接从 `req_to_token_pool[req_pool_idx, :]` 取 KV indices 插入树。
**目标接口**(在 [BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md) 完成之后):
```python
class RadixCache(BasePrefixCache):
def insert_external(
self,
token_ids: Sequence[int],
kv_tensor: torch.Tensor,
*,
source_worker_id: str,
session_id: str,
) -> InsertExternalResult:
"""
Insert KV blocks supplied by an external worker (D→P sync).
Allocates fresh slots in token_to_kv_pool, copies kv_tensor into them,
and threads the resulting indices through the radix tree exactly like
cache_finished_req would for a local prefill.
Invariants:
- Same model layout (verified at handshake time, not per-call).
- On collision with existing radix path, no-op for the shared prefix
and only insert the diverging suffix.
- Inserted nodes get lock_ref += 1 if `pin=True`, default False.
D→P sync is best-effort; LRU is allowed to evict the inserted leaves.
"""
```
**关键设计点**
| 决策 | 选项 | 推荐 |
|---|---|---|
| KV index 重映射 | A) D 发原 indices, P 重映射B) D 发紧密打包的 tensorP 重新分配 | **B**:避免跨 worker 索引泄漏 |
| 失败处理 | A) D→P 失败 → 退化为重 prefillB) 重试 N 次 | **A** + 后续 reseed 时若 P 未命中走旧路径 |
| Reference counting | sync 进 P 的 KV 是否被 pin | **不 pin**P 端 LRU 自然管理,避免 backup 把生产 KV 挤出 |
| 与 evict 协调 | sync 来到时 P 满怎么办? | 让 sync insert 触发 inner.evict → 与本地生产 KV 公平 LRU 竞争 |
| 同 session 多 P 实例 | router round-robin 把 turn 派到不同 P 怎么办? | **接受 multi-source**:每个 P 维护自己的 backupreseed 时挑 staleness 最小者 |
### 2.3 agentic-pd-hybrid 层Hooks 与状态机
**新增 CLI flag**
```bash
--enable-d2p-sync # off by default
--d2p-staleness-budget-tokens 4096 # β_max
--d2p-sync-batch-min-tokens 24 # 至少 ≥ 1 page 才触发
--d2p-sync-target-policy {last_p, round_robin, broadcast}
# last_p: 推回该 session 上次 seed 的 P
# broadcast: 推到所有 Preseed 时灵活但带宽大)
```
**新增 state 字段**`replay.py``DirectSessionState`
```python
@dataclass
class DirectSessionState:
...
# NEW: per-P backup view, populated by D->P sync callbacks.
prefill_resident_tokens_by_p: dict[str, int] = field(default_factory=dict)
last_d2p_sync_at: float | None = None
```
**Hook 在 `_invoke_session_direct` 完成后**
```python
async def _invoke_session_direct(...):
...
response = await self._stream_direct_to_d(...)
if response.ok and self.config.enable_d2p_sync:
new_committed = response.kv_committed_len
prev_p_resident = max(session.prefill_resident_tokens_by_p.values(), default=0)
staleness = new_committed - prev_p_resident
if staleness >= self.config.d2p_sync_batch_min_tokens:
target_p = self._choose_d2p_target(session)
asyncio.create_task(
self._issue_d2p_sync(session, target_p, prev_p_resident, new_committed)
)
```
**Hook 在 reseed 路径**`_invoke_kvcache_seeded_router`
```python
async def _invoke_kvcache_seeded_router(..., request):
...
if self.config.enable_d2p_sync:
# Probe P-side residency before issuing full re-prefill.
probe = await self._probe_prefill_residency(session_id)
if probe.resident_tokens >= request.prefix_len - β_max:
# Use the up-to-date backup: skip re-prefill, just trigger P→D transfer.
return await self._invoke_p_to_d_transfer_only(...)
# Fall back to existing path.
return await self._invoke_kvcache_seeded_router_legacy(...)
```
---
## 3. 性质(待证明)
### 3.1 Theorem 4 候选(论文形式)
*设 staleness budget β 维持成立。对一个 session `s` 在 D 上累积长度 L、被 evict 后 reseed 触发:*
```
reseed_cost(s) ≤ T_p2d(L) + T_prefill(min(β, L))
```
*其中 T_p2d 是 P→D transfer 时间(在 RDMA 下 ~L · 4 ns/tokenT_prefill 是 prefill 时间(在 H100 TP1 Qwen3-30B 下 ~50K tokens/s。当 β ≪ L 时退化为 single P→D transfer 主导。*
**对比 baseline**(无 D→P sync`reseed_cost = T_p2d(L) + T_prefill(L seed_size)`re-prefill 占主导。
### 3.2 与 Theorem 2 的关系
Theorem 2 只保证 direct-to-D 路径的快速命中。Theorem 4 把"fast path miss 时的 fallback cost"也压低到次秒级,使 KVC 在**全分位数**击败 DP 成为可能。
---
## 4. 四阶段 Rollout
| Phase | 范围 | GPU 需求 | 验收指标 |
|---|---|---|---|
| **P1** | block-level eviction refactor[BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md) | 4×H100 smoke | evict 单次平均 ≤ 500 tokens |
| **P2** | mooncake 双角色化 + microbenchD→P 单包 RTT、带宽利用 | 单机 + RDMA | P→D RTT < 50mslocal 16K-token block 带宽 50% 理论上限 |
| **P3** | SGLang `insert_external` + agentic-pd-hybrid hook best-effort reseed probe | 4×H100 + RDMA | sync 触发率 > 80% 同 turn 内完成;不引入新 failure mode |
| **P4** | reseed probe 接通 + 端到端 evaluation | 4×H100 + RDMA | reseed 单次 < 0.5svs 当前 37sTTFT p99 < 0.5s |
**关键决策点**P1 P2 之间需要走 audit确认 SGLang radix `insert_external` 不会与 streaming-session decode 路径冲突若发现严重冲突引入 "P-only sync mode" 占位等架构稳定再放开
---
## 5. 风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| Mooncake 双角色化破坏现有 PD 单向路径 | E2 已暴露 mooncake "instance not alive" 级联再加一条通道可能放大 | P2 阶段先用独立 bootstrap channel + feature flag保留 disable 路径 |
| DP sync 占用 D 出口带宽影响 direct-to-D append-prefill 延迟 | 直接劣化主路径 | sync 用低优先级 QPRDMA SL=0 batch 触发 turn 内最多 1 |
| P radix backup 填满反而挤出本地生产 KV | P prefill 速度降 | sync 插入不 pin(§2.2 LRU 公平竞争 |
| P backup view 协调复杂 | router 选择 target_p 时需考虑 staleness | 起点用 `last_p` policyrecency-biased观察实测分布再决定是否上 `broadcast` |
| SGLang patch 升级时 `insert_external` upstream API 漂移 | 维护负担 | API 限制在我方 vendor patch 边界不污染 upstream radix并写 contract test |
---
## 6. 与 block-level eviction 的解耦关系
| 工作 | 是否依赖另一个 |
|---|---|
| block-level eviction | 不依赖 DP sync可独立交付能单独降低 reseed 频次 |
| DP sync | **依赖** block-level eviction需要 P radix streaming session KV 的真值源 |
| 一起做 | 收益最大reseed 频次降一个数量级 + 单次 reseed 时间降一个数量级 |
rollout 顺序block-level eviction landDP sync 随后开 `feat/d-to-p-sync` 推进两者**不应**合在一个 PR
---
## 7. 接班 agent 的最小动作
1. `feat/d-to-p-sync` 分支上 land 本文
2. block-level eviction main P2 阶段mooncake 双角色化 + microbench单测 SGLang 主路径耦合)。
3. P3 阶段加 `insert_external` hook disabled-by-default main
4. P4 端到端 evaluation 后再判断 reseed probe policy`last_p` vs `broadcast`)。
---
**核心句**DP 增量同步不是"再加一条网络通道"那么简单关键是把 P radix 从单生产者扩展到允许 best-effort 外部喂入Block-level eviction 是这件事的前置条件——所以两件工作可以一前一后不能颠倒