[PD] Support --enable-unified-memory with PD disaggregation (kimi-linear MLA hybrid-Mamba) (#33362)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8a7c8a72d6
commit
ceeaec2078
@@ -327,6 +327,8 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
|
||||
self.transfer_queue = transfer_queue
|
||||
self.tree_cache = tree_cache
|
||||
self.gloo_group = gloo_group
|
||||
# Destinations visible to prefill but not yet on the transfer queue.
|
||||
self._num_published_destinations = 0
|
||||
self.tp_rank = tp_rank
|
||||
self.tp_size = tp_size
|
||||
self.dp_size = dp_size
|
||||
@@ -1151,14 +1153,21 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
|
||||
kv_indices = self.req_to_token_pool.req_to_token[
|
||||
decode_req.req.req_pool_idx
|
||||
][total_prefix_len:origin_input_len]
|
||||
kv_indices = (
|
||||
self.token_to_kv_pool_allocator.translate_kv_indices_for_transfer(
|
||||
kv_indices
|
||||
)
|
||||
)
|
||||
|
||||
seq_len = origin_input_len
|
||||
|
||||
def _mamba_payload():
|
||||
return [
|
||||
self.req_to_token_pool.req_index_to_mamba_index_mapping[
|
||||
decode_req.req.req_pool_idx
|
||||
]
|
||||
self.req_to_token_pool.translate_mamba_indices(
|
||||
self.req_to_token_pool.req_index_to_mamba_index_mapping[
|
||||
decode_req.req.req_pool_idx
|
||||
]
|
||||
)
|
||||
.cpu()
|
||||
.numpy()
|
||||
]
|
||||
@@ -1306,6 +1315,7 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
|
||||
decode_req.kv_receiver,
|
||||
decode_req.req.build_rebootstrap_payload(),
|
||||
)
|
||||
self._num_published_destinations += 1
|
||||
preallocated_reqs.append(decode_req)
|
||||
indices_to_remove.add(i)
|
||||
decode_req.req.time_stats.set_decode_transfer_queue_entry_time()
|
||||
@@ -1316,6 +1326,18 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
|
||||
|
||||
return preallocated_reqs, failed_reqs
|
||||
|
||||
@property
|
||||
def has_published_destinations(self) -> bool:
|
||||
"""Whether any destination address is visible to prefill but not yet
|
||||
protected by the transfer queue."""
|
||||
return self._num_published_destinations > 0
|
||||
|
||||
def note_destinations_queued(self, count: int) -> None:
|
||||
"""Hand `count` published destinations over to the transfer queue."""
|
||||
self._num_published_destinations = max(
|
||||
0, self._num_published_destinations - count
|
||||
)
|
||||
|
||||
@property
|
||||
def num_tokens_pre_allocated(self):
|
||||
return sum(
|
||||
@@ -1798,6 +1820,10 @@ class DecodeTransferQueue(DecodeHiCacheTransferMixin):
|
||||
|
||||
def extend(self, decode_reqs: List[DecodeRequest]) -> None:
|
||||
self.queue.extend(decode_reqs)
|
||||
# This queue now covers them.
|
||||
prealloc_queue = self.scheduler.disagg_decode_prealloc_queue
|
||||
if prealloc_queue is not None:
|
||||
prealloc_queue.note_destinations_queued(len(decode_reqs))
|
||||
|
||||
def _commit_transfer_to_req(self, decode_req: DecodeRequest):
|
||||
idx = decode_req.metadata_buffer_index
|
||||
|
||||
@@ -8,7 +8,7 @@ import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from typing import List, Optional, Set, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
@@ -59,7 +59,7 @@ from sglang.srt.observability.trace import (
|
||||
TraceReqContext,
|
||||
trace_set_thread_info,
|
||||
)
|
||||
from sglang.srt.runtime_context import get_parallel, get_schedule
|
||||
from sglang.srt.runtime_context import get_memory, get_parallel, get_schedule
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.srt.utils.network import NetworkAddress
|
||||
|
||||
@@ -282,35 +282,41 @@ class MooncakeKVManager(CommonKVManager):
|
||||
def init_engine(self):
|
||||
self.engine = get_mooncake_transfer_engine()
|
||||
|
||||
def register_buffer_to_engine(self):
|
||||
# Batch register KV data buffers
|
||||
if self.kv_args.kv_data_ptrs and self.kv_args.kv_data_lens:
|
||||
self.engine.batch_register(
|
||||
self.kv_args.kv_data_ptrs, self.kv_args.kv_data_lens
|
||||
)
|
||||
def _registerable_regions(self) -> List[Tuple[int, int]]:
|
||||
"""(ptr, len) regions to (de)register, exact duplicates removed.
|
||||
|
||||
# Batch register auxiliary data buffers
|
||||
if self.kv_args.aux_data_ptrs and self.kv_args.aux_data_lens:
|
||||
self.engine.batch_register(
|
||||
self.kv_args.aux_data_ptrs, self.kv_args.aux_data_lens
|
||||
)
|
||||
Deduped because the unified memory pool reports one raw buffer as both
|
||||
its KV and its mamba state component, and double registration fails in
|
||||
the engine.
|
||||
"""
|
||||
regions: List[Tuple[int, int]] = []
|
||||
seen: Set[Tuple[int, int]] = set()
|
||||
|
||||
def add(ptrs: List[int], lens: List[int]) -> None:
|
||||
for ptr, length in zip(ptrs or [], lens or []):
|
||||
if (ptr, length) not in seen:
|
||||
seen.add((ptr, length))
|
||||
regions.append((ptr, length))
|
||||
|
||||
add(self.kv_args.kv_data_ptrs, self.kv_args.kv_data_lens)
|
||||
add(self.kv_args.aux_data_ptrs, self.kv_args.aux_data_lens)
|
||||
for ptrs, lens in zip(
|
||||
self.kv_args.state_data_ptrs, self.kv_args.state_data_lens
|
||||
):
|
||||
if ptrs and lens:
|
||||
self.engine.batch_register(ptrs, lens)
|
||||
add(ptrs, lens)
|
||||
return regions
|
||||
|
||||
def register_buffer_to_engine(self):
|
||||
regions = self._registerable_regions()
|
||||
if regions:
|
||||
ptrs, lens = zip(*regions)
|
||||
self.engine.batch_register(list(ptrs), list(lens))
|
||||
|
||||
def deregister_buffer_to_engine(self):
|
||||
if self.kv_args.kv_data_ptrs:
|
||||
self.engine.batch_deregister(self.kv_args.kv_data_ptrs)
|
||||
|
||||
if self.kv_args.aux_data_ptrs:
|
||||
self.engine.batch_deregister(self.kv_args.aux_data_ptrs)
|
||||
|
||||
for ptrs in self.kv_args.state_data_ptrs or []:
|
||||
if ptrs:
|
||||
self.engine.batch_deregister(ptrs)
|
||||
regions = self._registerable_regions()
|
||||
if regions:
|
||||
ptrs, _ = zip(*regions)
|
||||
self.engine.batch_deregister(list(ptrs))
|
||||
|
||||
if hasattr(self, "connection_pool"):
|
||||
with self.connection_lock:
|
||||
@@ -775,6 +781,52 @@ class MooncakeKVManager(CommonKVManager):
|
||||
# compared to using multiple threads
|
||||
return process_layers(layers_params)
|
||||
|
||||
def _validate_envelope_kv_layout(
|
||||
self,
|
||||
dst_kv_ptrs: list[int],
|
||||
dst_kv_item_len: Optional[int],
|
||||
dst_attn_tp_size: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Reject a peer whose KV registration shape differs from ours.
|
||||
|
||||
The unified memory pool registers ONE whole-envelope region and
|
||||
addresses the destination as ``dst_ptr + page_id * item_len`` using OUR
|
||||
``item_len``, so a peer on a different page size / spec, or without
|
||||
unified memory, would take envelope-sized blocks at the wrong offsets.
|
||||
Must run before the first RDMA write.
|
||||
|
||||
Scoped to unified memory by config, not by region count: a non-unified
|
||||
PP stage owning a single full-attention layer also registers one region,
|
||||
and `_send_kvcache_generic` pairs that with the peer by layer id.
|
||||
"""
|
||||
if not get_memory().enable_unified_memory:
|
||||
return
|
||||
if dst_attn_tp_size is not None and self.attn_tp_size != dst_attn_tp_size:
|
||||
# The unified mamba state ships as one whole-slot envelope with no
|
||||
# per-tensor dims, so `_send_mamba_state_slice` cannot reslice it and
|
||||
# silently falls back to an unsliced copy. Reject here, before any KV
|
||||
# is written, rather than in `maybe_send_extra` afterwards.
|
||||
raise RuntimeError(
|
||||
"--enable-unified-memory does not support different prefill / "
|
||||
f"decode attention TP sizes (prefill={self.attn_tp_size}, "
|
||||
f"decode={dst_attn_tp_size}): the whole-envelope state cannot "
|
||||
"be TP-resliced."
|
||||
)
|
||||
src_item_lens = self.kv_args.kv_item_lens
|
||||
if (
|
||||
len(src_item_lens) != 1
|
||||
or len(dst_kv_ptrs) != 1
|
||||
or dst_kv_item_len is None
|
||||
or src_item_lens[0] != dst_kv_item_len
|
||||
):
|
||||
raise RuntimeError(
|
||||
"PD KV layout mismatch on the whole-envelope path: prefill has "
|
||||
f"{len(src_item_lens)} KV region(s) with item_lens="
|
||||
f"{src_item_lens}, decode has {len(dst_kv_ptrs)} with item_len="
|
||||
f"{dst_kv_item_len}. With --enable-unified-memory both sides "
|
||||
"must enable it and use the same page size and model spec."
|
||||
)
|
||||
|
||||
def send_kvcache(
|
||||
self,
|
||||
mooncake_session_id: str,
|
||||
@@ -784,7 +836,12 @@ class MooncakeKVManager(CommonKVManager):
|
||||
executor: concurrent.futures.ThreadPoolExecutor,
|
||||
dst_layer_ids: Optional[List[int]] = None,
|
||||
dst_device_kv_indices: Optional[npt.NDArray[np.int32]] = None,
|
||||
dst_kv_item_len: Optional[int] = None,
|
||||
dst_attn_tp_size: Optional[int] = None,
|
||||
):
|
||||
self._validate_envelope_kv_layout(
|
||||
dst_kv_ptrs, dst_kv_item_len, dst_attn_tp_size
|
||||
)
|
||||
dst_device_kv_ptrs = None
|
||||
if dst_device_kv_indices is not None:
|
||||
compression_ratios = self.kv_args.mla_compression_ratios
|
||||
@@ -1243,6 +1300,17 @@ class MooncakeKVManager(CommonKVManager):
|
||||
)
|
||||
|
||||
if st == StateType.MAMBA:
|
||||
if (not src_dim_per_tensor or not dst_dim_per_tensor) and list(
|
||||
src_item_lens
|
||||
) != list(dst_item_lens):
|
||||
raise RuntimeError(
|
||||
"Mamba state layouts differ between prefill and decode "
|
||||
f"(src item_lens={src_item_lens}, dst item_lens="
|
||||
f"{dst_item_lens}) and no per-tensor dim metadata is "
|
||||
"available to reslice. With --enable-unified-memory, "
|
||||
"prefill and decode must both enable it and use equal "
|
||||
"attention TP sizes."
|
||||
)
|
||||
if (
|
||||
target_rank_registration_info is not None
|
||||
and self.attn_tp_size
|
||||
@@ -1693,6 +1761,8 @@ class MooncakeKVManager(CommonKVManager):
|
||||
executor,
|
||||
dst_layer_ids=target_rank_registration_info.dst_kv_layer_ids,
|
||||
dst_device_kv_indices=chunked_dst_device_kv_indice,
|
||||
dst_kv_item_len=target_rank_registration_info.dst_kv_item_len,
|
||||
dst_attn_tp_size=target_rank_registration_info.dst_attn_tp_size,
|
||||
)
|
||||
elif (
|
||||
self.enable_staging
|
||||
|
||||
@@ -790,6 +790,7 @@ class SchedulerDisaggregationPrefillMixin:
|
||||
# Optimistic bootstrap can fail while this overlapped chunk is
|
||||
# already running. Drop aborted chunks instead of sending KV.
|
||||
if is_aborted(req):
|
||||
self.clear_pending_chunk_send(req)
|
||||
advance_logprob_pt(i, req)
|
||||
req.time_stats.set_last_chunked_prefill_finish_time()
|
||||
continue
|
||||
@@ -980,7 +981,17 @@ class SchedulerDisaggregationPrefillMixin:
|
||||
|
||||
return transferred_rids
|
||||
|
||||
def clear_pending_chunk_send(self: Scheduler, req: Req) -> None:
|
||||
"""Drop `req` from the sent-but-unconcluded chunk set.
|
||||
|
||||
Every path that retires a request without a `last_chunk=True` send must
|
||||
call this: a stale entry holds the unified-memory compaction gate closed
|
||||
for the process lifetime.
|
||||
"""
|
||||
self.disagg_prefill_pending_chunk_rids.discard(req.rid)
|
||||
|
||||
def handle_bootstrap_failure(self: Scheduler, req: Req) -> None:
|
||||
self.clear_pending_chunk_send(req)
|
||||
error_message = (
|
||||
f"Prefill bootstrap failed for request rank={self.ps.tp_rank} "
|
||||
f"{req.rid=} {req.bootstrap_room=}"
|
||||
@@ -1178,9 +1189,11 @@ class SchedulerDisaggregationPrefillMixin:
|
||||
|
||||
def _mamba_payload():
|
||||
return [
|
||||
self.req_to_token_pool.req_index_to_mamba_index_mapping[
|
||||
req.req_pool_idx
|
||||
]
|
||||
self.req_to_token_pool.translate_mamba_indices(
|
||||
self.req_to_token_pool.req_index_to_mamba_index_mapping[
|
||||
req.req_pool_idx
|
||||
]
|
||||
)
|
||||
.cpu()
|
||||
.numpy()
|
||||
]
|
||||
@@ -1287,6 +1300,13 @@ class SchedulerDisaggregationPrefillMixin:
|
||||
kv_indices = self.req_to_token_pool.req_to_token[
|
||||
req.req_pool_idx, seg_start:seg_end
|
||||
]
|
||||
# Unified memory: req_to_token holds VIRTUAL ids; the transfer needs
|
||||
# physical ones. Per segment, since each is its own gather.
|
||||
kv_indices = (
|
||||
self.token_to_kv_pool_allocator.translate_kv_indices_for_transfer(
|
||||
kv_indices
|
||||
)
|
||||
)
|
||||
page_indices = kv_to_page_indices(kv_indices, page_size)
|
||||
segment_is_last = last_chunk and is_final_segment
|
||||
if not req.disagg_kv_sender.should_send_kv_chunk(
|
||||
@@ -1299,6 +1319,12 @@ class SchedulerDisaggregationPrefillMixin:
|
||||
num_kv_tokens=seg_end - seg_start,
|
||||
)
|
||||
req.start_send_idx = end_idx
|
||||
# A last chunk needs no entry: every `last_chunk=True` call site has
|
||||
# already put the request on `disagg_prefill_inflight_queue`.
|
||||
if last_chunk:
|
||||
self.disagg_prefill_pending_chunk_rids.discard(req.rid)
|
||||
else:
|
||||
self.disagg_prefill_pending_chunk_rids.add(req.rid)
|
||||
|
||||
def optimistic_release_and_requeue(self: Scheduler, req: Req) -> None:
|
||||
"""Release KV cache and requeue an optimistic prefill request."""
|
||||
@@ -1308,6 +1334,7 @@ class SchedulerDisaggregationPrefillMixin:
|
||||
req.reset_for_retract()
|
||||
req.output_ids = array("q")
|
||||
req.start_send_idx = 0
|
||||
self.clear_pending_chunk_send(req) # re-sends from scratch
|
||||
req.tmp_end_idx = -1
|
||||
req.disagg_decode_prefix_len = 0
|
||||
req.early_send_prefix_end = None
|
||||
|
||||
@@ -111,6 +111,50 @@ class DisaggregationMode(Enum):
|
||||
return "unified"
|
||||
|
||||
|
||||
def unified_memory_disagg_move_gate(scheduler):
|
||||
"""Compaction move gate for a PD node running the unified memory pool.
|
||||
|
||||
Returns a predicate that is True only when no transfer can be in flight, so
|
||||
compaction never relocates a page the RDMA engine is reading or writing.
|
||||
Safe to read this state from here: every mover runs on the scheduler thread.
|
||||
|
||||
A page is exposed from the moment its address reaches the peer until the
|
||||
transfer concludes, and for part of that lifetime the request is in NEITHER
|
||||
end's queue -- so queue emptiness alone is not enough:
|
||||
|
||||
- PREFILL: scheduling the final chunk clears `chunked_req` while earlier
|
||||
chunks may still be draining, and the request only reaches the inflight
|
||||
queue later, in the result path.
|
||||
- DECODE: `pop_preallocated` publishes one request's destinations and keeps
|
||||
allocating for the next, whose allocation can urgently flush the peer
|
||||
sub-allocator; the batch reaches the transfer queue only after the loop.
|
||||
"""
|
||||
if scheduler.disaggregation_mode == DisaggregationMode.PREFILL:
|
||||
|
||||
def prefill_gate() -> bool:
|
||||
return not (
|
||||
scheduler.disagg_prefill_inflight_queue
|
||||
or scheduler.disagg_prefill_pending_chunk_rids
|
||||
)
|
||||
|
||||
return prefill_gate
|
||||
|
||||
if scheduler.disaggregation_mode == DisaggregationMode.DECODE:
|
||||
|
||||
def decode_gate() -> bool:
|
||||
return not (
|
||||
scheduler.disagg_decode_transfer_queue.queue
|
||||
or scheduler.disagg_decode_prealloc_queue.has_published_destinations
|
||||
)
|
||||
|
||||
return decode_gate
|
||||
|
||||
raise ValueError(
|
||||
"unified_memory_disagg_move_gate: scheduler is not a PD node "
|
||||
f"(mode={scheduler.disaggregation_mode})"
|
||||
)
|
||||
|
||||
|
||||
#########################
|
||||
# Synchronization
|
||||
#########################
|
||||
|
||||
@@ -25,7 +25,7 @@ from collections import deque
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from functools import partial
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Any, Deque, Dict, List, Optional, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, Deque, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from sglang.srt.runtime_context import (
|
||||
get_device,
|
||||
@@ -90,6 +90,7 @@ from sglang.srt.disaggregation.utils import (
|
||||
TransferBackend,
|
||||
get_dsa_seed_metadata_dim,
|
||||
prepare_abort,
|
||||
unified_memory_disagg_move_gate,
|
||||
)
|
||||
from sglang.srt.distributed import get_pp_group, get_world_group
|
||||
from sglang.srt.distributed.parallel_state import get_tp_group
|
||||
@@ -1396,9 +1397,19 @@ class Scheduler(
|
||||
)
|
||||
# The prefill requests that are in the middle of kv sending
|
||||
self.disagg_prefill_inflight_queue: List[Req] = []
|
||||
# Requests with a sent chunk that are not yet on the inflight queue.
|
||||
self.disagg_prefill_pending_chunk_rids: Set[str] = set()
|
||||
|
||||
self.enable_staging = envs.SGLANG_DISAGG_STAGING_BUFFER.get()
|
||||
|
||||
if (
|
||||
self.enable_unified_memory
|
||||
and self.disaggregation_mode != DisaggregationMode.NULL
|
||||
):
|
||||
self.token_to_kv_pool_allocator.set_disagg_move_gate(
|
||||
unified_memory_disagg_move_gate(self)
|
||||
)
|
||||
|
||||
# Init mm receiver for EPD disaggregation mode
|
||||
if get_disagg().language_only and get_disagg().encoder_transfer_backend in [
|
||||
"zmq_to_scheduler",
|
||||
@@ -2928,6 +2939,7 @@ class Scheduler(
|
||||
req.time_stats.trace_ctx.abort(abort_info={"reason": "Aborted"})
|
||||
req.to_finish = None
|
||||
if self.disaggregation_mode == DisaggregationMode.PREFILL:
|
||||
self.clear_pending_chunk_send(req)
|
||||
req.disagg_kv_sender.abort()
|
||||
maybe_release_metadata_buffer(
|
||||
req, self.req_to_metadata_buffer_idx_allocator
|
||||
|
||||
@@ -82,6 +82,16 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
|
||||
(0,), dtype=self.release_pages.dtype, device=self.device
|
||||
)
|
||||
|
||||
def translate_kv_indices_for_transfer(
|
||||
self, kv_indices: torch.Tensor
|
||||
) -> torch.Tensor:
|
||||
"""Token ids as the PD-disaggregation transfer engine addresses them.
|
||||
|
||||
Identity here: a static pool's token ids index its registered buffers
|
||||
directly. Virtual-id pools must override.
|
||||
"""
|
||||
return kv_indices
|
||||
|
||||
def get_cpu_copy(self, indices, mamba_indices=None):
|
||||
# FIXME: reuse the get_cpu_copy after paged allocator is implemented
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -353,17 +353,29 @@ class KVCacheConfigurator:
|
||||
# Unified-pool fast path: build req_to_token + token_to_kv pool + allocator
|
||||
# from one byte buffer, then return. Gated to the target worker
|
||||
# (req_to_token_pool is None); supports hybrid Mamba and hybrid SWA (not DSV4).
|
||||
if (
|
||||
get_memory().enable_unified_memory
|
||||
and get_disagg().disaggregation_mode == "null"
|
||||
and req_to_token_pool is None
|
||||
):
|
||||
if get_memory().enable_unified_memory and req_to_token_pool is None:
|
||||
pd_enabled = get_disagg().disaggregation_mode != "null"
|
||||
if self.mambaish_config is not None:
|
||||
if pd_enabled and not self.use_mla_backend:
|
||||
raise ValueError(
|
||||
"--enable-unified-memory with PD disaggregation "
|
||||
"currently supports only MLA hybrid-Mamba models "
|
||||
"(e.g. kimi-linear); this model uses the MHA full-"
|
||||
"attention pool. Drop --enable-unified-memory or run "
|
||||
"without PD disaggregation."
|
||||
)
|
||||
bundle = self._init_unified_mamba_pools(
|
||||
max_num_reqs=sizes.max_running_requests,
|
||||
max_total_num_tokens=sizes.max_total_num_tokens,
|
||||
)
|
||||
elif self.is_hybrid_swa and not is_deepseek_v4(self.model_config.hf_config):
|
||||
if pd_enabled:
|
||||
raise ValueError(
|
||||
"--enable-unified-memory with PD disaggregation does "
|
||||
"not support hybrid-SWA models yet (no whole-envelope "
|
||||
"transfer scheme for the SWA sub-pool). Drop "
|
||||
"--enable-unified-memory or run without PD."
|
||||
)
|
||||
bundle = self._init_unified_swa_pools(
|
||||
max_num_reqs=sizes.max_running_requests,
|
||||
full_max_total_num_tokens=sizes.full_max_total_num_tokens,
|
||||
@@ -564,6 +576,11 @@ class KVCacheConfigurator:
|
||||
speculative_num_draft_tokens=get_spec().speculative_num_draft_tokens,
|
||||
disable_overlap_schedule=get_schedule().disable_overlap_schedule,
|
||||
need_sort=get_disagg().disaggregation_mode in ("decode", "prefill"),
|
||||
decode_pre_alloc_size=(
|
||||
get_disagg().disaggregation_decode_extra_slots
|
||||
if get_disagg().disaggregation_mode == "decode"
|
||||
else 0
|
||||
),
|
||||
mamba_full_memory_ratio=get_schedule().mamba_full_memory_ratio,
|
||||
# Overlap mode: the allocator's `free` drops a wait_stream(forward_stream)
|
||||
# barrier so eager compaction serializes after the in-flight forward's
|
||||
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import torch
|
||||
from torch.profiler import record_function
|
||||
@@ -218,6 +218,8 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
_STATS_INSTANCES.add(self)
|
||||
_install_signal_handlers_once()
|
||||
self.live_page_count = 0
|
||||
# While this returns False, `_flush` must not relocate any page.
|
||||
self.disagg_move_gate: Optional[Callable[[], bool]] = None
|
||||
self._latest_forward_done_event: Optional[torch.cuda.Event] = None
|
||||
# Most-recent forward's (done_event, out_cache_loc_virtual) for `_flush`'s
|
||||
# write-race check. Single slot: at most ONE forward in flight per call site.
|
||||
@@ -393,6 +395,12 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
peer = self._peer
|
||||
if peer is None or not peer.lazy_compaction:
|
||||
return 0
|
||||
if peer.disagg_move_gate is not None and not peer.disagg_move_gate():
|
||||
# The peer cannot compact while a PD transfer is in flight, so these
|
||||
# holes are not realizable. Crediting them would let the scheduler
|
||||
# admit work that `_flush_peer_for_alloc` then cannot satisfy, and
|
||||
# the caller treats a failed alloc as a memory-estimation bug.
|
||||
return 0
|
||||
return len(peer._free_phys_pages) * peer.entry_bytes_per_page
|
||||
|
||||
def schedulable_available_size(self) -> int:
|
||||
@@ -1058,6 +1066,10 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
self._compact_pending_impl(freed_physical_pages)
|
||||
|
||||
def _compact_pending_impl(self, freed_physical_pages: torch.Tensor) -> None:
|
||||
assert self.disagg_move_gate is None, (
|
||||
f"_compact_pending({self.sub_pool_name!r}): eager compaction ran with "
|
||||
"a PD-disaggregation move gate installed; PD requires lazy_compaction."
|
||||
)
|
||||
freed_set = set(int(x) for x in freed_physical_pages.tolist())
|
||||
if not freed_set:
|
||||
return
|
||||
@@ -1440,6 +1452,9 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
"""
|
||||
if not self.lazy_compaction:
|
||||
return 0
|
||||
if self.disagg_move_gate is not None and not self.disagg_move_gate():
|
||||
# Holes stay in the free list; the next flush picks them up.
|
||||
return 0
|
||||
self._stats_n_flush_calls += 1
|
||||
with record_function("MultiEndedAlloc._flush"):
|
||||
self._drain_pending_reuse(urgent=urgent)
|
||||
@@ -1936,6 +1951,26 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
|
||||
to the physical translate when `kernel_page_multiplier == 1` (MHA)."""
|
||||
return self.full_attn_allocator.translate_kv_loc_dense(loc, out=out)
|
||||
|
||||
def translate_kv_indices_for_transfer(
|
||||
self, kv_indices: torch.Tensor
|
||||
) -> torch.Tensor:
|
||||
"""Virtual TOKEN ids -> PHYSICAL token ids for the PD transfer engine.
|
||||
|
||||
PHYSICAL, not dense: the transfer registers page ENVELOPES (see
|
||||
`UnifiedMLATokenToKVPool.get_contiguous_buf_infos`).
|
||||
"""
|
||||
return self.full_attn_allocator.translate_kv_loc(kv_indices.to(torch.int64))
|
||||
|
||||
def set_disagg_move_gate(self, gate: Callable[[], bool]) -> None:
|
||||
"""Install the PD-disaggregation move gate on both sub-allocators."""
|
||||
assert self.lazy_compaction, (
|
||||
"PD disaggregation with the unified memory pool requires lazy "
|
||||
"compaction (eager free-path compaction moves pages under "
|
||||
"in-flight transfers)."
|
||||
)
|
||||
self.full_attn_allocator.disagg_move_gate = gate
|
||||
self.mamba_allocator.disagg_move_gate = gate
|
||||
|
||||
def is_slot_allocated(self, slot: int) -> bool:
|
||||
return self.full_attn_allocator.is_slot_allocated(slot)
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from sglang.srt.mem_cache.layout.page_major import (
|
||||
build_page_major_mha_views,
|
||||
)
|
||||
from sglang.srt.mem_cache.memory_pool import (
|
||||
HybridLinearKVPool,
|
||||
HybridReqToTokenPool,
|
||||
MambaPool,
|
||||
MHATokenToKVPool,
|
||||
@@ -663,6 +664,20 @@ class UnifiedMLATokenToKVPool(MLATokenToKVPool):
|
||||
def get_kv_size_bytes(self):
|
||||
return 0 # UnifiedKVPool logs the total; per-sub-pool would double-count
|
||||
|
||||
def get_contiguous_buf_infos(self):
|
||||
"""PD-transfer registration: ONE entry, the raw buffer, addressed as
|
||||
``raw_ptr + physical_page_id * page_envelope_bytes``.
|
||||
|
||||
The transfer item is the whole page envelope (all layers of one page)
|
||||
rather than a per-layer region, because the per-layer dense views
|
||||
overlap and index in dense ids. Both sides must therefore build the
|
||||
pool with identical specs.
|
||||
"""
|
||||
# The address formula omits the anchor; a nonzero one would mis-address.
|
||||
assert self._unified_buffer.anchor_bytes(self._sub_pool_name) == 0
|
||||
raw = self._unified_buffer._raw
|
||||
return [raw.data_ptr()], [raw.numel()], [self._page_bytes]
|
||||
|
||||
def move_kv_cache(self, tgt_loc: torch.Tensor, src_loc: torch.Tensor):
|
||||
"""Relocate whole page envelopes.
|
||||
|
||||
@@ -812,6 +827,31 @@ class UnifiedMambaPool(MambaPool):
|
||||
# Physical-slot copy used by the allocator's `_compact_pending`.
|
||||
MambaPool.copy_from(self, src_index, dst_index)
|
||||
|
||||
# -- PD state transfer (StateType.MAMBA) --
|
||||
# The transfer item is the whole per-slot envelope, addressed as
|
||||
# `raw_ptr + physical_slot * entry_bytes`. An envelope cannot be TP-resliced
|
||||
# or PP-subset, so the per-tensor metadata below stays empty and both sides
|
||||
# must build identical mamba specs (equal attn TP, pp=1).
|
||||
|
||||
def get_contiguous_buf_infos(self):
|
||||
# The address formula omits the anchor; a nonzero one would mis-address.
|
||||
assert self._unified_buffer.anchor_bytes(self._sub_pool_name) == 0
|
||||
spec = self._unified_buffer.mamba_spec(self._sub_pool_name)
|
||||
raw = self._unified_buffer._raw
|
||||
return [raw.data_ptr()], [raw.numel()], [spec.entry_bytes()]
|
||||
|
||||
def get_state_dim_per_tensor(self):
|
||||
return []
|
||||
|
||||
def get_state_layer_ids(self):
|
||||
return []
|
||||
|
||||
def get_state_slice_outer_counts(self):
|
||||
return []
|
||||
|
||||
def get_state_conv_shard_groups(self):
|
||||
return []
|
||||
|
||||
|
||||
class UnifiedMambaSlotAllocator:
|
||||
"""Mamba slot allocator (PHYSICAL view) for the unified memory pool.
|
||||
@@ -929,6 +969,7 @@ class UnifiedHybridReqToTokenPool(HybridReqToTokenPool):
|
||||
speculative_num_draft_tokens: Optional[int] = None,
|
||||
enable_overlap_schedule: bool = True,
|
||||
start_layer: Optional[int] = None,
|
||||
pre_alloc_size: int = 0,
|
||||
):
|
||||
self._unified_buffer = unified_buffer
|
||||
self._mamba_sub_pool_name = mamba_sub_pool_name
|
||||
@@ -936,7 +977,10 @@ class UnifiedHybridReqToTokenPool(HybridReqToTokenPool):
|
||||
unified_buffer.max_slots(mamba_sub_pool_name) - 1
|
||||
) # reserve slot 0
|
||||
super().__init__(
|
||||
size=size,
|
||||
# `DecodeReqToTokenPool` semantics: rows cover the preallocated
|
||||
# requests too, while `self.size` (rebound below) stays the
|
||||
# running-request cap the scheduler and leak invariant expect.
|
||||
size=size + pre_alloc_size,
|
||||
mamba_size=self._shared_mamba_size,
|
||||
mamba_spec_state_size=mamba_spec_state_size,
|
||||
max_context_len=max_context_len,
|
||||
@@ -949,6 +993,8 @@ class UnifiedHybridReqToTokenPool(HybridReqToTokenPool):
|
||||
enable_overlap_schedule=enable_overlap_schedule,
|
||||
start_layer=start_layer,
|
||||
)
|
||||
self.size = size
|
||||
self.pre_alloc_size = pre_alloc_size
|
||||
|
||||
def _init_mamba_pool(
|
||||
self,
|
||||
@@ -1013,6 +1059,17 @@ class UnifiedHybridReqToTokenPool(HybridReqToTokenPool):
|
||||
return self.mamba_allocator.translate(virtual_ids).to(torch.int32)
|
||||
|
||||
|
||||
class UnifiedHybridLinearKVPool(HybridLinearKVPool):
|
||||
"""`HybridLinearKVPool` over unified sub-pools (full = Unified{MLA,MHA},
|
||||
mamba = UnifiedMambaPool)."""
|
||||
|
||||
def get_kv_layer_ids(self):
|
||||
# Empty: the KV component is one whole-envelope entry, so there are no
|
||||
# per-layer entries to pair by layer id (the sender falls back to
|
||||
# positional pairing).
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1054,9 +1111,9 @@ def init_unified_mamba_pools(
|
||||
mamba_full_memory_ratio: Optional[float] = None, # informational only
|
||||
forward_stream: Optional[torch.cuda.Stream] = None,
|
||||
lazy_compaction: bool = False,
|
||||
decode_pre_alloc_size: int = 0,
|
||||
) -> UnifiedPoolBundle:
|
||||
"""Build the Mamba-hybrid unified-memory-pool stack."""
|
||||
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
|
||||
from sglang.srt.mem_cache.multi_ended_allocator import (
|
||||
UnifiedMambaTokenToKVPoolAllocator,
|
||||
)
|
||||
@@ -1132,6 +1189,7 @@ def init_unified_mamba_pools(
|
||||
speculative_num_draft_tokens=speculative_num_draft_tokens,
|
||||
enable_overlap_schedule=not disable_overlap_schedule,
|
||||
start_layer=start_layer,
|
||||
pre_alloc_size=decode_pre_alloc_size,
|
||||
)
|
||||
if use_mla_backend:
|
||||
# start_layer stays 0: HybridLinearKVPool patches layer ids to the dense
|
||||
@@ -1153,7 +1211,7 @@ def init_unified_mamba_pools(
|
||||
full_attn_layer_ids_for_pool = (
|
||||
[0] if is_draft_worker else list(full_attention_layer_ids)
|
||||
)
|
||||
token_to_kv_pool = HybridLinearKVPool(
|
||||
token_to_kv_pool = UnifiedHybridLinearKVPool(
|
||||
page_size=page_size,
|
||||
size=max_total_num_tokens,
|
||||
dtype=kv_cache_dtype,
|
||||
|
||||
@@ -8007,9 +8007,29 @@ class ServerArgs:
|
||||
def _handle_unified_memory_pool(self):
|
||||
if not self.enable_unified_memory:
|
||||
return
|
||||
assert self.disaggregation_mode == "null", (
|
||||
"--enable-unified-memory is not yet compatible with PD " "disaggregation."
|
||||
)
|
||||
if self.disaggregation_mode != "null":
|
||||
# Constraints of the whole-envelope transfer; see
|
||||
# UnifiedMLATokenToKVPool.get_contiguous_buf_infos.
|
||||
assert self.disaggregation_transfer_backend == "mooncake", (
|
||||
"--enable-unified-memory with PD disaggregation supports only "
|
||||
"the mooncake transfer backend; got "
|
||||
f"{self.disaggregation_transfer_backend!r}."
|
||||
)
|
||||
assert self.pp_size == 1, (
|
||||
"--enable-unified-memory with PD disaggregation does not support "
|
||||
"pipeline parallelism (whole-envelope transfer has no per-layer "
|
||||
"entries to subset)."
|
||||
)
|
||||
assert not envs.SGLANG_DISABLE_LAZY_COMPACTION.get(), (
|
||||
"--enable-unified-memory with PD disaggregation requires lazy "
|
||||
"compaction; unset SGLANG_DISABLE_LAZY_COMPACTION."
|
||||
)
|
||||
assert not self.enable_hisparse, (
|
||||
"--enable-unified-memory with PD disaggregation is not compatible "
|
||||
"with --enable-hisparse: the decode-side HiSparse prealloc path "
|
||||
"ships host/C4 rows straight from the allocator, bypassing the "
|
||||
"virtual->physical translation the unified pool needs."
|
||||
)
|
||||
assert self.speculative_algorithm in (None, "DSPARK"), (
|
||||
"--enable-unified-memory only supports --speculative-algorithm "
|
||||
"DSPARK (chain draft); other speculative algorithms are not yet "
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""PD disaggregation with --enable-unified-memory (MLA hybrid-Mamba).
|
||||
|
||||
Guards the unified-memory PD transfer scheme end to end: whole page-envelope
|
||||
KV registration (`UnifiedMLATokenToKVPool.get_contiguous_buf_infos`), whole
|
||||
slot-envelope KDA/mamba state transfer, virtual->physical index translation at
|
||||
the prefill send / decode prealloc sites, and the compaction move gate. A
|
||||
regression in any of them shifts the decode-side KV/state bytes and breaks
|
||||
logprob parity with the non-PD unified-memory reference.
|
||||
|
||||
`--attention-backend` is deliberately NOT pinned, matching
|
||||
`models_e2e/test_kimi_linear_unified_memory.py`, which documents that pinning
|
||||
hides defects reachable only under the resolved default. The transferred bytes
|
||||
are backend-independent, so the default (fa3 on this suite's H100 runner) covers
|
||||
this file's subject either way. The linear-attn/Mamba backends stay pinned to
|
||||
triton -- the page-major layout requires them.
|
||||
|
||||
`--enable-deterministic-inference` is deliberately NOT set. It would only guard
|
||||
against batch-shape-dependent kernel variation, and the reference and P+D paths
|
||||
run the same shapes: measured, two fresh servers on separate GPUs produce
|
||||
bit-identical logits without it. Setting it would narrow the test to the
|
||||
batch-invariant op set and a non-default sampling backend -- a less
|
||||
representative config -- and couple a PD-transfer test to the deterministic code
|
||||
path, so a defect there would fail this file for an unrelated reason.
|
||||
"""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import requests
|
||||
|
||||
from sglang.srt.utils import kill_process_tree
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.server_fixtures.disaggregation_fixture import (
|
||||
PDDisaggregationServerBase,
|
||||
assert_process_healthy,
|
||||
)
|
||||
from sglang.test.test_utils import (
|
||||
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
popen_launch_server,
|
||||
)
|
||||
|
||||
register_cuda_ci(est_time=900, stage="base-c", runner_config="4-gpu-h100")
|
||||
|
||||
KIMI_LINEAR_MODEL = "yujiepan/kimi-linear-tiny-random"
|
||||
SERVER_ENV = {"SGLANG_BATCH_INVARIANT_OPS_ENABLE_MM_DEEPGEMM": "0"}
|
||||
SERVER_ARGS = [
|
||||
"--skip-tokenizer-init",
|
||||
"--random-seed",
|
||||
"1",
|
||||
"--enable-unified-memory",
|
||||
"--linear-attn-backend",
|
||||
"triton",
|
||||
"--mamba-backend",
|
||||
"triton",
|
||||
"--max-mamba-cache-size",
|
||||
"32",
|
||||
"--max-total-tokens",
|
||||
"4096",
|
||||
"--cuda-graph-backend-decode",
|
||||
"disabled",
|
||||
"--cuda-graph-backend-prefill",
|
||||
"disabled",
|
||||
]
|
||||
|
||||
|
||||
class TestUnifiedMemoryDisaggregation(PDDisaggregationServerBase):
|
||||
"""1 prefill + 1 decode, both with --enable-unified-memory, vs a non-PD
|
||||
unified-memory reference server."""
|
||||
|
||||
prefill_tp_size = 1
|
||||
decode_tp_size = 1
|
||||
decode_base_gpu_id = 1
|
||||
extra_prefill_args = SERVER_ARGS
|
||||
extra_decode_args = SERVER_ARGS
|
||||
extra_prefill_env = SERVER_ENV
|
||||
extra_decode_env = SERVER_ENV
|
||||
baseline_args = SERVER_ARGS
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.model = KIMI_LINEAR_MODEL
|
||||
|
||||
@staticmethod
|
||||
def generate(base_url):
|
||||
response = requests.post(
|
||||
base_url + "/generate",
|
||||
json={
|
||||
"input_ids": [1] + [100 + i % 1000 for i in range(256)],
|
||||
"sampling_params": {
|
||||
"temperature": 0,
|
||||
"max_new_tokens": 4,
|
||||
"ignore_eos": True,
|
||||
},
|
||||
"return_logprob": True,
|
||||
"top_logprobs_num": 5,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["meta_info"]
|
||||
|
||||
def test_logprob_parity(self):
|
||||
baseline = popen_launch_server(
|
||||
self.model,
|
||||
self.lb_url,
|
||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
other_args=["--trust-remote-code"] + self.baseline_args,
|
||||
env=SERVER_ENV,
|
||||
)
|
||||
try:
|
||||
reference = self.generate(self.lb_url)
|
||||
finally:
|
||||
kill_process_tree(baseline.pid, wait_timeout=60)
|
||||
time.sleep(5)
|
||||
|
||||
self.launch_all()
|
||||
disaggregated = self.generate(self.lb_url)
|
||||
|
||||
reference_logprobs = reference["output_token_logprobs"]
|
||||
disaggregated_logprobs = disaggregated["output_token_logprobs"]
|
||||
self.assertEqual(
|
||||
[item[1] for item in reference_logprobs],
|
||||
[item[1] for item in disaggregated_logprobs],
|
||||
)
|
||||
self.assertEqual(len(reference_logprobs), 4)
|
||||
for reference_item, disaggregated_item in zip(
|
||||
reference_logprobs, disaggregated_logprobs
|
||||
):
|
||||
self.assertAlmostEqual(reference_item[0], disaggregated_item[0], delta=0.05)
|
||||
|
||||
assert_process_healthy(self, "load balancer", self.process_lb, self.lb_url)
|
||||
assert_process_healthy(self, "prefill", self.process_prefill, self.prefill_url)
|
||||
assert_process_healthy(self, "decode", self.process_decode, self.decode_url)
|
||||
|
||||
|
||||
class TestUnifiedMemoryDisaggregationChunkedPrefill(TestUnifiedMemoryDisaggregation):
|
||||
"""Multi-chunk prefill (257-token prompt, 64-token chunks): each chunk's KV
|
||||
pages are translated to physical ids and shipped while later chunks still
|
||||
run, exercising the chunked send path and the prefill-side move gate
|
||||
(`chunked_req.start_send_idx > 0`). The reference server uses the same
|
||||
chunk size so any parity break isolates to the PD transfer.
|
||||
"""
|
||||
|
||||
_chunked_args = SERVER_ARGS + ["--chunked-prefill-size", "64"]
|
||||
extra_prefill_args = _chunked_args
|
||||
extra_decode_args = _chunked_args
|
||||
baseline_args = _chunked_args
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Regression tests for the unified-memory PD compaction move gate.
|
||||
|
||||
The gate decides when lazy compaction may relocate physical pages. A page is
|
||||
exposed to the peer from the moment its address is published until the transfer
|
||||
concludes, and for part of that lifetime the request sits in NEITHER end's
|
||||
queue. Both cases below are exactly those windows: an earlier version of the
|
||||
predicates looked only at `disagg_prefill_inflight_queue` /
|
||||
`disagg_decode_transfer_queue` (plus `scheduler.chunked_req`) and returned True
|
||||
here, letting compaction move pages under in-flight RDMA -- silent KV
|
||||
corruption with no crash.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from sglang.srt.disaggregation.utils import (
|
||||
DisaggregationMode,
|
||||
unified_memory_disagg_move_gate,
|
||||
)
|
||||
from sglang.srt.mem_cache.multi_ended_allocator import MultiEndedAllocator
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=30, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class _FakeTransferQueue:
|
||||
def __init__(self):
|
||||
self.queue: List[object] = []
|
||||
|
||||
|
||||
class _FakePreallocQueue:
|
||||
"""Mirrors the real queue's published-destination bookkeeping."""
|
||||
|
||||
def __init__(self):
|
||||
self._num_published_destinations = 0
|
||||
|
||||
@property
|
||||
def has_published_destinations(self) -> bool:
|
||||
return self._num_published_destinations > 0
|
||||
|
||||
def note_destinations_published(self) -> None:
|
||||
self._num_published_destinations += 1
|
||||
|
||||
def note_destinations_queued(self, count: int) -> None:
|
||||
self._num_published_destinations = max(
|
||||
0, self._num_published_destinations - count
|
||||
)
|
||||
|
||||
|
||||
class _FakeScheduler:
|
||||
def __init__(self, mode: DisaggregationMode):
|
||||
self.disaggregation_mode = mode
|
||||
self.chunked_req: Optional[object] = None
|
||||
self.disagg_prefill_inflight_queue: List[object] = []
|
||||
self.disagg_prefill_pending_chunk_rids: Set[str] = set()
|
||||
self.disagg_decode_transfer_queue = _FakeTransferQueue()
|
||||
self.disagg_decode_prealloc_queue = _FakePreallocQueue()
|
||||
|
||||
|
||||
class TestDecodeMoveGate(CustomTestCase):
|
||||
def test_closed_while_destination_published_but_not_queued(self):
|
||||
"""`pop_preallocated` publishes request A's destination addresses via
|
||||
`send_metadata`, then keeps allocating for request B in the same loop;
|
||||
the batch only reaches the transfer queue after the loop returns. B's
|
||||
allocation can urgently flush the peer sub-allocator, so the gate must
|
||||
stay closed across that window even though the transfer queue is empty.
|
||||
"""
|
||||
scheduler = _FakeScheduler(DisaggregationMode.DECODE)
|
||||
gate = unified_memory_disagg_move_gate(scheduler)
|
||||
self.assertTrue(gate(), "idle decode node should allow compaction")
|
||||
|
||||
# A's destination is now visible to prefill; transfer queue still empty.
|
||||
scheduler.disagg_decode_prealloc_queue.note_destinations_published()
|
||||
self.assertFalse(scheduler.disagg_decode_transfer_queue.queue)
|
||||
self.assertFalse(gate())
|
||||
|
||||
# Handing the batch to the transfer queue transfers responsibility.
|
||||
scheduler.disagg_decode_transfer_queue.queue.append(object())
|
||||
scheduler.disagg_decode_prealloc_queue.note_destinations_queued(1)
|
||||
self.assertFalse(gate(), "transfer queue still holds it")
|
||||
|
||||
scheduler.disagg_decode_transfer_queue.queue.clear()
|
||||
self.assertTrue(gate())
|
||||
|
||||
|
||||
class TestPrefillMoveGate(CustomTestCase):
|
||||
def test_closed_after_final_chunk_clears_chunked_req(self):
|
||||
"""Scheduling the final chunk clears `scheduler.chunked_req`, but the
|
||||
request only reaches `disagg_prefill_inflight_queue` later in the result
|
||||
path. Earlier middle chunks may still be draining in that window, so the
|
||||
gate must not key off `chunked_req` alone.
|
||||
"""
|
||||
scheduler = _FakeScheduler(DisaggregationMode.PREFILL)
|
||||
gate = unified_memory_disagg_move_gate(scheduler)
|
||||
self.assertTrue(gate(), "idle prefill node should allow compaction")
|
||||
|
||||
# A middle chunk went out for rid "r0".
|
||||
scheduler.chunked_req = object()
|
||||
scheduler.disagg_prefill_pending_chunk_rids.add("r0")
|
||||
self.assertFalse(gate())
|
||||
|
||||
# Final chunk scheduled: chunked_req cleared, not yet inflight-queued.
|
||||
scheduler.chunked_req = None
|
||||
self.assertFalse(scheduler.disagg_prefill_inflight_queue)
|
||||
self.assertFalse(gate())
|
||||
|
||||
# Last chunk sent: the request is on the inflight queue, which covers it.
|
||||
scheduler.disagg_prefill_inflight_queue.append(object())
|
||||
scheduler.disagg_prefill_pending_chunk_rids.discard("r0")
|
||||
self.assertFalse(gate())
|
||||
|
||||
scheduler.disagg_prefill_inflight_queue.clear()
|
||||
self.assertTrue(gate())
|
||||
|
||||
def test_reopens_when_middle_sent_request_is_retired_without_final_chunk(self):
|
||||
"""A request aborted after a middle chunk never reaches a `last_chunk`
|
||||
send, so its rid is only dropped by the abort/release cleanup. Without
|
||||
that discard the gate stays closed for the process lifetime and lazy
|
||||
compaction never packs the free list again -- a liveness leak that ends
|
||||
in allocation failure despite reclaimable space.
|
||||
"""
|
||||
scheduler = _FakeScheduler(DisaggregationMode.PREFILL)
|
||||
gate = unified_memory_disagg_move_gate(scheduler)
|
||||
|
||||
scheduler.chunked_req = object()
|
||||
scheduler.disagg_prefill_pending_chunk_rids.add("r0")
|
||||
self.assertFalse(gate())
|
||||
|
||||
# Aborted mid-chunking: chunked_req dropped, no final send, never queued.
|
||||
scheduler.chunked_req = None
|
||||
scheduler.disagg_prefill_pending_chunk_rids.discard("r0")
|
||||
self.assertTrue(gate(), "abort cleanup must let compaction resume")
|
||||
|
||||
|
||||
class TestGatedPeerHolesAreNotSchedulable(CustomTestCase):
|
||||
"""`schedulable_available_size` credits holes a peer urgent-flush would
|
||||
release. While the move gate is closed that flush relocates nothing, so
|
||||
crediting them lets the scheduler admit work `_flush_peer_for_alloc` cannot
|
||||
satisfy; the alloc then returns None and the decode prealloc path treats
|
||||
that as a memory-estimation bug and aborts the scheduler.
|
||||
"""
|
||||
|
||||
class _Peer:
|
||||
def __init__(self, gate):
|
||||
self.lazy_compaction = True
|
||||
self._free_phys_pages = [0, 1, 2, 3] # only len() is read
|
||||
self.entry_bytes_per_page = 512
|
||||
self.disagg_move_gate = gate
|
||||
|
||||
class _Owner:
|
||||
def __init__(self, peer):
|
||||
self._peer = peer
|
||||
|
||||
def _credit(self, gate):
|
||||
peer = self._Peer(gate)
|
||||
owner = self._Owner(peer)
|
||||
return MultiEndedAllocator._peer_drainable_hole_bytes(owner)
|
||||
|
||||
def test_credit_follows_the_gate(self):
|
||||
# No PD gate installed (non-disagg): holes are realizable as before.
|
||||
self.assertEqual(self._credit(gate=None), 4 * 512)
|
||||
# Gate open: peer can compact, so the credit stands.
|
||||
self.assertEqual(self._credit(gate=lambda: True), 4 * 512)
|
||||
# Gate closed: an urgent flush would move nothing, so credit nothing.
|
||||
self.assertEqual(self._credit(gate=lambda: False), 0)
|
||||
|
||||
|
||||
class TestMoveGateRejectsNonPdNode(CustomTestCase):
|
||||
def test_null_mode_is_rejected(self):
|
||||
"""The gate is only meaningful on a PD node; a NULL-mode scheduler is a
|
||||
wiring bug and must not silently produce an always-open predicate."""
|
||||
scheduler = _FakeScheduler(DisaggregationMode.NULL)
|
||||
with self.assertRaises(ValueError):
|
||||
unified_memory_disagg_move_gate(scheduler)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -109,6 +109,9 @@ class TestDecodePreallocQueuePriority(unittest.TestCase):
|
||||
queue.pending_reqs = []
|
||||
queue.retracted_queue = []
|
||||
queue.num_reserved_decode_tokens = 0
|
||||
# `pop_preallocated` credits this counter; `__new__` skips the __init__
|
||||
# that seeds it.
|
||||
queue._num_published_destinations = 0
|
||||
queue._resolve_pending_reqs = MagicMock()
|
||||
queue._update_handshake_waiters = MagicMock()
|
||||
queue._allocatable_tokens = MagicMock(return_value=1000)
|
||||
|
||||
@@ -9,6 +9,7 @@ import torch
|
||||
from sglang.srt.mem_cache.allocator.hisparse import (
|
||||
DeepSeekV4HiSparseTokenToKVPoolAllocator,
|
||||
)
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
@@ -214,14 +215,18 @@ class TestDeepSeekV4HiSparseAllocator(CustomTestCase):
|
||||
manager._send_kvcache_generic = MagicMock(return_value=0)
|
||||
executor = MagicMock()
|
||||
|
||||
manager.send_kvcache(
|
||||
"session",
|
||||
np.array([1], dtype=np.int32),
|
||||
[10000, 20000, 30000],
|
||||
np.array([7], dtype=np.int32),
|
||||
executor,
|
||||
dst_device_kv_indices=np.array([21], dtype=np.int32),
|
||||
)
|
||||
# send_kvcache reads the memory bag (the unified-memory envelope-layout
|
||||
# check), so the context has to be published. This is the non-unified
|
||||
# path -- pin that explicitly rather than leaning on the default.
|
||||
with get_context().override_server_args(enable_unified_memory=False):
|
||||
manager.send_kvcache(
|
||||
"session",
|
||||
np.array([1], dtype=np.int32),
|
||||
[10000, 20000, 30000],
|
||||
np.array([7], dtype=np.int32),
|
||||
executor,
|
||||
dst_device_kv_indices=np.array([21], dtype=np.int32),
|
||||
)
|
||||
|
||||
kwargs = manager._send_kvcache_generic.call_args.kwargs
|
||||
self.assertEqual(kwargs["dst_device_data_ptrs"], {20000, 30000})
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Derived-property tests for the PD whole-envelope transfer addressing.
|
||||
|
||||
PD disaggregation transfers the unified memory pool as whole envelopes with
|
||||
``addr = raw_ptr + physical_index * item_len`` (see
|
||||
``UnifiedMLATokenToKVPool.get_contiguous_buf_infos`` /
|
||||
``UnifiedMambaPool.get_contiguous_buf_infos`` and mooncake's
|
||||
``_send_kvcache_generic`` / ``_send_mamba_state``). That contract only holds if
|
||||
the page-major view builders keep (a) one page's data for ALL layers inside one
|
||||
contiguous ``page_envelope_bytes`` block, and (b) one mamba slot's conv+temporal
|
||||
state for all layers inside one contiguous ``entry_bytes`` block. A
|
||||
"looks equivalent" reordering of the view layout (e.g. layer-major across
|
||||
pages) would silently corrupt every PD transfer while all kernels keep working,
|
||||
because kernels read through the strided views, not through raw offsets.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.layout.page_major import (
|
||||
build_dense_mla_views,
|
||||
build_page_major_mamba_views,
|
||||
mamba_entry_bytes,
|
||||
mla_entry_bytes,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=60, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestMLAEnvelopeTransferAddressing(CustomTestCase):
|
||||
def test_page_envelope_matches_dense_views(self):
|
||||
"""Every (page, layer, slot) row written through the dense MLA views
|
||||
must land at raw_ptr + page * page_envelope_bytes + layer-block offset,
|
||||
i.e. inside the page's transfer envelope."""
|
||||
layer_num, page_size, kv_dim, num_pages = 3, 4, 8, 6
|
||||
store_dtype = torch.bfloat16
|
||||
row_bytes = kv_dim * store_dtype.itemsize
|
||||
page_bytes = page_size * layer_num * row_bytes
|
||||
self.assertEqual(
|
||||
page_bytes,
|
||||
page_size
|
||||
* mla_entry_bytes(
|
||||
layer_num=layer_num,
|
||||
kv_cache_dim=kv_dim,
|
||||
itemsize=store_dtype.itemsize,
|
||||
),
|
||||
)
|
||||
# +1 page envelope of tail pad, as UnifiedKVPool allocates for MLA.
|
||||
raw = torch.zeros((num_pages + 1) * page_bytes, dtype=torch.uint8)
|
||||
views = build_dense_mla_views(
|
||||
raw,
|
||||
layer_num=layer_num,
|
||||
kv_cache_dim=kv_dim,
|
||||
store_dtype=store_dtype,
|
||||
page_size=page_size,
|
||||
num_pages=num_pages,
|
||||
anchor_bytes=0,
|
||||
)
|
||||
torch.manual_seed(0)
|
||||
for page in range(num_pages):
|
||||
for layer in range(layer_num):
|
||||
for off in range(page_size):
|
||||
dense_id = page * layer_num * page_size + off
|
||||
val = torch.randn(kv_dim, dtype=store_dtype)
|
||||
views[layer][dense_id, 0] = val
|
||||
start = (
|
||||
page * page_bytes
|
||||
+ layer * page_size * row_bytes
|
||||
+ off * row_bytes
|
||||
)
|
||||
got = raw[start : start + row_bytes].view(store_dtype)
|
||||
self.assertTrue(torch.equal(got, val), (page, layer, off))
|
||||
|
||||
|
||||
class TestMambaEnvelopeTransferAddressing(CustomTestCase):
|
||||
def test_slot_envelope_is_self_contained(self):
|
||||
"""A slot's conv+temporal state for all layers must live exactly in
|
||||
raw[slot * entry_bytes : (slot+1) * entry_bytes]: no byte outside the
|
||||
envelope may change, and the payload byte count must fill it."""
|
||||
layer_num, max_slots = 2, 5
|
||||
conv_shapes = ((3, 4), (2, 6))
|
||||
temporal_shape = (2, 3, 4)
|
||||
conv_dtype = torch.bfloat16
|
||||
temporal_dtype = torch.float32
|
||||
entry = mamba_entry_bytes(
|
||||
layer_num=layer_num,
|
||||
conv_state_shapes=conv_shapes,
|
||||
conv_dtype=conv_dtype,
|
||||
temporal_state_shape=temporal_shape,
|
||||
temporal_dtype=temporal_dtype,
|
||||
)
|
||||
raw = torch.zeros(max_slots * entry, dtype=torch.uint8)
|
||||
conv_views, temporal_view = build_page_major_mamba_views(
|
||||
raw,
|
||||
layer_num=layer_num,
|
||||
conv_state_shapes=conv_shapes,
|
||||
conv_dtype=conv_dtype,
|
||||
temporal_state_shape=temporal_shape,
|
||||
temporal_dtype=temporal_dtype,
|
||||
max_slots=max_slots,
|
||||
anchor_bytes=0,
|
||||
)
|
||||
torch.manual_seed(0)
|
||||
for slot in range(max_slots):
|
||||
raw.zero_()
|
||||
n_payload = 0
|
||||
for i, conv_view in enumerate(conv_views):
|
||||
val = torch.randn((layer_num,) + conv_shapes[i], dtype=conv_dtype)
|
||||
conv_view[:, slot] = val
|
||||
n_payload += val.numel() * val.element_size()
|
||||
val = torch.randn((layer_num,) + temporal_shape, dtype=temporal_dtype)
|
||||
temporal_view[:, slot] = val
|
||||
n_payload += val.numel() * val.element_size()
|
||||
|
||||
outside = torch.cat([raw[: slot * entry], raw[(slot + 1) * entry :]])
|
||||
self.assertTrue(
|
||||
bool(outside.eq(0).all()),
|
||||
f"slot {slot} state bled outside its transfer envelope",
|
||||
)
|
||||
self.assertEqual(n_payload, entry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user