chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,6 @@
from sglang.srt.disaggregation.ascend.conn import (
AscendKVBootstrapServer,
AscendKVManager,
AscendKVReceiver,
AscendKVSender,
)
@@ -0,0 +1,191 @@
import concurrent.futures
import logging
from typing import List, Tuple
import numpy as np
import numpy.typing as npt
from sglang.srt.disaggregation.ascend.transfer_engine import AscendTransferEngine
from sglang.srt.disaggregation.common.utils import group_concurrent_contiguous
from sglang.srt.disaggregation.mooncake.conn import (
MooncakeKVBootstrapServer,
MooncakeKVManager,
MooncakeKVReceiver,
MooncakeKVSender,
)
from sglang.srt.utils.network import get_local_ip_auto
logger = logging.getLogger(__name__)
class AscendKVManager(MooncakeKVManager):
def init_engine(self):
# TransferEngine initialized on ascend.
local_ip = get_local_ip_auto()
self.engine = AscendTransferEngine(
hostname=local_ip,
npu_id=self.kv_args.gpu_id,
disaggregation_mode=self.disaggregation_mode,
)
def register_buffer_to_engine(self):
self.engine.batch_register(self.kv_args.kv_data_ptrs, self.kv_args.kv_data_lens)
# The Ascend backend optimize batch registration for small memory blocks.
self.engine.batch_register(
self.kv_args.aux_data_ptrs, self.kv_args.aux_data_lens
)
# Batch register state/extra pool data buffers
for component_ptrs, component_lens in zip(
self.kv_args.state_data_ptrs or [],
self.kv_args.state_data_lens or [],
):
self.engine.batch_register(component_ptrs, component_lens)
def get_mla_kv_ptrs_with_pp(
self, src_kv_ptrs: List[int], dst_kv_ptrs: List[int]
) -> Tuple[List[int], List[int], int]:
# src_kv_ptrs: k_data, v_data, index_k_data(optional)
# dst_kv_ptrs: k_data, v_data, index_k_data(optional)
start_layer = self.kv_args.prefill_start_layer
kv_buf_groups = getattr(self.kv_args, "kv_buf_groups", 1)
total_kv_layers = getattr(self.kv_args, "total_kv_layers", 0)
src_layers = len(src_kv_ptrs) // kv_buf_groups
# When only speculative-algorithm is enabled for decode
# the KV has one more layer than prefill.
# The draft layer needs to be skipped.
dst_total_layers = (
min(len(dst_kv_ptrs) // kv_buf_groups, total_kv_layers)
if total_kv_layers
else len(dst_kv_ptrs) // kv_buf_groups
)
end_layer = start_layer + src_layers
if src_layers == dst_total_layers:
sliced_dst_kv_ptrs = dst_kv_ptrs
else:
sliced_dst_kv_ptrs = []
for i in range(kv_buf_groups):
layer_offset = i * dst_total_layers
sliced_dst_kv_ptrs.extend(
dst_kv_ptrs[layer_offset + start_layer : layer_offset + end_layer]
)
layers_current_pp_stage = len(src_kv_ptrs)
return src_kv_ptrs, sliced_dst_kv_ptrs, layers_current_pp_stage
def send_kvcache(
self,
mooncake_session_id: str,
prefill_kv_indices: npt.NDArray[np.int32],
dst_kv_ptrs: list[int],
dst_kv_indices: npt.NDArray[np.int32],
executor: concurrent.futures.ThreadPoolExecutor,
):
# Group by indices
prefill_kv_blocks, dst_kv_blocks = group_concurrent_contiguous(
prefill_kv_indices, dst_kv_indices
)
if self.pp_size > 1:
if self.is_mla_backend:
src_kv_ptrs, sliced_dst_kv_ptrs, layers_current_pp_stage = (
self.get_mla_kv_ptrs_with_pp(self.kv_args.kv_data_ptrs, dst_kv_ptrs)
)
layers_params = [
(
src_kv_ptrs[layer_id],
sliced_dst_kv_ptrs[layer_id],
self.kv_args.kv_item_lens[layer_id],
)
for layer_id in range(layers_current_pp_stage)
]
else:
(
src_k_ptrs,
src_v_ptrs,
dst_k_ptrs,
dst_v_ptrs,
layers_current_pp_stage,
) = self.get_mha_kv_ptrs_with_pp(self.kv_args.kv_data_ptrs, dst_kv_ptrs)
layers_params = [
(
src_k_ptrs[layer_id],
dst_k_ptrs[layer_id],
self.kv_args.kv_item_lens[layer_id],
)
for layer_id in range(layers_current_pp_stage)
] + [
(
src_v_ptrs[layer_id],
dst_v_ptrs[layer_id],
self.kv_args.kv_item_lens[layers_current_pp_stage + layer_id],
)
for layer_id in range(layers_current_pp_stage)
]
else:
num_layers = len(self.kv_args.kv_data_ptrs)
layers_params = [
(
self.kv_args.kv_data_ptrs[layer_id],
dst_kv_ptrs[layer_id],
self.kv_args.kv_item_lens[layer_id],
)
for layer_id in range(num_layers)
]
def set_transfer_blocks(
src_ptr: int, dst_ptr: int, item_len: int
) -> List[Tuple[int, int, int]]:
transfer_blocks = []
for prefill_index, decode_index in zip(prefill_kv_blocks, dst_kv_blocks):
src_addr = src_ptr + int(prefill_index[0]) * item_len
dst_addr = dst_ptr + int(decode_index[0]) * item_len
length = item_len * len(prefill_index)
transfer_blocks.append((src_addr, dst_addr, length))
return transfer_blocks
# Worker function for processing a single layer
def process_layer(src_ptr: int, dst_ptr: int, item_len: int) -> int:
transfer_blocks = set_transfer_blocks(src_ptr, dst_ptr, item_len)
return self._transfer_data(mooncake_session_id, transfer_blocks)
# Worker function for processing all layers in a batch
def process_layers(layers_params: List[Tuple[int, int, int]]) -> int:
transfer_blocks = []
for src_ptr, dst_ptr, item_len in layers_params:
transfer_blocks.extend(set_transfer_blocks(src_ptr, dst_ptr, item_len))
return self._transfer_data(mooncake_session_id, transfer_blocks)
if self.enable_custom_mem_pool:
futures = [
executor.submit(
process_layer,
src_ptr,
dst_ptr,
item_len,
)
for (src_ptr, dst_ptr, item_len) in layers_params
]
for future in concurrent.futures.as_completed(futures):
status = future.result()
if status != 0:
for f in futures:
f.cancel()
return status
else:
# Combining all layers' params in one batch transfer is more efficient
# compared to using multiple threads
return process_layers(layers_params)
return 0
class AscendKVSender(MooncakeKVSender):
pass
class AscendKVReceiver(MooncakeKVReceiver):
pass
class AscendKVBootstrapServer(MooncakeKVBootstrapServer):
pass
@@ -0,0 +1,103 @@
import logging
import os
from typing import List
import torch
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.distributed.device_communicators.mooncake_transfer_engine import (
MooncakeTransferEngine,
)
from sglang.srt.utils.network import NetworkAddress
try:
from memfabric_hybrid import TransferEngine
import_error = None
except ImportError as e:
import_error = e
pass
logger = logging.getLogger(__name__)
class AscendTransferEngine(MooncakeTransferEngine):
def __init__(
self,
hostname: str,
npu_id: int,
disaggregation_mode: DisaggregationMode,
):
if import_error is not None:
logger.warning(
"Please install memfabric_hybrid, for details, see docs/backend/pd_disaggregation.md"
)
raise import_error
self.engine = TransferEngine()
self.hostname = hostname
self.npu_id = npu_id
# Centralized storage address of the AscendTransferEngine
self.store_url = os.getenv("ASCEND_MF_STORE_URL")
if disaggregation_mode == DisaggregationMode.PREFILL:
self.role = "Prefill"
elif disaggregation_mode == DisaggregationMode.DECODE:
self.role = "Decode"
else:
logger.error(f"Unsupported DisaggregationMode: {disaggregation_mode}")
raise ValueError(f"Unsupported DisaggregationMode: {disaggregation_mode}")
self.session_id = NetworkAddress(
self.hostname, self.engine.get_rpc_port()
).to_host_port_str()
self.initialize()
def initialize(self) -> None:
from sglang.srt.distributed.parallel_state import (
get_world_group,
get_world_size,
)
transfer_protocol = self._get_transfer_protocol()
if transfer_protocol is None or transfer_protocol == "sdma":
trans_op_type = TransferEngine.TransDataOpType.SDMA
else:
trans_op_type = TransferEngine.TransDataOpType.DEVICE_RDMA
"""with device RDMA for PD transfer"""
tmp_tensor = torch.zeros(1, device="npu")
output_tensor_list = [
torch.empty_like(tmp_tensor) for _ in range(get_world_size())
]
# Initialize hccl in advance through all_gather to avoid conflicts with rdma initialization.
torch.distributed.all_gather(
output_tensor_list, tmp_tensor, group=get_world_group().device_group
)
"""Initialize the ascend transfer instance."""
ret_value = self.engine.initialize(
self.store_url, self.session_id, self.role, self.npu_id, trans_op_type
)
if ret_value != 0:
logger.error("Ascend Transfer Engine initialization failed.")
raise RuntimeError("Ascend Transfer Engine initialization failed.")
def batch_register(self, ptrs: List[int], lengths: List[int]):
try:
ret_value = self.engine.batch_register_memory(ptrs, lengths)
except Exception:
# Mark register as failed
ret_value = -1
if ret_value != 0:
logger.debug(f"Ascend memory registration for ptr {ptrs} failed.")
@staticmethod
def _get_transfer_protocol():
protocol = os.getenv("ASCEND_MF_TRANSFER_PROTOCOL")
allowed_protocols = {"device_rdma", "sdma"}
if protocol and protocol.lower() in allowed_protocols:
return protocol.lower()
else:
logger.warning(
"Invalid or no transfer protocol specified, using default protocol."
)
return None
@@ -0,0 +1,8 @@
from sglang.srt.disaggregation.base.conn import (
BaseKVBootstrapServer,
BaseKVManager,
BaseKVReceiver,
BaseKVSender,
KVArgs,
KVPoll,
)
@@ -0,0 +1,223 @@
from __future__ import annotations
import dataclasses
import enum
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, List, Optional
import numpy as np
import numpy.typing as npt
from sglang.srt.server_args import ServerArgs
if TYPE_CHECKING:
from sglang.srt.disaggregation.utils import DisaggregationMode
class StateType(str, enum.Enum):
MAMBA = "mamba"
SWA = "swa"
DSA = "dsa"
MINIMAX_INDEX_K = "minimax_index_k"
# DeepSeek-V4 unified_kv SWA ring: addressed per-row by ring slot
# (req_pool_idx * ring_stride + pos % ring_stride), needs its own component.
SWA_RING = "swa_ring"
# DeepSeek-V4 online C128 request-scoped state.
C128_STATE = "c128_state"
@dataclasses.dataclass
class KVTransferMetric:
# Backends that cannot isolate transfer latency can leave this as None.
transfer_latency_s: Optional[float] = None
# Backends that cannot isolate allocation wait latency can leave this as None.
alloc_latency_s: Optional[float] = None
transfer_total_bytes: Optional[int] = None
class KVArgs:
engine_rank: int
kv_data_ptrs: List[int]
kv_data_lens: List[int]
kv_item_lens: List[int]
aux_data_ptrs: List[int]
aux_data_lens: List[int]
aux_item_lens: List[int]
state_types: List[StateType]
state_data_ptrs: List[List[int]]
state_data_lens: List[List[int]]
state_item_lens: List[List[int]]
# Per-tensor TP slice dim, used when prefill/decode attn_tp_size differ.
state_dim_per_tensor: List[List[int]]
is_hybrid_mla_backend: bool
ib_device: str
ib_traffic_class: str
gpu_id: int
kv_head_num: int
total_kv_head_num: int
page_size: int
# for system dp
system_dp_rank: int
# for pp prefill
pp_rank: int
prefill_start_layer: int
# Absolute end layer (exclusive) for this prefill PP stage. Needed to
# reconstruct PP sub-ranges when kv_data_ptrs does not use a flat
# layer-indexed layout (e.g. DeepSeek V4's buffer-type-organized flat
# list).
prefill_end_layer: Optional[int]
# For DeepSeek V4 (and other compressed-MLA) memory pools only.
# Full-model compression ratio per layer (entries are 0/4/128). Used by
# the connection layer to slice the buffer-type-organized flat list in a
# PP-aware manner.
mla_compression_ratios: Optional[List[int]]
# Only used of npu, for kv buf groups
kv_buf_groups: int
# Only used of npu, for decode total kv layers
total_kv_layers: int
class KVPoll:
Failed = 0
Bootstrapping = 1
WaitingForInput = 2
Transferring = 3
Success = 4
class BaseKVManager(ABC):
"""Base class for managing transfer states"""
@abstractmethod
def __init__(
self,
args: KVArgs,
disaggregation_mode: DisaggregationMode,
server_args: ServerArgs,
is_mla_backend: Optional[bool] = False,
): ...
@abstractmethod
def register_to_bootstrap(self):
"""Register prefill server info to the bootstrap server."""
...
class BaseKVSender(ABC):
@abstractmethod
def __init__(
self,
mgr: BaseKVManager,
bootstrap_addr: str,
bootstrap_room: int,
dest_tp_ranks: List[int],
pp_rank: int,
req_has_disagg_prefill_dp_rank: bool = False,
): ...
@abstractmethod
def init(self, num_kv_indices: int, aux_index: Optional[int] = None):
"""
Set req's index metadata locally or notify the decoder server about the kv indices length and aux index.
"""
...
@abstractmethod
def send(
self,
kv_indices: npt.NDArray[np.int32],
state_indices: Optional[List] = None,
):
"""
Send the kv cache at the given kv indices and the extra cache/state at the given indices to the decoder server.
"""
...
def pop_decode_prefix_len(self) -> int:
return 0
def should_send_kv_chunk(self, num_pages: int, last_chunk: bool) -> bool:
return num_pages > 0
@abstractmethod
def get_transfer_metric(self) -> KVTransferMetric:
"""Return backend-specific transfer metrics for this sender."""
...
@abstractmethod
def poll(self) -> KVPoll:
"""
Check the status of the kv cache transfer.
"""
...
@abstractmethod
def failure_exception(self):
"""
Raise an exception if the kv cache transfer fails.
"""
...
class BaseKVReceiver(ABC):
@abstractmethod
def __init__(
self,
mgr: BaseKVManager,
bootstrap_addr: str,
bootstrap_room: Optional[int] = None,
): ...
@abstractmethod
def init(
self,
prefill_dp_rank: int,
):
"""
Resolve bootstrap metadata and mark the receiver ready for transfer metadata.
"""
...
@abstractmethod
def send_metadata(
self,
kv_indices: npt.NDArray[np.int32],
aux_index: Optional[int] = None,
state_indices: Optional[List] = None,
decode_prefix_len: Optional[int] = None,
):
"""
Notify the prefill server about the kv indices, aux index, and state_indices.
"""
...
@abstractmethod
def poll(self) -> KVPoll:
"""
Check the status of the kv cache transfer.
"""
...
@abstractmethod
def failure_exception(self):
"""
Raise an exception if the kv cache transfer fails.
"""
...
def clear(self):
"""
Clear any internal states.
"""
pass
def abort(self):
"""
Abort the current transfer.
"""
pass
class BaseKVBootstrapServer(ABC):
@abstractmethod
def __init__(self, host: str, port: int): ...
@@ -0,0 +1,5 @@
from sglang.srt.disaggregation.common.conn import (
CommonKVBootstrapServer,
CommonKVManager,
CommonKVReceiver,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,768 @@
"""
GPU Staging Buffer for heterogeneous TP KV cache transfer.
When prefill attn_tp_size != decode attn_tp_size, the per-token RDMA approach
generates O(tokens * layers) small RDMA requests. This module provides a staging
buffer mechanism that gathers scattered head slices into contiguous GPU memory,
enabling bulk RDMA transfers that reduce request count to O(layers) or O(1).
Usage:
Activated by setting SGLANG_DISAGG_STAGING_BUFFER=1.
"""
from __future__ import annotations
import logging
import os
import threading
from typing import List, Optional, Tuple
import torch
import triton
import triton.language as tl
logger = logging.getLogger(__name__)
# TODO(yangminl): remove torch fallback implementations once the Triton kernels
# have been validated in production across all configurations.
_USE_TRITON_STAGING = not bool(os.environ.get("SGLANG_STAGING_USE_TORCH", ""))
@triton.jit
def _fused_gather_to_staging_kernel(
layer_ptrs,
page_indices,
staging,
num_tokens,
stride_pool_token,
head_offset,
per_layer_elems,
ELEMS_PER_TOKEN: tl.constexpr,
PAGE_SIZE: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
layer_id = tl.program_id(0)
block_id = tl.program_id(1)
layer_ptr = tl.load(layer_ptrs + layer_id).to(staging.dtype)
offsets = block_id * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
mask = offsets < per_layer_elems
t_idx = offsets // ELEMS_PER_TOKEN
e_idx = offsets % ELEMS_PER_TOKEN
page_id = t_idx // PAGE_SIZE
intra_page = t_idx % PAGE_SIZE
page_val = tl.load(page_indices + page_id, mask=mask, other=0)
pool_token = page_val * PAGE_SIZE + intra_page
src_offsets = (
pool_token * stride_pool_token.to(tl.int64) + head_offset.to(tl.int64) + e_idx
)
vals = tl.load(layer_ptr + src_offsets, mask=mask)
dst_offsets = tl.program_id(0).to(tl.int64) * per_layer_elems.to(tl.int64) + offsets
tl.store(staging + dst_offsets, vals, mask=mask)
@triton.jit
def _fused_scatter_from_staging_kernel(
layer_ptrs,
page_indices,
staging,
writer_head_offsets,
num_tokens,
stride_pool_token,
per_layer_elems,
ELEMS_PER_TOKEN: tl.constexpr,
PAGE_SIZE: tl.constexpr,
NUM_LAYERS_X2: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
prog_id = tl.program_id(0)
block_id = tl.program_id(1)
writer_id = prog_id // NUM_LAYERS_X2
layer_kv_id = prog_id % NUM_LAYERS_X2
layer_ptr = tl.load(layer_ptrs + layer_kv_id).to(staging.dtype)
head_offset = tl.load(writer_head_offsets + writer_id)
offsets = block_id * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
mask = offsets < per_layer_elems
t_idx = offsets // ELEMS_PER_TOKEN
e_idx = offsets % ELEMS_PER_TOKEN
page_id = t_idx // PAGE_SIZE
intra_page = t_idx % PAGE_SIZE
page_val = tl.load(page_indices + page_id, mask=mask, other=0)
pool_token = page_val * PAGE_SIZE + intra_page
per_rank_elems = per_layer_elems.to(tl.int64) * NUM_LAYERS_X2
src_offsets = (
writer_id.to(tl.int64) * per_rank_elems
+ layer_kv_id.to(tl.int64) * per_layer_elems.to(tl.int64)
+ offsets
)
vals = tl.load(staging + src_offsets, mask=mask)
dst_offsets = (
pool_token * stride_pool_token.to(tl.int64) + head_offset.to(tl.int64) + e_idx
)
tl.store(layer_ptr + dst_offsets, vals, mask=mask)
class StagingBuffer:
"""Pre-allocated GPU staging buffer for bulk KV transfer.
When a custom_mem_pool is provided (e.g., mooncake NVLink allocator),
the buffer is allocated within that pool so it's compatible with
NVLink/MNNVL transport (requires cuMemCreate-backed memory).
"""
def __init__(
self,
size_bytes: int,
device: str,
gpu_id: int,
custom_mem_pool=None,
):
self.size_bytes = size_bytes
self.device = device
self.gpu_id = gpu_id
torch.cuda.set_device(gpu_id)
if custom_mem_pool is not None:
with torch.cuda.use_mem_pool(custom_mem_pool):
self.buffer = torch.empty(size_bytes, dtype=torch.uint8, device=device)
alloc_method = "custom_mem_pool (cuMemCreate)"
else:
self.buffer = torch.empty(size_bytes, dtype=torch.uint8, device=device)
alloc_method = "cudaMalloc"
self.data_ptr = self.buffer.data_ptr()
logger.info(
f"StagingBuffer allocated: {size_bytes / (1024*1024):.1f} MB "
f"on {device}, method={alloc_method}, ptr=0x{self.data_ptr:x}"
)
def get_ptr(self) -> int:
return self.data_ptr
def get_size(self) -> int:
return self.size_bytes
def fits(self, required_bytes: int) -> bool:
return required_bytes <= self.size_bytes
class StagingAllocator:
"""Decode-side dynamic staging ring buffer allocator with overcommit.
One large pre-allocated GPU buffer used as a ring buffer. Each request
gets a (alloc_id, offset, round) triple based on its actual byte
requirement. Allocation (assign) is overcommit — it always succeeds
as long as the request fits in the buffer. Overlap safety is enforced
on the prefill side before RDMA, using a watermark that tracks the
oldest un-freed allocation.
The watermark (round, tail_offset) is periodically sent to prefill.
Prefill transfer workers wait before writing if their target region
overlaps with not-yet-freed data from a previous round.
"""
# Permanent alloc failure: chunk exceeds ring buffer total size.
ALLOC_OVERSIZED = -2
def __init__(
self,
total_size_bytes: int,
device: str,
gpu_id: int,
custom_mem_pool=None,
):
self.buffer = StagingBuffer(total_size_bytes, device, gpu_id, custom_mem_pool)
self.total_size = total_size_bytes
self.base_ptr = self.buffer.data_ptr
self.head = 0
self.round = 0
self.allocations: dict = {} # alloc_id -> (offset, size, round)
self.alloc_order: List[int] = []
self.next_alloc_id = 0
self.watermark_round = 0
self.watermark_tail = 0
self.lock = threading.Lock()
logger.info(
f"StagingAllocator (ring+overcommit): "
f"{total_size_bytes / (1024*1024):.1f} MB "
f"on {device}, ptr=0x{self.base_ptr:x}"
)
def assign(self, required_bytes: int) -> Optional[Tuple[int, int, int]]:
"""Allocate a region. Returns (alloc_id, offset, round) or None."""
with self.lock:
if required_bytes > self.total_size:
return None
space_at_end = self.total_size - self.head
if required_bytes <= space_at_end:
offset = self.head
self.head += required_bytes
else:
self.round += 1
offset = 0
self.head = required_bytes
alloc_id = self.next_alloc_id
self.next_alloc_id += 1
self.allocations[alloc_id] = (offset, required_bytes, self.round)
self.alloc_order.append(alloc_id)
return (alloc_id, offset, self.round)
def free(self, alloc_id: int):
"""Free an allocation and advance watermark past consecutive freed entries."""
with self.lock:
if alloc_id not in self.allocations:
return
self.allocations.pop(alloc_id)
while self.alloc_order and self.alloc_order[0] not in self.allocations:
self.alloc_order.pop(0)
if not self.allocations:
self.watermark_round = self.round
self.watermark_tail = self.head
elif self.alloc_order:
off, _, rnd = self.allocations[self.alloc_order[0]]
self.watermark_round = rnd
self.watermark_tail = off
def get_watermark(self) -> Tuple[int, int]:
"""Return (round, tail_offset). Everything before this is safe to write."""
with self.lock:
return (self.watermark_round, self.watermark_tail)
def get_ptr(self, alloc_id: int) -> int:
offset, _, _ = self.allocations[alloc_id]
return self.base_ptr + offset
def get_offset(self, alloc_id: int) -> int:
offset, _, _ = self.allocations[alloc_id]
return offset
def get_round(self, alloc_id: int) -> int:
_, _, rnd = self.allocations[alloc_id]
return rnd
def get_base_ptr(self) -> int:
return self.base_ptr
def get_total_size(self) -> int:
return self.total_size
def gather_kv_head_slices(
kv_buffer_tensor: torch.Tensor,
gather_idx: torch.Tensor,
head_start: int,
num_heads: int,
staging_tensor: torch.Tensor,
):
"""Gather KV head slices from scattered pages into contiguous staging buffer.
Uses torch.gather(out=) to write directly into staging_tensor without
allocating temporary tensors (avoids CUDA caching allocator stalls).
Args:
kv_buffer_tensor: [pool_size, head_num, head_dim], one layer.
gather_idx: [num_tokens, num_heads, head_dim] int64, pre-computed
token indices expanded for gather on dim=0.
head_start: Starting head index for the slice.
num_heads: Number of heads to gather.
staging_tensor: Output tensor, shape [num_tokens, num_heads, head_dim].
"""
src = kv_buffer_tensor[:, head_start : head_start + num_heads, :]
torch.gather(src, 0, gather_idx, out=staging_tensor)
def scatter_kv_head_slices(
staging_tensor: torch.Tensor,
kv_buffer_tensor: torch.Tensor,
page_indices: torch.Tensor,
head_start: int,
num_heads: int,
page_size: int = 1,
):
"""Scatter KV head slices from contiguous staging buffer to KV cache.
Args:
staging_tensor: Input tensor from staging buffer (contiguous packed data).
kv_buffer_tensor: The KV buffer for one layer, shape [pool_size, head_num, head_dim].
page_indices: [num_pages] int32/int64 tensor of page indices.
head_start: Starting head index for the slice.
num_heads: Number of heads to scatter.
page_size: Number of tokens per page.
"""
head_dim = kv_buffer_tensor.shape[-1]
if page_size == 1:
num_tokens = page_indices.shape[0]
data = staging_tensor.reshape(num_tokens, num_heads, head_dim)
kv_buffer_tensor[page_indices, head_start : head_start + num_heads, :] = data
else:
num_tokens = page_indices.shape[0] * page_size
offsets = torch.arange(page_size, device=page_indices.device)
token_indices = (page_indices.unsqueeze(1) * page_size + offsets).reshape(-1)
data = staging_tensor.reshape(num_tokens, num_heads, head_dim)
kv_buffer_tensor[token_indices, head_start : head_start + num_heads, :] = data
def _gather_all_layers_torch(
k_buffers: list,
v_buffers: list,
page_indices_np,
staging_buffer: StagingBuffer,
src_head_start: int,
num_heads: int,
page_size: int,
gpu_id: int,
) -> int:
"""torch.gather path: zero per-layer allocation, one kernel per layer."""
import numpy as np
num_layers = len(k_buffers)
head_dim = k_buffers[0].shape[-1]
dtype_size = k_buffers[0].element_size()
num_tokens = len(page_indices_np) * page_size
per_layer_bytes = num_tokens * num_heads * head_dim * dtype_size
device = f"cuda:{gpu_id}"
torch.cuda.set_device(gpu_id)
page_idx_tensor = torch.from_numpy(page_indices_np.astype(np.int64)).to(device)
if page_size == 1:
token_indices = page_idx_tensor
else:
offsets = torch.arange(page_size, device=device)
token_indices = (page_idx_tensor.unsqueeze(1) * page_size + offsets).reshape(-1)
gather_idx = token_indices.view(-1, 1, 1).expand(num_tokens, num_heads, head_dim)
if not hasattr(staging_buffer, "_gather_stream"):
staging_buffer._gather_stream = torch.cuda.Stream(device=device)
staging_buffer._gather_stream.wait_stream(
torch.cuda.default_stream(torch.device(device))
)
staging_view = staging_buffer.buffer
offset = 0
with torch.cuda.stream(staging_buffer._gather_stream):
for layer_id in range(num_layers):
dst = (
staging_view[offset : offset + per_layer_bytes]
.view(k_buffers[layer_id].dtype)
.reshape(num_tokens, num_heads, head_dim)
)
gather_kv_head_slices(
k_buffers[layer_id],
gather_idx,
src_head_start,
num_heads,
dst,
)
offset += per_layer_bytes
for layer_id in range(num_layers):
dst = (
staging_view[offset : offset + per_layer_bytes]
.view(v_buffers[layer_id].dtype)
.reshape(num_tokens, num_heads, head_dim)
)
gather_kv_head_slices(
v_buffers[layer_id],
gather_idx,
src_head_start,
num_heads,
dst,
)
offset += per_layer_bytes
staging_buffer._gather_stream.synchronize()
return offset
def _gather_all_layers_triton(
k_buffers: list,
v_buffers: list,
page_indices_np,
staging_buffer: StagingBuffer,
src_head_start: int,
num_heads: int,
page_size: int,
gpu_id: int,
) -> int:
"""Triton fused kernel path: single kernel launch for all layers."""
import numpy as np
num_layers = len(k_buffers)
head_dim = k_buffers[0].shape[-1]
total_heads = k_buffers[0].shape[1]
dtype_size = k_buffers[0].element_size()
num_tokens = len(page_indices_np) * page_size
elems_per_token = num_heads * head_dim
per_layer_elems = num_tokens * elems_per_token
per_layer_bytes = per_layer_elems * dtype_size
total_bytes = per_layer_bytes * num_layers * 2
device = f"cuda:{gpu_id}"
torch.cuda.set_device(gpu_id)
page_idx_tensor = torch.from_numpy(page_indices_np.astype(np.int64)).to(device)
layer_ptrs = torch.tensor(
[buf.data_ptr() for buf in k_buffers] + [buf.data_ptr() for buf in v_buffers],
dtype=torch.int64,
device=device,
)
# Use integer dtype matching element size for bit-preserving copy
int_dtype_map = {1: torch.int8, 2: torch.int16, 4: torch.int32}
int_dtype = int_dtype_map.get(dtype_size, torch.int16)
staging_typed = staging_buffer.buffer[:total_bytes].view(int_dtype)
if not hasattr(staging_buffer, "_gather_stream"):
staging_buffer._gather_stream = torch.cuda.Stream(device=device)
staging_buffer._gather_stream.wait_stream(
torch.cuda.default_stream(torch.device(device))
)
BLOCK_SIZE = 1024
grid = (2 * num_layers, triton.cdiv(per_layer_elems, BLOCK_SIZE))
with torch.cuda.stream(staging_buffer._gather_stream):
_fused_gather_to_staging_kernel[grid](
layer_ptrs,
page_idx_tensor,
staging_typed,
num_tokens,
total_heads * head_dim,
src_head_start * head_dim,
per_layer_elems,
elems_per_token,
page_size,
BLOCK_SIZE,
)
staging_buffer._gather_stream.synchronize()
return total_bytes
def gather_all_layers_to_staging(
k_buffers: list,
v_buffers: list,
page_indices_np,
staging_buffer: StagingBuffer,
src_head_start: int,
num_heads: int,
page_size: int,
gpu_id: int,
) -> int:
"""Gather all layers' K and V head slices into a staging buffer.
Returns total bytes written.
Dispatches to Triton fused kernel when available, falls back to torch.gather.
"""
if _USE_TRITON_STAGING:
return _gather_all_layers_triton(
k_buffers,
v_buffers,
page_indices_np,
staging_buffer,
src_head_start,
num_heads,
page_size,
gpu_id,
)
return _gather_all_layers_torch(
k_buffers,
v_buffers,
page_indices_np,
staging_buffer,
src_head_start,
num_heads,
page_size,
gpu_id,
)
def _scatter_staging_to_kv_torch(
staging_buffer_view: torch.Tensor,
k_buffers: list,
v_buffers: list,
page_idx_tensor: torch.Tensor,
page_size: int,
prefill_attn_tp_size: int,
decode_attn_tp_size: int,
dst_tp_rank: int,
total_kv_heads: int,
) -> None:
"""torch path for scatter."""
num_layers = len(k_buffers)
head_dim = k_buffers[0].shape[-1]
dtype_size = k_buffers[0].element_size()
num_tokens = page_idx_tensor.shape[0] * page_size
if prefill_attn_tp_size > decode_attn_tp_size:
num_writers = prefill_attn_tp_size // max(1, decode_attn_tp_size)
else:
num_writers = 1
for writer_rank in range(num_writers):
_, num_heads, dst_head_start, _ = compute_head_slice_params(
prefill_attn_tp_size,
decode_attn_tp_size,
writer_rank,
dst_tp_rank,
total_kv_heads,
)
per_layer_bytes = num_tokens * num_heads * head_dim * dtype_size
per_rank_bytes = per_layer_bytes * num_layers * 2
rank_base = writer_rank * per_rank_bytes
offset = rank_base
for layer_id in range(num_layers):
layer_data = (
staging_buffer_view[offset : offset + per_layer_bytes]
.view(k_buffers[layer_id].dtype)
.reshape(num_tokens, num_heads, head_dim)
)
scatter_kv_head_slices(
layer_data,
k_buffers[layer_id],
page_idx_tensor,
dst_head_start,
num_heads,
page_size,
)
offset += per_layer_bytes
for layer_id in range(num_layers):
layer_data = (
staging_buffer_view[offset : offset + per_layer_bytes]
.view(v_buffers[layer_id].dtype)
.reshape(num_tokens, num_heads, head_dim)
)
scatter_kv_head_slices(
layer_data,
v_buffers[layer_id],
page_idx_tensor,
dst_head_start,
num_heads,
page_size,
)
offset += per_layer_bytes
def _scatter_staging_to_kv_triton(
staging_buffer_view: torch.Tensor,
k_buffers: list,
v_buffers: list,
page_idx_tensor: torch.Tensor,
page_size: int,
prefill_attn_tp_size: int,
decode_attn_tp_size: int,
dst_tp_rank: int,
total_kv_heads: int,
) -> None:
"""Triton fused kernel path for scatter."""
num_layers = len(k_buffers)
head_dim = k_buffers[0].shape[-1]
total_heads = k_buffers[0].shape[1]
dtype_size = k_buffers[0].element_size()
num_tokens = page_idx_tensor.shape[0] * page_size
device = page_idx_tensor.device
if prefill_attn_tp_size > decode_attn_tp_size:
num_writers = prefill_attn_tp_size // max(1, decode_attn_tp_size)
else:
num_writers = 1
# All writers share the same num_heads; only dst_head_start differs
_, num_heads, _, _ = compute_head_slice_params(
prefill_attn_tp_size,
decode_attn_tp_size,
0,
dst_tp_rank,
total_kv_heads,
)
elems_per_token = num_heads * head_dim
per_layer_elems = num_tokens * elems_per_token
layer_ptrs = torch.tensor(
[buf.data_ptr() for buf in k_buffers] + [buf.data_ptr() for buf in v_buffers],
dtype=torch.int64,
device=device,
)
writer_head_offsets = torch.tensor(
[
compute_head_slice_params(
prefill_attn_tp_size,
decode_attn_tp_size,
wr,
dst_tp_rank,
total_kv_heads,
)[2]
* head_dim
for wr in range(num_writers)
],
dtype=torch.int64,
device=device,
)
int_dtype_map = {1: torch.int8, 2: torch.int16, 4: torch.int32}
int_dtype = int_dtype_map.get(dtype_size, torch.int16)
total_staging_bytes = (
num_tokens * elems_per_token * dtype_size * num_layers * 2 * num_writers
)
staging_typed = staging_buffer_view[:total_staging_bytes].view(int_dtype)
BLOCK_SIZE = 1024
num_layers_x2 = 2 * num_layers
grid = (num_writers * num_layers_x2, triton.cdiv(per_layer_elems, BLOCK_SIZE))
_fused_scatter_from_staging_kernel[grid](
layer_ptrs,
page_idx_tensor,
staging_typed,
writer_head_offsets,
num_tokens,
total_heads * head_dim,
per_layer_elems,
elems_per_token,
page_size,
num_layers_x2,
BLOCK_SIZE,
)
def scatter_staging_to_kv(
staging_buffer_view: torch.Tensor,
k_buffers: list,
v_buffers: list,
page_idx_tensor: torch.Tensor,
page_size: int,
prefill_attn_tp_size: int,
decode_attn_tp_size: int,
dst_tp_rank: int,
total_kv_heads: int,
) -> None:
"""Scatter data from a contiguous staging region into KV cache buffers."""
if _USE_TRITON_STAGING:
return _scatter_staging_to_kv_triton(
staging_buffer_view,
k_buffers,
v_buffers,
page_idx_tensor,
page_size,
prefill_attn_tp_size,
decode_attn_tp_size,
dst_tp_rank,
total_kv_heads,
)
return _scatter_staging_to_kv_torch(
staging_buffer_view,
k_buffers,
v_buffers,
page_idx_tensor,
page_size,
prefill_attn_tp_size,
decode_attn_tp_size,
dst_tp_rank,
total_kv_heads,
)
def compute_head_slice_params(
src_attn_tp_size: int,
dst_attn_tp_size: int,
src_tp_rank: int,
dst_tp_rank: int,
total_kv_heads: int,
) -> Tuple[int, int, int, int]:
"""Compute head slicing parameters for heterogeneous TP transfer.
Returns:
(src_head_start, num_heads_to_send, dst_head_start, num_heads_to_send)
"""
src_heads_per_rank = max(1, total_kv_heads // src_attn_tp_size)
dst_heads_per_rank = max(1, total_kv_heads // dst_attn_tp_size)
local_tp_rank = src_tp_rank % src_attn_tp_size
dst_tp_rank_in_group = dst_tp_rank % dst_attn_tp_size
if src_attn_tp_size > dst_attn_tp_size:
src_head_start = 0
num_heads_to_send = src_heads_per_rank
src_replication = max(1, src_attn_tp_size // total_kv_heads)
unique_head_idx = local_tp_rank // src_replication
dst_head_start = (unique_head_idx * src_heads_per_rank) % dst_heads_per_rank
else:
src_head_start = (
dst_tp_rank_in_group * dst_heads_per_rank
) % src_heads_per_rank
num_heads_to_send = dst_heads_per_rank
dst_head_start = 0
return src_head_start, num_heads_to_send, dst_head_start, num_heads_to_send
def compute_staging_layout(
src_attn_tp_size: int,
dst_attn_tp_size: int,
dst_tp_rank: int,
total_kv_heads: int,
num_tokens: int,
bytes_per_head_token: int,
num_layers: int,
) -> Tuple[int, List[int], int]:
"""Compute per-writer byte layout for a staging region.
Returns:
(num_writers, writer_bytes_list, total_bytes)
where writer_bytes_list[i] = bytes for writer i covering all layers (K+V).
"""
if src_attn_tp_size > dst_attn_tp_size:
num_writers = src_attn_tp_size // max(1, dst_attn_tp_size)
else:
num_writers = 1
writer_bytes = []
for wr in range(num_writers):
_, nh, _, _ = compute_head_slice_params(
src_attn_tp_size,
dst_attn_tp_size,
wr,
dst_tp_rank,
total_kv_heads,
)
writer_bytes.append(num_tokens * nh * bytes_per_head_token * num_layers * 2)
return num_writers, writer_bytes, sum(writer_bytes)
def resolve_total_kv_heads(
kv_args,
attn_tp_size: int,
) -> int:
"""Resolve the global total KV head count from kv_args metadata."""
total = getattr(kv_args, "total_kv_head_num", 0)
if total > 0:
return total
per_rank = getattr(kv_args, "kv_head_num", 0)
if per_rank > 0:
return per_rank * attn_tp_size
raise ValueError(
"Cannot resolve total_kv_heads: kv_args has neither total_kv_head_num "
"nor kv_head_num. "
"Ensure DecodePreallocQueue._init_kv_manager sets kv_args.kv_head_num."
)
@@ -0,0 +1,840 @@
"""
Staging handler for heterogeneous TP KV cache transfer.
Isolates staging scatter lifecycle from decode.py and conn.py.
Generic (backend-agnostic) code is at the top; mooncake-specific
protocol code is at the bottom.
"""
from __future__ import annotations
import dataclasses
import logging
import struct
import threading
from typing import TYPE_CHECKING, List, Optional, Tuple
import torch
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.disaggregation.decode import DecodeRequest
# ======================================================================
# Generic staging state and handler (backend-agnostic)
# ======================================================================
@dataclasses.dataclass
class DecodeStagingContext:
"""Staging-specific context for decode mode."""
allocator: object = None
room_bootstrap: dict = dataclasses.field(default_factory=dict)
room_receivers: dict = dataclasses.field(default_factory=dict)
@dataclasses.dataclass
class PrefillStagingContext:
"""Staging-specific context for prefill mode."""
buffers: list = dataclasses.field(default_factory=list)
remote_watermarks: dict = dataclasses.field(default_factory=dict)
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)
class DecodeStagingHandler:
"""Decode-side staging scatter lifecycle manager.
Scatter submission can be called from the decode_thread (background) as
soon as all writers/ranks have arrived, while event checking and freeing
always run on the scheduler main thread.
"""
def __init__(
self,
kv_manager,
staging_allocator,
kv_buffer_info: dict,
decode_tp: int,
total_kv_heads: int,
tp_rank: int,
scheduler,
):
self.kv_manager = kv_manager
self.staging_allocator = staging_allocator
self.kv_buffer_info = kv_buffer_info
self.decode_tp = decode_tp
self.total_kv_heads = total_kv_heads
self.tp_rank = tp_rank
self.scheduler = scheduler
self._room_to_decode_req: dict = {}
self._wm_subscribers: dict = {}
def register_wm_subscriber(self, receiver, session_id: str) -> None:
"""Register a prefill's bootstrap connection for watermark broadcasts."""
if receiver is None or not getattr(receiver, "bootstrap_infos", None):
return
key = tuple(str(bi) for bi in receiver.bootstrap_infos)
if key not in self._wm_subscribers:
self._wm_subscribers[key] = (receiver, session_id)
def num_writers_for(self, decode_req) -> int:
"""Compute num_writers for a specific request based on its prefill TP."""
prefill_tp = decode_req.kv_receiver.prefill_info.attn_tp_size
if prefill_tp > self.decode_tp:
return prefill_tp // max(1, self.decode_tp)
return 1
@classmethod
def create(cls, kv_manager, scheduler, tp_rank: int) -> DecodeStagingHandler:
"""Factory: create handler. Raises if staging infra is missing."""
staging_allocator = kv_manager._staging_ctx.allocator
if staging_allocator is None:
raise RuntimeError(
"Staging is enabled but kv_manager._staging_ctx.allocator is None. "
"Check that the transfer backend correctly initializes the staging allocator."
)
kv_buffer_info = kv_manager.kv_buffer_tensors
if kv_buffer_info is None:
raise RuntimeError(
"Staging is enabled but kv_manager.kv_buffer_tensors is None. "
"Check that set_kv_buffer_tensors() was called during kv_manager init."
)
decode_tp = kv_manager.attn_tp_size
from sglang.srt.disaggregation.common.staging_buffer import (
resolve_total_kv_heads,
)
total_kv_heads = resolve_total_kv_heads(kv_manager.kv_args, decode_tp)
return cls(
kv_manager=kv_manager,
staging_allocator=staging_allocator,
kv_buffer_info=kv_buffer_info,
decode_tp=decode_tp,
total_kv_heads=total_kv_heads,
tp_rank=tp_rank,
scheduler=scheduler,
)
# ------------------------------------------------------------------
# Registration: called from main thread (DecodeTransferQueue)
# ------------------------------------------------------------------
def register_decode_req(self, room: int, decode_req: DecodeRequest) -> None:
self._room_to_decode_req[room] = decode_req
def unregister_decode_req(self, room: int) -> None:
self._room_to_decode_req.pop(room, None)
# ------------------------------------------------------------------
# Scatter submission: called from decode_thread (background)
# ------------------------------------------------------------------
def submit_chunk_scatter(
self, room: int, chunk_idx: int, page_start: int, num_pages: int
) -> bool:
"""Submit scatter for an intermediate chunk whose writers all arrived.
Called from decode_thread. Records a CUDA event on decode_req so
the main thread can later check completion and free the allocation.
"""
decode_req = self._room_to_decode_req.get(room)
if decode_req is None:
logger.warning(
"[STAGING] submit_chunk_scatter: room=%s not registered, "
"chunk_idx=%s. This should not happen if register_decode_req "
"is called at kv_receiver.init() time.",
room,
chunk_idx,
)
return False
chunk_infos = getattr(decode_req.kv_receiver, "chunk_staging_infos", [])
if chunk_idx >= len(chunk_infos):
return False
alloc_id, staging_offset, _, _, _ = chunk_infos[chunk_idx]
if staging_offset < 0 or alloc_id < 0:
return False
ok = self._scatter_region(staging_offset, page_start, num_pages, decode_req)
if ok:
event = torch.cuda.Event()
event.record(self.staging_allocator._scatter_stream)
if not hasattr(decode_req, "_chunk_events"):
decode_req._chunk_events = []
decode_req._chunk_events.append((event, alloc_id))
chunk_infos[chunk_idx] = (-1, -1, 0, -1, 0)
else:
logger.warning(
"submit_chunk_scatter failed room=%s chunk_idx=%s tp_rank=%s",
room,
chunk_idx,
self.tp_rank,
)
return ok
def is_staging_room(self, room: int) -> bool:
"""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.
Called from decode_thread. Sets ``_scatter_event`` **before**
``_staging_last_scatter_submitted`` so the main thread sees the
event when it checks the flag (CPython GIL guarantees ordering).
"""
decode_req = self._room_to_decode_req.get(room)
if decode_req is None:
logger.warning(
"[STAGING] submit_last_scatter_async: room=%s not registered. "
"This should not happen if register_decode_req is called at "
"kv_receiver.init() time.",
room,
)
return False
alloc_id = self._submit_last_scatter(decode_req)
if alloc_id >= 0:
event = torch.cuda.Event()
event.record(self.staging_allocator._scatter_stream)
decode_req._scatter_event = event
decode_req._scatter_alloc_id = alloc_id
decode_req._staging_last_scatter_submitted = True
else:
decode_req._staging_scatter_done = True
return True
# ------------------------------------------------------------------
# Event check + free: called from main thread (pop_transferred)
# ------------------------------------------------------------------
def is_done(self, decode_req: DecodeRequest) -> bool:
"""Return True if staging scatter is complete for this request."""
if not getattr(decode_req, "_staging_scatter_done", False):
return False
return not getattr(decode_req, "_chunk_events", None)
def advance_scatter(self, decode_req: DecodeRequest) -> None:
"""Check CUDA events and free completed staging allocations.
Scatter kernels have already been submitted by the decode_thread
(via submit_chunk_scatter / submit_last_scatter_async). This
method only polls the recorded events and releases staging memory.
"""
room = decode_req.req.bootstrap_room
chunk_events = getattr(decode_req, "_chunk_events", None)
if chunk_events:
for i in range(len(chunk_events) - 1, -1, -1):
event, alloc_id = chunk_events[i]
if event.query():
chunk_events.pop(i)
self._free_and_send_watermark(alloc_id, decode_req)
if not getattr(decode_req, "_staging_last_scatter_submitted", False):
return
event = getattr(decode_req, "_scatter_event", None)
if event is not None and event.query():
self._free_and_send_watermark(decode_req._scatter_alloc_id, decode_req)
decode_req._scatter_event = None
decode_req._scatter_alloc_id = -1
decode_req._staging_scatter_done = True
# ------------------------------------------------------------------
# Internal methods
# ------------------------------------------------------------------
def _scatter_region(
self,
staging_offset: int,
page_start: int,
num_pages: int,
decode_req: DecodeRequest,
) -> bool:
"""Submit scatter kernels for a staging region to scatter_stream.
May be called from the decode_thread (background). All GPU work
runs on scatter_stream so that the decode_thread never blocks on
the default stream (which carries the main-thread forward pass).
"""
from sglang.srt.disaggregation.common.staging_buffer import (
scatter_staging_to_kv,
)
k_buffers = self.kv_buffer_info["k_buffers"]
v_buffers = self.kv_buffer_info["v_buffers"]
page_size = self.kv_buffer_info["page_size"]
dst_tp_rank = self.kv_manager.kv_args.engine_rank % self.decode_tp
device = k_buffers[0].device
torch.cuda.set_device(device)
if not hasattr(self.staging_allocator, "_scatter_stream"):
self.staging_allocator._scatter_stream = torch.cuda.Stream(device=device)
scatter_stream = self.staging_allocator._scatter_stream
staging_view = self.staging_allocator.buffer.buffer[staging_offset:]
req_pool_idx = decode_req.req.req_pool_idx
token_start = page_start * page_size
token_end = token_start + num_pages * page_size
prefill_tp = decode_req.kv_receiver.prefill_info.attn_tp_size
with torch.cuda.stream(scatter_stream):
kv_indices = self.scheduler.req_to_token_pool.req_to_token[
req_pool_idx, token_start:token_end
]
if page_size > 1:
page_idx_tensor = kv_indices[::page_size] // page_size
else:
page_idx_tensor = kv_indices
scatter_staging_to_kv(
staging_view,
k_buffers,
v_buffers,
page_idx_tensor,
page_size,
prefill_tp,
self.decode_tp,
dst_tp_rank,
self.total_kv_heads,
)
return True
def _submit_last_scatter(self, decode_req: DecodeRequest) -> int:
"""Submit scatter for the last chunk. Returns alloc_id >= 0, or -1."""
receiver = decode_req.kv_receiver
chunk_infos = getattr(receiver, "chunk_staging_infos", [])
if not chunk_infos:
return -1
last_info = chunk_infos[-1]
alloc_id, staging_offset, _, _, last_num_pages = last_info
if staging_offset < 0 or alloc_id < 0:
return -1
seq_len = len(decode_req.req.origin_input_ids)
ps = self.scheduler.token_to_kv_pool_allocator.page_size
total_pages = (seq_len + ps - 1) // ps
page_start = total_pages - last_num_pages
ok = self._scatter_region(
staging_offset, page_start, last_num_pages, decode_req
)
return alloc_id if ok else -1
def _free_and_send_watermark(
self, alloc_id: int, decode_req: DecodeRequest
) -> None:
"""Free a staging allocation and broadcast watermark to all prefills."""
self.staging_allocator.free(alloc_id)
post_wm = self.staging_allocator.get_watermark()
room = decode_req.req.bootstrap_room
wm_round, wm_tail = post_wm
wm_round_b = str(wm_round).encode("ascii")
wm_tail_b = str(wm_tail).encode("ascii")
for _key, (receiver, session_id) in list(self._wm_subscribers.items()):
sid_b = session_id.encode("ascii")
for bootstrap_info in receiver.bootstrap_infos:
try:
sock, lock = receiver._connect_to_bootstrap_server(bootstrap_info)
with lock:
sock.send_multipart(
[b"WATERMARK", wm_round_b, wm_tail_b, sid_b]
)
except Exception:
pass
def is_watermark_ready(
staging_state, session_id: str, alloc_round: int, alloc_end: int
) -> bool:
"""Non-blocking check: is the staging region safe to write?"""
if alloc_round <= 0:
return True
prev_round = alloc_round - 1
wm_round, wm_tail = staging_state.remote_watermarks.get(session_id, (0, 0))
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,
)
# ======================================================================
# Staging data structures and protocol utilities
# ======================================================================
@dataclasses.dataclass
class StagingTransferInfo:
"""Per-chunk staging allocation info attached to a TransferInfo."""
offsets: List[int] = dataclasses.field(default_factory=lambda: [-1])
rounds: List[int] = dataclasses.field(default_factory=lambda: [0])
ends: List[int] = dataclasses.field(default_factory=lambda: [-1])
def set_chunk(self, idx: int, offset: int, rnd: int, end: int):
while len(self.offsets) <= idx:
self.offsets.append(-1)
self.rounds.append(0)
self.ends.append(-1)
self.offsets[idx] = offset
self.rounds[idx] = rnd
self.ends[idx] = end
@dataclasses.dataclass
class StagingRegisterInfo:
"""Staging buffer registration info attached to a KVArgsRegisterInfo."""
base_ptr: int = 0
total_size: int = 0
@classmethod
def from_zmq_fields(
cls, msg: list, msg_start_offset: int
) -> Optional[StagingRegisterInfo]:
i = msg_start_offset
base_ptr = (
struct.unpack("Q", msg[i])[0] if len(msg) > i and len(msg[i]) == 8 else 0
)
total_size = (
int(msg[i + 1].decode("ascii"))
if len(msg) > i + 1 and len(msg[i + 1]) > 0
else 0
)
if base_ptr == 0 and total_size == 0:
return None
return cls(base_ptr=base_ptr, total_size=total_size)
class PrefillStagingStrategy:
"""Prefill-side staging transfer: readiness check + gather-RDMA execution.
Encapsulates the decision logic (chunk index calculation, staging offset
lookup, watermark readiness) and delegates actual RDMA to the kv_manager.
"""
def __init__(self, kv_manager, staging_buffer):
self.kv_manager = kv_manager
self.staging_buffer = staging_buffer
page_size = kv_manager.kv_buffer_tensors["page_size"]
cps = kv_manager.server_args.chunked_prefill_size or 8192
self.full_chunk_pages = max(1, cps // page_size)
def check_ready(
self,
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).
"""
from sglang.srt.disaggregation.common.staging_buffer import StagingAllocator
chunk_idx = (
kv_chunk_index_start // self.full_chunk_pages
if self.full_chunk_pages > 0
else 0
)
stg = req.staging
if stg is None or chunk_idx >= len(stg.offsets):
return (False, chunk_idx, -1, 0, -1)
c_offset = stg.offsets[chunk_idx]
if c_offset == StagingAllocator.ALLOC_OVERSIZED:
return (False, chunk_idx, StagingAllocator.ALLOC_OVERSIZED, 0, -1)
if c_offset < 0:
return (False, chunk_idx, -1, 0, -1)
c_round = stg.rounds[chunk_idx]
c_end = stg.ends[chunk_idx]
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)
def transfer(
self,
session_id: str,
prefill_kv_indices,
dst_staging_ptr: int,
dst_staging_size: int,
target_info,
) -> int:
"""Execute staged transfer (gather + RDMA).
Returns 0 on success, -1 to signal fallback to slice path.
"""
try:
return self.kv_manager.send_kvcache_staged(
session_id,
prefill_kv_indices,
dst_staging_ptr,
dst_staging_size,
target_info.dst_tp_rank,
target_info.dst_attn_tp_size,
target_info.dst_kv_item_len,
staging_buffer=self.staging_buffer,
)
except Exception as e:
raise RuntimeError(
f"[Staging] KV transfer via staging buffer failed: {e}. "
f"session={session_id}"
) from e
def _get_custom_mem_pool(device: str):
"""Get custom memory pool for staging buffer allocation (backend-agnostic).
Returns (custom_mem_pool, pool_type) tuple. custom_mem_pool may be None
if no custom pool is configured.
"""
from sglang.srt.disaggregation.mooncake.utils import (
init_mooncake_custom_mem_pool,
)
_, custom_mem_pool, pool_type = init_mooncake_custom_mem_pool(device)
if custom_mem_pool is None:
logger.info(
"Staging buffer using cudaMalloc (no custom mem pool). "
"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)
register_fn(buf.get_ptr(), buf.get_size())
buffers.append(buf)
return buffers
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.environ import envs
pool_size_mb = envs.SGLANG_DISAGG_STAGING_POOL_SIZE_MB.get()
pool_size_bytes = pool_size_mb * 1024 * 1024
gpu_id = kv_args.gpu_id
device = f"cuda:{gpu_id}"
custom_mem_pool, _ = _get_custom_mem_pool(device)
allocator = StagingAllocator(pool_size_bytes, device, gpu_id, custom_mem_pool)
register_fn(allocator.get_base_ptr(), allocator.get_total_size())
return allocator
def handle_staging_req(
msg,
staging_allocator,
kv_args,
attn_tp_size: int,
prefill_attn_tp_size: int,
kv_buffer_tensors,
room_receivers: dict,
room_bootstrap: dict,
):
"""Allocate staging for a chunk on-demand and send STAGING_RSP to prefill.
Deduplicates: multiple prefill TP ranks requesting the same (room, chunk_idx)
only allocate once. Sends ALLOC_OVERSIZED on permanent failure.
"""
from sglang.srt.disaggregation.common.staging_buffer import StagingAllocator
room = int(msg[1].decode("ascii"))
chunk_idx = int(msg[2].decode("ascii"))
chunk_num_pages = int(msg[3].decode("ascii"))
session_id = msg[4].decode("ascii")
if staging_allocator is None:
logger.warning(
"STAGING_REQ ignored: allocator is None room=%s chunk=%s",
room,
chunk_idx,
)
return
receiver = room_receivers.get(room)
if receiver is None:
logger.warning(
"STAGING_REQ dropped: no receiver for room=%s chunk=%s session=%s",
room,
chunk_idx,
session_id,
)
return
infos = getattr(receiver, "chunk_staging_infos", [])
if chunk_idx < len(infos) and infos[chunk_idx][0] >= 0:
_, offset, rnd, end, _ = infos[chunk_idx]
elif (
chunk_idx < len(infos)
and infos[chunk_idx][1] == StagingAllocator.ALLOC_OVERSIZED
):
offset, rnd, end = StagingAllocator.ALLOC_OVERSIZED, 0, -1
else:
from sglang.srt.disaggregation.common.staging_buffer import (
compute_staging_layout,
resolve_total_kv_heads,
)
page_size = kv_args.page_size
kv_item_lens = kv_args.kv_item_lens
num_kv_layers = len(kv_item_lens) // 2
decode_bytes_per_token = kv_item_lens[0] // page_size
total_kv_heads = resolve_total_kv_heads(kv_args, attn_tp_size)
dst_heads_per_rank = max(1, total_kv_heads // max(1, attn_tp_size))
bytes_per_head_per_token = decode_bytes_per_token // dst_heads_per_rank
dst_tp_rank = kv_args.engine_rank % max(1, attn_tp_size)
chunk_tokens = chunk_num_pages * page_size
_, _, required = compute_staging_layout(
prefill_attn_tp_size,
attn_tp_size,
dst_tp_rank,
total_kv_heads,
chunk_tokens,
bytes_per_head_per_token,
num_kv_layers,
)
result = staging_allocator.assign(required)
if result is None:
logger.error(
"[STAGING_REQ] alloc failed room=%s chunk=%d (need %d bytes, "
"buffer total=%d bytes). Increase SGLANG_DISAGG_STAGING_POOL_SIZE_MB.",
room,
chunk_idx,
required,
staging_allocator.total_size,
)
offset, rnd, end = StagingAllocator.ALLOC_OVERSIZED, 0, -1
while len(infos) <= chunk_idx:
infos.append((-1, -1, 0, -1, 0))
infos[chunk_idx] = (
-1,
StagingAllocator.ALLOC_OVERSIZED,
0,
-1,
chunk_num_pages,
)
else:
alloc_id, offset, rnd = result
end = offset + required
while len(infos) <= chunk_idx:
infos.append((-1, -1, 0, -1, 0))
infos[chunk_idx] = (alloc_id, offset, rnd, end, chunk_num_pages)
bootstrap_infos = room_bootstrap.get(room)
if bootstrap_infos:
for bi in bootstrap_infos:
try:
sock, lock = receiver._connect_to_bootstrap_server(bi)
with lock:
sock.send_multipart(
[
b"STAGING_RSP",
str(room).encode("ascii"),
str(chunk_idx).encode("ascii"),
str(offset).encode("ascii"),
str(rnd).encode("ascii"),
str(end).encode("ascii"),
session_id.encode("ascii"),
]
)
except Exception:
pass
def prefetch_staging_reqs(
room: int,
transfer_infos: dict,
kv_buffer_tensors: dict,
chunked_prefill_size: int,
staging_requested: set,
prefetch_sockets: dict,
) -> None:
"""Send STAGING_REQ for all chunks before the prefill forward starts.
Called from the scheduler right after batch formation, so that decode
allocates staging during the GPU forward pass.
"""
import zmq
from sglang.srt.utils.network import NetworkAddress
page_size = kv_buffer_tensors["page_size"]
cps = chunked_prefill_size or 8192
full_chunk_pages = max(1, cps // page_size)
for session_id, tinfo in transfer_infos[room].items():
# 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:
continue
num_chunks = (total_pages + full_chunk_pages - 1) // full_chunk_pages
for chunk_idx in range(num_chunks):
stg_key = (room, chunk_idx, session_id)
if stg_key in staging_requested:
continue
staging_requested.add(stg_key)
remaining = total_pages - chunk_idx * full_chunk_pages
chunk_pages = min(full_chunk_pages, remaining)
try:
na = NetworkAddress(tinfo.endpoint, tinfo.dst_port)
ep = na.to_tcp()
if ep not in prefetch_sockets:
sock = zmq.Context().socket(zmq.PUSH)
if na.is_ipv6:
sock.setsockopt(zmq.IPV6, 1)
sock.connect(ep)
prefetch_sockets[ep] = sock
prefetch_sockets[ep].send_multipart(
[
b"STAGING_REQ",
str(room).encode("ascii"),
str(chunk_idx).encode("ascii"),
str(chunk_pages).encode("ascii"),
session_id.encode("ascii"),
]
)
except Exception:
staging_requested.discard(stg_key)
@@ -0,0 +1,129 @@
import ctypes
import dataclasses
import struct
import threading
from collections import deque
from typing import List, Optional, Tuple, Union
import numpy as np
import numpy.typing as npt
from sglang.srt.observability.trace import (
TraceNullContext,
TraceReqContext,
)
@dataclasses.dataclass
class TransferKVChunk:
"""Work unit for KV cache transfer from prefill to decode."""
room: int
prefill_kv_indices: npt.NDArray[np.int32]
index_slice: slice
is_last_chunk: bool
prefill_aux_index: Optional[int]
state_indices: Optional[List]
chunk_id: Optional[int] = None
trace_ctx: Union[TraceReqContext, TraceNullContext] = dataclasses.field(
default_factory=TraceNullContext
)
def pack_list_of_buffers(buffers: List[bytes]) -> bytes:
if not buffers:
return b""
n = len(buffers)
header = struct.pack(f"<{n+1}I", n, *(len(b) for b in buffers))
return header + b"".join(buffers)
def unpack_list_of_buffers(buf: bytes) -> List[bytes]:
if buf == b"":
return []
(n,) = struct.unpack("<I", buf[:4])
lens = struct.unpack(f"<{n}I", buf[4 : 4 + 4 * n])
out = []
offset = 4 + 4 * n
for length in lens:
out.append(buf[offset : offset + length])
offset += length
return out
def pack_int_lists(lists, fmt: str) -> bytes:
return pack_list_of_buffers([struct.pack(f"<{len(a)}{fmt}", *a) for a in lists])
def unpack_int_lists(buf: bytes, fmt: str) -> List[List[int]]:
width = struct.calcsize(fmt)
return [
list(struct.unpack(f"<{len(b)//width}{fmt}", b))
for b in unpack_list_of_buffers(buf)
]
class FastQueue:
def __init__(self):
self._buf = deque()
self._cond = threading.Condition()
def put(self, item):
with self._cond:
self._buf.append(item)
# wake up a thread of wait()
self._cond.notify()
def get(self):
with self._cond:
# if queue is empty ,block until is notified()
while not self._buf:
self._cond.wait()
return self._buf.popleft()
class AuxDataCodec:
"""Handles serialization and deserialization of auxiliary data buffers."""
@staticmethod
def serialize_data_from_buffer(src_addr, data_length):
"""Serialize data from memory buffer to bytes."""
buffer = (ctypes.c_byte * data_length).from_address(src_addr)
return bytes(buffer)
@staticmethod
def deserialize_data_to_buffer(kv_args, buffer_index, aux_index, data):
"""Deserialize bytes into target memory buffer."""
dst_aux_ptr = kv_args.aux_data_ptrs[buffer_index]
item_len = kv_args.aux_item_lens[buffer_index]
dst_addr = dst_aux_ptr + item_len * aux_index
buffer = (ctypes.c_byte * len(data)).from_address(dst_addr)
buffer[:] = data
return
def group_concurrent_contiguous(
src_indices: npt.NDArray[np.int32], dst_indices: npt.NDArray[np.int32]
) -> Tuple[List[npt.NDArray[np.int32]], List[npt.NDArray[np.int32]]]:
"""Vectorised NumPy implementation."""
# src/dst indices are transferred pairwise, so an empty side means there is
# nothing to transfer. Guarding both sides (not just src) avoids a cryptic
# NumPy broadcast error from np.diff() below when only one side is empty, e.g.
# a non-empty prefill DSA/SWA state list paired with an empty decode registration.
if src_indices.size == 0 or dst_indices.size == 0:
return [], []
if src_indices.size != dst_indices.size:
raise ValueError(
"group_concurrent_contiguous requires equal-length src/dst index arrays, "
f"got {src_indices.size} and {dst_indices.size}"
)
brk = np.where((np.diff(src_indices) != 1) | (np.diff(dst_indices) != 1))[0] + 1
src_groups = np.split(src_indices, brk)
dst_groups = np.split(dst_indices, brk)
src_groups = [g.tolist() for g in src_groups]
dst_groups = [g.tolist() for g in dst_groups]
return src_groups, dst_groups
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
"""HiCache integration mixins for the decode side of PD disaggregation"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, List, Optional
import torch
from sglang.srt.disaggregation.base import KVPoll
from sglang.srt.managers.schedule_policy import match_prefix_for_req
from sglang.srt.mem_cache.base_prefix_cache import InitLoadBackParams
if TYPE_CHECKING:
from sglang.srt.disaggregation.decode import DecodeRequest
from sglang.srt.managers.schedule_batch import Req
logger = logging.getLogger(__name__)
@dataclass
class DecodePrefixMatch:
prefix_indices: torch.Tensor
l2_host_hit_length: int
l3_storage_hit_length: int
last_device_node: Any
last_host_node: Any = None
prefetch_registered: bool = False
@property
def l1_prefix_len(self) -> int:
return len(self.prefix_indices)
@property
def decode_prefix_len(self) -> int:
return self.l1_prefix_len + self.l2_host_hit_length + self.l3_storage_hit_length
@property
def needs_local_restore(self) -> bool:
return self.decode_prefix_len > self.l1_prefix_len
@property
def restore_token_count(self) -> int:
"""Number of tokens that need L2/L3 load_back to device."""
return self.decode_prefix_len - self.l1_prefix_len
class HiCacheRestoreResult(Enum):
"""Outcome of one tick of the HiCache local-restore state machine."""
PENDING = "pending"
READY = "ready"
FAILED = "failed"
class DecodeHiCachePreallocMixin:
"""HiCache hooks for ``DecodePreallocQueue``: issue prefetch + reserve tokens."""
def _build_decode_prefix_match(self, req: Req, result: Any) -> DecodePrefixMatch:
"""Convert a ``match_prefix_for_req`` result into ``DecodePrefixMatch``.
Performs the optional L3 storage hit length query when decode-side
HiCache is enabled and the last host node is backed up.
"""
prefix_indices = result.device_indices
l1_prefix_len = len(prefix_indices)
l2_host_hit_length = result.host_hit_length
l3_storage_hit_length = 0
last_host_node = None
if self.scheduler.enable_decode_hicache:
last_host_node = result.last_host_node
if last_host_node.backuped or last_host_node is self.tree_cache.root_node:
matched_len = l1_prefix_len + l2_host_hit_length
suffix_tokens = req.origin_input_ids[matched_len:]
last_hash = last_host_node.get_last_hash_value()
prefix_keys = (
last_host_node.get_prefix_hash_values(last_host_node.parent)
if self.tree_cache.hicache_storage_pass_prefix_keys
else None
)
l3_storage_hit_length = self.tree_cache.query_storage_hit_length(
last_host_node,
suffix_tokens,
last_hash,
prefix_keys,
)
return DecodePrefixMatch(
prefix_indices=prefix_indices,
l2_host_hit_length=l2_host_hit_length,
l3_storage_hit_length=l3_storage_hit_length,
last_device_node=result.last_device_node,
last_host_node=last_host_node if l3_storage_hit_length > 0 else None,
)
def _start_hicache_prefetch(
self, req: Req, prefix_match: Optional[DecodePrefixMatch]
) -> None:
"""Issue L3 storage prefetch after admission succeeds.
On failure, degrades to L2-only restore by clearing l3 fields.
"""
if (
prefix_match is None
or prefix_match.l3_storage_hit_length <= 0
or prefix_match.last_host_node is None
):
return
try:
node = prefix_match.last_host_node
matched_len = prefix_match.l1_prefix_len + prefix_match.l2_host_hit_length
suffix = req.origin_input_ids[
matched_len : matched_len + prefix_match.l3_storage_hit_length
]
last_hash = node.get_last_hash_value()
prefix_keys = (
node.get_prefix_hash_values(node.parent)
if self.tree_cache.hicache_storage_pass_prefix_keys
else None
)
self.tree_cache.prefetch_from_storage(
req.rid, node, suffix, last_hash, prefix_keys
)
prefix_match.prefetch_registered = (
req.rid in self.tree_cache.ongoing_prefetch
)
except Exception as e:
logger.warning(
"HiCache L3 prefetch failed for rid=%s: %s; falling back to L2-only LoadingBack",
req.rid,
e,
)
prefix_match.l3_storage_hit_length = 0
prefix_match.prefetch_registered = False
def _hicache_pending_restore_tokens(self) -> int:
"""Total device tokens reserved for pending HiCache L2/L3 load_back."""
if not self.scheduler.enable_decode_hicache:
return 0
return sum(
dr.prefix_match.restore_token_count
for dr in self.transfer_queue.queue
if dr.prefix_match is not None
and dr.hicache_restore_status == HiCacheRestoreResult.PENDING
and dr.hicache_restored_node is None
)
class HiCacheRestoreGatedKVReceiver:
"""Wraps a kv_receiver so KVPoll.Success is gated on HiCache restore READY."""
def __init__(self, decode_req: DecodeRequest):
self.decode_req = decode_req
def poll(self) -> KVPoll:
poll = self.decode_req.kv_receiver.poll()
if (
poll == KVPoll.Success
and self.decode_req.hicache_restore_status == HiCacheRestoreResult.PENDING
):
return KVPoll.Transferring
return poll
class DecodeHiCacheTransferMixin:
"""HiCache hooks for ``DecodeTransferQueue``: drive restore state machine."""
def _clean_hicache_prefetch_resources(self, decode_req: DecodeRequest) -> None:
if (
decode_req.prefix_match is not None
and decode_req.prefix_match.prefetch_registered
):
self.tree_cache.release_aborted_request(decode_req.req.rid)
if decode_req.hicache_restored_node is not None:
self.tree_cache.dec_lock_ref(decode_req.hicache_restored_node)
decode_req.hicache_restored_node = None
def _try_hicache_queue_load_back(self, dr: DecodeRequest) -> bool:
"""Queue one L2->L1 load_back op for ``dr``; True iff a DMA was queued.
On success, ``dr.hicache_restored_node`` and ``hicache_restored_kv_indices``
are populated, and an inc_lock_ref is held until commit/abort.
Trivial cases (all-on-device / no needed coverage) auto-flip to READY.
Failback paths flip to FAILED.
"""
pm = dr.prefix_match
# Wait for L3 -> L2 prefetch to drain (skip when no L3 hit).
if pm.l3_storage_hit_length > 0:
if not self.tree_cache.check_prefetch_progress(dr.req.rid):
return False
self.tree_cache.pop_prefetch_loaded_tokens(dr.req.rid)
# Re-match: req.last_node / prefix_indices updated to current device state.
rematch = match_prefix_for_req(
self.tree_cache,
dr.req,
dr.req.origin_input_ids,
cow_mamba=False,
include_req=True,
)
new_indices, restored_node = self.tree_cache.init_load_back(
InitLoadBackParams(
best_match_node=rematch.best_match_node,
host_hit_length=rematch.host_hit_length,
req=dr.req,
)
)
# Failback: total coverage < required prefix means device alloc likely failed.
if len(rematch.device_indices) + len(new_indices) < pm.decode_prefix_len:
logger.warning(
"HiCache load_back failed for rid=%s: device_indices=%d, "
"new_indices=%d, expected decode_prefix_len=%d (l1=%d, l2=%d, l3=%d)",
dr.req.rid,
len(rematch.device_indices),
len(new_indices),
pm.decode_prefix_len,
pm.l1_prefix_len,
pm.l2_host_hit_length,
pm.l3_storage_hit_length,
)
dr.hicache_restore_status = HiCacheRestoreResult.FAILED
return False
dr.hicache_restored_kv_indices = torch.cat(
[rematch.device_indices[pm.l1_prefix_len :], new_indices]
)
dr.hicache_restored_node = restored_node
self.tree_cache.inc_lock_ref(restored_node)
if len(new_indices) == 0:
# Whole prefix already on device; no DMA needed.
dr.hicache_restore_status = HiCacheRestoreResult.READY
return False
return True
def _process_hicache_local_restores(self, decode_reqs: List[DecodeRequest]) -> None:
if not hasattr(self.tree_cache, "is_load_back_event_done"):
return
# Filter once: keep only PENDING reqs that still need restore work;
# trivially-done reqs (no prefix_match / nothing to restore) flip to READY.
active: List[DecodeRequest] = []
for dr in decode_reqs:
if dr.hicache_restore_status != HiCacheRestoreResult.PENDING:
continue
pm = dr.prefix_match
if pm is None or not pm.needs_local_restore:
dr.hicache_restore_status = HiCacheRestoreResult.READY
continue
active.append(dr)
# Phase A: advance in-flight DMAs to READY.
for dr in active:
if (
dr.hicache_restored_node is not None
and self.tree_cache.is_load_back_event_done(
dr.hicache_load_consumer_index
)
):
dr.hicache_restore_status = HiCacheRestoreResult.READY
# Phase B: queue new load_back ops if the next slot is free.
# The (producer_index + 1) check ensures we never overwrite a still-in-flight slot:
# if a previous req holds that slot and isn't done, its event won't be signaled.
counter = self.tree_cache.cache_controller.layer_done_counter
if not self.tree_cache.is_load_back_event_done(
(counter.producer_index + 1) % counter.num_counters
):
return
queued = [
dr
for dr in active
if dr.hicache_restored_node is None
and self._try_hicache_queue_load_back(dr)
]
if not queued:
return
# Phase C: kick off merged DMA, bind consumer_index for Phase A polling next tick.
consumer_index = self.tree_cache.ready_to_load_host_cache()
if consumer_index < 0:
for dr in queued:
dr.hicache_restore_status = HiCacheRestoreResult.READY
return
for dr in queued:
dr.hicache_load_consumer_index = consumer_index
def _commit_hicache_local_restore_to_req(self, decode_req: DecodeRequest) -> None:
prefix_match = decode_req.prefix_match
if prefix_match is None or not prefix_match.needs_local_restore:
return
self.tree_cache.dec_lock_ref(prefix_match.last_device_node)
self.tree_cache.req_to_token_pool.write(
(
decode_req.req.req_pool_idx,
slice(prefix_match.l1_prefix_len, prefix_match.decode_prefix_len),
),
decode_req.hicache_restored_kv_indices,
)
decode_req.req.prefix_indices = torch.cat(
[prefix_match.prefix_indices, decode_req.hicache_restored_kv_indices]
)
decode_req.req.last_node = decode_req.hicache_restored_node
@@ -0,0 +1,351 @@
from __future__ import annotations
import json
import logging
import threading
import time
from typing import TYPE_CHECKING
import torch
from sglang.srt.disaggregation.kv_events import OffloadedState
from sglang.srt.environ import envs
from sglang.srt.managers.cache_controller import HiCacheController
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import (
MHATokenToKVPool,
MLATokenToKVPool,
ReqToTokenPool,
)
from sglang.srt.mem_cache.memory_pool_host import MLATokenToKVPoolHost
from sglang.srt.mem_cache.pool_host.mha import get_mha_host_pool_cls
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils.common import ceil_align
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req
logger = logging.getLogger(__name__)
class DecodeKVCacheOffloadManager:
"""Manage decode-side KV cache offloading lifecycle and operations."""
def __init__(
self,
req_to_token_pool: ReqToTokenPool,
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator,
tp_group: torch.distributed.ProcessGroup,
tree_cache: BasePrefixCache,
server_args: ServerArgs,
) -> None:
self.req_to_token_pool = req_to_token_pool
self.token_to_kv_pool_allocator = token_to_kv_pool_allocator
self.page_size = server_args.page_size
self.server_args = server_args
self.request_counter = 0
self.tree_cache = tree_cache
env_stride = envs.SGLANG_HICACHE_DECODE_OFFLOAD_STRIDE.get()
if env_stride is None or env_stride <= 0:
self.offload_stride = self.page_size
else:
self.offload_stride = max(
self.page_size, (env_stride // self.page_size) * self.page_size
)
kv_cache = self.token_to_kv_pool_allocator.get_kvcache()
if isinstance(kv_cache, MHATokenToKVPool):
self.decode_host_mem_pool = get_mha_host_pool_cls(kv_cache)(
kv_cache,
server_args.hicache_ratio,
server_args.hicache_size,
self.page_size,
server_args.hicache_mem_layout,
)
elif isinstance(kv_cache, MLATokenToKVPool):
self.decode_host_mem_pool = MLATokenToKVPoolHost(
kv_cache,
server_args.hicache_ratio,
server_args.hicache_size,
self.page_size,
server_args.hicache_mem_layout,
)
else:
raise ValueError("Unsupported KV cache type for decode offload")
self.tp_group = tp_group
self.tp_world_size = torch.distributed.get_world_size(group=self.tp_group)
hicache_storage_backend_extra_config = {}
if server_args.hicache_storage_backend_extra_config:
try:
hicache_storage_backend_extra_config = json.loads(
server_args.hicache_storage_backend_extra_config
)
except json.JSONDecodeError as e:
raise ValueError(
f"Invalid hicache storage backend extra config JSON: {e}"
)
self.cache_controller = HiCacheController(
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
mem_pool_host=self.decode_host_mem_pool,
page_size=self.page_size,
tp_group=tp_group,
io_backend=server_args.hicache_io_backend,
load_cache_event=threading.Event(),
storage_backend=server_args.hicache_storage_backend,
model_name=server_args.served_model_name,
storage_backend_extra_config=hicache_storage_backend_extra_config,
)
self.ongoing_offload = {}
self.ongoing_backup = {}
self.offloaded_state = {}
self.offload_inflight = {}
logger.info("Enable offload kv cache for decode side")
def _mark_offload_started(self, rid):
self.offload_inflight[rid] = self.offload_inflight.get(rid, 0) + 1
def _mark_offload_finished(self, rid):
count = self.offload_inflight.get(rid, 0)
if count <= 1:
self.offload_inflight.pop(rid, None)
else:
self.offload_inflight[rid] = count - 1
def _has_inflight_offload(self, rid):
return self.offload_inflight.get(rid, 0) > 0
def offload_kv_cache(self, req) -> bool:
"""Offload incremental KV cache for decode side."""
if self.cache_controller is None or self.decode_host_mem_pool is None:
return False
if req.req_pool_idx == -1 or len(req.output_ids) == 0:
return False
token_indices = self.req_to_token_pool.req_to_token[req.req_pool_idx]
if token_indices.dim() == 0 or token_indices.numel() == 0:
return False
# Prefill side offloads page-aligned origin_input_ids, decode side offloads the incremental part
all_tokens = req.origin_input_ids + req.output_ids[:-1]
prefill_offloaded_len = (
len(req.origin_input_ids) // self.page_size * self.page_size
)
state = self.offloaded_state.get(req.rid)
if state is None:
prefill_hashes = self._compute_prefix_hash(
req.origin_input_ids[:prefill_offloaded_len]
)
last_prefill_hash = (
prefill_hashes[-1] if prefill_offloaded_len > 0 else None
)
state = OffloadedState(
prefill_len=prefill_offloaded_len,
inc_len=0,
last_hash=last_prefill_hash,
)
self.offloaded_state[req.rid] = state
incremental_total = len(all_tokens) - state.prefill_len
incremental_new = incremental_total - state.inc_len
incremental_aligned_len = (
incremental_new // self.offload_stride * self.offload_stride
)
if incremental_aligned_len == 0:
return False
# Extract incremental tokens and indices for the newly available chunk
start = state.prefill_len + state.inc_len
end = start + incremental_aligned_len
incremental_tokens = all_tokens[start:end]
incremental_indices = token_indices[start:end]
# Prefill-aligned GPU slots are freed at request finish in
# _release_finished_req, NOT here. The decoding request
# continues to attend to those slots via req_to_token; freeing
# them mid-decode races with concurrent admission, which can
# reuse the slots and produce cross-pollinated KV reads.
# Asynchronously offload incremental KV cache from device to host
self.request_counter += 1
ack_id = self.request_counter
host_indices = self.cache_controller.write(
device_indices=incremental_indices.long(),
node_id=ack_id,
)
if host_indices is None:
logger.error(f"Not enough host memory for request {req.rid}")
return False
self._mark_offload_started(req.rid)
self.ongoing_offload[ack_id] = (
req,
host_indices,
incremental_tokens,
time.time(),
start,
end,
)
state.inc_len += incremental_aligned_len
return True
def check_offload_progress(self):
"""Check the progress of offload from device to host and backup from host to storage."""
cc = self.cache_controller
qsizes = torch.tensor(
[
len(cc.ack_write_queue),
cc.ack_backup_queue.qsize(),
],
dtype=torch.int,
)
if self.tp_world_size > 1:
torch.distributed.all_reduce(
qsizes, op=torch.distributed.ReduceOp.MIN, group=self.tp_group
)
n_write, n_backup = map(int, qsizes.tolist())
self._check_offload_progress(n_write)
self._check_backup_progress(n_backup)
def _check_offload_progress(self, finish_count):
"""Check the progress of offload from device to host."""
while finish_count > 0:
_, finish_event, ack_list = self.cache_controller.ack_write_queue.pop(0)
finish_event.synchronize()
for ack_id in ack_list:
(
req,
host_indices,
incremental_tokens,
start_time,
start,
end,
) = self.ongoing_offload.pop(ack_id)
self._mark_offload_finished(req.rid)
prior_hash = (
self.offloaded_state[req.rid].last_hash
if req.rid in self.offloaded_state
else None
)
last_hash = self._trigger_backup(
req, host_indices, incremental_tokens, start_time, prior_hash
)
if req.rid in self.offloaded_state:
self.offloaded_state[req.rid].last_hash = last_hash
if req.finished() and not self._has_inflight_offload(req.rid):
state = self.offloaded_state.get(req.rid)
start_offset = state.prefill_len if state is not None else start
self._release_finished_req(req, start_offset)
finish_count -= 1
def _release_finished_req(self, req: Req, start_offset: int):
# Defensive guard: ReqToTokenPool.free sets req_pool_idx to None,
# so a previously-released request must be skipped here to avoid
# non-idempotent side effects (e.g. tree_cache.protected_size_
# double-decrement, host pool double-free).
if req.req_pool_idx is None or req.req_pool_idx == -1:
return
kv_committed_len = req.pop_committed_kv_cache()
# Free the prefill-aligned slots. Previously this was done
# eagerly in offload_kv_cache (mid-decode), which raced with
# concurrent admission. Now consolidated here at request
# finish, where the request is guaranteed to no longer attend
# to those slots.
state = self.offloaded_state.get(req.rid)
if state is not None and state.prefill_len > 0:
prefill_indices = self.req_to_token_pool.req_to_token[
req.req_pool_idx, : state.prefill_len
]
self.token_to_kv_pool_allocator.free(prefill_indices)
start = start_offset
end = kv_committed_len
# Free the incremental part of the request (DSA-aware)
kv_indices = self.req_to_token_pool.req_to_token[req.req_pool_idx, start:end]
self.token_to_kv_pool_allocator.free(kv_indices)
# Free over-allocated KV cache slots (e.g. from speculative decoding v2).
# Without spec v2, start_p == end_p so this is a no-op.
start_p, end_p = req.pop_overallocated_kv_cache()
if self.page_size > 1:
start_p = ceil_align(start_p, self.page_size)
if start_p < end_p:
overalloc_indices = self.req_to_token_pool.req_to_token[
req.req_pool_idx, start_p:end_p
]
self.token_to_kv_pool_allocator.free(overalloc_indices)
self.req_to_token_pool.free(req)
self.tree_cache.protected_size_ -= len(req.prefix_indices)
if req.rid in self.offloaded_state:
del self.offloaded_state[req.rid]
def _check_backup_progress(self, finish_count):
"""Check the progress of backup from host to storage."""
for _ in range(finish_count):
storage_operation = self.cache_controller.ack_backup_queue.get()
ack_id = storage_operation.id
req_id, host_indices, start_time = self.ongoing_backup.pop(ack_id)
# Release host memory
self.decode_host_mem_pool.free(host_indices)
logger.debug(
f"Finished backup request {req_id}, free host memory, len:{len(host_indices)}, cost time:{time.time() - start_time:.2f} seconds."
)
def _trigger_backup(
self, req, host_indices, incremental_tokens, start_time, prior_hash
):
"""Trigger async backup from host to storage."""
page_hashes = self._compute_prefix_hash(incremental_tokens, prior_hash)
ack_id = self.cache_controller.write_storage(
host_indices,
incremental_tokens,
hash_value=page_hashes,
)
self.ongoing_backup[ack_id] = (req.rid, host_indices, start_time)
return page_hashes[-1] if len(page_hashes) > 0 else prior_hash
def _compute_prefix_hash(self, tokens, prior_hash=""):
page_hashes = []
last_hash = prior_hash
for offset in range(0, len(tokens), self.page_size):
page_tokens = tokens[offset : offset + self.page_size]
last_hash = self.cache_controller.get_hash_str(page_tokens, last_hash)
page_hashes.append(last_hash)
return page_hashes
def finalize_release_on_finish(self, req: Req):
"""Free any remaining tail KV that was not offloaded due to non-aligned length."""
# ReqToTokenPool.free sets req_pool_idx to None on release, so
# guard against both sentinels here.
if req.req_pool_idx is None or req.req_pool_idx == -1:
return
state = self.offloaded_state.get(req.rid)
if state is None:
prefill_len = len(req.origin_input_ids) // self.page_size * self.page_size
inc_len = 0
else:
prefill_len = state.prefill_len
inc_len = state.inc_len
# Prefill-aligned slots are freed by _release_finished_req. Make
# sure state exists so it can find prefill_len.
if state is None:
self.offloaded_state[req.rid] = OffloadedState(
prefill_len=prefill_len, inc_len=0, last_hash=None
)
if self._has_inflight_offload(req.rid):
return
start_offset = prefill_len
self._release_finished_req(req, start_offset)
@@ -0,0 +1,159 @@
from __future__ import annotations
import logging
from array import array
from http import HTTPStatus
from typing import TYPE_CHECKING, List
import torch
from sglang.srt.managers.overlap_utils import RelayPayload
from sglang.srt.mem_cache.common import maybe_cache_unfinished_req
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.sampling.sampling_batch_info import SamplingBatchInfo
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.managers.overlap_utils import FutureMap
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.server_args import ServerArgs
class ScheduleBatchDisaggregationDecodeMixin:
def prepare_for_prebuilt(self: ScheduleBatch):
"""
Prepare a prebuilt extend by populate metadata
Adapted from .prepare_for_extend().
"""
self.forward_mode = ForwardMode.PREBUILT
reqs = self.reqs
input_ids = [r.get_fill_ids()[len(r.prefix_indices) :] for r in reqs]
extend_num_tokens = sum(len(ids) for ids in input_ids)
seq_lens = []
pre_lens = []
req_pool_indices = []
# Pre-calculate total size
total_size = sum(req.extend_range.length for req in reqs)
out_cache_loc = torch.empty(total_size, dtype=torch.int64, device=self.device)
# Fill the tensor in one pass
offset = 0
for i, req in enumerate(reqs):
req_pool_indices.append(req.req_pool_idx)
pre_len = len(req.prefix_indices)
chunk = self.req_to_token_pool.req_to_token[req.req_pool_idx][
pre_len : pre_len + req.extend_range.length
]
assert (
offset + req.extend_range.length <= total_size
), f"Exceeds total size: offset={offset}, req.extend_range.length={req.extend_range.length}, total_size={total_size}"
out_cache_loc[offset : offset + req.extend_range.length] = chunk
offset += req.extend_range.length
seq_len = len(req.origin_input_ids) + max(0, len(req.output_ids) - 1)
seq_lens.append(seq_len)
if len(req.output_ids) == 0:
assert (
seq_len - pre_len == req.extend_range.length
), f"seq_len={seq_len}, pre_len={pre_len}, req.extend_range.length={req.extend_range.length}"
if not req.retracted_stain:
# Clamp to avoid double-counting: already_computed is seeded from
# the prefill-reported cached_tokens in _commit_transfer_to_req, so
# a decode-side prefix shorter than the prefill report must not
# subtract from cached_tokens.
delta = max(0, pre_len - req.already_computed)
req.cached_tokens += delta
req.cached_tokens_device += delta
req.already_computed = seq_len
req.is_retracted = False
if getattr(req, "pd_rebootstrap_in_progress", False):
req.pd_rebootstrap_in_progress = False
pre_lens.append(pre_len)
# Set fields
self.input_ids = torch.tensor(
sum(input_ids, array("q")), dtype=torch.int32, device=self.device
)
self.req_pool_indices = torch.tensor(
req_pool_indices, dtype=torch.int64, device=self.device
)
self.req_pool_indices_cpu = torch.tensor(req_pool_indices, dtype=torch.int64)
self.seq_lens = torch.tensor(seq_lens, dtype=torch.int64, device=self.device)
self.seq_lens_cpu = torch.tensor(seq_lens, dtype=torch.int64)
self.orig_seq_lens = torch.tensor(
seq_lens, dtype=torch.int32, device=self.device
)
self.out_cache_loc = out_cache_loc
self.seq_lens_sum = sum(seq_lens)
if self.return_logprob:
self.top_logprobs_nums = [r.logprob.top_logprobs_num for r in reqs]
self.token_ids_logprobs = [r.logprob.token_ids_logprob for r in reqs]
self.extend_num_tokens = extend_num_tokens
self.prefix_lens = [len(r.prefix_indices) for r in reqs]
self.extend_lens = [r.extend_range.length for r in reqs]
self.extend_logprob_start_lens = None
self.extend_input_logprob_token_ids = None
self.multimodal_inputs = [r.multimodal_inputs for r in reqs]
# Build sampling info
self.sampling_info = SamplingBatchInfo.from_schedule_batch(
self,
self.model_config.vocab_size,
)
def process_prebuilt(
self: ScheduleBatch,
server_args: ServerArgs,
future_map: FutureMap,
):
"""Assign the buffered last input id to schedule batch"""
last_tokens: List[int] = []
for req in self.reqs:
last_tokens.append(req.output_ids[-1])
maybe_cache_unfinished_req(req, self.tree_cache)
if req.grammar is not None:
# FIXME: this try-except block is for handling unexpected xgrammar issue.
try:
# if it is not None, then the grammar is from a retracted request, and we should not
# accept the token as it's already accepted
if req.grammar.current_token is None:
req.grammar.accept_token(req.output_ids[-1])
except ValueError as e:
from sglang.srt.managers.schedule_batch import FINISH_ABORT
# Grammar accept_token can raise ValueError if the token is not in the grammar.
# This can happen if the grammar is not set correctly or the token is invalid.
# Use to_finish (not finished_reason) so that process_batch_result_prebuilt
# handles the release via update_finish_state -> release_kv_cache in one place.
error_message = f"Grammar accept_token failed for req {req.rid} with token {req.output_ids[-1]}: {e}"
req.to_finish = FINISH_ABORT(
error_message, HTTPStatus.INTERNAL_SERVER_ERROR
)
req.grammar.finished = req.finished()
last_tokens_tensor = torch.tensor(
last_tokens, dtype=torch.int64, device=self.device
)
spec_info = self.spec_algorithm.build_disagg_draft_input(
self,
server_args,
last_tokens_tensor,
future_map,
)
if spec_info is not None:
self.spec_info = spec_info
else:
# Non-spec: stash last token into the relay so the first DECODE's
# resolve_forward_inputs gathers it like any other decode iter.
future_map.stash(
self.req_pool_indices, RelayPayload(bonus_tokens=last_tokens_tensor)
)
self.input_ids = None
@@ -0,0 +1,275 @@
"""
gRPC Encoder Server for SGLang EPD (Encode-Prefill-Decode) mode.
This server provides gRPC-based encoding for multimodal inputs.
Usage:
python -m sglang.launch_server --model-path <model> --encoder-only --grpc-mode
"""
import asyncio
import logging
import multiprocessing as mp
import traceback
from concurrent import futures
from typing import List
import grpc
import zmq
import zmq.asyncio
from grpc_health.v1 import health_pb2, health_pb2_grpc
from grpc_reflection.v1alpha import reflection
from smg_grpc_proto import sglang_encoder_pb2, sglang_encoder_pb2_grpc
from sglang.srt.disaggregation.encode_server import (
MMEncoder,
handle_scheduler_receive_url_request,
launch_encoder,
)
from sglang.srt.managers.io_struct import async_sock_send, wrap_as_pickle
from sglang.srt.managers.schedule_batch import Modality
from sglang.srt.server_args import PortArgs, ServerArgs
from sglang.srt.utils import random_uuid
from sglang.srt.utils.network import NetworkAddress, get_zmq_socket
logger = logging.getLogger(__name__)
SGLangEncoderServicer = sglang_encoder_pb2_grpc.SglangEncoderServicer
add_SGLangEncoderServicer_to_server = (
sglang_encoder_pb2_grpc.add_SglangEncoderServicer_to_server
)
class EncoderHealthServicer(health_pb2_grpc.HealthServicer):
"""
Standard gRPC health check service for encoder server.
Implements grpc.health.v1.Health for Kubernetes probes.
"""
OVERALL_SERVER = ""
ENCODER_SERVICE = "sglang.grpc.encoder.SglangEncoder"
def __init__(self):
self._serving = False
def set_serving(self):
self._serving = True
def set_not_serving(self):
self._serving = False
async def Check(self, request, context) -> health_pb2.HealthCheckResponse:
if self._serving:
return health_pb2.HealthCheckResponse(
status=health_pb2.HealthCheckResponse.SERVING
)
return health_pb2.HealthCheckResponse(
status=health_pb2.HealthCheckResponse.NOT_SERVING
)
async def Watch(self, request, context):
yield await self.Check(request, context)
class SGLangEncoderServer(SGLangEncoderServicer):
"""
gRPC service implementation for SGLang encoder.
"""
def __init__(
self,
encoder: MMEncoder,
send_sockets: List[zmq.Socket],
server_args: ServerArgs,
):
self.encoder = encoder
self.send_sockets = send_sockets
self.server_args = server_args
async def Encode(
self, request: sglang_encoder_pb2.EncodeRequest, context
) -> sglang_encoder_pb2.EncodeResponse:
try:
request_dict = {
"mm_items": list(request.mm_items),
"req_id": request.req_id,
"num_parts": request.num_parts,
"part_idx": request.part_idx,
}
for socket in self.send_sockets:
await async_sock_send(socket, wrap_as_pickle(request_dict))
# gRPC encode is image-only; encoder.encode() requires modality
(
nbytes,
embedding_len,
embedding_dim,
error_msg,
error_code,
) = await self.encoder.encode(
mm_items=list(request.mm_items),
modality=Modality.IMAGE,
req_id=request.req_id,
num_parts=request.num_parts,
part_idx=request.part_idx,
)
if error_msg is not None:
context.set_code(grpc.StatusCode.INTERNAL)
context.set_details(error_msg)
return sglang_encoder_pb2.EncodeResponse()
if self.server_args.encoder_transfer_backend == "mooncake":
return sglang_encoder_pb2.EncodeResponse(
embedding_size=nbytes,
embedding_len=embedding_len,
embedding_dim=embedding_dim,
)
elif self.server_args.encoder_transfer_backend == "zmq_to_scheduler":
embedding_ports = list(request.embedding_port)
logger.info(f"embedding_port = {embedding_ports}")
if not embedding_ports:
await self.encoder.send_with_url(req_id=request.req_id)
else:
tasks = []
for embedding_port in embedding_ports:
tasks.append(
self.encoder.send(
req_id=request.req_id,
prefill_host=request.prefill_host,
embedding_port=embedding_port,
)
)
await asyncio.gather(*tasks)
self.encoder.embedding_to_send.pop(request.req_id, None)
return sglang_encoder_pb2.EncodeResponse()
elif self.server_args.encoder_transfer_backend == "zmq_to_tokenizer":
embedding_port = (
request.embedding_port[0] if request.embedding_port else 0
)
await self.encoder.send(
req_id=request.req_id,
prefill_host=request.prefill_host,
embedding_port=embedding_port,
)
self.encoder.embedding_to_send.pop(request.req_id, None)
return sglang_encoder_pb2.EncodeResponse()
return sglang_encoder_pb2.EncodeResponse()
except Exception as e:
logger.error(f"Encode error: {e}")
traceback.print_exc()
context.set_code(grpc.StatusCode.INTERNAL)
context.set_details(str(e))
return sglang_encoder_pb2.EncodeResponse()
async def Send(
self, request: sglang_encoder_pb2.SendRequest, context
) -> sglang_encoder_pb2.SendResponse:
try:
await self.encoder.send(
req_id=request.req_id,
prefill_host=request.prefill_host,
embedding_port=request.embedding_port,
session_id=request.session_id if request.session_id else None,
buffer_address=(
request.buffer_address if request.buffer_address else None
),
)
self.encoder.embedding_to_send.pop(request.req_id, None)
return sglang_encoder_pb2.SendResponse()
except Exception as e:
logger.error(f"Send error: {e}")
traceback.print_exc()
context.set_code(grpc.StatusCode.INTERNAL)
context.set_details(str(e))
return sglang_encoder_pb2.SendResponse()
async def SchedulerReceiveUrl(
self, request: sglang_encoder_pb2.SchedulerReceiveUrlRequest, context
) -> sglang_encoder_pb2.SchedulerReceiveUrlResponse:
try:
await handle_scheduler_receive_url_request(
{
"req_id": request.req_id,
"receive_count": request.receive_count,
"receive_url": request.receive_url,
}
)
return sglang_encoder_pb2.SchedulerReceiveUrlResponse()
except Exception as e:
logger.error(f"SchedulerReceiveUrl error: {e}")
traceback.print_exc()
context.set_code(grpc.StatusCode.INTERNAL)
context.set_details(str(e))
return sglang_encoder_pb2.SchedulerReceiveUrlResponse()
async def serve_grpc_encoder(server_args: ServerArgs):
ctx = mp.get_context("spawn")
zmq_ctx = zmq.asyncio.Context(10)
ipc_path_prefix = random_uuid()
port_args = PortArgs.init_new(server_args)
if server_args.dist_init_addr:
na = NetworkAddress.parse(server_args.dist_init_addr)
dist_init_method = na.to_tcp()
else:
dist_init_method = NetworkAddress(
server_args.host or "127.0.0.1", port_args.nccl_port
).to_tcp()
send_sockets: List[zmq.Socket] = []
for rank in range(1, server_args.tp_size):
schedule_path = f"ipc:///tmp/{ipc_path_prefix}_schedule_{rank}"
send_sockets.append(
get_zmq_socket(zmq_ctx, zmq.PUSH, schedule_path, bind=False)
)
ctx.Process(
target=launch_encoder,
args=(server_args, schedule_path, dist_init_method, rank),
daemon=True,
).start()
encoder = MMEncoder(server_args, dist_init_method=dist_init_method)
server = grpc.aio.server(
futures.ThreadPoolExecutor(max_workers=10),
options=[
("grpc.max_send_message_length", 1024 * 1024 * 256),
("grpc.max_receive_message_length", 1024 * 1024 * 256),
],
)
health_servicer = EncoderHealthServicer()
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
encoder_servicer = SGLangEncoderServer(
encoder=encoder,
send_sockets=send_sockets,
server_args=server_args,
)
add_SGLangEncoderServicer_to_server(encoder_servicer, server)
SERVICE_NAMES = (
sglang_encoder_pb2.DESCRIPTOR.services_by_name["SglangEncoder"].full_name,
"grpc.health.v1.Health",
reflection.SERVICE_NAME,
)
reflection.enable_server_reflection(SERVICE_NAMES, server)
listen_addr = NetworkAddress(server_args.host, server_args.port).to_host_port_str()
server.add_insecure_port(listen_addr)
await server.start()
logger.info(f"gRPC encoder server listening on {listen_addr}")
health_servicer.set_serving()
try:
await server.wait_for_termination()
except KeyboardInterrupt:
logger.info("Shutting down gRPC encoder server...")
health_servicer.set_not_serving()
await server.stop(grace=5)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
from sglang.srt.disaggregation.fake.conn import (
FakeKVManager,
FakeKVReceiver,
FakeKVSender,
)
@@ -0,0 +1,139 @@
import logging
from typing import List, Optional
import numpy as np
import numpy.typing as npt
from sglang.srt.disaggregation.base.conn import (
BaseKVManager,
BaseKVReceiver,
BaseKVSender,
KVArgs,
KVPoll,
KVTransferMetric,
)
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
# For warmup reqs, we don't kv transfer, we use the fake manager, sender and receiver
class FakeKVManager(BaseKVManager):
def __init__(
self,
args: KVArgs,
disaggregation_mode: DisaggregationMode,
server_args: ServerArgs,
is_mla_backend: Optional[bool] = False,
):
super().__init__(args, disaggregation_mode, server_args, is_mla_backend)
self.kv_args = args
self.req_to_decode_prefix_len = {}
def register_to_bootstrap(self):
pass
class FakeKVSender(BaseKVSender):
def __init__(
self,
mgr: BaseKVManager,
bootstrap_addr: str,
bootstrap_room: int,
dest_tp_ranks: List[int],
pp_rank: int,
req_has_disagg_prefill_dp_rank: bool = False,
):
self.kv_mgr = mgr
self.has_sent = False
self.conclude_state: Optional[KVPoll] = None
def poll(self) -> KVPoll:
if self.conclude_state is not None:
return self.conclude_state
if not self.has_sent:
# Assume handshake completed instantly
return KVPoll.WaitingForInput
# Assume transfer completed instantly
logger.debug("FakeKVSender poll success")
self.conclude_state = KVPoll.Success
return KVPoll.Success
def get_transfer_metric(self) -> KVTransferMetric:
return KVTransferMetric()
def init(
self,
kv_indices: list[int],
aux_index: Optional[int] = None,
):
logger.debug(
f"FakeKVSender init with kv_indices: {kv_indices}, aux_index: {aux_index}"
)
pass
def send(
self,
kv_indices: npt.NDArray[np.int32],
state_indices: Optional[List] = None,
):
self.has_sent = True
logger.debug(
f"FakeKVSender send with kv_indices: {kv_indices}, state_indices: {state_indices}"
)
def failure_exception(self):
raise Exception("Fake KVSender Exception")
def abort(self):
self.conclude_state = KVPoll.Failed
class FakeKVReceiver(BaseKVReceiver):
def __init__(
self,
mgr: BaseKVManager,
bootstrap_addr: str,
bootstrap_room: Optional[int] = None,
):
self.bootstrap_done = False
self.has_sent_metadata = False
self.require_staging: bool = False
self.conclude_state: Optional[KVPoll] = None
def poll(self) -> KVPoll:
if self.conclude_state is not None:
return self.conclude_state
if not self.bootstrap_done:
return KVPoll.Bootstrapping
if not self.has_sent_metadata:
return KVPoll.WaitingForInput
logger.debug("FakeKVReceiver poll success")
self.conclude_state = KVPoll.Success
return KVPoll.Success
def init(
self,
prefill_dp_rank: int,
):
self.bootstrap_done = True
def send_metadata(
self,
kv_indices: list[int],
aux_index: Optional[int] = None,
state_indices: Optional[List] = None,
decode_prefix_len: Optional[int] = None,
):
self.has_sent_metadata = True
logger.debug(
f"FakeKVReceiver send_metadata with kv_indices: {kv_indices}, aux_index: {aux_index}, state_indices: {state_indices}"
)
def failure_exception(self):
raise Exception("Fake KVReceiver Exception")
def abort(self):
self.conclude_state = KVPoll.Failed
@@ -0,0 +1,470 @@
"""
Copyright 2025 SGLang Team
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""
KV caching events
"""
import atexit
import enum
import logging
import queue
import threading
import time
from abc import ABC, abstractmethod
from collections import deque
from itertools import count
from queue import Queue
from typing import Any, Callable, Optional, Union
import msgspec
import zmq
from pydantic import BaseModel
logger = logging.getLogger(__name__)
def select_kv_publisher_dp_rank(
attn_dp_size: int, attn_dp_rank: int, dp_rank: Optional[int]
) -> int:
"""Index used to offset this scheduler's KV-event publisher port.
Each independent KV cache must publish on its own port so a consumer can
subscribe per replica. There are always ``dp_size`` such publishers; which
rank distinguishes them depends on the parallelism mode:
- DP-attention (``attn_dp_size > 1``): each attention-DP rank owns a KV
cache shard, so distinguish by ``attn_dp_rank``.
- Pure DP (``attn_dp_size == 1``): every worker has ``attn_dp_rank == 0``,
so distinguish by ``dp_rank`` (the data-parallel replica index).
Both span ``0..dp_size-1``, matching the ``dp_size`` advertised in
``/server_info`` and the per-rank ports the router subscribes to.
"""
if attn_dp_size > 1:
return attn_dp_rank
return dp_rank or 0
class EventBatch(
msgspec.Struct,
array_like=True, # type: ignore[call-arg]
omit_defaults=True, # type: ignore[call-arg]
gc=False, # type: ignore[call-arg]
):
ts: float
events: list[Any]
attn_dp_rank: Optional[int] = None
class KVCacheEvent(
msgspec.Struct,
array_like=True, # type: ignore[call-arg]
omit_defaults=True, # type: ignore[call-arg]
gc=False, # type: ignore[call-arg]
tag=True,
):
"""Base class for all KV cache-related events"""
class StorageMedium(str, enum.Enum):
"""Storage tier for KV cache events."""
GPU = "GPU" # L1: device HBM
CPU = "CPU_PINNED" # L2: host pinned memory
DISK = "DISK" # L3: SSD / NVMe
EXTERNAL = "EXTERNAL" # L4: shared / remote pool (e.g. Mooncake)
class OffloadedState:
"""
OffloadedState represents the state of a KV cache block offloaded to the hicache.
- prefill_len (int): The length of the prefill part of the KV cache block.
- inc_len (int): The length of the incremental part of the KV cache block.
- last_hash (Optional[str]): The hash of the last token in the KV cache block.
"""
def __init__(
self, prefill_len: int, inc_len: int = 0, last_hash: Optional[str] = None
):
self.prefill_len = prefill_len
self.inc_len = inc_len
self.last_hash = last_hash
class BlockStored(KVCacheEvent):
block_hashes: list[int]
parent_block_hash: Optional[int]
token_ids: list[int]
block_size: int
lora_id: Optional[int]
medium: Optional[str] = None
class BlockRemoved(KVCacheEvent):
block_hashes: list[int]
medium: Optional[str] = None
class AllBlocksCleared(KVCacheEvent):
pass
class KVEventBatch(EventBatch):
events: list[Union[BlockStored, BlockRemoved, AllBlocksCleared]]
class EventPublisher(ABC):
"""
Lightweight publisher for EventBatch batches with
support for DP attention.
In DP attention - each rank has its own Scheduler and
KV cache instance in order to avoid duplicate events
and ensure proper event attribution. In our implementation
- Each DP rank has its own EventPublisher
- Publishers annotate events with the dp rank
- This allows consumers to distinguish events from different DP ranks
"""
@abstractmethod
def publish(self, events: EventBatch) -> None:
"""Emit events in order.
Implementations should guarantee at-least-once delivery and
monotonic ordering (e.g., via sequence numbers).
"""
@abstractmethod
def shutdown(self) -> None:
"""Shutdown the publisher."""
class NullEventPublisher(EventPublisher):
"""No-op implementation (default when disabled)."""
def publish(self, events) -> None:
return
def shutdown(self) -> None:
return
class ZmqEventPublisher(EventPublisher):
"""Reliable PUB/ROUTER publisher with an in-memory replay buffer.
Spawns a separate thread to handle publishing from a queue.
Parameters
----------
endpoint:
PUB address. Use ``tcp://*:5557`` to bind or ``tcp://host:5557`` to
connect.
replay_endpoint:
Optional ROUTER address for replay requests. When given, subscribers can
request missed batches by sending the starting sequence number as an
8-byte big-endian integer.
buffer_steps:
Number of past batches to keep for replay.
hwm:
ZeroMQ high-water-mark for PUB socket.
max_queue_size:
Maximum number of events to buffer in memory.
topic:
Topic to publish events to.
"""
SHUTDOWN_TIMEOUT: float = 1.0
END_SEQ = (-1).to_bytes(8, "big", signed=True)
def __init__(
self,
attn_dp_rank: int,
endpoint: str = "tcp://*:5557",
replay_endpoint: Optional[str] = None,
buffer_steps: int = 10_000,
hwm: int = 100_000,
max_queue_size: int = 100_000,
topic: str = "",
) -> None:
# Storage
self._event_queue = Queue[Optional[EventBatch]](maxsize=max_queue_size)
self._buffer = deque[tuple[int, bytes]](maxlen=buffer_steps)
# ZMQ sockets
self._ctx = zmq.Context.instance()
self._pub: Optional[zmq.Socket] = None
self._replay: Optional[zmq.Socket] = None
self._dp_rank = attn_dp_rank
self._endpoint = self.offset_endpoint_port(endpoint, self._dp_rank)
self._replay_endpoint = self.offset_endpoint_port(
replay_endpoint, self._dp_rank
)
self._hwm = hwm
self._socket_setup()
# Payload
self._seq_gen = count()
self._topic_bytes = topic.encode("utf-8")
# Thread
self._running = True
logger.info("Starting ZMQ publisher thread")
self._thread = threading.Thread(
target=self._publisher_thread, daemon=True, name="zmq-publisher"
)
self._thread.start()
atexit.register(self.shutdown)
def publish(self, events: EventBatch) -> None:
if not self._running:
raise RuntimeError("Publisher is closed")
if events.attn_dp_rank is None:
events.attn_dp_rank = self._dp_rank
self._event_queue.put(events)
def shutdown(self) -> None:
"""Stop the publisher thread and clean up resources."""
self._running = False
self._event_queue.put_nowait(None)
start = time.time()
pending_items = True
while pending_items and (time.time() - start < self.SHUTDOWN_TIMEOUT):
pending_items = not self._event_queue.empty()
if pending_items:
time.sleep(0.1)
if pending_items:
logger.warning(
"Warning: Queue still has %s items after %s seconds timeout",
self._event_queue.qsize(),
self.SHUTDOWN_TIMEOUT,
)
if self._thread.is_alive():
self._thread.join(timeout=self.SHUTDOWN_TIMEOUT)
# Clean up ZMQ resources
try:
if self._pub is not None:
self._pub.close(linger=0)
if self._replay is not None:
self._replay.close(linger=0)
finally:
pass # Do not terminate context; other sockets may use it
def _socket_setup(self) -> None:
"""Initialize sockets
https://pyzmq.readthedocs.io/en/v19.0.0/morethanbindings.html#thread-safety
"""
if self._pub is None:
self._pub = self._ctx.socket(zmq.PUB)
self._pub.set_hwm(self._hwm)
# Heuristic: bind if wildcard / * present, else connect.
# bind stable, connect volatile convention.
# ``0.0.0.0`` is the IPv4 bind-all wildcard alongside ``*``
# and ``::``; ``/server_info`` advertises it as a wildcard,
# so the publisher must bind it for the advertised endpoint
# to actually be listening.
if (
"*" in self._endpoint
or "::" in self._endpoint
or "0.0.0.0" in self._endpoint
or self._endpoint.startswith("ipc://")
or self._endpoint.startswith("inproc://")
):
logger.debug(
f"ZmqEventPublisher socket publisher_endpoint bind to {self._endpoint}"
)
self._pub.bind(self._endpoint)
else:
self._pub.connect(self._endpoint)
# Set up replay socket: use ROUTER
# 1) handles multiple REQ clients (identities)
# 2) lets us send back one request → many replies (streamed events)
# 3) works in our nonblocking poll loop alongside PUB
if self._replay_endpoint is not None:
self._replay = self._ctx.socket(zmq.ROUTER)
logger.debug(
f"ZmqEventPublisher socket replay_endpoint bind to {self._replay_endpoint}"
)
self._replay.bind(self._replay_endpoint)
def _publisher_thread(self) -> None:
"""Background thread that processes the event queue."""
self._pack = msgspec.msgpack.Encoder()
assert self._pub is not None # narrows type for mypy
while self._running or self._event_queue.qsize() > 0:
# --- replay (non-critical) ---------------------------------
if self._replay is not None and self._replay.poll(0):
try:
self._service_replay()
except Exception as e:
logger.exception("Error in replay: %s", e)
# --- main queue (critical) ---------------------------------
try:
event = self._event_queue.get(timeout=0.1)
if event is None:
break # Sentinel received, exit thread
except queue.Empty:
continue
try:
seq = next(self._seq_gen)
payload = self._pack.encode(event)
seq_bytes = seq.to_bytes(8, "big")
self._pub.send_multipart((self._topic_bytes, seq_bytes, payload))
self._buffer.append((seq, payload))
self._event_queue.task_done()
except Exception as e:
# Publishing failed; back-off a bit to avoid a tight error loop
logger.exception("Error in publisher thread: %s", e)
time.sleep(0.1)
def _service_replay(self) -> None:
"""If a replay request is waiting, send buffered batches."""
assert self._replay is not None # narrows type for mypy
frame = self._replay.recv_multipart()
if len(frame) != 3:
logger.warning("Invalid replay request: %s", frame)
return
client_id, _, start_seq_bytes = frame
start_seq = int.from_bytes(start_seq_bytes, "big")
for seq, buf in self._buffer:
if seq >= start_seq:
# [identity, empty_delim, seq_bytes, payload]
# (identity, empty_delim) are stripped off by the router
# receiving payload is (seq_bytes, payload)
self._replay.send_multipart(
(client_id, b"", seq.to_bytes(8, "big"), buf)
)
# Send end of sequence marker
# receiving payload is (-1, b""")
self._replay.send_multipart((client_id, b"", self.END_SEQ, b""))
@staticmethod
def offset_endpoint_port(
endpoint: Optional[str], data_parallel_rank: int
) -> Optional[str]:
"""Helper function to offset the port in an endpoint by
the data parallel rank.
Args:
endpoint: The endpoint string
(e.g., "tcp://*:5557" or "inproc://cache")
data_parallel_rank: The data parallel rank to offset by
Returns:
The endpoint with the port offset by data_parallel_rank
or suffix appended
"""
# Do nothing if input is None or data_parallel_rank is 0
if not endpoint or data_parallel_rank == 0:
return endpoint
if "inproc" in endpoint:
return f"{endpoint}_dp{data_parallel_rank}"
if "tcp" in endpoint:
if endpoint and ":" in endpoint:
# Get everything after the last colon (the port)
last_colon_idx = endpoint.rfind(":")
base_addr = endpoint[:last_colon_idx]
base_port = int(endpoint[last_colon_idx + 1 :])
new_port = base_port + data_parallel_rank
return f"{base_addr}:{new_port}"
return endpoint
raise ValueError("Invalid endpoint: must contain 'inproc' or 'tcp'")
class KVEventsConfig(BaseModel):
"""Configuration for KV event publishing."""
publisher: str = "null"
"""The publisher to use for publishing kv events. Can be "null", "zmq".
"""
endpoint: str = "tcp://*:5557"
"""The zmq endpoint to use for publishing kv events.
"""
replay_endpoint: Optional[str] = None
"""The zmq endpoint to use for replaying kv events.
"""
buffer_steps: int = 10_000
"""The number of steps to cache for replay endpoint. Will only save
events from the last N steps for the replay endpoint.
"""
hwm: int = 100_000
"""The zmq high water mark for the event publisher. After queueing N events,
events will start dropping if the consumer is not keeping up.
"""
max_queue_size: int = 100_000
"""The maximum number of events to queue while waiting for publishing.
"""
topic: str = ""
"""The topic to use for the event publisher. Consumers can subscribe to
this topic to receive events.
"""
@classmethod
def from_cli(cls, cli_value: str) -> "KVEventsConfig":
"""Parse the CLI value for the event publisher config."""
return KVEventsConfig.model_validate_json(cli_value)
class EventPublisherFactory:
_registry: dict[str, Callable[..., EventPublisher]] = {
"null": NullEventPublisher,
"zmq": ZmqEventPublisher,
}
@classmethod
def register_publisher(cls, name: str, ctor: Callable[..., EventPublisher]) -> None:
if name in cls._registry:
raise KeyError(f"publisher '{name}' already registered")
cls._registry[name] = ctor
@classmethod
def create(cls, config: Optional[str], attn_dp_rank: int = 0) -> EventPublisher:
"""Create publisher from a config mapping."""
if not config:
return NullEventPublisher()
config = KVEventsConfig.from_cli(config)
config_dict = config.model_dump()
kind = config_dict.pop("publisher", "null")
try:
constructor = cls._registry[kind]
except KeyError as exc:
raise ValueError(f"Unknown event publisher '{kind}'") from exc
return constructor(attn_dp_rank=attn_dp_rank, **config_dict)
@@ -0,0 +1,6 @@
from sglang.srt.disaggregation.mooncake.conn import (
MooncakeKVBootstrapServer,
MooncakeKVManager,
MooncakeKVReceiver,
MooncakeKVSender,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
# Copyright 2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Mooncake-specific utilities for custom memory pool management."""
import logging
from typing import Any, Optional, Tuple
import torch
from sglang.srt.environ import envs
logger = logging.getLogger(__name__)
# Global constants for custom memory pool types
SUPPORTED_MOONCAKE_CUSTOM_MEM_POOL_TYPES = ["NVLINK", "BAREX", "INTRA_NODE_NVLINK"]
def init_mooncake_custom_mem_pool(
device: str,
) -> Tuple[bool, Optional[Any], Optional[str]]:
"""
Initialize custom memory pool based on environment variable.
Args:
device: The device to allocate memory on
Returns:
Tuple of (enable_custom_mem_pool, custom_mem_pool, custom_mem_pool_type)
"""
enable_custom_mem_pool, custom_mem_pool_type = (
check_mooncake_custom_mem_pool_enabled()
)
custom_mem_pool = None
if enable_custom_mem_pool:
try:
# TODO(shangming): abstract custom allocator class for more backends
if custom_mem_pool_type == "NVLINK":
from mooncake.allocator import NVLinkAllocator
allocator = NVLinkAllocator.get_allocator(device)
elif custom_mem_pool_type == "BAREX":
from mooncake.allocator import BarexAllocator
allocator = BarexAllocator.get_allocator(device)
elif custom_mem_pool_type == "INTRA_NODE_NVLINK":
return False, None, None
else:
# This should not happen due to the enable_custom_mem_pool check above
raise ValueError(
f"Unsupported custom mem pool type: {custom_mem_pool_type}"
)
custom_mem_pool = torch.cuda.MemPool(allocator.allocator())
logger.debug(
f"Initialized custom memory pool: {custom_mem_pool_type} on device {device}"
)
except ImportError as e:
logger.warning(
f"Failed to import mooncake allocator for {custom_mem_pool_type}: {e}. "
f"Falling back to default memory pool."
)
enable_custom_mem_pool = False
custom_mem_pool = None
custom_mem_pool_type = None
except Exception as e:
logger.error(
f"Failed to initialize custom memory pool {custom_mem_pool_type}: {e}. "
f"Falling back to default memory pool."
)
enable_custom_mem_pool = False
custom_mem_pool = None
custom_mem_pool_type = None
else:
return False, None, None
return enable_custom_mem_pool, custom_mem_pool, custom_mem_pool_type
def check_mooncake_custom_mem_pool_enabled() -> Tuple[bool, Optional[str]]:
"""
Check if custom memory pool is enabled without importing allocators.
Returns:
Tuple of (enable_custom_mem_pool, custom_mem_pool_type)
"""
custom_mem_pool_type = envs.SGLANG_MOONCAKE_CUSTOM_MEM_POOL.get()
if custom_mem_pool_type is not None:
# Handle boolean True as NVLINK
if custom_mem_pool_type.lower() == "true":
custom_mem_pool_type = "NVLINK"
enable_custom_mem_pool = (
custom_mem_pool_type in SUPPORTED_MOONCAKE_CUSTOM_MEM_POOL_TYPES
)
else:
enable_custom_mem_pool = False
custom_mem_pool_type = None
return enable_custom_mem_pool, custom_mem_pool_type
@@ -0,0 +1,6 @@
from sglang.srt.disaggregation.mori.conn import (
MoriKVBootstrapServer,
MoriKVManager,
MoriKVReceiver,
MoriKVSender,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
from sglang.srt.disaggregation.nixl.conn import (
NixlKVBootstrapServer,
NixlKVManager,
NixlKVReceiver,
NixlKVSender,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+878
View File
@@ -0,0 +1,878 @@
from __future__ import annotations
import os
import random
from collections import deque
from contextlib import nullcontext
from enum import Enum
from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Type, overload
import numpy as np
import torch
import torch.distributed as dist
from sglang.srt.disaggregation.base import KVPoll
from sglang.srt.environ import envs
from sglang.srt.utils import is_hip, is_npu
if TYPE_CHECKING:
from sglang.srt.disaggregation.base.conn import KVArgs, StateType
from sglang.srt.disaggregation.common.conn import (
CommonKVBootstrapServer,
CommonKVManager,
CommonKVReceiver,
CommonKVSender,
)
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.server_args import ServerArgs
#########################
# Constants & Enums
#########################
FAKE_BOOTSTRAP_HOST = "2.2.2.2"
_IS_HIP = is_hip()
def is_dsv4_c128_online_enabled() -> bool:
"""Return whether DSV4 C128 uses request-scoped online state."""
return not _IS_HIP and envs.SGLANG_OPT_USE_ONLINE_COMPRESS.get()
def get_dsv4_c128_state_indices(
req_pool_idx: int,
seq_len: int,
*,
online: bool,
ring_size: int,
) -> np.ndarray:
"""Return the PD transfer row/page indices for DSV4 C128 state."""
if seq_len == 0 or seq_len % 128 == 0:
return np.empty((0,), dtype=np.int32)
if online:
return np.array([int(req_pool_idx)], dtype=np.int32)
assert ring_size % 128 == 0, f"C128 ring_size must be 128-aligned, got {ring_size}"
pages_per_req = ring_size // 128
page = int(req_pool_idx) * pages_per_req + ((seq_len - 1) % ring_size) // 128
return np.array([page], dtype=np.int32)
class DisaggregationMode(Enum):
NULL = "null"
PREFILL = "prefill"
DECODE = "decode"
@staticmethod
def to_engine_type(mode: str) -> str:
if mode == DisaggregationMode.PREFILL.value:
return "prefill"
elif mode == DisaggregationMode.DECODE.value:
return "decode"
return "unified"
#########################
# Synchronization
#########################
def _get_failure_prob() -> float:
try:
return float(envs.SGLANG_TEST_DISAGG_FAILURE_PROB.get())
except Exception:
# fallback to legacy env var
return float(os.getenv("DISAGGREGATION_TEST_FAILURE_PROB", "0"))
def _poll_with_failure_injection(pollers) -> List[int]:
if (failure_prob := _get_failure_prob()) > 0:
return [
int(KVPoll.Failed) if random.random() < failure_prob else int(poller.poll())
for poller in pollers
]
return [int(poller.poll()) for poller in pollers]
def _is_fake_transfer(req: Req, server_args: ServerArgs) -> bool:
return req.bootstrap_host == FAKE_BOOTSTRAP_HOST or (
req.bootstrap_host is None
and server_args.disaggregation_transfer_backend == "fake"
)
def _apply_metadata_gate(polls, decode_reqs, metadata_buffers, server_args) -> None:
"""Downgrade Success → Transferring for requests whose metadata hasn't landed.
Mutates `polls` in-place. Called before all-reduce so that MIN across TP
ranks naturally prevents any rank from committing before all ranks are ready.
"""
for i, poll_val in enumerate(polls):
if poll_val == int(KVPoll.Success):
decode_req = decode_reqs[i]
if _is_fake_transfer(decode_req.req, server_args):
continue
actual_room = metadata_buffers.bootstrap_room[
decode_req.metadata_buffer_index, 0
].item()
if actual_room == 0:
polls[i] = int(KVPoll.Transferring)
def poll_and_all_reduce(
pollers,
gloo_group: dist.ProcessGroup,
decode_reqs=None,
metadata_buffers: Optional[MetadataBuffers] = None,
server_args: Optional[ServerArgs] = None,
):
# at a certain prob, the poll is failed to simulate failure
polls = _poll_with_failure_injection(pollers)
# Apply metadata gate on the decode requests to downgrade Success → Transferring for requests whose metadata hasn't landed.
if (
decode_reqs is not None
and metadata_buffers is not None
and server_args is not None
):
_apply_metadata_gate(polls, decode_reqs, metadata_buffers, server_args)
tensor_to_reduce = torch.tensor(polls, dtype=torch.uint8, device="cpu")
dist.all_reduce(tensor_to_reduce, op=dist.ReduceOp.MIN, group=gloo_group)
return tensor_to_reduce.tolist()
def poll_and_all_reduce_attn_cp_tp_group(
pollers,
attn_cp_cpu_group: dist.ProcessGroup,
attn_tp_cpu_group: dist.ProcessGroup,
):
# First sync across attn-tp ranks so all TP participants for a given (dp, cp)
# shard observe the same status transitions.
polls = poll_and_all_reduce(pollers, attn_tp_cpu_group)
# Then sync across attn-cp ranks, so all TPxCP participants in one DP shard
# converge to the same global status.
tensor_to_reduce = torch.tensor(polls, dtype=torch.uint8, device="cpu")
dist.all_reduce(
tensor_to_reduce,
op=dist.ReduceOp.MIN,
group=attn_cp_cpu_group,
)
return tensor_to_reduce.tolist()
def poll_and_all_reduce_with_staging(
decode_reqs,
staging_handler,
gloo_group: dist.ProcessGroup,
metadata_buffers: Optional[MetadataBuffers] = None,
server_args: Optional[ServerArgs] = None,
):
"""Staging-aware polling: advance scatter, demote incomplete transfers, all_reduce."""
for decode_req in decode_reqs:
if decode_req.kv_receiver.require_staging and not staging_handler.is_done(
decode_req
):
staging_handler.advance_scatter(decode_req)
# allow test injection of failure probability at runtime
receivers = [dr.kv_receiver for dr in decode_reqs]
raw_polls = _poll_with_failure_injection(receivers)
for i, decode_req in enumerate(decode_reqs):
if raw_polls[i] == int(KVPoll.Success):
if decode_req.kv_receiver.require_staging and not staging_handler.is_done(
decode_req
):
raw_polls[i] = int(KVPoll.Transferring)
# Apply metadata gate on the decode requests to downgrade Success → Transferring for requests whose metadata hasn't landed.
if metadata_buffers is not None and server_args is not None:
_apply_metadata_gate(raw_polls, decode_reqs, metadata_buffers, server_args)
poll_tensor = torch.tensor(raw_polls, dtype=torch.uint8, device="cpu")
dist.all_reduce(poll_tensor, op=dist.ReduceOp.MIN, group=gloo_group)
return poll_tensor.tolist()
#########################
# Metadata Buffers
#########################
class ReqToMetadataIdxAllocator:
"""A memory pool that maps a request to its first output token location."""
def __init__(
self,
size: int,
):
self.size = size
self.free_slots = deque(list(range(size)))
def available_size(self):
return len(self.free_slots)
def alloc(self) -> Optional[int]:
if len(self.free_slots) == 0:
return None
return self.free_slots.popleft()
def free(self, free_index: int):
self.free_slots.append(free_index)
class MetadataBuffers:
def __init__(
self,
size: int,
hidden_size: int,
hidden_states_dtype: torch.dtype,
max_top_logprobs_num: int = 128,
custom_mem_pool: torch.cuda.MemPool = None,
):
self.custom_mem_pool = custom_mem_pool
bootstrap_room_dtype = torch.uint64
device = "cpu"
if is_npu():
# For ascend backend, output tokens are placed in the NPU and will be transferred by D2D channel.
device = "npu"
# TODO: Fix me when npu backend supports torch.uint64
bootstrap_room_dtype = torch.int64
elif self.custom_mem_pool:
# TODO(shangming): Fix me (use 'cuda') when nvlink_transport of Mooncake is bug-free
device = "cpu"
elif envs.SGLANG_MOONCAKE_CUSTOM_MEM_POOL.get() == "INTRA_NODE_NVLINK":
device = "cuda"
with (
torch.cuda.use_mem_pool(self.custom_mem_pool)
if self.custom_mem_pool
else nullcontext()
):
# TODO: abort top_logprobs_num > 128 in PD
# We transfer the metadata of first output token to decode
# The minimal size for RDMA is 64Bytes, so we pad it to > 64Bytes
self.output_ids = torch.zeros((size, 16), dtype=torch.int32, device=device)
self.cached_tokens = torch.zeros(
(size, 16), dtype=torch.int32, device=device
)
self.output_token_logprobs_val = torch.zeros(
(size, 16), dtype=torch.float32, device=device
)
self.output_token_logprobs_idx = torch.zeros(
(size, 16), dtype=torch.int32, device=device
)
self.output_top_logprobs_val = torch.zeros(
(size, max_top_logprobs_num), dtype=torch.float32, device=device
)
self.output_top_logprobs_idx = torch.zeros(
(size, max_top_logprobs_num), dtype=torch.int32, device=device
)
# For PD + spec decode
self.output_topk_p = torch.zeros(
(size, 16), dtype=torch.float32, device=device
)
self.output_topk_index = torch.zeros(
(size, 16), dtype=torch.int64, device=device
)
self.output_hidden_states = torch.zeros(
(size, hidden_size), dtype=hidden_states_dtype, device=device
)
# Request validation: store bootstrap_room to detect metadata corruption
self.bootstrap_room = torch.zeros(
(size, 8), dtype=bootstrap_room_dtype, device=device
)
def get_buf_infos(self):
ptrs = [
self.output_ids.data_ptr(),
self.cached_tokens.data_ptr(),
self.output_token_logprobs_val.data_ptr(),
self.output_token_logprobs_idx.data_ptr(),
self.output_top_logprobs_val.data_ptr(),
self.output_top_logprobs_idx.data_ptr(),
self.output_topk_p.data_ptr(),
self.output_topk_index.data_ptr(),
self.output_hidden_states.data_ptr(),
self.bootstrap_room.data_ptr(),
]
data_lens = [
self.output_ids.nbytes,
self.cached_tokens.nbytes,
self.output_token_logprobs_val.nbytes,
self.output_token_logprobs_idx.nbytes,
self.output_top_logprobs_val.nbytes,
self.output_top_logprobs_idx.nbytes,
self.output_topk_p.nbytes,
self.output_topk_index.nbytes,
self.output_hidden_states.nbytes,
self.bootstrap_room.nbytes,
]
item_lens = [
self.output_ids[0].nbytes,
self.cached_tokens[0].nbytes,
self.output_token_logprobs_val[0].nbytes,
self.output_token_logprobs_idx[0].nbytes,
self.output_top_logprobs_val[0].nbytes,
self.output_top_logprobs_idx[0].nbytes,
self.output_topk_p[0].nbytes,
self.output_topk_index[0].nbytes,
self.output_hidden_states[0].nbytes,
self.bootstrap_room[0].nbytes,
]
return ptrs, data_lens, item_lens
def get_buf(self, idx: int):
return (
self.output_ids[idx].clone(),
self.cached_tokens[idx].clone(),
self.output_token_logprobs_val[idx].clone(),
self.output_token_logprobs_idx[idx].clone(),
self.output_top_logprobs_val[idx].clone(),
self.output_top_logprobs_idx[idx].clone(),
self.output_topk_p[idx].clone(),
self.output_topk_index[idx].clone(),
self.output_hidden_states[idx].clone(),
self.bootstrap_room[idx].clone(),
)
def set_buf(self, req: Req):
self.output_ids[req.metadata_buffer_index][0] = req.output_ids[0]
# The cached_tokens buffer is (size, 16); slots 0-3 hold cached token
# counts and slots 4-6 are reused for multimodal prompt token counts
# (slots 7-15 remain spare). This avoids adding new RDMA buffers.
# Slot map: 0=cached 1=device 2=host 3=storage 4=image 5=audio 6=video.
self.cached_tokens[req.metadata_buffer_index][0] = req.cached_tokens
self.cached_tokens[req.metadata_buffer_index][1] = req.cached_tokens_device
self.cached_tokens[req.metadata_buffer_index][2] = req.cached_tokens_host
self.cached_tokens[req.metadata_buffer_index][3] = req.cached_tokens_storage
# Compute multimodal prompt token counts on the prefill node so decode
# can report them in usage.
if req.multimodal_inputs:
image_t, audio_t, video_t = req.multimodal_inputs.compute_mm_token_counts()
else:
image_t = audio_t = video_t = 0
self.cached_tokens[req.metadata_buffer_index][4] = image_t
self.cached_tokens[req.metadata_buffer_index][5] = audio_t
self.cached_tokens[req.metadata_buffer_index][6] = video_t
if req.return_logprob:
if req.logprob.output_token_logprobs_val: # not none or empty list
self.output_token_logprobs_val[req.metadata_buffer_index][0] = (
req.logprob.output_token_logprobs_val[0]
)
if req.logprob.output_token_logprobs_idx: # not none or empty list
self.output_token_logprobs_idx[req.metadata_buffer_index][0] = (
req.logprob.output_token_logprobs_idx[0]
)
if req.logprob.output_top_logprobs_val: # not none or empty list
self.output_top_logprobs_val[req.metadata_buffer_index][
: len(req.logprob.output_top_logprobs_val[0])
] = torch.tensor(
req.logprob.output_top_logprobs_val[0],
dtype=torch.float32,
device="cpu",
)
if req.logprob.output_top_logprobs_idx: # not none or empty list
self.output_top_logprobs_idx[req.metadata_buffer_index][
: len(req.logprob.output_top_logprobs_idx[0])
] = torch.tensor(
req.logprob.output_top_logprobs_idx[0],
dtype=torch.int32,
device="cpu",
)
# For PD + spec decode
if req.hidden_states_tensor is not None:
# speculative_eagle_topk should not be greater than 16 currently
topk = req.output_topk_p.size(0)
self.output_topk_p[req.metadata_buffer_index, :topk].copy_(
req.output_topk_p
)
self.output_topk_index[req.metadata_buffer_index, :topk].copy_(
req.output_topk_index
)
self.output_hidden_states[req.metadata_buffer_index].copy_(
req.hidden_states_tensor
)
# Store bootstrap_room for validation on decode side
self.bootstrap_room[req.metadata_buffer_index, 0] = (
req.bootstrap_room if req.bootstrap_room is not None else 0
)
#########################
# Transfer Backend
#########################
class TransferBackend(Enum):
MOONCAKE = "mooncake"
MORI = "mori"
NIXL = "nixl"
ASCEND = "ascend"
FAKE = "fake"
class KVClassType(Enum):
KVARGS = "kvargs"
MANAGER = "manager"
SENDER = "sender"
RECEIVER = "receiver"
BOOTSTRAP_SERVER = "bootstrap_server"
@overload
def get_kv_class(
transfer_backend: TransferBackend, class_type: Literal[KVClassType.KVARGS]
) -> Type[KVArgs]: ...
@overload
def get_kv_class(
transfer_backend: TransferBackend, class_type: Literal[KVClassType.MANAGER]
) -> Type[CommonKVManager]: ...
@overload
def get_kv_class(
transfer_backend: TransferBackend, class_type: Literal[KVClassType.SENDER]
) -> Type[CommonKVSender]: ...
@overload
def get_kv_class(
transfer_backend: TransferBackend, class_type: Literal[KVClassType.RECEIVER]
) -> Type[CommonKVReceiver]: ...
@overload
def get_kv_class(
transfer_backend: TransferBackend, class_type: Literal[KVClassType.BOOTSTRAP_SERVER]
) -> Type[CommonKVBootstrapServer]: ...
def get_kv_class(
transfer_backend: TransferBackend, class_type: KVClassType
) -> Optional[Type]:
from sglang.srt.disaggregation.fake import FakeKVReceiver, FakeKVSender
if transfer_backend == TransferBackend.MOONCAKE:
from sglang.srt.disaggregation.base import KVArgs
from sglang.srt.disaggregation.mooncake import (
MooncakeKVBootstrapServer,
MooncakeKVManager,
MooncakeKVReceiver,
MooncakeKVSender,
)
class_mapping = {
KVClassType.KVARGS: KVArgs,
KVClassType.MANAGER: MooncakeKVManager,
KVClassType.SENDER: MooncakeKVSender,
KVClassType.RECEIVER: (MooncakeKVReceiver),
KVClassType.BOOTSTRAP_SERVER: MooncakeKVBootstrapServer,
}
return class_mapping.get(class_type)
elif transfer_backend == TransferBackend.MORI:
from sglang.srt.disaggregation.base import KVArgs
from sglang.srt.disaggregation.mori import (
MoriKVBootstrapServer,
MoriKVManager,
MoriKVReceiver,
MoriKVSender,
)
class_mapping = {
KVClassType.KVARGS: KVArgs,
KVClassType.MANAGER: MoriKVManager,
KVClassType.SENDER: MoriKVSender,
KVClassType.RECEIVER: (MoriKVReceiver),
KVClassType.BOOTSTRAP_SERVER: MoriKVBootstrapServer,
}
return class_mapping.get(class_type)
elif transfer_backend == TransferBackend.ASCEND:
from sglang.srt.disaggregation.ascend import (
AscendKVBootstrapServer,
AscendKVManager,
AscendKVReceiver,
AscendKVSender,
)
from sglang.srt.disaggregation.base import KVArgs
class_mapping = {
KVClassType.KVARGS: KVArgs,
KVClassType.MANAGER: AscendKVManager,
KVClassType.SENDER: AscendKVSender,
KVClassType.RECEIVER: (AscendKVReceiver),
KVClassType.BOOTSTRAP_SERVER: AscendKVBootstrapServer,
}
return class_mapping.get(class_type)
elif transfer_backend == TransferBackend.NIXL:
from sglang.srt.disaggregation.base import KVArgs
from sglang.srt.disaggregation.nixl import (
NixlKVBootstrapServer,
NixlKVManager,
NixlKVReceiver,
NixlKVSender,
)
class_mapping = {
KVClassType.KVARGS: KVArgs,
KVClassType.MANAGER: NixlKVManager,
KVClassType.SENDER: NixlKVSender,
KVClassType.RECEIVER: (NixlKVReceiver),
KVClassType.BOOTSTRAP_SERVER: NixlKVBootstrapServer,
}
return class_mapping.get(class_type)
elif transfer_backend == TransferBackend.FAKE:
from sglang.srt.disaggregation.base import KVArgs
from sglang.srt.disaggregation.fake import (
FakeKVManager,
FakeKVReceiver,
FakeKVSender,
)
class_mapping = {
KVClassType.KVARGS: KVArgs,
KVClassType.MANAGER: FakeKVManager,
KVClassType.SENDER: FakeKVSender,
KVClassType.RECEIVER: (FakeKVReceiver),
}
return class_mapping.get(class_type)
raise ValueError(f"Unsupported transfer backend: {transfer_backend}")
def _get_cp_rank_page_bounds(
total_pages: int, cp_rank: int, cp_size: int
) -> Tuple[int, int]:
base = total_pages // cp_size
rem = total_pages % cp_size
local_start = cp_rank * base + min(cp_rank, rem)
n_pages = base + (1 if cp_rank < rem else 0)
return local_start, local_start + n_pages
def page_indices_to_cp_rank_page_indices(
page_indices: np.ndarray,
total_pages: int,
cp_rank: int,
cp_size: int,
) -> np.ndarray:
"""
Filter page_indices (which are *global* page ids in the KV pool) to those
belonging to the given CP rank for this request.
For a single request, its pages occupy a contiguous global range
[first_page, first_page + total_pages). We first compute the local
split [0, total_pages) across cp_size ranks, then shift that local
range by first_page back into the global page id space and take
the intersection with page_indices.
Returns:
Subset of page_indices that fall in this rank's global
[start_page, end_page) slice for the given CP rank.
"""
if cp_size <= 1:
return page_indices
if page_indices.size == 0:
return np.asarray(page_indices)
first_page = int(page_indices.min())
base = total_pages // cp_size
rem = total_pages % cp_size
if rem == 0:
local_start = cp_rank * base
local_end = local_start + base
else:
local_start = cp_rank * base + min(cp_rank, rem)
n_pages = base + (1 if cp_rank < rem else 0)
local_end = local_start + n_pages
# Map back to global page ids.
start_page = first_page + local_start
end_page = first_page + local_end
mask = (page_indices >= start_page) & (page_indices < end_page)
return np.asarray(page_indices)[mask]
def filter_kv_indices_for_cp_rank(
kv_mgr: CommonKVManager,
kv_indices: np.ndarray,
index_slice: slice,
total_pages: Optional[int] = None,
) -> Tuple[np.ndarray, slice]:
"""Filters kv_indices and index_slice for the current CP rank."""
if total_pages is None:
total_pages = len(kv_indices)
cp_rank = kv_mgr.attn_cp_rank
cp_size = kv_mgr.attn_cp_size
if cp_size <= 1:
return kv_indices, index_slice
rank_start, rank_end = _get_cp_rank_page_bounds(total_pages, cp_rank, cp_size)
chunk_start = index_slice.start if index_slice.start is not None else 0
chunk_end = index_slice.stop if index_slice.stop is not None else total_pages
first_pos = max(rank_start, chunk_start) - chunk_start
last_pos = min(rank_end, chunk_end) - chunk_start
if last_pos <= first_pos:
new_kv_indices = kv_indices[:0]
new_index_slice = slice(chunk_start, chunk_start)
else:
new_kv_indices = kv_indices[first_pos:last_pos]
new_index_slice = slice(
chunk_start + first_pos,
chunk_start + last_pos,
)
return new_kv_indices, new_index_slice
#########################
# Misc
#########################
def is_mla_backend(target_kv_pool) -> bool:
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.memory_pool import MLATokenToKVPool
return isinstance(target_kv_pool, (MLATokenToKVPool, DeepSeekV4TokenToKVPool))
def append_state_component(
kv_args: KVArgs,
state_type: StateType,
data_ptrs: List[int],
data_lens: List[int],
item_lens: List[int],
dim_per_tensor: Optional[List[int]] = None,
) -> None:
"""Append one state component. Caller orders state_types consistently
on prefill and decode sides."""
kv_args.state_types.append(state_type)
kv_args.state_data_ptrs.append(data_ptrs)
kv_args.state_data_lens.append(data_lens)
kv_args.state_item_lens.append(item_lens)
kv_args.state_dim_per_tensor.append(dim_per_tensor or [])
def setup_state_kv_args(
kv_args: KVArgs,
token_to_kv_pool,
draft_token_to_kv_pool=None,
total_kv_layers: int = None,
req_to_token_pool=None,
) -> None:
"""Populate ``kv_args`` state-buffer fields from the given pool.
Shared by prefill and decode bootstrap paths so the state_type dispatch
lives in one place.
"""
from sglang.srt.disaggregation.base.conn import StateType
from sglang.srt.hardware_backend.npu.memory_pool_npu import NPUMLATokenToKVPool
from sglang.srt.mem_cache.base_swa_memory_pool import BaseSWAKVPool
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.memory_pool import (
DSATokenToKVPool,
HybridLinearKVPool,
MiniMaxSparseKVPool,
)
kv_args.state_types = []
kv_args.state_data_ptrs = []
kv_args.state_data_lens = []
kv_args.state_item_lens = []
kv_args.state_dim_per_tensor = []
kv_args.is_hybrid_mla_backend = False
if isinstance(token_to_kv_pool, MiniMaxSparseKVPool):
if token_to_kv_pool.index_kv_pool is not None:
raise NotImplementedError(
"PD disaggregation for MiniMax sparse layers with index value "
"(index_kv_pool) is not yet supported; only K-only sparse layers are."
)
if token_to_kv_pool.index_k_pool is not None:
dp, dl, il = token_to_kv_pool.get_index_k_state_buf_infos()
append_state_component(kv_args, StateType.MINIMAX_INDEX_K, dp, dl, il)
elif hasattr(token_to_kv_pool, "get_state_buf_infos"):
data_ptrs, data_lens, item_lens = token_to_kv_pool.get_state_buf_infos()
# DeepSeekV4TokenToKVPool inherits BaseSWAKVPool; its heterogeneous
# state list is described per-entry via get_state_buf_infos.
if isinstance(token_to_kv_pool, BaseSWAKVPool):
append_state_component(
kv_args, StateType.SWA, data_ptrs, data_lens, item_lens
)
# unified_kv: the SWA ring lives in the unified buffers (no separate
# swa_kv_pool) and is addressed per-row, so ship it as SWA_RING.
if getattr(token_to_kv_pool, "_unified_kv", False) and hasattr(
token_to_kv_pool, "get_unified_swa_ring_buf_infos"
):
ring_ptrs, ring_lens, ring_item_lens = (
token_to_kv_pool.get_unified_swa_ring_buf_infos()
)
if ring_ptrs:
append_state_component(
kv_args,
StateType.SWA_RING,
ring_ptrs,
ring_lens,
ring_item_lens,
)
if hasattr(token_to_kv_pool, "get_c128_state_buf_infos"):
c128_ptrs, c128_lens, c128_item_lens = (
token_to_kv_pool.get_c128_state_buf_infos()
)
if c128_ptrs:
append_state_component(
kv_args,
StateType.C128_STATE,
c128_ptrs,
c128_lens,
c128_item_lens,
)
elif isinstance(token_to_kv_pool, HybridLinearKVPool):
dim = (
token_to_kv_pool.get_state_dim_per_tensor()
if hasattr(token_to_kv_pool, "get_state_dim_per_tensor")
else None
)
kv_args.is_hybrid_mla_backend = is_mla_backend(
token_to_kv_pool.full_kv_pool
)
append_state_component(
kv_args, StateType.MAMBA, data_ptrs, data_lens, item_lens, dim
)
elif isinstance(token_to_kv_pool, (DSATokenToKVPool, NPUMLATokenToKVPool)):
if draft_token_to_kv_pool is not None and isinstance(
draft_token_to_kv_pool, DSATokenToKVPool
):
(
draft_data_ptrs,
draft_data_lens,
draft_item_lens,
) = draft_token_to_kv_pool.get_state_buf_infos()
data_ptrs = data_ptrs + draft_data_ptrs
data_lens = data_lens + draft_data_lens
item_lens = item_lens + draft_item_lens
if isinstance(token_to_kv_pool, NPUMLATokenToKVPool):
kv_args.kv_buf_groups = (
len(kv_args.kv_data_ptrs) // token_to_kv_pool.layer_num
)
kv_args.total_kv_layers = total_kv_layers
else:
append_state_component(
kv_args, StateType.DSA, data_ptrs, data_lens, item_lens
)
# DSV4 NextN shares the target allocator, so target and draft use the same
# local SWA indices. Keep draft buffers in a separate positional component
# to avoid mixing them into the target's heterogeneous state layout, while
# reusing the existing SWA transport dispatch. NPU has a different paged
# state layout and is intentionally left unchanged.
if (
not is_npu()
and isinstance(token_to_kv_pool, DeepSeekV4TokenToKVPool)
and isinstance(draft_token_to_kv_pool, DeepSeekV4TokenToKVPool)
):
if not draft_token_to_kv_pool.compression_ratios or not all(
ratio == 0 for ratio in draft_token_to_kv_pool.compression_ratios
):
raise RuntimeError(
"DSV4 draft state transfer expects SWA-only NextN layers"
)
if token_to_kv_pool._unified_kv != draft_token_to_kv_pool._unified_kv:
raise RuntimeError(
"DSV4 target and draft pools must use the same unified-KV mode"
)
if token_to_kv_pool._unified_kv:
target_geometry = (
token_to_kv_pool.unified_swa_window,
token_to_kv_pool.unified_swa_ring_size,
token_to_kv_pool.unified_swa_pages,
)
draft_geometry = (
draft_token_to_kv_pool.unified_swa_window,
draft_token_to_kv_pool.unified_swa_ring_size,
draft_token_to_kv_pool.unified_swa_pages,
)
if target_geometry != draft_geometry:
raise RuntimeError(
"DSV4 target and draft pools must share SWA ring geometry: "
f"target={target_geometry}, draft={draft_geometry}"
)
draft_ptrs, draft_lens, draft_item_lens = (
draft_token_to_kv_pool.get_unified_swa_ring_buf_infos()
)
draft_state_type = StateType.SWA_RING
else:
if (
token_to_kv_pool.full_to_swa_index_mapping
is not draft_token_to_kv_pool.full_to_swa_index_mapping
):
raise RuntimeError(
"DSV4 target and draft pools must share the SWA index mapping"
)
target_geometry = (
token_to_kv_pool.page_size,
token_to_kv_pool.sliding_window,
)
draft_geometry = (
draft_token_to_kv_pool.page_size,
draft_token_to_kv_pool.sliding_window,
)
if target_geometry != draft_geometry:
raise RuntimeError(
"DSV4 target and draft pools must share paged SWA geometry: "
f"target={target_geometry}, draft={draft_geometry}"
)
draft_ptrs, draft_lens, draft_item_lens = (
draft_token_to_kv_pool.get_state_buf_infos()
)
draft_state_type = StateType.SWA
if draft_ptrs:
append_state_component(
kv_args,
draft_state_type,
draft_ptrs,
draft_lens,
draft_item_lens,
)
if (
StateType.MAMBA not in kv_args.state_types
and req_to_token_pool is not None
and hasattr(req_to_token_pool, "get_state_buf_infos")
):
data_ptrs, data_lens, item_lens = req_to_token_pool.get_state_buf_infos()
if data_ptrs:
dim = (
req_to_token_pool.get_state_dim_per_tensor()
if hasattr(req_to_token_pool, "get_state_dim_per_tensor")
else None
)
append_state_component(
kv_args, StateType.MAMBA, data_ptrs, data_lens, item_lens, dim
)
def prepare_abort(req: Req, error_message: str, status_code=None):
from sglang.srt.managers.schedule_batch import FINISH_ABORT
# populate finish metadata and stream output
req.finished_reason = FINISH_ABORT(error_message, status_code)
if req.return_logprob:
req.logprob.input_token_logprobs_val = []
req.logprob.input_token_logprobs_idx = []
req.logprob.input_top_logprobs_val = []
req.logprob.input_top_logprobs_idx = []
req.logprob.input_token_ids_logprobs_val = []
req.logprob.input_token_ids_logprobs_idx = []
def is_aborted(req: Req) -> bool:
from sglang.srt.managers.schedule_batch import FINISH_ABORT
return isinstance(req.to_finish, FINISH_ABORT) or isinstance(
req.finished_reason, FINISH_ABORT
)