Compare commits
3 Commits
0b180c191e
...
7529284cee
| Author | SHA1 | Date | |
|---|---|---|---|
| 7529284cee | |||
| fafc44da79 | |||
| a2111b6e18 |
@@ -97,6 +97,18 @@ dash1 GPU 0 单 instance(无 kv_connector),chunked-prefill 默认开启,
|
||||
- MB1 + MB2 的合计 cost-benefit 在 phase isolation 维度上 PD-disagg 是赢的,**但这件事被容量天花板压倒**。
|
||||
- Paper §3.2 论证应该聚焦"D 池装不下",MB1/MB2 数据用作 supporting context(per-request transfer charge 量化、phase isolation benefit 量化)而不是 main argument。
|
||||
|
||||
> ✅ **2026-05-30 更新 — 干净栈三轴 ablation 证实本节、并加 regime 细化。**
|
||||
> 本节的容量论点(D 池容量天花板 / decode 减半)在修复 `e13391e` 污染后的 clean stack
|
||||
> 上**得到确认**:concurrency 轴 N=64 时 PD 倾覆,**producer APC 从 71% 崩到 1.4%、TPS −30%**,
|
||||
> 而 colo 线性 scale(Fig 3)。**细化**:PD 并非"在 agentic 上一律失败"——它在
|
||||
> *低负载 / decode-heavy / 低复用* 区间**赢** colo;真正失败的是 agentic corner(高复用 +
|
||||
> 短输出 + 大上下文 + 高并发)——静态 P:D split 无法同时给出复用所需的 producer 容量
|
||||
> *和* decode 容量,而 colo 的弹性池两者兼得。
|
||||
> **另注**:旧 MB5 文档(`PD_DISAGG_RESULTS.md` §6)"session-affinity 救不了 PD / PD 复用=0%"
|
||||
> 的结论是 `e13391e`(producer 每次 KV 传输后 evict prefix)的**污染产物,已撤回**;
|
||||
> 干净栈上 session 路由的 producer APC 与 colo 持平(71–82%)。
|
||||
> 图:[`figs/mb5_pd_ablation/`](figs/mb5_pd_ablation/)。
|
||||
|
||||
## 5. EAR 设计的实证状态(§4)
|
||||
|
||||
| Pillar | 已实证 | 待实证 |
|
||||
|
||||
1
analysis/mb5_pd_ablation/fig1.json
Normal file
1
analysis/mb5_pd_ablation/fig1.json
Normal file
File diff suppressed because one or more lines are too long
1
analysis/mb5_pd_ablation/fig2.json
Normal file
1
analysis/mb5_pd_ablation/fig2.json
Normal file
File diff suppressed because one or more lines are too long
1
analysis/mb5_pd_ablation/fig3.json
Normal file
1
analysis/mb5_pd_ablation/fig3.json
Normal file
File diff suppressed because one or more lines are too long
@@ -23,6 +23,22 @@ Per-request breakdown shows **87.7% of TTFT** is spent waiting for KV cache memo
|
||||
> Earlier cross-machine comparison (commit `1e86285`) was invalidated — baseline used warm instances. See REPORT.md §3.5.
|
||||
| **Delta** | **-45%** | **-36%** | **-44%** | **+30pp** |
|
||||
|
||||
> ✅⚠️ **2026-05-30 — confirmed + refined by the clean MB5 ablation; one caveat.**
|
||||
> A producer-side contamination (`e13391e`: evicts a producer's prefix-cache on every
|
||||
> KV transfer) was found in the *agentic-kv-fresh* MB5 stack and gated off; everything
|
||||
> was re-run clean.
|
||||
> - **Confirmed:** this doc's central thesis — PD's failure is a **decode-side KV memory
|
||||
> wall**, not transfer/prefill cost — is reproduced on the clean stack (concurrency
|
||||
> axis: at N=64 the split collapses, APC 71%→1.4%, TPS −30%; colo scales). Fig 3.
|
||||
> - **Refined:** "PD separation is net negative" is **regime-dependent**, not universal.
|
||||
> Clean ablation shows PD *wins* at low load / decode-heavy / low-reuse and loses the
|
||||
> **agentic corner** (high reuse + short output + large context + high concurrency).
|
||||
> - **Caveat (cross-check):** if this study's runs used the fork vLLM that contains
|
||||
> `e13391e`, any **producer prefix-cache / APC reuse** figures here (e.g. §5.3.1) may be
|
||||
> understated. The decode-memory-wall result is *not* reuse-dependent and is unaffected.
|
||||
> On the clean stack, session-routed producers reach APC parity with colo (71–82%).
|
||||
> Figures: [`figs/mb5_pd_ablation/`](../figs/mb5_pd_ablation/).
|
||||
|
||||
---
|
||||
|
||||
## 1. Workload Characterization
|
||||
|
||||
108
analysis/v2/PD_DISAGG_LMETRIC.md
Normal file
108
analysis/v2/PD_DISAGG_LMETRIC.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# PD-colo vs PD-disagg on the real agentic trace — LMetric (cache-aware) clean-stack anchor
|
||||
|
||||
**Figure:** [`figs/v2/fig4_lmetric_pd_vs_colo.png`](../../figs/v2/fig4_lmetric_pd_vs_colo.png)
|
||||
**Data:** [`analysis/v2/fig4l_lmetric.json`](fig4l_lmetric.json)
|
||||
**Date:** 2026-05-31 · Hardware: dash1, 8×H20 · Model: Qwen3-Coder-30B-A3B-Instruct
|
||||
· vLLM 0.18.1 (V1, chunked-prefill on, `e13391e` eviction gated **off**)
|
||||
· Mooncake 0.3.11 · Routing: cache-aware proxy with **`--policy lmetric`**
|
||||
· Replayer per-request timeout 600 s.
|
||||
|
||||
## TL;DR
|
||||
|
||||
On the production agentic trace with the *right* routing baseline (LMetric, cache-aware),
|
||||
**PD-colo (8× kv_both) keeps 100 % completion on both traces** and matches the daily-bench
|
||||
expectation (~17 min for the high-load first600s, ~50 min for the full trace, with E2E p50
|
||||
~3 s and TTFT p50 ~1 s — **3.5–7× better than the original §3 round-robin baseline at the
|
||||
same wall-clock**). Every static **PD-disagg ratio fails** (14–65 % completion), and the
|
||||
failure mode rotates predictably with the split — **no static partition has a working
|
||||
operating point on this workload**. LMetric improves colo dramatically; it does *not*
|
||||
rescue PD-disagg, confirming the bottleneck is structural (D-pool admission capacity +
|
||||
multi-turn KV accumulation), not routing.
|
||||
|
||||
## Setup
|
||||
|
||||
- Trace: `w600_r0.0015_st30.jsonl` (1214 reqs, 274 sessions, agentic multi-turn,
|
||||
contexts up to ~112 k tokens; "first600s" variant = same heavy sessions compressed
|
||||
into 600 s → 807 reqs at 3.2× higher arrival rate).
|
||||
- 8 instances on 8 GPUs.
|
||||
- `--mode baseline` for colo (plain vLLM); `--mode pdsep --pd-ratio P:D` for the three PD
|
||||
splits, all with Mooncake KV transfer.
|
||||
- Cache-aware proxy with LMetric scoring (`P_tokens × num_requests`) + session affinity
|
||||
for multi-turn (the colleague's canonical baseline).
|
||||
|
||||
## Results
|
||||
|
||||
### first600s (1.35 req/s, high-load stress)
|
||||
|
||||
| arm | success | E2E mean / p50 / p90 / p99 | TTFT p90 | TPOT p99 | TPS | wall |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **colo (8C)** | **807/807 = 100 %** | 11.1 / 3.27 / 28.6 / 95.9 s | 14.5 s | 388 ms | 226 | 17.0 min |
|
||||
| pd6 (6:2) | 474/807 = **58.7 %** | 83.2 / 6.75 / 382 / 542 s | 380 s | 19 ms | 40 | 55 min |
|
||||
| pd4 (4:4) | 348/807 = **43.1 %** | 203 / 215 / 477 / 575 s | 475 s | 25 ms | 15 | 114 min |
|
||||
| pd2 (2:6) | 180/807 = **22.3 %** | 380 / 536 / 579 / 602 s | 577 s | 18 ms | 34 | 321 min* |
|
||||
|
||||
### Full trace (0.42 req/s, original §3 anchor load)
|
||||
|
||||
| arm | success | E2E mean / p50 / p90 / p99 | TTFT p90 | TPOT p99 | TPS | wall |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **colo (8C)** | **1214/1214 = 100 %** | 10.9 / 3.13 / 29.6 / 93.7 s | 16.9 s | 254 ms | 125 | 49.9 min |
|
||||
| pd6 (6:2) | 793/1214 = **65.3 %** | 61.9 / 3.70 / 307 / 477 s | 300 s | 18 ms | 46 | 94 min |
|
||||
| pd4 (4:4) | 533/1214 = **43.9 %** | 131 / 8.22 / 468 / 531 s | 467 s | 21 ms | 13 | 231 min |
|
||||
| pd2 (2:6) | 169/1214 = **13.9 %** | 195 / 6.82 / 552 / 593 s | 549 s | 13 ms | 1 | 563 min |
|
||||
|
||||
\* The pd2 wall-clock is dominated by per-request timeouts (`request_timeout=600 s`)
|
||||
draining concurrently behind the multi-turn session causality.
|
||||
|
||||
## Five clean findings
|
||||
|
||||
1. **LMetric+colo is the right baseline.** Full-trace colo wall **49.9 min ≈ the original
|
||||
§3 RR's 49.9 min**, but E2E p50 **3.13 s vs §3's 10.8 s (3.5×)** and TTFT p50
|
||||
**1.02 s vs §3's 7.0 s (7×)**. Same throughput envelope, far better latency — by virtue
|
||||
of cache-aware routing concentrating each session's turns onto one instance for
|
||||
prefix-cache reuse. The original §3 RR was an *unfairly weak* colo baseline.
|
||||
|
||||
2. **Every static PD-disagg ratio fails on the agentic workload.** Completion drops to
|
||||
14–65 %, on *both* traces. The drop is not a high-load artifact (it holds at the
|
||||
original §3 arrival rate of 0.42 req/s); it is structural.
|
||||
|
||||
3. **Failure mode rotates predictably with the P:D split:**
|
||||
- **pd2 (2 producers)** → prefill-bound → 78–86 % TTFT timeouts.
|
||||
- **pd6 (2 decode)** → decode-admission-bound → 35–41 % TTFT timeouts.
|
||||
- **pd4 (4P+4D)** → both bottlenecks hit → 57 % TTFT timeouts.
|
||||
- **No static ratio works.** Colo's elastic 8-GPU pool absorbs whichever phase is
|
||||
hot at the moment.
|
||||
|
||||
4. **Decode isolation works, but doesn't matter under failure.** TPOT p99 on every PD
|
||||
arm is **13–25 ms** — an order of magnitude better than colo's 254–388 ms — but the
|
||||
win applies only to the 14–65 % of requests that get admitted. The other 35–86 %
|
||||
time out before ever seeing a first token, so the TPOT win is invisible to the end user.
|
||||
|
||||
5. **The §3 RR "100 % PD completion" was a measurement artifact.** Original §3
|
||||
(contaminated stack, RR routing) reported 100 % completion for pd6/pd4. LMetric on
|
||||
the clean stack shows 44–65 %. Most plausible mechanism: `e13391e`'s eviction of
|
||||
producer KV on every transfer acted as a **relief valve**, reducing producer-pool
|
||||
pressure and letting more requests squeeze under the 600 s timeout — at the (uncosted)
|
||||
price of cross-turn re-prefill. With the eviction off, producers retain prefix
|
||||
correctly → cache works on PD too → but the cache itself contends for producer
|
||||
pool capacity, and the decode-pool admission ceiling tips earlier. **PD-disagg is
|
||||
worse on agentic than §3 advertised, not better.**
|
||||
|
||||
## Reproduce
|
||||
|
||||
```bash
|
||||
# On dash1, from the main repo /home/admin/cpfs/wjh/agentic-kv:
|
||||
for TR in w600_r0.0015_st30.jsonl w600_r0.0015_st30_first600s.jsonl; do
|
||||
TRACE=traces/$TR bash scripts/bench.sh --tag fig4l_lmetric_colo_${TR%.*} \
|
||||
--mode baseline --policy lmetric
|
||||
for r in 6:2 4:4 2:6; do
|
||||
TRACE=traces/$TR bash scripts/bench.sh --tag fig4l_lmetric_${r/:/p}_${TR%.*} \
|
||||
--mode pdsep --pd-ratio $r --policy lmetric
|
||||
done
|
||||
done
|
||||
|
||||
python microbench/fresh_setup/plot_fig4l_lmetric.py
|
||||
```
|
||||
|
||||
Source `bench.sh` cleans GPUs before each arm and writes `metrics.jsonl` +
|
||||
`metrics.summary.json` per tag. Aggregation script: see the inline JSON dump used
|
||||
to build `analysis/v2/fig4l_lmetric.json`.
|
||||
1
analysis/v2/fig4l_lmetric.json
Normal file
1
analysis/v2/fig4l_lmetric.json
Normal file
@@ -0,0 +1 @@
|
||||
[{"tag": "fig4l_lmetric_colo_first600s", "arm": "colo", "trace": "first600s", "n": 807, "req": 807, "e2e": {"count": 807.0, "mean": 11.066699584425269, "p50": 3.27055042097345, "p90": 28.745733462180937, "p99": 97.40008939541167}, "ttft": {"count": 807.0, "mean": 5.119651803458883, "p50": 1.2114678020589054, "p90": 14.777630288852365, "p99": 50.68302261995841}, "tpot": {"count": 807.0, "mean": 0.03004899278845205, "p50": 0.009643197803618922, "p90": 0.042092699501536976, "p99": 0.3919741264067197}, "wall": 1020.5351374909515, "tps": 226.12940164644368}, {"tag": "fig4l_lmetric_colo_full", "arm": "colo", "trace": "full", "n": 1214, "req": 1214, "e2e": {"count": 1214.0, "mean": 10.928977524270508, "p50": 3.1279119075043127, "p90": 30.011970606888667, "p99": 94.77313101590481}, "ttft": {"count": 1214.0, "mean": 5.533819193267678, "p50": 1.017395684029907, "p90": 17.36427243486981, "p99": 51.49416554694993}, "tpot": {"count": 1214.0, "mean": 0.02049970290344434, "p50": 0.009544484575988867, "p90": 0.032480608771520716, "p99": 0.26057810739537074}, "wall": 2993.276069591986, "tps": 125.38402448497122}, {"tag": "fig4l_lmetric_pd2_first600s", "arm": "2P+6D", "trace": "first600s", "n": 180, "req": 807, "e2e": {"count": 180.0, "mean": 380.2505690135715, "p50": 535.6594606440049, "p90": 579.5011055286858, "p99": 601.5567972306756}, "ttft": {"count": 180.0, "mean": 378.7133691522933, "p50": 534.4269686369807, "p90": 577.3534130641376, "p99": 596.404559875431}, "tpot": {"count": 180.0, "mean": 0.007975266077679418, "p50": 0.007166497974743372, "p90": 0.012511071875514153, "p99": 0.017508981961061446}, "wall": 19275.367093455978, "tps": 1.8895100582735462}, {"tag": "fig4l_lmetric_pd2_full", "arm": "2P+6D", "trace": "full", "n": 169, "req": 1214, "e2e": {"count": 169.0, "mean": 194.88523891245458, "p50": 6.817620265996084, "p90": 552.1569225640735, "p99": 595.3934216396092}, "ttft": {"count": 169.0, "mean": 193.4153314989016, "p50": 5.60239192598965, "p90": 549.3611521873856, "p99": 582.4436428000824}, "tpot": {"count": 169.0, "mean": 0.007747395842651413, "p50": 0.007691574401794991, "p90": 0.011201243427351017, "p99": 0.013311375577245894}, "wall": 33770.57413210906, "tps": 0.9869539045920406}, {"tag": "fig4l_lmetric_pd4_first600s", "arm": "4P+4D", "trace": "first600s", "n": 348, "req": 807, "e2e": {"count": 348.0, "mean": 202.63302869595395, "p50": 214.03008900902933, "p90": 477.40967412578175, "p99": 576.6393926549597}, "ttft": {"count": 348.0, "mean": 199.96385804087797, "p50": 213.50966987549327, "p90": 475.7766476540827, "p99": 559.6153268160638}, "tpot": {"count": 348.0, "mean": 0.008873619369764751, "p50": 0.007645836479973812, "p90": 0.013845969236959285, "p99": 0.02567216653158788}, "wall": 6850.181333696004, "tps": 15.00296050477674}, {"tag": "fig4l_lmetric_pd4_full", "arm": "4P+4D", "trace": "full", "n": 533, "req": 1214, "e2e": {"count": 533.0, "mean": 130.94711188977982, "p50": 8.219856544979848, "p90": 473.44134307731883, "p99": 533.2597587251009}, "ttft": {"count": 533.0, "mean": 127.83193208824007, "p50": 4.8246813879814, "p90": 467.54664219671395, "p99": 528.8304683346115}, "tpot": {"count": 533.0, "mean": 0.008886429490232585, "p50": 0.007981476340708988, "p90": 0.013570741891233497, "p99": 0.023050950961825044}, "wall": 13884.384965199977, "tps": 12.621372890425038}, {"tag": "fig4l_lmetric_pd6_first600s", "arm": "6P+2D", "trace": "first600s", "n": 474, "req": 807, "e2e": {"count": 474.0, "mean": 83.15809065495806, "p50": 6.7270191764691845, "p90": 391.6558471220078, "p99": 544.7372293809171}, "ttft": {"count": 474.0, "mean": 80.70155321074382, "p50": 4.1273433425230905, "p90": 390.00296151017517, "p99": 539.0574236416071}, "tpot": {"count": 474.0, "mean": 0.008519881756330928, "p50": 0.00803907146806204, "p90": 0.012583933303093976, "p99": 0.018606097790947705}, "wall": 3325.2749515309697, "tps": 39.705588838364164}, {"tag": "fig4l_lmetric_pd6_full", "arm": "6P+2D", "trace": "full", "n": 793, "req": 1214, "e2e": {"count": 793.0, "mean": 61.907526705667, "p50": 3.69814173609484, "p90": 308.2633092067672, "p99": 477.48038318102715}, "ttft": {"count": 793.0, "mean": 59.25069201986225, "p50": 1.402295546955429, "p90": 302.5604081378088, "p99": 475.3738951798529}, "tpot": {"count": 793.0, "mean": 0.009137289999448822, "p50": 0.008635683270933276, "p90": 0.013065757584108427, "p99": 0.01816783740464599}, "wall": 5662.029295974993, "tps": 39.24494000021532}]
|
||||
19
figs/mb5/CORRECTION.md
Normal file
19
figs/mb5/CORRECTION.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# ⚠️ Correction notice for figs/mb5/ (2026-05-30)
|
||||
|
||||
These figures back `microbench/fresh_setup/PD_DISAGG_RESULTS.md`. A producer-side
|
||||
contamination was later found in the stack that produced them: commit **`e13391e`**
|
||||
(deployed over the "fresh" pip vLLM by `scripts/deploy_vllm_patches.sh`) evicts a
|
||||
producer's prefix-cache blocks on every KV transfer, so a disaggregated producer
|
||||
could never keep a session's prefix warm. It is now gated behind
|
||||
`VLLM_EVICT_SENT_BLOCKS` (default off) and everything was re-run clean.
|
||||
|
||||
| figure | section | status |
|
||||
|---|---|---|
|
||||
| `mb5_producer_hotspot.png` | §6.3 session-affinity hot-pinning | 🛑 **RETRACTED** — pure `e13391e` artifact. On the clean stack, session-routed producers reach APC parity with colo (71–82%); there is no 0%-util stall / hot-pin pathology. |
|
||||
| `mb5_latency_compare.png` | §3 round-robin headline | ✅ stands — RR's ~0% prefix-hit is a *routing* artifact (consecutive turns → different producers), not the eviction bug; reproduced clean. |
|
||||
| `mb5_kv_timeline.png`, `mb5_role_split.png`, `mb5_peak_utilization.png` | §5 per-role KV pool occupancy | ✅ D-pool capacity-ceiling mechanism stands (decode pegs while prefill strands). P-pool occupancy may read slightly low under eviction; the qualitative split is unaffected. |
|
||||
| `mb5_summary.csv` | aggregate | mixed — §3/§5 rows valid; any session-affinity rows superseded. |
|
||||
|
||||
**Superseded by the corrected three-axis ablation:** [`../mb5_pd_ablation/`](../mb5_pd_ablation/)
|
||||
(reuse / shape / concurrency), data in [`../../analysis/mb5_pd_ablation/`](../../analysis/mb5_pd_ablation/).
|
||||
Raw §6 data `analysis/mb5/session_prod.json` is contaminated; `analysis/mb5/rr_prod.json` (round-robin) stands.
|
||||
BIN
figs/mb5_pd_ablation/fig1_reuse_axis.png
Normal file
BIN
figs/mb5_pd_ablation/fig1_reuse_axis.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
figs/mb5_pd_ablation/fig2_shape_axis.png
Normal file
BIN
figs/mb5_pd_ablation/fig2_shape_axis.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
BIN
figs/mb5_pd_ablation/fig3_concurrency_axis.png
Normal file
BIN
figs/mb5_pd_ablation/fig3_concurrency_axis.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
figs/v2/fig4_lmetric_pd_vs_colo.png
Normal file
BIN
figs/v2/fig4_lmetric_pd_vs_colo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
@@ -10,6 +10,51 @@ Date: 2026-05-28 · Hardware: dash1, 8×GPU · Model: Qwen3-Coder-30B-A3B-Instru
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CORRECTION (2026-05-30) — read before §6
|
||||
|
||||
A contamination was found in the "fresh" vLLM used for the runs below.
|
||||
`scripts/deploy_vllm_patches.sh` had copied our fork commit **`e13391e`** over the
|
||||
pip-installed release; that commit calls `evict_blocks(sent_block_ids)` on
|
||||
`finished_sending`, i.e. it **evicts a producer's prefix-cache blocks on every KV
|
||||
transfer**. So a disaggregated producer could never keep a session's prefix warm,
|
||||
*regardless of routing*. We have since gated that behind `VLLM_EVICT_SENT_BLOCKS`
|
||||
(default off) and re-run everything on the corrected stack.
|
||||
|
||||
**Retracted (was a pure artifact of `e13391e`):**
|
||||
- **All of §6** ("smarter routing does not save PD" / "session-affinity is
|
||||
*strictly worse*" / "GPUs at ~0%" / "producer hot-pinning" / "producer prefix-hit
|
||||
~0.2%"). On the corrected stack, **session-affinity recovers producer reuse to
|
||||
full parity with colo (APC 71–82%)** — the collapse was the eviction bug starving
|
||||
the very cache session-affinity exists to fill, not a routing pathology.
|
||||
- The framing that PD reuse is "0% / fundamentally broken." PD reuses prefix
|
||||
*exactly as well as colo* once routing is session-sticky.
|
||||
|
||||
**Still stands (independent of `e13391e`):**
|
||||
- **§3 round-robin** numbers — RR sends consecutive turns to *different* producers,
|
||||
so its ~0% prefix-hit is a **routing** artifact (not the eviction bug) and is
|
||||
reproduced on the clean stack; RR PD still loses to 8C.
|
||||
- **§4** PD wins TPOT (decode isolation) — robust.
|
||||
- **§5.1** the consumer counter-underflow crash — a real, separate vLLM 0.18.1 bug.
|
||||
- **§5** the D-pool capacity-ceiling mechanism (decode side pegs while prefill
|
||||
strands) — real.
|
||||
|
||||
**Corrected verdict (the real reason PD loses on agentic).** It is *not* "routing
|
||||
can't help." On the clean stack PD is **regime-dependent**: it *wins* at low
|
||||
load / decode-heavy / low-reuse, and loses the **agentic corner** (high reuse +
|
||||
short output + large context + high concurrency) through a structural crossover —
|
||||
its static P:D split cannot simultaneously provide the prefix-cache capacity
|
||||
(needs many producers) *and* the decode capacity (needs many decoders) that
|
||||
agentic demands at once, while colo's elastic pool provides both. See the
|
||||
three-axis ablation: **reuse** erodes the edge (1.57×→1.10×), **shape** rotates the
|
||||
best ratio and is catastrophic at the prefill extreme, and **concurrency** tips PD
|
||||
at N=64 (APC craters 71%→1.4%, TPS −30%) while colo scales cleanly.
|
||||
|
||||
→ Figures: [`figs/mb5_pd_ablation/`](../../figs/mb5_pd_ablation/) ·
|
||||
data: [`analysis/mb5_pd_ablation/`](../../analysis/mb5_pd_ablation/) ·
|
||||
the clean re-run of *this exact* w600 experiment (ratio-swept) is the Fig 4 anchor.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (verdict)
|
||||
|
||||
**No static prefill/decode split beats 8-way colocation (8C) on this agentic
|
||||
@@ -205,6 +250,15 @@ single failed request, which is required to compare routing arms fairly in §6.
|
||||
|
||||
## 6. The routing handicap — and whether smarter routing rescues PD
|
||||
|
||||
> 🛑 **RETRACTED (2026-05-30) — this entire section is an artifact of `e13391e`.**
|
||||
> The session-affinity runs below were starved by the producer-eviction bug, so
|
||||
> they could never collect prefix-cache reuse. On the corrected stack
|
||||
> session-affinity reaches **APC parity with colo (71–82%)** and does *not* stall
|
||||
> at 0% GPU util. The real mechanism is the capacity/concurrency crossover, not a
|
||||
> routing pathology — see the CORRECTION banner at the top and
|
||||
> [`figs/mb5_pd_ablation/`](../../figs/mb5_pd_ablation/). Text kept below for the
|
||||
> record only.
|
||||
|
||||
Every PD config above shows **prefix-cache hit = 0%**, versus 8C's 19%. That
|
||||
is not fundamental to disaggregation — it is the stock proxy round-robining
|
||||
the **prefill** side: consecutive turns of one agentic session land on
|
||||
|
||||
140
microbench/fresh_setup/fig_agg.py
Normal file
140
microbench/fresh_setup/fig_agg.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Aggregate a set of MB5 run dirs into one comparison table.
|
||||
|
||||
Pulls the three core metrics the analysis cares about, per run:
|
||||
- E2E latency (from replay_metrics.summary.json: latency_stats_s)
|
||||
- TPS (output tokens / wall_clock_s)
|
||||
- GPU util by workers (gpu_util.csv over run_window, split prefill/decode by role)
|
||||
plus honest reuse (producer-side APC from instance_apc.txt) and TTFT/TPOT for logs.
|
||||
|
||||
Arm + GPU role split + producer APC ports are inferred from the dir name:
|
||||
*_colo_* -> 8 kv_both ; apc ports 8000-8007 (all keep prefix)
|
||||
*_pd6_* -> 6P+2D P0-5/D6-7 ; apc 8000-8005
|
||||
*_pd_* -> 4P+4D P0-3/D4-7 ; apc 8000-8003 (note: "pd" not "pd4")
|
||||
*_pd2_* -> 2P+6D P0-1/D2-7 ; apc 8000-8001
|
||||
|
||||
Usage: fig_agg.py <run_dir> [<run_dir> ...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def arm_of(name: str):
|
||||
# New driver naming (run_conc.sh / run_reuse_fixed.sh): "..._<CONFIG>_rep<r>".
|
||||
if "8C-proxy" in name:
|
||||
return "colo", list(range(8)), [], list(range(8000, 8008))
|
||||
if "6P+2D" in name:
|
||||
return "6P+2D", [0, 1, 2, 3, 4, 5], [6, 7], list(range(8000, 8006))
|
||||
if "2P+6D" in name:
|
||||
return "2P+6D", [0, 1], [2, 3, 4, 5, 6, 7], list(range(8000, 8002))
|
||||
if "4P+4D" in name:
|
||||
return "4P+4D", [0, 1, 2, 3], [4, 5, 6, 7], list(range(8000, 8004))
|
||||
# Legacy naming (original May-30 corrected runs).
|
||||
if "_colo_" in name or name.endswith("_colo"):
|
||||
return "colo", list(range(8)), [], list(range(8000, 8008))
|
||||
if "_pd6_" in name:
|
||||
return "6P+2D", [0, 1, 2, 3, 4, 5], [6, 7], list(range(8000, 8006))
|
||||
if "_pd2_" in name:
|
||||
return "2P+6D", [0, 1], [2, 3, 4, 5, 6, 7], list(range(8000, 8002))
|
||||
if "_pd4_" in name or "_pd_" in name:
|
||||
return "4P+4D", [0, 1, 2, 3], [4, 5, 6, 7], list(range(8000, 8004))
|
||||
return "?", list(range(8)), [], list(range(8000, 8008))
|
||||
|
||||
|
||||
def util_split(run: Path, pgpus, dgpus):
|
||||
win = {}
|
||||
wp = run / "run_window.json"
|
||||
if wp.exists():
|
||||
win = json.load(open(wp))
|
||||
t0, t1 = win.get("t_start_unix"), win.get("t_end_unix")
|
||||
csvp = run / "gpu_util.csv"
|
||||
if not csvp.exists():
|
||||
return None, None
|
||||
by = {}
|
||||
for row in csv.DictReader(open(csvp)):
|
||||
try:
|
||||
ts = float(row["timestamp"]); g = int(row["gpu"]); u = float(row["util_pct"])
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if t0 and not (t0 <= ts <= t1):
|
||||
continue
|
||||
by.setdefault(g, []).append(u)
|
||||
pm = [v for g in pgpus for v in by.get(g, [])]
|
||||
dm = [v for g in dgpus for v in by.get(g, [])]
|
||||
return (statistics.fmean(pm) if pm else None,
|
||||
statistics.fmean(dm) if dm else None)
|
||||
|
||||
|
||||
def apc(run: Path, ports):
|
||||
f = run / "instance_apc.txt"
|
||||
if not f.exists():
|
||||
return None
|
||||
q = h = 0
|
||||
for line in open(f):
|
||||
m = dict(re.findall(r"(\w+)=(\S+)", line))
|
||||
try:
|
||||
p = int(m.get("port", -1))
|
||||
except ValueError:
|
||||
continue
|
||||
if p in ports:
|
||||
q += float(m.get("queries", 0)); h += float(m.get("hits", 0))
|
||||
return (h / q) if q else None
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
as_json = False
|
||||
if "--json" in args:
|
||||
as_json = True
|
||||
args = [a for a in args if a != "--json"]
|
||||
rows = []
|
||||
for d in args:
|
||||
run = Path(d)
|
||||
sp = run / "replay_metrics.summary.json"
|
||||
if not sp.exists():
|
||||
continue
|
||||
s = json.load(open(sp))
|
||||
arm, pg, dg, ports = arm_of(run.name)
|
||||
lat = s.get("latency_stats_s", {})
|
||||
ttft = s.get("ttft_stats_s", {})
|
||||
tpot = s.get("tpot_stats_s", {})
|
||||
wall = s.get("wall_clock_s") or 1.0
|
||||
out = s.get("actual_output_tokens_stats", {})
|
||||
n = s.get("success_count", 0); req = s.get("request_count", 0)
|
||||
tot_out = out.get("count", 0) * out.get("mean", 0)
|
||||
tps = tot_out / wall
|
||||
pu, du = util_split(run, pg, dg)
|
||||
a = apc(run, ports)
|
||||
rows.append({
|
||||
"name": run.name, "arm": arm, "n": n, "req": req,
|
||||
"e2e_p50": lat.get("p50"), "e2e_p90": lat.get("p90"), "e2e_p99": lat.get("p99"),
|
||||
"e2e_mean": lat.get("mean"),
|
||||
"ttft_p90": ttft.get("p90"), "tpot_p99": tpot.get("p99"),
|
||||
"tps": tps, "wall": wall, "pu": pu, "du": du, "apc": a,
|
||||
})
|
||||
|
||||
if as_json:
|
||||
print(json.dumps(rows))
|
||||
return
|
||||
|
||||
def f(x, w=7, p=1):
|
||||
return f"{x:>{w}.{p}f}" if isinstance(x, (int, float)) else f"{'-':>{w}}"
|
||||
|
||||
hdr = (f"{'run':<34}{'arm':>7}{'ok/req':>9}{'E2Ep50':>8}{'E2Ep90':>8}{'E2Ep99':>8}"
|
||||
f"{'TPS':>8}{'Putil':>7}{'Dutil':>7}{'APC%':>7}{'TTFTp90':>9}{'TPOTp99ms':>10}")
|
||||
print(hdr); print("-" * len(hdr))
|
||||
for r in sorted(rows, key=lambda r: r["name"]):
|
||||
print(f"{r['name']:<34}{r['arm']:>7}{str(r['n'])+'/'+str(r['req']):>9}"
|
||||
f"{f(r['e2e_p50'])}{f(r['e2e_p90'])}{f(r['e2e_p99'])}"
|
||||
f"{f(r['tps'],8,1)}{f(r['pu'])}{f(r['du'])}"
|
||||
f"{f((r['apc'] or 0)*100)}{f(r['ttft_p90'],9,2)}"
|
||||
f"{f((r['tpot_p99'] or 0)*1000,10,1)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
71
microbench/fresh_setup/gpu_util_report.py
Normal file
71
microbench/fresh_setup/gpu_util_report.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Per-GPU utilization report from gpu_util.csv (companion to bench_report.py).
|
||||
|
||||
bench_report's per-worker GPU util needs request routing (breakdown.json), which
|
||||
the MB5 proxy doesn't log. But worker == GPU by index, and the prefill/decode role
|
||||
split is fixed by config, so per-GPU util from gpu_util.csv directly answers
|
||||
"GPU utils by workers" — and for PD it exposes the key signal: are the prefill-side
|
||||
GPUs saturated while the decode-side idles (or vice versa, or stalled at ~0)?
|
||||
|
||||
Usage:
|
||||
gpu_util_report.py <run_dir> [--prefill-gpus 0,1,2,3 --decode-gpus 4,5,6,7]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def pct(xs, p):
|
||||
xs = sorted(xs)
|
||||
return xs[max(0, min(len(xs) - 1, int(round(p / 100 * (len(xs) - 1)))))] if xs else None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("run_dir", type=Path)
|
||||
ap.add_argument("--prefill-gpus", default="")
|
||||
ap.add_argument("--decode-gpus", default="")
|
||||
a = ap.parse_args()
|
||||
|
||||
win = {}
|
||||
wp = a.run_dir / "run_window.json"
|
||||
if wp.exists():
|
||||
win = json.load(open(wp))
|
||||
t0, t1 = win.get("t_start_unix"), win.get("t_end_unix")
|
||||
|
||||
csvp = a.run_dir / "gpu_util.csv"
|
||||
if not csvp.exists():
|
||||
print(f"{a.run_dir.name}: gpu_util.csv absent"); return
|
||||
by_gpu = {}
|
||||
for row in csv.DictReader(open(csvp)):
|
||||
try:
|
||||
ts = float(row["timestamp"]); g = int(row["gpu"]); u = float(row["util_pct"]); m = float(row["mem_used_mb"])
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if t0 and not (t0 <= ts <= t1):
|
||||
continue
|
||||
by_gpu.setdefault(g, {"u": [], "m": []})
|
||||
by_gpu[g]["u"].append(u); by_gpu[g]["m"].append(m)
|
||||
|
||||
print(f"=== {a.run_dir.name}: per-GPU util over replay window ({sum(len(d['u']) for d in by_gpu.values())} samples) ===")
|
||||
print(f"{'gpu':>4}{'util_mean':>11}{'util_p90':>10}{'util_max':>10}{'mem_max_GB':>12}")
|
||||
for g in sorted(by_gpu):
|
||||
u, m = by_gpu[g]["u"], by_gpu[g]["m"]
|
||||
print(f"{g:>4}{statistics.fmean(u):>11.1f}{pct(u,90):>10.1f}{max(u):>10.1f}{max(m)/1024:>12.1f}")
|
||||
|
||||
def agg(gpus, label):
|
||||
gpus = [int(x) for x in gpus.split(",") if x != ""]
|
||||
us = [v for g in gpus for v in by_gpu.get(g, {}).get("u", [])]
|
||||
if us:
|
||||
print(f" {label:<14} gpus={gpus} util mean={statistics.fmean(us):.1f}% p90={pct(us,90):.1f}% max={max(us):.1f}%")
|
||||
if a.prefill_gpus:
|
||||
agg(a.prefill_gpus, "prefill-side")
|
||||
if a.decode_gpus:
|
||||
agg(a.decode_gpus, "decode-side")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -69,6 +69,13 @@ run_one() {
|
||||
source "${VENV}/bin/activate"
|
||||
local replay_out="${rundir}/replay_metrics.jsonl"
|
||||
mkdir -p "$(dirname "${replay_out}")"
|
||||
# bench_report.py inputs: worker->gpu map (worker i == gpu i for every config;
|
||||
# for PD, workers 0-3 are producers on gpu0-3, 4-7 consumers on gpu4-7).
|
||||
printf '{"base_port":8000,"n_instances":8,"gpu_indices":[0,1,2,3,4,5,6,7]}\n' \
|
||||
> "${rundir}/bench_config.json"
|
||||
# per-GPU utilization timeseries over the replay window (2s sampling)
|
||||
bash "${SCRIPT_DIR}/gpu_monitor.sh" "${rundir}/gpu_util.csv" 2 >/dev/null 2>&1 &
|
||||
local GPU_MON=$!
|
||||
local t0
|
||||
t0=$(date +%s.%N)
|
||||
if ! PYTHONPATH="${FRESH_ROOT}" python -m replayer \
|
||||
@@ -82,6 +89,7 @@ run_one() {
|
||||
t1=$(date +%s.%N)
|
||||
local wall=$(python -c "print(${t1} - ${t0})")
|
||||
echo "[mb5-run] REPLAY FAILED after ${wall} s; see ${OUT_ROOT}/${config}_rep${rep}_replay.log"
|
||||
kill "${GPU_MON}" 2>/dev/null || true
|
||||
bash "${LAUNCH}" stop > /dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
@@ -91,6 +99,9 @@ run_one() {
|
||||
wall_clock_s=$(python -c "print(${t1} - ${t0})")
|
||||
echo "[mb5-run] replay done in ${wall_clock_s}s"
|
||||
echo "${wall_clock_s}" > "${rundir}/wall_clock_s.txt"
|
||||
kill "${GPU_MON}" 2>/dev/null || true
|
||||
printf '{"t_start_unix":%s,"t_end_unix":%s}\n' "${t0}" "${t1}" > "${rundir}/run_window.json"
|
||||
cp -f "${replay_out}" "${rundir}/metrics.jsonl" # bench_report.py expects metrics.jsonl
|
||||
|
||||
# Per-instance prefix-cache counters, scraped from each backend BEFORE
|
||||
# teardown. For PD this is the only honest reuse signal: producer ports
|
||||
|
||||
98
microbench/fresh_setup/partial_summary.py
Normal file
98
microbench/fresh_setup/partial_summary.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Compute a per-run summary directly from replay_metrics.jsonl (for partial / in-flight runs).
|
||||
|
||||
Used when the replayer hasn't completed (so replay_metrics.summary.json doesn't exist
|
||||
yet) but enough records have streamed to disk to read out the per-arm result.
|
||||
|
||||
Also accepts a finished run's directory and prints the same one-line summary for
|
||||
apples-to-apples comparison.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def stats(xs):
|
||||
xs = sorted(xs)
|
||||
n = len(xs)
|
||||
if n == 0:
|
||||
return None
|
||||
return {
|
||||
"n": n,
|
||||
"mean": statistics.fmean(xs),
|
||||
"p50": xs[n // 2],
|
||||
"p90": xs[int(0.9 * (n - 1))],
|
||||
"p99": xs[int(0.99 * (n - 1))],
|
||||
}
|
||||
|
||||
|
||||
def apc(run: Path, producer_ports):
|
||||
f = run / "instance_apc.txt"
|
||||
if not f.exists():
|
||||
return None
|
||||
q = h = 0.0
|
||||
for line in open(f):
|
||||
m = dict(re.findall(r"(\w+)=(\S+)", line))
|
||||
try:
|
||||
p = int(m.get("port", -1))
|
||||
except ValueError:
|
||||
continue
|
||||
if p in producer_ports:
|
||||
q += float(m.get("queries", 0))
|
||||
h += float(m.get("hits", 0))
|
||||
return (h / q) if q else None
|
||||
|
||||
|
||||
def main():
|
||||
for d in sys.argv[1:]:
|
||||
run = Path(d)
|
||||
# prefer the live replay_metrics.jsonl (so partials work); fall back to metrics.jsonl
|
||||
for fn in ("replay_metrics.partial.jsonl", "replay_metrics.jsonl", "metrics.jsonl"):
|
||||
p = run / fn
|
||||
if p.exists():
|
||||
rec_path = p
|
||||
break
|
||||
else:
|
||||
print(f"{run.name}: no records"); continue
|
||||
recs = [json.loads(l) for l in open(rec_path)]
|
||||
oks = [r for r in recs if r.get("error") is None]
|
||||
lat = stats([r["latency_s"] for r in oks if "latency_s" in r])
|
||||
ttft = stats([r["ttft_s"] for r in oks if "ttft_s" in r])
|
||||
tpot = stats([r["tpot_s"] for r in oks if "tpot_s" in r])
|
||||
out = sum(r.get("actual_output_tokens", r.get("output_length", 0)) for r in oks)
|
||||
ts = [r["t_dispatch_unix"] for r in oks if "t_dispatch_unix" in r]
|
||||
tf = [r["t_finish_unix"] for r in oks if "t_finish_unix" in r]
|
||||
span = max(tf) - min(ts) if ts and tf else 0
|
||||
tps = out / span if span else 0
|
||||
|
||||
# producer ports by arm tag in dirname
|
||||
n = run.name
|
||||
if "_colo_" in n:
|
||||
ports = list(range(8000, 8008))
|
||||
elif "_pd6_" in n:
|
||||
ports = list(range(8000, 8006))
|
||||
elif "_pd2_" in n:
|
||||
ports = list(range(8000, 8002))
|
||||
else:
|
||||
ports = list(range(8000, 8004))
|
||||
a = apc(run, ports)
|
||||
|
||||
print(f"{run.name}")
|
||||
print(f" n_ok={len(oks)}/{len(recs)}"
|
||||
+ (f" (target=1214 -> {len(oks)*100/1214:.1f}%)" if len(recs) < 1214 else ""))
|
||||
if lat:
|
||||
print(f" E2E mean={lat['mean']:.2f} p50={lat['p50']:.2f} p90={lat['p90']:.2f} p99={lat['p99']:.2f}")
|
||||
if ttft:
|
||||
print(f" TTFT mean={ttft['mean']:.2f} p50={ttft['p50']:.2f} p90={ttft['p90']:.2f} p99={ttft['p99']:.2f}")
|
||||
if tpot:
|
||||
print(f" TPOT mean={tpot['mean']*1000:.1f}ms p90={tpot['p90']*1000:.1f}ms p99={tpot['p99']*1000:.1f}ms")
|
||||
print(f" output_tokens={out:.0f} span={span:.0f}s TPS={tps:.0f}")
|
||||
if a is not None:
|
||||
print(f" producer APC={a*100:.1f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
microbench/fresh_setup/plot_fig4l_lmetric.py
Normal file
113
microbench/fresh_setup/plot_fig4l_lmetric.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Render the LMetric PD-colo vs PD-disagg figure on the real agentic trace.
|
||||
|
||||
Input : analysis/v2/fig4l_lmetric.json (8 arms = 4 ratios x 2 traces)
|
||||
Output : figs/v2/fig4_lmetric_pd_vs_colo.png
|
||||
|
||||
Four panels x four ratios x two traces:
|
||||
(a) completion rate %
|
||||
(b) E2E latency (mean / p50 / p90)
|
||||
(c) throughput (output tokens / second)
|
||||
(d) bench wall-clock seconds
|
||||
|
||||
The thesis the figure visualizes: with LMetric routing,
|
||||
- colo (elastic 8-GPU pool) holds 100% completion on both traces
|
||||
- every PD-disagg ratio fails (completion 14-65%), and the failure mode
|
||||
rotates with the split (pd2 = prefill-bound, pd6 = decode-bound)
|
||||
- routing policy does not rescue PD-disagg; the bottleneck is structural.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA = ROOT / "analysis" / "v2" / "fig4l_lmetric.json"
|
||||
OUT = ROOT / "figs" / "v2" / "fig4_lmetric_pd_vs_colo.png"
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ARMS = ["colo", "6P+2D", "4P+4D", "2P+6D"] # decode-rich -> prefill-rich
|
||||
TRACES = ["first600s", "full"]
|
||||
TRACE_LABEL = {"first600s": "first600s (1.35 req/s, high load)",
|
||||
"full": "full w600 (0.42 req/s, original §3)"}
|
||||
COLOR = {"first600s": "#1f77b4", "full": "#ff7f0e"}
|
||||
|
||||
|
||||
def pick(rows, trace, arm):
|
||||
for r in rows:
|
||||
if r["trace"] == trace and r["arm"] == arm:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
rows = json.load(open(DATA))
|
||||
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
|
||||
width = 0.38
|
||||
x = np.arange(len(ARMS))
|
||||
|
||||
# (a) completion %
|
||||
ax = axes[0, 0]
|
||||
for i, tr in enumerate(TRACES):
|
||||
vals = [pick(rows, tr, a)["n"] / pick(rows, tr, a)["req"] * 100 for a in ARMS]
|
||||
bars = ax.bar(x + (i - 0.5) * width, vals, width, color=COLOR[tr], label=TRACE_LABEL[tr])
|
||||
for bx, bv in zip(x + (i - 0.5) * width, vals):
|
||||
ax.annotate(f"{bv:.0f}%", (bx, bv + 1.5), ha="center", fontsize=8)
|
||||
ax.axhline(100, color="grey", ls=":", lw=1)
|
||||
ax.set_xticks(x); ax.set_xticklabels(ARMS)
|
||||
ax.set_ylabel("completion (%)"); ax.set_ylim(0, 115)
|
||||
ax.set_title("(a) request completion — colo holds 100%, all PD ratios fail")
|
||||
ax.legend(fontsize=8); ax.grid(alpha=.3, axis="y")
|
||||
|
||||
# (b) E2E percentiles
|
||||
ax = axes[0, 1]
|
||||
for i, tr in enumerate(TRACES):
|
||||
p50 = [pick(rows, tr, a)["e2e"]["p50"] for a in ARMS]
|
||||
p90 = [pick(rows, tr, a)["e2e"]["p90"] for a in ARMS]
|
||||
off = (i - 0.5) * width
|
||||
ax.bar(x + off, p90, width, color=COLOR[tr], alpha=0.55, label=f"{tr} p90")
|
||||
ax.bar(x + off, p50, width, color=COLOR[tr], alpha=1.0, label=f"{tr} p50")
|
||||
ax.axhline(600, color="red", ls=":", lw=1, label="600 s request timeout")
|
||||
ax.set_xticks(x); ax.set_xticklabels(ARMS)
|
||||
ax.set_ylabel("E2E latency (s, log)"); ax.set_yscale("log")
|
||||
ax.set_title("(b) E2E p50 (solid) + p90 (faded) — PD pegs at the timeout")
|
||||
ax.legend(fontsize=7, ncol=2); ax.grid(alpha=.3, which="both", axis="y")
|
||||
|
||||
# (c) TPS
|
||||
ax = axes[1, 0]
|
||||
for i, tr in enumerate(TRACES):
|
||||
vals = [pick(rows, tr, a)["tps"] for a in ARMS]
|
||||
ax.bar(x + (i - 0.5) * width, vals, width, color=COLOR[tr], label=TRACE_LABEL[tr])
|
||||
for bx, bv in zip(x + (i - 0.5) * width, vals):
|
||||
ax.annotate(f"{bv:.0f}", (bx, bv + 4), ha="center", fontsize=8)
|
||||
ax.set_xticks(x); ax.set_xticklabels(ARMS)
|
||||
ax.set_ylabel("throughput (output tokens/s)")
|
||||
ax.set_title("(c) throughput — PD throughput crashes 5–100×")
|
||||
ax.legend(fontsize=8); ax.grid(alpha=.3, axis="y")
|
||||
|
||||
# (d) wall (min)
|
||||
ax = axes[1, 1]
|
||||
for i, tr in enumerate(TRACES):
|
||||
vals = [pick(rows, tr, a)["wall"] / 60 for a in ARMS]
|
||||
ax.bar(x + (i - 0.5) * width, vals, width, color=COLOR[tr], label=TRACE_LABEL[tr])
|
||||
for bx, bv in zip(x + (i - 0.5) * width, vals):
|
||||
ax.annotate(f"{bv:.0f}m", (bx, bv * 1.05), ha="center", fontsize=8)
|
||||
ax.set_xticks(x); ax.set_xticklabels(ARMS)
|
||||
ax.set_ylabel("bench wall-clock (min, log)"); ax.set_yscale("log")
|
||||
ax.set_title("(d) wall-clock — PD drain dilates the run")
|
||||
ax.legend(fontsize=8); ax.grid(alpha=.3, which="both", axis="y")
|
||||
|
||||
fig.suptitle("Fig 4 (LMetric) — PD-colo vs PD-disagg on the real agentic trace "
|
||||
"(`w600_r0.0015_st30`), clean stack, cache-aware LMetric routing",
|
||||
fontsize=12, y=1.0)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT, dpi=130, bbox_inches="tight")
|
||||
print(f"wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,12 +30,23 @@ def main() -> None:
|
||||
default=float(_env_think) if _env_think else None,
|
||||
help="Closed-loop think-time (s) after each turn completes; "
|
||||
"ignore absolute trace schedule. Env: REPLAY_INTER_TURN_THINK_S")
|
||||
p.add_argument("--no-realized-prefix",
|
||||
action="store_true",
|
||||
default=bool(os.environ.get("REPLAY_NO_REALIZED_PREFIX")),
|
||||
help="Controlled-reuse mode: prompt = hash-built tokens only "
|
||||
"(reuse set by hash_ids). Env: REPLAY_NO_REALIZED_PREFIX")
|
||||
p.add_argument("--dispatch-mode", choices=["tracets", "thinktime"],
|
||||
default=os.environ.get("REPLAY_DISPATCH_MODE", "tracets"),
|
||||
help="tracets (Mode 1): absolute trace ts = max(prev_finished, ts). "
|
||||
"thinktime (Mode 2): turn-k at prev_finished + "
|
||||
"time_to_parent_chat. Env: REPLAY_DISPATCH_MODE")
|
||||
p.add_argument("--request-timeout", type=float, default=600.0)
|
||||
_env_maxdur = os.environ.get("REPLAY_MAX_DURATION")
|
||||
p.add_argument("--max-duration", type=float,
|
||||
default=float(_env_maxdur) if _env_maxdur else None,
|
||||
help="Overall wall-clock deadline (s): cancel in-flight + write "
|
||||
"summary (un-run turns counted as failures) to bound a "
|
||||
"collapsed config's drain. Env: REPLAY_MAX_DURATION")
|
||||
p.add_argument("--request-limit", type=int, default=None,
|
||||
help="Limit number of requests to replay")
|
||||
p.add_argument("-v", "--verbose", action="store_true")
|
||||
@@ -56,7 +67,9 @@ def main() -> None:
|
||||
request_limit=args.request_limit,
|
||||
max_inflight_sessions=args.max_inflight_sessions,
|
||||
inter_turn_think_s=args.inter_turn_think,
|
||||
no_realized_prefix=args.no_realized_prefix,
|
||||
dispatch_mode=args.dispatch_mode,
|
||||
max_duration_s=args.max_duration,
|
||||
)
|
||||
|
||||
results = asyncio.run(replay_trace(config))
|
||||
|
||||
@@ -66,6 +66,13 @@ class ReplayConfig:
|
||||
# max_inflight_sessions=N this is a stable N-user closed-loop (no open-loop
|
||||
# runaway), so it removes the "immediate retrigger under load" artifact.
|
||||
inter_turn_think_s: float | None = None
|
||||
# Controlled-reuse mode: skip _apply_realized_prefix so each turn's prompt is
|
||||
# exactly the hash-built tokens. Then prefix-cache reuse is governed solely by
|
||||
# the generated hash_ids (shared prefix blocks hit, fresh delta blocks miss) —
|
||||
# required for the reuse-fraction sweep, where realized-prefix would otherwise
|
||||
# force every fixed-length turn to ≈ the prior turn (≈100% reuse regardless).
|
||||
# Keep OFF (realized-prefix ON) for the real agentic trace.
|
||||
no_realized_prefix: bool = False
|
||||
# Dispatch timing for intra-session turns:
|
||||
# "tracets" (Mode 1): fire at absolute trace timestamp -> effectively
|
||||
# max(prev_finished, trace_ts); collapses think-time to 0 when
|
||||
@@ -73,6 +80,25 @@ class ReplayConfig:
|
||||
# "thinktime" (Mode 2): turn-1 at trace arrival; turn-k at
|
||||
# prev_finished + time_to_parent_chat (real production gap).
|
||||
dispatch_mode: str = "tracets"
|
||||
# Overall wall-clock deadline for the whole replay (seconds). When exceeded,
|
||||
# stop awaiting in-flight sessions, cancel them, and write the summary over
|
||||
# whatever completed — un-run turns are counted as failures so completion%
|
||||
# stays honest (request_count == full trace). None = no deadline (default,
|
||||
# original behavior unchanged). Used to bound the slow drain of a collapsed
|
||||
# config in a sweep. Env: REPLAY_MAX_DURATION.
|
||||
max_duration_s: float | None = None
|
||||
|
||||
|
||||
def _skipped_metric() -> "RequestMetrics":
|
||||
"""Placeholder failure row for a turn never run due to a max_duration cutoff.
|
||||
Only its error (non-None) matters: it counts toward request/error totals but
|
||||
is excluded from latency/ttft/tpot percentiles (successes only)."""
|
||||
return RequestMetrics(
|
||||
request_id="deadline_skipped", session_id="", turn_id=-1,
|
||||
trace_timestamp_s=0.0, input_length=0, output_length=0,
|
||||
request_type="skipped", effective_input_length=None, cached_tokens=0,
|
||||
latency_s=None, ttft_s=None, tpot_s=None, error="deadline_skipped",
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt_token_ids(req: TraceRequest) -> list[int]:
|
||||
@@ -318,10 +344,9 @@ async def _run_session(
|
||||
if elapsed < target_wall:
|
||||
await asyncio.sleep(target_wall - elapsed)
|
||||
|
||||
token_ids = _apply_realized_prefix(
|
||||
_build_prompt_token_ids(req),
|
||||
realized_context,
|
||||
)
|
||||
token_ids = _build_prompt_token_ids(req)
|
||||
if not config.no_realized_prefix:
|
||||
token_ids = _apply_realized_prefix(token_ids, realized_context)
|
||||
result = await _dispatch_request(
|
||||
client=client, config=config, req=req,
|
||||
prompt_token_ids=token_ids, sem=request_sem,
|
||||
@@ -410,25 +435,44 @@ async def replay_trace(config: ReplayConfig) -> list[RequestMetrics]:
|
||||
trust_env=False,
|
||||
limits=limits,
|
||||
) as client:
|
||||
states = [_SessionState(session_id=sid, turns=turns)
|
||||
for sid, turns in sessions]
|
||||
tasks = [
|
||||
asyncio.create_task(_run_session(
|
||||
state=_SessionState(session_id=sid, turns=turns),
|
||||
config=config, client=client,
|
||||
state=st, config=config, client=client,
|
||||
request_sem=request_sem,
|
||||
earliest_ts=earliest_ts, sweep_start=sweep_start,
|
||||
sink=sink,
|
||||
session_sem=session_sem,
|
||||
))
|
||||
for sid, turns in sessions
|
||||
for st in states
|
||||
]
|
||||
all_results = await asyncio.gather(*tasks)
|
||||
if config.max_duration_s and config.max_duration_s > 0:
|
||||
_done, pending = await asyncio.wait(
|
||||
tasks, timeout=config.max_duration_s)
|
||||
if pending:
|
||||
logger.warning(
|
||||
"max_duration %.0fs reached: cancelling %d in-flight "
|
||||
"session(s); un-run turns counted as failures",
|
||||
config.max_duration_s, len(pending))
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
else:
|
||||
await asyncio.gather(*tasks)
|
||||
finally:
|
||||
sink.close()
|
||||
|
||||
sweep_elapsed = time.perf_counter() - sweep_start
|
||||
post_metrics = await _snapshot_prefix_cache_metrics(config.endpoint_url)
|
||||
|
||||
flat = [m for group in all_results for m in group]
|
||||
# Build from the session states (identical to the gather return in the
|
||||
# uncapped path) so partially-completed (cancelled) sessions still contribute
|
||||
# their finished turns; pad un-run turns as failures so request_count == trace.
|
||||
flat = [m for st in states for m in st.metrics]
|
||||
missing = n_requests - len(flat)
|
||||
if missing > 0:
|
||||
flat.extend(_skipped_metric() for _ in range(missing))
|
||||
summary_path = config.output_path.with_suffix(".summary.json")
|
||||
write_summary_json(summary_path, flat)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user