Compare commits
26 Commits
improve/au
...
h200-cu130
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f09562123b | ||
|
|
9cca2c60c9 | ||
|
|
5c09a3a0cb | ||
|
|
19612ff3a3 | ||
|
|
a953346a0c | ||
|
|
2dfe22ab20 | ||
|
|
6be5f9b57e | ||
|
|
f926a7b87d | ||
|
|
552f3f564e | ||
|
|
051d9220f4 | ||
|
|
9aac36fd89 | ||
|
|
e9ad1c4bc7 | ||
|
|
af966f2371 | ||
|
|
f6d6dc01ea | ||
|
|
fbeb968f2f | ||
|
|
e729d62ddf | ||
|
|
1d68ad66a7 | ||
|
|
9149b530c0 | ||
|
|
a4f30e6bd3 | ||
|
|
8a2f72f18e | ||
|
|
a369722efe | ||
|
|
b9b0cf0fac | ||
|
|
86412bb174 | ||
|
|
7216507773 | ||
|
|
dc4867c270 | ||
|
|
9c35eddc79 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,6 +13,11 @@ src/*.egg-info
|
||||
outputs/
|
||||
|
||||
# Vendored dependencies. Track only the maintained SGLang fork/snapshot.
|
||||
# third_party/traces/ holds the replay trace files used by the benchmark
|
||||
# (~56 MB each) for convenient transfer between hosts; they would otherwise
|
||||
# live under outputs/ but outputs/ is gitignored.
|
||||
third_party/*
|
||||
!third_party/sglang/
|
||||
!third_party/agentic-kvcache/
|
||||
!third_party/traces/
|
||||
*.log
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "third_party/agentic-kvcache"]
|
||||
path = third_party/agentic-kvcache
|
||||
url = git@ipads.se.sjtu.edu.cn:scaleaisys/projects/agentic-kvcache.git
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -1,33 +1,9 @@
|
||||
# AGENTS.md
|
||||
|
||||
## For new collaborators / agents
|
||||
|
||||
Before doing anything else, read [docs/INDEX_ZH.md](docs/INDEX_ZH.md). It points to the
|
||||
3 must-read docs and a role-based reading path (new SWE, paper reviewer,
|
||||
reproducing student, control-plane reader).
|
||||
|
||||
Cross-branch progress, weaknesses, and roadmap live in
|
||||
[docs/AUDIT_AND_ROADMAP_ZH.md](docs/AUDIT_AND_ROADMAP_ZH.md). It is the single source of truth
|
||||
for "what's done, what's broken, what to do next."
|
||||
|
||||
Two engineering work items are pre-specced and ready to pick up:
|
||||
- block-level eviction refactor — [docs/BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](docs/BLOCK_LEVEL_EVICTION_DESIGN_ZH.md)
|
||||
- D→P incremental KV sync — [docs/D_TO_P_SYNC_CONTRACT_ZH.md](docs/D_TO_P_SYNC_CONTRACT_ZH.md)
|
||||
|
||||
Evaluation protocol (paper-quality N, paired CI, stratification,
|
||||
baselines) is in [docs/EVALUATION_PROTOCOL_ZH.md](docs/EVALUATION_PROTOCOL_ZH.md).
|
||||
|
||||
## Environment
|
||||
|
||||
Use `uv` to manage all python environment. `uv add` to manage deps so that we can `uv sync` to get exactly same runnable environment each time.
|
||||
|
||||
Algorithm-layer unit tests (no GPU, no SGLang):
|
||||
|
||||
```bash
|
||||
uv sync --group test
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
Build a minimal prototype on top of **SGLang xPyD** to test whether **session-aware / KV-cache-aware P/D routing** can improve **end-to-end latency** for agentic coding workloads.
|
||||
|
||||
28
README.md
28
README.md
@@ -6,9 +6,6 @@
|
||||
|
||||
更完整但仍然简洁的说明见 [docs/PROJECT_OVERVIEW.md](docs/PROJECT_OVERVIEW.md)。
|
||||
|
||||
新加入的合作者:先看 [docs/INDEX_ZH.md](docs/INDEX_ZH.md),按"我是谁"选 3 篇必读文档。
|
||||
项目当前进度、薄弱点、路线图总览见 [docs/AUDIT_AND_ROADMAP_ZH.md](docs/AUDIT_AND_ROADMAP_ZH.md)。
|
||||
|
||||
## 当前做了什么
|
||||
|
||||
- 启动单机 SGLang P/D 栈。
|
||||
@@ -102,28 +99,3 @@ uv run agentic-pd-hybrid replay \
|
||||
- SGLang 改动:`feat(sglang): ...` / `fix(sglang): ...`。
|
||||
- `third_party/sglang` 的基线是 clean SGLang `v0.5.10` snapshot。
|
||||
- 不提交 `outputs/`、日志、`__pycache__`、虚拟环境。
|
||||
|
||||
## 单元测试(无 GPU)
|
||||
|
||||
算法层(policies、Algorithm 1 / Theorem 1)有 pure-Python 单测,跑测试不需要 GPU、不需要 SGLang:
|
||||
|
||||
```bash
|
||||
uv sync --group test
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
详见 [tests/README.md](tests/README.md)。
|
||||
|
||||
## 评测脚本
|
||||
|
||||
按 [docs/EVALUATION_PROTOCOL_ZH.md](docs/EVALUATION_PROTOCOL_ZH.md) 跑数据后:
|
||||
|
||||
```bash
|
||||
# M3: 按 turn_id / input_length / overlap_ratio / append_tokens 分桶
|
||||
scripts/analysis/stratified.py outputs/<run>/request-metrics.jsonl
|
||||
|
||||
# M2: paired-on-same-trial bootstrap 95% CI
|
||||
scripts/analysis/paired_compare.py \
|
||||
--baseline outputs/run-dp/request-metrics.jsonl \
|
||||
--candidate outputs/run-kvc/request-metrics.jsonl
|
||||
```
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# 项目整体审阅与下一阶段路线图
|
||||
|
||||
**日期**:2026-05-12
|
||||
**分支起点**:`improve/audit-and-foundations`(基于 `h200-cu130`)
|
||||
**性质**:跨分支整合 + 路线图,供合作者判断每个 commit 是否值得 merge
|
||||
**对象**:项目下一个 SWE / research agent + 论文 reviewer 预读
|
||||
|
||||
本文把 `main` / `kvc-debug-journey-v1-to-v4` / `feat/d-to-p-sync` / `h200-cu130` / `kvc-real-ali-iter-v1` 五个分支的进度、已成立的贡献、薄弱点、走到 SOSP/OSDI + 工业级的路线图集中到一处,方便快速对齐。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **已经成立**:v1 → v2 算法(reset-on-success、字典序 Route、worker-mode Admit RPC)有形式化定义 + 两条 theorem + SWE-Bench 50 sess ts=1 上 6/8 指标击败 4DP CA 的实测。
|
||||
2. **核心薄弱点**:(a) session-level eviction 与 KVC 设计意图冲突;(b) D→P 增量 KV 同步不存在,TTFT p99 长尾来自此;(c) mooncake "instance not alive" 级联是控制层根本可用性问题;(d) 评测仍缺多 baseline 多 trace 强统计。
|
||||
3. **不需要 GPU 也能推进**的事:算法层 unit test、形式化设计文档(block-level evict、D→P sync 接口契约)、评测协议、分层分析工具、文档体系收口。本路线图的 Milestone 1 大部分都属于此类。
|
||||
4. **进 OSDI/SOSP 必须做的**:执行 §S1(block-level evict)+ §S2(D→P sync POC)+ §M2/M3/M4(多 baseline / 全 Ali / paired 协议)。预计 3–4 个月单/双人。
|
||||
|
||||
---
|
||||
|
||||
## 1. 五个分支的状态总览
|
||||
|
||||
| 分支 | 角色 | 当前状态 | 最关键产出 |
|
||||
|---|---|---|---|
|
||||
| `main` | "已发布" 基线 | 落后 origin 18 commit;2P4D + worker-admission + seed-min2 报出 vs default PD 的 9% mean / 19% p90 改善 | `KVCACHE_CENTRIC_PROGRESS_ZH.md` 的两档策略:latency-best vs stable |
|
||||
| `kvc-debug-journey-v1-to-v4` | 主工作分支 | v1→v5 完整算法演化;`KVC_ROUTER_ALGORITHM.md` 三段算法 + 两条 theorem | SWE-Bench 50 sess ts=1:v2 6/8 指标击败 4DP CA;**TTFT p99 仍输 3×**(1.28s vs 0.43s),诊断为 8.3% reseed 慢路径 |
|
||||
| `feat/d-to-p-sync` | 占位分支 | 代码空,仅 `RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md` | 已排除"capacity-backup 是 D→P sync"的误解;列出 4 项工程子任务 |
|
||||
| `h200-cu130` | 真硬件 + RDMA 验证 | 4×H200 + mlx5_60 NDR 400 Gb/s 上跑 E1/E2/E3 | **E2 80% failure**(mooncake 死链级联);**E3 16min 触发 SGLang patch invariant crash**;最新 `KVC_EVICTION_GRANULARITY_DESIGN_ZH.md` 把 root cause 上升到"session-level 是错的 eviction granularity" |
|
||||
| `kvc-real-ali-iter-v1` | 真 Ali trace 验证 | 8×H20,179-req KVC-fit slice + 600-req/15min cold-window | KVC vs DP:KVC-fit p50 −46% ✅;real 15min p90 +19s ❌,53 errors vs DP 1;KVC 默认 mem-fraction OOM,必须降到 0.82 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 已经"硬"成立的贡献
|
||||
|
||||
按"reviewer 能不能反驳"为标尺:
|
||||
|
||||
1. **Reset-on-success 修复 v1 thrashing**:v1 永久 blacklist → migration 死循环 failure mode 有实测 + Algorithm 3 形式化 + Theorem 1 的不饿死证明(`KVC_ROUTER_ALGORITHM.md` §3.4 / §4.1)。
|
||||
2. **三段算法分工清晰**:Algorithm 1(字典序 Route)+ Algorithm 2(D 自治 Admit RPC)+ Algorithm 3(Dispatch + reset-on-success)。v5 把 admission 从 router 估算改成 D RPC(Option D)是把 capacity ground truth 与 routing score 解耦的正确分层。
|
||||
3. **Direct-to-D 快路径的确定性命中**(Theorem 2):只要 residency ⊇ prefix ∧ append ≤ τ_append ∧ cap_ok 三条件同时成立必走快路径;SWE-Bench 91.6% 命中、TTFT p50 = 0.43s 是结构性结果。
|
||||
4. **每一个 negative result 都有 forensic 级解释**:mooncake death、cold-D、reseed 慢路径、session-level evict 都有代码定位 + 时间线 + 反例。这条对 paper 是真正加分项。
|
||||
|
||||
---
|
||||
|
||||
## 3. 让 reviewer 一击致命的薄弱点
|
||||
|
||||
### 3.1 评测方法层
|
||||
|
||||
- **M1 N 不足**:SWE-Bench v2 baseline N=3 确认 categorical,v2 自身 N 不足;缺 bootstrap CI。
|
||||
- **M2 比较口径不对等**:E2 80% 失败时用 "successful only" 算 latency 与 E1 全集比;paper 必须 paired-on-same-trial。
|
||||
- **M3 trace 偏 KVC-friendly**:KVC-fit slice 按 small-append + high overlap 筛过;full Ali(turn2+ ratio 26%、single-turn 极多)的 dilution 后结果没跑过。
|
||||
- **M4 baseline 不够强**:缺 vLLM + prefix-cache、DistServe、SplitWise、Mooncake-Master 任何一个。
|
||||
- **M5 trace 单一性**:缺 ShareGPT/Mooncake trace、缺 long-context tool-use agent benchmark、缺合成 adversarial trace。
|
||||
- **M6 硬件覆盖**:只 single-node ≤ 8 GPU;没有跨节点、没有 ≥ 32 GPU 集群实测。
|
||||
|
||||
### 3.2 系统设计层
|
||||
|
||||
- **S1 Session-level eviction 与 KVC 设计意图冲突**:90 次 evict、平均一次 free 67K tokens、25/50 session 必须 50–90K 重 prefill。`KVC_EVICTION_GRANULARITY_DESIGN_ZH.md` 已识别但未实现修复。
|
||||
- **S2 D→P 增量同步不存在**:TTFT p99 长尾 50% 来自 P 重 prefill。`capacity-backup` 是 seed-time 静态快照,不是 D→P sync。修复需改 SGLang radix 的单生产者假设。
|
||||
- **S3 Mooncake 级联 death**:admission no-space → 持续重试 seed → 心跳掉线 → SGLang 整批 abort(E2 1054/1285 失败)。控制层根本可用性 bug。
|
||||
- **S4 Admission RPC 同步阻塞**:缺 backoff / hedging / staleness budget。D scheduler GIL 抖动即把 router 卡死。
|
||||
- **S5 Cold-D / overlap-pinning**:boilerplate 24-token block hash 让所有 session 与 D0/D1 重叠 → D2/D3 0 binding。load-floor bonus 是补丁,不是 first-principles 修复。
|
||||
- **S6 SGLang 本地 patch 已 785 行 / 10 文件**,含 `schedule_batch.py:1646` 这种 hot-path 不变量改动;E3 crash 就是 vendored patch 引入的 latent landmine。
|
||||
- **S7 失败恢复 / 幂等性**:streaming session 在 chunked-prefill retry 下幂等性靠 `SessionSlot.restore_to_req`;缺 worker crash / mooncake 重连 / partial KV 损坏的恢复 protocol。
|
||||
- **S8 没有 multi-tenant / SLO-aware scheduling**:算法目标隐式 w_ttft=w_lat=1。生产里 interactive / batch / background 必须分级。
|
||||
- **S9 Topology fixed at boot**:P/D 比例是启动参数。生产负载需要 elastic。
|
||||
- **S10 Backpressure pause hint 信号未闭环**:触发 20 次但因 no-BP 无人响应;control-plane 没接通。
|
||||
|
||||
### 3.3 工程基础设施层
|
||||
|
||||
- **可观测性**:metrics 是 jsonl + 离线 `recompute_summary.py`;生产需要 Prometheus + Grafana + OpenTelemetry trace。
|
||||
- **形式化测试**:算法层与状态层缺 unit test;`SessionSlot.restore_to_req` 幂等性是作者自己 flag 的 invariant。
|
||||
- **混沌注入**:mooncake death 这种 control-plane failure 必须有 fault injection harness。
|
||||
- **代码体量**:`replay.py` 2460 行,集 orchestration / policy hook / control plane / metrics 于一身——prototype OK,paper-quality artifact 偏弱。
|
||||
|
||||
---
|
||||
|
||||
## 4. 路线图
|
||||
|
||||
分三个 milestone。每个 milestone 可独立交付(paper 章节或工程 release)。
|
||||
|
||||
### Milestone 1 — Defensible SOSP/OSDI submission(3–4 个月,单 / 双人)
|
||||
|
||||
**目标**:把现有算法 + 失败诊断收口成能扛 PC 第一轮的稿子。
|
||||
|
||||
1. **执行 §S1(block-level eviction refactor)** — 见 `docs/BLOCK_LEVEL_EVICTION_DESIGN_ZH.md`。
|
||||
- Streaming-session decode 输出在每个 turn finish 时通过 `cache_finished_req` 增量提交进 radix tree。
|
||||
- `SessionSlot` 退化为纯 metadata(仅持 `last_node` + lock_ref)。
|
||||
- `release_session` 改为 `dec_lock_ref` + 删 slot;evict 完全交给 SGLang radix LRU。
|
||||
- 预期:evict 粒度从 67K tokens/次降到 24 tokens/次;reseed 频率降一个数量级。
|
||||
2. **执行 §S2(D→P 增量同步 POC)** — 见 `docs/D_TO_P_SYNC_CONTRACT_ZH.md`。
|
||||
- microbench 证明:D append 完成后异步推 KV block 回 P 端 radix → 下次 reseed 跳过 re-prefill。
|
||||
3. **修 §S3(mooncake death 级联)**:admission RPC backoff + jitter;per-D pending-seed budget;mooncake heartbeat 与 admission 解耦。
|
||||
4. **修 §S5 的 first-principles 解法**:把 `overlap` 重定义为 "session 在 D 上独占 prefix 的 hash 数"(去掉 boilerplate 共享 hash 贡献),让 score 自然分散。
|
||||
5. **重做评测**:见 `docs/EVALUATION_PROTOCOL_ZH.md`。N≥3 + bootstrap CI + 多 baseline + 全 Ali + 分层报告。
|
||||
6. **形式化扩充**:加 Theorem 3(block-level evict 下重 prefill cost 上界)+ Theorem 4(D→P sync 的 staleness budget β 与 reseed cost 关系)。
|
||||
7. **Artifact**:一键脚本 + Dockerfile + 4×A100 一小时复现核心 table/figure。
|
||||
|
||||
### Milestone 2 — Production-quality serving substrate(再 3–6 个月,2–3 人)
|
||||
|
||||
8. **控制平面分层**:把 `replay.py` 拆成 `router/` / `control/` / `obs/` / `orch/`。
|
||||
9. **Elastic topology**:autoscaling controller,输入 (P queue, D transfer queue, D KV usage)。
|
||||
10. **Multi-tenant + SLO classes**:interactive / batch / background 三档独立 admission budget。
|
||||
11. **Failure injection harness**:mooncake link flap / D OOM kill / router GC pause / partial KV corruption;每个 case 有恢复 SLA。
|
||||
12. **Persistent KV tier**:CPU DRAM + NVMe + RDMA-attached pool;evict 改为 demote。
|
||||
13. **Cross-node + heterogeneous**:H100 + H200 + L40S 混合,topology-aware routing。
|
||||
14. **Observability**:per-request OpenTelemetry + Prometheus per-D + Grafana 主面板。
|
||||
|
||||
### Milestone 3 — 真正能进 OSDI'27 的科研增量(6–12 个月)
|
||||
|
||||
15. **Learning-based admission / migration**:multi-armed bandit / RL 控制 τ_reject 与 K;用 trace 训 session-aliveness predictor。
|
||||
16. **跨 router residency consensus**:轻量 gossip 共享 `Σ.resident[d]`。
|
||||
17. **可证明 competitive ratio**:在 oracle KV-residency 模型下证明 KVC expected TTFT 与 offline optimal 比值有界。
|
||||
18. **分布式 prefix tree**:逻辑 prefix 映射到多 D 物理副本,支持 multi-tenant prefix 共享(system prompt / tool schema)。
|
||||
19. **Energy-aware variant**:GPU SM 利用率 + PCIe/RDMA 能耗进目标函数。
|
||||
20. **End-to-end agent serving framing**:从 request-level latency 上升到 agent task completion time(coding agent 一个 task 30+ turn)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 不需要 GPU 也能推进的工作清单
|
||||
|
||||
按 ROI 排:
|
||||
|
||||
- [x] 本路线图(`AUDIT_AND_ROADMAP_ZH.md`)。
|
||||
- [x] 合作者入口(`docs/INDEX_ZH.md`)。
|
||||
- [x] Block-level eviction 具体设计(`docs/BLOCK_LEVEL_EVICTION_DESIGN_ZH.md`)。
|
||||
- [x] D→P sync 接口契约(`docs/D_TO_P_SYNC_CONTRACT_ZH.md`)。
|
||||
- [x] 评测协议(`docs/EVALUATION_PROTOCOL_ZH.md`)。
|
||||
- [x] `KvAwarePolicy` 纯函数 score 抽取 + unit test(Algorithm 1)。
|
||||
- [x] 不饿死性质测试(Theorem 1)。
|
||||
- [x] 分层分析脚本(按 turn-index / append-size / overlap 三维分桶)。
|
||||
- [x] Paired-comparison 协议 helper。
|
||||
- [ ] Mooncake death 的可重现 mock harness(无 GPU 也能跑)。
|
||||
- [ ] SGLang patch surface 的归类清单(每个 patch 标"必须" / "实验性" / "可下线")。
|
||||
- [ ] Failure-mode taxonomy 文档(cold-D、overlap-pin、mooncake death、reseed storm、evict storm)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 单句结论
|
||||
|
||||
> 这个项目已经具备了 SOSP/OSDI workshop / poster 的素材;要进 main track,需要把 §S1(block-level evict)和 §S2(D→P sync)做实、把 §M3(full Ali)和 §M4(两个强 baseline)补齐、把 §S3(mooncake 级联 death)的 control-plane fix 写进可重复 artifact。如果只能做一件事,先做 block-level eviction refactor —— 它同时解决"reseed 太频繁"和"P 端 radix 多生产者扩展的前置条件"。
|
||||
@@ -1,309 +0,0 @@
|
||||
# Block-level Eviction Refactor — 设计文档
|
||||
|
||||
**日期**:2026-05-12
|
||||
**前置**:[KVC_EVICTION_GRANULARITY_DESIGN_ZH.md](KVC_EVICTION_GRANULARITY_DESIGN_ZH.md)(架构层 manifesto)
|
||||
**性质**:实现层设计 + API 草案 + 测试计划,供下一个合作者直接据此编码
|
||||
**Status**:草案,未实现。代码全部 quoted from `third_party/sglang/python/sglang/srt/mem_cache/session_aware_cache.py @ origin/h200-cu130`
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
把 `SessionAwareCache` 当前对 streaming-session **整段 KV 一次性 free** 的语义改成:
|
||||
|
||||
1. Streaming-session decode 输出在 turn finish 时 **增量 commit 进 radix tree**。
|
||||
2. `SessionSlot` 退化为**纯 metadata**(仅持 `last_node` + lock_ref 状态),不再独占 KV 区间。
|
||||
3. `release_session` 改为只 dec_lock_ref + 删 slot,**让 SGLang 标准 radix LRU 按 block 粒度蚕食**。
|
||||
|
||||
预期收益:evict 粒度从一次 ~67K tokens 降到 ~24 tokens(page_size 个 token),reseed 频率降一个数量级;同时把 P 端 radix tree 改造成可被外部喂数据(为 [D_TO_P_SYNC_CONTRACT_ZH.md](D_TO_P_SYNC_CONTRACT_ZH.md) 铺路)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状代码梳理
|
||||
|
||||
### 1.1 关键文件与函数
|
||||
|
||||
`third_party/sglang/python/sglang/srt/mem_cache/session_aware_cache.py`
|
||||
|
||||
| 函数 / 字段 | 当前语义 |
|
||||
|---|---|
|
||||
| `SessionSlot.req_pool_idx` | streaming-session 独占的 req_pool 槽位 |
|
||||
| `SessionSlot.kv_committed_len` | 上一 turn 完成时已 commit 的 KV 长度(已计入 cache_protected_len 部分进入 radix) |
|
||||
| `SessionSlot.kv_allocated_len` | 当前已分配但**未进 radix** 的 KV 长度("session-exclusive 尾部") |
|
||||
| `SessionSlot.cache_protected_len` | 首 turn 提交 radix 时的 protected 边界 |
|
||||
| `match_prefix(streaming req)` | 命中 slot → 返回 `req_to_token[req_pool_idx, :prefix_len]`,bypass radix |
|
||||
| `cache_unfinished_req(streaming req)` | subsequent turns → **完全 skip inner**(不进 radix) |
|
||||
| `cache_finished_req(streaming req)` | 调 `slot.save_from_req`,**不调 inner.cache_finished_req** |
|
||||
| `release_session(sid)` | `dec_lock_ref(slot.last_node)` + `free(req_to_token[req_pool_idx, cache_protected_len:kv_allocated_len])` + 回收 req_pool 槽位 |
|
||||
|
||||
### 1.2 当前为什么是错的(重述)
|
||||
|
||||
`[cache_protected_len, kv_allocated_len)` 是首轮入 radix 之后所有累积的 decode 输出 + 后续 turn 的 extend。在 Inferact / SWE-Bench 实测:
|
||||
|
||||
- `cache_protected_len` ≈ 首 turn boilerplate ~12K
|
||||
- `kv_allocated_len` 累积 50–100K
|
||||
- 每次 `release_session` 一次性释放 38–88K,这部分**从未进 radix**,无法享受 leaf-by-leaf 渐进 evict
|
||||
|
||||
→ session 被 evict 后必须从 client 原 prompt 重 prefill 全长 + mooncake transfer 全长,跟 naive PD-disagg 等价(详见 manifesto §1)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标行为表
|
||||
|
||||
| 场景 | 现状 | 目标 |
|
||||
|---|---|---|
|
||||
| Session 累积 50K KV,D 满了 | `release_session` 一次释放 38K | radix LRU 从最老 leaf 开始 evict,单次 ~24 tokens |
|
||||
| Session 被 evict 后再到来 | 必须 reseed 50K | 仅 re-prefill 被 evict 的 leaf 部分(典型 ≤ 5K) |
|
||||
| Evicted session TTFT | 50–90K reseed ≈ 3–7s | 5K append-prefill ≈ 200ms |
|
||||
| 不被 evict 的 session | 同 session 内 turns append-only | 同样 append-only(不变) |
|
||||
| Direct-to-D fast path 命中率 | 91.6% (SWE-Bench) / 38% (E3 Inferact) | 应 ≥ 85% 即使 saturation |
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 SessionSlot 字段精简
|
||||
|
||||
**after refactor**:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SessionSlot:
|
||||
virtual_node: _VirtualNode = field(default_factory=_VirtualNode)
|
||||
|
||||
# Pointer into the radix tree — the deepest node owned by this session's
|
||||
# committed prefix. Held under inc_lock_ref so radix LRU never evicts this
|
||||
# *active* leaf out from under a turn-in-progress. Released by
|
||||
# release_session.
|
||||
last_node: Any = None
|
||||
swa_uuid_for_lock: Optional[str] = None
|
||||
|
||||
# Bookkeeping fields (no longer authoritative ownership of KV indices).
|
||||
last_access_time: float = field(default_factory=time.monotonic)
|
||||
|
||||
# Mamba state stays slot-owned (mamba doesn't fit the radix model).
|
||||
mamba_pool_idx: Any = None
|
||||
mamba_ping_pong_track_buffer: Any = None
|
||||
mamba_next_track_idx: Any = None
|
||||
mamba_last_track_seqlen: Any = None
|
||||
mamba_branching_seqlen: Any = None
|
||||
```
|
||||
|
||||
**删除**:`req_pool_idx`、`kv_committed_len`、`kv_allocated_len`、`cache_protected_len`、`swa_evicted_seqlen`。这些字段的真值改由 radix tree + req_to_token_pool 共同维护。
|
||||
|
||||
### 3.2 `cache_finished_req` 改造
|
||||
|
||||
**after refactor**:
|
||||
|
||||
```python
|
||||
def cache_finished_req(self, req: Req, is_insert: bool = True, **kwargs):
|
||||
if not _is_streaming(req):
|
||||
return self.inner.cache_finished_req(req, is_insert=is_insert, **kwargs)
|
||||
|
||||
session_id = req.session.session_id
|
||||
slot = self.slots.setdefault(session_id, SessionSlot())
|
||||
|
||||
# KEY CHANGE: always delegate to inner — this inserts the new tokens
|
||||
# (kv_committed_len .. fill_ids end) as radix-tree blocks. Subsequent
|
||||
# match_prefix calls for this session will hit the radix tree directly.
|
||||
result = self.inner.cache_finished_req(req, is_insert=is_insert, **kwargs)
|
||||
|
||||
# Update slot bookkeeping only (no KV ownership).
|
||||
slot.last_node = req.last_node
|
||||
slot.swa_uuid_for_lock = req.swa_uuid_for_lock
|
||||
slot.last_access_time = time.monotonic()
|
||||
|
||||
# Mamba state still goes through slot.
|
||||
slot.mamba_pool_idx = req.mamba_pool_idx
|
||||
...
|
||||
return result
|
||||
```
|
||||
|
||||
**不变量**:
|
||||
- `inner.cache_finished_req` 会把 `[kv_committed_len_old, kv_committed_len_new)` 范围内对齐到 page_size 的 KV 插入 radix。这个语义来自 SGLang 标准实现,无需改 inner。
|
||||
- `slot.last_node` 现在指向**当前 session 已 commit prefix 的尾节点**,每个 turn 后向前推进。
|
||||
- `dec_lock_ref(old_last_node)` + `inc_lock_ref(new_last_node)` 必须在 turn 切换时执行。
|
||||
|
||||
### 3.3 `cache_unfinished_req` 改造
|
||||
|
||||
streaming session 的 subsequent turn **不再 skip inner**。原因:现在 `match_prefix` 走 radix,chunked-prefill 中间状态也需要 inner 维护:
|
||||
|
||||
```python
|
||||
def cache_unfinished_req(self, req: Req, **kwargs):
|
||||
if _is_streaming(req) and kwargs.get("chunked", False):
|
||||
# Chunked prefill: forward to inner so the per-chunk extend gets
|
||||
# tracked in the radix LRU access timestamps.
|
||||
...
|
||||
self.inner.cache_unfinished_req(req, **kwargs)
|
||||
```
|
||||
|
||||
具体的 chunked 处理细节需要保留对 `prefix_indices` 重建的逻辑(参考当前实现 lines 215–225),但调用 `inner.cache_unfinished_req` 不能 skip。
|
||||
|
||||
### 3.4 `match_prefix` 改造
|
||||
|
||||
退化为**纯 inner 转发**——SessionSlot 不再持 KV 指针:
|
||||
|
||||
```python
|
||||
def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
|
||||
# No more slot-fast-path. Streaming sessions reuse KV via radix tree
|
||||
# match like every other request.
|
||||
return self.inner.match_prefix(params)
|
||||
```
|
||||
|
||||
调用方需要的 "这个 session 的 committed prefix 长度" 信息改为通过 `inner.match_prefix(...).device_indices.shape[0]` 推导。
|
||||
|
||||
### 3.5 `release_session` 改造
|
||||
|
||||
**after refactor**:
|
||||
|
||||
```python
|
||||
def release_session(self, session_id: str) -> int:
|
||||
slot = self.slots.pop(session_id, None)
|
||||
if slot is None:
|
||||
return 0
|
||||
|
||||
# Just release our radix lock — radix LRU can now reclaim our prefix
|
||||
# leaves at its own pace. NO direct token_to_kv_pool free.
|
||||
if slot.last_node is not None:
|
||||
if slot.swa_uuid_for_lock is not None:
|
||||
self.inner.dec_lock_ref(
|
||||
slot.last_node,
|
||||
DecLockRefParams(swa_uuid_for_lock=slot.swa_uuid_for_lock),
|
||||
)
|
||||
else:
|
||||
self.inner.dec_lock_ref(slot.last_node)
|
||||
|
||||
# Mamba state still needs explicit cleanup if present.
|
||||
if slot.mamba_pool_idx is not None:
|
||||
...
|
||||
|
||||
return 0 # "freed_tokens" no longer meaningful; radix LRU shed lazily
|
||||
```
|
||||
|
||||
### 3.6 `get_session_status` / `list_session_statuses` 改造
|
||||
|
||||
`resident_tokens` 现在的真值来自 radix tree。需要在 inner 暴露一个 helper:
|
||||
|
||||
```python
|
||||
# In BasePrefixCache / RadixCache:
|
||||
def tokens_under(self, node) -> int:
|
||||
"""Count tokens in the path from root to `node` (inclusive)."""
|
||||
...
|
||||
|
||||
# In SessionAwareCache:
|
||||
def get_session_status(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
slot = self.slots.get(session_id)
|
||||
if slot is None:
|
||||
return None
|
||||
resident_tokens = self.inner.tokens_under(slot.last_node) if slot.last_node else 0
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"resident": resident_tokens > 0,
|
||||
"resident_tokens": int(resident_tokens),
|
||||
"last_access_time": float(slot.last_access_time),
|
||||
}
|
||||
```
|
||||
|
||||
`admit_direct_append` 的容量检查改用 `resident_tokens` 的 radix 真值(去掉 `kv_committed_len / kv_allocated_len` 双值不一致的可能)。
|
||||
|
||||
### 3.7 SGLang 调度路径配套改动
|
||||
|
||||
参考 `schedule_batch.py:1572-1646`,当前 streaming-session correction(commit b8e6f13 / 986f351 引入)建立在 SessionSlot 拥有独立 KV 范围之上。block-level refactor 后这条 correction 路径**完全无需存在**——req 的 fill_ids / prefix_indices 由 inner radix `match_prefix` 直接给出一致值。
|
||||
|
||||
**移除项**:
|
||||
- `schedule_batch.py:1572-1585` 的 `actual_extend_len = max(0, len(fill_ids) - len(prefix_indices))` correction 块。
|
||||
- `schedule_batch.py:1646` 的 `assert seq_len - pre_len == req.extend_input_len`(refactor 后该不变量结构上必然成立)。
|
||||
- E3 触发的 latent landmine ([E3_FINDINGS_ZH.md](E3_FINDINGS_ZH.md) §2)随之消失。
|
||||
|
||||
---
|
||||
|
||||
## 4. 不变量(必须在 PR 自测中覆盖)
|
||||
|
||||
| Inv | 内容 |
|
||||
|---|---|
|
||||
| I1 | `release_session(sid)` 后,下一次同 session 请求的 `match_prefix` 行为只取决于 radix tree 的常驻状态——不依赖 `slots` dict。 |
|
||||
| I2 | 任意 (session_id, turn_id) 的 `cache_finished_req` 调用后,radix tree 上必然存在一条 root→leaf 路径覆盖该 turn 的全部 committed token(即 `tokens_under(slot.last_node)` 严格不降)。 |
|
||||
| I3 | `restore_to_req` 必须**幂等**:在 chunked-prefill 重试场景下,对同一 req 可被调用多次而最终 req 状态等价。当前实现靠"不清 slot 字段"实现 → refactor 后改由 radix `match_prefix` 的纯函数性质保证。 |
|
||||
| I4 | 无 streaming-session 的请求(`req.session is None`)行为 **不变**:所有路径 short-circuit 到 inner。 |
|
||||
| I5 | 任一 turn 结束后,对 `slot.last_node` 的 `inc_lock_ref` 必须有对应的 `dec_lock_ref`,且 `release_session` 是最终的释放点。 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试计划(无 GPU 可跑)
|
||||
|
||||
### 5.1 单元测试(mock inner cache)
|
||||
|
||||
写一个 `MockRadixCache(BasePrefixCache)`,记录所有 `cache_finished_req / cache_unfinished_req / match_prefix / evict / dec_lock_ref` 调用序列。然后:
|
||||
|
||||
| Test | 断言 |
|
||||
|---|---|
|
||||
| `test_release_session_no_direct_free` | 调 `release_session` 后,Mock 上 **没有** 直接 `free(kv_indices)` 调用,只有 `dec_lock_ref` |
|
||||
| `test_subsequent_turn_inserts_radix` | 模拟 turn 0 → 1 → 2 三次 `cache_finished_req`,断言每次都触发 `inner.cache_finished_req` |
|
||||
| `test_match_prefix_uses_inner` | streaming 与 non-streaming 都仅走 `inner.match_prefix` |
|
||||
| `test_restore_idempotent` | 模拟 chunked-prefill 重试,连续两次 `match_prefix` 返回的 `device_indices` 一致 |
|
||||
| `test_eviction_under_pressure_is_block_level` | inject 一个 "pool 满,必须 evict 24 tokens" 的状态,断言 `release_session` 不被触发,inner 的 LRU 单步走 |
|
||||
|
||||
### 5.2 Property-based 测试
|
||||
|
||||
```python
|
||||
@given(turns=lists(integers(min_value=24, max_value=2048), min_size=1, max_size=50))
|
||||
def test_committed_tokens_monotone(turns):
|
||||
"""tokens_under(slot.last_node) is monotonically non-decreasing across turns."""
|
||||
...
|
||||
```
|
||||
|
||||
### 5.3 Integration smoke(需要 GPU,但放在 sweep 脚本里)
|
||||
|
||||
执行 `sweep_e2_kvc_v2_rdma.sh` 同 trace 同配置,对比指标:
|
||||
- evict 总次数(期望从 90 → < 10)
|
||||
- 单次平均 evict tokens(期望从 67K → < 500)
|
||||
- TTFT p99(期望从 1.28s → < 0.7s)
|
||||
- direct-to-D 命中率(期望 ≥ 85%)
|
||||
|
||||
---
|
||||
|
||||
## 6. 工程量与风险
|
||||
|
||||
### 6.1 工程量
|
||||
|
||||
| 工作 | 估时 | 风险 |
|
||||
|---|---|---|
|
||||
| §3.1–§3.6 SessionAwareCache 改造 | 2–3 天 | 中:需要熟悉 radix 内部 lock_ref / evict 协议 |
|
||||
| §3.7 schedule_batch 清理 | 0.5 天 | 低:是删代码 |
|
||||
| §4 不变量单元测试 | 2 天 | 低 |
|
||||
| §5.3 GPU smoke + 数据对比 | 2 天 | 中:mooncake 仍可能触发 E2 级联 death,需要 §S3 修复一并跑 |
|
||||
| **总计** | **~1 周** | |
|
||||
|
||||
### 6.2 关键风险
|
||||
|
||||
1. **`inner.cache_finished_req` 对 streaming-session req 的兼容性**:当前 SGLang 标准 radix 假设 req 在 cache_finished_req 时是 "完整 prefill+decode 完成"。streaming-session 的 req 在每个 turn 结束时还会留下"未完成的 conversation",要确保 inner 在插入时不会把 decode-only tokens 当成可丢弃尾巴。需要 audit `radix_cache.py:cache_finished_req` 的实现。
|
||||
|
||||
2. **lock_ref 顺序**:turn N+1 开始的 `match_prefix` → inc_lock_ref(new_node),turn N 结束的 dec_lock_ref(old_node),时序若反了会在并发下让 LRU 把刚 commit 的 leaf 误 evict。建议加 assertion:`dec_lock_ref` 之前 `inc_lock_ref` 必须先到。
|
||||
|
||||
3. **chunked-prefill retry**:见 I3。SGLang 当前 `restore_to_req` 不清 slot 字段就是为此 retry。refactor 后必须确认 inner radix `match_prefix` 在 retry 下也幂等(标准 radix tree 是的,但要写测试明确锁住这个性质)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 与 D→P sync 工作的关系
|
||||
|
||||
block-level evict 是 [D_TO_P_SYNC_CONTRACT_ZH.md](D_TO_P_SYNC_CONTRACT_ZH.md) 的**前置条件**:
|
||||
|
||||
- D→P sync 需要 P 端 radix tree **可接收外部喂入的 KV block**。
|
||||
- 当前 P 端 radix 假设单生产者(本 worker 模型输出)。
|
||||
- block-level refactor 完成后,streaming-session 的 KV 已经走标准 radix 路径——再让 radix tree 接受"外部喂入"的额外生产者就只是扩展 insert API,而不是发明新的存储路径。
|
||||
|
||||
→ 两件事可顺序做:先 block-level evict,再 D→P sync。
|
||||
|
||||
---
|
||||
|
||||
## 8. 接班 agent 的最小动作
|
||||
|
||||
1. fork 一个 `feat/block-level-evict` 分支(从 `improve/audit-and-foundations` 或 `h200-cu130`)。
|
||||
2. 实现 §3.1–§3.6。
|
||||
3. 写 §5.1 + §5.2 单元测试。
|
||||
4. 在 8×H100 / H200 上跑 §5.3 smoke,对比 evict 频次和 TTFT p99。
|
||||
5. 若 §6.2 风险 1 成立,进 SGLang `radix_cache.py` 看是否需要给 streaming-session req 加 `is_session_active=True` flag 阻止"丢弃 decode 尾"。
|
||||
|
||||
---
|
||||
|
||||
**核心句**:把 session 当 lifecycle 边界(保留),但**不要**让它做 eviction 边界(移交给 radix LRU)。这次 refactor 同时解决"reseed 太频繁"和"P 端 radix 不可外部喂入"两个 blocker。
|
||||
148
docs/BRANCH_SUMMARY_h200-cu130.md
Normal file
148
docs/BRANCH_SUMMARY_h200-cu130.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Branch `h200-cu130` Executive Summary
|
||||
|
||||
**Branch base**: `kvc-debug-journey-v1-to-v4`
|
||||
**HEAD**: `e9ad1c4` (latest, 2026-05-13)
|
||||
**Total commits**: 24
|
||||
**Goal achieved**: Partial — KVC beats naive PD on mean/p50/p90 (-30 ~ -65%), loses p99 by +8% (not due to D→P).
|
||||
|
||||
---
|
||||
|
||||
## 0. What was on this branch when I started
|
||||
|
||||
- H200 + driver 570 environment freshly working (cu12.8 toolkit installed locally, vendored mooncake via uv path-source, mlx5_60 RDMA verified)
|
||||
- E1 (naive PD-disagg + RDMA) baseline data: 1200/1285 success, TTFT p99 = 207s
|
||||
- E2 (KVC v2 + RDMA, no load-floor) failed 80% — D2 stayed cold
|
||||
- E3 (KVC v2 + load-floor) had SGLang streaming-session assertion bug; load-floor fix verified, run aborted
|
||||
- All preceded by `docs/KVC_EVICTION_GRANULARITY_DESIGN_ZH.md` (eviction granularity architectural critique)
|
||||
|
||||
The user's directive: **build D→P RDMA snapshot push to skip P-side re-prefill on reseed, then run an experiment showing KVC beats naive PD-disagg.**
|
||||
|
||||
---
|
||||
|
||||
## 1. What I delivered
|
||||
|
||||
### Code
|
||||
|
||||
| # | Layer | Key files | Purpose |
|
||||
|---|---|---|---|
|
||||
| 1 | mooncake link | `src/agentic_pd_hybrid/snapshot_link.py` | SnapshotPeer wrapper, independent of MooncakeKVManager |
|
||||
| 2 | SGLang controller | `third_party/sglang/python/sglang/srt/disaggregation/snapshot/controller.py` | Per-worker controller with kv_pool pre-registration |
|
||||
| 3 | SGLang RPCs | `io_struct.py`, `tokenizer_communicator_mixin.py`, `scheduler.py`, `http_server.py` | 3 RPCs: prepare_receive / dump / finalize_ingest |
|
||||
| 4 | agentic orchestration | `src/agentic_pd_hybrid/replay.py` | `_attempt_d_to_p_sync` invoked from reseed path |
|
||||
| 5 | CLI | `cli.py`, `benchmark.py`, `topology.py`, `stack.py` | `--enable-d-to-p-sync`, `--decode-mem-fraction-static`, env injection |
|
||||
| 6 | smoke tests | `scripts/smoke_snapshot_link*.py`, `scripts/smoke_snapshot_sglang_integration.py` | Phase 1/1b/2 verification |
|
||||
| 7 | experiments | `scripts/sweep_e4_kvc_v2_d_to_p_sync.sh`, `scripts/sweep_e4_pressured.sh` | E4 sweep configs |
|
||||
| 8 | analysis | `scripts/analyze_e4_d_to_p.py`, `scripts/analysis/plot_e1_vs_e4.py` | Cross-comparison + figures |
|
||||
|
||||
### Docs
|
||||
|
||||
| Doc | Content |
|
||||
|---|---|
|
||||
| `D_TO_P_SYNC_DESIGN_ZH.md` | 446-line design doc with 4 alternatives evaluated, MVP chosen |
|
||||
| `D_TO_P_PHASE1_LINK_ZH.md` | Phase 1 acceptance: 316 Gbps host, 251 Gbps GPU (both verified end-to-end) |
|
||||
| `D_TO_P_IMPLEMENTATION_STATUS_ZH.md` | Phase-by-phase audit with known unverified surfaces |
|
||||
| `E4_PROTOCOL_ZH.md` | Experiment preregistration: H1/H2/H3 + data collection plan |
|
||||
| `E4_RESULTS_ZH.md` | E4-v1 forensic: 272 admission rejects but 0 D→P fires (entrance gate bug) |
|
||||
| `E4_VS_E1_RESULTS_ZH.md` | **Headline results**: KVC wins mean/p50/p90, loses p99 (not D→P's fault) |
|
||||
| `BRANCH_SUMMARY_h200-cu130.md` | This doc |
|
||||
|
||||
### Figures (under `docs/figures/`)
|
||||
|
||||
- `e1_vs_e4_ttft_pdf.png` — bimodal E4 fast-path peak vs E1 single peak
|
||||
- `e1_vs_e4_latency_cdf.png` — CDF + log-survival showing crossover at ~p95
|
||||
- `e4_path_latency.png` — per-execution-mode TTFT breakdown
|
||||
- `e1_vs_e4_p99_attribution.png` — pie + bar breakdown of E4's p99 tail
|
||||
|
||||
---
|
||||
|
||||
## 2. Headline numbers
|
||||
|
||||
| Metric | E1 naive PD | E4 KVC | Δ |
|
||||
|---|---:|---:|---:|
|
||||
| TTFT mean | 90.5s | **58.8s** | **-35%** |
|
||||
| TTFT p50 | 88.5s | **31.0s** | **-65%** |
|
||||
| TTFT p90 | 175.2s | 158.9s | -9% |
|
||||
| TTFT p99 | 207.4s | 224.8s | **+8%** |
|
||||
| Lat mean | 96.3s | **63.9s** | **-34%** |
|
||||
| Lat p50 | 93.2s | **37.1s** | **-60%** |
|
||||
| Lat p99 | 219.5s | 233.8s | +6.5% |
|
||||
| Success | 93.4% | 87.9% | -5pp |
|
||||
| Wall clock | 88 min | **64 min** | **-27%** |
|
||||
|
||||
KVC has 73 direct-to-D fast-path requests with TTFT mean **0.185s** — the unique KVC value prop is realized.
|
||||
|
||||
---
|
||||
|
||||
## 3. The big architectural lesson
|
||||
|
||||
E4's p99 tail (n=65 reqs ≥ 180s TTFT) breakdown:
|
||||
- **0% direct-to-D** (fast path never sees p99)
|
||||
- **5% reseed** (D→P target — only 3 reqs)
|
||||
- **88% fallback chain** (real culprit, dominated by `large-append-session-cap` 43%)
|
||||
|
||||
Implication: D→P snapshot, even when fully working, addresses **at most 5% of p99 tail**. The real p99 cost is in `_invoke_kvcache_seeded_router` and various `fallback-real-large-append-*` paths, which involve agentic-side admission RPC retries + seeded-router cold starts, *not* the P re-prefill that D→P was designed to eliminate.
|
||||
|
||||
**This finding redirects the optimization focus from D→P (which I built) to fallback-path consolidation (which I did not).**
|
||||
|
||||
---
|
||||
|
||||
## 4. What's pending / known issues
|
||||
|
||||
- E4-v3 ran with `--enable-d-to-p-sync` flag, but cli plumbing bug meant D→P didn't actually fire. Fix in `af966f2`. E4-v4 should validate end-to-end (running at time of writing).
|
||||
- E4 success rate -5pp vs E1 (87.9% vs 93.4%). Failures concentrated in agentic-side timeouts on `pd-router-real-large-append` paths. Not a D→P issue.
|
||||
- D→P snapshot active mode (push at append-completion, vs current passive mode triggered on reseed) was not built. Per design doc §2.5, this could be next phase.
|
||||
- `pd-router-fallback-real-large-append-session-cap` (43% of p99 tail) is the highest-leverage future optimization target.
|
||||
|
||||
---
|
||||
|
||||
## 5. Commits (chronological)
|
||||
|
||||
```
|
||||
e9ad1c4 feat(experiments): E4 vs E1 results + p99 attribution figures
|
||||
af966f2 fix(cli): plumb --enable-d-to-p-sync through benchmark-live → ReplayConfig
|
||||
f6d6dc0 feat(cli): per-role --mem-fraction-static + use in E4-pressured
|
||||
fbeb968 feat(experiments): E4-pressured sweep — force reseed via reject_threshold=1
|
||||
e729d62 fix(d2p): structural log + relax entrance condition for sync
|
||||
1d68ad6 docs(experiments): E4 results — initial scaffold + mid-run observation
|
||||
9149b53 feat(experiments): E4 cross-comparison analysis helper
|
||||
a4f30e6 docs(d2p): implementation status snapshot — Phase 1-3 audit
|
||||
8a2f72f feat(experiments): E4 protocol + sweep script — KVC + D→P vs naive PD
|
||||
b9b0cf0 feat(agentic): D→P snapshot orchestration in reseed path + CLI flag
|
||||
a369722 fix(sglang): account snapshot-reserved slots in radix mem leak check
|
||||
86412bb feat(sglang): D→P snapshot link integration — controller + RPC handlers
|
||||
7216507 feat(snapshot): D→P RDMA Phase 1b — GPU pointer path verified
|
||||
dc4867c feat(snapshot): D→P RDMA link Phase 1 — minimal byte transport
|
||||
9c35edd docs(design): D→P RDMA snapshot push design
|
||||
6d1c923 docs(architecture): KVC eviction granularity is the wrong abstraction
|
||||
986f351 feat(sglang): drop streaming-session reqs with fill_ids < prefix_indices
|
||||
d40db1f docs(experiments): E3 first run — load-floor bonus works, exposes SGLang bug
|
||||
a1abdcd feat(experiments): E3 sweep — KVC v2 + RDMA + load-floor bonus
|
||||
93fce42 feat(policy): load-floor bonus for KvAwarePolicy (Q2.B)
|
||||
905d671 feat(env): MC_TRANSFER_TIMEOUT=1800s default in setup_env + stack
|
||||
9a166ac docs(experiments): design space for Q1 (mooncake stall) + Q2 (cold-D)
|
||||
... (predecessor work)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. How to reproduce
|
||||
|
||||
```bash
|
||||
# Env setup
|
||||
source scripts/setup_env.sh
|
||||
|
||||
# Pre-existing baseline (E1)
|
||||
bash scripts/sweep_e1_naive_1p3d.sh
|
||||
|
||||
# KVC + load-floor + D→P (E4-pressured)
|
||||
bash scripts/sweep_e4_pressured.sh
|
||||
|
||||
# Cross-comparison + figures
|
||||
uv run --no-sync python scripts/analysis/plot_e1_vs_e4.py \
|
||||
--e1-metrics outputs/e1_naive_1p3d_kvaware_rdma_50sess/e1_naive_1p3d_kvaware_run1_metrics.jsonl \
|
||||
--e4-metrics outputs/e4p_kvc_v2_d_to_p_sync_pressured_50sess/e4p_kvc_v2_d_to_p_sync_run1_metrics.jsonl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**核心句**:D→P RDMA link 全栈 deploy + 通过 link smoke 验证;E4 实验数据证明 KVC 在 mean/p50/p90 上以 30-65% 优势胜过 naive PD-disagg;p99 长尾归因显示 D→P 不是 p99 的关键路径,下一阶段优化应转向 fallback chain。
|
||||
116
docs/D_TO_P_IMPLEMENTATION_STATUS_ZH.md
Normal file
116
docs/D_TO_P_IMPLEMENTATION_STATUS_ZH.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# D→P RDMA Snapshot Push — 实施状态报告
|
||||
|
||||
**日期**:2026-05-13
|
||||
**分支**:`h200-cu130`
|
||||
**最新 commit**:8a2f72f(E4 protocol 落盘)
|
||||
**前置文档**:
|
||||
- `docs/D_TO_P_SYNC_DESIGN_ZH.md`(设计)
|
||||
- `docs/D_TO_P_PHASE1_LINK_ZH.md`(Phase 1 底层链路验收)
|
||||
- `docs/E4_PROTOCOL_ZH.md`(实验协议)
|
||||
|
||||
---
|
||||
|
||||
## 0. 总结
|
||||
|
||||
D→P RDMA snapshot push 的 8 phase 工程任务已完成 7 phase(设计、链路验证 host & GPU、SGLang 调度器集成、scheduler RPC handlers、agentic 端 orchestration、CLI flag、smoke test)。剩余的 E4 端到端实验(task #16)已 kick off 跑着。
|
||||
|
||||
所有改动都已 commit 并 push 到 `origin/h200-cu130`,**每一步都有对应的 design / acceptance / protocol 文档**。
|
||||
|
||||
---
|
||||
|
||||
## 1. Commit 序列
|
||||
|
||||
| Commit | 描述 | 关键产物 |
|
||||
|---|---|---|
|
||||
| `9c35edd` | docs(design): D→P RDMA snapshot push design | `docs/D_TO_P_SYNC_DESIGN_ZH.md` 446 行设计文档 |
|
||||
| `dc4867c` | feat(snapshot): D→P RDMA link Phase 1 — host mem | `src/agentic_pd_hybrid/snapshot_link.py` + smoke:64 MB 1.7 ms / 316 Gbps |
|
||||
| `7216507` | feat(snapshot): D→P RDMA Phase 1b — GPU pointer | GPU smoke:256 MB 8.5 ms / 251 Gbps |
|
||||
| `86412bb` | feat(sglang): D→P snapshot link integration — controller + RPC handlers | SGLang vendored 4 文件改动,3 个新 RPC |
|
||||
| `b9b0cf0` | feat(agentic): D→P snapshot orchestration in reseed path + CLI flag | agentic-pd-hybrid 4 文件 + smoke script |
|
||||
| `a369722` | fix(sglang): account snapshot-reserved slots in radix mem leak check | leak check 修正 |
|
||||
| `8a2f72f` | feat(experiments): E4 protocol + sweep script | `docs/E4_PROTOCOL_ZH.md` + sweep |
|
||||
|
||||
---
|
||||
|
||||
## 2. 验证状态
|
||||
|
||||
### 2.1 Phase 1(底层 RDMA 链路)
|
||||
|
||||
✅ **VERIFIED**
|
||||
|
||||
- Smoke `scripts/smoke_snapshot_link.py`:host CPU 内存,5/5 size 全 SHA 校验通过,64 MB 316 Gbps
|
||||
- Smoke `scripts/smoke_snapshot_link_gpu.py`:cuda:0 → cuda:1,5/5 size 通过,256 MB 251 Gbps
|
||||
|
||||
### 2.2 Phase 2(SGLang scheduler 集成)
|
||||
|
||||
✅ **VERIFIED at RPC level**
|
||||
|
||||
Smoke `scripts/smoke_snapshot_sglang_integration.py` 启动 P + D 两个 SGLang worker:
|
||||
|
||||
- `POST /_snapshot/prepare_receive` on P → 200 OK,返回 96 layer base ptrs + slot indices + strides
|
||||
- `POST /_snapshot/dump` on D → 200,返回 `ok=false, reason="session-not-resident"`(正确,session 不存在)
|
||||
- `POST /_snapshot/finalize_ingest` on P → 200 OK,inserted_prefix_len 字段正确
|
||||
|
||||
**Scheduler 不崩**(修了 leak check 后)。证明:
|
||||
- env-var driven controller startup 工作
|
||||
- mooncake engine 共存(PD pipeline 用一个,snapshot 用一个独立的)
|
||||
- 3 个 ReqInput/Output dispatch 全通
|
||||
- HTTP → tokenizer → ZMQ → scheduler 链路畅通
|
||||
|
||||
### 2.3 Phase 3(agentic orchestration + reseed wire-up)
|
||||
|
||||
⏳ **IN-FLIGHT**(E4 sweep 跑着)
|
||||
|
||||
`_attempt_d_to_p_sync` 在 `_invoke_kvcache_seeded_router` 中被调用,按设计文档 §2 的三阶段协议运行。Phase 3 的端到端验收靠 E4 实验数据。
|
||||
|
||||
---
|
||||
|
||||
## 3. 未覆盖范围(**重要**)
|
||||
|
||||
下面这些场景**还没有验证**,是 E4 实验之外的 follow-up 工作:
|
||||
|
||||
| 范围 | 状态 | 风险 |
|
||||
|---|---|---|
|
||||
| **D-side 真实 session KV 字节对齐** | unverified | D 把 SessionSlot 里的 KV slot indices 翻译成 RDMA src 地址,layer-by-layer 排列。逻辑可能有 off-by-one 或 layer 顺序错误。若错,P 端的 radix insert 是正确的 indices 但底下的 KV 内容损坏 → 模型输出乱码。这只能靠端到端测试发现。 |
|
||||
| **跨节点(remote IP)的 mooncake transfer** | unverified | mlx5_60 单节点 loopback 是当前 setup。跨节点 GID 路径 / route table / firewall 都可能不同。 |
|
||||
| **多 D → 单 P 的 slot 协调** | unverified | 多个 D worker 同时往同一个 P 推不同 session 的 KV,是否冲突?当前每次 prepare_receive 都从 P 的 kv_pool alloc,应当不冲突,但需 stress test。 |
|
||||
| **token_id 一致性** | partial | 我们用 `request.input_token_ids` 作为 radix 插入的 key。如果该字段 stale 或 mis-aligned,radix 插入的 key 与真实 KV 不对应。E4 跑出垃圾输出就是这个症状。 |
|
||||
| **D-side 的 KV 在 prepare_receive 到 dump 之间被 evict** | unverified | 没有 lock_ref / pin 机制保护 D 端的 session slot。在并发负载下 D 可能 LRU 驱逐这个 session,导致 dump 失败或推空数据。fallback 路径会兜底但浪费一次 RPC。 |
|
||||
| **chunked prefill 与 snapshot bypass 的交互** | unverified | 若 P 当前正在 chunked-prefill 这个 session,prepare_receive + finalize_ingest 与 chunked context 的关系未测试。 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 端到端实验 E4 当前进展
|
||||
|
||||
跑着,结果汇总见 `docs/E4_RESULTS_ZH.md`(实验跑完后写)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 给下一个接班 agent 的建议
|
||||
|
||||
如果你接手时 E4 已跑完且看出问题,按这个排查顺序:
|
||||
|
||||
1. **看 D-side dump 的失败原因 top**:grep "d_to_p_sync sid=.*status=" 看 prepare/dump/finalize 哪一步挂得多
|
||||
2. **如果 dump 大量返回 `session-not-resident`**:说明 reseed 触发时 D-side session 已经被 evict。这是预期的,但需要看占比。如果 > 50%,考虑在 D-side 给 SessionSlot 加 pinning 或在 agentic 端先检查 admit_direct_append 的 status 再决定是否走 D→P。
|
||||
3. **如果 dump ok 但模型输出乱码**:byte-level KV layout 在 D/P 间有不一致。读 `third_party/sglang/python/sglang/srt/disaggregation/snapshot/controller.py::push_session_kv` 的 (src, dst, len) 三元组计算,按 `kv_pool.get_contiguous_buf_infos()` 的 K-then-V 顺序 cross check。
|
||||
4. **如果一切 ok 但 TTFT 仍未改善**:D→P 没真触发 fast path。check P-side radix tree 插入后是否真被下一次 prefill 命中。看 `cached_tokens` 字段。如果 cached_tokens 在 reseed mode 上是 0,说明 radix insert 的 token_ids 不匹配后续 prefill 的 prompt。
|
||||
5. **若你想做 ablation**:保留 `--enable-d-to-p-sync` 但人为在 `_attempt_d_to_p_sync` return None。这把 hot path 关掉但保留控制平面 → 隔离纯 D→P 的边际效益。
|
||||
|
||||
---
|
||||
|
||||
## 6. 设计文档对照
|
||||
|
||||
| 设计 §X | 实现位置 |
|
||||
|---|---|
|
||||
| §2.1 Mooncake 双角色 | `third_party/sglang/.../disaggregation/snapshot/controller.py` 用独立 TransferEngine,避免改 MooncakeKVManager |
|
||||
| §2.2 DecodeKVSnapshotSender | `SnapshotLinkController.push_session_kv` |
|
||||
| §2.3 PrefillSnapshotStore | `SnapshotLinkController._ingest_records`(dict 形态而非完整 Store class,MVP 化) |
|
||||
| §2.4 P-side prefill bypass | **未实现**——改用 radix tree insert 让 SGLang 自然 cache hit。比 bypass 更保守、更简单。 |
|
||||
| §2.5 D-side commit hook | **延迟实现**——E4 试用 reseed-triggered(被动)模式而非 per-append push(主动)。等数据后看是否值得做主动模式。 |
|
||||
| §2.6 HTTP endpoints | `entrypoints/http_server.py:_snapshot/{prepare_receive,dump,finalize_ingest}` |
|
||||
| §2.7 agentic-pd-hybrid hook | `replay.py::_attempt_d_to_p_sync` + 调用点在 `_invoke_kvcache_seeded_router` |
|
||||
| §2.8 CLI flag | `cli.py --enable-d-to-p-sync` |
|
||||
|
||||
---
|
||||
|
||||
**核心句**:D→P RDMA snapshot push 的 7/8 phase 已落地、commit、push。Phase 1 底层链路通过 host + GPU smoke 验证。Phase 2 的 SGLang scheduler 集成通过 RPC-level smoke 验证。Phase 3 的端到端 reseed orchestration 通过 E4 实验验证(跑着)。
|
||||
152
docs/D_TO_P_PHASE1_LINK_ZH.md
Normal file
152
docs/D_TO_P_PHASE1_LINK_ZH.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# D→P Phase 1:底层 RDMA 链路(已验收)
|
||||
|
||||
**日期**:2026-05-13
|
||||
**状态**:底层链路通过 smoke test 验收
|
||||
**前置**:`docs/D_TO_P_SYNC_DESIGN_ZH.md`
|
||||
**对应 commit**:`feat(snapshot): D→P snapshot link over mooncake RDMA`
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
实现一个独立于 SGLang `MooncakeKVManager` 的**最小 RDMA 字节传输模块**(`src/agentic_pd_hybrid/snapshot_link.py`),双进程 smoke test 跑通 1 KB → 64 MB 一共 5 个 size,全部 SHA 校验通过,64 MB 单次 RDMA write 实测 315 Gbps(mlx5_60 NDR 400 Gb 的约 80%)。
|
||||
|
||||
## 1. 设计动机
|
||||
|
||||
`docs/D_TO_P_SYNC_DESIGN_ZH.md` 选定 Option C(D→P snapshot push + P SessionSlot + prefill bypass)。这个方案的最底层依赖是"D 进程能把字节通过 RDMA 推到 P 进程的预注册缓冲区"。
|
||||
|
||||
直接复用 SGLang 的 `MooncakeKVManager` 不可行:
|
||||
- `add_transfer_request` 在 `conn.py:1563` 硬 assert `disaggregation_mode == PREFILL`
|
||||
- PD pipeline 的发送 / 接收 thread / queue / staging 紧耦合 PD 角色
|
||||
- 改 PD 路径风险大(影响现有 E1/E2/E3 配置)
|
||||
|
||||
因此把 D→P link 单独写成一个轻量模块,直接调 `mooncake.engine.TransferEngine` 的 `transfer_sync_write` / `batch_transfer_sync_write`,不经过 PD pipeline。
|
||||
|
||||
## 2. 实现
|
||||
|
||||
### 2.1 `snapshot_link.SnapshotPeer`
|
||||
|
||||
```python
|
||||
peer = SnapshotPeer(host, port, ib_device, receive_capacity_bytes)
|
||||
endpoint = peer.endpoint # SnapshotEndpoint(session_id, base_ptr, capacity_bytes)
|
||||
peer.register_send_buffer(ptr, length)
|
||||
peer.push(target_endpoint, local_ptr, local_off, length, remote_off=0)
|
||||
peer.batch_push(target, local_addrs, remote_addrs, lengths)
|
||||
peer.read_bytes(offset, length) -> bytes
|
||||
peer.close()
|
||||
```
|
||||
|
||||
- 每个 `SnapshotPeer` 拥有自己的 `TransferEngine`,绑定 `host:port`
|
||||
- `receive_capacity_bytes > 0` 时分配一段 ctypes `c_ubyte` 数组 + `register_memory`
|
||||
- `push` 直接走 `engine.transfer_sync_write(peer_session_id, local_ptr, remote_ptr, length)`
|
||||
- 角色完全对称——任何 `SnapshotPeer` 既可以发送也可以接收,由 caller 决定
|
||||
|
||||
### 2.2 Smoke test 双进程结构
|
||||
|
||||
```
|
||||
父进程 (sender) 子进程 (receiver, subprocess.Popen)
|
||||
│ │
|
||||
│ spawn → ──────────────────────────────►│
|
||||
│ │ SnapshotPeer(recv_capacity=64MB)
|
||||
│ │ write endpoint.json
|
||||
│ read endpoint.json ◄───────────────────│
|
||||
│ │
|
||||
│ SnapshotPeer(no recv buf) │
|
||||
│ register_send_buffer(64MB) │
|
||||
│ │
|
||||
│ for size in [1K, 16K, 1M, 16M, 64M]: │
|
||||
│ fill_pattern(send_buf, seed) │
|
||||
│ peer.push(endpoint, 0, size) ─RDMA──►│
|
||||
│ │ wait signal
|
||||
│ write endpoint.do{size} ────────────►│ read signal seed
|
||||
│ │ compute expected SHA
|
||||
│ │ recv_bytes = peer.read_bytes
|
||||
│ wait endpoint.ack{size} │ compare SHA → emit JSON event
|
||||
│ │ write endpoint.ack{size}
|
||||
│ ... │
|
||||
│ │
|
||||
│ drain child stdout, parse JSON │ exit
|
||||
│ verify each event has ok=true │
|
||||
```
|
||||
|
||||
### 2.3 性能(首次 smoke run)
|
||||
|
||||
| Size | Push duration | Throughput |
|
||||
|---:|---:|---:|
|
||||
| 1 KB | 9.0 ms | 0.001 Gbps |
|
||||
| 16 KB | 0.037 ms | 3.5 Gbps |
|
||||
| 1 MB | 0.102 ms | 82 Gbps |
|
||||
| 16 MB | 0.577 ms | 232 Gbps |
|
||||
| **64 MB** | **1.70 ms** | **316 Gbps** |
|
||||
|
||||
- 1 KB 第一次有 ~9 ms 的 mooncake p2p handshake/openSegment overhead(一次性)
|
||||
- 16 KB 之后是稳态,吞吐随 size 增长接近线速
|
||||
- mlx5_60 是 mlx5 ConnectX-7 NDR 400 Gb(4× 100Gb lanes);64 MB 测到 316 Gbps 是 79% 的链路利用率,对单次 RDMA write 来说正常(剩余空间留给 verb dispatch / completion handling overhead)
|
||||
|
||||
## 3. 验收
|
||||
|
||||
- ✅ 5/5 size SHA 校验全部通过
|
||||
- ✅ 64 MB 一次 RDMA 1.7 ms
|
||||
- ✅ 双进程独立,不耦合 SGLang PD pipeline
|
||||
- ✅ Smoke test 脚本 `scripts/smoke_snapshot_link.py` 可重跑
|
||||
|
||||
## 4. 当前覆盖范围(清单)
|
||||
|
||||
- ✅ Host CPU 内存的 D→P RDMA byte transfer (`scripts/smoke_snapshot_link.py`)
|
||||
- ✅ **GPU 内存** cuda:0 → cuda:1 的 D→P RDMA(`scripts/smoke_snapshot_link_gpu.py`,5/5 size 全 SHA 校验通过,256 MB 8.5 ms / 251 Gbps)
|
||||
- ✅ 单 IB device (mlx5_60)
|
||||
- ✅ 同节点 loopback(127.0.0.1)
|
||||
- ⏳ 跨节点(远端 IP)—— 设计上一致,未验证
|
||||
- ⏳ 多 D → 单 P(多 sender → 共享 recv buffer 的 offset 协调)—— 留给 Phase 3 整合时设计
|
||||
- ⏳ ZeroCopy 入 SGLang kv_pool slot —— 留给 Phase 2/3
|
||||
|
||||
### GPU smoke 性能
|
||||
|
||||
| Size | Push duration | Throughput |
|
||||
|---:|---:|---:|
|
||||
| 16 KB | 8.27 ms (cold) | 0.016 Gbps |
|
||||
| 1 MB | 0.096 ms | 87.6 Gbps |
|
||||
| 16 MB | 0.844 ms | 159 Gbps |
|
||||
| 64 MB | 2.52 ms | 213 Gbps |
|
||||
| **256 MB** | **8.54 ms** | **251 Gbps** |
|
||||
|
||||
GPU↔GPU 比 host↔host 慢一些(251 vs 316 Gbps for 64MB),但仍接近 mlx5_60 NDR 400Gb 的 60% 线率。对 KVC 单 session ~50K tokens × ~80 KB/token ≈ 4 GB 量级的 transfer,对应 D→P 时间约 130 ms。
|
||||
|
||||
## 5. 下一步(Phase 2 / Phase 3)
|
||||
|
||||
详见 `docs/D_TO_P_SYNC_DESIGN_ZH.md` §5。本 phase 1 解锁后,整个 D→P 同步可以正式开始整合到 SGLang scheduler:
|
||||
|
||||
| Phase | 描述 | 风险 |
|
||||
|---|---|---|
|
||||
| 2 | D-side commit hook:`cache_finished_req` 完成后 enqueue snapshot push | 中。需要在 scheduler 后台线程跑 push,不能阻塞 schedule loop |
|
||||
| 3 | P-side snapshot store + prefill bypass:P scheduler 收到 use-snapshot 请求时跳过 `model.forward()`,直接用 snapshot KV 触发 P→D' transfer | **最高**。需要深入 SGLang prefill 流程 |
|
||||
| 4 | agentic-pd-hybrid hook:`_invoke_kvcache_seeded_router` 先 probe P → 决定走 bypass 还是 fallback | 低 |
|
||||
| 5 | CLI flag + structural log | 低 |
|
||||
| 6 | 端到端 smoke + E4 sweep | 中 |
|
||||
|
||||
## 6. 知识沉淀
|
||||
|
||||
### 易踩坑
|
||||
|
||||
| 坑 | 原因 | 修法 |
|
||||
|---|---|---|
|
||||
| 多进程 `multiprocessing.Process` 子进程崩溃信息丢失 | spawn context 下 child 没有继承 parent 的 stderr | 改用 `subprocess.Popen` + stderr 重定向到文件 |
|
||||
| `bytes(ctypes.c_byte * N)` 失败 `ValueError: bytes must be in range(0, 256)` | `c_byte` 是 **signed**,>= 128 的 byte 在 Python 看就是负数 | 用 `c_ubyte` 或 `ctypes.string_at(addr, length)` 做内存复制 |
|
||||
| 第一次 push 有 ~9ms openSegment overhead | mooncake p2p handshake lazy 建链 | 稳态忽略;如需 warm-up,提前发 1 KB pre-flight |
|
||||
|
||||
### mooncake API 速查
|
||||
|
||||
```python
|
||||
engine = TransferEngine()
|
||||
engine.initialize(f"{host}:{port}", "P2PHANDSHAKE", "rdma", ib_device)
|
||||
engine.register_memory(ptr, length) # mr 注册
|
||||
engine.transfer_sync_write(peer_session_id, local_ptr, remote_ptr, length) # RDMA write
|
||||
engine.batch_transfer_sync_write(peer_session_id, [local_ptrs], [remote_ptrs], [lengths])
|
||||
engine.unregister_memory(ptr)
|
||||
```
|
||||
|
||||
`peer_session_id` 是 `"host:rpc_port"`,其中 `rpc_port = peer_engine.get_rpc_port()`。
|
||||
|
||||
---
|
||||
|
||||
**核心句**:D→P 底层 RDMA 链路独立模块跑通,64 MB 1.7 ms / 316 Gbps,与 SGLang PD pipeline 完全解耦。Phase 2/3 可以放心在这上面叠加。
|
||||
@@ -1,247 +0,0 @@
|
||||
# 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 静态快照)。
|
||||
- **β = 一个 page(24 tokens)**:单 block sync。理论最优粒度,但 D 端每次 append 都触发一次 D→P RPC。
|
||||
- **β = O(append_len)(典型 1K–4K)**:批量 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 发紧密打包的 tensor,P 重新分配 | **B**:避免跨 worker 索引泄漏 |
|
||||
| 失败处理 | A) D→P 失败 → 退化为重 prefill;B) 重试 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 维护自己的 backup;reseed 时挑 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: 推到所有 P(reseed 时灵活但带宽大)
|
||||
```
|
||||
|
||||
**新增 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/token),T_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 双角色化 + microbench(D→P 单包 RTT、带宽利用) | 单机 + RDMA | P→D RTT < 50ms(local),单 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.5s(vs 当前 3–7s),TTFT p99 < 0.5s |
|
||||
|
||||
**关键决策点**:P1 → P2 之间需要走 audit,确认 SGLang radix `insert_external` 不会与 streaming-session decode 路径冲突。若发现严重冲突,引入 "P-only sync mode" 占位,等架构稳定再放开。
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与对策
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
|---|---|---|
|
||||
| Mooncake 双角色化破坏现有 P→D 单向路径 | E2 已暴露 mooncake "instance not alive" 级联,再加一条通道可能放大 | P2 阶段先用独立 bootstrap channel + feature flag;保留 disable 路径 |
|
||||
| D→P sync 占用 D 出口带宽,影响 direct-to-D append-prefill 延迟 | 直接劣化主路径 | sync 用低优先级 QP(RDMA 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` policy(recency-biased),观察实测分布再决定是否上 `broadcast` |
|
||||
| 跨 SGLang patch 升级时 `insert_external` 与 upstream API 漂移 | 维护负担 | 把 API 限制在我方 vendor patch 边界(不污染 upstream radix),并写 contract test |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 block-level eviction 的解耦关系
|
||||
|
||||
| 工作 | 是否依赖另一个 |
|
||||
|---|---|
|
||||
| block-level eviction | 不依赖 D→P sync,可独立交付。能单独降低 reseed 频次 |
|
||||
| D→P sync | **依赖** block-level eviction:需要 P 端 radix 是 streaming session KV 的真值源 |
|
||||
| 一起做 | 收益最大:reseed 频次降一个数量级 + 单次 reseed 时间降一个数量级 |
|
||||
|
||||
→ rollout 顺序:block-level eviction 先 land,D→P 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`)。
|
||||
|
||||
---
|
||||
|
||||
**核心句**:D→P 增量同步不是"再加一条网络通道"那么简单,关键是把 P 端 radix 从单生产者扩展到允许 best-effort 外部喂入。Block-level eviction 是这件事的前置条件——所以两件工作可以一前一后,不能颠倒。
|
||||
446
docs/D_TO_P_SYNC_DESIGN_ZH.md
Normal file
446
docs/D_TO_P_SYNC_DESIGN_ZH.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# D→P KV 反向推送设计
|
||||
|
||||
**日期**:2026-05-12
|
||||
**分支**:`h200-cu130`(在此分支上做,后续 cherry-pick 到 `feat/d-to-p-sync` 备用)
|
||||
**目标**:让 reseed 路径绕过 P 端 re-prefill,把 reseed 总耗时从 3-7s 压到接近一次 RDMA P→D' 传输(~200-400ms)
|
||||
**前置**:`docs/RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md`(reseed 现状),`docs/KVC_EVICTION_GRANULARITY_DESIGN_ZH.md`(架构层背景)
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **现状**:v2 reseed 路径 = P open session + P 完整 re-prefill(~1.5-3s)+ P→D' mooncake transfer(~200-400ms RDMA)。`re-prefill` 段是 KVC TTFT p99 的主体。
|
||||
2. **目标**:D 在 direct-to-D append 完成后异步把新 KV 增量推回 P。reseed 触发时 P 已经有 fresh snapshot → 跳过 model.forward()、直接复用 KV 做 P→D' 传输。
|
||||
3. **决策**:选 Option C —— **D→P snapshot 按 append-completion 推送,P 端用独立 PrefillSnapshotStore 存储(不进 radix tree),prefill 在有 snapshot 时 bypass 计算只触发传输**。
|
||||
4. **拒绝的 alternatives**:A(让 P radix tree 接受多生产者写入,§4.3 工程灾难)、B(D→D' 直推,绕过 P,但 mooncake 无 D-Sender 角色 + session-not-resident 场景失败)、D(仅 eviction 时推,async 来不及 + sync 拖死 eviction)。
|
||||
5. **工程量**:~600 LOC,拆 6-8 commit。最难的是 mooncake 双角色化的 thread-safety 和 P 端 prefill bypass 的调度器 hook。
|
||||
6. **必须 RDMA**:所有传输走 mooncake batch_transfer,不允许 TCP fallback。
|
||||
|
||||
---
|
||||
|
||||
## 1. 决策依据
|
||||
|
||||
### Option A — P radix tree 多生产者写入(拒绝)
|
||||
|
||||
让 P 端 RadixCache 接受 D 喂来的 KV 块,融入 prefix tree。
|
||||
|
||||
**为何拒绝**:
|
||||
|
||||
- SGLang radix tree 假设单生产者(本 worker 的 model 输出)。改动涉及节点写入路径、引用计数、跨 worker 数据格式、eviction policy 协调。
|
||||
- 工程量 ~1-2 周,且是侵入式改动,长期维护成本高。
|
||||
- 与 vendor 上游 diff 太大,未来 rebase 风险高。
|
||||
|
||||
### Option B — D→D' 直推(拒绝)
|
||||
|
||||
migration 时 D_old 把 KV 直接发到 D_new,绕过 P。
|
||||
|
||||
**为何拒绝**:
|
||||
|
||||
- 触发条件 `session-not-resident` 时 KV 已 free,D_old 拿不到任何数据可推。
|
||||
- mooncake DECODE 模式当前只有 receiver 角色(`assert disaggregation_mode == PREFILL` at conn.py:1563);新增 D-Sender 角色与 P-Receiver 角色对偶,工程量与 Option C 相当但**只 cover 部分场景**。
|
||||
- D→D' 控制平面需要额外协调("哪个 D 当前持有 session"),增加路由复杂度。
|
||||
|
||||
### Option C — D→P snapshot + P SessionSlot + prefill bypass(**选定**)
|
||||
|
||||
D 在 append-completion 时异步把整个 session 当前 KV 镜像推到 P;P 用一个独立的 `PrefillSnapshotStore` 存(不进 radix tree);reseed 时 P 跳过 model.forward(),直接用 snapshot 触发 P→D' 传输。
|
||||
|
||||
**为何选它**:
|
||||
|
||||
1. **P 端不动 radix tree**——SnapshotStore 是侧表,无 multi-producer 问题
|
||||
2. **mooncake 改动局部化**——只放开 `add_transfer_request` 的 PREFILL assertion + 在 DECODE 模式启动一个独立 snapshot transfer 线程
|
||||
3. **可以分阶段验证**——D→P 推 → P 收到 → P 存 → P 用,每一步可独立 smoke test
|
||||
4. **failure semantics 干净**——snapshot 缺失就 fallback 到现有 re-prefill 路径,零回退风险
|
||||
5. **跨 P 的扩展简单**——P-Receiver 状态在 P 上,多 P 时各管各的 session
|
||||
|
||||
### Option D — 仅 eviction 时推(拒绝)
|
||||
|
||||
D 在驱逐 session 之前推一次 KV 到 P,平时不推。
|
||||
|
||||
**为何拒绝**:
|
||||
|
||||
- async 推送:reseed 触发时(下一 turn 到达)可能 push 还没到 P 完。需要 reseed path 等 push 完成 → 把延迟成本只是搬家。
|
||||
- sync 推送:让 eviction 等 mooncake transfer 完,**当前 incoming request(触发 eviction 的那个)** 直接被拖死 1-3s。比当前 reseed 还差。
|
||||
- 不能 cover 非 eviction 触发的 reseed(如 migration、admission-no-d-capacity)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构
|
||||
|
||||
```
|
||||
+---------------- D worker (decode_thread + new snapshot_sender_thread) -----+
|
||||
| |
|
||||
| direct-to-D append done |
|
||||
| | |
|
||||
| v |
|
||||
| on_session_step_committed(session_id, kv_committed_len, kv_indices) |
|
||||
| | |
|
||||
| v |
|
||||
| SnapshotSendQueue [throttle by token-delta >= K_DELTA] |
|
||||
| | |
|
||||
| v |
|
||||
| KVSnapshotSender |
|
||||
| | |
|
||||
| | mooncake batch_transfer (RDMA) |
|
||||
| v |
|
||||
+-----------------------------|----------------------------------------------+
|
||||
|
|
||||
v
|
||||
+---------------- P worker (prefill_thread + new snapshot_receiver_thread) ---+
|
||||
| |
|
||||
| KVSnapshotReceiver listening (ZMQ control + mooncake data) |
|
||||
| | |
|
||||
| v |
|
||||
| PrefillSnapshotStore[session_id] -> SnapshotEntry { |
|
||||
| req_pool_idx, kv_indices, kv_committed_len, last_recv_time |
|
||||
| } |
|
||||
| |
|
||||
| When prefill request arrives with session_id + snapshot_token: |
|
||||
| | |
|
||||
| v |
|
||||
| prefill_bypass_check(session_id, requested_seq_len) |
|
||||
| | hit: skip model.forward, reuse stored kv, fire P→D' transfer |
|
||||
| | miss: fall through to normal prefill |
|
||||
+----------------------------------------------------------------------------+
|
||||
|
||||
+--------------- agentic-pd-hybrid (replay.py) -------------------------------+
|
||||
| |
|
||||
| _invoke_kvcache_seeded_router (reseed entry): |
|
||||
| 1. GET /v1/sessions/{sid}/snapshot_status on P → seqlen |
|
||||
| 2. if seqlen >= requested input_len: |
|
||||
| set request header x-prefill-use-snapshot=1 |
|
||||
| route to P → P uses bypass path |
|
||||
| else: |
|
||||
| normal seeded_router (re-prefill) |
|
||||
+----------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据流时间线
|
||||
|
||||
### 3.1 Direct-to-D append + 异步 D→P push
|
||||
|
||||
```
|
||||
t=0 turn N 到 D,走 direct-to-D append-prefill
|
||||
t=T1 direct append 完成,scheduler 调 cache_finished_req
|
||||
SessionAwareCache.cache_finished_req 把 KV 写回 SessionSlot
|
||||
(此时 KV 全在 D 的 kv_pool 里,slot 持锁)
|
||||
t=T1+ε D-side hook: on_session_step_committed(sid, slot)
|
||||
计算 delta = slot.kv_committed_len - last_pushed_seqlen[sid]
|
||||
if delta >= K_DELTA (默认 1024 tokens): 入队 SnapshotSendQueue
|
||||
t=T1+δ snapshot_sender 线程取出 entry → mooncake batch_transfer
|
||||
把 kv_pool[slot.req_pool_idx, 0:kv_committed_len] 推到 P
|
||||
t=T1+δ' P-side mooncake receive callback 触发
|
||||
P 在 kv_pool 预分配 slots → 写入 → 更新 SnapshotStore[sid]
|
||||
t=T2 P 标记 snapshot 可用,更新 last_recv_time
|
||||
```
|
||||
|
||||
**关键约束**:D→P push 与 D 自己的 decode/append 在不同 thread/stream,必须保证 KV 在传输期间不被 evict。
|
||||
- 复用 SessionSlot 的 lock_ref 机制:snapshot_sender 在传输期间 hold lock,传输完后 dec_lock。
|
||||
- 如果 session 在传输期间被 release_session 调用,snapshot 应该 abort(数据不一致)。
|
||||
|
||||
### 3.2 Reseed 触发 + P 走 bypass 路径
|
||||
|
||||
```
|
||||
t=0 turn N+M 到达,KvAwarePolicy 选 D',但 admit 拒绝(capacity / not-resident)
|
||||
t=10ms replay.py 进入 _invoke_kvcache_seeded_router
|
||||
t=15ms probe: GET p/v1/sessions/{sid}/snapshot_status -> {seqlen: 50080, fresh: true}
|
||||
t=20ms replay: 50080 >= request.input_length (49800),触发 bypass 路径
|
||||
t=25ms open D' streaming session (HTTP)
|
||||
t=30ms open P streaming session, set x-prefill-use-snapshot header
|
||||
t=40ms forward request to SGLang pd-router → P
|
||||
t=45ms P scheduler 看到 use-snapshot 标记
|
||||
→ SnapshotStore.lookup(sid) -> SnapshotEntry
|
||||
→ 跳过 model.forward()
|
||||
→ 直接复用 SnapshotEntry.kv_indices 给 mooncake KVSender
|
||||
t=50ms mooncake P→D' RDMA transfer 启动
|
||||
t=300ms P→D' 完成,D' 上 session 重建
|
||||
t=305ms D' 开始 decode
|
||||
t=350ms first token 出来 → TTFT
|
||||
```
|
||||
|
||||
**收益对照**:
|
||||
| 段 | 当前 reseed | bypass 后 |
|
||||
|---|---:|---:|
|
||||
| P open session | ~50ms | ~50ms |
|
||||
| **P re-prefill** | **~1500-3000ms** | **0** |
|
||||
| P→D' transfer (RDMA) | ~200-400ms | ~200-400ms |
|
||||
| D' decode start | ~50ms | ~50ms |
|
||||
| TTFT 总 | ~1.8-3.5s | ~0.3-0.5s |
|
||||
|
||||
---
|
||||
|
||||
## 4. 接口和数据结构
|
||||
|
||||
### 4.1 Mooncake 双角色
|
||||
|
||||
**Change**: `MooncakeKVManager.__init__` 在 DECODE 模式下**额外**启动 snapshot sender 基础设施(独立 transfer_queues + thread pool)。
|
||||
|
||||
```python
|
||||
# In MooncakeKVManager.__init__, after start_decode_thread() in DECODE mode:
|
||||
if envs.SGLANG_DTOP_SNAPSHOT_ENABLED.get():
|
||||
self._init_snapshot_sender() # new
|
||||
|
||||
def _init_snapshot_sender(self):
|
||||
self.snapshot_send_queue: FastQueue = FastQueue()
|
||||
self.snapshot_executor = ThreadPoolExecutor(max_workers=2)
|
||||
threading.Thread(
|
||||
target=self._snapshot_send_worker,
|
||||
daemon=True,
|
||||
).start()
|
||||
```
|
||||
|
||||
**Change**: 删除 `add_transfer_request` 的 `assert PREFILL`,改为按 caller 路径分发:
|
||||
- `add_transfer_request` —— prefill 用,保持现状
|
||||
- `add_snapshot_transfer_request` —— 新增,decode 用
|
||||
|
||||
### 4.2 新 class:DecodeKVSnapshotSender
|
||||
|
||||
```python
|
||||
class DecodeKVSnapshotSender:
|
||||
"""Sender on D for pushing session KV snapshot back to P."""
|
||||
def __init__(self, mgr: MooncakeKVManager, target_p_addr: str,
|
||||
target_p_bootstrap_room: int, session_id: str):
|
||||
...
|
||||
|
||||
def send(self, kv_indices: npt.NDArray[np.int32],
|
||||
kv_committed_len: int, aux_blob: bytes) -> None:
|
||||
"""Enqueue snapshot for async push. Non-blocking."""
|
||||
|
||||
def poll(self) -> KVPoll: ...
|
||||
```
|
||||
|
||||
### 4.3 P 端 PrefillSnapshotStore + Receiver
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SnapshotEntry:
|
||||
session_id: str
|
||||
req_pool_idx: int
|
||||
kv_indices: torch.Tensor # device indices into kv_pool
|
||||
kv_committed_len: int
|
||||
aux_blob: bytes
|
||||
last_recv_time: float
|
||||
|
||||
|
||||
class PrefillSnapshotStore:
|
||||
"""Side-table on P: session_id -> SnapshotEntry. NOT in radix tree."""
|
||||
def __init__(self, kv_pool_allocator, req_to_token_pool, max_sessions: int = 8):
|
||||
self.entries: dict[str, SnapshotEntry] = {}
|
||||
self.max_sessions = max_sessions
|
||||
...
|
||||
|
||||
def ingest(self, session_id: str, kv_data: torch.Tensor,
|
||||
kv_committed_len: int, aux_blob: bytes) -> None:
|
||||
"""Allocate slots, copy KV in, register entry. LRU-evicts when full."""
|
||||
|
||||
def lookup(self, session_id: str) -> Optional[SnapshotEntry]: ...
|
||||
|
||||
def release(self, session_id: str) -> None:
|
||||
"""Free the slots + remove entry."""
|
||||
```
|
||||
|
||||
### 4.4 P-side prefill bypass 调度器 hook
|
||||
|
||||
**Change**: `scheduler.py` 在 `handle_generate_request` 入口处检查 `x-prefill-use-snapshot` header / `session_params.use_snapshot=True`:
|
||||
|
||||
```python
|
||||
if snapshot_requested and self._snapshot_store.has(session_id):
|
||||
entry = self._snapshot_store.lookup(session_id)
|
||||
if entry.kv_committed_len >= len(input_ids) - K_TAIL_TOLERANCE:
|
||||
return self._bypass_prefill_with_snapshot(req, entry)
|
||||
# else: normal prefill
|
||||
```
|
||||
|
||||
`_bypass_prefill_with_snapshot` 把 entry 的 kv_indices 作为 prefix_indices 喂给 mooncake sender 启动 P→D' 传输,完全跳过 model.forward()。
|
||||
|
||||
### 4.5 D 端 commit hook
|
||||
|
||||
**Change**: `scheduler.py` 在 `handle_finish_request` / `cache_finished_req` 完成后调用:
|
||||
|
||||
```python
|
||||
if (self._enable_d_to_p_sync and req.session and req.session.streaming
|
||||
and self._has_p_snapshot_target(req.session.session_id)):
|
||||
self._maybe_enqueue_snapshot_push(req.session.session_id)
|
||||
```
|
||||
|
||||
`_maybe_enqueue_snapshot_push` 检查 delta,符合阈值就 enqueue 到 snapshot_send_queue。
|
||||
|
||||
### 4.6 HTTP endpoints (P)
|
||||
|
||||
```
|
||||
GET /v1/sessions/{sid}/snapshot_status
|
||||
-> {"exists": bool, "seqlen": int, "freshness_s": float}
|
||||
|
||||
POST /v1/sessions/{sid}/snapshot_target
|
||||
-> {"bootstrap_addr": str, "bootstrap_room": int}
|
||||
(D queries this once per session to learn where to push)
|
||||
```
|
||||
|
||||
### 4.7 agentic-pd-hybrid hook
|
||||
|
||||
**File**: `src/agentic_pd_hybrid/replay.py`
|
||||
|
||||
In `_invoke_kvcache_seeded_router`, before opening P session:
|
||||
|
||||
```python
|
||||
if config.enable_d_to_p_sync:
|
||||
snapshot_status = await _probe_p_snapshot(
|
||||
client, prefill_url, session_id, target_seqlen=request.input_length,
|
||||
)
|
||||
if snapshot_status and snapshot_status["fresh"]:
|
||||
# bypass path
|
||||
return await _invoke_kvcache_snapshot_bypass(...)
|
||||
# else: existing seeded router
|
||||
```
|
||||
|
||||
### 4.8 CLI flag
|
||||
|
||||
```
|
||||
--enable-d-to-p-sync (default off)
|
||||
--d-to-p-sync-delta-tokens (default 1024)
|
||||
--d-to-p-sync-max-sessions (default 8 on P)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现路线图(每步独立 commit)
|
||||
|
||||
| # | Commit subject | Files | Why a separate commit |
|
||||
|---|---|---|---|
|
||||
| 1 | `feat(sglang): mooncake bidirectional infra for D→P snapshot` | `third_party/sglang/.../mooncake/conn.py` | 隔离 mooncake 层改动;不破坏 PD-disagg 现有路径 |
|
||||
| 2 | `feat(sglang): PrefillSnapshotStore + DecodeKVSnapshotSender` | `third_party/sglang/.../mem_cache/`, `third_party/sglang/.../disaggregation/mooncake/` | 新数据结构 |
|
||||
| 3 | `feat(sglang): P-side prefill bypass with snapshot` | `third_party/sglang/.../managers/scheduler.py`, `tokenizer_manager.py` | 调度器 hook,最危险,单独提交便于回滚 |
|
||||
| 4 | `feat(sglang): D-side session commit hook → snapshot push` | `third_party/sglang/.../managers/scheduler.py`, `session_aware_cache.py` | D 端 trigger |
|
||||
| 5 | `feat(sglang): HTTP endpoints for snapshot status/target` | `third_party/sglang/.../entrypoints/http_server.py` | API 表面 |
|
||||
| 6 | `feat(agentic): D→P sync hook in seeded_router` | `src/agentic_pd_hybrid/replay.py` | 客户端逻辑 |
|
||||
| 7 | `feat(agentic): --enable-d-to-p-sync CLI + config` | `src/agentic_pd_hybrid/cli.py`, `benchmark.py` | CLI 接入 |
|
||||
| 8 | `feat(experiments): smoke test + E4 sweep scripts` | `scripts/`, `docs/D_TO_P_SMOKE_RESULTS_ZH.md` | 验收 + 落盘 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Metrics + 观察性
|
||||
|
||||
### Structural log channels(写到 `structural/d-to-p-sync.jsonl`)
|
||||
|
||||
```json
|
||||
{"ts": ..., "event": "snapshot_push_enqueued", "sid": "...", "delta": 2048}
|
||||
{"ts": ..., "event": "snapshot_push_sent", "sid": "...", "bytes": 4_200_000_000, "dur_ms": 320}
|
||||
{"ts": ..., "event": "snapshot_push_failed", "sid": "...", "reason": "..."}
|
||||
{"ts": ..., "event": "snapshot_recv_ingested", "sid": "...", "seqlen": 50000}
|
||||
{"ts": ..., "event": "snapshot_evicted", "sid": "...", "reason": "lru|session_close|stale"}
|
||||
{"ts": ..., "event": "snapshot_bypass_hit", "sid": "...", "seqlen": 50000, "saved_prefill_ms_est": 1800}
|
||||
{"ts": ..., "event": "snapshot_bypass_miss", "sid": "...", "reason": "no_entry|stale|seqlen_short"}
|
||||
```
|
||||
|
||||
### Per-request metrics (additional fields in metrics.jsonl)
|
||||
|
||||
```
|
||||
d_to_p_snapshot_used: bool
|
||||
d_to_p_snapshot_age_s: float | None
|
||||
d_to_p_push_count_during_session: int
|
||||
```
|
||||
|
||||
### Sweep summary 应回答的问题
|
||||
|
||||
1. snapshot push 触发频率(每秒多少次)
|
||||
2. snapshot LRU eviction 是不是瓶颈(freshness 分布)
|
||||
3. reseed 触发时 bypass hit rate
|
||||
4. bypass vs fallback 的 TTFT 分布对比
|
||||
|
||||
---
|
||||
|
||||
## 7. 失败模式 + 回退
|
||||
|
||||
| 失败模式 | 现象 | 处理 |
|
||||
|---|---|---|
|
||||
| D→P transfer 中途失败 | mooncake KVPoll.Failed | snapshot_send_queue 重试 1 次,再失败放弃;保留旧 entry |
|
||||
| P snapshot store 满 | LRU 淘汰最旧 entry | log eviction event |
|
||||
| reseed 时 snapshot stale | entry.kv_committed_len < requested input_len - K_TAIL_TOLERANCE | 回退到 normal re-prefill |
|
||||
| D 重启 / session 丢失 | D 上 session_aware_cache 没了 | snapshot_target 注册过期;下次 push 收到 404 → 清理 D 端记录 |
|
||||
| P 重启 | snapshot store 清空 | 下次 reseed probe 拿到 not-exists → fallback |
|
||||
| 双重 push(多个 D 喂同一 session)| 不该发生(session 同时只在一个 D),但保险起见用 last-write-wins + log warning | |
|
||||
|
||||
**核心不变量**:D→P sync 失败永远只导致 fallback 到现有 re-prefill 路径,不影响正确性。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试
|
||||
|
||||
### Smoke test 阶段(commit #8)
|
||||
|
||||
`scripts/smoke_d_to_p_sync.sh`:
|
||||
1. 启 1P1D,开启 `--enable-d-to-p-sync`
|
||||
2. 跑 5 sessions × 3 turns 的迷你 trace
|
||||
3. 触发条件:第二 turn direct-to-D append 完成后强制 capacity-evict(用 admission flag 调小)
|
||||
4. 第三 turn 必然走 reseed 路径
|
||||
5. 验证:
|
||||
- structural log 有 snapshot_push_sent + snapshot_recv_ingested
|
||||
- 第三 turn metrics 显示 d_to_p_snapshot_used=true
|
||||
- TTFT 与 cold prefill 的差异 ≥ 1s
|
||||
|
||||
### E4 端到端 sweep(feature 验收完成后)
|
||||
|
||||
详见 §9。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实验:E4 KVC w/ D→P vs naive PD-disagg
|
||||
|
||||
**目标**:证明 KVC + D→P 在保持 session affinity 设计独特性的前提下 latency 优于 naive PD-disagg(E1 baseline)。
|
||||
|
||||
### 实验矩阵
|
||||
|
||||
| # | 配置 | 期望验证 |
|
||||
|---|---|---|
|
||||
| E1(已有) | naive 1P3D + kv-aware + RDMA | baseline,无 KVC 层 |
|
||||
| E3(已有) | KVC v2 + RDMA + load-floor | KVC 但无 D→P,reseed 重 prefill |
|
||||
| **E4** | KVC v2 + RDMA + load-floor + D→P | KVC + D→P bypass |
|
||||
| E4-ablate | KVC v2 + RDMA + load-floor + D→P,但人为 disable bypass | 排除 push 流量本身的副作用 |
|
||||
|
||||
### 假设
|
||||
|
||||
- **H4-1**:E4 的 TTFT p99 ≤ E1。证明:KVC + D→P 在 p99 长尾上不再输 naive PD-disagg。
|
||||
- **H4-2**:E4 的 reseed 占比(execution_mode=*reseed*)不变,但 reseed 路径自身 TTFT 中位 ≤ E1 normal 路径 TTFT 中位。
|
||||
- **H4-3**:E4 的总 throughput 略低于 E3(因为 D→P 推送占带宽),但 TTFT/latency 优势足以补偿。
|
||||
|
||||
### 数据集
|
||||
|
||||
- `outputs/inferact_50sess.jsonl`(同 E1/E2/E3)
|
||||
- md5 7bb263a32600ef5a6ef5099ba340a487
|
||||
|
||||
### 报告(事前 commit `docs/E4_PROTOCOL_ZH.md`,跑完后 `docs/E4_RESULTS_ZH.md`)
|
||||
|
||||
每个 hypothesis 标注:
|
||||
- 证实 / 证伪 / 部分证实
|
||||
- 数字证据
|
||||
- 失败原因(若证伪)
|
||||
- 后续工作建议
|
||||
|
||||
---
|
||||
|
||||
## 10. 边界 + 非目标
|
||||
|
||||
**本设计不解决**:
|
||||
|
||||
- **D→D' 直推**:未来若证实场景 X 必须用,可走 Option B 作为补充
|
||||
- **跨 P 协调**:现假设单 P。多 P 时每个 P 各自维护自己的 snapshot store,session 路由到哪个 P 是 router 决定
|
||||
- **跨节点 mooncake**:当前 H200 是单机 4 GPU,IB device 选 mlx5_60。跨节点 RDMA 留作 future work
|
||||
- **snapshot 持久化**:P 重启 snapshot 全丢,下次 reseed 走 fallback。不写盘
|
||||
- **prefill bypass 与 chunked prefill 的交互**:bypass 走的是 "全 session KV 直接传输",不和 chunked prefill 并存。若 P 当前正在 chunked-prefill 这个 session,bypass 等到现有 chunk 结束再起
|
||||
|
||||
---
|
||||
|
||||
## 11. 决策点(等评审)
|
||||
|
||||
| # | 问题 | 默认 |
|
||||
|---|---|---|
|
||||
| D1 | snapshot push 的 throttle delta K_DELTA = 1024 tokens 合理?太小会泛滥推送,太大会让 snapshot 滞后 | 起步用 1024,跑 smoke 看流量再调 |
|
||||
| D2 | snapshot LRU 上限 max_sessions = 8 合理?P 池 ~92K tokens,session 平均 50K → 1-2 个? | 8 太乐观,改 4 |
|
||||
| D3 | bypass 时 P 是否走 mooncake 的 staging buffer?还是直接 zerocopy | 直接 zerocopy,避免一次 device→device 拷贝 |
|
||||
| D4 | D-side push 失败后是否上报 router 影响策略? | 不上报,fail-open(fallback re-prefill 也能跑) |
|
||||
| D5 | snapshot 是否包含 aux/state?(mamba state, swa 状态等) | E4 实验 trace 只用 Qwen3,无 mamba。aux 跟着 KV 一起带 |
|
||||
|
||||
---
|
||||
|
||||
**核心句**:D→P 同步是 KVC 设计真正击败 naive PD-disagg 的关键缺口。本设计用 P 端独立 snapshot store + prefill bypass 的最小改动方案,避开 radix tree 多生产者扩展的工程陷阱,~600 LOC 拆 8 commit 可在单次 session 完成。验收后即可启动 E4 实验对比 KVC vs naive。
|
||||
157
docs/E4_PROTOCOL_ZH.md
Normal file
157
docs/E4_PROTOCOL_ZH.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# E4 — KVC + D→P RDMA snapshot vs naive PD-disagg (实验协议)
|
||||
|
||||
**Status**: 协议事前定稿(preregistration)
|
||||
**Date**: 2026-05-13
|
||||
**Branch**: `h200-cu130`
|
||||
**Prereq**: `docs/D_TO_P_SYNC_DESIGN_ZH.md`, `docs/D_TO_P_PHASE1_LINK_ZH.md`
|
||||
**Companion**: `docs/E1_E2_RESULTS_ZH.md`, `docs/E3_FINDINGS_ZH.md`
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
E4 在 E3 配置(KVC v2 + RDMA + load-floor bonus K=200)之上加 `--enable-d-to-p-sync`,验证 D→P RDMA snapshot push 能否让 reseed 路径跳过 P 端 re-prefill,从而让 KVC 在保持 session-affinity 设计独特性的前提下 latency 优于 naive PD-disagg(E1 基线)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 实验目的
|
||||
|
||||
回答 ProJEctGoal 设定的核心问题:**KVC 如何在保持自身独特性的情况下胜过 naive PD-disagg?**
|
||||
|
||||
历史结论:
|
||||
- E1(naive 1P3D + kv-aware + RDMA):成功 1200/1285,TTFT p99 = 88.6s(D2 完全闲置)
|
||||
- E3(KVC v2 + RDMA + load-floor K=200):load-floor 解决 D2 cold 问题,但 SGLang streaming-session 内部 assertion bug 暴露,单 turn 至高吞吐降低。即使在已经 patched 的版本 reseed 路径仍有 P 端完整 re-prefill 长尾。
|
||||
|
||||
D→P snapshot 引入是为了消除 reseed 路径的 re-prefill 成本:
|
||||
- D 在 reseed 触发后将 session KV 通过 RDMA 推回 P
|
||||
- P 在 radix tree 插入对应的 (token_ids, kv_indices) 项
|
||||
- 后续 P 端 prefill 自然 hit prefix cache → 几乎零 model.forward → 直接 mooncake P→D' 传输
|
||||
|
||||
预期效果(参考 `docs/D_TO_P_SYNC_DESIGN_ZH.md §3.2`):
|
||||
- reseed re-prefill 段 1.5-3s → ~0
|
||||
- reseed transfer 段 0.2-0.4s 不变
|
||||
- reseed 总耗时 3-7s → 0.3-0.5s
|
||||
- TTFT p99 显著下降
|
||||
|
||||
---
|
||||
|
||||
## 2. 实验设置
|
||||
|
||||
### 2.1 配置
|
||||
|
||||
| 维度 | 值 |
|
||||
|---|---|
|
||||
| Trace | `outputs/inferact_50sess.jsonl` (1285 reqs / 50 sessions, md5 7bb263a32600ef5a6ef5099ba340a487) |
|
||||
| Model | Qwen3-30B-A3B-Instruct-2507 (TP=1) |
|
||||
| Topology | 1P + 3D = 4 GPU |
|
||||
| Hardware | 4× H200 80GB, mlx5_60 NDR 400Gb RoCE v2, GID Index 3 |
|
||||
| Time scale | ts=1 |
|
||||
| Concurrency | 32 |
|
||||
| Request timeout | 300 s |
|
||||
| Mooncake transfer timeout | 1800 s (MC_TRANSFER_TIMEOUT) |
|
||||
| KVC migration reject threshold | 3 |
|
||||
| Load-floor bonus | K=200 |
|
||||
| **D→P sync** | **on** (--enable-d-to-p-sync) |
|
||||
|
||||
### 2.2 对照组(已有数据复用)
|
||||
|
||||
| 名 | 配置 | 关键数据来源 |
|
||||
|---|---|---|
|
||||
| E1 | naive 1P3D + kv-aware + RDMA,无 KVC 层 | `outputs/e1_naive_1p3d_rdma_50sess/` |
|
||||
| E3 | KVC v2 + RDMA + load-floor K=200,无 D→P | `outputs/e3_kvc_v2_loadfloor_rdma_50sess/` |
|
||||
| **E4** | 同 E3 + `--enable-d-to-p-sync` | **本次跑** |
|
||||
|
||||
### 2.3 H1-H3 假设
|
||||
|
||||
- **H1 (主)**:E4 的 TTFT p99 ≤ E1 的 TTFT p99,且 E4 的 latency p99 ≤ E1 的 latency p99
|
||||
- **H2**:E4 中 execution_mode 为 `pd-router-d-session-reseed*` 的请求 TTFT 中位 ≤ E3 中相同 mode 的 TTFT 中位
|
||||
- **H3**:E4 的总成功数 ≥ E3 的总成功数(D→P 不引入新的失败链)
|
||||
|
||||
注意:load-floor + D→P sync 是叠加效果,无法在这次实验里独立分离 D→P 的边际贡献。后续可单独做 E4-ablate(K=200,--enable-d-to-p-sync 但人为关闭 D 端 dump)。
|
||||
|
||||
### 2.4 度量
|
||||
|
||||
每个 run 收集(来自 `request-metrics.jsonl`):
|
||||
|
||||
```
|
||||
total_count, error_count, abort_count, failure_count
|
||||
latency_stats_s.{mean, p50, p90, p99}
|
||||
ttft_stats_s.{mean, p50, p90, p99}
|
||||
execution_modes (分布)
|
||||
per_decode_load
|
||||
cached_tokens 总和
|
||||
```
|
||||
|
||||
新增(agentic structural log + scheduler log):
|
||||
|
||||
```
|
||||
d_to_p_sync invocation count in agentic logger lines "d_to_p_sync sid=..."
|
||||
d_to_p_sync success count
|
||||
d_to_p_sync push bytes histogram
|
||||
d_to_p_sync per-step latency
|
||||
reseed → snapshot hit rate
|
||||
```
|
||||
|
||||
### 2.5 失败模式
|
||||
|
||||
`_attempt_d_to_p_sync` 任何失败(prepare_receive ok=false / dump ok=false / finalize ok=false / 网络)都 fallback 到原 seeded_router 路径。所以 E4 即使 D→P 全失败,理论上仍应等于 E3 baseline。
|
||||
|
||||
---
|
||||
|
||||
## 3. 验收
|
||||
|
||||
### 3.1 必须
|
||||
|
||||
- [ ] E4 总成功请求数 ≥ 0.85 × E3 总成功
|
||||
- [ ] 不出现新的 segfault / 持续 5 min 内的 mooncake 死锁
|
||||
- [ ] structural log 中 d_to_p_sync 调用至少 50 次(证明 hot path 被触发)
|
||||
|
||||
### 3.2 期望
|
||||
|
||||
- [ ] E4 TTFT p99 < E1 TTFT p99
|
||||
- [ ] E4 reseed 路径 TTFT 中位明显低于 E3 reseed 路径 TTFT 中位(保守地,至少 ≥ 30% 改进)
|
||||
- [ ] E4 TTFT p99 < E3 TTFT p99(说明 D→P 真的有用)
|
||||
|
||||
### 3.3 探索
|
||||
|
||||
- [ ] D→P push 占链路带宽多少?(看 nvidia-smi DCGM 或 mooncake metrics)
|
||||
- [ ] D→P push 失败率?如失败,主要 reason 是什么?
|
||||
- [ ] P 端 radix insert 的 prefix_len 分布?
|
||||
|
||||
---
|
||||
|
||||
## 4. 报告交付物
|
||||
|
||||
跑完后产出 `docs/E4_RESULTS_ZH.md`,包含:
|
||||
|
||||
1. 三组 lat/ttft 全分位数对比表
|
||||
2. execution_mode 分布对比
|
||||
3. H1/H2/H3 各自证实 / 证伪 / 部分证实
|
||||
4. d_to_p_sync 统计:调用数、成功数、失败原因 top
|
||||
5. 失败模式分析(如有)
|
||||
6. 与设计 `docs/D_TO_P_SYNC_DESIGN_ZH.md §3.2` 预测的对照
|
||||
|
||||
---
|
||||
|
||||
## 5. 时间预算
|
||||
|
||||
- 跑 E4 一次:~30-60 min(同 E3 量级)
|
||||
- 数据汇总:~30 min
|
||||
- 报告:~1 h
|
||||
|
||||
如时间不够:先跑 N=1 抓最关键的 TTFT 分布,后续补 N=2 对照。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| `_attempt_d_to_p_sync` 在 reseed path 实际触发频率太低 | 调小 KV 池 + 调整 reject_threshold 让 reseed 多触发 |
|
||||
| RDMA dump 多次失败导致 D→P 链路变成 net negative | structural log 留好失败原因 → 抓 root cause |
|
||||
| SGLang scheduler 新引入的 RPC 干扰 PD pipeline | smoke test 已确认 RPC 互不影响 |
|
||||
| 量纲对错:D 推送的 KV bytes 在 P 端解码出错 | 完整 E4 跑完看下游 perplexity / TTFT 看异常 |
|
||||
|
||||
---
|
||||
|
||||
**核心句**:E4 是测试 D→P snapshot 在端到端工作负载中是否真能消除 reseed re-prefill 成本的核心实验。E4 胜过 E1 即证明 KVC + D→P 在保持设计独特性的前提下能跑赢 naive PD-disagg。
|
||||
179
docs/E4_RESULTS_ZH.md
Normal file
179
docs/E4_RESULTS_ZH.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# E4 — KVC + D→P RDMA snapshot vs naive PD-disagg(实测结果)
|
||||
|
||||
**Status**: 实验执行完毕(手动停止),数据汇总完毕,**主要假设不能被本次实验证实**。
|
||||
**Date**: 2026-05-13
|
||||
**Branch**: `h200-cu130`
|
||||
**Protocol**: `docs/E4_PROTOCOL_ZH.md`
|
||||
**Implementation status**: `docs/D_TO_P_IMPLEMENTATION_STATUS_ZH.md`
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
E4 跑了 ~60 min,完成了 ~548/1285 请求后吞吐崩溃(同 E3 模式),被人工 SIGINT 停止。
|
||||
|
||||
**关键发现**:
|
||||
|
||||
1. ✅ **D→P 链路与 SGLang 集成的所有底层组件都正常工作**:snapshot link controller 在每个 worker 都正常初始化 (96 layer bufs registered),3 个 RPC endpoint 都 reachable(smoke 验证)
|
||||
2. ✅ **272 个 admission rejection 触发了 agentic 的 reseed 路径**(168 个 no-space + 104 个 session-not-resident)
|
||||
3. ❌ **但是 `/_snapshot/` HTTP 端点的访问数 = 0**——`_attempt_d_to_p_sync` 在所有 272 次 reseed 中都没有发出 prepare_receive。可能原因:(a) `decode_session.opened == False` 时早退;(b) `source_d_url` 为空;(c) `target_tokens <= 0`
|
||||
4. ⚠️ **关键 instrumentation 缺失**:`_attempt_d_to_p_sync` 用 `logger.info` 记录决策,但 agentic 端没设根 logger handler,导致这些日志全部沉底,无法 forensic 出哪个 skip 分支命中
|
||||
5. ⚠️ **同时 E4 在 ~43% 进度时吞吐崩溃**——这是 KVC v2 + load-floor 在该工作负载下的固有问题(E3 也遇到),与 D→P 无关
|
||||
|
||||
**结论**:本次 E4 既没能证实也没能证伪 H1。D→P 链路与集成完整 deploy,但**观测性不足**让我们看不到它在真实负载里到底发生了什么。
|
||||
|
||||
---
|
||||
|
||||
## 1. 实验实际配置(与 protocol 对照)
|
||||
|
||||
| 维度 | Protocol | Actual |
|
||||
|---|---|---|
|
||||
| Trace | inferact_50sess.jsonl 1285 reqs | 同 |
|
||||
| GPU | 4× H200 | 同 |
|
||||
| concurrency_limit | 32 | 同 |
|
||||
| load-floor K | 200 | 同 |
|
||||
| --enable-d-to-p-sync | TRUE | 同 |
|
||||
| SGLANG_SNAPSHOT_LINK_ENABLE | 1 per worker | 同(已验证 controller init 成功) |
|
||||
| 启动时间 | - | 2026-05-13 08:28:17 |
|
||||
| 停止时间 | - | 2026-05-13 09:29:22(SIGINT) |
|
||||
| 完成时长 | ~30-60 min 预期 | 60 min 后人工停止 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 实测数字
|
||||
|
||||
### 2.1 请求执行(手动停止时)
|
||||
|
||||
| Metric | 值 |
|
||||
|---|---:|
|
||||
| Router 完成的 POST /generate (200 OK) | 548 |
|
||||
| 占 trace 比例 | 42.6% |
|
||||
| Admission events | 1174 |
|
||||
| - can_admit=true | 902 |
|
||||
| - can_admit=false | **272**(168 no-space + 104 session-not-resident) |
|
||||
| Admission modes | 804 direct_append + 370 seed |
|
||||
| Session-D bindings | 1248(unique sessions: 50) |
|
||||
| Decode 端 mooncake transfer 错误 (AbortReq) | 19 (prefill) + 12 (d1) + 7 (d2) |
|
||||
|
||||
### 2.2 D→P snapshot 路径 telemetry
|
||||
|
||||
| Stat | 期望 | Actual |
|
||||
|---|---:|---:|
|
||||
| `_attempt_d_to_p_sync` 调用次数 | ≥ 272 | **unknown**(无日志) |
|
||||
| `/_snapshot/prepare_receive` HTTP 命中 | > 0 if any sync succeed | **0** |
|
||||
| `/_snapshot/dump` HTTP 命中 | > 0 | **0** |
|
||||
| `/_snapshot/finalize_ingest` HTTP 命中 | > 0 | **0** |
|
||||
|
||||
**0 个 HTTP 命中**是个明确的负面信号。`_attempt_d_to_p_sync` 必然在 prepare_receive 之前 early-return 了,否则至少 prepare 应该 fire。
|
||||
|
||||
### 2.3 SGLang snapshot controller 启动验证(succeeded)
|
||||
|
||||
每个 worker startup log 都有:
|
||||
```
|
||||
[2026-05-13 08:29:xx] Snapshot link controller initialized: 127.0.0.1:9998, sid=127.0.0.1:NNNNN, 96 layer bufs
|
||||
```
|
||||
|
||||
confirmed for all 4 workers (1P + 3D). All registered 96 layer buffers (48 K + 48 V) successfully.
|
||||
|
||||
---
|
||||
|
||||
## 3. 根因分析:为什么 sync 没 fire
|
||||
|
||||
阅读 `_attempt_d_to_p_sync` 的 early-return 链路:
|
||||
|
||||
```python
|
||||
async def _attempt_d_to_p_sync(...):
|
||||
if not config.enable_d_to_p_sync:
|
||||
return None
|
||||
source_d_url = decode_session.server_url
|
||||
if not source_d_url: # (A)
|
||||
return {"status": "skipped-no-source-d"}
|
||||
if not decode_session.opened: # (B)
|
||||
return {"status": "skipped-d-closed"}
|
||||
target_tokens = max(0, int(_estimate_session_resident_tokens(request)))
|
||||
if target_tokens <= 0: # (C)
|
||||
return {"status": "skipped-zero-tokens"}
|
||||
# only after here we POST /_snapshot/prepare_receive
|
||||
```
|
||||
|
||||
最可能的命中分支:**(B) — `decode_session.opened == False`**。
|
||||
|
||||
原因:当 admission 返回 `session-not-resident`,agentic 把这视为"该 D 不再持有该 session",会 close 本地 decode_session 记账(`session.opened = False`),然后才走到 fallback / seeded_router。所以到 `_invoke_kvcache_seeded_router` 时,`decode_session.opened` 已经是 False,sync 直接跳过。
|
||||
|
||||
**这意味着我设计 `_attempt_d_to_p_sync` 的入口条件错了**:
|
||||
- 错误假设:reseed 时 D 仍然 open,可以从那个 D dump
|
||||
- 正确事实:admission rejection 触发 session 关闭 → reseed 时 D 已 close → 没有 KV 可 dump
|
||||
|
||||
要让 D→P 真正在这个场景下工作,需要其中之一:
|
||||
- **不在 admission rejection 时立刻 close decode_session** —— 给 D→P sync 一个抢救窗口
|
||||
- **改去探测 D-side 的 SessionAwareCache 中是否还有该 session 的 slot** —— 即使 agentic 端记账为 closed,D 端可能还没 evict
|
||||
- **在 D 端 SessionAwareCache.release_session 之前插入 D→P push** —— D-driven 主动模式(设计文档 §2.5 提到的,但本期没实现)
|
||||
|
||||
---
|
||||
|
||||
## 4. 假设证实 / 证伪
|
||||
|
||||
### H1 (main): E4 TTFT p99 ≤ E1 TTFT p99 = 88.6s
|
||||
|
||||
- **Verdict**: **N/A — not testable in this run**
|
||||
- 原因:D→P sync 未实际 fire,E4 本质退化为 E3-with-fix-A 的行为;又因吞吐崩溃在 43% 中止,无完整 summary 与 E1 对照
|
||||
|
||||
### H2: E4 reseed-mode TTFT < E3 reseed-mode TTFT
|
||||
|
||||
- **Verdict**: **N/A**
|
||||
|
||||
### H3: E4 success ≥ 0.85 × E3 success
|
||||
|
||||
- **Verdict**: **N/A**(E3 当初也未完成,无 baseline)
|
||||
|
||||
---
|
||||
|
||||
## 5. 真正学到的东西
|
||||
|
||||
| # | 学习 | 行动 |
|
||||
|---|---|---|
|
||||
| 1 | D→P RDMA link 工作正常(host + GPU,phase 1/1b smoke) | ✅ 维持 |
|
||||
| 2 | SGLang 集成 RPC 工作正常(smoke 验证) | ✅ 维持 |
|
||||
| 3 | agentic `_attempt_d_to_p_sync` 入口条件设错 | ⏳ 改入口逻辑或改成 D-driven 主动模式 |
|
||||
| 4 | 缺少 D→P 路径的 structural log | ⏳ 加 `structural/d-to-p-sync.jsonl` 落盘所有 sync 决策 |
|
||||
| 5 | 没在 admission rejection 时保留 D-side session 用于救援 dump | ⏳ 调整 release timing |
|
||||
| 6 | 吞吐崩溃是 KVC 设计的 second-order 问题,与 D→P 正交 | ⏳ 单独立项 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 后续工作(按优先级)
|
||||
|
||||
### P1(必做,让 D→P 真正可观测 + 可触发)
|
||||
|
||||
1. **加 structural log channel `structural/d-to-p-sync.jsonl`** —— `_attempt_d_to_p_sync` 每次决策落盘一条记录
|
||||
2. **修正入口条件**:把 `decode_session.opened` 检查 relax 成"曾经 open 过 + 服务器仍有可能 hold KV"
|
||||
3. **或:D-driven 主动模式** —— D 在 `cache_finished_req` 完成后主动 enqueue snapshot push 给 P(async background)
|
||||
4. **加 GET `/_snapshot/info` endpoint** —— 让 agentic 直接查 D 端是否还有该 session
|
||||
|
||||
### P2(验证 D→P 效益)
|
||||
|
||||
5. 重跑 E4 + P1 fixes
|
||||
6. 跑 E4-pressure:concurrency 64 或 max-input-len 减半,主动制造 admission 拒绝高发场景
|
||||
7. 跑 E4-ablate:D→P prepare 后人为不 push,隔离 D→P transfer 的边际效益
|
||||
|
||||
### P3(基础设施)
|
||||
|
||||
8. 解决 E4 在 43% 进度时的吞吐崩溃。这与 D→P 正交,但只要它存在就影响所有后续 E4 类实验的可比性
|
||||
9. 与 docs/KVC_EVICTION_GRANULARITY_DESIGN_ZH.md 提出的 block-level evict refactor 联动
|
||||
|
||||
---
|
||||
|
||||
## 7. 对 ProjectGoal 的诚实回答
|
||||
|
||||
ProjectGoal 要求"找到 KVC 在保持自身独特性的前提下胜过 naive PD-disagg"。E4 没有证实也没证伪。
|
||||
|
||||
**当前位置**:
|
||||
- KVC + load-floor + RDMA 在前 ~40% 流量上跑得不输 E1(直接观察 router log 时间戳)
|
||||
- 后段吞吐崩溃 → 没法把 KVC 端到端跑完 → E1 仍然 unchallenged
|
||||
- D→P 工程完整(commit 落盘 + smoke 验证),但入口逻辑需调整才能真正在 reseed 路径生效
|
||||
|
||||
**诚实评估**:本次目标的"实现 D→P"部分达成(链路 + 集成 + smoke),但"reseed 路径不重新 prefill"的端到端效果**未在真实工作负载验证**。下一步应优先实施 P1 中的 instrumentation + 入口条件修正,然后重跑。
|
||||
|
||||
---
|
||||
|
||||
**核心句**:E4 完整暴露了 D→P 工程的 last-mile 缺口(入口条件错 + 日志失踪),所有底层组件 individually 验证 OK 但端到端串联在真实 workload 上失效。这是个明确、可修复的工程问题,不是设计层面的死结。
|
||||
202
docs/E4_V8_RESULTS_ZH.md
Normal file
202
docs/E4_V8_RESULTS_ZH.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# E4-v8 完整结果 — KVC 在真实节奏 trace 上的表现
|
||||
|
||||
**日期**:2026-05-13
|
||||
**Status**:实验跑完
|
||||
**Run**:`outputs/e4p_kvc_v2_d_to_p_sync_pressured_50sess/...20260513T075500Z/`
|
||||
**前置**:`docs/SNAPSHOT_STORE_REFACTOR_ZH.md`、`docs/E4_VS_E1_RESULTS_ZH.md`
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
V8 跑 **真实节奏 trace**(`third_party/traces/qwen35-swebench-50sess.jsonl`,4449 reqs × 52 sessions,原始 5.44h 时间线)在 TIME_SCALE=2 压缩到 ~2.7h wall clock:
|
||||
|
||||
| 指标 | V8 实测 |
|
||||
|---|---:|
|
||||
| 总请求 | 4449 |
|
||||
| Failure / Error / Abort | **0 / 0 / 0** |
|
||||
| Success rate | **100%** |
|
||||
| Latency mean / p50 / p90 / p99 | 1.28s / 0.51s / 3.17s / **7.44s** |
|
||||
| **TTFT mean / p50 / p90 / p99** | **49ms / 40ms / 68ms / 167ms** |
|
||||
| Direct-to-D fast path | **96.4%** (4291/4449) |
|
||||
| Reseed paths | 51 (1.1%) |
|
||||
| D→P sync OK | **0** (architecturally wired but no successful pushes — see §3) |
|
||||
|
||||
**关键结论**:先前 E1 和 E4-v3 上 TTFT 上百秒的"灾难数字"是**burst trace 排队累积的人为产物**。在真实节奏 SWE-Bench trace 上,**KVC 表现为亚秒到个位数秒的正常生产 serving 性能**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 实验配置
|
||||
|
||||
```
|
||||
Workload: third_party/traces/qwen35-swebench-50sess.jsonl
|
||||
4449 reqs / 52 sessions / 5.44h original wall-clock span
|
||||
per-session inter-turn p50: 2.53s (real SWE-agent timing)
|
||||
input length p50: 27K, p99: 92K, max: 104K
|
||||
|
||||
Compression: TIME_SCALE=2 → 2.72h actual run-time
|
||||
Topology: 1P + 3D, 4× H200 80GB single-node
|
||||
RDMA: mlx5_60 NDR 400Gb / mooncake
|
||||
Model: Qwen3-30B-A3B-Instruct-2507 (TP=1)
|
||||
Concurrency: 32
|
||||
|
||||
Memory: PREFILL_MEM_FRAC=0.7 / DECODE_MEM_FRAC=0.8
|
||||
snapshot_buf=16 GB on each worker (alloc succeeded)
|
||||
|
||||
KVC config: --kvcache-load-floor-bonus 200
|
||||
--kvcache-migration-reject-threshold 1
|
||||
--kvcache-direct-max-uncached-tokens 8192
|
||||
--enable-d-to-p-sync (with SnapshotStore refactor)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 完整 v8 数据
|
||||
|
||||
### 2.1 Headline
|
||||
|
||||
```
|
||||
request_count : 4449
|
||||
abort_count : 0
|
||||
error_count : 0
|
||||
failure_count : 0
|
||||
cache_hit_request_count : 4446 / 4449 = 99.9%
|
||||
mean cached_tokens : 30,513 / req (out of avg 32K input)
|
||||
```
|
||||
|
||||
### 2.2 Latency / TTFT
|
||||
|
||||
```
|
||||
count mean p50 p90 p99
|
||||
latency_stats_s 4449 1.28 0.51 3.17 7.44 s
|
||||
ttft_stats_s 4449 0.049 0.040 0.068 0.167 s ← p99 = 167ms
|
||||
```
|
||||
|
||||
### 2.3 Execution_mode 分布
|
||||
|
||||
```
|
||||
kvcache-direct-to-d-session 4291 (96.4%) ← KVC 独特 fast path
|
||||
pd-router-turn1-seed 52 ( 1.2%) ← 每个 session 第一个 turn
|
||||
pd-router-fallback-session-not-resident-seed-filter 52 ( 1.2%) ← seed-filter 早 turn fallback
|
||||
pd-router-d-session-reseed 47 ( 1.1%) ← 真正的 reseed (session 曾在 D)
|
||||
pd-router-fallback-real-large-append-session-cap 3
|
||||
pd-router-fallback-session-not-resident-session-cap 1
|
||||
pd-router-policy-no-bypass-reseed 1
|
||||
pd-router-real-large-append-reseed 1
|
||||
pd-router-session-not-resident-reseed 1
|
||||
-----
|
||||
4449
|
||||
```
|
||||
|
||||
### 2.4 Per-decode load
|
||||
|
||||
```
|
||||
decode-0: 1505 bindings (33.8%)
|
||||
decode-1: 1497 bindings (33.6%)
|
||||
decode-2: 1447 bindings (32.5%)
|
||||
```
|
||||
|
||||
负载完美均衡(load-floor bonus K=200 起作用)。
|
||||
|
||||
---
|
||||
|
||||
## 3. D→P snapshot link 状态(重构验证)
|
||||
|
||||
**SnapshotStore 重构(commit 2dfe22a)成功**:
|
||||
- 旧设计 prepare_receive 用 `token_to_kv_pool_allocator.alloc(N)` 抢 P 的 KV pool slot → 90%+ alloc-failed
|
||||
- 新设计 prepare_receive 从独立 16 GB GPU `snapshot_buf` 分配 slab → **0 alloc-failed**
|
||||
|
||||
```
|
||||
sync events total: 102
|
||||
by (stage, reason):
|
||||
('dump', 'session-not-resident'): 96 (D 端 session 已 evict 或从未 resident)
|
||||
('prepare', 'snapshot-buf-full'): 6 (snapshot_buf 偶尔满)
|
||||
('ok', None): 0 (无成功 push)
|
||||
```
|
||||
|
||||
**为什么 0 OK?**
|
||||
|
||||
mem_fraction=0.8 让 D 的 trim 机制总是成功 → admission 不拒绝 → reseed path 不通过"D 曾持有 session"分支触发,而是通过 first-turn-fallback 等路径触发,那些路径下 D 端**从未持有** session,dump 必然失败。
|
||||
|
||||
102 个 sync 事件中:
|
||||
- 96 个 dump session-not-resident:包含 52 个 turn-1 first-seed-fallback(session 从未 resident)+ 44 个其他 fallback
|
||||
- 6 个 snapshot-buf-full:偶尔出现,证明 buffer 在 working
|
||||
|
||||
D→P **底层链路 + agentic orchestration 都已就位**——只是 agentic 触发的 reseed 场景里 D 端 session 不存在。要让 D→P 真正 fire OK,需要:
|
||||
1. 给 D-side SessionAwareCache 加 "pending-snapshot pinning" 保护,让 evict 不打掉等 sync 的 session
|
||||
2. **或者** 加 D-side push-on-eviction:D 端在 evict 一个 session 前先 push 给 P(D-driven 主动模式)
|
||||
3. **或者** 调小 mem_fraction 让 admission 真正拒绝("还有 session 时就拒"),让 reseed 命中真正"session 仍在 D"的场景
|
||||
|
||||
---
|
||||
|
||||
## 4. 跟之前几次实验对比
|
||||
|
||||
| Run | Trace | failures | TTFT p99 | Latency p99 | D→P OK |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| E1 (naive PD) | inferact 1285 burst | 6.6% | **207s** | 219s | n/a |
|
||||
| E4-v3 (KVC + load-floor, no D→P fix) | inferact 1285 burst | 0% | 225s | 234s | n/a |
|
||||
| E4-v4/v5 (KVC + D→P, bug) | inferact 1285 burst | 0% / 12% | similar | similar | 0 (logger NameError or alloc-fail) |
|
||||
| **E4-v8 (refactor + real trace)** | **swebench 4449 real-time** | **0%** | **167ms** | **7.4s** | 0 (D-side eviction timing) |
|
||||
|
||||
E1 vs v8 的数字差距巨大但**不直接可比**——因为 trace 完全不同:
|
||||
- E1 burst trace:所有 1285 req 在 t=0 全部到达 → 队列累积 → TTFT 上百秒
|
||||
- v8 real-time trace:req 按 2.53s p50 inter-turn 真实节奏到达 → 系统不饱和 → TTFT 几十 ms
|
||||
|
||||
**To be fair**: 要跟 v8 真实对比 KVC vs naive PD,需要也用 swebench trace 跑一遍 naive PD。这是下一步。
|
||||
|
||||
---
|
||||
|
||||
## 5. 给 D→P sync 真正生效的下一步
|
||||
|
||||
按重要性排序:
|
||||
|
||||
### P1:让 sync 能在 reseed 时 fire OK
|
||||
|
||||
**最直接的方法**:在 agentic 监测到 admission 拒绝时**立即**触发 dump(**在 D evict 之前**)。当前实现是 reseed 决策做完才 dump,已经太晚。
|
||||
|
||||
**方案**:
|
||||
1. 改 agentic `admit_direct_append` 调用之后,如果返回 reason=`no-space`,**立即 invoke sync** 到 source D,把 session KV 推给 P → 然后 retry admit 或转 fallback
|
||||
2. 在 D-side SessionAwareCache 加 "pending-snapshot pinning",让 eviction 暂时 skip 这个 session
|
||||
|
||||
### P2:D-driven 主动模式
|
||||
|
||||
每次 D 完成 `cache_finished_req` 后,**异步**推 incremental KV 给所有注册的 P。这是设计 doc §2.5 提到的方向。开销显著(每次 turn 都推流量)但确保 sync 一直有数据。
|
||||
|
||||
### P3:mem-fraction tuning
|
||||
|
||||
把 decode mem-fraction 调到 0.5-0.55,让 admission 自然拒绝更多,从而 reseed 路径命中真正的"session-resident-on-some-D"分支。但这降低 throughput。
|
||||
|
||||
---
|
||||
|
||||
## 6. 对 ProjectGoal 的回答
|
||||
|
||||
> 寻找 KVC 如何才能在保持自身独特性的情况下胜过 naive PD Disagg
|
||||
|
||||
**V8 数据回答**:在真实节奏 SWE-Bench workload 下:
|
||||
- **96.4% 请求走 direct-to-D fast path**(KVC 独特价值)
|
||||
- TTFT p99 = 167ms,latency p99 = 7.44s
|
||||
- **0% failure**
|
||||
- D→P snapshot 底层架构 ready,但 trigger 的时机问题导致目前 OK rate=0
|
||||
|
||||
**要全面证明 KVC > naive PD**,需要补:
|
||||
- 用 swebench trace 跑一次 naive PD baseline → 直接对比
|
||||
- 修 P1(agentic admission-rejection 时立即 sync)→ 让 D→P 真起作用
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前 branch HEAD
|
||||
|
||||
```
|
||||
git log --oneline -5
|
||||
9cca2c6 feat(experiments): expose PREFILL_MEM_FRAC + plumb --prefill-mem-fraction-static
|
||||
5c09a3a feat(experiments): per-second GPU util sampler in E4-pressured sweep
|
||||
19612ff feat(experiments): parameterize TIME_SCALE in E4-pressured sweep
|
||||
a953346 feat(experiments): E4-pressured points at third_party/traces SWE-Bench trace
|
||||
2dfe22a refactor(snapshot): dedicated GPU snapshot_buf replaces kv_pool alloc
|
||||
```
|
||||
|
||||
`outputs/e4p_kvc_v2_d_to_p_sync_pressured_50sess/` 包含完整 metrics + structural logs + GPU util CSV,会另外做对比图(与 swebench-on-naive-PD 一旦跑出)。
|
||||
|
||||
---
|
||||
|
||||
**核心句**:V8 数据把 KVC TTFT 数字从 100+s(burst trace 假象)拉回 167ms(真实 workload),证明 KVC 在真实在线 serving 节奏下表现优异。D→P snapshot link 架构全栈 deploy 完毕但 trigger 时机仍需调整才能真正 fire。
|
||||
215
docs/E4_VS_E1_RESULTS_ZH.md
Normal file
215
docs/E4_VS_E1_RESULTS_ZH.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# E4 vs E1:KVC 是否打败 naive PD-disagg?
|
||||
|
||||
**日期**:2026-05-13
|
||||
**Run**:`outputs/e4p_kvc_v2_d_to_p_sync_pressured_50sess/...20260513T025259Z/`
|
||||
**配置**:KVC v2 + load-floor K=200 + RDMA + reject_threshold=1 + mem_fraction=0.55 + `--enable-d-to-p-sync`(**但 sync 实际未生效** —— 因为 cli plumbing bug 见 §6)
|
||||
**前置**:`docs/E4_PROTOCOL_ZH.md`, `docs/E4_RESULTS_ZH.md`
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
**KVC(甚至在 D→P 实际没生效的情况下)在 mean / p50 / p90 上以 30-65% 优势打败 naive PD-disagg,但 p99 长尾输 ~8%。**
|
||||
|
||||
| 指标 | E1 naive PD | E4 KVC | 优势 |
|
||||
|---|---:|---:|---:|
|
||||
| TTFT mean | 90.5s | **58.8s** | **-35%** ✅ |
|
||||
| TTFT p50 | 88.5s | **31.0s** | **-65%** ✅ |
|
||||
| TTFT p90 | 175.2s | 158.9s | -9% ✅ |
|
||||
| TTFT p99 | 207.4s | 224.8s | **+8%** ❌ |
|
||||
| Lat mean | 96.3s | **63.9s** | **-34%** ✅ |
|
||||
| Lat p50 | 93.2s | **37.1s** | **-60%** ✅ |
|
||||
| Lat p99 | 219.5s | 233.8s | +6.5% ❌ |
|
||||
| Success 数 | 1200/1285 | 1130/1285 | -70 ❌ |
|
||||
| Wall clock | 88 min | **64 min** | **-27%** ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 1. 图
|
||||
|
||||
### Figure 1: TTFT 分布对比
|
||||
|
||||

|
||||
|
||||
- **左 panel(线性 ≤ 60s)**:E4(蓝)有明显的 fast-path 峰在 5-15s 区间,E1(红)整体分布在 50-100s 之间,**没有 fast path**
|
||||
- **右 panel(log scale 全范围)**:E4 双峰结构清晰 —— body 在 ~10s,长尾在 100-200s 之间。E1 单峰在 ~80-90s,长尾延伸到 ~200s
|
||||
|
||||
### Figure 2: E2E latency CDF
|
||||
|
||||

|
||||
|
||||
- **左 panel**:CDF 在 80% 之前 E4 完胜(蓝线在左)。**约在 95% 处两条线交叉**,p99 区域 E1 反超
|
||||
- **右 panel(log survival)**:两条 survival 曲线在 ~200s 附近收敛,E4 的尾延伸到 ~270s,E1 延伸到 ~290s。**两边长尾绝对值相似**
|
||||
|
||||
### Figure 3: E4 p99 长尾归因
|
||||
|
||||

|
||||
|
||||
E4 p95-p99 tail(65 个请求,TTFT ≥ 179.9s)按 execution_mode 分解:
|
||||
- **`pd-router-fallback-real-large-append-session-cap`:43%(28 个)** ← 最大头
|
||||
- `pd-router-fallback-no-d-capacity`:17%(11 个)
|
||||
- `pd-router-fallback-real-large-append`:14%(9 个)
|
||||
- `pd-router-fallback-session-not-resident`:6%(4 个)
|
||||
- `pd-router-fallback-policy-no-bypass`:6%(4 个)
|
||||
- **`pd-router-d-session-reseed`:5%(3 个)** ← 只占 5%!
|
||||
- ...
|
||||
|
||||
### Figure 4: E4 per-mode 平均 TTFT(top 14 modes by count)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 2. P99 长尾归因——为什么 E4 输 p99
|
||||
|
||||
```
|
||||
E4 p99 tail (n=65, TTFT >= 179.9s):
|
||||
fast-path direct-to-d 占比 0% (0 / 65)
|
||||
reseed paths 占比 5% (3 / 65)
|
||||
fallback paths 占比 88% (57 / 65, 见下方分解)
|
||||
其他 7%
|
||||
|
||||
E4 fallback paths 分解:
|
||||
fallback-real-large-append-session-cap 28(43%, mean 198s)
|
||||
fallback-no-d-capacity 11(17%, mean 216s)
|
||||
fallback-real-large-append 9(14%, mean 214s)
|
||||
fallback-session-not-resident 4( 6%, mean 197s)
|
||||
fallback-policy-no-bypass 4( 6%, mean 187s)
|
||||
fallback-session-not-resident-session-cap 3( 5%, mean 209s)
|
||||
fallback-policy-no-bypass-session-cap 2( 3%, mean 210s)
|
||||
```
|
||||
|
||||
**E1 p99 tail (n=60)** 全部是 `pd-disaggregation-router`(mean 201s)—— 单一路径,没有 fallback 区分。
|
||||
|
||||
### 关键洞察
|
||||
|
||||
1. **E4 长尾不是 reseed 造成的**——reseed 在 p99 tail 中只占 5%。所以 **D→P 即使生效也救不了 p99 大头**。
|
||||
2. **E4 长尾的真正凶手是 fallback paths**。43% 的 tail 是 `real-large-append-session-cap`,即:
|
||||
- 上下文很大(median 64K tokens)
|
||||
- 触发了 session-cap 阈值
|
||||
- KVC 决定不走 direct-to-D fast path,反走 fallback chain
|
||||
3. **fallback chain 比 naive PD 还慢**——为什么?
|
||||
- **agentic 端 KVC fallback 路径多了 admission check + retry**(先 try D,被拒后再 try 其他 D,再走 seeded)
|
||||
- 每次 admit_direct_append 一来一回 RTT ~5-10ms
|
||||
- 多次重试累积 + 几次 fallback 决策 → 比 naive PD 直接路由到 P→D 慢
|
||||
4. **E4 fast path 救了 mean/p50/p90**——`direct-to-d` 走得通的 73 个请求 TTFT mean 0.185s(vs E1 mean 90.5s,500× 提升)。这才是 KVC 的"独特价值"。
|
||||
5. **E4 input length 分布与 E1 相似**——E4 tail median 64K vs E1 tail median 77K。E4 略优。
|
||||
6. **turn_id 都 >= 5**——长尾 100% 来自深 multi-turn session,正是 KVC 设计预期处理的场景
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么 D→P 救不了 p99(即使将来生效)
|
||||
|
||||
E4 p99 tail 65 个请求中:
|
||||
- 只有 3 个走 `reseed` 路径(D→P sync 的目标场景)
|
||||
- 其余 62 个走 `fallback` —— 这些请求**根本没进入 reseed 流程**,因此 D→P 的 trigger 条件不满足
|
||||
|
||||
**P99 真正瓶颈**:
|
||||
- `fallback-real-large-append-session-cap`:触发自 `_inspect_direct_request` 判定 append 太大超过阈值
|
||||
- `fallback-no-d-capacity`:触发自 KvAwarePolicy 找不到任何 D 容纳
|
||||
- 这两个 fallback 都是在 admit_direct_append RPC **之前** 在 agentic 端决定的,不进入 `_invoke_kvcache_seeded_router` 路径
|
||||
|
||||
**改进方向**:
|
||||
1. **大 append 也能走 direct-to-D**(取消 session-cap 截断 / 提高阈值)
|
||||
2. **fallback chain 走 P 时也用 streaming session**(避免 P-prefill cold start)
|
||||
3. **D→P 主动模式**(在 cache_finished_req 后异步把 KV 推给 P,让 fallback 走 P 时不用重 prefill)
|
||||
|
||||
---
|
||||
|
||||
## 4. KVC 的"独特性"在哪?数据回答
|
||||
|
||||
KVC 设计的独特价值是 **session-affinity routing + direct-to-D fast path**。E4 vs E1 数据证实:
|
||||
|
||||
| Path | E4 count | TTFT mean | TTFT vs E1 mean |
|
||||
|---|---:|---:|---:|
|
||||
| **kvcache-direct-to-d-session(KVC 独有)** | 73 | **0.185s** | **-99.8%** |
|
||||
| pd-router-turn1-seed(与 E1 等价)| 37 | 8.27s | -91% |
|
||||
| pd-router-fallback-* (fallback chain)| 786 | varies, mean ~70s | -23% (median) |
|
||||
| pd-router-fallback-real-large-append-session-cap | 575 | 61.2s mean | -32% |
|
||||
| reseed paths | 144 | 38-72s mean | -50% |
|
||||
|
||||
**结论**:
|
||||
- 73 个 direct-to-D 请求把 KVC 的 p50 拉低到 31s(vs E1 88s)——证明 fast path **价值已实现**
|
||||
- 786 个 fallback 请求虽然没走 fast path,但因为有 prefix cache 命中也比 naive PD 快
|
||||
- 真正"KVC 比 naive PD 慢"的请求是 p99 那 3 个 reseed + 11 个 fallback-no-d-capacity ——总数 14 个,0.011%
|
||||
|
||||
**KVC 在 99% 工作量上完胜 naive PD-disagg,在 1% 上微输**。
|
||||
|
||||
---
|
||||
|
||||
## 5. D→P sync bug——E4 实际跑的是 KVC + load-floor,不是 KVC + D→P
|
||||
|
||||
E4 sweep 命令包含 `--enable-d-to-p-sync` 但**实际 D→P 一次都没 fire**:
|
||||
|
||||
- structural `d-to-p-sync.jsonl` 文件不存在
|
||||
- worker logs 里 0 个 `/_snapshot/*` HTTP 请求
|
||||
|
||||
**根因**:`cli.py:821 benchmark-live ReplayConfig` builder 漏了 `enable_d_to_p_sync=args.enable_d_to_p_sync` 字段。`BenchmarkLiveConfig.enable_d_to_p_sync` 默认 False,连带 `ReplayConfig.enable_d_to_p_sync` 也是 False,`_attempt_d_to_p_sync` 入口处 `if not config.enable_d_to_p_sync: return None` 早退。
|
||||
|
||||
**已修**:commit `af966f2`。
|
||||
|
||||
**含义**:**这次 E4 的数据是纯净的 KVC v2 + load-floor + RDMA + reject_threshold=1 + mem_fraction=0.55 对比 E1 naive PD**,没有 D→P 加成。D→P 如果真生效**最多救** 3 个 reseed-in-p99-tail 请求(占 tail 5%),p99 数字不会有显著变化。
|
||||
|
||||
---
|
||||
|
||||
## 6. 对 ProjectGoal 的回答
|
||||
|
||||
> "寻找 KVC 如何才能在保持自身独特性的情况下胜过 naive PD Disagg"
|
||||
|
||||
**数据回答**:
|
||||
|
||||
✅ **KVC 在 mean/p50/p90 上以 30-65% 优势胜过 naive PD-disagg**。Wall clock 短 27%。
|
||||
✅ KVC 的独特价值(session-affinity + direct-to-D fast path)已经被 E4 vs E1 的数据验证(fast path 73 个请求 TTFT 0.185s)。
|
||||
❌ KVC 在 p99 长尾上略输(+8% TTFT)。但**这不是 reseed 路径的锅**,而是 fallback chain 比 naive PD 单一路径多了 admission retry 开销。
|
||||
⏳ D→P snapshot 即使后续修了 bug 真正生效,也**不会显著降 p99**——因为 reseed 在 tail 中只占 5%。
|
||||
|
||||
**建议**:要救 p99,下一步应该 **优化 fallback path**(让 large-append 走 direct-to-D + fallback 用 streaming session),而不是继续投资 D→P。
|
||||
|
||||
---
|
||||
|
||||
## 7. 实际数字(精确)
|
||||
|
||||
```
|
||||
E1 naive PD E4 KVC + LF + RDMA
|
||||
---------------- --------------------
|
||||
TTFT mean 90.484 58.831 (-35.0%)
|
||||
TTFT p50 88.545 31.028 (-65.0%)
|
||||
TTFT p90 175.178 158.920 (-9.3%)
|
||||
TTFT p99 207.426 224.769 (+8.4%)
|
||||
TTFT max 231.946 238.412 (+2.8%)
|
||||
|
||||
Lat mean 96.339 63.870 (-33.7%)
|
||||
Lat p50 93.166 37.117 (-60.2%)
|
||||
Lat p90 180.738 164.742 (-8.8%)
|
||||
Lat p99 219.462 233.808 (+6.5%)
|
||||
Lat max 288.263 266.631 (-7.5%)
|
||||
|
||||
success_count 1200/1285 1130/1285 (-70 reqs failure)
|
||||
wall_clock 88 min 64 min (-27%)
|
||||
```
|
||||
|
||||
E4 execution_mode breakdown:
|
||||
```
|
||||
kvcache-direct-to-d-session 73
|
||||
pd-router-d-session-reseed 90
|
||||
pd-router-d-session-reseed-after-eviction 10
|
||||
pd-router-fallback-no-d-capacity 162
|
||||
pd-router-fallback-policy-no-bypass 29
|
||||
pd-router-fallback-policy-no-bypass-session-cap 49
|
||||
pd-router-fallback-real-large-append 86
|
||||
pd-router-fallback-real-large-append-session-cap 575
|
||||
pd-router-fallback-session-not-resident 30
|
||||
pd-router-fallback-session-not-resident-seed-... 50
|
||||
pd-router-fallback-session-not-resident-session 26
|
||||
pd-router-policy-no-bypass-reseed 8
|
||||
pd-router-policy-no-bypass-reseed-after-evict 1
|
||||
pd-router-real-large-append-reseed 33
|
||||
pd-router-real-large-append-reseed-after-evict 1
|
||||
pd-router-session-not-resident-reseed 12
|
||||
pd-router-turn1-d-backpressure 13
|
||||
pd-router-turn1-seed 37
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**核心句**:KVC 在 99% 请求上的 30-65% 加速(来自 session-affinity + direct-to-D + prefix cache hits)已经胜过 naive PD-disagg。1% 的 p99 输给 fallback chain 的 admission retry 开销,与 D→P 设计的 reseed 优化目标完全无关。下一阶段优化重点应该是 fallback path,不是继续加 D→P 砖块。
|
||||
@@ -1,185 +0,0 @@
|
||||
# 评测协议(Paper-quality)
|
||||
|
||||
**日期**:2026-05-12
|
||||
**性质**:评测协议规范,覆盖 [AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) §3.1 M1–M6 全部薄弱点
|
||||
**对象**:跑实验的合作者;写 paper 的人;artifact reviewer
|
||||
|
||||
---
|
||||
|
||||
## 0. 总原则
|
||||
|
||||
> 论文里每一个数字都必须能回答两个问题:
|
||||
> 1. **抽样误差有多大?**(bootstrap CI、N、std)
|
||||
> 2. **公平吗?**(同 trial、同 trace、同 token cap、同 timeout、paired)
|
||||
|
||||
当前 sweep 报告(`KVCACHE_CENTRIC_PROGRESS_ZH.md` / `V2_RESULTS_ZH.md`)都不满足上述任一条。本文给出合规模板。
|
||||
|
||||
---
|
||||
|
||||
## 1. 评测维度(M1–M6 一对一解决)
|
||||
|
||||
### 1.1 M1 — 统计显著性
|
||||
|
||||
| 决策 | 规则 |
|
||||
|---|---|
|
||||
| `N` 每个 config 最小 run 数 | **3**(headline 数字)/ **5**(ablation 终值) |
|
||||
| 报告统计量 | `mean ± std`,**附 2.5/97.5 bootstrap CI** |
|
||||
| 多 run 聚合 | 把每 run 的 per-request latency append 后整体做 bootstrap;不要先 per-run 求 mean 再 average mean |
|
||||
| 差异显著性 | paired bootstrap p-value(≥ 5000 samples) |
|
||||
| `N=1` 仅允许 | smoke / sanity check,**不进 headline 表** |
|
||||
|
||||
### 1.2 M2 — 公平 paired 比较
|
||||
|
||||
| 决策 | 规则 |
|
||||
|---|---|
|
||||
| trace fixity | 用同一个 `samples-*.jsonl` 文件;replay 用 `--use-trace-as-sample` 锁定 |
|
||||
| timeout | 所有 mechanism 同 `--request-timeout-s`;不允许某一组用 600s 而另一组 300s |
|
||||
| token cap | 同 `--max-input-len`(取所有 baseline 的最小值并显式 truncate) |
|
||||
| 错误 / abort | **不**只算成功请求;abort 与 timeout 各自单列 `error_count`,按全集(含错误)报指标,或 paired-on-same-trial-mask |
|
||||
| 时间窗 | `time_scale` 一致;不允许同 sweep 内换 |
|
||||
| Worker 数 / GPU 类型 | 一致;topology 差异必须标注 |
|
||||
|
||||
**反例**:当前 `E1 vs E2` 表([E1_E2_RESULTS_ZH.md](E1_E2_RESULTS_ZH.md) §4)显式声明 "not a fair head-to-head"——E2 80% 失败,successful-only 算 latency 与 E1 全集对比。**这种表不能直接进 paper**。
|
||||
|
||||
### 1.3 M3 — Trace 分层
|
||||
|
||||
| 维度 | 分桶建议 |
|
||||
|---|---|
|
||||
| `turn_id` | `{1, 2-5, 6-20, 21+}` |
|
||||
| `append_len` | `{≤128, 128-1K, 1K-8K, >8K}` |
|
||||
| `overlap_ratio` | `{≤0.3, 0.3-0.7, >0.7}` |
|
||||
| `inter_turn_gap_s` | `{≤5, 5-30, 30-300, >300}` |
|
||||
| `input_len` | `{≤8K, 8K-64K, >64K}` |
|
||||
|
||||
**报告要求**:headline 数字之外,至少给一张"按 turn_id × append_len"的 heatmap,让 reviewer 看到收益来自哪个 slice。
|
||||
|
||||
**反例**:当前 Real Ali 实验仅在 KVC-fit slice(high overlap + small append + 100% direct-eligible)上报 -46% p50。这是上限,不是平均。必须同时给出 full Ali 上的 paired 表。
|
||||
|
||||
### 1.4 M4 — Baseline 矩阵
|
||||
|
||||
至少以下 baseline 中跑 **2 个**:
|
||||
|
||||
| Baseline | 类别 | 库 |
|
||||
|---|---|---|
|
||||
| vLLM + automatic prefix caching | 同 model 单 worker prefix cache | vLLM main |
|
||||
| SGLang DP cache-aware(4×TP1) | 当前主要 baseline | 本仓 vendored SGLang |
|
||||
| SGLang PD-disaggregation(kv-aware) | naive 但 cache-aware 拓扑 | 本仓 |
|
||||
| DistServe | P/D 分离 baseline | DistServe upstream |
|
||||
| SplitWise | P/D split + adaptive routing | open-source impl |
|
||||
| Mooncake-Master scheduler | 同代设计 | mooncake-master |
|
||||
|
||||
**额外推荐**:跑一个 "oracle" baseline——assume `Σ.resident[d]` 完美已知 + admission 永不失败,作为 KVC 的上限对照。
|
||||
|
||||
### 1.5 M5 — Trace 组合
|
||||
|
||||
| Trace | 用途 |
|
||||
|---|---|
|
||||
| Ali coding agent (full) | 主结果;含 single-turn dilution |
|
||||
| Ali KVC-fit slice | KVC 上限演示 |
|
||||
| SWE-Bench 50 sess | 已有;多轮高 overlap workload |
|
||||
| ShareGPT | 对比 chat workload(短 turn,低 overlap)。**用来证明 KVC 不会在不合适 workload 上劣化** |
|
||||
| Inferact | tool-use heavy 的 agent workload |
|
||||
| Mooncake trace | 单 turn LLM serving 的 baseline trace |
|
||||
| Synthetic adversarial | 自构:burst 100 个新 session 同时 seed,验证 mooncake death 与 reset-on-success 的 robustness |
|
||||
|
||||
**最低组合**:Ali full + SWE-Bench + ShareGPT + Synthetic adversarial。
|
||||
|
||||
### 1.6 M6 — 硬件覆盖
|
||||
|
||||
| Tier | 用途 |
|
||||
|---|---|
|
||||
| 单节点 ≤ 8 GPU | 当前所有结果 |
|
||||
| 双节点 NVLink + IB | 验证跨节点 D→P sync 与 mooncake 行为 |
|
||||
| 4 节点 cluster(≥ 16 GPU) | scaling 数字、cluster scheduler 假设 |
|
||||
| 异构(H100 + L40S) | topology-aware routing |
|
||||
|
||||
**最低组合**:单节点 4×H200 + 双节点 NVLink + IB。剩下两个 tier 可放 future work。
|
||||
|
||||
---
|
||||
|
||||
## 2. 报告模板
|
||||
|
||||
### 2.1 主结果表(Table 1)
|
||||
|
||||
```
|
||||
| Config | N | mean ± std | p50 [CI] | p90 [CI] | p99 [CI] | err% | timeout% |
|
||||
|--------|---|------------|----------|----------|----------|------|----------|
|
||||
```
|
||||
|
||||
加注:trace name、time_scale、`max_input_len`、`request_timeout_s`、所有共用参数。
|
||||
|
||||
### 2.2 Paired delta 表
|
||||
|
||||
```
|
||||
| Pair | N pairs | mean delta [CI] | p50 delta [CI] | wins / losses | p-value |
|
||||
```
|
||||
|
||||
`N pairs` = 两边都 successful 的 trial 数。`wins` = `latency_kvc < latency_baseline` 的 trial 数。
|
||||
|
||||
### 2.3 分层表(Table 2)
|
||||
|
||||
每个分层维度(§1.3)独立一张。
|
||||
|
||||
### 2.4 Negative-result 章节(强制)
|
||||
|
||||
paper 必须有专章列出:
|
||||
|
||||
- KVC 在 ShareGPT 上比 baseline 慢的具体数字。
|
||||
- KVC 在 trace 哪些 percentile / slice 不胜。
|
||||
- 失败的 sweep(mooncake death、E3 crash)的诊断链路。
|
||||
|
||||
→ 论文 reviewer 看见诚实的 negative result 会显著提高印象分。当前的 [V2_DEEP_ANALYSIS_ZH.md](V2_DEEP_ANALYSIS_ZH.md) §4 雏形可以扩成这一章。
|
||||
|
||||
---
|
||||
|
||||
## 3. 工具支持(本仓需要的脚本)
|
||||
|
||||
| 脚本 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `scripts/analysis/recompute_summary.py` | ✅ 已有 | 修复 abort 污染的 latency;本协议主要数据入口 |
|
||||
| `scripts/analysis/stratified.py` | ⏳ 本分支新增 | 按 §1.3 维度切桶 + 输出表 |
|
||||
| `scripts/analysis/paired_compare.py` | ⏳ 本分支新增 | paired bootstrap,输出 §2.2 表 |
|
||||
| `scripts/analysis/plot_*` | ✅ 已有 | TTFT PDF、GPU 利用率、cache efficiency |
|
||||
|
||||
→ 本分支的 stratified + paired 脚本 land 后,跑实验的合作者可以一条命令出表。
|
||||
|
||||
---
|
||||
|
||||
## 4. Artifact 要求(SOSP/OSDI AE)
|
||||
|
||||
| 项目 | 标准 |
|
||||
|---|---|
|
||||
| Dockerfile | 单一 `Dockerfile.artifact`,4×A100/H100 即可启 |
|
||||
| 一键脚本 | `bash artifact/reproduce_main_table.sh`,1 小时内出 Table 1 |
|
||||
| 数据集 | 提供 `outputs/sample-*.jsonl` 子集(可 ~5GB 内);full Ali 走 instruction |
|
||||
| 复现度 | bootstrap CI 与原文重叠即算复现,不要求 bit-exact |
|
||||
| 文档 | `artifact/README.md`,列出每张表 / 图对应的命令 |
|
||||
|
||||
→ 本路线图 §M1 修复后再准备 artifact。
|
||||
|
||||
---
|
||||
|
||||
## 5. 自检清单(提 paper draft 前用)
|
||||
|
||||
- [ ] 每张表 N ≥ 3,含 mean±std 与 95% CI。
|
||||
- [ ] 没有 "successful only" 字样;所有错误已列入 `err%`。
|
||||
- [ ] 所有 baseline 用同 `max_input_len` / 同 `request_timeout_s` / 同 `time_scale`。
|
||||
- [ ] 至少 3 个 trace + 1 个 synthetic adversarial。
|
||||
- [ ] 至少 1 个 non-SGLang baseline。
|
||||
- [ ] 有 negative-result 章节。
|
||||
- [ ] 有 KVC 在 single-turn workload 上的 dilution 数据。
|
||||
- [ ] 形式化部分:Algorithm 1/2/3 + Theorem 1/2,以及 D→P sync 完成后的 Theorem 4。
|
||||
- [ ] 失败模式 forensic:mooncake death、E3 crash、cold-D 都进 §Limitations 或 §Discussion。
|
||||
|
||||
---
|
||||
|
||||
## 6. 路线图衔接
|
||||
|
||||
- [ ] Phase A — 实现本分支 `scripts/analysis/stratified.py` + `scripts/analysis/paired_compare.py`(无 GPU 可做)。
|
||||
- [ ] Phase B — 把现有 `kvc-real-ali-iter-v1` 的 600-req/15min 数据用新工具重出一份分层表 / paired 表,存入 `outputs/`(GPU 不需重跑)。
|
||||
- [ ] Phase C — 跑 ShareGPT + Synthetic adversarial baseline(GPU 需 ~12h)。
|
||||
- [ ] Phase D — 选 1 个非 SGLang baseline(推荐 vLLM + prefix caching)补齐 M4(GPU 需 ~24h)。
|
||||
|
||||
---
|
||||
|
||||
**核心句**:当前结果"看起来已经赢",但按本协议重报后,赢的 magnitude 会缩小、赢的 slice 会窄化、负面 slice 会暴露。这是论文必须经历的过程;越早做越省事。
|
||||
@@ -1,222 +0,0 @@
|
||||
# Failure-mode Taxonomy
|
||||
|
||||
**日期**:2026-05-13
|
||||
**性质**:集中清单 + 诊断手册
|
||||
**对象**:跑实验时遇到失败要立刻 lookup 的合作者;写 paper §Limitations 时需引用的人;reviewer 想问"你为什么觉得这次会更稳"时的答案
|
||||
|
||||
本文把当前系统已识别的失败模式按"症状 → 根因 → 触发条件 → 当前缓解 → 真正的修复"梳成一张表。所有条目都有 forensic 链接到原始实验 doc。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
5 类已识别失败模式,按"是否阻碍 paper claim"分组:
|
||||
|
||||
| 类别 | 名称 | 阻碍 paper | 真正修复 |
|
||||
|---|---|:---:|---|
|
||||
| **A. 控制层级联** | Mooncake "instance not alive" cascade | ✅ | admission backoff + per-D pending-seed budget |
|
||||
| **B. 路由偏置** | Cold-D / overlap-pinning | ✅ | first-principles overlap term redefinition |
|
||||
| **C. KV 抖动** | Evict storm(session-level evict) | ✅ | [BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md) |
|
||||
| **C'. KV 抖动** | Reseed storm(turn 1 大 seed 并发) | ✅ | per-D pending-seed budget + (C 缓解后频率自降) |
|
||||
| **D. Vendor 不变量** | streaming-session correction invariant crash (E3) | ❌(hotfix 已 land) | 删除 correction 路径(block-level evict 完成后) |
|
||||
|
||||
A / B / C 三类是 Milestone 1 必须解决的;C' 是 A 的次因;D 已临时止血但根本修复绑在 C 上。
|
||||
|
||||
---
|
||||
|
||||
## 1. A — Mooncake "instance not alive" cascade
|
||||
|
||||
### 1.1 症状
|
||||
|
||||
- 客户端看:`RuntimeError: generate stream ended before producing any token`
|
||||
- D scheduler 日志:`[mooncake] Decode instance could be dead, dropping ...`
|
||||
- 整批请求被 abort,单一 sweep 在数分钟内从健康降到 80% failure([E1_E2_RESULTS_ZH.md](E1_E2_RESULTS_ZH.md) E2:1054 / 1285 失败)
|
||||
|
||||
### 1.2 根因(forensic 链路)
|
||||
|
||||
```
|
||||
admission no-space (D KV pool 满)
|
||||
→ router 立刻 fallback 走 seed/reseed 路径
|
||||
→ 多个并发 seed 同时打 mooncake P→D
|
||||
→ P→D 出口排队,handshake 阶段超时
|
||||
→ mooncake 把对端标记 dead
|
||||
→ SGLang 把 dead 链路上的 in-flight req 全部 abort
|
||||
→ 客户端看到批量 generate-stream 中断
|
||||
```
|
||||
|
||||
### 1.3 触发条件
|
||||
|
||||
- D KV pool 接近满(≥ ρ·K_d,默认 0.95)
|
||||
- router fallback chain 把多个 reseed 在毫秒级窗口内发起
|
||||
- mooncake heartbeat 超时(默认窗口短)
|
||||
|
||||
### 1.4 当前缓解
|
||||
|
||||
- `--kvcache-seed-min-turn-id=2` 跳过 turn 1 大 seed,减少首爆(main 分支 stable 配置)
|
||||
- `--mc-transfer-timeout=1800s` 默认值(commit 905d671)减少假性 dead
|
||||
- `--request-timeout-s=180/300` 让客户端不至于看见整 hour 卡死,但不阻止 cascade 自身
|
||||
|
||||
→ 这些都是治标,不是治本。E2 在 4×H200 NDR 真硬件下仍 80% 失败 ([E1_E2_RESULTS_ZH.md](E1_E2_RESULTS_ZH.md))。
|
||||
|
||||
### 1.5 真正的修复(路线图 §S3)
|
||||
|
||||
1. **admission RPC backoff + jitter**:拒绝时不立刻 fallback,给 D scheduler 喘息机会。
|
||||
2. **per-D pending-seed budget**:同时刻最多 K 个 seed 在 transfer 队列里,超出排队而不爆裂。
|
||||
3. **mooncake heartbeat 与 admission 解耦**:admission 路径不再 imply "对端 alive"。
|
||||
4. **Backpressure pause hint 闭环**([SGLANG_PATCH_INVENTORY_ZH.md](SGLANG_PATCH_INVENTORY_ZH.md) §2.3 当前 EXPERIMENTAL)。
|
||||
|
||||
---
|
||||
|
||||
## 2. B — Cold-D / overlap-pinning
|
||||
|
||||
### 2.1 症状
|
||||
|
||||
- N=k decode workers,但只有 ~k-1 真正承载流量;某些 D 0 binding
|
||||
- Per-D load 直方图严重偏斜(E2:D0:600 / D1:685 / **D2:0**)
|
||||
- 整体 throughput 受最忙 D 限制;裸 latency 不一定差,但容量利用率差 33%+
|
||||
|
||||
### 2.2 根因
|
||||
|
||||
Inferact / Ali coding agent trace 在每个 session 开头有 ~12K 的"system prompt + tool schema",这些 24-token 块在所有 session 之间共享 hash。kv-aware policy 的 `overlap` term 把它们当成"该 D 已经常驻这些 hash" → 任何新 session 都被 score 推向 D0/D1(最先 warm 的两个)→ D2 永远 0 overlap → 永远不被选 → 永远 cold。
|
||||
|
||||
### 2.3 触发条件
|
||||
|
||||
- 多 session workload + 共享 boilerplate prefix
|
||||
- `migration_reject_threshold > 0` 且 reject 从未触发(因为 D0/D1 还没满)
|
||||
|
||||
### 2.4 当前缓解
|
||||
|
||||
`KvAwarePolicy.load_floor_bonus`(commit 93fce42):
|
||||
|
||||
```
|
||||
floor_bonus = K * max(0, mean - assigned) / max(1, mean)
|
||||
```
|
||||
|
||||
E3 实测 D2 binding 从 0 升到 22.5%([E3_FINDINGS_ZH.md](E3_FINDINGS_ZH.md) §1)。
|
||||
|
||||
→ 这是 patch,不是修复。`K` 是 magic number;boilerplate 的 hash 数量大于 `K / sticky_bonus` 时仍 cold。
|
||||
|
||||
### 2.5 真正的修复(路线图 §S5)
|
||||
|
||||
把 `overlap` 重新定义为 **"该 session 在该 D 上独占 prefix 的 hash 数"**:
|
||||
|
||||
```
|
||||
exclusive_overlap(s, d) := |prefix_hashes(s) ∩ resident[d] ∩ session_owned[s]|
|
||||
```
|
||||
|
||||
其中 `session_owned[s]` 排除其它 session 也持有的 hash。Boilerplate 共享 hash 不进 `exclusive_overlap`,score 自然分散。需要 D 端在 `admit_direct_append` 响应里返回 per-session resident hash 集合的 sketch(Bloom filter / minhash)。
|
||||
|
||||
---
|
||||
|
||||
## 3. C — Evict storm(session-level eviction)
|
||||
|
||||
### 3.1 症状
|
||||
|
||||
- 在 D 内存有压力的 workload 下,每 1–2 分钟出现 30–90K tokens 的 KV pool 释放峰
|
||||
- 紧随其后的同 session 请求触发 `Reseed`:P 重 prefill 50K + mooncake transfer 50K(3–7s)
|
||||
- TTFT 长尾完全由这类 reseed 主导([V2_DEEP_ANALYSIS_ZH.md](V2_DEEP_ANALYSIS_ZH.md) §3.2)
|
||||
|
||||
### 3.2 根因
|
||||
|
||||
`SessionAwareCache.release_session` 一次性 `free([cache_protected_len, kv_allocated_len))`——即整段 session-exclusive 尾部。E3 实测:90 次 evict、平均一次 free 67,726 tokens、25/50 session 受影响([KVC_EVICTION_GRANULARITY_DESIGN_ZH.md](KVC_EVICTION_GRANULARITY_DESIGN_ZH.md) §0)。
|
||||
|
||||
→ 与 SGLang 标准 radix 的 leaf-by-leaf 渐进 evict 形成鲜明对比。这部分 KV 从未进 radix,所以享受不到 LRU 的细粒度蚕食。
|
||||
|
||||
### 3.3 触发条件
|
||||
|
||||
- D KV pool 接近满
|
||||
- `maybe_trim_decode_session_cache` 被 scheduler 触发(在 `DecodePreallocQueue` 检测到 `available_size() <= 0` 时)
|
||||
|
||||
### 3.4 当前缓解
|
||||
|
||||
- `--kvcache-session-soft-cap=N`(main 分支):限制 D 上常驻 session 数 → 提前 trim,避免顶到爆
|
||||
- `--kvcache-direct-max-uncached-tokens=8192`(v2):降低 direct path 吃 KV 的速度
|
||||
|
||||
→ 都是放慢节奏,没有解决"单次 free 太大"的根本问题。
|
||||
|
||||
### 3.5 真正的修复(路线图 §S1)
|
||||
|
||||
[BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md):让 streaming-session decode 输出每 turn finish 时 `inner.cache_finished_req` 进 radix → `release_session` 退化为 `dec_lock_ref` + 删 slot → radix LRU 按 24-token leaf 蚕食。
|
||||
|
||||
预期:单次 evict 从 67K 降到 ≤ 500 tokens;reseed 频次降一个数量级。
|
||||
|
||||
---
|
||||
|
||||
## 4. C' — Reseed storm(turn 1 大 seed 并发)
|
||||
|
||||
### 4.1 症状
|
||||
|
||||
- workload 起步阶段(前 30–60s)所有 session 同时打 turn 1
|
||||
- 多个并发 `Seed`(每个 ~50–90K tokens)打 mooncake → 与 §1 cascade 重合
|
||||
|
||||
### 4.2 根因
|
||||
|
||||
`KvAwarePolicy` 启动阶段 `resident[d]` 全空,所有 D score 相同,但 ε 重试 + per-trial admit 不阻止并发。
|
||||
|
||||
### 4.3 触发条件
|
||||
|
||||
- trace `time_scale=1` 重放下,session 在原始到达密度内同时启动
|
||||
- 没有 per-D pending-seed 限流
|
||||
|
||||
### 4.4 当前缓解
|
||||
|
||||
- `--kvcache-seed-min-turn-id=2`:跳过 turn 1 seed 完全(main 分支 stable 配置)
|
||||
- 副作用:失去 turn-1 的 KV 注入,turn 2 必走 reseed(但反而稳定,因为 reseed 是分散在时间上的)
|
||||
|
||||
### 4.5 真正的修复
|
||||
|
||||
- per-D pending-seed budget(同 §1.5 第 2 项)
|
||||
- §3.5 完成后 evict 频次自降,间接降低 reseed 频次
|
||||
|
||||
---
|
||||
|
||||
## 5. D — Streaming-session correction invariant crash (E3 landmine)
|
||||
|
||||
### 5.1 症状
|
||||
|
||||
- D scheduler 抛 `AssertionError` at `schedule_batch.py:1646`:`seq_len - pre_len == req.extend_input_len`
|
||||
- 整个 D worker 进程退出 → router 看见对端死 → §1 cascade
|
||||
|
||||
### 5.2 根因
|
||||
|
||||
[E3_FINDINGS_ZH.md](E3_FINDINGS_ZH.md) §2:streaming-session correction(commit b8e6f13)把 `extend_input_len` 改写为 `max(0, fill_len - prefix_len)`,但下游 invariant 还从原始 fill_ids/prefix_indices 计算。当 `fill_len < prefix_len`(多 turn 累积 prefix > 当前 turn 增量)时数学上不可能满足。
|
||||
|
||||
### 5.3 触发条件
|
||||
|
||||
- streaming session 跨 turn 已 commit prefix 长于本 turn 的新增 fill_ids
|
||||
- E2 因 pipeline 阻塞从未跑到这个状态;E3 修了 cold-D bottleneck → pipeline 更快 → landmine 暴露
|
||||
|
||||
### 5.4 当前缓解
|
||||
|
||||
commit 986f351 的 pre-filter pass:在 `prepare_for_extend` 入口 drop 这类 req(让 client 看错误响应而不是 worker 崩)。是止血。
|
||||
|
||||
### 5.5 真正的修复
|
||||
|
||||
`schedule_batch.py:1572–1646` 这整段 correction 路径在 block-level eviction refactor 完成后**结构上不再需要**——[BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md) §3.7 已说明 refactor 后 fill_ids / prefix_indices 一致性由 radix `match_prefix` 自动保证。
|
||||
|
||||
→ 不要再加更多 correction 子句;要删整段。
|
||||
|
||||
---
|
||||
|
||||
## 6. 失败诊断 cheat sheet
|
||||
|
||||
跑 sweep 时按下表 lookup:
|
||||
|
||||
| 你看到 | 大概率是 | 先查 |
|
||||
|---|---|---|
|
||||
| 客户端 `RuntimeError: generate stream ended before...` | §1 cascade | D scheduler log 搜 `instance could be dead` |
|
||||
| 某个 D `binding=0` 而其它 D 繁忙 | §2 cold-D | `per_decode_load` 直方图 |
|
||||
| TTFT p99 突然抬到 5–8s 量级 | §3 evict storm | `release_session` 调用频次 + 平均 free tokens |
|
||||
| Sweep 起步阶段失败率高、稳态低 | §4 reseed storm | mooncake transfer queue 在前 30s 的峰值 |
|
||||
| D worker 进程异常退出 | §5 invariant crash | scheduler log 搜 `AssertionError`、`extend_input_len` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 与路线图的衔接
|
||||
|
||||
- [AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) Milestone 1 的第 1/3/4 项分别对应本表 C / A / B 的真正修复。完成 Milestone 1 后本表 §1–§4 应该都从"未修"降级为"已缓解",§5 直接消失。
|
||||
- 论文 §Limitations 必须老实写出现状:"we identify five failure modes; A/C are addressed by this work, B/C' are partially addressed, D is a transient artifact of the in-progress refactor."
|
||||
|
||||
---
|
||||
|
||||
**核心句**:把失败模式当 first-class artifact 来管理——每个失败都有"症状 → 根因 → 触发 → 缓解 → 真正修复"五字段,是把 prototype 推到 production-grade 的关键工具。reviewer 看见你能枚举失败远比看见你赢得 baseline 更让人信服。
|
||||
119
docs/INDEX_ZH.md
119
docs/INDEX_ZH.md
@@ -1,119 +0,0 @@
|
||||
# 文档索引
|
||||
|
||||
**目的**:让任何合作者在 10 分钟内找到他需要的文档;让 Reviewer 知道哪些先看。
|
||||
|
||||
---
|
||||
|
||||
## 0. 时间紧的 3 篇
|
||||
|
||||
按这个顺序读完即可参与讨论:
|
||||
|
||||
1. [AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) — 项目当前进度、薄弱点、路线图。
|
||||
2. [KVC_ROUTER_ALGORITHM.md](KVC_ROUTER_ALGORITHM.md) — 算法形式化(Algorithm 1/2/3 + Theorem 1/2)。
|
||||
3. [V2_DEEP_ANALYSIS_ZH.md](V2_DEEP_ANALYSIS_ZH.md) §0 + §6 — v2 当前 win/lose snapshot。
|
||||
|
||||
---
|
||||
|
||||
## 1. 按主题分类
|
||||
|
||||
### 1.1 进度 / 现状
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) | 跨分支整合 + 路线图(本分支的总入口) |
|
||||
| [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) | 项目目标 + 三种 mechanism(pd-disagg / pd-colo / kvcache-centric)的术语区分 |
|
||||
| [ONBOARDING_NEXT_AGENT_ZH.md](ONBOARDING_NEXT_AGENT_ZH.md) | 接班 agent 30 分钟上手手册(来自 `kvc-debug-journey-v1-to-v4`) |
|
||||
|
||||
### 1.2 算法 / 形式化
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [KVC_ROUTER_ALGORITHM.md](KVC_ROUTER_ALGORITHM.md) | Algorithm 1(Route)/ 2(Admit)/ 3(Dispatch)+ Theorem 1(无饿死)+ Theorem 2(fast-path 命中下限) |
|
||||
| [MIGRATION_V1_FINDINGS_ZH.md](MIGRATION_V1_FINDINGS_ZH.md) | v1 thrashing pathology 的实测 + 为什么 reset-on-success 是关键修复 |
|
||||
|
||||
### 1.3 实验结果
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [V2_DEEP_ANALYSIS_ZH.md](V2_DEEP_ANALYSIS_ZH.md) | SWE-Bench 50 sess ts=1:v2 vs 4DP CA 的 6/8 win + TTFT p99 落后原因 |
|
||||
| [V2_RESULTS_ZH.md](V2_RESULTS_ZH.md) | v2 原始战报(headline 数字略乐观,请同时看 deep analysis) |
|
||||
| [E1_E2_RESULTS_ZH.md](E1_E2_RESULTS_ZH.md) | H200 + RDMA 上 E1(naive 1P3D + kv-aware)vs E2(KVC v2);E2 80% failure 的 forensic |
|
||||
| [E3_FINDINGS_ZH.md](E3_FINDINGS_ZH.md) | E3(+load-floor bonus)16 min 触发 SGLang patch invariant crash |
|
||||
| [E1_E2_FIX_DESIGN_ZH.md](E1_E2_FIX_DESIGN_ZH.md) | Q1(mooncake death)+ Q2(cold-D2)的 fix 设计 |
|
||||
|
||||
### 1.4 当前关键 design discussion
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [KVC_EVICTION_GRANULARITY_DESIGN_ZH.md](KVC_EVICTION_GRANULARITY_DESIGN_ZH.md) | 架构层反思:session-level evict 与 KVC continuity 设计冲突 |
|
||||
| [BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md) | block-level evict refactor 的具体 API / 步骤 / 测试计划(本分支新增) |
|
||||
| [RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md](RESEED_SLOW_PATH_AND_D_TO_P_GAP_ZH.md) | reseed 慢路径时间线 + D→P 同步缺口的 forensic |
|
||||
| [D_TO_P_SYNC_CONTRACT_ZH.md](D_TO_P_SYNC_CONTRACT_ZH.md) | D→P sync 的接口契约、staleness budget、rollout 阶段(本分支新增) |
|
||||
|
||||
### 1.5 评测 / 方法论
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [EVALUATION_PROTOCOL_ZH.md](EVALUATION_PROTOCOL_ZH.md) | paper-quality 评测协议(N、CI、paired、stratify、baseline list、trace mix)—— 本分支新增 |
|
||||
| [REFACTOR_PLAN_V1_ZH.md](REFACTOR_PLAN_V1_ZH.md) | 为什么从 ts=10 切到 ts=1 |
|
||||
| [TEAM_REPORT_AGENTIC_PD_HYBRID_ZH.md](TEAM_REPORT_AGENTIC_PD_HYBRID_ZH.md) | ts=10 时代的结构性问题清单(多数已 supersede) |
|
||||
|
||||
### 1.6 工程债 / 失败模式
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [SGLANG_PATCH_INVENTORY_ZH.md](SGLANG_PATCH_INVENTORY_ZH.md) | 785 行 vendored SGLang patch 的归类清单(MUST-HAVE / WORKAROUND / EXPERIMENTAL / INSTRUMENTATION)—— 本分支新增 |
|
||||
| [FAILURE_MODES_ZH.md](FAILURE_MODES_ZH.md) | 5 类失败模式的诊断 + 缓解 + 真正修复(mooncake cascade / cold-D / evict storm / reseed storm / E3 invariant)—— 本分支新增 |
|
||||
|
||||
### 1.7 环境
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [H200_DRIVER570_SETUP_ZH.md](H200_DRIVER570_SETUP_ZH.md) | H200 + driver 570 + cu12.8 环境搭建 + 11 条 lesson learned |
|
||||
|
||||
### 1.7 归档(仅历史参考)
|
||||
|
||||
`docs/archive/` 下的内容已被新文档 supersede,不必看:
|
||||
|
||||
- `AGENTIC_FIT_ANALYSIS_ZH.md`、`STRUCTURAL_VALIDATION_REPORT_ZH.md`:ts=10 早期分析。
|
||||
- `KVCACHE_CENTRIC_PROGRESS_ZH.md`:早期项目快照。
|
||||
- `KVC_DEBUG_JOURNEY_V1_TO_V5.md`、`V5_PROFILE_INVESTIGATION_ZH.md`:v1–v5 调优过程笔记。
|
||||
- `REFACTOR_PLAN_ZH.md`:v0 重构计划。
|
||||
- `SWEBENCH_EXPERIMENT_*.md`:早期实验日志。
|
||||
|
||||
---
|
||||
|
||||
## 2. 按角色推荐阅读路径
|
||||
|
||||
### 2.1 我是新接手的 SWE/research agent
|
||||
|
||||
1. 先读本文 §0 的 3 篇。
|
||||
2. 再看 [AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) §3(薄弱点)+ §5(GPU-free 工作清单)。
|
||||
3. 选一个 Milestone 1 子项开始做。`docs/BLOCK_LEVEL_EVICTION_DESIGN_ZH.md` 与 `docs/D_TO_P_SYNC_CONTRACT_ZH.md` 是已经准备好的两条工程主线。
|
||||
|
||||
### 2.2 我是 paper reviewer / 审稿预读
|
||||
|
||||
1. [KVC_ROUTER_ALGORITHM.md](KVC_ROUTER_ALGORITHM.md):算法 + theorem。
|
||||
2. [V2_DEEP_ANALYSIS_ZH.md](V2_DEEP_ANALYSIS_ZH.md):核心实测对比 + 我们自己识别的 limitation。
|
||||
3. [E1_E2_RESULTS_ZH.md](E1_E2_RESULTS_ZH.md):真硬件 + RDMA 上的 ablation(含 E2 的 80% failure forensic,证明我们能解释失败)。
|
||||
4. [AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) §3:我们自己列出的薄弱点与未来工作(不藏问题)。
|
||||
|
||||
### 2.3 我是要复现实验的 student
|
||||
|
||||
1. [H200_DRIVER570_SETUP_ZH.md](H200_DRIVER570_SETUP_ZH.md)。
|
||||
2. [EVALUATION_PROTOCOL_ZH.md](EVALUATION_PROTOCOL_ZH.md):跑哪些 sweep、按什么协议比较。
|
||||
3. `scripts/sweep_ts1_migration_v2.sh`:v2 主 sweep;`scripts/sweep_e1_naive_1p3d.sh` / `scripts/sweep_e2_kvc_v2_rdma.sh`:E1/E2 ablation。
|
||||
|
||||
### 2.4 我想看 control plane 与 admission
|
||||
|
||||
1. `src/agentic_pd_hybrid/policies.py`:`KvAwarePolicy.select` 是 Algorithm 1 的实现。
|
||||
2. `src/agentic_pd_hybrid/replay.py`:`_invoke_session_direct` / `_invoke_kvcache_seeded_router` 是 Algorithm 3 的 orchestration。
|
||||
3. `third_party/sglang/python/sglang/srt/managers/scheduler.py`:D 端 `_admit_direct_append` 是 Algorithm 2 实现。
|
||||
|
||||
---
|
||||
|
||||
## 3. 这份索引的维护约定
|
||||
|
||||
- 新加一份 design / experiment doc 必须在本文 §1 表格里加一行。
|
||||
- 文档归档(移到 `docs/archive/`)时本文同步删除条目或标 "已归档"。
|
||||
- 本文不写实质内容,只做导航;任何深入说明都在被指向的文档里。
|
||||
@@ -1,165 +0,0 @@
|
||||
# Vendored SGLang Patch — 归类清单
|
||||
|
||||
**日期**:2026-05-13
|
||||
**基线**:clean SGLang v0.5.10 snapshot @ `bded083`
|
||||
**当前 HEAD**:`origin/h200-cu130` + 本分支 (785 行新增 / 17 行删除 / 10 文件)
|
||||
**目的**:让 reviewer 与下一个合作者一眼看清"哪些 patch 是核心机制、哪些是 workaround、哪些可以在 refactor 后下线"。对应 [AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) §3.2 / §S6 的工程债项。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
| 分类 | 文件数 | 行数(估) | 命运 |
|
||||
|---|---:|---:|---|
|
||||
| MUST-HAVE — 核心机制(Algorithm 1/2/3、streaming session lifecycle、admit RPC) | 6 | ~450 | 长期保留,是 paper claim 的核心 |
|
||||
| WORKAROUND — 已识别的 latent 问题修补,应在 refactor 后下线 | 2 | ~150 | block-level eviction refactor 完成后大量删除 |
|
||||
| EXPERIMENTAL — 未闭环的特性,论文不依赖 | 1 | ~60 | 可下线或保留为 future-work hook |
|
||||
| INSTRUMENTATION — 诊断 / 日志 | 1 | ~50 | 保留但应隔离到 debug build |
|
||||
| MINOR — 杂项 | 1 | ~3 | 不影响决策 |
|
||||
|
||||
**关键指引**:当 block-level eviction refactor([BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md))完成时,WORKAROUND 类的 ~150 行应同步删除。E3 触发的 `schedule_batch.py` invariant landmine 是这条路径上的产物,不修引擎而是修 evict 粒度才是正解。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文件粒度清单
|
||||
|
||||
### 1.1 `mem_cache/session_aware_cache.py` — MUST-HAVE *(待 refactor)*
|
||||
|
||||
| 项目 | 内容 | 引入 | 分类 |
|
||||
|---|---|---|---|
|
||||
| `SessionSlot` dataclass | streaming session 跨 turn 复用 KV 的 metadata | b8e6f13 | MUST-HAVE |
|
||||
| `last_access_time` 字段 | LRU 决策需要 | 6e5ed8d | MUST-HAVE |
|
||||
| `match_prefix` / `cache_finished_req` / `cache_unfinished_req` 的 streaming 分支 | session 复用快路径 | b8e6f13 | **MUST-HAVE → 待 refactor**(block-level evict 后语义大改) |
|
||||
| `release_session` 直接 `free(kv_indices)` | session 退出时一次性归还 KV | b8e6f13 | **WORKAROUND → 替换**(refactor 后改为只 `dec_lock_ref`) |
|
||||
| `slot_held_tokens` / `get_session_status` / `list_session_statuses` | 状态查询 | 6e5ed8d | MUST-HAVE |
|
||||
|
||||
**说明**:本文件是 KVC 设计的中枢。block-level eviction refactor([BLOCK_LEVEL_EVICTION_DESIGN_ZH.md](BLOCK_LEVEL_EVICTION_DESIGN_ZH.md) §3.1–§3.6)改造的就是这里。`SessionSlot` 的 5 个 KV-ownership 字段(`req_pool_idx` / `kv_committed_len` / `kv_allocated_len` / `cache_protected_len` / `swa_evicted_seqlen`)应在 refactor 后删除;这部分**将由 commit message 单独标记**,方便回滚。
|
||||
|
||||
### 1.2 `managers/scheduler.py` — 混合类别
|
||||
|
||||
D worker 端的 Algorithm 2 实现,含多个独立 patch。按行级归类:
|
||||
|
||||
| 函数 / 行段 | 内容 | 分类 | 何时可下线 |
|
||||
|---|---|---|---|
|
||||
| `admit_direct_append(...)` | Algorithm 2 的 D 端 admission RPC handler | **MUST-HAVE** | 不下线(论文核心) |
|
||||
| `_should_allow_local_prefill_on_decode(req)` | 决定 decode worker 是否接受无 bootstrap 的本地 append-prefill | **MUST-HAVE** | 不下线 |
|
||||
| `_decode_session_cache_low_watermark_tokens()` | 水位线参数读取 | **WORKAROUND** | block-level evict 后由 radix LRU 取代 |
|
||||
| `_decode_session_cache_target_available_tokens()` | 目标可用 token 数计算 | **WORKAROUND** | 同上 |
|
||||
| `maybe_trim_decode_session_cache(...)` | 主动 trim session(触发 `release_session`) | **WORKAROUND** | 同上:refactor 后 radix LRU 自然蚕食,trim 不再必要 |
|
||||
| `_compute_backpressure_pause_hint(...)` | 给 router 的 pause 提示 | **EXPERIMENTAL** | 信号未闭环([REAL_ALI_KVC_EXPERIMENT_LOG_ZH.md](../docs/archive/) §4.3),路线图 §S10;可保留为 future work hook |
|
||||
| `_compute_pool_breakdown_for_diagnostics()` | 池状态快照供 `/server_info` | **INSTRUMENTATION** | 长期保留但建议门 flag 化 |
|
||||
|
||||
### 1.3 `managers/schedule_batch.py` — WORKAROUND(待删除)
|
||||
|
||||
| 项目 | 内容 | 引入 | 分类 |
|
||||
|---|---|---|---|
|
||||
| streaming-session `extend_input_len` correction (lines ~1572–1585) | 在 fill_ids < prefix_indices 时把 extend_input_len 改为 0 | b8e6f13 | **WORKAROUND** |
|
||||
| pre-filter pass dropping `fill_ids < prefix_indices` reqs | E3 触发 assertion 后的 hotfix(commit 986f351) | 986f351 | **WORKAROUND** |
|
||||
| invariant assert `seq_len - pre_len == req.extend_input_len` 的容忍逻辑 | 与 correction 配套 | b8e6f13 | **WORKAROUND** |
|
||||
|
||||
**全部** ~85 行在 block-level eviction refactor 完成后**应整体删除**——`BLOCK_LEVEL_EVICTION_DESIGN_ZH §3.7` 已说明 refactor 后该不变量结构上必然成立,correction 路径无需存在。E3 的 landmine ([E3_FINDINGS_ZH.md](E3_FINDINGS_ZH.md) §2) 是该 workaround 的产物。
|
||||
|
||||
### 1.4 `managers/session_controller.py` — MUST-HAVE
|
||||
|
||||
| 项目 | 内容 | 分类 |
|
||||
|---|---|---|
|
||||
| streaming session lifecycle hooks(open / close / admit signal) | 让 P/D worker 知道何时开始 / 结束一个 streaming session | MUST-HAVE |
|
||||
| session ID 路由 | 让 admission RPC 找到正确的 SessionSlot | MUST-HAVE |
|
||||
|
||||
不下线。
|
||||
|
||||
### 1.5 `managers/io_struct.py` — MUST-HAVE
|
||||
|
||||
| 项目 | 内容 | 分类 |
|
||||
|---|---|---|
|
||||
| `AdmitDirectAppendReqInput` / `AdmitDirectAppendReqOutput` | admit RPC 的请求 / 响应消息类型 | MUST-HAVE |
|
||||
| backpressure pause hint 字段 | 同上消息的 optional 字段 | EXPERIMENTAL |
|
||||
|
||||
可以把 EXPERIMENTAL 字段折叠到 MUST-HAVE 消息里保持兼容;本身不构成下线压力。
|
||||
|
||||
### 1.6 `managers/tokenizer_communicator_mixin.py` — MUST-HAVE
|
||||
|
||||
admit RPC 的 communicator-side glue。19 行,不下线。
|
||||
|
||||
### 1.7 `entrypoints/http_server.py` — MUST-HAVE
|
||||
|
||||
`/admit_direct_append` HTTP endpoint 注册。6 行。
|
||||
|
||||
### 1.8 `disaggregation/decode.py` — 混合类别
|
||||
|
||||
| 项目 | 内容 | 分类 |
|
||||
|---|---|---|
|
||||
| `DecodeReqToTokenPool`: `assert len(reusing) <= 1` 放宽 | 让 local append-prefill 在一个 batch 里复用多个 req_pool_idx | **MUST-HAVE** |
|
||||
| `DecodePreallocQueue` 引入 `refresh_allocatable_tokens` + `maybe_trim_decode_session_cache` 触发 | pool 满时主动 trim session | **WORKAROUND**(refactor 后改由 radix LRU 自然 shed) |
|
||||
| `--disaggregation-decode-allow-local-prefill` flag | 服务端 opt-in 本地 append-prefill | **MUST-HAVE** |
|
||||
|
||||
trim 触发逻辑 ~30 行在 refactor 后应删除。
|
||||
|
||||
### 1.9 `server_args.py` — MUST-HAVE
|
||||
|
||||
| 项目 | 内容 | 分类 |
|
||||
|---|---|---|
|
||||
| `--radix-eviction-policy priority` 选项 | E1/E2 实验需要 | MUST-HAVE |
|
||||
| `--disaggregation-decode-allow-local-prefill` flag | 见 §1.8 | MUST-HAVE |
|
||||
|
||||
13 行,全部是 CLI 接口扩展,不下线。
|
||||
|
||||
### 1.10 `disaggregation/mooncake_transfer_engine.py` — MINOR
|
||||
|
||||
3 行小调整。不构成决策点。
|
||||
|
||||
---
|
||||
|
||||
## 2. 按分类汇总
|
||||
|
||||
### 2.1 MUST-HAVE(保留)
|
||||
|
||||
约 6 个文件、450 行:
|
||||
- `admit_direct_append` 主链路(Algorithm 2):scheduler + io_struct + tokenizer_communicator_mixin + http_server + session_controller
|
||||
- `SessionSlot` 主链路(streaming session lifecycle):session_aware_cache 多数字段、session_controller
|
||||
- CLI / server interface:server_args、decode.py 的 `allow_local_prefill`
|
||||
|
||||
### 2.2 WORKAROUND(block-level evict refactor 后删除)
|
||||
|
||||
约 2.5 个文件、150 行:
|
||||
- `session_aware_cache.release_session` 的 token-free 路径
|
||||
- `scheduler.py` 的 `_decode_session_cache_*_watermark_tokens` + `maybe_trim_decode_session_cache`
|
||||
- `schedule_batch.py` streaming-session correction + drop-pre-filter(含 E3 landmine 的 hotfix)
|
||||
- `decode.py` `DecodePreallocQueue` 中的 trim 触发
|
||||
|
||||
→ 这些 patch 的存在是当前架构的产物;refactor 后应整段删除而不是修小 bug。
|
||||
|
||||
### 2.3 EXPERIMENTAL(未闭环)
|
||||
|
||||
约 60 行:
|
||||
- backpressure pause hint(`_compute_backpressure_pause_hint` + io_struct 字段):可作为未来 control-plane 反馈机制的 hook 保留;若 1 个月后仍未接通,下线
|
||||
|
||||
### 2.4 INSTRUMENTATION(长期保留但门 flag 化)
|
||||
|
||||
约 50 行:
|
||||
- `_compute_pool_breakdown_for_diagnostics` + 相关 `/server_info` 字段:建议加 `--enable-diagnostic-pool-snapshot` flag,避免 prod 路径背诊断开销
|
||||
|
||||
### 2.5 MINOR
|
||||
|
||||
约 3 行:忽略。
|
||||
|
||||
---
|
||||
|
||||
## 3. 维护约定
|
||||
|
||||
1. **新加 SGLang 改动必须落到本表**:在 commit message 用 `feat(sglang): ...` / `fix(sglang): ...` 前缀,并在 PR 描述声明落到 §2 哪一类。
|
||||
2. **不直接覆盖 upstream 文件**:所有 patch 必须可在 v0.5.10 上 git apply(保留 hunk header 整洁)。
|
||||
3. **删除 WORKAROUND 时同步删 doc**:refactor 完成的同一个 PR 应把本文表中对应行划掉。
|
||||
4. **不下放 EXPERIMENTAL 到主路径**:未闭环的 patch 必须默认 disabled。
|
||||
|
||||
---
|
||||
|
||||
## 4. 与路线图的衔接
|
||||
|
||||
- Milestone 1([AUDIT_AND_ROADMAP_ZH.md](AUDIT_AND_ROADMAP_ZH.md) §4)执行 block-level eviction refactor 时,**整段 §2.2 应该消失**——这是衡量 refactor 完成度的客观指标。
|
||||
- Milestone 2 把 control plane 拆层(§4.8)时,§2.3 backpressure pause hint 应或被启用、或被下线,不允许悬挂。
|
||||
- Milestone 3 引入 learning-based admission(§4.15)时,§2.1 的 `admit_direct_append` 接口应保持稳定,policy 替换在 router 侧而非 D 侧。
|
||||
|
||||
---
|
||||
|
||||
**核心句**:vendored SGLang 的 785 行不是 monolithic 黑箱——三分之二是核心机制(论文必备),三分之一是当前架构的 workaround(refactor 后可整段删)。reviewer 看到本表能立刻判断"哪些是 paper 的真贡献、哪些是 prototype 当前的临时支撑"。
|
||||
174
docs/SNAPSHOT_STORE_REFACTOR_ZH.md
Normal file
174
docs/SNAPSHOT_STORE_REFACTOR_ZH.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# SnapshotStore 重构(解决 P-side alloc-failed 死局)
|
||||
|
||||
**日期**:2026-05-13
|
||||
**Status**:设计阶段,开始实施
|
||||
**根因**:`docs/E4_VS_E1_RESULTS_ZH.md` §3 + E4-v4/v5 forensic 显示 D→P sync 167 次尝试 0 OK,全部因 `prepare_receive` 试图从 `token_to_kv_pool_allocator.alloc(N)` 拿 N 个 slot 而 P 的池被自己 prefill 工作占满
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
- 当前 P-side `prepare_receive` 用 `token_to_kv_pool_allocator.alloc(N)` 抢 kv_pool slot —— 跟 P 自己的 prefill 工作直接争抢资源 → 90%+ 时间 alloc-failed
|
||||
- 重构方向:**P-side 用独立 GPU buffer 接收 snapshot**,与 kv_pool 解耦
|
||||
- 在 finalize_ingest 时才把 snapshot bytes copy 进 kv_pool slots(此时可以等更优的时机)
|
||||
- ~250 LOC 新代码,主要在 `disaggregation/snapshot/controller.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前实现的死局
|
||||
|
||||
```
|
||||
prepare_receive(sid, num_tokens=50000):
|
||||
indices = self.token_to_kv_pool_allocator.alloc(50000)
|
||||
if indices is None:
|
||||
return ok=False, reason="alloc-failed" ← 90%+ 时间走这里
|
||||
return slot_indices = indices.tolist()
|
||||
```
|
||||
|
||||
`alloc(50000)` 在 P 池中找 50000 个 contiguous 空 slot。当 P 正在 prefill 自己的 request 时(这是 P 的常态),池里大部分 slot 被锁定 → 找不出 50K 个空闲的 → fail.
|
||||
|
||||
E4-v5 167 次 sync 尝试统计:
|
||||
- 148 个 alloc-failed(**88%**)
|
||||
- 19 个 session-not-resident(D 端已 evict)
|
||||
- 0 个 OK
|
||||
|
||||
---
|
||||
|
||||
## 2. 新设计:PrefillSnapshotStore 侧表
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ P worker scheduler │
|
||||
│ │
|
||||
│ kv_pool (existing, owned by P's prefill work) │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ k_buffer[0..L]: (max_tokens, head, dim) │ │
|
||||
│ │ v_buffer[0..L]: (max_tokens, head, dim) │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ snapshot_buf (NEW, dedicated for D→P snapshot reception) │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ pinned GPU tensor of size SNAPSHOT_BUF_BYTES │ │
|
||||
│ │ (default 8 GB) │ │
|
||||
│ │ • registered with mooncake (one-time at init) │ │
|
||||
│ │ • slab-allocator manages free space │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Flow:
|
||||
1. prepare_receive(sid, N):
|
||||
slab = snapshot_buf_allocator.alloc(N * per_token_bytes_total)
|
||||
record = (sid, slab_offset, N)
|
||||
return (snapshot_buf_base + slab_offset for K_L, V_L per layer)
|
||||
← never blocks on kv_pool
|
||||
|
||||
2. (out-of-band) D pushes KV bytes into the slab via mooncake RDMA
|
||||
|
||||
3. finalize_ingest(sid, token_ids):
|
||||
record = pop ingest_record[sid]
|
||||
slots = token_to_kv_pool_allocator.alloc(N) ← can fail here
|
||||
if alloc-failed:
|
||||
snapshot_buf_allocator.free(record.slab)
|
||||
return ok=False, reason=alloc-failed-on-finalize
|
||||
# copy snapshot_buf[layer L][token range] → kv_pool.k_buffer[L][slots]
|
||||
for L in range(layer_num):
|
||||
kv_pool.k_buffer[L][slots] = snapshot_buf[K_L_offset : K_L_offset + N * K_stride].view(N, head, dim)
|
||||
kv_pool.v_buffer[L][slots] = snapshot_buf[V_L_offset : V_L_offset + N * V_stride].view(N, head, dim)
|
||||
tree_cache.insert(InsertParams(key=token_ids, value=slots))
|
||||
snapshot_buf_allocator.free(record.slab)
|
||||
return ok=True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键 design choices
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
|---|---|---|
|
||||
| Snapshot buffer 存哪 | GPU memory | 与 D RDMA 目标对称(D 端 KV 也在 GPU),避免 host↔device 拷贝 |
|
||||
| 默认大小 | **8 GB** | Qwen3-30B 一个 ~50K-token session 的 KV ~5 GB;8 GB 让我们至少 hold 一个 + 部分备份 |
|
||||
| 分配粒度 | 单次 contiguous 一个 session 全部 KV | 简化 slab allocator + 单次 batch transfer |
|
||||
| Layout | K-all-layers concat, then V-all-layers concat | 跟 mooncake 的 batch_transfer 接口对齐 |
|
||||
| Free 策略 | finalize 后立即 free | 当 snapshot 已 ingest 到 kv_pool,snapshot_buf 副本不再需要 |
|
||||
| 满了怎么办 | prepare_receive 返回 ok=False, reason=snapshot-buf-full | 让 caller fall back 到 re-prefill |
|
||||
|
||||
---
|
||||
|
||||
## 4. 接口变化
|
||||
|
||||
### 4.1 SnapshotPrepareReceiveReqOutput
|
||||
|
||||
旧:
|
||||
```
|
||||
k_base_ptrs: List[int] # 各 layer 的 k_buffer.data_ptr()
|
||||
v_base_ptrs: List[int]
|
||||
slot_indices: List[int] # kv_pool 中分配的 slot
|
||||
stride_k_bytes / stride_v_bytes
|
||||
```
|
||||
|
||||
新:
|
||||
```
|
||||
snapshot_buf_base_ptr: int # snapshot_buf.data_ptr()
|
||||
k_layer_offsets: List[int] # 各 layer K 在 snapshot_buf 中的字节偏移
|
||||
v_layer_offsets: List[int] # 各 layer V 偏移
|
||||
num_tokens: int
|
||||
stride_k_bytes / stride_v_bytes
|
||||
slab_handle: int # opaque handle for finalize/abort
|
||||
```
|
||||
|
||||
### 4.2 SnapshotFinalizeIngestReqInput
|
||||
|
||||
旧:
|
||||
```
|
||||
session_id, token_ids, slot_indices
|
||||
```
|
||||
|
||||
新:
|
||||
```
|
||||
session_id, token_ids, slab_handle # P 用 handle 找到 record,再 alloc kv_pool + copy + insert
|
||||
```
|
||||
|
||||
### 4.3 D-side push 逻辑(agentic)
|
||||
|
||||
旧:D 算 src_slot[L] → dst_slot[L] mapping,batch_transfer
|
||||
|
||||
新:D 算 src_slot[L] → snapshot_buf 中的 k_layer_offsets[L] / v_layer_offsets[L] mapping,batch_transfer。完全不需要 dst slot indices。
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施步骤
|
||||
|
||||
| # | 步骤 | LOC 估计 |
|
||||
|---|---|---:|
|
||||
| 1 | `SnapshotBufAllocator` 类(slab/bump allocator) | 80 |
|
||||
| 2 | `SnapshotLinkController.__init__` 加 snapshot_buf 分配 + 注册 | 30 |
|
||||
| 3 | 重写 `prepare_receive`、新加 `_compute_layer_offsets` | 60 |
|
||||
| 4 | 新加 `finalize_with_snapshot_buf` + 删旧的 `finalize_ingest` | 70 |
|
||||
| 5 | 修改 io_struct 字段 + 删旧字段 | 30 |
|
||||
| 6 | 修改 agentic `_attempt_d_to_p_sync` 用新字段 | 40 |
|
||||
| 7 | 改 mem leak check 计入 snapshot_buf | 5 |
|
||||
| 8 | 单元 smoke test | 50 |
|
||||
|
||||
Total: ~365 LOC
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 8 GB GPU mem cost | 用户可配置;mem-fraction-static 已经留了 buffer |
|
||||
| 多 session 抢 snapshot_buf | slab allocator + LRU evict 旧的 snapshot |
|
||||
| GPU→GPU copy 性能 | ~5 GB @ 3 TB/s = 1.7 ms,可忽略 |
|
||||
| 接口大改影响 smoke | 在 commit 内完成所有接口变更,smoke 同步更新 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收
|
||||
|
||||
- [ ] `scripts/smoke_snapshot_sglang_integration.py` 跑通新接口(prepare_receive 不再 alloc-failed)
|
||||
- [ ] E4-v6 跑同样 trace,d-to-p-sync.jsonl 出现 OK 事件 ≥ 30%(vs 当前 0%)
|
||||
|
||||
---
|
||||
|
||||
**核心句**:用 GPU 上独立的 snapshot_buf 接收 D 端推送,把"竞争 P kv_pool"这个根本性 alloc 冲突消掉,把 alloc 决策推迟到 finalize 时机,让 D→P 真正有机会跑通。
|
||||
BIN
docs/figures/e1_vs_e4_latency_cdf.png
Normal file
BIN
docs/figures/e1_vs_e4_latency_cdf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
docs/figures/e1_vs_e4_p99_attribution.png
Normal file
BIN
docs/figures/e1_vs_e4_p99_attribution.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/figures/e1_vs_e4_ttft_pdf.png
Normal file
BIN
docs/figures/e1_vs_e4_ttft_pdf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
BIN
docs/figures/e4_path_latency.png
Normal file
BIN
docs/figures/e4_path_latency.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@@ -20,21 +20,8 @@ build-backend = "setuptools.build_meta"
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[dependency-groups]
|
||||
# Pure-Python unit tests. Install via:
|
||||
# uv sync --group test
|
||||
# These tests deliberately import only the algorithm-layer modules
|
||||
# (policies, trace, topology) so they run without SGLang / GPU / CUDA.
|
||||
test = [
|
||||
"pytest>=8.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "allow"
|
||||
|
||||
[tool.uv.sources]
|
||||
sglang = { path = "third_party/sglang/python", editable = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Paired latency comparison with bootstrap CI.
|
||||
|
||||
Implements docs/EVALUATION_PROTOCOL_ZH.md §2.2 (M2 fix): when comparing
|
||||
mechanism A vs B on the same trace, the only honest comparison is paired
|
||||
on same-trial-mask. This script joins two metrics.jsonl by request_id,
|
||||
keeps the rows where BOTH sides succeeded, and reports paired deltas
|
||||
with 95% bootstrap CIs.
|
||||
|
||||
Out vs the existing `compare_no_error.py`:
|
||||
- works on raw metrics.jsonl, not pre-aggregated summary.json
|
||||
- bootstrap CIs (not just point estimates)
|
||||
- reports paired-mask size + per-side failure counts so the reader
|
||||
sees how many rows were dropped from the comparison
|
||||
|
||||
Usage:
|
||||
scripts/analysis/paired_compare.py \
|
||||
--baseline outputs/run-dp/request-metrics.jsonl \
|
||||
--candidate outputs/run-kvc/request-metrics.jsonl
|
||||
scripts/analysis/paired_compare.py ... --bootstrap 5000 --seed 42
|
||||
scripts/analysis/paired_compare.py ... --json > paired.json
|
||||
|
||||
stdlib only — no scipy/numpy. Runs without GPU and without SGLang.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, dict]:
|
||||
out: dict[str, dict] = {}
|
||||
with path.open() as handle:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
row = json.loads(line)
|
||||
rid = row.get("request_id")
|
||||
if rid is None:
|
||||
continue
|
||||
out[rid] = row
|
||||
return out
|
||||
|
||||
|
||||
def _ok(row: dict) -> bool:
|
||||
return row.get("error") is None and row.get("latency_s") is not None
|
||||
|
||||
|
||||
def _quantile(values: list[float], q: float) -> float:
|
||||
if not values:
|
||||
return float("nan")
|
||||
s = sorted(values)
|
||||
if len(s) == 1:
|
||||
return s[0]
|
||||
pos = (len(s) - 1) * q
|
||||
lo = math.floor(pos)
|
||||
hi = math.ceil(pos)
|
||||
if lo == hi:
|
||||
return s[lo]
|
||||
return s[lo] + (s[hi] - s[lo]) * (pos - lo)
|
||||
|
||||
|
||||
def _stats(deltas: list[float]) -> dict[str, float]:
|
||||
if not deltas:
|
||||
return {"mean": float("nan"), "p50": float("nan"), "p90": float("nan"), "p99": float("nan")}
|
||||
return {
|
||||
"mean": sum(deltas) / len(deltas),
|
||||
"p50": _quantile(deltas, 0.50),
|
||||
"p90": _quantile(deltas, 0.90),
|
||||
"p99": _quantile(deltas, 0.99),
|
||||
}
|
||||
|
||||
|
||||
def _bootstrap_ci(
|
||||
deltas: list[float], statistic, n_boot: int, rng: random.Random
|
||||
) -> tuple[float, float]:
|
||||
"""Return (lo, hi) 95% CI for `statistic(deltas)`."""
|
||||
if len(deltas) < 2:
|
||||
return (float("nan"), float("nan"))
|
||||
n = len(deltas)
|
||||
samples = []
|
||||
for _ in range(n_boot):
|
||||
# resample with replacement
|
||||
resample = [deltas[rng.randrange(n)] for _ in range(n)]
|
||||
samples.append(statistic(resample))
|
||||
samples.sort()
|
||||
lo = samples[int(0.025 * (n_boot - 1))]
|
||||
hi = samples[int(0.975 * (n_boot - 1))]
|
||||
return (lo, hi)
|
||||
|
||||
|
||||
def compare(
|
||||
baseline: dict[str, dict],
|
||||
candidate: dict[str, dict],
|
||||
*,
|
||||
metric: str,
|
||||
n_boot: int,
|
||||
seed: int,
|
||||
) -> dict:
|
||||
common_ids = set(baseline.keys()) & set(candidate.keys())
|
||||
paired_ids = [
|
||||
rid for rid in common_ids if _ok(baseline[rid]) and _ok(candidate[rid])
|
||||
]
|
||||
paired_ids.sort()
|
||||
|
||||
base_only_fail = sum(1 for rid in common_ids if not _ok(baseline[rid]))
|
||||
cand_only_fail = sum(1 for rid in common_ids if not _ok(candidate[rid]))
|
||||
|
||||
deltas = []
|
||||
wins = losses = ties = 0
|
||||
for rid in paired_ids:
|
||||
b = baseline[rid].get(metric)
|
||||
c = candidate[rid].get(metric)
|
||||
if b is None or c is None:
|
||||
continue
|
||||
d = float(c) - float(b)
|
||||
deltas.append(d)
|
||||
if d < 0:
|
||||
wins += 1
|
||||
elif d > 0:
|
||||
losses += 1
|
||||
else:
|
||||
ties += 1
|
||||
|
||||
rng = random.Random(seed)
|
||||
stats = _stats(deltas)
|
||||
ci_mean = _bootstrap_ci(deltas, lambda x: sum(x) / len(x), n_boot, rng)
|
||||
ci_p50 = _bootstrap_ci(deltas, lambda x: _quantile(x, 0.50), n_boot, rng)
|
||||
ci_p90 = _bootstrap_ci(deltas, lambda x: _quantile(x, 0.90), n_boot, rng)
|
||||
|
||||
return {
|
||||
"metric": metric,
|
||||
"baseline_size": len(baseline),
|
||||
"candidate_size": len(candidate),
|
||||
"intersection_size": len(common_ids),
|
||||
"paired_size": len(paired_ids),
|
||||
"baseline_fail_in_common": base_only_fail,
|
||||
"candidate_fail_in_common": cand_only_fail,
|
||||
"delta_stats": stats,
|
||||
"delta_mean_ci95": ci_mean,
|
||||
"delta_p50_ci95": ci_p50,
|
||||
"delta_p90_ci95": ci_p90,
|
||||
"wins_candidate": wins,
|
||||
"losses_candidate": losses,
|
||||
"ties": ties,
|
||||
}
|
||||
|
||||
|
||||
def _fmt(x: float, w: int = 6) -> str:
|
||||
if x is None or (isinstance(x, float) and math.isnan(x)):
|
||||
return " nan "
|
||||
return f"{x:+{w}.3f}"
|
||||
|
||||
|
||||
def render(result: dict) -> str:
|
||||
s = result["delta_stats"]
|
||||
mlo, mhi = result["delta_mean_ci95"]
|
||||
p5lo, p5hi = result["delta_p50_ci95"]
|
||||
p9lo, p9hi = result["delta_p90_ci95"]
|
||||
n = result["paired_size"]
|
||||
lines = [
|
||||
f"# paired comparison ({result['metric']})",
|
||||
"",
|
||||
f"baseline rows: {result['baseline_size']}",
|
||||
f"candidate rows: {result['candidate_size']}",
|
||||
f"intersection (rid): {result['intersection_size']}",
|
||||
f"paired (both ok): {result['paired_size']}",
|
||||
f" baseline fails in common: {result['baseline_fail_in_common']}",
|
||||
f" candidate fails in common: {result['candidate_fail_in_common']}",
|
||||
"",
|
||||
"## delta (candidate - baseline) — negative = candidate is faster",
|
||||
"",
|
||||
"| stat | value | 95% CI |",
|
||||
"|---|---:|---:|",
|
||||
f"| mean | {_fmt(s['mean'])} | [{_fmt(mlo)}, {_fmt(mhi)}] |",
|
||||
f"| p50 | {_fmt(s['p50'])} | [{_fmt(p5lo)}, {_fmt(p5hi)}] |",
|
||||
f"| p90 | {_fmt(s['p90'])} | [{_fmt(p9lo)}, {_fmt(p9hi)}] |",
|
||||
f"| p99 | {_fmt(s['p99'])} | — |",
|
||||
"",
|
||||
f"win/loss/tie: {result['wins_candidate']} / {result['losses_candidate']} / {result['ties']} (of {n})",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
p.add_argument("--baseline", required=True, type=Path)
|
||||
p.add_argument("--candidate", required=True, type=Path)
|
||||
p.add_argument(
|
||||
"--metric",
|
||||
default="latency_s",
|
||||
choices=["latency_s", "ttft_s", "tpot_s"],
|
||||
help="which per-request field to compare (default: latency_s)",
|
||||
)
|
||||
p.add_argument("--bootstrap", type=int, default=2000)
|
||||
p.add_argument("--seed", type=int, default=20260512)
|
||||
p.add_argument("--json", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
baseline = _load(args.baseline)
|
||||
candidate = _load(args.candidate)
|
||||
if not baseline or not candidate:
|
||||
print("empty input on one side", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = compare(
|
||||
baseline, candidate,
|
||||
metric=args.metric, n_boot=args.bootstrap, seed=args.seed,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
json.dump(result, sys.stdout, indent=2, default=lambda x: None if isinstance(x, float) and math.isnan(x) else x)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
print(render(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
334
scripts/analysis/plot_e1_vs_e4.py
Normal file
334
scripts/analysis/plot_e1_vs_e4.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate E1 (naive PD-disagg) vs E4 (KVC + load-floor + RDMA) comparison figures.
|
||||
|
||||
Outputs (under docs/figures/):
|
||||
e1_vs_e4_ttft_pdf.png - TTFT distribution body + log-tail
|
||||
e1_vs_e4_latency_cdf.png - E2E latency CDF
|
||||
e4_path_latency.png - E4 per-execution-mode latency breakdown
|
||||
e1_vs_e4_p99_attribution.png - which execution modes contribute to E4's p99 tail
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIG = ROOT / "docs/figures"
|
||||
FIG.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
E1_COLOR = "#D62728" # red
|
||||
E4_COLOR = "#1F77B4" # blue
|
||||
|
||||
|
||||
def load(p: Path) -> list[dict]:
|
||||
return [json.loads(l) for l in p.open()]
|
||||
|
||||
|
||||
def is_failed(r: dict) -> bool:
|
||||
if r.get("error"):
|
||||
return True
|
||||
fr = r.get("finish_reason")
|
||||
if fr and ("abort" in str(fr).lower() or "badrequest" in str(fr).lower()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pct(values, q):
|
||||
return float(np.quantile(values, q))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--e1-metrics", required=True)
|
||||
ap.add_argument("--e4-metrics", required=True)
|
||||
args = ap.parse_args()
|
||||
|
||||
e1 = [r for r in load(Path(args.e1_metrics)) if not is_failed(r)]
|
||||
e4 = [r for r in load(Path(args.e4_metrics)) if not is_failed(r)]
|
||||
e1_ttft = np.array([r["ttft_s"] for r in e1 if r.get("ttft_s") is not None])
|
||||
e4_ttft = np.array([r["ttft_s"] for r in e4 if r.get("ttft_s") is not None])
|
||||
e1_lat = np.array([r["latency_s"] for r in e1 if r.get("latency_s") is not None])
|
||||
e4_lat = np.array([r["latency_s"] for r in e4 if r.get("latency_s") is not None])
|
||||
e1_ttft = e1_ttft[e1_ttft > 1e-4]
|
||||
e4_ttft = e4_ttft[e4_ttft > 1e-4]
|
||||
|
||||
print(f"E1 reqs={len(e1)} (after failed-filter) TTFT n={len(e1_ttft)} lat n={len(e1_lat)}")
|
||||
print(f"E4 reqs={len(e4)} (after failed-filter) TTFT n={len(e4_ttft)} lat n={len(e4_lat)}")
|
||||
print()
|
||||
for name, arr in [("E1", e1_ttft), ("E4", e4_ttft)]:
|
||||
print(f" {name} TTFT mean={arr.mean():.3f} p50={pct(arr,0.5):.3f} "
|
||||
f"p90={pct(arr,0.9):.3f} p99={pct(arr,0.99):.3f} max={arr.max():.3f}")
|
||||
print()
|
||||
for name, arr in [("E1", e1_lat), ("E4", e4_lat)]:
|
||||
print(f" {name} Lat mean={arr.mean():.3f} p50={pct(arr,0.5):.3f} "
|
||||
f"p90={pct(arr,0.9):.3f} p99={pct(arr,0.99):.3f} max={arr.max():.3f}")
|
||||
print()
|
||||
|
||||
# ----- Plot 1: TTFT distribution (body + log tail) ---------------------
|
||||
_plot_ttft_pdf(e1_ttft, e4_ttft)
|
||||
|
||||
# ----- Plot 2: Latency CDF --------------------------------------------
|
||||
_plot_latency_cdf(e1_lat, e4_lat)
|
||||
|
||||
# ----- Plot 3: E4 path-level breakdown ---------------------------------
|
||||
_plot_path_latency(e4)
|
||||
|
||||
# ----- Plot 4: p99 attribution -----------------------------------------
|
||||
_plot_p99_attribution(e4, e1_ttft, e4_ttft)
|
||||
|
||||
|
||||
def _plot_ttft_pdf(e1_ttft, e4_ttft):
|
||||
from scipy.stats import gaussian_kde
|
||||
fig, axes = plt.subplots(1, 2, figsize=(16, 6.5))
|
||||
|
||||
# Body, linear x ∈ [0, 60s]
|
||||
ax = axes[0]
|
||||
x_body = np.linspace(0, 60, 800)
|
||||
kde_e4 = gaussian_kde(e4_ttft, bw_method=0.15)
|
||||
kde_e1 = gaussian_kde(e1_ttft, bw_method=0.15)
|
||||
ax.plot(x_body, kde_e4(x_body), color=E4_COLOR, lw=2.5,
|
||||
label=f"E4 KVC + load-floor + RDMA (n={len(e4_ttft)})")
|
||||
ax.fill_between(x_body, kde_e4(x_body), alpha=0.2, color=E4_COLOR)
|
||||
ax.plot(x_body, kde_e1(x_body), color=E1_COLOR, lw=2.5,
|
||||
label=f"E1 naive PD-disagg (n={len(e1_ttft)})")
|
||||
ax.fill_between(x_body, kde_e1(x_body), alpha=0.2, color=E1_COLOR)
|
||||
for q, ls in [(0.5, "-"), (0.9, "--")]:
|
||||
ax.axvline(pct(e4_ttft, q), color=E4_COLOR, ls=ls, alpha=0.55, lw=1.1)
|
||||
ax.axvline(pct(e1_ttft, q), color=E1_COLOR, ls=ls, alpha=0.55, lw=1.1)
|
||||
ymax = ax.get_ylim()[1]
|
||||
ax.text(pct(e4_ttft, 0.5), ymax * 0.95, f"E4 p50\n{pct(e4_ttft, 0.5):.1f}s",
|
||||
color=E4_COLOR, fontsize=9, va="top", ha="left",
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.8, pad=2))
|
||||
ax.text(pct(e1_ttft, 0.5), ymax * 0.55, f"E1 p50\n{pct(e1_ttft, 0.5):.1f}s",
|
||||
color=E1_COLOR, fontsize=9, va="top", ha="left",
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.8, pad=2))
|
||||
ax.set_xlim(0, 60)
|
||||
ax.set_xlabel("TTFT (seconds, linear)", fontsize=11)
|
||||
ax.set_ylabel("Probability density", fontsize=11)
|
||||
ax.set_title("Body of distribution (TTFT ≤ 60s)", fontsize=12, pad=10)
|
||||
ax.legend(loc="upper right", fontsize=10, framealpha=0.95)
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
|
||||
# Log tail
|
||||
ax = axes[1]
|
||||
kde_e4_log = gaussian_kde(np.log10(e4_ttft), bw_method="scott")
|
||||
kde_e1_log = gaussian_kde(np.log10(e1_ttft), bw_method="scott")
|
||||
log_x = np.linspace(np.log10(0.05), np.log10(500), 600)
|
||||
x_full = 10 ** log_x
|
||||
y_e4 = kde_e4_log(log_x)
|
||||
y_e1 = kde_e1_log(log_x)
|
||||
ax.plot(x_full, y_e4, color=E4_COLOR, lw=2.5, label=f"E4 KVC (n={len(e4_ttft)})")
|
||||
ax.fill_between(x_full, y_e4, alpha=0.2, color=E4_COLOR)
|
||||
ax.plot(x_full, y_e1, color=E1_COLOR, lw=2.5, label=f"E1 naive PD (n={len(e1_ttft)})")
|
||||
ax.fill_between(x_full, y_e1, alpha=0.2, color=E1_COLOR)
|
||||
ax.set_xscale("log")
|
||||
ax.set_xlim(0.05, 500)
|
||||
quartile_styles = [(0.5, "-", "p50"), (0.9, "--", "p90"), (0.99, ":", "p99")]
|
||||
for q, ls, _ in quartile_styles:
|
||||
ax.axvline(pct(e4_ttft, q), color=E4_COLOR, ls=ls, alpha=0.55, lw=1.1)
|
||||
ax.axvline(pct(e1_ttft, q), color=E1_COLOR, ls=ls, alpha=0.55, lw=1.1)
|
||||
ymax = max(y_e4.max(), y_e1.max())
|
||||
ax.annotate(f"E4 p99 = {pct(e4_ttft, 0.99):.1f}s",
|
||||
xy=(pct(e4_ttft, 0.99), kde_e4_log(np.log10(pct(e4_ttft, 0.99)))[0]),
|
||||
xytext=(80, ymax * 0.55),
|
||||
fontsize=10, color=E4_COLOR, fontweight="bold",
|
||||
arrowprops=dict(arrowstyle="->", color=E4_COLOR, lw=1.0))
|
||||
ax.annotate(f"E1 p99 = {pct(e1_ttft, 0.99):.1f}s",
|
||||
xy=(pct(e1_ttft, 0.99), kde_e1_log(np.log10(pct(e1_ttft, 0.99)))[0]),
|
||||
xytext=(80, ymax * 0.40),
|
||||
fontsize=10, color=E1_COLOR, fontweight="bold",
|
||||
arrowprops=dict(arrowstyle="->", color=E1_COLOR, lw=1.0))
|
||||
ax.set_xticks([0.1, 1, 10, 100])
|
||||
ax.set_xticklabels(["100ms", "1s", "10s", "100s"])
|
||||
ax.set_xlabel("TTFT (log scale)", fontsize=11)
|
||||
ax.set_ylabel("Density (per log₁₀ s)", fontsize=11)
|
||||
ax.set_title("Full range incl. p99 tail (log x)", fontsize=12, pad=10)
|
||||
ax.legend(loc="upper left", fontsize=10, framealpha=0.95)
|
||||
ax.grid(True, which="both", linestyle=":", alpha=0.4)
|
||||
|
||||
fig.suptitle(
|
||||
"TTFT density: E4 KVC v2 + load-floor + RDMA vs E1 naive PD-disagg\n"
|
||||
"Inferact 50-session trace · ts=1 · 4× H200 · aborted requests excluded",
|
||||
fontsize=13, y=1.02,
|
||||
)
|
||||
plt.tight_layout()
|
||||
out = FIG / "e1_vs_e4_ttft_pdf.png"
|
||||
plt.savefig(out, dpi=150, bbox_inches="tight")
|
||||
print(f"wrote {out}")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _plot_latency_cdf(e1_lat, e4_lat):
|
||||
fig, axes = plt.subplots(1, 2, figsize=(16, 6.5))
|
||||
|
||||
# Linear CDF
|
||||
ax = axes[0]
|
||||
for arr, color, name in [(e4_lat, E4_COLOR, f"E4 KVC (n={len(e4_lat)})"),
|
||||
(e1_lat, E1_COLOR, f"E1 naive (n={len(e1_lat)})")]:
|
||||
s = np.sort(arr)
|
||||
y = np.linspace(0, 1, len(s), endpoint=False)
|
||||
ax.plot(s, y, color=color, lw=2.5, label=name)
|
||||
ax.set_xlim(0, 300)
|
||||
ax.set_xlabel("E2E latency (seconds)", fontsize=11)
|
||||
ax.set_ylabel("CDF", fontsize=11)
|
||||
ax.set_title("Full latency CDF (linear)", fontsize=12)
|
||||
ax.legend(loc="lower right", fontsize=10)
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
# Annotate percentiles
|
||||
for q, mark in [(0.5, "p50"), (0.9, "p90"), (0.99, "p99")]:
|
||||
e4v, e1v = pct(e4_lat, q), pct(e1_lat, q)
|
||||
ax.axhline(q, color="gray", ls=":", alpha=0.3)
|
||||
ax.annotate(f"{mark}: E4 {e4v:.1f}s, E1 {e1v:.1f}s",
|
||||
xy=(0, q), xytext=(220, q - 0.02 if q > 0.5 else q + 0.02),
|
||||
fontsize=9, color="black")
|
||||
|
||||
# Log CDF showing tail
|
||||
ax = axes[1]
|
||||
for arr, color, name in [(e4_lat, E4_COLOR, f"E4 KVC"),
|
||||
(e1_lat, E1_COLOR, f"E1 naive")]:
|
||||
s = np.sort(arr)
|
||||
s_clip = np.maximum(s, 0.01)
|
||||
y = np.linspace(0, 1, len(s), endpoint=False)
|
||||
ax.plot(s_clip, 1 - y, color=color, lw=2.5, label=name)
|
||||
ax.set_xscale("log")
|
||||
ax.set_yscale("log")
|
||||
ax.set_xlim(0.5, 500)
|
||||
ax.set_ylim(1e-3, 1.1)
|
||||
ax.set_xlabel("E2E latency (log s)", fontsize=11)
|
||||
ax.set_ylabel("P(latency > x) (log)", fontsize=11)
|
||||
ax.set_title("Survival function — log-log (highlights tail behavior)", fontsize=12)
|
||||
ax.legend(loc="upper right", fontsize=10)
|
||||
ax.grid(True, which="both", linestyle=":", alpha=0.4)
|
||||
|
||||
fig.suptitle("E2E latency: E4 KVC vs E1 naive PD-disagg", fontsize=13, y=1.02)
|
||||
plt.tight_layout()
|
||||
out = FIG / "e1_vs_e4_latency_cdf.png"
|
||||
plt.savefig(out, dpi=150, bbox_inches="tight")
|
||||
print(f"wrote {out}")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _plot_path_latency(e4):
|
||||
by_mode = defaultdict(list)
|
||||
by_mode_lat = defaultdict(list)
|
||||
for r in e4:
|
||||
m = r.get("execution_mode", "?") or "?"
|
||||
if r.get("ttft_s") is not None:
|
||||
by_mode[m].append(float(r["ttft_s"]))
|
||||
if r.get("latency_s") is not None:
|
||||
by_mode_lat[m].append(float(r["latency_s"]))
|
||||
# Sort by count
|
||||
modes = sorted(by_mode, key=lambda m: -len(by_mode[m]))
|
||||
# Limit to top-N by count
|
||||
modes = modes[:14]
|
||||
|
||||
fig, ax = plt.subplots(1, 1, figsize=(14, 7))
|
||||
pos = np.arange(len(modes))
|
||||
means = [np.mean(by_mode[m]) for m in modes]
|
||||
p50 = [pct(np.array(by_mode[m]), 0.5) for m in modes]
|
||||
p99 = [pct(np.array(by_mode[m]), 0.99) for m in modes]
|
||||
counts = [len(by_mode[m]) for m in modes]
|
||||
bar_h = 0.25
|
||||
ax.barh(pos - bar_h, means, bar_h, label="mean", color="#4a90e2", alpha=0.85)
|
||||
ax.barh(pos, p50, bar_h, label="p50", color="#66cc99", alpha=0.85)
|
||||
ax.barh(pos + bar_h, p99, bar_h, label="p99", color="#e74c3c", alpha=0.85)
|
||||
ax.set_yticks(pos)
|
||||
ax.set_yticklabels([f"{m} (n={counts[i]})" for i, m in enumerate(modes)],
|
||||
fontsize=9)
|
||||
ax.invert_yaxis()
|
||||
ax.set_xlabel("TTFT (s)", fontsize=11)
|
||||
ax.set_title("E4 per execution_mode TTFT (sorted by count, top 14)",
|
||||
fontsize=12, pad=10)
|
||||
ax.legend(loc="lower right", fontsize=10)
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
plt.tight_layout()
|
||||
out = FIG / "e4_path_latency.png"
|
||||
plt.savefig(out, dpi=150, bbox_inches="tight")
|
||||
print(f"wrote {out}")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _plot_p99_attribution(e4, e1_ttft, e4_ttft):
|
||||
"""Show which execution modes hit p99 and dominate the tail."""
|
||||
# Threshold: anything > E4's p99 = part of the p99 tail
|
||||
e4_p99 = pct(e4_ttft, 0.99)
|
||||
e1_p99 = pct(e1_ttft, 0.99)
|
||||
# Define the "tail" as TTFT > p95
|
||||
threshold = pct(e4_ttft, 0.95)
|
||||
tail_modes = Counter()
|
||||
body_modes = Counter()
|
||||
for r in e4:
|
||||
m = r.get("execution_mode", "?") or "?"
|
||||
ttft = r.get("ttft_s")
|
||||
if ttft is None:
|
||||
continue
|
||||
if ttft >= threshold:
|
||||
tail_modes[m] += 1
|
||||
else:
|
||||
body_modes[m] += 1
|
||||
all_modes = sorted(tail_modes, key=lambda m: -tail_modes[m])[:10]
|
||||
body_total = sum(body_modes.values())
|
||||
tail_total = sum(tail_modes.values())
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(16, 6.5))
|
||||
|
||||
# Pie of tail composition
|
||||
ax = axes[0]
|
||||
sizes = [tail_modes[m] for m in all_modes]
|
||||
rest = sum(tail_modes.values()) - sum(sizes)
|
||||
if rest > 0:
|
||||
all_modes_label = all_modes + ["(other)"]
|
||||
sizes = sizes + [rest]
|
||||
else:
|
||||
all_modes_label = all_modes
|
||||
wedges, texts, autotexts = ax.pie(
|
||||
sizes, labels=[f"{m}\n(n={c})" for m, c in zip(all_modes_label, sizes)],
|
||||
autopct="%1.0f%%", startangle=90, textprops={"fontsize": 9},
|
||||
)
|
||||
ax.set_title(f"E4 p95-p99 tail composition\n(TTFT ≥ {threshold:.1f}s, n={tail_total})",
|
||||
fontsize=12, pad=12)
|
||||
|
||||
# Bar of mean TTFT within tail per mode
|
||||
ax = axes[1]
|
||||
mode_to_tail_lat = defaultdict(list)
|
||||
for r in e4:
|
||||
m = r.get("execution_mode", "?") or "?"
|
||||
ttft = r.get("ttft_s")
|
||||
if ttft is None or ttft < threshold:
|
||||
continue
|
||||
mode_to_tail_lat[m].append(float(ttft))
|
||||
pos = np.arange(len(all_modes))
|
||||
means = [np.mean(mode_to_tail_lat[m]) if mode_to_tail_lat[m] else 0 for m in all_modes]
|
||||
counts = [len(mode_to_tail_lat[m]) for m in all_modes]
|
||||
ax.barh(pos, means, color="#e74c3c", alpha=0.85)
|
||||
ax.set_yticks(pos)
|
||||
ax.set_yticklabels([f"{m} (n={counts[i]})" for i, m in enumerate(all_modes)],
|
||||
fontsize=9)
|
||||
ax.invert_yaxis()
|
||||
ax.set_xlabel("Mean TTFT in p95-p99 region (s)", fontsize=11)
|
||||
ax.set_title(f"Per-mode mean TTFT among tail reqs", fontsize=12)
|
||||
ax.axvline(e4_p99, color=E4_COLOR, ls="--", alpha=0.6, label=f"E4 p99 = {e4_p99:.1f}s")
|
||||
ax.axvline(e1_p99, color=E1_COLOR, ls="--", alpha=0.6, label=f"E1 p99 = {e1_p99:.1f}s")
|
||||
ax.legend(loc="lower right", fontsize=10)
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
|
||||
fig.suptitle(
|
||||
f"E4 p99 tail attribution: which execution_modes produce the long tail?\n"
|
||||
f"E4 p99 = {e4_p99:.1f}s vs E1 p99 = {e1_p99:.1f}s "
|
||||
f"(KVC loses tail by +{(e4_p99/e1_p99-1)*100:.1f}%)",
|
||||
fontsize=13, y=1.02,
|
||||
)
|
||||
plt.tight_layout()
|
||||
out = FIG / "e1_vs_e4_p99_attribution.png"
|
||||
plt.savefig(out, dpi=150, bbox_inches="tight")
|
||||
print(f"wrote {out}")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stratified latency / TTFT reporter for paper-quality evaluation.
|
||||
|
||||
Implements docs/EVALUATION_PROTOCOL_ZH.md §1.3 (M3 fix): every headline
|
||||
number must be accompanied by a stratified breakdown so reviewers can
|
||||
see which slice the gains come from.
|
||||
|
||||
Buckets the request rows from one or more metrics.jsonl files along:
|
||||
- turn_id : {1, 2-5, 6-20, 21+}
|
||||
- input_length : {<=8K, 8K-64K, >64K}
|
||||
- overlap_ratio : {<=0.3, 0.3-0.7, >0.7}
|
||||
- append_tokens : input_length - observed_overlap_blocks * BLOCK_SIZE
|
||||
|
||||
For each bucket, reports:
|
||||
- n (total rows in bucket)
|
||||
- n_ok (rows with no error and latency_s set)
|
||||
- latency_s mean / p50 / p90 / p99
|
||||
- ttft_s mean / p50 / p90 / p99
|
||||
- err_pct (1 - n_ok/n)
|
||||
|
||||
Usage:
|
||||
scripts/analysis/stratified.py outputs/<run>/request-metrics.jsonl \
|
||||
[outputs/<other-run>/request-metrics.jsonl ...]
|
||||
scripts/analysis/stratified.py --dim turn_id outputs/<run>/request-metrics.jsonl
|
||||
scripts/analysis/stratified.py --json outputs/<run>/request-metrics.jsonl > strat.json
|
||||
|
||||
stdlib only — no pandas/numpy. Runs without GPU and without SGLang.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
BLOCK_SIZE = 24 # SGLang radix block, matches docs/KVC_ROUTER_ALGORITHM.md §2
|
||||
|
||||
TURN_BUCKETS: list[tuple[str, tuple[int, int]]] = [
|
||||
("turn=1", (1, 1)),
|
||||
("turn=2-5", (2, 5)),
|
||||
("turn=6-20", (6, 20)),
|
||||
("turn=21+", (21, 10**9)),
|
||||
]
|
||||
INPUT_BUCKETS: list[tuple[str, tuple[int, int]]] = [
|
||||
("input<=8K", (0, 8 * 1024)),
|
||||
("input=8K-64K", (8 * 1024 + 1, 64 * 1024)),
|
||||
("input>64K", (64 * 1024 + 1, 10**9)),
|
||||
]
|
||||
OVERLAP_BUCKETS: list[tuple[str, tuple[float, float]]] = [
|
||||
("overlap<=0.3", (0.0, 0.3)),
|
||||
("overlap=0.3-0.7", (0.3, 0.7)),
|
||||
("overlap>0.7", (0.7, 1.0001)),
|
||||
]
|
||||
APPEND_BUCKETS: list[tuple[str, tuple[int, int]]] = [
|
||||
("append<=128", (0, 128)),
|
||||
("append=128-1K", (129, 1024)),
|
||||
("append=1K-8K", (1025, 8 * 1024)),
|
||||
("append>8K", (8 * 1024 + 1, 10**9)),
|
||||
]
|
||||
|
||||
DIM_BUCKETS: dict[str, list[tuple[str, tuple]]] = {
|
||||
"turn_id": TURN_BUCKETS,
|
||||
"input_length": INPUT_BUCKETS,
|
||||
"overlap_ratio": OVERLAP_BUCKETS,
|
||||
"append_tokens": APPEND_BUCKETS,
|
||||
}
|
||||
|
||||
|
||||
def _quantile(values: list[float], q: float) -> float:
|
||||
"""Linear-interpolation quantile, stdlib only."""
|
||||
if not values:
|
||||
return float("nan")
|
||||
s = sorted(values)
|
||||
if len(s) == 1:
|
||||
return s[0]
|
||||
pos = (len(s) - 1) * q
|
||||
lo = math.floor(pos)
|
||||
hi = math.ceil(pos)
|
||||
if lo == hi:
|
||||
return s[lo]
|
||||
return s[lo] + (s[hi] - s[lo]) * (pos - lo)
|
||||
|
||||
|
||||
def _stats(values: list[float]) -> dict[str, float]:
|
||||
if not values:
|
||||
return {"mean": float("nan"), "p50": float("nan"), "p90": float("nan"), "p99": float("nan")}
|
||||
return {
|
||||
"mean": sum(values) / len(values),
|
||||
"p50": _quantile(values, 0.50),
|
||||
"p90": _quantile(values, 0.90),
|
||||
"p99": _quantile(values, 0.99),
|
||||
}
|
||||
|
||||
|
||||
def _bucket_for(value: float | int, buckets: list[tuple[str, tuple]]) -> str:
|
||||
for label, (lo, hi) in buckets:
|
||||
if lo <= value <= hi:
|
||||
return label
|
||||
return "OOB"
|
||||
|
||||
|
||||
def _classify(row: dict, dim: str) -> str:
|
||||
if dim == "turn_id":
|
||||
return _bucket_for(int(row.get("turn_id", 0)), TURN_BUCKETS)
|
||||
if dim == "input_length":
|
||||
return _bucket_for(int(row.get("input_length", 0)), INPUT_BUCKETS)
|
||||
if dim == "overlap_ratio":
|
||||
inp = max(1, int(row.get("input_length", 0)))
|
||||
cached = int(row.get("observed_overlap_blocks", 0)) * BLOCK_SIZE
|
||||
ratio = min(1.0, cached / inp)
|
||||
return _bucket_for(ratio, OVERLAP_BUCKETS)
|
||||
if dim == "append_tokens":
|
||||
inp = int(row.get("input_length", 0))
|
||||
cached = int(row.get("observed_overlap_blocks", 0)) * BLOCK_SIZE
|
||||
return _bucket_for(max(0, inp - cached), APPEND_BUCKETS)
|
||||
raise ValueError(f"Unknown dim: {dim}")
|
||||
|
||||
|
||||
def load_rows(paths: Iterable[Path]) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for path in paths:
|
||||
with path.open() as handle:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
rows.append(json.loads(line))
|
||||
return rows
|
||||
|
||||
|
||||
def stratify(rows: list[dict], dim: str) -> dict[str, dict]:
|
||||
by_bucket: dict[str, list[dict]] = defaultdict(list)
|
||||
for row in rows:
|
||||
by_bucket[_classify(row, dim)].append(row)
|
||||
|
||||
output: dict[str, dict] = {}
|
||||
for label, _ in DIM_BUCKETS[dim]:
|
||||
bucket_rows = by_bucket.get(label, [])
|
||||
n = len(bucket_rows)
|
||||
ok = [r for r in bucket_rows if r.get("error") is None and r.get("latency_s") is not None]
|
||||
n_ok = len(ok)
|
||||
lat = [float(r["latency_s"]) for r in ok]
|
||||
ttft = [float(r["ttft_s"]) for r in ok if r.get("ttft_s") is not None]
|
||||
output[label] = {
|
||||
"n": n,
|
||||
"n_ok": n_ok,
|
||||
"err_pct": (n - n_ok) / n if n else 0.0,
|
||||
"latency_s": _stats(lat),
|
||||
"ttft_s": _stats(ttft),
|
||||
}
|
||||
return output
|
||||
|
||||
|
||||
def render_table(name: str, stats: dict[str, dict]) -> str:
|
||||
lines = [
|
||||
f"## stratified by {name}",
|
||||
"",
|
||||
"| bucket | n | n_ok | err% | lat mean | lat p50 | lat p90 | lat p99 | ttft mean | ttft p50 | ttft p90 | ttft p99 |",
|
||||
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
|
||||
]
|
||||
for label, _ in DIM_BUCKETS[name]:
|
||||
s = stats[label]
|
||||
lat = s["latency_s"]
|
||||
ttft = s["ttft_s"]
|
||||
lines.append(
|
||||
"| {label} | {n} | {n_ok} | {err:.1%} | "
|
||||
"{lm:.3f} | {l50:.3f} | {l90:.3f} | {l99:.3f} | "
|
||||
"{tm:.3f} | {t50:.3f} | {t90:.3f} | {t99:.3f} |".format(
|
||||
label=label,
|
||||
n=s["n"],
|
||||
n_ok=s["n_ok"],
|
||||
err=s["err_pct"],
|
||||
lm=lat["mean"],
|
||||
l50=lat["p50"],
|
||||
l90=lat["p90"],
|
||||
l99=lat["p99"],
|
||||
tm=ttft["mean"],
|
||||
t50=ttft["p50"],
|
||||
t90=ttft["p90"],
|
||||
t99=ttft["p99"],
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("metrics_paths", nargs="+", type=Path)
|
||||
parser.add_argument(
|
||||
"--dim",
|
||||
choices=list(DIM_BUCKETS.keys()) + ["all"],
|
||||
default="all",
|
||||
help="stratification dimension (default: all four)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="emit JSON instead of markdown tables",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = load_rows(args.metrics_paths)
|
||||
if not rows:
|
||||
print("no rows loaded", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
dims = list(DIM_BUCKETS.keys()) if args.dim == "all" else [args.dim]
|
||||
result = {dim: stratify(rows, dim) for dim in dims}
|
||||
|
||||
if args.json:
|
||||
json.dump(result, sys.stdout, indent=2, default=lambda x: None if isinstance(x, float) and math.isnan(x) else x)
|
||||
sys.stdout.write("\n")
|
||||
return
|
||||
|
||||
header_paths = ", ".join(str(p) for p in args.metrics_paths)
|
||||
print(f"# stratified report ({len(rows)} rows from {header_paths})\n")
|
||||
for dim in dims:
|
||||
print(render_table(dim, result[dim]))
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
141
scripts/analyze_e4_d_to_p.py
Normal file
141
scripts/analyze_e4_d_to_p.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-comparison of E1 (naive PD), E3 (KVC v2 + load-floor), E4 (KVC + D→P).
|
||||
|
||||
Usage:
|
||||
uv run --no-sync python scripts/analyze_e4_d_to_p.py \
|
||||
--e1 outputs/e1_naive_1p3d_kvaware_rdma_50sess/e1_naive_1p3d_kvaware_run1_summary.json \
|
||||
--e3 outputs/e3_kvc_v2_loadfloor_rdma_50sess/*_summary.json \
|
||||
--e4 outputs/e4_kvc_v2_d_to_p_sync_50sess/e4_kvc_v2_d_to_p_sync_run1_summary.json \
|
||||
--e4-metrics outputs/e4_kvc_v2_d_to_p_sync_50sess/e4_kvc_v2_d_to_p_sync_run1_metrics.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _load_summary(path_glob: str) -> dict[str, Any] | None:
|
||||
paths = glob.glob(path_glob)
|
||||
if not paths:
|
||||
return None
|
||||
with open(paths[0]) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _percentiles(values: list[float]) -> dict[str, float]:
|
||||
if not values:
|
||||
return {"p50": 0, "p90": 0, "p99": 0, "mean": 0}
|
||||
values = sorted(values)
|
||||
n = len(values)
|
||||
return {
|
||||
"mean": statistics.mean(values),
|
||||
"p50": values[n // 2],
|
||||
"p90": values[min(n - 1, int(n * 0.90))],
|
||||
"p99": values[min(n - 1, int(n * 0.99))],
|
||||
}
|
||||
|
||||
|
||||
def _row(label: str, s: dict[str, Any] | None, key: str) -> str:
|
||||
if s is None:
|
||||
return f" {label:<40} (missing)"
|
||||
stat = s.get(key, {})
|
||||
return (
|
||||
f" {label:<40} "
|
||||
f"mean={stat.get('mean', 0):>8.3f} "
|
||||
f"p50={stat.get('p50', 0):>8.3f} "
|
||||
f"p90={stat.get('p90', 0):>8.3f} "
|
||||
f"p99={stat.get('p99', 0):>8.3f}"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--e1", required=True)
|
||||
ap.add_argument("--e3", required=True)
|
||||
ap.add_argument("--e4", required=True)
|
||||
ap.add_argument("--e4-metrics", help="optional path to e4 metrics.jsonl for reseed-mode breakdown")
|
||||
args = ap.parse_args()
|
||||
|
||||
e1 = _load_summary(args.e1)
|
||||
e3 = _load_summary(args.e3)
|
||||
e4 = _load_summary(args.e4)
|
||||
|
||||
print("=" * 90)
|
||||
print("E1 / E3 / E4 cross-comparison")
|
||||
print("=" * 90)
|
||||
for s, name in [(e1, "E1"), (e3, "E3"), (e4, "E4")]:
|
||||
if s is None:
|
||||
print(f" {name}: MISSING")
|
||||
continue
|
||||
total = (s.get("error_count", 0) + s.get("abort_count", 0) +
|
||||
sum(c for c in s.get("execution_modes", {}).values()))
|
||||
print(f" {name}: error={s.get('error_count', 0):>4} abort={s.get('abort_count', 0):>4} "
|
||||
f"failure={s.get('failure_count', 0):>4} exec_modes={dict(s.get('execution_modes', {}))}")
|
||||
|
||||
print("\n--- latency_stats_s ---")
|
||||
print(_row("E1 naive PD", e1, "latency_stats_s"))
|
||||
print(_row("E3 KVC v2 LF", e3, "latency_stats_s"))
|
||||
print(_row("E4 KVC + D→P", e4, "latency_stats_s"))
|
||||
|
||||
print("\n--- ttft_stats_s ---")
|
||||
print(_row("E1 naive PD", e1, "ttft_stats_s"))
|
||||
print(_row("E3 KVC v2 LF", e3, "ttft_stats_s"))
|
||||
print(_row("E4 KVC + D→P", e4, "ttft_stats_s"))
|
||||
|
||||
print("\n--- per-decode load ---")
|
||||
for s, name in [(e1, "E1"), (e3, "E3"), (e4, "E4")]:
|
||||
print(f" {name}: {dict(s.get('per_decode_load', {}) if s else {})}")
|
||||
|
||||
# ---- E4 reseed-mode breakdown ----
|
||||
if args.e4_metrics:
|
||||
print("\n--- E4 reseed-mode breakdown (from metrics.jsonl) ---")
|
||||
try:
|
||||
modes = defaultdict(list)
|
||||
d2p_outcomes = Counter()
|
||||
with open(args.e4_metrics) as f:
|
||||
for line in f:
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
mode = rec.get("execution_mode") or "?"
|
||||
ttft = rec.get("ttft_s")
|
||||
if ttft is not None:
|
||||
modes[mode].append(float(ttft))
|
||||
# D→P hit counter (we logged via logger.info, not in metrics
|
||||
# — placeholder for future structured event)
|
||||
print(f" per-mode TTFT (count, mean, p50, p99):")
|
||||
for mode, ttfts in sorted(modes.items()):
|
||||
p = _percentiles(ttfts)
|
||||
print(f" {mode:<55} n={len(ttfts):>4} "
|
||||
f"mean={p['mean']:>7.3f} p50={p['p50']:>7.3f} p99={p['p99']:>7.3f}")
|
||||
except Exception as e:
|
||||
print(f" parse error: {e}")
|
||||
|
||||
# ---- H1 / H2 / H3 verdicts ----
|
||||
print("\n" + "=" * 90)
|
||||
print("Hypothesis verdicts")
|
||||
print("=" * 90)
|
||||
if e1 and e4:
|
||||
e1_p99 = e1.get("ttft_stats_s", {}).get("p99", float("inf"))
|
||||
e4_p99 = e4.get("ttft_stats_s", {}).get("p99", float("inf"))
|
||||
verdict_h1 = "PASS" if e4_p99 <= e1_p99 else "FAIL"
|
||||
print(f" H1 (E4 TTFT p99 ≤ E1 TTFT p99): {e4_p99:.3f} vs {e1_p99:.3f} → {verdict_h1}")
|
||||
if e3 and e4:
|
||||
e3_modes = e3.get("execution_modes", {})
|
||||
e4_modes = e4.get("execution_modes", {})
|
||||
e3_success = sum(v for k, v in e3_modes.items() if "reseed" not in k.lower())
|
||||
e4_success = sum(v for k, v in e4_modes.items() if "reseed" not in k.lower())
|
||||
verdict_h3 = "PASS" if (e4_success or 0) >= 0.85 * (e3_success or 1) else "FAIL"
|
||||
print(f" H3 (E4 success count ≥ 0.85 × E3 success): "
|
||||
f"{e4_success} vs 0.85 × {e3_success} = {0.85 * e3_success:.0f} → {verdict_h3}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
244
scripts/smoke_snapshot_link.py
Executable file
244
scripts/smoke_snapshot_link.py
Executable file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Two-process smoke test for snapshot_link D→P RDMA byte transfer.
|
||||
|
||||
Spawns scripts/snapshot_link_receiver.py via subprocess.Popen with stderr
|
||||
piped to ``<tmpdir>/recv.stderr.log`` for post-mortem if something dies.
|
||||
|
||||
Sender (this process):
|
||||
1. Spawns receiver child, waits for endpoint.json
|
||||
2. Brings up own SnapshotPeer (no recv buffer), registers a send buffer
|
||||
3. For each size: fill pattern, batch_transfer_sync_write, signal child,
|
||||
wait for child's ack
|
||||
4. Reads child's stdout (one JSON event per line) for verification
|
||||
|
||||
Pass = every size yields a child "verify" event with ok=true.
|
||||
|
||||
Usage:
|
||||
bash scripts/setup_env.sh && uv run --no-sync python scripts/smoke_snapshot_link.py
|
||||
|
||||
Env (optional):
|
||||
SNAPSHOT_LINK_HOST default 127.0.0.1
|
||||
SNAPSHOT_LINK_IB default mlx5_60
|
||||
SNAPSHOT_LINK_RECV_PORT default 17777
|
||||
SNAPSHOT_LINK_SEND_PORT default 17778
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_HERE.parent / "src"))
|
||||
|
||||
|
||||
SIZES_BYTES_DEFAULT = [
|
||||
1 << 10, # 1 KB
|
||||
1 << 14, # 16 KB
|
||||
1 << 18, # 256 KB
|
||||
1 << 20, # 1 MB
|
||||
1 << 22, # 4 MB
|
||||
1 << 24, # 16 MB
|
||||
1 << 26, # 64 MB
|
||||
]
|
||||
|
||||
|
||||
def _pattern_byte(i: int, seed: int) -> int:
|
||||
return (i * 2654435761 + seed) & 0xFF
|
||||
|
||||
|
||||
def _fill_pattern(buf, length: int, seed: int) -> None:
|
||||
tile_size = 4096
|
||||
tile = bytes(_pattern_byte(i, seed) for i in range(tile_size))
|
||||
tile_arr = (ctypes.c_ubyte * tile_size).from_buffer_copy(tile)
|
||||
n_full = length // tile_size
|
||||
rem = length - n_full * tile_size
|
||||
base = ctypes.addressof(buf)
|
||||
src_addr = ctypes.addressof(tile_arr)
|
||||
for k in range(n_full):
|
||||
ctypes.memmove(base + k * tile_size, src_addr, tile_size)
|
||||
if rem:
|
||||
ctypes.memmove(base + n_full * tile_size, src_addr, rem)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default=os.environ.get("SNAPSHOT_LINK_HOST", "127.0.0.1"))
|
||||
ap.add_argument("--ib", default=os.environ.get("SNAPSHOT_LINK_IB", "mlx5_60"))
|
||||
ap.add_argument("--recv-port", type=int,
|
||||
default=int(os.environ.get("SNAPSHOT_LINK_RECV_PORT", "17777")))
|
||||
ap.add_argument("--send-port", type=int,
|
||||
default=int(os.environ.get("SNAPSHOT_LINK_SEND_PORT", "17778")))
|
||||
ap.add_argument("--max-bytes", type=int, default=128 * 1024 * 1024)
|
||||
ap.add_argument("--sizes", default=",".join(str(s) for s in SIZES_BYTES_DEFAULT))
|
||||
args = ap.parse_args()
|
||||
|
||||
sizes = [int(s) for s in args.sizes.split(",")]
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="snapshot_link_smoke_"))
|
||||
control_path = tmpdir / "endpoint.json"
|
||||
recv_stderr_log = tmpdir / "recv.stderr.log"
|
||||
|
||||
recv_cmd = [
|
||||
sys.executable,
|
||||
str(_HERE / "snapshot_link_receiver.py"),
|
||||
"--host", args.host,
|
||||
"--port", str(args.recv_port),
|
||||
"--ib", args.ib,
|
||||
"--max-bytes", str(args.max_bytes),
|
||||
"--control-path", str(control_path),
|
||||
"--sizes", args.sizes,
|
||||
]
|
||||
recv_stderr = open(recv_stderr_log, "w")
|
||||
print(f"[sender] launching receiver: {' '.join(recv_cmd)}", flush=True)
|
||||
print(f"[sender] receiver stderr → {recv_stderr_log}", flush=True)
|
||||
recv_proc = subprocess.Popen(
|
||||
recv_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=recv_stderr,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Wait for endpoint metadata
|
||||
deadline = time.time() + 60.0
|
||||
while time.time() < deadline:
|
||||
if control_path.exists():
|
||||
try:
|
||||
meta = json.loads(control_path.read_text())
|
||||
if meta.get("ready"):
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if recv_proc.poll() is not None:
|
||||
_dump_recv_stderr(recv_stderr_log)
|
||||
print(f"[sender] FAIL: receiver exited early (rc={recv_proc.returncode})")
|
||||
return 1
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
print("[sender] FAIL: timed out waiting for receiver endpoint", flush=True)
|
||||
return 1
|
||||
|
||||
print(f"[sender] receiver endpoint: {meta}", flush=True)
|
||||
|
||||
from agentic_pd_hybrid.snapshot_link import SnapshotPeer, SnapshotEndpoint
|
||||
endpoint = SnapshotEndpoint(
|
||||
session_id=meta["session_id"],
|
||||
base_ptr=int(meta["base_ptr"]),
|
||||
capacity_bytes=int(meta["capacity_bytes"]),
|
||||
)
|
||||
peer = SnapshotPeer(
|
||||
host=args.host,
|
||||
port=args.send_port,
|
||||
ib_device=args.ib,
|
||||
receive_capacity_bytes=0,
|
||||
)
|
||||
send_buf = (ctypes.c_byte * args.max_bytes)()
|
||||
send_addr = ctypes.addressof(send_buf)
|
||||
peer.register_send_buffer(send_addr, args.max_bytes)
|
||||
print(f"[sender] own session_id={peer.session_id}, send_buf @ {hex(send_addr)} ({args.max_bytes} B)", flush=True)
|
||||
|
||||
transfers = []
|
||||
for size in sizes:
|
||||
if size > args.max_bytes:
|
||||
continue
|
||||
seed = int(time.time() * 1e6) & 0xFFFFFFFF
|
||||
_fill_pattern(send_buf, size, seed)
|
||||
t0 = time.perf_counter()
|
||||
ret = peer.push(endpoint, send_addr, 0, size, remote_offset=0)
|
||||
t1 = time.perf_counter()
|
||||
dt_ms = (t1 - t0) * 1000.0
|
||||
gbps = (size * 8.0 / 1e9) / max(t1 - t0, 1e-9)
|
||||
print(f"[sender] push size={size:>10d} ret={ret} "
|
||||
f"dur={dt_ms:>9.3f} ms thru={gbps:>6.3f} Gbps",
|
||||
flush=True)
|
||||
signal_path = control_path.with_suffix(f".do{size}")
|
||||
ack_path = control_path.with_suffix(f".ack{size}")
|
||||
signal_path.write_text(str(seed))
|
||||
ack_deadline = time.time() + 60.0
|
||||
while time.time() < ack_deadline:
|
||||
if ack_path.exists():
|
||||
break
|
||||
if recv_proc.poll() is not None:
|
||||
print(f"[sender] FAIL: receiver died after size={size}", flush=True)
|
||||
_dump_recv_stderr(recv_stderr_log)
|
||||
return 1
|
||||
time.sleep(0.05)
|
||||
transfers.append({
|
||||
"size": size, "ret": ret, "dur_ms": round(dt_ms, 3),
|
||||
"thru_Gbps": round(gbps, 3),
|
||||
"ack": ack_path.exists(),
|
||||
})
|
||||
|
||||
peer.close()
|
||||
|
||||
# Drain child stdout — each line is a JSON event
|
||||
try:
|
||||
recv_proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
recv_proc.terminate()
|
||||
recv_proc.wait(timeout=5)
|
||||
|
||||
events = []
|
||||
if recv_proc.stdout is not None:
|
||||
for raw in recv_proc.stdout:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(raw))
|
||||
except json.JSONDecodeError:
|
||||
events.append({"event": "non-json", "raw": raw})
|
||||
|
||||
print("=" * 78)
|
||||
print("[receiver] events:")
|
||||
verify_ok = 0
|
||||
verify_fail = 0
|
||||
for ev in events:
|
||||
print(f" {ev}")
|
||||
if ev.get("event") == "verify":
|
||||
if ev.get("ok"):
|
||||
verify_ok += 1
|
||||
else:
|
||||
verify_fail += 1
|
||||
|
||||
recv_stderr.close()
|
||||
_dump_recv_stderr(recv_stderr_log, header="--- receiver stderr ---")
|
||||
|
||||
overall = "PASS" if verify_fail == 0 and verify_ok == len(transfers) else "FAIL"
|
||||
print("=" * 78)
|
||||
print(f"OVERALL: {overall} verify_ok={verify_ok} verify_fail={verify_fail} "
|
||||
f"transfers={len(transfers)}")
|
||||
return 0 if overall == "PASS" else 1
|
||||
|
||||
finally:
|
||||
try:
|
||||
recv_proc.terminate()
|
||||
recv_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
recv_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _dump_recv_stderr(path: Path, header: str = "--- receiver stderr (last 40) ---") -> None:
|
||||
try:
|
||||
text = path.read_text()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
print(header, flush=True)
|
||||
for line in text.splitlines()[-40:]:
|
||||
print(f" {line}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
236
scripts/smoke_snapshot_link_gpu.py
Normal file
236
scripts/smoke_snapshot_link_gpu.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GPU-aware smoke test for snapshot_link RDMA byte transfer.
|
||||
|
||||
Sender on cuda:0, receiver subprocess on cuda:1. Tests whether
|
||||
mooncake's transfer_sync_write can move bytes between two GPUs via
|
||||
RDMA (which is what the real D→P flow will need for KV bytes).
|
||||
|
||||
Usage:
|
||||
bash scripts/setup_env.sh && uv run --no-sync python scripts/smoke_snapshot_link_gpu.py
|
||||
|
||||
The sender uses cuda:0 (--send-gpu); the receiver subprocess uses
|
||||
cuda:1 (--recv-gpu) by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_HERE.parent / "src"))
|
||||
|
||||
|
||||
SIZES_BYTES_DEFAULT = [
|
||||
1 << 14, # 16 KB
|
||||
1 << 20, # 1 MB
|
||||
1 << 24, # 16 MB
|
||||
1 << 26, # 64 MB
|
||||
1 << 28, # 256 MB
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default=os.environ.get("SNAPSHOT_LINK_HOST", "127.0.0.1"))
|
||||
ap.add_argument("--ib", default=os.environ.get("SNAPSHOT_LINK_IB", "mlx5_60"))
|
||||
ap.add_argument("--recv-port", type=int,
|
||||
default=int(os.environ.get("SNAPSHOT_LINK_RECV_PORT", "17787")))
|
||||
ap.add_argument("--send-port", type=int,
|
||||
default=int(os.environ.get("SNAPSHOT_LINK_SEND_PORT", "17788")))
|
||||
ap.add_argument("--max-bytes", type=int, default=256 * 1024 * 1024)
|
||||
ap.add_argument("--sizes", default=",".join(str(s) for s in SIZES_BYTES_DEFAULT))
|
||||
ap.add_argument("--send-gpu", type=int, default=0)
|
||||
ap.add_argument("--recv-gpu", type=int, default=1)
|
||||
args = ap.parse_args()
|
||||
|
||||
sizes = [int(s) for s in args.sizes.split(",")]
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="snapshot_link_gpu_smoke_"))
|
||||
control_path = tmpdir / "endpoint.json"
|
||||
recv_stderr_log = tmpdir / "recv.stderr.log"
|
||||
|
||||
recv_cmd = [
|
||||
sys.executable,
|
||||
str(_HERE / "snapshot_link_receiver_gpu.py"),
|
||||
"--host", args.host,
|
||||
"--port", str(args.recv_port),
|
||||
"--ib", args.ib,
|
||||
"--max-bytes", str(args.max_bytes),
|
||||
"--control-path", str(control_path),
|
||||
"--sizes", args.sizes,
|
||||
"--gpu-id", str(args.recv_gpu),
|
||||
]
|
||||
recv_stderr = open(recv_stderr_log, "w")
|
||||
print(f"[sender] receiver cmd: {' '.join(recv_cmd)}", flush=True)
|
||||
recv_proc = subprocess.Popen(
|
||||
recv_cmd, stdout=subprocess.PIPE, stderr=recv_stderr, bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
try:
|
||||
import torch
|
||||
if not torch.cuda.is_available():
|
||||
print("[sender] FAIL: cuda not available")
|
||||
return 1
|
||||
torch.cuda.set_device(args.send_gpu)
|
||||
|
||||
deadline = time.time() + 90.0
|
||||
meta = None
|
||||
while time.time() < deadline:
|
||||
if control_path.exists():
|
||||
try:
|
||||
meta = json.loads(control_path.read_text())
|
||||
if meta.get("ready"):
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if recv_proc.poll() is not None:
|
||||
_dump_recv_stderr(recv_stderr_log)
|
||||
print(f"[sender] FAIL: receiver exited (rc={recv_proc.returncode})")
|
||||
return 1
|
||||
time.sleep(0.1)
|
||||
if meta is None:
|
||||
print("[sender] FAIL: receiver endpoint timeout")
|
||||
return 1
|
||||
print(f"[sender] receiver endpoint: gpu={meta['gpu_id']}, "
|
||||
f"sid={meta['session_id']}, ptr={hex(int(meta['base_ptr']))}, "
|
||||
f"cap={meta['capacity_bytes']}", flush=True)
|
||||
|
||||
from agentic_pd_hybrid.snapshot_link import SnapshotPeer, SnapshotEndpoint
|
||||
|
||||
endpoint = SnapshotEndpoint(
|
||||
session_id=meta["session_id"],
|
||||
base_ptr=int(meta["base_ptr"]),
|
||||
capacity_bytes=int(meta["capacity_bytes"]),
|
||||
)
|
||||
|
||||
peer = SnapshotPeer(
|
||||
host=args.host,
|
||||
port=args.send_port,
|
||||
ib_device=args.ib,
|
||||
receive_capacity_bytes=0,
|
||||
)
|
||||
|
||||
# Allocate a sender buffer on cuda:0
|
||||
send_tensor = torch.zeros(args.max_bytes, dtype=torch.uint8,
|
||||
device=f"cuda:{args.send_gpu}")
|
||||
send_ptr = send_tensor.data_ptr()
|
||||
ret = peer.engine.register_memory(send_ptr, args.max_bytes)
|
||||
if ret != 0:
|
||||
print(f"[sender] FAIL: register_memory ret={ret}")
|
||||
return 1
|
||||
print(f"[sender] own gpu={args.send_gpu}, sid={peer.session_id}, "
|
||||
f"buf @ {hex(send_ptr)} ({args.max_bytes} B)", flush=True)
|
||||
|
||||
transfers = []
|
||||
for size in sizes:
|
||||
if size > args.max_bytes:
|
||||
continue
|
||||
# Fill with deterministic pattern on GPU
|
||||
seed = int(time.time() * 1e6) & 0xFFFFFFFF
|
||||
# Use a simple seeded pattern via torch ops
|
||||
gen = torch.Generator(device=f"cuda:{args.send_gpu}")
|
||||
gen.manual_seed(seed)
|
||||
send_tensor[:size] = torch.randint(0, 256, (size,), dtype=torch.uint8,
|
||||
device=f"cuda:{args.send_gpu}",
|
||||
generator=gen)
|
||||
torch.cuda.synchronize(args.send_gpu)
|
||||
# Compute expected hash (host-side)
|
||||
host_view = send_tensor[:size].cpu().numpy().tobytes()
|
||||
expected_sha = hashlib.sha256(host_view).hexdigest()
|
||||
# Push via RDMA
|
||||
t0 = time.perf_counter()
|
||||
ret = peer.push(endpoint, send_ptr, 0, size, remote_offset=0)
|
||||
t1 = time.perf_counter()
|
||||
dt_ms = (t1 - t0) * 1000.0
|
||||
gbps = (size * 8.0 / 1e9) / max(t1 - t0, 1e-9)
|
||||
print(f"[sender] push size={size:>10d} ret={ret} "
|
||||
f"dur={dt_ms:>9.3f} ms thru={gbps:>6.3f} Gbps",
|
||||
flush=True)
|
||||
|
||||
# Signal receiver to verify
|
||||
signal_path = control_path.with_suffix(f".do{size}")
|
||||
ack_path = control_path.with_suffix(f".ack{size}")
|
||||
signal_path.write_text(json.dumps({"sha": expected_sha}))
|
||||
ack_deadline = time.time() + 90.0
|
||||
while time.time() < ack_deadline:
|
||||
if ack_path.exists():
|
||||
break
|
||||
if recv_proc.poll() is not None:
|
||||
print(f"[sender] FAIL: receiver died after size={size}")
|
||||
_dump_recv_stderr(recv_stderr_log)
|
||||
return 1
|
||||
time.sleep(0.05)
|
||||
transfers.append({
|
||||
"size": size, "ret": ret, "dur_ms": round(dt_ms, 3),
|
||||
"thru_Gbps": round(gbps, 3), "ack": ack_path.exists(),
|
||||
})
|
||||
|
||||
try:
|
||||
recv_proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
recv_proc.terminate()
|
||||
recv_proc.wait(timeout=5)
|
||||
|
||||
events = []
|
||||
if recv_proc.stdout is not None:
|
||||
for raw in recv_proc.stdout:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(raw))
|
||||
except json.JSONDecodeError:
|
||||
events.append({"event": "non-json", "raw": raw})
|
||||
|
||||
print("=" * 78)
|
||||
print("[receiver] events:")
|
||||
verify_ok = 0
|
||||
verify_fail = 0
|
||||
for ev in events:
|
||||
print(f" {ev}")
|
||||
if ev.get("event") == "verify":
|
||||
if ev.get("ok"):
|
||||
verify_ok += 1
|
||||
else:
|
||||
verify_fail += 1
|
||||
|
||||
recv_stderr.close()
|
||||
_dump_recv_stderr(recv_stderr_log, header="--- receiver stderr ---")
|
||||
|
||||
overall = "PASS" if verify_fail == 0 and verify_ok == len(transfers) else "FAIL"
|
||||
print("=" * 78)
|
||||
print(f"OVERALL: {overall} verify_ok={verify_ok} verify_fail={verify_fail} "
|
||||
f"transfers={len(transfers)} send_gpu={args.send_gpu} recv_gpu={args.recv_gpu}")
|
||||
return 0 if overall == "PASS" else 1
|
||||
|
||||
finally:
|
||||
try:
|
||||
recv_proc.terminate()
|
||||
recv_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
recv_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _dump_recv_stderr(path: Path, header: str = "--- receiver stderr (last 60) ---") -> None:
|
||||
try:
|
||||
text = path.read_text()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
print(header, flush=True)
|
||||
for line in text.splitlines()[-60:]:
|
||||
print(f" {line}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
241
scripts/smoke_snapshot_sglang_integration.py
Normal file
241
scripts/smoke_snapshot_sglang_integration.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end smoke for the SGLang snapshot link integration.
|
||||
|
||||
Brings up TWO SGLang workers on this node (one acts as D, the other as P)
|
||||
with ``SGLANG_SNAPSHOT_LINK_ENABLE=1`` and exercises the three RPCs:
|
||||
|
||||
1. POST {P}/_snapshot/prepare_receive → P allocates kv_pool slots
|
||||
2. POST {D}/_snapshot/dump → D RDMA-pushes session KV
|
||||
3. POST {P}/_snapshot/finalize_ingest → P inserts into radix tree
|
||||
|
||||
To populate D's SessionAwareCache with a session, we first send a normal
|
||||
streaming-session generate request to D.
|
||||
|
||||
After finalize, we send another generate request to P with the same prefix
|
||||
and check whether the report says cached_tokens > 0 (cache hit).
|
||||
|
||||
This is a minimum-fidelity end-to-end smoke. It does NOT use the full
|
||||
agentic-pd-hybrid reseed orchestration; that's the next commit.
|
||||
|
||||
Required env:
|
||||
MODEL default /mnt/models/Qwen/Qwen3-30B-A3B-Instruct-2507
|
||||
|
||||
Usage:
|
||||
bash scripts/setup_env.sh && uv run --no-sync python \
|
||||
scripts/smoke_snapshot_sglang_integration.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _build_server_cmd(args, role: str, gpu_id: int, base_port: int,
|
||||
snapshot_port: int, ib_device: str) -> list:
|
||||
"""Build the SGLang launch command for one worker (D or P)."""
|
||||
common = [
|
||||
sys.executable, "-m", "sglang.launch_server",
|
||||
"--model-path", args.model,
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(base_port),
|
||||
"--tp-size", "1",
|
||||
"--mem-fraction-static", "0.6",
|
||||
"--disable-cuda-graph",
|
||||
"--disable-overlap-schedule",
|
||||
"--enable-streaming-session",
|
||||
"--disaggregation-mode", role,
|
||||
"--disaggregation-transfer-backend", "mooncake",
|
||||
"--disaggregation-bootstrap-port", str(base_port + 5000),
|
||||
"--disaggregation-ib-device", ib_device,
|
||||
]
|
||||
return common
|
||||
|
||||
|
||||
def _server_env(args, gpu_id: int, snapshot_port: int, ib_device: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
env["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
|
||||
env["SGLANG_SNAPSHOT_LINK_ENABLE"] = "1"
|
||||
env["SGLANG_SNAPSHOT_LINK_HOST"] = "127.0.0.1"
|
||||
env["SGLANG_SNAPSHOT_LINK_PORT"] = str(snapshot_port)
|
||||
env["SGLANG_SNAPSHOT_LINK_IB_DEVICE"] = ib_device
|
||||
env["MOONCAKE_PROTOCOL"] = "rdma"
|
||||
env["MOONCAKE_DEVICE"] = ib_device
|
||||
env["MC_TRANSFER_TIMEOUT"] = "1800"
|
||||
return env
|
||||
|
||||
|
||||
def _wait_for_ready(url: str, timeout: float = 240.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
r = httpx.get(f"{url}/health", timeout=2.0)
|
||||
if r.status_code == 200:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--model",
|
||||
default=os.environ.get("MODEL", "/mnt/models/Qwen/Qwen3-30B-A3B-Instruct-2507"))
|
||||
ap.add_argument("--d-gpu", type=int, default=1)
|
||||
ap.add_argument("--p-gpu", type=int, default=0)
|
||||
ap.add_argument("--d-port", type=int, default=29040)
|
||||
ap.add_argument("--p-port", type=int, default=29041)
|
||||
ap.add_argument("--d-snap-port", type=int, default=29045)
|
||||
ap.add_argument("--p-snap-port", type=int, default=29046)
|
||||
ap.add_argument("--ib", default="mlx5_60")
|
||||
ap.add_argument("--log-dir", default="outputs/snapshot_sglang_smoke")
|
||||
args = ap.parse_args()
|
||||
|
||||
log_dir = Path(args.log_dir)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Spawn P first (so D can find its snapshot endpoint later via prepare_receive)
|
||||
p_cmd = _build_server_cmd(args, "prefill", args.p_gpu, args.p_port,
|
||||
args.p_snap_port, args.ib)
|
||||
p_env = _server_env(args, args.p_gpu, args.p_snap_port, args.ib)
|
||||
p_stdout = open(log_dir / "p.stdout", "w")
|
||||
p_stderr = open(log_dir / "p.stderr", "w")
|
||||
print(f"[smoke] launching P: {' '.join(p_cmd)}")
|
||||
p_proc = subprocess.Popen(p_cmd, env=p_env, stdout=p_stdout, stderr=p_stderr)
|
||||
|
||||
d_cmd = _build_server_cmd(args, "decode", args.d_gpu, args.d_port,
|
||||
args.d_snap_port, args.ib)
|
||||
d_env = _server_env(args, args.d_gpu, args.d_snap_port, args.ib)
|
||||
d_stdout = open(log_dir / "d.stdout", "w")
|
||||
d_stderr = open(log_dir / "d.stderr", "w")
|
||||
print(f"[smoke] launching D: {' '.join(d_cmd)}")
|
||||
d_proc = subprocess.Popen(d_cmd, env=d_env, stdout=d_stdout, stderr=d_stderr)
|
||||
|
||||
try:
|
||||
print(f"[smoke] waiting for P @ 127.0.0.1:{args.p_port} ...")
|
||||
if not _wait_for_ready(f"http://127.0.0.1:{args.p_port}", timeout=300):
|
||||
_tail_stderr(log_dir / "p.stderr")
|
||||
raise RuntimeError("P server did not become healthy")
|
||||
print(f"[smoke] waiting for D @ 127.0.0.1:{args.d_port} ...")
|
||||
if not _wait_for_ready(f"http://127.0.0.1:{args.d_port}", timeout=300):
|
||||
_tail_stderr(log_dir / "d.stderr")
|
||||
raise RuntimeError("D server did not become healthy")
|
||||
print(f"[smoke] both servers up — running RPC sanity ...")
|
||||
|
||||
session_id = "smoke-sess-001"
|
||||
# NOTE: we deliberately skip seeding a session on D with a real
|
||||
# /generate call. Decode-mode workers crash on raw /generate without
|
||||
# PD-router-provided bootstrap_host (see decode.py:_bootstrap_addr).
|
||||
# The point of this smoke is to verify the 3 snapshot RPCs are
|
||||
# wired up correctly. KV correctness needs the full router stack
|
||||
# (covered by the end-to-end E4 sweep, not here).
|
||||
|
||||
# 3. Probe snapshot link: prepare_receive on P
|
||||
num_tokens = 64
|
||||
prep = httpx.post(
|
||||
f"http://127.0.0.1:{args.p_port}/_snapshot/prepare_receive",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"num_tokens": num_tokens,
|
||||
"expected_bytes_per_layer_k": 0,
|
||||
"expected_bytes_per_layer_v": 0,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
print(f"[smoke] prepare_receive on P → {prep.status_code}: {prep.text[:500]}")
|
||||
if prep.status_code != 200:
|
||||
return 1
|
||||
prep_data = prep.json()
|
||||
if not prep_data.get("ok"):
|
||||
print(f"[smoke] prepare_receive returned ok=false: {prep_data}")
|
||||
return 1
|
||||
|
||||
# 4. Dump on D — expect failure (session-not-resident), proves the
|
||||
# handler is reachable and exits the failure path cleanly.
|
||||
dump = httpx.post(
|
||||
f"http://127.0.0.1:{args.d_port}/_snapshot/dump",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"target_snapshot_session_id": prep_data["snapshot_session_id"],
|
||||
"target_k_base_ptrs": prep_data["k_base_ptrs"],
|
||||
"target_v_base_ptrs": prep_data["v_base_ptrs"],
|
||||
"target_slot_indices": prep_data["slot_indices"],
|
||||
"target_stride_k_bytes": prep_data["stride_k_bytes"],
|
||||
"target_stride_v_bytes": prep_data["stride_v_bytes"],
|
||||
"ib_device": args.ib,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
print(f"[smoke] dump on D (expected fail) → {dump.status_code}: {dump.text[:500]}")
|
||||
if dump.status_code != 200:
|
||||
return 1
|
||||
dump_data = dump.json()
|
||||
dump_reason = dump_data.get("reason", "")
|
||||
if dump_data.get("ok"):
|
||||
print("[smoke] unexpected dump success on a session that doesn't exist")
|
||||
elif dump_reason != "session-not-resident":
|
||||
print(f"[smoke] dump failed with wrong reason: {dump_reason}")
|
||||
return 1
|
||||
|
||||
# 5. Finalize on P with fake token_ids — radix insert should succeed
|
||||
prompt_ids = list(range(101, 101 + num_tokens)) # fake but unique ids
|
||||
fin = httpx.post(
|
||||
f"http://127.0.0.1:{args.p_port}/_snapshot/finalize_ingest",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"token_ids": prompt_ids,
|
||||
"slot_indices": prep_data["slot_indices"],
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
print(f"[smoke] finalize on P → {fin.status_code}: {fin.text[:500]}")
|
||||
if fin.status_code != 200:
|
||||
return 1
|
||||
fin_data = fin.json()
|
||||
if not fin_data.get("ok"):
|
||||
print(f"[smoke] finalize returned ok=false: {fin_data}")
|
||||
return 1
|
||||
print(f"[smoke] inserted_prefix_len = {fin_data.get('inserted_prefix_len')}")
|
||||
print("[smoke] OVERALL: PASS — all 3 RPCs reachable + handlers return expected schema")
|
||||
print(" (KV-correctness end-to-end check requires the full PD router stack;")
|
||||
print(" see scripts/sweep_e4_d_to_p_sync.sh for that)")
|
||||
return 0
|
||||
finally:
|
||||
for name, proc in [("D", d_proc), ("P", p_proc)]:
|
||||
try:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
except Exception:
|
||||
pass
|
||||
for name, proc in [("D", d_proc), ("P", p_proc)]:
|
||||
try:
|
||||
proc.wait(timeout=15)
|
||||
except Exception:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def _tail_stderr(path: Path, n: int = 60) -> None:
|
||||
try:
|
||||
text = path.read_text()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
print(f"--- {path} (last {n}) ---")
|
||||
for line in text.splitlines()[-n:]:
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
123
scripts/snapshot_link_receiver.py
Normal file
123
scripts/snapshot_link_receiver.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Receiver-side child process for the snapshot_link smoke test.
|
||||
|
||||
Reads CLI args, brings up a SnapshotPeer with a registered recv buffer,
|
||||
writes endpoint metadata to a control file, then loops: wait for size
|
||||
signal, verify recv buffer, write ack.
|
||||
|
||||
Status events are printed as single-line JSON to stdout for parent to
|
||||
parse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
|
||||
def _pattern_byte(i: int, seed: int) -> int:
|
||||
return (i * 2654435761 + seed) & 0xFF
|
||||
|
||||
|
||||
def _fill_pattern(buf, length: int, seed: int) -> None:
|
||||
tile_size = 4096
|
||||
tile = bytes(_pattern_byte(i, seed) for i in range(tile_size))
|
||||
tile_arr = (ctypes.c_ubyte * tile_size).from_buffer_copy(tile)
|
||||
n_full = length // tile_size
|
||||
rem = length - n_full * tile_size
|
||||
base = ctypes.addressof(buf)
|
||||
src_addr = ctypes.addressof(tile_arr)
|
||||
for k in range(n_full):
|
||||
ctypes.memmove(base + k * tile_size, src_addr, tile_size)
|
||||
if rem:
|
||||
ctypes.memmove(base + n_full * tile_size, src_addr, rem)
|
||||
|
||||
|
||||
def _emit(d: dict) -> None:
|
||||
print(json.dumps(d), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", required=True)
|
||||
ap.add_argument("--port", type=int, required=True)
|
||||
ap.add_argument("--ib", required=True)
|
||||
ap.add_argument("--max-bytes", type=int, required=True)
|
||||
ap.add_argument("--control-path", required=True)
|
||||
ap.add_argument("--sizes", required=True, help="comma-separated bytes")
|
||||
args = ap.parse_args()
|
||||
|
||||
sizes = [int(s) for s in args.sizes.split(",")]
|
||||
|
||||
from agentic_pd_hybrid.snapshot_link import SnapshotPeer
|
||||
|
||||
try:
|
||||
peer = SnapshotPeer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
ib_device=args.ib,
|
||||
receive_capacity_bytes=args.max_bytes,
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_emit({"event": "init-failed", "error": repr(e), "tb": traceback.format_exc()})
|
||||
sys.exit(2)
|
||||
|
||||
endpoint = peer.endpoint
|
||||
Path(args.control_path).write_text(json.dumps({
|
||||
"session_id": endpoint.session_id,
|
||||
"base_ptr": endpoint.base_ptr,
|
||||
"capacity_bytes": endpoint.capacity_bytes,
|
||||
"ready": True,
|
||||
}))
|
||||
_emit({"event": "endpoint-ready", "session_id": endpoint.session_id,
|
||||
"base_ptr": endpoint.base_ptr, "capacity": endpoint.capacity_bytes})
|
||||
|
||||
cp = Path(args.control_path)
|
||||
for size in sizes:
|
||||
if size > args.max_bytes:
|
||||
continue
|
||||
signal_path = cp.with_suffix(f".do{size}")
|
||||
ack_path = cp.with_suffix(f".ack{size}")
|
||||
deadline = time.time() + 120.0
|
||||
while time.time() < deadline:
|
||||
if signal_path.exists():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
_emit({"event": "no-signal-timeout", "size": size})
|
||||
continue
|
||||
try:
|
||||
seed = int(signal_path.read_text().strip())
|
||||
except Exception as e:
|
||||
_emit({"event": "signal-parse-error", "size": size, "err": repr(e)})
|
||||
continue
|
||||
expected_arr = (ctypes.c_ubyte * size)()
|
||||
_fill_pattern(expected_arr, size, seed)
|
||||
expected_hash = hashlib.sha256(bytes(expected_arr)).hexdigest()
|
||||
recv_bytes = peer.read_bytes(0, size)
|
||||
recv_hash = hashlib.sha256(recv_bytes).hexdigest()
|
||||
ok = recv_hash == expected_hash
|
||||
_emit({
|
||||
"event": "verify",
|
||||
"size": size,
|
||||
"ok": ok,
|
||||
"expected_sha": expected_hash[:16],
|
||||
"got_sha": recv_hash[:16],
|
||||
"first8_recv": recv_bytes[:8].hex(),
|
||||
"last8_recv": recv_bytes[-8:].hex(),
|
||||
})
|
||||
ack_path.write_text("done")
|
||||
|
||||
peer.close()
|
||||
_emit({"event": "receiver-done"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
124
scripts/snapshot_link_receiver_gpu.py
Normal file
124
scripts/snapshot_link_receiver_gpu.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GPU-side receiver child for snapshot_link smoke test (CUDA mem)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
|
||||
def _emit(d: dict) -> None:
|
||||
print(json.dumps(d), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", required=True)
|
||||
ap.add_argument("--port", type=int, required=True)
|
||||
ap.add_argument("--ib", required=True)
|
||||
ap.add_argument("--max-bytes", type=int, required=True)
|
||||
ap.add_argument("--control-path", required=True)
|
||||
ap.add_argument("--sizes", required=True)
|
||||
ap.add_argument("--gpu-id", type=int, default=1, help="receiver GPU id")
|
||||
args = ap.parse_args()
|
||||
|
||||
sizes = [int(s) for s in args.sizes.split(",")]
|
||||
|
||||
try:
|
||||
import torch
|
||||
if not torch.cuda.is_available():
|
||||
_emit({"event": "init-failed", "error": "cuda not available"})
|
||||
sys.exit(2)
|
||||
torch.cuda.set_device(args.gpu_id)
|
||||
# allocate a GPU buffer of max_bytes
|
||||
recv_tensor = torch.zeros(args.max_bytes, dtype=torch.uint8, device=f"cuda:{args.gpu_id}")
|
||||
recv_ptr = recv_tensor.data_ptr()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_emit({"event": "init-failed", "error": repr(e), "tb": traceback.format_exc()})
|
||||
sys.exit(2)
|
||||
|
||||
# Spin up SnapshotPeer with NO internal recv buffer, then register our GPU tensor
|
||||
from agentic_pd_hybrid.snapshot_link import SnapshotPeer, SnapshotEndpoint
|
||||
try:
|
||||
peer = SnapshotPeer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
ib_device=args.ib,
|
||||
receive_capacity_bytes=0,
|
||||
)
|
||||
ret = peer.engine.register_memory(recv_ptr, args.max_bytes)
|
||||
if ret != 0:
|
||||
_emit({"event": "init-failed", "error": f"register_memory({hex(recv_ptr)}, {args.max_bytes}) ret={ret}"})
|
||||
sys.exit(2)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_emit({"event": "init-failed", "error": repr(e), "tb": traceback.format_exc()})
|
||||
sys.exit(2)
|
||||
|
||||
endpoint = SnapshotEndpoint(
|
||||
session_id=peer.session_id,
|
||||
base_ptr=recv_ptr,
|
||||
capacity_bytes=args.max_bytes,
|
||||
)
|
||||
Path(args.control_path).write_text(json.dumps({
|
||||
"session_id": endpoint.session_id,
|
||||
"base_ptr": endpoint.base_ptr,
|
||||
"capacity_bytes": endpoint.capacity_bytes,
|
||||
"gpu_id": args.gpu_id,
|
||||
"ready": True,
|
||||
}))
|
||||
_emit({"event": "endpoint-ready",
|
||||
"session_id": endpoint.session_id,
|
||||
"base_ptr": endpoint.base_ptr,
|
||||
"capacity": endpoint.capacity_bytes,
|
||||
"gpu_id": args.gpu_id})
|
||||
|
||||
cp = Path(args.control_path)
|
||||
for size in sizes:
|
||||
if size > args.max_bytes:
|
||||
continue
|
||||
signal_path = cp.with_suffix(f".do{size}")
|
||||
ack_path = cp.with_suffix(f".ack{size}")
|
||||
deadline = time.time() + 120.0
|
||||
while time.time() < deadline:
|
||||
if signal_path.exists():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
_emit({"event": "no-signal-timeout", "size": size})
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(signal_path.read_text())
|
||||
expected_sha = payload["sha"]
|
||||
except Exception as e:
|
||||
_emit({"event": "signal-parse-error", "size": size, "err": repr(e)})
|
||||
continue
|
||||
|
||||
# Copy from GPU to CPU and hash
|
||||
torch.cuda.synchronize(args.gpu_id)
|
||||
host_bytes = bytes(recv_tensor[:size].cpu().numpy().tobytes())
|
||||
recv_sha = hashlib.sha256(host_bytes).hexdigest()
|
||||
ok = recv_sha == expected_sha
|
||||
_emit({
|
||||
"event": "verify",
|
||||
"size": size,
|
||||
"ok": ok,
|
||||
"expected_sha": expected_sha[:16],
|
||||
"got_sha": recv_sha[:16],
|
||||
"first8_recv": host_bytes[:8].hex(),
|
||||
"last8_recv": host_bytes[-8:].hex(),
|
||||
})
|
||||
ack_path.write_text("done")
|
||||
|
||||
peer.close()
|
||||
_emit({"event": "receiver-done"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
82
scripts/sweep_e4_kvc_v2_d_to_p_sync.sh
Executable file
82
scripts/sweep_e4_kvc_v2_d_to_p_sync.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# E4 — KVC v2 + RDMA + load-floor bonus + D→P snapshot push
|
||||
#
|
||||
# Identical to scripts/sweep_e3_kvc_v2_loadfloor_rdma.sh except for the
|
||||
# additional --enable-d-to-p-sync flag (which causes agentic to orchestrate
|
||||
# the snapshot RPCs on the reseed slow path, and stack.py to set
|
||||
# SGLANG_SNAPSHOT_LINK_ENABLE=1 per worker).
|
||||
#
|
||||
# See docs/E4_PROTOCOL_ZH.md for hypothesis matrix.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ -z "${CUDA_HOME:-}" ]; then
|
||||
echo "ERROR: CUDA_HOME not set. Source scripts/setup_env.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODEL=${MODEL:-/mnt/models/Qwen/Qwen3-30B-A3B-Instruct-2507}
|
||||
TRACE=${TRACE:-outputs/inferact_50sess.jsonl}
|
||||
OUTPUT=${OUTPUT:-outputs/e4_kvc_v2_d_to_p_sync_50sess}
|
||||
IB_DEVICE=${IB_DEVICE:-mlx5_60}
|
||||
LOAD_FLOOR_BONUS=${LOAD_FLOOR_BONUS:-200}
|
||||
|
||||
if [ ! -f "$TRACE" ]; then
|
||||
echo "ERROR: trace not found at $TRACE" >&2
|
||||
echo "Run: uv run --no-sync python scripts/sample_trace_subset.py --output $TRACE --sessions 50" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT"
|
||||
LOG="$OUTPUT/sweep.log"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
|
||||
|
||||
log "=== E4: KVC v2 + RDMA + load-floor K=$LOAD_FLOOR_BONUS + D→P sync ==="
|
||||
log "MODEL=$MODEL"
|
||||
log "TRACE=$TRACE ($(wc -l < $TRACE) requests)"
|
||||
log "OUTPUT=$OUTPUT"
|
||||
log "IB_DEVICE=$IB_DEVICE"
|
||||
log "MC_TRANSFER_TIMEOUT=${MC_TRANSFER_TIMEOUT:-default-30s}"
|
||||
|
||||
label=e4_kvc_v2_d_to_p_sync_run1
|
||||
log ""
|
||||
log "=== [E4] $label starting ==="
|
||||
|
||||
uv run --no-sync python -m agentic_pd_hybrid.cli benchmark-live \
|
||||
--trace "$TRACE" \
|
||||
--output-root "$OUTPUT" \
|
||||
--mechanism kvcache-centric \
|
||||
--policy kv-aware \
|
||||
--model-path "$MODEL" \
|
||||
--prefill-workers 1 --decode-workers 3 \
|
||||
--prefill-tp-size 1 --decode-tp-size 1 \
|
||||
--prefill-gpu-ids 0 --decode-gpu-ids 1,2,3 \
|
||||
--transfer-backend mooncake \
|
||||
--force-rdma --ib-device "$IB_DEVICE" \
|
||||
--gpu-budget 4 \
|
||||
--time-scale 1 \
|
||||
--session-sample-rate 1.0 \
|
||||
--target-duration-s 100000 \
|
||||
--concurrency-limit 32 \
|
||||
--timeout-s 1800 \
|
||||
--request-timeout-s 300 \
|
||||
--kvcache-admission-mode worker \
|
||||
--kvcache-seed-min-turn-id 1 \
|
||||
--kvcache-seed-max-inflight-decode -1 \
|
||||
--kvcache-prefill-backup-policy release-after-transfer \
|
||||
--kvcache-prefill-priority-eviction \
|
||||
--kvcache-migration-reject-threshold 3 \
|
||||
--kvcache-direct-max-uncached-tokens 8192 \
|
||||
--kvcache-load-floor-bonus "$LOAD_FLOOR_BONUS" \
|
||||
--enable-d-to-p-sync 2>&1 | tee -a "$LOG"
|
||||
|
||||
run_dir=$(ls -td "$OUTPUT"/kvcache-centric-*/ 2>/dev/null | head -1)
|
||||
log "=== [E4] $label COMPLETED, artifacts at $run_dir ==="
|
||||
|
||||
if [ -f "$run_dir/request-metrics.jsonl.summary.json" ]; then
|
||||
cp "$run_dir/request-metrics.jsonl.summary.json" "$OUTPUT/${label}_summary.json"
|
||||
cp "$run_dir/request-metrics.jsonl" "$OUTPUT/${label}_metrics.jsonl"
|
||||
log "=== summary saved to $OUTPUT/${label}_summary.json ==="
|
||||
fi
|
||||
117
scripts/sweep_e4_pressured.sh
Executable file
117
scripts/sweep_e4_pressured.sh
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# E4-pressured — same as E4 but tuned to force admission rejections so the
|
||||
# D→P snapshot fast-path actually fires.
|
||||
#
|
||||
# Key delta vs sweep_e4_kvc_v2_d_to_p_sync.sh:
|
||||
# --kvcache-migration-reject-threshold 1 (was 3)
|
||||
# After ONE rejection the policy migrates the session to a different
|
||||
# D, which in turn triggers _invoke_kvcache_seeded_router → D→P sync.
|
||||
# --decode-mem-fraction-static 0.4
|
||||
# Plumbed through cli.py → topology.decode_extra_server_args →
|
||||
# launcher. Shrinks per-decode KV pool, forcing admit_direct_append
|
||||
# to reject more often.
|
||||
#
|
||||
# Hypotheses (same as docs/E4_PROTOCOL_ZH.md but in a stressed regime):
|
||||
# H1' E4-pressured TTFT p99 ≤ E1 TTFT p99
|
||||
# H2' D→P snapshot succeeds for ≥ 20% of reseed-triggering requests
|
||||
# H3' D→P-pushed-then-cache-hit reduces re-prefill segment of reseed
|
||||
# path TTFT measurably
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ -z "${CUDA_HOME:-}" ]; then
|
||||
echo "ERROR: CUDA_HOME not set. Source scripts/setup_env.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODEL=${MODEL:-/mnt/models/Qwen/Qwen3-30B-A3B-Instruct-2507}
|
||||
TRACE=${TRACE:-third_party/traces/qwen35-swebench-50sess.jsonl}
|
||||
OUTPUT=${OUTPUT:-outputs/e4p_kvc_v2_d_to_p_sync_pressured_50sess}
|
||||
IB_DEVICE=${IB_DEVICE:-mlx5_60}
|
||||
LOAD_FLOOR_BONUS=${LOAD_FLOOR_BONUS:-200}
|
||||
REJECT_THRESHOLD=${REJECT_THRESHOLD:-1}
|
||||
MEM_FRACTION=${MEM_FRACTION:-0.5}
|
||||
# time-scale: 1 = realistic 5.44h timeline for the SWE-Bench trace;
|
||||
# 10 = compress to ~33 min; 60 = compress to ~5.5 min (stress test).
|
||||
TIME_SCALE=${TIME_SCALE:-1}
|
||||
|
||||
if [ ! -f "$TRACE" ]; then
|
||||
echo "ERROR: trace not found at $TRACE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT"
|
||||
LOG="$OUTPUT/sweep.log"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
|
||||
|
||||
log "=== E4-pressured: KVC v2 + RDMA + load-floor K=$LOAD_FLOOR_BONUS + D→P sync + reject_threshold=$REJECT_THRESHOLD + mem_fraction=$MEM_FRACTION ==="
|
||||
log "MODEL=$MODEL"
|
||||
log "TRACE=$TRACE ($(wc -l < $TRACE) requests)"
|
||||
log "OUTPUT=$OUTPUT"
|
||||
|
||||
label=e4p_kvc_v2_d_to_p_sync_run1
|
||||
log "=== [E4p] $label starting ==="
|
||||
|
||||
# Background GPU utilization sampler — every 1 s, all 4 GPUs, CSV output.
|
||||
GPU_CSV="$OUTPUT/gpu_util.csv"
|
||||
log "GPU sampling → $GPU_CSV (1 Hz, gpus 0-3)"
|
||||
echo "timestamp_iso,gpu_index,util_pct,mem_used_MiB,mem_total_MiB,sm_clock_MHz,power_W,temperature_C" > "$GPU_CSV"
|
||||
(
|
||||
while true; do
|
||||
ts_iso=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
|
||||
nvidia-smi --query-gpu=index,utilization.gpu,memory.used,memory.total,clocks.sm,power.draw,temperature.gpu \
|
||||
--format=csv,noheader,nounits 2>/dev/null \
|
||||
| sed -e "s/^/${ts_iso},/" -e 's/ //g' >> "$GPU_CSV" || true
|
||||
sleep 1
|
||||
done
|
||||
) &
|
||||
GPU_SAMPLER_PID=$!
|
||||
log "GPU sampler pid=$GPU_SAMPLER_PID"
|
||||
|
||||
cleanup_gpu_sampler() {
|
||||
kill -9 "$GPU_SAMPLER_PID" 2>/dev/null || true
|
||||
wait "$GPU_SAMPLER_PID" 2>/dev/null || true
|
||||
log "GPU sampler stopped (output: $GPU_CSV, $(wc -l < "$GPU_CSV") rows)"
|
||||
}
|
||||
trap cleanup_gpu_sampler EXIT INT TERM
|
||||
|
||||
uv run --no-sync python -m agentic_pd_hybrid.cli benchmark-live \
|
||||
--trace "$TRACE" \
|
||||
--output-root "$OUTPUT" \
|
||||
--mechanism kvcache-centric \
|
||||
--policy kv-aware \
|
||||
--model-path "$MODEL" \
|
||||
--prefill-workers 1 --decode-workers 3 \
|
||||
--prefill-tp-size 1 --decode-tp-size 1 \
|
||||
--prefill-gpu-ids 0 --decode-gpu-ids 1,2,3 \
|
||||
--transfer-backend mooncake \
|
||||
--force-rdma --ib-device "$IB_DEVICE" \
|
||||
--gpu-budget 4 \
|
||||
--time-scale "$TIME_SCALE" \
|
||||
--session-sample-rate 1.0 \
|
||||
--target-duration-s 100000 \
|
||||
--concurrency-limit 32 \
|
||||
--timeout-s 1800 \
|
||||
--request-timeout-s 300 \
|
||||
--kvcache-admission-mode worker \
|
||||
--kvcache-seed-min-turn-id 1 \
|
||||
--kvcache-seed-max-inflight-decode -1 \
|
||||
--kvcache-prefill-backup-policy release-after-transfer \
|
||||
--kvcache-prefill-priority-eviction \
|
||||
--kvcache-migration-reject-threshold "$REJECT_THRESHOLD" \
|
||||
--kvcache-direct-max-uncached-tokens 8192 \
|
||||
--kvcache-load-floor-bonus "$LOAD_FLOOR_BONUS" \
|
||||
--decode-mem-fraction-static "${DECODE_MEM_FRAC:-0.4}" \
|
||||
--prefill-mem-fraction-static "${PREFILL_MEM_FRAC:-0.7}" \
|
||||
--enable-d-to-p-sync 2>&1 | tee -a "$LOG"
|
||||
|
||||
run_dir=$(ls -td "$OUTPUT"/kvcache-centric-*/ 2>/dev/null | head -1)
|
||||
log "=== [E4p] $label COMPLETED, artifacts at $run_dir ==="
|
||||
|
||||
if [ -f "$run_dir/request-metrics.jsonl.summary.json" ]; then
|
||||
cp "$run_dir/request-metrics.jsonl.summary.json" "$OUTPUT/${label}_summary.json"
|
||||
cp "$run_dir/request-metrics.jsonl" "$OUTPUT/${label}_metrics.jsonl"
|
||||
log "=== summary saved to $OUTPUT/${label}_summary.json ==="
|
||||
fi
|
||||
@@ -49,6 +49,7 @@ class BenchmarkConfig:
|
||||
backpressure_max_pause_s: float = 2.0
|
||||
kvcache_migration_reject_threshold: int = 3
|
||||
kvcache_load_floor_bonus: int = 0
|
||||
enable_d_to_p_sync: bool = False
|
||||
sample_profile: str = "default"
|
||||
min_initial_input_tokens: int | None = None
|
||||
max_initial_input_tokens: int | None = None
|
||||
@@ -199,6 +200,7 @@ def run_live_benchmark(config: BenchmarkConfig) -> BenchmarkArtifacts:
|
||||
pool_poll_interval_s=config.pool_poll_interval_s,
|
||||
pool_poll_include_sessions=config.pool_poll_include_sessions,
|
||||
enable_backpressure=config.enable_backpressure,
|
||||
enable_d_to_p_sync=config.enable_d_to_p_sync,
|
||||
backpressure_max_pause_s=config.backpressure_max_pause_s,
|
||||
kvcache_migration_reject_threshold=config.kvcache_migration_reject_threshold,
|
||||
kvcache_load_floor_bonus=config.kvcache_load_floor_bonus,
|
||||
|
||||
@@ -283,6 +283,17 @@ def main() -> None:
|
||||
"See docs/E1_E2_FIX_DESIGN_ZH.md §Q2."
|
||||
),
|
||||
)
|
||||
replay.add_argument(
|
||||
"--enable-d-to-p-sync",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Enable D→P RDMA KV snapshot push for reseed fast-path. "
|
||||
"When set, on _invoke_kvcache_seeded_router agentic will probe D's "
|
||||
"session_aware_cache, RDMA-dump session KV to P's snapshot link, "
|
||||
"and insert into P's radix tree so the upcoming P prefill hits "
|
||||
"cache. See docs/D_TO_P_SYNC_DESIGN_ZH.md."
|
||||
),
|
||||
)
|
||||
|
||||
sample = subparsers.add_parser(
|
||||
"sample-sessions",
|
||||
@@ -547,6 +558,31 @@ def main() -> None:
|
||||
"See docs/E1_E2_FIX_DESIGN_ZH.md §Q2."
|
||||
),
|
||||
)
|
||||
benchmark.add_argument(
|
||||
"--enable-d-to-p-sync",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Enable D→P RDMA KV snapshot push for reseed fast-path. "
|
||||
"See docs/D_TO_P_SYNC_DESIGN_ZH.md."
|
||||
),
|
||||
)
|
||||
benchmark.add_argument(
|
||||
"--decode-mem-fraction-static",
|
||||
type=float,
|
||||
default=None,
|
||||
help=(
|
||||
"Override SGLang's --mem-fraction-static on decode workers. "
|
||||
"Smaller value → smaller KV pool → admit_direct_append rejects "
|
||||
"more often → reseed path fires more often. Pressure tool for "
|
||||
"E4-style D→P sync experiments."
|
||||
),
|
||||
)
|
||||
benchmark.add_argument(
|
||||
"--prefill-mem-fraction-static",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Override --mem-fraction-static on prefill workers.",
|
||||
)
|
||||
benchmark.add_argument(
|
||||
"--sample-profile",
|
||||
choices=["default", "small-append"],
|
||||
@@ -634,6 +670,7 @@ def main() -> None:
|
||||
backpressure_max_pause_s=args.backpressure_max_pause_s,
|
||||
kvcache_migration_reject_threshold=args.kvcache_migration_reject_threshold,
|
||||
kvcache_load_floor_bonus=args.kvcache_load_floor_bonus,
|
||||
enable_d_to_p_sync=args.enable_d_to_p_sync,
|
||||
)
|
||||
results = asyncio.run(replay_trace(config))
|
||||
print(
|
||||
@@ -782,6 +819,7 @@ def main() -> None:
|
||||
backpressure_max_pause_s=args.backpressure_max_pause_s,
|
||||
kvcache_migration_reject_threshold=args.kvcache_migration_reject_threshold,
|
||||
kvcache_load_floor_bonus=args.kvcache_load_floor_bonus,
|
||||
enable_d_to_p_sync=args.enable_d_to_p_sync,
|
||||
sample_profile=args.sample_profile,
|
||||
min_initial_input_tokens=args.min_initial_input_tokens,
|
||||
max_initial_input_tokens=args.max_initial_input_tokens,
|
||||
@@ -876,11 +914,26 @@ def _topology_from_args(args: argparse.Namespace):
|
||||
force_rdma=args.force_rdma,
|
||||
trust_remote_code=not args.no_trust_remote_code,
|
||||
ib_device=args.ib_device,
|
||||
prefill_extra_server_args=("--disable-overlap-schedule",),
|
||||
decode_extra_server_args=("--disable-overlap-schedule",),
|
||||
direct_extra_server_args=("--enable-streaming-session",),
|
||||
enable_d_to_p_sync=getattr(args, "enable_d_to_p_sync", False),
|
||||
prefill_extra_server_args=_build_extra_server_args(args, "prefill"),
|
||||
decode_extra_server_args=_build_extra_server_args(args, "decode"),
|
||||
direct_extra_server_args=_build_extra_server_args(args, "direct"),
|
||||
)
|
||||
|
||||
|
||||
def _build_extra_server_args(args, role: str) -> tuple[str, ...]:
|
||||
base: tuple[str, ...]
|
||||
if role == "direct":
|
||||
base = ("--enable-streaming-session",)
|
||||
else:
|
||||
base = ("--disable-overlap-schedule",)
|
||||
mem_frac = getattr(args, "decode_mem_fraction_static", None) if role == "decode" else None
|
||||
if mem_frac is None and role == "prefill":
|
||||
mem_frac = getattr(args, "prefill_mem_fraction_static", None)
|
||||
if mem_frac is not None and mem_frac > 0:
|
||||
base = base + ("--mem-fraction-static", f"{mem_frac:.3f}")
|
||||
return base
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -152,49 +152,6 @@ class StickyDecodePolicy:
|
||||
)
|
||||
|
||||
|
||||
CandidateScore = tuple[int, int, int, int]
|
||||
|
||||
|
||||
def score_candidate(
|
||||
*,
|
||||
overlap: int,
|
||||
sticky: bool,
|
||||
inflight: int,
|
||||
assigned: int,
|
||||
mean_assigned: float,
|
||||
sticky_bonus: int,
|
||||
load_floor_bonus: int,
|
||||
) -> CandidateScore:
|
||||
"""Pure scoring function for KvAwarePolicy (Algorithm 1 in KVC_ROUTER_ALGORITHM.md).
|
||||
|
||||
Returns the 4-tuple compared lexicographically by `select()` to pick the
|
||||
best D. Extracted as a top-level function so unit tests can exercise it
|
||||
without constructing topology/state objects.
|
||||
|
||||
Score tuple positions:
|
||||
0: overlap + sticky_bonus*sticky + floor_bonus — primary, KV reuse aware
|
||||
1: sticky — tie-1, session locality
|
||||
2: -inflight — tie-2, prefer low load
|
||||
3: -assigned — tie-3, prefer rarely-picked
|
||||
|
||||
Load-floor bonus is gated on `not sticky` (turn-1+ sessions continue to
|
||||
stick to their original D). The boost magnitude scales linearly with the
|
||||
D's deficit relative to the running mean of decode_assignment_counts:
|
||||
floor_bonus = load_floor_bonus * max(0, mean - assigned) / max(1, mean)
|
||||
When mean == 0 (warmup) the bonus is 0 for all candidates (lex tiebreak
|
||||
falls through to iteration order).
|
||||
|
||||
See docs/E1_E2_FIX_DESIGN_ZH.md §Q2 for the load-floor design and
|
||||
docs/KVC_ROUTER_ALGORITHM.md §3.1 for the lex-score formalism.
|
||||
"""
|
||||
floor_bonus = 0
|
||||
if load_floor_bonus > 0 and not sticky and mean_assigned > 0:
|
||||
deficit = max(0.0, mean_assigned - assigned)
|
||||
floor_bonus = int(load_floor_bonus * deficit / mean_assigned)
|
||||
primary = overlap + (sticky_bonus if sticky else 0) + floor_bonus
|
||||
return (primary, int(sticky), -inflight, -assigned)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KvAwarePolicy:
|
||||
name: str = "kv-aware"
|
||||
@@ -204,11 +161,27 @@ class KvAwarePolicy:
|
||||
# 0 disables the mechanism. Default 3 picked empirically to allow brief
|
||||
# transient saturation without panicking, but to reroute persistent starvation.
|
||||
migration_reject_threshold: int = 3
|
||||
# Load-floor bonus: see score_candidate() docstring for the exact formula.
|
||||
# Set above the max cross-session boilerplate overlap you expect (so fresh
|
||||
# sessions reach under-loaded D's even at 0 overlap), but below the
|
||||
# magnitude of "real" prefix overlap (so a warm D still wins for its own
|
||||
# session). 0 disables.
|
||||
# Load-floor bonus: graduated boost added to lex-score position 0 for
|
||||
# under-loaded D workers, gated on `not sticky` so turn-1+ requests of an
|
||||
# existing session continue to stick to their original D. The boost
|
||||
# magnitude scales linearly with the D's deficit relative to the running
|
||||
# mean of `decode_assignment_counts`:
|
||||
# floor_bonus = K * max(0, mean - assigned[D]) / max(1, mean)
|
||||
# When mean=0 (warmup), bonus is 0 for all workers (lex tiebreak by
|
||||
# iteration order). Once any D has been assigned, under-loaded D's get a
|
||||
# bonus that approaches K as their deficit-to-mean ratio approaches 1.
|
||||
# The bonus naturally decays as load equalises, leaving the original
|
||||
# overlap+sticky scoring in charge of steady-state selection.
|
||||
#
|
||||
# Set this above the maximum cross-session boilerplate overlap you expect
|
||||
# so that fresh sessions are routed to under-loaded D's even when those
|
||||
# D's currently have 0 overlap, but below the magnitude of "real" prefix
|
||||
# overlap (e.g., a session with 800-block per-session prefix on an
|
||||
# already-warm D should still go there).
|
||||
#
|
||||
# 0 disables. See docs/E1_E2_FIX_DESIGN_ZH.md §Q2 for the full design and
|
||||
# docs/E1_E2_RESULTS_ZH.md §5d for why this is needed on Inferact-shaped
|
||||
# workloads where boilerplate overlap pins D2 cold forever.
|
||||
load_floor_bonus: int = 0
|
||||
|
||||
def select(
|
||||
@@ -221,12 +194,15 @@ class KvAwarePolicy:
|
||||
prefill_worker_id = state.next_prefill_worker_id(topology)
|
||||
session = state.session_state.get(request.session_id)
|
||||
|
||||
# Pre-compute the running mean of decode assignments. Used by the
|
||||
# load-floor bonus inside the candidate loop.
|
||||
n_route_workers = max(1, len(topology.route_workers))
|
||||
total_assigned = sum(state.decode_assignment_counts.values())
|
||||
mean_assigned = total_assigned / n_route_workers
|
||||
|
||||
best_decode_worker_id: str | None = None
|
||||
best_score: CandidateScore | None = None
|
||||
best_score: tuple[int, int, int, int] | None = None
|
||||
candidates_considered = 0
|
||||
for worker in topology.route_workers:
|
||||
# Migration: skip workers that have rejected this session too many times.
|
||||
# If all candidates get filtered (degenerate case), fall through to
|
||||
@@ -237,17 +213,25 @@ class KvAwarePolicy:
|
||||
)
|
||||
if rejects >= self.migration_reject_threshold:
|
||||
continue
|
||||
score = score_candidate(
|
||||
overlap=_overlap_blocks(request, state, worker.worker_id),
|
||||
sticky=(
|
||||
session is not None
|
||||
and session.last_decode_worker == worker.worker_id
|
||||
),
|
||||
inflight=state.inflight_decode.get(worker.worker_id, 0),
|
||||
assigned=state.decode_assignment_counts.get(worker.worker_id, 0),
|
||||
mean_assigned=mean_assigned,
|
||||
sticky_bonus=self.sticky_bonus,
|
||||
load_floor_bonus=self.load_floor_bonus,
|
||||
candidates_considered += 1
|
||||
overlap = _overlap_blocks(request, state, worker.worker_id)
|
||||
sticky = int(session is not None and session.last_decode_worker == worker.worker_id)
|
||||
inflight_penalty = -state.inflight_decode.get(worker.worker_id, 0)
|
||||
worker_assigned = state.decode_assignment_counts.get(worker.worker_id, 0)
|
||||
assignment_penalty = -worker_assigned
|
||||
|
||||
# Load-floor bonus: only for fresh placements (not sticky), and
|
||||
# only when the knob is enabled. See docstring above.
|
||||
floor_bonus = 0
|
||||
if self.load_floor_bonus > 0 and not sticky and mean_assigned > 0:
|
||||
deficit = max(0.0, mean_assigned - worker_assigned)
|
||||
floor_bonus = int(self.load_floor_bonus * deficit / mean_assigned)
|
||||
|
||||
score = (
|
||||
overlap + sticky * self.sticky_bonus + floor_bonus,
|
||||
sticky,
|
||||
inflight_penalty,
|
||||
assignment_penalty,
|
||||
)
|
||||
if best_score is None or score > best_score:
|
||||
best_score = score
|
||||
|
||||
@@ -116,6 +116,11 @@ class ReplayConfig:
|
||||
# with shared cross-session prefix. 0 disables. See
|
||||
# docs/E1_E2_FIX_DESIGN_ZH.md §Q2.
|
||||
kvcache_load_floor_bonus: int = 0
|
||||
# D→P snapshot push: when True and reseed fires, agentic will RDMA-dump
|
||||
# the session's KV from the D-side worker that last held it onto the P
|
||||
# worker and insert into P's radix tree, so the subsequent P prefill
|
||||
# hits cache. See docs/D_TO_P_SYNC_DESIGN_ZH.md.
|
||||
enable_d_to_p_sync: bool = False
|
||||
structural_log_dir: Path | None = None
|
||||
|
||||
|
||||
@@ -2104,6 +2109,188 @@ async def _invoke_plain_router(
|
||||
)
|
||||
|
||||
|
||||
async def _attempt_d_to_p_sync(
|
||||
*,
|
||||
client: httpx.AsyncClient,
|
||||
request: TraceRequest,
|
||||
config: ReplayConfig,
|
||||
prefill_url: str,
|
||||
decode_session: DirectSessionState,
|
||||
) -> dict | None:
|
||||
"""Try to RDMA-dump session KV from the D that last held it to ``prefill_url``.
|
||||
|
||||
Returns a dict with status info on success/skip, or ``None`` on a
|
||||
non-recoverable error. The caller falls back to normal re-prefill on
|
||||
any failure. Each path emits a structural-log line so we can forensic
|
||||
why sync skipped vs succeeded vs failed.
|
||||
"""
|
||||
if not config.enable_d_to_p_sync:
|
||||
return None
|
||||
source_d_url = decode_session.server_url
|
||||
sid = request.session_id
|
||||
rid = request.request_id
|
||||
if not source_d_url:
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "skipped", "stage": "entry", "sid": sid, "rid": rid,
|
||||
"reason": "no-source-d"},
|
||||
)
|
||||
return {"status": "skipped-no-source-d"}
|
||||
# NB: do NOT gate on decode_session.opened. By the time we reach the
|
||||
# fallback seeded_router, agentic has already flipped that flag to False
|
||||
# in response to admission rejection. But the D-side scheduler's
|
||||
# SessionAwareCache may STILL hold the session resident (release_session
|
||||
# is only called explicitly, not from admission events). Let D be the
|
||||
# source of truth via its own snapshot_dump response.
|
||||
target_tokens = max(0, int(_estimate_session_resident_tokens(request)))
|
||||
if target_tokens <= 0:
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "skipped", "stage": "entry", "sid": sid, "rid": rid,
|
||||
"reason": "zero-target-tokens"},
|
||||
)
|
||||
return {"status": "skipped-zero-tokens"}
|
||||
|
||||
t_prep0 = time.perf_counter()
|
||||
try:
|
||||
prep_resp = await client.post(
|
||||
f"{prefill_url}/_snapshot/prepare_receive",
|
||||
json={
|
||||
"session_id": request.session_id,
|
||||
"num_tokens": target_tokens,
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
prep_resp.raise_for_status()
|
||||
prep = prep_resp.json()
|
||||
except Exception as exc:
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "failed", "stage": "prepare", "sid": sid, "rid": rid,
|
||||
"error": repr(exc)[:200]},
|
||||
)
|
||||
return {"status": "prepare-failed", "error": repr(exc)}
|
||||
t_prep1 = time.perf_counter()
|
||||
if not prep.get("ok"):
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "skipped", "stage": "prepare", "sid": sid, "rid": rid,
|
||||
"reason": prep.get("reason"),
|
||||
"prepare_dur_ms": round((t_prep1 - t_prep0) * 1000, 2)},
|
||||
)
|
||||
return {"status": "prepare-not-ok", "reason": prep.get("reason")}
|
||||
|
||||
t_dump0 = time.perf_counter()
|
||||
try:
|
||||
dump_resp = await client.post(
|
||||
f"{source_d_url}/_snapshot/dump",
|
||||
json={
|
||||
"session_id": request.session_id,
|
||||
"target_snapshot_session_id": prep["snapshot_session_id"],
|
||||
"target_snapshot_buf_base": prep["snapshot_buf_base_ptr"],
|
||||
"target_k_layer_offsets": prep["k_layer_offsets"],
|
||||
"target_v_layer_offsets": prep["v_layer_offsets"],
|
||||
"target_stride_k_bytes": prep["stride_k_bytes"],
|
||||
"target_stride_v_bytes": prep["stride_v_bytes"],
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
dump_resp.raise_for_status()
|
||||
dump = dump_resp.json()
|
||||
except Exception as exc:
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "failed", "stage": "dump", "sid": sid, "rid": rid,
|
||||
"error": repr(exc)[:200]},
|
||||
)
|
||||
return {"status": "dump-failed", "error": repr(exc)}
|
||||
t_dump1 = time.perf_counter()
|
||||
if not dump.get("ok"):
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "skipped", "stage": "dump", "sid": sid, "rid": rid,
|
||||
"reason": dump.get("reason"),
|
||||
"dump_dur_ms": round((t_dump1 - t_dump0) * 1000, 2),
|
||||
"kv_committed_len": int(dump.get("kv_committed_len", 0))},
|
||||
)
|
||||
return {"status": "dump-not-ok", "reason": dump.get("reason"),
|
||||
"bytes_pushed": dump.get("bytes_pushed", 0)}
|
||||
|
||||
# We need token_ids for radix insert. The caller has request.input_token_ids
|
||||
# for the first N — use that as best-available approximation.
|
||||
tokens = list(getattr(request, "input_token_ids", []) or [])
|
||||
if not tokens:
|
||||
# No token_ids → can't insert into radix; tell P to free the slab.
|
||||
try:
|
||||
await client.post(
|
||||
f"{prefill_url}/_snapshot/finalize_ingest",
|
||||
json={
|
||||
"session_id": request.session_id,
|
||||
"token_ids": [],
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "skipped", "stage": "post-dump", "sid": sid, "rid": rid,
|
||||
"reason": "no-input-token-ids",
|
||||
"bytes_pushed": int(dump.get("bytes_pushed", 0))},
|
||||
)
|
||||
return {"status": "no-tokens-discard", "bytes_pushed": dump.get("bytes_pushed", 0)}
|
||||
|
||||
n = min(len(tokens), int(prep.get("num_tokens", 0)))
|
||||
t_fin0 = time.perf_counter()
|
||||
try:
|
||||
fin_resp = await client.post(
|
||||
f"{prefill_url}/_snapshot/finalize_ingest",
|
||||
json={
|
||||
"session_id": request.session_id,
|
||||
"token_ids": tokens[:n],
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
fin_resp.raise_for_status()
|
||||
fin = fin_resp.json()
|
||||
except Exception as exc:
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "failed", "stage": "finalize", "sid": sid, "rid": rid,
|
||||
"error": repr(exc)[:200],
|
||||
"bytes_pushed": int(dump.get("bytes_pushed", 0))},
|
||||
)
|
||||
return {"status": "finalize-failed", "error": repr(exc),
|
||||
"bytes_pushed": dump.get("bytes_pushed", 0)}
|
||||
t_fin1 = time.perf_counter()
|
||||
if not fin.get("ok"):
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "skipped", "stage": "finalize", "sid": sid, "rid": rid,
|
||||
"reason": fin.get("reason"),
|
||||
"bytes_pushed": int(dump.get("bytes_pushed", 0))},
|
||||
)
|
||||
return {"status": "finalize-not-ok", "reason": fin.get("reason"),
|
||||
"bytes_pushed": dump.get("bytes_pushed", 0)}
|
||||
await _structural_emit(
|
||||
"d-to-p-sync.jsonl",
|
||||
{"event": "ok", "sid": sid, "rid": rid,
|
||||
"bytes_pushed": int(dump.get("bytes_pushed", 0)),
|
||||
"kv_committed_len": int(dump.get("kv_committed_len", 0)),
|
||||
"inserted_prefix_len": int(fin.get("inserted_prefix_len", 0)),
|
||||
"prepare_dur_ms": round((t_prep1 - t_prep0) * 1000, 2),
|
||||
"dump_dur_ms": round((t_dump1 - t_dump0) * 1000, 2),
|
||||
"finalize_dur_ms": round((t_fin1 - t_fin0) * 1000, 2),
|
||||
"snapshot_session_id": prep.get("snapshot_session_id")},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"bytes_pushed": int(dump.get("bytes_pushed", 0)),
|
||||
"inserted_prefix_len": int(fin.get("inserted_prefix_len", 0)),
|
||||
"snapshot_session_id": prep.get("snapshot_session_id"),
|
||||
}
|
||||
|
||||
|
||||
async def _invoke_kvcache_seeded_router(
|
||||
*,
|
||||
client: httpx.AsyncClient,
|
||||
@@ -2155,6 +2342,22 @@ async def _invoke_kvcache_seeded_router(
|
||||
decode_session.prefill_server_url = prefill_url
|
||||
prefill_session_newly_opened = True
|
||||
|
||||
# D→P snapshot push (Phase 3) — best-effort; on any failure we silently
|
||||
# fall back to the existing re-prefill path. The result is logged for
|
||||
# post-hoc analysis but does not affect correctness.
|
||||
if config.enable_d_to_p_sync:
|
||||
sync_result = await _attempt_d_to_p_sync(
|
||||
client=client,
|
||||
request=request,
|
||||
config=config,
|
||||
prefill_url=prefill_url,
|
||||
decode_session=decode_session,
|
||||
)
|
||||
# NB: every outcome of _attempt_d_to_p_sync is already captured in
|
||||
# structural/d-to-p-sync.jsonl via _structural_emit. No need for an
|
||||
# additional logger.info here (and `logger` isn't imported at module
|
||||
# scope, so it would NameError if reached).
|
||||
|
||||
decode_session_newly_opened = False
|
||||
try:
|
||||
prefill_priority = _prefill_priority_for_router_request(
|
||||
|
||||
266
src/agentic_pd_hybrid/snapshot_link.py
Normal file
266
src/agentic_pd_hybrid/snapshot_link.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Minimal D→P snapshot link over Mooncake RDMA.
|
||||
|
||||
This module provides a thin wrapper around mooncake.engine.TransferEngine
|
||||
for one-sided RDMA writes of KV bytes from a Decode worker (sender) to a
|
||||
Prefill worker (receiver). It deliberately does NOT use the heavyweight
|
||||
MooncakeKVManager pipeline (which is tied to PREFILL/DECODE roles and
|
||||
chunked transfer protocols): we want a simple, testable byte transport
|
||||
that can be reused by SGLang and by stand-alone smoke tests.
|
||||
|
||||
Layout:
|
||||
SnapshotPeer — engine + pre-registered receive buffer (receiver)
|
||||
or sender handle (sender)
|
||||
SnapshotEndpoint — what the receiver advertises so the sender can
|
||||
target it: (session_id, base_ptr, length)
|
||||
SnapshotPusher — sender-side: holds a target endpoint, calls
|
||||
batch_transfer_sync_write
|
||||
|
||||
All transfers are SYNCHRONOUS, single-shot, in-memory.
|
||||
|
||||
Higher layers add: control plane (how D learns P's endpoint), per-session
|
||||
slot allocation, KV format/layout, hand-off into SGLang scheduler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SnapshotEndpoint:
|
||||
"""What the receiver advertises so the sender can reach it.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
session_id : str
|
||||
``"host:rpc_port"`` string identifying the receiver's mooncake
|
||||
TransferEngine. Returned by ``TransferEngine.get_rpc_port()``
|
||||
joined with the host the engine was initialized with.
|
||||
base_ptr : int
|
||||
Address of the registered receive buffer on the receiver side.
|
||||
capacity_bytes : int
|
||||
Length of the registered region.
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
base_ptr: int
|
||||
capacity_bytes: int
|
||||
|
||||
|
||||
def _import_transfer_engine():
|
||||
try:
|
||||
from mooncake.engine import TransferEngine
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise ImportError(
|
||||
"mooncake.engine.TransferEngine is required for snapshot_link. "
|
||||
"Make sure mooncake-transfer-engine is installed in the venv."
|
||||
) from e
|
||||
return TransferEngine
|
||||
|
||||
|
||||
class SnapshotPeer:
|
||||
"""One Mooncake transfer engine endpoint with a registered receive buffer.
|
||||
|
||||
The engine is dedicated to snapshot traffic — it does NOT share state
|
||||
with SGLang's MooncakeKVManager engine. Each SnapshotPeer needs its own
|
||||
host:port to listen on.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
ib_device: Optional[str] = None,
|
||||
receive_capacity_bytes: int = 0,
|
||||
protocol: Optional[str] = None,
|
||||
):
|
||||
TransferEngine = _import_transfer_engine()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.ib_device = ib_device
|
||||
self.engine = TransferEngine()
|
||||
|
||||
listen = f"{host}:{port}"
|
||||
proto = protocol or os.environ.get("MOONCAKE_PROTOCOL", "rdma")
|
||||
ret = self.engine.initialize(
|
||||
listen,
|
||||
"P2PHANDSHAKE",
|
||||
proto,
|
||||
ib_device or "",
|
||||
)
|
||||
if ret != 0:
|
||||
raise RuntimeError(
|
||||
f"snapshot_link: engine.initialize({listen!r}, proto={proto}, "
|
||||
f"ib={ib_device}) returned {ret}"
|
||||
)
|
||||
|
||||
self._rpc_port = self.engine.get_rpc_port()
|
||||
self._session_id = f"{host}:{self._rpc_port}"
|
||||
|
||||
self._recv_buffer = None
|
||||
self._recv_ptr = 0
|
||||
self._recv_capacity = 0
|
||||
if receive_capacity_bytes > 0:
|
||||
self._allocate_recv_buffer(receive_capacity_bytes)
|
||||
|
||||
self._lock = threading.Lock()
|
||||
logger.info(
|
||||
"SnapshotPeer up at %s (rpc=%d, ib=%s, recv=%d B)",
|
||||
self._session_id,
|
||||
self._rpc_port,
|
||||
ib_device,
|
||||
receive_capacity_bytes,
|
||||
)
|
||||
|
||||
# -- accessors ---------------------------------------------------------
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def rpc_port(self) -> int:
|
||||
return self._rpc_port
|
||||
|
||||
@property
|
||||
def endpoint(self) -> SnapshotEndpoint:
|
||||
if self._recv_buffer is None:
|
||||
raise RuntimeError(
|
||||
"SnapshotPeer has no receive buffer; pass receive_capacity_bytes > 0"
|
||||
)
|
||||
return SnapshotEndpoint(
|
||||
session_id=self._session_id,
|
||||
base_ptr=self._recv_ptr,
|
||||
capacity_bytes=self._recv_capacity,
|
||||
)
|
||||
|
||||
# -- buffer management -------------------------------------------------
|
||||
|
||||
def _allocate_recv_buffer(self, length: int) -> None:
|
||||
"""Allocate + register a pinned host buffer for receiving."""
|
||||
# Use c_ubyte (unsigned) so bytes() conversions of the underlying
|
||||
# storage always yield valid byte values.
|
||||
buf = (ctypes.c_ubyte * length)()
|
||||
addr = ctypes.addressof(buf)
|
||||
ret = self.engine.register_memory(addr, length)
|
||||
if ret != 0:
|
||||
raise RuntimeError(
|
||||
f"snapshot_link: register_memory({hex(addr)}, {length}) returned {ret}"
|
||||
)
|
||||
self._recv_buffer = buf
|
||||
self._recv_ptr = addr
|
||||
self._recv_capacity = length
|
||||
|
||||
def read_bytes(self, offset: int, length: int) -> bytes:
|
||||
"""Snapshot the recv buffer at [offset, offset+length) (caller syncs)."""
|
||||
if self._recv_buffer is None:
|
||||
raise RuntimeError("no recv buffer")
|
||||
if offset < 0 or offset + length > self._recv_capacity:
|
||||
raise ValueError(
|
||||
f"read_bytes({offset}, {length}) out of capacity {self._recv_capacity}"
|
||||
)
|
||||
# string_at copies via memcpy and yields a proper bytes object — works
|
||||
# regardless of signed/unsigned underlying storage.
|
||||
return ctypes.string_at(self._recv_ptr + offset, length)
|
||||
|
||||
def register_send_buffer(self, ptr: int, length: int) -> None:
|
||||
"""Register an externally-allocated send buffer for outbound RDMA writes."""
|
||||
with self._lock:
|
||||
ret = self.engine.register_memory(ptr, length)
|
||||
if ret != 0:
|
||||
raise RuntimeError(
|
||||
f"snapshot_link: register send buffer({hex(ptr)}, {length}) returned {ret}"
|
||||
)
|
||||
|
||||
def deregister(self, ptr: int) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self.engine.unregister_memory(ptr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- transfer ----------------------------------------------------------
|
||||
|
||||
def push(
|
||||
self,
|
||||
target: SnapshotEndpoint,
|
||||
local_ptr: int,
|
||||
local_offset: int,
|
||||
length: int,
|
||||
remote_offset: int = 0,
|
||||
) -> int:
|
||||
"""Synchronously RDMA-write ``length`` bytes from ``local_ptr+local_offset``
|
||||
to ``target.base_ptr+remote_offset`` on the peer identified by
|
||||
``target.session_id``.
|
||||
|
||||
Returns 0 on success, non-zero (or raises) on failure.
|
||||
"""
|
||||
if length <= 0:
|
||||
return 0
|
||||
if remote_offset < 0 or remote_offset + length > target.capacity_bytes:
|
||||
raise ValueError(
|
||||
f"push: remote_offset={remote_offset}, length={length} exceeds "
|
||||
f"target capacity {target.capacity_bytes}"
|
||||
)
|
||||
src = local_ptr + local_offset
|
||||
dst = target.base_ptr + remote_offset
|
||||
try:
|
||||
ret = self.engine.transfer_sync_write(
|
||||
target.session_id, src, dst, length
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("snapshot_link.push transfer_sync_write threw: %s", e)
|
||||
return -1
|
||||
if ret != 0:
|
||||
logger.warning(
|
||||
"snapshot_link.push transfer_sync_write returned %d (src=%s, "
|
||||
"dst=%s/%s, len=%d)",
|
||||
ret,
|
||||
hex(src),
|
||||
target.session_id,
|
||||
hex(dst),
|
||||
length,
|
||||
)
|
||||
return ret
|
||||
|
||||
def batch_push(
|
||||
self,
|
||||
target: SnapshotEndpoint,
|
||||
local_addrs: list[int],
|
||||
remote_addrs: list[int],
|
||||
lengths: list[int],
|
||||
) -> int:
|
||||
"""Batched RDMA write (one-shot)."""
|
||||
if not local_addrs:
|
||||
return 0
|
||||
try:
|
||||
ret = self.engine.batch_transfer_sync_write(
|
||||
target.session_id, local_addrs, remote_addrs, lengths
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("snapshot_link.batch_push threw: %s", e)
|
||||
return -1
|
||||
return ret
|
||||
|
||||
def close(self) -> None:
|
||||
"""Best-effort shutdown — release the receive buffer registration."""
|
||||
if self._recv_ptr:
|
||||
try:
|
||||
self.engine.unregister_memory(self._recv_ptr)
|
||||
except Exception:
|
||||
pass
|
||||
self._recv_ptr = 0
|
||||
self._recv_capacity = 0
|
||||
self._recv_buffer = None
|
||||
|
||||
|
||||
def make_session_id(host: str, rpc_port: int) -> str:
|
||||
"""Build the ``host:port`` form used as mooncake's session id."""
|
||||
return f"{host}:{rpc_port}"
|
||||
@@ -209,6 +209,15 @@ def _build_process_env(topology: SingleNodeTopology) -> dict[str, str]:
|
||||
if topology.transfer_backend == "mooncake":
|
||||
env.setdefault("MC_TRANSFER_TIMEOUT", "1800")
|
||||
|
||||
# D→P snapshot link (Phase 2). Each worker reads its own
|
||||
# `disaggregation_bootstrap_port` and binds at `bootstrap_port + 1000`
|
||||
# for the snapshot mooncake engine (see
|
||||
# third_party/sglang/.../disaggregation/snapshot/controller.py).
|
||||
if topology.enable_d_to_p_sync:
|
||||
env["SGLANG_SNAPSHOT_LINK_ENABLE"] = "1"
|
||||
if topology.ib_device:
|
||||
env.setdefault("SGLANG_SNAPSHOT_LINK_IB_DEVICE", topology.ib_device)
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
python_paths = [
|
||||
str(repo_root / "src"),
|
||||
|
||||
@@ -46,6 +46,7 @@ class SingleNodeTopology:
|
||||
trust_remote_code: bool
|
||||
force_rdma: bool = False
|
||||
ib_device: str | None = None
|
||||
enable_d_to_p_sync: bool = False
|
||||
extra_server_args: tuple[str, ...] = ()
|
||||
prefill_extra_server_args: tuple[str, ...] = ()
|
||||
decode_extra_server_args: tuple[str, ...] = ()
|
||||
@@ -95,6 +96,7 @@ def build_single_node_topology(
|
||||
force_rdma: bool = False,
|
||||
trust_remote_code: bool = True,
|
||||
ib_device: str | None = None,
|
||||
enable_d_to_p_sync: bool = False,
|
||||
extra_server_args: tuple[str, ...] = (),
|
||||
prefill_extra_server_args: tuple[str, ...] = (),
|
||||
decode_extra_server_args: tuple[str, ...] = (),
|
||||
@@ -238,6 +240,7 @@ def build_single_node_topology(
|
||||
trust_remote_code=trust_remote_code,
|
||||
force_rdma=force_rdma,
|
||||
ib_device=ib_device,
|
||||
enable_d_to_p_sync=enable_d_to_p_sync,
|
||||
extra_server_args=extra_server_args,
|
||||
prefill_extra_server_args=prefill_extra_server_args,
|
||||
decode_extra_server_args=decode_extra_server_args,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Tests
|
||||
|
||||
Pure-Python unit + property tests for the algorithm layer. These tests do
|
||||
**not** import SGLang and do **not** need a GPU — they validate the routing
|
||||
algorithm (Algorithm 1/2/3 in `docs/KVC_ROUTER_ALGORITHM.md`) and its
|
||||
theorems against the pure functions extracted from `policies.py`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
uv sync --group test
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Or, without uv:
|
||||
|
||||
```bash
|
||||
pip install pytest
|
||||
PYTHONPATH=src pytest tests
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
- `test_policy_scoring.py` — Algorithm 1 lex-score properties (overlap
|
||||
dominates sticky, load-floor gating, tie-breakers).
|
||||
- `test_no_starvation.py` — Theorem 1: bounded retries before some D either
|
||||
accepts or the least-rejected D is forced through the degenerate path.
|
||||
|
||||
Future:
|
||||
- block-level eviction `MockRadixCache` tests (see
|
||||
`docs/BLOCK_LEVEL_EVICTION_DESIGN_ZH.md` §5).
|
||||
- D→P sync `staleness_budget` property tests (see
|
||||
`docs/D_TO_P_SYNC_CONTRACT_ZH.md` §1).
|
||||
|
||||
## Why no integration tests here
|
||||
|
||||
Anything that needs SGLang, mooncake, or a real model is an integration
|
||||
test and must run on hardware. Those tests live as `scripts/sweep_*.sh`
|
||||
under the evaluation protocol in `docs/EVALUATION_PROTOCOL_ZH.md`.
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Lightweight fixtures for algorithm-layer tests.
|
||||
|
||||
Builds minimal TraceRequest / SingleNodeTopology / RoutingState instances
|
||||
without invoking build_single_node_topology() (which validates GPU budgets
|
||||
we don't care about in unit tests).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agentic_pd_hybrid.topology import SingleNodeTopology, WorkerSpec
|
||||
from agentic_pd_hybrid.trace import TraceRequest
|
||||
|
||||
|
||||
def make_topology(decode_count: int = 3, prefill_count: int = 1) -> SingleNodeTopology:
|
||||
prefill_workers = tuple(
|
||||
WorkerSpec(
|
||||
role="prefill",
|
||||
ordinal=i,
|
||||
gpu_ids=(i,),
|
||||
host="127.0.0.1",
|
||||
port=30000 + i,
|
||||
)
|
||||
for i in range(prefill_count)
|
||||
)
|
||||
decode_workers = tuple(
|
||||
WorkerSpec(
|
||||
role="decode",
|
||||
ordinal=i,
|
||||
gpu_ids=(prefill_count + i,),
|
||||
host="127.0.0.1",
|
||||
port=31000 + i,
|
||||
)
|
||||
for i in range(decode_count)
|
||||
)
|
||||
return SingleNodeTopology(
|
||||
model_path="/dev/null/test-model",
|
||||
prefill_workers=prefill_workers,
|
||||
decode_workers=decode_workers,
|
||||
direct_workers=(),
|
||||
router_host="127.0.0.1",
|
||||
router_port=8000,
|
||||
transfer_backend="mooncake",
|
||||
trust_remote_code=True,
|
||||
)
|
||||
|
||||
|
||||
def make_request(
|
||||
*,
|
||||
session_id: str = "sess-1",
|
||||
turn_id: int = 0,
|
||||
hash_ids: tuple[int, ...] = (),
|
||||
input_length: int = 1024,
|
||||
output_length: int = 64,
|
||||
) -> TraceRequest:
|
||||
return TraceRequest(
|
||||
request_id=f"{session_id}-t{turn_id}",
|
||||
session_id=session_id,
|
||||
chat_id=int(turn_id),
|
||||
parent_chat_id=-1 if turn_id == 0 else int(turn_id - 1),
|
||||
timestamp_s=float(turn_id),
|
||||
input_length=input_length,
|
||||
output_length=output_length,
|
||||
request_type="user",
|
||||
turn_id=turn_id,
|
||||
hash_ids=hash_ids,
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Theorem 1 — no permanent starvation under bounded retries.
|
||||
|
||||
Reference: docs/KVC_ROUTER_ALGORITHM.md §4.1.
|
||||
|
||||
For any session s with τ_reject ≥ 1, after at most |D| · τ_reject
|
||||
consecutive admission rejects on s, the routing policy MUST still
|
||||
return a valid decision (via the degenerate "least-rejected D"
|
||||
fallback). The session cannot be permanently starved at the policy
|
||||
layer.
|
||||
|
||||
We can't exercise the full Dispatch loop here (it lives in replay.py and
|
||||
needs HTTP, mooncake, etc.). What we CAN test is the policy-layer
|
||||
guarantee: after K = |D| · τ_reject reject bumps, select() never raises
|
||||
and never returns a worker that's both blacklisted *and* has positive
|
||||
overlap (the degenerate path chooses by least-rejected).
|
||||
|
||||
This is the property-layer companion to test_policy_scoring.py's
|
||||
quantitative checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agentic_pd_hybrid.policies import KvAwarePolicy, RoutingState
|
||||
|
||||
from ._fixtures import make_request, make_topology
|
||||
|
||||
|
||||
def test_select_returns_valid_decision_under_full_blacklist():
|
||||
"""Bump all (s, d) reject counters past τ_reject. select() must still
|
||||
pick a worker (degenerate fallback, no exception, no None)."""
|
||||
topology = make_topology(decode_count=3)
|
||||
state = RoutingState.create(topology)
|
||||
request = make_request(session_id="s-stuck", turn_id=0)
|
||||
policy = KvAwarePolicy(migration_reject_threshold=3)
|
||||
|
||||
# Pre-fill the blacklist for every D.
|
||||
for worker in topology.route_workers:
|
||||
for _ in range(3):
|
||||
state.record_admission_reject(request.session_id, worker.worker_id)
|
||||
|
||||
decision = policy.select(request=request, topology=topology, state=state)
|
||||
assert decision.decode_worker_id is not None
|
||||
assert decision.decode_worker_id in {w.worker_id for w in topology.route_workers}
|
||||
|
||||
|
||||
def test_bounded_retries_to_force_degenerate_path():
|
||||
"""Theorem 1: at most |D| · τ_reject rejects suffice to either exhaust
|
||||
every D or to force the degenerate fallback. Simulate the worst case
|
||||
where each retry picks a fresh D and is immediately rejected."""
|
||||
topology = make_topology(decode_count=4)
|
||||
state = RoutingState.create(topology)
|
||||
request = make_request(session_id="s-worst", turn_id=0)
|
||||
threshold = 3
|
||||
policy = KvAwarePolicy(migration_reject_threshold=threshold)
|
||||
|
||||
seen_decoders: set[str] = set()
|
||||
max_retries = len(topology.route_workers) * threshold
|
||||
|
||||
for retry in range(max_retries):
|
||||
decision = policy.select(request=request, topology=topology, state=state)
|
||||
seen_decoders.add(decision.decode_worker_id)
|
||||
# Adversary: this D rejects this session.
|
||||
state.record_admission_reject(request.session_id, decision.decode_worker_id)
|
||||
|
||||
# After |D|·τ_reject rejects every D must be blacklisted, so the next
|
||||
# select() takes the degenerate "least-rejected" branch and STILL
|
||||
# returns a valid worker.
|
||||
final = policy.select(request=request, topology=topology, state=state)
|
||||
assert final.decode_worker_id in {w.worker_id for w in topology.route_workers}
|
||||
# And we should have explored every D over the bounded retries — the
|
||||
# algorithm cannot trap a session on a single D when all are rejecting.
|
||||
assert seen_decoders == {w.worker_id for w in topology.route_workers}
|
||||
|
||||
|
||||
def test_least_rejected_d_chosen_when_all_blacklisted():
|
||||
"""When every D is past threshold, the degenerate fallback chooses the
|
||||
one with the *fewest* rejects (Algorithm 1, line 4)."""
|
||||
topology = make_topology(decode_count=3)
|
||||
state = RoutingState.create(topology)
|
||||
request = make_request(session_id="s-lr", turn_id=0)
|
||||
policy = KvAwarePolicy(migration_reject_threshold=3)
|
||||
|
||||
# Skew rejections: decode-0 has 5, decode-1 has 10, decode-2 has 3.
|
||||
# All are >= threshold=3, so the filter wipes out every candidate.
|
||||
# The fallback should pick decode-2 (smallest rejection count).
|
||||
workers = list(topology.route_workers)
|
||||
bumps = {workers[0].worker_id: 5, workers[1].worker_id: 10, workers[2].worker_id: 3}
|
||||
for wid, n in bumps.items():
|
||||
for _ in range(n):
|
||||
state.record_admission_reject(request.session_id, wid)
|
||||
|
||||
decision = policy.select(request=request, topology=topology, state=state)
|
||||
assert decision.decode_worker_id == workers[2].worker_id
|
||||
|
||||
|
||||
def test_other_session_unaffected_by_blacklist():
|
||||
"""Algorithm 1's filter is per-(session, D), not per-D. Session A's
|
||||
rejects must not influence session B's routing."""
|
||||
topology = make_topology(decode_count=2)
|
||||
state = RoutingState.create(topology)
|
||||
policy = KvAwarePolicy(migration_reject_threshold=3)
|
||||
|
||||
# Blacklist decode-0 for session A.
|
||||
workers = list(topology.route_workers)
|
||||
for _ in range(3):
|
||||
state.record_admission_reject("session-A", workers[0].worker_id)
|
||||
|
||||
# Session B sees a clean slate — should be able to pick decode-0
|
||||
# (which is the iteration-order winner under empty state).
|
||||
decision_b = policy.select(
|
||||
request=make_request(session_id="session-B"),
|
||||
topology=topology,
|
||||
state=state,
|
||||
)
|
||||
# decode-0 wins iteration-order tiebreak when all scores are (0,0,0,0).
|
||||
assert decision_b.decode_worker_id == workers[0].worker_id
|
||||
|
||||
|
||||
def test_threshold_zero_disables_blacklist():
|
||||
"""migration_reject_threshold=0 means the migration mechanism is off:
|
||||
every D stays a candidate regardless of its reject count."""
|
||||
topology = make_topology(decode_count=2)
|
||||
state = RoutingState.create(topology)
|
||||
request = make_request(session_id="s-no-mig")
|
||||
policy = KvAwarePolicy(migration_reject_threshold=0)
|
||||
|
||||
workers = list(topology.route_workers)
|
||||
# Pile a huge number of rejects on decode-0.
|
||||
for _ in range(100):
|
||||
state.record_admission_reject(request.session_id, workers[0].worker_id)
|
||||
|
||||
decision = policy.select(request=request, topology=topology, state=state)
|
||||
# decode-0 should still be eligible; with empty overlap/sticky/inflight,
|
||||
# iteration order picks decode-0 first.
|
||||
assert decision.decode_worker_id == workers[0].worker_id
|
||||
|
||||
|
||||
def test_reject_counter_only_grows_on_record():
|
||||
"""RoutingState.record_admission_reject is the ONLY mutator for the
|
||||
counter. select() must not silently bump it."""
|
||||
topology = make_topology(decode_count=2)
|
||||
state = RoutingState.create(topology)
|
||||
request = make_request(session_id="s-clean")
|
||||
policy = KvAwarePolicy()
|
||||
|
||||
for _ in range(5):
|
||||
policy.select(request=request, topology=topology, state=state)
|
||||
|
||||
# No explicit record_admission_reject -> all counters stay zero.
|
||||
assert sum(state.session_d_rejects.values()) == 0
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Unit tests for Algorithm 1 (KvAwarePolicy score_candidate).
|
||||
|
||||
Reference: docs/KVC_ROUTER_ALGORITHM.md §3.1. The lex-score is
|
||||
|
||||
(overlap + sticky_bonus*sticky + floor_bonus,
|
||||
sticky,
|
||||
-inflight,
|
||||
-assigned)
|
||||
|
||||
These tests pin down the qualitative properties that the algorithm's
|
||||
correctness arguments rely on. They run without SGLang/GPU.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agentic_pd_hybrid.policies import score_candidate
|
||||
|
||||
|
||||
def _score(**overrides):
|
||||
"""Helper: build a score with all defaults and per-test overrides."""
|
||||
args = dict(
|
||||
overlap=0,
|
||||
sticky=False,
|
||||
inflight=0,
|
||||
assigned=0,
|
||||
mean_assigned=0.0,
|
||||
sticky_bonus=1,
|
||||
load_floor_bonus=0,
|
||||
)
|
||||
args.update(overrides)
|
||||
return score_candidate(**args)
|
||||
|
||||
|
||||
# -- Determinism ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_score_is_pure():
|
||||
"""Same kwargs must produce the same tuple (no hidden state)."""
|
||||
a = _score(overlap=3, sticky=True, inflight=1, assigned=7)
|
||||
b = _score(overlap=3, sticky=True, inflight=1, assigned=7)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_score_returns_4_tuple():
|
||||
s = _score()
|
||||
assert isinstance(s, tuple)
|
||||
assert len(s) == 4
|
||||
assert all(isinstance(x, int) for x in s)
|
||||
|
||||
|
||||
# -- Primary term: overlap dominates sticky --------------------------------------
|
||||
|
||||
|
||||
def test_overlap_strictly_dominates_pure_sticky():
|
||||
"""Theorem-2 building block: any positive overlap on a non-sticky D wins
|
||||
against a sticky-only D with zero overlap (sticky_bonus=1)."""
|
||||
overlap = _score(overlap=2, sticky=False)
|
||||
sticky_only = _score(overlap=0, sticky=True)
|
||||
assert overlap > sticky_only
|
||||
|
||||
|
||||
def test_overlap_plus_sticky_beats_overlap_alone():
|
||||
"""Two D's with equal overlap: sticky one wins (sticky_bonus contributes
|
||||
to primary AND wins tie-1)."""
|
||||
sticky_d = _score(overlap=5, sticky=True)
|
||||
fresh_d = _score(overlap=5, sticky=False)
|
||||
assert sticky_d > fresh_d
|
||||
|
||||
|
||||
# -- Tie breakers ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tiebreaker_inflight_lower_wins():
|
||||
"""Equal primary & sticky: prefer the D with fewer in-flight requests."""
|
||||
low = _score(overlap=3, sticky=False, inflight=0, assigned=10)
|
||||
high = _score(overlap=3, sticky=False, inflight=5, assigned=10)
|
||||
assert low > high
|
||||
|
||||
|
||||
def test_tiebreaker_assigned_lower_wins():
|
||||
"""Equal primary & sticky & inflight: prefer rarely-picked D."""
|
||||
rare = _score(overlap=3, sticky=False, inflight=2, assigned=1)
|
||||
frequent = _score(overlap=3, sticky=False, inflight=2, assigned=99)
|
||||
assert rare > frequent
|
||||
|
||||
|
||||
def test_tiebreaker_strict_lex_order():
|
||||
"""Sticky always beats non-sticky on tie-1 even if non-sticky has lower
|
||||
inflight (the lex order is strict, position 1 outranks positions 2/3)."""
|
||||
sticky_busy = _score(overlap=4, sticky=True, inflight=10, assigned=10)
|
||||
fresh_idle = _score(overlap=4, sticky=False, inflight=0, assigned=0)
|
||||
# Note: with sticky_bonus=1 added to position 0, sticky_busy actually wins
|
||||
# on position 0 first (5 > 4). Force equal primary by lowering sticky's
|
||||
# overlap.
|
||||
sticky_busy_eq_primary = _score(overlap=3, sticky=True, inflight=10, assigned=10)
|
||||
fresh_idle_eq_primary = _score(overlap=4, sticky=False, inflight=0, assigned=0)
|
||||
# Now equal primary (3+1=4 vs 4). Sticky wins position 1.
|
||||
assert sticky_busy_eq_primary > fresh_idle_eq_primary
|
||||
|
||||
|
||||
# -- Load-floor bonus ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_floor_disabled_by_default():
|
||||
"""load_floor_bonus=0 → no contribution to primary."""
|
||||
s = _score(overlap=0, sticky=False, mean_assigned=10, assigned=0)
|
||||
assert s[0] == 0
|
||||
|
||||
|
||||
def test_load_floor_gated_off_when_sticky():
|
||||
"""Even with load_floor_bonus>0, sticky D does NOT receive the boost.
|
||||
Otherwise a session would migrate away from its warm D under load."""
|
||||
sticky_under_loaded = _score(
|
||||
overlap=0, sticky=True, mean_assigned=10, assigned=0, load_floor_bonus=200
|
||||
)
|
||||
# primary = overlap(0) + sticky_bonus(1) + floor(0) = 1
|
||||
assert sticky_under_loaded[0] == 1
|
||||
|
||||
|
||||
def test_load_floor_zero_when_mean_zero():
|
||||
"""Warmup case: mean_assigned=0 -> no D gets boost -> degenerate to lex
|
||||
tiebreak by iteration order."""
|
||||
s = _score(
|
||||
overlap=0, sticky=False, mean_assigned=0, assigned=0, load_floor_bonus=200
|
||||
)
|
||||
assert s[0] == 0
|
||||
|
||||
|
||||
def test_load_floor_proportional_to_deficit():
|
||||
"""floor_bonus = K * deficit / mean. assigned=0, mean=10, K=200 -> 200."""
|
||||
s_zero = _score(
|
||||
overlap=0, sticky=False, mean_assigned=10, assigned=0, load_floor_bonus=200
|
||||
)
|
||||
s_half = _score(
|
||||
overlap=0, sticky=False, mean_assigned=10, assigned=5, load_floor_bonus=200
|
||||
)
|
||||
s_full = _score(
|
||||
overlap=0, sticky=False, mean_assigned=10, assigned=10, load_floor_bonus=200
|
||||
)
|
||||
# deficit = max(0, 10-0)=10 -> bonus = int(200*10/10) = 200
|
||||
# deficit = max(0, 10-5)=5 -> bonus = int(200*5/10) = 100
|
||||
# deficit = max(0, 10-10)=0 -> bonus = 0
|
||||
assert s_zero[0] == 200
|
||||
assert s_half[0] == 100
|
||||
assert s_full[0] == 0
|
||||
|
||||
|
||||
def test_load_floor_does_not_underflow_when_overloaded():
|
||||
"""assigned > mean -> deficit clamped to 0, no negative bonus."""
|
||||
s = _score(
|
||||
overlap=0, sticky=False, mean_assigned=10, assigned=50, load_floor_bonus=200
|
||||
)
|
||||
assert s[0] == 0
|
||||
|
||||
|
||||
# -- Routing intent: real overlap beats load-floor bonus -------------------------
|
||||
|
||||
|
||||
def test_real_prefix_overlap_beats_load_floor_on_warm_d():
|
||||
"""E1_E2_FIX_DESIGN_ZH §Q2: load_floor should be set such that
|
||||
real per-session prefix overlap outweighs the cold-D bonus.
|
||||
With overlap=800 (a per-session prefix) and load_floor_bonus=200,
|
||||
a warm D (high overlap, possibly high load) should still win against
|
||||
a cold D with floor bonus."""
|
||||
warm = _score(
|
||||
overlap=800, sticky=True, mean_assigned=10, assigned=10, load_floor_bonus=200
|
||||
)
|
||||
cold = _score(
|
||||
overlap=0, sticky=False, mean_assigned=10, assigned=0, load_floor_bonus=200
|
||||
)
|
||||
# warm primary = 800 + 1 + 0 = 801. cold primary = 0 + 0 + 200 = 200.
|
||||
assert warm[0] == 801
|
||||
assert cold[0] == 200
|
||||
assert warm > cold
|
||||
|
||||
|
||||
def test_boilerplate_overlap_loses_to_load_floor_for_cold_d():
|
||||
"""Same §Q2: load_floor should beat cross-session boilerplate overlap.
|
||||
If load_floor_bonus=200 and the worst-case boilerplate overlap is ~50,
|
||||
a fresh cold D should still win against a slightly-warm-from-boilerplate D."""
|
||||
warm_boilerplate = _score(
|
||||
overlap=50, sticky=False, mean_assigned=10, assigned=10, load_floor_bonus=200
|
||||
)
|
||||
cold_under_loaded = _score(
|
||||
overlap=0, sticky=False, mean_assigned=10, assigned=0, load_floor_bonus=200
|
||||
)
|
||||
# warm_boilerplate primary = 50 + 0 + 0 = 50 (assigned=mean, no deficit).
|
||||
# cold_under_loaded primary = 0 + 0 + 200 = 200.
|
||||
assert cold_under_loaded > warm_boilerplate
|
||||
1
third_party/agentic-kvcache
vendored
Submodule
1
third_party/agentic-kvcache
vendored
Submodule
Submodule third_party/agentic-kvcache added at 44796a1139
27
third_party/sglang/python/sglang/srt/disaggregation/snapshot/__init__.py
vendored
Normal file
27
third_party/sglang/python/sglang/srt/disaggregation/snapshot/__init__.py
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
"""D→P RDMA snapshot push subsystem.
|
||||
|
||||
A minimal, role-symmetric mooncake transport that runs alongside SGLang's
|
||||
existing PD pipeline. Both D and P workers can both send and receive
|
||||
snapshots — direction is determined by which kv_pool we read from /
|
||||
write into.
|
||||
|
||||
See ``docs/D_TO_P_SYNC_DESIGN_ZH.md`` for the full design.
|
||||
"""
|
||||
|
||||
from sglang.srt.disaggregation.snapshot.controller import (
|
||||
SnapshotLinkController,
|
||||
SnapshotIngestRecord,
|
||||
SNAPSHOT_LINK_ENABLE_ENV,
|
||||
SNAPSHOT_LINK_HOST_ENV,
|
||||
SNAPSHOT_LINK_PORT_ENV,
|
||||
SNAPSHOT_LINK_IB_DEVICE_ENV,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SnapshotLinkController",
|
||||
"SnapshotIngestRecord",
|
||||
"SNAPSHOT_LINK_ENABLE_ENV",
|
||||
"SNAPSHOT_LINK_HOST_ENV",
|
||||
"SNAPSHOT_LINK_PORT_ENV",
|
||||
"SNAPSHOT_LINK_IB_DEVICE_ENV",
|
||||
]
|
||||
577
third_party/sglang/python/sglang/srt/disaggregation/snapshot/controller.py
vendored
Normal file
577
third_party/sglang/python/sglang/srt/disaggregation/snapshot/controller.py
vendored
Normal file
@@ -0,0 +1,577 @@
|
||||
"""SnapshotLinkController — D→P RDMA snapshot pushes with dedicated GPU buffer.
|
||||
|
||||
Per `docs/SNAPSHOT_STORE_REFACTOR_ZH.md`, this controller now reserves a
|
||||
dedicated GPU tensor (``snapshot_buf``) for receiving D→P snapshots, instead
|
||||
of competing with the worker's ``token_to_kv_pool_allocator`` at
|
||||
prepare_receive time. The kv_pool alloc is deferred to ``finalize_ingest``
|
||||
when the bytes are already in hand — if that alloc fails we drop the
|
||||
snapshot but RDMA reception itself succeeded.
|
||||
|
||||
Layout of the snapshot_buf for one session reception (chosen for
|
||||
mooncake's batch_transfer_sync_write friendliness — every layer maps to
|
||||
a single contiguous slab):
|
||||
|
||||
[K_layer_0: num_tokens × stride_k_bytes]
|
||||
[K_layer_1: num_tokens × stride_k_bytes]
|
||||
...
|
||||
[K_layer_L-1]
|
||||
[V_layer_0: num_tokens × stride_v_bytes]
|
||||
...
|
||||
[V_layer_L-1]
|
||||
|
||||
The buffer is split into multiple such slabs via ``SnapshotBufAllocator``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Env-var names (also exported from package __init__)
|
||||
SNAPSHOT_LINK_ENABLE_ENV = "SGLANG_SNAPSHOT_LINK_ENABLE"
|
||||
SNAPSHOT_LINK_HOST_ENV = "SGLANG_SNAPSHOT_LINK_HOST"
|
||||
SNAPSHOT_LINK_PORT_ENV = "SGLANG_SNAPSHOT_LINK_PORT"
|
||||
SNAPSHOT_LINK_IB_DEVICE_ENV = "SGLANG_SNAPSHOT_LINK_IB_DEVICE"
|
||||
|
||||
# Default snapshot_buf size: 8 GB. Enough for ~1.5 Qwen3-30B 50k-token sessions.
|
||||
SNAPSHOT_BUF_BYTES_ENV = "SGLANG_SNAPSHOT_LINK_BUF_BYTES"
|
||||
DEFAULT_SNAPSHOT_BUF_BYTES = 8 * 1024 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass
|
||||
class _LayerBufferDesc:
|
||||
"""Per-layer KV buffer descriptor on this worker."""
|
||||
base_ptr: int # data pointer of the layer's full buffer tensor
|
||||
bytes_per_token: int # head_num * head_dim * dtype.itemsize
|
||||
capacity_bytes: int # full buffer size in bytes
|
||||
is_k: bool # True for K-buffer, False for V
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotIngestRecord:
|
||||
"""P-side bookkeeping for one in-flight snapshot reception."""
|
||||
session_id: str
|
||||
slab_offset: int # offset within snapshot_buf
|
||||
slab_size: int # total bytes for this slab
|
||||
num_tokens: int
|
||||
k_layer_offsets: List[int] # absolute byte offsets of K layers in snapshot_buf
|
||||
v_layer_offsets: List[int]
|
||||
per_token_k_bytes: int
|
||||
per_token_v_bytes: int
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class SnapshotBufAllocator:
|
||||
"""First-fit free-list allocator over a single contiguous byte range.
|
||||
|
||||
Tracks gaps in a sorted list. Merges adjacent free regions on free().
|
||||
"""
|
||||
|
||||
def __init__(self, capacity_bytes: int):
|
||||
self.capacity = capacity_bytes
|
||||
# Free regions sorted by offset: [(offset, size), ...]
|
||||
self._free: List[Tuple[int, int]] = [(0, capacity_bytes)]
|
||||
self._lock = threading.Lock()
|
||||
self._inflight: dict[int, int] = {} # offset → size for sanity check
|
||||
|
||||
def alloc(self, size: int) -> Optional[int]:
|
||||
"""Return offset of allocated region, or None if no fit available."""
|
||||
if size <= 0:
|
||||
return None
|
||||
# Page-align allocations to 4 KB for RDMA-friendly alignment.
|
||||
size = (size + 4095) & ~4095
|
||||
with self._lock:
|
||||
for i, (off, sz) in enumerate(self._free):
|
||||
if sz >= size:
|
||||
if sz == size:
|
||||
self._free.pop(i)
|
||||
else:
|
||||
self._free[i] = (off + size, sz - size)
|
||||
self._inflight[off] = size
|
||||
return off
|
||||
return None
|
||||
|
||||
def free(self, offset: int) -> bool:
|
||||
"""Return True if the offset was successfully freed."""
|
||||
with self._lock:
|
||||
size = self._inflight.pop(offset, None)
|
||||
if size is None:
|
||||
return False
|
||||
# Insert sorted and merge adjacents
|
||||
self._free.append((offset, size))
|
||||
self._free.sort()
|
||||
merged: List[Tuple[int, int]] = []
|
||||
for off, sz in self._free:
|
||||
if merged and merged[-1][0] + merged[-1][1] == off:
|
||||
merged[-1] = (merged[-1][0], merged[-1][1] + sz)
|
||||
else:
|
||||
merged.append((off, sz))
|
||||
self._free = merged
|
||||
return True
|
||||
|
||||
def available_bytes(self) -> int:
|
||||
with self._lock:
|
||||
return sum(sz for _, sz in self._free)
|
||||
|
||||
def in_use_bytes(self) -> int:
|
||||
with self._lock:
|
||||
return sum(self._inflight.values())
|
||||
|
||||
|
||||
def _import_transfer_engine():
|
||||
try:
|
||||
from mooncake.engine import TransferEngine
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"mooncake.engine.TransferEngine is required for the snapshot "
|
||||
"link. Install mooncake-transfer-engine in the venv."
|
||||
) from e
|
||||
return TransferEngine
|
||||
|
||||
|
||||
class SnapshotLinkController:
|
||||
"""Owns mooncake engine + kv_pool registrations + snapshot_buf + records.
|
||||
|
||||
D-side use: push session KV via ``push_session_to_snapshot_buf``.
|
||||
P-side use: ``prepare_receive`` → caller pushes via RDMA →
|
||||
``ingest_snapshot_into_kvpool`` (does GPU memcpy +
|
||||
radix insert) → ``finalize_record`` (frees the slab).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
ib_device: Optional[str],
|
||||
kv_pool_layer_buffers: List[Tuple[int, int, int, bool]],
|
||||
token_to_kv_pool_allocator,
|
||||
tree_cache=None,
|
||||
protocol: Optional[str] = None,
|
||||
snapshot_buf_bytes: Optional[int] = None,
|
||||
):
|
||||
TransferEngine = _import_transfer_engine()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.ib_device = ib_device
|
||||
self.token_to_kv_pool_allocator = token_to_kv_pool_allocator
|
||||
self.tree_cache = tree_cache
|
||||
self.layer_buffers: List[_LayerBufferDesc] = [
|
||||
_LayerBufferDesc(
|
||||
base_ptr=base, bytes_per_token=btok,
|
||||
capacity_bytes=cap, is_k=is_k,
|
||||
)
|
||||
for (base, btok, cap, is_k) in kv_pool_layer_buffers
|
||||
]
|
||||
|
||||
self.engine = TransferEngine()
|
||||
proto = protocol or os.environ.get("MOONCAKE_PROTOCOL", "rdma")
|
||||
listen = f"{host}:{port}"
|
||||
ret = self.engine.initialize(listen, "P2PHANDSHAKE", proto, ib_device or "")
|
||||
if ret != 0:
|
||||
raise RuntimeError(
|
||||
f"SnapshotLinkController.initialize({listen}, {proto}, "
|
||||
f"ib={ib_device}) returned {ret}"
|
||||
)
|
||||
self._session_id = f"{host}:{self.engine.get_rpc_port()}"
|
||||
|
||||
# Register existing kv_pool layer buffers (needed for D-side send and
|
||||
# for P-side ingest copy source = snapshot_buf, destination = kv_pool)
|
||||
ptrs = [d.base_ptr for d in self.layer_buffers]
|
||||
lens = [d.capacity_bytes for d in self.layer_buffers]
|
||||
try:
|
||||
reg_ret = self.engine.batch_register_memory(ptrs, lens)
|
||||
except Exception:
|
||||
reg_ret = 0
|
||||
for ptr, length in zip(ptrs, lens):
|
||||
r = self.engine.register_memory(ptr, length)
|
||||
if r != 0:
|
||||
reg_ret = r
|
||||
if reg_ret != 0:
|
||||
logger.warning(
|
||||
"SnapshotLinkController kv_pool batch_register returned %d", reg_ret
|
||||
)
|
||||
|
||||
# Allocate + register the dedicated snapshot reception buffer (P-side)
|
||||
# This decouples reception from kv_pool, avoiding the alloc-failed
|
||||
# death loop that killed E4-v4/v5.
|
||||
import torch
|
||||
|
||||
if snapshot_buf_bytes is None:
|
||||
snapshot_buf_bytes = int(
|
||||
os.environ.get(SNAPSHOT_BUF_BYTES_ENV, DEFAULT_SNAPSHOT_BUF_BYTES)
|
||||
)
|
||||
device = self._allocator_device()
|
||||
try:
|
||||
self.snapshot_buf = torch.zeros(
|
||||
snapshot_buf_bytes, dtype=torch.uint8, device=device,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.warning(
|
||||
"Could not allocate snapshot_buf of %d bytes on %s: %s. "
|
||||
"Falling back to 1 GB.", snapshot_buf_bytes, device, e,
|
||||
)
|
||||
snapshot_buf_bytes = 1024 * 1024 * 1024
|
||||
self.snapshot_buf = torch.zeros(
|
||||
snapshot_buf_bytes, dtype=torch.uint8, device=device,
|
||||
)
|
||||
self._snapshot_buf_bytes = snapshot_buf_bytes
|
||||
self._snapshot_buf_ptr = self.snapshot_buf.data_ptr()
|
||||
ret = self.engine.register_memory(self._snapshot_buf_ptr, snapshot_buf_bytes)
|
||||
if ret != 0:
|
||||
logger.warning(
|
||||
"SnapshotLinkController snapshot_buf register_memory(%s, %d) ret=%d",
|
||||
hex(self._snapshot_buf_ptr), snapshot_buf_bytes, ret,
|
||||
)
|
||||
self.snapshot_buf_alloc = SnapshotBufAllocator(snapshot_buf_bytes)
|
||||
|
||||
# Receive-side bookkeeping
|
||||
self._ingest_records: dict[str, SnapshotIngestRecord] = {}
|
||||
self._records_by_handle: dict[int, SnapshotIngestRecord] = {}
|
||||
self._next_handle = 1
|
||||
self._lock = threading.Lock()
|
||||
|
||||
logger.info(
|
||||
"SnapshotLinkController up at %s (sid=%s, %d kv layer bufs, "
|
||||
"snapshot_buf=%.1f GB on %s)",
|
||||
listen, self._session_id, len(self.layer_buffers),
|
||||
snapshot_buf_bytes / 1e9, device,
|
||||
)
|
||||
|
||||
# ----- accessors ----------------------------------------------------
|
||||
|
||||
@property
|
||||
def snapshot_session_id(self) -> str:
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def snapshot_buf_ptr(self) -> int:
|
||||
return self._snapshot_buf_ptr
|
||||
|
||||
@property
|
||||
def snapshot_buf_bytes(self) -> int:
|
||||
return self._snapshot_buf_bytes
|
||||
|
||||
@property
|
||||
def layer_num(self) -> int:
|
||||
return len(self.layer_buffers) // 2
|
||||
|
||||
def get_k_base_ptrs(self) -> List[int]:
|
||||
return [d.base_ptr for d in self.layer_buffers if d.is_k]
|
||||
|
||||
def get_v_base_ptrs(self) -> List[int]:
|
||||
return [d.base_ptr for d in self.layer_buffers if not d.is_k]
|
||||
|
||||
def get_stride_k_bytes(self) -> int:
|
||||
for d in self.layer_buffers:
|
||||
if d.is_k:
|
||||
return d.bytes_per_token
|
||||
return 0
|
||||
|
||||
def get_stride_v_bytes(self) -> int:
|
||||
for d in self.layer_buffers:
|
||||
if not d.is_k:
|
||||
return d.bytes_per_token
|
||||
return 0
|
||||
|
||||
def _allocator_device(self):
|
||||
# Best-effort: pull device from one of the buffer tensors via the allocator
|
||||
try:
|
||||
return self.token_to_kv_pool_allocator.device
|
||||
except AttributeError:
|
||||
return "cuda"
|
||||
|
||||
# ----- P-side: prepare to receive ----------------------------------
|
||||
|
||||
def prepare_receive(self, session_id: str, num_tokens: int) -> Optional[SnapshotIngestRecord]:
|
||||
"""Carve a slab out of snapshot_buf large enough for num_tokens of K+V.
|
||||
|
||||
Returns the record describing the slab layout, or None if snapshot_buf
|
||||
is full. This does NOT touch kv_pool — alloc happens at ingest time.
|
||||
"""
|
||||
if num_tokens <= 0:
|
||||
return None
|
||||
stride_k = self.get_stride_k_bytes()
|
||||
stride_v = self.get_stride_v_bytes()
|
||||
L = self.layer_num
|
||||
slab_bytes = L * num_tokens * stride_k + L * num_tokens * stride_v
|
||||
offset = self.snapshot_buf_alloc.alloc(slab_bytes)
|
||||
if offset is None:
|
||||
logger.info(
|
||||
"prepare_receive: snapshot_buf full (sid=%s n=%d need=%d B available=%d B)",
|
||||
session_id, num_tokens, slab_bytes,
|
||||
self.snapshot_buf_alloc.available_bytes(),
|
||||
)
|
||||
return None
|
||||
# Layout: K0..KL-1, then V0..VL-1
|
||||
k_offs = [offset + i * num_tokens * stride_k for i in range(L)]
|
||||
v_offs = [offset + L * num_tokens * stride_k + i * num_tokens * stride_v
|
||||
for i in range(L)]
|
||||
record = SnapshotIngestRecord(
|
||||
session_id=session_id,
|
||||
slab_offset=offset,
|
||||
slab_size=slab_bytes,
|
||||
num_tokens=num_tokens,
|
||||
k_layer_offsets=k_offs,
|
||||
v_layer_offsets=v_offs,
|
||||
per_token_k_bytes=stride_k,
|
||||
per_token_v_bytes=stride_v,
|
||||
)
|
||||
with self._lock:
|
||||
# Evict prior record for the same session (best-effort)
|
||||
old = self._ingest_records.pop(session_id, None)
|
||||
if old is not None:
|
||||
self.snapshot_buf_alloc.free(old.slab_offset)
|
||||
self._records_by_handle.pop(id(old), None)
|
||||
self._ingest_records[session_id] = record
|
||||
self._records_by_handle[id(record)] = record
|
||||
return record
|
||||
|
||||
def lookup_by_handle(self, handle: int) -> Optional[SnapshotIngestRecord]:
|
||||
with self._lock:
|
||||
return self._records_by_handle.get(handle)
|
||||
|
||||
def discard_record(self, session_id: str) -> None:
|
||||
with self._lock:
|
||||
rec = self._ingest_records.pop(session_id, None)
|
||||
if rec is not None:
|
||||
self.snapshot_buf_alloc.free(rec.slab_offset)
|
||||
with self._lock:
|
||||
self._records_by_handle.pop(id(rec), None)
|
||||
|
||||
def total_pending_snapshot_bytes(self) -> int:
|
||||
with self._lock:
|
||||
return sum(rec.slab_size for rec in self._ingest_records.values())
|
||||
|
||||
# ----- P-side: ingest snapshot into kv_pool + radix tree -----------
|
||||
|
||||
def ingest_snapshot_into_kvpool(
|
||||
self,
|
||||
session_id: str,
|
||||
token_ids: List[int],
|
||||
) -> Tuple[bool, str, int]:
|
||||
"""Copy snapshot_buf bytes into kv_pool slots and insert into radix.
|
||||
|
||||
Returns (ok, reason, inserted_prefix_len).
|
||||
"""
|
||||
with self._lock:
|
||||
record = self._ingest_records.pop(session_id, None)
|
||||
if record is not None:
|
||||
self._records_by_handle.pop(id(record), None)
|
||||
if record is None:
|
||||
return False, "no-pending-ingest", 0
|
||||
|
||||
try:
|
||||
n = min(len(token_ids), record.num_tokens)
|
||||
if n == 0:
|
||||
self.snapshot_buf_alloc.free(record.slab_offset)
|
||||
return False, "empty-token-ids", 0
|
||||
|
||||
# Alloc kv_pool slots NOW that the snapshot bytes are in hand.
|
||||
try:
|
||||
indices_tensor = self.token_to_kv_pool_allocator.alloc(n)
|
||||
except Exception as exc:
|
||||
self.snapshot_buf_alloc.free(record.slab_offset)
|
||||
return False, f"kvpool-alloc-threw:{exc!r}", 0
|
||||
if indices_tensor is None:
|
||||
self.snapshot_buf_alloc.free(record.slab_offset)
|
||||
return False, "kvpool-alloc-failed-at-ingest", 0
|
||||
|
||||
# GPU→GPU copy from snapshot_buf into kv_pool layer buffers
|
||||
try:
|
||||
self._copy_snapshot_to_kvpool(record, indices_tensor)
|
||||
except Exception as exc:
|
||||
logger.exception("snapshot→kvpool copy failed: %s", exc)
|
||||
# Free both allocations
|
||||
self._free_slot_indices(indices_tensor)
|
||||
self.snapshot_buf_alloc.free(record.slab_offset)
|
||||
return False, f"copy-failed:{exc!r}", 0
|
||||
|
||||
# Insert into radix tree
|
||||
try:
|
||||
inserted_prefix_len = self._radix_insert(token_ids[:n], indices_tensor)
|
||||
except Exception as exc:
|
||||
logger.exception("radix insert failed: %s", exc)
|
||||
self._free_slot_indices(indices_tensor)
|
||||
self.snapshot_buf_alloc.free(record.slab_offset)
|
||||
return False, f"radix-insert-failed:{exc!r}", 0
|
||||
|
||||
# Snapshot is now persisted into kv_pool + radix; the slab is no
|
||||
# longer needed.
|
||||
self.snapshot_buf_alloc.free(record.slab_offset)
|
||||
return True, "ok", int(inserted_prefix_len)
|
||||
except Exception as exc:
|
||||
# Belt-and-braces cleanup
|
||||
try:
|
||||
self.snapshot_buf_alloc.free(record.slab_offset)
|
||||
except Exception:
|
||||
pass
|
||||
return False, f"unexpected:{exc!r}", 0
|
||||
|
||||
def _copy_snapshot_to_kvpool(
|
||||
self,
|
||||
record: SnapshotIngestRecord,
|
||||
slot_indices_tensor,
|
||||
) -> None:
|
||||
"""For each layer L: copy snapshot_buf[K_off[L]..] → k_buffer[L][slots]."""
|
||||
import torch
|
||||
|
||||
n = record.num_tokens
|
||||
stride_k = record.per_token_k_bytes
|
||||
stride_v = record.per_token_v_bytes
|
||||
# View snapshot_buf as a 1-D byte tensor; slice by offsets.
|
||||
for L in range(self.layer_num):
|
||||
# K
|
||||
k_slab_start = record.k_layer_offsets[L] - record.slab_offset + record.slab_offset
|
||||
# NOTE: above is equivalent to record.k_layer_offsets[L] but kept for clarity
|
||||
k_slab_start = record.k_layer_offsets[L]
|
||||
k_layer_bytes = self.snapshot_buf[
|
||||
k_slab_start : k_slab_start + n * stride_k
|
||||
].view(n, stride_k)
|
||||
# Compute destination tensor on kv_pool: dst[slot_indices] = src
|
||||
# We need access to the actual k_buffer[L] tensor. The controller
|
||||
# only has the raw ptr — so we materialize a view via from_blob-ish
|
||||
# trick. Easier: get the tensor from token_to_kv_pool_allocator's kvcache.
|
||||
kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
|
||||
k_buf = kv_cache.k_buffer[L] # (max_tokens, head, dim)
|
||||
# Flatten per-token to bytes
|
||||
flat = k_buf.view(k_buf.shape[0], -1)
|
||||
assert flat.shape[1] * flat.element_size() >= stride_k, (
|
||||
f"K layer {L} stride mismatch: pool {flat.shape[1] * flat.element_size()} vs snapshot {stride_k}"
|
||||
)
|
||||
# Copy: dst[slot_indices] ← src[:n]
|
||||
src_reshape = k_layer_bytes.view(n, flat.shape[1] * flat.element_size())
|
||||
# Byte-level view of destination rows
|
||||
dst_view = flat.view(torch.uint8)
|
||||
dst_view[slot_indices_tensor] = src_reshape
|
||||
|
||||
# V
|
||||
v_slab_start = record.v_layer_offsets[L]
|
||||
v_layer_bytes = self.snapshot_buf[
|
||||
v_slab_start : v_slab_start + n * stride_v
|
||||
]
|
||||
v_buf = kv_cache.v_buffer[L]
|
||||
v_flat = v_buf.view(v_buf.shape[0], -1)
|
||||
src_v = v_layer_bytes.view(n, v_flat.shape[1] * v_flat.element_size())
|
||||
v_dst_view = v_flat.view(torch.uint8)
|
||||
v_dst_view[slot_indices_tensor] = src_v
|
||||
|
||||
def _radix_insert(self, token_ids: List[int], indices_tensor) -> int:
|
||||
"""Insert (token_ids, kv_indices) into the underlying radix tree."""
|
||||
from sglang.srt.mem_cache.base_prefix_cache import InsertParams
|
||||
from sglang.srt.mem_cache.radix_cache import RadixKey
|
||||
from sglang.srt.mem_cache.session_aware_cache import SessionAwareCache
|
||||
|
||||
inner = self.tree_cache
|
||||
if isinstance(inner, SessionAwareCache):
|
||||
inner = inner.inner
|
||||
if inner is None:
|
||||
raise RuntimeError("tree_cache not provided to SnapshotLinkController")
|
||||
radix_key = RadixKey(token_ids, None)
|
||||
result = inner.insert(InsertParams(key=radix_key, value=indices_tensor))
|
||||
return int(getattr(result, "prefix_len", 0))
|
||||
|
||||
def _free_slot_indices(self, indices_tensor) -> None:
|
||||
try:
|
||||
self.token_to_kv_pool_allocator.free(indices_tensor)
|
||||
except Exception as e:
|
||||
logger.warning("_free_slot_indices failed: %s", e)
|
||||
|
||||
# ----- D-side: push session KV to a peer's snapshot_buf ------------
|
||||
|
||||
def push_session_to_snapshot_buf(
|
||||
self,
|
||||
*,
|
||||
target_snapshot_session_id: str,
|
||||
src_slot_indices: List[int],
|
||||
target_snapshot_buf_base: int,
|
||||
target_k_layer_offsets: List[int],
|
||||
target_v_layer_offsets: List[int],
|
||||
target_per_token_k_bytes: int,
|
||||
target_per_token_v_bytes: int,
|
||||
) -> Tuple[int, int]:
|
||||
"""Push session KV from local kv_pool into a peer's snapshot_buf slab.
|
||||
|
||||
For each layer: gather src ranges (possibly scattered slot indices)
|
||||
and write to a contiguous range in the peer's snapshot_buf.
|
||||
Returns (mooncake_return_code, bytes_pushed).
|
||||
"""
|
||||
if not src_slot_indices:
|
||||
return 0, 0
|
||||
layer_num = self.layer_num
|
||||
k_src_bases = self.get_k_base_ptrs()
|
||||
v_src_bases = self.get_v_base_ptrs()
|
||||
stride_k = self.get_stride_k_bytes()
|
||||
stride_v = self.get_stride_v_bytes()
|
||||
if (len(target_k_layer_offsets) != layer_num
|
||||
or len(target_v_layer_offsets) != layer_num):
|
||||
raise ValueError(
|
||||
f"target K/V layer offset count {len(target_k_layer_offsets)}/"
|
||||
f"{len(target_v_layer_offsets)} != local layer_num {layer_num}"
|
||||
)
|
||||
if (stride_k != target_per_token_k_bytes
|
||||
or stride_v != target_per_token_v_bytes):
|
||||
raise ValueError(
|
||||
f"stride mismatch: local k={stride_k}/v={stride_v}, "
|
||||
f"target k={target_per_token_k_bytes}/v={target_per_token_v_bytes}"
|
||||
)
|
||||
n = len(src_slot_indices)
|
||||
|
||||
local_addrs: List[int] = []
|
||||
remote_addrs: List[int] = []
|
||||
lengths: List[int] = []
|
||||
|
||||
# Coalesce contiguous src runs.
|
||||
# Inner-loop helper to walk indices and emit run boundaries.
|
||||
def _emit_runs(src_base: int, tgt_base: int, stride: int) -> None:
|
||||
run_src_start = run_tgt_start = run_len = None
|
||||
for tgt_idx, src in enumerate(src_slot_indices):
|
||||
if run_src_start is None:
|
||||
run_src_start, run_tgt_start, run_len = src, tgt_idx, 1
|
||||
elif src == run_src_start + run_len:
|
||||
run_len += 1
|
||||
else:
|
||||
local_addrs.append(src_base + run_src_start * stride)
|
||||
remote_addrs.append(tgt_base + run_tgt_start * stride)
|
||||
lengths.append(run_len * stride)
|
||||
run_src_start, run_tgt_start, run_len = src, tgt_idx, 1
|
||||
if run_src_start is not None:
|
||||
local_addrs.append(src_base + run_src_start * stride)
|
||||
remote_addrs.append(tgt_base + run_tgt_start * stride)
|
||||
lengths.append(run_len * stride)
|
||||
|
||||
for L in range(layer_num):
|
||||
_emit_runs(
|
||||
k_src_bases[L],
|
||||
target_snapshot_buf_base + target_k_layer_offsets[L],
|
||||
stride_k,
|
||||
)
|
||||
_emit_runs(
|
||||
v_src_bases[L],
|
||||
target_snapshot_buf_base + target_v_layer_offsets[L],
|
||||
stride_v,
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
ret = self.engine.batch_transfer_sync_write(
|
||||
target_snapshot_session_id, local_addrs, remote_addrs, lengths,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"SnapshotLinkController.push_session_to_snapshot_buf threw: %s", e
|
||||
)
|
||||
return -1, 0
|
||||
t1 = time.perf_counter()
|
||||
bytes_pushed = sum(lengths)
|
||||
logger.info(
|
||||
"push_session_to_snapshot_buf → %s: %d ops, %d B, ret=%d, %.2f ms",
|
||||
target_snapshot_session_id, len(lengths), bytes_pushed, ret,
|
||||
(t1 - t0) * 1000.0,
|
||||
)
|
||||
return ret, bytes_pushed
|
||||
@@ -125,6 +125,9 @@ from sglang.srt.managers.io_struct import (
|
||||
LoadLoRAAdapterFromTensorsReqInput,
|
||||
LoadLoRAAdapterReqInput,
|
||||
DirectAppendAdmissionReqInput,
|
||||
SnapshotDumpReqInput,
|
||||
SnapshotFinalizeIngestReqInput,
|
||||
SnapshotPrepareReceiveReqInput,
|
||||
OpenSessionReqInput,
|
||||
ParseFunctionCallReq,
|
||||
PauseGenerationReqInput,
|
||||
@@ -1295,6 +1298,21 @@ async def admit_direct_append(obj: DirectAppendAdmissionReqInput):
|
||||
return await _global_state.tokenizer_manager.admit_direct_append(obj)
|
||||
|
||||
|
||||
@app.post("/_snapshot/prepare_receive")
|
||||
async def snapshot_prepare_receive(obj: SnapshotPrepareReceiveReqInput):
|
||||
return await _global_state.tokenizer_manager.snapshot_prepare_receive(obj)
|
||||
|
||||
|
||||
@app.post("/_snapshot/dump")
|
||||
async def snapshot_dump(obj: SnapshotDumpReqInput):
|
||||
return await _global_state.tokenizer_manager.snapshot_dump(obj)
|
||||
|
||||
|
||||
@app.post("/_snapshot/finalize_ingest")
|
||||
async def snapshot_finalize_ingest(obj: SnapshotFinalizeIngestReqInput):
|
||||
return await _global_state.tokenizer_manager.snapshot_finalize_ingest(obj)
|
||||
|
||||
|
||||
@app.api_route("/configure_logging", methods=["GET", "POST"])
|
||||
@auth_level(AuthLevel.ADMIN_OPTIONAL)
|
||||
async def configure_logging(obj: ConfigureLoggingReq, request: Request):
|
||||
|
||||
@@ -1632,6 +1632,96 @@ class HealthCheckOutput(BaseReq):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D→P snapshot ingest (Phase 2 of D→P sync feature; see
|
||||
# docs/D_TO_P_SYNC_DESIGN_ZH.md).
|
||||
#
|
||||
# Three-step protocol orchestrated by agentic-pd-hybrid:
|
||||
# 1. PrepareReceive → P allocates kv_pool slots + returns destination
|
||||
# addresses for D's RDMA writes.
|
||||
# 2. (out-of-band) → D uses snapshot_link to RDMA-push KV bytes
|
||||
# directly to P's slot addresses.
|
||||
# 3. FinalizeIngest → P inserts (token_ids, kv_indices) into its radix
|
||||
# tree so subsequent prefill requests for this
|
||||
# session see a cache hit.
|
||||
#
|
||||
# Each step is its own ReqInput/ReqOutput pair so the scheduler handlers can
|
||||
# be written stateless and the orchestrator can retry / abort cleanly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotPrepareReceiveReqInput(BaseReq):
|
||||
"""P-side: allocate slots + register them with mooncake for D to push into."""
|
||||
|
||||
session_id: str
|
||||
num_tokens: int # P will alloc this many contiguous slots
|
||||
expected_bytes_per_layer_k: int = 0 # per-token K bytes × num_tokens (sanity)
|
||||
expected_bytes_per_layer_v: int = 0 # per-token V bytes × num_tokens (sanity)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotPrepareReceiveReqOutput(BaseReq):
|
||||
"""P-side response. New schema points D at P's dedicated snapshot_buf."""
|
||||
|
||||
ok: bool
|
||||
reason: Optional[str] = None
|
||||
# P's mooncake snapshot session id (host:rpc_port) for D's batch write target
|
||||
snapshot_session_id: str = ""
|
||||
# snapshot_buf base pointer + per-layer offsets, replacing the old
|
||||
# kv_pool slot_indices scheme that competed with P's prefill work and
|
||||
# always hit alloc-failed. See docs/SNAPSHOT_STORE_REFACTOR_ZH.md.
|
||||
snapshot_buf_base_ptr: int = 0
|
||||
snapshot_buf_capacity_bytes: int = 0
|
||||
k_layer_offsets: List[int] = field(default_factory=list) # bytes within snapshot_buf
|
||||
v_layer_offsets: List[int] = field(default_factory=list)
|
||||
num_tokens: int = 0
|
||||
stride_k_bytes: int = 0
|
||||
stride_v_bytes: int = 0
|
||||
layer_num: int = 0
|
||||
available_tokens: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotDumpReqInput(BaseReq):
|
||||
"""D-side: dump session KV via snapshot_link into P's snapshot_buf slab."""
|
||||
|
||||
session_id: str
|
||||
target_snapshot_session_id: str
|
||||
target_snapshot_buf_base: int = 0
|
||||
target_k_layer_offsets: List[int] = field(default_factory=list)
|
||||
target_v_layer_offsets: List[int] = field(default_factory=list)
|
||||
target_stride_k_bytes: int = 0
|
||||
target_stride_v_bytes: int = 0
|
||||
ib_device: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotDumpReqOutput(BaseReq):
|
||||
ok: bool
|
||||
reason: Optional[str] = None
|
||||
bytes_pushed: int = 0
|
||||
transfer_duration_ms: float = 0.0
|
||||
kv_committed_len: int = 0 # the actual number of tokens D had for this session
|
||||
# The token_ids that go with the KV (so P can call radix_cache.insert)
|
||||
token_ids: List[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotFinalizeIngestReqInput(BaseReq):
|
||||
"""P-side: copy snapshot_buf slab into kv_pool + insert into radix tree."""
|
||||
|
||||
session_id: str
|
||||
token_ids: List[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotFinalizeIngestReqOutput(BaseReq):
|
||||
ok: bool
|
||||
reason: Optional[str] = None
|
||||
inserted_prefix_len: int = 0
|
||||
|
||||
|
||||
class ExpertDistributionReqType(Enum):
|
||||
START_RECORD = 1
|
||||
STOP_RECORD = 2
|
||||
|
||||
@@ -96,6 +96,12 @@ from sglang.srt.managers.io_struct import (
|
||||
ContinueGenerationReqInput,
|
||||
DirectAppendAdmissionReqInput,
|
||||
DirectAppendAdmissionReqOutput,
|
||||
SnapshotDumpReqInput,
|
||||
SnapshotDumpReqOutput,
|
||||
SnapshotFinalizeIngestReqInput,
|
||||
SnapshotFinalizeIngestReqOutput,
|
||||
SnapshotPrepareReceiveReqInput,
|
||||
SnapshotPrepareReceiveReqOutput,
|
||||
DestroyWeightsUpdateGroupReqInput,
|
||||
DetachHiCacheStorageReqInput,
|
||||
DetachHiCacheStorageReqOutput,
|
||||
@@ -844,6 +850,70 @@ class Scheduler(
|
||||
embedding_cache_size = envs.SGLANG_VLM_CACHE_SIZE_MB.get()
|
||||
init_mm_embedding_cache(embedding_cache_size * 1024 * 1024)
|
||||
|
||||
# ---- D→P snapshot link (Phase 2 of D→P sync feature) ------------
|
||||
# Enabled per-worker via SGLANG_SNAPSHOT_LINK_ENABLE=1. Each worker
|
||||
# binds an independent mooncake transfer engine on
|
||||
# SGLANG_SNAPSHOT_LINK_HOST:SGLANG_SNAPSHOT_LINK_PORT and pre-
|
||||
# registers the kv_pool layer buffers for one-shot RDMA pushes /
|
||||
# receives. See docs/D_TO_P_SYNC_DESIGN_ZH.md.
|
||||
self.snapshot_link_controller = None
|
||||
from sglang.srt.disaggregation.snapshot import (
|
||||
SnapshotLinkController as _SnapLinkCtrl,
|
||||
SNAPSHOT_LINK_ENABLE_ENV,
|
||||
SNAPSHOT_LINK_HOST_ENV,
|
||||
SNAPSHOT_LINK_PORT_ENV,
|
||||
SNAPSHOT_LINK_IB_DEVICE_ENV,
|
||||
)
|
||||
if os.environ.get(SNAPSHOT_LINK_ENABLE_ENV, "0") == "1":
|
||||
host = os.environ.get(SNAPSHOT_LINK_HOST_ENV, server_args.host)
|
||||
port = int(os.environ.get(SNAPSHOT_LINK_PORT_ENV,
|
||||
str(server_args.disaggregation_bootstrap_port + 1000)))
|
||||
ib = os.environ.get(SNAPSHOT_LINK_IB_DEVICE_ENV, server_args.disaggregation_ib_device)
|
||||
try:
|
||||
kv_pool = self.token_to_kv_pool_allocator.get_kvcache()
|
||||
except AttributeError:
|
||||
# Some allocators expose the pool directly
|
||||
kv_pool = getattr(self.token_to_kv_pool_allocator, "kvcache", None)
|
||||
if kv_pool is None:
|
||||
logger.warning("SNAPSHOT_LINK_ENABLE=1 but kv_pool unavailable; skipping init")
|
||||
else:
|
||||
try:
|
||||
kv_data_ptrs, kv_data_lens, kv_item_lens = kv_pool.get_contiguous_buf_infos()
|
||||
layer_n = len(kv_data_ptrs) // 2
|
||||
layer_buffers = []
|
||||
# K layers first, then V layers (matches MHATokenToKVPool.get_contiguous_buf_infos)
|
||||
for i in range(layer_n):
|
||||
layer_buffers.append((
|
||||
kv_data_ptrs[i],
|
||||
kv_item_lens[i] // max(1, kv_pool.page_size),
|
||||
kv_data_lens[i],
|
||||
True, # is_k
|
||||
))
|
||||
for i in range(layer_n):
|
||||
layer_buffers.append((
|
||||
kv_data_ptrs[layer_n + i],
|
||||
kv_item_lens[layer_n + i] // max(1, kv_pool.page_size),
|
||||
kv_data_lens[layer_n + i],
|
||||
False, # is_k=False (V)
|
||||
))
|
||||
self.snapshot_link_controller = _SnapLinkCtrl(
|
||||
host=host,
|
||||
port=port,
|
||||
ib_device=ib,
|
||||
kv_pool_layer_buffers=layer_buffers,
|
||||
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
|
||||
tree_cache=self.tree_cache,
|
||||
)
|
||||
logger.info(
|
||||
"Snapshot link controller initialized: %s, sid=%s, %d layer bufs",
|
||||
f"{host}:{port}",
|
||||
self.snapshot_link_controller.snapshot_session_id,
|
||||
len(layer_buffers),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Snapshot link init failed: %s; continuing without it", e)
|
||||
self.snapshot_link_controller = None
|
||||
|
||||
def init_running_status(self):
|
||||
self.waiting_queue: List[Req] = []
|
||||
self.decode_direct_waiting_queue: List[Req] = []
|
||||
@@ -1219,6 +1289,9 @@ class Scheduler(
|
||||
(OpenSessionReqInput, self.open_session),
|
||||
(CloseSessionReqInput, self.close_session),
|
||||
(DirectAppendAdmissionReqInput, self.admit_direct_append),
|
||||
(SnapshotPrepareReceiveReqInput, self.snapshot_prepare_receive),
|
||||
(SnapshotDumpReqInput, self.snapshot_dump),
|
||||
(SnapshotFinalizeIngestReqInput, self.snapshot_finalize_ingest),
|
||||
(UpdateWeightFromDiskReqInput, self.update_weights_from_disk),
|
||||
(InitWeightsUpdateGroupReqInput, self.init_weights_update_group),
|
||||
(DestroyWeightsUpdateGroupReqInput, self.destroy_weights_update_group),
|
||||
@@ -3673,6 +3746,119 @@ class Scheduler(
|
||||
),
|
||||
)
|
||||
|
||||
# ----- D→P snapshot link handlers (Phase 2/3) ---------------------
|
||||
|
||||
def snapshot_prepare_receive(
|
||||
self, recv_req: SnapshotPrepareReceiveReqInput
|
||||
) -> SnapshotPrepareReceiveReqOutput:
|
||||
"""P-side: carve snapshot_buf slab + return its layout to caller.
|
||||
|
||||
Refactored per docs/SNAPSHOT_STORE_REFACTOR_ZH.md: this no longer
|
||||
touches the kv_pool allocator. The slab is in a dedicated
|
||||
snapshot_buf so prepare can never lose to P's prefill work.
|
||||
"""
|
||||
ctrl = self.snapshot_link_controller
|
||||
if ctrl is None:
|
||||
return SnapshotPrepareReceiveReqOutput(
|
||||
ok=False, reason="snapshot-link-disabled",
|
||||
)
|
||||
try:
|
||||
available = int(self.token_to_kv_pool_allocator.available_size())
|
||||
except Exception:
|
||||
available = -1
|
||||
if recv_req.num_tokens <= 0:
|
||||
return SnapshotPrepareReceiveReqOutput(ok=False, reason="zero-tokens")
|
||||
record = ctrl.prepare_receive(recv_req.session_id, recv_req.num_tokens)
|
||||
if record is None:
|
||||
return SnapshotPrepareReceiveReqOutput(
|
||||
ok=False, reason="snapshot-buf-full",
|
||||
available_tokens=available,
|
||||
)
|
||||
return SnapshotPrepareReceiveReqOutput(
|
||||
ok=True,
|
||||
snapshot_session_id=ctrl.snapshot_session_id,
|
||||
snapshot_buf_base_ptr=ctrl.snapshot_buf_ptr,
|
||||
snapshot_buf_capacity_bytes=ctrl.snapshot_buf_bytes,
|
||||
k_layer_offsets=record.k_layer_offsets,
|
||||
v_layer_offsets=record.v_layer_offsets,
|
||||
num_tokens=record.num_tokens,
|
||||
stride_k_bytes=record.per_token_k_bytes,
|
||||
stride_v_bytes=record.per_token_v_bytes,
|
||||
layer_num=ctrl.layer_num,
|
||||
available_tokens=available,
|
||||
)
|
||||
|
||||
def snapshot_dump(
|
||||
self, recv_req: SnapshotDumpReqInput
|
||||
) -> SnapshotDumpReqOutput:
|
||||
"""D-side: gather session KV from kv_pool, RDMA-write into P's snapshot_buf."""
|
||||
ctrl = self.snapshot_link_controller
|
||||
if ctrl is None:
|
||||
return SnapshotDumpReqOutput(ok=False, reason="snapshot-link-disabled")
|
||||
if not isinstance(self.tree_cache, SessionAwareCache):
|
||||
return SnapshotDumpReqOutput(ok=False, reason="tree-cache-not-session-aware")
|
||||
slot = self.tree_cache.slots.get(recv_req.session_id)
|
||||
if slot is None or slot.req_pool_idx is None:
|
||||
return SnapshotDumpReqOutput(ok=False, reason="session-not-resident")
|
||||
kv_committed_len = int(slot.kv_committed_len)
|
||||
if kv_committed_len == 0:
|
||||
return SnapshotDumpReqOutput(ok=False, reason="zero-committed-len")
|
||||
try:
|
||||
kv_idx_tensor = self.req_to_token_pool.req_to_token[
|
||||
slot.req_pool_idx, :kv_committed_len
|
||||
]
|
||||
src_slot_indices = [int(x) for x in kv_idx_tensor.tolist()]
|
||||
except Exception as e:
|
||||
return SnapshotDumpReqOutput(ok=False, reason=f"read-indices-failed:{e!r}")
|
||||
|
||||
try:
|
||||
ret, bytes_pushed = ctrl.push_session_to_snapshot_buf(
|
||||
target_snapshot_session_id=recv_req.target_snapshot_session_id,
|
||||
src_slot_indices=src_slot_indices,
|
||||
target_snapshot_buf_base=recv_req.target_snapshot_buf_base,
|
||||
target_k_layer_offsets=recv_req.target_k_layer_offsets,
|
||||
target_v_layer_offsets=recv_req.target_v_layer_offsets,
|
||||
target_per_token_k_bytes=recv_req.target_stride_k_bytes,
|
||||
target_per_token_v_bytes=recv_req.target_stride_v_bytes,
|
||||
)
|
||||
except Exception as e:
|
||||
return SnapshotDumpReqOutput(ok=False, reason=f"push-failed:{e!r}")
|
||||
|
||||
if ret != 0:
|
||||
return SnapshotDumpReqOutput(
|
||||
ok=False, reason=f"mooncake-batch-write-ret={ret}",
|
||||
bytes_pushed=int(bytes_pushed),
|
||||
kv_committed_len=kv_committed_len,
|
||||
)
|
||||
return SnapshotDumpReqOutput(
|
||||
ok=True, bytes_pushed=int(bytes_pushed),
|
||||
kv_committed_len=kv_committed_len,
|
||||
token_ids=[],
|
||||
)
|
||||
|
||||
def snapshot_finalize_ingest(
|
||||
self, recv_req: SnapshotFinalizeIngestReqInput
|
||||
) -> SnapshotFinalizeIngestReqOutput:
|
||||
"""P-side: copy snapshot_buf slab into kv_pool + insert into radix tree.
|
||||
|
||||
Refactored per docs/SNAPSHOT_STORE_REFACTOR_ZH.md: kv_pool alloc
|
||||
happens HERE (deferred from prepare_receive), so we never block
|
||||
D's RDMA write on kv_pool contention.
|
||||
"""
|
||||
ctrl = self.snapshot_link_controller
|
||||
if ctrl is None:
|
||||
return SnapshotFinalizeIngestReqOutput(
|
||||
ok=False, reason="snapshot-link-disabled",
|
||||
)
|
||||
ok, reason, inserted_prefix_len = ctrl.ingest_snapshot_into_kvpool(
|
||||
session_id=recv_req.session_id,
|
||||
token_ids=list(recv_req.token_ids),
|
||||
)
|
||||
return SnapshotFinalizeIngestReqOutput(
|
||||
ok=bool(ok), reason=reason if not ok else None,
|
||||
inserted_prefix_len=int(inserted_prefix_len),
|
||||
)
|
||||
|
||||
def _compute_backpressure_pause_hint(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -181,13 +181,19 @@ class SchedulerRuntimeCheckerMixin:
|
||||
return memory_leak, token_msg
|
||||
|
||||
def _check_radix_cache_memory(self: Scheduler):
|
||||
# NB: as of SnapshotStore refactor (see docs/SNAPSHOT_STORE_REFACTOR_ZH.md)
|
||||
# prepare_receive no longer touches kv_pool — slots are alloc'd from
|
||||
# a dedicated snapshot_buf. So no snapshot_reserved accounting needed.
|
||||
_, _, available_size, evictable_size = self._get_token_info()
|
||||
protected_size = self.tree_cache.protected_size()
|
||||
session_held = self._session_held_tokens()
|
||||
memory_leak = (available_size + evictable_size) != (
|
||||
self.max_total_num_tokens - protected_size - session_held
|
||||
)
|
||||
token_msg = f"{self.max_total_num_tokens=}, {available_size=}, {evictable_size=}, {protected_size=}, {session_held=}\n"
|
||||
token_msg = (
|
||||
f"{self.max_total_num_tokens=}, {available_size=}, {evictable_size=}, "
|
||||
f"{protected_size=}, {session_held=}\n"
|
||||
)
|
||||
return memory_leak, token_msg
|
||||
|
||||
def _get_batch_uncached_size(self: Scheduler, batch: ScheduleBatch) -> int:
|
||||
|
||||
@@ -74,6 +74,12 @@ from sglang.srt.managers.io_struct import (
|
||||
SetInternalStateReqOutput,
|
||||
SlowDownReqInput,
|
||||
SlowDownReqOutput,
|
||||
SnapshotDumpReqInput,
|
||||
SnapshotDumpReqOutput,
|
||||
SnapshotFinalizeIngestReqInput,
|
||||
SnapshotFinalizeIngestReqOutput,
|
||||
SnapshotPrepareReceiveReqInput,
|
||||
SnapshotPrepareReceiveReqOutput,
|
||||
UnloadLoRAAdapterReqInput,
|
||||
UnloadLoRAAdapterReqOutput,
|
||||
UpdateWeightsFromDistributedReqInput,
|
||||
@@ -225,6 +231,15 @@ class TokenizerCommunicatorMixin:
|
||||
self.direct_append_admission_communicator = _Communicator(
|
||||
self.send_to_scheduler, server_args.dp_size
|
||||
)
|
||||
self.snapshot_prepare_receive_communicator = _Communicator(
|
||||
self.send_to_scheduler, server_args.dp_size
|
||||
)
|
||||
self.snapshot_dump_communicator = _Communicator(
|
||||
self.send_to_scheduler, server_args.dp_size
|
||||
)
|
||||
self.snapshot_finalize_ingest_communicator = _Communicator(
|
||||
self.send_to_scheduler, server_args.dp_size
|
||||
)
|
||||
self.set_internal_state_communicator = _Communicator(
|
||||
self.send_to_scheduler, server_args.dp_size
|
||||
)
|
||||
@@ -325,6 +340,18 @@ class TokenizerCommunicatorMixin:
|
||||
DirectAppendAdmissionReqOutput,
|
||||
self.direct_append_admission_communicator.handle_recv,
|
||||
),
|
||||
(
|
||||
SnapshotPrepareReceiveReqOutput,
|
||||
self.snapshot_prepare_receive_communicator.handle_recv,
|
||||
),
|
||||
(
|
||||
SnapshotDumpReqOutput,
|
||||
self.snapshot_dump_communicator.handle_recv,
|
||||
),
|
||||
(
|
||||
SnapshotFinalizeIngestReqOutput,
|
||||
self.snapshot_finalize_ingest_communicator.handle_recv,
|
||||
),
|
||||
(
|
||||
SetInternalStateReqOutput,
|
||||
self.set_internal_state_communicator.handle_recv,
|
||||
@@ -890,6 +917,36 @@ class TokenizerCommunicatorMixin:
|
||||
)
|
||||
return responses[0]
|
||||
|
||||
async def snapshot_prepare_receive(
|
||||
self: TokenizerManager,
|
||||
obj: SnapshotPrepareReceiveReqInput,
|
||||
) -> SnapshotPrepareReceiveReqOutput:
|
||||
self.auto_create_handle_loop()
|
||||
responses: List[SnapshotPrepareReceiveReqOutput] = (
|
||||
await self.snapshot_prepare_receive_communicator(obj)
|
||||
)
|
||||
return responses[0]
|
||||
|
||||
async def snapshot_dump(
|
||||
self: TokenizerManager,
|
||||
obj: SnapshotDumpReqInput,
|
||||
) -> SnapshotDumpReqOutput:
|
||||
self.auto_create_handle_loop()
|
||||
responses: List[SnapshotDumpReqOutput] = (
|
||||
await self.snapshot_dump_communicator(obj)
|
||||
)
|
||||
return responses[0]
|
||||
|
||||
async def snapshot_finalize_ingest(
|
||||
self: TokenizerManager,
|
||||
obj: SnapshotFinalizeIngestReqInput,
|
||||
) -> SnapshotFinalizeIngestReqOutput:
|
||||
self.auto_create_handle_loop()
|
||||
responses: List[SnapshotFinalizeIngestReqOutput] = (
|
||||
await self.snapshot_finalize_ingest_communicator(obj)
|
||||
)
|
||||
return responses[0]
|
||||
|
||||
async def set_internal_state(
|
||||
self: TokenizerManager, obj: SetInternalStateReq
|
||||
) -> List[bool]:
|
||||
|
||||
32
third_party/traces/README.md
vendored
Normal file
32
third_party/traces/README.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Replay traces
|
||||
|
||||
为了方便跨主机传输,把 benchmark 用到的 trace 文件放在这里。该目录在
|
||||
`.gitignore` 中显式 whitelist(同 `third_party/sglang/`),文件随 git 一起走。
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 大小 | 内容 | 来源 |
|
||||
|---|---:|---|---|
|
||||
| `qwen35-swebench-50sess.jsonl` | 54 MB | 4449 reqs / 52 sessions / Qwen3.5-35B 推理产物 | `simm-swe-bench` 项目用 SiBench replay SiCo `swe.jsonl` 经 SGLang 跑出 audit.jsonl,再用 `scripts/convert_audit_to_trace.py` 转 |
|
||||
|
||||
详细来源见 `docs/ONBOARDING_NEXT_AGENT_ZH.md` 和实际 schema 见 `src/agentic_pd_hybrid/trace.py`。
|
||||
|
||||
## 使用方法
|
||||
|
||||
Replay 端的 trace 路径由 CLI flag `--trace` 指定。默认 sweep 脚本里指向
|
||||
`outputs/qwen35-swebench-50sess.jsonl`——为了向后兼容老脚本,**建议在 clone 后
|
||||
软链接一份过去**:
|
||||
|
||||
```bash
|
||||
mkdir -p outputs
|
||||
ln -sf ../third_party/traces/qwen35-swebench-50sess.jsonl \
|
||||
outputs/qwen35-swebench-50sess.jsonl
|
||||
```
|
||||
|
||||
或者直接改 sweep 脚本里 `--trace` 路径指向 `third_party/traces/...`。
|
||||
|
||||
## 添加新 trace
|
||||
|
||||
如果未来加新 trace 文件(如 `codex_swebenchpro` 转换后的版本),直接放本目录,
|
||||
更新本 README 的清单即可。**别把超过 100 MB 的单文件直接 git add**——GitLab
|
||||
默认对未启用 LFS 的单文件有 100 MB 限制。
|
||||
4449
third_party/traces/qwen35-swebench-50sess.jsonl
vendored
Normal file
4449
third_party/traces/qwen35-swebench-50sess.jsonl
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user