Files
agentic-pd-hybrid/src/agentic_pd_hybrid/snapshot_link.py
Claude Code Agent dc4867c270 feat(snapshot): D→P RDMA link Phase 1 — minimal byte transport
A thin wrapper around mooncake.engine.TransferEngine that does
one-sided RDMA writes between two SnapshotPeer endpoints. Bypasses
SGLang's MooncakeKVManager (which is hard-gated to PREFILL/DECODE
roles via add_transfer_request assertion at conn.py:1563) so the
D→P direction doesn't require invasive role-axis changes upstream.

Smoke test (two subprocess.Popen processes, mlx5_60, 127.0.0.1):
  1 KB    9.0 ms   (one-time openSegment handshake)
  16 KB   0.04 ms  3.5 Gbps
  1 MB    0.10 ms  82 Gbps
  16 MB   0.58 ms  232 Gbps
  64 MB   1.70 ms  316 Gbps   (~80% of NDR 400G line rate)

All 5 sizes pass SHA256 verification end-to-end.

Files:
  src/agentic_pd_hybrid/snapshot_link.py — SnapshotPeer, SnapshotEndpoint
  scripts/snapshot_link_receiver.py      — child-process receiver
  scripts/smoke_snapshot_link.py         — sender + verifier
  docs/D_TO_P_PHASE1_LINK_ZH.md          — phase 1 acceptance doc

Next: Phase 2 (D-side scheduler commit hook), Phase 3 (P-side prefill
bypass with snapshot KV). See docs/D_TO_P_SYNC_DESIGN_ZH.md §5.
2026-05-13 00:55:55 +08:00

267 lines
8.9 KiB
Python

"""Minimal D→P snapshot link over Mooncake RDMA.
This module provides a thin wrapper around mooncake.engine.TransferEngine
for one-sided RDMA writes of KV bytes from a Decode worker (sender) to a
Prefill worker (receiver). It deliberately does NOT use the heavyweight
MooncakeKVManager pipeline (which is tied to PREFILL/DECODE roles and
chunked transfer protocols): we want a simple, testable byte transport
that can be reused by SGLang and by stand-alone smoke tests.
Layout:
SnapshotPeer — engine + pre-registered receive buffer (receiver)
or sender handle (sender)
SnapshotEndpoint — what the receiver advertises so the sender can
target it: (session_id, base_ptr, length)
SnapshotPusher — sender-side: holds a target endpoint, calls
batch_transfer_sync_write
All transfers are SYNCHRONOUS, single-shot, in-memory.
Higher layers add: control plane (how D learns P's endpoint), per-session
slot allocation, KV format/layout, hand-off into SGLang scheduler.
"""
from __future__ import annotations
import ctypes
import logging
import os
import threading
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SnapshotEndpoint:
"""What the receiver advertises so the sender can reach it.
Attributes
----------
session_id : str
``"host:rpc_port"`` string identifying the receiver's mooncake
TransferEngine. Returned by ``TransferEngine.get_rpc_port()``
joined with the host the engine was initialized with.
base_ptr : int
Address of the registered receive buffer on the receiver side.
capacity_bytes : int
Length of the registered region.
"""
session_id: str
base_ptr: int
capacity_bytes: int
def _import_transfer_engine():
try:
from mooncake.engine import TransferEngine
except ImportError as e: # pragma: no cover
raise ImportError(
"mooncake.engine.TransferEngine is required for snapshot_link. "
"Make sure mooncake-transfer-engine is installed in the venv."
) from e
return TransferEngine
class SnapshotPeer:
"""One Mooncake transfer engine endpoint with a registered receive buffer.
The engine is dedicated to snapshot traffic — it does NOT share state
with SGLang's MooncakeKVManager engine. Each SnapshotPeer needs its own
host:port to listen on.
"""
def __init__(
self,
host: str,
port: int,
ib_device: Optional[str] = None,
receive_capacity_bytes: int = 0,
protocol: Optional[str] = None,
):
TransferEngine = _import_transfer_engine()
self.host = host
self.port = port
self.ib_device = ib_device
self.engine = TransferEngine()
listen = f"{host}:{port}"
proto = protocol or os.environ.get("MOONCAKE_PROTOCOL", "rdma")
ret = self.engine.initialize(
listen,
"P2PHANDSHAKE",
proto,
ib_device or "",
)
if ret != 0:
raise RuntimeError(
f"snapshot_link: engine.initialize({listen!r}, proto={proto}, "
f"ib={ib_device}) returned {ret}"
)
self._rpc_port = self.engine.get_rpc_port()
self._session_id = f"{host}:{self._rpc_port}"
self._recv_buffer = None
self._recv_ptr = 0
self._recv_capacity = 0
if receive_capacity_bytes > 0:
self._allocate_recv_buffer(receive_capacity_bytes)
self._lock = threading.Lock()
logger.info(
"SnapshotPeer up at %s (rpc=%d, ib=%s, recv=%d B)",
self._session_id,
self._rpc_port,
ib_device,
receive_capacity_bytes,
)
# -- accessors ---------------------------------------------------------
@property
def session_id(self) -> str:
return self._session_id
@property
def rpc_port(self) -> int:
return self._rpc_port
@property
def endpoint(self) -> SnapshotEndpoint:
if self._recv_buffer is None:
raise RuntimeError(
"SnapshotPeer has no receive buffer; pass receive_capacity_bytes > 0"
)
return SnapshotEndpoint(
session_id=self._session_id,
base_ptr=self._recv_ptr,
capacity_bytes=self._recv_capacity,
)
# -- buffer management -------------------------------------------------
def _allocate_recv_buffer(self, length: int) -> None:
"""Allocate + register a pinned host buffer for receiving."""
# Use c_ubyte (unsigned) so bytes() conversions of the underlying
# storage always yield valid byte values.
buf = (ctypes.c_ubyte * length)()
addr = ctypes.addressof(buf)
ret = self.engine.register_memory(addr, length)
if ret != 0:
raise RuntimeError(
f"snapshot_link: register_memory({hex(addr)}, {length}) returned {ret}"
)
self._recv_buffer = buf
self._recv_ptr = addr
self._recv_capacity = length
def read_bytes(self, offset: int, length: int) -> bytes:
"""Snapshot the recv buffer at [offset, offset+length) (caller syncs)."""
if self._recv_buffer is None:
raise RuntimeError("no recv buffer")
if offset < 0 or offset + length > self._recv_capacity:
raise ValueError(
f"read_bytes({offset}, {length}) out of capacity {self._recv_capacity}"
)
# string_at copies via memcpy and yields a proper bytes object — works
# regardless of signed/unsigned underlying storage.
return ctypes.string_at(self._recv_ptr + offset, length)
def register_send_buffer(self, ptr: int, length: int) -> None:
"""Register an externally-allocated send buffer for outbound RDMA writes."""
with self._lock:
ret = self.engine.register_memory(ptr, length)
if ret != 0:
raise RuntimeError(
f"snapshot_link: register send buffer({hex(ptr)}, {length}) returned {ret}"
)
def deregister(self, ptr: int) -> None:
with self._lock:
try:
self.engine.unregister_memory(ptr)
except Exception:
pass
# -- transfer ----------------------------------------------------------
def push(
self,
target: SnapshotEndpoint,
local_ptr: int,
local_offset: int,
length: int,
remote_offset: int = 0,
) -> int:
"""Synchronously RDMA-write ``length`` bytes from ``local_ptr+local_offset``
to ``target.base_ptr+remote_offset`` on the peer identified by
``target.session_id``.
Returns 0 on success, non-zero (or raises) on failure.
"""
if length <= 0:
return 0
if remote_offset < 0 or remote_offset + length > target.capacity_bytes:
raise ValueError(
f"push: remote_offset={remote_offset}, length={length} exceeds "
f"target capacity {target.capacity_bytes}"
)
src = local_ptr + local_offset
dst = target.base_ptr + remote_offset
try:
ret = self.engine.transfer_sync_write(
target.session_id, src, dst, length
)
except Exception as e:
logger.exception("snapshot_link.push transfer_sync_write threw: %s", e)
return -1
if ret != 0:
logger.warning(
"snapshot_link.push transfer_sync_write returned %d (src=%s, "
"dst=%s/%s, len=%d)",
ret,
hex(src),
target.session_id,
hex(dst),
length,
)
return ret
def batch_push(
self,
target: SnapshotEndpoint,
local_addrs: list[int],
remote_addrs: list[int],
lengths: list[int],
) -> int:
"""Batched RDMA write (one-shot)."""
if not local_addrs:
return 0
try:
ret = self.engine.batch_transfer_sync_write(
target.session_id, local_addrs, remote_addrs, lengths
)
except Exception as e:
logger.exception("snapshot_link.batch_push threw: %s", e)
return -1
return ret
def close(self) -> None:
"""Best-effort shutdown — release the receive buffer registration."""
if self._recv_ptr:
try:
self.engine.unregister_memory(self._recv_ptr)
except Exception:
pass
self._recv_ptr = 0
self._recv_capacity = 0
self._recv_buffer = None
def make_session_id(host: str, rpc_port: int) -> str:
"""Build the ``host:port`` form used as mooncake's session id."""
return f"{host}:{rpc_port}"