Compare commits

...

67 Commits

Author SHA1 Message Date
54e1f5266a MB5 PD ablation v2 results: concurrency axis + reuse 3-way writeup
- fig3_conc32k.json + fig3_concurrency_axis.png: agentic-corner concurrency
  sweep (in=32768, reuse=0.984, out=128), N 8->128, PD capped 600s / colo
  uncapped. colo completes 100% at every N (graceful, E2E 2.4s->81s); every
  static PD split collapses, earlier as N rises (viable only N<=16; <27% by N=32).
- analysis/mb5_pd_ablation/README.md: self-contained v2 writeup. Reuse axis
  3-way (A=d2048/o256, C=d2048/o128, B=d1024/o128) decomposes shape: output
  ~negligible, delta (real prefill/turn) dominant; crossover to colo at reuse
  ~90-95% robust. Run on dash2 (dash0 NICs faulted for Mooncake).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:35:25 +08:00
3f997fda14 MB5 PD ablation v2 tooling: conc completion-panel plot + gpu_monitor dep
- plot_pd_crossover.py fig_conc: lead with request-completion % (the honest
  collapse signal; latency percentiles count successes only), then mean-E2E /
  TPS; note PD-capped/colo-uncapped in the title.
- add microbench/fresh_setup/gpu_monitor.sh (referenced by the committed
  mb5_run_gpu.sh:73 for per-GPU util collection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:35:05 +08:00
19c443e3bc paper f2a: reuse-topology decomposition + mixture-sensitivity sweep
Full-trace analysis backing figure 2a on the real 2h cluster trace:

- f2a_reuse_topology_analyze.py: infinite-KV-cache (LRU) decomposition of
  prefix-cache reuse hits into intra-session vs cross-session, by most-recent
  prior holder of each content-addressed block.
- f2a_mixture_sweep.py: sensitivity of the intra/cross split to the
  single-turn session fraction (tests whether the 93%-intra sample vs 54.6%
  full-trace gap is session-mixture selection bias) -- keep all multi-turn
  sessions, downsample single-turn to each target fraction, reclassify.

Includes the result JSONs for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:03:40 +08:00
9c105cf05a MB5 PD ablation: controlled-variable reuse/conc redo + campaign tooling
Reuse and concurrency axes redone with proper controlled variables, plus
the orchestration used to run them on dash0:

- run_reuse_fixed.sh: hold REAL prefill work (delta) constant, vary only
  cached prefix -> reuse = C/(C+U). Supersedes old fig1 (which held
  input=8192 and sliced prefix out, confounding "more reuse" with "less
  prefill").
- run_conc.sh: agentic-corner config (in=32768, delta=512, reuse=0.984,
  out=128) that exposes PD's structural KV-transfer tax. Supersedes old fig3.
- run_campaign{,2,3}.sh, backfill_d2048o128.sh: serial campaign drivers
  (strictly one driver at a time), out=128 sweeps, PD wall-cap for
  collapse-draining high-reuse arms, and flaked-arm backfill.
- mb5_run_gpu.sh: per-config bring-up / replay / teardown orchestrator.
- plot_pd_crossover.py: render the reuse_compare figures from fig_agg dumps.
- fig_agg.py: tolerate null stats from fully-collapsed arms (0 successes
  write the stat keys as null; `dict.get(k, {})` returns null, not {}).

Data: fig1_reuse_fixed.json, fig1_reuse_d{1024,2048}_o128.json
Figs: reuse_compare_AB.png, reuse_compare_ABC.png

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:03:27 +08:00
32f7f55990 v2: linear (default cache-aware) baseline + 2x wall-cap on first600s
Follow-up to the LMetric sweep: rerun with --policy linear (cache-aware
load + sticky session affinity, the cache_aware_proxy default) and cap
each PD-disagg arm at 2x the colo bench wall (SIGTERM bench.sh once cap
is exceeded; the cleanup trap clears vLLM and proxy; capped runs lack
metrics.summary.json so the analysis script computes from raw
metrics.jsonl).

Headline: the success-rate ceiling is policy-invariant.

  arm        linear (capped at 2x)    lmetric (uncapped)
  colo       807/807 = 100%, 964s     807/807 = 100%, 1021s
  pd6 (6:2)  472/807 =  58%, 2280s ⊗  474/807 =  59%, 3325s
  pd4 (4:4)  349/807 =  43%, 2281s ⊗  348/807 =  43%, 6850s
  pd2 (2:6)  176/807 =  22%, 2280s ⊗  180/807 =  22%, 19275s

Routing affects only how much wall is wasted timing out unreachable
requests at 600s each. Linear hits the same ceiling in 2280s as
LMetric does in 3300-19000s. This *strengthens* the §5 D-pool
capacity-ceiling thesis -- the cap is structural, not a routing
artifact.

Artifacts:
  analysis/v2/fig4r_linear.json          -- 4-arm linear summary
  analysis/v2/PD_DISAGG_LMETRIC.md       -- extended with wall-cap section
  figs/v2/fig4_linear_vs_lmetric.png     -- 3-panel side-by-side comparison
  microbench/fresh_setup/plot_fig4_linear_vs_lmetric.py
2026-06-01 00:55:40 +08:00
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
0b180c191e v2 exp(d): expand figure to 6 panels (TTFT/E2E mean+p90, TPS, per-worker GPU util)
Per request: TTFT mean+p90, E2E mean+p90, decode TPS (output goodput; total/
prefill TPS omitted as cache-miss-inflated), and per-worker GPU-util boxplots
(8 workers/arm, tracets vs thinktime) showing utilization level + balance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:10:27 +08:00
9b6091fe6e v2 exp(d): 5-policy routing under tracets vs thinktime — ranking flip
Extends exp(c) (dispatch ablation, 1 round-robin policy) to the full 5-policy
routing comparison, both modes on the SAME ttp trace (807 reqs, fresh vLLM/arm,
dash0 8xH20). Confirms exp(c)'s prediction and finds something stronger: the
dispatch mode FLIPS which policy wins.

- thinktime helps every policy but helps LPWL most (TTFT p90 -40%, E2E mean -31%
  vs -3..-16% for the rest): tracets bursts punish prefill-spreading.
- Ranking flip: tracets -> LPWL only ties unified_ab on TTFT p90 and is 3rd on
  E2E mean; thinktime -> LPWL is 1st on both (TTFT p90 -31%, best TPOT/balance,
  zero knobs) vs the tuned unified+A+B.
- => benchmark agentic routing with thinktime; tracets' burst artifact erases
  LPWL's advantage. Caveat n=1: tracets ranking is run-sensitive (does not
  reproduce dash1 lpwl_5policy_600s.md), the thinktime advantage is the robust
  signal (appears in both environments).

README + grouped-bar fig (figs/exp_d_policy_dispatch.png) + bench_report
summaries in results/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:59:18 +08:00
68f21bef23 bench harness: env-tunable vLLM health timeout + both-modes 5-policy driver
- b3_isolated_policy.sh: HEALTH_MAX_TRIES now env-overridable (default 180 ->
  360s unchanged); slow-node launches can pass HEALTH_MAX_TRIES=300 (600s) to
  ride out a single-instance startup flake without aborting the whole arm.
- run_5policy_both_modes.sh: runs run_5policy_600s.sh twice on the SAME ttp
  trace with REPLAY_DISPATCH_MODE={tracets,thinktime}, so the only variable is
  dispatch mode. Outputs to outputs/policy5_600s_{mode}_<date>/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:59:02 +08:00
075f5bbc22 trace: time_to_parent_chat annotation + thinktime trace variants
Adds `scripts/add_ttp_streaming.py`: one streaming pass over the 522 GB raw
glm5.1 trace to build {chat_id: (ready_ms, end_ms)} and join the real
inter-turn gap onto the COMPLETE formatted trace (no early-exit, low memory).
  time_to_parent_chat = (this.request_ready_time_ms - parent.request_end_time_ms)/1000
  = tool-exec + agent think-time; turn-1 -> null, negatives clamped to 0.

Ships the two ttp-annotated sampled traces (same anonymized data + one
timestamp-derived field; regenerated via sample_trace.py --seed 42 so they are
row-for-row identical to the non-ttp variants on all 9 shared fields):
  traces/w600_r0.0015_st30_ttp.jsonl          (1214 reqs)
  traces/w600_r0.0015_st30_first600s_ttp.jsonl (807 reqs)
They are needed to replay with --dispatch-mode thinktime without the
non-redistributable raw trace, so they are added to the .gitignore allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:58:49 +08:00
8a6b22c11c Replayer think-time dispatch mode + benchmarking guidance
Adds `--dispatch-mode {tracets,thinktime}` to the replayer and documents that
agentic serving should be benchmarked with `thinktime` (the faithful load).

- `tracets` (old default): turn-k at the absolute trace timestamp, i.e.
  max(prev_finished, trace_ts) -- collapses inter-turn think-time to ~0 when the
  system is behind, manufacturing request bursts.
- `thinktime`: turn-1 at trace arrival; turn-k at prev_finished +
  time_to_parent_chat (real production gap). scripts/add_time_to_parent.py
  annotates a trace with that gap from the raw trace's request_ready/end_ms.

exp(c) ablation (v2/exp_c_dispatch_ablation/): at N=8 (capacity slack) thinktime
beats tracets -- E2E p90 -28% (73.5 vs 102.8s), TTFT p90 -29%, TPS +7%, because
tracets' bursts spike concurrency -> KV pressure -> preemption. At N=6
(saturated) they converge. So tracets makes the system look ~30% worse on tail
latency than realistic agent pacing. Root README.md carries the headline
guidance; raw per-request metrics gitignored (perf_summary.json kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:28:36 +08:00
f0d085ceda Merge remote-tracking branch 'origin/main' 2026-05-30 15:39:25 +08:00
Gahow Wang
8d422c4301 Migration trigger validation: unified_v4 fires at 2x QPS, not at 1x
Ran unified vs unified_v4 A/B on dash2 (8×H20, kv_both+DR-fix substrate,
w600_r0.0015_st30_first600s trace). Key findings:

- At 1x QPS (~1.3 req/s): zero migrations. pending_prefill_tokens is 0 for
  95% of routing decisions because instances complete prefill before the next
  request arrives. The relative arm (src_pp > fleet_median*1.5) never fires.
- At 2x QPS (~2.7 req/s): 4 migrations (0.5%). src_pp>0 rises to 24% of
  eligible decisions. Trigger correctly identifies genuinely overloaded
  instances (src_pp 13k–73k vs fleet median 3.8k–33k).

Conclusion: mechanism is correct but migration benefit requires higher
concurrency (scale-out or >3x QPS) where queue pressure makes the signal
non-trivial. At single-node 8-instance scale, Pillar 1 (affinity routing)
is sufficient and Pillar 2 gracefully degrades to no-op.

Next: scale-out validation (16+ GPU) where session skew naturally
concentrates load and triggers migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 15:36:58 +08:00
d9cf3126c6 docs: reframe PAPER_OUTLINE to GPU-hit-first + embed v2 figures
Reorganizes the outline from the EAR / dispatch-coupling framing (kept in
git history) into the GPU-hit-first structure:

- §1 background splits PD-colo / PD-disagg / KV storage hierarchy, each with
  a forward pointer to where it is used or refuted.
- §2 leads with the metric argument (request latency / TPS / GPU util, not
  TTFT/TPOT); dispatch coupling is demoted to that justification. §2.2 embeds
  the two new v2 figures -- the measured 4-tier hit hierarchy
  (GPU < CPU-local < remote-RDMA-store << miss) and the capacity->APC/latency
  knee (Evidence #1) -- plus the cluster-scale correction to the working_set
  "14 nodes" number.
- §3 recasts the three optimizations as corollaries of GPU-hit-first:
  make PD-colocation default (3.1), biased KV-awareness routing (3.2),
  dedup via migration not replication (3.3).
- §5 related work now engages the storage-hierarchy camp directly.
- Validation-status table and work plan updated (top priority: wall-clock
  amplification sweep).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 13:34:19 +08:00
dc8e6dd5a8 v2 exp(a): add remote KV-store (RDMA) tier
Extends the hit-latency microbench to a 4th tier: a remote global-KV-store
hit over RDMA, the Mooncake-Store mechanism. Two kv_both MooncakeConnector
instances (run_rdma.sh); for each prefix length, instance B serves the
request by pulling instance A's cached prefix over RDMA (do_remote_prefill,
via microbench/fresh_setup/mb2_kv_transfer.py) instead of recomputing -- the
timed pull is the remote-hit latency.

Result (TTFT p50, 11 reps): strict tier ordering
GPU(HBM) < CPU(local DRAM) < remote-RDMA-store << miss, gaps growing with
context. At 64k: GPU 0.11s, CPU 0.27s, RDMA 0.97s, miss 15.2s -> miss/RDMA
15.8x, RDMA/CPU 3.6x, CPU/GPU 2.4x. So a global RDMA store is a real win
over recompute (the blog's 46x) but pays the NIC tax (~5-7 GB/s effective)
and sits a tier below local CPU and two below GPU -- reinforcing
GPU-hit-first. README + figure updated to four tiers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:48:37 +08:00
ad754cfe0b v2 exp(b): GPU KV-capacity APC/latency knee + writeup
Sweeps GPU KV-cache capacity (--num-gpu-blocks-override) under a closed-loop
replay (concurrency 4) of a controlled multi-turn workload (cumulative
intra-session prefix, gen_synth_trace.py), measuring realized APC
(prefix_cache hits/queries delta) and latency per capacity.

Result: a sharp knee at 3.6 GB = exactly the active working set
(4 sessions x 0.91 GB). APC rises 7->12->36->80% then saturates at the
~71% intra-session ceiling; TTFT p90 collapses 13.0 s -> 0.53 s at the same
point; dead flat to 14.5 GB, 100% completion throughout. So only the active
working set needs HBM; capacity beyond it -- and the CPU/storage tier built
to chase the reuse tail -- buys ~0. Knee scales linearly with concurrency
= cluster GPU count.

README.md ties exp(a)+exp(b) into the section-2.2 GPU-hit-first argument
with tables, conclusions, and caveats. Raw per-request dumps gitignored;
summary/m0/m1 deltas kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:23:31 +08:00
837df6bc9e v2 exp(a): three-tier KV-hit latency microbench (GPU >> CPU >> miss)
Measures TTFT to serve a reused prefix of length L from each KV tier on a
single H20 (Qwen3-Coder-30B-A3B, vLLM 0.18.1): miss (recompute), CPU-tier
hit (native DRAM offload), GPU-tier hit (HBM prefix cache). Each measured
request is bracketed by /metrics scrapes so the tier is verified
(vllm:prefix_cache_hits vs external_prefix_cache_hits), not assumed.

Result: GPU hit is ~flat (42->111 ms over 1k->64k tokens); CPU hit is
transfer-bound (PCIe H2D ~54 GB/s, 57->272 ms); miss grows superlinearly
(78 ms -> 15.2 s). GPU beats CPU 1.4-2.5x (gap grows with context);
miss/CPU up to 56x, miss/GPU up to 137x. pcie_transfer.py is the
independent CPU-hit floor backstop. Evidence for the GPU-hit-first
principle (paper section 2.2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:23:04 +08:00
cf812b6264 Workload characterization C1-C3 on full production trace
Joint/temporal characterizations of the full 051315 cluster trace (2.11M
req / 1.31M sessions / 2h), beyond the existing single-variable marginals:

- C1 mixture: 90.3% sessions single-turn, but multi-turn (9.7%) = 44% reqs /
  67% prefill mass; continuation hazard rises 10%->94% (Lindy); heaviness
  unpredictable at turn 1 (corr 0.04-0.15) => reactive routing justified.
- C2 resident/delta: resident context 11k->56k while new-prefill 2.7k->~200;
  per-turn reuse ->99.6%; resident/delta ("PD tax") ->~250-450x.
- C3 prefill/decode: token mass 98.7% input / 1.3% output, BUT decode ~70% of
  TIME (robust 68-71%); "decode negligible" is wrong (tokens != time). Correct
  colo argument = roofline complementarity, not "no decode".

Maps each to (1) PD-colocation and (2) routing. compute_chars.py + chars.json
+ figs/workload_chars/. Raw-file exact validation (cached_tokens, real
timings) pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:19:39 +08:00
847f52f03b PD-disagg crossover: regular synthetic trace + goodput sweep + figure
gen_synthetic_trace.py --mode regular: maximally-regular multi-turn trace
(fixed prefix/delta/turns, constant arrivals, zero session skew) to isolate
the structural PD cost (per-turn full-context transfer + P/D capacity split)
from the skew/hot-pin artifact.

analysis/crossover/: SLO-goodput PD_advantage sweeps bracketing the
prefill<->decode bottleneck axis (D1 grow input -> prefill-bound; D2 grow
output -> decode-bound). figs/crossover_pd_advantage.png shows the crossover
(y=1) with the agentic operating region annotated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:19:23 +08:00
48ae72467a Replayer: closed-loop inter-turn think-time mode
Add --inter-turn-think (env REPLAY_INTER_TURN_THINK_S): turn 1 fires on
session admission, each later turn a FIXED think-time after the previous
turn COMPLETES, ignoring absolute trace timestamps. Combined with
--max-inflight-sessions (env REPLAY_MAX_INFLIGHT) this is a stable N-user
closed loop, removing the open-loop "fire immediately because timestamp is
in the past" retrigger artifact. Needed for the dispatch-coupling
(wall-clock amplification) sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:19:12 +08:00
657cd36f3d Gate evict_sent_blocks behind VLLM_EVICT_SENT_BLOCKS
Fork commit e13391e unconditionally evicts sent blocks from the prefix
cache on every KV transfer. That is correct only for session MIGRATION
(source won't see the session again); for plain PD-disagg producer->
consumer transfers it destroys cross-turn producer reuse and contaminates
PD reuse experiments. Default OFF; enable for migration runs via
VLLM_EVICT_SENT_BLOCKS=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:18:59 +08:00
a0db3cbe77 Add leastwork_kappa decode-aware ablation (net-negative, documented)
--policy leastwork_kappa + --kappa (default 2.5e-6, derived from KV ~100KB/tok
/ HBM 4TB/s / TPOT 10ms on H20+Qwen3-30B-A3B): score = prefill_work * (1 +
kappa * ongoing_decode_tokens), modelling decode as a fractional throughput tax
on a new prefill.

Result on the 600s trace: NET-NEGATIVE vs plain leastwork — TTFT p90 +18%,
E2E p90 +14%, balance 1.55x->1.97x, and it does NOT fix the E2E-p99 it targeted.
Decode is too cheap in agentic (output p50~80) for the term to help; it just
bounces heavy reqs off their cache-owner into cold re-prefill. The E2E-p99 tail
is the structural HEAVY+>50k floor (per-class p99 ~51-52k for ALL policies), not
decode interference. Kept in-tree as a documented ablation justifying LPWL's
omission of any decode term; do not revive without a decode-heavy regime.
See analysis/lpwl_5policy_600s.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:07:23 +08:00
71b0747b3b 600s-truncated trace + LPWL 5-policy results
traces/w600_r0.0015_st30_first600s.jsonl: first-600s cut of the shipped w600
trace (807 reqs, 274 sessions, all turn-1s + early later-turns; theoretical
APC ceiling ~70% vs 80% full). Faster iteration (~18 min/arm) but a colder,
lower-locality regime; whitelisted alongside the parent anonymized trace.

analysis/lpwl_5policy_600s.md: LPWL vs LMetric/sticky/unified/unified+A+B on
the 600s trace (dash1 8xH20, cold APC, n=1). LPWL is overall best with zero
knobs — TTFT p90 7983ms vs tuned A+B 11562 (-31%), E2E p90 -16%, best request
balance; APC 0.648 (emergent affinity, far above LMetric 0.507); only loss is
E2E p99 from heavy-class decode concentration. Demonstrates anti-overfit: A+B
was tuned on full w600 yet is beaten by the knob-free policy on this regime.
Includes the run_5policy_600s.sh repro driver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:08:35 +08:00
160c29133d Unified bench report: mean+TPS+per-worker GPU util, auto-captured
scripts/bench_report.py is now the canonical analyzer: per run + per input-
class it emits TTFT/TPOT/E2E mean+p50+p90+p99, decode/prefill TPS (aggregate
and per-worker), APC, per-worker GPU util mean/max, and load-spread ratios.

b3_isolated_policy.sh auto-captures the inputs for every run: gpu_util.csv
(via gpu_monitor.sh, 5s, replay-window only) + bench_config.json (worker->GPU
map); teardown stops the sampler. Future runs populate per-worker GPU util
automatically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:08:22 +08:00
d9046322c6 Add parameter-free LPWL routing policy (--policy leastwork)
Least-Prefill-Work-Left: score = pending_prefill_tokens + max(0, input -
cache_hit_here), pure argmin with (num_requests, round-robin) tie-break.
Zero hyperparameters — derived from the agentic pattern: decode is cheap
(I/O ~217x) so outstanding prefill-token-work is the only load worth
modelling. Dropping LMetric's x num_requests factor (a) un-swallows the
cache signal so affinity emerges with no gate, and (b) makes an idle-but-
decoding host score `input` (its true marginal cost) instead of 0,
removing the empty-batch degeneracy. Stick-vs-spill crossover is computed
from real token-work, replacing overload_factor + cache_ratio gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:08:10 +08:00
8a876e90d1 traces/README: clarify w600 is the session-start window, not span
The trace actually spans ~2912 s (~48.5 min): all 274 sessions START within
the 600 s --window-seconds window, but their later multi-turn requests (34%
of rows, inter-turn gaps up to ~700 s) extend well past t=600 s. Remove the
misleading "~600 s span".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 12:04:14 +08:00
e532e83d3e mb5_run: scrape per-instance prefix-cache counters before teardown
Per-port vllm:prefix_cache_{queries,hits}_total -> instance_apc.txt. For PD
this is the only honest reuse signal: producer ports show cross-turn prefix
hits, while the consumer's per-request cached_tokens just counts transferred KV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:56:43 +08:00
d376d91fe1 Engine-state ablation: full sweep harness + results
Real-time engine state is NOT the routing lever. Across 6 policies × es0/es1,
real state reshuffles 44-76% of decisions but never beats the champion
(unified+A+B, p90 7.62s). The effect's SIGN is set by reactivity: one-shot
placement (sticky) HELPS -26%; per-request affinity-dominated is a wash;
per-request pure-load (lmetric +17%, load_only +27%) HURTS via herding (stale
shadow was a dampener). Feed verified fresh (median 25ms, <=92ms during
prefills). Prior shadow-state results stand. ES_ABLATION_RESULTS.md has the
table + mechanism; run_full_ablation.sh / fresh_sampler.py / cmp_es.py are the
harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:55:49 +08:00
08c3cf48aa Ship anonymized benchmark trace w600_r0.0015_st30 + provenance
Whitelist the sampled replay trace (1214 reqs / 274 sessions / ~600 s) past
the traces/ ignore so the repo is runnable without dash0 access. Metadata
only (token counts, opaque KV-block hashes, timing, session structure) — no
prompts/outputs/PII. traces/README documents schema, provenance (sampled
from the internal GLM-5.1 production trace via scripts/sample_trace.py), and
the regeneration command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:54:43 +08:00
8708b75520 Merge layerwise KV transfer + engine-state ablation onto main
Brings the worktree-mooncake-layerwise line (layerwise Mooncake connector,
write-mode proxy, real engine-state feed + eff_ accessors, mb7 microbench,
v3 trace re-profile, A/B x migration matrix runner) into main so the repo
is self-contained for these experiments. Disjoint paths
(microbench/connector_tax/layerwise/*) => clean merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:53:40 +08:00
ee5db0b321 MB5 driver updates: PD-proxy + snapshot instrument + launcher tweaks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:53:27 +08:00
bad512d3c5 PD-disagg crossover: synthetic-trace generator + morpher + plotter
gen_synthetic_trace (vanilla Poisson, zero prefix reuse — the regime where
PD-disagg is expected to win), mutate_trace (morph reuse/burst/skew toward
the agentic regime), and plot_crossover. Emits the replayer's JSONL schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:53:21 +08:00
41a0c1c48f Migration correctness smoke tests: direct-read, partial-transfer, NIXL
Standalone smoke tests validating KV-migration correctness paths before
trace replay: full migrate-cache, partial-prefill transfer, and a
NIXL-connector variant, each with a runner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:53:13 +08:00
1262c9c22e Migration transfer-cost study: KV transfer is slow on busy GPUs
MIGRATION_TRANSFER_COST.md: under real load, migration KV transfer runs at
~3 GB/s vs ~10 GB/s idle. Decomposed (instruments + MB6 microbench) into
~55% RDMA-actual (HBM/PCIe contention with running kernels: 7.6->4.0 GB/s)
+ ~45% control-plane GIL starvation during long prefills. Reproduced on a
fresh upstream venv (byte-identical transfer path) -> upstream/hardware
inherent, not our patch. Layerwise is the wrong lever; the tax is structural
on a loaded agentic cluster. Includes mb6_transfer_under_load + run_mb6,
instrument_dst_migration/mooncake, and the dst/transfer decomposition analyzers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:53:01 +08:00
67fcec7933 Unified-routing A+B ablation: decode-aware LMetric + v3 anti-hotspot
cache_aware_proxy: add lmetric_decode_weight (decode-load penalty in the
LMetric fallback score) and a v3 anti-hotspot recent-migration penalty
(effective_load = num_req + recent-migration count over a sliding window),
preventing back-to-back migration clustering. UNIFIED_ABLATION.md documents
the A (overload_factor=1.3) + B' (decode-weight, max(num_req,1)) + RaceFix
sweep: A+B'+RaceFix reaches TTFT p90 7770ms, beating v3 PD-sep migration by
~20%. Runners/analyzer for the b3 trace replay included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:52:44 +08:00
a2f2645fda PD_DISAGG_RESULTS §6.3: producer hot-pinning figure
Direct per-producer KV-pool evidence for the session-affinity backfire.
At the same 4P+4D ratio:
- round-robin: 4 producers within 1pp of each other (spread 0pp, CV 0.01)
- session-affinity: spread 49pp (one producer ~93%, another 45%; CV 0.25)

A 25x jump in producer load imbalance — heavy multi-turn sessions
concentrate onto single producers, the same hot-pinning pathology as
sticky routing in the colocated §3.3 study.

plot_producer_hotspot.py: reduce (numpy, per-producer KV timeline from
snapshots, runs on the serving host) + plot (matplotlib, 2-panel rr vs
session comparison) — same two-stage pattern as aggregate_mb5.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:38:20 +08:00
7947831e0f run_v3_trace.sh: stage LAYERWISE conn + enhanced proxy from shared cpfs (dash1-ready) 2026-05-29 00:29:56 +08:00
6243b78bba PD_DISAGG_RESULTS §6: session-affinity routing does not rescue PD
Swept session-affinity P routing (MB5_P_ROUTING=session) across all
four ratios on the metrics-fixed stack. Findings:

- Strictly worse than round-robin at every ratio. 4P+4D: round-robin
  100% vs session-affinity 36% completion.
- Success DECREASES monotonically as decode capacity grows
  (6P+2D 59% -> 4P+4D 36% -> 3P+5D 24% -> 2P+6D 19%) — refutes the
  "session prefill is faster so it needs more D" hypothesis.
- GPUs sit at ~0% utilization (2P+6D entirely idle) — the cluster
  stalls on KV-transfer/admission coordination, not compute. This is
  the deepest anti-PD argument: paid-for hardware does nothing while
  requests pile up; colocation keeps every GPU busy.
- Mechanism: session-affinity pins heavy multi-turn sessions onto
  single producers (producer hot-pinning, same pathology as sticky
  routing in the colocated §3.3 study); fewer producers -> worse
  concentration -> the monotonic decline. Failed transfers also pin
  producer KV (kv_load_failure_policy=fail), compounding to deadlock.

Verdict: neither ratio tuning nor routing policy rescues static
PD-disagg for this agentic workload — the failure is structural.

mb5_launch.sh: add 5P+3D / 3P+5D ratios for the sweep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:25:10 +08:00
5b26c345f4 P2: all routing policies read real state via eff_ accessors + ablation harness
InstanceState.eff_{num_requests,pending_prefill,ongoing_decode,ongoing_tokens}
= max(shadow, real) when feed fresh (fixes 30s-stale under-count, keeps
in-flight RaceFix), plus real-only r_max_prefill_remaining / r_kv_used_frac.
Wired into load_only, lmetric, sticky, unified(_kv_both), unified_v3, and
snapshot logging. Feed off => identical to before. run_v3_trace.sh gains ES=1
toggle (always deploys enhanced proxy); run_ablation_es.sh runs each config
ES0-vs-ES1 to test whether real state changes policy performance/ranking.
All unit-tested without GPU.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:21:12 +08:00
be948d32b8 P2: real engine-state feed replaces stale shadow counters for migration targeting
vLLM scheduler publishes real state (running/waiting, KV free, and the
max-in-progress-prefill signal /metrics lacks) to a tmpfs/redis store ~20Hz;
router reads it and avoids GIL-stall (mid-large-prefill) + KV-capacity-wall
targets, using real load over 30s-stale shadow counters. Components:
engine_state.py (canonical+reader), instrument_engine_state.py (scheduler
patch, file/redis writer), migration_target.py (scorer), proxy wiring
(--engine-state-uri, off=unchanged). All unit-tested without GPU; not yet
run live. See P2_ENGINE_STATE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:01:26 +08:00
19191940e6 A/B x migration matrix runner (parameterized run_v3_trace.sh + wrapper) 2026-05-28 19:23:16 +08:00
63387f614d Full v3 trace re-profile with layer-wise: matched migrations improve
1213/1214 success; matched migrations (4 common) improved -2.6 to -7.2s,
scaling with prefill hidden behind transfer. Trace-level TTFT p90 -6% / p99
-5% (modest: migrations are 2% of reqs and partly queue-bound). Confirms
layer-wise removes the transfer half of migration overhead but not the
control-plane/queue residual. DESIGN.md updated with results.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:16:37 +08:00
21db2affb4 Trace runner (run_v3_trace.sh) + concurrent mb7 correctness test 2026-05-28 17:28:48 +08:00
e705bb33b6 Proxy write-mode: concurrent prefill+decode dispatch for v3 (EAR_WRITE_MODE=1) 2026-05-28 17:22:18 +08:00
4242bba034 Chunk-safe + concurrent layer-wise connector (per-step incremental shipping)
Scheduler tracks per-producer block_ids (accumulated from scheduler_output)
and emits per-step LWSendMeta with cumulative computed_tokens. Worker
lw_wait_for_save records a CUDA event per step and enqueues progress; the
sender-loop ship loop drains it, shipping only computed+dst-wanted+unshipped
blocks in order (correct under chunked prefill). Per-transfer state =
concurrent-safe. Keeps v1 single-transfer version as reference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:15:54 +08:00
4cd71b6631 Working-set figure: extend left panel to ~50 nodes
Include T=600s/1800s points so the diminishing-returns tail is visible:
14 -> 52 nodes buys only +6pp APC (74%->79.8%), still under the 80.4%
ceiling that oracle/LRU reaches at 14 nodes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:12 +08:00
2247d1de08 Working-set figure: right panel = W(t) time series
Replace the (redundant) nodes-vs-T cost curve with the working-set
W(t) over wall-clock time for T=2/30/300s. Shows footprint is steady
(peak ~ median) after a short warm-up, so peak-based sizing is sound;
the 300s curve hugs the 14-node ceiling throughout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:31:26 +08:00
e77bdcac5a Layerwise under load: overlap benefit survives (bg=16)
mb7 with background decode load (8/instance). Critical-path transfer overhead
stays ~constant ~90ms for layerwise vs 158/239/749ms baseline (up to 7.9x at
32k), prefill not slowed, KV correct. Confirms the overlap holds on busy
instances. DESIGN.md updated with idle-vs-load table + the two blockers
(chunk-safety, concurrent-transfer safety) that the full 1200-req trace needs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:30:14 +08:00
c94b2e237a Working-set figure: linear node axes + benefit/cost split
Drop log node axis (decade ticks were unreadable). Left = APC vs #nodes
(linear), right = #nodes vs retention window T. Mark the 1-node budget
crossing (~7s reuse, ~8% APC) and the 14-node oracle ceiling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:24:15 +08:00
3b8be5bb61 Working-set figure: express footprint in node count, not GB
Both axes now in "# nodes" (footprint / per-node KV pool) so the
cluster-size implication is direct: 1-node budget line + 14-node oracle
ceiling, instead of raw GB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:16:00 +08:00
dae98c6472 Working-set sizing tool + GLM-5.1-FP8/B300 result
Configurable KV working-set analyzer (GPU model x TP/PP/EP x model
config.json with MLA/GQA auto x KV/weight dtype). Computes Denning W(T),
oracle [first,last], and retain-forever footprints vs a per-replica KV
pool, plus the APC captured at each retention window.

GLM-5.1-FP8 (MLA, 43.9 KiB/token) on 1x B300 node (1528 GB KV pool):
live KV fits trivially (~533 GB), but the full 80.4% APC ceiling needs
~14 nodes (oracle) -> long-tail reuse motivates DRAM offload, not HBM.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:03:25 +08:00
fec50fa45d Layerwise KV transfer on Mooncake: PoC + microbench (worktree exploration)
Implements per-layer KV push during prefill (write mode) on vLLM's
MooncakeConnector, env-gated by MOONCAKE_LAYERWISE=1. 2-instance microbench
(mb7) shows correctness (KV lands, cached==prompt) and that the transfer is
hidden behind prefill compute: critical-path overhead drops from O(KV size)
(123/202/529ms for 8k/16k/32k) to a flat ~58ms (2-9x), with no prefill
slowdown, on idle instances. Caveats: idle-only, chunked-prefill disabled,
single concurrent transfer — see DESIGN.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:43 +08:00
2e6a369046 PD_DISAGG_RESULTS §5.1: D-pool pressure crashes consumers
Document the consumer EngineCore crash chain (D-pool 97% -> 112k-token
KV transfer fails -> negative prompt-token counter -> prometheus
ValueError -> engine dead -> cliff failure). Explains the round-robin
6P+2D rep variance (100/56/80%) as intermittent consumer death, and
notes the counter-clamp patch needed to compare routing arms fairly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:02:21 +08:00
3957c2df86 MB5 patch: clamp PD-consumer metrics counter underflow
Root cause of the 6P+2D run-to-run collapse (rep1 100%, rep2 56%,
rep3 80%, session-routing 6.6%): not load-shedding, but a consumer
EngineCore crash.

Failure chain observed in the consumer logs:
  1. D-pool fills to ~97% (decode-side capacity ceiling, the H1 story)
  2. a large request's KV transfer fails: "Mooncake transfer engine
     returned -1" (112k-token request, pool full)
  3. scheduler fails the request (kv_load_failure_policy=fail)
  4. PromptTokenStats.local_cache_hit = num_cached + recomputed -
     num_external_computed goes NEGATIVE (external transfer exceeded
     cached count)
  5. loggers.record() calls Counter.inc(negative) -> prometheus raises
     "Counters can only be incremented by non-negative amounts."
  6. EngineCore dies -> every subsequent request fails (the cliff:
     all successes in the first ~110s, zero after)

This turns ONE failed request into a total config collapse, and is
what made the round-robin 6P+2D reps look randomly variable.

Fix: clamp the three per-source prompt-token counts to >= 0 in
loggers.record() before they hit Counter.inc(). Pure insertion,
revertible via the existing sentinel mechanism. Lets a transfer
failure stay a single failed request instead of killing the engine,
so routing arms can be compared on equal footing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:01:23 +08:00
8596135680 MB5 analysis: per-role KV split proves static-partition mismatch
aggregate_mb5.py:
- Split the cluster KV timeline by role (P-pool vs D-pool) using a
  PID->role map parsed from vllm_logs filenames. The cluster average
  hid the result — 6P+2D/4P+4D look ~45% utilized but the decode pool
  is actually pegged at ~100% while prefill idles at ~30%.
- Two-stage reduce/plot: --reduce-to (numpy-only, runs on the serving
  host over multi-GB snapshot dirs) dumps a compact JSON; --from-reduced
  (matplotlib) renders locally. matplotlib import is now lazy.
- New plot_role_split figure + p/d peak/steady columns in the CSV.

PD_DISAGG_RESULTS.md: consolidated writeup with figures inline.
Verdict: no static P:D ratio beats 8C colocation. The binding
constraint moves with the ratio (D-pool saturates at 6P+2D/4P+4D,
P-pool jams at 2P+6D -> 91% request loss); 8C's shared pool stays
elastic at 34% steady, 100% completion. PD wins TPOT (10-35x cleaner,
the MB1 phase-isolation benefit is real) but loses TTFT and sheds
load. Round-robin P routing also zeroes prefix-cache reuse; a
session-affinity re-run of 6P+2D is in flight to test the fix.

Figures (rep1): mb5_kv_timeline, mb5_role_split, mb5_peak_utilization,
mb5_latency_compare + mb5_summary.csv.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:05:17 +08:00
e8980ce957 MB5 proxy: session-affinity P routing (MB5_P_ROUTING=session)
The upstream mooncake_connector_proxy round-robins both P and D
selection. For agentic multi-turn sessions this destroys prefix-cache
reuse on the producer side — every turn of a session lands on a
different P, so the prefix-cache hit ratio collapses to 0 (observed in
the 6P+2D round-robin baseline) and every turn re-prefills from
scratch, piling extra load on the P pool.

Add an env-gated routing mode so the same proxy serves both arms of a
clean A/B:
  MB5_P_ROUTING=rr       round-robin (default, = upstream behavior)
  MB5_P_ROUTING=session  consistent md5 hash on X-Session-Id -> same
                         producer for all turns of a session

Decode side stays round-robin (load balance) in both modes — decode
KV is freshly transferred per turn, so D gains nothing from affinity
but everything from even load spreading.

mb5_launch.sh threads MB5_P_ROUTING through to the proxy and logs the
active mode. Default path is byte-for-byte the old behavior, so an
in-flight round-robin sweep is unaffected if this is redeployed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:05:25 +08:00
b13ca10d19 PD_DISAGG_INVESTIGATION: snapshot Phase 0 done + sweep in flight
Phase 0 infrastructure (vendored proxy, dual-file vLLM patcher,
per-instance + cross-config plotters) is fully assembled and
smoke-validated. Sweep RUN_TAG=20260527_164040 (4 configs × 3 reps
on w600) is running on dash1.

Also realigned the figure list with what `aggregate_mb5.py`
actually produces (mb5_kv_timeline, mb5_peak_utilization,
mb5_latency_compare, mb5_summary.csv).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:51:28 +08:00
a66f24d242 MB5 aggregate: cross-config KV-pool + latency comparison
Reads sweep root + tag, for each (config, rep):
- merges per-PID snapshots into cluster-wide KV timeline (carry-forward
  for PIDs without a sample in the bin)
- computes peak (max) and steady-state (10-90% median) pool utilization
- pulls latency p50/p90/p99 from replay_metrics.summary.json

Produces 4 outputs in --out-dir:
- mb5_kv_timeline.png    — N-panel cluster KV % over time, one panel per
                            config, faint per-rep lines + bold median
- mb5_peak_utilization.png — bar chart (peak vs steady) with ±std error bars
- mb5_latency_compare.png  — bar chart p50/p90/p99 e2e latency per config
- mb5_summary.csv          — flat per-(config, rep) table for the writeup

Validated on 4P+4D × 20-req smoke:
  4P+4D rep1: peak=12.8% steady=10.7% peak_wait=1
  p50=1.3s p90=10.5s p99=17.1s (vs. <1s for 8C — expected gap).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:49:21 +08:00
a9c7310f4a MB5 PD-disagg pipeline: working end-to-end
Three independent bugs were blocking PD-disagg smoke; each fix is
isolated so the next PD experiment doesn't re-hit them.

1. mb5_launch.sh
   - stop_all() also kills mb5_pd_proxy.py (our vendored copy),
     not just the upstream filename, and asserts ports 8000-8007 +
     PROXY_PORT are free before launching — stale proxies were
     silently passing the readiness check.
   - Proxy readiness uses a generic "any HTTP response" probe;
     mooncake_connector_proxy only exposes /v1/completions so
     /v1/models 404 is expected.

2. mb5_pd_proxy.py (vendored from third_party so deploy.sh ships it)
   - Force min_tokens=1 on the prefill leg. Clients that set
     min_tokens == max_tokens (our replayer does) collide with
     vLLM's min_tokens<=max_tokens check after the proxy caps
     max_tokens=1.

3. instrument_kv_snapshot.py
   - Adds a second patch target: initialize
     MooncakeConnectorWorker.bootstrap_server = None in __init__.
     vLLM 0.18.1 only sets it under the is_kv_producer branch, so
     kv_consumer hits AttributeError as soon as the first remote
     prefill request lands.
   - apply/revert refactored to iterate over (path, patches) pairs.

plot_kv_pool_timeline.py also handles snapshot files that never
captured a running request (would otherwise IndexError on an empty
stackplot input).

Smoke: 4P+4D × 20 reqs → 20/20 success, mean 3.9s, p99 17s, 8 PIDs
all writing snapshots (601 total), well above the 8C baseline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:14:22 +08:00
e0d3b5150a MB5 driver fixes: bash env-prefix + replayer flag names + python date math
Two bugs caught by 8C smoke:

mb5_launch.sh
  ${env_bp_arg} expanded as a literal command line prefix doesn't work
  when env_bp_arg is itself a variable — bash only treats VAR=val as
  an env assignment if it sees the literal in the parsed command, not
  after expansion. Fix: always export VLLM_MOONCAKE_BOOTSTRAP_PORT as
  a literal, defaulting to 9999 when caller passed no port (consumer
  mode ignores the var so the placeholder is harmless).

mb5_run.sh
  replayer's actual CLI flags are --trace / --output / --endpoint /
  --model, not the --*-path / --*-name variants I had. Plus dash1
  has no `bc`; compute wall_clock_s via python instead.

Both fixed; 8C smoke (CONFIG=8C REPS=1 REQUEST_LIMIT=20) now runs
end-to-end in ~30 s:
  - 8 vLLM kv_both instances on GPU 0-7 come up
  - replayer round-robins 20 reqs across them
  - MB5 instrumentation captures 8 snapshot files (one per EngineCore
    PID), ranging 7-139 snapshots each = ~10 Hz throttle works
  - plot_kv_pool_timeline.py renders the stacked-area + queue-depth
    chart cleanly (figs/mb5_smoke/*.png)

Pipeline validated. Ready for the real PD-ratio sweep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:23:23 +08:00
e9abd70c8d MB5 driver: launcher, orchestrator, KV-pool timeline plotter
Three new files to drive the PD ratio sweep + per-request KV occupancy
capture, plus a deploy.sh update so the patched replayer rides along
to the fresh-venv host.

mb5_launch.sh
  One script handles all four configs we plan to sweep:
    CONFIG=8C / 6P+2D / 4P+4D / 2P+6D
  - For 8C: 8 vLLM instances with kv_role=kv_both on GPU 0-7. Replayer
    talks to them via the existing comma-separated round-robin in
    replayer/replay.py — no proxy.
  - For PD configs: kv_role=kv_producer for the P pool (with
    VLLM_MOONCAKE_BOOTSTRAP_PORT) + kv_role=kv_consumer for the D pool,
    routed by the official vLLM example
    third_party/vllm/examples/online_serving/disaggregated_serving/
    mooncake_connector/mooncake_connector_proxy.py — no policy choice
    made by us, per user instruction to use the standard recipe.
  - Applies instrument_kv_snapshot.py before launching so every
    EngineCore writes its per-step KV snapshot to
    $RUN_ROOT/kv_snapshots/mb5_kv_snapshot_pid<pid>.jsonl
  - Reverts the patch on stop.
  - Emits ENDPOINTS= line on stdout for the orchestrator to read.

mb5_run.sh
  For each CONFIG × rep: launch, replay w600 trace via the existing
  replayer, capture wall-clock, tear down, cool down 10 s. Defaults:
    CONFIGS="8C 6P+2D 4P+4D 2P+6D"
    REPS=3
    TRACE=traces/w600_r0.0015_st30.jsonl
  All artefacts go under $FRESH_ROOT/mb5_runs/$RUN_TAG_${config}_rep${rep}/
  (vllm_logs/, kv_snapshots/, replay_metrics.jsonl, wall_clock_s.txt).

plot_kv_pool_timeline.py
  Reads one or more mb5_kv_snapshot_pid*.jsonl files and renders a
  stacked-area chart per file:
    x = wall-clock since first snapshot
    y = KV block count, stacked by per-request contribution
    overlay: pool-total ceiling, 90% line, waiting-queue depth subplot
  Bands are colored by a deterministic hash of request_id so individual
  requests are visually tractable across the run.
  This is the figure the user asked for — turns headline "PD-disagg is
  10× worse" into a system-level picture of *where* the KV pool is
  blocked, when, and by which requests.

deploy.sh
  Also tar-syncs the local replayer/ dir to
  /home/admin/cpfs/wjh/agentic-kv-fresh/replayer/ so mb5_run.sh can
  `python -m replayer` against the patched (trace_span_s/amplification)
  version, not the older copy under /home/admin/cpfs/wjh/agentic-kv/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:02:57 +08:00
a4f5dd56aa MB5 instrumentation: per-request KV-block snapshot from vLLM V1 scheduler
The §3.2 H1 (D-pool capacity wall) argument needs system-level evidence,
not just headline latency. This patch lets us record, every ~100 ms,
the exact composition of each vLLM instance's KV pool:

  - total / free / used block counts
  - for each RUNNING request: blocks held, computed tokens, prompt tokens
  - for each WAITING request: prompt tokens, status

Hook: inside Scheduler.schedule() right before the return. Per-request
blocks come from coordinator.single_type_managers[*].req_to_blocks
(vLLM 0.18.1's own per-request bookkeeping; no new tracking layer).
Throttled by MB5_PERIOD_MS env var (default 100 ms = 10 Hz) so a
13-min trace replay produces ~8 k snapshots per instance instead of
~80 k unthrottled.

Output: $MB5_LOG_DIR/mb5_kv_snapshot_pid<pid>.jsonl
(default MB5_LOG_DIR=/tmp). One file per EngineCore PID.

Apply/revert idempotent, same pattern as instrument_mooncake.py.
Markers: # MB5_INSTRUMENT_START / # MB5_INSTRUMENT_END.

Validated on dash1 venv: apply → py_compile ok → revert → py_compile ok.

With this in place we can build the stacked-area "KV pool composition
over time" figure the user asked for: x = wall-clock, y = block count,
colored bands = per-request portions. Comparing 8C colo vs 4P+4D
on the same trace will directly show whether (and when) the D pool
hits its ceiling — turning "PD-disagg is X× worse" into "PD-disagg
is X× worse BECAUSE these specific requests at this specific time
filled the pool and forced this queue depth".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:30:53 +08:00
4a93096c1e Add PD_DISAGG_INVESTIGATION.md — living TODO for proving H1–H4
We don't have paper-grade evidence yet that PD-disagg fails in agentic.
MB1+MB2 corrected accounting puts phase-isolation cost-benefit on
PD-disagg's side; the only direct support is colleague's one data point
on a patched dash0 build (TTFT p50 62×, success 52%) and the f4b
geometric capacity argument. To close §3.2 properly we need fresh-venv
empirical replication PLUS system-level instrumentation that tells the
reviewer *which* component is the bottleneck — not just headline
latency.

This document tracks the four candidate failure hypotheses (H1 D-pool
capacity, H2 static-partition mismatch, H3 cache reuse + P-pool hotspot,
H4 end-to-end throughput loss), their current evidence status, and the
phased experiment plan to address each.

Key findings already recorded:
- Phase 0 TODO 0.1 (find standard PD-disagg deployment) is done — vLLM
  ships an official example at
  examples/online_serving/disaggregated_serving/mooncake_connector/
  with a kv_producer+kv_consumer launcher and a Mooncake-aware proxy
  that supports arbitrary P:D ratios via env vars. Per user direction,
  we will NOT polish PD-disagg policy ourselves; we use the official
  recipe as the "PD-disagg" baseline in §3.2 / §5.2.
- Phase 1 (MB5+3 combined: PD ratio sweep with D-pool occupancy logging)
  is the critical path. Designed to either confirm H1 with system
  breakdown evidence (D-pool ≥ 90% for ≥ 30% of trace + queue depth
  spike) or falsify it (some ratio matches 8C colo, in which case §3.2
  needs rewriting).
- D-pool occupancy timeline is the single most important new
  instrumentation — turns "PD-disagg is 10× worse" into "PD-disagg is
  10× worse BECAUSE the D pool sits at >90% for X% of the trace".

Configurations to run on dash1 8-GPU first:
  8C (colo baseline), 6P+2D, 4P+4D, 2P+6D  ×  3 reps  ×  w600 trace.

Open question still in the doc: vLLM 0.18.1 had an AttributeError on
self.bootstrap_server in kv_consumer mode when we hit it during MB2
sanity; likely the issue was bad kv_transfer_params from our side
(missing transfer_id, wrong field names), which we have since fixed.
Official proxy uses the same handshake we now have, so it should
just work. If not, single-line patch to initialize self.bootstrap_server
= None for consumer mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:24:31 +08:00
f739f7d461 Proxy/runner support for Nixl connector + unified_v3 (offload-decode) policy
scripts/b3_isolated_policy.sh:
  Recognize unified_v3 as a kv_both-requiring policy; respect explicit
  KV_CONNECTOR=Nixl override (so unified_v2 / unified_v3 / unified_kv_both
  can run against either Mooncake or Nixl back-end). When Nixl is
  selected, skip the bootstrap-ports plumbing — Nixl uses its own UCX
  side-channel and the proxy forwards kv_transfer_params from the src
  response body instead of pre-baking engine_id/bootstrap_addr.

scripts/cache_aware_proxy.py:
  - New unified_v3 policy (~250 lines): prefill stays on session-affinity
    host (preserves intra-session prefix-cache reuse), decode is migrated
    to a lower-load target when the affinity host is busy with concurrent
    decodes. KV transfer flows prefill_host → decode_target, opposite of
    v2. Knobs: v3_min_new_tokens, v3_min_prefill_decode_busy,
    v3_target_load_ratio, v3_min_load_gap, v3_rotate_affinity,
    v3_prefer_cache_target. cache_miss_audit found rotation hurts cross-
    turn locality (9.5% hit with vs ~80% without) so default
    v3_rotate_affinity=False.
  - New connector_type setting ("mooncake" | "nixl") gating the PD-sep
    handshake form: mooncake uses pre-baked kv_transfer_params,
    nixl forwards them from the response body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:05:19 +08:00
da39ab6804 Correct PD-disagg cost/benefit framing across repo
The §3.2 cost-vs-benefit math in commits 029821c (MB1 plot +
pd_cost_vs_benefit.png) and abde010 (RESULTS_SUMMARY.md) was wrong.

What was wrong:
  I framed PD-disagg's max phase-isolation benefit as "≤ decode duration
  of the new request (~50–200 ms)" — implicitly treating the benefit as
  per-request and bounded by that request's own decode. The correct
  accounting is per-prefill-event across all stalled streams:

      benefit_per_prefill = D × T_prefill × (1 − TPOT_baseline/TPOT_during)
                          ≈ D × T_prefill

  which follows from the chunked-prefill math (each of L/N chunks slows
  D ongoing decode steps from ~10 ms to t ms, summing to D × T_prefill).

Plug MB1 + MB2 numbers in:

  prefill size | T_prefill | T_transfer | D=8 benefit | cost/benefit
   2k tok      | 0.14 s    |     8 ms   |   1.1 s     |    0.7 %
  33k tok      | 4.5  s    |  320 ms    |  36   s     |    0.9 %
 125k tok      | 57   s    |  1.9 s     | 456   s     |    0.4 %

On the phase-isolation axis alone, PD-disagg WINS by 100×–250× — the
opposite of what the deleted figure showed.

The actual dominant reason static PD-disagg fails in agentic is the
D-side KV pool capacity wall (figs/f4b_pdsep_kv_wall.png) — p99
single-request KV is 11.5 GiB, per-D-instance pool is 38 GiB, so 4P+4D
halves system decode capacity. Colleague's 4P+4D experiment showed
TTFT p50 62× worse and success rate 99.5% → 52%, driven by pool
overflow + queueing, not by transfer latency.

Changes (all touched files explicitly listed; no `git add -u`):
- figs/pd_cost_vs_benefit.png : DELETED (figure built on wrong math)
- microbench/fresh_setup/plot_mb1.py : drop the pd_cost_vs_benefit
  function; keep mb1_interference.png and update its title to note
  per-prefill aggregate stall = D × T_prefill (not capped by decode)
- figs/mb1_interference.png : regenerated, no misleading band annotation
- analysis/mb1/README.md : Summary block rewritten ("what MB1 measures";
  no more "max benefit = decode duration" claim); §3.2 implications
  section replaced with the corrected per-prefill-event table; explicit
  ⚠ Correction note documents what was wrong
- analysis/mb2/README.md : Summary block + §3.2 implications section
  rewritten the same way; ⚠ Correction note links to RESULTS_SUMMARY §4
- RESULTS_SUMMARY.md §4 + §6 : §4 reordered to lead with the D-side
  capacity argument (the real failure mode), MB1/MB2 demoted from
  "kill-shot for PD-disagg" to "supporting context inputs to a
  cost-benefit table that actually favors PD-disagg on this axis";
  §6 paper-claims list reordered to remove the wrong "PD-disagg loses
  on cost-vs-benefit" claim and replace with the corrected ones

PAPER_OUTLINE.md and MEETING.md were checked and never picked up this
specific wrong claim — they already (correctly) frame §3.2 around the
D-side KV memory wall.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:04:49 +08:00
207 changed files with 35256 additions and 582 deletions

9
.gitignore vendored
View File

@@ -3,7 +3,14 @@ __pycache__/
.venv/
*.egg-info/
outputs/
traces/
traces/*
# ship the anonymized sampled trace + its provenance (metadata only, no cleartext)
!traces/w600_r0.0015_st30.jsonl
!traces/README.md
*.log
.claude/
# third_party/vllm tracked in git for patch management
!traces/w600_r0.0015_st30_first600s.jsonl
# + time_to_parent_chat annotation (for --dispatch-mode thinktime); same anon data
!traces/w600_r0.0015_st30_ttp.jsonl
!traces/w600_r0.0015_st30_first600s_ttp.jsonl

View File

@@ -1,423 +1,362 @@
# EAR: Elastic Affinity Routing for Agentic LLM Serving
# GPU-Hit-First: Serving Agentic LLM Workloads by Keeping the Working Set in HBM
> **One-liner**: Agentic LLM workload 的 KV reuse 93% 是 intra-session 的,且 turn 间 tool-call 反馈耦合把单 request 的延迟差放大成 throughput 差距 —— locality 因此成为主导调度杠杆;现有 load-balance 丢 locality、静态 PD-disagg 撞 D 侧 KV 墙、pure session-sticky 造 hot pin我们提出 session-affinity routing + hot-instance 触发 session migration 的调度器 **EAR (Elastic Affinity Router)**,单一方案同时拿到 locality 和 balance。
> **Thesis (one-liner)**: 对 agentic LLM 负载,用户感受到的端到端 metric 是 **request latency / TPS / GPU
> utilization**,而它们由一件事主导 —— **KV cache 命中是否发生在 GPU HBM 上**。Agentic 的 KV reuse 93% 在
> session 内、且活跃 working set 小到一个节点就能常驻 HBM命中层级 `GPU ≫ CPU-local > remote-RDMA-store ≫
> recompute` 的代价差随 context 拉大。由此得到一条统一原则 —— **GPU-hit-first**:把活跃 working set 留在 HBM
> 而不是建深的 CPU/storage hierarchy 去追长尾。三个推论分别修复现有系统的三处失配:(3.1) 让 PD-colocation 重新
> 成为默认;(3.2) 在全局路由里做 biased KV-cache-awareness(3.3) 用 KV migration 而非 replication 做跨实例
> GPU 去重。
> **Framing note (2026-05-30)**:本 outline 取代早期的 "EAR: Elastic Affinity Routing" 版本(保留在 git
> 历史里)。原 EAR 的 dispatch-coupling 形式化在此 **降级为 §2 的 metric 论证**(解释"为什么是 request latency
> 而不是 TTFT/TPOT"),不再是 headlineheadline 升格为 GPU-hit-first 原则affinity routing / migration
> 成为该原则的两个推论§3.2 / §3.3)。
---
## 📊 Validation Status (2026-05-27)
## 📊 Validation Status (2026-05-30)
| 部分 | 现有数据 | 待补 |
|---|---|---|
| §2 Workload characterization | ✅ 完整 (3 张图复用) | |
| §3.1 Load-balance 丢 locality | ✅ 完整 (`f4a`) | — |
| §3.2 静态 PD-disagg 撞 KV 墙 | ✅ 完整 (`f4b`) | |
| §3.3 Sticky 造 hot pin | ✅ 完整 (`f4c`, `f4d`) | — |
| §4.1-2 Affinity routing | ✅ 已实现current `unified` 算法)| — |
| `kv_both` substrate cost | ✅ **VERIFIED net-positive** (2026-05-27, commit `ef9e010`) | TTFT p90 18.6% w/o DR-fix, 36.6% w/ DR-fix |
| §4.3 Migration mechanism (e2e) | 🚧 **PARTIAL** | substrate 已通e2e trigger + target selection 实验未跑 |
| §5.2 End-to-end | ⚠️ 5/6 baseline 有数据 (`f6`) | static PD-disaggEAR 列待 migration |
| §5.3 Ablation | 🚧 **PARTIAL DEFER** | 仅 affinity-only 现可做full 待 migration |
| §5.4 Dispatch coupling validation | 🚧 **NEW DATA NEEDED** | 5 baseline wall-clock 重跑Phase 1 patch 后)|
| §5.5 Sensitivity | 🚧 **PARTIAL DEFER** | λ/skew/KV pool 可做;`T_hot`/`T_cool` 待 migration |
| §5.6 Migration microbench | 🚧 **FULL DEFER** | 完全依赖 migration validation |
**前提背景**team 之前 4 次尝试 migration 都因 transfer overhead 被还原(见 `analysis/unified_routing_fix_review.md`2026-05-27 的 trace-replay A/B/C`microbench/connector_tax/cache_sweep/REPORT_TRACE_REPLAY.md`)证明 `kv_both` substrate 已经反转 —— 不仅 +45% penalty obsoletesubstrate 本身就是 net positiveTTFT p90 18.6% vs plainDR-fix 后 36.6%)。**之前 4 次 migration revert 的最大根因消失,但 e2e migration 策略层trigger + target selection 在反馈环里的真实收益)仍未直接验证 —— EAR 的 migration 部分实验已无 substrate 风险,只剩策略层风险。**
| 章节 | 论点 | 证据 | 状态 |
|---|---|---|---|
| §1 | 背景PD-colo / PD-disagg / KV storage hierarchy | — | 写作 |
| §2 metric | request latency 而非 TTFT/TPOTTPSGPU util | dispatch coupling + amplification`bench_report.py` | 🟡 论证全wall-clock sweep 待补 |
| §2.1 | KV hit 普遍且关键 | `f2a` intra 93.2%、APC 上界 79.6%、C1/C2 | |
| **§2.2** | **GPU hit > CPU hit > RDMA-store hit ≫ miss** | **`v2/figs/exp_a_tier_latency.png`(四层实测)** | ✅ **NEW** |
| **§2.2 Ev#1** | **GPU 足以常驻"有价值的" working set** | **`v2/figs/exp_b_capacity_knee.png`knee+ working_set + cluster-scale 校正** | ✅ **NEW** |
| §3.1 | Make PD-colocation great again | `PD_DISAGG_RESULTS``crossover_pd_advantage`、MB1/MB2、C2/C3 | ✅ |
| §3.2 | Biased KV-aware global routing | LPWLTTFT p90 31%、LMetric 乘性稀释、sticky hot-pin、ES ablation | ✅ |
| §3.3 | GPU dedup via migration not replication | substrate net-positive18.6%/36.6%correctness smoke tests | 🟡 substrate 通policy e2e 待验证 |
| §4 | 集成系统端到端 eval | 散落 mb1/mb2/mb5/crossover/lpwl需统一 | 🚧 |
| §5 | Related work含 storage hierarchy 正面回应)| — | 写作 |
---
## §1 Introduction
## §1 Background and System Setup
Agentic LLM workload —— 由 LLM 通过 tool call 自驱、多 turn 完成任务 —— 已经成为推理系统的主导负载,但现有为 chatbot 设计的调度策略在 agentic 下普遍失败。本文先刻画 agentic 与 chatbot 的本质区别,然后说明为什么三类主流调度都不够,最后给出 EAR 设计。
### §1.1 LLM 与 KV cache
**Contributions**:
Transformer 自回归推理分两段:**prefill**(一次性算完 prompt 的全部 KVcompute-bound**decode**(逐 token
生成memory-bandwidth-bound。每个 token 的 KV 常驻 GPU HBM 才能被后续 attention 复用。Prefix cachingAPC
相同 prompt 前缀直接命中已算好的 KV省掉重复 prefill —— 这是本文全部优化的物理基础。
- **C1 Dispatch coupling 论证**:我们形式化一个 agentic workload 独有的反馈环 —— 单 turn 服务时间通过 Little's Law 隐式方程影响并发 session 数,从而把 per-request 延迟差放大成 throughput 差距。实测load-balance baseline 在 600s trace 上跑出 **8x** wall-clock amplificationEAR 跑出 **TBDx**
- **C2 EAR 设计**:两个 pillar 的调度器 —— affinity-default routing 抓 intra-session localityhot-instance 触发的 session migration 在 hotspot 出现时把整个 session 的 KV 搬到更轻的 instance避免 hot pin
- **C3 评估**:在真实 Qwen3-Coder agentic trace 上EAR 同时 dominate 5 个 baseline 的 TTFT、TPOT、APC、worst-worker p90、wall-clock 五个维度。
> Qwen3-Coder-30B-A3BGQA, 48 层, 4 KV heads, head_dim 128, bf16**KV = 96 KiB/token**1 GiB = 10,923
> tokenblock(16 tok) = 1.573 MB
**Figure 1: Teaser — wall-clock vs trace-time across schedulers**`figs/f1_teaser.png` **🚧 TBD (NEW DATA NEEDED)**
> Needs Phase 3 measurements: 5 baselines × 3 runs of trace replay, extract `amplification = wall_clock_s / trace_span_s` from each summary (Phase 1 patch already exposes the field). Plot as bar chart with y=1 reference line. EAR row 暂为 TBD待 migration validation
### §1.2 Agentic workflow
Agentic 负载 = LLM 通过 tool-call 自驱、多 turn 完成任务。与 chatbot 的本质差异:每个 turn 由上一个 turn 的
tool-call 结果触发(无人类 think-timeprefill-dominatedinput/output ≈ 75×
**但它是一个 mixture不是"全多轮"**C1, `figs/workload_chars/c1_session_mixture.png`
- **90.3%** 的 session 是单轮mean 1.62 turns但多轮 session9.7%= **44.2% 的请求**、**66.9% 的
prefill 质量**。
- Continuation hazardLindyturn1→2 仅 **10.2%**turn5→6 87%turn12→13 **94.3%** —— heaviness 在
cold-start 几乎不可预测corr(turn1_input, n_turns) = **0.04**)。
> **Routing 含义(贯穿 §3.2**heaviness 在 session 起点不可预测 → 必须 **reactive**(观测累计负载),不能
> proactive 预判;单轮海洋与深尾的最优策略相反(前者 load-balance、后者 affinity-pin且 turn-1 无法区分 →
> 唯一可行的策略是"人人 load-balanced 起步、随 turn 累积变 sticky" —— 正是 LPWL 的 emergent 行为。
### §1.3 Serving agents in the wild
- **PD colocation8C**:每个 instance 对称prefill 与 decode 在同一 GPU 上由 chunked-prefill +
continuous batching 交错。弹性 KV 池,无静态分区。
- **PD disaggregation**:把 instance 静态分成 prefill 池P与 decode 池D物理隔离两个阶段
DistServe / Splitwise
- **KV cache storage hierarchy**GPU HBM → CPU DRAM → 远端 pooled storeRDMA/SSD如 Mooncake Store /
LMCache。把被淘汰/跨实例的 KV 下沉到更慢但更大的层,用传输换重算。
> 三者各被本文一节回应PD-colo 在 §3.1 被"复活"为默认PD-disagg 在 §3.1 被证否agentic regimestorage
> hierarchy 在 §2.2 被定量地"限位"GPU 命中远胜下层,且活跃 working set 本就装得下)。
---
## §2 Background and Workload Characterization
## §2 GPU memory hit is the key to serving agents
### §2.1 Agentic Workload Primer
### §2.0 正确的 metricrequest latency / TPS / GPU utilization不是 TTFT/TPOT
Agentic workload 与 chatbot 的三个本质差异:
**为什么不是 per-request TTFT/TPOT**agentic 的 turn 之间有反馈环,单 turn 的延迟会跨 turn **复利**成 session
端到端时间与系统吞吐差距。只有 **request/session latency、tokens-per-second、GPU utilization** 能 capture 这件事。
- **Multi-turn, programmatic continuation**:每个 turn 由上一个 turn 的 tool-call 结果触发,没有人类 think-time
- **Prefill-dominated**input/output token ratio **75x**98% 计算在 prefill 阶段chatbot 为 1-10x
- **Skewed sessions**(来自 Qwen3 production tracen=1.3M session / 2.1M req / 7200stop 1% 贡献 **46.5%** input tokentop 5% **66.5%**top 10% **74.6%**top 25% **87.5%**top 50% **96.0%** —— 半数 session 几乎占满全部 input mass
**Dispatch coupling降级为本节论证**。每个 turn 间有外部 gap `T_external`chatbot 是人在读/想/打字agentic
是 tool 执行。Little's Law`L = Λ · N · (W_turn(L) + T_external)`。当 `W_turn ≳ T_external`
`W_turn(L)` 经 KV 竞争耦合到并发 Lscheduler 的 ε 退步被反馈环放大成 wall-clock 数倍差。
平均 session 长度 TBD turn、TBD 输入 token。Per-request KV footprintQwen3-Coder-30B-A3B, 98304 B/tokenp50 **1.8 GiB**, p90 **8.0 GiB**, p95 **9.6 GiB**, p99 **11.5 GiB**. 单 instance KV pool ≈ 0.4 × 96 GiB = **38.4 GiB**(剩 50% model params bf16 + 10% runtime activation所以 p99 请求一个 instance 只能装 **3 个 concurrent decode**;改 PD-disagg 4P+4D 让系统 decode 容量直接减半(系统并发 24 → 12
production trace 实测 `T_external` CDF`figs/f3a_inter_turn_gap.png`
### §2.2 KV Cache Reuse Topology
Trace 上 KV reuse 的分解:
| Class | Share |
|---|---|
| Intra-session | **93.2%** |
| Cross-session | 5.7% |
| Shared prefix | 1.1% |
理论 APC 上界any-session **80.3%**intra-session-only **79.6%**,差距 <1pp。**cache 本质上是 session-local **任何不保留 session affinity 的调度都丢掉绝大部分 reuse 机会
**Figure 2: Workload characterization (3 panels)** 现有数据可复用
![F2a Reuse topology — intra 93.2% / cross 5.7% / shared 1.1%](figs/f2a_reuse_topology.png)
![F2b Session input-token mass CDF — production trace top 1%/5%/10%/25%/50% = 46.5%/66.5%/74.6%/87.5%/96.0% (replay window overlaid for sanity)](figs/f2b_session_skew.png)
![F2c Per-instance decode concurrency vs deployment (KV pool 38.4 GiB; p99 req fits only 3/inst; PD-disagg halves system decode capacity)](figs/f2c_kv_footprint_cdf.png)
> 📝 Layout TBD三张拼成 1×3 还是分散到 §2.1/§2.2/§2.4 各一张。
### §2.3 Dispatch Coupling — Why Locality Dominates
这是本文最依赖直觉的论证单独成节
**直觉**每个 turn 之间有一段**外部 gap** `T_external`chatbot 是人在读++打字agentic tool 执行)。下一 turn `T_external` 之后到达Little's Law: `L = Λ · N · (W_turn(L) + T_external)`系统能不能避免反馈环取决于 `W_turn` 是否**远小于** `T_external`
- 如果 `W_turn ≪ T_external`session 停留时间被 `T_external` 主导scheduler 调度速度的小变化几乎不动 `L`系统在**开环 regime**
- 如果 `W_turn ≳ T_external``W_turn(L)` 这一项被 KV 竞争耦合到 LLittle's Law 变成 L 的隐式方程**闭环 regime**scheduler 上的 ε 退步被反馈环放大成几倍的 L*。
**Agentic 与 chatbot 不在二元区分上,而在 `T_external` 的分布上**下面是 production trace 实测的 `T_external = next.start prev.end` CDFagentic = Qwen3-Coder, n=783k inter-turn gaps; chatbot = qwen3-max chat, n=42k gaps
![F3a Inter-turn external gap CDF — agentic vs chatbot](figs/f3a_inter_turn_gap.png)
| Metric | Agentic | Chatbot |
| | Agentic | Chatbot |
|---|---:|---:|
| p25 | 0.69s | 4.85s |
| **p50** | **1.6s** | **7.2s** |
| p90 | 44s | 15s |
| p99 | 738s | 43s |
| gap < 1s | **39%** | 4% |
| gap < 5s | 67% | 29% |
| p50 | **1.6 s** | 7.2 s |
| gap < 1 s | **39%** | 4% |
| gap < 5 s | 67% | 29% |
| p99 | 738 s | 43 s |
两个分布形状完全不同
- **Chatbot unimodal**515s 紧密集中人类交互节奏
- **Agentic bimodal**39% gap < 1sautonomous tool-call modechatbot 4%+ 13% gap > 30ssession paused/abandonedchatbot 仅 2%)。
agentic 有一段 chatbot 没有的 **sub-second tool-call mass39% vs 4%**几乎天然 `W_turn ≫ T_external` 闭环
**实测**lmetric 600s trace 49 min wall-clock = **8× amplification**。**结论per-turn 延迟的小差被放大成
端到端数量级差 必须用 request latency / TPS / GPU util 衡量。**
**Agentic 的危险来自 sub-second tool-call mode** —— 这 39% 的 turn 几乎天然 `W_turn ≫ T_external`dispatch coupling 必然激活;而 chatbot 没有这一段质量,要把 W_turn 推得很大才会进入闭环。
- **TPS / GPU util** 的工具`microbench/fresh_setup/bench_report.py`TTFT/TPOT/E2E 全分位 + TPS +
per-worker GPU util)。PD stall GPU ~0% util vs colo 34%(§3.1即是 GPU-util 作为 metric 的直接体现
**实测 regime 对照**
> **🚧 待补**5 baseline × ≥3 runs 的 wall-clock amplification sweepreplayer 已输出 `amplification` 字段),
> 钉死本节实证 closure。优先级高。
| Scheduler | TTFT p90 | Agentic frac(W_turn > T_ext) | Chatbot frac(W_turn > T_ext) |
|---|---:|---:|---:|
| unified | 7.3s | **73%** | 32% |
| lmetric | 15.7s | **80%** | 88% |
### §2.1 KV$ hit is common and critical
unified 在 agentic 上把 73% 的 turn 推进闭环,在 chatbot 上只有 32%。lmetric 在 agentic 上 80%、**chatbot 上也到 88%** —— lmetric 的 W_turn 大到把 chatbot 自己也推进闭环,这是 lmetric 在两种 workload 都 underperform 的一个直接根因。
Trace KV reuse 的分解`figs/f2a_reuse_topology.png`**intra-session 93.2% / cross-session 5.7% /
shared-prefix 1.1%**。理论 APC 上界intra-only **79.6%** vs any-session 80.3% <1pp —— **cache 本质上是
session-local **。
**具体例子**:一个 coding agent 跑 20 turn 的任务,假设 `T_external` 是 sub-second 模式tool-call 0.5s)。
per-turn 视角C2, `figs/workload_chars/c2_work_amortization.png`resident context 11k56k+ token 增长而
new-prefill 2.7k 坍缩到 ~200 tokenper-turn reuse 爬到 **99.6%**resident/new"PD tax" turn 12
250×、turn 30 450×。**绝大部分 prefill 工作是可被命中省掉的**命中与否直接决定 TTFT
- 快策略:`W_turn` = 2s每 turn 总 2.5ssession 共 50s平均并发 10 session
- 慢策略(线性估算):`W_turn` = 3s每 turn 3.5ssession 70s应并发 14
- 慢策略实际14 并发让 KV pool 更紧 → `W_turn` 推到 4s → session 90s → 18 并发 → `W_turn` 5s …… 反馈环放大到撞墙或落到一个远更糟的不动点
### §2.2 Hits on GPU is more important than the CPU
**形式化**。记 `Λ` = session 到达率,`N` = 每 session turn 数,`W_turn(L)` = 单 turn 服务时间作为并发 L 的递增函数并发越多、KV 竞争越激烈、`W_turn` 越大。Little's Law:
**命中层级的代价是实测的,不是断言的**Qwen3-Coder-30B-A3B / H20)。TTFT(s, p50) 服务一段长 L 的复用前缀
来自每个 KV
```
L = Λ · N · (W_turn(L) + T_external)
```
![四层命中延迟GPU < CPU-local < remote-RDMA-store ≪ miss差距随 context 拉大](v2/figs/exp_a_tier_latency.png)
设策略变化让 `W_turn` 整体放大 `(1+ε)` 倍,小扰动分析得到不动点 L\* 的灵敏度:
```
dL*/dε = L* · W_turn(L*) / [W_turn(L*) + T_external Λ · N · W_turn(L*) · W'_turn(L*)]
```
| prefix L | miss(recompute) | **remote RDMA store** | CPU-local(DRAM,PCIe) | GPU(HBM) | miss/RDMA | RDMA/CPU | CPU/GPU |
|---:|---:|---:|---:|---:|---:|---:|---:|
| 8k | 0.588 | 0.151 | 0.076 | 0.053 | 3.9× | 2.0× | 1.5× |
| 16k | 1.547 | 0.262 | 0.105 | 0.063 | 5.9× | 2.5× | 1.7× |
| 32k | 4.604 | 0.680 | 0.158 | 0.080 | 6.8× | 4.3× | 2.0× |
| **64k** | **15.23** | **0.97** | **0.27** | **0.11** | **15.8×** | **3.6×** | **2.4×** |
注意两点:
1. 分子 ∝ `W_turn / (W_turn + T_external)`:当 `T_external ≫ W_turn` 时灵敏度 → 0开环`T_external → 0` 时灵敏度趋于其上界(闭环)。**所以 agentic 的 sub-second tool-call mass 把灵敏度推到上界chatbot 的 515s mass 把灵敏度压低**。
2. 分母 `... Λ · N · W'_turn(L*)`:接近 KV 饱和时趋于 0**任何调度退步在饱和附近都被无限放大** —— 这是 lmetric 在 600s trace 上跑出 8x wall-clock 的根因。
- **GPU hit ~flat**42111 ms / 1k64k命中即整段前缀在 HBM只重算最后一个 token
- **CPU-local hit** transfer-boundPCIe H2D 实测 ~54 GB/sCPU-hit GPU-hit + KV/PCIe + ~0.15s 开销
native KV offload命中经 `vllm:external_prefix_cache_hits` 100% 验证。)
- **remote RDMA-store hit** = Mooncake-Store 机制实测 instanceB `do_remote_prefill` RDMA A 拉取
缓存前缀而非重算`mb2_kv_transfer.py` / `v2/.../run_rdma.sh`)。 recompute 是大赢**最高 16×** blog
46× 同向但付 **NIC 税**有效 ~57 GB/scf. MB2 raw ~9.7 GB/smulti-NIC pooling 可抬高故比 CPU-local
3.6×、 GPU ~9×64k**代价差随 context 拉大**。
- **结论 —— 层级严格且随 context 拉大`GPU < CPU-local < remote-RDMA-store ≪ miss`**。global KV store 确实
有用这也是该路线存在的理由但每靠近 GPU 一层就再省 1.44× TTFT。**最值钱的复用是 GPU-resident 的那种。**
**Figure 3: Dispatch coupling schematic**`figs/f3_coupling_schematic.png` **🚧 TBD (CUSTOM DRAW)**
> 需要新画一张示意图:左半 timeline 对比chatbot`system → T_external (515s) → system`agentic`system → T_external (sub-second to long-tail) → system`),右半反馈环 `W_turn → L → W_turn`,标注两个 regime 的判别条件 `W_turn ≷ T_external`。
#### Evidence #1GPU is sufficient to hold most KV requests
### §2.4 Takeaway
**realized APC 与 latency 在很小的 GPU 容量就饱和**closed-loop 多轮负载并发 4 GPU KV 容量
三个性质 —— intra-session locality dominant (§2.2)、long context + prefill-heavy (§2.1)、dispatch coupling (§2.3) —— 共同决定了 agentic workload 的调度必须以 **locality 为主导**,并能容忍 skew 带来的 instance 间负载不均。
![容量 kneeAPC 与 TTFT p90 在 3.6 GB=活跃 working set饱和之后 dead-flat](v2/figs/exp_b_capacity_knee.png)
| GPU KV (GB) | realized APC | TTFT p90 |
|---:|---:|---:|
| 1.2 | 7.4% | 13.00 s |
| 2.4 | 36.3% | 4.62 s |
| **3.6** | **80.3%** | **0.53 s** |
| 9.7 | 72.9% | 0.65 s |
| 14.5| 72.9% | 0.65 s |
**Knee 出现在 3.6 GB = 恰好 = 活跃 working set4 session × 0.91 GB**APC 饱和到上界TTFT p90 13.0s 坍缩
0.53s之后 dead-flat。**超过 working set HBM 买不到额外收益为追长尾而建的 CPU/storage tier 同理 0。**
**Cluster-scale 校正(关键)**working_set 分析`analysis/working_set/``figs/working_set/`显示"装下整个
2h cluster 的全部 reuse 尾巴需 ~14 节点"**这不构成 CPU offload 的动机** —— 那是用 1 replica 容量去装**整个
cluster** reuse产出该 trace 的真实 cluster 远不止 14 节点trace cluster 聚合
`project-trace-is-cluster-level`)。 per-cluster HBM 总量下活跃 working set 本就 GPU-residentlive KV
5331157 GB 单节点 1528 GB)。**knee 位置随并发线性增长 = cluster GPU 数增长** cluster 本就提供了它
> ⚠ **Scope**:本小节"装得下"指的是**活跃 working set 产生的近期高价值复用**,不是"全部 reuse 尾巴"。冷
> session 长 gap 后回来的深尾命中(既低价值/byte 又贵于 fetch正是 storage-hierarchy 派追的东西;本文论点是
> 在 agentic 下这条尾巴不值得为之建深层级。§5 正面回应该派。
### §2.3 Takeaway
正确的 metric(§2.0+ 命中集中在 GPU 才便宜(§2.2+ 活跃 working set 装得下 HBMEv#1)⇒ **GPU-hit-first**
设计目标是最大化活跃 working set **GPU 常驻 + 命中**而非建深 CPU/storage hierarchy。§3 给出三个推论
---
## §3 Why Existing Schedulers Don't Fit
## §3 Optimizing agent serving with the GPU-Hit-First Principle
三类现有调度各自撞上 §2 三个性质中的一个:
### §3.1 Make PD-colocation great again
### §3.1 Load-balanced routing 丢 locality
静态 PD-disaggregation chatbot 有效 agentic 结构性失败 —— colocation 才应是默认
Round-robin 和 load-aware routing如 LMetric, OSDI'26最大化 instance 利用率,但忽略 session affinity。**实测 APC 跌到 56.9%**vs 上界 79.6%23pp 的差距直接来自丢失的 intra-session cache hit。违反 §2.2。
**端到端证据**`microbench/fresh_setup/PD_DISAGG_RESULTS.md`8×H20trace replay**没有任何静态 P/D 比能赢
8-way colocation8C**且失败模式随比例移动
**为什么"cache-aware load routing"也不够 —— LMetric 的 cache 信号被乘性 score 稀释**。LMetric 的打分是
| Metric | **8C** | 6P+2D | 4P+4D | 2P+6D |
|---|---:|---:|---:|---:|
| completion | **100%** | 100% | 100% | **9%** 💀 |
| wall-clock | **2994 s** | 3419 | 4171 | 5762 |
| prefix-cache hit | **19.4%** | 0% | 0% | 0% |
| TTFT p50 | **7.0 s** | 41.0 | 56.4 | 23.6 |
| E2E p90 | **83.3 s** | 91.8 | 157.1 | 499 |
```
P = pending_prefill_tokens + (input_length - cache_hit)
score = P × num_requests
```
- **D-heavy4P+4D**decode 池饱和 **97.5%**prefill ~30% —— 半个 cluster KV 被困在错的一侧agentic
请求大p99 KV **11.5 GiB**4D 让系统 decode 容量直接减半2412 并发`figs/f2c_kv_footprint_cdf.png`
`figs/f4b_pdsep_kv_wall.png`)。
- **P-heavy2P+6D**prefill jam 99.7%872 请求堆积**91% 永不完成**。
- **更聪明的路由救不了**(§6.x P 侧加 session-affinity 反而更差4P+4D completion 100%→36%GPU ~0%
utilcluster 卡在 KV-transfer 协调而非 compute —— 复现 producer hot-pinning
cache_hit 只在 `P` 里作减项;而 `score` 是**乘性**的。一个 session affinity 的 instance 会因为持续接到 session 而 `num_requests` 升高,乘积把 cache 收益吃掉。例8000 输入 token、暖 instance cache_hit = 7500 vs 冷 instance cache_hit = 0、pending_prefill 都是 2000、num_requests 分别 5 vs 1则 LMetric score 暖 = `2500 × 5 = 12500`、冷 = `10000 × 1 = 10000`**LMetric 选冷**,丢掉 ~90% cache。结果
**为什么 colo 赢正确论证C2/C3 支撑)**
| 策略 | APC | vs load_only | 设计点 |
- **时变 P:D 需求**agentic 同时在 roofline 两侧有实质工作 —— compute-bound prefill~30% 时间+
memory-bound decode**~70% 时间**C3 tokentime 校正`figs/workload_chars/c3_prefill_decode_balance.png`)。
colo 的弹性池吸收当下热的那一相静态分区让 P-instance 带宽闲D-instance 算力闲
- **resident KV 本地化**C2下一 turn prefix = [prevPrompt+prevAnswer] 横跨 P/D 两侧disagg 必须
gather/transfercolo 免费本地保留
- **transfer 不便宜且拓扑无关**MB2`figs/mb2_transfer_time_compare.png`Mooncake `batch_transfer_sync_write`
恒走 RDMA NIC~9.7 GB/sintra interPD-disagg per-request transfer 税无法靠拓扑买回
- **phase-isolation disagg 唯一的真赢面但被压倒**MB1`figs/mb1_interference.png`32k prefill
per-stream TPOT 退化 52×131k 183×)—— 但被 D 侧容量天花板压倒见上)。
**边界(不 overclaim**crossover sweep`analysis/crossover/``figs/crossover_pd_advantage.png`给出 colo
停止占优的 input 长度 —— colo agentic 工作点赢且我们知道边界在哪
### §3.2 Biased KV-cache-awareness in global routing
GPU-hit-first 在路由层 = **把 cache-awareness 作为带偏置的独立路由路径**,而不是折叠进 load cost
**反例load-balanced / 朴素 cache-aware-load 丢 locality**`figs/f4a_apc_loss.png`)。LMetricOSDI'26打分
`P = pending_prefill + (input cache_hit)``score = P × num_requests` —— cache 只作 cost-model **减项** score
**乘性** affinity instance num_requests 高被乘式吃掉 cache 收益
| 策略 | APC | vs load_only | cache 处理方式 |
|---|---:|---:|---|
| load_only | 53.9% | — | 纯负载 (`score = num_requests`) |
| LMetric | 57.2% | **+3.3pp** | cache 作 cost-model 减项 |
| sticky | 77.7% | **+23.8pp** | cache 作硬约束 |
| unified | 78.7% | **+24.8pp** | cache 作硬+软偏好混合 |
| load_only | 53.9% | | |
| LMetric | 57.2% | **+3.3pp** | cost-model 减项被稀释|
| sticky | 77.7% | **+23.8pp** | 硬约束 |
| unified | 78.7% | **+24.8pp** | +软混合 |
**`load_onlyLMetric` 的 +3.3pp 几乎可忽略;`LMetric → sticky` 的 +20.5pp 才是 cache 信号被正确处理的回报**。Cache awareness 不能只作为 cost-model 的一项被吞掉 —— 必须作为**独立路由路径**sticky / unified hybrid。这是 §3.1 比"丢 locality"更具体的失败模式。
`load_onlyLMetric` +3.3pp 几乎可忽略**+20.5pp 的回报来自把 cache 作独立路由路径**。
### §3.2 静态 PD-disaggregation 撞 D 侧 KV 墙
**本文方法LPWLleast-prefill-workparameter-free**`project-lpwl-policy``analysis/lpwl_5policy_600s.md`
`new_uncached ≈ input cache_hit` 路由 —— `new_uncached≈input`/ session自动按负载分散`new_uncached≈0`
session自动 stick。**零旋钮** 600s trace 上击败 tuned unified+A+BTTFT p90 **31%**full w600 上打平
这正是 C1 mixture 要的形状无需 classifier 自动切分单轮海洋与深尾
静态把 instance 分成 P pool 和 D pool 对 chatbot 有效,对 agentic 失败agentic 请求平均 33.6k token需要 **3.3GB** KV4D 方案下 p90 请求占 D 侧 KV pool **69%**p99 直接 **溢出 138%**。结果:**TTFT p50 暴涨 62-72x**,成功率从 99.5% 跌至 **52-68%**。违反 §2.1prefill-dominant + 长 context
**两条被否的杠杆(节省读者时间)**
### §3.3 Pure session-sticky 的真正失败:全员被 hot session 拖累
- **real-time engine state 不是路由杠杆**ES ablation`project-es-ablation-sweep`只对一次性 placement 有用
sticky 26% per-req load-chasing 有害load_only +27%)。
- **derived-κ decode net-negative**decode-awareness 是错的杠杆
硬 session-instance 绑定恢复 localityAPC **77.2%**,达到上界 97%),但**绝对 worker latency** 全员被拖累 —— 是 pure sticky 的真正失败模式。
**Pure sticky 的失败用绝对 per-worker latency 衡量**`figs/f4c_per_worker_ttft.png`不能用 max/median 比值
| | median worker TTFT p90 | max worker | system e2e p90 |
|---|---:|---:|---:|
| `sticky` | **20.3 s** | 55.4 s | **34.6 s** |
| `unified` (affinity + LMetric fallback) | **10.3 s** | 37.7 s | **18.0 s** |
| `lmetric` | 14.0 s | 31.3 s | 24.8 s |
| sticky | **20.3 s** | 55.4 s | **34.6 s** |
| unified | **10.3 s** | 37.7 s | **18.0 s** |
机制production trace 上 top 1% session 占 46.5% input mass、top 5% 占 66.5%hot session 数量远大于 instance 数8sticky 的 hash 绑定让 **每个 worker 都自己承接一份 hot session**median worker 也被拖慢到 20s 量级。unified 用 LMetric fallback 把 cold/new session 重路由到非 hot worker保留 7/8 worker 的速度。系统 p90 由大多数请求决定,所以 unified 在 e2e p90 上 ~2x 快于 sticky。
hot session instance 8sticky hash 绑定让**每个 worker 都自接一份 hot session**median 也被拖慢
biased 路由把 cold/new 重路由到非 hot worker 7/8 worker 速度 e2e p90 ~2× 。**这引出 §3.3sticky 的残余
hot-pin 需要 migration 。**
**§3.3 sub-finding**hot pin failure 必须用 **per-worker absolute latency**median + max衡量**不能用 normalized ratio**。`max/median` 在 unified 这样的"affinity + escape"方案上会反向惩罚 —— sticky 的 ratio 2.73 比 unified 的 3.67 低,但 sticky 的 median 也高20.3s vs unified 10.3sratio 越低反而越糟。本文 paper 中所有 worker 平衡相关的比较一律用 (median, max) 双指标,不用单一比值。
### §3.3 GPU KV-cache dedup with migration instead of replication
违反 §2.4 的 skew 容忍要求。
**视角**多个 instance 各自缓存同一段 prefix = GPU 容量被 **replication** 浪费GPU-hit-first 要求**全局只留一份 +
session 搬到那一份**migration/dedup既保 GPU 命中又均衡负载修复 §3.2 的残余 hot-pin
**Figure 4: Three baselines, three failure modes** — 拆成三个子图,分别放在 §3.1/§3.2/§3.3
- **Trigger**host `pending_prefill > T_hot` session `T_cool` 内未迁移过
- **Target** **observable pending prefill tokens** 选最轻 instance**不用** cost-model 预测mooncake cost
model 误差 1021×by construction 绕过)。
- **Mechanism**当前 request 重定向到 targetsession KV Mooncake `kv_connector` 迁移binding 更新后续
turn affinity 路由到新 host迁移成本在该 session 剩余 turn 上摊销
- **Thrashing prevention**per-session cooldown `T_cool`
§3.1 — APC 实测 vs 理论上界 79.6% (lmetric 56.9%, load_only 54.1%, sticky 77.2%, unified 79.4%)
![F4a APC loss](figs/f4a_apc_loss.png)
§3.2 — D 侧 KV pool 占用 vs per-request KV footprint4P+4D 和 6P+2D 在 agentic regime 都穿过 90% 内存墙:
![F4b PD-sep KV memory wall](figs/f4b_pdsep_kv_wall.png)
§3.3 — Per-worker TTFT p90 across 8 instances × 5 policies。sticky 的所有 worker 都被拖慢median 20.3sunified 把伤害集中在 e4 上、其他 worker 快median 10.3s
![F4c Per-worker TTFT p90 distribution](figs/f4c_per_worker_ttft.png)
> 📝 可选支撑图 — Prefill-decode 干扰(同 GPU 8k prefill 让 TPOT 退化 66x放 §3.3 支撑 sticky 的 interference 论证:
![F4d PD interference](figs/f4d_pd_interference.png)
### §3.4 Takeaway
**问题不是任何单一 baseline 太弱,而是没有一个方案同时满足 §2 的三个性质**:保留 locality、尊重 D 侧 KV 容量、容忍 skew 带来的负载不均。EAR 是据我们所知第一个三件事同时做到的调度器。
> **状态****substrate 已验证 net-positive**`kv_both` connector 在 trace replay 上 TTFT p90 **18.6%**
> DR-fix 后 **36.6%** vs plain之前认为是 blocker 的 transfer overhead 已不存在4 次历史 revert 的主因消失)。
> migration correctness smoke tests 通过。**policy 层 e2etrigger + target 在反馈环里的真实收益)仍未直接验证**
> —— 这是全文最弱的一环,独立风险是"决策错误 + cooldown thrashing"。affinity-only pillar§3.2 LPWL已独立成立
> 即便 migration 仍 marginalpaper 也有 strong-routing 主线。
---
## §4 Design: EAR
## §4 System Evaluation
### §4.1 Architecture
> **🚧 关键缺口**:目前证据散落在 mb1/mb2/mb5/crossover/lpwl/v2§4 需要一个**集成系统**colocation +
> biased routing + dedup-migration统一命名跑端到端、用 §2.0 的新 metricrequest latency / TPS / GPU util
> 评测,并把 §3.1/§3.2/§3.3 做成 ablation。
EAR 是位于 N 个同质 instance 之上的 router。每个 instance 是对称的 PD-colocated没有静态 P/D 分区。每个 session 在 router 内维护一个 **host binding** —— 当前持有该 session KV 状态的 instance。Binding 在常态下稳定,仅在 hotspot 触发时通过 migration 改变。
### §4.1 Setup
- **Trace**真实 Qwen3-Coder agentic tracecluster-level `project-trace-is-cluster-level`日常迭代
`w600_r0.0015_st30`~850 req / ~13 min / peak QPS ~1.6 / APC headroom ~76%)。
- **Hardware**8× H20 (96 GB)vLLM 0.18.1 (V1, chunked-prefill) + Mooncake
- **Baselines**:① round-robin LMetric (OSDI'26) kvcache-aware+load 线性混合 sticky static PD-disagg
(4P/4D) **本文系统**colo + LPWL + dedup-migration)。
- **Metrics**request latency (mean/p50/p90/p99)、**TPS**、**GPU util (median/max worker)**、APC
wall-clock amplification不单看 TTFT/TPOT,§2.0)。
**Figure 5: EAR architecture and request flow**`figs/f5_architecture.png` **🚧 TBD (CUSTOM DRAW)**
> 组件图router (含 session→host table) → N 个 symmetric instancesaffinity 路径实线migration path 虚线。适合 TikZ / draw.io。
### §4.2 End-to-end
- `figs/f6_e2e_latency_bars.png` / `f6_e2e_latency_full_grid.png`现有 45 baseline;🚧 static PD-disagg
+ 本文系统列)。
### §4.2 Pillar 1: Affinity-Default Routing
### §4.3 AblationGPU-hit-first 三推论各关一个)
- colocation(→ static PD-disagg,§3.1
- biased routing(→ load-balance / LMetric,§3.2
- dedup-migration(→ pure sticky,§3.3
- 🚧 migration-only / full policy e2e 验证
- **Cold start**:新 session 到达时router 用 load-balance选 pending prefill tokens 最少的 instance分配初始 host
- **Warm path**:已建立 session 的后续每个 turn 一律路由到当前 host
- **效果**intra-session KV reuse 被构造性保留APC 接近 §2.2 的上界 79.6%
### §4.4 Microbench 支撑(已有,复用)
- §2.2 四层命中`v2/figs/exp_a_tier_latency.png`
- §2.2 容量 knee`v2/figs/exp_b_capacity_knee.png`
- §3.1 PD-disagg 失败`figs/f4b``PD_DISAGG_RESULTS``crossover_pd_advantage`
- transfer cost`figs/mb2_*`phase interference`figs/mb1_interference.png`
### §4.3 Pillar 2: Hot-Triggered Session Migration 🚧 PARTIAL VALIDATION
避免 Pillar 1 退化成 pure sticky 的关键 mechanism。
> **状态2026-05-27 更新)**
> - **Substrate 验证 PASS**commit `ef9e010``kv_both` connector 在 trace replay 上 net positiveTTFT p90 18.6%DR-fix 后再 22%。之前认为是 migration blocker 的 transfer overhead 已不存在。
> - **策略层 e2e 验证 PENDING**trigger 阈值 + target selection 在 agentic 反馈环里的真实收益仍未直接测。之前 4 次 migration 尝试(`6b255fa`, `e991960/5772149`, `cc6e562`, `4c583f2`被还原的主因substrate overhead已消失但 trigger 决策错误 + cooldown thrashing 是独立风险,需新一轮 e2e 实验确认。
#### §4.3.1 Trigger signal
EAR 实时监控每个 instance 的 **pending prefill tokens**。新 request 到达且按 affinity 应该路由到 host H 时router 先检查:
- `H.pending_prefill > T_hot`hotspot 检测)
- session 在过去 `T_cool` 秒内未发生过 migrationthrashing prevention§4.3.4
两个条件同时满足才考虑触发 migration。`T_hot``T_cool` 的取值见 §5.5 sensitivity。
#### §4.3.2 Target selection
候选集:所有 instance 中 (a) 剩余 KV 容量能装下 session 现有 context、(b) `pending_prefill` 严格小于 H 的。选 `pending_prefill` 最低者。
**关键设计点**:我们用 **observable current load** 而不是 **predicted transfer time** 排序。文献和 colleague 数据均显示 mooncake cost model 的预测误差达 10-21x而 pending prefill tokens 是 router 直接观察到的数值accuracy by construction。
若候选集为空(所有其他 instance 都装不下,或都比 H 更忙EAR 保留当前 binding继续在 H 上处理请求 —— **migration 是 opportunistic不是 mandatory**
#### §4.3.3 Mechanism
Migration 触发时:
1. 当前 request 直接重定向到 target instance T
2. session 累计的 KV 状态从 source H 通过 Mooncake `kv_connector` 传输到 T
3. session 的 host binding 更新为 T后续 turn 按 affinity 自动路由到 T
KV transfer 发生在触发该 migration 的 request 的 critical path 上,但被该 session 剩余的 TBD turn 摊销。
#### §4.3.4 Thrashing prevention
每个 session 维护 `last_migration_timestamp`。在 cooldown `T_cool` 内被禁止再次 migrate。Cooldown 把 migration 行为限制在 O(session_lifetime / T_cool) 量级。
### §4.4 Implementation
基于 vLLM 0.18.1 + Mooncake (vanilla kv_connector)。EAR 是一个 router 进程,~TBD LoC。Session→host 表用 TBDin-memory dict / Redis维护。
### §4.5 Sensitivity
- 到达率 λ / skew(Zipf α) / KV pool size不依赖 migration可做`T_hot`/`T_cool`依赖 migrationdeferred)。
---
## §5 Evaluation
## §5 Related Work
### §5.1 Setup
- **Trace**: 真实 Qwen3-Coder agentic traceTBD requests / TBD seconds / r=0.0015 st=0.3peak QPS ~1.6APC headroom ~76%
- **Hardware**: TBD × H20 (96GB HBM)
- **Engine**: vLLM 0.18.1 + Mooncake `kv_connector`
- **Baselines** (6 个):
1. `load-balance` —— round-robin
2. `LMetric` —— OSDI'26 load-aware routing
3. `kvcache-aware + load-balance` —— linear combination of cache score and load score
4. `sticky` —— 硬 session-instance pinning
5. `static PD-disagg` —— 4P / 4D 静态分区
6. `EAR` —— 本文
- **Metrics**: TTFT (mean/p50/p90/p99)、TPOT (同上)、E2E、APC、worker TTFT p90 (median + max)、wall-clock vs trace-time
### §5.2 End-to-end Performance
**Figure 6 (headline, p90 only)** — ✅ (PARTIAL缺 PD-disagg 列)
![F6 E2E latency bars — 4 policies, p90 only](figs/f6_e2e_latency_bars.png)
**Figure 6 full (mean / p50 / p90 / p99 × TTFT / TPOT / E2E)** — ✅ 数据完备:
![F6 full latency grid — 4 percentiles × 3 metrics](figs/f6_e2e_latency_full_grid.png)
> **🚧 TBD (NEW DATA)**:两张图都缺 `static PD-disagg` 那一列EAR 列也是 TBD需 migration validation。要再补同样格式但包含全 6 个 baseline 的版本。Headline 图用 p90 一行进 main paper完整 grid 可进附录或 supplementary。
| Scheduler | TTFT p50 | TTFT p90 | TPOT p90 | APC | Worker p90 (median / max) | Wall-clock factor |
|---|---|---|---|---|---|---|
| load-balance | TBD | TBD | TBD | TBD | TBD | TBD |
| LMetric | TBD | TBD | TBD | 56.9% | 6.53 | ~8x |
| kvcache+load | TBD | TBD | TBD | TBD | TBD | TBD |
| sticky | TBD | 18.02s | TBD | 77.2% | 13.65 | TBD |
| static PD-disagg | 62.8s | TBD | TBD | TBD | TBD | TBD |
| **EAR** | TBD | **7.35s** | TBD | **79.4%** | TBD | TBD |
(粗体数字来自现有 "unified" 原型测量。)
### §5.3 Ablation 🚧 PARTIAL DEFER
我们独立关闭两个 pillar:
- **EAR (affinity only)**: 等价于 pure sticky衡量 locality 单独贡献
- **EAR (migration only)**: cold-balance + reactive migration无 affinity衡量 migration 能否独立成立
- **EAR (full)**: 两个 pillar 都开
**Figure 7: Ablation**`figs/f7_ablation.png` **🚧 TBD — DEFERRED (BLOCKED ON MIGRATION VALIDATION)**
> 完整 ablation 需要 migration-only / both / affinity-only 三个配置。Migration-only 和 both 都依赖 migration 重测。现阶段可先做 affinity-only vs load-balance 的两点对比已有数据unified 79.4% APC vs lmetric 56.9% APC
预期结论affinity-only 拿到 locality 但 interference 翻倍migration-only 抓不住 locality两者都必须。
### §5.4 Dispatch Coupling Validation
闭环 §2.3 的论证。对每个 baseline 测量:
- 单 turn 平均服务时间 `W_turn`x 轴)
- Wall-clock / trace-time amplificationy 轴)
**Figure 8: Wall-clock amplification vs per-turn service time**`figs/f8_coupling_measured.png` **🚧 TBD (NEW DATA)**
> 散点x = 平均 per-turn `W_turn`(从 per-request metrics 算 TTFT + decode_timey = amplification (`wall_clock / trace_span`Phase 1 patch 已暴露)。每个 baseline 一个点。理论曲线 `L*/(1 Λ·N·W'(L*))` 叠加(可选)。这是 §2.3 论证的实证 closure**优先级最高**。
预期EAR 在 `W_turn` 最小且放大系数最低的角上。
### §5.5 Sensitivity
| 参数 | 范围 | 检验 |
|---|---|---|
| 到达率 λ | TBD | EAR 在低/高负载下是否稳定 dominate |
| Skew 程度 (Zipf α) | TBD | sticky 与 EAR 的差距是否随 skew 拉开 |
| KV pool size | TBD | static PD-disagg 撞墙边界 |
| `T_hot` (migration threshold) | TBD | 触发太宽 → thrash太严 → 错过 |
| `T_cool` (cooldown) | TBD | 同上 |
**Figure 9: Sensitivity heatmaps**`figs/f9_sensitivity.png` **🚧 TBD (NEW DATA, PARTIAL DEFER)**
> Arrival rate / skew / KV pool size 这三轴可现在做(不依赖 migration`T_hot` / `T_cool` 两轴依赖 migration validationdeferred。
### §5.6 Migration Microbenchmark 🚧 FULL DEFER
刻画 EAR 内部 migration 行为:
- Migration 触发率(% of requests
- 平均 KV transfer 时间
- Migration accuracy迁移后 target instance 在接下来 TBD 个 turn 内保持非 hot 的比例
- Thrashing ratecooldown 窗口内多次迁移的 session 占比(应为 0
**Figure 10: Migration timeline**`figs/f10_migration_timeline.png` **🚧 TBD — DEFERRED (BLOCKED ON MIGRATION VALIDATION)**
> 时间轴上每个 instance 的 pending prefill tokens heatmapmigration 事件以箭头标出。完全依赖 migration 重测。
- **Serving systems**vLLM, Mooncake, SGLang, DistServe, Splitwise本文基于 vLLM+Mooncake
DistServe/Splitwise 不同在于**不做静态 P/D 分区**(§3.1 给出 agentic regime 的证否 + crossover 边界)。
- **Cache-aware routing**LMCache, Production-Stack, LMetric (OSDI'26)。本文指出 cache 信号不能折叠进乘性 load
score(§3.2 LMetric 稀释分析须作独立 biased 路由路径
- **KV storage hierarchy / offload主要对手正面回应**Mooncake Store, LMCache, AttentionStore 等把 KV 下沉
CPU/SSD/远端 pool。**本文不否认其对 recompute 的收益**(§2.2 实测 remote-RDMA 命中比 recompute 快达 16×
Mooncake-Store blog 46× 同向但论证在 **agentic** (i) 命中层级 GPU CPU RDMA-store(§2.2
(ii) 活跃 working set 本就 GPU-resident(§2.2 Ev#1)⇒ 深层级的边际收益低应优先 GPU 常驻而非建深 hierarchy
- **Stateful migration**Pollux, GandivaRL training migration-as-rebalancing)。本文把该思路迁到 LLM KV
cache dedup(§3.3)。
---
## §6 Discussion and Limitations
## §6 Conclusion
- **Extreme skew**: 若单个 session 自己就把任意 instance 撑成 hotEAR 退化为 sticky。我们未在该 regime 做 stress test。
- **Cost model accuracy**: EAR 用 observable load 绕过了预测误差问题。但未来若引入 predictive admission control需要解决 mooncake cost model 10-21x 误差。
- **Heterogeneous hardware / multi-model**: EAR 假设 instance 同质。混合模型 / 混合 GPU 池需要扩展 binding 模型。
- **Per-instance batch tuning (future)**: 动态调整 `max_batched_tokens` 可能进一步降低 instance 内部 prefill-decode 干扰,留作 future work。
---
## §7 Related Work
- **LLM serving systems**: vLLM, Mooncake, SGLang, DistServe, Splitwise. EAR 基于 vLLM + Mooncake 实现,与 DistServe/Splitwise 不同之处在于不做静态 P/D 分区。
- **Cache-aware routing**: LMCache, Production-Stack, LMetric (OSDI'26)。这些工作最小化 cross-instance cache miss但不迁移状态。
- **Stateful service migration**: Pollux, Gandiva (RL training)。EAR 借鉴 migration-as-rebalancing 思路,将其迁移到 LLM inference 的 KV cache 场景。
---
## §8 Conclusion
对 agentic LLM workloadlocality 是主导调度杠杆。EAR 用 session-affinity routing 抓住它,用 hot-triggered session migration 保护它,单一方案在 TTFT、APC、worst-worker p90、wall-clock throughput 四个维度同时 dominate 五个 baseline。
agentic LLM 负载用户感受的 request latency / TPS / GPU util **KV 命中是否在 GPU HBM 上** 主导命中层级
`GPU ≫ CPU > RDMA-store ≫ recompute` 且代价差随 context 拉大(§2.2而活跃 working set 小到本就 GPU-resident
(§2.2 Ev#1)。**GPU-hit-first** 原则由此统一三件事复活 PD-colocation(§3.1)、在路由里做 biased
KV-awareness(§3.2)、 migration 而非 replication GPU 去重(§3.3)。
---
## Work Plan
### ✅ Done
- §12 背景与 metric 论证dispatch-coupling 数学inter-turn gap CDF`f3a`
- §2.1 reuse topology / C1C3`f2a``workload_chars/*`
- **§2.2 四层命中实测`v2/exp_a_tier_latency`GPU/CPU/RDMA/miss**
- **§2.2 Ev#1 容量 knee`v2/exp_b_capacity_knee`+ working_set + cluster-scale 校正**
- §3.1 PD-disagg 证否`PD_DISAGG_RESULTS`crossoverMB1/MB2
- §3.2 LPWL31%)、LMetric 稀释sticky hot-pinES ablation
- §3.3 migration substrate net-positive + correctness smoke tests
- [x] §1 anchor sentence + contribution bullets
- [x] §2 outline + reuse existing characterization figures (`f2a`/`f2b`/`f2c`)
- [x] §3.1/§3.2/§3.3 outline + reuse existing baseline failure figures (`f4a`/`f4b`/`f4c`/`f4d`)
- [x] §4 design description (§4.3 待实证)
- [x] §5.2 partial figure (`f6` 5/6 baselines)
- [x] `replayer/replay.py` patched to emit `trace_span_s` + `amplification` in summary
### 🟢 不依赖 migration现在可做
- §2.0 wall-clock amplification sweep5 baseline × 3 runs)— **优先级最高**
- §4 集成系统命名 + 端到端 baseline 矩阵 static PD-disagg
- §4.5 λ / skew / KV pool sensitivity
- 草拟 §1–§3 正文证据/图已齐
### 🟢 Can do without migration (paper writing now possible)
### 🚧 Deferred待 migration policy e2e
- §3.3 migration trigger + target 的反馈环收益验证
- §4.3 full / migration-only ablation
- §4.5 `T_hot` / `T_cool` sensitivity
- [ ] Draft §1-§4 正文数据全有figures 已 copy 完)
- [ ] §2.3 dispatch coupling 那一节的正文 draft数学已经在 conversation 里推完)
- [ ] §3 三个失败模式正文 draft
- [ ] §5.4 wall-clock amplification 实测5 baseline × ≥3 runs**优先级最高**,这是 §2.3 的实证 closure
- [ ] §5.2 把 static PD-disagg 补进 `f6` 那张图(重跑或合并现有 PD-sep 数据)
- [ ] §5.5 sensitivity 的 λ / skew / KV pool 三轴
- [ ] §3 三张子图各自独立的 latex/markdown layout 决定
### 🎨 待画
- §1.3 storage-hierarchy 示意GPU HBM CPU DRAM RDMA store
- §2.0 dispatch-coupling schematicchatbot vs agentic timeline + 反馈环
- 集成系统 architecture
### 🚧 Deferred (待 migration validation)
- [ ] §4.3 migration mechanism e2e 验证substrate 已通commit `ef9e010`),缺 trigger + target selection 的策略层实验
- [ ] §5.3 full ablation (migration-only + both 两个配置)
- [ ] §5.5 `T_hot` / `T_cool` 两轴 sensitivity
- [ ] §5.6 migration microbench 全部
- [ ] §1 teaser 图 (`f1`) EAR 那一列
- [ ] §5.2 表里 EAR 那一行
- [ ] §4.3.1 / §4.3.4 的 `T_hot``T_cool` 取值
### 🎨 Custom drawings (paper-writing 阶段)
- [ ] `f3_coupling_schematic.png` —— chatbot vs agentic timeline + 反馈环
- [ ] `f5_architecture.png` —— EAR 组件图
### ❓ Open design decisions
- [ ] §4.4 session→host 表的存储介质in-memory dict vs Redis
- [ ] §5.1 instance 数量、trace 总长度的最终定稿
### ❓ Open
- 集成系统最终命名GPU-hit-first 是原则系统名待定
- §4 instance / trace 总长定稿

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# agentic-kv
Serving agentic LLM workloads by keeping the KV working set in GPU HBM
(GPU-hit-first). Research outline: [`PAPER_OUTLINE.md`](PAPER_OUTLINE.md).
Evidence + experiments: [`v2/`](v2/).
---
## ⚠️ Benchmarking methodology — read this first
> **Replay agentic traces with `--dispatch-mode thinktime`, not the default
> `tracets`.** It is the faithful, more realistic load — and the dispatch mode
> materially changes the performance you measure.
The replayer offers two ways to time each turn:
| mode | turn-k dispatched at | what it models |
|---|---|---|
| `tracets` (default) | `max(prev_turn_finished, trace_ts)` | absolute production schedule |
| **`thinktime` (use this)** | `prev_turn_finished + time_to_parent_chat` | real closed-loop agent pacing |
**Why it matters.** `tracets` collapses the inter-turn think-time to ~0 whenever
the system falls behind (it fires the next turn immediately because the trace
timestamp is already in the past). That manufactures **artificial request
bursts** — spiking instantaneous concurrency → KV-pool pressure → preemption →
inflated tail latency and wasted throughput. `thinktime` keeps each turn's real
gap (tool-exec + agent think), so the offered load is what a real agent produces.
**Measured (w600 first-300s window, 8×H20, round-robin, 100% completion):**
| metric (N=8) | `tracets` (Mode 1) | **`thinktime` (Mode 2)** | Δ |
|---|---:|---:|---:|
| E2E p90 | 102.8 s | **73.5 s** | **28%** |
| E2E p99 | 245 s | **227 s** | 7% |
| TTFT p90 | 56.1 s | **39.7 s** | **29%** |
| system TPS | 111.8 | **119.3** | **+7%** |
| wall-clock | 967 s | **787 s** | 19% |
| TPOT p90 | 0.174 s | 0.188 s | ~flat |
So under realistic capacity, `tracets` makes the system look **~30% worse on
tail latency** than it actually is. Tell-tale: scaling 6→8 instances barely helped
`tracets` (975→967 s — its bursts re-saturate regardless of capacity) but helped
`thinktime` a lot (1125→787 s). Under heavy saturation (N=6) the two converge
(E2E p90 ≈ 118120 s), since there is no slack for bursts to harm. Decode (TPOT)
is dispatch-independent everywhere.
**Recommendation:** benchmark with `--dispatch-mode thinktime`; use `tracets`
only as an explicit bursty stress case. Full ablation:
[`v2/exp_c_dispatch_ablation/`](v2/exp_c_dispatch_ablation/).
### How to use it
```bash
# 1. annotate a trace with the real per-turn gap (one-time; scans the raw trace)
python scripts/add_time_to_parent.py traces/w600_r0.0015_st30.jsonl traces/w600_ttp.jsonl
# 2. replay closed-loop with faithful think-time
python -m replayer --trace traces/w600_ttp.jsonl --endpoint <eps> \
--model <model> --dispatch-mode thinktime
```
`time_to_parent_chat = this_turn.request_ready_time_ms parent_turn.request_end_time_ms`,
computed from the raw trace and stored per request; turn-1 has none (fires at its
trace arrival). Traces without the field fall back to `tracets`.
---
## Project map
- [`PAPER_OUTLINE.md`](PAPER_OUTLINE.md) — GPU-hit-first paper outline (the thesis).
- [`v2/`](v2/) — evidence experiments:
- `exp_a_tier_latency/` — KV-hit cost by tier (GPU < CPU-local < remote-RDMA < miss).
- `exp_b_capacity_knee/` realized APC / latency knee vs GPU capacity.
- `exp_c_dispatch_ablation/` the replay-mode study above.
- `replayer/` trace replayer (`--dispatch-mode`, closed-loop think-time).
- `scripts/add_time_to_parent.py` trace annotation for `thinktime`.
- `microbench/`, `analysis/` PD-disagg, routing, workload characterization.

View File

@@ -39,52 +39,75 @@ Production trace = Qwen3-Coder agentic1.3 M sessions / 2.1 M reqs / 7200 s。
参考图:`figs/f4a_apc_loss.png``figs/f4b_pdsep_kv_wall.png``figs/f4c_per_worker_ttft.png``figs/f6_e2e_latency_bars.png``figs/f6_e2e_latency_full_grid.png`
## 4. PD-disagg 在 agentic 下输——cost vs benefit§3.2
## 4. Static PD-disagg 为什么失败§3.2)—— 容量问题,不是 cost-benefit 问题
由两个独立 microbench 钉死(**全用 vanilla vLLM 0.18.1 + Mooncake 0.3.11fresh venv无 patch**
**2026-05-27 纠正**:本节前一版本论证"PD-disagg 因为 transfer cost > phase isolation benefit 而失败"。**这个论证算错了**。正确的 phase-isolation benefit 是**每个 prefill 事件 × D 个 concurrent stream** 的总和(≈ `D × T_prefill`),不是单个 request 的 decode 时长。用正确公式PD-disagg 在 phase-isolation 这一维上**赢 colo 一两个数量级**。Static PD-disagg 在 agentic 上失败的**真正根因是 D 侧 KV pool 容量**,不是这一维
### 4.1 MB2 — KV transfer cost
### 4.1 真正的失败模式D 侧 KV 容量天花板
dash1 GPU 0+1intra-node和 dash1 ↔ dash2inter-node, 200 Gbps RoCE扫 9 个 size × 5 reps。
| | 8C colo | 4P+4D PD-disagg |
|---|---:|---:|
| Per-D-instance KV pool0.4 × 96 GiB | 38 GiB | 38 GiB |
| 系统总 decode 容量D 实例数 × 单池) | 8 × 38 = **304 GiB** | 4 × 38 = **152 GiB** |
| p99 单请求 KV = 11.5 GiB → 最多并发 decode | 24 | **12减半** |
| 路径 | 稳态带宽(≤ 3 GiB | p99 agentic 请求11.5 GiBtransfer 时间 |
Colleague 4P+4D 实测TTFT p50 0.91 s → **62.8 s62×**、success rate **99.5% → 52%**。失败模式:**D 池溢出 + 排队**,不是 transfer 延迟。
参考图:`figs/f4b_pdsep_kv_wall.png`pdf 版本是高质量 paper figure
### 4.2 MB2 — KV transfer costper-request 一次性成本,**不**是 dominant cost
dash1 GPU 0+1intra和 dash1 ↔ dash2inter, 200 Gbps RoCE扫 9 个 size × 5 reps。
| 路径 | 稳态带宽(≤ 3 GiB | p99 agentic 请求 11.5 GiB transfer |
|---|---|---|
| Intra-node | **9.7 GB/s** | p50 **1.9 s** · min 1.5 s · max 10 s |
| Inter-node | **10.0 GB/s**(差 <3% | p50 **1.7 s** · min 1.3 s · max 9.2 s |
| Intra-node | **9.7 GB/s** | p50 **1.9 s** · max 10 s |
| Inter-node | **10.0 GB/s**(差 <3% | p50 **1.7 s** · max 9.2 s |
**新发现**intra/inter 几乎重合 **Mooncake `batch_transfer_sync_write` 永远走 RDMA NIC,包括 intra-node loopback**不走 NVLink200 Gbps NIC 是天花板**PD-disagg transfer cost 与拓扑无关**。
**新发现**intra/inter 几乎重合 **Mooncake `batch_transfer_sync_write` 永远走 RDMA NIC**不走 NVLink200 Gbps NIC 是天花板**PD-disagg transfer cost 与拓扑无关**。
参考图`figs/mb2_transfer_time_compare.png``figs/mb2_transfer_bw_compare.png`doc `analysis/mb2/README.md`
参考图`figs/mb2_transfer_time_compare.png`doc `analysis/mb2/README.md`
### 4.2 MB1 — Phase interferencechunked-prefill on, 默认 baseline
### 4.3 MB1 — Phase interferencePD-disagg 的潜在 benefit 上界
dash1 GPU 0 instanceDconcurrent decodes× Pprefill size扫描
dash1 GPU 0 instance kv_connectorchunked-prefill 默认开启D × P 扫描D=8 结果
D=8 agentic-realistic的结果
| Prefill | prefill_ttft | per-stream TPOT during | penalty |
| Prefill | T_prefill | per-stream TPOT during | penalty |
|---|---:|---:|---:|
| 2k tok | 143 ms | 32 ms | 4× |
| 8k | 583 ms | 114 ms | 15× |
| 32k | 4.5 s | 388 ms | **52×** |
| 65k | 15.6 s | 757 ms | **99×** |
| 131k | 57 s | 1419 ms | **183×** |
| 32k tok | 4.5 s | 388 ms | **52×** |
| 131k tok | 57 s | 1419 ms | **183×** |
baseline TPOT 7.7 ms。**Decode 在大 prefill 期间基本被 halted**。chunked-prefill 已经默认开启PD-disagg 在它之上能额外提供的 phase isolation = **decode 在 prefill 期间被 halted 的那部分时间**
**Decode 在 prefill 期间被几乎完全 halted** stream 损失 `T_prefill` 的时间。**每个 prefill event decode 损失 `D × T_prefill`**
参考图`figs/mb1_interference.png`doc `analysis/mb1/README.md`
### 4.3 联合结论
### 4.4 联合 cost-benefitper-prefill event
| | Per-request |
|---|---|
| **Max PD-disagg benefit**救回来的 decode 时间| **decode 时长 = 50200 ms**agentic tool-call output|
| **PD-disagg cost**MB2 transfer p50| 80 MiB 8 ms · 3 GiB 320 ms · 11.5 GiB **1.9 s**p99 实测最差 10 s|
| Cost / Benefit | **每个 KV ≥ 80 MiB 的请求都输**trace 平均 KV 192 MiB 已经输 |
| Prefill (KV size) | T_prefill | Cost = T_transfer | Benefit = D × T_prefill (D=8) | Cost / Benefit |
|---:|---:|---:|---:|---:|
| 2k tok (192 MiB) | 0.14 s | 8 ms | 1.1 s | **0.7%** |
| 33k tok (3 GiB, trace mean) | 4.5 s | 0.32 s | 36 s | **0.9%** |
| 125k tok (12 GiB, ~p99) | 57 s | 1.9 s | 456 s | **0.4%** |
**结论** agentic **PD-disaggregation 是结构性失败的**Chunked-prefill 默认已经在 colocation 内做了 first-order phase isolationPD-disagg 在此之上能额外补的decode 短时段没被 prefill 小于它新带来的每个 routed 请求都付 KV transfer)。这个结论与拓扑无关intra-node inter-node 一样)。
**PD-disagg 在 phase-isolation 这一维赢 100×250×****这不是 §3.2 该用的论证**因为 §3.2 真正的 dominant failure §4.1 D 池容量天花板颠覆了上面的全部数学)。
参考图`figs/pd_cost_vs_benefit.png`(§3.2 headline)。
**总结**
- D KV 容量天花板(§4.1)→ PD-disagg agentic **结构性失败**。
- 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
@@ -96,10 +119,12 @@ baseline TPOT 7.7 ms。**Decode 在大 prefill 期间基本被 halted**。chunke
## 6. 已经能写的 paper 主张(按 confidence 排序)
1. **Agentic vs chatbot 在调度上是不同 regime**dispatch coupling + sub-second tool-call mass)—— 实证完整
2. **PD-disaggregation 在 agentic 下输**cost > benefit跨拓扑—— **MB1 + MB2 实证完整**
3. **三类现有调度 baseline 各自的失败模式** —— 实证完整
4. **Affinity-default 调度current unified达到 APC 上界**per-worker latency 也压倒 sticky —— 实证完整
5. **Hot-triggered migration 修复 sticky 的 hot pin** —— **design 完整、e2e 待验证**
2. **三类现有调度 baseline 各自的失败模式**load-balance / static PD-disagg / pure sticky)—— 实证完整
3. **Static PD-disagg 在 agentic 下失败的 dominant 根因是 D 侧 KV 容量**不是 phase-isolation cost-benefit)—— 实证完整`f4b` + colleague 4P+4D 数据
4. **Mooncake transfer cost 拓扑无关**intra inter~9.7 GB/s NIC 上限)—— 实证完整MB2
5. **Phase isolation interference 在 chunked-prefill on 下仍然显著**per-stream TPOT during prefill 15×2000× baseline)—— 实证完整MB1)。**注意**这条数据本身不直接论证 "PD-disagg 失败"因为算正确账后 PD-disagg 反而在这一维上赢它的用途是给 §3.2 提供 phase-isolation benefit 上界的量化
6. **Affinity-default 调度current unified达到 APC 上界**per-worker latency 也压倒 sticky —— 实证完整
7. **Hot-triggered migration 修复 sticky 的 hot pin** —— design 完整e2e 待验证
## 7. 待做

View File

@@ -0,0 +1,142 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.1385939740576,
"amplification": 1.0013425037685975,
"ttft": {
"count": 1167,
"mean": 0.08144469193556772,
"p50": 0.07862715201918036,
"p90": 0.08015060934703797,
"p99": 0.0875979653932154
},
"tpot": {
"count": 1167,
"mean": 0.005001699398049616,
"p50": 0.004988961030788246,
"p90": 0.005045765990923557,
"p99": 0.005062779263327164
},
"e2e": {
"count": 1167,
"mean": 0.3968209869372152,
"p50": 0.393534954986535,
"p90": 0.39730903680901974,
"p99": 0.40925762055674536
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.1604231200181,
"amplification": 1.0014153316291596,
"ttft": {
"count": 1167,
"mean": 0.09946277569807849,
"p50": 0.09600010397844017,
"p90": 0.10452785079833121,
"p99": 0.11205230774357905
},
"tpot": {
"count": 1167,
"mean": 0.005007447102661814,
"p50": 0.004987124730611131,
"p90": 0.005003212126977151,
"p99": 0.005478902989961502
},
"e2e": {
"count": 1167,
"mean": 0.415208436744531,
"p50": 0.41056320699863136,
"p90": 0.4200975856045261,
"p99": 0.44871115096379066
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.2032543020323,
"amplification": 1.0015582278907484,
"ttft": {
"count": 1167,
"mean": 0.10561635944505095,
"p50": 0.10468761203810573,
"p90": 0.11257308297790587,
"p99": 0.12065987563692024
},
"tpot": {
"count": 1167,
"mean": 0.005328901365752947,
"p50": 0.005144592110318915,
"p90": 0.005990574603515958,
"p99": 0.006688486758013448
},
"e2e": {
"count": 1167,
"mean": 0.4416300980896939,
"p50": 0.42991435900330544,
"p90": 0.4854830394731835,
"p99": 0.5404117306252005
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
},
{
"ttft_slo_s": 5.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
}
]
}

View File

@@ -0,0 +1,142 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 303.05708941399644,
"amplification": 1.0110793839624128,
"ttft": {
"count": 1167,
"mean": 1.674437926279444,
"p50": 1.5353219069947954,
"p90": 2.0787689138029237,
"p99": 3.039117059087727
},
"tpot": {
"count": 1167,
"mean": 0.035498316425319934,
"p50": 0.02951206674587743,
"p90": 0.085249871320677,
"p99": 0.15422643764865662
},
"e2e": {
"count": 1167,
"mean": 3.9111985703531236,
"p50": 3.392241114997887,
"p90": 7.760864628604043,
"p99": 11.30427318874542
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 1167,
"n_success": 1136,
"completion_rate": 0.9734361610968295,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 866.9596620649972,
"amplification": 2.8924089607535697,
"ttft": {
"count": 1136,
"mean": 65.09021699308856,
"p50": 66.22900710900285,
"p90": 112.5535424454938,
"p99": 124.55262411334482
},
"tpot": {
"count": 1136,
"mean": 0.005710658520121912,
"p50": 0.005725543936557461,
"p90": 0.005750613698356098,
"p99": 0.0058447879207267845
},
"e2e": {
"count": 1136,
"mean": 65.4504681098121,
"p50": 66.59053339700768,
"p90": 112.9150809329949,
"p99": 124.91415351489852
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 307.42712411200046,
"amplification": 1.0256589867656205,
"ttft": {
"count": 1167,
"mean": 3.6233640342625417,
"p50": 3.255483777000336,
"p90": 6.0935156565916255,
"p99": 7.349482456580735
},
"tpot": {
"count": 1167,
"mean": 0.006360297526341433,
"p50": 0.006324973206372104,
"p90": 0.007198417158741947,
"p99": 0.007942749238420567
},
"e2e": {
"count": 1167,
"mean": 4.024414356593621,
"p50": 3.6796783979953034,
"p90": 6.510242249601289,
"p99": 7.7530036393977895
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 0.6563838903170522,
"pd_advantage": 1.0,
"n_slo": 766
},
"4P+4D": {
"attainment": 0.0,
"pd_advantage": 0.0,
"n_slo": 0
},
"6P+2D": {
"attainment": 0.20565552699228792,
"pd_advantage": 0.3133159268929504,
"n_slo": 240
}
}
},
{
"ttft_slo_s": 5.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 0.7095115681233933,
"pd_advantage": 1.0,
"n_slo": 828
},
"4P+4D": {
"attainment": 0.012853470437017995,
"pd_advantage": 0.018115942028985508,
"n_slo": 15
},
"6P+2D": {
"attainment": 0.7746358183376179,
"pd_advantage": 1.0917874396135265,
"n_slo": 904
}
}
}
]
}

View File

@@ -0,0 +1,142 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.1927249849541,
"amplification": 1.0015230992770423,
"ttft": {
"count": 1167,
"mean": 0.1367582682356152,
"p50": 0.1337889520218596,
"p90": 0.13568343156948687,
"p99": 0.1417779766954481
},
"tpot": {
"count": 1167,
"mean": 0.005039774583944486,
"p50": 0.005025791809968059,
"p90": 0.005088228446667984,
"p99": 0.0051380019550329985
},
"e2e": {
"count": 1167,
"mean": 0.45452996390628697,
"p50": 0.45110413199290633,
"p90": 0.4555703255813569,
"p99": 0.4688122991356063
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.2166367470054,
"amplification": 1.0016028753010615,
"ttft": {
"count": 1167,
"mean": 0.1660798433322169,
"p50": 0.16653224604669958,
"p90": 0.17306958099361508,
"p99": 0.1832630861806683
},
"tpot": {
"count": 1167,
"mean": 0.005046875948133808,
"p50": 0.0050270736812510425,
"p90": 0.0050664691831029595,
"p99": 0.005532995437730161
},
"e2e": {
"count": 1167,
"mean": 0.48433690918382055,
"p50": 0.4845748710213229,
"p90": 0.4916891472181305,
"p99": 0.5235905526624989
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.2855537990108,
"amplification": 1.001832800991369,
"ttft": {
"count": 1167,
"mean": 0.17076528707394278,
"p50": 0.16959826194215566,
"p90": 0.17923957279417663,
"p99": 0.18792458378709853
},
"tpot": {
"count": 1167,
"mean": 0.005377765550956012,
"p50": 0.005177637602808693,
"p90": 0.006078562941697855,
"p99": 0.007001848420689976
},
"e2e": {
"count": 1167,
"mean": 0.5098518045703324,
"p50": 0.4961305959150195,
"p90": 0.5569328068522736,
"p99": 0.6314893902023319
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
},
{
"ttft_slo_s": 5.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
}
]
}

View File

@@ -0,0 +1,142 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 1167,
"n_success": 951,
"completion_rate": 0.8149100257069408,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 567.1572082909988,
"amplification": 1.8921879104611408,
"ttft": {
"count": 951,
"mean": 156.08624654169503,
"p50": 159.80228465000982,
"p90": 263.44082942500245,
"p99": 267.3189407904938
},
"tpot": {
"count": 951,
"mean": 0.5120599246480225,
"p50": 0.5265210192539223,
"p90": 0.5403215261268621,
"p99": 0.5436686666190138
},
"e2e": {
"count": 951,
"mean": 188.3470215559188,
"p50": 192.9057134770119,
"p90": 294.68771972299146,
"p99": 300.2205298934932
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 1167,
"n_success": 596,
"completion_rate": 0.5107112253641817,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 789.8321366070013,
"amplification": 2.63509093833936,
"ttft": {
"count": 596,
"mean": 240.99040966441748,
"p50": 259.1066791820049,
"p90": 379.7789027979961,
"p99": 381.22047349565617
},
"tpot": {
"count": 596,
"mean": 0.006412497327210905,
"p50": 0.006444117785865692,
"p90": 0.0064974606745333095,
"p99": 0.006660929446938789
},
"e2e": {
"count": 596,
"mean": 241.39516468475196,
"p50": 259.5132920899996,
"p90": 380.1822811405,
"p99": 381.620277955458
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 1167,
"n_success": 712,
"completion_rate": 0.6101113967437874,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 880.7267912379903,
"amplification": 2.938339780290167,
"ttft": {
"count": 712,
"mean": 175.69983718378106,
"p50": 172.43730882649834,
"p90": 250.6456608123961,
"p99": 594.014642834873
},
"tpot": {
"count": 712,
"mean": 0.007946936024875445,
"p50": 0.007568808706382119,
"p90": 0.010293392149296345,
"p99": 0.011275891952114068
},
"e2e": {
"count": 712,
"mean": 176.20114515147316,
"p50": 172.91078094949626,
"p90": 251.09661155799986,
"p99": 594.4257701966026
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 0.0,
"pd_advantage": NaN,
"n_slo": 0
},
"4P+4D": {
"attainment": 0.0,
"pd_advantage": NaN,
"n_slo": 0
},
"6P+2D": {
"attainment": 0.0,
"pd_advantage": NaN,
"n_slo": 0
}
}
},
{
"ttft_slo_s": 5.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 0.0,
"pd_advantage": NaN,
"n_slo": 0
},
"4P+4D": {
"attainment": 0.0,
"pd_advantage": NaN,
"n_slo": 0
},
"6P+2D": {
"attainment": 0.0,
"pd_advantage": NaN,
"n_slo": 0
}
}
}
]
}

View File

@@ -0,0 +1,142 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.32634208898526,
"amplification": 1.0019688816195438,
"ttft": {
"count": 1167,
"mean": 0.2628433253752162,
"p50": 0.2604120689211413,
"p90": 0.2637787788407877,
"p99": 0.2976114549371393
},
"tpot": {
"count": 1167,
"mean": 0.005143964614959416,
"p50": 0.005124552334318795,
"p90": 0.005180289564166395,
"p99": 0.0052467077378080115
},
"e2e": {
"count": 1167,
"mean": 0.5871811069348818,
"p50": 0.5839827890740708,
"p90": 0.5883447280153632,
"p99": 0.6408826243295329
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.3759389320621,
"amplification": 1.0021343499332585,
"ttft": {
"count": 1167,
"mean": 0.30861241175426407,
"p50": 0.3091614870354533,
"p90": 0.3227507559815422,
"p99": 0.3547600568598136
},
"tpot": {
"count": 1167,
"mean": 0.005148936446188915,
"p50": 0.0051316127147791645,
"p90": 0.005184003428393413,
"p99": 0.00547781713853871
},
"e2e": {
"count": 1167,
"mean": 0.6332931331915089,
"p50": 0.6365591660141945,
"p90": 0.6472898541716858,
"p99": 0.7053035433730102
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.4041277950164,
"amplification": 1.0022283955081221,
"ttft": {
"count": 1167,
"mean": 0.31527804771431056,
"p50": 0.31678270106203854,
"p90": 0.3284846659982577,
"p99": 0.3402980738645419
},
"tpot": {
"count": 1167,
"mean": 0.0055213440500518985,
"p50": 0.005325577683776381,
"p90": 0.006278165857056304,
"p99": 0.0070092806818761975
},
"e2e": {
"count": 1167,
"mean": 0.6634175265073216,
"p50": 0.6494167000055313,
"p90": 0.7135140666039661,
"p99": 0.7741130576422437
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
},
{
"ttft_slo_s": 5.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
}
]
}

View File

@@ -0,0 +1,142 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.66455180699995,
"amplification": 1.0030972395602922,
"ttft": {
"count": 1167,
"mean": 0.584800394000121,
"p50": 0.5817365200055065,
"p90": 0.5870239008043427,
"p99": 0.6088061455392736
},
"tpot": {
"count": 1167,
"mean": 0.005585180932058873,
"p50": 0.005308644777941813,
"p90": 0.005355837698406836,
"p99": 0.014590345752427337
},
"e2e": {
"count": 1167,
"mean": 0.9369382756572071,
"p50": 0.9158835929993074,
"p90": 0.9229304600012256,
"p99": 1.5052119748995754
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.7607197400066,
"amplification": 1.003418081467173,
"ttft": {
"count": 1167,
"mean": 0.7293765576349805,
"p50": 0.6887177099997643,
"p90": 0.8911576298065484,
"p99": 1.1879012519156076
},
"tpot": {
"count": 1167,
"mean": 0.005308951736224922,
"p50": 0.0053098901269203495,
"p90": 0.005347346425488857,
"p99": 0.005420954272433716
},
"e2e": {
"count": 1167,
"mean": 1.064155324243521,
"p50": 1.0238807429996086,
"p90": 1.22566919879755,
"p99": 1.521622942700049
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 1167,
"n_success": 1167,
"completion_rate": 1.0,
"offered_window_s": 299.736197,
"offered_qps": 3.8934236561358655,
"wall_clock_s": 300.8507765819959,
"amplification": 1.003718535142407,
"ttft": {
"count": 1167,
"mean": 0.7028102769056583,
"p50": 0.6950660040020011,
"p90": 0.7394948218076024,
"p99": 0.8658222487580485
},
"tpot": {
"count": 1167,
"mean": 0.005782890183689697,
"p50": 0.00558671395230617,
"p90": 0.006629064533303286,
"p99": 0.007366264003757681
},
"e2e": {
"count": 1167,
"mean": 1.0674261210719522,
"p50": 1.0612060459970962,
"p90": 1.1425091800017981,
"p99": 1.3045775910036173
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
},
{
"ttft_slo_s": 5.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 1167
}
}
}
]
}

View File

@@ -0,0 +1,121 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 3552,
"n_success": 1950,
"completion_rate": 0.5489864864864865,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 348.4953830049999,
"amplification": 1.1626534102882857,
"ttft": {
"count": 1950,
"mean": 0.8245253885559363,
"p50": 0.7988759850013594,
"p90": 1.5384256363991882,
"p99": 2.125257717882705
},
"tpot": {
"count": 1950,
"mean": 0.07444567704430907,
"p50": 0.08914986800879843,
"p90": 0.09127071481447005,
"p99": 0.09233926755616054
},
"e2e": {
"count": 1950,
"mean": 77.04034660455785,
"p50": 92.29079191399796,
"p90": 94.47993849659906,
"p99": 95.72745552870882
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 3552,
"n_success": 2754,
"completion_rate": 0.7753378378378378,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 744.3694221920014,
"amplification": 2.4833719166185753,
"ttft": {
"count": 2754,
"mean": 4.5006646864741695,
"p50": 2.329877773499902,
"p90": 10.863291689799375,
"p99": 21.572781211729307
},
"tpot": {
"count": 2754,
"mean": 0.046089308743682535,
"p50": 0.04704797274047173,
"p90": 0.04792202048768524,
"p99": 0.059544689888886115
},
"e2e": {
"count": 2754,
"mean": 51.68415100813728,
"p50": 50.91020956099965,
"p90": 59.22212425199977,
"p99": 68.42946432940865
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 3552,
"n_success": 1928,
"completion_rate": 0.5427927927927928,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 821.7403777359868,
"amplification": 2.7414975897474716,
"ttft": {
"count": 1928,
"mean": 39.07585104131927,
"p50": 42.6814165695032,
"p90": 63.28579387369681,
"p99": 73.84470144698193
},
"tpot": {
"count": 1928,
"mean": 0.04064862157373329,
"p50": 0.0398688508558163,
"p90": 0.04277554483881431,
"p99": 0.0695667276081441
},
"e2e": {
"count": 1928,
"mean": 80.66499786808757,
"p50": 84.05398454950046,
"p90": 105.42814423799427,
"p99": 113.52591980495636
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 0.10191441441441441,
"pd_advantage": 1.0,
"n_slo": 362
},
"4P+4D": {
"attainment": 0.3502252252252252,
"pd_advantage": 3.43646408839779,
"n_slo": 1244
},
"6P+2D": {
"attainment": 0.06447072072072071,
"pd_advantage": 0.6325966850828729,
"n_slo": 229
}
}
}
]
}

View File

@@ -0,0 +1,121 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 300.61600343801547,
"amplification": 1.0029177964157683,
"ttft": {
"count": 3552,
"mean": 0.139721406387908,
"p50": 0.13759525847854093,
"p90": 0.14188919204752892,
"p99": 0.17052793529117438
},
"tpot": {
"count": 3552,
"mean": 0.006496044329480822,
"p50": 0.006430442302898453,
"p90": 0.007889476366154117,
"p99": 0.009282101713493095
},
"e2e": {
"count": 3552,
"mean": 0.9651016481538636,
"p50": 0.9548319909954444,
"p90": 1.1440699087572284,
"p99": 1.3324073514551853
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 300.7410873459885,
"amplification": 1.0033351024670651,
"ttft": {
"count": 3552,
"mean": 0.18467717330343286,
"p50": 0.1806962049449794,
"p90": 0.1929074571467936,
"p99": 0.2592483600333798
},
"tpot": {
"count": 3552,
"mean": 0.006980159784640828,
"p50": 0.006945047342387123,
"p90": 0.008164690015837552,
"p99": 0.009163911323187739
},
"e2e": {
"count": 3552,
"mean": 1.0715517728354285,
"p50": 1.063076548918616,
"p90": 1.2275727374362762,
"p99": 1.4021264237130526
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 301.1243950970238,
"amplification": 1.004613897210595,
"ttft": {
"count": 3552,
"mean": 0.20078434001432569,
"p50": 0.19958186394069344,
"p90": 0.213167638995219,
"p99": 0.23225504373782313
},
"tpot": {
"count": 3552,
"mean": 0.010393430778126047,
"p50": 0.010396533917478015,
"p90": 0.012065167099743436,
"p99": 0.01323438493101344
},
"e2e": {
"count": 3552,
"mean": 1.5212831554822883,
"p50": 1.5218885459471494,
"p90": 1.7440477049094625,
"p99": 1.9191367691196497
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
},
"4P+4D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
}
}
}
]
}

View File

@@ -0,0 +1,121 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 3552,
"n_success": 1050,
"completion_rate": 0.2956081081081081,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 414.92026689198974,
"amplification": 1.3842607013613617,
"ttft": {
"count": 1050,
"mean": 0.8393797435154972,
"p50": 0.8442945880015031,
"p90": 1.4299255040969001,
"p99": 2.1686189359705894
},
"tpot": {
"count": 1050,
"mean": 0.08418485046407832,
"p50": 0.08937374470713316,
"p90": 0.09085719287396188,
"p99": 0.09190563933350096
},
"e2e": {
"count": 1050,
"mean": 173.22149556513375,
"p50": 183.9983977669981,
"p90": 187.03014375649946,
"p99": 189.36978534828302
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 3552,
"n_success": 1320,
"completion_rate": 0.3716216216216216,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 802.1317602040072,
"amplification": 2.6760791447511205,
"ttft": {
"count": 1320,
"mean": 19.92388417242484,
"p50": 8.059029546493548,
"p90": 64.71132232169766,
"p99": 87.07121913193099
},
"tpot": {
"count": 1320,
"mean": 0.05007250707090057,
"p50": 0.0492711045202749,
"p90": 0.07142825628519621,
"p99": 0.08267229383702501
},
"e2e": {
"count": 1320,
"mean": 122.44309290737698,
"p50": 118.35192646600626,
"p90": 167.89536100080443,
"p99": 187.19021692074878
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 3552,
"n_success": 1041,
"completion_rate": 0.29307432432432434,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 879.8981197069952,
"amplification": 2.935523968100636,
"ttft": {
"count": 1041,
"mean": 86.06142147925078,
"p50": 92.45557322700915,
"p90": 153.47880471601093,
"p99": 159.16995789420034
},
"tpot": {
"count": 1041,
"mean": 0.04275836784091199,
"p50": 0.038143618996089604,
"p90": 0.06297858072350163,
"p99": 0.07100282797010407
},
"e2e": {
"count": 1041,
"mean": 173.5925440399473,
"p50": 199.81516771799943,
"p90": 230.71809448600106,
"p99": 234.9438766648003
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 0.009009009009009009,
"pd_advantage": 1.0,
"n_slo": 32
},
"4P+4D": {
"attainment": 0.11486486486486487,
"pd_advantage": 12.75,
"n_slo": 408
},
"6P+2D": {
"attainment": 0.04954954954954955,
"pd_advantage": 5.5,
"n_slo": 176
}
}
}
]
}

View File

@@ -0,0 +1,121 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 301.78380814404227,
"amplification": 1.0068138369093473,
"ttft": {
"count": 3552,
"mean": 0.14506312318632342,
"p50": 0.14291562204016373,
"p90": 0.1491016250802204,
"p99": 0.1832571337954128
},
"tpot": {
"count": 3552,
"mean": 0.0089611879488046,
"p50": 0.008925844482420122,
"p90": 0.010698060123752072,
"p99": 0.011754058378410762
},
"e2e": {
"count": 3552,
"mean": 2.430673889741114,
"p50": 2.419925262918696,
"p90": 2.874893436266575,
"p99": 3.1480205075151746
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 3552,
"n_success": 3551,
"completion_rate": 0.9997184684684685,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 744.8868488909211,
"amplification": 2.4850981601942745,
"ttft": {
"count": 3551,
"mean": 0.20500909556101396,
"p50": 0.20017725799698383,
"p90": 0.21613375598099083,
"p99": 0.29023518750909716
},
"tpot": {
"count": 3551,
"mean": 0.010307210080394247,
"p50": 0.010299421709450874,
"p90": 0.011793581666607482,
"p99": 0.012639003840969035
},
"e2e": {
"count": 3551,
"mean": 2.8339171693501317,
"p50": 2.8360355379991233,
"p90": 3.2198793930001557,
"p99": 3.438588996999897
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 303.4300506779691,
"amplification": 1.0123060459587976,
"ttft": {
"count": 3552,
"mean": 0.2333820717117756,
"p50": 0.23283391550648957,
"p90": 0.24394672318594532,
"p99": 0.2734717815916518
},
"tpot": {
"count": 3552,
"mean": 0.016453822599812207,
"p50": 0.016576926972415737,
"p90": 0.017214638463623238,
"p99": 0.01769411424845092
},
"e2e": {
"count": 3552,
"mean": 4.430368151474328,
"p50": 4.463736370031256,
"p90": 4.628522685484495,
"p99": 4.75812664218829
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
},
"4P+4D": {
"attainment": 0.9997184684684685,
"pd_advantage": 0.9997184684684685,
"n_slo": 3551
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
}
}
}
]
}

View File

@@ -0,0 +1,121 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 3552,
"n_success": 545,
"completion_rate": 0.15343468468468469,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 505.4139580530027,
"amplification": 1.6861665622961597,
"ttft": {
"count": 545,
"mean": 4.137136383750316,
"p50": 0.5702517530007754,
"p90": 1.6976309113961179,
"p99": 56.1814190310361
},
"tpot": {
"count": 545,
"mean": 0.08390926873267023,
"p50": 0.08933810225128375,
"p90": 0.09449654152820693,
"p99": 0.10591519025133134
},
"e2e": {
"count": 545,
"mean": 347.7765141811284,
"p50": 366.3568219640001,
"p90": 388.1241692415934,
"p99": 435.2848098113155
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 3552,
"n_success": 771,
"completion_rate": 0.2170608108108108,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 850.1328976760124,
"amplification": 2.8362209684341706,
"ttft": {
"count": 771,
"mean": 63.210636654403324,
"p50": 1.1097561890055658,
"p90": 179.64081536799495,
"p99": 186.67013871119852
},
"tpot": {
"count": 771,
"mean": 0.05484690890217986,
"p50": 0.04795774376874187,
"p90": 0.08067800507863548,
"p99": 0.09099416300415113
},
"e2e": {
"count": 771,
"mean": 287.81779562259663,
"p50": 312.2160719559906,
"p90": 372.0004520520015,
"p99": 379.3279856524052
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 3552,
"n_success": 627,
"completion_rate": 0.17652027027027026,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 867.8833550199925,
"amplification": 2.8954402028102515,
"ttft": {
"count": 627,
"mean": 179.58769048342904,
"p50": 238.1998468660022,
"p90": 378.29023678940143,
"p99": 385.40577973942356
},
"tpot": {
"count": 627,
"mean": 0.04188420961205498,
"p50": 0.03654626756630041,
"p90": 0.06031132874202571,
"p99": 0.06738955674930582
},
"e2e": {
"count": 627,
"mean": 351.1066520824709,
"p50": 387.2650127700035,
"p90": 507.9008203571953,
"p99": 570.6463984230224
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 0.0,
"pd_advantage": NaN,
"n_slo": 0
},
"4P+4D": {
"attainment": 0.05855855855855856,
"pd_advantage": NaN,
"n_slo": 208
},
"6P+2D": {
"attainment": 0.036036036036036036,
"pd_advantage": NaN,
"n_slo": 128
}
}
}
]
}

View File

@@ -0,0 +1,121 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 304.97662343096454,
"amplification": 1.0174657357946404,
"ttft": {
"count": 3552,
"mean": 0.16242270423816507,
"p50": 0.15836620499612764,
"p90": 0.17363918052287775,
"p99": 0.24847999344696287
},
"tpot": {
"count": 3552,
"mean": 0.013691483182659388,
"p50": 0.013865661168213087,
"p90": 0.015242900696529327,
"p99": 0.01626683903372128
},
"e2e": {
"count": 3552,
"mean": 7.160272567887971,
"p50": 7.247483663493767,
"p90": 7.962273431208451,
"p99": 8.480892732607899
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 3552,
"n_success": 3551,
"completion_rate": 0.9997184684684685,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 744.9637431279989,
"amplification": 2.4853546954349977,
"ttft": {
"count": 3551,
"mean": 0.2446379999283825,
"p50": 0.24067969399038702,
"p90": 0.2630795220611617,
"p99": 0.3426029055262916
},
"tpot": {
"count": 3551,
"mean": 0.016916919575073883,
"p50": 0.016998299133030737,
"p90": 0.01775875886689886,
"p99": 0.018166751548973206
},
"e2e": {
"count": 3551,
"mean": 8.89104466149889,
"p50": 8.933106195996515,
"p90": 9.333998591057025,
"p99": 9.536390463996213
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 3552,
"n_success": 3551,
"completion_rate": 0.9997184684684685,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 312.45468625507783,
"amplification": 1.0424141157985172,
"ttft": {
"count": 3551,
"mean": 0.36231219612768273,
"p50": 0.3462264990666881,
"p90": 0.4059687410481274,
"p99": 0.9837204645154998
},
"tpot": {
"count": 3551,
"mean": 0.03268022218101953,
"p50": 0.03333031399418835,
"p90": 0.03557429400772957,
"p99": 0.038558618127279086
},
"e2e": {
"count": 3551,
"mean": 17.068018403084057,
"p50": 17.392655145958997,
"p90": 18.56302695896011,
"p99": 20.159411454980727
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
},
"4P+4D": {
"attainment": 0.9997184684684685,
"pd_advantage": 0.9997184684684685,
"n_slo": 3551
},
"6P+2D": {
"attainment": 0.9997184684684685,
"pd_advantage": 0.9997184684684685,
"n_slo": 3551
}
}
}
]
}

View File

@@ -0,0 +1,121 @@
{
"baseline": "8C-proxy",
"arms": {
"8C-proxy": {
"name": "8C-proxy",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 300.21845804993063,
"amplification": 1.001591501940313,
"ttft": {
"count": 3552,
"mean": 0.1369056966029782,
"p50": 0.13516780693316832,
"p90": 0.13835511771030723,
"p99": 0.15317223175661632
},
"tpot": {
"count": 3552,
"mean": 0.005430781936309906,
"p50": 0.005039893150780468,
"p90": 0.007063257358303028,
"p99": 0.0077793481296283135
},
"e2e": {
"count": 3552,
"mean": 0.4793568905444592,
"p50": 0.45412574551301077,
"p90": 0.5810393166844734,
"p99": 0.6301419002050533
}
},
"4P+4D": {
"name": "4P+4D",
"n_offered": 3552,
"n_success": 3551,
"completion_rate": 0.9997184684684685,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 735.1059510430787,
"amplification": 2.452467041410379,
"ttft": {
"count": 3551,
"mean": 0.17374673821534295,
"p50": 0.17102365801110864,
"p90": 0.18273873499128968,
"p99": 0.24688138795318082
},
"tpot": {
"count": 3551,
"mean": 0.005485491774206834,
"p50": 0.0053864502226046865,
"p90": 0.006091995366168992,
"p99": 0.007109403222178419
},
"e2e": {
"count": 3551,
"mean": 0.5196563635758822,
"p50": 0.5100997349945828,
"p90": 0.5655082209268585,
"p99": 0.6639982180204242
}
},
"6P+2D": {
"name": "6P+2D",
"n_offered": 3552,
"n_success": 3552,
"completion_rate": 1.0,
"offered_window_s": 299.74141899999995,
"offered_qps": 11.850214134070008,
"wall_clock_s": 300.38101644301787,
"amplification": 1.0021338307036503,
"ttft": {
"count": 3552,
"mean": 0.18293367427152893,
"p50": 0.1822461549891159,
"p90": 0.1938482352765277,
"p99": 0.21272844232735222
},
"tpot": {
"count": 3552,
"mean": 0.007192309629699456,
"p50": 0.007143509595484902,
"p90": 0.008732455453672816,
"p99": 0.009842920153335268
},
"e2e": {
"count": 3552,
"mean": 0.636424056327808,
"p50": 0.6324848984950222,
"p90": 0.7393875011475757,
"p99": 0.8261980937235056
}
}
},
"slo_grid": [
{
"ttft_slo_s": 2.0,
"tpot_slo_s": 0.05,
"arms": {
"8C-proxy": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
},
"4P+4D": {
"attainment": 0.9997184684684685,
"pd_advantage": 0.9997184684684685,
"n_slo": 3551
},
"6P+2D": {
"attainment": 1.0,
"pd_advantage": 1.0,
"n_slo": 3552
}
}
}
]
}

View File

@@ -0,0 +1,131 @@
# LPWL vs 4 baselines — parameter-free routing for agentic workloads
Date: 2026-05-29. Hardware: dash1, 8×H20, Qwen3-Coder-30B-A3B, TP=1,
max_model_len=200000, fresh vLLM per arm (cold APC), `--policy` via
`scripts/b3_isolated_policy.sh`. Analyzer: `scripts/bench_report.py`.
## Motivation
unified+A+B carries too many knobs (`overload_factor`, `lmetric_decode_weight`,
the 0.5 `cache_ratio` gate). Goal: a policy derived from the agentic *pattern*
with no tuned constants, that does not overfit.
## LPWL (Least-Prefill-Work-Left)
`scripts/cache_aware_proxy.py:pick_instance_leastwork`, `--policy leastwork`:
```
score_i = pending_prefill_tokens_i + max(0, input_len cache_hit_i) → argmin
```
Tie-break: fewest `num_requests`, then round-robin. **Zero hyperparameters.**
Why this shape (straight from the workload):
- Decode is cheap (I/O ~217×) ⇒ the only load worth modeling is outstanding
*prefill* token-work. No decode weight; dropping LMetric's `×num_requests`
also makes an idle-but-decoding host score `input` (its true marginal cost),
not 0 — fixing the empty-batch degeneracy for free.
- Cache-awareness *is* the affinity mechanism: a returning session's owner has
`new_uncached ≈ 0`, so it sticks unless its prefill backlog exceeds the cache
saving (`input`). The stick-vs-spill crossover is computed from real
token-work — no `overload_factor`, no `cache_ratio` gate.
- Session skew degrades gracefully: a heavy session inflates its owner's
`pending_prefill`, auto-diverting *other* sessions while the heavy one stays
put (no cold re-prefill).
## Results — 600s trace (`w600_r0.0015_st30_first600s.jsonl`, 807 reqs)
This is the colder regime (theoretical APC ceiling ≈ 70% vs 80% for full w600).
| policy | knobs | TTFT mean | TTFT p90 | E2E mean | E2E p90 | E2E p99 | TPOT p90 | APC | req-bal |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| **LPWL** | **0** | **3398** | **7983** | **8116** | **19014** | 87024 | 26 | 0.648 | **1.55×** |
| unified+A+B | 3 | 3876 | 11562 | 8199 | 22569 | **74266** | **25** | 0.661 | 1.56× |
| unified default | 2 | 5066 | 16389 | 10481 | 28427 | 96361 | 34 | 0.689 | 2.28× |
| LMetric | 0 | 4809 | 14037 | 10051 | 26726 | 97442 | 32 | 0.507 | 2.11× |
| sticky | 0 | 5758 | 20356 | 10815 | 34734 | 82732 | 28 | **0.696** | 3.86× |
(latencies ms; req-bal = max:min per-worker request count; this batch predates
the GPU-capture harness change so per-worker GPU util reads N/A.)
### Findings
1. **LPWL is overall best with zero knobs:** TTFT mean 12% / p90 31%, E2E
mean ~tie / p90 16% vs the tuned unified+A+B; best request balance; TPOT
tied-best. Only loss is E2E p99 (+17%) from heavy-class decode concentration.
2. **The baselines bracket the problem and explain why LPWL works:** sticky has
the highest APC (0.696) but worst latency (hot-pin, 3.86× imbalance); LMetric
has the worst APC (0.507) because `×num_requests` swallows the cache signal.
LPWL drops exactly that factor, so locality re-emerges (APC 0.648, beside the
explicit-affinity policies) while balance stays tight — the sweet spot, no
gate, no tuning.
3. **Anti-overfit, demonstrated:** unified+A+B was tuned (of=1.3, lmw=0.01) on
the *full* w600; on the colder 600s regime the parameter-free policy beats it
by 31% TTFT p90. The tuning did not transfer; LPWL did.
### Per-class TTFT (ms, mean / p50 / p90 / p99) — LPWL dominates except the floor
| class | LPWL p90 | A+B p90 | LPWL p99 | A+B p99 |
|---|---:|---:|---:|---:|
| WARM<5k | 319 | 324 | 1032 | 2092 |
| MED5-20k | 1618 | 1952 | 3013 | 33189 |
| HEAVY20-50k | 4851 | 6198 | 14599 | 29044 |
| HEAVY+>50k | 28942 | 33777 | 52651 | 50778 |
LPWL's only weak class is the workload-inherent HEAVY+>50k floor (≈tied across
all policies). Elsewhere it avoids the mid-class tails the unified gate creates
when it pins a mid request behind a 50k-token turn on a barely-warm owner.
## Full-w600 cross-check (1214 reqs, `outputs/lpwl_vs_ab_live/`)
On the warmer full trace, LPWL vs unified+A+B is a wash: LPWL wins TTFT p90
(14%) but loses TPOT (+38%) and per-worker balance. Combined claim across both
regimes: **LPWL ∈ [tied, clearly-better] vs a tuned baseline, at zero knobs.**
## Ablation: derived-κ decode term (`leastwork_kappa`) — NET-NEGATIVE
Tested the proposed knob-free fix for LPWL's E2E-p99: `--policy leastwork_kappa`,
`score = (pending_prefill + new_uncached) × (1 + κ·ongoing_decode_tokens)`, with
κ = 2.5e-6 *derived* from hardware (KV ~100 KB/tok ÷ HBM 4 TB/s ÷ TPOT 10 ms on
H20+Qwen3-30B-A3B), not trace-tuned. Same 600s trace, fresh vLLM, cold APC.
| metric | leastwork | leastwork_kappa | Δ |
|---|---:|---:|---:|
| TTFT p90 | 7983 | 9390 | +18% (worse) |
| TTFT p99 | 44891 | 42370 | 6% |
| E2E p90 | 19014 | 21674 | +14% (worse) |
| E2E p99 | 87024 | 90155 | +4% (did NOT fix) |
| APC | 0.648 | 0.647 | tie |
| req-balance | 1.55× | 1.97× | worse |
**Verdict: decode-awareness is the wrong lever for agentic.** The κ term is
correct physics aimed at a negligible effect (decode is cheap, output p50≈80),
so it mostly bounces heavy requests off their cache-owner → cold re-prefill
elsewhere → new hotspots (balance degrades 1.55×→1.97×). It does NOT fix E2E-p99
because that tail is the **structural HEAVY+>50k floor** (per-class p99 ≈5152k
for *all* policies), not decode interference — i.e. not routing-fixable. This is
a negative result that *justifies* LPWL's omission of any decode term. The policy
is kept in-tree as a documented ablation; do not revive without a decode-heavy
regime. (First run on the GPU-capturing harness: per-worker GPU util mean 4283%,
1.95× spread — it even shows the κ-induced imbalance.)
## Caveats / open work
- n=1 per arm. The 600s 31% TTFT p90 is corroborated by mean/p50/per-class, but
repeat to bound run-to-run noise (no 3× repeats yet, by request — quick single
set first).
- E2E-p99 deep tail is the one consistent LPWL weak spot (heavy-session decode
concentration). Proposed knob-free fix: add `+ κ·ongoing_decode` with
`κ = measured(TPOT/token) / prefill_throughput` (a derived hardware ratio, not
a tuned scalar). Not yet implemented.
## Repro
```bash
# 5-policy, 600s trace (≈18 min/arm, ~90 min total)
OUTROOT=.../outputs/policy5_600s \
bash microbench/connector_tax/cache_sweep/run_5policy_600s.sh
# unified report
.venv/bin/python scripts/bench_report.py --root .../outputs/policy5_600s \
leastwork unified_ab unified_def lmetric sticky
```

View File

@@ -15,20 +15,28 @@ bottom; the **Summary** block is what gets cited.
| Effective per-stream TPOT during **8k-token** prefill burst (D=8) | **114 ms (≈15× baseline)** |
| Effective per-stream TPOT during **32k-token** prefill burst (D=8) | **388 ms (≈52×)** |
| Effective per-stream TPOT during **131k-token** prefill burst (D=8) | **1419 ms (≈183×)** |
| Maximum PD-disagg benefit per agentic decode | **≤ 50200 ms** (= decode duration) |
**§3.2 headline (cost vs benefit, this run + MB2)**:
**What MB1 actually measures**:
> Under chunked-prefill, every ongoing decode stream is essentially
> **halted while a prefill chunk is in flight** — per-stream effective
> TPOT during the burst is 15× to 2000× baseline, scaling with prefill
> size. PD-disagg can recover this stall, but the recovery is bounded by
> the **decode duration** of the request being protected. For agentic,
> decode is 50200 ms (tool-call output). MB2 shows PD-disagg pays
> 300 ms 10 s of KV-transfer cost per request to do that recovery. The
> cost exceeds the benefit ceiling for any per-request KV ≥ ~80 MiB
> (~830 tokens) — well below all agentic operating points. The benefit
> never beats the cost in this workload.
> During a prefill burst, every ongoing decode stream is essentially
> halted (per-stream effective TPOT is 15×2000× baseline, scaling with
> prefill size). The **total decode time lost per prefill event is
> `D × T_prefill`** (D concurrent decodes each lose ~T_prefill of useful
> work). For the trace mean (P ≈ 33k tokens, T_prefill ≈ 4.5 s) at D=8
> that's **~36 seconds of decode-equivalent work lost per request**.
> This is the **upper bound on what PD-disaggregation's phase isolation
> could recover** on the decode side.
**⚠ Correction (2026-05-27)**: an earlier version of this README framed
the §3.2 PD-disagg argument as "phase-isolation benefit is capped at
the decode duration of the new request (~50200 ms), so MB2 transfer
cost dominates". That framing was wrong. The correct accounting is
benefit-per-prefill-event = D × T_prefill (aggregate decode time saved
across all stalled streams), which is **much larger than per-request
transfer cost**. The actual reason static PD-disagg fails in agentic
is **D-side KV pool capacity** (`figs/f4b_pdsep_kv_wall.png`), not a
cost-vs-benefit imbalance on phase isolation. See `RESULTS_SUMMARY.md`
section 4 for the corrected framing.
## Setup
@@ -107,25 +115,30 @@ the cleanest "average over the whole burst window" number).
halts decode). This is the entire prefill duration's worth of decode
time that could in principle be recovered.
Two big caveats for **agentic** application:
**Connecting to the §3.2 PD-disagg argument** (corrected):
1. **Decode is short** (~50200 ms for tool-call output). The actual
recoverable benefit per request is bounded by the decode duration,
not by `prefill_ttft`. If a decode lasts 100 ms and a 5-second prefill
collides with it, PD-disagg can save at most 100 ms — not 5 s.
2. **PD-disagg pays KV-transfer cost** (MB2: 300 ms 10 s per request
for agentic sizes). For any KV ≥ ~80 MiB the cost already exceeds the
~100 ms benefit ceiling. Cost > benefit across the whole agentic
distribution.
PD-disagg's promised phase-isolation benefit is **per prefill event**,
not per request. When a new prefill arrives, it stalls every concurrent
decode stream on the same GPU. The aggregate decode time lost across
those D streams is `D × T_prefill`. PD-disagg moving prefill off-decode-GPU
recovers all of it.
## §3.2 cost-vs-benefit figure
Plugging numbers per prefill event:
`figs/pd_cost_vs_benefit.png` overlays MB1 benefit ceiling (50200 ms
band, capped by decode duration) on top of MB2 transfer cost curve. The
cost curve crosses the benefit ceiling somewhere around **80 MiB / 830
tokens** of KV — well below the trace mean (192 MiB / 2k tok ≈ trace
mean per request KV, and we know agentic averages 33k tokens, p99
125k). For anything bigger PD-disagg pays more than it can recover.
| Prefill size | T_prefill | PD-disagg cost (MB2 T_transfer) | PD-disagg benefit (D=8 × T_prefill) | Ratio |
|---:|---:|---:|---:|---:|
| 2k tok (trace lower) | 0.14 s | 8 ms | 1.1 s | 0.7 % |
| 33k tok (trace mean) | 4.5 s | 320 ms | 36 s | 0.9 % |
| 125k tok (~p99) | 57 s | 1.9 s | 456 s | 0.4 % |
On the **phase-isolation axis alone**, PD-disagg wins by 100×250×.
The reason static PD-disagg nonetheless **fails in agentic** is a
*different* failure mode: the D-side KV pool cannot fit p90+ requests
(p99 = 11.5 GiB; D-instance pool ≈ 38 GiB; 4P+4D halves system-wide
decode capacity → TTFT p50 62×, success rate 99.5% → 52% in colleague's
4P+4D experiment). The structural problem is **capacity** (see
`figs/f4b_pdsep_kv_wall.png`), not transfer-cost vs phase-isolation
trade-off.
## Reproduction
@@ -174,4 +187,7 @@ ssh dash1 'bash /home/admin/cpfs/wjh/agentic-kv-fresh/scripts/mb1_launch.sh stop
3 × 5 × 3 sweep. CSV: `analysis/mb1/summary.csv`. Per-config JSONs on
dash1 at `/home/admin/cpfs/wjh/agentic-kv-fresh/mb1_results/chunk8192/`.
Figures: `figs/mb1_interference.png`, `figs/pd_cost_vs_benefit.png`.
Figure: `figs/mb1_interference.png`. The figure
`figs/pd_cost_vs_benefit.png` from the original commit `029821c` was
based on the wrong "benefit ≤ decode duration" accounting; **deleted in
the correction commit**.

View File

@@ -24,12 +24,25 @@ get cheaper by co-locating P and D on the same node — the ~9.7 GB/s
ceiling applies regardless. Halving the transfer cost cannot be bought
back by topology.
**Headline for the paper §3.2**: at the agentic tail, **pure KV transfer
takes 1.5 10 s**. A median agentic decode is **50 200 ms** of tool-call
output. So **PD-disaggregation adds 8 100 × decode-time of transfer on
top of every routed request**. Phase isolation (the thing PD-disagg
trades transfer cost for) can only win back at most one decode duration
— for agentic that's negligible. The arithmetic is one-sided.
**What MB2 actually measures**: the **per-request charge** that
PD-disagg pays for every routed request — `T_transfer ≈ KV_size / 9.7
GB/s`. For agentic this is **8 ms (192 MiB / trace lower) 1.9 s
(11.5 GiB / p99)**.
**⚠ Correction (2026-05-27)**: an earlier version of this README
framed §3.2 as "transfer cost (1.510 s) >> decode duration (50200 ms),
so PD-disagg loses on cost-vs-benefit." That accounting was wrong:
PD-disagg's phase-isolation benefit is **per-prefill-event** and equals
`D × T_prefill` (aggregate across stalled decode streams), not the
single-request decode duration. With trace-mean `T_prefill = 4.5 s` and
D = 8, the benefit is ~36 s — far larger than the ~0.32 s transfer
cost. PD-disagg's phase-isolation axis is a *win*, not a loss.
The actual reason static PD-disagg fails in agentic is **D-side KV
capacity** (`figs/f4b_pdsep_kv_wall.png`), not a cost-vs-benefit
imbalance. See `RESULTS_SUMMARY.md` section 4 for the corrected
framing. MB2 still serves as the source of the per-request transfer
cost number used in that analysis.
---
@@ -137,43 +150,44 @@ analysis; not done yet.
treats them as additional samples (same sizes); the per-size
aggregates use all of them.
## Implications for §3.2 PD-disagg cost argument
## Implications for §3.2 PD-disagg argument
For each PD-disagg-routed request, transfer wall-time is:
```
T_transfer(KV_size) = max( pure_transfer(KV_size), rx_overhead )
≈ KV_size / 9.7 GB/s for KV_size <= 3 GiB
T_transfer(KV_size) ≈ KV_size / 9.7 GB/s for KV_size ≤ 3 GiB
≈ 0.3 10 s for KV_size in [3, 12] GiB
```
Agentic decode wall-time is typically 50 200 ms (tool-call output of
a few tens of tokens at ~50 tok/s). So the **transfer/decode ratio**
under intra-node best-case Mooncake is:
This is the **per-request transfer charge** of PD-disagg. It's a
real cost, but in the context of phase-isolation accounting it is
*small* compared to the benefit:
| KV size | T_transfer @9.7 GB/s | typical decode | T_transfer / T_decode |
|---|---:|---:|---:|
| 192 MiB (2k tok) | 20 ms | 100 ms | 0.2× |
| 768 MiB (8k tok) | 84 ms | 100 ms | 0.8× |
| 3 GiB (33k tok ≈ trace mean) | 321 ms | 100 ms | **3.2×** |
| 6 GiB (~p90) | 1900 ms | 100 ms | **19×** |
| 12 GiB (~p99) | 2800 ms | 100 ms | **28×** (median) **100×** (p99 variance) |
| Prefill | T_prefill (MB1) | T_transfer (MB2) | Phase-isolation benefit at D=8 = D × T_prefill |
|---:|---:|---:|---:|
| 2k tok (trace lower) | 0.14 s | 8 ms | 1.1 s |
| 33k tok (trace mean) | 4.5 s | 320 ms | 36 s |
| 125k tok (~p99) | 57 s | 1.9 s | 456 s |
PD-disagg's promised payoff is *eliminating prefilldecode interference
on the decode instance*. The maximum benefit it can buy is bounded
above by the **decode duration itself** (you cannot recover more time
than the decode existed). For agentic that's 50 200 ms. The cost is
the table column above — 0.3 10 s of transfer per routed request.
On the phase-isolation axis alone, PD-disagg recovers two orders of
magnitude more decode time than it pays in transfer. **It is NOT this
axis that defeats static PD-disagg in agentic** — see colleague's
4P+4D experiment (TTFT p50 62×, success rate 99.5% → 52%) which is
driven by **D-side KV-pool overflow** on long-context requests
(`figs/f4b_pdsep_kv_wall.png`), not by transfer latency.
**Cost > Benefit by 5× to 100× across the agentic distribution.** Below
~3 GiB the ratio is small (≤1×); above 3 GiB the ratio explodes; above
6 GiB even individual draws can take 10 s for a single transfer.
What MB2 contributes to the paper is therefore:
- The **per-request transfer cost number** (used as the cost input
to the cost-benefit accounting above).
- The empirical observation that **Mooncake's transfer cost is
topology-independent** — intra-node and inter-node both go through
the RDMA NIC and hit the same 9.7 GB/s ceiling. PD-disagg's
transfer cost does not get cheaper by co-locating P and D.
This data alone is not the whole §3.2 argument — we still need to
account for D-side KV capacity (`f4b`, separate axis), cache reuse loss,
and static-partition mismatch (MB3 / MB4 / MB5). But it nails one
of the two key cost axes with measured numbers from vanilla mooncake,
not the dash0 patched build.
The dominant §3.2 failure mode of static PD-disagg in agentic is
**capacity**, not transfer cost. MB3 / MB4 / MB5 will quantify the
remaining axes (D-pool occupancy, cache reuse degradation under PD
routing, static-partition mismatch).
## Open questions / next runs

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

@@ -0,0 +1,107 @@
# PD-disagg vs colocation — controlled reuse & concurrency axes (v2)
Self-contained results for the **controlled-variable** redo of the MB5 PD-vs-colo
ablation. Supersedes the confounded first cut (held input fixed and sliced the
prefix, so "more reuse" was entangled with "less prefill"). All arms route through
the proxy at fair **APC parity** (session-routed producers reach the same prefix-cache
hit rate as colo), so PD loses on *structure*, not on broken cache.
- **Config arms:** `colo` = 8×kv_both (8C-proxy, session-affinity); PD = `6P+2D / 4P+4D / 2P+6D`.
- **Driver:** closed-loop N (`REPLAY_MAX_INFLIGHT`) + fixed think-time; `gen_synthetic_trace.py --mode regular`.
- **PD-arm wall-cap:** collapsed PD arms drain pathologically slowly, so PD arms run with a
wall-deadline (`REPLAY_MAX_DURATION`; un-run turns counted as failures → honest completion%);
**colo is uncapped** so the reference is always fully measured.
- **Hardware:** run on **dash2** (8×H20). dash0's RDMA NICs were faulted for Mooncake during
this work (could not init the transfer engine; needs an admin reset — no sudo); dash2's NICs
are healthy. cpfs/venv/data are shared across the boxes.
---
## 1. Reuse / APC axis — fixed real prefill, vary cached prefix
N=8. Hold the **real new-prefill work per turn constant** (`--delta-len`) and grow the cached
prefix → reuse = prefix/(prefix+delta). Three shapes isolate output vs delta:
| | delta (real prefill/turn) | output | role |
|---|---|---|---|
| **A** | 2048 | 256 | original |
| **C** | 2048 | 128 | A vs C = pure **output** 256→128 |
| **B** | 1024 | 128 | C vs B = pure **delta** 2048→1024 |
**PD-best advantage** = colo E2E-p90 / best-PD E2E-p90 (>1 ⇒ PD wins):
| reuse% | A d2048/o256 | C d2048/o128 | B d1024/o128 |
|---|---|---|---|
| 20 | 1.34 | 1.41 | — |
| 50 | 1.36 | 1.37 | — |
| 67 | **1.47** | **1.49** | **1.27** |
| 80 | 1.31 | 1.23 | 1.25 |
| 90 | 1.15 | 1.01 | — |
| 95 | 0.87 | 0.89 | 0.89 |
![reuse 3-way](../../figs/mb5_pd_ablation/reuse_compare_ABC.png)
**Findings:**
1. **Output length is ~negligible.** A and C (same delta) track each other across the whole
range — halving output barely moves PD's advantage.
2. **Delta (real prefill/turn) is the dominant shape factor.** B (delta=1024) sits clearly
below A/C at mid reuse (67%: 1.27 vs ~1.48). More real prefill per turn → bigger PD win,
because PD-disagg's benefit is isolating prefill from decode — more prefill to isolate.
3. **Crossover to colo at reuse ~9095% is robust** across all three shapes: PD always loses
the high-reuse / large-resident-context corner (it must KV-transfer the whole resident
context every turn for a few hundred new tokens; colo keeps it local).
*Caveat:* the clean, uncapped, 100%-completion comparison region is reuse **2080%** (carries
findings 12). At reuse 90/95% the PD arms collapse and C's points are capped-completion, while
A/B are full-drain — comparable in direction, not in exact PD completion%.
Data: `fig1_reuse_fixed.json` (A), `fig1_reuse_d2048_o128.json` (C), `fig1_reuse_d1024_o128.json` (B).
---
## 2. Concurrency axis — agentic corner, sweep N
in=32768 (prefix 32256 + delta 512, **reuse 0.984**), out=128; closed-loop N ∈ {8,16,32,48,64,96,128};
PD arms capped 600s, colo uncapped.
| N | **colo** completion · E2E-mean · TPS | best PD-arm completion |
|---|---|---|
| 8 | **256/256** · 2.4s · 326 | 6P+2D 256/256 |
| 16 | **512/512** · 3.5s · 462 | 6P+2D 439/512 (86%) |
| 32 | **1024/1024** · 13.3s · 190 | all PD **<27%** |
| 48 | **1536/1536** · 24.9s · 168 | all PD <32% |
| 64 | **2048/2048** · 38.4s · 166 | all PD <31% |
| 96 | **3072/3072** · 60.0s · 171 | PD **27%** |
| 128 | **4096/4096** · 80.8s · 181 | 4P+4D 6%, 2P+6D <1% |
![concurrency](../../figs/mb5_pd_ablation/fig3_concurrency_axis.png)
**Finding:** **colo completes 100% of requests at every concurrency level** it degrades
*gracefully* (latency rises 2.4s81s, nothing dropped). **Every static PD split collapses, and
progressively earlier as N rises**: PD is viable only at N816; by N32 it drops 7099% of
requests while its prefix-cache hit-rate craters to ~0%. colo's elastic pool absorbs the
time-varying P/D demand; the static partition + per-turn 32k KV-transfer cannot. (Latency
percentiles count successes only, so they *understate* PD read them with the completion column.)
Data: `fig3_conc32k.json`.
*Caveat:* N=128 6P+2D is missing (one transient vLLM/Mooncake startup flake at the end); does
not change the picture (all PD arms are already collapsed by N=128). The SLO auto-stop in the
driver is a no-op (a stdout-capture bug), so the full grid ran more points, not fewer.
---
## 3. Reproduce
```bash
# on a box with healthy Mooncake/RDMA NICs (dash2), cpfs mounted:
R=/home/admin/cpfs/wjh/agentic-kv-fresh
# reuse axis (three shapes): DELTA/OL pick the shape; tag carries _o${OL}
ssh dash2 "cd $R && DELTA=2048 OL=256 bash microbench/fresh_setup/run_reuse_fixed.sh"
ssh dash2 "cd $R && DELTA=2048 OL=128 bash microbench/fresh_setup/run_reuse_fixed.sh"
ssh dash2 "cd $R && DELTA=1024 OL=128 bash microbench/fresh_setup/run_reuse_fixed.sh"
# concurrency axis (capped):
ssh dash2 "cd $R && NLIST='8 16 32 48 64 96 128' CONC_PD_MAXDUR=600 bash microbench/fresh_setup/run_conc.sh"
# render (reads the *.json in this dir):
python microbench/fresh_setup/plot_pd_crossover.py
```

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

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,147 @@
# Migration Trigger Validation (unified_v4) — 2026-05-30
Hardware: dash2, 8×H20, Qwen3-Coder-30B-A3B, TP=1, kv_both + DR-fix substrate.
Trace: `w600_r0.0015_st30_first600s.jsonl` (807 reqs, 600s span).
Policy: `unified_v4` = unified hybrid routing + pending-prefill-queue-triggered
session migration (commit `3a6bf5d` on `kzlin-dev` branch).
## Research Question
Does Pillar 2 (hot-triggered session migration) provide measurable benefit on
top of Pillar 1 (affinity-default routing)?
## Experiment Design
| Arm | Policy | Substrate | Trace QPS |
|---|---|---|---|
| unified_1x | unified (affinity-only) | kv_both + DR-fix | ~1.3 (original) |
| unified_v4_1x | unified_v4 (affinity + migration) | kv_both + DR-fix | ~1.3 |
| unified_v4_2x | unified_v4 (affinity + migration) | kv_both + DR-fix | ~2.7 (2× compressed) |
The 2× trace was generated by halving inter-request intervals:
`ts_new = ts_min + (ts_orig - ts_min) / 2`.
## Results
### 1x QPS: unified vs unified_v4
| Metric | unified | unified_v4 | Delta |
|---|---:|---:|---:|
| OK/total | 807/807 | 807/807 | — |
| TTFT mean | 3.990s | 4.142s | +3.8% |
| TTFT p50 | 0.719s | 0.711s | 1.0% |
| TTFT p90 | 11.499s | 12.293s | +6.9% |
| TPOT p90 | 0.024s | 0.022s | 9.3% |
| E2E p50 | 2.265s | 2.293s | +1.2% |
| E2E p90 | 24.507s | 23.955s | 2.3% |
| **Migrations** | **0** | **0** | — |
**Conclusion**: At 1x QPS (~1.3 req/s, ~0.16 req/instance/s), the migration
trigger NEVER fires. The two arms produce statistically identical results.
### 2x QPS: unified_v4 under higher load
| Metric | unified_v4 @ 2x |
|---|---:|
| OK/total | 807/807 |
| TTFT mean | 5.227s |
| TTFT p90 | 15.000s |
| E2E p90 | 39.401s |
| **Migrations** | **4/807 (0.5%)** |
4 migrated requests (all verified via `v3_decode_target_url` in breakdown):
| Session | Input | new_local | src_pp | fleet_median | proj_prefill | Target |
|---|---:|---:|---:|---:|---:|---|
| 1313181 | 22,686 | 22,686 | 13,360 | 6,680 | 5.1s | inst_5 |
| 1310590 | 32,440 | 14,520 | 57,051 | 12,630 | 10.2s | inst_4 |
| 1373431 | 126,340 | 126,340 | 73,385 | 33,294 | 28.5s | inst_4 |
| 1313181 | 60,004 | 17,508 | 19,503 | 3,806 | 5.3s | inst_5 |
## Root Cause Analysis: Why Zero Migrations at 1x
The unified_v4 trigger requires BOTH arms to fire simultaneously:
- **Absolute SLO arm**: `proj_prefill_s(src) > 2.5s` — fires for 41% of eligible requests
- **Relative arm**: `src_pending_prefill > fleet_median × 1.5` — NEVER fires at 1x
The relative arm fails because `pending_prefill_tokens` (the proxy's shadow
counter) is 0 for **95% of routing decisions** at 1x QPS:
| QPS | src_pp > 0 (% of eligible) | Migrations |
|---:|---:|---:|
| 1.3 (1×) | 5% (8/241) | 0 |
| 2.7 (2×) | 24% (62/257) | 4 |
**Mechanism**: `pending_prefill_tokens` reflects previously-dispatched requests
that haven't finished their prefill yet. At 0.16 req/instance/s, each instance
completes its prefill before the next request arrives — the counter is almost
always 0 at decision time. Only under genuine queueing pressure (2× and above)
does the counter become non-zero and the relative arm can fire.
The high TTFT at 1x (~11.5s p90) comes from **compute-bound large prefills**
(single 60k+ token requests inherently need ~9s), NOT from queue depth.
## Interpretation for Paper
1. **The migration mechanism is functionally correct.** At 2x it fires on the
right signal (src genuinely overloaded relative to fleet) and selects valid
targets (cooler instances with load gap).
2. **At benchmark scale (8 instances, ~1 QPS), migration is not needed.** The
affinity-default routing (Pillar 1) already achieves APC ~79% and the
remaining hot-pin issue is mild (max-worker/median-worker ≈ 3.7×). The
"dispatch coupling" feedback loop is present but not yet at the catastrophic
amplification regime.
3. **Migration becomes relevant under scale-out + higher load.** With more
instances (1632), session skew concentrates more load per hot instance
while cold instances sit idle — exactly the condition where `src_pp >
fleet_median × 1.5` naturally fires. The 1x→2x progression (0%→0.5%
migration rate) shows the correct scaling direction.
4. **Paper §3.3 framing**: Migration is a **scale-out insurance mechanism**
that gracefully degrades to no-op under low load. Its value is NOT
demonstrable at 8-instance single-node benchmark; the argument must rely on
(a) the mechanism's correctness (this experiment), (b) the substrate's
net-positive property (commit `ef9e010`), and (c) scale-out projection
(future: 16+ GPU, multi-node).
## Next Steps
- [ ] **Scale-out validation** (16 GPU, 2 nodes): With more instances and the
same trace, more sessions compete per-instance → higher pending_prefill →
migration triggers naturally. This is the strongest evidence path.
- [ ] **34× QPS on 8 instances**: Push to saturation to measure migration's
effect in the catastrophic regime. Risk: may exceed serving capacity (errors).
- [ ] **Threshold sensitivity**: Ablate `v4_rel_hi` (1.5→1.2→1.0) and
`v4_ttft_slo_s` (2.5→1.5→1.0) to characterize the trigger landscape.
## Reproduction
```bash
# On dash2 (local /tmp, does NOT modify shared NAS):
# 1x QPS
bash /tmp/migration_exp/run_migration_ab.sh # interleaved unified vs unified_v4
# 2x QPS
python3 -c "
import json
trace_in = '/home/admin/cpfs/wjh/agentic-kv/traces/w600_r0.0015_st30_first600s.jsonl'
rows = [json.loads(l) for l in open(trace_in)]
ts_min = min(r['timestamp'] for r in rows)
for r in rows: r['timestamp'] = ts_min + (r['timestamp'] - ts_min) / 2.0
with open('/tmp/migration_exp/trace_2x.jsonl', 'w') as f:
for r in rows: f.write(json.dumps(r) + '\n')
"
bash /tmp/migration_exp/run_2x.sh
```
## Data Locations (dash2 /tmp, ephemeral)
| Path | Content |
|---|---|
| `/tmp/migration_exp/outputs/unified_run1/` | Baseline arm (1x) |
| `/tmp/migration_exp/outputs/unified_v4_run1/` | Migration arm (1x) |
| `/tmp/migration_exp/outputs/unified_v4_2x/` | Migration arm (2x) |
| `/tmp/migration_exp/outputs/*/breakdown.json` | Per-request routing decisions with v4_* fields |
| `/tmp/migration_exp/outputs/*/metrics.jsonl` | Per-request latency metrics |

View File

@@ -0,0 +1,110 @@
{
"experiment": "migration_trigger_validation",
"date": "2026-05-30",
"hardware": "dash2, 8xH20, Qwen3-Coder-30B-A3B, TP=1",
"substrate": "kv_both + DR-fix (delay_free_blocks + VLLM_EVICT_SENT_BLOCKS gate)",
"arms": {
"unified_1x": {
"metrics": {
"ok": 807,
"total": 807,
"ttft_mean": 3.99,
"ttft_p50": 0.719,
"ttft_p90": 11.499,
"ttft_p99": 45.982,
"tpot_p90": 0.0239,
"e2e_p50": 2.265,
"e2e_p90": 24.507,
"e2e_p99": 71.233
},
"migrations": 0
},
"unified_v4_1x": {
"metrics": {
"ok": 807,
"total": 807,
"ttft_mean": 4.142,
"ttft_p50": 0.711,
"ttft_p90": 12.293,
"ttft_p99": 46.148,
"tpot_p90": 0.0217,
"e2e_p50": 2.293,
"e2e_p90": 23.955,
"e2e_p99": 75.915
},
"trigger_summary": {
"trace": "w600_r0.0015_st30_first600s.jsonl",
"qps_factor": 1,
"total_requests": 807,
"migrations_triggered": 13,
"size_floor_filtered": 552,
"eligible_requests": 255,
"slo_arm_true": 100,
"src_pp_nonzero": 22
}
},
"unified_v4_2x": {
"metrics": {
"ok": 807,
"total": 807,
"ttft_mean": 5.227,
"ttft_p50": 0.942,
"ttft_p90": 15.0,
"ttft_p99": 59.227,
"tpot_p90": 0.1087,
"e2e_p50": 5.035,
"e2e_p90": 39.401,
"e2e_p99": 163.032
},
"trigger_summary": {
"trace": "trace_2x.jsonl (timestamps / 2)",
"qps_factor": 2,
"total_requests": 807,
"migrations_triggered": 4,
"size_floor_filtered": 550,
"eligible_requests": 257,
"slo_arm_true": 133,
"src_pp_nonzero": 62,
"pending_prefill_p90_when_nonzero": 68131,
"migrated_details": [
{
"session_id": "1313181",
"input_length": 22686,
"new_local": 22686,
"src_pending_prefill": 13360,
"fleet_median_pp": 6680.0,
"proj_prefill_s": 5.15,
"target_idx": 5
},
{
"session_id": "1310590",
"input_length": 32440,
"new_local": 14520,
"src_pending_prefill": 57051,
"fleet_median_pp": 12630.5,
"proj_prefill_s": 10.22,
"target_idx": 4
},
{
"session_id": "1373431",
"input_length": 126340,
"new_local": 126340,
"src_pending_prefill": 73385,
"fleet_median_pp": 33294.5,
"proj_prefill_s": 28.53,
"target_idx": 4
},
{
"session_id": "1313181",
"input_length": 60004,
"new_local": 17508,
"src_pending_prefill": 19503,
"fleet_median_pp": 3806.5,
"proj_prefill_s": 5.29,
"target_idx": 5
}
]
}
}
}
}

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,165 @@
# 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. A follow-up linear-policy run with PD-disagg
**wall-capped at 2× the colo wall** (see end of doc) hits the **identical** success-rate
ceiling — confirming the cap is structural, not policy-driven.
## 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.**
## Linear-policy + wall-cap follow-up (2026-06-01) — the success ceiling is policy-invariant
To check whether the LMetric routing was secretly handicapping PD-disagg, we re-ran
first600s with the **default `--policy linear`** (cache-aware load score + sticky
session affinity — the original baseline of the cache_aware_proxy stack) and
**wall-capped each PD-disagg arm at 2 × the colo wall** (kill bench.sh + cleanup
GPUs once cap is exceeded, record `records_at_cap`).
| arm | linear success | linear wall | linear @-cap? | lmetric success | lmetric wall |
|---|---|---|---|---|---|
| **colo** | 807/807 = **100 %** | 964 s | — | 807/807 = **100 %** | 1021 s |
| **pd6 (6:2)** | **472/807 = 58 %** | 2280 s | ⊗ cap (706 dispatched) | 474/807 = 59 % | 3325 s |
| **pd4 (4:4)** | **349/807 = 43 %** | 2281 s | ⊗ cap (577 dispatched) | 348/807 = 43 % | 6850 s |
| **pd2 (2:6)** | **176/807 = 22 %** | 2280 s | ⊗ cap (521 dispatched) | 180/807 = 22 % | 19275 s |
→ Figure: [`figs/v2/fig4_linear_vs_lmetric.png`](../../figs/v2/fig4_linear_vs_lmetric.png) ·
data: [`fig4r_linear.json`](fig4r_linear.json)
**Three clean conclusions from the wall-cap experiment:**
1. **The success-rate ceiling is structural, not a routing artifact.** Linear and
LMetric — two very different scoring policies (one session-sticky cache-aware,
the other non-sticky pure load) — converge on **identical success rates**
(58 / 43 / 22 %) for every PD-disagg ratio. Routing has *zero* effect on the
completion ceiling. The bottleneck is the static P:D split itself.
2. **LMetric's longer wall was wall *wasted on requests that will never succeed*.**
When the cap is enforced, linear hits the same ceiling in 2280 s as LMetric did
in 330019000 s — the extra wall just slowly times out the unreachable
requests at 600 s each.
3. **The wall-cap is the right way to bench PD-disagg.** Reporting "completion %"
without a wall budget is misleading (the bench eventually completes if you wait
forever, but only by counting timeouts as failures over hours). The honest
metric is **success-in-2×-colo-wall**, which gives the same answer for both
routings and matches what an end user would observe on a real SLO.
This **strengthens** the §5 D-pool capacity-ceiling thesis: even with
session-affinity routing serving every request to a warm prefix cache (which
*should* maximise PD's throughput), the static D-pool can't admit more than
~22 / 43 / 58 % of the agentic trace within 2× the colo budget. Colo wins not
because routing is smarter, but because its **elastic pool** absorbs whichever
phase is hot — there's no cap to hit.
---
## 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
python microbench/fresh_setup/plot_fig4_linear_vs_lmetric.py
```
For the linear + 2× wall-cap variant, run colo first to get `wall_clock_s`,
compute `CAP=2*wall`, then launch each PD-disagg arm in the background and
`SIGTERM` it (so bench.sh's cleanup trap fires) once `date +%s` minus the
arm's start time exceeds `CAP`. The capped runs lack `metrics.summary.json`
(replayer was killed before it could write); compute the summary directly from
`metrics.jsonl` (see the inline collector used to build
`analysis/v2/fig4r_linear.json`).
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}]

View File

@@ -0,0 +1 @@
[{"tag": "fig4r_linear_colo_first600s", "arm": "colo", "trace": "first600s", "policy": "linear", "n": 807, "req": 807, "dispatched": 807, "e2e": {"count": 807.0, "mean": 8.436370009274967, "p50": 2.5224755640374497, "p90": 22.65510415879542, "p99": 75.54369598095519}, "ttft": {"count": 807.0, "mean": 4.2332503390957195, "p50": 0.8872958200518042, "p90": 11.684667797433207, "p99": 44.98891795879462}, "tpot": {"count": 807.0, "mean": 0.020958194728517718, "p50": 0.00851320761584622, "p90": 0.026440129078245465, "p99": 0.30344440533287176}, "wall": 963.6191155100241, "tps": 239.4857016486815, "capped": false}, {"tag": "fig4r_linear_pd2_first600s", "arm": "2P+6D", "trace": "first600s", "policy": "linear", "n": 176, "req": 807, "dispatched": 521, "e2e": {"count": 176, "mean": 378.5561210460834, "p50": 536.7719694490079, "p90": 583.832092280034, "p99": 601.3415494390065}, "ttft": {"count": 176, "mean": 377.12570991374446, "p50": 536.1157373189926, "p90": 580.3465002350276, "p99": 598.0943597999867}, "tpot": {"count": 176, "mean": 0.007864906140929698, "p50": 0.007212154543958604, "p90": 0.011962352272927423, "p99": 0.017870794738764347}, "wall": 2280, "tps": 14.419736842105262, "capped": true}, {"tag": "fig4r_linear_pd4_first600s", "arm": "4P+4D", "trace": "first600s", "policy": "linear", "n": 349, "req": 807, "dispatched": 577, "e2e": {"count": 349, "mean": 264.8537863784421, "p50": 306.6853819829412, "p90": 488.64622142596636, "p99": 596.5830293919425}, "ttft": {"count": 349, "mean": 262.3163347712099, "p50": 299.75751709297765, "p90": 485.475125996978, "p99": 596.4081599479541}, "tpot": {"count": 349, "mean": 0.010442244895290958, "p50": 0.008213572105774598, "p90": 0.019443845545703716, "p99": 0.028178529054794}, "wall": 2281, "tps": 38.306882946076286, "capped": true}, {"tag": "fig4r_linear_pd6_first600s", "arm": "6P+2D", "trace": "first600s", "policy": "linear", "n": 472, "req": 807, "dispatched": 706, "e2e": {"count": 472, "mean": 118.632779156234, "p50": 12.702161715948023, "p90": 458.1609142010566, "p99": 526.5488834320568}, "ttft": {"count": 472, "mean": 115.80202843308507, "p50": 9.745031949947588, "p90": 455.81679951993283, "p99": 516.5850186559837}, "tpot": {"count": 472, "mean": 0.00950947083585719, "p50": 0.008435572332624966, "p90": 0.015233499645638644, "p99": 0.023447183093280886}, "wall": 2280, "tps": 61.69210526315789, "capped": true}]

View File

@@ -0,0 +1,67 @@
# KV-cache Working-Set Sizing — GLM-5.1-FP8 · TP=8 · 1× B300 node
工具:`scripts/working_set_analysis.py`(可配置 GPU 型号 / 并行度 TP·PP·EP / 模型 config.json /
KV dtype / 权重大小)。图:`figs/working_set/glm5_fp8_tp8_b300.png`
## 复现
```bash
.venv/bin/python scripts/working_set_analysis.py \
/home/gahow/phd/kvcache-simulator/bailian-traces/glm_coder_blksz_512_040915-040917.jsonl \
--model-config /home/gahow/phd/kvcache-simulator/models/GLM-5/config.json \
--gpu B300 --tp 8 --ep 8 --kv-dtype-bytes 1 --weight-gb 744 --min-ts 0 \
--out figs/working_set/glm5_fp8_tp8_b300.png
```
## 方法
`hash_ids` 是全局内容寻址 block id同内容=同 id复用=同 id 再现。vLLM prefix cache 是
block 级,所以**集群级 KV footprint = 任一时刻必须常驻的 distinct block 数**,与 placement 无关
affinity 只搬运 block不改总量。三种 working set
- `W_all` 永不淘汰(真上界)
- `W_oracle` 每 block 只在 `[首次, 末次复用]` 常驻Belady 完美预知 → 满 APC 上界的最小 HBM
- `W_denning(T)` 滑窗 T 内被访问的 distinct block现实 TTL-LRU
KV/tokenMLA → `L·(kv_lora_rank+qk_rope_head_dim)·dtype`GQA → `2·L·kv_heads·head_dim·dtype`
(与 `kvcache-simulator/src/config.rs::kv_block_bytes` 一致)。
## 配置
| 项 | 值 |
|---|---|
| 模型 | GLM-5.1-FP8MLA, L=78, kv_lora=512+rope=64 |
| KV/token · KV/block(512) | **43.9 KiB** · **23.0 MB**(≈ Qwen3 GQA 96 KiB 的一半) |
| 硬件 | 8× B300 (288 GB) = 2304 GB HBM/replica |
| 预算 | FP8 权重 744 GB + act 32 GB → **KV pool = 1528 GB/node** |
| trace | dash0 glm_coder475k req**1.25h active @ 106 QPS**~40k tok/req剔除 77 条负 ts 暖机) |
| APC 上界 | **80.4%** |
## 结果
| 保留窗口 T | peak footprint | = 节点 (GPU) | APC@T |
|---:|---:|---:|---:|
| 2s在飞下限| 533 GB | 0.3 (3) | 1.7% |
| 10s | 2,068 GB | 1.4 (11) | 15% |
| 30s | 4,906 GB | 3.2 (26) | 42% |
| 60s | 7,698 GB | 5.0 (40) | 56% |
| 300s | 21,960 GB | 14.4 (115) | 74% |
| **oracle满 80.4%** | **21,399 GB** | **14.0 (112)** | 80.4% |
| retain-forever | 167,018 GB | 109 (874) | — |
## 结论
1. **Serving1 节点绰绰有余。** 在飞 KVτ≈2-5s仅 5331157 GB ≪ 单节点 1528 GB。
MLA + B300 大 HBM 让 live footprint 微不足道——跑起来根本不缺显存。
2. **缓存全部复用80.4%1 节点差 ~14×。** oracle 下限 21.4 TB = 14 节点112 GPU
真实 LRU ~2× → ~28 节点。单节点1528 GB只能 hold ~10s 窗口 → cache 侧 APC 仅 ~10-15%。
要 ~56% 需 5 节点,~74% 需 ~14 节点。
3. **瓶颈在长尾,不在 live。** 把 APC 50%→80% 装进 GPU HBM 要 5→14 节点,极不经济
→ offload/migration 到 CPU DRAM每节点 ~1.5 TB是定量动机。与 Qwen 结论方向一致。
## 注意
- footprint 是 TTL-LRU最浪费+ shared-cache 下限:真实 capacity-LRU 同容量下 APC 更高,
但分区/affinity 不均衡又抬高需求oracle / retain-forever 给出下/上界。
- GLM trace mean ~40k tok/req是 Qwen trace11k的 ~3.5×tokenizer + 抽取不同),
**绝对 GB 不可跨模型横比**,方法与定性结论可比。
- EP 不改变 KV 总量(只影响 expert 权重分布),`--ep` 仅作标注。

View File

@@ -0,0 +1,81 @@
# Agentic workload characterization C1C3 (full 051315 production trace)
Date 2026-05-29. Source: `trace-glm5.1-formatted/051315-051317.jsonl` on dash1
(release file, 2,114,220 requests / 1,307,276 sessions / 2h, type=100% `coder`).
This release file **is the full cluster-level production trace** — session skew
reproduces 46.5/66.5/74.6/87.5/96.0 exactly. Compute: `compute_chars.py`
(2-pass, ~65s, `~/ali-trace/.venv` python). Numbers: `chars.json`.
> ⚠️ **Cluster-level, not per-instance.** This is one cluster's aggregate stream.
> Concurrent-session counts have NO denominator of "8 instances" — do not compare
> them to a single deployment's instance count.
These three are NOT in the existing 13 analyzer figures (which are single-variable
marginals on the older 041x traces). C1C3 are joint/temporal and argument-bearing.
## C1 — the workload is a MIXTURE, not "multi-turn agentic" (`c1_session_mixture.png`)
- **90.3%** of sessions are single-turn; mean 1.62 turns, p99=18, max=3091.
- But multi-turn sessions (9.7%) = **44.2% of requests** and **66.9% of input
(prefill) mass**. Single-turn = **60.2% of output (decode) mass**.
- Continuation hazard P(reach k+1 | reached k): turn1→2 only **10.2%**, but
turn2→3 50.6%, turn5→6 87%, turn12→13 **94.3%** (Lindy / Pareto).
- Predictability of heaviness at cold-start is near-zero:
corr(turn1_input, session_mass)=0.15, corr(turn1_input, n_turns)=**0.04**.
**Routing:** heaviness is unpredictable at session start → proactive placement
cannot pre-empt hot-pin → a REACTIVE mechanism (observable-load routing /
migration) is required. But once a session has shown depth, it almost surely
continues → "observed accumulated load" is the signal that works (not turn-1
features, not cost-model prediction). The single/multi optimal strategies are
opposite (load-balance the 90% one-shot sea vs affinity-pin the deep tail) and
you can't tell them apart at turn 1 → the only viable policy starts everyone
load-balanced and becomes sticky as turns accrue. This is exactly LPWL's
emergent behavior (`new_uncached≈input`→by-load; `new_uncached≈0`→sticks), so
C1 explains *why* a cache-aware-load score is the right shape — it auto-segments
the mixture with no classifier.
## C2 — marginal work collapses while resident state explodes (`c2_work_amortization.png`)
Per turn: resident context grows 11k→56k+ tokens while new prefill collapses
2.7k→~200 tokens; per-turn reuse climbs 83%→**99.6%**; resident/new ratio
("the PD tax") grows to ~250× by turn 12, ~450× by turn 30.
**PD-colocation:** the dominant cost is keeping ~50k+ resident KV available for
the next turn's tiny delta. Disaggregation physically splits a turn's prefill-KV
(P) and decode-KV (D), and the next turn's prefix = [prevPrompt + prevAnswer]
spans both → must be gathered/transferred; colocation keeps it local for free.
**Routing:** route on delta (`input cache_hit`), never total input — C2 is the
trace-level justification for LPWL's score function.
## C3 — prefill/decode BALANCE (honest reframe) (`c3_prefill_decode_balance.png`)
- Token mass: 98.7% input / **1.3% output**; of input, 60% reused-prefix, 40%
new-prefill (28.6B new-prefill tokens vs 0.94B decode tokens).
- **But tokens ≠ time.** Under a per-request latency model (prefill@7k tok/s,
TPOT 10ms), aggregate decode-time share ≈ **70% (robust 6871% across
constants)** — each decode token costs ~70140× a prefill token. So this is
NOT a "decode is negligible" workload.
- Per-request the bottleneck FLIPS within a session: turn-1 (and the 90%
single-turn) is prefill-bound; turns ≥3 are strongly decode-bound.
**PD-colocation (correct argument):** the workload has *substantial* work on both
sides of the roofline — compute-bound prefill (~30% of time) and memory-bound
decode (~70%). Colocation interleaves them on one GPU (chunked prefill +
continuous batching) so compute and HBM bandwidth are both used; static
disaggregation strands P-instances bandwidth-idle and D-instances compute-idle.
The earlier "decode is 1.3% so nothing to isolate" instinct was WRONG (token vs
time confusion) — C3b is the correction.
**Caveat:** C3b's 70% is a per-request-latency-weighted estimate; batched decode
throughput will shift it. Ground-truth needs `-raw.jsonl` (`usage.cached_tokens`
for exact reuse; `backend_first_response_time_ms` / `total_cost_time_ms` for real
prefill vs decode wall time). Sampling that 522GB file is the next step.
## Goal mapping
| | argue PD-colocation | guide routing |
|---|---|---|
| C1 mixture + hazard | both segments favor colo (diff reasons) | reactive + auto-segment ⇒ LPWL shape |
| C2 resident/delta | the PD tax (transfer/split resident KV) | route on delta, not total |
| C3 prefill/decode | roofline complementarity (interleave) | per-req bottleneck flips within session |

View File

@@ -0,0 +1,964 @@
{
"mixture": {
"single_sessions": 1179990,
"multi_sessions": 127286,
"req_single_pct": 55.81207253738968,
"req_multi_pct": 44.187927462610325,
"in_single_pct": 33.12487590117447,
"in_multi_pct": 66.87512409882554,
"out_single_pct": 60.24502960903973,
"out_multi_pct": 39.75497039096027
},
"turns": {
"mean": 1.6172713336739908,
"p99": 18.0,
"max": 3091,
"single_turn_pct": 90.26326498765371
},
"hazard": {
"1": 0.102101621998721,
"2": 0.5062146469376287,
"3": 0.7351961756478754,
"4": 0.8113739305485657,
"5": 0.8723731546954472,
"6": 0.8669264241631353,
"7": 0.9093235352011023,
"8": 0.9240204920989971,
"9": 0.901725753553022,
"10": 0.9346178826585841,
"11": 0.9260597637248089,
"12": 0.9427685226874781,
"13": 0.91950119395065,
"14": 0.936865189289012,
"15": 0.9382160896883085,
"16": 0.9308646838684262,
"17": 0.9371561574269995,
"18": 0.9312862196131557,
"19": 0.9333279456925813,
"20": 0.9351459000779289,
"21": 0.9399074074074074,
"22": 0.9404984730568416,
"23": 0.9473132921336546,
"24": 0.9193940734188413,
"25": 0.9497294046903187,
"26": 0.9323793845764214,
"27": 0.9483906016569333,
"28": 0.9368466275239868,
"29": 0.9472638336900031
},
"token_mass": {
"total_input": 71116829368,
"total_output": 940765734,
"out_in_ratio_pct": 1.3228454394837104,
"new_prefill": 28616906067,
"reused_prefix": 42499923301,
"new_prefill_pct_of_input": 40.23928839532401
},
"decode_time_fraction": {
"optimistic_for_prefill": 0.6812079219496285,
"mid": 0.6970810590484581,
"pessimistic": 0.711448473592609
},
"per_turn": {
"turn": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43,
44,
45,
46,
47,
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59,
60,
61,
62,
63,
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91,
92,
93,
94,
95,
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107,
108,
109,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123,
124,
125,
126,
127,
128,
129,
130,
131,
132,
133,
134,
135,
136,
137,
138,
139,
140,
141,
142,
143,
144,
145,
146,
147,
148
],
"med_resident_input": [
11035.0,
19505.0,
28059.0,
35089.0,
41215.0,
44750.0,
47419.5,
49874.0,
51905.0,
53068.0,
54782.0,
56414.0,
58229.0,
59123.5,
60434.5,
61320.0,
62243.0,
63411.0,
64510.5,
65423.0,
66942.5,
67965.0,
68826.0,
70165.5,
70052.0,
70936.0,
71547.0,
72648.0,
73406.0,
73844.0,
73604.0,
74937.5,
74778.0,
75460.0,
75029.0,
74978.0,
75933.0,
76590.0,
74695.0,
76813.0,
77079.5,
78310.0,
77848.0,
77549.0,
78203.0,
79102.0,
79202.0,
78821.0,
79868.0,
80229.5,
80912.0,
81620.0,
81612.5,
81836.5,
82506.0,
82948.0,
82633.0,
84107.5,
84176.0,
84441.0,
84101.0,
85192.0,
84127.0,
84783.5,
85087.0,
85771.5,
86110.0,
85374.5,
87137.0,
87677.0,
88587.0,
88656.0,
88882.0,
89284.0,
91512.0,
89850.0,
90596.0,
91244.0,
92102.0,
93431.0,
92333.5,
96682.0,
94999.0,
95226.5,
95173.0,
95910.0,
96528.0,
96508.0,
97270.0,
97301.0,
97076.5,
97105.0,
98032.0,
97962.5,
97968.5,
98310.0,
97061.0,
97631.0,
100126.0,
97765.0,
101076.0,
98198.5,
98678.0,
98307.0,
99174.0,
99882.0,
99974.0,
99757.0,
100065.5,
99943.0,
100612.0,
101138.0,
106738.0,
99621.0,
101980.0,
102252.0,
103018.0,
101238.0,
102005.0,
101897.0,
103576.0,
102159.5,
102695.5,
100590.5,
103236.0,
101812.0,
103074.0,
99966.0,
102183.5,
101882.0,
102572.5,
105622.5,
106066.0,
103974.0,
105443.5,
104716.0,
105041.0,
106628.0,
108320.0,
108022.5,
107621.5,
107664.0,
107913.0,
108630.0,
108382.0,
107216.5,
105731.0,
103986.0
],
"med_new_prefill": [
11035.0,
2920.0,
1249.0,
767.0,
628.0,
485.0,
400.0,
359.0,
314.0,
274.0,
263.0,
258.0,
244.0,
231.0,
227.0,
222.0,
201.0,
200.0,
198.0,
189.0,
182.5,
184.0,
179.0,
188.0,
173.0,
180.0,
164.0,
167.0,
159.5,
168.0,
156.0,
174.0,
156.0,
159.0,
166.0,
165.0,
153.0,
158.0,
182.0,
149.0,
184.0,
172.0,
149.0,
167.0,
163.0,
152.0,
153.0,
171.0,
151.0,
146.0,
162.0,
153.0,
156.0,
164.0,
148.0,
143.0,
143.0,
149.0,
170.5,
159.0,
144.0,
168.0,
148.0,
144.5,
142.5,
146.5,
147.0,
157.0,
168.0,
153.0,
155.0,
127.5,
145.0,
143.0,
146.0,
123.0,
139.0,
137.0,
115.0,
139.5,
117.0,
154.0,
111.0,
124.0,
118.0,
90.0,
104.0,
116.0,
112.0,
76.5,
110.0,
101.0,
123.0,
114.0,
86.0,
92.0,
108.0,
85.0,
146.0,
77.5,
101.0,
102.0,
85.0,
77.0,
114.0,
66.0,
105.0,
90.0,
89.0,
100.0,
108.5,
100.0,
169.0,
89.0,
106.5,
78.0,
75.0,
90.0,
77.0,
88.0,
102.0,
83.5,
123.5,
116.5,
108.0,
119.0,
82.0,
80.0,
105.0,
90.0,
91.0,
113.0,
122.0,
102.0,
101.5,
64.0,
78.0,
52.5,
98.5,
72.0,
87.0,
102.0,
97.0,
123.0,
80.0,
132.5,
86.5,
111.0
],
"med_output": [
63.0,
67.0,
111.0,
142.0,
158.0,
162.0,
164.0,
164.0,
159.0,
160.0,
159.0,
161.0,
160.0,
158.0,
154.0,
154.0,
154.0,
149.0,
146.0,
147.0,
142.0,
144.0,
143.0,
142.0,
140.0,
136.0,
137.0,
139.0,
136.0,
133.0,
130.0,
131.0,
125.0,
123.0,
122.0,
122.0,
118.0,
122.0,
114.0,
112.0,
115.0,
111.0,
109.0,
112.0,
109.0,
107.0,
111.0,
105.0,
108.0,
107.0,
100.0,
100.0,
95.0,
105.0,
103.0,
102.0,
100.0,
100.0,
98.0,
98.0,
101.0,
99.0,
101.0,
102.0,
97.0,
91.0,
100.0,
97.0,
94.0,
98.5,
92.5,
97.0,
102.0,
92.0,
95.0,
91.0,
91.0,
92.0,
85.0,
98.0,
96.0,
99.0,
94.0,
96.0,
90.0,
85.0,
99.0,
86.0,
99.0,
93.0,
92.0,
93.0,
87.0,
83.0,
87.5,
82.0,
80.0,
90.0,
92.0,
80.0,
77.0,
82.0,
87.0,
74.0,
83.0,
79.0,
84.0,
80.5,
79.0,
76.0,
78.5,
71.5,
81.0,
87.0,
82.0,
85.0,
87.0,
75.0,
75.0,
82.0,
86.0,
76.5,
77.5,
70.0,
78.0,
85.0,
77.0,
67.0,
76.5,
107.0,
92.0,
80.5,
85.0,
83.0,
77.0,
70.0,
84.0,
69.0,
97.0,
72.0,
81.0,
87.0,
89.0,
102.0,
83.0,
82.5,
91.0,
79.5
],
"resident_over_new": [
1.0,
6.679794520547945,
22.46517213771017,
45.748370273794,
65.62898089171975,
92.26804123711341,
118.54875,
138.92479108635098,
165.30254777070064,
193.67883211678833,
208.29657794676805,
218.65891472868216,
238.64344262295083,
255.94588744588745,
266.23127753303964,
276.2162162162162,
309.6666666666667,
317.055,
325.81060606060606,
346.15343915343914,
366.8082191780822,
369.375,
384.5027932960894,
373.22074468085106,
404.9248554913295,
394.0888888888889,
436.2621951219512,
435.0179640718563,
460.2257053291536,
439.54761904761904,
471.8205128205128,
430.67528735632186,
479.34615384615387,
474.59119496855345,
451.98192771084337,
454.41212121212124,
496.29411764705884,
484.746835443038,
410.4120879120879,
515.5234899328859,
418.9103260869565,
455.2906976744186,
522.4697986577181,
464.36526946107784,
479.7730061349693,
520.4078947368421,
517.6601307189543,
460.94152046783626,
528.9271523178808,
549.5171232876712,
499.4567901234568,
533.4640522875817,
523.1570512820513,
499.0030487804878,
557.472972972973,
580.0559440559441,
577.8531468531469,
564.4798657718121,
493.7008797653959,
531.0754716981132,
584.0347222222222,
507.0952380952381,
568.4256756756756,
586.7370242214533,
597.1017543859649,
585.4709897610921,
585.7823129251701,
543.7866242038217,
518.672619047619,
573.0522875816994,
571.5290322580645,
695.3411764705883,
612.9793103448276,
624.3636363636364,
626.7945205479452,
730.4878048780488,
651.7697841726618,
666.014598540146,
800.8869565217391,
669.7562724014336,
789.1752136752136,
627.8051948051948,
855.8468468468468,
767.9556451612904,
806.5508474576271,
1065.6666666666667,
928.1538461538462,
831.9655172413793,
868.4821428571429,
1271.9084967320262,
882.5136363636364,
961.4356435643564,
797.0081300813008,
859.3201754385965,
1139.1686046511627,
1068.5869565217392,
898.7129629629629,
1148.6,
685.7945205479452,
1261.483870967742,
1000.7524752475248,
962.7303921568628,
1160.9176470588236,
1276.7142857142858,
869.9473684210526,
1513.3636363636363,
952.1333333333333,
1108.411111111111,
1124.3314606741574,
999.43,
927.2995391705069,
1011.38,
631.5857988165681,
1119.3370786516855,
957.5586854460093,
1310.923076923077,
1373.5733333333333,
1124.8666666666666,
1324.7402597402597,
1157.9204545454545,
1015.4509803921569,
1223.4670658682635,
831.5425101214574,
863.4377682403433,
955.8888888888889,
855.563025210084,
1257.0,
1249.575,
973.1761904761905,
1132.0222222222221,
1127.1703296703297,
934.712389380531,
869.3934426229508,
1019.3529411764706,
1038.8522167487686,
1636.1875,
1346.679487179487,
2031.009523809524,
1099.6954314720813,
1500.3125,
1237.028735632184,
1055.5294117647059,
1112.5051546391753,
883.170731707317,
1354.775,
809.1811320754717,
1222.3236994219653,
936.8108108108108
],
"reuse_pct": [
0.0,
85.02947962061009,
95.5486653123775,
97.81412978426287,
98.47628290670872,
98.91620111731844,
99.1564651672835,
99.28018606889361,
99.39504864656584,
99.48368131453984,
99.5199153006462,
99.54266671393626,
99.5809648113483,
99.60929241333818,
99.62438673274372,
99.63796477495107,
99.67707212055974,
99.68459730961506,
99.69307322063851,
99.71111077144124,
99.72737797363409,
99.72927241962775,
99.73992386598088,
99.73206205328829,
99.75304059841261,
99.74625014097215,
99.7707800466826,
99.77012443563484,
99.78271530937526,
99.77249336438979,
99.78805499701103,
99.76780650542119,
99.79138249217684,
99.78929234031276,
99.77875221580989,
99.77993544773133,
99.7985065781676,
99.7937067502285,
99.75634245933462,
99.80602241808027,
99.76128542608606,
99.78036010726599,
99.80860137704244,
99.78465228436214,
99.79156809841055,
99.8078430381027,
99.80682306002375,
99.7830527397521,
99.81093804777883,
99.81802204924622,
99.79978247973106,
99.8125459446214,
99.8088528105376,
99.79960042279423,
99.82061910648923,
99.82760283551141,
99.82694565125313,
99.822845762863,
99.79744820376354,
99.81170284577398,
99.82877730348034,
99.80279838482487,
99.82407550489143,
99.82956589430727,
99.8325243574224,
99.82919734410615,
99.82928811984671,
99.81610434028896,
99.80720015607606,
99.82549585410084,
99.8250307607211,
99.85618570655116,
99.83686235683265,
99.83983692486895,
99.84045808200017,
99.86310517529216,
99.84657159256479,
99.84985314102846,
99.87513843347593,
99.85069195449047,
99.87328542728262,
99.84071492108149,
99.88315666480699,
99.8697841462198,
99.87601525642776,
99.90616202690022,
99.89225924084204,
99.87980271065611,
99.88485658476407,
99.9213779920042,
99.88668730331234,
99.89598887801864,
99.87453076546434,
99.88362893964528,
99.91221668189266,
99.90641847217984,
99.88872976787793,
99.91293748911718,
99.8541837285021,
99.92072827699074,
99.90007519094543,
99.89612875960428,
99.91386124566772,
99.92167393980083,
99.88505051727267,
99.93392202799302,
99.89497269290015,
99.90978076726445,
99.91105825684177,
99.89994296749147,
99.89215998091679,
99.90112519527774,
99.84166838426802,
99.91066140673152,
99.89556775838399,
99.92371787348903,
99.9271971888408,
99.91110057488295,
99.92451350423998,
99.91363828179436,
99.90152158801267,
99.91826506590185,
99.87974156608614,
99.8841838941053,
99.89538533069859,
99.883117903587,
99.92044550517105,
99.91997279074886,
99.89724368415645,
99.91166251153295,
99.91128226376466,
99.89301521929514,
99.88497727829841,
99.90189855156096,
99.9037399175862,
99.93888231024867,
99.92574328119497,
99.95076340173313,
99.90906573116692,
99.9333472193293,
99.91916113415999,
99.90526081141329,
99.91011277603255,
99.88677161005248,
99.92618700522226,
99.8764182751722,
99.91818861071965,
99.89325486123131
]
}
}

View File

@@ -0,0 +1,180 @@
import json, sys, math, statistics as st
from collections import defaultdict, Counter
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
PATH="/home/admin/cpfs/wjh/ali-trace/trace-glm5.1-formatted/051315-051317.jsonl"
OUT="/tmp/wlc_out"; import os; os.makedirs(OUT, exist_ok=True)
BLOCK=512
# --- transparent cost model for C3 (clearly-labeled estimate; raw-timing validation pending) ---
PREFILL_TOK_S=7000.0 # MB1: 32k->4.5s ~7100 tok/s effective on H20 / 30B-A3B
TPOT_S=0.010 # ~10ms/token decode (crossover unloaded ~5ms, loaded ~25ms)
def pct(v,p):
if not v: return float('nan')
s=sorted(v);k=(len(s)-1)*p;f=int(k)
return s[f] if f+1>=len(s) else s[f]+(s[f+1]-s[f])*(k-f)
# ---------- Pass A: structure (scalars only) ----------
parents={}; recs={}; childcount=Counter()
for line in open(PATH):
if not line.strip(): continue
d=json.loads(line); cid=d["chat_id"]; pid=d["parent_chat_id"]
parents[cid]=pid
recs[cid]=(float(d["timestamp"]),int(d["input_length"]),int(d["output_length"]),int(d["turn"]))
if pid!="-1": childcount[pid]+=1
print(f"[A] records={len(recs)}", file=sys.stderr)
root_of={}
def root(cid):
path=[];c=cid
while True:
if c in root_of:r=root_of[c];break
p=parents.get(c,"-1")
if p=="-1" or p not in recs:r=c;break
path.append(c);c=p
for x in path:root_of[x]=r
root_of[cid]=r;return r
sessions=defaultdict(list)
for cid in recs: sessions[root(cid)].append(cid)
seq={r:sorted(m,key=lambda c:(recs[c][3],recs[c][0])) for r,m in sessions.items()}
print(f"[A] sessions={len(seq)}", file=sys.stderr)
# ---------- C1: mixture + turn tail + hazard ----------
sr=mr=sm=mm=so=mo=0
turns_per=[]
for r,s in seq.items():
multi=len(s)>1; turns_per.append(len(s))
for c in s:
_,inl,outl,_=recs[c]
if multi: mr+=1;mm+=inl;mo+=outl
else: sr+=1;sm+=inl;so+=outl
tot_r=sr+mr; tot_in=sm+mm; tot_out=so+mo
cnt_turn=Counter()
for r,s in seq.items():
for c in s: cnt_turn[recs[c][3]]+=1
hazard={k: (cnt_turn[k+1]/cnt_turn[k] if cnt_turn[k] else 0) for k in range(1,30)}
# ---------- C2/C3: per-turn resident vs new-prefill (scalar) + hash_ids reuse ----------
by_in=defaultdict(list); by_new=defaultdict(list); by_out=defaultdict(list)
by_reuse_hash=defaultdict(list) # hash-block prefix stability: reused/parent_blocks
store={} # cid -> (blockset, in, out) for chats with pending children
tot_new_prefill=0; tot_reused=0
for line in open(PATH):
if not line.strip(): continue
d=json.loads(line); cid=d["chat_id"]; pid=d["parent_chat_id"]
inl=int(d["input_length"]); outl=int(d["output_length"]); turn=int(d["turn"])
blocks=set(d["hash_ids"])
if pid in store:
pblk,pin,pout=store[pid]
new_prefill=max(0, inl - pin - pout) # actual recompute (accounts for cached answer)
reused_blk=len(blocks & pblk)
by_reuse_hash[turn].append(reused_blk/len(pblk) if pblk else 0)
childcount[pid]-=1
if childcount[pid]<=0: del store[pid]
tot_reused += (inl-new_prefill)
else:
new_prefill=inl # session start: all new (intra-session)
tot_new_prefill+=new_prefill
by_in[turn].append(inl); by_new[turn].append(new_prefill); by_out[turn].append(outl)
if childcount[cid]>0: store[cid]=(blocks,inl,outl)
print(f"[B] done; store residual={len(store)}", file=sys.stderr)
TURNS=[t for t in sorted(by_in) if len(by_in[t])>=50]
med_in=[pct(by_in[t],.5) for t in TURNS]
med_new=[max(pct(by_new[t],.5),1) for t in TURNS]
med_out=[pct(by_out[t],.5) for t in TURNS]
ratio=[med_in[i]/med_new[i] for i in range(len(TURNS))]
reuse_pct=[(1-med_new[i]/med_in[i])*100 for i in range(len(TURNS))]
# C3 time per turn (cost model)
t_pref=[med_new[i]/PREFILL_TOK_S for i in range(len(TURNS))]
t_dec=[med_out[i]*TPOT_S for i in range(len(TURNS))]
# aggregate decode/prefill time fraction over a RANGE of constants
def agg_time(prate,tpot):
tp=tot_new_prefill/prate; td=tot_out*tpot; return td/(tp+td)
frac_lo=agg_time(13000,0.005); frac_mid=agg_time(7000,0.010); frac_hi=agg_time(3000,0.025)
chars={
"mixture":{"single_sessions":sr if False else sum(1 for s in seq.values() if len(s)==1),
"multi_sessions":sum(1 for s in seq.values() if len(s)>1),
"req_single_pct":sr/tot_r*100,"req_multi_pct":mr/tot_r*100,
"in_single_pct":sm/tot_in*100,"in_multi_pct":mm/tot_in*100,
"out_single_pct":so/tot_out*100,"out_multi_pct":mo/tot_out*100},
"turns":{"mean":st.mean(turns_per),"p99":pct(turns_per,.99),"max":max(turns_per),
"single_turn_pct":sum(1 for x in turns_per if x==1)/len(turns_per)*100},
"hazard":hazard,
"token_mass":{"total_input":tot_in,"total_output":tot_out,"out_in_ratio_pct":tot_out/tot_in*100,
"new_prefill":tot_new_prefill,"reused_prefix":tot_reused,
"new_prefill_pct_of_input":tot_new_prefill/tot_in*100},
"decode_time_fraction":{"optimistic_for_prefill":frac_lo,"mid":frac_mid,"pessimistic":frac_hi},
"per_turn":{"turn":TURNS,"med_resident_input":med_in,"med_new_prefill":med_new,
"med_output":med_out,"resident_over_new":ratio,"reuse_pct":reuse_pct},
}
json.dump(chars, open(f"{OUT}/chars.json","w"), indent=2)
# ================= FIGURES =================
plt.rcParams.update({"figure.dpi":140,"font.size":10,"axes.grid":True,"grid.alpha":.3})
# ---- C1 ----
fig,ax=plt.subplots(1,3,figsize=(15,4.2))
cats=["% sessions","% requests","% input\ntokens","% output\ntokens"];
singv=[chars["mixture"]["single_sessions"]/len(seq)*100, chars["mixture"]["req_single_pct"],
chars["mixture"]["in_single_pct"], chars["mixture"]["out_single_pct"]]
multv=[100-x for x in singv]
x=np.arange(len(cats))
ax[0].bar(x,singv,label="single-turn",color="#7fb3d5")
ax[0].bar(x,multv,bottom=singv,label="multi-turn",color="#e74c3c")
for i in range(len(cats)):
ax[0].text(i,singv[i]/2,f"{singv[i]:.0f}",ha="center",va="center",fontsize=9)
ax[0].text(i,singv[i]+multv[i]/2,f"{multv[i]:.0f}",ha="center",va="center",color="white",fontsize=9)
ax[0].set_xticks(x);ax[0].set_xticklabels(cats);ax[0].set_ylabel("%");ax[0].set_ylim(0,100)
ax[0].set_title("C1a Mixture: 90% sessions single-turn,\nbut multi-turn carries 2/3 prefill mass");ax[0].legend(loc="center right")
# turn CCDF log-log
tc=sorted(turns_per); n=len(tc); xs=sorted(set(tc))
ccdf=[sum(1 for v in tc if v>=xx)/n for xx in xs]
ax[1].loglog(xs,ccdf,marker=".",ms=3,color="#34495e")
ax[1].set_xlabel("turns per session (k)");ax[1].set_ylabel("P(turns >= k)")
ax[1].set_title(f"C1b Heavy-tailed session length\n(p99={chars['turns']['p99']:.0f}, max={chars['turns']['max']})")
# hazard
hk=list(range(1,20)); hv=[hazard[k]*100 for k in hk]
ax[2].plot(hk,hv,marker="o",color="#16a085")
ax[2].set_xlabel("reached turn k");ax[2].set_ylabel("P(continue to k+1) %");ax[2].set_ylim(0,100)
ax[2].set_title("C1c Continuation hazard rises 10%->94%\n(unpredictable at start, Lindy after)")
fig.tight_layout(); fig.savefig(f"{OUT}/c1_session_mixture.png"); plt.close(fig)
# ---- C2 ----
fig,ax=plt.subplots(1,3,figsize=(15,4.2))
ax[0].semilogy(TURNS,med_in,marker="o",label="resident context (input)",color="#e74c3c")
ax[0].semilogy(TURNS,med_new,marker="s",label="new prefill this turn",color="#2980b9")
ax[0].set_xlabel("turn");ax[0].set_ylabel("tokens (median, log)");ax[0].legend()
ax[0].set_xlim(1,30)
ax[0].set_title("C2a Resident state explodes,\nmarginal work collapses")
ax[1].plot(TURNS,ratio,marker="o",color="#8e44ad")
ax[1].set_xlabel("turn");ax[1].set_ylabel("resident / new-prefill");ax[1].set_xlim(1,30)
ax[1].set_title("C2b The PD tax = resident/delta\n(grows to ~250x by deep turns)")
ax[2].plot(TURNS,reuse_pct,marker="o",color="#27ae60")
ax[2].set_xlabel("turn");ax[2].set_ylabel("per-turn reuse %");ax[2].set_ylim(50,100);ax[2].set_xlim(1,30)
ax[2].set_title("C2c Per-turn reuse climbs to 99.6%\n(deep turns are near-pure cache hits)")
fig.tight_layout(); fig.savefig(f"{OUT}/c2_work_amortization.png"); plt.close(fig)
# ---- C3 ----
fig,ax=plt.subplots(1,2,figsize=(11,4.4))
# token mass decomposition
vals=[tot_reused/1e9, tot_new_prefill/1e9, tot_out/1e9]
labs=[f"reused prefix\n{tot_reused/tot_in*100:.0f}% of input",
f"new prefill\n{tot_new_prefill/tot_in*100:.0f}% of input",
f"decode output\n{tot_out/tot_in*100:.1f}% of input"]
ax[0].bar(range(3),vals,color=["#95a5a6","#2980b9","#e67e22"])
ax[0].set_xticks(range(3));ax[0].set_xticklabels(labs,fontsize=8.5)
ax[0].set_ylabel("tokens (billions)")
ax[0].set_title("C3a Token mass: prefill-dominated\n(but tokens != time, see C3b)")
# per-turn prefill vs decode TIME (cost model)
ax[1].semilogy(TURNS,t_pref,marker="o",label="prefill time (new tok / 7k·s⁻¹)",color="#2980b9")
ax[1].semilogy(TURNS,t_dec,marker="s",label="decode time (out·10ms)",color="#e67e22")
ax[1].set_xlabel("turn");ax[1].set_ylabel("seconds (median, log)");ax[1].legend(fontsize=8);ax[1].set_xlim(1,30)
ax[1].set_title(f"C3b Prefill→decode bottleneck flips within a session\n(agg decode-time share ≈ {frac_mid*100:.0f}%, range {frac_lo*100:.0f}{frac_hi*100:.0f}%)")
fig.tight_layout(); fig.savefig(f"{OUT}/c3_prefill_decode_balance.png"); plt.close(fig)
print("FIGURES + chars.json written to", OUT)
print(json.dumps({k:chars[k] for k in ["mixture","turns","token_mass","decode_time_fraction"]}, indent=2))

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 114 KiB

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: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

BIN
figs/mb5/mb5_role_split.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

5
figs/mb5/mb5_summary.csv Normal file
View File

@@ -0,0 +1,5 @@
config,rep,n_requests,n_success,wall_clock_s,peak_pool_frac,steady_pool_frac,p_pool_peak_frac,p_pool_steady_frac,d_pool_peak_frac,d_pool_steady_frac,peak_waiting,latency_p50_s,latency_p90_s,latency_p99_s,ttft_p50_s,ttft_p90_s,ttft_p99_s,prefix_cache_hit_ratio
8C,1,1214,1214,2994.218414353032,0.7174957362137578,0.3439702956225128,,,,,29,10.82550932947197,83.34998885790122,194.10265863158946,6.967104309005663,53.12018221841427,114.12611859919207,0.1937163528742694
6P+2D,1,1214,1214,3419.065942236979,0.7726478112563957,0.42145750426378625,0.743272692817889,0.3082291074474133,0.9959636156907333,0.7434906196702672,128,44.48975181748392,91.82252187062406,147.70196208347772,40.95952733900049,86.68752026481089,142.84028979733685,0.0
4P+4D,1,1214,1214,4170.666486939997,0.6997939169982945,0.45876918703808983,0.6438459351904491,0.28540363843092664,0.9753411028993746,0.5977686185332576,152,59.52004547297838,157.08703426021387,224.03997302683115,56.419772224500775,153.07864206891392,219.73412787001706,0.0
2P+6D,1,1214,109,5761.816568834998,0.9698692438885731,0.9435119386014781,0.9969869243888573,0.9198408186469585,0.9620238772029562,0.9494504453287853,872,26.293884326005355,499.3484142678091,577.7122636228032,23.580788671970367,498.0334587502061,576.5306194114453,0.0
1 config rep n_requests n_success wall_clock_s peak_pool_frac steady_pool_frac p_pool_peak_frac p_pool_steady_frac d_pool_peak_frac d_pool_steady_frac peak_waiting latency_p50_s latency_p90_s latency_p99_s ttft_p50_s ttft_p90_s ttft_p99_s prefix_cache_hit_ratio
2 8C 1 1214 1214 2994.218414353032 0.7174957362137578 0.3439702956225128 29 10.82550932947197 83.34998885790122 194.10265863158946 6.967104309005663 53.12018221841427 114.12611859919207 0.1937163528742694
3 6P+2D 1 1214 1214 3419.065942236979 0.7726478112563957 0.42145750426378625 0.743272692817889 0.3082291074474133 0.9959636156907333 0.7434906196702672 128 44.48975181748392 91.82252187062406 147.70196208347772 40.95952733900049 86.68752026481089 142.84028979733685 0.0
4 4P+4D 1 1214 1214 4170.666486939997 0.6997939169982945 0.45876918703808983 0.6438459351904491 0.28540363843092664 0.9753411028993746 0.5977686185332576 152 59.52004547297838 157.08703426021387 224.03997302683115 56.419772224500775 153.07864206891392 219.73412787001706 0.0
5 2P+6D 1 1214 109 5761.816568834998 0.9698692438885731 0.9435119386014781 0.9969869243888573 0.9198408186469585 0.9620238772029562 0.9494504453287853 872 26.293884326005355 499.3484142678091 577.7122636228032 23.580788671970367 498.0334587502061 576.5306194114453 0.0

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: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,178 @@
# Why KV-transfer is slow during migration under real load
**Question.** EAR's unified+A+B routing beats migration (v3) on agentic
workloads. We wanted to know whether *layerwise* KV transfer would shrink
migration's overhead enough to make it viable. Investigating that led to a
sharper question: **in a real (loaded) cluster, when we migrate, the KV
transfer is already slow — the effective bandwidth is far below the
~10 GB/s wire rate. Why?**
This doc answers that with instrumented measurements.
**TL;DR.** Migration fires precisely when instances are *busy* (that's the
trigger). But on a busy instance, KV transfer runs at **~3 GB/s instead of
~10 GB/s**, because:
1. **The RDMA write itself slows ~2× under compute load** — GPU-direct RDMA
(`batch_transfer_sync_write`) contends with the running attention/MLP
kernels for **HBM and PCIe bandwidth**. (idle 7.6 GB/s → busy 4.0 GB/s)
2. **The connector's Python control plane gets GIL-starved** — mooncake's
ZMQ handshake + transfer orchestration run on asyncio threads inside the
engine process; when the engine's main thread is doing a long forward
pass (e.g. a 100k-token prefill), those threads stall for *seconds*.
Both are **inherent to upstream vLLM 0.18.1 + mooncake** (reproduced on a
clean fresh venv; the transfer path is byte-identical to upstream — our
patches did not cause this), and both get **worse**, not better, with
layerwise transfer. So the bandwidth gap is not a layerwise problem; it is a
*transfer-on-a-busy-GPU* problem.
---
## 1. Evidence chain
Three independent measurements, all on dash0 (8×H100, Qwen3-Coder-30B-A3B,
TP=1), Mooncake `kv_both`.
### 1a. Instrumented v3 trace replay — where does migration time go?
Run `outputs/b3_v3_fullbreak_20260528_0338/`. Instruments:
`instrument_dst_migration.py` (dst scheduler lifecycle) +
`instrument_mooncake.py` (connector internals: `send_blocks` RDMA,
`receive_kv` window, `ready_wait`).
25 migrations fired over the trace. Dst-side migration overhead
(`T_kv_pull` = scheduler marks `WAITING_FOR_REMOTE_KVS``finished_recving`):
| component | share | what it is |
|---|---:|---|
| RDMA-actual (`batch_transfer_sync_write`) | **55%** (55.2 s) | the real RDMA write |
| dst control-plane gap | **45%** (45.4 s) | scheduler↔receiver_loop dispatch + completion propagation |
| `ready_wait` (src KV not committed) | 0% | 25/25 already committed — **ruled out** |
- Pure RDMA aggregate rate: **2.03 GB/s** (vs MB2 idle 9.7 GB/s).
- RDMA rate **collapses with transfer size**: <3 GiB 49.5 GB/s,
>5 GiB → 0.92.6 GB/s.
- The control-plane gap is **bimodal**: median 0.04 s, but a handful of
requests stall ~10 s. Those are small-KV transfers (0.18 s of actual RDMA)
whose `T_kv_pull` is 811 s — i.e. the dst's `receiver_loop` thread was
GIL-starved for ~10 s while the engine did a big forward pass.
> Earlier (pre-instrumentation) we wrongly attributed ~90% of migration
> overhead to "dst scheduler queueing" by estimating transfer at clean wire
> speed. With real instrumentation, dst *scheduler admission* is ~0
> (`T_admission_post_kv` = 0.003 s); the time is the transfer phase (RDMA +
> connector control plane), both degraded by instance busy-ness.
### 1b. MB6 controlled microbench — does busy-ness cause it?
`microbench/fresh_setup/mb6_transfer_under_load.py` + `run_mb6.sh`: 2
instances, transfer a fixed-size KV (prefill on A → migrate to B) while
holding *N* background decode streams on both. Sweep N.
Effective transfer bandwidth (65k-token KV ≈ 6 GiB), main venv:
| background load | 65k transfer | eff bandwidth |
|---|---:|---:|
| **0 (idle)** | 747 ms | **8.76 GB/s** |
| 8 (4/instance) | 2423 ms | 4.53 GB/s |
| **24 (12/instance)** | 2015 ms | **3.33 GB/s** |
Monotonic degradation with load. **The busy level (3.3 GB/s) matches the
v3 trace's 3.3 GB/s median exactly** — because agentic instances run
~10+ concurrent requests, i.e. the bg=24 regime.
Decomposing the 65k transfer into RDMA-actual vs control-plane:
| bg | RDMA rate | control-plane share |
|---|---:|---:|
| 0 (idle) | 7.56 GB/s | 13% |
| 8 | 4.07 GB/s | 11% |
| 24 (busy) | 3.97 GB/s | 15% |
In the clean microbench the **RDMA write itself is the dominant degrading
term** (7.6 → 4.0 GB/s). The ~10 s control-plane stalls seen in the trace
(1a) don't reproduce here because steady decode forward passes are short;
they require the long (100k-token) prefills that the real trace has.
### 1c. Fresh-venv comparison — is it our patch?
Same MB6 sweep on `agentic-kv-fresh/.venv` (clean upstream-style 0.18.1):
| bg | 65k eff (fresh) | 65k eff (main/patched) |
|---|---:|---:|
| 0 | 8.73 GB/s | 8.76 GB/s |
| 8 | 4.52 GB/s | 4.53 GB/s |
| 24 | 3.27 GB/s | 3.33 GB/s |
**Identical within noise.** Plus a static check: the v3 transfer path
(`send_kv_to_decode`, `_send_blocks`/`batch_transfer_sync_write`,
`_build_transfer_params`) is **byte-identical** to pristine upstream 0.18.1
(commit `445e491`); `receive_kv_from_single_worker` differs only by a 4-line
error branch. Our mooncake commits (`a7df84b` direct-read,
`ea51497` partial-prefill, `e3a1d70` read→push) only touch a *separate*
`direct_read` path that v3 does **not** use (v3 requests carry no
`direct_read` flag → normal push path).
**The slowdown is upstream/hardware-inherent, not introduced by us.**
---
## 2. Root cause
Migration in agentic serving transfers KV **between instances that are
concurrently busy with compute** — by construction, since v3 migrates *away
from* a busy host. On a busy instance:
- **HBM/PCIe contention (the dominant, irreducible part).** Mooncake's
transfer is GPU-direct RDMA: the NIC DMAs KV straight out of / into GPU
HBM. While the GPU runs attention+MLP kernels, those kernels saturate HBM
bandwidth, so the NIC's RDMA gets a smaller slice. Effective transfer
bandwidth roughly halves (7.6 → 4.0 GB/s at our load), and degrades
further for large multi-segment transfers.
- **Control-plane GIL starvation (secondary, bursty).** The connector runs
its ZMQ handshake + `send_kv_to_decode`/`receive_kv` orchestration on
asyncio threads (`sender_loop`/`receiver_loop`) *inside the engine
process*. A long forward pass (100k-token prefill) holds the GIL for
seconds, stalling those threads → multi-second dispatch gaps even when the
actual transfer is 0.2 s.
MB2 measured 9.7 GB/s precisely because both endpoints were **idle**. The
real-workload gap is the difference between "idle benchmark" and "transfer
while the GPU is doing the day job."
---
## 3. Implication: layerwise is the wrong lever; migration's tax is largely irreducible
| lever | effect on the gap |
|---|---|
| **Model-level layerwise transfer** (push each layer's KV during prefill) | **Worse.** Prefill is the most HBM-intensive phase, so per-layer transfers contend *harder* for HBM (Cause 1); and they multiply the control-plane round-trips (Cause 2). |
| **Control-plane fix** (move mooncake orchestration off the GIL-contended threads / separate process) | Addresses only the bursty ~10 s stalls (~15% in the clean case, up to ~45% of the trace tail). Does **not** touch the HBM-contention half. |
| **Reduce bytes** (cache-aware target so less KV moves) | Helps linearly; v3 Mechanism B already does some. Orthogonal. |
| **Migrate to/from idle instances** | Would restore ~10 GB/s — but defeats the purpose (we migrate *because* the host is busy). |
The dominant cost (RDMA contending with compute for HBM on busy instances)
is a **hardware reality**, not a software bug we can patch away, and not
something layerwise improves. This reinforces
[UNIFIED_ABLATION.md](UNIFIED_ABLATION.md): the unified no-migration path
(A+B'+RaceFix) remains the right default; migration's transfer tax is
structural on a loaded agentic cluster.
---
## 4. Repro / artifacts
- Instrumented v3 breakdown: `outputs/b3_v3_fullbreak_20260528_0338/unified_v3/`
(`transfer_decomp.txt`, `dst_migration_breakdown.{csv,png}`,
`transfer_rootcause.png`)
- MB6 main: `outputs/mb6_agentic-kv_20260528_0552/mb6_result.json`
- MB6 fresh: `outputs/mb6_fresh_20260528_0559/mb6_result.json`
- Instruments: `microbench/fresh_setup/instrument_dst_migration.py`,
`microbench/fresh_setup/instrument_mooncake.py`
- Microbench: `microbench/fresh_setup/mb6_transfer_under_load.py` +
`run_mb6.sh` (`VENV=… bash run_mb6.sh`)
- Analyzers: `analyze_dst_migration.py`, `analyze_transfer_decomp.py`
All instruments apply/revert cleanly via `--apply`/`--revert`; both venvs
were restored after the runs.

View File

@@ -0,0 +1,431 @@
# Unified routing ablation: A (tighter affinity) + B (decode-aware LMetric)
Goal: judge whether `unified` (cache-aware hybrid affinity + LMetric fallback)
has enough headroom to surpass v3 migration-based routing on agentic
workloads, without invoking PD-sep migration.
## Workload / baseline
- Trace: `w600_r0.0015_st30.jsonl` (1214 reqs, 274 sessions)
- Hardware: 8 × H100 (dash0), Qwen3-Coder-30B-A3B, TP=1, max_model_len=200000
- Trace replay through `cache_aware_proxy.py` with policy `unified`
- `b3_replay_20260527_0114/unified/` reference
| Metric (ms) | baseline (`overload_factor=2.0`) |
|---|---:|
| TTFT p50 | 520 |
| TTFT p90 | **8781** |
| TTFT p99 | 47647 |
| TPOT p90 | 17.8 |
| E2E p90 | 19989 |
| E2E p99 | 85841 |
Reference points we're trying to beat / match:
- v3 fixed rotation (cache-blind picker): TTFT p90 = 10828
- v3 + Mechanism B (cache-rich picker): TTFT p90 = 9711
- All v3 variants are +1023% worse than `unified` baseline.
## Tail-source diagnostic on baseline
Decision split, baseline unified:
| Decision | n | TTFT mean | TTFT p90 | TTFT p99 |
|---|---:|---:|---:|---:|
| affinity | 852 | 3183 | 7011 | 47432 |
| lmetric_fallback | 362 | 4285 | 12083 | 46036 |
Long-tail (>20s, n=65):
- 40 / 65 came from `affinity` decisions
- 25 / 65 came from `lmetric_fallback`
For the 40 slow `affinity` reqs:
- only 12 / 40 were actually overloaded at decision time (`aff_num_req > avg_num_req`)
- overload ratio at decision: mean=0.93, p50=0.87
- **most slow affinity reqs looked fine when the picker stuck — load piled
on after dispatch**.
This is a snapshot-based-routing limitation. Tightening
`overload_factor` only helps the genuine cases above the new threshold —
expected to be a 5-10% improvement at best.
---
## Direction A — tighten affinity overflow
**Hypothesis.** `overload_factor=2.0` lets the picker stick to affinity
even when `affinity.num_req` is up to 2× the cluster average. Reducing to
1.3 forces earlier overflow to LMetric fallback, escaping busy affinity
hosts before the tail blows up.
**Change.** Single CLI flag: `--overload-factor 1.3`. No code change.
**Run.** `unified_of13_20260527_1532/unified/`.
### A vs baseline
| Metric (ms) | baseline (of=2.0) | A (of=1.3) | Δ |
|---|---:|---:|---:|
| TTFT p50 | 520 | 495 | 5% |
| TTFT p90 | 8781 | 8730 | ≈0 |
| TTFT p99 | 47647 | 43059 | 10% |
| TPOT p50 | 7.9 | 8.0 | ≈0 |
| TPOT p90 | 17.8 | **15.5** | **13%** |
| E2E p50 | 1761 | 1824 | +4% |
| E2E p90 | 19989 | 18407 | 8% |
| E2E p99 | 85841 | **71396** | **17%** |
TTFT p90 is essentially unchanged but the **deeper tail (p99) and
TPOT both improved meaningfully**. Net: A alone gives roughly 10% to
17% on the long tail without hurting medians.
### Decision split, A vs baseline
| Decision | baseline n / p90 | A n / p90 | Δ p90 |
|---|---|---|---|
| affinity | 852 / 7011 | 817 / **5817** | **17%** ✅ |
| lmetric_fallback | 362 / 12083 | 397 / **15360** | **+27%** ⚠️ |
The picker now sticks to affinity 35 fewer times. The remaining affinity
decisions are higher-quality (no longer "barely-fitting" cases), so their
p90 drops 17%.
But the 35 extra reqs that got pushed into fallback **got slower**:
fallback p90 went from 12083 → 15360. The LMetric scorer is selecting a
worse instance for them.
### Per-worker TTFT under A (of=1.3)
```
port 8000: n= 94 mean=4424 p90=12290 port 8004: n=192 mean=2597 p90=6968
port 8001: n= 135 mean=2779 p90= 5553 port 8005: n=202 mean=3102 p90=6113
port 8002: n= 88 mean=5827 p90=15804 port 8006: n=136 mean=4006 p90=10899
port 8003: n= 217 mean=2674 p90= 4598 port 8007: n=150 mean=3648 p90= 7025
```
Compared to baseline (88..217 reqs/port), A redistributes more evenly
(88..217 still but distribution is fatter in the middle). port 8002
remains slow (p90 15.8s) — its cache pool seems to keep getting cold
work routed there by LMetric.
### Why A alone isn't enough
LMetric scorer (`unified_hybrid` fallback path):
```python
score = (pending_prefill_tokens + new_uncached_tokens) * num_requests
```
This **ignores `ongoing_decode_tokens`** entirely. An instance with no
pending prefill but 200k tokens currently in decode looks "ideal"
(score=0×num_req=0) — yet a new request landing there waits behind
slow decode iters caused by the large batch KV reads.
A pushes more requests into fallback, but fallback can't tell which
instance is actually free. → Direction B is mandatory companion.
---
## Direction B — decode-aware LMetric
**Hypothesis.** Adding a decode-load penalty to the LMetric score lets
fallback distinguish "no prefill queued but heavy decode running" from
"truly idle". Should restore fallback p90 ≤ 12s baseline level.
**Change.**
```python
score = (pending_prefill + new + lmetric_decode_weight * ongoing_decode_tokens) * num_requests
```
- `lmetric_decode_weight=0.0` ⇒ original LMetric (control)
- `lmetric_decode_weight=0.01` ⇒ first experiment (rationale: 1 decode token
in batch costs ~0.01 prefill-token-equivalent in scheduler iter time
on H100 + Qwen3-30B-A3B)
CLI: `--lmetric-decode-weight 0.01`. Setting in code:
`cache_aware_proxy.py:Settings.lmetric_decode_weight`.
**Run.** `unified_of13_lmw001_20260527_1628/unified/`.
### A+B vs baseline / A
| Metric (ms) | baseline | A (of=1.3) | A+B (of=1.3, lmw=0.01) | Δ vs baseline |
|---|---:|---:|---:|---:|
| TTFT p50 | 520 | 495 | 514 | 1% |
| **TTFT p90** | 8781 | 8730 | **8421** | **4%** ✅ |
| TTFT p99 | 47647 | 43059 | 44800 | 6% |
| TPOT p50 | 7.9 | 8.0 | 7.9 | ≈0 |
| TPOT p90 | 17.8 | 15.5 | 15.7 | 12% |
| E2E p50 | 1761 | 1824 | 1870 | +6% |
| E2E p90 | 19989 | 18407 | **21064** | **+5%** ⚠️ |
| E2E p99 | 85841 | 71396 | **64344** | **25%** ✅ |
Long-tail counts:
```
thresh baseline A A+B v3 MechB
> 5000ms 170 173 170 177
> 10000ms 105 109 109 119
> 20000ms 65 64 59 78
> 30000ms 41 40 37 50
> 50000ms 8 5 6 14
```
A+B is best on every long-tail-count threshold ≤30s, marginal worse at 50s.
### Decision split (A+B vs A)
| Decision | A (of=1.3) | A+B | Note |
|---|---|---|---|
| affinity p90 | 5817 | 5836 | ≈ same |
| fallback p90 | **15360** | **13501** | B recovered some of A's fallback regression |
B partially fixed fallback's selection (12% on fallback p90 vs A alone),
but still worse than baseline (12083).
### Per-worker TTFT (A+B)
```
port 8000: n=134 mean=3495 p90=10967 port 8004: n=136 mean=3102 p90= 7906
port 8001: n=143 mean=2981 p90=10189 port 8005: n=179 mean=1624 p90= 2735
port 8002: n=221 mean=2355 p90= 3502 port 8006: n=137 mean=5356 p90= 9628
port 8003: n=146 mean=3932 p90=10729 port 8007: n=118 mean=5210 p90=26798 ← new hotspot
```
A+B trades the baseline's 8002 hotspot (p90=35s) for a new 8007 hotspot
(p90=26.8s). Lower amplitude but hotspot survives.
### Why 8007 became a hotspot under A+B — **found a bug in B**
8007 in A+B: 118 reqs, **53% affinity / 47% fallback** (vs other ports
6077% affinity), **cache_hit_mean=50.5% (lowest)**.
Top-10 slowest at 8007: all are big-prompt (100k+ tokens) fallback decisions
with `cached_tokens=0` (cold prefill). LMetric is pushing many cold-prefill
fallbacks to 8007.
Looking at the B formula:
```python
decode_pen = lmetric_decode_weight * ongoing_decode_tokens
score = (pending_prefill + new + decode_pen) * num_requests # ← BUG
```
When `num_requests = 0`, the entire score (including decode penalty) zeros
out. So an idle-but-decoding host (num_req=0 because its last prefill
finished but decode is still running) looks like score=0, beating every
busy host.
**Fix (B'):** multiply by `max(num_requests, 1)`:
```python
score = (pending_prefill + new + decode_pen) * max(num_requests, 1)
```
Now idle hosts with high decode load get score = decode_pen × 1 = real
nonzero penalty, beating zero-load hosts only when decode is small.
### A+B' — re-run with the fix
**Run.** `unified_of13_lmw001_v2_20260527_1724/unified/`.
| Metric (ms) | baseline | A+B (BUG) | A+B' (fix) | Δ vs baseline |
|---|---:|---:|---:|---:|
| TTFT p50 | 520 | 514 | **485** | 7% |
| **TTFT p90** | 8781 | 8421 | **8287** | **5.6%** ✅ |
| TTFT p99 | 47647 | 44800 | **41876** | **12%** ✅ |
| TPOT p90 | 17.8 | 15.7 | 17.5 | 2% |
| E2E p90 | 19989 | 21064 | 20625 | +3% |
| E2E p99 | 85841 | 64344 | 77827 | 9% |
A+B' **best of all variants on TTFT p90 (8287) and TTFT p99 (41876)**.
Long-tail counts (>30s, >50s) also best across variants.
vs v3 reference points:
| | TTFT p90 | TPOT p90 | E2E p99 |
|---|---:|---:|---:|
| **A+B'** | **8287** | 17.5 | 77827 |
| v3 fixed (cache-blind) | 10828 | 21.0 | 47610 |
| v3 + Mech B | 9711 | 18.3 | 84492 |
A+B' **beats v3 Mech B by 15% TTFT p90** with no migration overhead.
### Per-worker (A+B' fixed)
```
8000: n=158 p90= 5688 8004: n=189 p90= 4249
8001: n=159 p90= 7323 8005: n=116 p90=14598
8002: n=114 p90= 8726 8006: n=180 p90= 6198
8003: n=173 p90= 6715 8007: n=125 p90=22242 ← still hot
```
A+B' redistributed load more evenly (114..189) but **8007 still has p90=22s**.
### 8007 deep-dive in A+B'
```
8007: n=125, affinity=69 (55%), fallback=56 (45%), cache_hit_mean=lowest
```
Top-15 slow at 8007:
- 7 of them are session **1313181** turns 914 (130k+ tokens each, agentic
long context, ~50% cache hit)
- Several others are cold-start turn-1 of large-prompt sessions
- First two slow reqs arrived **0.7 s apart** — strong hint of concurrent
picker race
### Iteration 3: race-condition fix
**Diagnosis.** In `_handle_combined`:
```python
chosen, best_idx, decision = pick_instance_unified_hybrid(...) # sync
# ... sync breakdown updates ...
return await _handle_local_request(...) # ← await yields here
# THEN reservation happens
```
`return await async_func(...)` evaluates the async call (creates coroutine)
and yields to the event loop **before** the coroutine body executes. The
reservation (`chosen.pending_prefill_tokens += new`, etc.) lives at the top
of `_handle_local_request`, so between the picker and the reservation there
is a **window where another coroutine can run and re-pick the same instance**.
When two big-prompt reqs arrive within milliseconds, both run pick →
both pick the "free" 8007 → both yield → both reserve. Result: 8007 gets
back-to-back 130k-token cold prefills, each waiting for the other.
**Fix.** Move the reservation **before** the await, inside `_handle_combined`:
```python
# Race fix: reserve atomically with pick, before any await.
chosen.ongoing_tokens += input_length
chosen.pending_prefill_tokens += estimated_new
chosen.num_requests += 1
return await _handle_local_request(..., _pre_reserved=True)
```
`_handle_local_request` skips its own reservation when `_pre_reserved=True`.
PD-sep paths are unaffected (they have their own reservation).
**Run.** Pending — `unified_of13_lmw001_racefix_*`. Hypothesis: 8007 p90
drops to within ±3s of cluster median, since concurrent picks for the
same "free" instance no longer happen.
---
## A+B'+RaceFix — results
**Run.** `unified_of13_lmw001_racefix_20260527_1821/unified/`.
| Metric (ms) | baseline | A+B' | A+B'+RF | Δ vs baseline |
|---|---:|---:|---:|---:|
| TTFT p50 | 520 | 485 | **478** | 8% |
| **TTFT p90** | 8781 | 8287 | **7770** | **11.5%** ✅ |
| TTFT p99 | 47647 | 41876 | **42447** | 11% |
| TPOT p90 | 17.8 | 17.5 | 18.0 | +1% |
| E2E p90 | 19989 | 20625 | **18418** | 8% |
| E2E p99 | 85841 | 77827 | **71227** | 17% |
vs v3 reference:
- **A+B'+RF TTFT p90 = 7770ms, vs v3 Mech B 9711ms → 20%** ✅
Long-tail counts (best across all variants):
```
> 5s: 170 → 158 > 30s: 41 → 33
>10s: 105 → 103 > 50s: 8 → 4
>20s: 65 → 57 >100s: 0 → 0
```
### Decision split — race fix mainly helped affinity
| Decision | baseline | A+B'+RF |
|---|---:|---:|
| affinity p90 | 7011 | **5042** ✅ (28%) |
| fallback p90 | 12083 | 13944 (+15%) |
The race-condition was hurting affinity decisions the most. When two
concurrent reqs both stuck to a "free-looking" affinity instance, they
piled up and inflated affinity's tail. Fix removed this collision.
### Per-worker
```
8000: n=86 p90=11541 8004: n=150 p90=11906
8001: n=186 p90= 8307 8005: n=109 p90= 4798
8002: n=105 p90=14540 8006: n=183 p90= 6258
8003: n=264 p90= 3079 8007: n=131 p90=21850 ← still hot
8000 spread now 86..264 — race fix did disperse routing
```
### 8007 still hot — but it's **workload-inherent, not a routing bug**
Top sessions on 8007:
```
session 1279412: n=22 mean= 2208 max=18985 decisions: 91% affinity
session 1313181: n=17 mean=17399 max=49089 decisions: 65% affinity
session 1262354: n=15 mean= 622 max= 2325 decisions: 87% affinity
session 1342921: n= 7 mean=17817 max=55589 decisions: 86% affinity
session 1260327: n= 8 mean= 1636 max= 5382 decisions: 75% affinity
session 1268831: n= 5 mean= 1443 max= 2673 decisions: 80% affinity
```
Sessions 1313181 and 1342921 are **long agentic contexts**: 100k130k tokens
per turn with ~50% cache hit (i.e. 50k new tokens prefill per turn). Even
on a perfectly load-balanced instance, each turn is 715s of pure compute.
Forcing these sessions to spread across instances would mean **cold prefill
every turn (0% cache hit)** → each turn becomes 2030s instead of 715s.
Spreading is **net-negative**.
→ The 8007 p90=22s is the floor imposed by these sessions' structure,
not by routing policy. Unified is at its ceiling for this workload.
---
## Final ranking and take-aways
| Policy | TTFT p90 (ms) | Δ vs baseline | Notes |
|---|---:|---:|---|
| baseline unified (of=2.0) | 8781 | — | reference |
| A (of=1.3) | 8730 | ≈0 | affinity p90 -17%, fallback p90 +27% |
| A+B (of=1.3, lmw=0.01, BUG) | 8421 | 4% | 8007 hotspot from `*num_req` zeroing bug |
| A+B' (formula fix) | 8287 | 5.6% | Bug fixed, still 8007 mild hotspot |
| **A+B'+RaceFix** | **7770** | **11.5%** ✅ | **Best unified variant** |
| v3 fixed | 10828 | +23% | PD-sep migration, cache-blind picker |
| v3 + Mech B | 9711 | +11% | PD-sep + cache-rich target picker |
### Conclusions
1. **Unified path beats v3 PD-sep on this workload by 20%+ TTFT p90.**
PD-sep migration's fixed cost (src prefill + dst first-token waiting on
loaded scheduler) outweighs any decode-time savings for short-output
agentic turns.
2. **Three orthogonal fixes compound for a 11.5% TTFT p90 win:**
- A (`overload_factor=1.3`): tighter affinity overflow → 0.6% but
much cleaner affinity decisions (p90 -17%)
- B' (`lmetric_decode_weight=0.01` with `max(num_req,1)`): decode-aware
fallback → 3.5%
- RaceFix (atomic reserve before await): kills concurrent-pick
collisions → 5.6%
3. **Race condition was the biggest single hidden bug.** `return await
async_func(...)` yields to the event loop **before** the body of
`async_func` runs, so reservations done in the body don't take effect
in time to deter concurrent picks. This affects ANY async dispatch
with separate pick/reserve steps — worth checking other routing
policies.
4. **8007 p90=22s is workload-inherent.** Sessions with 100k+ token turns
at 50% cache hit cannot finish faster than 715s per turn regardless
of routing. Forcing spread would hurt rather than help.
5. **Migration (v3) is not necessary** when unified routing is tuned
well. Save the PD-sep mechanism for cases where it can be proven
net-positive (e.g. very-long-output sessions on extremely overloaded
prefill hosts) and use unified A+B'+RaceFix as the default.
---
## Direction A+B — run pending
(Will be filled when `unified_of13_lmw001_*/unified/` finishes.)

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""B3 5-policy re-test analyser.
Compute TTFT/TPOT/E2E mean/p50/p90/p99 for each policy from
metrics.jsonl, compare against the historical b3_policy_comparison.json
that drives fig_b3_latency_bars.png, and emit a side-by-side table
plus a new figure with the same layout as the original.
Usage:
python analyze_b3_replay.py --root <outroot> [--old-data <path>] [--figure <path>]
"""
import argparse
import json
import statistics
from pathlib import Path
POLICIES = ["lmetric", "load_only", "sticky", "unified", "unified_v2"]
def pct(xs, p):
if not xs:
return None
xs = sorted(xs)
k = max(0, min(len(xs) - 1, int(p / 100.0 * (len(xs) - 1))))
return xs[k]
def summarise(path):
rows = [json.loads(l) for l in open(path) if l.strip()]
ok = [r for r in rows if not r.get("error")]
ttft = [r["ttft_s"] * 1000 for r in ok if r.get("ttft_s") is not None]
tpot = [r["tpot_s"] * 1000 for r in ok if r.get("tpot_s")]
e2e = [r["latency_s"] * 1000 for r in ok if r.get("latency_s") is not None]
return {
"n_total": len(rows),
"n_ok": len(ok),
"ttft_mean_ms": statistics.mean(ttft) if ttft else None,
"ttft_p50_ms": pct(ttft, 50),
"ttft_p90_ms": pct(ttft, 90),
"ttft_p99_ms": pct(ttft, 99),
"tpot_mean_ms": statistics.mean(tpot) if tpot else None,
"tpot_p50_ms": pct(tpot, 50),
"tpot_p90_ms": pct(tpot, 90),
"tpot_p99_ms": pct(tpot, 99),
"e2e_mean_ms": statistics.mean(e2e) if e2e else None,
"e2e_p50_ms": pct(e2e, 50),
"e2e_p90_ms": pct(e2e, 90),
"e2e_p99_ms": pct(e2e, 99),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=Path, required=True)
ap.add_argument("--old-data", type=Path,
default=Path("analysis/characterization/window_1_results/b3_policy_comparison.json"))
ap.add_argument("--figure", type=Path, default=None)
args = ap.parse_args()
new = {}
for p in POLICIES:
path = args.root / p / "metrics.jsonl"
if not path.exists():
print(f"MISSING: {path}")
continue
new[p] = summarise(path)
old = {}
if args.old_data.exists():
d = json.load(open(args.old_data))
for r in d.get("rows", []):
old[r["policy"]] = {
"ttft_p50_ms": r["ttft_p50_s"] * 1000,
"ttft_p90_ms": r["ttft_p90_s"] * 1000,
"ttft_p99_ms": r["ttft_p99_s"] * 1000,
"tpot_p90_ms": r["tpot_p90_s"] * 1000,
"e2e_p90_ms": r.get("e2e_p90_s", 0) * 1000,
}
def fmt(v): return f"{v:.0f}" if v is not None else "-"
def pctd(a, b):
if a is None or b is None or a == 0: return "-"
return f"{(b/a-1)*100:+.1f}%"
# Headline table
print(f"\n# NEW: today's re-test")
print(f"{'policy':<14}{'n_ok':>6}{'TTFTp50':>10}{'TTFTp90':>10}{'TTFTp99':>10}{'TPOTp90':>10}{'E2Ep90':>10}")
print("-" * 70)
for p in POLICIES:
if p not in new: continue
r = new[p]
print(f"{p:<14}{r['n_ok']:>6}{fmt(r['ttft_p50_ms']):>9}ms{fmt(r['ttft_p90_ms']):>9}ms{fmt(r['ttft_p99_ms']):>9}ms{fmt(r['tpot_p90_ms']):>9}ms{fmt(r['e2e_p90_ms']):>9}ms")
print(f"\n# OLD: window_1_results/b3_policy_comparison.json")
print(f"{'policy':<14}{'TTFTp50':>10}{'TTFTp90':>10}{'TTFTp99':>10}{'TPOTp90':>10}{'E2Ep90':>10}")
print("-" * 60)
for p in POLICIES:
if p not in old: continue
r = old[p]
print(f"{p:<14}{fmt(r['ttft_p50_ms']):>9}ms{fmt(r['ttft_p90_ms']):>9}ms{fmt(r['ttft_p99_ms']):>9}ms{fmt(r['tpot_p90_ms']):>9}ms{fmt(r['e2e_p90_ms']):>9}ms")
print(f"\n# DRIFT: today vs old (same policy)")
print(f"{'policy':<14}{'ΔTTFTp50':>10}{'ΔTTFTp90':>10}{'ΔTTFTp99':>10}{'ΔTPOTp90':>10}{'ΔE2Ep90':>10}")
print("-" * 60)
for p in POLICIES:
if p not in new or p not in old: continue
n, o = new[p], old[p]
print(f"{p:<14}{pctd(o['ttft_p50_ms'], n['ttft_p50_ms']):>10}"
f"{pctd(o['ttft_p90_ms'], n['ttft_p90_ms']):>10}"
f"{pctd(o['ttft_p99_ms'], n['ttft_p99_ms']):>10}"
f"{pctd(o['tpot_p90_ms'], n['tpot_p90_ms']):>10}"
f"{pctd(o['e2e_p90_ms'], n['e2e_p90_ms']):>10}")
# Relative ordering check
def ranks(values_dict, key):
items = [(p, r[key]) for p, r in values_dict.items() if r.get(key)]
items.sort(key=lambda x: x[1])
return [p for p, _ in items]
print(f"\n# TTFT p90 ranking (best → worst)")
for label, src in [("OLD", old), ("NEW", new)]:
if src:
order = ranks(src, "ttft_p90_ms")
print(f" {label}: {' < '.join(order)}")
out = {"new": new, "old": old}
out_path = args.root / "b3_replay_summary.json"
out_path.write_text(json.dumps(out, indent=2))
print(f"\nWrote {out_path}")
# Bar plot (matplotlib)
if not args.figure:
args.figure = args.root / "fig_b3_latency_bars_new.png"
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
pols = [p for p in POLICIES if p in new]
metrics = [("TTFT p90 (s)", "ttft_p90_ms", 1000),
("TPOT p90 (ms)", "tpot_p90_ms", 1),
("E2E p90 (s)", "e2e_p90_ms", 1000)]
colors = {"lmetric": "tab:blue", "load_only": "tab:orange",
"sticky": "tab:green", "unified": "tab:red",
"unified_v2": "tab:purple"}
fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
for ax, (label, key, div) in zip(axes, metrics):
vals = [new[p][key] / div for p in pols]
bars = ax.bar(pols, vals,
color=[colors.get(p, "gray") for p in pols],
edgecolor="black", linewidth=0.5)
ax.set_title(label)
ax.tick_params(axis="x", rotation=20)
for b, v in zip(bars, vals):
ax.text(b.get_x() + b.get_width() / 2, v, f"{v:.1f}",
ha="center", va="bottom", fontsize=9)
ax.grid(alpha=0.3, axis="y")
fig.suptitle(f"B3 5-policy re-test ({args.root.name})")
fig.tight_layout()
fig.savefig(args.figure, dpi=120)
print(f"Wrote {args.figure}")
except Exception as e:
print(f"(figure skipped: {e})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""Analyze dst-side migration breakdown for unified_v3 runs.
Joins the proxy `breakdown.json` (per-request route + phase timestamps)
with the dst engine per-PID logs written by
`instrument_dst_migration.py` (`dm_mig_pid<pid>.jsonl`), to attribute
each migration's dst-side wall-clock into:
T_relay proxy decode-sent → dst arrival
T_admission_pre_kv dst arrival → status=WAITING_FOR_REMOTE_KVS
(waiting in dst's scheduler queue before KV pull
is even initiated)
T_kv_pull WAITING_FOR_REMOTE_KVS → finished_recving
(the actual RDMA transfer + connector ack)
T_admission_post_kv finished_recving → first time in self.running
(KV ready, waiting for batch slot)
T_first_iter first scheduled → first generated token
(one decode-iter compute + sampler latency)
Layerwise transfer can at best eliminate T_kv_pull. Everything else is
queueing or compute that layerwise does not touch.
Usage:
python analyze_dst_migration.py \
--proxy-breakdown <RUNDIR>/breakdown.json \
--dst-log-dir <DST_LOG_DIR>
[--output <RUNDIR>/dst_migration_breakdown.csv]
[--plot <RUNDIR>/dst_migration_breakdown.png]
"""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import statistics
import sys
from pathlib import Path
def _core_req_id(rid: str) -> str:
"""Normalize a vLLM engine req_id back to the proxy's request_id.
vLLM wraps the proxy id `S:T:U:N` as `cmpl-S:T:U:N-<dp_rank>-<hex>`.
Strip the `cmpl-` prefix and the trailing `-<digits>-<hex>` suffix so
it joins against the proxy `breakdown.json` request_id.
"""
if not rid:
return rid
s = rid
if s.startswith("cmpl-"):
s = s[len("cmpl-"):]
m = re.match(r"^(.*)-\d+-[0-9a-fA-F]+$", s)
if m:
s = m.group(1)
return s
def _pct(vals: list[float], q: float) -> float:
if not vals:
return float("nan")
vs = sorted(vals)
i = max(0, min(len(vs) - 1, int(math.ceil(q * len(vs))) - 1))
return vs[i]
def _summary(name: str, vals: list[float]) -> dict:
if not vals:
return {"name": name, "n": 0}
return {
"name": name,
"n": len(vals),
"mean_s": statistics.mean(vals),
"p50_s": _pct(vals, 0.5),
"p90_s": _pct(vals, 0.9),
"p99_s": _pct(vals, 0.99),
"max_s": max(vals),
"sum_s": sum(vals),
}
def load_dst_log(dst_log_dir: Path) -> dict[str, dict]:
by_req: dict[str, dict] = {}
found_files = sorted(dst_log_dir.glob("dm_mig_pid*.jsonl"))
print(f"[analyze] dst log files: {len(found_files)} under {dst_log_dir}")
for f in found_files:
with f.open() as fh:
for line in fh:
try:
rec = json.loads(line)
except Exception:
continue
rid = rec.get("req_id")
if not rid:
continue
key = _core_req_id(rid)
rec["_raw_req_id"] = rid
# If a req shows up twice (shouldn't, but be safe), prefer the
# one with t_first_token_unix populated.
prev = by_req.get(key)
if prev is None or (
rec.get("t_first_token_unix") and
not prev.get("t_first_token_unix")
):
by_req[key] = rec
print(f"[analyze] unique dst records: {len(by_req)}")
return by_req
def load_proxy_breakdown(path: Path) -> list[dict]:
with path.open() as fh:
data = json.load(fh)
assert isinstance(data, list), f"unexpected breakdown.json shape: {type(data)}"
return data
def decompose(proxy_recs: list[dict], dst_by_req: dict[str, dict]) -> list[dict]:
"""Build per-migration breakdown rows by joining proxy + dst by req_id."""
rows: list[dict] = []
migrations = [x for x in proxy_recs if x.get("route_class") == "PD_SEP_V2"]
print(f"[analyze] proxy migrations: {len(migrations)} "
f"(of {len(proxy_recs)} total requests)")
miss_in_dst = 0
missing_phases = 0
for p in migrations:
rid = p.get("request_id")
dst = dst_by_req.get(rid)
if dst is None:
miss_in_dst += 1
continue
if dst.get("t_first_token_unix") is None:
missing_phases += 1
# still include the row but mark phases as NaN downstream
t_decode_sent = p.get("t_decode_sent_unix")
t_first_tok = p.get("t_first_token_unix")
t_arrival = dst.get("t_arrival_unix")
t_wait_kvs = dst.get("t_wait_for_kvs_unix")
t_kv_done = dst.get("t_kv_recv_done_unix")
t_first_sched = dst.get("t_first_scheduled_unix")
t_first_tok_dst = dst.get("t_first_token_unix")
def _diff(a, b):
if a is None or b is None:
return None
return float(a) - float(b)
rows.append({
"request_id": rid,
"session_id": p.get("session_id"),
"input_length": p.get("input_length"),
"v3_new_local": p.get("v3_new_local"),
"v3_target_idx": p.get("v3_target_idx") or p.get("v3_decode_target_idx"),
"arrival_n_running": (dst.get("arrival_state") or {}).get("n_running"),
"arrival_n_waiting": (dst.get("arrival_state") or {}).get("n_waiting"),
"arrival_pending_prefill_tok": (dst.get("arrival_state") or {}).get("pending_prefill_tok"),
"arrival_n_waiting_for_kvs": (dst.get("arrival_state") or {}).get("n_waiting_for_kvs"),
# Phase durations (seconds)
"T_proxy_total_dst_first_token_s": _diff(t_first_tok, t_decode_sent),
"T_relay_s": _diff(t_arrival, t_decode_sent),
"T_admission_pre_kv_s": _diff(t_wait_kvs, t_arrival),
"T_kv_pull_s": _diff(t_kv_done, t_wait_kvs),
"T_admission_post_kv_s": _diff(t_first_sched, t_kv_done),
"T_first_iter_s": _diff(t_first_tok_dst, t_first_sched),
# Raw timestamps for debugging
"t_decode_sent_unix": t_decode_sent,
"t_dst_arrival_unix": t_arrival,
"t_dst_wait_for_kvs_unix": t_wait_kvs,
"t_dst_kv_recv_done_unix": t_kv_done,
"t_dst_first_scheduled_unix": t_first_sched,
"t_dst_first_token_unix": t_first_tok_dst,
"t_proxy_first_token_unix": t_first_tok,
})
print(f"[analyze] missing in dst log: {miss_in_dst}")
print(f"[analyze] dst record incomplete (no t_first_token): {missing_phases}")
return rows
def emit_summary(rows: list[dict]) -> None:
if not rows:
print("[analyze] no rows — nothing to summarize.")
return
phase_keys = [
"T_proxy_total_dst_first_token_s",
"T_relay_s",
"T_admission_pre_kv_s",
"T_kv_pull_s",
"T_admission_post_kv_s",
"T_first_iter_s",
]
print()
print("=" * 88)
print(f"Migration dst-side phase breakdown (n_migrations={len(rows)})")
print("=" * 88)
print(f"{'phase':<36} {'n':>4} {'mean(s)':>9} {'p50':>8} {'p90':>8} "
f"{'p99':>8} {'max':>8} {'sum(s)':>9}")
print("-" * 88)
for k in phase_keys:
vals = [r[k] for r in rows if r.get(k) is not None]
if not vals:
print(f"{k:<36} {'n/a':>4}")
continue
s = _summary(k, vals)
print(f"{k:<36} {s['n']:>4} {s['mean_s']:>9.3f} {s['p50_s']:>8.3f} "
f"{s['p90_s']:>8.3f} {s['p99_s']:>8.3f} {s['max_s']:>8.3f} "
f"{s['sum_s']:>9.2f}")
print()
print("Aggregate attribution (sum across all migrations):")
sums = {}
for k in ("T_relay_s", "T_admission_pre_kv_s", "T_kv_pull_s",
"T_admission_post_kv_s", "T_first_iter_s"):
sums[k] = sum(r[k] for r in rows if r.get(k) is not None)
total = sum(sums.values())
total_proxy = sum(r["T_proxy_total_dst_first_token_s"] for r in rows
if r.get("T_proxy_total_dst_first_token_s") is not None)
print(f" decomposed sum : {total:>8.2f} s")
print(f" proxy total sum : {total_proxy:>8.2f} s "
f"(should be ~equal; gap = uninstrumented)")
if total > 0:
for k, v in sums.items():
print(f" {k:<28} {v:>8.2f} s ({v/total*100:5.1f} %)")
# Headline: "How much could layerwise save?"
layerwise_addressable = sums.get("T_kv_pull_s", 0.0)
queue_residual = sum(v for k, v in sums.items() if k != "T_kv_pull_s")
print()
print("Layerwise-addressable vs queue-residual:")
print(f" T_kv_pull_s (addressable by layerwise) : {layerwise_addressable:>8.2f} s "
f"({layerwise_addressable / total * 100 if total else 0:5.1f} %)")
print(f" everything else (queue/admission/iter) : {queue_residual:>8.2f} s "
f"({queue_residual / total * 100 if total else 0:5.1f} %)")
def write_csv(rows: list[dict], path: Path) -> None:
import csv
if not rows:
path.write_text("")
return
fields = list(rows[0].keys())
with path.open("w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=fields)
w.writeheader()
w.writerows(rows)
print(f"[analyze] wrote CSV: {path} (n={len(rows)})")
def maybe_plot(rows: list[dict], out_path: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except Exception as e:
print(f"[analyze] matplotlib unavailable ({e}); skipping plot.")
return
if not rows:
return
rows_sorted = sorted(
rows,
key=lambda r: r.get("T_proxy_total_dst_first_token_s") or 0.0,
)
n = len(rows_sorted)
idx = list(range(n))
def col(k):
return [(r.get(k) or 0.0) for r in rows_sorted]
relay = col("T_relay_s")
pre = col("T_admission_pre_kv_s")
pull = col("T_kv_pull_s")
post = col("T_admission_post_kv_s")
first_iter = col("T_first_iter_s")
fig, ax = plt.subplots(figsize=(11, 5))
bot = [0.0] * n
for vals, label, color in [
(relay, "HTTP relay", "#cccccc"),
(pre, "admission pre-KV", "#f4a261"),
(pull, "KV pull (layerwise-addressable)", "#e76f51"),
(post, "admission post-KV", "#2a9d8f"),
(first_iter, "first decode iter", "#264653"),
]:
ax.bar(idx, vals, bottom=bot, color=color, label=label, width=0.85)
bot = [b + v for b, v in zip(bot, vals)]
ax.set_xticks(idx)
ax.set_xticklabels([str(i + 1) for i in idx], rotation=0, fontsize=8)
ax.set_xlabel("Migrated request (sorted by total dst wait, ascending)")
ax.set_ylabel("Time (s)")
ax.set_title("Per-migration dst-side phase breakdown (v3 unified_v3 run)")
ax.legend(loc="upper left", fontsize=9)
ax.grid(axis="y", linestyle=":", alpha=0.5)
fig.tight_layout()
fig.savefig(out_path, dpi=120)
plt.close(fig)
print(f"[analyze] wrote plot: {out_path}")
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--proxy-breakdown", type=Path, required=True)
p.add_argument("--dst-log-dir", type=Path, required=True)
p.add_argument("--output", type=Path, default=None,
help="CSV path (default: <run>/dst_migration_breakdown.csv)")
p.add_argument("--plot", type=Path, default=None,
help="PNG path (default: <run>/dst_migration_breakdown.png)")
args = p.parse_args()
if not args.proxy_breakdown.is_file():
sys.exit(f"missing proxy breakdown: {args.proxy_breakdown}")
if not args.dst_log_dir.is_dir():
sys.exit(f"missing dst log dir: {args.dst_log_dir}")
run_dir = args.proxy_breakdown.parent
out_csv = args.output or (run_dir / "dst_migration_breakdown.csv")
out_png = args.plot or (run_dir / "dst_migration_breakdown.png")
proxy_recs = load_proxy_breakdown(args.proxy_breakdown)
dst_by_req = load_dst_log(args.dst_log_dir)
rows = decompose(proxy_recs, dst_by_req)
emit_summary(rows)
write_csv(rows, out_csv)
maybe_plot(rows, out_png)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Per-migration log + per-instance summary for a v3 trace replay.
Reads <run_dir>/breakdown.json and <run_dir>/metrics.jsonl and emits:
1. A row per migration showing src→dst, per-side state snapshots, and
the resulting TTFT.
2. Histograms: migrations received per inst, sent per inst, all
(src→dst) pairs.
3. Post-rotation tail: how many turns of migrated sessions ended up on
each inst (downstream impact of rotation).
4. Anti-hotspot signal: recent_mig_received_in_window at decision time.
Run any v3 replay through this to spot pathological clustering of
migrations on the same dst within a short window.
Usage:
python analyze_migration_log.py <RUN_DIR>
where <RUN_DIR> contains breakdown.json + metrics.jsonl (i.e. the proxy's
per-policy output folder, e.g. .../b3_v3_20260527_1344/unified_v3).
"""
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
def main(run_dir: Path) -> None:
bd = json.load(open(run_dir / "breakdown.json"))
m = {json.loads(l)["request_id"]: json.loads(l)
for l in open(run_dir / "metrics.jsonl")}
mig = [e for e in bd if e.get("v3_migrate")]
mig.sort(key=lambda x: x.get("t_decision_unix", 0))
print(f"=== {len(mig)} migrations in {run_dir.name} ===\n")
cols = (
"#", "t_rel", "session", "turn",
"src", "dst", "src_nreq", "src_dec_tok",
"dst_nreq", "dst_cache", "dst_recent_recv",
"inlen", "self_ttft_ms",
)
print(" " + " ".join(f"{c:>13}" for c in cols))
print("-" * (15 * len(cols)))
t0 = mig[0]["t_decision_unix"] if mig else 0
for i, e in enumerate(mig):
rid = e["request_id"]
src_idx = e.get("v3_src_idx", e["chosen_idx"])
dst_idx = e.get("v3_target_idx", -1)
src_state = e.get("v3_src_state") or {}
dst_state = e.get("v3_target_state") or {}
cands = {c["idx"]: c for c in e.get("candidate_scores", [])}
# Fall back to candidate_scores if dedicated v3_*_state fields aren't present.
src_nreq = src_state.get("num_requests", cands.get(src_idx, {}).get("num_requests", "-"))
src_dec_tok = src_state.get("ongoing_decode_tokens",
cands.get(src_idx, {}).get("ongoing_decode_tokens", "-"))
dst_nreq = dst_state.get("num_requests", cands.get(dst_idx, {}).get("num_requests", "-"))
dst_cache = e.get("v3_target_cache_hit", dst_state.get("cache_hit_estimate", 0))
dst_recent = e.get("v3_target_recent_received",
dst_state.get("recent_mig_received_in_window", "-"))
inlen = e.get("input_length") or m.get(rid, {}).get("input_length", 0)
ttft = m.get(rid, {}).get("ttft_s") or 0
t_rel = e["t_decision_unix"] - t0
turn = m.get(rid, {}).get("turn_id", "?")
print(
f" {i+1:>13} {t_rel:>13.1f} {e['session_id']:>13} {turn:>13} "
f"{src_idx:>13} {dst_idx:>13} {src_nreq:>13} {src_dec_tok:>13} "
f"{dst_nreq:>13} {dst_cache:>13} {dst_recent:>13} "
f"{inlen:>13} {ttft*1000:>13.0f}"
)
# Aggregate counts
print("\n=== Migrations TO each instance ===")
to_count = Counter(e.get("v3_target_idx", -1) for e in mig)
for idx in range(8):
print(f" inst_{idx}: {to_count.get(idx, 0)} migrations received")
print("\n=== Migrations FROM each instance ===")
from_count = Counter(e.get("v3_src_idx", e["chosen_idx"]) for e in mig)
for idx in range(8):
print(f" inst_{idx}: {from_count.get(idx, 0)} migrations sent")
print("\n=== Migration pairs (src→dst, count) ===")
pair_count = Counter(
(e.get("v3_src_idx", e["chosen_idx"]), e.get("v3_target_idx", -1))
for e in mig
)
for (s, d), n in sorted(pair_count.items(), key=lambda x: -x[1]):
print(f" {s}{d}: {n}")
print("\n=== Sessions migrating multiple times ===")
sess_mig = defaultdict(list)
for e in mig:
sess_mig[e["session_id"]].append(
(e.get("t_decision_unix", 0),
e.get("v3_src_idx", e["chosen_idx"]),
e.get("v3_target_idx", -1))
)
multi = {s: ev for s, ev in sess_mig.items() if len(ev) > 1}
if not multi:
print(" (none)")
for sess, events in sorted(multi.items()):
chain = "".join(f"{s}->{d}" for _, s, d in sorted(events))
print(f" session {sess}: {chain}")
# Recent-received hotspot signal — non-zero values mean the picker
# accepted a target that recently got another migration.
print("\n=== Anti-hotspot signal: dst.recent_mig_received_in_window ===")
rec = [e.get("v3_target_recent_received", 0) for e in mig]
if rec:
nonzero = [v for v in rec if v]
print(f" total migrations: {len(rec)}, "
f"with recent_received > 0: {len(nonzero)}, "
f"max recent_received: {max(rec)}")
# Post-rotation tail: turns of migrated sessions after their LAST mig
print("\n=== Post-rotation tail per inst (turns of migrated sessions after last mig) ===")
tail = Counter()
for sess, events in sess_mig.items():
final_dst = sorted(events)[-1][2]
last_t = max(t for t, _, _ in events)
sess_turns = [mm for rid, mm in m.items() if mm["session_id"] == sess]
tail[final_dst] += sum(1 for mm in sess_turns
if mm.get("t_dispatch_unix", 0) > last_t)
for idx in range(8):
print(f" inst_{idx}: {tail.get(idx, 0)} tail turns")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: analyze_migration_log.py <run_dir>", file=sys.stderr)
sys.exit(1)
main(Path(sys.argv[1]))

View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Decompose migration KV-transfer time into RDMA-actual vs control-plane.
Joins three logs from an instrumented unified_v3 run:
proxy breakdown.json — per-request route + phase timestamps
dst_mig_log/dm_mig_pid*.jsonl — dst lifecycle (instrument_dst_migration.py)
gives T_kv_pull = wait_for_kvs -> recv_done
mooncake xfer/mb2_transfer_pid*.jsonl — connector internals
(instrument_mooncake.py):
send_blocks : pure RDMA (total_bytes, duration_s) [producer]
receive_kv_enter/finish: consumer-observed transfer window [consumer]
ready_wait : producer wait for src KV commit [producer]
send_kv_to_decode_enter: producer received the pull request [producer]
Decisive question: of the 87% dst-side overhead that is T_kv_pull, how
much is the actual RDMA write (`send_blocks`) vs control-plane
(handshake / ready-wait / GIL starvation on the busy src)?
- send_blocks bandwidth ~ wire (10 GB/s) AND << T_kv_pull
=> loss is control-plane; layerwise (which only moves WHEN the
RDMA fires) will NOT fix it.
- send_blocks bandwidth << wire
=> the RDMA write itself is slow (NIC / src-side servicing);
characterize with a load microbench next.
Usage:
python analyze_transfer_decomp.py \
--proxy-breakdown <RUN>/unified_v3/breakdown.json \
--dst-log-dir <RUN>/dst_mig_log \
--xfer-log-dir <RUN>/xfer_log
"""
from __future__ import annotations
import argparse
import json
import math
import re
import statistics
import sys
from pathlib import Path
def _core_req_id(rid: str) -> str:
if not rid:
return rid
s = rid
if s.startswith("cmpl-"):
s = s[len("cmpl-"):]
m = re.match(r"^(.*)-\d+-[0-9a-fA-F]+$", s)
if m:
s = m.group(1)
return s
def _pct(vals, q):
if not vals:
return float("nan")
vs = sorted(vals)
i = max(0, min(len(vs) - 1, int(math.ceil(q * len(vs))) - 1))
return vs[i]
def _stat_line(name, vals, unit="s"):
if not vals:
print(f"{name:<34} n=0")
return
print(f"{name:<34} n={len(vals):>3} mean={statistics.mean(vals):>8.3f} "
f"p50={_pct(vals,0.5):>8.3f} p90={_pct(vals,0.9):>8.3f} "
f"max={max(vals):>8.3f} sum={sum(vals):>8.2f} {unit}")
def load_events(xfer_dir: Path):
files = sorted(xfer_dir.glob("mb2_transfer_pid*.jsonl"))
print(f"[xfer] log files: {len(files)} under {xfer_dir}")
send_blocks, recv_enter, recv_finish, ready_wait, send_enter = [], [], [], [], []
for f in files:
pid = f.stem.replace("mb2_transfer_pid", "")
with f.open() as fh:
for line in fh:
try:
e = json.loads(line)
except Exception:
continue
e["_pid"] = pid
ev = e.get("event")
if ev == "send_blocks":
send_blocks.append(e)
elif ev == "receive_kv_enter":
recv_enter.append(e)
elif ev == "receive_kv_finish":
recv_finish.append(e)
elif ev == "ready_wait":
ready_wait.append(e)
elif ev == "send_kv_to_decode_enter":
send_enter.append(e)
print(f"[xfer] events: send_blocks={len(send_blocks)} "
f"recv_enter={len(recv_enter)} recv_finish={len(recv_finish)} "
f"ready_wait={len(ready_wait)} send_enter={len(send_enter)}")
return send_blocks, recv_enter, recv_finish, ready_wait, send_enter
def main():
p = argparse.ArgumentParser()
p.add_argument("--proxy-breakdown", type=Path, required=True)
p.add_argument("--dst-log-dir", type=Path, required=True)
p.add_argument("--xfer-log-dir", type=Path, required=True)
args = p.parse_args()
for pth in (args.proxy_breakdown, args.dst_log_dir, args.xfer_log_dir):
if not pth.exists():
sys.exit(f"missing: {pth}")
proxy = json.load(args.proxy_breakdown.open())
migrations = [x for x in proxy if x.get("route_class") == "PD_SEP_V2"]
mig_ids = {x.get("request_id") for x in migrations}
print(f"[proxy] migrations: {len(migrations)} / {len(proxy)} total")
# dst lifecycle: T_kv_pull per migration (core req id)
dst_pull = {}
for f in sorted(args.dst_log_dir.glob("dm_mig_pid*.jsonl")):
for line in f.open():
try:
r = json.loads(line)
except Exception:
continue
tw = r.get("t_wait_for_kvs_unix")
td = r.get("t_kv_recv_done_unix")
if tw and td:
dst_pull[_core_req_id(r.get("req_id"))] = td - tw
sb, re_enter, re_finish, rw, se = load_events(args.xfer_log_dir)
# ---- 1. Pure RDMA bandwidth from send_blocks (the decisive number) ----
print("\n" + "=" * 90)
print("1. PURE RDMA WRITE rate (`send_blocks` = batch_transfer_sync_write)")
print("=" * 90)
bws, durs, bytes_l = [], [], []
for e in sb:
b = e.get("total_bytes", 0)
d = e.get("duration_s", 0)
if d and d > 0 and b > 0:
bws.append(b / 1e9 / d)
durs.append(d)
bytes_l.append(b)
if bws:
tot_b = sum(bytes_l)
tot_d = sum(durs)
print(f" send_blocks calls: {len(bws)}")
print(f" total bytes moved : {tot_b/2**30:.2f} GiB")
print(f" total RDMA time : {tot_d:.2f} s")
print(f" AGGREGATE rate : {tot_b/1e9/tot_d:.2f} GB/s "
f"(MB2 idle-src steady-state = ~9.7-10 GB/s)")
_stat_line(" per-call rate (GB/s)", bws, unit="GB/s")
_stat_line(" per-call duration", durs)
# bandwidth vs size — small ops are latency-bound
print("\n rate vs transfer size:")
pairs = sorted(zip(bytes_l, bws))
for b, w in pairs:
bar = "#" * int(min(40, w * 4))
print(f" {b/2**20:>8.1f} MiB {w:>6.2f} GB/s {bar}")
else:
print(" no send_blocks events with positive duration")
# ---- 2. Producer ready-wait (src KV commit) ----
print("\n" + "=" * 90)
print("2. PRODUCER ready-wait (src KV not yet committed when pull arrived)")
print("=" * 90)
rw_vals = [e.get("ready_wait_s", 0) for e in rw if e.get("ready_wait_s") is not None]
already = sum(1 for e in rw if e.get("ready_already_set"))
_stat_line(" ready_wait", rw_vals)
print(f" ready_already_set at entry: {already}/{len(rw)} "
f"(if most are True, src commit is not the bottleneck)")
# ---- 3. Consumer-observed receive_kv window ----
print("\n" + "=" * 90)
print("3. CONSUMER receive_kv window (enter->FINISH, ~most of T_kv_pull)")
print("=" * 90)
rf_vals = [e.get("duration_s", 0) for e in re_finish if e.get("duration_s")]
_stat_line(" receive_kv duration", rf_vals)
# ---- 4. Per-migration join: T_kv_pull vs receive_kv vs ready_wait ----
print("\n" + "=" * 90)
print("4. PER-MIGRATION join (T_kv_pull from dst vs connector internals)")
print("=" * 90)
# index connector events by core req id
rf_by_req = {}
for e in re_finish:
for rid in e.get("req_ids", []):
rf_by_req[_core_req_id(rid)] = e.get("duration_s")
rw_by_req = {}
for e in rw:
rw_by_req[_core_req_id(e.get("d_req_id", ""))] = e.get("ready_wait_s")
joined = 0
sum_pull = sum_recv = sum_rw = 0.0
rows = []
for m in migrations:
core = m.get("request_id")
pull = dst_pull.get(core)
recv = rf_by_req.get(core)
rwv = rw_by_req.get(core)
if pull is None and recv is None:
continue
joined += 1
if pull: sum_pull += pull
if recv: sum_recv += recv
if rwv: sum_rw += rwv
rows.append((core, m.get("input_length"), m.get("v3_target_cache_hit"),
pull, recv, rwv))
print(f" joined migrations: {joined}")
print(f" Σ T_kv_pull (dst) = {sum_pull:8.2f} s")
print(f" Σ receive_kv (consumer) = {sum_recv:8.2f} s")
print(f" Σ ready_wait (producer) = {sum_rw:8.2f} s")
# The RDMA share: best-effort total send_blocks time
sum_rdma = sum(durs) if durs else 0.0
print(f" Σ send_blocks RDMA = {sum_rdma:8.2f} s (all transfers, "
f"not just migrations)")
if sum_pull > 0:
print(f"\n RDMA-actual / T_kv_pull ≈ {sum_rdma/sum_pull*100:5.1f} %")
print(f" ready-wait / T_kv_pull ≈ {sum_rw/sum_pull*100:5.1f} %")
resid = sum_pull - sum_rdma - sum_rw
print(f" control-plane residual ≈ {resid/sum_pull*100:5.1f} % "
f"(handshake / ZMQ / GIL starvation)")
print("\n per-migration detail:")
print(f" {'req_id':<22} {'in_len':>7} {'dst_hit':>8} {'kv_pull':>8} "
f"{'recv_kv':>8} {'rdy_wait':>8}")
for core, il, hit, pull, recv, rwv in sorted(
rows, key=lambda r: -(r[3] or 0)):
def s(v): return f"{v:.2f}" if v is not None else " --"
print(f" {core:<22} {str(il):>7} {str(hit):>8} {s(pull):>8} "
f"{s(recv):>8} {s(rwv):>8}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# 5-policy comparison on the first-600s trace, fresh vLLM (cold APC) each arm.
# leastwork LPWL (parameter-free)
# unified_ab unified hybrid, A+B' tuned (of=1.3, lmw=0.01)
# unified_def unified hybrid, defaults (of=2.0, lmw=0.0)
# lmetric P_tokens x BS, no affinity
# sticky hard session affinity
set -uo pipefail
export ROOT=/home/admin/cpfs/wjh/agentic-kv
export MODEL=/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct
TRACE="${TRACE:-$ROOT/traces/w600_r0.0015_st30_first600s.jsonl}"
DATE="$(date +%Y%m%d_%H%M)"
OUT="${OUTROOT:-$ROOT/outputs/policy5_600s_$DATE}"
mkdir -p "$OUT"
echo "OUTROOT=$OUT" | tee "$OUT/RUNINFO.txt"
echo "TRACE=$TRACE ($(wc -l < "$TRACE") reqs)" | tee -a "$OUT/RUNINFO.txt"
date | tee -a "$OUT/RUNINFO.txt"
run_arm() { # name policy extra_args
local name="$1" policy="$2" extra="$3"
echo "===== ARM: $name (policy=$policy args='$extra') =====" | tee -a "$OUT/RUNINFO.txt"
local t0=$(date +%s)
EXTRA_PROXY_ARGS="$extra" bash "$ROOT/scripts/b3_isolated_policy.sh" \
"$policy" "$TRACE" "$OUT/$name" > "$OUT/$name.log" 2>&1
echo "$name rc=$? rows=$(wc -l < "$OUT/$name/metrics.jsonl" 2>/dev/null) dur=$(( $(date +%s) - t0 ))s" | tee -a "$OUT/RUNINFO.txt"
}
# Headline contrasts first (so a late failure still leaves the key arms).
run_arm leastwork leastwork ""
run_arm unified_ab unified "--overload-factor 1.3 --lmetric-decode-weight 0.01"
run_arm unified_def unified "--overload-factor 2.0 --lmetric-decode-weight 0.0"
run_arm lmetric lmetric ""
run_arm sticky sticky ""
echo "===== ALL DONE: $OUT =====" | tee -a "$OUT/RUNINFO.txt"
date | tee -a "$OUT/RUNINFO.txt"

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# 5-policy comparison in BOTH dispatch modes on the SAME ttp-annotated trace,
# so the only variable is dispatch-mode (tracets vs thinktime). Fresh vLLM
# (cold APC) per arm via run_5policy_600s.sh -> b3_isolated_policy.sh.
set -uo pipefail
ROOT="${ROOT:-/home/admin/cpfs/wjh/agentic-kv}"
TRACE_FILE="${TRACE_FILE:-$ROOT/traces/w600_r0.0015_st30_first600s_ttp.jsonl}"
RUN5="$ROOT/microbench/connector_tax/cache_sweep/run_5policy_600s.sh"
DATE="$(date +%Y%m%d_%H%M)"
echo "=== 5policy x {tracets,thinktime} | trace=$(basename "$TRACE_FILE") | $DATE ==="
for MODE in tracets thinktime; do
OUT="$ROOT/outputs/policy5_600s_${MODE}_${DATE}"
echo "############ MODE=$MODE OUT=$OUT $(date) ############"
TRACE="$TRACE_FILE" REPLAY_DISPATCH_MODE="$MODE" OUTROOT="$OUT" \
bash "$RUN5"
echo "dispatch_mode=$MODE" >> "$OUT/RUNINFO.txt"
echo "trace=$TRACE_FILE" >> "$OUT/RUNINFO.txt"
done
echo "=== ALL DONE (both modes) $(date) ==="

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# B3 routing-policy reproducibility re-test.
#
# Re-runs the 5 routing policies from fig_b3_latency_bars.png on the same
# trace, in a single same-day session, to check whether the ordering
# (unified < load_only < sticky etc.) still holds today.
#
# Policies (in run order):
# lmetric plain — cache-aware P_tokens × BS
# load_only plain — pure min-num_requests
# sticky plain — hard session affinity
# unified plain — hybrid affinity + LMetric fallback
# unified_v2 Mooncake kv_both + selective PD-sep (with DR-fix applied)
#
# unified_v2 is run with VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC=1 so we
# get the "best Mooncake state" we have today (DR-fix on top of the
# already-fixed mainline after e3a1d70 etc.). The other 4 policies don't
# load any connector so the patch is irrelevant.
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
TRACE="${TRACE:-$PROJ_DIR/traces/w600_r0.0015_st30.jsonl}"
DATE="$(date +%Y%m%d_%H%M)"
OUTROOT="${OUTROOT:-$PROJ_DIR/outputs/b3_replay_${DATE}}"
PYTHON="$PROJ_DIR/.venv/bin/python"
DR_FIX_SCRIPT="$PROJ_DIR/microbench/connector_tax/cache_sweep/apply_direct_read_fix.py"
VLLM_ROOT="${VLLM_ROOT:-$PROJ_DIR/.venv/lib/python3.12/site-packages/vllm}"
mkdir -p "$OUTROOT"
echo "=== B3 5-policy re-test ==="
echo "Trace : $TRACE"
echo "Out : $OUTROOT"
echo "Order : lmetric → load_only → sticky → unified → unified_v2 (DR-fix on)"
echo ""
cleanup_all() {
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT" 2>/dev/null || true
}
trap cleanup_all EXIT
cleanup_all
# Apply DR-fix once — it's env-gated so only unified_v2 (with env=1) sees it
echo "[stage 0] applying CT_DR_FIX (env-gated, only activates when VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC=1)"
"$PYTHON" "$DR_FIX_SCRIPT" --apply --vllm-root "$VLLM_ROOT"
run_policy() {
local policy="$1"
local skip_dr="$2"
local rundir="$OUTROOT/$policy"
mkdir -p "$rundir"
echo ""
echo "====== $policy ; DR_SYNC_DISABLED=$skip_dr ======"
if [ "$skip_dr" = "1" ]; then
export VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC=1
else
unset VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC
fi
bash "$PROJ_DIR/scripts/b3_isolated_policy.sh" "$policy" "$TRACE" "$rundir" \
2>&1 | tee "$rundir/orchestrator.log" | tail -30
rc="${PIPESTATUS[0]}"
if [ "$rc" != "0" ]; then
echo "[FAIL] policy $policy rc=$rc"
fi
# Belt-and-braces cleanup between policies
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 10
return 0
}
run_policy "lmetric" "0"
run_policy "load_only" "0"
run_policy "sticky" "0"
run_policy "unified" "0"
run_policy "unified_v2" "1" # uses Mooncake kv_both; activate DR-fix
echo ""
echo "[stage Z] reverting CT_DR_FIX"
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT"
echo ""
echo "Done. Artifacts: $OUTROOT"
for p in lmetric load_only sticky unified unified_v2; do
echo " $p: $OUTROOT/$p/metrics.jsonl"
done

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Smoke test for Nixl-based PD-sep migration (NVLink intra-node via UCX).
#
# Drops 2 vLLM kv_both NixlConnector instances on GPU 0,1 and runs
# smoke_test_migrate_cache.py against them with the kv_transfer_params
# format Nixl expects (only do_remote_decode on src; proxy must forward
# kv_transfer_params from src's response to dst).
#
# Since smoke_test_migrate_cache.py is currently hard-coded for Mooncake
# (transfer_id + remote_bootstrap_addr), we use a tiny Python in-line
# variant here that does the Nixl response-forward handshake directly.
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
MODEL="${MODEL:-/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct}"
VENV="$PROJ_DIR/.venv/bin"
LOGS_DIR="${LOGS_DIR:-$PROJ_DIR/outputs/smoke_nixl_$(date +%Y%m%d_%H%M%S)}"
mkdir -p "$LOGS_DIR"
cleanup() {
echo "[smoke-nixl] cleaning up vLLM..."
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 2
}
trap cleanup EXIT
cleanup
echo "[smoke-nixl] starting 2 vLLM kv_both NixlConnector on GPU 0,1"
for i in 0 1; do
port=$((8000 + i))
nixl_port=$((5600 + i))
master=$((29500 + i))
PYTHONHASHSEED=42 \
VLLM_NIXL_SIDE_CHANNEL_PORT=$nixl_port \
CUDA_VISIBLE_DEVICES=$i \
MASTER_PORT=$master \
nohup "$VENV/vllm" serve "$MODEL" \
--host 0.0.0.0 --port "$port" \
--tensor-parallel-size 1 \
--trust-remote-code --enable-prefix-caching \
--dtype auto --gpu-memory-utilization 0.9 \
--max-model-len 200000 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both"}' \
--enable-prompt-tokens-details \
> "$LOGS_DIR/vllm_inst_${i}_gpu${i}.log" 2>&1 &
disown
sleep 2
done
echo "[smoke-nixl] waiting for health on 8000 and 8001 ..."
for port in 8000 8001; do
tries=0
while ! curl -sf "http://127.0.0.1:$port/health" >/dev/null 2>&1; do
tries=$((tries+1))
if [ $tries -gt 240 ]; then
echo "[smoke-nixl] FATAL: $port not ready"; exit 1
fi
sleep 2
done
echo " port=$port ready"
done
echo "[smoke-nixl] running smoke_nixl_migrate.py"
"$VENV/python" "$PROJ_DIR/microbench/connector_tax/cache_sweep/smoke_nixl_migrate.py" \
--src-port 8000 --dst-port 8001 \
${EXTRA_SMOKE_ARGS:-} \
2>&1 | tee "$LOGS_DIR/smoke_output.log"
ec=${PIPESTATUS[0]}
echo "[smoke-nixl] test exit=$ec, logs at $LOGS_DIR"
exit $ec

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Smoke test for Mechanism B (partial KV transfer):
# Start 3 vLLM kv_both Mooncake instances on GPU 0,1,2:
# - inst_0 = src (port 8000, bp 8998)
# - inst_1 = dst_warm (port 8001, bp 8999) — will be pre-warmed
# - inst_2 = dst_cold (port 8002, bp 9000) — control, no cache
#
# Then run smoke_partial_transfer.py which migrates the same prompt
# to both warm and cold dst, comparing transfer cost.
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
MODEL="${MODEL:-/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct}"
VENV="$PROJ_DIR/.venv/bin"
LOGS_DIR="${LOGS_DIR:-$PROJ_DIR/outputs/smoke_partial_$(date +%Y%m%d_%H%M%S)}"
mkdir -p "$LOGS_DIR"
cleanup() {
echo "[smoke-partial] cleaning up vLLM..."
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 2
}
trap cleanup EXIT
cleanup
echo "[smoke-partial] starting 3 vLLM kv_both Mooncake on GPU 0,1,2"
for i in 0 1 2; do
port=$((8000 + i))
bp=$((8998 + i))
master=$((29500 + i))
PYTHONHASHSEED=42 \
VLLM_MOONCAKE_BOOTSTRAP_PORT=$bp \
CUDA_VISIBLE_DEVICES=$i \
MASTER_PORT=$master \
nohup "$VENV/vllm" serve "$MODEL" \
--host 0.0.0.0 --port "$port" \
--tensor-parallel-size 1 \
--trust-remote-code --enable-prefix-caching \
--dtype auto --gpu-memory-utilization 0.9 \
--max-model-len 200000 \
--kv-transfer-config '{"kv_connector":"MooncakeConnector","kv_role":"kv_both"}' \
--enable-prompt-tokens-details \
> "$LOGS_DIR/vllm_inst_${i}_gpu${i}.log" 2>&1 &
disown
sleep 2
done
echo "[smoke-partial] waiting for health ..."
for port in 8000 8001 8002; do
tries=0
while ! curl -sf "http://127.0.0.1:$port/health" >/dev/null 2>&1; do
tries=$((tries+1))
if [ $tries -gt 240 ]; then echo "FATAL: $port"; exit 1; fi
sleep 2
done
echo " port=$port ready"
done
echo "[smoke-partial] running smoke_partial_transfer.py"
"$VENV/python" "$PROJ_DIR/microbench/connector_tax/cache_sweep/smoke_partial_transfer.py" \
${EXTRA_SMOKE_ARGS:-} \
2>&1 | tee "$LOGS_DIR/smoke_output.log"
ec=${PIPESTATUS[0]}
echo "[smoke-partial] exit=$ec, logs at $LOGS_DIR"
exit $ec

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Single vLLM warmup, multiple smoke-test iterations under varying load.
#
# Each iteration uses a distinct --prefix-base to avoid prefix-cache pollution
# from prior iterations. We sweep noise levels 0, 8, 32, 64 to see at which
# point the migration cache becomes invisible to the follow-up.
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
MODEL="${MODEL:-/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct}"
VENV="$PROJ_DIR/.venv/bin"
LOGS_DIR="${LOGS_DIR:-$PROJ_DIR/outputs/smoke_sweep_$(date +%Y%m%d_%H%M%S)}"
mkdir -p "$LOGS_DIR"
cleanup() {
echo "[sweep] cleaning up vLLM..."
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 2
}
trap cleanup EXIT
cleanup
echo "[sweep] starting 2 vLLM kv_both on GPU 0,1"
for i in 0 1; do
port=$((8000 + i))
bp=$((8998 + i))
master=$((29500 + i))
PYTHONHASHSEED=42 \
VLLM_MOONCAKE_BOOTSTRAP_PORT=$bp \
CUDA_VISIBLE_DEVICES=$i \
MASTER_PORT=$master \
nohup "$VENV/vllm" serve "$MODEL" \
--host 0.0.0.0 --port "$port" \
--tensor-parallel-size 1 \
--trust-remote-code --enable-prefix-caching \
--dtype auto --gpu-memory-utilization 0.9 \
--max-model-len 200000 \
--kv-transfer-config '{"kv_connector":"MooncakeConnector","kv_role":"kv_both"}' \
--enable-prompt-tokens-details \
> "$LOGS_DIR/vllm_inst_${i}_gpu${i}.log" 2>&1 &
disown
sleep 2
done
echo "[sweep] waiting for health ..."
for port in 8000 8001; do
tries=0
while ! curl -sf "http://127.0.0.1:$port/health" >/dev/null 2>&1; do
tries=$((tries+1))
if [ $tries -gt 180 ]; then echo "[sweep] FATAL: $port not ready"; exit 1; fi
sleep 2
done
echo " port=$port ready"
done
base=100
for noise in 0 8 32 64 128; do
echo ""
echo "============================================"
echo "[sweep] iteration noise=$noise prefix_base=$base"
echo "============================================"
"$VENV/python" "$PROJ_DIR/microbench/connector_tax/cache_sweep/smoke_test_migrate_cache.py" \
--src-port 8000 --dst-port 8001 \
--src-bp 8998 --dst-bp 8999 \
--noise-reqs "$noise" \
--prefix-base "$base" \
2>&1 | tee "$LOGS_DIR/iter_noise${noise}.log" | tail -25
base=$((base + 100000))
done
echo ""
echo "[sweep] all iterations done; logs in $LOGS_DIR"

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Fast iteration: start 2 vLLM kv_both, run smoke_test_migrate_cache, tear down.
#
# Usage: bash run_smoke_test.sh [WAIT_BETWEEN_S]
#
# Iteration overhead: ~3-4 min warmup + a few sec for the test. Cleanly
# tears everything down on exit so you can re-run repeatedly.
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
MODEL="${MODEL:-/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct}"
VENV="$PROJ_DIR/.venv/bin"
LOGS_DIR="${LOGS_DIR:-$PROJ_DIR/outputs/smoke_test_$(date +%Y%m%d_%H%M%S)}"
mkdir -p "$LOGS_DIR"
# MOONCAKE_PROTOCOL controls Mooncake's C++ TransferEngine transport.
# Options exposed: rdma (default), tcp, nvlink_intra (NVLink intra-node).
PROTO="${MOONCAKE_PROTOCOL:-rdma}"
echo "[smoke] using Mooncake protocol: $PROTO"
cleanup() {
echo "[smoke] cleaning up vLLM..."
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 2
}
trap cleanup EXIT
cleanup
echo "[smoke] starting 2 vLLM kv_both on GPU 0,1"
for i in 0 1; do
port=$((8000 + i))
bp=$((8998 + i))
master=$((29500 + i))
PYTHONHASHSEED=42 \
VLLM_MOONCAKE_BOOTSTRAP_PORT=$bp \
CUDA_VISIBLE_DEVICES=$i \
MASTER_PORT=$master \
nohup "$VENV/vllm" serve "$MODEL" \
--host 0.0.0.0 --port "$port" \
--tensor-parallel-size 1 \
--trust-remote-code --enable-prefix-caching \
--dtype auto --gpu-memory-utilization 0.9 \
--max-model-len 200000 \
--kv-transfer-config "{\"kv_connector\":\"MooncakeConnector\",\"kv_role\":\"kv_both\",\"kv_connector_extra_config\":{\"mooncake_protocol\":\"$PROTO\"}}" \
--enable-prompt-tokens-details \
> "$LOGS_DIR/vllm_inst_${i}_gpu${i}.log" 2>&1 &
disown
sleep 2
done
echo "[smoke] waiting for health on 8000 and 8001 ..."
for port in 8000 8001; do
tries=0
while ! curl -sf "http://127.0.0.1:$port/health" >/dev/null 2>&1; do
tries=$((tries+1))
if [ $tries -gt 180 ]; then
echo "[smoke] FATAL: $port not ready"; exit 1
fi
sleep 2
done
echo " port=$port ready"
done
echo "[smoke] running migration smoke test"
"$VENV/python" "$PROJ_DIR/microbench/connector_tax/cache_sweep/smoke_test_migrate_cache.py" \
--src-port 8000 --dst-port 8001 \
--src-bp 8998 --dst-bp 8999 \
${EXTRA_SMOKE_ARGS:-} \
2>&1 | tee "$LOGS_DIR/smoke_output.log"
ec=${PIPESTATUS[0]}
echo "[smoke] test exit=$ec, logs at $LOGS_DIR"
exit $ec

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Single-policy trace replay for unified, with tunable overload-factor.
# Used to test direction A: does tightening affinity overflow improve unified?
#
# Usage:
# OVERLOAD_FACTOR=1.3 bash run_unified_ablation.sh
# OVERLOAD_FACTOR=1.0 bash run_unified_ablation.sh
#
# Output: $PROJ_DIR/outputs/unified_of${OF}_${DATE}/unified/
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
TRACE="${TRACE:-$PROJ_DIR/traces/w600_r0.0015_st30.jsonl}"
OF="${OVERLOAD_FACTOR:-1.3}"
LMW="${LMETRIC_DECODE_WEIGHT:-0.0}"
TAG_DEFAULT="of${OF/./}"
if [ "$(printf '%s' "$LMW" | grep -v '^0\.\?0*$' || true)" != "" ]; then
TAG_DEFAULT="${TAG_DEFAULT}_lmw${LMW/./}"
fi
TAG="${TAG:-$TAG_DEFAULT}"
DATE="$(date +%Y%m%d_%H%M)"
OUTROOT="${OUTROOT:-$PROJ_DIR/outputs/unified_${TAG}_${DATE}}"
mkdir -p "$OUTROOT"
echo "=== unified ablation: overload_factor=$OF ==="
echo "Trace : $TRACE"
echo "Out : $OUTROOT"
echo ""
cleanup() {
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 3
}
trap cleanup EXIT
cleanup
cfg_dir="$OUTROOT/unified"
mkdir -p "$cfg_dir"
export EXTRA_PROXY_ARGS="--overload-factor $OF --lmetric-decode-weight $LMW"
echo ""
echo "====== unified ; overload_factor=$OF lmetric_decode_weight=$LMW ======"
bash "$PROJ_DIR/scripts/b3_isolated_policy.sh" "unified" "$TRACE" "$cfg_dir" \
2>&1 | tee "$cfg_dir/orchestrator.log" | tail -30
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
echo ""
echo "Done. Artifacts: $OUTROOT/unified/metrics.jsonl"

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# v3 trace replay with dst-side migration breakdown instrumentation.
#
# Same trace + DR_FIX as `run_v3_replay.sh`, plus:
# - instrument_dst_migration.py applied to vLLM scheduler
# - DM_LOG_DIR exported to all 8 vLLM instances so per-PID
# dst-migration logs land in <RUNDIR>/dst_mig_log/
# - analyze_dst_migration.py runs on completion to print the
# T_kv_pull vs queue-residual decomposition
#
# Usage: bash run_v3_dst_breakdown.sh
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
TRACE="${TRACE:-$PROJ_DIR/traces/w600_r0.0015_st30.jsonl}"
DATE="$(date +%Y%m%d_%H%M)"
OUTROOT="${OUTROOT:-$PROJ_DIR/outputs/b3_v3_dstbreak_${DATE}}"
PYTHON="$PROJ_DIR/.venv/bin/python"
VLLM_ROOT="${VLLM_ROOT:-$PROJ_DIR/.venv/lib/python3.12/site-packages/vllm}"
DR_FIX_SCRIPT="$PROJ_DIR/microbench/connector_tax/cache_sweep/apply_direct_read_fix.py"
DM_INSTRUMENT="$PROJ_DIR/microbench/fresh_setup/instrument_dst_migration.py"
ANALYZE="$PROJ_DIR/microbench/connector_tax/cache_sweep/analyze_dst_migration.py"
mkdir -p "$OUTROOT"
DST_LOG_DIR="$OUTROOT/dst_mig_log"
mkdir -p "$DST_LOG_DIR"
echo "=== unified_v3 + dst-side migration breakdown ==="
echo "Trace : $TRACE"
echo "Out : $OUTROOT"
echo "DST logs : $DST_LOG_DIR"
echo ""
cleanup_all() {
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT" 2>/dev/null || true
"$PYTHON" "$DM_INSTRUMENT" --revert --venv "$PROJ_DIR/.venv" 2>/dev/null || true
}
trap cleanup_all EXIT
cleanup_all
echo "[stage 0a] applying CT_DR_FIX (env-gated)"
"$PYTHON" "$DR_FIX_SCRIPT" --apply --vllm-root "$VLLM_ROOT"
echo "[stage 0b] applying DST migration instrumentation"
"$PYTHON" "$DM_INSTRUMENT" --apply --venv "$PROJ_DIR/.venv"
"$PYTHON" "$DM_INSTRUMENT" --check --venv "$PROJ_DIR/.venv"
cfg_dir="$OUTROOT/unified_v3"
mkdir -p "$cfg_dir"
# Activate DR-fix env gate (consistent with run_v3_replay.sh)
export VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC=1
# Export DM_LOG_DIR — every vLLM EngineCore inherits this env and writes
# its own dm_mig_pid<pid>.jsonl into it.
export DM_LOG_DIR="$DST_LOG_DIR"
echo ""
echo "====== unified_v3 ; DR_SYNC_DISABLED=1 ; DM_LOG_DIR=$DST_LOG_DIR ======"
bash "$PROJ_DIR/scripts/b3_isolated_policy.sh" "unified_v3" "$TRACE" "$cfg_dir" \
2>&1 | tee "$cfg_dir/orchestrator.log" | tail -30
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
echo ""
echo "[stage Z] reverting DR_FIX + DM instrument"
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT"
"$PYTHON" "$DM_INSTRUMENT" --revert --venv "$PROJ_DIR/.venv"
echo ""
echo "[stage analyze] dst-side migration breakdown"
"$PYTHON" "$ANALYZE" \
--proxy-breakdown "$cfg_dir/breakdown.json" \
--dst-log-dir "$DST_LOG_DIR" \
--output "$cfg_dir/dst_migration_breakdown.csv" \
--plot "$cfg_dir/dst_migration_breakdown.png" \
2>&1 | tee "$cfg_dir/dst_migration_breakdown.txt"
echo ""
echo "Done."
echo " proxy breakdown : $cfg_dir/breakdown.json"
echo " dst per-PID log : $DST_LOG_DIR/"
echo " decomposition : $cfg_dir/dst_migration_breakdown.{csv,png,txt}"

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# v3 trace replay with FULL migration instrumentation:
# - instrument_dst_migration.py : dst lifecycle -> T_kv_pull
# - instrument_mooncake.py : connector internals (send_blocks RDMA,
# receive_kv window, ready_wait)
# Goal: decompose the 87% T_kv_pull into RDMA-actual vs control-plane to
# explain why effective bandwidth is far below the ~10 GB/s wire rate.
#
# Usage: bash run_v3_full_breakdown.sh
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
TRACE="${TRACE:-$PROJ_DIR/traces/w600_r0.0015_st30.jsonl}"
DATE="$(date +%Y%m%d_%H%M)"
OUTROOT="${OUTROOT:-$PROJ_DIR/outputs/b3_v3_fullbreak_${DATE}}"
PYTHON="$PROJ_DIR/.venv/bin/python"
VENV="$PROJ_DIR/.venv"
VLLM_ROOT="${VLLM_ROOT:-$VENV/lib/python3.12/site-packages/vllm}"
DR_FIX="$PROJ_DIR/microbench/connector_tax/cache_sweep/apply_direct_read_fix.py"
DM_INSTR="$PROJ_DIR/microbench/fresh_setup/instrument_dst_migration.py"
MC_INSTR="$PROJ_DIR/microbench/fresh_setup/instrument_mooncake.py"
ANALYZE_DST="$PROJ_DIR/microbench/connector_tax/cache_sweep/analyze_dst_migration.py"
ANALYZE_XFER="$PROJ_DIR/microbench/connector_tax/cache_sweep/analyze_transfer_decomp.py"
mkdir -p "$OUTROOT"
DST_LOG_DIR="$OUTROOT/dst_mig_log"
XFER_LOG_DIR="$OUTROOT/xfer_log"
mkdir -p "$DST_LOG_DIR" "$XFER_LOG_DIR"
echo "=== unified_v3 + FULL migration breakdown ==="
echo "Out : $OUTROOT"
echo "DST logs : $DST_LOG_DIR"
echo "XFER logs: $XFER_LOG_DIR"
echo ""
cleanup_all() {
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
"$PYTHON" "$DR_FIX" --revert --vllm-root "$VLLM_ROOT" 2>/dev/null || true
"$PYTHON" "$DM_INSTR" --revert --venv "$VENV" 2>/dev/null || true
"$PYTHON" "$MC_INSTR" --revert --venv "$VLLM_ROOT/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py" 2>/dev/null || true
}
trap cleanup_all EXIT
cleanup_all
echo "[0a] DR_FIX"
"$PYTHON" "$DR_FIX" --apply --vllm-root "$VLLM_ROOT"
echo "[0b] DST migration instrument"
"$PYTHON" "$DM_INSTR" --apply --venv "$VENV"
echo "[0c] Mooncake transfer instrument"
"$PYTHON" "$MC_INSTR" --apply --venv "$VLLM_ROOT/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py"
"$PYTHON" "$DM_INSTR" --check --venv "$VENV"
"$PYTHON" "$MC_INSTR" --check --venv "$VLLM_ROOT/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py"
cfg_dir="$OUTROOT/unified_v3"
mkdir -p "$cfg_dir"
export VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC=1
export DM_LOG_DIR="$DST_LOG_DIR"
export MB2_LOG_DIR="$XFER_LOG_DIR"
echo ""
echo "====== unified_v3 ; DM_LOG_DIR + MB2_LOG_DIR set ======"
bash "$PROJ_DIR/scripts/b3_isolated_policy.sh" "unified_v3" "$TRACE" "$cfg_dir" \
2>&1 | tee "$cfg_dir/orchestrator.log" | tail -25
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
echo ""
echo "[Z] revert all instruments"
"$PYTHON" "$DR_FIX" --revert --vllm-root "$VLLM_ROOT"
"$PYTHON" "$DM_INSTR" --revert --venv "$VENV"
"$PYTHON" "$MC_INSTR" --revert --venv "$VLLM_ROOT/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py"
echo ""
echo "[analyze 1] dst-side T_kv_pull breakdown"
"$PYTHON" "$ANALYZE_DST" \
--proxy-breakdown "$cfg_dir/breakdown.json" \
--dst-log-dir "$DST_LOG_DIR" \
--output "$cfg_dir/dst_migration_breakdown.csv" \
--plot "$cfg_dir/dst_migration_breakdown.png" \
2>&1 | tee "$cfg_dir/dst_migration_breakdown.txt" || echo "(dst analyze failed)"
echo ""
echo "[analyze 2] transfer decomposition: RDMA-actual vs control-plane"
"$PYTHON" "$ANALYZE_XFER" \
--proxy-breakdown "$cfg_dir/breakdown.json" \
--dst-log-dir "$DST_LOG_DIR" \
--xfer-log-dir "$XFER_LOG_DIR" \
2>&1 | tee "$cfg_dir/transfer_decomp.txt" || echo "(xfer analyze failed)"
echo ""
echo "Done. Artifacts in $cfg_dir/"
echo " dst_migration_breakdown.{csv,png,txt}"
echo " transfer_decomp.txt"
echo " raw: $DST_LOG_DIR/ $XFER_LOG_DIR/"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Trace replay for unified_v3 WITHOUT affinity rotation.
#
# This is the #2 follow-up to cache_miss_audit: prior run showed v3 with
# rotation hits 9.5% cache on post-migration next turn vs 80.6% for unified.
# Hypothesis: rotation destroys prefix cache locality. Test by keeping the
# session affinity on prefill_host even after migration (i.e., the same
# behavior as unified for the post-migration write), so only the *current*
# turn's decode is migrated.
#
# Applies CT_DR_FIX (Mooncake DR sync disabled).
# Output: $PROJ_DIR/outputs/b3_v3_norot_${DATE}/unified_v3/
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
TRACE="${TRACE:-$PROJ_DIR/traces/w600_r0.0015_st30.jsonl}"
DATE="$(date +%Y%m%d_%H%M)"
OUTROOT="${OUTROOT:-$PROJ_DIR/outputs/b3_v3_norot_${DATE}}"
PYTHON="$PROJ_DIR/.venv/bin/python"
DR_FIX_SCRIPT="$PROJ_DIR/microbench/connector_tax/cache_sweep/apply_direct_read_fix.py"
VLLM_ROOT="${VLLM_ROOT:-$PROJ_DIR/.venv/lib/python3.12/site-packages/vllm}"
mkdir -p "$OUTROOT"
echo "=== unified_v3 (no rotation) trace replay ==="
echo "Trace : $TRACE"
echo "Out : $OUTROOT"
echo ""
cleanup_all() {
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT" 2>/dev/null || true
}
trap cleanup_all EXIT
cleanup_all
echo "[stage 0] applying CT_DR_FIX (env-gated)"
"$PYTHON" "$DR_FIX_SCRIPT" --apply --vllm-root "$VLLM_ROOT"
cfg_dir="$OUTROOT/unified_v3"
mkdir -p "$cfg_dir"
export VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC=1
export EXTRA_PROXY_ARGS="--v3-rotate-affinity 0"
echo ""
echo "====== unified_v3 (no rotation) ; DR_SYNC_DISABLED=1 ======"
bash "$PROJ_DIR/scripts/b3_isolated_policy.sh" "unified_v3" "$TRACE" "$cfg_dir" \
2>&1 | tee "$cfg_dir/orchestrator.log" | tail -30
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
echo ""
echo "[stage Z] reverting CT_DR_FIX"
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT"
echo ""
echo "Done. Artifacts: $OUTROOT/unified_v3/metrics.jsonl"

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Trace replay for the new unified_v3 (offload-decode) policy.
#
# Runs the same trace as run_b3_replay.sh on a single policy:
# unified_v3 — prefill on session-affinity host (uses prefix cache),
# decode migrated to a low-load target via Mooncake
# KV transfer (kv_role=kv_both). Session affinity rotates
# to decode_target after migration so next turn lands
# where the KV now lives.
#
# Applies CT_DR_FIX so the run uses the "best Mooncake state" we have
# today (post-e3a1d70 + DR sync skipped).
#
# Usage: bash run_v3_replay.sh
set -uo pipefail
PROJ_DIR="${PROJ_DIR:-/home/admin/cpfs/wjh/agentic-kv}"
TRACE="${TRACE:-$PROJ_DIR/traces/w600_r0.0015_st30.jsonl}"
DATE="$(date +%Y%m%d_%H%M)"
OUTROOT="${OUTROOT:-$PROJ_DIR/outputs/b3_v3_${DATE}}"
PYTHON="$PROJ_DIR/.venv/bin/python"
DR_FIX_SCRIPT="$PROJ_DIR/microbench/connector_tax/cache_sweep/apply_direct_read_fix.py"
VLLM_ROOT="${VLLM_ROOT:-$PROJ_DIR/.venv/lib/python3.12/site-packages/vllm}"
mkdir -p "$OUTROOT"
echo "=== unified_v3 (offload-decode) trace replay ==="
echo "Trace : $TRACE"
echo "Out : $OUTROOT"
echo ""
cleanup_all() {
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT" 2>/dev/null || true
}
trap cleanup_all EXIT
cleanup_all
echo "[stage 0] applying CT_DR_FIX (env-gated)"
"$PYTHON" "$DR_FIX_SCRIPT" --apply --vllm-root "$VLLM_ROOT"
cfg_dir="$OUTROOT/unified_v3"
mkdir -p "$cfg_dir"
# Activate the DR-fix env-gate (unified_v3 uses Mooncake kv_both)
export VLLM_MOONCAKE_DISABLE_DIRECT_READ_SYNC=1
echo ""
echo "====== unified_v3 ; DR_SYNC_DISABLED=1 ======"
bash "$PROJ_DIR/scripts/b3_isolated_policy.sh" "unified_v3" "$TRACE" "$cfg_dir" \
2>&1 | tee "$cfg_dir/orchestrator.log" | tail -30
pkill -9 -f cache_aware_proxy 2>/dev/null || true
pkill -9 -f "vllm serve" 2>/dev/null || true
pkill -9 -f "EngineCore" 2>/dev/null || true
sleep 5
echo ""
echo "[stage Z] reverting CT_DR_FIX"
"$PYTHON" "$DR_FIX_SCRIPT" --revert --vllm-root "$VLLM_ROOT"
echo ""
echo "Done. Artifacts: $OUTROOT/unified_v3/metrics.jsonl"

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Smoke test for Nixl PD-sep migration.
Nixl handshake (vs Mooncake's pre-baked engine_id):
1. POST to src with kv_transfer_params={"do_remote_decode": True},
max_tokens=1, stream=False.
2. src returns kv_transfer_params in the response body containing
remote_block_ids, remote_engine_id, remote_host, remote_port,
remote_request_id, tp_size.
3. POST to dst with the SAME kv_transfer_params dict.
4. dst pulls KV via UCX (NVLink intra-node) and decodes.
Verifies migration correctness + measures KV transfer latency on Nixl
so we can ablate vs Mooncake/RDMA on the same workload.
"""
import asyncio, argparse, json, sys, uuid, time
import httpx
import random as _r
MODEL = "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct"
async def send(client, port, prompt, max_tokens, kv_xfer, stream):
payload = {
"model": MODEL,
"prompt": prompt,
"max_tokens": max_tokens,
"min_tokens": max_tokens if max_tokens == 1 else 1,
"temperature": 0.0,
"stream": stream,
}
if kv_xfer is not None:
payload["kv_transfer_params"] = kv_xfer
if stream:
payload["stream_options"] = {"include_usage": True}
url = f"http://127.0.0.1:{port}/v1/completions"
if not stream:
r = await client.post(url, json=payload, timeout=300.0)
r.raise_for_status()
return r.json()
last_with_usage = None; last_any = None
async with client.stream("POST", url, json=payload, timeout=300.0) as resp:
resp.raise_for_status()
buf = ""
async for chunk in resp.aiter_bytes():
buf += chunk.decode("utf-8", errors="replace")
while "\n\n" in buf:
line, buf = buf.split("\n\n", 1)
if line.startswith("data: "):
s = line[6:].strip()
if s == "[DONE]": continue
try:
d = json.loads(s); last_any = d
if d.get("usage"): last_with_usage = d
except Exception:
pass
return last_with_usage or last_any or {}
def short(d):
if not d: return "no_resp"
usage = d.get("usage") or {}
details = usage.get("prompt_tokens_details") or {}
cached = details.get("cached_tokens", 0) or usage.get("cached_tokens", 0)
return (f"cached={cached}/{usage.get('prompt_tokens',0)} "
f"completion={usage.get('completion_tokens',0)}")
async def main():
p = argparse.ArgumentParser()
p.add_argument("--src-port", type=int, default=8000)
p.add_argument("--dst-port", type=int, default=8001)
p.add_argument("--n-prefix-tokens", type=int, default=8192)
p.add_argument("--n-extension", type=int, default=32)
p.add_argument("--decode-tokens", type=int, default=16)
p.add_argument("--prefix-base", type=int, default=100)
args = p.parse_args()
rng = _r.Random(f"prefix-{args.prefix_base}")
prompt = [rng.randint(1024, 99_999) for _ in range(args.n_prefix_tokens)]
async with httpx.AsyncClient() as client:
# ----- Step 1: src prefills with do_remote_decode=True -----
t_src_start = time.monotonic()
src_resp = await send(
client, args.src_port, prompt, max_tokens=1,
kv_xfer={"do_remote_decode": True}, stream=False,
)
t_src_done = time.monotonic()
src_kv = src_resp.get("kv_transfer_params")
print(f"[1] src prefill ({(t_src_done-t_src_start)*1000:.0f}ms): {short(src_resp)}")
if not src_kv:
print(f" FAIL: src returned no kv_transfer_params")
print(f" response keys: {list(src_resp.keys())}")
sys.exit(1)
print(f" src kv_transfer_params keys: {list(src_kv.keys())}")
print(f" remote_block_ids: {len(src_kv.get('remote_block_ids', [[]])[0]) if src_kv.get('remote_block_ids') else 0} blocks")
# ----- Step 2: dst pulls KV using forwarded kv_transfer_params -----
t_dst_start = time.monotonic()
dst_resp = await send(
client, args.dst_port, prompt, max_tokens=args.decode_tokens,
kv_xfer=src_kv, stream=True,
)
t_dst_done = time.monotonic()
dst_total_ms = (t_dst_done - t_dst_start) * 1000
n_completion = (dst_resp.get("usage") or {}).get("completion_tokens", 0)
print(f"[2] dst decode ({dst_total_ms:.0f}ms, {n_completion} completion tokens): {short(dst_resp)}")
print(f" [TIMING] proto=nixl src_prefill={int((t_src_done-t_src_start)*1000)}ms "
f"dst_total={int(dst_total_ms)}ms (KV xfer + {n_completion}-token decode)")
print()
# ----- Step 3: follow-up on dst (no kv_transfer_params) -----
ext = [_r.Random(f"ext-{args.prefix_base}").randint(1024, 99_999)
for _ in range(args.n_extension)]
follow_prompt = prompt + ext
fu = await send(client, args.dst_port, follow_prompt, max_tokens=4,
kv_xfer=None, stream=False)
print(f"[3] follow-up dst (cache hit test): {short(fu)}")
usage_fu = fu.get("usage") or {}
details_fu = usage_fu.get("prompt_tokens_details") or {}
cached_fu = details_fu.get("cached_tokens", 0) or usage_fu.get("cached_tokens", 0)
expected_min = int(args.n_prefix_tokens * 0.95)
verdict = "PASS" if cached_fu >= expected_min else "FAIL"
print(f"\n=== verdict: {verdict} (follow-up cached={cached_fu}, "
f"expected >= {expected_min} of {args.n_prefix_tokens}) ===")
sys.exit(0 if verdict == "PASS" else 1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Smoke test for partial KV transfer (Mechanism B).
Test if vLLM's Mooncake connector actually transfers only the
NON-OVERLAPPING portion when dst already has prefix cache.
Sequence:
step 0: warm dst with prompt P (cold prefill) — dst now has cache for [0, P]
step 1: cold prefill on src with prompt P+ext (src now has [0, P+ext])
step 2: migrate src→dst with prompt P+ext
- dst local cache should hit [0, P]
- only [P, P+ext] needs to come from src (~ext tokens)
- dst decode should be fast
step 3: control: another migrate src→dst_cold with prompt P+ext
- dst_cold has no cache, must pull all P+ext tokens
- compare with step 2
If step 2 is dramatically faster than step 3, partial transfer works
and Mechanism B is viable. If step 2 ~= step 3, partial transfer isn't
being exploited and we need to dig deeper.
"""
import asyncio, argparse, json, sys, uuid, time
import httpx
import random as _r
MODEL = "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct"
async def send(client, port, prompt, max_tokens, kv_xfer, stream):
payload = {
"model": MODEL,
"prompt": prompt,
"max_tokens": max_tokens,
"min_tokens": max_tokens if max_tokens == 1 else 1,
"temperature": 0.0,
"stream": stream,
}
if kv_xfer is not None:
payload["kv_transfer_params"] = kv_xfer
if stream:
payload["stream_options"] = {"include_usage": True}
url = f"http://127.0.0.1:{port}/v1/completions"
if not stream:
r = await client.post(url, json=payload, timeout=300.0)
r.raise_for_status()
return r.json()
last_w = None; last_any = None
async with client.stream("POST", url, json=payload, timeout=300.0) as resp:
resp.raise_for_status()
buf = ""
async for chunk in resp.aiter_bytes():
buf += chunk.decode("utf-8", errors="replace")
while "\n\n" in buf:
line, buf = buf.split("\n\n", 1)
if line.startswith("data: "):
s = line[6:].strip()
if s == "[DONE]": continue
try:
d = json.loads(s); last_any = d
if d.get("usage"): last_w = d
except Exception:
pass
return last_w or last_any or {}
async def get_engine_id(client, bp):
r = await client.get(f"http://127.0.0.1:{bp}/query")
return r.json()["0"]["engine_id"]
def cached_of(d):
usage = d.get("usage") or {}
det = usage.get("prompt_tokens_details") or {}
return det.get("cached_tokens", 0) or usage.get("cached_tokens", 0)
async def do_migration(client, src_port, dst_port, src_bp, prompt, max_tokens, label):
"""Perform Mooncake-style PD-sep migration, returns (src_ms, dst_ms, response)."""
src_id = await get_engine_id(client, src_bp)
transfer_id = f"smoke-xfer-{uuid.uuid4().hex[:8]}"
t0 = time.monotonic()
src_resp = await send(client, src_port, prompt, max_tokens=1,
kv_xfer={"do_remote_decode": True, "do_remote_prefill": False,
"transfer_id": transfer_id}, stream=False)
t1 = time.monotonic()
bootstrap_addr = f"http://127.0.0.1:{src_bp}"
dst_resp = await send(client, dst_port, prompt, max_tokens=max_tokens,
kv_xfer={"do_remote_decode": False, "do_remote_prefill": True,
"remote_bootstrap_addr": bootstrap_addr,
"remote_engine_id": src_id,
"transfer_id": transfer_id}, stream=True)
t2 = time.monotonic()
src_ms = int((t1-t0)*1000); dst_ms = int((t2-t1)*1000)
cached = cached_of(dst_resp)
print(f" [{label}] src_prefill={src_ms}ms dst_total={dst_ms}ms cached={cached}/{len(prompt)}")
return src_ms, dst_ms, dst_resp
async def main():
p = argparse.ArgumentParser()
p.add_argument("--src-port", type=int, default=8000)
p.add_argument("--src-bp", type=int, default=8998)
p.add_argument("--dst-warm-port", type=int, default=8001) # will be pre-warmed
p.add_argument("--dst-warm-bp", type=int, default=8999)
p.add_argument("--dst-cold-port", type=int, default=8002) # cold control
p.add_argument("--dst-cold-bp", type=int, default=9000)
p.add_argument("--prefix-tokens", type=int, default=32768)
p.add_argument("--ext-tokens", type=int, default=512)
args = p.parse_args()
rng = _r.Random("partial-1")
prompt_base = [rng.randint(1024, 99_999) for _ in range(args.prefix_tokens)]
ext_tokens = [_r.Random("ext-partial").randint(1024, 99_999) for _ in range(args.ext_tokens)]
prompt_ext = prompt_base + ext_tokens
async with httpx.AsyncClient() as client:
print(f"\n=== Setup ===")
print(f"Prompt prefix: {args.prefix_tokens} tokens, extension: {args.ext_tokens} tokens")
# Step 0: warm dst_warm with prompt_base (normal request, no kv_transfer)
print(f"\n=== Step 0: warm dst_warm (port {args.dst_warm_port}) with prompt_base ===")
t0 = time.monotonic()
r = await send(client, args.dst_warm_port, prompt_base, max_tokens=1,
kv_xfer=None, stream=False)
t1 = time.monotonic()
print(f" cold prefill on dst_warm: {int((t1-t0)*1000)}ms, cached={cached_of(r)}/{args.prefix_tokens}")
# Sanity: 2nd request to dst_warm hits local cache
print(f"\n=== Sanity: 2nd request to dst_warm with same prompt — should hit local cache ===")
t0 = time.monotonic()
r = await send(client, args.dst_warm_port, prompt_base, max_tokens=1,
kv_xfer=None, stream=False)
t1 = time.monotonic()
print(f" warm request on dst_warm: {int((t1-t0)*1000)}ms, cached={cached_of(r)}/{args.prefix_tokens}")
# Step 1: cold prefill on src with prompt_ext (also caches at src)
print(f"\n=== Step 1: cold prefill on src (port {args.src_port}) with prompt_ext ===")
t0 = time.monotonic()
r = await send(client, args.src_port, prompt_ext, max_tokens=1,
kv_xfer=None, stream=False)
t1 = time.monotonic()
print(f" cold prefill on src: {int((t1-t0)*1000)}ms, cached={cached_of(r)}/{len(prompt_ext)}")
# Step 2: MIGRATE src -> dst_warm (which has cache for prompt_base)
print(f"\n=== Step 2: MIGRATE src -> dst_warm (cache-rich) with prompt_ext ===")
s_ms_warm, d_ms_warm, _ = await do_migration(
client, args.src_port, args.dst_warm_port, args.src_bp,
prompt_ext, max_tokens=4, label="cache-rich dst")
# Step 3: MIGRATE src -> dst_cold (no cache)
print(f"\n=== Step 3: MIGRATE src -> dst_cold (port {args.dst_cold_port}, cold) with prompt_ext ===")
s_ms_cold, d_ms_cold, _ = await do_migration(
client, args.src_port, args.dst_cold_port, args.src_bp,
prompt_ext, max_tokens=4, label="cold dst (control)")
# Verdict
print(f"\n=== VERDICT ===")
print(f"Cache-rich dst (Mechanism B): dst_total={d_ms_warm}ms")
print(f"Cold dst (full transfer): dst_total={d_ms_cold}ms")
speedup = (d_ms_cold - d_ms_warm) / d_ms_cold * 100 if d_ms_cold > 0 else 0
print(f"Δ = {d_ms_cold - d_ms_warm}ms ({speedup:+.1f}% faster with cache-rich dst)")
print(f"Partial transfer {'WORKING' if speedup > 30 else 'NOT working / not exploited'}.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Smoke test: does remote-prefill on dst leave its prefix cache discoverable
to a follow-up turn on the same instance?
Reproducer for the v3 rotation bug observed in unified_v3 (next-turn at
decode_target sees cached_tokens=0 despite migration's `cache_blocks`
supposedly running).
Expects 2 vLLM instances running on 127.0.0.1:8000 and 8001 with
--kv-transfer-config '{"kv_connector":"MooncakeConnector","kv_role":"kv_both"}'
and Mooncake bootstrap servers on 8998 and 8999.
Run flow:
1. Query both bootstrap servers for engine_ids.
2. Send migration: src=8000 do_remote_decode (max_tokens=1), then
dst=8001 do_remote_prefill (pulls KV via Mooncake) with same prompt.
3. Send follow-up: same session prompt + tiny extension, hit 8001
directly (no kv_transfer_params), check cached_tokens.
A working migration with prefix-cache visibility would see ~100% cached
on the follow-up (full prefix hit). The v3 bug shows cached=0.
"""
import asyncio
import argparse
import json
import sys
import uuid
import httpx
async def get_engine_id(client: httpx.AsyncClient, port: int) -> str:
url = f"http://127.0.0.1:{port}/query"
r = await client.get(url)
r.raise_for_status()
data = r.json()
# data = {"0": {"engine_id": "..."}, ...}
return data["0"]["engine_id"]
async def send_completion(
client: httpx.AsyncClient,
host_port: int,
prompt: list[int],
max_tokens: int,
kv_transfer_params: dict | None = None,
stream: bool = False,
) -> dict:
payload = {
"model": "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct",
"prompt": prompt,
"max_tokens": max_tokens,
"min_tokens": max_tokens if max_tokens == 1 else 1,
"temperature": 0.0,
"stream": stream,
}
if stream:
payload["stream_options"] = {"include_usage": True}
if kv_transfer_params:
payload["kv_transfer_params"] = kv_transfer_params
url = f"http://127.0.0.1:{host_port}/v1/completions"
if not stream:
r = await client.post(url, json=payload, timeout=300.0)
r.raise_for_status()
return r.json()
else:
# Stream and collect chunks; keep the LAST chunk that contains
# `usage` (with include_usage, the very last data: chunk has it).
last_with_usage = None
last_any = None
async with client.stream("POST", url, json=payload, timeout=300.0) as resp:
resp.raise_for_status()
buffer = ""
async for chunk in resp.aiter_bytes():
buffer += chunk.decode("utf-8", errors="replace")
while "\n\n" in buffer:
line, buffer = buffer.split("\n\n", 1)
if line.startswith("data: "):
data_str = line[6:].strip()
if data_str == "[DONE]":
continue
try:
d = json.loads(data_str)
last_any = d
if d.get("usage"):
last_with_usage = d
except Exception:
pass
return last_with_usage or last_any or {}
def short(d: dict) -> str:
"""Pull cached_tokens out of the response usage section."""
if not d:
return "no_resp"
usage = d.get("usage") or {}
details = usage.get("prompt_tokens_details") or {}
cached = details.get("cached_tokens", 0) or usage.get("cached_tokens", 0)
return (
f"cached={cached}/{usage.get('prompt_tokens', 0)} "
f"completion={usage.get('completion_tokens', 0)} "
f"id={d.get('id', '?')[:24]}"
)
async def main():
p = argparse.ArgumentParser()
p.add_argument("--src-port", type=int, default=8000)
p.add_argument("--dst-port", type=int, default=8001)
p.add_argument("--src-bp", type=int, default=8998)
p.add_argument("--dst-bp", type=int, default=8999)
p.add_argument("--n-prefix-tokens", type=int, default=8192,
help="Length of synthetic prompt prefix (tokens)")
p.add_argument("--n-extension", type=int, default=32,
help="Tokens added in the follow-up request")
p.add_argument("--noise-reqs", type=int, default=0,
help="Number of unrelated requests to send to dst between "
"migration and follow-up (eviction-pressure test)")
p.add_argument("--noise-tokens", type=int, default=16384,
help="Tokens per noise request")
p.add_argument("--noise-parallel", type=int, default=4,
help="How many noise requests in parallel")
p.add_argument("--prefix-base", type=int, default=100,
help="Token-id base for the prompt prefix (use distinct value "
"across iterations to avoid prefix-cache pollution).")
p.add_argument("--decode-tokens", type=int, default=16,
help="max_tokens on dst decode request")
args = p.parse_args()
async with httpx.AsyncClient() as client:
src_id = await get_engine_id(client, args.src_bp)
dst_id = await get_engine_id(client, args.dst_bp)
print(f"src engine_id = {src_id}")
print(f"dst engine_id = {dst_id}")
print()
# Build a deterministic prompt: a long enough token sequence to be
# multiple blocks. Use simple range to avoid tokenizer dependence.
# Build deterministic prompt using values in safe vocab range [1024, 100000)
# so high prefix-base values don't overflow the tokenizer vocab.
import random as _r
rng = _r.Random(f"prefix-{args.prefix_base}")
prompt = [rng.randint(1024, 99_999) for _ in range(args.n_prefix_tokens)]
# ----- Step 1: Migration. src does prefill (max_tokens=1, no stream),
# then dst pulls KV and decodes. -----
transfer_id = f"smoke-xfer-{uuid.uuid4().hex[:8]}"
print(f"[1] migration: transfer_id={transfer_id}")
# First: cold-prefill on src (no Mooncake yet, just establish src has the KV)
# The proxy convention: src sees do_remote_decode=True so it will SEND its KV
# via Mooncake later. We do this as a single-shot.
import time as _t
t_src_start = _t.monotonic()
src_resp_task = asyncio.create_task(
send_completion(
client, args.src_port, prompt, max_tokens=1,
kv_transfer_params={
"do_remote_decode": True,
"do_remote_prefill": False,
"transfer_id": transfer_id,
},
stream=False,
)
)
# Slight stagger: dst needs to be ready to pull. In production the
# proxy waits for src to finish before sending dst. We'll do the
# same — await src then send dst.
src_resp = await src_resp_task
t_src_done = _t.monotonic()
print(f" src prefill resp ({(t_src_done-t_src_start)*1000:.0f}ms): {short(src_resp)}")
bootstrap_addr = f"http://127.0.0.1:{args.src_bp}"
t_dst_start = _t.monotonic()
dst_resp = await send_completion(
client, args.dst_port, prompt, max_tokens=args.decode_tokens,
kv_transfer_params={
"do_remote_decode": False,
"do_remote_prefill": True,
"remote_bootstrap_addr": bootstrap_addr,
"remote_engine_id": src_id,
"transfer_id": transfer_id,
},
stream=True,
)
t_dst_done = _t.monotonic()
# dst time = KV transfer + decode of N tokens. Subtract approx decode time
# to isolate transfer cost.
usage_d = dst_resp.get("usage") or {}
n_completion = usage_d.get("completion_tokens", 0)
dst_total_ms = (t_dst_done - t_dst_start) * 1000
print(f" dst decode resp ({dst_total_ms:.0f}ms, {n_completion} completion tokens): {short(dst_resp)}")
print(f" [TIMING] proto={args.n_prefix_tokens}p src_prefill={int((t_src_done-t_src_start)*1000)}ms "
f"dst_total={int(dst_total_ms)}ms (KV xfer + {n_completion}-token decode)")
print()
# ----- Step 1.5: Noise. Send unrelated requests to dst to test eviction. -----
if args.noise_reqs > 0:
print(f"[1.5] sending {args.noise_reqs} noise requests "
f"(tokens={args.noise_tokens}, parallel={args.noise_parallel}) to dst")
async def noise(idx):
rng_n = _r.Random(f"noise-{args.prefix_base}-{idx}")
p_n = [rng_n.randint(1024, 99_999) for _ in range(args.noise_tokens)]
return await send_completion(
client, args.dst_port, p_n, max_tokens=1,
kv_transfer_params=None, stream=False,
)
sem = asyncio.Semaphore(args.noise_parallel)
async def gated(idx):
async with sem:
return await noise(idx)
results = await asyncio.gather(*[gated(i) for i in range(args.noise_reqs)])
done = sum(1 for r in results if r and r.get("usage"))
print(f" noise: {done}/{args.noise_reqs} completed")
print()
# ----- Step 2: Follow-up. Same session, extended prompt, hit dst directly. -----
rng_ext = _r.Random(f"ext-{args.prefix_base}")
follow_prompt = prompt + [rng_ext.randint(1024, 99_999) for _ in range(args.n_extension)]
print(f"[2] follow-up direct to dst (no kv_transfer_params): "
f"prefix_len={args.n_prefix_tokens}, extended_len={len(follow_prompt)}")
fu = await send_completion(
client, args.dst_port, follow_prompt, max_tokens=4,
kv_transfer_params=None,
stream=False,
)
print(f" follow-up resp: {short(fu)}")
print()
# ----- Step 3: Same prompt twice on dst (sanity) -----
print(f"[3] sanity: same prompt again to dst (should see local hit "
f"from step 2's just-cached blocks)")
sanity = await send_completion(
client, args.dst_port, follow_prompt, max_tokens=4,
kv_transfer_params=None,
stream=False,
)
print(f" sanity resp: {short(sanity)}")
print()
# ----- Verdict -----
usage_fu = fu.get("usage") or {}
details_fu = usage_fu.get("prompt_tokens_details") or {}
cached_fu = details_fu.get("cached_tokens", 0) or usage_fu.get("cached_tokens", 0)
# Expect ~n_prefix_tokens (minus the last token + alignment)
expected_min = int(args.n_prefix_tokens * 0.95)
verdict = "PASS" if cached_fu >= expected_min else "FAIL"
print(f"=== verdict: {verdict} (follow-up cached={cached_fu}, "
f"expected >= {expected_min} of {args.n_prefix_tokens}) ===")
sys.exit(0 if verdict == "PASS" else 1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,188 @@
# Layer-wise KV transfer on Mooncake — exploration
Goal: make vLLM's `MooncakeConnector` push KV **per-layer during prefill**
(write mode) instead of the current **post-hoc full-request transfer**, then
microbench correctness + whether it hides the transfer behind prefill compute
(the thing MoRIIO's write mode does on AMD; no NVIDIA connector ships it).
Everything here is isolated in worktree `worktree-mooncake-layerwise`. The
dash0 venv connector is backed up at `mooncake_connector.py.ORIG_BACKUP`;
revert = copy the backup back. Opt-in via env `MOONCAKE_LAYERWISE=1`, so with
the env unset the connector behaves exactly as upstream.
## Baseline flow (post-hoc, what we have)
1. Proxy: prefill on src (`do_remote_decode`, max_tokens=1) → **await done**
decode on dst (`do_remote_prefill`) which pulls.
2. dst `start_load_kv``receive_kv` sends ZMQ `MooncakeXferMetadata` (its block
addrs) to src bootstrap.
3. src `send_kv_to_decode`: waits `send_meta.ready` (set at `request_finished`,
i.e. **after full prefill**) → `_build_transfer_params` (all layers) →
`_send_blocks` (one big `batch_transfer_sync_write`) → FINISH response.
Measured: this full transfer is on the critical path, runs at ~3 GB/s under
load (vs ~10 GB/s idle), dominating migration TTFT.
## Layer-wise flow (write mode, this exploration)
Key idea: keep all RDMA + completion on the `sender_loop` thread (clean), but
issue **one `batch_transfer_sync_write` per layer**, each fired as soon as that
layer's KV is computed — so writes overlap the remaining prefill compute.
Signaling: `save_kv_layer(layer_name, ...)` (called by vLLM's attention hook
after each layer's forward, on the main worker thread) records "layer L
computed" and wakes the sender_loop. `send_kv_to_decode` loops L=0..N-1,
waits until L is computed, writes layer L's blocks, then sends FINISH.
### Edits to `mooncake_connector.py` (all gated by `_lw_enabled`)
1. **Worker `__init__`**: `_lw_enabled` (env), layer-name→position map,
`_lw_computed: dict[transfer_id,int]`, `_lw_active: set[transfer_id]`,
wake event, lock.
2. **`register_kv_caches`**: build `_lw_layer_pos[layer_name]` (0..N-1) and
`_lw_addr_idx[pos]` = indices into `kv_caches_base_addr` (×2 if
`split_k_and_v`).
3. **Scheduler `update_state_after_alloc`** (`do_remote_decode` branch): in
layer-wise mode capture `blocks.get_block_ids()[0]` and store non-empty in
`_reqs_need_send` so the worker learns local block_ids + sets `ready`
**before** prefill finishes.
4. **Worker `note_layer_computed(layer_name)`** (new) called from
`MooncakeConnector.save_kv_layer`: bump `_lw_computed[tid]` for active
producers, `call_soon_threadsafe(wake.set)`.
5. **Worker `send_kv_to_decode`**: in layer-wise mode, mark transfer active,
loop layers: await `_lw_computed[tid] >= L`, `_send_blocks` for layer L
only (subset of `_build_transfer_params`), then send FINISH.
6. **Worker `_build_layer_transfer_params`** (new): like
`_build_transfer_params` but only the addr indices for one layer position.
### Microbench requirements
- Disable chunked prefill (`--max-num-batched-tokens` ≥ prompt) so prefill is a
single forward and `save_kv_layer` fires once per layer in order.
- Dispatch the dst (`do_remote_prefill`) request **first/concurrently** so the
ZMQ handshake reaches src during prefill.
- Correctness: dst follow-up `cached_tokens == prompt_len` (KV landed),
identical to baseline.
- Perf: src prefill wall-clock (does layer-wise slow it?) and dst TTFT (does
transfer leave the critical path?), swept over KV size, vs baseline.
## Status
- [x] worktree + connector backup + design
- [x] modified connector (LAYERWISE.py, +193/-4 lines, env-gated)
- [x] correctness microbench (mb7_layerwise.py) + launcher (run_mb7.sh)
- [x] correctness run on dash0 — PASS (KV lands; cached == prompt)
- [x] perf run + verdict — POSITIVE (transfer hidden behind prefill)
## Results (2-instance, idle, chunked-prefill off, Qwen3-30B-A3B, 48 layers)
Metric: `overhead = total prefill_only` = the transfer cost left on the
critical path (TTFT). Baseline = post-hoc full pull (sequential).
| KV size | baseline overhead | **layerwise overhead** | reduction |
|--------:|------------------:|-----------------------:|----------:|
| 8192 (0.75 GiB) | 123 ms | **58 ms** | 2.1× |
| 16384 (1.5 GiB) | 202 ms | **58 ms** | 3.5× |
| 32768 (3.0 GiB) | 529 ms | **57 ms** | 9.3× |
Key signatures:
- **Layerwise overhead is ~constant (~58 ms)** regardless of KV size, while
baseline grows O(KV size). The 58 ms is handshake + last-layer tail + 1
decode; the bulk transfer is hidden behind prefill compute.
- **Prefill did NOT slow down**: layerwise `t_A` (575/1495/4440 ms) ==
`prefill_only` (574/1492/4440 ms). The concurrent RDMA was "free" on idle
GPUs — no measurable HBM contention with prefill compute here.
- Producer logs confirm the transfer itself took 0.39/0.55/4.37 s (grows with
size) yet ran *inside* the prefill window, so it left the critical path.
- **Correctness PASS**: B's follow-up cached == prompt for all sizes; the
48-layer / 96-base-addr (split K&V) per-layer addressing is correct.
## Caveats (why this is a proof-of-concept, not a verdict for production)
1. **Idle instances only.** Real migration happens between *busy* instances.
Under load both prefill and transfer slow; transfer (even at ~3 GB/s) is
still < prefill for big contexts so it should still hide, but receive-side
(B) and HBM contention during prefill are untested here. NEXT: rerun with
background load on both A and B.
2. **Chunked prefill disabled.** The monotonic layer counter assumes one
forward, layers in order. Production uses chunked prefill (multi-step),
which needs per-(chunk,layer) tracking not implemented.
3. **Single concurrent producer transfer.** Global counter; real migration is
concurrent. Would need per-transfer state.
4. **Microbench dispatch.** mb7 fires B then A with a 50 ms head start to get
the handshake to A before its forward. The real proxy path
(`_handle_combined_pd_sep_v2`) dispatches sequentially and would need the
write-mode (concurrent) restructure.
## Results under LOAD (bg=16 background decode streams, 8 per instance)
Critical-path transfer overhead (ms), `total prefill_only`:
| KV size | idle base | idle LW | **load base** | **load LW** |
|--------:|----------:|--------:|--------------:|------------:|
| 8k | 123 | 58 | 158 | **94** |
| 16k | 202 | 58 | 239 | **83** |
| 32k | 529 | 57 | **749** | **95** |
The overlap **survives load**: layerwise overhead stays ~constant (~90 ms)
under load while baseline grows to 749 ms at 32k (7.9× reduction). Prefill did
not slow (load LW `t_A` == load `prefill_only`); the transfer (0.56/1.46/4.37 s,
producer logs) ran inside the prefill window even with 16 concurrent decodes.
Correctness PASS under load.
## FULL 1200-req v3 TRACE re-profile (chunk-safe + concurrent + write-mode)
Hardened connector (per-step incremental shipping, per-transfer state) +
write-mode proxy (concurrent prefill/decode dispatch). Two passes of
`w600_r0.0015_st30.jsonl` under `unified_v3`, differing only in transfer mode.
Correctness: layer-wise **1213/1214 success** (1 connection-error on the 128k
req, not KV corruption); byte-level KV correctness validated on mb7
(chunked + 3-way concurrent, `cached==prompt`); producer logs confirm
incremental shipping (e.g. `shipped 7872/7872 blocks`).
Migration sets differ between runs (write-mode timing shifts which requests
trigger migration; only 4 migrated in both), but are distributionally
comparable (median new_local/input 0.42 vs 0.46). **Matched migrations
all improved**, scaling with the transfer hidden behind prefill:
| request | input | new_local | base TTFT | LW TTFT | Δ |
|---|--:|--:|--:|--:|--:|
| 1268630 | 102k | 97k | 41.20 | 33.96 | **7.23s** |
| 1334223 | 37k | 14k | 6.04 | 3.23 | 2.81s |
| 1279412 | 40k | 8k | 5.50 | 2.92 | 2.58s |
| 1271459 | 8.9k | 8.9k | 37.01 | 36.98 | 0.03s (queue-bound) |
Trace-level TTFT (different sets, directional): overall p90 9.799.16 (6%),
p99 44.8942.85 (5%). **Modest** because (a) migrations are only 25/1214
**2%** of requests, and (b) several migrations are queue/contention-bound, not
transfer-bound layer-wise removes the transfer component but not the
control-plane/queue residual (the ~45% from the b3_v3_fullbreak profile).
**Verdict on the trace re-profile:** layer-wise does exactly what the profile
predicted it removes the transfer half of migration overhead (matched
migrations 2.6 to 7.2s, biggest where there's the most prefill to hide
behind), but the trace-level gain is small because migrations are rare and
partly queue-bound. It does NOT, on its own, flip migration to a clear win
over unified for this agentic workload.
## Verdict (microbench)
The mechanism **works and the benefit holds under load**: layer-wise push turns
migration's KV-transfer cost from O(KV size) on the critical path into a
near-constant ~90 ms tail, by overlapping it with prefill compute what
MoRIIO's write mode does on AMD, now demonstrated on NVIDIA/Mooncake.
**BUT this is single-transfer, non-chunked.** Running the actual 1200-req trace
correctly needs two more pieces this PoC does NOT have:
1. **Chunk-safe tracking** long agentic prompts force chunked prefill;
`save_kv_layer` then fires per-chunk and the monotonic counter would ship
uncomputed blocks. Needs slot-mapping-aware per-(request,chunk) tracking.
2. **Concurrent-transfer safety** the global counter assumes one producer at
a time; the trace migrates from busy instances running other forwards.
Also: even with those fixed, layer-wise only removes the **transfer half** of
the measured migration overhead. The b3_v3_fullbreak profile showed dst-side
`T_kv_pull` = ~55% RDMA + ~45% control-plane GIL-dispatch stalls; layer-wise
hides the RDMA half but the control-plane half is orthogonal. So a trace
re-profile would show roughly the transfer half collapse, not the whole thing.

View File

@@ -0,0 +1,77 @@
# Engine-state ablation: real-time state vs router shadow counters
**Question.** The router (`cache_aware_proxy`) routes on **shadow counters** it
maintains itself (incremented at dispatch, reconciled to vLLM `/metrics` only
every 30 s → stale). Does feeding it **real** per-engine state (running/waiting,
KV-used, pending-prefill, `max_prefill_remaining`) change routing decisions,
performance, or the policy ranking?
**Setup.** dash1, 8×H20 (TP=1), Qwen3-Coder-30B-A3B, trace
`w600_r0.0015_st30.jsonl` (1214 reqs / 274 sessions). Each policy run as a
matched pair: `es0` (shadow only) vs `es1` (real-state feed via
`file:///dev/shm/...`, published ~50 ms by a scheduler daemon thread, read by
the proxy via `eff_* = max(shadow, real)`). Only the state source differs.
Driver: `run_full_ablation.sh`; per-cell freshness via `fresh_sampler.py`;
comparison via `cmp_es.py`.
## Result — real-time state is NOT the routing lever
It reshuffles 4476% of routing decisions but **never beats the champion**;
the cache-affinity champion (`unified+A+B`, es0 p90 **7.62 s**) stays best.
| Policy | how it uses load | inst/session | reroute % | TTFT p90 es0→es1 | mean es0→es1 | verdict |
|---|---|--:|--:|--:|--:|---|
| `sticky` | **once at session birth, then pinned** | 1.00 | 44.5% | 13.42 → **9.95 (26%)** | 4.13→3.65 | **HELPS** |
| `unified+A+B` | per-req, affinity-dominated | 1.22 | 76.4% | 7.62 → 7.76 (+1.8%) | 3.20→3.24 | wash |
| `v3_AB_lw` | per-req, affinity-dom + migration | ~1.2 | 71.7% | 9.35 → 9.49 (+1.5%) | 3.34→3.58 | wash* |
| `unified_kv_both` | per-req, affinity-dom (same picker) | ~1.2 | 73.6% | 6.45 → 9.28 (+44%) | 3.07→3.49 | worse† |
| `lmetric` | per-req, load×batch | 2.04 | 73.4% | 15.63 → 18.23 (+16.6%) | 5.18→5.80 | HURTS |
| `load_only` | per-req, pure load | 2.22 | 72.7% | 21.79 → 27.69 (+27%) | 6.65→8.42 | HURTS |
\* v3 real-state migration targeting backfired: migrations 26→32, migrated-req
mean TTFT 11.99→18.45 s (+54%). Real state does not rescue migration.
† same picker as `unified`; the 1.8%-vs-44% spread is run-variance (single
pairs) in which reshuffled routes hit hotspots — sign is consistently ≥ neutral.
## Mechanism — the sign is set by reactivity, not "affinity vs not"
- **One-shot placement (`sticky`) → HELPS.** `pick_instance_sticky` is *not* a
stateless hash: the first turn picks `min(eff_num_requests())` (load), then
`affinity[session]` pins it for all later turns. State enters at exactly one
decision per session; real load → better placement that compounds across the
session, locality preserved, no per-request oscillation.
- **Per-request, affinity-dominated (`unified`/`v3`/`kv_both`) → wash-to-worse.**
The hybrid picker mostly obeys affinity; only the ~12% fallback fraction
consults load. Net 0…+44%, never helps.
- **Per-request, pure load (`lmetric`/`load_only`) → HURTS, monotonic in
load-purity.** Routing on *instantaneous* load induces **herding** (everyone
piles onto whatever momentarily looks idle → transient overload → tail
inflation); the stale shadow counter was inadvertently a **dampener**.
## Why the result is trustworthy (not a stale-feed artifact)
The feed was fresh on every es1 cell: age median **25 ms**, **≤92 ms even
during 100k-token prefills**, <0.5 % of samples >2 s stale (and those not
during prefills → reader drops them → shadow fallback). The feared GIL-
starvation of the publisher during big prefills did **not** materialize.
## Implications
1. Don't invest in real-time state for per-request routing — it never wins and
degrades load-driven policies up to +27 %.
2. The cache-affinity champion is robust to state source; A+B+RaceFix already
handled the staleness that mattered.
3. **Design insight:** the only place ground-truth state helps is **one-shot
session placement** (decide well once on real load, then commit) — not
per-request load polling.
4. All prior shadow-state results stand; the router's approximate state was
never the bottleneck. Workload skew + affinity discipline are.
## Reproduce
```bash
# per-cell: same proxy, ES=0 (shadow) vs ES=1 (real); see run_v3_trace.sh
MODE=baseline POLICY=unified AB_FLAGS="--overload-factor 1.3 --lmetric-decode-weight 0.01" \
ES=1 TAG=unified_AB_es1 bash run_v3_trace.sh
# full sweep (waits for the champion es1 marker, then runs the rest):
bash run_full_ablation.sh
# compare a pair:
python cmp_es.py <es0_dir>/unified_v3 <es1_dir>/unified_v3 abl_<tag>_es1.freshness.jsonl
```

View File

@@ -0,0 +1,82 @@
# P2: real engine-state feed for migration target selection
Problem: the router (`cache_aware_proxy.py`) decides migration targets from
**shadow counters** it maintains itself (incremented at dispatch, decremented
at completion) and reconciles to vLLM `/metrics` only every **30 s**
(`_reconcile_loop`). So every routing/migration decision is on stale state.
Worse, the signal that predicts the ~45% control-plane stall — *is the target
mid-large-prefill?* (a big prefill holds the GIL and starves the mooncake
receiver_loop) — isn't visible at all, and `/metrics` doesn't expose it either.
Fix: vLLM publishes **real** per-engine state to a shared store ~20 Hz; the
router reads ground truth and avoids GIL-stall / capacity-wall targets.
## Components (all unit-tested without GPUs)
- `engine_state.py` — canonical `compute_snapshot(scheduler, id)`, `StateWriter`,
`StateReader`. Schema per engine: `ts, num_running, num_waiting,
gpu_blocks_total/free, gpu_kv_used_frac, pending_prefill_tokens,
ongoing_decode_tokens, num_prefilling, max_prefill_remaining`.
- `instrument_engine_state.py` — vLLM `Scheduler` patch (apply/revert markers
`ES_INSTRUMENT_*`): a daemon thread publishes the snapshot every
`AGENTIC_ENGINE_STATE_PERIOD_MS` (50 ms) off the forward hot path. Inlined
writer (engine process needs no repo import). Coexists with MB5.
- `migration_target.py` — pure target scorer: avoid `max_prefill_remaining ≥
es_big_prefill_threshold` (GIL stall) and `gpu_kv_used_frac ≥ es_kv_wall_frac`
(capacity wall), then rank by cache-richness and **real** load.
- `cache_aware_proxy.WRITEMODE.py` — wired: `InstanceState.real_state`,
`_engine_state_poll_loop` (instance i ← `engine_{i}`), `_real_load`/Gate-3 and
Mechanism-B now real-state-aware. `--engine-state-uri` flag; off ⇒ identical
to before (shadow only).
Transport (`AGENTIC_ENGINE_STATE_URI` / `--engine-state-uri`):
`file:///dev/shm/agentic_engine_state` (default, zero-dep, single-node) or
`redis://host:port/0` (multi-node; needs redis-py + server — not installed on
dash0, so file backend is the working default).
## Tests (no GPU)
- `compute_snapshot` field math (mock scheduler): running/waiting,
max_prefill_remaining, pending, decode, kv_used_frac.
- writer→reader round-trip + staleness drop (file backend).
- target scorer: 5 cases incl. *avoid GIL-stall target even when its shadow
load is lower*, *real load beats stale shadow*, *cache-rich wins*,
*avoid KV wall*, *graceful fallback when feed missing*.
- end-to-end: publish 8 engines (one mid-130k-prefill) → proxy inlined reader →
target selection avoids it.
## Enabling in a GPU run (when free)
1. `instrument_engine_state.py --apply` on the dash0 venv.
2. `export AGENTIC_ENGINE_STATE_URI=file:///dev/shm/agentic_engine_state`
before the launcher (vLLM instances inherit it; `AGENTIC_WORKER_ID=engine_{i}`
already set by `b3_isolated_policy.sh` → publishes as `engine_{i}`).
3. Proxy: `EXTRA_PROXY_ARGS="--engine-state-uri file:///dev/shm/agentic_engine_state ..."`.
4. Revert the patch + `rm -rf /dev/shm/agentic_engine_state` after.
## ALL policies now read the real state (update)
`InstanceState` exposes effective accessors used by **every** picker:
`eff_num_requests / eff_pending_prefill / eff_ongoing_decode /
eff_ongoing_tokens` = `max(shadow, real)` when the feed is fresh (real fixes
the 30s-stale under-count; shadow's atomic pre-await reservation still covers
the in-flight window, preserving the RaceFix), plus real-only
`r_max_prefill_remaining / r_kv_used_frac`. Wired into: `load_only`, `lmetric`,
`sticky`, `pick_instance` (legacy), `pick_instance_unified_hybrid`
(unified / unified_kv_both), `pick_instance_unified_v3` (gate + Mechanism B),
and `snapshot_workers` (logged scores now match the decision + real fields).
Feed off ⇒ `real_state is None` ⇒ accessors return shadow ⇒ byte-identical to
before. (legacy `unified_v2` left on shadow — retired, not in the ablation.)
## Ablation (when GPU free)
`run_v3_trace.sh` gains `ES=1` (apply engine-state patch + feed + proxy flag)
and always deploys the enhanced proxy (dormant when feed/write-mode off).
`run_ablation_es.sh` runs each config twice (ES=0 vs ES=1) so the only
difference is the state source. Default decisive set (4 runs): champion
`unified+A+B` and `unified_v3+A+B+layerwise`, each ES0/ES1. Extend CONFIGS for
`lmetric` / `unified_kv_both` / `load_only`. Compares per-policy TTFT
(overall + migrated) and whether the **ranking** changes with ground-truth
state.
## Status / scope
- Built + unit-tested (snapshot, round-trip, target scorer, eff_ accessors,
end-to-end publish→read→select); NOT yet run against live engines (GPU busy).
- TP=1 only (one EngineCore/instance → one publisher/engine_id). TP>1 needs
per-rank ids.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Compare an es0 (shadow) vs es1 (real-state) run: TTFT, decision split,
routing flips, load distribution. Optional 3rd arg = es1 freshness jsonl."""
import json, sys, statistics, os
def load(d):
ms = {}
for l in open(os.path.join(d, "metrics.jsonl")):
m = json.loads(l); ms[m["request_id"]] = m
bd = {x["request_id"]: x for x in json.load(open(os.path.join(d, "breakdown.json")))}
return ms, bd
def pct(xs, q):
xs = sorted(xs)
return xs[min(len(xs) - 1, int(q * len(xs)))] if xs else 0
def chosen(x):
return x.get("routed_to", x.get("chosen_idx"))
d0, d1 = sys.argv[1], sys.argv[2]
m0, b0 = load(d0); m1, b1 = load(d1)
def ttfts(ms):
return [m["ttft_s"] for m in ms.values() if not m.get("error") and m.get("ttft_s") is not None]
print("=== overall TTFT ===")
for tag, ms in [("es0/shadow", m0), ("es1/real ", m1)]:
t = ttfts(ms)
print(f"{tag}: {len(t)}/{len(ms)} ok p50={pct(t,.5):.2f} p90={pct(t,.9):.2f} "
f"p99={pct(t,.99):.2f} max={max(t):.2f} mean={statistics.mean(t):.2f}")
def byclass(ms, bd):
cls = {}
for rid, m in ms.items():
if m.get("error") or m.get("ttft_s") is None: continue
dec = bd.get(rid, {}).get("decision", "?")
cls.setdefault(dec, []).append(m["ttft_s"])
return cls
print("\n=== decision split (n / p90 / p99) ===")
for tag, ms, bd in [("es0", m0, b0), ("es1", m1, b1)]:
print(f" [{tag}]")
for dec, ts in sorted(byclass(ms, bd).items()):
print(f" {dec:18s} n={len(ts):4d} p90={pct(ts,.9):7.2f} p99={pct(ts,.99):7.2f}")
common = set(b0) & set(b1)
flips = [r for r in common if chosen(b0[r]) != chosen(b1[r])]
decflip = [r for r in common if b0[r].get("decision") != b1[r].get("decision")]
print(f"\n=== routing changes (common reqs={len(common)}) ===")
print(f" instance flips : {len(flips)} ({100*len(flips)/max(1,len(common)):.1f}%)")
print(f" decision-type flips: {len(decflip)} ({100*len(decflip)/max(1,len(common)):.1f}%)")
# TTFT of flipped vs non-flipped (es1 side)
fl_t = [m1[r]["ttft_s"] for r in flips if not m1[r].get("error") and m1[r].get("ttft_s") is not None]
if fl_t:
print(f" flipped reqs es1 TTFT: p50={pct(fl_t,.5):.2f} p90={pct(fl_t,.9):.2f} mean={statistics.mean(fl_t):.2f}")
def dist(bd):
d = {}
for x in bd.values():
d[chosen(x)] = d.get(chosen(x), 0) + 1
return dict(sorted(d.items(), key=lambda kv: str(kv[0])))
print("\n=== per-instance request count ===")
print(" es0:", dist(b0))
print(" es1:", dist(b1))
if len(sys.argv) > 3 and os.path.exists(sys.argv[3]):
rows = [json.loads(l) for l in open(sys.argv[3]) if l.strip()]
ages = [r["age_s"] for r in rows]
busy = [r["age_s"] for r in rows if (r.get("num_prefilling") or 0) > 0]
print(f"\n=== es1 feed freshness (full run, n={len(ages)}) ===")
if ages:
print(f" age_s med={statistics.median(ages):.3f} p90={pct(ages,.9):.3f} max={max(ages):.3f} "
f"stale>2s={sum(1 for a in ages if a>2)}")
if busy:
print(f" during-prefill n={len(busy)} med={statistics.median(busy):.3f} max={max(busy):.3f}")

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Engine-state store: canonical snapshot + writer/reader, shared schema.
The vLLM scheduler patch (instrument_engine_state.py) inlines a faithful copy
of `compute_snapshot` + the file/redis writer (engine process needs no repo
import). The router (cache_aware_proxy) imports `StateReader` here to read the
real per-engine state instead of its stale shadow counters.
Schema (one record per engine, key = engine_id):
ts, engine_id, num_running, num_waiting, gpu_blocks_total, gpu_blocks_free,
gpu_kv_used_frac, pending_prefill_tokens, ongoing_decode_tokens,
num_prefilling, max_prefill_remaining
Transport URIs:
file:///dev/shm/agentic_engine_state (default; atomic temp+rename)
redis://host:port/0 (optional; needs redis-py)
"""
from __future__ import annotations
import json
import os
import time
def compute_snapshot(scheduler, engine_id: str) -> dict:
"""Cheap O(batch) read of routing-relevant real state from a live
vLLM V1 Scheduler (duck-typed for testability)."""
try:
pool = scheduler.kv_cache_manager.block_pool
total = int(pool.num_gpu_blocks)
free = int(pool.get_num_free_blocks())
except Exception:
total = free = -1
n_run = pend = dec = n_pref = max_pref = 0
try:
for r in scheduler.running:
n_run += 1
npr = int(getattr(r, "num_prompt_tokens", 0))
nct = int(getattr(r, "num_computed_tokens", 0))
if nct < npr:
rem = npr - nct
pend += rem
n_pref += 1
max_pref = max(max_pref, rem)
else:
dec += int(getattr(r, "num_tokens", 0))
except Exception:
pass
n_wait = 0
try:
n_wait = len(scheduler.waiting) + len(getattr(scheduler, "skipped_waiting", []))
for r in list(scheduler.waiting):
pend += max(0, int(getattr(r, "num_prompt_tokens", 0))
- int(getattr(r, "num_computed_tokens", 0)))
except Exception:
pass
used = ((total - free) / total) if (total and total > 0) else -1.0
return {
"ts": time.time(),
"engine_id": engine_id,
"num_running": n_run,
"num_waiting": int(n_wait),
"gpu_blocks_total": total,
"gpu_blocks_free": free,
"gpu_kv_used_frac": used,
"pending_prefill_tokens": int(pend),
"ongoing_decode_tokens": int(dec),
"num_prefilling": n_pref,
"max_prefill_remaining": int(max_pref),
}
class StateWriter:
def __init__(self, uri: str, engine_id: str):
self.engine_id = engine_id
self.kind = None
if uri.startswith("file://"):
self.kind = "file"
self.dir = uri[len("file://"):]
os.makedirs(self.dir, exist_ok=True)
self.path = os.path.join(self.dir, f"{engine_id}.json")
self.tmp = self.path + f".tmp.{os.getpid()}"
elif uri.startswith("redis://"):
self.kind = "redis"
import redis
self.r = redis.Redis.from_url(uri)
self.key = f"engine_state:{engine_id}"
else:
raise ValueError(f"unsupported engine-state URI: {uri}")
def publish(self, state: dict):
if self.kind == "file":
with open(self.tmp, "w") as f:
f.write(json.dumps(state))
os.replace(self.tmp, self.path)
elif self.kind == "redis":
self.r.set(self.key, json.dumps(state), ex=5)
class StateReader:
"""Router-side reader. read_all() returns {engine_id: state}, dropping
records older than max_age_s (so a dead/hung engine is ignored)."""
def __init__(self, uri: str, max_age_s: float = 2.0):
self.uri = uri
self.max_age_s = max_age_s
self.kind = None
if uri.startswith("file://"):
self.kind = "file"
self.dir = uri[len("file://"):]
elif uri.startswith("redis://"):
self.kind = "redis"
import redis
self.r = redis.Redis.from_url(uri)
else:
raise ValueError(f"unsupported engine-state URI: {uri}")
def read_all(self) -> dict[str, dict]:
now = time.time()
out: dict[str, dict] = {}
try:
if self.kind == "file":
import glob
for p in glob.glob(os.path.join(self.dir, "*.json")):
try:
s = json.load(open(p))
except Exception:
continue
if now - s.get("ts", 0) <= self.max_age_s:
out[s.get("engine_id", os.path.basename(p)[:-5])] = s
elif self.kind == "redis":
for k in self.r.scan_iter("engine_state:*"):
v = self.r.get(k)
if not v:
continue
s = json.loads(v)
if now - s.get("ts", 0) <= self.max_age_s:
out[s.get("engine_id")] = s
except Exception:
pass
return out

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Sample engine-state feed freshness during the es1 run.
Writes one jsonl record per engine per tick: age_s = now - state.ts.
Stops when the DONE marker appears (run finished + /dev/shm wiped) or after 90min.
"""
import json, os, time, sys, glob
esdir, outpath, donemarker = sys.argv[1], sys.argv[2], sys.argv[3]
deadline = time.time() + 90 * 60
with open(outpath, "a") as out:
while not os.path.exists(donemarker) and time.time() < deadline:
now = time.time()
if os.path.isdir(esdir):
for f in sorted(glob.glob(os.path.join(esdir, "engine_*.json"))):
try:
s = json.load(open(f))
rec = {
"now": round(now, 3),
"engine": os.path.basename(f)[:-5],
"age_s": round(now - s.get("ts", 0), 4),
"num_running": s.get("num_running"),
"num_prefilling": s.get("num_prefilling"),
"max_prefill_remaining": s.get("max_prefill_remaining"),
"kv_used": s.get("gpu_kv_used_frac"),
}
out.write(json.dumps(rec) + "\n")
except Exception:
pass
out.flush()
time.sleep(5)

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""Patch vLLM V1 scheduler to publish REAL engine state to a shared store,
so the global router reads ground truth instead of its own stale shadow
counters (reconciled only every 30s).
Published per engine (key = AGENTIC_ENGINE_ID), throttled ~20 Hz from a
daemon thread (off the forward hot path):
{ts, num_running, num_waiting, gpu_blocks_total, gpu_blocks_free,
gpu_kv_used_frac, pending_prefill_tokens, ongoing_decode_tokens,
num_prefilling, max_prefill_remaining}
`max_prefill_remaining` is the key signal /metrics does NOT expose: the
largest in-progress prefill on the engine. A big in-progress prefill holds
the GIL and stalls the mooncake receiver_loop — so the router should avoid
migrating KV to such an instance (P2).
Transport (env AGENTIC_ENGINE_STATE_URI):
file:///dev/shm/agentic_engine_state (default; atomic temp+rename)
redis://host:port/0 (optional; needs redis-py + server)
Self-contained (inlined writer) so the engine process needs no repo import.
Apply/revert markers: # ES_INSTRUMENT_START / # ES_INSTRUMENT_END.
Usage:
python instrument_engine_state.py --apply [--venv PATH]
python instrument_engine_state.py --revert [--venv PATH]
python instrument_engine_state.py --check [--venv PATH]
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
DEFAULT_VENV = Path("/home/admin/cpfs/wjh/agentic-kv/.venv")
TARGET_REL = "lib/python3.12/site-packages/vllm/v1/core/sched/scheduler.py"
START = "# ES_INSTRUMENT_START"
END = "# ES_INSTRUMENT_END"
# ---- Patch 1: header (writer + publisher thread), before class Scheduler ----
HEADER_ANCHOR = "class Scheduler(SchedulerInterface):"
HEADER = f'''{START}
import json as _es_json
import os as _es_os
import threading as _es_threading
import time as _es_time
_ES_URI = _es_os.environ.get("AGENTIC_ENGINE_STATE_URI", "")
_ES_ID = _es_os.environ.get("AGENTIC_ENGINE_ID") or _es_os.environ.get(
"AGENTIC_WORKER_ID", f"engine_{{_es_os.getpid()}}")
_ES_PERIOD_S = float(_es_os.environ.get("AGENTIC_ENGINE_STATE_PERIOD_MS", "50")) / 1000.0
class _ESWriter:
"""Pluggable state writer: file:// (atomic temp+rename) or redis://."""
def __init__(self, uri: str, engine_id: str):
self.engine_id = engine_id
self.kind = None
if uri.startswith("file://"):
self.kind = "file"
self.dir = uri[len("file://"):]
_es_os.makedirs(self.dir, exist_ok=True)
self.path = _es_os.path.join(self.dir, f"{{engine_id}}.json")
self.tmp = self.path + f".tmp.{{_es_os.getpid()}}"
elif uri.startswith("redis://"):
self.kind = "redis"
import redis # lazy
self.r = redis.Redis.from_url(uri)
self.key = f"engine_state:{{engine_id}}"
def publish(self, state: dict):
try:
if self.kind == "file":
with open(self.tmp, "w") as f:
f.write(_es_json.dumps(state))
_es_os.replace(self.tmp, self.path) # atomic
elif self.kind == "redis":
self.r.set(self.key, _es_json.dumps(state), ex=5)
except Exception:
pass
def _es_compute_snapshot(scheduler) -> dict:
"""Cheap O(batch) state read from the live scheduler."""
try:
kvm = scheduler.kv_cache_manager
pool = kvm.block_pool
total = int(pool.num_gpu_blocks)
free = int(pool.get_num_free_blocks())
except Exception:
total = free = -1
n_run = 0
pend = 0
dec = 0
n_pref = 0
max_pref = 0
try:
for r in scheduler.running:
n_run += 1
npr = int(getattr(r, "num_prompt_tokens", 0))
nct = int(getattr(r, "num_computed_tokens", 0))
if nct < npr: # still prefilling
rem = npr - nct
pend += rem
n_pref += 1
if rem > max_pref:
max_pref = rem
else: # decoding
dec += int(getattr(r, "num_tokens", 0))
except Exception:
pass
n_wait = 0
try:
n_wait = len(scheduler.waiting) + len(getattr(scheduler, "skipped_waiting", []))
for r in list(scheduler.waiting):
pend += max(0, int(getattr(r, "num_prompt_tokens", 0))
- int(getattr(r, "num_computed_tokens", 0)))
except Exception:
pass
used_frac = ((total - free) / total) if (total and total > 0) else -1.0
return {{
"ts": _es_time.time(),
"engine_id": _ES_ID,
"num_running": n_run,
"num_waiting": int(n_wait),
"gpu_blocks_total": total,
"gpu_blocks_free": free,
"gpu_kv_used_frac": used_frac,
"pending_prefill_tokens": int(pend),
"ongoing_decode_tokens": int(dec),
"num_prefilling": n_pref,
"max_prefill_remaining": int(max_pref),
}}
class _ESPublisher:
def __init__(self, scheduler):
self._sched = scheduler
self._writer = _ESWriter(_ES_URI, _ES_ID)
self._stop = _es_threading.Event()
self._t = _es_threading.Thread(target=self._loop, daemon=True)
self._t.start()
def _loop(self):
while not self._stop.is_set():
try:
self._writer.publish(_es_compute_snapshot(self._sched))
except Exception:
pass
_es_time.sleep(_ES_PERIOD_S)
{END}
'''
# ---- Patch 2: start the publisher at the end of Scheduler.__init__ ----------
# Anchor on the existing agentic step-log block tail in __init__.
INIT_ANCHOR = """ _step_path = _os.environ.get("AGENTIC_STEP_LOG_PATH")"""
INIT_INSERT = f""" {START}
if _ES_URI:
try:
self._es_publisher = _ESPublisher(self)
logger.info("agentic engine-state publisher: uri=%s id=%s",
_ES_URI, _ES_ID)
except Exception as _e:
logger.warning("engine-state publisher disabled (%r)", _e)
{END}
_step_path = _os.environ.get("AGENTIC_STEP_LOG_PATH")"""
PATCHES = [
("header", HEADER_ANCHOR, HEADER + HEADER_ANCHOR),
("init", INIT_ANCHOR, INIT_INSERT),
]
def find_target(venv: Path) -> Path:
for c in (venv / TARGET_REL, DEFAULT_VENV / TARGET_REL):
if c.is_file():
return c
raise FileNotFoundError(f"cannot find {TARGET_REL} under {venv}")
def is_patched(t: str) -> bool:
return START in t
def apply(target: Path):
text = target.read_text()
if is_patched(text):
print(f"[es-instr] already patched: {target}")
return
new = text
for name, src, dst in PATCHES:
if src not in new:
raise RuntimeError(f"patch {name!r}: anchor not found in {target}")
new = new.replace(src, dst, 1)
target.write_text(new)
print(f"[es-instr] applied {len(PATCHES)} patches -> {target}")
def revert(target: Path):
text = target.read_text()
if not is_patched(text):
print(f"[es-instr] not patched: {target}")
return
pat = re.compile(r"[ \t]*" + re.escape(START) + r".*?" + re.escape(END) + r"\n",
flags=re.DOTALL)
new = pat.sub("", text)
new = re.sub(r"\n{3,}class Scheduler\(", "\n\nclass Scheduler(", new)
target.write_text(new)
print(f"[es-instr] reverted: {target}")
def main():
p = argparse.ArgumentParser()
p.add_argument("--apply", action="store_true")
p.add_argument("--revert", action="store_true")
p.add_argument("--check", action="store_true")
p.add_argument("--venv", type=Path, default=DEFAULT_VENV)
a = p.parse_args()
t = find_target(a.venv)
if a.apply:
apply(t)
elif a.revert:
revert(t)
elif a.check:
print(f"[es-instr] {'PATCHED' if is_patched(t.read_text()) else 'CLEAN'}: {t}")
else:
p.error("specify --apply/--revert/--check")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""MB7: correctness + perf of layer-wise KV push vs post-hoc transfer.
Two 2-instance modes against A (src/producer) and B (dst/consumer):
baseline : prefill A (await) -> THEN B pulls (post-hoc full transfer).
T_total = T_prefill + T_xfer (sequential)
layerwise : dispatch B's remote-prefill (handshake) and A's prefill
CONCURRENTLY, so A pushes each layer as it computes it.
If overlap works, T_total ~= max(T_prefill, T_xfer) ~= T_prefill.
Reference: T_prefill_only = a plain prefill on A with no transfer.
Correctness: after the transfer, a plain follow-up to B on the same prompt
must report cached_tokens >= ~prompt_len (the KV actually landed on B).
The connector mode is selected by the launcher (run_mb7.sh): baseline uses the
stock connector; layerwise deploys mooncake_connector.LAYERWISE.py +
MOONCAKE_LAYERWISE=1. This script just drives the requests and measures.
Usage:
python mb7_layerwise.py --mode layerwise --sizes 8192,32768,65536 --repeats 3 \
--src-port 8000 --dst-port 8001 --src-bp 8998 --dst-bp 8999 --out mb7.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import statistics
import time
import uuid
from pathlib import Path
import httpx
MODEL = "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct"
KV_PER_TOK = 98304
def synth_prompt(seed: int, n: int) -> list[int]:
import random
rng = random.Random(seed)
return [rng.randint(100, 150000) for _ in range(n)]
async def get_engine_id(client, host, bp):
r = await client.get(f"http://{host}:{bp}/query")
r.raise_for_status()
return r.json()["0"]["engine_id"]
async def completion(client, host, port, prompt, max_tokens, ktp=None):
payload = {
"model": MODEL, "prompt": prompt, "max_tokens": max_tokens,
"min_tokens": max_tokens if max_tokens == 1 else 1,
"temperature": 0.0, "stream": False,
}
if ktp:
payload["kv_transfer_params"] = ktp
t0 = time.perf_counter()
r = await client.post(f"http://{host}:{port}/v1/completions",
json=payload, timeout=600.0)
dt = time.perf_counter() - t0
r.raise_for_status()
return dt, r.json()
def cached_of(resp) -> int:
usage = resp.get("usage") or {}
det = usage.get("prompt_tokens_details") or {}
return det.get("cached_tokens", 0) or usage.get("cached_tokens", 0) or 0
async def _stream_completion(client, host, port, prompt, max_tokens):
payload = {"model": MODEL, "prompt": prompt, "max_tokens": max_tokens,
"min_tokens": 1, "temperature": 0.0, "stream": True}
async with client.stream("POST", f"http://{host}:{port}/v1/completions",
json=payload, timeout=600.0) as r:
r.raise_for_status()
async for _ in r.aiter_bytes():
pass
class BackgroundLoad:
"""Hold N concurrent long-decode streams across endpoints to keep busy."""
def __init__(self, client, endpoints, n, prompt_tokens=2000, out_tokens=6000):
self.client, self.endpoints, self.n = client, endpoints, n
self.pt, self.ot = prompt_tokens, out_tokens
self._stop = asyncio.Event()
self._tasks = []
async def _w(self, idx):
host, port = self.endpoints[idx % len(self.endpoints)]
seed = 800000 + idx
while not self._stop.is_set():
try:
await _stream_completion(self.client, host, port,
synth_prompt(seed, self.pt), self.ot)
except Exception:
await asyncio.sleep(0.5)
seed += 1
def start(self):
self._tasks = [asyncio.create_task(self._w(i)) for i in range(self.n)]
async def stop(self):
self._stop.set()
for t in self._tasks:
t.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
async def num_running(client, host, port):
try:
r = await client.get(f"http://{host}:{port}/metrics", timeout=5.0)
for line in r.text.splitlines():
if line.startswith("vllm:num_requests_running"):
return int(float(line.split()[-1]))
except Exception:
pass
return -1
async def prefill_only(client, host, port, prompt):
"""Reference: plain prefill cost on A, no transfer."""
dt, _ = await completion(client, host, port, prompt, max_tokens=1)
return dt
async def measure_baseline(client, A, B, src_eid, src_bp_addr, prompt, seed):
tid = uuid.uuid4().hex
t0 = time.perf_counter()
t_pf, _ = await completion(client, *A, prompt, 1,
ktp={"do_remote_decode": True, "transfer_id": tid})
t_xfer, _ = await completion(client, *B, prompt, 1,
ktp={"do_remote_prefill": True, "transfer_id": tid,
"remote_engine_id": src_eid,
"remote_bootstrap_addr": src_bp_addr})
t_total = time.perf_counter() - t0
# correctness: B follow-up should hit cache
_, fr = await completion(client, *B, prompt, 1)
return {"t_prefill_s": t_pf, "t_xfer_s": t_xfer, "t_total_s": t_total,
"cached": cached_of(fr)}
async def measure_layerwise(client, A, B, src_eid, src_bp_addr, prompt, seed):
"""Dispatch B handshake + A prefill concurrently => layer-wise overlap."""
tid = uuid.uuid4().hex
t0 = time.perf_counter()
async def run_B():
return await completion(client, *B, prompt, 1,
ktp={"do_remote_prefill": True, "transfer_id": tid,
"remote_engine_id": src_eid,
"remote_bootstrap_addr": src_bp_addr})
async def run_A():
# small head start for B's handshake to reach A before A's forward
await asyncio.sleep(0.05)
return await completion(client, *A, prompt, 1,
ktp={"do_remote_decode": True, "transfer_id": tid})
b_task = asyncio.create_task(run_B())
a_task = asyncio.create_task(run_A())
(t_b, _), (t_a, _) = await asyncio.gather(b_task, a_task)
t_total = time.perf_counter() - t0
_, fr = await completion(client, *B, prompt, 1)
return {"t_A_s": t_a, "t_B_s": t_b, "t_total_s": t_total,
"cached": cached_of(fr)}
async def main_async(a):
sizes = [int(s) for s in a.sizes.split(",")]
A = (a.src_host, a.src_port)
B = (a.dst_host, a.dst_port)
limits = httpx.Limits(max_connections=64, max_keepalive_connections=64)
async with httpx.AsyncClient(limits=limits, trust_env=False) as client:
src_eid = await get_engine_id(client, a.src_host, a.src_bp)
src_bp_addr = f"http://{a.src_host}:{a.src_bp}"
print(f"[mb7] mode={a.mode} bg_load={a.bg_load} src_eid={src_eid[:16]}...")
loader = None
if a.bg_load > 0:
loader = BackgroundLoad(client, [A, B], a.bg_load)
loader.start()
print(f"[mb7] ramping background load ({a.bg_load}) ...")
for _ in range(40):
await asyncio.sleep(1.0)
na = await num_running(client, *A)
nb = await num_running(client, *B)
if na >= 1 and nb >= 1:
print(f"[mb7] busy: A_run={na} B_run={nb}")
break
# --- concurrent correctness mode: fire N transfers at once ----------
if a.concurrent > 1 and a.mode == "layerwise":
print(f"[mb7] CONCURRENT correctness: {a.concurrent} simultaneous "
f"transfers per size (src=A stresses concurrent producing)")
all_ok = True
for sz in sizes:
tasks = [
asyncio.create_task(measure_layerwise(
client, A, B, src_eid, src_bp_addr,
synth_prompt(sz * 1000 + j, sz), sz * 1000 + j))
for j in range(a.concurrent)
]
rows = await asyncio.gather(*tasks)
oks = [r["cached"] >= int(sz * 0.9) for r in rows]
all_ok = all_ok and all(oks)
print(f" sz={sz:>6} x{a.concurrent}: cached="
f"{[r['cached'] for r in rows]} correct={oks}")
print(f"[mb7] CONCURRENT correctness: "
f"{'ALL PASS' if all_ok else 'FAILURE'}")
if loader:
await loader.stop()
return
results = []
for sz in sizes:
for rep in range(a.repeats):
prompt = synth_prompt(sz * 100 + rep, sz)
# reference prefill-only cost (fresh prompt, different seed so no cache)
t_pf_only = await prefill_only(
client, *A, synth_prompt(sz * 100 + rep + 555, sz))
if a.mode == "baseline":
row = await measure_baseline(client, A, B, src_eid, src_bp_addr,
prompt, sz * 100 + rep)
else:
row = await measure_layerwise(client, A, B, src_eid, src_bp_addr,
prompt, sz * 100 + rep)
row.update({"mode": a.mode, "size": sz, "rep": rep,
"t_prefill_only_s": t_pf_only,
"kv_gib": sz * KV_PER_TOK / 2**30,
"correct": row["cached"] >= int(sz * 0.9)})
results.append(row)
extra = (f"xfer={row.get('t_xfer_s', 0)*1000:.0f}ms"
if a.mode == "baseline"
else f"tA={row.get('t_A_s',0)*1000:.0f}ms tB={row.get('t_B_s',0)*1000:.0f}ms")
print(f" sz={sz:>6} rep={rep} pf_only={t_pf_only*1000:6.0f}ms "
f"total={row['t_total_s']*1000:7.0f}ms {extra} "
f"cached={row['cached']}/{sz} correct={row['correct']}")
if loader:
await loader.stop()
# summary
print(f"\n=== {a.mode} (bg={a.bg_load}) summary ===")
print(f"{'size':>7} {'n':>2} {'pf_only_ms':>11} {'total_ms':>9} "
f"{'overhead_ms':>12} {'correct':>8}")
summary = []
for sz in sizes:
rs = [r for r in results if r["size"] == sz]
if not rs:
continue
pf = statistics.median(r["t_prefill_only_s"] for r in rs) * 1000
tot = statistics.median(r["t_total_s"] for r in rs) * 1000
allok = all(r["correct"] for r in rs)
# overhead = total - prefill_only = the part NOT hidden behind prefill
overhead = tot - pf
summary.append({"size": sz, "n": len(rs), "pf_only_ms": pf,
"total_ms": tot, "overhead_ms": overhead,
"all_correct": allok})
print(f"{sz:>7} {len(rs):>2} {pf:>11.0f} {tot:>9.0f} {overhead:>12.0f} "
f"{str(allok):>8}")
Path(a.out).write_text(json.dumps(
{"mode": a.mode, "model": MODEL, "raw": results, "summary": summary}, indent=2))
print(f"\n[mb7] wrote {a.out}")
def main():
p = argparse.ArgumentParser()
p.add_argument("--mode", choices=["baseline", "layerwise"], required=True)
p.add_argument("--src-host", default="127.0.0.1")
p.add_argument("--dst-host", default="127.0.0.1")
p.add_argument("--src-port", type=int, default=8000)
p.add_argument("--dst-port", type=int, default=8001)
p.add_argument("--src-bp", type=int, default=8998)
p.add_argument("--dst-bp", type=int, default=8999)
p.add_argument("--sizes", default="8192,32768,65536")
p.add_argument("--repeats", type=int, default=3)
p.add_argument("--bg-load", type=int, default=0,
help="N concurrent background decode streams across A+B")
p.add_argument("--concurrent", type=int, default=1,
help="layerwise: fire N simultaneous transfers to test "
"concurrent-producing correctness")
p.add_argument("--out", default="mb7_result.json")
args = p.parse_args()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""P2: real-state-aware migration target selection.
Pure helpers (no proxy deps) so they're unit-testable. The router calls
`rank_migration_targets` to pick the decode target, using REAL engine state
(from the engine-state store) when available, falling back to shadow counters.
Key fix over the shadow-only Mechanism B: deprioritise targets that are
mid-large-prefill (`max_prefill_remaining` high) — those hold the GIL and
stall the mooncake receiver_loop, which is the ~45% control-plane residual
that layer-wise transfer does NOT fix. Also avoid targets near the KV
capacity wall (`gpu_kv_used_frac` high).
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class TargetCandidate:
idx: int
cache_hit: int # estimated transfer bytes saved (tokens)
shadow_num_req: int # proxy shadow counter (fallback)
ongoing_tokens: int # shadow tertiary
real_state: dict | None = None # engine-state record, or None if stale/missing
def real_load(c: TargetCandidate) -> float:
"""Effective load: prefer real (running + waiting); else shadow."""
rs = c.real_state
if rs is not None:
return float(rs.get("num_running", 0) + rs.get("num_waiting", 0))
return float(c.shadow_num_req)
def big_prefill_remaining(c: TargetCandidate) -> int:
"""Largest in-progress prefill on the candidate (GIL-stall predictor).
0 when unknown (no real state) so we don't over-penalise blind."""
rs = c.real_state
return int(rs.get("max_prefill_remaining", 0)) if rs is not None else 0
def kv_used_frac(c: TargetCandidate) -> float:
rs = c.real_state
if rs is not None:
f = rs.get("gpu_kv_used_frac", -1.0)
return float(f) if f is not None and f >= 0 else 0.0
return 0.0
def target_sort_key(
c: TargetCandidate,
big_prefill_threshold: int = 16000,
kv_wall_frac: float = 0.90,
):
"""Sort key (lower = better). Ordering of concerns:
1. NOT mid-large-prefill (avoid the GIL-stall dst) [bool]
2. NOT near the KV capacity wall [bool]
3. most cache-rich (fewest transfer bytes) -> -cache_hit
4. lowest real load
5. lowest ongoing_tokens (shadow tertiary tie-break)
"""
stalls = 1 if big_prefill_remaining(c) >= big_prefill_threshold else 0
near_wall = 1 if kv_used_frac(c) >= kv_wall_frac else 0
return (stalls, near_wall, -c.cache_hit, real_load(c), c.ongoing_tokens)
def rank_migration_targets(
candidates: list[TargetCandidate],
big_prefill_threshold: int = 16000,
kv_wall_frac: float = 0.90,
) -> TargetCandidate | None:
"""Return the best candidate, or None if the list is empty."""
if not candidates:
return None
return min(
candidates,
key=lambda c: target_sort_key(c, big_prefill_threshold, kv_wall_frac),
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
{
"mode": "baseline",
"model": "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct",
"raw": [
{
"t_prefill_s": 0.5736213000018324,
"t_xfer_s": 0.36388630099827424,
"t_total_s": 0.9375749369974073,
"cached": 8176,
"mode": "baseline",
"size": 8192,
"rep": 0,
"t_prefill_only_s": 1.0551288530004967,
"kv_gib": 0.75,
"correct": true
},
{
"t_prefill_s": 0.5740011439993395,
"t_xfer_s": 0.12374231500143651,
"t_total_s": 0.6978207100000873,
"cached": 8176,
"mode": "baseline",
"size": 8192,
"rep": 1,
"t_prefill_only_s": 0.5743715360003989,
"kv_gib": 0.75,
"correct": true
},
{
"t_prefill_s": 0.5732713990000775,
"t_xfer_s": 0.10885842400239198,
"t_total_s": 0.6821924389987544,
"cached": 8176,
"mode": "baseline",
"size": 8192,
"rep": 2,
"t_prefill_only_s": 0.5745713680007611,
"kv_gib": 0.75,
"correct": true
},
{
"t_prefill_s": 1.4892208660021424,
"t_xfer_s": 0.2091717740004242,
"t_total_s": 1.6984740270017937,
"cached": 16368,
"mode": "baseline",
"size": 16384,
"rep": 0,
"t_prefill_only_s": 1.4990949730017746,
"kv_gib": 1.5,
"correct": true
},
{
"t_prefill_s": 1.4885207330007688,
"t_xfer_s": 0.2010940889995254,
"t_total_s": 1.6896768289989268,
"cached": 16368,
"mode": "baseline",
"size": 16384,
"rep": 1,
"t_prefill_only_s": 1.4898170189990196,
"kv_gib": 1.5,
"correct": true
},
{
"t_prefill_s": 1.4895933570005582,
"t_xfer_s": 0.2026357979993918,
"t_total_s": 1.6922962099997676,
"cached": 16368,
"mode": "baseline",
"size": 16384,
"rep": 2,
"t_prefill_only_s": 1.4907751430000644,
"kv_gib": 1.5,
"correct": true
},
{
"t_prefill_s": 4.438586502998078,
"t_xfer_s": 0.37847799000155646,
"t_total_s": 4.817142683001293,
"cached": 32752,
"mode": "baseline",
"size": 32768,
"rep": 0,
"t_prefill_only_s": 4.437922253000579,
"kv_gib": 3.0,
"correct": true
},
{
"t_prefill_s": 4.4350325649975275,
"t_xfer_s": 0.5313337980005599,
"t_total_s": 4.966431269000168,
"cached": 32752,
"mode": "baseline",
"size": 32768,
"rep": 1,
"t_prefill_only_s": 4.437473922000208,
"kv_gib": 3.0,
"correct": true
},
{
"t_prefill_s": 4.436279826000828,
"t_xfer_s": 0.6335160570015432,
"t_total_s": 5.069869226001174,
"cached": 32752,
"mode": "baseline",
"size": 32768,
"rep": 2,
"t_prefill_only_s": 4.440119222999783,
"kv_gib": 3.0,
"correct": true
}
],
"summary": [
{
"size": 8192,
"n": 3,
"pf_only_ms": 574.5713680007611,
"total_ms": 697.8207100000873,
"overhead_ms": 123.24934199932613,
"all_correct": true
},
{
"size": 16384,
"n": 3,
"pf_only_ms": 1490.7751430000644,
"total_ms": 1692.2962099997676,
"overhead_ms": 201.52106699970318,
"all_correct": true
},
{
"size": 32768,
"n": 3,
"pf_only_ms": 4437.922253000579,
"total_ms": 4966.431269000168,
"overhead_ms": 528.5090159995889,
"all_correct": true
}
]
}

View File

@@ -0,0 +1,140 @@
{
"mode": "baseline",
"model": "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct",
"raw": [
{
"t_prefill_s": 0.5868483350022871,
"t_xfer_s": 0.19584889299949282,
"t_total_s": 0.7827702419999696,
"cached": 8176,
"mode": "baseline",
"size": 8192,
"rep": 0,
"t_prefill_only_s": 0.5920699099988269,
"kv_gib": 0.75,
"correct": true
},
{
"t_prefill_s": 0.5875704979989678,
"t_xfer_s": 0.1554814909977722,
"t_total_s": 0.7431365060001554,
"cached": 8176,
"mode": "baseline",
"size": 8192,
"rep": 1,
"t_prefill_only_s": 0.5814537600017502,
"kv_gib": 0.75,
"correct": true
},
{
"t_prefill_s": 0.5852241569991747,
"t_xfer_s": 0.15129724399957922,
"t_total_s": 0.7365909610016388,
"cached": 8176,
"mode": "baseline",
"size": 8192,
"rep": 2,
"t_prefill_only_s": 0.5846994370003813,
"kv_gib": 0.75,
"correct": true
},
{
"t_prefill_s": 1.498547145001794,
"t_xfer_s": 0.2475714690008317,
"t_total_s": 1.7462187470009667,
"cached": 16368,
"mode": "baseline",
"size": 16384,
"rep": 0,
"t_prefill_only_s": 1.5670790190015396,
"kv_gib": 1.5,
"correct": true
},
{
"t_prefill_s": 1.5025789940009417,
"t_xfer_s": 0.24532966799961287,
"t_total_s": 1.7479741930001182,
"cached": 16368,
"mode": "baseline",
"size": 16384,
"rep": 1,
"t_prefill_only_s": 1.5008903820016712,
"kv_gib": 1.5,
"correct": true
},
{
"t_prefill_s": 1.5021674179988622,
"t_xfer_s": 0.24640760400143336,
"t_total_s": 1.7486415580024186,
"cached": 16368,
"mode": "baseline",
"size": 16384,
"rep": 2,
"t_prefill_only_s": 1.509417139001016,
"kv_gib": 1.5,
"correct": true
},
{
"t_prefill_s": 4.444555983998725,
"t_xfer_s": 0.4227471090016479,
"t_total_s": 4.86737214599998,
"cached": 32752,
"mode": "baseline",
"size": 32768,
"rep": 0,
"t_prefill_only_s": 4.4467717689985875,
"kv_gib": 3.0,
"correct": true
},
{
"t_prefill_s": 4.442135782999685,
"t_xfer_s": 0.7519038230020669,
"t_total_s": 5.194113359000767,
"cached": 32752,
"mode": "baseline",
"size": 32768,
"rep": 1,
"t_prefill_only_s": 4.445541313998547,
"kv_gib": 3.0,
"correct": true
},
{
"t_prefill_s": 4.439772993999213,
"t_xfer_s": 0.7855456319994119,
"t_total_s": 5.225392060998274,
"cached": 32752,
"mode": "baseline",
"size": 32768,
"rep": 2,
"t_prefill_only_s": 4.442906365002273,
"kv_gib": 3.0,
"correct": true
}
],
"summary": [
{
"size": 8192,
"n": 3,
"pf_only_ms": 584.6994370003813,
"total_ms": 743.1365060001554,
"overhead_ms": 158.43706899977406,
"all_correct": true
},
{
"size": 16384,
"n": 3,
"pf_only_ms": 1509.417139001016,
"total_ms": 1747.9741930001182,
"overhead_ms": 238.5570539991022,
"all_correct": true
},
{
"size": 32768,
"n": 3,
"pf_only_ms": 4445.541313998547,
"total_ms": 5194.113359000767,
"overhead_ms": 748.57204500222,
"all_correct": true
}
]
}

View File

@@ -0,0 +1,140 @@
{
"mode": "layerwise",
"model": "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct",
"raw": [
{
"t_A_s": 0.5749198459998297,
"t_B_s": 0.6508419569981925,
"t_total_s": 0.6509377910006151,
"cached": 8176,
"mode": "layerwise",
"size": 8192,
"rep": 0,
"t_prefill_only_s": 1.0447357020020718,
"kv_gib": 0.75,
"correct": true
},
{
"t_A_s": 0.574626908000937,
"t_B_s": 0.6306310719992325,
"t_total_s": 0.6307087300010608,
"cached": 8176,
"mode": "layerwise",
"size": 8192,
"rep": 1,
"t_prefill_only_s": 0.5731983850018878,
"kv_gib": 0.75,
"correct": true
},
{
"t_A_s": 0.5756587910000235,
"t_B_s": 0.6316753270002664,
"t_total_s": 0.6317471290021786,
"cached": 8176,
"mode": "layerwise",
"size": 8192,
"rep": 2,
"t_prefill_only_s": 0.5737888650000968,
"kv_gib": 0.75,
"correct": true
},
{
"t_A_s": 1.4953326409995498,
"t_B_s": 1.5502465710014803,
"t_total_s": 1.5503262860001996,
"cached": 16368,
"mode": "layerwise",
"size": 16384,
"rep": 0,
"t_prefill_only_s": 1.5000705940001353,
"kv_gib": 1.5,
"correct": true
},
{
"t_A_s": 1.493850356000621,
"t_B_s": 1.5505031290012994,
"t_total_s": 1.5505791659998067,
"cached": 16368,
"mode": "layerwise",
"size": 16384,
"rep": 1,
"t_prefill_only_s": 1.4924546469992492,
"kv_gib": 1.5,
"correct": true
},
{
"t_A_s": 1.4979969070009247,
"t_B_s": 1.554968774002191,
"t_total_s": 1.5551903560008213,
"cached": 16368,
"mode": "layerwise",
"size": 16384,
"rep": 2,
"t_prefill_only_s": 1.4914496510027675,
"kv_gib": 1.5,
"correct": true
},
{
"t_A_s": 4.4403588690001925,
"t_B_s": 4.496483378999983,
"t_total_s": 4.4965666819989565,
"cached": 32752,
"mode": "layerwise",
"size": 32768,
"rep": 0,
"t_prefill_only_s": 4.440080869000667,
"kv_gib": 3.0,
"correct": true
},
{
"t_A_s": 4.44209005599987,
"t_B_s": 4.499940814999718,
"t_total_s": 4.500021006002498,
"cached": 32752,
"mode": "layerwise",
"size": 32768,
"rep": 1,
"t_prefill_only_s": 4.440225810998527,
"kv_gib": 3.0,
"correct": true
},
{
"t_A_s": 4.437084657998639,
"t_B_s": 4.496842522999941,
"t_total_s": 4.496926485000586,
"cached": 32752,
"mode": "layerwise",
"size": 32768,
"rep": 2,
"t_prefill_only_s": 4.439449855002749,
"kv_gib": 3.0,
"correct": true
}
],
"summary": [
{
"size": 8192,
"n": 3,
"pf_only_ms": 573.7888650000968,
"total_ms": 631.7471290021786,
"overhead_ms": 57.958264002081705,
"all_correct": true
},
{
"size": 16384,
"n": 3,
"pf_only_ms": 1492.4546469992492,
"total_ms": 1550.5791659998067,
"overhead_ms": 58.124519000557484,
"all_correct": true
},
{
"size": 32768,
"n": 3,
"pf_only_ms": 4440.080869000667,
"total_ms": 4496.926485000586,
"overhead_ms": 56.845615999918664,
"all_correct": true
}
]
}

View File

@@ -0,0 +1,140 @@
{
"mode": "layerwise",
"model": "/home/admin/cpfs/wjh/models/Qwen/Qwen3-Coder-30B-A3B-Instruct",
"raw": [
{
"t_A_s": 0.5905098549992545,
"t_B_s": 0.6900827390018094,
"t_total_s": 0.6904724189989793,
"cached": 8176,
"mode": "layerwise",
"size": 8192,
"rep": 0,
"t_prefill_only_s": 0.5852864849985053,
"kv_gib": 0.75,
"correct": true
},
{
"t_A_s": 0.5897548109969648,
"t_B_s": 0.6827381169969158,
"t_total_s": 0.6828304180016858,
"cached": 8176,
"mode": "layerwise",
"size": 8192,
"rep": 1,
"t_prefill_only_s": 0.5890174580017629,
"kv_gib": 0.75,
"correct": true
},
{
"t_A_s": 0.5850713190011447,
"t_B_s": 0.6744917560026806,
"t_total_s": 0.6745770380002796,
"cached": 8176,
"mode": "layerwise",
"size": 8192,
"rep": 2,
"t_prefill_only_s": 0.5943713950000529,
"kv_gib": 0.75,
"correct": true
},
{
"t_A_s": 1.5030149390004226,
"t_B_s": 1.596173029000056,
"t_total_s": 1.597060264000902,
"cached": 16368,
"mode": "layerwise",
"size": 16384,
"rep": 0,
"t_prefill_only_s": 1.5130829510017065,
"kv_gib": 1.5,
"correct": true
},
{
"t_A_s": 1.499876754998695,
"t_B_s": 1.5940461120007967,
"t_total_s": 1.5948001770011615,
"cached": 16368,
"mode": "layerwise",
"size": 16384,
"rep": 1,
"t_prefill_only_s": 1.5024838620010996,
"kv_gib": 1.5,
"correct": true
},
{
"t_A_s": 1.5068977490009274,
"t_B_s": 1.5950395179970656,
"t_total_s": 1.59571184500237,
"cached": 16368,
"mode": "layerwise",
"size": 16384,
"rep": 2,
"t_prefill_only_s": 1.5303227439981129,
"kv_gib": 1.5,
"correct": true
},
{
"t_A_s": 4.4503932609986805,
"t_B_s": 4.538851200999488,
"t_total_s": 4.539281312001549,
"cached": 32752,
"mode": "layerwise",
"size": 32768,
"rep": 0,
"t_prefill_only_s": 4.446753306998289,
"kv_gib": 3.0,
"correct": true
},
{
"t_A_s": 4.44226107799841,
"t_B_s": 4.551636377997056,
"t_total_s": 4.552389411001059,
"cached": 32752,
"mode": "layerwise",
"size": 32768,
"rep": 1,
"t_prefill_only_s": 4.44538704000297,
"kv_gib": 3.0,
"correct": true
},
{
"t_A_s": 4.440309538000292,
"t_B_s": 4.539836316998844,
"t_total_s": 4.540553365997766,
"cached": 32752,
"mode": "layerwise",
"size": 32768,
"rep": 2,
"t_prefill_only_s": 4.443476915999781,
"kv_gib": 3.0,
"correct": true
}
],
"summary": [
{
"size": 8192,
"n": 3,
"pf_only_ms": 589.0174580017629,
"total_ms": 682.8304180016858,
"overhead_ms": 93.8129599999229,
"all_correct": true
},
{
"size": 16384,
"n": 3,
"pf_only_ms": 1513.0829510017065,
"total_ms": 1595.71184500237,
"overhead_ms": 82.62889400066342,
"all_correct": true
},
{
"size": 32768,
"n": 3,
"pf_only_ms": 4445.38704000297,
"total_ms": 4540.553365997766,
"overhead_ms": 95.16632599479635,
"all_correct": true
}
]
}

Some files were not shown because too many files have changed in this diff Show More