feat(snapshot): D→P RDMA Phase 1b — GPU pointer path verified
Confirms snapshot_link works for cuda device pointers, not just host memory. Sender on cuda:0 pushes to receiver on cuda:1 via RDMA over mlx5_60. All 5 sizes (16K, 1M, 16M, 64M, 256M) pass SHA verification. 16 KB 8.3 ms 0.016 Gbps (cold openSegment) 1 MB 0.10 ms 87.6 Gbps 16 MB 0.84 ms 159 Gbps 64 MB 2.52 ms 213 Gbps 256 MB 8.54 ms 251 Gbps (~60% NDR400 line rate) For Inferact-scale sessions (~50K tokens × ~80 KB layer-per-token = ~4 GB), this projects D→P transfer time at ~130 ms — within the "reseed-savings" envelope sketched in design doc §3.2. Files: scripts/snapshot_link_receiver_gpu.py scripts/smoke_snapshot_link_gpu.py Next: SGLang scheduler integration for D-side dump + P-side ingest.
This commit is contained in:
@@ -92,13 +92,25 @@ peer.close()
|
||||
|
||||
## 4. 当前覆盖范围(清单)
|
||||
|
||||
- ✅ Host CPU 内存的 D→P RDMA byte transfer
|
||||
- ✅ Host CPU 内存的 D→P RDMA byte transfer (`scripts/smoke_snapshot_link.py`)
|
||||
- ✅ **GPU 内存** cuda:0 → cuda:1 的 D→P RDMA(`scripts/smoke_snapshot_link_gpu.py`,5/5 size 全 SHA 校验通过,256 MB 8.5 ms / 251 Gbps)
|
||||
- ✅ 单 IB device (mlx5_60)
|
||||
- ✅ 同节点 loopback(127.0.0.1)
|
||||
- ⏳ GPU 内存(设备指针 + `batch_transfer_write_on_cuda`)—— 现有 `push()` 走 `transfer_sync_write`,对 GPU 指针支持取决于 mooncake 的 protocol;下一步验证
|
||||
- ⏳ 跨节点(远端 IP)—— 设计上一致,未验证
|
||||
- ⏳ 多 D → 单 P(多 sender → 共享 recv buffer 的 offset 协调)—— 留给 Phase 3 整合时设计
|
||||
- ⏳ ZeroCopy 入 SGLang kv_pool slot —— 留给 Phase 2
|
||||
- ⏳ ZeroCopy 入 SGLang kv_pool slot —— 留给 Phase 2/3
|
||||
|
||||
### GPU smoke 性能
|
||||
|
||||
| Size | Push duration | Throughput |
|
||||
|---:|---:|---:|
|
||||
| 16 KB | 8.27 ms (cold) | 0.016 Gbps |
|
||||
| 1 MB | 0.096 ms | 87.6 Gbps |
|
||||
| 16 MB | 0.844 ms | 159 Gbps |
|
||||
| 64 MB | 2.52 ms | 213 Gbps |
|
||||
| **256 MB** | **8.54 ms** | **251 Gbps** |
|
||||
|
||||
GPU↔GPU 比 host↔host 慢一些(251 vs 316 Gbps for 64MB),但仍接近 mlx5_60 NDR 400Gb 的 60% 线率。对 KVC 单 session ~50K tokens × ~80 KB/token ≈ 4 GB 量级的 transfer,对应 D→P 时间约 130 ms。
|
||||
|
||||
## 5. 下一步(Phase 2 / Phase 3)
|
||||
|
||||
|
||||
236
scripts/smoke_snapshot_link_gpu.py
Normal file
236
scripts/smoke_snapshot_link_gpu.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GPU-aware smoke test for snapshot_link RDMA byte transfer.
|
||||
|
||||
Sender on cuda:0, receiver subprocess on cuda:1. Tests whether
|
||||
mooncake's transfer_sync_write can move bytes between two GPUs via
|
||||
RDMA (which is what the real D→P flow will need for KV bytes).
|
||||
|
||||
Usage:
|
||||
bash scripts/setup_env.sh && uv run --no-sync python scripts/smoke_snapshot_link_gpu.py
|
||||
|
||||
The sender uses cuda:0 (--send-gpu); the receiver subprocess uses
|
||||
cuda:1 (--recv-gpu) by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_HERE.parent / "src"))
|
||||
|
||||
|
||||
SIZES_BYTES_DEFAULT = [
|
||||
1 << 14, # 16 KB
|
||||
1 << 20, # 1 MB
|
||||
1 << 24, # 16 MB
|
||||
1 << 26, # 64 MB
|
||||
1 << 28, # 256 MB
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default=os.environ.get("SNAPSHOT_LINK_HOST", "127.0.0.1"))
|
||||
ap.add_argument("--ib", default=os.environ.get("SNAPSHOT_LINK_IB", "mlx5_60"))
|
||||
ap.add_argument("--recv-port", type=int,
|
||||
default=int(os.environ.get("SNAPSHOT_LINK_RECV_PORT", "17787")))
|
||||
ap.add_argument("--send-port", type=int,
|
||||
default=int(os.environ.get("SNAPSHOT_LINK_SEND_PORT", "17788")))
|
||||
ap.add_argument("--max-bytes", type=int, default=256 * 1024 * 1024)
|
||||
ap.add_argument("--sizes", default=",".join(str(s) for s in SIZES_BYTES_DEFAULT))
|
||||
ap.add_argument("--send-gpu", type=int, default=0)
|
||||
ap.add_argument("--recv-gpu", type=int, default=1)
|
||||
args = ap.parse_args()
|
||||
|
||||
sizes = [int(s) for s in args.sizes.split(",")]
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="snapshot_link_gpu_smoke_"))
|
||||
control_path = tmpdir / "endpoint.json"
|
||||
recv_stderr_log = tmpdir / "recv.stderr.log"
|
||||
|
||||
recv_cmd = [
|
||||
sys.executable,
|
||||
str(_HERE / "snapshot_link_receiver_gpu.py"),
|
||||
"--host", args.host,
|
||||
"--port", str(args.recv_port),
|
||||
"--ib", args.ib,
|
||||
"--max-bytes", str(args.max_bytes),
|
||||
"--control-path", str(control_path),
|
||||
"--sizes", args.sizes,
|
||||
"--gpu-id", str(args.recv_gpu),
|
||||
]
|
||||
recv_stderr = open(recv_stderr_log, "w")
|
||||
print(f"[sender] receiver cmd: {' '.join(recv_cmd)}", flush=True)
|
||||
recv_proc = subprocess.Popen(
|
||||
recv_cmd, stdout=subprocess.PIPE, stderr=recv_stderr, bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
try:
|
||||
import torch
|
||||
if not torch.cuda.is_available():
|
||||
print("[sender] FAIL: cuda not available")
|
||||
return 1
|
||||
torch.cuda.set_device(args.send_gpu)
|
||||
|
||||
deadline = time.time() + 90.0
|
||||
meta = None
|
||||
while time.time() < deadline:
|
||||
if control_path.exists():
|
||||
try:
|
||||
meta = json.loads(control_path.read_text())
|
||||
if meta.get("ready"):
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if recv_proc.poll() is not None:
|
||||
_dump_recv_stderr(recv_stderr_log)
|
||||
print(f"[sender] FAIL: receiver exited (rc={recv_proc.returncode})")
|
||||
return 1
|
||||
time.sleep(0.1)
|
||||
if meta is None:
|
||||
print("[sender] FAIL: receiver endpoint timeout")
|
||||
return 1
|
||||
print(f"[sender] receiver endpoint: gpu={meta['gpu_id']}, "
|
||||
f"sid={meta['session_id']}, ptr={hex(int(meta['base_ptr']))}, "
|
||||
f"cap={meta['capacity_bytes']}", flush=True)
|
||||
|
||||
from agentic_pd_hybrid.snapshot_link import SnapshotPeer, SnapshotEndpoint
|
||||
|
||||
endpoint = SnapshotEndpoint(
|
||||
session_id=meta["session_id"],
|
||||
base_ptr=int(meta["base_ptr"]),
|
||||
capacity_bytes=int(meta["capacity_bytes"]),
|
||||
)
|
||||
|
||||
peer = SnapshotPeer(
|
||||
host=args.host,
|
||||
port=args.send_port,
|
||||
ib_device=args.ib,
|
||||
receive_capacity_bytes=0,
|
||||
)
|
||||
|
||||
# Allocate a sender buffer on cuda:0
|
||||
send_tensor = torch.zeros(args.max_bytes, dtype=torch.uint8,
|
||||
device=f"cuda:{args.send_gpu}")
|
||||
send_ptr = send_tensor.data_ptr()
|
||||
ret = peer.engine.register_memory(send_ptr, args.max_bytes)
|
||||
if ret != 0:
|
||||
print(f"[sender] FAIL: register_memory ret={ret}")
|
||||
return 1
|
||||
print(f"[sender] own gpu={args.send_gpu}, sid={peer.session_id}, "
|
||||
f"buf @ {hex(send_ptr)} ({args.max_bytes} B)", flush=True)
|
||||
|
||||
transfers = []
|
||||
for size in sizes:
|
||||
if size > args.max_bytes:
|
||||
continue
|
||||
# Fill with deterministic pattern on GPU
|
||||
seed = int(time.time() * 1e6) & 0xFFFFFFFF
|
||||
# Use a simple seeded pattern via torch ops
|
||||
gen = torch.Generator(device=f"cuda:{args.send_gpu}")
|
||||
gen.manual_seed(seed)
|
||||
send_tensor[:size] = torch.randint(0, 256, (size,), dtype=torch.uint8,
|
||||
device=f"cuda:{args.send_gpu}",
|
||||
generator=gen)
|
||||
torch.cuda.synchronize(args.send_gpu)
|
||||
# Compute expected hash (host-side)
|
||||
host_view = send_tensor[:size].cpu().numpy().tobytes()
|
||||
expected_sha = hashlib.sha256(host_view).hexdigest()
|
||||
# Push via RDMA
|
||||
t0 = time.perf_counter()
|
||||
ret = peer.push(endpoint, send_ptr, 0, size, remote_offset=0)
|
||||
t1 = time.perf_counter()
|
||||
dt_ms = (t1 - t0) * 1000.0
|
||||
gbps = (size * 8.0 / 1e9) / max(t1 - t0, 1e-9)
|
||||
print(f"[sender] push size={size:>10d} ret={ret} "
|
||||
f"dur={dt_ms:>9.3f} ms thru={gbps:>6.3f} Gbps",
|
||||
flush=True)
|
||||
|
||||
# Signal receiver to verify
|
||||
signal_path = control_path.with_suffix(f".do{size}")
|
||||
ack_path = control_path.with_suffix(f".ack{size}")
|
||||
signal_path.write_text(json.dumps({"sha": expected_sha}))
|
||||
ack_deadline = time.time() + 90.0
|
||||
while time.time() < ack_deadline:
|
||||
if ack_path.exists():
|
||||
break
|
||||
if recv_proc.poll() is not None:
|
||||
print(f"[sender] FAIL: receiver died after size={size}")
|
||||
_dump_recv_stderr(recv_stderr_log)
|
||||
return 1
|
||||
time.sleep(0.05)
|
||||
transfers.append({
|
||||
"size": size, "ret": ret, "dur_ms": round(dt_ms, 3),
|
||||
"thru_Gbps": round(gbps, 3), "ack": ack_path.exists(),
|
||||
})
|
||||
|
||||
try:
|
||||
recv_proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
recv_proc.terminate()
|
||||
recv_proc.wait(timeout=5)
|
||||
|
||||
events = []
|
||||
if recv_proc.stdout is not None:
|
||||
for raw in recv_proc.stdout:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(raw))
|
||||
except json.JSONDecodeError:
|
||||
events.append({"event": "non-json", "raw": raw})
|
||||
|
||||
print("=" * 78)
|
||||
print("[receiver] events:")
|
||||
verify_ok = 0
|
||||
verify_fail = 0
|
||||
for ev in events:
|
||||
print(f" {ev}")
|
||||
if ev.get("event") == "verify":
|
||||
if ev.get("ok"):
|
||||
verify_ok += 1
|
||||
else:
|
||||
verify_fail += 1
|
||||
|
||||
recv_stderr.close()
|
||||
_dump_recv_stderr(recv_stderr_log, header="--- receiver stderr ---")
|
||||
|
||||
overall = "PASS" if verify_fail == 0 and verify_ok == len(transfers) else "FAIL"
|
||||
print("=" * 78)
|
||||
print(f"OVERALL: {overall} verify_ok={verify_ok} verify_fail={verify_fail} "
|
||||
f"transfers={len(transfers)} send_gpu={args.send_gpu} recv_gpu={args.recv_gpu}")
|
||||
return 0 if overall == "PASS" else 1
|
||||
|
||||
finally:
|
||||
try:
|
||||
recv_proc.terminate()
|
||||
recv_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
recv_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _dump_recv_stderr(path: Path, header: str = "--- receiver stderr (last 60) ---") -> None:
|
||||
try:
|
||||
text = path.read_text()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
print(header, flush=True)
|
||||
for line in text.splitlines()[-60:]:
|
||||
print(f" {line}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
124
scripts/snapshot_link_receiver_gpu.py
Normal file
124
scripts/snapshot_link_receiver_gpu.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GPU-side receiver child for snapshot_link smoke test (CUDA mem)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
|
||||
def _emit(d: dict) -> None:
|
||||
print(json.dumps(d), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", required=True)
|
||||
ap.add_argument("--port", type=int, required=True)
|
||||
ap.add_argument("--ib", required=True)
|
||||
ap.add_argument("--max-bytes", type=int, required=True)
|
||||
ap.add_argument("--control-path", required=True)
|
||||
ap.add_argument("--sizes", required=True)
|
||||
ap.add_argument("--gpu-id", type=int, default=1, help="receiver GPU id")
|
||||
args = ap.parse_args()
|
||||
|
||||
sizes = [int(s) for s in args.sizes.split(",")]
|
||||
|
||||
try:
|
||||
import torch
|
||||
if not torch.cuda.is_available():
|
||||
_emit({"event": "init-failed", "error": "cuda not available"})
|
||||
sys.exit(2)
|
||||
torch.cuda.set_device(args.gpu_id)
|
||||
# allocate a GPU buffer of max_bytes
|
||||
recv_tensor = torch.zeros(args.max_bytes, dtype=torch.uint8, device=f"cuda:{args.gpu_id}")
|
||||
recv_ptr = recv_tensor.data_ptr()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_emit({"event": "init-failed", "error": repr(e), "tb": traceback.format_exc()})
|
||||
sys.exit(2)
|
||||
|
||||
# Spin up SnapshotPeer with NO internal recv buffer, then register our GPU tensor
|
||||
from agentic_pd_hybrid.snapshot_link import SnapshotPeer, SnapshotEndpoint
|
||||
try:
|
||||
peer = SnapshotPeer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
ib_device=args.ib,
|
||||
receive_capacity_bytes=0,
|
||||
)
|
||||
ret = peer.engine.register_memory(recv_ptr, args.max_bytes)
|
||||
if ret != 0:
|
||||
_emit({"event": "init-failed", "error": f"register_memory({hex(recv_ptr)}, {args.max_bytes}) ret={ret}"})
|
||||
sys.exit(2)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_emit({"event": "init-failed", "error": repr(e), "tb": traceback.format_exc()})
|
||||
sys.exit(2)
|
||||
|
||||
endpoint = SnapshotEndpoint(
|
||||
session_id=peer.session_id,
|
||||
base_ptr=recv_ptr,
|
||||
capacity_bytes=args.max_bytes,
|
||||
)
|
||||
Path(args.control_path).write_text(json.dumps({
|
||||
"session_id": endpoint.session_id,
|
||||
"base_ptr": endpoint.base_ptr,
|
||||
"capacity_bytes": endpoint.capacity_bytes,
|
||||
"gpu_id": args.gpu_id,
|
||||
"ready": True,
|
||||
}))
|
||||
_emit({"event": "endpoint-ready",
|
||||
"session_id": endpoint.session_id,
|
||||
"base_ptr": endpoint.base_ptr,
|
||||
"capacity": endpoint.capacity_bytes,
|
||||
"gpu_id": args.gpu_id})
|
||||
|
||||
cp = Path(args.control_path)
|
||||
for size in sizes:
|
||||
if size > args.max_bytes:
|
||||
continue
|
||||
signal_path = cp.with_suffix(f".do{size}")
|
||||
ack_path = cp.with_suffix(f".ack{size}")
|
||||
deadline = time.time() + 120.0
|
||||
while time.time() < deadline:
|
||||
if signal_path.exists():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
_emit({"event": "no-signal-timeout", "size": size})
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(signal_path.read_text())
|
||||
expected_sha = payload["sha"]
|
||||
except Exception as e:
|
||||
_emit({"event": "signal-parse-error", "size": size, "err": repr(e)})
|
||||
continue
|
||||
|
||||
# Copy from GPU to CPU and hash
|
||||
torch.cuda.synchronize(args.gpu_id)
|
||||
host_bytes = bytes(recv_tensor[:size].cpu().numpy().tobytes())
|
||||
recv_sha = hashlib.sha256(host_bytes).hexdigest()
|
||||
ok = recv_sha == expected_sha
|
||||
_emit({
|
||||
"event": "verify",
|
||||
"size": size,
|
||||
"ok": ok,
|
||||
"expected_sha": expected_sha[:16],
|
||||
"got_sha": recv_sha[:16],
|
||||
"first8_recv": host_bytes[:8].hex(),
|
||||
"last8_recv": host_bytes[-8:].hex(),
|
||||
})
|
||||
ack_path.write_text("done")
|
||||
|
||||
peer.close()
|
||||
_emit({"event": "receiver-done"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user