[Qwen3.8-Next] Add PD state transfer for Flash Next (#36651)

This commit is contained in:
YAMY
2026-09-11 22:31:45 -07:00
committed by GitHub
parent dbd4302bbb
commit 55e5e21c88
18 changed files with 734 additions and 98 deletions
@@ -28,8 +28,16 @@ def _qwen4_exp_overrides(server_args: Any, hf_config: Any) -> dict:
which MambaRadixCache allows only with mamba extra-buffer or --disable-radix-cache. which MambaRadixCache allows only with mamba extra-buffer or --disable-radix-cache.
""" """
cfg = resolving_view(server_args) cfg = resolving_view(server_args)
if cfg.disaggregation_mode != "null": if (
raise ValueError("Qwen4-Exp does not support PD disaggregation yet") cfg.disaggregation_mode != "null"
and cfg.disaggregation_transfer_backend == "mori"
and cfg.pp_size > 1
):
raise ValueError(
"Qwen4-Exp PD with MORI requires --pp-size 1; MORI does not yet "
"exchange the global QSA layer metadata needed to pair compact "
"state descriptors across pipeline stages."
)
if cfg.enable_unified_memory: if cfg.enable_unified_memory:
raise ValueError("Qwen4-Exp does not support --enable-unified-memory yet") raise ValueError("Qwen4-Exp does not support --enable-unified-memory yet")
overrides: Dict[str, Any] = {} overrides: Dict[str, Any] = {}
@@ -16,6 +16,8 @@ if TYPE_CHECKING:
class StateType(str, enum.Enum): class StateType(str, enum.Enum):
MAMBA = "mamba" MAMBA = "mamba"
QSA_PENDING = "qsa_pending"
QSA_COMPRESSED = "qsa_compressed"
SWA = "swa" SWA = "swa"
DSA = "dsa" DSA = "dsa"
# DSA kpool-compress tail: one per-request ring row. The indices encode # DSA kpool-compress tail: one per-request ring row. The indices encode
@@ -57,6 +57,7 @@ from sglang.srt.disaggregation.utils import (
get_dsa_tail_state_indices, get_dsa_tail_state_indices,
get_dsv4_c128_state_indices, get_dsv4_c128_state_indices,
get_kv_class, get_kv_class,
get_qsa_pending_state_indices,
is_dsv4_c128_online_enabled, is_dsv4_c128_online_enabled,
is_mla_backend, is_mla_backend,
poll_and_all_reduce, poll_and_all_reduce,
@@ -251,6 +252,10 @@ class HybridMambaDecodeReqToTokenPool(HybridReqToTokenPool):
linear_replayssm_cache_len: int = 16, linear_replayssm_cache_len: int = 16,
mamba_envelope_layout: bool = False, mamba_envelope_layout: bool = False,
enable_linear_replayssm_spec: bool = False, enable_linear_replayssm_spec: bool = False,
short_conv_layer_ids: Optional[List[int]] = None,
short_conv_state_shape: Optional[Tuple[int, int]] = None,
ngram_context_len: int = 0,
ngram_eos_token_id: int = 0,
): ):
DecodeReqToTokenPool.__init__( DecodeReqToTokenPool.__init__(
self, self,
@@ -298,6 +303,10 @@ class HybridMambaDecodeReqToTokenPool(HybridReqToTokenPool):
linear_replayssm_cache_len=linear_replayssm_cache_len, linear_replayssm_cache_len=linear_replayssm_cache_len,
mamba_envelope_layout=mamba_envelope_layout, mamba_envelope_layout=mamba_envelope_layout,
enable_linear_replayssm_spec=enable_linear_replayssm_spec, enable_linear_replayssm_spec=enable_linear_replayssm_spec,
short_conv_layer_ids=short_conv_layer_ids,
short_conv_state_shape=short_conv_state_shape,
ngram_context_len=ngram_context_len,
ngram_eos_token_id=ngram_eos_token_id,
) )
def clear(self): def clear(self):
@@ -1428,6 +1437,11 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
seq_len, seq_len,
) )
def _qsa_pending_payload():
# Match the prefill request-pool row positionally; the two
# req_pool_idx values need not be equal.
return get_qsa_pending_state_indices(decode_req.req)
def _swa_ring_payload(): def _swa_ring_payload():
# Mirror of prefill _swa_ring_payload using this side's req_pool_idx. # Mirror of prefill _swa_ring_payload using this side's req_pool_idx.
# Same window positions and order -> positional match with prefill. # Same window positions and order -> positional match with prefill.
@@ -1458,6 +1472,8 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
clear_c128_state(int(decode_req.req.kv.req_pool_idx)) clear_c128_state(int(decode_req.req.kv.req_pool_idx))
payloads = { payloads = {
StateType.MAMBA: _mamba_payload, StateType.MAMBA: _mamba_payload,
StateType.QSA_PENDING: _qsa_pending_payload,
StateType.QSA_COMPRESSED: _full_kv_pages_payload,
StateType.SWA: _swa_payload, StateType.SWA: _swa_payload,
StateType.DSA: _full_kv_pages_payload, StateType.DSA: _full_kv_pages_payload,
StateType.DSA_TAIL: _dsa_tail_payload, StateType.DSA_TAIL: _dsa_tail_payload,
@@ -51,6 +51,7 @@ from sglang.srt.disaggregation.utils import (
build_transfer_entry_pairs, build_transfer_entry_pairs,
compute_mamba_state_slice_byte_blocks, compute_mamba_state_slice_byte_blocks,
resolve_dcp_dst_entry_indices, resolve_dcp_dst_entry_indices,
should_send_replicated_state,
slice_dsa_tail_dst_ptrs_for_pp, slice_dsa_tail_dst_ptrs_for_pp,
) )
from sglang.srt.distributed.parallel_state import get_mooncake_transfer_engine from sglang.srt.distributed.parallel_state import get_mooncake_transfer_engine
@@ -1361,6 +1362,8 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
return st in ( return st in (
StateType.SWA, StateType.SWA,
StateType.DSA, StateType.DSA,
StateType.QSA_PENDING,
StateType.QSA_COMPRESSED,
StateType.SWA_RING, StateType.SWA_RING,
StateType.DSV4_REQUEST_STATE, StateType.DSV4_REQUEST_STATE,
StateType.BLOCK_SCALE, StateType.BLOCK_SCALE,
@@ -1369,7 +1372,12 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
def _requires_exact_state_index_match(self, st: StateType) -> bool: def _requires_exact_state_index_match(self, st: StateType) -> bool:
"""State types whose page lists are positional and must not be truncated.""" """State types whose page lists are positional and must not be truncated."""
return st in (StateType.SWA_RING, StateType.DSV4_REQUEST_STATE) return st in (
StateType.QSA_PENDING,
StateType.QSA_COMPRESSED,
StateType.SWA_RING,
StateType.DSV4_REQUEST_STATE,
)
def maybe_send_extra( def maybe_send_extra(
self, self,
@@ -1501,16 +1509,60 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
or rc or rc
) )
elif self._is_generic_kvcache_state_type(st): elif self._is_generic_kvcache_state_type(st):
if ( is_qwen4_qsa_state = st in (
StateType.QSA_PENDING,
StateType.QSA_COMPRESSED,
)
has_heterogeneous_attn_tp = (
target_rank_registration_info is not None target_rank_registration_info is not None
and not self.is_mla_backend
and not self.is_hybrid_mla_backend
and self.attn_tp_size and self.attn_tp_size
!= target_rank_registration_info.dst_attn_tp_size != target_rank_registration_info.dst_attn_tp_size
)
if (
has_heterogeneous_attn_tp
and not self.is_mla_backend
and not self.is_hybrid_mla_backend
and not is_qwen4_qsa_state
): ):
raise RuntimeError( raise RuntimeError(
f"PD Disaggregation does NOT support PD different TP sizes for non-MLA {st.upper()} hybrid models yet." f"PD Disaggregation does NOT support PD different TP sizes for non-MLA {st.upper()} hybrid models yet."
) )
if has_heterogeneous_attn_tp and is_qwen4_qsa_state:
if len(dst_item_lens) != len(dst_data_ptrs):
raise RuntimeError(
f"Replicated {st.upper()} destination pointer/item-length "
"metadata is inconsistent: "
f"dst ptrs={len(dst_data_ptrs)} lens={len(dst_item_lens)}"
)
qsa_entry_pairs = build_transfer_entry_pairs(
src_state_layer_ids,
dst_state_layer_ids,
len(src_data_ptrs),
len(dst_data_ptrs),
allow_positional_fallback=self.pp_size == 1,
)
layout_mismatches = [
(i, j, src_item_lens[i], dst_item_lens[j])
for i, j in qsa_entry_pairs
if src_item_lens[i] != dst_item_lens[j]
]
if layout_mismatches:
raise RuntimeError(
f"Replicated {st.upper()} layout differs between mapped "
"prefill and decode entries: "
f"{layout_mismatches}"
)
local_tp_rank_in_group = (
self.kv_args.engine_rank % self.attn_tp_size
)
if not should_send_replicated_state(
src_attn_tp_size=self.attn_tp_size,
dst_attn_tp_size=(
target_rank_registration_info.dst_attn_tp_size
),
local_tp_rank_in_group=local_tp_rank_in_group,
):
continue
src_indices = list(indices) src_indices = list(indices)
dst_indices_local = list(dst_indices) dst_indices_local = list(dst_indices)
if ( if (
@@ -1546,6 +1598,10 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
dst_data_indices=np.array(dst_indices_local, dtype=np.int32), dst_data_indices=np.array(dst_indices_local, dtype=np.int32),
executor=executor, executor=executor,
state_type=st, state_type=st,
force_flat=st
in (StateType.QSA_PENDING, StateType.QSA_COMPRESSED),
src_layer_ids=src_state_layer_ids,
dst_layer_ids=dst_state_layer_ids,
) )
or rc or rc
) )
@@ -1685,8 +1741,8 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
compute_mamba_state_slice_byte_blocks). compute_mamba_state_slice_byte_blocks).
""" """
logger.warning_once( logger.warning_once(
"Using Mamba state slice transfer for different TP sizes between prefill and decode. " "Using Mamba state slice transfer for different runtime attention TP "
f"Prefill attn_tp_size={self.attn_tp_size}, Decode attn_tp_size={dst_attn_tp_size}. " f"sizes: prefill={self.attn_tp_size}, decode={dst_attn_tp_size}. "
"Performance may be affected." "Performance may be affected."
) )
assert len(prefill_mamba_index) == 1, "Mamba should have single state index" assert len(prefill_mamba_index) == 1, "Mamba should have single state index"
+28 -5
View File
@@ -1171,6 +1171,11 @@ class MoriKVManager(CommonKVManager):
) )
if st == "mamba": if st == "mamba":
if peer_info.decode_tp_size != self.attn_tp_size and 0 in src_dims:
raise RuntimeError(
"Replicated Mamba PD state transfer currently requires "
"matching prefill/decode attention TP sizes"
)
statuses.extend( statuses.extend(
self._send_mamba_state( self._send_mamba_state(
peer_info, peer_info,
@@ -1184,7 +1189,15 @@ class MoriKVManager(CommonKVManager):
dst_dims, dst_dims,
) )
) )
elif st in ("swa", "dsa", "swa_ring", "c128_state", "minimax_index_k"): elif st in (
"swa",
"dsa",
"qsa_pending",
"qsa_compressed",
"swa_ring",
"c128_state",
"minimax_index_k",
):
statuses.extend( statuses.extend(
self._send_swa_dsa_state( self._send_swa_dsa_state(
peer_info, peer_info,
@@ -1311,14 +1324,19 @@ class MoriKVManager(CommonKVManager):
f"PD state transfer does not support TP-mismatched non-MLA SWA models " f"PD state transfer does not support TP-mismatched non-MLA SWA models "
f"(prefill_tp_size={self.attn_tp_size}, decode_tp_size={peer_info.decode_tp_size})" f"(prefill_tp_size={self.attn_tp_size}, decode_tp_size={peer_info.decode_tp_size})"
) )
if state_type == "minimax_index_k": if state_type in ("qsa_pending", "qsa_compressed", "minimax_index_k"):
if self.pp_size is not None and self.pp_size > 1: if self.pp_size is not None and self.pp_size > 1:
# MORI registration does not exchange state_layer_ids. Compact
# sparse-state lists therefore cannot be paired safely across
# pipeline stages until that metadata is added to its protocol.
raise RuntimeError( raise RuntimeError(
"PD disagg: PP>1 not supported for MiniMax sparse index yet." f"MORI PD disaggregation requires PP=1 for {state_type}; "
"PP>1 needs peer state_layer_ids for global-layer descriptor "
"pairing."
) )
if peer_info.decode_tp_size != self.attn_tp_size: if peer_info.decode_tp_size != self.attn_tp_size:
raise RuntimeError( raise RuntimeError(
"PD disagg: heterogeneous TP not supported for MiniMax sparse index yet." f"PD disagg: heterogeneous TP not supported for {state_type} yet."
) )
common_len = min(src_state_indices.size, dst_state_indices.size) common_len = min(src_state_indices.size, dst_state_indices.size)
@@ -1337,7 +1355,12 @@ class MoriKVManager(CommonKVManager):
# These components are position- or request-indexed: truncating # These components are position- or request-indexed: truncating
# silently misaligns rows and corrupts KV. Paged swa/dsa tolerate # silently misaligns rows and corrupts KV. Paged swa/dsa tolerate
# a 1-page drift -> keep truncation. # a 1-page drift -> keep truncation.
if state_type in ("swa_ring", "c128_state"): if state_type in (
"qsa_pending",
"qsa_compressed",
"swa_ring",
"c128_state",
):
raise RuntimeError( raise RuntimeError(
f"{state_type.upper()} state index length mismatch: " f"{state_type.upper()} state index length mismatch: "
f"src={src_state_indices.size}, dst={dst_state_indices.size}" f"src={src_state_indices.size}, dst={dst_state_indices.size}"
+51 -10
View File
@@ -1574,6 +1574,9 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
dst_mem_kind: str = "VRAM", dst_mem_kind: str = "VRAM",
force_flat: bool = False, force_flat: bool = False,
bypass_prepped: bool = False, bypass_prepped: bool = False,
src_layer_ids: Optional[List[int]] = None,
dst_layer_ids: Optional[List[int]] = None,
dst_item_lens: Optional[List[int]] = None,
): ):
"""Generic KV cache transfer supporting both MHA and MLA architectures. """Generic KV cache transfer supporting both MHA and MLA architectures.
Used by both send_kvcache and maybe_send_extra. Used by both send_kvcache and maybe_send_extra.
@@ -1635,17 +1638,41 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
logger.debug(f"sending kvcache to {peer_name} with notif {notif}") logger.debug(f"sending kvcache to {peer_name} with notif {notif}")
# Make descs # Make descs
if self.is_mla_backend or force_flat: if self.is_mla_backend or force_flat:
src_kv_ptrs, dst_kv_ptrs, layers_current_pp_stage = ( if src_layer_ids or dst_layer_ids:
self.get_mla_kv_ptrs_with_pp(src_data_ptrs, dst_data_ptrs, state_type) pairs = build_transfer_entry_pairs(
) src_layer_ids or [],
layers_params = [ dst_layer_ids or [],
( len(src_data_ptrs),
src_kv_ptrs[layer_id], len(dst_data_ptrs),
dst_kv_ptrs[layer_id], allow_positional_fallback=self.pp_size == 1,
item_lens[layer_id],
) )
for layer_id in range(layers_current_pp_stage) # The source item length is used as the destination stride, so
] # the paired entries must have identical layouts.
if dst_item_lens is not None:
for i, j in pairs:
if item_lens[i] != dst_item_lens[j]:
raise RuntimeError(
f"{state_type} item length mismatch for paired "
f"entries src[{i}]={item_lens[i]} "
f"dst[{j}]={dst_item_lens[j]}"
)
layers_params = [
(src_data_ptrs[i], dst_data_ptrs[j], item_lens[i]) for i, j in pairs
]
else:
src_kv_ptrs, dst_kv_ptrs, layers_current_pp_stage = (
self.get_mla_kv_ptrs_with_pp(
src_data_ptrs, dst_data_ptrs, state_type
)
)
layers_params = [
(
src_kv_ptrs[layer_id],
dst_kv_ptrs[layer_id],
item_lens[layer_id],
)
for layer_id in range(layers_current_pp_stage)
]
else: else:
src_k_ptrs, src_v_ptrs, dst_k_ptrs, dst_v_ptrs, layers_current_pp_stage = ( src_k_ptrs, src_v_ptrs, dst_k_ptrs, dst_v_ptrs, layers_current_pp_stage = (
self.get_mha_kv_ptrs_with_pp(src_data_ptrs, dst_data_ptrs) self.get_mha_kv_ptrs_with_pp(src_data_ptrs, dst_data_ptrs)
@@ -1667,6 +1694,9 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
for layer_id in range(layers_current_pp_stage) for layer_id in range(layers_current_pp_stage)
] ]
if not layers_params:
return None
src_addrs = [] src_addrs = []
src_lens = [] src_lens = []
dst_addrs = [] dst_addrs = []
@@ -2507,6 +2537,11 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
if st == StateType.MAMBA: if st == StateType.MAMBA:
if self.attn_tp_size != decode_tp_size: if self.attn_tp_size != decode_tp_size:
if 0 in src_dims:
raise RuntimeError(
"Replicated Mamba PD state transfer currently requires "
"matching prefill/decode attention TP sizes"
)
h = self._send_mamba_state_slice( h = self._send_mamba_state_slice(
peer_name, peer_name,
src_indices, src_indices,
@@ -2571,6 +2606,8 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
) )
elif st in ( elif st in (
StateType.SWA, StateType.SWA,
StateType.QSA_PENDING,
StateType.QSA_COMPRESSED,
StateType.SWA_RING, StateType.SWA_RING,
StateType.DSV4_REQUEST_STATE, StateType.DSV4_REQUEST_STATE,
): ):
@@ -2599,6 +2636,10 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
dst_gpu_id=dst_gpu_id, dst_gpu_id=dst_gpu_id,
notif=comp_notif, notif=comp_notif,
state_type=st, state_type=st,
force_flat=st in (StateType.QSA_PENDING, StateType.QSA_COMPRESSED),
src_layer_ids=src_lids,
dst_layer_ids=dst_lids,
dst_item_lens=dst_lens,
) )
elif st == StateType.MINIMAX_INDEX_K: elif st == StateType.MINIMAX_INDEX_K:
# Equal-TP / PP=1 only. Sub-pools are compacted sparse-layer # Equal-TP / PP=1 only. Sub-pools are compacted sparse-layer
@@ -48,6 +48,7 @@ from sglang.srt.disaggregation.utils import (
get_dsa_tail_state_indices, get_dsa_tail_state_indices,
get_dsv4_c128_state_indices, get_dsv4_c128_state_indices,
get_kv_class, get_kv_class,
get_qsa_pending_state_indices,
is_aborted, is_aborted,
is_dsv4_c128_online_enabled, is_dsv4_c128_online_enabled,
is_mla_backend, is_mla_backend,
@@ -1346,6 +1347,11 @@ class SchedulerDisaggregationPrefillMixin:
seq_len, seq_len,
) )
def _qsa_pending_payload():
# Raw index-K/RoPE state is one full compression-group ring per
# request, addressed by the request-pool slot rather than KV pages.
return get_qsa_pending_state_indices(req)
def _swa_ring_payload(): def _swa_ring_payload():
# Unified_kv SWA ring rows (req_pool_idx*ring_stride + pos%ring_stride) # Unified_kv SWA ring rows (req_pool_idx*ring_stride + pos%ring_stride)
# for the last `window` positions, in ascending position order so # for the last `window` positions, in ascending position order so
@@ -1380,6 +1386,8 @@ class SchedulerDisaggregationPrefillMixin:
) )
payloads = { payloads = {
StateType.MAMBA: _mamba_payload, StateType.MAMBA: _mamba_payload,
StateType.QSA_PENDING: _qsa_pending_payload,
StateType.QSA_COMPRESSED: _full_kv_pages_payload,
StateType.SWA: _swa_payload, StateType.SWA: _swa_payload,
StateType.DSA: _full_kv_pages_payload, StateType.DSA: _full_kv_pages_payload,
StateType.DSA_TAIL: _dsa_tail_payload, StateType.DSA_TAIL: _dsa_tail_payload,
+88 -1
View File
@@ -122,6 +122,14 @@ def get_dsv4_c128_state_indices(
return np.array([page], dtype=np.int32) return np.array([page], dtype=np.int32)
def get_qsa_pending_state_indices(req: Req) -> np.ndarray:
"""Return the request-pool row that owns a QSA pending-state ring."""
req_pool_idx = req.kv.req_pool_idx
if req_pool_idx is None:
raise ValueError("QSA pending-state transfer requires an allocated request row")
return np.array([int(req_pool_idx)], dtype=np.int32)
class DisaggregationMode(Enum): class DisaggregationMode(Enum):
NULL = "null" NULL = "null"
PREFILL = "prefill" PREFILL = "prefill"
@@ -762,6 +770,41 @@ def is_mla_backend(target_kv_pool) -> bool:
return isinstance(target_kv_pool, (MLATokenToKVPool, DeepSeekV4TokenToKVPool)) return isinstance(target_kv_pool, (MLATokenToKVPool, DeepSeekV4TokenToKVPool))
def should_send_replicated_state(
*,
src_attn_tp_size: int,
dst_attn_tp_size: int,
local_tp_rank_in_group: int,
) -> bool:
"""Elect writers for state replicated within an attention-TP group.
Scatter (one source rank to several destination ranks) is a broadcast, so
the source sends to every destination registration. Aggregation has several
equivalent source copies targeting one destination; only the first source
in each aggregation group writes it.
"""
if src_attn_tp_size <= 0 or dst_attn_tp_size <= 0:
raise ValueError(
"Attention TP sizes must be positive for replicated-state transfer"
)
larger_tp_size = max(src_attn_tp_size, dst_attn_tp_size)
smaller_tp_size = min(src_attn_tp_size, dst_attn_tp_size)
if larger_tp_size % smaller_tp_size != 0:
raise ValueError(
"One attention TP size must divide the other for replicated-state "
f"transfer: src={src_attn_tp_size}, dst={dst_attn_tp_size}"
)
if not 0 <= local_tp_rank_in_group < src_attn_tp_size:
raise ValueError(
"Source attention TP rank is out of range for replicated-state "
f"transfer: rank={local_tp_rank_in_group}, size={src_attn_tp_size}"
)
if src_attn_tp_size <= dst_attn_tp_size:
return True
writers_per_decode = src_attn_tp_size // dst_attn_tp_size
return local_tp_rank_in_group % writers_per_decode == 0
def compute_mamba_state_slice_blocks( def compute_mamba_state_slice_blocks(
src_dim: int, src_dim: int,
dst_dim: int, dst_dim: int,
@@ -851,8 +894,28 @@ def compute_mamba_state_slice_byte_blocks(
``outer_count`` is one for the usual ``[slice_dim, ...]`` layout. Kimi ``outer_count`` is one for the usual ``[slice_dim, ...]`` layout. Kimi
conv state is ``[K - 1, slice_dim]``, so each logical channel slice expands conv state is ``[K - 1, slice_dim]``, so each logical channel slice expands
into one byte block per convolution row. into one byte block per convolution row. A zero src/dst dim marks an item
replicated across attention TP and copies the whole item from an elected
source rank.
""" """
if (src_dim == 0) != (dst_dim == 0):
raise ValueError(
"Mamba state replication metadata differs between prefill and decode"
)
if src_dim == 0:
if src_item_len != dst_item_len:
raise ValueError(
"Replicated Mamba state item lengths differ between prefill and "
f"decode: {src_item_len} != {dst_item_len}"
)
if not should_send_replicated_state(
src_attn_tp_size=src_attn_tp_size,
dst_attn_tp_size=dst_attn_tp_size,
local_tp_rank_in_group=local_tp_rank_in_group,
):
return []
return [(0, 0, src_item_len)]
src_bytes_per_dim = src_item_len // (src_dim * outer_count) src_bytes_per_dim = src_item_len // (src_dim * outer_count)
dst_bytes_per_dim = dst_item_len // (dst_dim * outer_count) dst_bytes_per_dim = dst_item_len // (dst_dim * outer_count)
logical_blocks = compute_mamba_state_slice_blocks( logical_blocks = compute_mamba_state_slice_blocks(
@@ -1281,6 +1344,7 @@ def setup_state_kv_args(
MHATokenToKVPoolMXFP8, MHATokenToKVPoolMXFP8,
MiniMaxSparseKVPool, MiniMaxSparseKVPool,
) )
from sglang.srt.mem_cache.qsa_kv_pool import QSATokenToKVPool
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
kv_args.state_types = [] kv_args.state_types = []
@@ -1422,6 +1486,29 @@ def setup_state_kv_args(
dsa_item_lens, dsa_item_lens,
) )
append_dsa_tail(dsa_pool) append_dsa_tail(dsa_pool)
if isinstance(token_to_kv_pool, QSATokenToKVPool):
qsa_ptrs, qsa_lens, qsa_item_lens = (
token_to_kv_pool.get_qsa_pending_state_buf_infos()
)
append_state_component(
kv_args,
StateType.QSA_PENDING,
qsa_ptrs,
qsa_lens,
qsa_item_lens,
layer_ids=token_to_kv_pool.get_qsa_pending_state_layer_ids(),
)
compressed_ptrs, compressed_lens, compressed_item_lens = (
token_to_kv_pool.get_qsa_compressed_state_buf_infos()
)
append_state_component(
kv_args,
StateType.QSA_COMPRESSED,
compressed_ptrs,
compressed_lens,
compressed_item_lens,
layer_ids=token_to_kv_pool.get_qsa_compressed_state_layer_ids(),
)
elif isinstance(token_to_kv_pool, (DSATokenToKVPool, NPUMLATokenToKVPool)): elif isinstance(token_to_kv_pool, (DSATokenToKVPool, NPUMLATokenToKVPool)):
tail_ptrs, tail_lens, tail_item_lens = [], [], [] tail_ptrs, tail_lens, tail_item_lens = [], [], []
if isinstance(token_to_kv_pool, DSATokenToKVPool): if isinstance(token_to_kv_pool, DSATokenToKVPool):
@@ -256,7 +256,16 @@ class QwenSparseAttnBackend(AttentionBackend):
) )
return max(1, int(sequence_lengths.max())) return max(1, int(sequence_lengths.max()))
spec_info = forward_batch.spec_info spec_info = forward_batch.spec_info
draft_window = int(spec_info.draft_token_num) if spec_info is not None else 0 # Target verify exposes ``draft_token_num`` while draft-extend exposes
# ``num_tokens_per_req``. Both modes use this gather-width bound.
draft_window = int(
getattr(
spec_info,
"draft_token_num",
getattr(spec_info, "num_tokens_per_req", 0),
)
or 0
)
return max(1, int(seq_lens_cpu.max()) + draft_window) return max(1, int(seq_lens_cpu.max()) + draft_window)
@staticmethod @staticmethod
@@ -1024,6 +1024,22 @@ class KVCacheConfigurator:
mamba_layer_ids.append(layer_id) mamba_layer_ids.append(layer_id)
return mamba_layer_ids return mamba_layer_ids
def _get_ple_req_pool_kwargs(self) -> dict[str, Any]:
from sglang.srt.configs.qwen4_exp import Qwen4ExpTextConfig
if not isinstance(self.mambaish_config, Qwen4ExpTextConfig):
return {}
return {
"short_conv_layer_ids": [
i
for i in self.mambaish_config.short_conv_layer_ids
if self.layer_info.start_layer <= i < self.layer_info.end_layer
],
"short_conv_state_shape": self.mambaish_config.short_conv_state_shape,
"ngram_context_len": self.mambaish_config.ngram_context_len,
"ngram_eos_token_id": int(self.mambaish_config.eos_token_id),
}
def _build_hybrid_mamba_decode_req_pool( def _build_hybrid_mamba_decode_req_pool(
self, self,
*, *,
@@ -1049,6 +1065,7 @@ class KVCacheConfigurator:
enable_overlap_schedule=not get_schedule().disable_overlap_schedule, enable_overlap_schedule=not get_schedule().disable_overlap_schedule,
mamba_size=get_schedule().max_mamba_cache_size, mamba_size=get_schedule().max_mamba_cache_size,
start_layer=self.layer_info.start_layer, start_layer=self.layer_info.start_layer,
**self._get_ple_req_pool_kwargs(),
linear_replayssm_cache_len=get_exec().mamba.linear_replayssm_cache_len, linear_replayssm_cache_len=get_exec().mamba.linear_replayssm_cache_len,
mamba_envelope_layout=get_memory().enable_page_major_kv_layout, mamba_envelope_layout=get_memory().enable_page_major_kv_layout,
# ReplaySSM spec-verify is for linear-attn models (GDN fold or KDA # ReplaySSM spec-verify is for linear-attn models (GDN fold or KDA
@@ -1106,20 +1123,6 @@ class KVCacheConfigurator:
"--enable-linear-replayssm-spec with DSPARK/DFLASH requires a KDA " "--enable-linear-replayssm-spec with DSPARK/DFLASH requires a KDA "
"(kimi_linear) model; got a non-KDA model." "(kimi_linear) model; got a non-KDA model."
) )
from sglang.srt.configs.qwen4_exp import Qwen4ExpTextConfig
ple_kwargs = {}
if isinstance(self.mambaish_config, Qwen4ExpTextConfig):
ple_kwargs = dict(
short_conv_layer_ids=[
i
for i in self.mambaish_config.short_conv_layer_ids
if self.layer_info.start_layer <= i < self.layer_info.end_layer
],
short_conv_state_shape=self.mambaish_config.short_conv_state_shape,
ngram_context_len=self.mambaish_config.ngram_context_len,
ngram_eos_token_id=int(self.mambaish_config.eos_token_id),
)
req_to_token_pool = HybridReqToTokenPool( req_to_token_pool = HybridReqToTokenPool(
size=max_num_reqs, size=max_num_reqs,
mamba_size=get_schedule().max_mamba_cache_size, mamba_size=get_schedule().max_mamba_cache_size,
@@ -1131,7 +1134,7 @@ class KVCacheConfigurator:
mamba_layer_ids=self._get_mamba_layer_ids_for_req_pool(), mamba_layer_ids=self._get_mamba_layer_ids_for_req_pool(),
enable_mamba_extra_buffer=get_exec().mamba.enable_mamba_extra_buffer, enable_mamba_extra_buffer=get_exec().mamba.enable_mamba_extra_buffer,
enable_mamba_extra_buffer_lazy=get_exec().mamba.enable_mamba_extra_buffer_lazy, enable_mamba_extra_buffer_lazy=get_exec().mamba.enable_mamba_extra_buffer_lazy,
**ple_kwargs, **self._get_ple_req_pool_kwargs(),
# A PD prefill server never runs TARGET_VERIFY, so skip the # A PD prefill server never runs TARGET_VERIFY, so skip the
# verify-only per-draft-token state snapshots (see the draft-head # verify-only per-draft-token state snapshots (see the draft-head
# case above: None => the pool skips SpeculativeState). # case above: None => the pool skips SpeculativeState).
+32 -25
View File
@@ -1113,8 +1113,8 @@ class MambaPool:
} }
) )
def _iter_transfer_state_tensors(self): def _iter_transfer_state_entries(self):
"""Yield transferable state tensors with their per-slot slice axis.""" """Yield ``[slot, ...]`` state entries and their transfer metadata."""
for field, value in vars(self.mamba_cache).items(): for field, value in vars(self.mamba_cache).items():
if field in self._NON_TRANSFER_STATE_FIELDS or value is None: if field in self._NON_TRANSFER_STATE_FIELDS or value is None:
continue continue
@@ -1125,20 +1125,20 @@ class MambaPool:
# empty. Advertising it fails the whole batch registration. # empty. Advertising it fails the whole batch registration.
if state_tensor.numel() == 0: if state_tensor.numel() == 0:
continue continue
yield field, state_tensor, slice_axis for layer_index, layer_id in enumerate(self.mamba_layer_ids):
yield field, state_tensor[layer_index], slice_axis, layer_id
for sibling in self._slot_siblings:
yield from sibling.iter_transfer_state_entries()
def get_contiguous_buf_infos(self): def get_contiguous_buf_infos(self):
"""Get transferable state buffer information for RDMA registration.""" """Get transferable state buffer information for RDMA registration."""
data_ptrs, data_lens, item_lens = [], [], [] data_ptrs, data_lens, item_lens = [], [], []
for _, state_tensor, _ in self._iter_transfer_state_tensors(): for _, state_tensor, _, _ in self._iter_transfer_state_entries():
data_ptrs += [ data_ptrs.append(state_tensor.data_ptr())
state_tensor[i].data_ptr() for i in range(self.num_mamba_layers) data_lens.append(state_tensor.nbytes)
] item_lens.append(state_tensor[0].nbytes)
data_lens += [state_tensor[i].nbytes for i in range(self.num_mamba_layers)]
item_lens += [
state_tensor[i][0].nbytes for i in range(self.num_mamba_layers)
]
return data_ptrs, data_lens, item_lens return data_ptrs, data_lens, item_lens
def get_state_dim_per_tensor(self): def get_state_dim_per_tensor(self):
@@ -1148,13 +1148,17 @@ class MambaPool:
while Kimi conv state uses the second per-slot axis. while Kimi conv state uses the second per-slot axis.
""" """
dim_per_tensor = [] dim_per_tensor = []
for _, state_tensor, slice_axis in self._iter_transfer_state_tensors(): for _, state_tensor, slice_axis, _ in self._iter_transfer_state_entries():
# state_tensor shape: [num_layers, size+1, sliceable_dim, ...] # Zero is a protocol marker for request state replicated across the
# Kimi conv state transposes the two per-slot axes to [K-1, dim]. # attention-TP group. Heterogeneous PD copies the whole item from one
axis = 2 + slice_axis # elected source rank instead of slicing it as a TP-sharded tensor.
sliceable_dim = state_tensor.shape[axis] if slice_axis is None:
# Repeat for each layer since we have per-layer data_ptrs dim_per_tensor.append(0)
dim_per_tensor += [sliceable_dim] * self.num_mamba_layers continue
# state_tensor shape: [size+1, sliceable_dim, ...]. Kimi conv state
# transposes the two per-slot axes to [K-1, dim].
axis = 1 + slice_axis
dim_per_tensor.append(state_tensor.shape[axis])
return dim_per_tensor return dim_per_tensor
def get_state_layer_ids(self): def get_state_layer_ids(self):
@@ -1164,15 +1168,18 @@ class MambaPool:
the state list tensor-major x layer. Lets PD transfer match entries the state list tensor-major x layer. Lets PD transfer match entries
by layer id when prefill (PP stage) holds a subset of the mamba layers. by layer id when prefill (PP stage) holds a subset of the mamba layers.
""" """
state_tensor_count = sum(1 for _ in self._iter_transfer_state_tensors()) return [layer_id for _, _, _, layer_id in self._iter_transfer_state_entries()]
return list(self.mamba_layer_ids) * state_tensor_count
def get_state_slice_outer_counts(self): def get_state_slice_outer_counts(self):
"""Get the number of rows preceding each tensor's TP slice axis.""" """Get the number of rows preceding each tensor's TP slice axis."""
outer_counts = [] outer_counts = []
for _, state_tensor, slice_axis in self._iter_transfer_state_tensors(): for _, state_tensor, slice_axis, _ in self._iter_transfer_state_entries():
outer_count = math.prod(state_tensor.shape[2 : 2 + slice_axis]) outer_count = (
outer_counts += [outer_count] * self.num_mamba_layers 1
if slice_axis is None
else math.prod(state_tensor.shape[1 : 1 + slice_axis])
)
outer_counts.append(outer_count)
return outer_counts return outer_counts
def get_state_conv_shard_groups(self): def get_state_conv_shard_groups(self):
@@ -1187,14 +1194,14 @@ class MambaPool:
those tensors keep the single contiguous slice. those tensors keep the single contiguous slice.
""" """
subdims_per_tensor = [] subdims_per_tensor = []
for field, _, _ in self._iter_transfer_state_tensors(): for field, _, _, _ in self._iter_transfer_state_entries():
# Only conv_state carries a q/k/v decomposition. # Only conv_state carries a q/k/v decomposition.
subdims = ( subdims = (
list(self.conv_shard_groups) list(self.conv_shard_groups)
if field == "conv" and self.conv_shard_groups is not None if field == "conv" and self.conv_shard_groups is not None
else None else None
) )
subdims_per_tensor += [subdims] * self.num_mamba_layers subdims_per_tensor.append(subdims)
return subdims_per_tensor return subdims_per_tensor
def get_kv_size_bytes(self): def get_kv_size_bytes(self):
@@ -13,6 +13,11 @@ from sglang.srt.constants import GPU_MEMORY_TYPE_KV_CACHE
from sglang.srt.mem_cache.utils import maybe_init_custom_mem_pool from sglang.srt.mem_cache.utils import maybe_init_custom_mem_pool
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
# State layer IDs are serialized as uint32 by the disaggregation protocols.
# Reserve the largest value for PLE's request-wide N-gram state, which is not
# owned by any model layer.
PLE_NGRAM_STATE_LAYER_ID = (1 << 32) - 1
class SlotIndexedState(Protocol): class SlotIndexedState(Protocol):
"""Per-request state addressed by MambaPool slot index, """Per-request state addressed by MambaPool slot index,
@@ -27,6 +32,8 @@ class SlotIndexedState(Protocol):
def load_cpu_slots(self, data: Any, indices: torch.Tensor) -> None: ... def load_cpu_slots(self, data: Any, indices: torch.Tensor) -> None: ...
def iter_transfer_state_entries(self): ...
class ShortConvPool: class ShortConvPool:
def __init__( def __init__(
@@ -119,6 +126,18 @@ class ShortConvPool:
return return
self.conv_state[:, indices] = data.to(self.conv_state.device, non_blocking=True) self.conv_state[:, indices] = data.to(self.conv_state.device, non_blocking=True)
def iter_transfer_state_entries(self):
"""Yield replicated per-layer state for PD transfer."""
if self.conv_state is None:
return
for layer_id, layer_index in self.layer_map.items():
yield (
"ple_short_conv",
self.conv_state[layer_index],
None,
layer_id,
)
class NGramPool: class NGramPool:
def __init__( def __init__(
@@ -216,3 +235,8 @@ class NGramPool:
self.context[indices.to(dtype=torch.long)] = data.to( self.context[indices.to(dtype=torch.long)] = data.to(
self.context.device, non_blocking=True self.context.device, non_blocking=True
) )
def iter_transfer_state_entries(self):
"""Yield replicated request-wide N-gram history for PD transfer."""
if self.context is not None:
yield "ple_ngram", self.context, None, PLE_NGRAM_STATE_LAYER_ID
+100 -23
View File
@@ -1,13 +1,26 @@
"""KV pools carrying the QSA sparse-attention indexer caches.""" """KV pools carrying the QSA sparse-attention indexer caches.
``QSATokenToKVPool`` (compressed, Qwen4-Exp) adds the per-request pending
index-key/RoPE ring and the paged compressed-K cache on top of the hybrid
full/linear KV pool. ``QwenDSATokenToKVPool`` (tokenwise,
Qwen3Next-DSA) adds only the flat per-token index-K cache.
"""
from __future__ import annotations from __future__ import annotations
from contextlib import nullcontext
from typing import List, Optional from typing import List, Optional
import torch import torch
from sglang.srt.constants import GPU_MEMORY_TYPE_KV_CACHE
from sglang.srt.mem_cache.memory_pool import GB, HybridLinearKVPool, MambaPool from sglang.srt.mem_cache.memory_pool import GB, HybridLinearKVPool, MambaPool
# State layer IDs are serialized as uint32 by the disaggregation protocols.
# Reserve the value below PLE's request-wide sentinel for QSA's request-wide
# RoPE ring, which is shared by all full-attention layers.
QSA_ROPE_STATE_LAYER_ID = (1 << 32) - 2
def _index_k_bytes(*, kv_heads: int, head_dim: int, dtype: torch.dtype) -> int: def _index_k_bytes(*, kv_heads: int, head_dim: int, dtype: torch.dtype) -> int:
return kv_heads * head_dim * dtype.itemsize return kv_heads * head_dim * dtype.itemsize
@@ -119,31 +132,49 @@ class QSATokenToKVPool(HybridLinearKVPool):
) )
self.qsa_num_request_slots = int(num_request_slots) self.qsa_num_request_slots = int(num_request_slots)
ring_slots = self.qsa_num_request_slots * self.qsa_compress_ratio ring_slots = self.qsa_num_request_slots * self.qsa_compress_ratio
self.qsa_key_state_buffer_pool = [ # These buffers participate in Mooncake PD transfer just like the base
torch.zeros( # KV and Mamba pools. Keep their allocation in the same memory-saver
(ring_slots, self.qsa_index_kv_heads, self.qsa_index_head_dim), # and Mooncake custom-pool regions; otherwise MNNVL cannot resolve the
# ordinary CUDA allocation when the first QSA state page is sent.
allocation_pool = self.full_kv_pool
with (
allocation_pool.memory_saver_adapter.region(GPU_MEMORY_TYPE_KV_CACHE),
(
torch.cuda.use_mem_pool(allocation_pool.custom_mem_pool)
if allocation_pool.enable_custom_mem_pool
else nullcontext()
),
):
self.qsa_key_state_buffer_pool = [
torch.zeros(
(
ring_slots,
self.qsa_index_kv_heads,
self.qsa_index_head_dim,
),
dtype=self.index_state_dtype,
device=device,
)
for _ in full_attention_layer_ids
]
# RoPE coordinates are layer-independent. Keep the exact Qwen4-Exp
# MRoPE position of every incomplete key so compression can rotate
# the pooled key with the group's real starting coordinate.
self.qsa_rope_position_buffer = torch.zeros(
(ring_slots, 3), dtype=torch.int64, device=device
)
# One contiguous allocation behind per-layer views: every layer's
# compressed pages are addressable from a single base pointer.
self.qsa_compressed_flat = torch.zeros(
(
len(full_attention_layer_ids),
self.qsa_compressed_capacity
* self.qsa_index_kv_heads
* self.qsa_index_head_dim,
),
dtype=self.index_state_dtype, dtype=self.index_state_dtype,
device=device, device=device,
) )
for _ in full_attention_layer_ids
]
# Layer-independent MRoPE coordinate of every pending key;
# the compress kernel rotates the pooled key at the group's real start position.
self.qsa_rope_position_buffer = torch.zeros(
(ring_slots, 3), dtype=torch.int64, device=device
)
# One contiguous allocation behind per-layer views: every layer's
# compressed pages are addressable from a single base pointer.
self.qsa_compressed_flat = torch.zeros(
(
len(full_attention_layer_ids),
self.qsa_compressed_capacity
* self.qsa_index_kv_heads
* self.qsa_index_head_dim,
),
dtype=self.index_state_dtype,
device=device,
)
self.qsa_compressed_k_buffer_pool = [ self.qsa_compressed_k_buffer_pool = [
self.qsa_compressed_flat[layer_offset].view( self.qsa_compressed_flat[layer_offset].view(
self.qsa_compressed_capacity, self.qsa_compressed_capacity,
@@ -192,6 +223,52 @@ class QSATokenToKVPool(HybridLinearKVPool):
buffer = self.get_qsa_compressed_k_buffer(layer_id) buffer = self.get_qsa_compressed_k_buffer(layer_id)
buffer[loc.long()] = compressed_k.to(buffer.dtype) buffer[loc.long()] = compressed_k.to(buffer.dtype)
@staticmethod
def _get_paged_state_buf_infos(tensors, page_size: int):
return (
[tensor.data_ptr() for tensor in tensors],
[tensor.nbytes for tensor in tensors],
[tensor[0].nbytes * page_size for tensor in tensors],
)
def get_qsa_pending_state_buf_infos(self):
"""Per-request pending key-state and RoPE ring transfer buffers."""
# A PP stage without a local QSA layer never writes the shared RoPE
# ring. Do not register it as a transfer source: otherwise that stage
# can race with a QSA-owning stage and overwrite valid positions with
# its zero-initialized or stale contents.
if not self.full_attention_layer_id_mapping:
return [], [], []
tensors = [*self.qsa_key_state_buffer_pool, self.qsa_rope_position_buffer]
return self._get_paged_state_buf_infos(
tensors,
self.qsa_compress_ratio,
)
def get_qsa_pending_state_layer_ids(self):
"""Global layer metadata for the compact QSA pending-state list."""
if not self.full_attention_layer_id_mapping:
return []
return [
*self.full_attention_layer_id_mapping.keys(),
QSA_ROPE_STATE_LAYER_ID,
]
def get_qsa_compressed_state_layer_ids(self):
"""Global layer metadata for the compact compressed-K list."""
return list(self.full_attention_layer_id_mapping.keys())
def get_qsa_compressed_state_buf_infos(self):
"""Per-full-page compressed-K transfer buffers.
One full KV page maps to one compressed page because the full page size
is an integer multiple of the compression ratio.
"""
return self._get_paged_state_buf_infos(
self.qsa_compressed_k_buffer_pool,
self.qsa_compressed_page_size,
)
def get_kv_size_bytes(self): def get_kv_size_bytes(self):
k_size, v_size = super().get_kv_size_bytes() k_size, v_size = super().get_kv_size_bytes()
qsa_k_size = ( qsa_k_size = (
@@ -33,15 +33,23 @@ from sglang.srt.disaggregation.mooncake.conn import (
) )
from sglang.srt.disaggregation.utils import ( from sglang.srt.disaggregation.utils import (
MetadataBuffers, MetadataBuffers,
build_transfer_entry_pairs,
compute_mamba_state_slice_byte_blocks,
get_dsv4_c4_state_indices, get_dsv4_c4_state_indices,
get_dsv4_c128_state_indices, get_dsv4_c128_state_indices,
get_qsa_pending_state_indices,
setup_state_kv_args, setup_state_kv_args,
should_send_replicated_state,
) )
from sglang.srt.environ import envs from sglang.srt.environ import envs
from sglang.srt.layers.attention.dsa.utils import should_use_dsa_fused_topk from sglang.srt.layers.attention.dsa.utils import should_use_dsa_fused_topk
from sglang.srt.managers.overlap_utils import FutureMap, RelayPayload from sglang.srt.managers.overlap_utils import FutureMap, RelayPayload
from sglang.srt.managers.schedule_batch import ReqKvInfo from sglang.srt.managers.schedule_batch import ReqKvInfo
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.qsa_kv_pool import (
QSA_ROPE_STATE_LAYER_ID,
QSATokenToKVPool,
)
from sglang.srt.runtime_context import get_context from sglang.srt.runtime_context import get_context
from sglang.srt.speculative.eagle_disaggregation import ( from sglang.srt.speculative.eagle_disaggregation import (
build_eagle_disagg_draft_input, build_eagle_disagg_draft_input,
@@ -189,6 +197,132 @@ class TestCPReplicatedStateTransfer(unittest.TestCase):
) )
class TestQwen4StateWire(unittest.TestCase):
def test_qsa_pending_payload_uses_nested_request_pool_row(self):
req = SimpleNamespace(kv=ReqKvInfo(req_pool_idx=7))
np.testing.assert_array_equal(
get_qsa_pending_state_indices(req),
np.array([7], dtype=np.int32),
)
def test_qsa_registers_request_ring_and_page_state_separately(self):
pool = object.__new__(QSATokenToKVPool)
pool.full_kv_pool = object()
pool.get_state_buf_infos = lambda: ([10], [100], [20])
pool.get_state_dim_per_tensor = lambda: [4]
pool.get_state_conv_shard_groups = lambda: [None]
pool.get_state_slice_outer_counts = lambda: [1]
pool.get_state_layer_ids = lambda: [2]
pool.page_size = 4
pool.qsa_compress_ratio = 2
pool.qsa_compressed_page_size = 2
pool.full_attention_layer_id_mapping = {24: 0}
pool.qsa_key_state_buffer_pool = [torch.zeros((6, 1, 8), dtype=torch.bfloat16)]
pool.qsa_rope_position_buffer = torch.zeros((6, 3), dtype=torch.int64)
pool.qsa_compressed_k_buffer_pool = [
torch.zeros((6, 1, 8), dtype=torch.bfloat16)
]
kv_args = SimpleNamespace()
setup_state_kv_args(kv_args, pool)
self.assertEqual(
kv_args.state_types,
[StateType.MAMBA, StateType.QSA_PENDING, StateType.QSA_COMPRESSED],
)
# Pending entries are whole two-row request rings; compressed-K remains
# a two-row compressed page corresponding to one four-token KV page.
self.assertEqual(kv_args.state_item_lens[1:], [[32, 48], [32]])
self.assertEqual(
kv_args.state_layer_ids[1:],
[[24, QSA_ROPE_STATE_LAYER_ID], [24]],
)
def test_qsa_stage_without_qsa_layers_does_not_register_rope_ring(self):
pool = object.__new__(QSATokenToKVPool)
pool.full_kv_pool = object()
pool.get_state_buf_infos = lambda: ([10], [100], [20])
pool.get_state_dim_per_tensor = lambda: [4]
pool.get_state_conv_shard_groups = lambda: [None]
pool.get_state_slice_outer_counts = lambda: [1]
pool.get_state_layer_ids = lambda: [2]
pool.page_size = 4
pool.qsa_compress_ratio = 2
pool.qsa_compressed_page_size = 2
pool.full_attention_layer_id_mapping = {}
pool.qsa_key_state_buffer_pool = []
pool.qsa_rope_position_buffer = torch.zeros((6, 3), dtype=torch.int64)
pool.qsa_compressed_k_buffer_pool = []
kv_args = SimpleNamespace()
setup_state_kv_args(kv_args, pool)
# Keep the component slots aligned across PP stages, but expose no QSA
# buffers or layer ids from a stage that cannot produce their contents.
self.assertEqual(
kv_args.state_types,
[StateType.MAMBA, StateType.QSA_PENDING, StateType.QSA_COMPRESSED],
)
self.assertEqual(kv_args.state_data_ptrs[1:], [[], []])
self.assertEqual(kv_args.state_data_lens[1:], [[], []])
self.assertEqual(kv_args.state_item_lens[1:], [[], []])
self.assertEqual(kv_args.state_layer_ids[1:], [[], []])
def test_compact_qsa_entries_map_by_global_layer_id(self):
self.assertEqual(
build_transfer_entry_pairs(
[24, QSA_ROPE_STATE_LAYER_ID],
[0, 12, 24, QSA_ROPE_STATE_LAYER_ID],
2,
4,
),
[(0, 2), (1, 3)],
)
def test_replicated_state_tp_policy(self):
for src_tp, dst_tp, rank, expected in (
(4, 1, 0, True),
(4, 1, 1, False),
(1, 4, 0, True),
(4, 4, 3, True),
):
with self.subTest(src_tp=src_tp, dst_tp=dst_tp, rank=rank):
self.assertEqual(
should_send_replicated_state(
src_attn_tp_size=src_tp,
dst_attn_tp_size=dst_tp,
local_tp_rank_in_group=rank,
),
expected,
)
common = dict(
src_item_len=96,
dst_item_len=96,
src_dim=0,
dst_dim=0,
outer_count=1,
src_attn_tp_size=4,
dst_attn_tp_size=1,
dst_tp_rank_in_group=0,
)
self.assertEqual(
compute_mamba_state_slice_byte_blocks(**common, local_tp_rank_in_group=0),
[(0, 0, 96)],
)
self.assertEqual(
compute_mamba_state_slice_byte_blocks(**common, local_tp_rank_in_group=1),
[],
)
with self.assertRaisesRegex(ValueError, "must divide"):
should_send_replicated_state(
src_attn_tp_size=3,
dst_attn_tp_size=2,
local_tp_rank_in_group=0,
)
class TestMooncakeTransferInfoIsDummy(unittest.TestCase): class TestMooncakeTransferInfoIsDummy(unittest.TestCase):
"""Truth table for mooncake's payload-inferred is_dummy, with frames built """Truth table for mooncake's payload-inferred is_dummy, with frames built
as KVSender sends them: kv and aux are empty iff dummy, state indices are as KVSender sends them: kv and aux are empty iff dummy, state indices are
@@ -11,7 +11,7 @@ from unittest.mock import MagicMock, patch
import numpy as np import numpy as np
from sglang.srt.disaggregation.base.conn import KVPoll from sglang.srt.disaggregation.base.conn import KVPoll, StateType
from sglang.srt.disaggregation.common.conn import CommonKVManager from sglang.srt.disaggregation.common.conn import CommonKVManager
from sglang.srt.disaggregation.common.staging_handler import PrefillStagingContext from sglang.srt.disaggregation.common.staging_handler import PrefillStagingContext
from sglang.srt.disaggregation.common.utils import pack_int_lists from sglang.srt.disaggregation.common.utils import pack_int_lists
@@ -439,6 +439,59 @@ class TestNixlKVSenderChunkPolicy(CustomTestCase):
self.assertTrue(sender.should_send_kv_chunk(3, last_chunk=False)) self.assertTrue(sender.should_send_kv_chunk(3, last_chunk=False))
class TestNixlEmptyStateTransfer(CustomTestCase):
def test_empty_pp_state_component_is_a_noop(self):
mgr = object.__new__(NixlKVManager)
mgr.agent = StagingFakeAgent()
mgr.is_mla_backend = False
mgr.pp_size = 2
mgr.kv_args = SimpleNamespace(prefill_start_layer=0, kv_data_ptrs=[1])
handle = mgr._send_kvcache_generic(
peer_name="decode",
src_data_ptrs=[],
dst_data_ptrs=[],
item_lens=[],
prefill_data_indices=np.array([3], dtype=np.int32),
dst_data_indices=np.array([5], dtype=np.int32),
dst_gpu_id=0,
notif="qsa-empty",
state_type=StateType.QSA_PENDING,
force_flat=True,
src_layer_ids=[],
dst_layer_ids=[],
)
self.assertIsNone(handle)
self.assertEqual(mgr.agent.get_xfer_descs_calls, [])
self.assertEqual(mgr.agent.initialize_xfer_calls, [])
def test_paired_state_entries_reject_item_length_mismatch(self):
mgr = object.__new__(NixlKVManager)
mgr.agent = StagingFakeAgent()
mgr.is_mla_backend = False
mgr.pp_size = 1
mgr.kv_args = SimpleNamespace(prefill_start_layer=0, kv_data_ptrs=[1])
with self.assertRaisesRegex(RuntimeError, "item length mismatch"):
mgr._send_kvcache_generic(
peer_name="decode",
src_data_ptrs=[10],
dst_data_ptrs=[20],
item_lens=[32],
prefill_data_indices=np.array([3], dtype=np.int32),
dst_data_indices=np.array([5], dtype=np.int32),
dst_gpu_id=0,
notif="qsa-mismatch",
state_type=StateType.QSA_PENDING,
force_flat=True,
src_layer_ids=[24],
dst_layer_ids=[24],
dst_item_lens=[48],
)
self.assertEqual(mgr.agent.initialize_xfer_calls, [])
class TestNixlAbortHandling(CustomTestCase): class TestNixlAbortHandling(CustomTestCase):
def _make_manager(self, request_status=None): def _make_manager(self, request_status=None):
mgr = object.__new__(NixlKVManager) mgr = object.__new__(NixlKVManager)
@@ -15,6 +15,8 @@ def _pool(temporal: torch.Tensor, num_conv: int = 2) -> MambaPool:
"""A MambaPool stub carrying only what the transfer accessors read.""" """A MambaPool stub carrying only what the transfer accessors read."""
pool = object.__new__(MambaPool) pool = object.__new__(MambaPool)
pool.num_mamba_layers = NUM_LAYERS pool.num_mamba_layers = NUM_LAYERS
pool.mamba_layer_ids = list(range(NUM_LAYERS))
pool._slot_siblings = []
pool.conv_slice_axis = 0 pool.conv_slice_axis = 0
pool.mamba_cache = MambaPool.State( pool.mamba_cache = MambaPool.State(
conv=[torch.zeros(NUM_LAYERS, NUM_SLOTS, 4, 5) for _ in range(num_conv)], conv=[torch.zeros(NUM_LAYERS, NUM_SLOTS, 4, 5) for _ in range(num_conv)],
@@ -54,6 +56,18 @@ class TestMambaStateTransferBuffers(unittest.TestCase):
self.assertEqual(len(pool.get_state_dim_per_tensor()), len(lens)) self.assertEqual(len(pool.get_state_dim_per_tensor()), len(lens))
def test_sibling_declares_replicated_transfer_without_field_name_coupling(self):
pool = _pool(torch.zeros(NUM_LAYERS, NUM_SLOTS, 6, 7, 8))
class ReplicatedSibling:
def iter_transfer_state_entries(self):
yield "future_sibling", torch.zeros(NUM_SLOTS, 9), None, 123
pool._slot_siblings = [ReplicatedSibling()]
self.assertEqual(pool.get_state_dim_per_tensor()[-1], 0)
self.assertEqual(pool.get_state_slice_outer_counts()[-1], 1)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,68 @@
import sys
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
import torch
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
from sglang.srt.mem_cache.qsa_kv_pool import QSATokenToKVPool
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def test_qsa_allocations_follow_parent_mooncake_scope(monkeypatch):
active_scopes = set()
allocations = 0
original_zeros = torch.zeros
@contextmanager
def scope(name):
active_scopes.add(name)
try:
yield
finally:
active_scopes.remove(name)
def init_parent(pool, **_):
pool.full_kv_pool = SimpleNamespace(
memory_saver_adapter=SimpleNamespace(
region=lambda _: scope("memory_saver")
),
enable_custom_mem_pool=True,
custom_mem_pool=object(),
)
def allocate(*args, **kwargs):
nonlocal allocations
assert active_scopes == {"memory_saver", "custom_pool"}
allocations += 1
return original_zeros(*args, **kwargs)
monkeypatch.setattr(HybridLinearKVPool, "__init__", init_parent)
monkeypatch.setattr(QSATokenToKVPool, "get_kv_size_bytes", lambda _: (0, 0))
monkeypatch.setattr(torch.cuda, "use_mem_pool", lambda _: scope("custom_pool"))
monkeypatch.setattr("sglang.srt.mem_cache.qsa_kv_pool.torch.zeros", allocate)
QSATokenToKVPool(
size=8,
dtype=torch.bfloat16,
page_size=4,
head_num=1,
head_dim=8,
full_attention_layer_ids=[1, 3],
device="cpu",
mamba_pool=object(),
qsa_index_kv_heads=1,
qsa_index_head_dim=8,
qsa_compress_ratio=2,
qsa_token_topk=4,
num_request_slots=3,
)
assert allocations == 4
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+15 -9
View File
@@ -615,16 +615,22 @@ class TestGoldenModelOverrides(_IsolatedPublish):
# value: readers only ever read flags. # value: readers only ever read flags.
self.assertEqual((self._publish(sa), self._leaf("dtype"))[1], "auto") self.assertEqual((self._publish(sa), self._leaf("dtype"))[1], "auto")
def test_qwen4_rejects_pd_and_unified_memory(self): def test_qwen4_pd_support_and_remaining_limits(self):
qwen4 = ("Qwen4ExpForConditionalGeneration", "qwen4_exp") qwen4 = ("Qwen4ExpForConditionalGeneration", "qwen4_exp")
for kwargs, message in ( with override_platform(is_cuda=True):
({"disaggregation_mode": "prefill"}, "PD disaggregation"), for mode in ("prefill", "decode"):
({"disaggregation_mode": "decode"}, "PD disaggregation"), with self.subTest(mode=mode):
({"enable_unified_memory": True}, "enable-unified-memory"), self._construct(*qwen4, disaggregation_mode=mode)
):
with self.subTest(**kwargs): with self.assertRaisesRegex(ValueError, "enable-unified-memory"):
with self.assertRaisesRegex(ValueError, message): self._construct(*qwen4, enable_unified_memory=True)
self._construct(*qwen4, **kwargs) with self.assertRaisesRegex(ValueError, "MORI requires --pp-size 1"):
self._construct(
*qwen4,
disaggregation_mode="prefill",
disaggregation_transfer_backend="mori",
pp_size=2,
)
def test_qwen4_ple_offload_default(self): def test_qwen4_ple_offload_default(self):
qwen4 = ("Qwen4ExpForConditionalGeneration", "qwen4_exp") qwen4 = ("Qwen4ExpForConditionalGeneration", "qwen4_exp")