Files
agentic-pd-hybrid/docs/KVC_DEBUG_JOURNEY_V1_TO_V4.md
kzlin c9d350b372 docs: KVC v1-v4 debug journey + raise session soft_cap to 16
Document the iterative debugging from v1 (broken KVC) through v4
(routing fixed + session cap raised), with code-level analysis of
the two main bugs encountered:

1. v2 root cause (mis-diagnosed previously as `allow_local_prefill`):
   `--policy default` for KVC mechanism caused replay's round-robin
   policy and the PD router's round-robin to diverge, sending requests
   with `session_params` to a D worker that did not have the session
   open. Resulted in 56-61% truncation with finish_reason
   "session id X does not exist".
   Fix: use `--policy kv-aware` (sweep_tp1_v3_kvaware.sh) so replay
   emits `x-smg-target-worker` and PD router uses consistent_hashing.

2. v3 new bottleneck: `pd-router-fallback-large-append-session-cap`
   dominated 52-65% of requests. Root cause was hardcoded
   `min(4, ...)` in `_decode_session_soft_cap`. With 7 D workers x 4
   sessions = 28 slots for 52 trace sessions, ~24 sessions starved
   permanently (bimodal direct-to-D rate of 0% or 99%).
   Fix: raise the cap to 16 (replay.py).

Also includes the v3 finding that direct-to-d-session path P50=0.495s
and TTFT P50=0.043s already beats the 8-way DP baseline (0.65s/0.093s)
- the KVC core mechanism works when fallback paths are avoided.

Files:
- docs/KVC_DEBUG_JOURNEY_V1_TO_V4.md: full journey + code location index
- docs/SWEBENCH_EXPERIMENT_{PROGRESS,RESULTS}.md: prior session notes
- scripts/sweep_tp1_v{2,3,4}*.sh: experiment driver scripts
- src/agentic_pd_hybrid/replay.py: cap 4 -> 16, audit fields
- src/agentic_pd_hybrid/pd_router.py: strip session_params from prefill
- src/agentic_pd_hybrid/metrics.py: truncated_request_count

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:10:41 +08:00

11 KiB
Raw Blame History

KVC 实验踩坑记录与代码 Bug 分析v1 → v4

记录从 v1 到 v4 KVC 实验的踩坑过程、错误诊断、以及最终定位的代码 bug。 模型: Qwen3-30B-A3B (TP1),硬件: 单节点 8×H100 80GB。 Trace: qwen35-swebench-50sess.jsonl4449 请求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 跑出来:

  • Exp18-way DPbaseline4449/4449 成功P50=0.65serror=0
  • Exp21P7D KVC2524 truncated (56.8%)18 errorsP50=0.08s* (假)
  • Exp32P6D KVC2733 truncated (61.4%)17 errorsP50=0.08s* (假)

每个截断请求 actual_output_tokens=1finish_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 机制下:

  1. Replay policypolicies.py:DefaultPolicyround-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 里没这个 sessionsession 在 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_routerreplay.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 OK1+ 全 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) 做两件事:

  1. _overlap_blocks + sticky_bonus 给每个 D 打分session 自然粘在同一个 Dsession 亲和性
  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 routerP 全量 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 需求。预期 session-cap fallback 占比降到 <10%,整体 P50 向 direct-to-D 的 0.46s 收敛。

实际数据见 outputs/qwen3-30b-tp1-v4-cap16/

后续可以考虑的更深方案:让 D 自己决定 admission

v4 的硬 cap 抬高只是把数字调大,实际容量管理还是 replay 自己估算。代码里 replay.py:_decode_session_soft_captarget_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:3497D 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 policysession 亲和) 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 的核心价值是真实存在的。