Compare commits

...

3 Commits

Author SHA1 Message Date
7529284cee v2: LMetric PD-colo vs PD-disagg on the real agentic trace
Anchor experiment for the clean-stack PD comparison using the canonical
cache-aware proxy with --policy lmetric (scripts/bench.sh harness). Two
traces x four arms = eight runs on dash1.

Headline: with the right routing baseline (LMetric), PD-colo holds 100%
completion on both traces while every static PD-disagg ratio fails
(14-65% completion), and the failure mode rotates with the split --
no static partition has a working operating point on this workload.
LMetric improves colo dramatically (TTFT p50 1.0s vs original §3 RR
7.0s; 7x) but does NOT rescue PD-disagg, confirming the bottleneck is
structural (D-pool admission + multi-turn KV accumulation), not routing.

Completion matrix:
                    first600s  full
  colo                 100%    100%
  pd6 (6:2)            58.7%   65.3%   (decode-bound)
  pd4 (4:4)            43.1%   43.9%   (both bottlenecks)
  pd2 (2:6)            22.3%   13.9%   (prefill-bound)

The original §3 RR "100% PD completion" appears to be a measurement
artifact of e13391e: producer-KV eviction acted as a relief valve,
letting more requests squeeze under the 600s timeout at the (uncosted)
price of cross-turn re-prefill. With the eviction off, PD-disagg is
worse than §3 advertised, not better.

Artifacts:
  analysis/v2/fig4l_lmetric.json     -- 8-arm summary data
  analysis/v2/PD_DISAGG_LMETRIC.md   -- writeup + reproduce recipe
  figs/v2/fig4_lmetric_pd_vs_colo.png -- 4-panel comparison figure
  microbench/fresh_setup/plot_fig4l_lmetric.py -- plot script
2026-05-31 20:15:10 +08:00
fafc44da79 MB5 PD reuse-centric ablation: tooling, data, Fig 1-3
Three-axis controlled ablation of PD-colo vs PD-disagg on synthetic regular
traces (closed-loop, controlled reuse via REPLAY_NO_REALIZED_PREFIX) on the
clean stack (e13391e gated off).

  Axis 1 (Fig 1) -- reuse 6%->94% at N=8, in8192/out256
  Axis 2 (Fig 2) -- shape in2048/out2048 -> in32768/out64 at N=8, reuse~70%
  Axis 3 (Fig 3) -- concurrency N=8/16/32/64 at reuse~71%, in8192/out256

Findings:
  * APC parity colo=PD at every reuse (5.5/22/44/66/77/82%) -- contamination
    fix validated.
  * PD edge erodes 1.57x->1.10x with reuse; prefill GPUs strand 26%->9%.
  * Shape: PD-best peaks mid-sweep (1.34x at in8192/out512); wrong PD ratio
    catastrophic at prefill extreme (in32768/out64 pd2 = 378/400, p99 432s).
  * Concurrency: PD wins N<=32 (1.23-1.29x), TIPS at N=64 -- pd2/pd4
    crater (APC 71%->1.4%, TPS -30%) while colo scales cleanly.

Infrastructure:
  * replayer: --max-inflight-sessions, --inter-turn-think, --no-realized-prefix
    (env-defaulted via REPLAY_MAX_INFLIGHT, REPLAY_INTER_TURN_THINK_S,
    REPLAY_NO_REALIZED_PREFIX).
  * mb5_run.sh: writes bench_config.json + gpu_util.csv + run_window.json +
    instance_apc.txt + metrics.jsonl for bench_report/fig_agg ingest.
  * fig_agg.py: per-arm GPU role split + producer-side APC; --json mode.
  * gpu_util_report.py: companion per-GPU util report from gpu_util.csv.
  * partial_summary.py: stats from in-flight replay_metrics.jsonl
    (works before metrics.summary.json exists).

Data: analysis/mb5_pd_ablation/fig{1,2,3}.json (24 + 20 + 16 rows).
Figures: figs/mb5_pd_ablation/fig{1_reuse,2_shape,3_concurrency}_axis.png.
2026-05-31 20:14:46 +08:00
a2111b6e18 PD-disagg docs: annotated corrections for e13391e contamination
Adds dated, non-destructive correction notes to the contaminated PD-vs-colo
artifacts after the producer-eviction bug (`evict_blocks(sent_block_ids)` on
`finished_sending`, deployed over the "fresh" pip vLLM by
`scripts/deploy_vllm_patches.sh`) was found and gated behind
`VLLM_EVICT_SENT_BLOCKS` (default off).

  PD_DISAGG_RESULTS.md  top CORRECTION banner + §6 RETRACTED marker.
                        §6 (session-affinity hot-pin) was an `e13391e`
                        artifact under controlled concurrency; §3 RR, §4
                        TPOT win, §5 D-pool ceiling, §5.1 consumer crash
                        stand.
  RESULTS_SUMMARY.md    §4 confirm+refine note: clean ablation confirms
                        the D-pool capacity thesis and adds regime-
                        dependence.
  pd_separation_analysis.md  scoped caution: thesis confirmed; flags
                        only reuse-dependent figures for cross-check
                        (this study used a different stack).
  figs/mb5/CORRECTION.md  flags mb5_producer_hotspot.png as retracted;
                        §3 RR and §5 D-pool figures stand.
2026-05-31 20:14:14 +08:00
20 changed files with 712 additions and 9 deletions

View File

@@ -97,6 +97,18 @@ dash1 GPU 0 单 instance无 kv_connectorchunked-prefill 默认开启,
- MB1 + MB2 的合计 cost-benefit phase isolation 维度上 PD-disagg 是赢的**但这件事被容量天花板压倒**。
- Paper §3.2 论证应该聚焦"D 池装不下"MB1/MB2 数据用作 supporting contextper-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 线性 scaleFig 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 持平7182%)。
> 图:[`figs/mb5_pd_ablation/`](figs/mb5_pd_ablation/)。
## 5. EAR 设计的实证状态§4
| Pillar | 已实证 | 待实证 |

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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 (7182%).
> Figures: [`figs/mb5_pd_ablation/`](../figs/mb5_pd_ablation/).
---
## 1. Workload Characterization

View 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.57× better than the original §3 round-robin baseline at the
same wall-clock**). Every static **PD-disagg ratio fails** (1465 % 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
1465 %, 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 → 7886 % TTFT timeouts.
- **pd6 (2 decode)** → decode-admission-bound → 3541 % 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 **1325 ms** — an order of magnitude better than colo's 254388 ms — but the
win applies only to the 1465 % of requests that get admitted. The other 3586 %
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 4465 %. 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`.

View 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
View 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 (7182%); 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View File

@@ -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 7182%)** — 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 (7182%)** 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

View 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()

View 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()

View File

@@ -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

View 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()

View 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 5100×")
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()

View File

@@ -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))

View File

@@ -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)