KVC v2 beats 4DP at ts=1 same-scale on 7/8 metrics: TTFT mean -24%, p50 -54%, p90 -64%; lat mean -0.8%, p50 -12.6%, p90 -0.7%. Direct-to-D rate jumped 42.8% -> 91.7%. REFACTOR_PLAN_V1 scenario C achieved. Two-knob fix: - reset-on-success blacklist decay: clear (sess, D) reject counter on successful direct-to-D path. Eliminates v1 thrashing where session 6880 was stable on decode-1 for 70 turns then collapsed to 75 D-changes after cumulative transient pressure tripped the permanent blacklist. - bump --kvcache-direct-max-uncached-tokens default 2048 -> 8192 via CLI flag. 41% of v1 fallbacks were 'real-large-append' (>2048 token append); raising the threshold lets these go through the direct-to-D fast path. Code: - policies.py: RoutingState.session_d_rejects counter + KvAwarePolicy migration_reject_threshold; degenerate fallback picks least-rejected D. - replay.py: record_admission_reject + reset-on-success in _run_request; _fallthrough_reason classifies turn-2+ fall-throughs as session-not-resident / real-large-append / etc, replacing misleading 'large-append' suffix (TEAM_REPORT §2.7). - cli.py + benchmark.py: --kvcache-migration-reject-threshold flag wiring. Docs: - REFACTOR_PLAN_V1_ZH.md: forward-looking plan after ts=1 validation. - MIGRATION_V1_FINDINGS_ZH.md: v1 thrashing root-cause analysis. - V2_RESULTS_ZH.md: v2 results, scenario C achievement, attribution. - TEAM_REPORT_AGENTIC_PD_HYBRID_ZH.md: comprehensive team report. Scripts: - sweep_ts1_kvc_n3_plus_dp.sh: ts=1 baseline (KVC 1P3D N=3 + 4DP CA). - sweep_ts1_migration_v1.sh / v2.sh: validation runs. - analyze_ts1_validation.py: 4-way comparison analyzer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
12 KiB
Markdown
284 lines
12 KiB
Markdown
# Migration v1 实验发现:blacklist 永久性导致 thrashing
|
||
|
||
**日期**:2026-05-08
|
||
**状态**:v1 run 进行中(~23% 完成时的中期分析)
|
||
**前置文档**:
|
||
- `docs/REFACTOR_PLAN_V1_ZH.md` §6.2(v1 设计)
|
||
- `docs/TEAM_REPORT_AGENTIC_PD_HYBRID_ZH.md` §2.1(§1 starvation claim)
|
||
|
||
**触发**:v1 实现的 session migration(rejection blacklist 机制)部署后,观测到 session-level thrashing——某些 session 在 3 个 D 之间 round-robin 高达 75-116 次。本文记录中期数据、根因诊断、v2 设计。
|
||
|
||
---
|
||
|
||
## 0. TL;DR
|
||
|
||
1. **v1 修复了 §1 starvation 但引入了新的 thrashing 失效模式**——不是 admission 过严,是 blacklist 永久累积的设计 bug
|
||
2. **核心证据**:session 6880 在 decode-1 上稳定 70 turns,然后某瞬时 burst 把 reject 计数累积到阈值,被永久 blacklist,之后陷入 3-D 间 round-robin 死循环
|
||
3. **85% admission 拒绝是 `session-not-resident`**——非 D 真容量问题,而是迁移后"新 D 第一次见你"的正常语义
|
||
4. **v2 设计**:reset-on-success 让 reject 计数在成功 turn 后清零,只有**持续**失败才迁移
|
||
5. **深层观察**:baseline 的"100% pin 但稳定"可能比"分布均匀但 thrashing"更好——糟糕的优化可能比不优化还糟
|
||
|
||
---
|
||
|
||
## 1. v1 实施回顾
|
||
|
||
### 1.1 改动文件
|
||
- `src/agentic_pd_hybrid/policies.py`:`RoutingState.session_d_rejects` Counter;`KvAwarePolicy.migration_reject_threshold=3` skip blacklisted D;degenerate fallback 选最少拒的 D
|
||
- `src/agentic_pd_hybrid/replay.py`:`_run_request` 末尾 `state.record_admission_reject(sess, D)`(基于 execution_mode 子串匹配);`_fallthrough_reason` 把 `pd-router-fallback-large-append-*` 拆成 `session-not-resident` / `real-large-append` / 等
|
||
- CLI / benchmark wiring
|
||
|
||
### 1.2 v1 假设(事后看部分错误)
|
||
- "reject 计数 + 阈值 3 = 容忍短期波动 + 持续失败迁移" ← **错**,counter 永久增长导致迁移成必然
|
||
- "迁移到新 D 后 session 在新 D 稳定下来" ← **部分错**,迁移到的新 D 也很可能很快 reject
|
||
- "session-not-resident 不会触发计数" ← **大致对**,但下游 fallback 可能间接触发
|
||
|
||
---
|
||
|
||
## 2. 中期数据(1023/4449 reqs,~23%)
|
||
|
||
### 2.1 头部指标 vs baseline
|
||
|
||
| 指标 | baseline kvc_1p3d_run1 | v1(中期) |
|
||
|---|---:|---:|
|
||
| Per-D 调用分布 | 1502/1445/1502(±3.8%)| 796/785/779(**±1.1%**,更均衡)|
|
||
| Per-D 峰值 token_usage | 0.99/0.99/0.99 | 0.31/0.30/0.00(**容量充裕**,未顶到 1.00)|
|
||
| KVTransferError | 5(全程)| 6(中期,趋势相近)|
|
||
| 已见 sessions | 52(全程)| 29(中期)|
|
||
|
||
**好的方面**:
|
||
- 负载均衡度跃升(±26%→±1.1% if normalized)
|
||
- D 容量从未饱和——§2 假设的"D drain time"机制配合 ts=1 充分发挥
|
||
- 0 sessions 永久 stuck 在饿死状态
|
||
|
||
### 2.2 Migration 触发情况(已见 29 sessions)
|
||
|
||
| 类别 | 数量 | 占比 |
|
||
|---|---:|---:|
|
||
| 仍 pin 在 1 个 D | 9 | 31% |
|
||
| 触碰 2 个 D | 3 | 10% |
|
||
| **触碰所有 3 个 D** | **17** | **59%** |
|
||
|
||
**D-切换次数分布**:
|
||
- mean = 26 次/session
|
||
- median = 16 次
|
||
- **max = 116 次**
|
||
- 15 sessions 切换 >10 次(明显 thrashing)
|
||
- **6 sessions 切换 >50 次**(严重 thrashing)
|
||
|
||
---
|
||
|
||
## 3. 根因诊断:session 6880 的轨迹
|
||
|
||
### 3.1 数据
|
||
|
||
```
|
||
turn 0-70: 全部在 decode-1 (71-turn 稳定 streak) ← §1 baseline 行为
|
||
turn 71-150: 在 3 个 D 间剧烈 thrashing
|
||
decode-0: 26 个短 streak
|
||
decode-1: 25 个短 streak
|
||
decode-2: 25 个短 streak
|
||
平均 streak 长度 = 2 turns
|
||
total streaks = 76
|
||
```
|
||
|
||
### 3.2 解读
|
||
|
||
**前 70 turn 完美稳定**:session 6880 在 decode-1 上正常运行 70 个 turn,每次都成功,是 baseline §1 "100% pin" 的复现——稳定但不公平(其他 session 没分到 decode-1 的资源)。
|
||
|
||
**第 71 turn 后崩溃**:
|
||
1. 某个瞬时 burst(其他 session 的活动?)让 decode-1 短暂饱和
|
||
2. session 6880 在 decode-1 上连续 3 次被 admission 拒(`no-space` 或 `d-session-cap`)
|
||
3. v1 的 `state.session_d_rejects[(6880, decode-1)]` 累积到 3 → blacklist
|
||
4. policy 改选 decode-0 → 同样发生 → blacklist
|
||
5. 改选 decode-2 → 同样 → blacklist
|
||
6. **3 D 全部 blacklisted** → degenerate fallback 在 3 D 间 round-robin
|
||
7. 每次 round-robin 又触发新 reject → 计数继续涨 → 永远在 thrashing 死循环
|
||
|
||
### 3.3 admission 数据交叉验证
|
||
|
||
中期 1932 admission events 解构:
|
||
|
||
| mode × can_admit × reason | count |
|
||
|---|---:|
|
||
| `direct_append, True, None` | 1721(成功)|
|
||
| `direct_append, False, session-not-resident` | **62** |
|
||
| `seed, True, None` | 142(成功)|
|
||
| `seed, False, no-space` | **11** |
|
||
|
||
**只有 11 个 "no-space" 才是真容量拒绝**(占总 admission 的 0.6%)。62 个 "session-not-resident" 是迁移后"新 D 第一次见你"的正常语义。
|
||
|
||
但因为 v1 用 `_is_admission_rejection_mode` 通过 execution_mode 子串匹配,下游 fallback chain 会把 `session-not-resident` 也间接累积到计数器(fallback 链路本身可能触发 session-cap)。
|
||
|
||
---
|
||
|
||
## 4. 设计 bug 三层
|
||
|
||
### 4.1 Bug 1:blacklist 永久性
|
||
|
||
```python
|
||
# policies.py 当前实现
|
||
if rejects >= self.migration_reject_threshold:
|
||
continue # skip this D forever
|
||
```
|
||
|
||
`session_d_rejects[(sess, D)]` 是单调递增 Counter。一旦达到阈值,**永远**被 skip。但 D 的容量是动态的——70 个 turn 后短暂饱和不代表它后续不能服务这个 session。
|
||
|
||
### 4.2 Bug 2:degenerate fallback 加剧问题
|
||
|
||
当所有 D 都被 blacklist:
|
||
```python
|
||
best_decode_worker_id = min(
|
||
(w.worker_id for w in topology.route_workers),
|
||
key=lambda wid: state.session_d_rejects.get((sess, wid), 0),
|
||
)
|
||
```
|
||
选"最少被拒"的 D。但每次 fallback 又增加该 D 的计数 → 下次选另一个 D → 形成完美 round-robin,永远走不出 thrashing。
|
||
|
||
### 4.3 Bug 3:信号归并粗糙
|
||
|
||
`_is_admission_rejection_mode` 子串匹配 `session-cap` / `no-d-capacity` / `d-backpressure`,但执行链路可能这样:
|
||
|
||
```
|
||
direct_append → session-not-resident(85% 占比,正常迁移后语义)
|
||
→ fallback 试 seed
|
||
→ seed admit ok(142/153 = 93%)→ execution_mode = pd-router-d-session-reseed-*(不计 reject)
|
||
→ seed no-space(11/153 = 7%)→ execution_mode = pd-router-fallback-X-no-d-capacity(计 reject)
|
||
```
|
||
|
||
绝大多数 fallback 不会触发 reject 计数。但 thrashing 一旦开始,很容易踩到那 7% no-space 路径,calculator 增长一次。15+ 次 thrashing 后,单 D 计数累到 3 完全可能。
|
||
|
||
**所以设计 bug 不在信号粗糙,而在永久累积 + degenerate round-robin。**
|
||
|
||
---
|
||
|
||
## 5. 深层观察:稳定 vs 公平的 trade-off
|
||
|
||
| | baseline(v0)| v1 |
|
||
|---|---|---|
|
||
| 公平性 | 18/52 永久饿死 | 0 永久饿死 |
|
||
| 稳定性 | 100% pin(结构稳定)| 6/29 严重 thrashing |
|
||
| Per-D 负载均衡 | ±26% | ±1.1% |
|
||
| 大 session 体验 | 慢但稳定(每 turn 都走 fallback ~1.0s)| 不稳定 + 频繁 D 切换 + 丢 KV state |
|
||
|
||
**预想反直觉的结果**:v1 在头部指标(per-D 均衡)赢,但在 session 体验可能输——
|
||
- baseline 的 fallback 路径有稳定 ~1s latency
|
||
- v1 的 thrashing session 每次 D 切换都 close 旧 session、丢 KV、新 D 上重新建立——有可能 latency 反而更高
|
||
|
||
需要等 run 结束的 lat mean / TTFT mean 数据验证。**糟糕的优化可能比不优化还糟。**
|
||
|
||
---
|
||
|
||
## 6. v2 设计
|
||
|
||
按 ROI 排序的修复层。**先做 #1,验证后再决定是否需要 #2/#3**。
|
||
|
||
### 6.1 v2-fix-1:reset-on-success(最高 ROI)
|
||
|
||
```python
|
||
# replay.py _run_request 末尾,在 state.finish 后
|
||
if execution.execution_mode == "kvcache-direct-to-d-session":
|
||
# 这次 direct-to-D 成功 = D-X 仍能服务这个 session
|
||
# 清零累积的 reject 计数(消除永久 blacklist)
|
||
state.session_d_rejects[(request.session_id, decision.decode_worker_id)] = 0
|
||
```
|
||
|
||
**预测效果**:
|
||
- session 6880 在 decode-1 上 70 个成功 turn 把计数反复清零
|
||
- 即使中间出现 1-2 次瞬时 reject,下次成功立刻清零
|
||
- 只有**持续**失败(reject 后 reject 后 reject,没有夹杂 success)才能累到阈值
|
||
- 真饿死的 session(如 35680/39360 input >92K)才会触发迁移
|
||
|
||
**工程量**:~5 行代码 + 1 个 smoke + 1 个完整 run(~5.5h)
|
||
|
||
### 6.2 v2-fix-2:sliding window(如果 #1 不够)
|
||
|
||
把 `Counter` 改成 `dict[(sess, D), deque[float]]` 存最近 K 次拒绝时间戳。判断时用最近 N 秒(或 N 个 turn)内的次数。
|
||
|
||
更稳健但更复杂。**若 #1 已能彻底解决 thrashing,跳过此项。**
|
||
|
||
### 6.3 v2-fix-3:reject 类型分离(如果 #1 + #2 不够)
|
||
|
||
把 admission reason 显式传到 _run_request,区分:
|
||
- `no-space` / `session-cap` / `backpressure` → 计 reject
|
||
- `session-not-resident` → 不计
|
||
|
||
需改 `ExecutionResult` 加 `admission_reject_reason` 字段,并在 fallback 链路传递。**不在第一轮**——先看 #1 是否够用。
|
||
|
||
### 6.4 v2 应保留的 v1 设计
|
||
|
||
- 阈值 3(不变)
|
||
- `record_admission_reject` 的子串匹配(不变)
|
||
- 新 fallback labels(`session-not-resident` 等)(不变)
|
||
- degenerate fallback 选最少拒的 D(不变,但因为 reset-on-success 几乎不会触发到此分支)
|
||
|
||
---
|
||
|
||
## 7. 实验计划
|
||
|
||
| 阶段 | 动作 | 时间 |
|
||
|---|---|---|
|
||
| 1 | 等 v1 run 完成(ETA ~16:30)| 自然 |
|
||
| 2 | 跑 analyzer 量化 v1 thrashing 实际代价 | 5 min |
|
||
| 3 | 实现 v2-fix-1(reset-on-success)| 30 min |
|
||
| 4 | smoke test | 10 min |
|
||
| 5 | 完整 v2 run(KVC 1P3D ts=1 N=1)| ~5.5h |
|
||
| 6 | 三方对比:baseline / v1 / v2 | 30 min |
|
||
| 7 | 决定是否需要 v2-fix-2 / v2-fix-3 | – |
|
||
|
||
---
|
||
|
||
## 8. 三方对比预测(待数据验证)
|
||
|
||
| 指标 | baseline(v0)| v1(thrashing)| **v2(self-healing 预测)** |
|
||
|---|---:|---:|---:|
|
||
| Errors | 5 | ? | 2-5(仅 35680/39360 等真容量超限)|
|
||
| Per-D 均衡 | ±26% | **±1.1%** | ±5-10%(部分 pin 仍 sticky)|
|
||
| Direct-to-D rate | 42.8% | ?(可能因 thrash 反而下降)| **65-75%**(持续 affinity,转换 §1 fallback)|
|
||
| Lat mean | 1.574s | ?(可能因 thrash 上升)| **1.30-1.45s**(达到 4DP 1.443s 水平)|
|
||
| TTFT mean | 0.244s | ? | **0.10-0.15s** |
|
||
| 最大 D-switches/session | 0 | 116 | <10(仅真饿死 session)|
|
||
| Sessions 永久饿死 | 18 | 0 | 2-3(仅真容量超限)|
|
||
|
||
预测核心:v2 应该结合 baseline 的稳定性(70-turn streak 应保留)+ v1 的公平性(无永久饿死),消除 v1 的 thrashing 副作用。
|
||
|
||
---
|
||
|
||
## 9. 局限与未验证
|
||
|
||
1. **v1 中期数据 (23%) 推测**:完整数据可能改变 thrashing 严重性的判断
|
||
2. **session 6880 trajectory 的崩溃机理是推断**:基于 admission events 数据 + streak 模式,但没有直接日志证明 reject 计数何时跨阈值(需要在 v2 加 instrument 输出)
|
||
3. **reset-on-success 的预测效果未验证**:基于"70 turn 成功" + "1-2 次瞬时 reject" 的假设;如果 burst 持续多 turn,仍可能跨阈值
|
||
4. **可能还有未发现的设计 bug**:v2 也许还会暴露新问题
|
||
5. **三方对比需 same trace + same scale + same ts=1**:baseline 已有 N=3,v1/v2 各 N=1(ts=1 确定性 → N=1 可信)
|
||
|
||
---
|
||
|
||
## 10. 给 TEAM_REPORT 和 REFACTOR_PLAN_V1 的更新建议
|
||
|
||
完成 v2 验证后:
|
||
|
||
1. 在 `TEAM_REPORT` §3 ts=1 验证更新章节加入 §3.3 "Migration mechanism evolution: v0 → v1 → v2"
|
||
2. 在 `REFACTOR_PLAN_V1` §6.2 标注实施反思——预设的 "rejection blacklist" 设计漏掉了 reset-on-success 这条
|
||
3. 在新文档 `docs/POLICY_DESIGN_PRINCIPLES_ZH.md` 提炼出原则:"任何会累积的代价机制必须配 healing/decay 机制,否则会陷入 self-amplifying 失效模式"
|
||
|
||
---
|
||
|
||
## 附录 A:本文数据来源
|
||
|
||
| 章节 | 数据源 |
|
||
|---|---|
|
||
| §2 | `outputs/qwen3-30b-tp1-ts1-migration-v1/kvcache-centric-*/` 中期日志 |
|
||
| §3.1 | `structural/session-d-binding.jsonl` 跨 turn 序列 |
|
||
| §3.3 | `structural/admission-events.jsonl` mode/reason 交叉表 |
|
||
|
||
## 附录 B:相关代码位置
|
||
|
||
| 内容 | 位置 |
|
||
|---|---|
|
||
| RoutingState.session_d_rejects | `src/agentic_pd_hybrid/policies.py:46` |
|
||
| KvAwarePolicy.select 跳过 blacklisted D | `src/agentic_pd_hybrid/policies.py:155-162` |
|
||
| Degenerate fallback 选最少拒的 D | `src/agentic_pd_hybrid/policies.py:184-192` |
|
||
| record_admission_reject 触发位置 | `src/agentic_pd_hybrid/replay.py:359-364`(_run_request) |
|
||
| _is_admission_rejection_mode 子串集合 | `src/agentic_pd_hybrid/replay.py` `_ADMISSION_REJECTION_SUBSTRINGS` |
|
||
| _fallthrough_reason 分类 | `src/agentic_pd_hybrid/replay.py` `_fallthrough_reason` |
|