[Disagg][NIXL] Add staging buffer support for heterogeneous TP KV transfer (#22536)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Shangming Cai <csmthu@gmail.com>
This commit is contained in:
YAMY
2026-05-13 19:54:02 +08:00
committed by GitHub
co-authored by Claude Opus 4.7 Cursor Shangming Cai
parent 5227b07669
commit 2a4d382b07
4 changed files with 820 additions and 146 deletions
@@ -45,7 +45,11 @@ class PrefillStagingContext:
watermark_cv: threading.Condition = dataclasses.field(
default_factory=threading.Condition
)
# (room, chunk_idx, session_id) keys for chunks already requested.
prefetch_requested: set = dataclasses.field(default_factory=set)
# Rooms that have already had their full prefetch fan-out triggered. Used
# to short-circuit per-room prefetch entry on every chunk after the first.
prefetched_rooms: set = dataclasses.field(default_factory=set)
prefetch_sockets: dict = dataclasses.field(default_factory=dict)
@@ -184,6 +188,38 @@ class DecodeStagingHandler:
"""Check if a room is registered for staging scatter."""
return room in self._room_to_decode_req
def handle_chunk_arrived(
self,
room: int,
chunk_idx: int,
page_start: int,
num_pages: int,
writer_id: str,
chunk_writer_counts: dict,
) -> bool:
"""Process a staging chunk arrival from any transport (NIXL RDMA notif or ZMQ CHUNK_READY).
Accumulates writer arrivals in *chunk_writer_counts* and submits scatter
once all writers for this chunk have reported in. Returns True if scatter
was submitted.
"""
chunk_writer_counts[room][chunk_idx].append((page_start, num_pages, writer_id))
decode_req = self._room_to_decode_req.get(room)
if decode_req is None:
logger.warning(
"Staging chunk arrived for unregistered room=%s chunk=%d, skipping",
room,
chunk_idx,
)
return False
writers_arrived = len(chunk_writer_counts[room][chunk_idx])
num_writers = self.num_writers_for(decode_req)
if writers_arrived >= num_writers:
self.submit_chunk_scatter(room, chunk_idx, page_start, num_pages)
del chunk_writer_counts[room][chunk_idx]
return True
return False
def submit_last_scatter_async(self, room: int) -> bool:
"""Submit scatter for the last chunk when all ranks report Success.
@@ -367,8 +403,46 @@ def is_watermark_ready(
return prev_round < wm_round or (prev_round == wm_round and alloc_end <= wm_tail)
def handle_watermark_msg(staging_ctx, msg_parts) -> None:
"""Process a WATERMARK message and update remote watermark tracking."""
wm_round = int(msg_parts[1].decode("ascii"))
wm_tail = int(msg_parts[2].decode("ascii"))
wm_session = msg_parts[3].decode("ascii") if len(msg_parts) > 3 else ""
with staging_ctx.watermark_cv:
prev = staging_ctx.remote_watermarks.get(wm_session, (0, 0))
if (wm_round, wm_tail) > prev:
staging_ctx.remote_watermarks[wm_session] = (
wm_round,
wm_tail,
)
staging_ctx.watermark_cv.notify_all()
def handle_staging_rsp(msg_parts, transfer_infos: dict) -> None:
"""Process a STAGING_RSP message and update transfer info with allocation."""
stg_room = int(msg_parts[1].decode("ascii"))
stg_chunk_idx = int(msg_parts[2].decode("ascii"))
stg_offset = int(msg_parts[3].decode("ascii"))
stg_round = int(msg_parts[4].decode("ascii"))
stg_end = int(msg_parts[5].decode("ascii"))
stg_session = msg_parts[6].decode("ascii")
room_infos = transfer_infos.get(stg_room, {})
tinfo = room_infos.get(stg_session)
if tinfo is not None:
if tinfo.staging is None:
tinfo.staging = StagingTransferInfo()
tinfo.staging.set_chunk(stg_chunk_idx, stg_offset, stg_round, stg_end)
else:
logger.warning(
"STAGING_RSP RECV but tinfo=None room=%s chunk=%d session=%s",
stg_room,
stg_chunk_idx,
stg_session,
)
# ======================================================================
# Mooncake-specific staging protocol and utilities
# Staging data structures and protocol utilities
# ======================================================================
@@ -434,9 +508,17 @@ class PrefillStagingStrategy:
req,
kv_chunk_index_start: int,
num_chunk_pages: int,
session_id: Optional[str] = None,
) -> Tuple[bool, int, int, int, int]:
"""Check if staging offset and watermark are ready for this chunk.
Args:
req: transfer request with a ``.staging`` attribute.
kv_chunk_index_start: page-level start index for this chunk.
num_chunk_pages: number of pages in this chunk.
session_id: identifier used for watermark lookup. Falls back to
``req.mooncake_session_id`` when *None* (mooncake compat).
Returns (ready, chunk_idx, offset, round, end).
offset == ALLOC_OVERSIZED means permanent failure (fall back to slice).
offset == -1 means allocation pending (re-enqueue).
@@ -462,9 +544,9 @@ class PrefillStagingStrategy:
c_round = stg.rounds[chunk_idx]
c_end = stg.ends[chunk_idx]
if not self.kv_manager._is_watermark_ready(
req.mooncake_session_id, c_round, c_end
):
if session_id is None:
session_id = req.mooncake_session_id
if not self.kv_manager._is_watermark_ready(session_id, c_round, c_end):
return (False, chunk_idx, c_offset, c_round, c_end)
return (True, chunk_idx, c_offset, c_round, c_end)
@@ -499,21 +581,15 @@ class PrefillStagingStrategy:
) from e
def init_staging_buffers(engine, kv_args, count: int) -> list:
"""Create prefill-side staging buffers and register them with the engine.
def _get_custom_mem_pool(device: str):
"""Get custom memory pool for staging buffer allocation (backend-agnostic).
Returns list of StagingBuffer instances.
Returns (custom_mem_pool, pool_type) tuple. custom_mem_pool may be None
if no custom pool is configured.
"""
from sglang.srt.disaggregation.common.staging_buffer import StagingBuffer
from sglang.srt.disaggregation.mooncake.utils import (
init_mooncake_custom_mem_pool,
)
from sglang.srt.environ import envs
size_mb = envs.SGLANG_DISAGG_STAGING_BUFFER_SIZE_MB.get()
size_bytes = size_mb * 1024 * 1024
gpu_id = kv_args.gpu_id
device = f"cuda:{gpu_id}"
_, custom_mem_pool, pool_type = init_mooncake_custom_mem_pool(device)
if custom_mem_pool is None:
@@ -522,24 +598,49 @@ def init_staging_buffers(engine, kv_args, count: int) -> list:
"This works for all GPU architectures. "
"For NVLink/MNNVL transport, set SGLANG_MOONCAKE_CUSTOM_MEM_POOL."
)
return custom_mem_pool, pool_type
def init_staging_buffers(register_fn, kv_args, count: int) -> list:
"""Create prefill-side staging buffers and register them with the transport.
Args:
register_fn: callable(ptr: int, size: int) that registers a memory
region with the transport backend.
kv_args: KVArgs with gpu_id.
count: number of staging buffers to create.
Returns list of StagingBuffer instances.
"""
from sglang.srt.disaggregation.common.staging_buffer import StagingBuffer
from sglang.srt.environ import envs
size_mb = envs.SGLANG_DISAGG_STAGING_BUFFER_SIZE_MB.get()
size_bytes = size_mb * 1024 * 1024
gpu_id = kv_args.gpu_id
device = f"cuda:{gpu_id}"
custom_mem_pool, _ = _get_custom_mem_pool(device)
buffers = []
for _ in range(count):
buf = StagingBuffer(size_bytes, device, gpu_id, custom_mem_pool=custom_mem_pool)
engine.batch_register([buf.get_ptr()], [buf.get_size()])
register_fn(buf.get_ptr(), buf.get_size())
buffers.append(buf)
return buffers
def init_staging_allocator(engine, kv_args):
"""Create decode-side staging ring-buffer allocator and register with engine.
def init_staging_allocator(register_fn, kv_args):
"""Create decode-side staging ring-buffer allocator and register with transport.
Args:
register_fn: callable(ptr: int, size: int) that registers a memory
region with the transport backend.
kv_args: KVArgs with gpu_id.
Returns a StagingAllocator instance.
"""
from sglang.srt.disaggregation.common.staging_buffer import StagingAllocator
from sglang.srt.disaggregation.mooncake.utils import (
init_mooncake_custom_mem_pool,
)
from sglang.srt.environ import envs
pool_size_mb = envs.SGLANG_DISAGG_STAGING_POOL_SIZE_MB.get()
@@ -547,9 +648,9 @@ def init_staging_allocator(engine, kv_args):
gpu_id = kv_args.gpu_id
device = f"cuda:{gpu_id}"
_, custom_mem_pool, _ = init_mooncake_custom_mem_pool(device)
custom_mem_pool, _ = _get_custom_mem_pool(device)
allocator = StagingAllocator(pool_size_bytes, device, gpu_id, custom_mem_pool)
engine.batch_register([allocator.get_base_ptr()], [allocator.get_total_size()])
register_fn(allocator.get_base_ptr(), allocator.get_total_size())
return allocator
@@ -696,7 +797,13 @@ def prefetch_staging_reqs(
full_chunk_pages = max(1, cps // page_size)
for session_id, tinfo in transfer_infos[room].items():
if tinfo.is_dummy:
# mooncake exposes is_dummy as a dataclass bool field, NIXL exposes it
# as a method (it consults decode_prefix_len). Normalize via callable()
# so this shared helper works for either backend; treating a bound
# method as truthy (the previous behavior) silently dropped every
# STAGING_REQ on NIXL and deadlocked the prefill transfer worker.
is_dummy_attr = tinfo.is_dummy
if is_dummy_attr() if callable(is_dummy_attr) else is_dummy_attr:
continue
total_pages = len(tinfo.dst_kv_indices)
if total_pages == 0:
@@ -294,7 +294,9 @@ class MooncakeKVManager(CommonKVManager):
)
self._staging_ctx.buffers = init_staging_buffers(
self.engine, self.kv_args, count
lambda ptr, size: self.engine.batch_register([ptr], [size]),
self.kv_args,
count,
)
self.kv_buffer_tensors = None
@@ -303,7 +305,10 @@ class MooncakeKVManager(CommonKVManager):
init_staging_allocator,
)
self._staging_ctx.allocator = init_staging_allocator(self.engine, self.kv_args)
self._staging_ctx.allocator = init_staging_allocator(
lambda ptr, size: self.engine.batch_register([ptr], [size]),
self.kv_args,
)
self.kv_buffer_tensors = None
def _handle_staging_req(self, msg):
@@ -1399,47 +1404,19 @@ class MooncakeKVManager(CommonKVManager):
room = waiting_req_bytes[0].decode("ascii")
# Staging: decode reports consumption watermark back to prefill
if room == "WATERMARK":
wm_round = int(waiting_req_bytes[1].decode("ascii"))
wm_tail = int(waiting_req_bytes[2].decode("ascii"))
wm_session = (
waiting_req_bytes[3].decode("ascii")
if len(waiting_req_bytes) > 3
else ""
from sglang.srt.disaggregation.common.staging_handler import (
handle_watermark_msg,
)
with self._staging_ctx.watermark_cv:
prev = self._staging_ctx.remote_watermarks.get(
wm_session, (0, 0)
)
if (wm_round, wm_tail) > prev:
self._staging_ctx.remote_watermarks[wm_session] = (
wm_round,
wm_tail,
)
self._staging_ctx.watermark_cv.notify_all()
handle_watermark_msg(self._staging_ctx, waiting_req_bytes)
continue
# Staging: decode replies with allocated staging offset
if room == "STAGING_RSP":
stg_room = int(waiting_req_bytes[1].decode("ascii"))
stg_chunk_idx = int(waiting_req_bytes[2].decode("ascii"))
stg_offset = int(waiting_req_bytes[3].decode("ascii"))
stg_round = int(waiting_req_bytes[4].decode("ascii"))
stg_end = int(waiting_req_bytes[5].decode("ascii"))
stg_session = waiting_req_bytes[6].decode("ascii")
room_infos = self.transfer_infos.get(stg_room, {})
tinfo = room_infos.get(stg_session)
if tinfo is not None:
if tinfo.staging is None:
tinfo.staging = StagingTransferInfo()
tinfo.staging.set_chunk(
stg_chunk_idx, stg_offset, stg_round, stg_end
)
else:
logger.warning(
"STAGING_RSP RECV but tinfo=None room=%s chunk=%d session=%s",
stg_room,
stg_chunk_idx,
stg_session,
)
from sglang.srt.disaggregation.common.staging_handler import (
handle_staging_rsp,
)
handle_staging_rsp(waiting_req_bytes, self.transfer_infos)
continue
mooncake_session_id = waiting_req_bytes[3].decode("ascii")
if room == "None":
@@ -1493,28 +1470,18 @@ class MooncakeKVManager(CommonKVManager):
page_start = int(msg[3].decode("ascii"))
num_pages = int(msg[4].decode("ascii"))
session_id = msg[5].decode("ascii")
self._chunk_writer_counts[room][chunk_idx].append(
(page_start, num_pages, session_id)
)
handler = self._staging_handler
assert (
handler is not None
), "CHUNK_READY received before staging handler initialized"
writers_arrived = len(self._chunk_writer_counts[room][chunk_idx])
decode_req = handler._room_to_decode_req.get(room)
if decode_req is None:
logger.warning(
"CHUNK_READY received for unregistered room=%s chunk=%d, skipping",
room,
chunk_idx,
)
continue
num_writers = handler.num_writers_for(decode_req)
if writers_arrived >= num_writers:
handler.submit_chunk_scatter(
room, chunk_idx, page_start, num_pages
)
del self._chunk_writer_counts[room][chunk_idx]
handler.handle_chunk_arrived(
room,
chunk_idx,
page_start,
num_pages,
session_id,
self._chunk_writer_counts,
)
continue
# Staging: prefill pre-requests staging allocation before forward
+664 -64
View File
@@ -8,11 +8,14 @@ import threading
import time
import uuid
from collections import defaultdict
from typing import Dict, List, Optional, Set
from typing import TYPE_CHECKING, Dict, List, Optional, Set
import numpy as np
import numpy.typing as npt
if TYPE_CHECKING:
from sglang.srt.disaggregation.common.staging_handler import StagingTransferInfo
from sglang.srt.disaggregation.base.conn import KVArgs, KVPoll, StateType
from sglang.srt.disaggregation.common.conn import (
CommonKVBootstrapServer,
@@ -20,6 +23,7 @@ from sglang.srt.disaggregation.common.conn import (
CommonKVReceiver,
CommonKVSender,
)
from sglang.srt.disaggregation.common.staging_handler import StagingRegisterInfo
from sglang.srt.disaggregation.common.utils import (
FastQueue,
group_concurrent_contiguous,
@@ -66,6 +70,9 @@ class TransferInfo:
required_dst_info_num: int
dst_state_indices: List[List[int]]
decode_prefix_len: Optional[int] = None # for decode radix cache
# NOTE: optional staging field; populated via STAGING_RSP. Keep at the
# end so positional construction in from_zmq() continues to work.
staging: Optional["StagingTransferInfo"] = None
def is_dummy(self):
# A transfer is "dummy" only for CP non-authoritative ranks.
@@ -126,6 +133,9 @@ class KVArgsRegisterInfo:
dst_kv_item_len: int
dst_state_item_lens: List[List[int]] = dataclasses.field(default_factory=list)
dst_state_dim_per_tensor: List[List[int]] = dataclasses.field(default_factory=list)
# Keep last: optional, parsed from a variable-length tail of the ZMQ
# frame in from_zmq() below, so positional construction stays stable.
staging: Optional["StagingRegisterInfo"] = None
@classmethod
def from_zmq(cls, msg: List[bytes]):
@@ -154,6 +164,7 @@ class KVArgsRegisterInfo:
dst_kv_item_len=int(msg[11].decode("ascii")),
dst_state_item_lens=dst_state_item_lens,
dst_state_dim_per_tensor=dst_state_dim_per_tensor,
staging=StagingRegisterInfo.from_zmq_fields(msg, 14),
)
@@ -256,27 +267,210 @@ class NixlKVManager(CommonKVManager):
self.register_buffer_to_engine()
self.enable_staging = envs.SGLANG_DISAGG_STAGING_BUFFER.get()
self.kv_buffer_tensors = None
if self.disaggregation_mode == DisaggregationMode.PREFILL:
transfer_queue_size = envs.SGLANG_DISAGGREGATION_QUEUE_SIZE.get()
self.transfer_queues: List[FastQueue] = [
FastQueue() for _ in range(transfer_queue_size)
]
self.exceptions: Dict[int, Exception] = {}
for queue in self.transfer_queues:
# Mirror mooncake: one staging buffer per worker queue, all
# built before workers spawn so each worker owns a private
# buffer (no cross-worker contention on the staging ring).
if self.enable_staging:
self._init_staging_prefill_ctx()
self._init_staging_buffers(len(self.transfer_queues))
for i, queue in enumerate(self.transfer_queues):
staging_buffer = (
self._staging_ctx.buffers[i]
if self.enable_staging and self._staging_ctx.buffers
else None
)
threading.Thread(
target=self.transfer_worker, args=(queue,), daemon=True
target=self.transfer_worker,
args=(queue, staging_buffer),
daemon=True,
).start()
self._start_bootstrap_thread()
elif self.disaggregation_mode == DisaggregationMode.DECODE:
self.transfer_statuses: Dict[int, TransferStatus] = defaultdict(
TransferStatus
)
if self.enable_staging:
self._init_staging_decode_ctx()
self._staging_handler = None
self._chunk_writer_counts: dict = defaultdict(lambda: defaultdict(list))
self._start_decode_staging_thread()
self._start_heartbeat_checker_thread()
else:
raise ValueError(
f"Unsupported DisaggregationMode: {self.disaggregation_mode}"
)
def _init_staging_prefill_ctx(self):
from sglang.srt.disaggregation.common.staging_handler import (
PrefillStagingContext,
)
self._staging_ctx = PrefillStagingContext()
def _init_staging_decode_ctx(self):
from sglang.srt.disaggregation.common.staging_handler import (
DecodeStagingContext,
)
self._staging_ctx = DecodeStagingContext()
self._init_staging_allocator()
def _init_staging_buffers(self, count: int):
from sglang.srt.disaggregation.common.staging_handler import (
init_staging_buffers,
)
gpu_id = self.kv_args.gpu_id
self._staging_ctx.buffers = init_staging_buffers(
lambda ptr, size: self._register_staging_memory(ptr, size, gpu_id),
self.kv_args,
count,
)
def _init_staging_allocator(self):
from sglang.srt.disaggregation.common.staging_handler import (
init_staging_allocator,
)
gpu_id = self.kv_args.gpu_id
self._staging_ctx.allocator = init_staging_allocator(
lambda ptr, size: self._register_staging_memory(ptr, size, gpu_id),
self.kv_args,
)
def _register_staging_memory(self, ptr: int, size: int, gpu_id: int):
"""Register a staging buffer with the NIXL agent."""
addrs = [(ptr, size, gpu_id, "")]
descs = self.agent.register_memory(addrs, "VRAM")
if not descs:
raise RuntimeError(
f"NIXL memory registration failed for staging buffer "
f"(ptr=0x{ptr:x}, size={size})"
)
def set_kv_buffer_tensors(self, k_buffers: list, v_buffers: list, page_size: int):
# NOTE: matches mooncake behavior -- staging buffers are now
# created in __init__ (per-worker), independent of the kv
# tensors. This setter only stashes the tensor metadata used by
# send_kvcache_staged().
self.kv_buffer_tensors = {
"k_buffers": k_buffers,
"v_buffers": v_buffers,
"page_size": page_size,
}
def register_staging_room_bootstrap(self, room, bootstrap_infos, receiver):
self._staging_ctx.room_bootstrap[room] = bootstrap_infos
self._staging_ctx.room_receivers[room] = receiver
def _is_watermark_ready(
self, agent_name: str, alloc_round: int, alloc_end: int
) -> bool:
from sglang.srt.disaggregation.common.staging_handler import (
is_watermark_ready,
)
return is_watermark_ready(self._staging_ctx, agent_name, alloc_round, alloc_end)
def _start_decode_staging_thread(self):
"""Start a thread on the decode side to recv STAGING_REQ from prefill via ZMQ."""
def decode_staging_thread():
while True:
msg = self.server_socket.recv_multipart()
if msg[0] == b"STAGING_REQ":
self._handle_staging_req(msg)
continue
logger.warning(
"decode_staging_thread: unexpected message tag %s",
msg[0][:20],
)
threading.Thread(target=decode_staging_thread, daemon=True).start()
def _handle_staging_req(self, msg):
from sglang.srt.disaggregation.common.staging_handler import (
handle_staging_req,
)
room = int(msg[1].decode("ascii"))
session_id = msg[4].decode("ascii")
handler = self._staging_handler
assert (
handler is not None
), "STAGING_REQ received before staging handler initialized"
decode_req = handler._room_to_decode_req.get(room)
if decode_req is None:
logger.warning(
"STAGING_REQ received for unregistered room=%s, skipping",
room,
)
return
prefill_tp = decode_req.kv_receiver.prefill_info.attn_tp_size
handle_staging_req(
msg,
self._staging_ctx.allocator,
self.kv_args,
self.attn_tp_size,
prefill_tp,
getattr(self, "kv_buffer_tensors", None),
self._staging_ctx.room_receivers,
self._staging_ctx.room_bootstrap,
)
receiver = self._staging_ctx.room_receivers.get(room)
if receiver is not None:
handler.register_wm_subscriber(receiver, session_id)
def _prefetch_staging_reqs(self, room: int):
"""Send STAGING_REQ for all chunks before the prefill forward starts.
Idempotent per room: the first call for a given room does the full
fan-out (one STAGING_REQ per chunk per peer); subsequent calls return
immediately. This lets the caller invoke this on every chunk without
depending on a chunk_id == 0 sentinel.
"""
if not self.enable_staging or self.kv_buffer_tensors is None:
return
if room in self._staging_ctx.prefetched_rooms:
return
room_infos = self.transfer_infos.get(room, {})
needs_staging = any(
not tinfo.is_dummy()
and tinfo.agent_name in self.decode_kv_args_table
and self.decode_kv_args_table[tinfo.agent_name].decode_tp_size
!= self.attn_tp_size
for tinfo in room_infos.values()
)
if not needs_staging:
# Mark anyway so we don't re-evaluate the predicate every chunk.
self._staging_ctx.prefetched_rooms.add(room)
return
from sglang.srt.disaggregation.common.staging_handler import (
prefetch_staging_reqs,
)
prefetch_staging_reqs(
room,
self.transfer_infos,
self.kv_buffer_tensors,
self.server_args.chunked_prefill_size,
self._staging_ctx.prefetch_requested,
self._staging_ctx.prefetch_sockets,
)
self._staging_ctx.prefetched_rooms.add(room)
def _start_heartbeat_checker_thread(self):
"""
Start the heartbeat checker thread for Decode worker.
@@ -366,7 +560,12 @@ class NixlKVManager(CommonKVManager):
def check_status(self, bootstrap_room: int):
return self.request_status.get(bootstrap_room, KVPoll.WaitingForInput)
def transfer_worker(self, queue: FastQueue):
def transfer_worker(self, queue: FastQueue, staging_buffer=None):
# Per-worker staging strategy: lazy-created on first chunk so we
# see kv_buffer_tensors (set by ModelRunner after engine init).
# Never cache on self -- multiple workers would race the ring.
staging_strategy = None
while True:
kv_chunk: TransferKVChunk = queue.get()
room = kv_chunk.room
@@ -376,20 +575,34 @@ class NixlKVManager(CommonKVManager):
assert room in self.transfer_infos
# Lazily build a per-worker staging strategy bound to this
# worker's private staging buffer (matches mooncake).
if (
self.enable_staging
and staging_strategy is None
and staging_buffer is not None
):
staging_strategy = self._try_create_staging_strategy(staging_buffer)
self.update_status(room, KVPoll.Transferring)
reqs_to_be_processed = list(self.transfer_infos[room].values())
handles: List = []
# Set when staging allocation/watermark is not yet ready and
# the chunk has been re-enqueued. We then break out of the
# per-req loop and `continue` the worker main loop without
# touching room status -- the next pop will retry.
staging_deferred = False
for req in reqs_to_be_processed:
assert room == req.room
if req.is_dummy():
continue
assert req.agent_name in self.decode_kv_args_table
decode_tp_size = self.decode_kv_args_table[
req.agent_name
].decode_tp_size
dst_info = self.decode_kv_args_table[req.agent_name]
decode_tp_size = dst_info.decode_tp_size
# Skip KV RDMA transfer when there are no pages to send
# (e.g., decode-side radix cache matched the entire prefix).
@@ -409,34 +622,69 @@ class NixlKVManager(CommonKVManager):
: len(chunked_dst_kv_indice)
]
notif = f"{req.room}_kv_{kv_chunk.chunk_id}_{int(kv_chunk.is_last)}_{self.kv_args.engine_rank}"
# Decide which kv send path to use:
# 1. Staging (heterogeneous TP, both sides have
# registered staging, watermark/alloc ready)
# 2. send_kvcache (MLA or homogeneous TP)
# 3. send_kvcache_slice (heterogeneous TP fallback,
# or staging hard-failed for this chunk)
use_staging = (
self.enable_staging
and staging_strategy is not None
and not self.is_mla_backend
and decode_tp_size != self.attn_tp_size
and dst_info.staging is not None
)
if self.is_mla_backend or (decode_tp_size == self.attn_tp_size):
kv_xfer_handle = self.send_kvcache(
req.agent_name,
kv_chunk.prefill_kv_indices,
self.decode_kv_args_table[req.agent_name].dst_kv_ptrs,
chunked_dst_kv_indice,
self.decode_kv_args_table[req.agent_name].gpu_id,
notif,
kv_xfer_handle = None
if use_staging:
kv_xfer_handle, deferred = self._do_staging_transfer(
staging_strategy,
kv_chunk,
req,
dst_info,
queue,
)
else:
kv_xfer_handle = self.send_kvcache_slice(
req.agent_name,
kv_chunk.prefill_kv_indices,
self.decode_kv_args_table[req.agent_name].dst_kv_ptrs,
chunked_dst_kv_indice,
self.decode_kv_args_table[req.agent_name].gpu_id,
notif,
prefill_tp_size=self.attn_tp_size,
decode_tp_size=decode_tp_size,
decode_tp_rank=self.decode_kv_args_table[
req.agent_name
].decode_tp_rank,
dst_kv_item_len=self.decode_kv_args_table[
req.agent_name
].dst_kv_item_len,
if deferred:
# Chunk re-enqueued; stop processing remaining
# reqs for this chunk and let the worker loop
# pick it up again on the next pop.
staging_deferred = True
break
# kv_xfer_handle is None here means staging
# send_kvcache_staged() returned None (e.g.
# decode buffer too small) -- fall through to
# the slice path below.
if kv_xfer_handle is None:
notif = (
f"{req.room}_kv_{kv_chunk.chunk_id}"
f"_{int(kv_chunk.is_last)}_{self.kv_args.engine_rank}"
)
if self.is_mla_backend or (
decode_tp_size == self.attn_tp_size
):
kv_xfer_handle = self.send_kvcache(
req.agent_name,
kv_chunk.prefill_kv_indices,
dst_info.dst_kv_ptrs,
chunked_dst_kv_indice,
dst_info.gpu_id,
notif,
)
else:
kv_xfer_handle = self.send_kvcache_slice(
req.agent_name,
kv_chunk.prefill_kv_indices,
dst_info.dst_kv_ptrs,
chunked_dst_kv_indice,
dst_info.gpu_id,
notif,
prefill_tp_size=self.attn_tp_size,
decode_tp_size=decode_tp_size,
decode_tp_rank=dst_info.decode_tp_rank,
dst_kv_item_len=dst_info.dst_kv_item_len,
)
handles.append(kv_xfer_handle)
@@ -470,12 +718,16 @@ class NixlKVManager(CommonKVManager):
aux_xfer_handle = self.send_aux(
req.agent_name,
kv_chunk.prefill_aux_index,
self.decode_kv_args_table[req.agent_name].dst_aux_ptrs,
dst_info.dst_aux_ptrs,
req.dst_aux_index,
aux_notif,
)
handles.append(aux_xfer_handle)
if staging_deferred:
# Chunk has been re-enqueued; do not advance status.
continue
while handles:
states = [self.agent.check_xfer_state(h) for h in handles]
if any(s == "ERR" for s in states):
@@ -486,6 +738,17 @@ class NixlKVManager(CommonKVManager):
if kv_chunk.is_last:
self.update_status(room, KVPoll.Success)
# Drop per-room state on Success (parity with mooncake
# transfer_worker; staging prefetch sets are NIXL-only).
self.transfer_infos.pop(room, None)
self.req_to_decode_prefix_len.pop(room, None)
if self.enable_staging and self._staging_ctx is not None:
self._staging_ctx.prefetched_rooms.discard(room)
self._staging_ctx.prefetch_requested = {
k
for k in self._staging_ctx.prefetch_requested
if k[0] != room
}
else:
self.update_status(room, KVPoll.Transferring)
except Exception as e:
@@ -825,6 +1088,198 @@ class NixlKVManager(CommonKVManager):
return xfer_handle
def send_kvcache_staged(
self,
peer_name: str,
prefill_kv_indices: npt.NDArray[np.int32],
dst_staging_ptr: int,
dst_staging_size: int,
dst_gpu_id: int,
dst_tp_rank: int,
dst_attn_tp_size: int,
dst_kv_item_len: int,
notif: str,
staging_buffer=None,
):
"""Transfer KV cache via staging buffers (gather -> bulk RDMA -> scatter on decode)."""
from sglang.srt.disaggregation.common.staging_buffer import (
compute_head_slice_params,
compute_staging_layout,
gather_all_layers_to_staging,
resolve_total_kv_heads,
)
if self.kv_buffer_tensors is None or staging_buffer is None:
return None
k_buffers = self.kv_buffer_tensors["k_buffers"]
v_buffers = self.kv_buffer_tensors["v_buffers"]
page_size = self.kv_buffer_tensors["page_size"]
num_layers = len(k_buffers)
head_dim = k_buffers[0].shape[-1]
dtype_size = k_buffers[0].element_size()
total_kv_heads = resolve_total_kv_heads(self.kv_args, self.attn_tp_size)
local_tp_rank = self.kv_args.engine_rank % self.attn_tp_size
src_head_start, num_heads_to_send, _, _ = compute_head_slice_params(
self.attn_tp_size,
dst_attn_tp_size,
local_tp_rank,
dst_tp_rank,
total_kv_heads,
)
num_tokens = len(prefill_kv_indices) * page_size
per_layer_bytes = num_tokens * num_heads_to_send * head_dim * dtype_size
per_rank_bytes = per_layer_bytes * num_layers * 2
num_writers, writer_rank_bytes, total_staging_needed = compute_staging_layout(
self.attn_tp_size,
dst_attn_tp_size,
dst_tp_rank,
total_kv_heads,
num_tokens,
head_dim * dtype_size,
num_layers,
)
writer_idx = local_tp_rank % num_writers if num_writers > 1 else 0
rank_offset = sum(writer_rank_bytes[:writer_idx])
if not staging_buffer.fits(per_rank_bytes):
logger.warning(
f"Prefill staging too small for {per_rank_bytes} bytes, falling back"
)
return None
if dst_staging_size < total_staging_needed:
logger.warning(
f"Decode staging too small: need {total_staging_needed} bytes, "
f"have {dst_staging_size}, falling back"
)
return None
# gather_all_layers_to_staging() runs the gather kernel on its own
# dedicated stream and synchronizes that stream before returning, so
# the staging buffer is fully populated and visible to the NIC by the
# time we post the RDMA WRITE below. No extra sync needed (matches
# mooncake's send_kvcache_staged behavior).
gather_all_layers_to_staging(
k_buffers,
v_buffers,
prefill_kv_indices,
staging_buffer,
src_head_start,
num_heads_to_send,
page_size,
self.kv_args.gpu_id,
)
dst_write_ptr = dst_staging_ptr + rank_offset
src_reqs = np.array(
[[staging_buffer.get_ptr(), per_rank_bytes, self.kv_args.gpu_id]],
dtype=np.int64,
)
dst_reqs = np.array(
[[dst_write_ptr, per_rank_bytes, dst_gpu_id]], dtype=np.int64
)
src_descs = self.agent.get_xfer_descs(src_reqs, "VRAM")
dst_descs = self.agent.get_xfer_descs(dst_reqs, "VRAM")
xfer_handle = self.agent.initialize_xfer(
"WRITE", src_descs, dst_descs, peer_name, notif.encode("ascii")
)
if not xfer_handle:
raise RuntimeError(
f"[Staging] Failed to create NIXL bulk transfer "
f"(src=0x{staging_buffer.get_ptr():x}, dst=0x{dst_write_ptr:x}, "
f"size={per_rank_bytes})"
)
state = self.agent.transfer(xfer_handle)
if state == "ERR":
raise RuntimeError("[Staging] NIXL bulk transfer failed to post")
return xfer_handle
def _try_create_staging_strategy(self, staging_buffer):
"""Create a per-worker PrefillStagingStrategy bound to ``staging_buffer``.
Returns ``None`` if staging is disabled or kv tensors not yet set.
Caller is expected to keep the returned strategy as a worker-local
variable; never cache on ``self`` (multiple workers would race on
the underlying staging ring buffer).
"""
if not self.enable_staging or self.kv_buffer_tensors is None:
return None
from sglang.srt.disaggregation.common.staging_handler import (
PrefillStagingStrategy,
)
return PrefillStagingStrategy(self, staging_buffer)
def _do_staging_transfer(
self,
staging_strategy,
kv_chunk: "TransferKVChunk",
req: "TransferInfo",
dst_info: "KVArgsRegisterInfo",
queue: FastQueue,
):
"""Attempt staging transfer for one chunk. Returns (xfer_handle, deferred).
Mirrors mooncake._do_staging_transfer semantics:
- staging not ready (watermark/alloc pending) -> ``queue.put(kv_chunk)``
re-enqueue the chunk and return ``(None, True)``. Caller should
``break`` out of the per-req loop and ``continue`` the worker
main loop without updating room status -- the chunk will be
retried on the next pop.
- oversized chunk (will never fit) -> raise RuntimeError.
- staging successfully posted -> return ``(handle, False)``. The
caller appends the handle to the per-chunk handle list and
busy-polls it to DONE alongside other handles.
- send_kvcache_staged returned None (decode buffer too small,
kv_buffer_tensors missing, etc.) -> return ``(None, False)``,
signalling the caller to fall back to send_kvcache_slice.
"""
page_start = kv_chunk.index_slice.start
num_pages = len(kv_chunk.prefill_kv_indices)
ready, chunk_idx, c_offset, _, _ = staging_strategy.check_ready(
req, page_start, num_pages, session_id=req.agent_name
)
if not ready:
from sglang.srt.disaggregation.common.staging_buffer import (
StagingAllocator,
)
if c_offset == StagingAllocator.ALLOC_OVERSIZED:
raise RuntimeError(
f"[Staging] Chunk staging allocation permanently failed: "
f"chunk exceeds ring buffer total size "
f"(room={kv_chunk.room}). Increase "
f"SGLANG_DISAGG_STAGING_POOL_SIZE_MB."
)
queue.put(kv_chunk)
return (None, True)
notif_tag = (
f"{req.room}_stg_{kv_chunk.chunk_id}_{int(kv_chunk.is_last)}"
f"_{self.kv_args.engine_rank}_{chunk_idx}"
f"_{page_start}_{num_pages}_{req.agent_name}"
)
handle = self.send_kvcache_staged(
req.agent_name,
kv_chunk.prefill_kv_indices,
dst_info.staging.base_ptr + c_offset,
dst_info.staging.total_size - c_offset,
dst_info.gpu_id,
dst_info.decode_tp_rank,
dst_info.decode_tp_size,
dst_info.dst_kv_item_len,
notif_tag,
staging_buffer=staging_strategy.staging_buffer,
)
return (handle, False)
def send_aux(
self,
peer_name: str,
@@ -1124,6 +1579,19 @@ class NixlKVManager(CommonKVManager):
assert self.disaggregation_mode == DisaggregationMode.PREFILL
assert not is_last or (is_last and aux_index is not None)
# Prefetch STAGING_REQ to decode before enqueueing so decode has
# already allocated staging by the time the worker picks up the
# chunk. Internally a no-op when staging is disabled or no peer
# in this room needs heterogeneous-TP staging.
if self.enable_staging:
self._prefetch_staging_reqs(bootstrap_room)
# Transfer is async: just enqueue the chunk; the per-queue worker
# (transfer_worker) does the actual gather + RDMA. Routing by
# ``room % N`` keeps every chunk of a given room on the same
# worker -- and therefore on the same private staging buffer --
# which is required for the staging ring's offset/watermark
# state machine to advance correctly.
shard_idx = bootstrap_room % len(self.transfer_queues)
self.transfer_queues[shard_idx].put(
TransferKVChunk(
@@ -1142,45 +1610,132 @@ class NixlKVManager(CommonKVManager):
# Process notifications from received transfers.
notif_map = self.agent.get_new_notifs()
for peer_name, messages in notif_map.items():
# We could also check that self.bootstrap_info['agent_name'] matches
# the message sender. But the bootstrap room alone should be
# sufficient to map the status.
for msg in messages:
components = msg.decode("ascii").split("_", 4)
# Notification tag layouts (underscore-separated):
# kv: {room}_kv_{chunk_id}_{is_last}_{pp_rank} -> 5 fields
# stg: {room}_stg_{chunk_id}_{is_last}_{pp_rank}_{chunk_idx}
# _{page_start}_{num_pages}_{agent_name} -> 9 fields
# aux: {room}_aux -> 2 fields
# state: {room}_state_{pp_rank} -> 3 fields
# maxsplit=8 keeps everything past the 8th underscore in the
# last component, so agent_name (which may itself contain
# underscores) lands intact in components[8] for the stg path.
components = msg.decode("ascii").split("_", 8)
room = int(components[0])
if components[1] == "kv":
tag = components[1]
if tag == "kv":
chunk_id = int(components[2])
is_last = bool(int(components[3]))
pp_rank = int(components[4]) if len(components) > 4 else 0
# Track received chunks per pp_rank
self.transfer_statuses[room].received_kvs_per_pp[pp_rank].add(
chunk_id
)
if is_last:
# Record expected chunk count for this pp_rank
self.transfer_statuses[room].expected_kvs_per_pp[pp_rank] = (
chunk_id + 1
)
# Set num_pp_ranks_expected from table (or default to 1)
if self.transfer_statuses[room].num_pp_ranks_expected is None:
self.transfer_statuses[room].num_pp_ranks_expected = (
self.required_prefill_response_num_table.get(room, 1)
)
elif components[1] == "aux":
self.transfer_statuses[room].received_aux = True
# Handle "nokv" marker: no KV pages were sent for
# this pp_rank (decode-side radix cache hit).
if len(components) > 3 and components[2] == "nokv":
pp_rank = int(components[3])
self.transfer_statuses[room].expected_kvs_per_pp[pp_rank] = 0
if self.transfer_statuses[room].num_pp_ranks_expected is None:
self.transfer_statuses[room].num_pp_ranks_expected = (
self.required_prefill_response_num_table.get(room, 1)
)
elif components[1] == "state":
self._track_kv_arrival(room, chunk_id, is_last, pp_rank)
elif tag == "stg":
self._handle_stg_notification(components, room)
elif tag == "aux":
# main's "nokv" marker (decode-side radix cache hit):
# mark expected_kvs_per_pp[pp_rank] = 0 for this rank.
self._handle_aux_notification(room, components)
elif tag == "state":
pp_rank = int(components[2]) if len(components) > 2 else 0
self.transfer_statuses[room].received_state_per_pp.add(pp_rank)
def _handle_stg_notification(self, components, room: int):
"""Handle a staging RDMA notification tag.
Format: {room}_stg_{chunk_id}_{is_last}_{pp_rank}_{chunk_idx}_{page_start}_{num_pages}_{agent_name}
"""
chunk_id = int(components[2])
is_last = bool(int(components[3]))
pp_rank = int(components[4])
chunk_idx = int(components[5])
page_start = int(components[6])
num_pages = int(components[7])
agent_name = components[8] if len(components) > 8 else ""
self._track_kv_arrival(room, chunk_id, is_last, pp_rank)
self._handle_staging_chunk_arrived(
room, chunk_idx, page_start, num_pages, agent_name
)
def _handle_aux_notification(self, room: int, components: List[str]):
"""Handle an aux notification and trigger last scatter if staging is complete.
Notification tag layouts:
aux: {room}_aux -> 2 fields
aux (nokv): {room}_aux_nokv_{pp_rank} -> 4 fields
(decode-side radix cache hit; this pp_rank sent
no KV pages, so expected_kvs_per_pp[pp_rank] = 0)
"""
self.transfer_statuses[room].received_aux = True
# main's "nokv" marker (decode-side radix cache hit, see #19746).
if len(components) > 3 and components[2] == "nokv":
pp_rank = int(components[3])
self.transfer_statuses[room].expected_kvs_per_pp[pp_rank] = 0
if self.transfer_statuses[room].num_pp_ranks_expected is None:
self.transfer_statuses[room].num_pp_ranks_expected = (
self.required_prefill_response_num_table.get(room, 1)
)
if (
self.enable_staging
and self._staging_handler is not None
and self._staging_handler.is_staging_room(room)
):
self._maybe_submit_last_scatter(room)
def _track_kv_arrival(self, room: int, chunk_id: int, is_last: bool, pp_rank: int):
"""Update transfer status tracking for a kv chunk arrival."""
self.transfer_statuses[room].received_kvs_per_pp[pp_rank].add(chunk_id)
if is_last:
self.transfer_statuses[room].expected_kvs_per_pp[pp_rank] = chunk_id + 1
if self.transfer_statuses[room].num_pp_ranks_expected is None:
self.transfer_statuses[room].num_pp_ranks_expected = (
self.required_prefill_response_num_table.get(room, 1)
)
if (
self.enable_staging
and self._staging_handler is not None
and self._staging_handler.is_staging_room(room)
):
self._maybe_submit_last_scatter(room)
def _handle_staging_chunk_arrived(
self,
room: int,
chunk_idx: int,
page_start: int,
num_pages: int,
agent_name: str,
):
"""Process a staging chunk arrival via RDMA notification."""
handler = self._staging_handler
if handler is None:
return
handler.handle_chunk_arrived(
room,
chunk_idx,
page_start,
num_pages,
agent_name,
self._chunk_writer_counts,
)
def _maybe_submit_last_scatter(self, room: int):
"""Check if all kv+aux transfers are done and submit last scatter if so."""
status = self.transfer_statuses.get(room)
if status is None:
return
if not status.received_aux:
return
if status.num_pp_ranks_expected is None:
return
if len(status.expected_kvs_per_pp) < status.num_pp_ranks_expected:
return
for pp_rank, expected in status.expected_kvs_per_pp.items():
if len(status.received_kvs_per_pp[pp_rank]) != expected:
return
handler = self._staging_handler
if handler is not None and handler.is_staging_room(room):
handler.submit_last_scatter_async(room)
self._chunk_writer_counts.pop(room, None)
def check_transfer_done(self, room: int):
if room not in self.transfer_statuses:
return False
@@ -1194,6 +1749,27 @@ class NixlKVManager(CommonKVManager):
logger.debug(
f"Received multipart with total byte size {sum(len(x) for x in waiting_req_bytes)}"
)
# Staging: decode reports consumption watermark back to prefill
if waiting_req_bytes[0] == b"WATERMARK":
if self.enable_staging:
from sglang.srt.disaggregation.common.staging_handler import (
handle_watermark_msg,
)
handle_watermark_msg(self._staging_ctx, waiting_req_bytes)
continue
# Staging: decode replies with allocated staging offset
if waiting_req_bytes[0] == b"STAGING_RSP":
if self.enable_staging:
from sglang.srt.disaggregation.common.staging_handler import (
handle_staging_rsp,
)
handle_staging_rsp(waiting_req_bytes, self.transfer_infos)
continue
assert (
waiting_req_bytes[0] == GUARD
), f"First message should be {GUARD}. Foreign traffic?"
@@ -1356,6 +1932,16 @@ class NixlKVReceiver(CommonKVReceiver):
self.kv_mgr.update_status(self.bootstrap_room, KVPoll.Failed)
return
# Register staging room bootstrap info for staging handler
if (
self.kv_mgr.enable_staging
and self.kv_mgr._staging_ctx.allocator is not None
):
self.chunk_staging_infos = []
self.kv_mgr.register_staging_room_bootstrap(
self.bootstrap_room, self.bootstrap_infos, self
)
for bootstrap_info in self.bootstrap_infos:
logger.debug(
f"Fetched bootstrap info: {bootstrap_info} for engine rank: {self.kv_mgr.kv_args.engine_rank}"
@@ -1453,6 +2039,18 @@ class NixlKVReceiver(CommonKVReceiver):
getattr(self.kv_mgr.kv_args, "state_dim_per_tensor", []) or [], "I"
)
# Include staging allocator metadata if available
if (
self.kv_mgr.enable_staging
and self.kv_mgr._staging_ctx.allocator is not None
):
_alloc = self.kv_mgr._staging_ctx.allocator
packed_staging_base_ptr = struct.pack("Q", _alloc.get_base_ptr())
staging_total_size_str = str(_alloc.get_total_size()).encode("ascii")
else:
packed_staging_base_ptr = b""
staging_total_size_str = b""
with lock:
sock.send_multipart(
[
@@ -1471,6 +2069,8 @@ class NixlKVReceiver(CommonKVReceiver):
str(self.kv_mgr.kv_args.kv_item_lens[0]).encode("ascii"),
packed_state_item_lens,
packed_state_dim_per_tensor,
packed_staging_base_ptr,
staging_total_size_str,
]
)
+2 -2
View File
@@ -3982,11 +3982,11 @@ class ServerArgs:
if self.disaggregation_mode in ("prefill", "decode"):
if (
envs.SGLANG_DISAGG_STAGING_BUFFER.get()
and self.disaggregation_transfer_backend != "mooncake"
and self.disaggregation_transfer_backend not in ("mooncake", "nixl")
):
raise ValueError(
f"SGLANG_DISAGG_STAGING_BUFFER requires "
f"disaggregation_transfer_backend='mooncake', "
f"disaggregation_transfer_backend='mooncake' or 'nixl', "
f"got '{self.disaggregation_transfer_backend}'."
)