Follow-up to Microbench 3 that finally tests H5 (cache-size
dependence) and instruments worker-side connector callbacks the
original patch missed.
Patch v2 (apply_step_timing_v2.py) adds:
scheduler: `cache_size` field in engine_step.jsonl
worker: `get_finished_us` + `start_load_kv_us` in worker_step.r0.jsonl
uses BLOCK_BEGIN/END sentinels for safe multi-line revert
(the original v1 patch survives this v2's apply/revert cycle)
Driver: continuous open-loop (1.5 req/s, 4096x256 random per req)
that lets APC fill from 0 → ceiling within one vLLM lifetime so a
single run produces the full cache_size sweep. Decode-only steps
are filtered post-hoc to remove prefill-mix variance.
Findings (H20 96GB, ceiling reached ~17.5k blocks; n=15-18k decode
steps per config):
config | slope (μs / 1k blocks) | step_dur p50 @ |cache|=16.6k
---------------|------------------------|-----------------------------
mooncake_both | +85.6 | 1528 μs (build_meta=1442, 94%)
noop_connector | -0.8 (≈0) | 79 μs
plain | +1.0 (≈0) | 84 μs
Worker-side get_finished p50/p90/p99 (μs/step):
mooncake_both: 180 / 257 / 333
noop_connector: 0 / 0 / 2
H5 PASSES. mooncake_both step_duration scales linearly with |cache|
because build_connector_meta walks set(cache.keys()) every step
(`mooncake_connector.py:434-450`). plain and noop are flat.
The previously-uninstrumented get_finished() adds a constant
180 μs/step on top — two `run_coroutine_threadsafe(...).result()`
blocking waits in kv_both mode (`mooncake_connector.py:1107-1137`)
fire every step even when no transfer is pending.
Trace-replay reconciliation (APC ≈ 79% → |cache| ≈ 13k blocks):
build_meta @ 13k ≈ 1060 μs + get_finished ≈ 180 μs = 1.24 ms/step
On ~7 ms decode forward → +15-20% TPOT per step.
This explains most of the trace-replay +25% TPOT p90 gap from
single-instance per-step cost alone, leaving a smaller residual
for multi-instance coupling than originally assumed.
Two clear fixes pointed out in REPORT.md:
1. replace O(|cache|) per-step walk with incremental delta
listener using block_pool's add/remove callbacks
2. short-circuit get_finished() when both producer/consumer
queues are empty in kv_both
Heavy raw artifacts (engine_step.jsonl, vllm_stdout/stderr,
.vllm.pid) are .gitignored — they re-derive from `bash run_all.sh`
and SUMMARY.md / per_config.json fully capture the conclusions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
301 lines
12 KiB
Python
Executable File
301 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Analyse cache-size sweep results.
|
|
|
|
For each subdir under --run-root containing engine_step.jsonl:
|
|
- read all per-step records
|
|
- bin by cache_size
|
|
- report median/p90 of step_duration_us, build_meta_us per bin
|
|
- fit step_duration_us ~ a + b * cache_size (linear least squares)
|
|
- tabulate connector tax(cache_size) = mc_step - plain_step (if plain present)
|
|
- optionally render matplotlib plot if matplotlib available
|
|
|
|
Outputs:
|
|
results/<date>/SUMMARY.md human report
|
|
results/<date>/per_config.json machine-readable
|
|
results/<date>/figure.png (optional)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import statistics
|
|
from pathlib import Path
|
|
|
|
|
|
def load_steps(p: Path):
|
|
rows = []
|
|
with open(p) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
rows.append(json.loads(line))
|
|
except Exception:
|
|
pass
|
|
return rows
|
|
|
|
|
|
def percentile(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 linfit(xs, ys):
|
|
"""Tiny linear least squares. Returns (slope, intercept)."""
|
|
n = len(xs)
|
|
if n < 2:
|
|
return None, None
|
|
sx = sum(xs); sy = sum(ys); sxx = sum(x*x for x in xs)
|
|
sxy = sum(x*y for x, y in zip(xs, ys))
|
|
denom = n * sxx - sx * sx
|
|
if denom == 0:
|
|
return None, None
|
|
b = (n * sxy - sx * sy) / denom
|
|
a = (sy - b * sx) / n
|
|
return b, a
|
|
|
|
|
|
def bucket(rows, key="cache_size", n_bins=10):
|
|
"""Equal-width bin on cache_size; returns dict bin_id -> list of rows."""
|
|
if not rows:
|
|
return {}
|
|
vmax = max(r.get(key, 0) for r in rows)
|
|
if vmax <= 0:
|
|
return {}
|
|
width = vmax / n_bins
|
|
out: dict[int, list[dict]] = {}
|
|
for r in rows:
|
|
v = r.get(key, 0)
|
|
bid = min(n_bins - 1, max(0, int(v / width))) if width > 0 else 0
|
|
out.setdefault(bid, []).append(r)
|
|
return out, width
|
|
|
|
|
|
def analyse_config(cfg_name: str, cfg_dir: Path):
|
|
eng_path = cfg_dir / "engine_step.jsonl"
|
|
if not eng_path.exists() or eng_path.stat().st_size == 0:
|
|
return None
|
|
raw = load_steps(eng_path)
|
|
if not raw:
|
|
return None
|
|
|
|
# Filter: skip first 500 steps (cold start), and steps with no cache_size.
|
|
base = [r for r in raw[500:] if r.get("cache_size", -1) >= 0]
|
|
if not base:
|
|
return None
|
|
|
|
# Decode-only filter: steps where the scheduler did NOT touch any
|
|
# new/resumed request and total tokens == n_running_total (each running
|
|
# request emits exactly one token). This gives the cleanest per-step
|
|
# baseline since prefill chunks dominate step time at high token counts.
|
|
decode_only = [r for r in base
|
|
if r.get("prefill_tokens", 0) == 0
|
|
and r.get("decode_tokens", 0) > 0]
|
|
# Fall back to "all post-warmup" if decode-only is too sparse
|
|
rows = decode_only if len(decode_only) >= 200 else base
|
|
decode_share = len(decode_only) / max(1, len(base))
|
|
|
|
cache_max = max(r["cache_size"] for r in rows)
|
|
bins, width = bucket(rows, n_bins=10)
|
|
|
|
per_bin = []
|
|
for bid in sorted(bins):
|
|
rs = bins[bid]
|
|
sd = [r["step_duration_us"] for r in rs if "step_duration_us" in r]
|
|
bm = [r.get("build_meta_us", 0) for r in rs]
|
|
cs = [r["cache_size"] for r in rs]
|
|
per_bin.append({
|
|
"bin_id": bid,
|
|
"cache_size_mid": (bid + 0.5) * width,
|
|
"n": len(rs),
|
|
"cache_size_p50": percentile(cs, 50),
|
|
"step_duration_us_p50": percentile(sd, 50),
|
|
"step_duration_us_p90": percentile(sd, 90),
|
|
"build_meta_us_p50": percentile(bm, 50),
|
|
"build_meta_us_p90": percentile(bm, 90),
|
|
})
|
|
|
|
# Fit per-step duration vs cache size on all records (not bin averages)
|
|
sd_b, sd_a = linfit([r["cache_size"] for r in rows if "step_duration_us" in r],
|
|
[r["step_duration_us"] for r in rows if "step_duration_us" in r])
|
|
bm_b, bm_a = linfit([r["cache_size"] for r in rows if "build_meta_us" in r],
|
|
[r.get("build_meta_us", 0) for r in rows if "build_meta_us" in r])
|
|
|
|
# Worker-side timings if available. Filename is `worker_step.r<rank>.jsonl`
|
|
# because os.path.splitext keeps the .jsonl extension.
|
|
worker_path = None
|
|
for c in sorted(cfg_dir.glob("worker_step.r*.jsonl")):
|
|
worker_path = c
|
|
break
|
|
if worker_path is None:
|
|
worker_path = cfg_dir / "missing"
|
|
worker_summary = None
|
|
if worker_path.exists() and worker_path.stat().st_size > 0:
|
|
wrows = load_steps(worker_path)
|
|
if wrows:
|
|
gf = [r["get_finished_us"] for r in wrows if "get_finished_us" in r]
|
|
sl = [r["start_load_kv_us"] for r in wrows if "start_load_kv_us" in r]
|
|
worker_summary = {
|
|
"n": len(wrows),
|
|
"get_finished_us_p50": percentile(gf, 50),
|
|
"get_finished_us_p90": percentile(gf, 90),
|
|
"get_finished_us_p99": percentile(gf, 99),
|
|
"start_load_kv_us_p50": percentile(sl, 50),
|
|
"start_load_kv_us_p90": percentile(sl, 90),
|
|
}
|
|
|
|
return {
|
|
"config": cfg_name,
|
|
"n_steps_total": len(raw),
|
|
"n_steps_after_warmup": len(base),
|
|
"n_steps_decode_only": len(decode_only),
|
|
"decode_share": decode_share,
|
|
"rows_used_for_fit": "decode_only" if rows is decode_only else "all_post_warmup",
|
|
"cache_size_max": cache_max,
|
|
"per_bin": per_bin,
|
|
"fit_step_duration": {"slope_us_per_block": sd_b, "intercept_us": sd_a},
|
|
"fit_build_meta": {"slope_us_per_block": bm_b, "intercept_us": bm_a},
|
|
"worker_summary": worker_summary,
|
|
}
|
|
|
|
|
|
def render(root: Path, all_cfg: dict):
|
|
lines = ["# Cache-size sweep — summary\n"]
|
|
lines.append(f"Run root: `{root}`\n")
|
|
lines.append("## Per-config fit (`step_duration_us ≈ a + b · cache_size`)\n")
|
|
lines.append("| config | n steps | cache max | step_dur p50 (μs) | build_meta p50 (μs) | slope (μs / 1k blocks) | intercept (μs) |")
|
|
lines.append("|---|---:|---:|---:|---:|---:|---:|")
|
|
for cfg, r in all_cfg.items():
|
|
if r is None:
|
|
lines.append(f"| {cfg} | — | — | — | — | — | — |")
|
|
continue
|
|
last_bin = r["per_bin"][-1] if r["per_bin"] else {}
|
|
slope = r["fit_step_duration"]["slope_us_per_block"]
|
|
intercept = r["fit_step_duration"]["intercept_us"]
|
|
slope1k = (slope or 0) * 1000
|
|
lines.append(
|
|
f"| {cfg} | {r['n_steps_after_warmup']} | {r['cache_size_max']} | "
|
|
f"{last_bin.get('step_duration_us_p50','-')} | "
|
|
f"{last_bin.get('build_meta_us_p50','-')} | "
|
|
f"{slope1k:.1f} | {intercept:.1f} |"
|
|
if slope is not None else
|
|
f"| {cfg} | {r['n_steps_after_warmup']} | {r['cache_size_max']} | - | - | - | - |"
|
|
)
|
|
|
|
# Per-bin tables
|
|
for cfg, r in all_cfg.items():
|
|
if r is None:
|
|
continue
|
|
lines.append(f"\n### {cfg} — per-bin\n")
|
|
lines.append("| bin | cache mid | n | step_dur p50 | step_dur p90 | build_meta p50 | build_meta p90 |")
|
|
lines.append("|---:|---:|---:|---:|---:|---:|---:|")
|
|
for b in r["per_bin"]:
|
|
lines.append(
|
|
f"| {b['bin_id']} | {b['cache_size_mid']:.0f} | {b['n']} | "
|
|
f"{b['step_duration_us_p50']} | {b['step_duration_us_p90']} | "
|
|
f"{b['build_meta_us_p50']} | {b['build_meta_us_p90']} |"
|
|
)
|
|
if r["worker_summary"]:
|
|
w = r["worker_summary"]
|
|
lines.append(f"\n*worker side (n={w['n']})* — "
|
|
f"get_finished p50/p90/p99 = "
|
|
f"{w['get_finished_us_p50']}/{w['get_finished_us_p90']}/"
|
|
f"{w['get_finished_us_p99']} μs; "
|
|
f"start_load_kv p50/p90 = "
|
|
f"{w['start_load_kv_us_p50']}/{w['start_load_kv_us_p90']} μs\n")
|
|
|
|
# Tax vs cache for mc vs plain
|
|
plain = all_cfg.get("plain")
|
|
mc = all_cfg.get("mooncake_both")
|
|
noop = all_cfg.get("noop_connector")
|
|
if plain and mc:
|
|
lines.append("\n## Connector tax(cache_size) — mooncake_both vs plain\n")
|
|
lines.append("| bin | cache mid | plain step p50 | mc step p50 | tax (μs) | tax (%) |")
|
|
lines.append("|---:|---:|---:|---:|---:|---:|")
|
|
for bp, bm in zip(plain["per_bin"], mc["per_bin"]):
|
|
if bp["step_duration_us_p50"] and bm["step_duration_us_p50"]:
|
|
tax = bm["step_duration_us_p50"] - bp["step_duration_us_p50"]
|
|
pct = tax / bp["step_duration_us_p50"] * 100
|
|
lines.append(
|
|
f"| {bp['bin_id']} | {bp['cache_size_mid']:.0f} | "
|
|
f"{bp['step_duration_us_p50']} | {bm['step_duration_us_p50']} | "
|
|
f"{tax:+d} | {pct:+.1f} |"
|
|
)
|
|
if plain and noop:
|
|
# Framework cost: noop_connector tax = pure dispatch
|
|
lines.append("\n## Framework cost — noop_connector vs plain\n")
|
|
lines.append("| bin | cache mid | plain step p50 | noop step p50 | tax (μs) |")
|
|
lines.append("|---:|---:|---:|---:|---:|")
|
|
for bp, bn in zip(plain["per_bin"], noop["per_bin"]):
|
|
if bp["step_duration_us_p50"] and bn["step_duration_us_p50"]:
|
|
tax = bn["step_duration_us_p50"] - bp["step_duration_us_p50"]
|
|
lines.append(
|
|
f"| {bp['bin_id']} | {bp['cache_size_mid']:.0f} | "
|
|
f"{bp['step_duration_us_p50']} | {bn['step_duration_us_p50']} | "
|
|
f"{tax:+d} |"
|
|
)
|
|
|
|
out_md = root / "SUMMARY.md"
|
|
out_md.write_text("\n".join(lines) + "\n")
|
|
out_json = root / "per_config.json"
|
|
out_json.write_text(json.dumps(all_cfg, indent=2, default=str))
|
|
print(f" wrote {out_md}")
|
|
print(f" wrote {out_json}")
|
|
|
|
# Optional plot
|
|
try:
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))
|
|
colors = {"plain": "tab:blue", "noop_connector": "tab:orange",
|
|
"mooncake_both": "tab:red"}
|
|
for cfg, r in all_cfg.items():
|
|
if r is None: continue
|
|
xs = [b["cache_size_mid"] for b in r["per_bin"]]
|
|
ys = [b["step_duration_us_p50"] or 0 for b in r["per_bin"]]
|
|
zs = [b["build_meta_us_p50"] or 0 for b in r["per_bin"]]
|
|
c = colors.get(cfg, None)
|
|
ax1.plot(xs, ys, marker="o", label=cfg, color=c)
|
|
ax2.plot(xs, zs, marker="s", label=cfg, color=c)
|
|
ax1.set_xlabel("cache_size (blocks)")
|
|
ax1.set_ylabel("step_duration_us p50")
|
|
ax1.set_title("Per-step scheduler time vs |cache|")
|
|
ax1.legend(); ax1.grid(True, alpha=0.3)
|
|
ax2.set_xlabel("cache_size (blocks)")
|
|
ax2.set_ylabel("build_meta_us p50")
|
|
ax2.set_title("build_connector_meta time vs |cache|")
|
|
ax2.legend(); ax2.grid(True, alpha=0.3)
|
|
fig.tight_layout()
|
|
fig.savefig(root / "figure.png", dpi=120)
|
|
print(f" wrote {root/'figure.png'}")
|
|
except Exception as e:
|
|
print(f" (skipped plot: {e})")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--run-root", type=Path, required=True)
|
|
args = ap.parse_args()
|
|
|
|
cfgs = {}
|
|
for d in sorted(args.run_root.iterdir()):
|
|
if not d.is_dir(): continue
|
|
r = analyse_config(d.name, d)
|
|
cfgs[d.name] = r
|
|
if r:
|
|
sl = r["fit_step_duration"]["slope_us_per_block"]
|
|
print(f" {d.name}: n={r['n_steps_after_warmup']} "
|
|
f"cache_max={r['cache_size_max']} "
|
|
f"slope={(sl or 0)*1000:.2f} μs/1k blocks")
|
|
else:
|
|
print(f" {d.name}: no data")
|
|
render(args.run_root, cfgs)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|