Add v4 sweep results and post-mortem analysis showing:
- direct-to-D path: 54.3% (1P7D) / 58.0% (2P6D) of requests now use
KVC cleanly. P50=0.5s and TTFT P50=0.043s; this path beats baseline
8DP across the board (P50 -24%, TTFT P50 -54%, TTFT P90 -79%).
- Overall vs baseline (errors+truncated excluded):
v4 2P6D P50=0.85s vs baseline 0.66s (28% slower).
Reason is not errors -- 35% of requests still hit
fallback-large-append-session-cap, where capacity-based
cap = usable_tokens / target_tokens evaluates to 1-2 (not 16)
for large agentic inputs.
- 9-10% errors on KVC variants are mooncake TCP transfer timeouts,
not SGLang logic bugs. Prefill log shows
"Failed to send kv chunk ... 32s timeout ... session not alive".
Errors concentrate in turn>=31 (large inputs) after run >44.8%.
Track:
- docs/KVC_DEBUG_JOURNEY_V1_TO_V4.md: append v4 results table,
per-mode breakdown, and error root cause.
- scripts/analysis/{analyze_v3,analyze_v4,analyze_errors,compare_no_error}.py
- outputs/qwen3-30b-tp1-v{3,4}*/exp*_summary.json (force-added,
small JSON; metrics.jsonl excluded due to size).
- outputs/qwen3-30b-tp1-v{3,4}*/sweep_results.txt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
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:
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 这两者的差别巨大:
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 机制下:
- Replay policy(
policies.py:DefaultPolicy)round-robin 选一个 D,比如 D-3 - Replay 在 D-3 上
open_session(session_id=X)(replay.py:1722-1731) - Replay 通过 PD Router 发请求(带
session_params),但header_mode=none,不发任何 routing header - PD Router (
pd_router.py:_select_decode_index) 看到decode_policy=round_robin,用自己独立的计数器round-robin,发到了 D-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:
- --policy default
+ --policy kv-aware
KvAwarePolicy (policies.py:146-187) 做两件事:
- 用
_overlap_blocks+sticky_bonus给每个 D 打分,session 自然粘在同一个 D(session 亲和性) header_mode=target-worker,发x-smg-target-workerheader- 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 原始代码:
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 一行修改:
- 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 <bootstrap_room> 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。修复方向:
- 加长 mooncake transfer timeout(现在 32s)
- 限制并发 inflight transfer 数量
- 改用 RDMA(loopback 是单机模拟,生产环境换真 RDMA)
- 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 |
经验教训
-
policy 和 mechanism 是两个正交维度——
--policy default不是"无脑默认值",它真的是 round-robin 无 session 亲和性。KVC 机制必须配 session 亲和的 policy。 -
不要无脑相信前一个 agent 的 RESULTS_SUMMARY——v2 的诊断("local prefill bug")和实际 finish_reason("session id does not exist")完全对不上。任何错误诊断必须用 finish_reason、execution_mode 这些原始字段交叉验证。
-
bimodal 分布是 starvation 的强信号——v3 数据里某些 session 100% 走快路径、某些 100% 走慢路径,几乎肯定是某种"先到先得"的资源竞争。看到这种模式立刻去找硬编码 cap 或全局共享资源。
-
测量要看分组而非整体均值——v3 整体 P50=1.5s 看似比 baseline 慢,但拆开看 direct-to-D 子集 P50=0.495s 已经反超 baseline。整体均值被 fallback 路径拖累,但 KVC 的核心价值是真实存在的。