#!/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())