# KVC 实验踩坑记录与代码 Bug 分析(v1 → v4) 记录从 v1 到 v4 KVC 实验的踩坑过程、错误诊断、以及最终定位的代码 bug。 模型: Qwen3-30B-A3B (TP1),硬件: 单节点 8×H100 80GB。 Trace: `qwen35-swebench-50sess.jsonl`(4449 请求,52 sessions)。 ## TL;DR | 版本 | 关键变化 | 截断率 | direct-to-D 占比 | P50 | 主要瓶颈 | |------|----------|:---:|:---:|:---:|----------| | v1 (smoke / 早期) | mechanism 跑通 | - | - | - | - | | v2 | KVC + `--policy default` | **56.8% / 61.4%** | <0.1% | 0.08s* | Routing 错位(默认策略) | | v3 | KVC + `--policy kv-aware` | **0.9%** | 30-42% | 1.5-1.8s | session-cap fallback (52-65%) | | v4 | v3 + soft_cap 4→16 | (待数据) | (待数据) | (待数据) | (待数据) | `*` v2 的 P50 是假数字——超过半数请求只生成 1 个 token 就被 abort。 ## v2 踩坑:Default policy 与 KVC 机制根本不兼容 ### 表象 `scripts/sweep_tp1_v2_fixed.sh` 跑出来: - Exp1(8-way DP,baseline):4449/4449 成功,P50=0.65s,error=0 - Exp2(1P7D KVC):**2524 truncated (56.8%)**,18 errors,P50=0.08s* (假) - Exp3(2P6D KVC):**2733 truncated (61.4%)**,17 errors,P50=0.08s* (假) 每个截断请求 `actual_output_tokens=1`,`finish_reason="abort: session id X does not exist"`。 ### 错误的早期诊断 之前 `RESULTS_SUMMARY.md` 把锅扣在 SGLang 的 `--disaggregation-decode-allow-local-prefill` flag 上,认为是 D worker 在有 `bootstrap_room` 时仍然做了 local prefill。这个诊断**完全错误**——查 `scheduler.py:1975-1980` 的 `_should_allow_local_prefill_on_decode`: ```python def _should_allow_local_prefill_on_decode(self, req: Req) -> bool: return ( self.disaggregation_mode == DisaggregationMode.DECODE and self.server_args.disaggregation_decode_allow_local_prefill and req.bootstrap_room is None # ← 有 bootstrap_room 不会走 local prefill ) ``` KVC reseed 路径的请求都带 `bootstrap_room`,根本不会触发 local prefill。 ### 实际根因:Replay 与 PD Router 的 round-robin 错位 实验脚本里 KVC 用 `--policy default`,而 baseline 用 `--policy kv-aware`。 看 `benchmark.py:287-300` 这两者的差别巨大: ```python def _decode_policy_for(policy_name: str) -> str: if policy_name == "sticky": return "manual" if policy_name == "kv-aware": return "consistent_hashing" return "round_robin" # default def _header_mode_for(policy_name: str) -> str: if policy_name == "sticky": return "routing-key" if policy_name == "kv-aware": return "target-worker" return "none" # default ``` `default` policy + KVC 机制下: 1. Replay policy(`policies.py:DefaultPolicy`)round-robin 选一个 D,比如 D-3 2. Replay 在 D-3 上 `open_session(session_id=X)`(`replay.py:1722-1731`) 3. Replay 通过 PD Router 发请求(带 `session_params`),但 `header_mode=none`,**不发任何 routing header** 4. PD Router (`pd_router.py:_select_decode_index`) 看到 `decode_policy=round_robin`,用**自己独立的计数器**round-robin,发到了 D-5 5. D-5 的 scheduler 看到 `session_params` 里有 session_id,但自己的 `session_controller` 里没这个 session(session 在 D-3 上)→ abort with `"Invalid request: session id X does not exist"` (`scheduler.py:1824-1836`) 两个独立的 round-robin 计数器只要一次错位(任何并发或 direct-to-D 绕过 router 的请求都会引起)就永远对不上。 ### 为什么 turn 0 不出问题? Turn 0 走 `_invoke_plain_router`(`replay.py:1894`),不带 `session_params`,作为普通 PD disagg 请求处理,发到任何 D 都行。Turn 1+ 才开始走带 session_params 的 KVC 路径,撞上路由错位。 ### 数据特征验证(per-session pattern) ``` session 11360 (58 turns): pattern = .TTTTT.TTTTTTT.TTTTTT... ← turn 0 OK,1+ 全 T session 18720 (87 turns): pattern = .TTTTTTTTTTTTTTTTTT... ``` 每个 D worker 收到了全部 52 个 session 的请求(理想情况下应该是 ~7-8 个/D,因为 round-robin 把 session 完全打散)。 ### 修复 唯一正确的修复是把 KVC 的 policy 从 `default` 改成 `kv-aware`: ```diff - --policy default + --policy kv-aware ``` `KvAwarePolicy` (`policies.py:146-187`) 做两件事: 1. 用 `_overlap_blocks` + `sticky_bonus` 给每个 D 打分,session 自然粘在同一个 D(**session 亲和性**) 2. `header_mode=target-worker`,发 `x-smg-target-worker` header 3. PD Router 用 `consistent_hashing` 模式,看到 header 就直接用,不再 round-robin ## v3 改 kv-aware policy 后:路由对了,但新瓶颈出现 `scripts/sweep_tp1_v3_kvaware.sh` 把所有 KVC 实验改成 `--policy kv-aware`,结果: | 指标 | v2 1P7D (default) | **v3 1P7D (kv-aware)** | v3 2P6D | 8-way DP baseline | |------|:---:|:---:|:---:|:---:| | 截断 | 56.8% | **0.9%** | 0.9% | 1.5% | | Errors | 18 | 363 (8.2%) | 9 | 0 | | Mean | 4.74s | 4.88s | 3.58s | 1.43s | | P50 | 0.08s* (假) | 1.75s | 1.52s | 0.65s | | P90 | 12.14s | 12.67s | 9.23s | 3.61s | | TTFT P50 | - | 0.36s | 0.33s | 0.09s | ✅ **截断从 56.8% 降到 0.9%,路由问题彻底解决**。 ❌ 但 P50 仍然是 baseline 的 2-3 倍。 ### Direct-to-D 路径表现优秀(KVC 该有的样子) 按 execution_mode 拆开看: | 路径 | Exp1 1P7D 占比 | Exp1 1P7D P50 | Exp1 1P7D TTFT P50 | |------|:---:|:---:|:---:| | `kvcache-direct-to-d-session` ✨ | 42.0% | **0.495s** | **0.043s** | | `pd-router-fallback-large-append-session-cap` 🔥 | **52.6%** | 5.6s | 3.7s | Direct-to-D 路径下: - P50 = 0.495s(**比 baseline 0.65s 快 25%**) - TTFT P50 = 0.043s(**比 baseline 0.093s 快 2 倍**) - KV transfer = 0(无 P 介入,纯 D 上 append-prefill) 这才是 KVC 真正的价值。但只有 30-42% 请求走到这条路。 ### 新瓶颈:session-cap fallback 占了 52-65% `pd-router-fallback-large-append-session-cap` 占 1P7D 的 52.6%、2P6D 的 65.4%。这条路径意味着 router 想开新 session 在 D 上,但 admission 拒绝了("d-session-cap"),只好回退到 plain router(P 全量 prefill + 传给 D,无 session 复用)。 ### Bimodal session 分布(starvation) | Session | Total turns | Direct-to-D | Session-cap fallback | |---------|:---:|:---:|:---:| | 22080 | 129 | **98%** | 0% | | 3840 | 118 | **97%** | 0% | | 70560 | 150 | **0%** | **99%** | | 39360 | 148 | **0%** | **99%** | | 61600 | 117 | **0%** | **99%** | 要么完全幸运,要么完全饿死——典型的双峰分布。 ### 根因:硬编码 cap=4 看 `replay.py:_decode_session_soft_cap` 原始代码: ```python def _decode_session_soft_cap(...) -> int: target_tokens = max(1, _estimate_session_resident_tokens(request)) usable_capacity_tokens = _usable_capacity_tokens(residency, server_url) ... if usable_capacity_tokens <= 0: return 4 return max(1, min(4, usable_capacity_tokens // target_tokens)) # ^^^ 硬编码上限 4 ``` 7 个 D × 每个 D 最多 4 个 session = **28 个 session slot 总容量**。Trace 有 52 个 session → 24 个 session 永远抢不到 slot。 启动期 race condition 决定了哪些 session 是"幸运儿"——前 28 个挤进来的 session 的所有后续 turn 都走 direct-to-D(快);剩下 24 个 session 永远走 session-cap fallback(慢)。 ## v4 改进:把硬 cap 从 4 提到 16 `replay.py:_decode_session_soft_cap` 一行修改: ```diff - if usable_capacity_tokens <= 0: - return 4 - return max(1, min(4, usable_capacity_tokens // target_tokens)) + if usable_capacity_tokens <= 0: + return 16 + return max(1, min(16, usable_capacity_tokens // target_tokens)) ``` 7 D × 16 = 112 个 slot,远超 52 个 session 需求。 ### v4 实际结果(vs v3 1P7D / 2P6D) | 指标 | v3 1P7D | **v4 1P7D** | v3 2P6D | **v4 2P6D** | baseline 8DP | |------|:---:|:---:|:---:|:---:|:---:| | Errors | 363 (8%) | 435 (10%) | 9 (0%) | **403 (9%)** | 0 | | 截断 | 42 | 43 | 42 | 36 | 68 | | **direct-to-D** | 38.6% | **54.3%** | 30.5% | **58.0%** ⭐ | - | | **session-cap fallback** | 48.3% | 37.4% | 65.4% | **34.7%** | - | | Session reused | 1716 | 2180 | 1358 | **2348** | - | | KV transfer blocks | 62K | 53K | 79K | **51K** | - | | Mean | 4.88s | 4.21s | 3.58s | **2.51s** | 1.43s | | **P50** | 1.75s | 1.08s | 1.52s | **0.84s** | **0.65s** | | P90 | 12.67s | 13.38s | 9.23s | **6.51s** | 3.61s | | P99 | 28.72s | 24.45s | 18.70s | 18.34s | 8.38s | | **TTFT P50** | 0.36s | 0.056s | 0.33s | **0.051s** ⭐ | 0.094s | | TTFT P90 | 10.97s | 11.90s | 6.95s | **2.64s** | 0.26s | ✓ direct-to-D 占比从 v3 的 30-38% 涨到 v4 的 54-58% ✓ session 复用 +27% (1P7D) / +73% (2P6D) ✓ KV transfer 量 -15% (1P7D) / -36% (2P6D) ✓ TTFT P50 反超 baseline 46%(0.051s vs 0.094s) ### Direct-to-D 路径全面碾压 baseline(KVC 真实价值) | Config | n | Lat P50 | Lat P90 | TTFT P50 | TTFT P90 | |--------|:---:|:---:|:---:|:---:|:---:| | baseline 8DP | 4381 | 0.66s | 3.65s | 0.094s | 0.256s | | v4 1P7D direct-to-D | 2179 | 0.495s | 3.03s | 0.044s | 0.055s | | **v4 2P6D direct-to-D** | **2348** | **0.499s** | **2.86s** | **0.043s** | **0.054s** | direct-to-D 子集相对 baseline: - P50 快 24-30% - P90 快 16-22% - TTFT P50 快 54% - TTFT P90 快 79% ### 整体性能(去掉 errors 和 truncated)vs baseline | Config | clean | Mean | P50 | P90 | P99 | |--------|:---:|:---:|:---:|:---:|:---:| | baseline 8DP | 4381 | 1.45s | 0.66s | 3.65s | 8.38s | | v4 2P6D | 4010 | 2.53s | 0.85s | 6.55s | 18.33s | vs baseline:P50 慢 28%、P90 慢 80%、P99 慢 119%。即使错误率为 0,整体仍输 baseline——根因是 35% 请求被推到 fallback 路径。 ### 新瓶颈 1:35% 请求仍走 session-cap fallback 抬到 16 后真实瓶颈是 capacity-based 计算:`min(16, usable_capacity_tokens // target_tokens)`。 - `target_tokens = input + output`,agentic 里常见 50-100K - D 的 KV pool ≈ 100-150K tokens(80GB H100, mem_fraction=0.835) - `usable / target` = 1-2,远没到 16 → 真实 cap 是 capacity 算出来的小数字 要解决必须改 capacity-based 估算逻辑(或上方案 D,让 D 自己决定)。 ### 新瓶颈 2:9-10% errors(mooncake 传输超时) P-side log 显示: ``` KVTransferError: Failed to send kv chunk of to 10.45.7.165:40319 Sync batch data transfer timeout after 32722558107ns (32 秒超时) Decode instance could be dead, remote mooncake session ... is not alive ``` 特征: - 所有 errors 在 run 的 44.8% 之后出现(系统压力累积) - 98% errors 集中在 turn ≥ 31(大 input 的请求) - v3 cap=4 时 1P7D 已有 363 errors(仅 1 个 D 集中受冲击),v4 cap=16 把压力均匀分布但量级更大 是 mooncake TCP loopback 在并发上去后撞超时,**不是 SGLang 逻辑 bug**。修复方向: 1. 加长 mooncake transfer timeout(现在 32s) 2. 限制并发 inflight transfer 数量 3. 改用 RDMA(loopback 是单机模拟,生产环境换真 RDMA) 4. chunked KV transfer ## 后续可以考虑的更深方案:让 D 自己决定 admission(方案 D) v4 的硬 cap 抬高只是把数字调大,实际容量管理还是 replay 自己估算。代码里 `replay.py:_decode_session_soft_cap` 用 `target_tokens = input + output`(基于当前请求的 size)估算每个 session footprint,但: - agentic context 越攒越长,target_tokens 动态增长,cap 会随之缩小 - 多个并发请求查询时 replay 视图会过期 - replay 自己写了 LRU eviction 逻辑(`_reserve_decode_session_capacity_from_router_state`),与 SGLang 内部的 `maybe_trim_decode_session_cache` 重复且永远滞后 SGLang 已经提供 `/session_cache/admit_direct_append` 端点(`scheduler.py:3497`),D worker 自己回答能不能 admit,并且查询时**主动调用 LRU eviction**。但这个端点只在 direct-to-D 路径用,seed/reseed 路径用的是 replay 自己估算的 soft_cap。 理想方案是扩展端点支持 `seed_new` / `reseed` 模式,replay 完全交给 D 决策——但这需要 SGLang 侧 patch + replay 重构(~200 行),工程量比 v4 大得多。 ## 关键文件与代码位置索引 | 现象 | 代码位置 | |------|----------| | Replay policy round-robin | `policies.py:63-67` `RoutingState.next_decode_worker_id` | | KV-aware policy(session 亲和) | `policies.py:146-187` `KvAwarePolicy.select` | | PD router decode 选择 | `pd_router.py:51-74` `_select_decode_index` | | Header 构建 | `replay.py:2407-2424` `_build_headers` | | Policy → router config 映射 | `benchmark.py:287-300` `_decode_policy_for/_header_mode_for` | | Session admission 软 cap | `replay.py:889-905` `_decode_session_soft_cap` | | 已有的 D 侧 admission 端点 | `scheduler.py:3497-3580` `admit_direct_append` | | Session 在 D 上找不到的报错 | `scheduler.py:1824-1836` | | `_should_allow_local_prefill_on_decode` | `scheduler.py:1975-1980` | | Reseed 流程入口 | `replay.py:1665-1809` `_invoke_kvcache_seeded_router` | | Direct-to-D 流程 | `replay.py:2351-2398` `_invoke_decode_session_direct` | ## 经验教训 1. **policy 和 mechanism 是两个正交维度**——`--policy default` 不是"无脑默认值",它真的是 round-robin 无 session 亲和性。KVC 机制必须配 session 亲和的 policy。 2. **不要无脑相信前一个 agent 的 RESULTS_SUMMARY**——v2 的诊断("local prefill bug")和实际 finish_reason("session id does not exist")完全对不上。任何错误诊断必须用 finish_reason、execution_mode 这些原始字段交叉验证。 3. **bimodal 分布是 starvation 的强信号**——v3 数据里某些 session 100% 走快路径、某些 100% 走慢路径,几乎肯定是某种"先到先得"的资源竞争。看到这种模式立刻去找硬编码 cap 或全局共享资源。 4. **测量要看分组而非整体均值**——v3 整体 P50=1.5s 看似比 baseline 慢,但拆开看 direct-to-D 子集 P50=0.495s 已经反超 baseline。整体均值被 fallback 路径拖累,但 KVC 的核心价值是真实存在的。