#!/usr/bin/env python3 """Prefill-Decode Interference Microbenchmark Driver. Measures TPOT degradation caused by prefill chunks interfering with ongoing decode batches. Produces: f(decode_batch_size, new_prefill_tokens, chunk_size) -> TPOT_penalty_ms Usage: python driver.py --host 127.0.0.1 --port 8000 \ --decode-batch-sizes 0,1,2,4,6,8,12 \ --prefill-tokens 512,1024,2048,4096,8192,16384,32768 \ --reps 5 --output-dir results/ """ import argparse import asyncio import json import os import time from dataclasses import dataclass, asdict from pathlib import Path from typing import Optional import httpx import numpy as np FIXED_SEED_PROMPT = ( "You are a helpful assistant. Please analyze the following document carefully " "and provide a comprehensive summary covering all key points, main arguments, " "supporting evidence, and conclusions. The document discusses various aspects " "of distributed systems, including consensus protocols, fault tolerance mechanisms, " "and performance optimization strategies for large-scale deployments.\n\n" ) * 50 # ~4k tokens worth of repeated text for prefix cache sharing WARMUP_TOKENS = 32 MEASURE_WINDOW_TOKENS = 500 @dataclass class Config: decode_batch_size: int new_prefill_tokens: int chunk_size: int model: str repetition: int @dataclass class BaselineResult: tpot_p50_ms: float tpot_p90_ms: float tpot_p99_ms: float tokens_collected: int @dataclass class InterferenceResult: tpot_during_prefill_p50_ms: float tpot_during_prefill_p90_ms: float tpot_after_prefill_p50_ms: float prefill_ttft_ms: float num_tokens_during_prefill: int async def stream_tokens(client: httpx.AsyncClient, url: str, payload: dict) -> list[float]: """Send a streaming request, return list of timestamps (seconds) for each token.""" timestamps = [] async with client.stream("POST", url, json=payload, timeout=300.0) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if line.startswith("data: "): data = line[6:] if data.strip() == "[DONE]": break try: chunk = json.loads(data) choices = chunk.get("choices", []) if not choices: continue delta = choices[0].get("delta", {}) if "role" in delta: continue timestamps.append(time.perf_counter()) except json.JSONDecodeError: continue return timestamps def compute_tpot(timestamps: list[float], skip_first: int = 0) -> np.ndarray: """Compute inter-token intervals in ms, skipping first N tokens.""" if len(timestamps) < skip_first + 2: return np.array([]) ts = np.array(timestamps[skip_first:]) return np.diff(ts) * 1000.0 # seconds → ms def make_decode_payload(model: str) -> dict: return { "model": model, "messages": [{"role": "user", "content": FIXED_SEED_PROMPT}], "max_tokens": WARMUP_TOKENS + MEASURE_WINDOW_TOKENS + 50, "temperature": 0, "stream": True, } def make_prefill_payload(model: str, num_tokens: int) -> dict: import hashlib import uuid # Generate UNIQUE content every call to guarantee zero prefix cache hits. # Calibration: each "Block N: <32-hex>" → ~35 tokens after tokenization unique_id = f"{uuid.uuid4().hex}_{time.time_ns()}" n_parts = max(1, num_tokens // 35) content_parts = [] for i in range(n_parts): seed = hashlib.md5(f"{unique_id}_{i}".encode()).hexdigest() content_parts.append(f"Block {i}: {seed}") content = " ".join(content_parts) return { "model": model, "messages": [{"role": "user", "content": content}], "max_tokens": 1, "temperature": 0, "stream": True, } async def wait_for_steady_state(decode_streams: list[asyncio.Task], min_tokens: int = 32): """Wait until all decode streams have emitted at least min_tokens.""" # We don't directly control this — we wait a fixed time based on expected TPOT # At ~50ms/token, 32 tokens ≈ 1.6s. Wait 3s to be safe. await asyncio.sleep(3.0) async def run_baseline( client: httpx.AsyncClient, url: str, model: str, decode_batch_size: int ) -> Optional[BaselineResult]: """Measure decode-only TPOT (no prefill interference).""" if decode_batch_size == 0: return BaselineResult(tpot_p50_ms=0, tpot_p90_ms=0, tpot_p99_ms=0, tokens_collected=0) payloads = [make_decode_payload(model) for _ in range(decode_batch_size)] tasks = [asyncio.create_task(stream_tokens(client, url, p)) for p in payloads] all_timestamps = await asyncio.gather(*tasks, return_exceptions=True) all_tpots = [] for ts in all_timestamps: if isinstance(ts, Exception): print(f" [WARN] decode stream error: {ts}") continue tpot = compute_tpot(ts, skip_first=WARMUP_TOKENS) if len(tpot) > 0: all_tpots.extend(tpot.tolist()) if not all_tpots: return None arr = np.array(all_tpots) return BaselineResult( tpot_p50_ms=float(np.percentile(arr, 50)), tpot_p90_ms=float(np.percentile(arr, 90)), tpot_p99_ms=float(np.percentile(arr, 99)), tokens_collected=len(arr), ) async def run_interference( client: httpx.AsyncClient, url: str, model: str, decode_batch_size: int, new_prefill_tokens: int, ) -> Optional[InterferenceResult]: """Measure TPOT while a prefill request is being processed.""" if decode_batch_size == 0: # No decode to interfere with; just measure prefill TTFT prefill_payload = make_prefill_payload(model, new_prefill_tokens) t_start = time.perf_counter() ts = await stream_tokens(client, url, prefill_payload) prefill_ttft = (ts[0] - t_start) * 1000.0 if ts else 0 return InterferenceResult( tpot_during_prefill_p50_ms=0, tpot_during_prefill_p90_ms=0, tpot_after_prefill_p50_ms=0, prefill_ttft_ms=prefill_ttft, num_tokens_during_prefill=0, ) # Phase 1: Start decode streams decode_payloads = [make_decode_payload(model) for _ in range(decode_batch_size)] decode_timestamps: list[list[float]] = [[] for _ in range(decode_batch_size)] prefill_done_event = asyncio.Event() prefill_ttft_ms = 0.0 prefill_inject_time = 0.0 async def decode_stream_with_tracking(idx: int, payload: dict): timestamps = await stream_tokens(client, url, payload) decode_timestamps[idx] = timestamps async def prefill_after_warmup(): nonlocal prefill_ttft_ms, prefill_inject_time # Wait for decode streams to stabilize await asyncio.sleep(1.0) prefill_inject_time = time.perf_counter() prefill_payload = make_prefill_payload(model, new_prefill_tokens) ts = await stream_tokens(client, url, prefill_payload) if ts: prefill_ttft_ms = (ts[0] - prefill_inject_time) * 1000.0 prefill_done_event.set() # Launch all decode_tasks = [ asyncio.create_task(decode_stream_with_tracking(i, p)) for i, p in enumerate(decode_payloads) ] prefill_task = asyncio.create_task(prefill_after_warmup()) await asyncio.gather(*decode_tasks, prefill_task, return_exceptions=True) # Analyze: split decode tokens into "during prefill" and "after prefill" prefill_end_time = prefill_inject_time + prefill_ttft_ms / 1000.0 tpot_during = [] tpot_after = [] for ts_list in decode_timestamps: if len(ts_list) < WARMUP_TOKENS + 5: continue for i in range(WARMUP_TOKENS + 1, len(ts_list)): t_prev = ts_list[i - 1] t_curr = ts_list[i] interval_ms = (t_curr - t_prev) * 1000.0 if prefill_inject_time <= t_prev <= prefill_end_time: tpot_during.append(interval_ms) elif t_curr > prefill_end_time + 0.05: # 50ms after prefill settles tpot_after.append(interval_ms) during_arr = np.array(tpot_during) if tpot_during else np.array([0.0]) after_arr = np.array(tpot_after) if tpot_after else np.array([0.0]) return InterferenceResult( tpot_during_prefill_p50_ms=float(np.percentile(during_arr, 50)), tpot_during_prefill_p90_ms=float(np.percentile(during_arr, 90)), tpot_after_prefill_p50_ms=float(np.percentile(after_arr, 50)), prefill_ttft_ms=prefill_ttft_ms, num_tokens_during_prefill=len(tpot_during), ) async def run_single_config( client: httpx.AsyncClient, url: str, model: str, decode_batch_size: int, new_prefill_tokens: int, chunk_size: int, rep: int, output_dir: Path, ): """Run one (D, P) configuration.""" config = Config( decode_batch_size=decode_batch_size, new_prefill_tokens=new_prefill_tokens, chunk_size=chunk_size, model=model, repetition=rep, ) print(f" [rep {rep}] Running baseline (D={decode_batch_size})...") baseline = await run_baseline(client, url, model, decode_batch_size) if baseline is None: print(f" [rep {rep}] Baseline failed, skipping") return # Brief cooldown between baseline and interference await asyncio.sleep(2.0) print(f" [rep {rep}] Running interference (D={decode_batch_size}, P={new_prefill_tokens})...") interference = await run_interference( client, url, model, decode_batch_size, new_prefill_tokens ) if interference is None: print(f" [rep {rep}] Interference measurement failed, skipping") return # Compute derived metrics tpot_penalty_p50 = interference.tpot_during_prefill_p50_ms - baseline.tpot_p50_ms penalty_ratio = ( interference.tpot_during_prefill_p50_ms / baseline.tpot_p50_ms if baseline.tpot_p50_ms > 0 else 0 ) result = { "config": asdict(config), "baseline": asdict(baseline), "interference": asdict(interference), "derived": { "tpot_penalty_p50_ms": tpot_penalty_p50, "tpot_penalty_ratio": penalty_ratio, }, } # Save fname = f"D{decode_batch_size}_P{new_prefill_tokens}_rep{rep}.json" out_path = output_dir / fname out_path.write_text(json.dumps(result, indent=2)) print(f" [rep {rep}] Done. penalty={tpot_penalty_p50:.1f}ms ratio={penalty_ratio:.2f}") async def main(): parser = argparse.ArgumentParser(description="Prefill-Decode Interference Microbenchmark") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8000) parser.add_argument("--model", default="Qwen3-Coder-30B-A3B-Instruct") parser.add_argument("--decode-batch-sizes", default="0,1,2,4,6,8,12", help="Comma-separated decode batch sizes") parser.add_argument("--prefill-tokens", default="512,1024,2048,4096,8192,16384,32768", help="Comma-separated prefill token counts") parser.add_argument("--chunk-size", type=int, default=8192, help="vLLM max_num_batched_tokens (effective chunk size)") parser.add_argument("--reps", type=int, default=5) parser.add_argument("--output-dir", default="results/interference") args = parser.parse_args() decode_sizes = [int(x) for x in args.decode_batch_sizes.split(",")] prefill_tokens = [int(x) for x in args.prefill_tokens.split(",")] output_dir = Path(args.output_dir) / f"chunk{args.chunk_size}" output_dir.mkdir(parents=True, exist_ok=True) url = f"http://{args.host}:{args.port}/v1/chat/completions" print(f"Target: {url}") print(f"Model: {args.model}") print(f"Chunk size: {args.chunk_size}") print(f"Decode batch sizes: {decode_sizes}") print(f"Prefill tokens: {prefill_tokens}") print(f"Repetitions: {args.reps}") print(f"Output: {output_dir}") print() async with httpx.AsyncClient(timeout=httpx.Timeout(600.0)) as client: # Sanity check: is the server up? try: resp = await client.get(f"http://{args.host}:{args.port}/v1/models") resp.raise_for_status() models = resp.json() print(f"Server ready. Models: {[m['id'] for m in models.get('data', [])]}") except Exception as e: print(f"ERROR: Cannot reach server at {args.host}:{args.port}: {e}") return total_configs = len(decode_sizes) * len(prefill_tokens) done = 0 for D in decode_sizes: for P in prefill_tokens: done += 1 print(f"\n[{done}/{total_configs}] D={D}, P={P}") for rep in range(args.reps): try: await run_single_config( client, url, args.model, D, P, args.chunk_size, rep, output_dir, ) except Exception as e: print(f" [rep {rep}] ERROR: {e}") # Cooldown between reps await asyncio.sleep(1.0) # Cooldown between configs await asyncio.sleep(3.0) print("\n\nDone! Results in:", output_dir) # Generate summary CSV await generate_summary(output_dir, args.chunk_size) async def generate_summary(output_dir: Path, chunk_size: int): """Aggregate all per-run JSONs into a summary CSV.""" import csv rows = [] for f in sorted(output_dir.glob("D*_P*_rep*.json")): data = json.loads(f.read_text()) cfg = data["config"] bl = data["baseline"] itf = data["interference"] drv = data["derived"] rows.append({ "chunk_size": cfg["chunk_size"], "decode_batch_size": cfg["decode_batch_size"], "new_prefill_tokens": cfg["new_prefill_tokens"], "repetition": cfg["repetition"], "tpot_baseline_p50_ms": bl["tpot_p50_ms"], "tpot_baseline_p90_ms": bl["tpot_p90_ms"], "tpot_during_prefill_p50_ms": itf["tpot_during_prefill_p50_ms"], "tpot_during_prefill_p90_ms": itf["tpot_during_prefill_p90_ms"], "tpot_after_prefill_p50_ms": itf["tpot_after_prefill_p50_ms"], "prefill_ttft_ms": itf["prefill_ttft_ms"], "num_tokens_during_prefill": itf["num_tokens_during_prefill"], "tpot_penalty_p50_ms": drv["tpot_penalty_p50_ms"], "tpot_penalty_ratio": drv["tpot_penalty_ratio"], }) if not rows: return csv_path = output_dir / "summary.csv" with open(csv_path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) print(f"Summary CSV written: {csv_path}") if __name__ == "__main__": asyncio.run(main())