# v5+Profile 调查报告(经 critic 审计修订版) **日期**: 2026-04-29(原稿)/ 2026-04-29(经审计修订) **实验配置**: Qwen3-30B-A3B (TP1)、单机 8×H100 80GB、trace = qwen35-swebench-50sess.jsonl (4449 reqs / 52 sessions)、time-scale=10、concurrency=32 **数据集**: `outputs/qwen3-30b-tp1-v5-optD-profile/`(EXP1 1P7D + EXP2 2P6D,均加入 1Hz `/server_info` 时序采样) **v5 baseline 对照**: `outputs/qwen3-30b-tp1-v5-optD/`(无 polling) **研究问题**: v5 (Option D) 把 errors 从 9-10% 降到 0.2%,但 session-cap fallback 反而升到 46-51%。fallback / errors 究竟来自哪里。 > **本稿是经过 hostile audit 后的修订版**。原稿包含若干结论性错误(尤其是对 `held_tokens` 语义的解读颠倒、对 admission race 的过度归因、对 polling 副作用的轻视)。审计意见保存在本会话记录中,关键纠错以 ⚠️ 标注。 --- ## TL;DR(已修订) 1. **真实容量**: 每张 D 的 `token_to_kv_pool_allocator.size = 92086 tokens (~92K)`。⚠️ 单 turn 真实 footprint **不是 50-100K**;`cached_tokens` p50=18K、p90=48K、p99=67K。原稿过度夸张。 2. **`other = capacity − held − available` 的解读已修订**: ⚠️ `held_tokens = sum(slot.kv_allocated_len − slot.cache_protected_len)`(代码:`session_aware_cache.py:278-282`),即"slot 拿到但**不在 radix tree 保护范围内**的部分"。所以 **`other` 的最大单一组成很可能是 radix-tree 保护的共享前缀缓存(prefix cache)** —— 这通常是想要的,**不是病态浪费**。原稿把 `other` 全归因为 running batch + 在途传输是错的。 3. **`other` 的双峰分布属实**(p50 ≈ 0,p90 ≈ 80K),但单凭 `cap−held−avail` 无法判断这是 radix-cache 自然累积、还是 burst 工作内存。**P1 的细分 instrument 必须先做**。 4. **errors 与 `other` 在时间上相关**属实,但**不能被解释为因果**。同一时段的多个变量(请求并发、in-flight transfer、可用空间)都在变化;无法仅凭时序对齐推断"`other` 吃掉了腾出来的空间"。 5. **EXP2 2P6D errors 9 → 415**:⚠️ **polling 被升级为 leading hypothesis**,而非"无关"。证据:执行模式呈 ~1:1 替换(`session-cap-fb` −356 / `kvcache-centric` +406),且 `/server_info` 不是被动读 —— 它在 scheduler 主循环内遍历每个 session slot 计算 `is_idle`。需要 P0 三次 baseline 复跑去伪。 6. **errors 集中在 18 个 session 上**(总共 52 个),每个 session 钉死在 1 个 D。per-D error rate 差异**无法解释为 D 的结构差别**,本质是 18 个"坏 session"如何被路由分配。 7. **v5+profile 1P7D 的延迟优于 baseline** 完全在 single-run variance 范围内。N=1,**不能作为任何性能结论**。 --- ## 1. 方法论 ### 1.1 Instrument 改动 - `src/agentic_pd_hybrid/replay.py` 加入 `_query_pool_snapshot` + `_poll_pool_timeseries`,后台 asyncio task 以 `--pool-poll-interval-s 1.0` 周期访问每个 P/D worker 的 `/server_info`。 - 每 tick 写一行 jsonl 到 `/d-pool-timeseries.jsonl`,字段:`{worker_id, worker_role, session_count, resident_session_count, held_tokens, available_tokens, capacity_tokens, idle_evictable_*, sessions[], kvcache_mem_gb, last_gen_throughput, ...}`。 - 分析脚本:`scripts/analysis/analyze_pool_timeseries.py`。 ### 1.2 字段定义(已修订 ⚠️) `/server_info` → `internal_states[0].session_cache` 的来源是 `session_controller.py:get_streaming_session_cache_status` → `tree_cache`(`SessionAwareCache`)。 | 字段 | 真实含义 | 备注 | |---|---|---| | `held_tokens` | `sum_over_slots(ceil(kv_allocated_len, page_size) − cache_protected_len)` | **不是** "session 在 cache 中占用的全部";只统计**slot-private、未被 radix tree 保护**的部分 | | `cache_protected_len` | radix tree 保护的共享前缀部分 | 多个 session 共享时只计一次 | | `available_tokens` | `token_to_kv_pool_allocator.available_size()` | 全局 KV 池剩余空间 | | `capacity_tokens` | `allocator.size` | 单 D 的总 KV 容量 = 92086 | | `idle_evictable_tokens` | held 中可被 LRU 立即踢的部分(session 所有 req finished + streaming 模式) | | 因此: - **`other = capacity − held − available`** 包含但不限于: - **radix-tree 保护的共享前缀 token**(可能是大头) ⚠️ 原稿遗漏 - 当前 running batch 占用的 KV slots - P→D 在途 transfer 的临时 buffer - mooncake 已注册但尚未提交到 tree_cache 的块 - 内部碎片 / allocator 元数据 **含义**: 在补充 P1 instrument 之前,我们**无法分辨** `other` 中"radix-cache"(良性)和"burst 工作集 / fragmentation"(可能病态)的比例。 ### 1.3 配置一致性与风险 - v5+profile 与 v5 baseline 唯一差别:加了 `--pool-poll-interval-s 1.0`(其余 CLI 参数完全一致)。 - **两次 run 时间间隔 ~21 小时**(2026-04-28 15:39/16:27 vs 2026-04-29 12:08/12:59)⚠️ 原稿误写 ~6h。同一台机,但 GPU 温度、PCIe、NUMA 分配未控制。 - **N=1 比较没有统计意义**;任何延迟差异 < 30% 都属于 single-run variance 合理范围。 --- ## 2. 整体性能对比 | 指标 | v5 1P7D | **v5+profile 1P7D** | v5 2P6D | **v5+profile 2P6D** | |---|---|---|---|---| | 总 requests | 4449 | 4449 | 4449 | 4449 | | **errors** | 9 (0.2%) | 6 (0.1%) | 9 (0.2%) | **415 (9.3%)** | | truncated | 42 | 43 | 42 | 42 | | direct-to-D | 44.7% | 54.9% | 41.3% | 41.1% | | session-cap fallback | 45.6% | 36.1% | 50.6% | 42.6% | | no-d-capacity | 1.2% | 0.7% | 0.8% | 0.6% | | pd-router-d-session-reseed | 4.8% | 4.3% | 3.4% | 2.9% | | pd-router-turn1-seed | 1.2% | 1.2% | 1.1% | 1.1% | | **kvcache-centric (failed mode)** | 0.2% (9) | 0.1% (6) | 0.2% (9) | **9.3% (415)** | | latency mean / p50 / p90 / p99 (s) | 5.18/1.59/14.7/26.1 | 4.21/1.18/11.3/28.8 | 3.49/1.31/9.1/24.9 | 3.23/1.11/8.4/20.3 | ⚠️ **不要从此表得出"v5+profile 改进了延迟"** —— N=1 single run,且 EXP2 引入了 415 个 errors 相当于换了一种回退策略,延迟均值的下降很可能只是**剔除了慢路径请求**的副作用。 ### 2.1 EXP2+profile 415 errors 解构(已修订) **Error type 分布**: | Error Type | 数量 | |---|---| | `RuntimeError: generate stream ended before producing any token` | 407 | | `ReadTimeout: ` | 8 | ⚠️ **关键约束**: - **414/415 个 error 的 `kv_transfer_blocks > 0`**(从 metrics jsonl 验证)。这些请求**已经过了 admission,P→D 传输已开始**,死于下游(server-side abort、流被关、生成阶段失败)。 - **`session_reused=False` 占 415/415**(全部是 seed,无一是 direct append)。 - **失败集中在 18 个 unique session**(top 5: 58080→decode-5 66 errs / 70560→decode-2 54 / 67200→decode-4 40 / 59200→decode-4 35 / 77280→decode-2 33),每个 session 钉死在一台 D。 **Per-D error rate(已修正百分比)**: | Decode Worker | Errors | Total Reqs | Error Rate | |---|---|---|---| | decode-0 | 56 | 758 | 7.4% | | decode-1 | 5 | 561 | 0.9% | | decode-2 | 141 | 858 | **16.4%** | | decode-3 | 0 | 838 | 0.0% | | decode-4 | 106 | 731 | 14.5% | | decode-5 | 107 | 703 | 15.2% | ⚠️ **不要解读为"decode-3 健康、decode-2 病态"**。每个 session 钉死在一台 D,18 个坏 session 是否落到某个 D 是路由分配的随机结果。**当前 N=1 数据无法分辨"D 结构差异"与"session 分配运气"**。 --- ## 3. D KV pool 时序分解(EXP1 1P7D 关键结果) 每张 D capacity=92086 tokens,运行 ~2696 秒(去掉前 10% 暖机): | Worker | mean_other | p50_other | p90_other | max_other | mean_held | mean_avail | |---|---:|---:|---:|---:|---:|---:| | decode-0 | 13599 | 63 | 77189 | 90959 | 47124 | 31363 | | decode-1 | 21242 | 0 | 76854 | 91074 | 37024 | 33820 | | decode-2 | 39333 | 46841 | 82782 | 91996 | 17381 | 35372 | | decode-3 | 30543 | 15864 | 81512 | 91511 | 9584 | 51959 | | decode-4 | 32659 | 32365 | 72995 | 92082 | 7643 | 51784 | | decode-5 | 31745 | 20366 | 86341 | 91211 | 11305 | 49036 | | decode-6 | 24602 | 701 | 82291 | 91000 | 20967 | 46517 | **已修订观察(去掉了原稿的过度归因)**: - **`other` 是双峰**(p50 接近 0,p90 接近 80K,mean 在 14-39K)。这一形态属实。 - **不同 D 的 mean_held / mean_other 差异巨大** —— 但⚠️ **不能直接归类为 "session-heavy" 或 "transfer-heavy"**,因为我们不知道 `other` 里 radix-cache vs 工作内存的比例。**P1 的拆分必做**。 - 由于 `held` 不包含 radix-protected token,`mean_held` 低**不代表**该 D 上 sessions 占用少 —— 只代表它们的"slot 私有部分"少;共享前缀可能很大,完全藏在 `other` 里。 ### 3.1 `other` 在某些时段持续高位(EXP1 decode-2 抽样) | t (s) | held | avail | other | sess_count | last_gen_throughput | |---:|---:|---:|---:|---:|---:| | 3 | 0 | 92086 | 0 | 0/0 | (未抽) | | 273 | 65310 | 26776 | 0 | 1/1 | (未抽) | | 543 | 15296 | 76589 | 201 | 1/1 | (未抽) | | 812 | 0 | 92086 | 0 | 0/0 | (未抽) | | 1082 | 52507 | 39579 | 0 | 1/1 | (未抽) | | 1351 | 40985 | 30175 | 20926 | 2/2 | (未抽) | | **1622** | **0** | 17703 | **74383** | **0/0** | **未核** | | 1891 | 0 | 46376 | 45710 | 0/0 | (未抽) | | 2161 | 0 | 27667 | 64419 | 0/0 | (未抽) | | 2430 | 0 | 62224 | 29862 | 0/0 | (未抽) | ⚠️ **t=1622 之后(约 30+ tick)持续 held=0/sess=0/other≈45-74K** —— 这种持久状态**不是 burst 工作集的形态**(burst 应是亚秒级)。更可能的解释包括: - 一个 stuck request 的 KV 块未能正常释放 - mooncake 注册但未 commit 的 transfer buffer 滞留 - 某个 cleanup 路径未触发 **未在原稿中验证 `last_gen_throughput`**,该字段记录在 timeseries 但未对齐分析。**P1 时一并补**。 --- ## 4. Errors 与 Saturation 时序相关性(EXP2 2P6D) ### 4.1 等数量 vs 等时间 decile(已修订 ⚠️) 原稿仅展示等时间分箱,有"第 10 decile 系统恢复"的视觉错觉。两种分箱并列: | Decile | 等时间(reqs / errs / rate) | 等数量(reqs / errs / rate) | |:---:|:---:|:---:| | 1 | 567 / 0 / 0.0% | 444 / 0 / 0.0% | | 2 | 268 / 0 / 0.0% | 445 / 0 / 0.0% | | 3 | 517 / 0 / 0.0% | 445 / 0 / 0.0% | | 4 | 189 / 0 / 0.0% | 445 / 0 / 0.0% | | 5 | 662 / 3 / 0.5% | 445 / 3 / 0.7% | | 6 | 417 / 27 / 6.5% | 445 / 28 / 6.3% | | 7 | 486 / 39 / 8.0% | 445 / 42 / 9.4% | | 8 | 612 / 177 / 28.9% | 445 / 114 / 25.6% | | 9 | 486 / 128 / 26.3% | 445 / 119 / 26.7% | | **10** | **245 / 41 / 16.7%** | **445 / 109 / 24.5%** | ⚠️ **第 10 decile 不是"系统恢复"**。等数量分箱显示 24.5% 的 error rate,与 decile 8/9 持平。原稿"恢复"叙事是分母 245 vs 612 造成的视觉假象。 ### 4.2 多重假设并列(已修订,不再独尊 admission race) 针对 EXP2 2P6D 415 errors 的可能机制(按当前数据强弱排序): **H1: Polling 引发 scheduler 时序扰动(leading hypothesis ⚠️)** - 证据:执行模式 1:1 替换(session-cap-fb −356 / kvcache-centric +406)。 - 证据:`/server_info` 进 scheduler 主循环遍历 session slot,1 Hz × 8 worker 不是 0 开销。 - 证伪条件:**P0(三次 baseline EXP2 复跑)如果都得到 ~9 errors,本假设确认**。 **H2: v5 自身存在 admission/transfer race** - v5 baseline 也出 9 个 errors(均为 ReadTimeout),说明该 race 在 baseline 已存在,profile 是被放大了。 - 证据弱化:原稿提的 "admission race"(admit_direct_append snapshot 过期)与数据冲突 —— **414/415 errors 的 `kv_transfer_blocks > 0`**,他们都过了 admission,死在下游。所以即便有 race,也不是发生在 admission 端,而是 P→D transfer 后 / 生成开始前。 **H3: 18 个特定 session 的工作负载结构性失败** - 18/52 session 集中失败,每个 session 都是高 turn_id (median=70)。 - 这些 session 可能 input 特别长,或某种 trace 结构会触发某个特定路径。 - 证伪条件:在 P0 三次 baseline 复跑后,看是否仍是同一组 18 个 session 失败。 **H4: 单次运行的 GPU/PCIe 状态扰动** - ~21 小时间隔,GPU 温度/clock 不同。 - 证伪条件:P0 三次 baseline 都 ~9 errors → 排除单次扰动主导。 ⚠️ **原稿独推 admission-race(H2)是错的**。当前数据无法决定 H1-H4 哪个是主因。 --- ## 5. 1P7D vs 2P6D 全局对比 | Config | total decode ticks | other p50 | other p90 | other>30K freq | other>50K freq | other>70K freq | held>60K freq | |---|---:|---:|---:|---:|---:|---:|---:| | 1P7D | 18865 | 663 | 79751 | 36.9% | 27.9% | 14.8% | 15.5% | | 2P6D | 14016 | 14459 | 77199 | 43.2% | 30.4% | 13.9% | 4.8% | ⚠️ **原稿"2P6D 的 p50_other 是 1P7D 的 22 倍 → 2P 推送压力更大"过度解读**。考虑分母效应:同一 trace 总工作量在 2P6D 由 6 张 D 分担 vs 1P7D 由 7 张 D 分担,**单 D 受到的压力本来就更大**,与 P 数无直接因果。这个数据只能说"2P6D 单 D 负担更高",**不能**得出"2P 在 transfer 上比 1P 更激进"。 --- ## 6. 关键解读(已大幅修订) ### 6.1 v5 真实瓶颈尚不明确 原稿声称"瓶颈是 D 的 KV pool 在压力期被 'other' 占据"。⚠️ **此结论已撤回**。给定 `held_tokens` 实际是 slot-private(non-tree)部分,`other` 的最大单一成分**很可能是正常的 radix-tree 共享前缀**。"被 running batch / 在途传输占据"是**未经验证的猜想**。需要 P1 的细分 instrument 才能给出真瓶颈。 ### 6.2 LRU eviction 的行为暂无可靠解读 原稿基于 mean_held 在压力期"暴跌"推断 LRU 在拼命踢。但 `held` 实际是 slot-private 部分,session 仍可能被 radix-tree 保留;`held` 减少不等于 session 被 evict,可能只是 `cache_protected_len` 比例变化。**P1 拆分前不下结论**。 ### 6.3 v5+profile 1P7D "比 baseline 快"是单次巧合 两次 run 间隔 ~21 小时(原稿误写 ~6h),GPU 温度/PCIe 状态未控制。**N=1**,任何性能差异 < 30% 都不可声称。 ### 6.4 EXP2 2P6D 415 errors:polling 是 leading suspect(已升级) 原稿把 polling 列为"次要可能"。⚠️ **现在升级为主嫌疑**: - 执行模式 1:1 替换(session-cap-fb −356 / kvcache-centric +406)说明 polling **改变了 admission 走哪条路**。 - `/server_info` 不是只读旁路 —— 调度内部循环 + 遍历 session slots 计算 `is_idle`。 - **必须做 P0 三次 baseline 复跑去伪**;在那之前不能动 v6。 ### 6.5 "Other" 在 P 上 90% 不是 backup blocks `prefill-0` 的 SessionAwareCache **未启用**(replay 数据 `held=0`),P 的 "other" 等于"P 全部 KV 使用量"(radix cache + running batch + 备份)。⚠️ 当前数据**无法分辨** prefill-backup-policy 是不是真的释放了。需在 P 加单独的 `prefill_backup_tokens` 字段。 --- ## 7. v6 行动项(已重排,以 P0 起步) ### **P0:验证 EXP2 errors=9 的可复现性**(最高优先级,先做) **操作**: 跑 3 次 v5 baseline EXP2(同 v5 配置,**不开 polling**),比较 error 分布。 - 如果 3 次都得到 ~9 errors → polling 被坐实为 415 暴涨主因。**必须把 polling 改成更轻量的形式**(如降低频率、改成 streaming push、或用 sidecar metrics 而非 HTTP poll)再做后续。 - 如果 3 次都得到 ~400 errors → polling 不是主因,415 是 v5 admission/transfer race + 单次 GPU 状态扰动的复合。 - 如果 3 次结果分布很广(如 9 / 50 / 400) → run-to-run variance 才是主导,任何 single-run 比较失效。 **预期工程量**: 1 个新 sweep 脚本(只跑 EXP2,3 次)+ ~3 × 50 min = ~2.5h GPU 时间。 **风险**: 0(纯重跑现有配置)。 ### **P1:把 D 的 `other` 拆开打表**(P0 跑的同时并行做代码) **操作**: 改 SGLang `scheduler.py:get_streaming_session_cache_status` 与 `session_aware_cache.py`,在返回的 dict 里加: - `radix_protected_tokens` = `sum(slot.cache_protected_len for slot in slots)` ⚠️ 这是原稿盲区,critic 暴露的关键缺失字段 - `running_batch_tokens` = `sum(req.fill_ids size for req in running_batch.reqs)` - `inflight_transfer_tokens` = `sum(req.size for req in disagg_decode_transfer_queue.queue)` - `prealloc_tokens` = `sum(req.size for req in disagg_decode_prealloc_queue.queue)` - `retracted_tokens` = `sum(req.size for req in disagg_decode_prealloc_queue.retracted_queue)` - `last_gen_throughput`(已有)更细 —— 加 `running_batch_size`(req 数) **预期收益**: `other_unaccounted = capacity − held − available − radix_protected − running_batch − inflight − prealloc − retracted` 应该接近 0。剩余的就是真"病态"内存。 **风险**: 低(纯只读 stat,不改 admission 逻辑)。 **工程量**: ~80 行 SGLang patch + 同步 replay.py 的 `_query_pool_snapshot` + analyzer。 ### **P2:如果 P0 暴露 polling 是主因,改 polling 实现** - 选项 A:把 `/server_info` 改成事件驱动 push(scheduler 在 step 末尾把 stats 写到环形缓冲区,polling 只读不进 scheduler 队列) - 选项 B:把 polling 频率从 1Hz 降到 5Hz/10s,在 P1 的拆分数据上验证够用 - 选项 C:scheduler 端加锁分离,把 stats 读和 admission 决策的临界区拆开 ### **P3(条件性,等 P0+P1 数据)**:决定真正的优化方向 原稿 §7 的 5 条优先级在 `other` 模型纠正后**全部需要重新评估**。等真实拆分数据出来再排。 --- ## 8. 局限与 Confounders(已扩充) 1. ⚠️ `held_tokens` 语义在原稿被解读颠倒,引发 `other` 的因果归因错误(已纠正,见 §1.2)。 2. `other` 字段是计算所得且**未细分**,无法直接归因。需要 P1 instrument 才能区分 radix-cache、running batch、inflight 等。 3. ⚠️ EXP2+profile 的 415 errors 与 baseline 9 errors **量级差异无法 deconfound**;polling 是 leading suspect 但未证实。**P0 是必经步骤**。 4. **N=1** 的实验配置:任何 v5+profile vs v5 baseline 的延迟/失败差异都属于 single-run variance 合理范围,**不能作为方向性结论**。 5. trace 是 single-shot,52 sessions × 4449 reqs 的特定结构可能放大某些路径。 6. `capacity = 92086` 是 `token_to_kv_pool_allocator.size`,来自 `mem_fraction_static`(未抽具体值),与"H100 80GB 的物理上限"差距是 SGLang 的安全裕量。 7. ⚠️ §3.1 t=1622 持续高 `other` 30+ tick 的现象 **未与 `last_gen_throughput` 交叉验证**;原稿"running batch + 在途传输"的解释是猜想而非证据。 8. ⚠️ 18/52 失败 session 的特征(turn_id、input 长度、prefix shape)**未做对比分析**;不能排除某个 session 类型本来就会触发某个固定 bug。 9. polling 频率 1Hz 错过亚秒级 burst —— `other` 的双峰可能比测到的更剧烈。 10. critic 指出 `pd-router-d-session-reseed` 在 EXP1 涨(193 vs 152)、EXP2 跌(127 vs 152)的反向移动**未在原稿分析**,这是 admission/路由 决策的清晰信号,应该在 P1 之后回看。 --- ## 9. 后续指令(已更新顺序) 1. **P0**: 跑 `scripts/sweep_tp1_v5_baseline_rerun_exp2.sh`,3 次 EXP2 baseline,无 polling。 2. **P1**: 同时改 SGLang 把 `other` 真正拆开。 3. 完成 P0+P1 后: - 重跑 EXP2 一次 + 新 instrument(同 polling),拿到 `other` 拆分。 - 对比 baseline-rerun 三次的 errors 分布。 - 决定是否回退 polling、调 admission、还是攻 specific 18 个 session 的工作负载特征。 4. 任何 v6 代码改动(优化 admission / eviction / transfer)**必须在 P0+P1 之后**。 --- ## 10. 数据产物 ``` outputs/qwen3-30b-tp1-v5-optD-profile/ ├── exp{1,2}_*_metrics.jsonl # 4449 行 / 实验 ├── exp{1,2}_*_summary.json ├── exp{1,2}_*_pool_timeseries.jsonl # 12 MB / 10 MB └── kvcache-centric-...20260429T{120847,125911}Z/ # 原始 run dir outputs/qwen3-30b-tp1-v5-optD/ # baseline 对照(N=1) └── exp{1,2}_1p7d_kvc_optD_* # 待 P0 产生: outputs/qwen3-30b-tp1-v5-optD-baseline-rerun/ └── exp2_2p6d_run{1,2,3}_* ``` 分析脚本:`scripts/analysis/analyze_pool_timeseries.py`(`--json` 拿机器可读输出)。