Support unified memory page-envelope transfers in PD (#39477)

Co-authored-by: yhzhuang <yhzhuang@fb.com>
Co-authored-by: Lianmin Zheng <lianminzheng@gmail.com>
Co-authored-by: Yonghao Zhuang <yhzhuang@users.noreply.github.com>
Co-authored-by: Cheng Wan <cheng.wan@radixark.ai>
This commit is contained in:
Yonghao Zhuang
2026-09-18 17:39:50 -07:00
committed by GitHub
co-authored by yhzhuang Lianmin Zheng Yonghao Zhuang Cheng Wan
parent d0730a0e8b
commit 5931fd60ee
27 changed files with 1012 additions and 247 deletions
@@ -88,10 +88,8 @@ class Memory(msgspec.Struct):
"Replace the statically-partitioned hybrid-model pools (full-attn KV + " "Replace the statically-partitioned hybrid-model pools (full-attn KV + "
"SWA/Mamba state) with one byte buffer split dynamically between " "SWA/Mamba state) with one byte buffer split dynamically between "
"sub-pools. Requires the Triton attention / linear-attn / Mamba " "sub-pools. Requires the Triton attention / linear-attn / Mamba "
"backends. PD disaggregation is supported over mooncake at equal " "backends. Supported PD-disaggregation and speculative-decoding "
"attention TP with pp=1; not yet compatible with hierarchical / " "configurations are validated at startup.",
"host-tiered KV cache, prefill cuda-graph capture, or speculative "
"decoding other than DSPARK.",
] = False ] = False
enable_session_radix_cache: A[ enable_session_radix_cache: A[
bool, bool,
+22 -5
View File
@@ -437,11 +437,12 @@ def handle_unified_memory_pool(server_args: Any) -> None:
if not cfg.enable_unified_memory: if not cfg.enable_unified_memory:
return return
if cfg.disaggregation_mode != "null": if cfg.disaggregation_mode != "null":
# Constraints of the whole-envelope transfer; see # Constraints of the whole-envelope transfer; see the unified MHA and
# UnifiedMLATokenToKVPool.get_contiguous_buf_infos. # MLA pool get_contiguous_buf_infos implementations.
assert cfg.disaggregation_transfer_backend == "mooncake", ( supported_backends = server_args._unified_memory_pd_transfer_backends()
"--enable-unified-memory with PD disaggregation supports only " assert cfg.disaggregation_transfer_backend in supported_backends, (
"the mooncake transfer backend; got " "--enable-unified-memory with PD disaggregation supports only these "
f"transfer backends: {', '.join(sorted(supported_backends))}; got "
f"{cfg.disaggregation_transfer_backend!r}." f"{cfg.disaggregation_transfer_backend!r}."
) )
assert cfg.pp_size == 1, ( assert cfg.pp_size == 1, (
@@ -449,6 +450,13 @@ def handle_unified_memory_pool(server_args: Any) -> None:
"pipeline parallelism (whole-envelope transfer has no per-layer " "pipeline parallelism (whole-envelope transfer has no per-layer "
"entries to subset)." "entries to subset)."
) )
assert not (
cfg.disaggregation_transfer_backend == "mooncake"
and cfg.speculative_algorithm is not None
), (
"--enable-unified-memory with PD disaggregation does not support "
"speculative decoding with the Mooncake transfer backend."
)
assert not envs.SGLANG_DISABLE_LAZY_COMPACTION.get(), ( assert not envs.SGLANG_DISABLE_LAZY_COMPACTION.get(), (
"--enable-unified-memory with PD disaggregation requires lazy " "--enable-unified-memory with PD disaggregation requires lazy "
"compaction; unset SGLANG_DISABLE_LAZY_COMPACTION." "compaction; unset SGLANG_DISABLE_LAZY_COMPACTION."
@@ -459,6 +467,15 @@ def handle_unified_memory_pool(server_args: Any) -> None:
"ships host/C4 rows straight from the allocator, bypassing the " "ships host/C4 rows straight from the allocator, bypassing the "
"virtual->physical translation the unified pool needs." "virtual->physical translation the unified pool needs."
) )
assert cfg.disaggregation_decode_retraction_backup != "host_pool", (
"--enable-unified-memory with PD disaggregation does not support "
"--disaggregation-decode-retraction-backup=host_pool; use "
"cpu_tensor (the automatic default for unified pools)."
)
assert not cfg.disaggregation_decode_enable_offload_kvcache, (
"--enable-unified-memory with PD disaggregation does not yet support "
"--disaggregation-decode-enable-offload-kvcache."
)
assert cfg.speculative_algorithm in (None, "DSPARK"), ( assert cfg.speculative_algorithm in (None, "DSPARK"), (
"--enable-unified-memory only supports --speculative-algorithm " "--enable-unified-memory only supports --speculative-algorithm "
"DSPARK (chain draft); other speculative algorithms are not yet " "DSPARK (chain draft); other speculative algorithms are not yet "
+127 -87
View File
@@ -455,6 +455,28 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
and hasattr(self.token_to_kv_pool_allocator, "alloc_extend_swa_tail") and hasattr(self.token_to_kv_pool_allocator, "alloc_extend_swa_tail")
) )
def _uses_swa_reservation(self) -> bool:
return (
self._uses_swa_tail_prealloc()
or self.token_to_kv_pool_allocator.prealloc_fits_assumes_reclaim()
)
def _prealloc_reservation_fits(
self,
full_tokens: int,
swa_tokens: int,
*,
full_allocatable_tokens: int,
swa_allocatable_tokens: Optional[int],
) -> bool:
return self.token_to_kv_pool_allocator.prealloc_fits(
self.tree_cache,
full_tokens,
swa_tokens,
full_budget_tokens=full_allocatable_tokens,
swa_budget_tokens=swa_allocatable_tokens,
)
def _release_matched_prefix_lock(self, req: Req) -> None: def _release_matched_prefix_lock(self, req: Req) -> None:
if req.swa_prefix_lock_released: if req.swa_prefix_lock_released:
self.tree_cache.dec_lock_ref(req.last_node, req.lock_receipt, skip_swa=True) self.tree_cache.dec_lock_ref(req.last_node, req.lock_receipt, skip_swa=True)
@@ -463,24 +485,16 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
self.tree_cache.dec_lock_ref(req.last_node, req.lock_receipt) self.tree_cache.dec_lock_ref(req.last_node, req.lock_receipt)
def _reclaim_swa_tail_capacity( def _reclaim_swa_tail_capacity(
self, swa_tail_len: int, req_id: str self, swa_tail_len: int, req_id: str, *, full_len: int = 0
) -> Optional[str]: ) -> Optional[str]:
page_size = self.token_to_kv_pool_allocator.page_size allocator = self.token_to_kv_pool_allocator
required = ceil_align(swa_tail_len, page_size) page_size = allocator.page_size
available = self.token_to_kv_pool_allocator.swa_available_size() shortfall = allocator.reclaim_for_prealloc(
if available < required: self.tree_cache,
self.tree_cache.evict_for_alloc( ceil_align(full_len, page_size),
EvictParams(swa_num_tokens=required - available) ceil_align(swa_tail_len, page_size),
) )
available = self.token_to_kv_pool_allocator.swa_available_size() return None if shortfall is None else f"{shortfall}, req={req_id}"
if available < required:
return (
f"SWA eviction insufficient: needed={required}, "
f"available={available}, req={req_id}"
)
return None
# SWA caches expose full-attention accounting through full_* accessors. # SWA caches expose full-attention accounting through full_* accessors.
def _radix_full_evictable(self) -> int: def _radix_full_evictable(self) -> int:
@@ -581,8 +595,8 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
kv_data_mem_kinds += ["VRAM"] * len(device_kv_data_ptrs[c4_layer_num:]) kv_data_mem_kinds += ["VRAM"] * len(device_kv_data_ptrs[c4_layer_num:])
num_draft_entries = 0 num_draft_entries = 0
if self.draft_token_to_kv_pool is not None: if self.draft_token_to_kv_pool is not None:
# We should also transfer draft model kv cache. The indices are # Draft KV shares target virtual ids. Unified target KV is transferred
# always shared with a target model. # with physical ids, so it needs a separate draft index vector.
draft_kv_data_ptrs, draft_kv_data_lens, draft_kv_item_lens = ( draft_kv_data_ptrs, draft_kv_data_lens, draft_kv_item_lens = (
self.draft_token_to_kv_pool.get_contiguous_buf_infos() self.draft_token_to_kv_pool.get_contiguous_buf_infos()
) )
@@ -814,30 +828,40 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
return len(req.origin_input_ids) + max(len(req.output_ids) - 1, 0) return len(req.origin_input_ids) + max(len(req.output_ids) - 1, 0)
def _check_if_req_exceed_kv_capacity(self, req: Req) -> bool: def _check_if_req_exceed_kv_capacity(self, req: Req) -> bool:
# HiSparse admits up to the host-backed logical capacity. message = None
if self.scheduler.enable_hisparse: allocator = self.token_to_kv_pool_allocator
capacity = self.scheduler.tp_worker.model_runner.max_token_pool_size full_required, swa_required = self._prealloc_required_tokens(req)
else: if not self._uses_swa_tail_prealloc():
capacity = self.max_total_num_tokens swa_required = full_required
input_len = self._rebootstrap_prefill_len(req) ceiling_fits = allocator.prealloc_ceiling_fits(full_required, swa_required)
if input_len > capacity: if ceiling_fits is False:
message = f"Request {req.rid} exceeds the maximum number of tokens: {input_len} > {capacity}" message = (
f"Request {req.rid} exceeds the unified FULL/SWA KV byte "
f"budget: full={full_required}, swa={swa_required}"
)
elif ceiling_fits is None:
# HiSparse admits up to the host-backed logical capacity.
capacity = (
self.scheduler.tp_worker.model_runner.max_token_pool_size
if self.scheduler.enable_hisparse
else self.max_total_num_tokens
)
input_len = self._rebootstrap_prefill_len(req)
if input_len > capacity:
message = f"Request {req.rid} exceeds the maximum number of tokens: {input_len} > {capacity}"
elif self._uses_swa_tail_prealloc():
_, swa_required = self._prealloc_required_tokens(req)
swa_capacity = self.token_to_kv_pool_allocator.size_swa
if swa_required > swa_capacity:
message = (
f"Request {req.rid} requires too many SWA KV tokens for "
f"decode preallocation: {swa_required} > {swa_capacity}"
)
if message is not None:
logger.error(message) logger.error(message)
prepare_abort(req, message, status_code=HTTPStatus.BAD_REQUEST) prepare_abort(req, message, status_code=HTTPStatus.BAD_REQUEST)
self.scheduler.output_streamer.stream_output([req], req.return_logprob) self.scheduler.output_streamer.stream_output([req], req.return_logprob)
return True return True
if self._uses_swa_tail_prealloc():
_, swa_required = self._prealloc_required_tokens(req)
swa_capacity = self.token_to_kv_pool_allocator.size_swa
if swa_required > swa_capacity:
message = (
f"Request {req.rid} requires too many SWA KV tokens for "
f"decode preallocation: {swa_required} > {swa_capacity}"
)
logger.error(message)
prepare_abort(req, message, status_code=HTTPStatus.BAD_REQUEST)
self.scheduler.output_streamer.stream_output([req], req.return_logprob)
return True
return False return False
def extend(self, reqs: List[Req], is_retracted: bool = False) -> None: def extend(self, reqs: List[Req], is_retracted: bool = False) -> None:
@@ -870,8 +894,8 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
# allocate memory # allocate memory
resumed_reqs = [] resumed_reqs = []
indices_to_remove = set() indices_to_remove = set()
uses_swa_tail_prealloc = self._uses_swa_tail_prealloc() swa_allocatable_tokens = None
if uses_swa_tail_prealloc: if self._uses_swa_reservation():
full_allocatable_tokens, swa_allocatable_tokens = ( full_allocatable_tokens, swa_allocatable_tokens = (
self._swa_aware_allocatable_token_budgets(count_retracted=False) self._swa_aware_allocatable_token_budgets(count_retracted=False)
) )
@@ -888,17 +912,31 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
break break
full_required, swa_required = self._prealloc_required_tokens(req) full_required, swa_required = self._prealloc_required_tokens(req)
if full_required > full_allocatable_tokens: if not self._prealloc_reservation_fits(
break full_required,
if uses_swa_tail_prealloc and swa_required > swa_allocatable_tokens: swa_required,
full_allocatable_tokens=full_allocatable_tokens,
swa_allocatable_tokens=swa_allocatable_tokens,
):
break break
if self.token_to_kv_pool_allocator.prealloc_fits_assumes_reclaim():
full_len, swa_len = self._prealloc_kv_lens(req)
if (
self._reclaim_swa_tail_capacity(swa_len, req.rid, full_len=full_len)
is not None
):
break
resumed_reqs.append(req) resumed_reqs.append(req)
indices_to_remove.add(i) indices_to_remove.add(i)
req.is_retracted = False req.is_retracted = False
self._pre_alloc(req) self._pre_alloc(req)
full_allocatable_tokens -= full_required full_allocatable_tokens = self._allocatable_token_budgets(
if uses_swa_tail_prealloc: count_retracted=False,
extra_reserved_reqs=len(resumed_reqs),
)
if swa_allocatable_tokens is not None:
swa_allocatable_tokens = self._swa_tail_allocatable_token_budget( swa_allocatable_tokens = self._swa_tail_allocatable_token_budget(
count_retracted=False, count_retracted=False,
extra_reserved_reqs=len(resumed_reqs), extra_reserved_reqs=len(resumed_reqs),
@@ -1159,8 +1197,8 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
) )
uses_swa_tail_prealloc = self._uses_swa_tail_prealloc() uses_swa_tail_prealloc = self._uses_swa_tail_prealloc()
swa_allocatable_tokens = 0 swa_allocatable_tokens = None
if uses_swa_tail_prealloc: if self._uses_swa_reservation():
retractable_swa_tokens = sum( retractable_swa_tokens = sum(
self._swa_retractable_len(r) for r in self.scheduler.running_batch.reqs self._swa_retractable_len(r) for r in self.scheduler.running_batch.reqs
) )
@@ -1352,27 +1390,18 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
required_alloc_tokens + self.num_reserved_decode_tokens required_alloc_tokens + self.num_reserved_decode_tokens
) )
if ( full_required_for_admission = max(
max( required_tokens_for_request,
required_tokens_for_request, origin_input_len
origin_input_len - prefix_len
- prefix_len + min(
+ min( decode_req.req.sampling_params.max_new_tokens,
decode_req.req.sampling_params.max_new_tokens, CLIP_MAX_NEW_TOKEN,
CLIP_MAX_NEW_TOKEN,
)
- retractable_tokens,
) )
> full_allocatable_tokens - retractable_tokens,
): )
if prefix_match is not None and prefix_match.l1_prefix_len > 0: swa_required_for_admission = 0
self._release_matched_prefix_lock(decode_req.req) swa_len = required_alloc_tokens
break
if required_tokens_for_request > full_allocatable_tokens:
if prefix_match is not None and prefix_match.l1_prefix_len > 0:
self._release_matched_prefix_lock(decode_req.req)
break
if uses_swa_tail_prealloc: if uses_swa_tail_prealloc:
_, swa_required = self._prealloc_required_tokens(decode_req.req) _, swa_required = self._prealloc_required_tokens(decode_req.req)
_, swa_len = self._prealloc_kv_lens(decode_req.req) _, swa_len = self._prealloc_kv_lens(decode_req.req)
@@ -1380,19 +1409,28 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
decode_req.req.sampling_params.max_new_tokens, decode_req.req.sampling_params.max_new_tokens,
CLIP_MAX_NEW_TOKEN, CLIP_MAX_NEW_TOKEN,
) )
if ( swa_required_for_admission = max(
max( swa_required,
swa_required, swa_len + max_new_tokens - retractable_swa_tokens,
swa_len + max_new_tokens - retractable_swa_tokens, )
) elif swa_allocatable_tokens is not None:
> swa_allocatable_tokens swa_required_for_admission = full_required_for_admission
):
if prefix_match is not None and prefix_match.l1_prefix_len > 0:
self._release_matched_prefix_lock(decode_req.req)
break
if not self._prealloc_reservation_fits(
full_required_for_admission,
swa_required_for_admission,
full_allocatable_tokens=full_allocatable_tokens,
swa_allocatable_tokens=swa_allocatable_tokens,
):
if prefix_match is not None and prefix_match.l1_prefix_len > 0:
self._release_matched_prefix_lock(decode_req.req)
break
if swa_allocatable_tokens is not None:
reclaim_error = self._reclaim_swa_tail_capacity( reclaim_error = self._reclaim_swa_tail_capacity(
swa_len, decode_req.req.rid swa_len,
decode_req.req.rid,
full_len=required_alloc_tokens,
) )
if reclaim_error is not None: if reclaim_error is not None:
if prefix_match is not None and prefix_match.l1_prefix_len > 0: if prefix_match is not None and prefix_match.l1_prefix_len > 0:
@@ -1431,7 +1469,7 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
extra_reserved_reqs=len(preallocated_reqs) + 1, extra_reserved_reqs=len(preallocated_reqs) + 1,
hicache_reserved_tokens=reserved_restore_tokens, hicache_reserved_tokens=reserved_restore_tokens,
) )
if uses_swa_tail_prealloc: if swa_allocatable_tokens is not None:
swa_allocatable_tokens = self._swa_tail_allocatable_token_budget( swa_allocatable_tokens = self._swa_tail_allocatable_token_budget(
retractable_tokens=retractable_tokens, retractable_tokens=retractable_tokens,
retractable_swa_tokens=retractable_swa_tokens, retractable_swa_tokens=retractable_swa_tokens,
@@ -1442,6 +1480,9 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
page_size = self.token_to_kv_pool_allocator.page_size page_size = self.token_to_kv_pool_allocator.page_size
kv_transfer_page_size = page_size kv_transfer_page_size = page_size
raw_kv_indices = self.req_to_token_pool.req_to_token[
decode_req.req.kv.req_pool_idx
][total_prefix_len:origin_input_len]
if self.scheduler.enable_hisparse: if self.scheduler.enable_hisparse:
# Direct-to-host sends host/C4 rows; keep allocator.page_size # Direct-to-host sends host/C4 rows; keep allocator.page_size
# logical and use the compressed page size only for these indices. # logical and use the compressed page size only for these indices.
@@ -1453,12 +1494,9 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
kv_indices = dst_kv_indices[: origin_input_len - prefix_len] kv_indices = dst_kv_indices[: origin_input_len - prefix_len]
else: else:
# Only send delta indices (beyond prefix) to prefill. # Only send delta indices (beyond prefix) to prefill.
kv_indices = self.req_to_token_pool.req_to_token[
decode_req.req.kv.req_pool_idx
][total_prefix_len:origin_input_len]
kv_indices = ( kv_indices = (
self.token_to_kv_pool_allocator.translate_kv_indices_for_transfer( self.token_to_kv_pool_allocator.translate_kv_indices_for_transfer(
kv_indices raw_kv_indices
) )
) )
@@ -1735,7 +1773,7 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
# HiSparse pre-alloc only allocates logical indices, so the # HiSparse pre-alloc only allocates logical indices, so the
# logical pool is the binding constraint for admission control. # logical pool is the binding constraint for admission control.
available_size = logical_allocator.available_size() available_size = logical_allocator.available_size()
elif self._uses_swa_tail_prealloc(): elif self._uses_swa_reservation():
available_size = self.token_to_kv_pool_allocator.full_available_size() available_size = self.token_to_kv_pool_allocator.full_available_size()
if get_disagg().disaggregation_decode_enable_radix_cache: if get_disagg().disaggregation_decode_enable_radix_cache:
available_size += self._radix_full_evictable() available_size += self._radix_full_evictable()
@@ -1802,8 +1840,10 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
# pool) over-reserves SWA in steady state. Cap by the actual # pool) over-reserves SWA in steady state. Cap by the actual
# remaining headroom up to per-req window cap. # remaining headroom up to per-req window cap.
window_size = self.scheduler.sliding_window_size or 0 window_size = self.scheduler.sliding_window_size or 0
swa_total = self.token_to_kv_pool_allocator.size_swa allocator = self.token_to_kv_pool_allocator
swa_available = self.token_to_kv_pool_allocator.swa_available_size() _, (swa_total, swa_available) = allocator.swa_capacity_and_available(
full_capacity=allocator.size_full, swa_capacity=allocator.size_swa
)
# Per-request SWA ring: cached prefixes still report swa_evictable, but # Per-request SWA ring: cached prefixes still report swa_evictable, but
# evicting them frees no ring space. # evicting them frees no ring space.
swa_evictable = ( swa_evictable = (
@@ -790,11 +790,18 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
# Published layer IDs give exact pairing; plain-MHA peers publish none # Published layer IDs give exact pairing; plain-MHA peers publish none
# and keep positional slicing. # and keep positional slicing.
has_layer_ids = bool(src_layer_ids or dst_layer_ids) has_layer_ids = bool(src_layer_ids or dst_layer_ids)
# Unified SWA publishes one page-envelope region even on an MHA backend.
is_single_region_swa = (
state_type == StateType.SWA
and len(src_data_ptrs) == 1
and len(dst_data_ptrs) == 1
)
if ( if (
self.is_mla_backend self.is_mla_backend
or self.is_hybrid_mla_backend or self.is_hybrid_mla_backend
or force_flat or force_flat
or has_layer_ids or has_layer_ids
or is_single_region_swa
): ):
# Layer IDs map PP-local buffers to global decode entries. # Layer IDs map PP-local buffers to global decode entries.
# Registrations without them retain the existing PP mapping. # Registrations without them retain the existing PP mapping.
@@ -1060,12 +1067,6 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
prefill_data_indices=prefill_kv_indices, prefill_data_indices=prefill_kv_indices,
dst_data_indices=dst_kv_indices, dst_data_indices=dst_kv_indices,
executor=executor, executor=executor,
# The unified pool registers ONE region holding every layer's K and
# V inside each page envelope. The MHA branch would half-split that
# single region into K and V halves and compute num_kv_layers = 0,
# transferring nothing at all; the flat branch addresses the region
# as-is. MLA-unified already reaches the flat branch via
# is_mla_backend, so this only adds the MHA-unified peer.
force_flat=get_memory().enable_unified_memory, force_flat=get_memory().enable_unified_memory,
src_layer_ids=self.kv_args.kv_layer_ids, src_layer_ids=self.kv_args.kv_layer_ids,
dst_layer_ids=dst_layer_ids, dst_layer_ids=dst_layer_ids,
+6 -5
View File
@@ -271,8 +271,8 @@ class PrefillBootstrapQueue:
) )
num_draft_entries = 0 num_draft_entries = 0
if draft_kv_pool is not None: if draft_kv_pool is not None:
# We should also transfer draft model kv cache. The indices are # Draft KV shares target virtual ids. Unified target KV is transferred
# always shared with a target model. # with physical ids, so it needs a separate draft index vector.
draft_kv_data_ptrs, draft_kv_data_lens, draft_kv_item_lens = ( draft_kv_data_ptrs, draft_kv_data_lens, draft_kv_item_lens = (
draft_kv_pool.get_contiguous_buf_infos() draft_kv_pool.get_contiguous_buf_infos()
) )
@@ -1463,14 +1463,14 @@ class SchedulerDisaggregationPrefillMixin:
for seg_start, seg_end in segments: for seg_start, seg_end in segments:
is_final_segment = seg_end == end_idx is_final_segment = seg_end == end_idx
kv_indices = self.req_to_token_pool.req_to_token[ raw_kv_indices = self.req_to_token_pool.req_to_token[
req.kv.req_pool_idx, seg_start:seg_end req.kv.req_pool_idx, seg_start:seg_end
] ]
# Unified memory: req_to_token holds VIRTUAL ids; the transfer needs # Unified memory: req_to_token holds VIRTUAL ids; the transfer needs
# physical ones. Per segment, since each is its own gather. # physical ones. Per segment, since each is its own gather.
kv_indices = ( kv_indices = (
self.token_to_kv_pool_allocator.translate_kv_indices_for_transfer( self.token_to_kv_pool_allocator.translate_kv_indices_for_transfer(
kv_indices raw_kv_indices
) )
) )
page_indices = kv_to_page_indices(kv_indices, page_size) page_indices = kv_to_page_indices(kv_indices, page_size)
@@ -1479,9 +1479,10 @@ class SchedulerDisaggregationPrefillMixin:
len(page_indices), segment_is_last len(page_indices), segment_is_last
): ):
continue continue
send_state_indices = state_indices if segment_is_last else None
req.disagg_kv_sender.send( req.disagg_kv_sender.send(
page_indices, page_indices,
state_indices if segment_is_last else None, send_state_indices,
num_kv_tokens=seg_end - seg_start, num_kv_tokens=seg_end - seg_start,
) )
req.start_send_idx = end_idx req.start_send_idx = end_idx
@@ -95,6 +95,43 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
0, min(max_new_tokens, token_capacity - paged_input - self.page_size - 1) 0, min(max_new_tokens, token_capacity - paged_input - self.page_size - 1)
) )
def prealloc_fits_assumes_reclaim(self) -> bool:
"""Whether `prealloc_fits` answers about the state reachable AFTER
reclaiming the evictable pages, so admitting on it still owes the
reclaim. False when the answer describes the pool as it stands.
"""
return False
def prealloc_ceiling_fits(self, full_tokens: int, swa_tokens: int) -> bool | None:
"""Whether a demand this size could EVER be preallocated, or None when
this pool has no ceiling of its own and the caller's token capacity is
the only bound.
"""
return None
def prealloc_fits(
self,
tree_cache,
full_tokens: int,
swa_tokens: int,
*,
full_budget_tokens: int,
swa_budget_tokens: int | None = None,
) -> bool:
"""Whether a decode-node preallocation of this size fits.
The budgets are the scheduler's policy: what each side has left once
decode headroom and retraction are reserved. Separate buffers make the
two sides independent, so each is checked against its own budget and
``tree_cache`` is never read -- what it could reclaim is already
inside that budget. A pool that cuts both sides from one buffer
overrides this to price them together, since a per-side token budget
cannot express a shared byte envelope.
"""
return full_tokens <= full_budget_tokens and (
swa_budget_tokens is None or swa_tokens <= swa_budget_tokens
)
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> bool | None: def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> bool | None:
"""Evict unlocked prefix-cache entries until this allocator can serve """Evict unlocked prefix-cache entries until this allocator can serve
``num_tokens`` or nothing evictable remains. ``num_tokens`` or nothing evictable remains.
@@ -349,10 +349,6 @@ class DeepSeekV4HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def translate_swa_indices_for_transfer( def translate_swa_indices_for_transfer(
self, kv_indices: torch.Tensor self, kv_indices: torch.Tensor
) -> torch.Tensor: ) -> torch.Tensor:
# Delegated like the read-path translate above: this composite is not a
# SWA allocator itself, so it inherits neither the default nor an
# override, and the PD payload path calls this on whatever allocator
# the scheduler holds.
return self.logical_attn_allocator.translate_swa_indices_for_transfer( return self.logical_attn_allocator.translate_swa_indices_for_transfer(
kv_indices kv_indices
) )
+25 -8
View File
@@ -166,6 +166,30 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self, tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens self, tree_cache, num_mixed_decode_tokens=num_mixed_decode_tokens
) )
def reclaim_for_prealloc(
self, tree_cache, full_tokens: int, swa_tokens: int
) -> str | None:
"""Free room for a decode-node preallocation; None means it is ready.
Returns a description of the shortfall when it cannot be met, for the
caller to attach to whichever request it was admitting. Separate
buffers make the sliding-window side the only one that needs
reclaiming here, since the full side is priced by the caller's budget.
"""
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
available = self.swa_available_size()
if available < swa_tokens:
tree_cache.evict_for_alloc(
EvictParams(swa_num_tokens=swa_tokens - available)
)
available = self.swa_available_size()
if available < swa_tokens:
return (
f"SWA eviction insufficient: needed={swa_tokens}, available={available}"
)
return None
def swa_capacity_and_available(self, *, full_capacity, swa_capacity): def swa_capacity_and_available(self, *, full_capacity, swa_capacity):
return ( return (
(full_capacity, self.full_available_size()), (full_capacity, self.full_available_size()),
@@ -232,14 +256,7 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def translate_swa_indices_for_transfer( def translate_swa_indices_for_transfer(
self, kv_indices: torch.Tensor self, kv_indices: torch.Tensor
) -> torch.Tensor: ) -> torch.Tensor:
"""Sliding-window token ids as the PD transfer engine addresses them. """Map full-pool token ids to SWA-buffer token ids for PD transfer."""
The sibling of `translate_kv_indices_for_transfer` for the SWA state
component. On a static pool the sliding-window buffers are indexed by
the same ids the kernels use, so the read-path translate IS the answer.
A virtual-id pool must override: the transfer addresses raw bytes and
needs PHYSICAL ids, not kernel-facing ones.
"""
return self.translate_loc_from_full_to_swa(kv_indices) return self.translate_loc_from_full_to_swa(kv_indices)
def alloc(self, need_size: int): def alloc(self, need_size: int):
@@ -388,6 +388,8 @@ class UnifiedSWAAllocatorBase(SWATokenToKVPoolAllocator):
seq_lens_cpu: torch.Tensor, seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor, last_loc: torch.Tensor,
extend_num_tokens: int, extend_num_tokens: int,
*,
num_swa_pages: Optional[int] = None,
) -> Optional[Tuple[torch.Tensor, torch.Tensor]]: ) -> Optional[Tuple[torch.Tensor, torch.Tensor]]:
"""Run the full side's paged extend and report which virtual PAGES it """Run the full side's paged extend and report which virtual PAGES it
newly took. Returns (virtual TOKEN ids, new virtual PAGE ids), or None newly took. Returns (virtual TOKEN ids, new virtual PAGE ids), or None
@@ -402,7 +404,10 @@ class UnifiedSWAAllocatorBase(SWATokenToKVPoolAllocator):
prefix_lens=prefix_lens_cpu, prefix_lens=prefix_lens_cpu,
) )
need_tokens = num_new_pages * self.page_size need_tokens = num_new_pages * self.page_size
if not self.ensure_capacity(need_tokens, need_tokens): swa_tokens = (
need_tokens if num_swa_pages is None else num_swa_pages * self.page_size
)
if not self.ensure_capacity(need_tokens, swa_tokens):
return None return None
# Snapshot the virtual PAGES the kernel will consume; clone so swa keeps # Snapshot the virtual PAGES the kernel will consume; clone so swa keeps
@@ -478,14 +483,22 @@ class UnifiedSWAAllocatorBase(SWATokenToKVPoolAllocator):
sink and is skipped by `free`'s `swa_v2p_page > 0` mask -- exactly the sink and is skipped by `free`'s `swa_v2p_page > 0` mask -- exactly the
out-of-window state the ratchet produces via `free_swa`. out-of-window state the ratchet produces via `free_swa`.
Admission is priced at the FULL side's page count, as plain Admission prices FULL's new pages and only the new pages in the SWA
`alloc_extend` is: pessimistic when the tail is short, but it reuses tail. A partial prefix page is already bound and costs no new SWA page.
the composite's audited joint capacity path, and the bytes actually
held still follow the tail.
""" """
assert len(prefix_lens_cpu) == 1 assert len(prefix_lens_cpu) == 1
assert 0 <= swa_tail_len <= extend_num_tokens assert 0 <= swa_tail_len <= extend_num_tokens
with record_function("UnifiedSWAAlloc.alloc_extend_swa_tail"): with record_function("UnifiedSWAAlloc.alloc_extend_swa_tail"):
prefix_len = int(prefix_lens_cpu[0])
seq_len = int(seq_lens_cpu[0])
first_new_page = (prefix_len + self.page_size - 1) // self.page_size
first_tail_page = (seq_len - swa_tail_len) // self.page_size
num_swa_pages = (
(seq_len + self.page_size - 1) // self.page_size
- max(first_new_page, first_tail_page)
if swa_tail_len
else 0
)
extended = self._extend_in_virtual_space( extended = self._extend_in_virtual_space(
prefix_lens, prefix_lens,
prefix_lens_cpu, prefix_lens_cpu,
@@ -493,6 +506,7 @@ class UnifiedSWAAllocatorBase(SWATokenToKVPoolAllocator):
seq_lens_cpu, seq_lens_cpu,
last_loc, last_loc,
extend_num_tokens, extend_num_tokens,
num_swa_pages=num_swa_pages,
) )
if extended is None: if extended is None:
return None return None
@@ -819,6 +833,63 @@ class UnifiedSWATokenToKVPoolAllocator(UnifiedSWAAllocatorBase):
"""No float in a two-END chain -- nothing can slide.""" """No float in a two-END chain -- nothing can slide."""
return None return None
def prealloc_fits_assumes_reclaim(self) -> bool:
return True
def prealloc_ceiling_fits(self, full_tokens: int, swa_tokens: int) -> bool | None:
return self.can_reserve(full_tokens, swa_tokens, empty_pool=True)
def reclaim_for_prealloc(
self, tree_cache, full_tokens: int, swa_tokens: int
) -> str | None:
"""Reclaim both sides together: freeing FULL pages can open SWA room
and the reverse, so the shared envelope is the only gate worth
re-checking."""
ready = self.evict_to_free_tokens(
tree_cache, full_tokens, swa_num_tokens=swa_tokens
)
if ready is None:
ready = self.ensure_capacity(full_tokens, swa_tokens)
if ready:
return None
return (
"Unified FULL/SWA byte reclamation insufficient: "
f"needed=({full_tokens}, {swa_tokens})"
)
def prealloc_fits(
self,
tree_cache,
full_tokens: int,
swa_tokens: int,
*,
full_budget_tokens: int,
swa_budget_tokens: int | None = None,
) -> bool:
"""Price both sides against the shared byte envelope.
There is no per-side capacity for the scheduler's budget to be
compared against, so the gap between that budget and what this side
can currently hand out is folded back into the demand; `can_reserve`
then prices the whole ask in bytes. Reachable only for hybrid-SWA
models, so the tree's `full_*` accounting is the full-attention one.
"""
full_evictable_tokens = tree_cache.full_evictable_size()
swa_evictable_tokens = tree_cache.swa_evictable_size()
full_tokens += (
self.full_available_size() + full_evictable_tokens - full_budget_tokens
)
if swa_budget_tokens is not None:
swa_tokens += (
self.swa_available_size() + swa_evictable_tokens - swa_budget_tokens
)
return self.can_reserve(
full_tokens,
swa_tokens,
full_evictable_tokens=full_evictable_tokens,
swa_evictable_tokens=swa_evictable_tokens,
)
def reclaim_plan( def reclaim_plan(
self, self,
full_tokens: int | float, full_tokens: int | float,
@@ -1086,14 +1157,17 @@ class UnifiedSWATokenToKVPoolAllocator(UnifiedSWAAllocatorBase):
hi = mid - 1 hi = mid - 1
return lo return lo
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> bool | None: def evict_to_free_tokens(
self, tree_cache, num_tokens: int, *, swa_num_tokens: Optional[int] = None
) -> bool | None:
from sglang.srt.mem_cache.base_prefix_cache import EvictParams from sglang.srt.mem_cache.base_prefix_cache import EvictParams
if tree_cache is None or tree_cache.is_chunk_cache(): if tree_cache is None or tree_cache.is_chunk_cache():
return return
required_swa = num_tokens if swa_num_tokens is None else swa_num_tokens
reclaim_plan = self.reclaim_plan( reclaim_plan = self.reclaim_plan(
num_tokens, num_tokens,
num_tokens, required_swa,
full_evictable_tokens=tree_cache.full_evictable_size(), full_evictable_tokens=tree_cache.full_evictable_size(),
swa_evictable_tokens=tree_cache.swa_evictable_size(), swa_evictable_tokens=tree_cache.swa_evictable_size(),
) )
@@ -1105,7 +1179,7 @@ class UnifiedSWATokenToKVPoolAllocator(UnifiedSWAAllocatorBase):
EvictParams(num_tokens=full_reclaim, swa_num_tokens=swa_reclaim) EvictParams(num_tokens=full_reclaim, swa_num_tokens=swa_reclaim)
) )
# A zero-reclaim plan can still depend on compaction before allocation. # A zero-reclaim plan can still depend on compaction before allocation.
return self.ensure_capacity(num_tokens, num_tokens) return self.ensure_capacity(num_tokens, required_swa)
def verify_byte_accounting(self) -> List[str]: def verify_byte_accounting(self) -> List[str]:
return ( return (
@@ -1234,69 +1308,104 @@ class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWAAllocatorBase):
if ( if (
full_tokens < 0 full_tokens < 0
or swa_tokens < 0 or swa_tokens < 0
or full_tokens != swa_tokens
or full_evictable_tokens or full_evictable_tokens
or swa_evictable_tokens or swa_evictable_tokens
or empty_pool or empty_pool
): ):
return False return False
return full_tokens <= self.available_size() return self._fits_page_demand(
math.ceil(full_tokens / self.page_size),
math.ceil(swa_tokens / self.page_size),
)
def prealloc_fits(
self,
tree_cache,
full_tokens: int,
swa_tokens: int,
*,
full_budget_tokens: int,
swa_budget_tokens: int | None = None,
) -> bool:
"""Price the pair on the float chain's grid, then against the budgets.
Each side's `available_size` takes `schedulable_available_size()`,
which credits the peer's drainable holes, so the two are backed by the
same bytes and a pair that fits each side alone can fail together. The
budgets still apply on top: they carry decode headroom this allocator
cannot see.
"""
page_size = self.page_size
if not self._fits_page_demand(
-(-full_tokens // page_size), -(-swa_tokens // page_size)
):
return False
return full_tokens <= full_budget_tokens and (
swa_budget_tokens is None or swa_tokens <= swa_budget_tokens
)
def ensure_capacity(self, full_tokens: int, swa_tokens: int) -> bool: def ensure_capacity(self, full_tokens: int, swa_tokens: int) -> bool:
if full_tokens < 0 or swa_tokens < 0 or full_tokens != swa_tokens: if full_tokens < 0 or swa_tokens < 0:
return False return False
if full_tokens == 0: if self.can_reserve(full_tokens, swa_tokens):
return True return True
need_tokens = int(full_tokens) for allocator in self._flush_targets():
if need_tokens <= self.available_size(): allocator.flush_for_allocation()
if self.can_reserve(full_tokens, swa_tokens):
return True return True
return _relieve_for_alloc(self, need_tokens) _float_open_short_side(
self.swa_attn_allocator,
{
self.full_attn_allocator: -(-full_tokens // self.page_size),
self.swa_attn_allocator: -(-swa_tokens // self.page_size),
self.mamba_allocator: 0,
},
)
return self.can_reserve(full_tokens, swa_tokens)
def _compute_available_size(self) -> int: def _fits_page_demand(self, full_pages: int, swa_pages: int) -> bool:
"""Joint TOKENS for `alloc(N)`: N costs N full pages AND N swa pages, drawn """Price FULL first, then SWA in one contiguous band on the float grid."""
from DIFFERENT bands -- full extends only into the high band, the float into
either side but only ONE per batch alloc. Feasibility is monotone in N, so
binary search; the order matches the alloc path (full takes the high band).
"""
fa, sa = self.full_attn_allocator, self.swa_attn_allocator fa, sa = self.full_attn_allocator, self.swa_attn_allocator
e_f = fa.entry_bytes_per_page
# full is grow-down: its chain gap IS the high band.
b_high = fa._current_gap_bytes()
h_f = len(fa._free_phys_pages) if fa.lazy_compaction else 0 h_f = len(fa._free_phys_pages) if fa.lazy_compaction else 0
h_s = sa._hole_pages() h_s = sa._hole_pages()
r_f = fa.num_pages - fa.min_page_index - fa._allocated_pages() r_f = fa.num_pages - fa.min_page_index - fa._allocated_pages()
r_s = sa.num_pages - sa.min_page_index - sa._allocated_pages() r_s = sa.num_pages - sa.min_page_index - sa._allocated_pages()
if full_pages > h_f + r_f or swa_pages > h_s + r_s:
def feasible(n: int) -> bool: return False
if n > h_f + r_f or n > h_s + r_s: full_bytes = max(0, full_pages - h_f) * fa.entry_bytes_per_page
return False if full_bytes > fa._current_gap_bytes():
ext_f = max(0, n - h_f) return False
if ext_f * e_f > b_high: ext_s = max(0, swa_pages - h_s)
return False full_low_after = fa._byte_low_frontier() - full_bytes
ext_s = max(0, n - h_s) if sa._is_frontier_transparent():
# On the float's page grid, never in raw bytes: a byte budget room = sa.pages_in_band(
# credits a page `take_physical_pages` cannot yield.
full_low_after = fa._byte_low_frontier() - ext_f * e_f
if sa._is_frontier_transparent():
room = sa.pages_in_band(
low_byte=sa._chain_high_frontier_below_bytes(),
high_byte=full_low_after,
)
return ext_s <= room
p_low = sa.pages_in_band(
low_byte=sa._chain_high_frontier_below_bytes(), low_byte=sa._chain_high_frontier_below_bytes(),
high_byte=sa._byte_low_frontier(),
)
p_high = sa.pages_in_band(
low_byte=sa._byte_high_frontier(),
high_byte=full_low_after, high_byte=full_low_after,
) )
return ext_s <= max(p_low, p_high) return ext_s <= room
p_low = sa.pages_in_band(
low_byte=sa._chain_high_frontier_below_bytes(),
high_byte=sa._byte_low_frontier(),
)
p_high = sa.pages_in_band(
low_byte=sa._byte_high_frontier(),
high_byte=full_low_after,
)
return ext_s <= max(p_low, p_high)
def _compute_available_size(self) -> int:
"""Joint TOKENS for equal FULL/SWA demand, using the same page predicate
as tail allocation. FULL takes the high band before SWA binds its pages.
"""
fa, sa = self.full_attn_allocator, self.swa_attn_allocator
h_f = len(fa._free_phys_pages) if fa.lazy_compaction else 0
h_s = sa._hole_pages()
r_f = fa.num_pages - fa.min_page_index - fa._allocated_pages()
r_s = sa.num_pages - sa.min_page_index - sa._allocated_pages()
lo_n, hi_n = 0, min(h_f + r_f, h_s + r_s) lo_n, hi_n = 0, min(h_f + r_f, h_s + r_s)
while lo_n < hi_n: while lo_n < hi_n:
mid = (lo_n + hi_n + 1) // 2 mid = (lo_n + hi_n + 1) // 2
if feasible(mid): if self._fits_page_demand(mid, mid):
lo_n = mid lo_n = mid
else: else:
hi_n = mid - 1 hi_n = mid - 1
@@ -307,6 +307,30 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
) )
return self.full_attn_allocator.translate_kv_loc(kv_indices.to(torch.int64)) return self.full_attn_allocator.translate_kv_loc(kv_indices.to(torch.int64))
def get_cpu_copy(self, indices, mamba_indices=None, req_pool_index=None):
"""Retraction backup for the FULL + mamba pair.
`Req.offload_kv_cache` hands over `req_to_token` rows, which hold
VIRTUAL ids here; both unified full pools index their host copy by
PHYSICAL ids. The mamba side is already slot-addressed and is
translated by the pool.
"""
return self._kvcache.get_cpu_copy(
self.full_attn_allocator.translate_kv_loc(indices.to(torch.int64)),
mamba_indices=mamba_indices,
req_pool_index=req_pool_index,
)
def load_cpu_copy(
self, kv_cache_cpu, indices, mamba_indices=None, req_pool_index=None
):
return self._kvcache.load_cpu_copy(
kv_cache_cpu,
self.full_attn_allocator.translate_kv_loc(indices.to(torch.int64)),
mamba_indices=mamba_indices,
req_pool_index=req_pool_index,
)
def _move_gate_targets(self): def _move_gate_targets(self):
"""Every member a compaction gate must cover. The mamba end is gated """Every member a compaction gate must cover. The mamba end is gated
even where its state is not itself transferred: the gate is about the even where its state is not itself transferred: the gate is about the
@@ -265,7 +265,11 @@ def install_move_gate(
class MultiEndedAllocator(BaseTokenToKVPoolAllocator): class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
"""Allocator for one sub-pool over a `UnifiedKVPool`.""" """Allocator for one sub-pool over a `UnifiedKVPool`.
``need_sort`` applies to transfer-facing physical ids, not virtual ids.
Physical free pages are sorted during compaction.
"""
# Capacity-bearing state: any rebind bumps `_capacity_epoch`, invalidating # Capacity-bearing state: any rebind bumps `_capacity_epoch`, invalidating
# the chain's capacity memos (see `_CapacityField`). # the chain's capacity memos (see `_CapacityField`).
@@ -1207,11 +1211,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
if not _relieve_for_alloc(self, need_tokens): if not _relieve_for_alloc(self, need_tokens):
return None return None
bs = len(prefix_lens) bs = len(prefix_lens)
if self.need_sort and extend_num_tokens // self.page_size + bs + 1 > len(
self.free_virtual_ids
):
self.merge_and_sort_free()
# Snapshot the virtual pages the kernel will consume, to bind them # Snapshot the virtual pages the kernel will consume, to bind them
# to physical pages afterward. # to physical pages afterward.
if num_new_pages > 0: if num_new_pages > 0:
@@ -1274,9 +1273,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
if need_tokens > self.available_size(): if need_tokens > self.available_size():
if not _relieve_for_alloc(self, need_tokens): if not _relieve_for_alloc(self, need_tokens):
return None return None
if self.need_sort and bs > len(self.free_virtual_ids):
self.merge_and_sort_free()
# Most decode steps reuse the prefix's tail page -> num_new_pages == 0. # Most decode steps reuse the prefix's tail page -> num_new_pages == 0.
if num_new_pages > 0: if num_new_pages > 0:
new_virtual_pages = self.free_virtual_ids[:num_new_pages].clone() new_virtual_pages = self.free_virtual_ids[:num_new_pages].clone()
@@ -154,19 +154,11 @@ def resolve_decode_retraction_backup(*, tp_worker: BaseTpWorker) -> str:
if tp_worker.is_hybrid_swa if tp_worker.is_hybrid_swa
else None else None
) )
# Host-pool retraction transfers full and sliding-window components # Host-pool retraction does not address unified page envelopes or
# only, so a model with recurrent state stays on cpu_tensor. # recurrent state, so those configurations stay on cpu_tensor.
#
# The unified pool is excluded for the same reason hierarchical cache is
# (see `handle_unified_memory_pool`): the host-transfer path indexes the
# device buffers with the ids it is handed, and under the unified pool
# those are VIRTUAL. It also cannot be sized from `kv_cache.size`, which
# is a KERNEL-FACING row count (`num_pages * 2 * layer_num * page_size`)
# rather than a token capacity -- gpt-oss-20b reports 85M "tokens" and
# asks for 418 GB of host memory per component.
supports_host_pool = ( supports_host_pool = (
not uses_ssm_state(tp_worker.model_runner.model_config) not memory.enable_unified_memory
and not memory.enable_unified_memory and not uses_ssm_state(tp_worker.model_runner.model_config)
and ( and (
isinstance(kv_cache, MHATokenToKVPool) isinstance(kv_cache, MHATokenToKVPool)
or (isinstance(kv_cache, SWAKVPool) and full_tokens_per_layer > 0) or (isinstance(kv_cache, SWAKVPool) and full_tokens_per_layer > 0)
@@ -868,32 +868,7 @@ class KVCacheConfigurator:
assert not self.use_mla_backend, ( assert not self.use_mla_backend, (
"unified memory pool does not support MLA-SWA hybrid yet" "unified memory pool does not support MLA-SWA hybrid yet"
) )
# Mirror the non-shared path's extra_max_context_len computation. req_to_token_pool = self._build_req_to_token_pool(max_num_reqs=max_num_reqs)
extra_max_context_len = 4
if get_spec().speculative_num_draft_tokens is not None:
extra_max_context_len += get_spec().speculative_num_draft_tokens
if get_disagg().disaggregation_mode == "decode":
# A decode node hands out request rows to PREALLOCATED transfers on
# top of its running set, so it needs the extra-slot pool (and the
# `pre_alloc_size` the scheduler's invariant checker reads). Mirrors
# `_build_req_to_token_pool`'s decode branch; the mamba composite
# already takes `decode_pre_alloc_size` the same way.
from sglang.srt.disaggregation.decode import DecodeReqToTokenPool
req_to_token_pool = DecodeReqToTokenPool(
size=max_num_reqs,
max_context_len=self.model_config.context_len + extra_max_context_len,
device=self.device,
enable_memory_saver=get_exec().features.enable_memory_saver,
pre_alloc_size=get_disagg().disaggregation_decode_extra_slots,
)
else:
req_to_token_pool = ReqToTokenPool(
size=max_num_reqs,
max_context_len=self.model_config.context_len + extra_max_context_len,
device=self.device,
enable_memory_saver=get_exec().features.enable_memory_saver,
)
head_num = self.model_config.get_num_kv_heads( head_num = self.model_config.get_num_kv_heads(
get_parallel().attn_tp_size, get_parallel().attn_dcp_size get_parallel().attn_tp_size, get_parallel().attn_dcp_size
@@ -624,29 +624,29 @@ class UnifiedMHATokenToKVPool(MHATokenToKVPool):
env[tgt_pages] = env[src_pages] env[tgt_pages] = env[src_pages]
def get_contiguous_buf_infos(self): def get_contiguous_buf_infos(self):
"""PD-transfer registration: ONE entry, the raw buffer, addressed as """Register the raw buffer as physical page envelopes for PD transfer.
``raw_ptr + physical_page_id * page_envelope_bytes``.
Same whole-envelope contract as `UnifiedMLATokenToKVPool`: the transfer Full and SWA expose the same allocation with different envelope sizes;
item is one page across ALL layers and both K and V, because the the transfer backend preserves both logical entries while deduplicating
per-layer views overlap inside the envelope and index in kernel-facing the underlying memory registration.
ids. A peer must therefore build an identical spec -- enforced on the
wire by `_validate_envelope_kv_layout`.
""" """
# The address formula omits the anchor; a nonzero one would mis-address.
assert self._unified_buffer.anchor_bytes(self._sub_pool_name) == 0 assert self._unified_buffer.anchor_bytes(self._sub_pool_name) == 0
raw = self._unified_buffer._raw raw = self._unified_buffer._raw
return [raw.data_ptr()], [raw.numel()], [self._page_bytes] return [raw.data_ptr()], [raw.numel()], [self._page_bytes]
def get_cpu_copy(self, indices, mamba_indices=None): def _physical_to_kernel_indices(self, indices: torch.Tensor) -> torch.Tensor:
raise NotImplementedError( return (indices // self.page_size) * (
"CPU offloading is unsupported under the unified layout." self.page_size * self.kernel_page_blocks
) ) + indices % self.page_size
def load_cpu_copy(self, kv_cache_cpu, indices, mamba_indices=None): def get_cpu_copy(self, indices, mamba_indices=None, req_pool_index=None):
raise NotImplementedError( """Translate physical host-pool ids for the page-major parent path."""
"CPU offloading is unsupported under the unified layout." return super().get_cpu_copy(self._physical_to_kernel_indices(indices))
)
def load_cpu_copy(
self, kv_cache_cpu, indices, mamba_indices=None, req_pool_index=None
):
super().load_cpu_copy(kv_cache_cpu, self._physical_to_kernel_indices(indices))
def set_kv_buffer_prefix_valid(self, *args, **kwargs): def set_kv_buffer_prefix_valid(self, *args, **kwargs):
raise NotImplementedError( raise NotImplementedError(
@@ -738,6 +738,22 @@ class UnifiedMLATokenToKVPool(MLATokenToKVPool):
raw = self._unified_buffer._raw raw = self._unified_buffer._raw
return [raw.data_ptr()], [raw.numel()], [self._page_bytes] return [raw.data_ptr()], [raw.numel()], [self._page_bytes]
def _physical_to_kernel_indices(self, indices: torch.Tensor) -> torch.Tensor:
"""Physical TOKEN ids -> the kernel-facing ids this class's `kv_buffer`
views are indexed by; the formula is the one in the class docstring."""
return (indices // self.page_size) * (
self.page_size * self.kernel_page_blocks
) + indices % self.page_size
def get_cpu_copy(self, indices, mamba_indices=None, req_pool_index=None):
"""Translate physical host-pool ids for the page-major parent path."""
return super().get_cpu_copy(self._physical_to_kernel_indices(indices))
def load_cpu_copy(
self, kv_cache_cpu, indices, mamba_indices=None, req_pool_index=None
):
super().load_cpu_copy(kv_cache_cpu, self._physical_to_kernel_indices(indices))
def move_kv_cache(self, tgt_loc: torch.Tensor, src_loc: torch.Tensor): def move_kv_cache(self, tgt_loc: torch.Tensor, src_loc: torch.Tensor):
"""Relocate whole page envelopes. """Relocate whole page envelopes.
@@ -1676,7 +1692,7 @@ class UnifiedSWAKVPool(SWAKVPool):
swa_cpu = None swa_cpu = None
if bool(valid.any().item()): if bool(valid.any().item()):
swa_cpu = self.swa_kv_pool.get_cpu_copy(swa_phys[valid]) swa_cpu = self.swa_kv_pool.get_cpu_copy(swa_phys[valid])
return {"full": full_cpu, "swa": swa_cpu} return {"full": full_cpu, "swa": swa_cpu, "swa_mask": valid.cpu()}
def load_cpu_copy( def load_cpu_copy(
self, kv_cache_cpu, indices, mamba_indices=None, req_pool_index=None self, kv_cache_cpu, indices, mamba_indices=None, req_pool_index=None
@@ -1689,7 +1705,14 @@ class UnifiedSWAKVPool(SWAKVPool):
if kv_cache_cpu.get("swa") is not None: if kv_cache_cpu.get("swa") is not None:
assert self._swa_allocator is not None assert self._swa_allocator is not None
swa_phys = self._virt_tokens_to_phys_tokens(indices, self._swa_allocator) swa_phys = self._virt_tokens_to_phys_tokens(indices, self._swa_allocator)
self.swa_kv_pool.load_cpu_copy(kv_cache_cpu["swa"], swa_phys) old_swa_mask = kv_cache_cpu["swa_mask"].to(indices.device)
assert old_swa_mask.shape == indices.shape
row_mask = (swa_phys >= 0)[old_swa_mask].cpu()
swa_phys = swa_phys[old_swa_mask][row_mask.to(indices.device)]
if swa_phys.numel() == 0:
return
swa_cpu = self._filter_swa_cpu_copy(kv_cache_cpu["swa"], row_mask)
self.swa_kv_pool.load_cpu_copy(swa_cpu, swa_phys)
class UnifiedSWAPoolBundle(NamedTuple): class UnifiedSWAPoolBundle(NamedTuple):
+3
View File
@@ -341,6 +341,9 @@ class ServerArgs:
# _handle_page_major_kv_layout); the model-family gate is enforced at pool # _handle_page_major_kv_layout); the model-family gate is enforced at pool
# construction in model_runner_kv_cache_mixin._init_pools. # construction in model_runner_kv_cache_mixin._init_pools.
def _unified_memory_pd_transfer_backends(self) -> set[str]:
return {"mooncake"}
@staticmethod @staticmethod
def add_cli_args(parser: argparse.ArgumentParser): def add_cli_args(parser: argparse.ArgumentParser):
@@ -0,0 +1,55 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Allocator double for scheduler tests that admit requests.
`DecodePreallocQueue` asks the allocator to price a preallocation rather than
doing the arithmetic itself, so a bare `MagicMock` returns a truthy `Mock` and
the admission decision under test stops being made anywhere. Binding the real
separate-buffer implementations keeps the arithmetic live while leaving the
per-test stubs (`size_swa`, `swa_available_size`, ...) in charge of the state.
"""
from unittest.mock import MagicMock
from sglang.srt.mem_cache.allocator.base import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
# Bound on the double, so each reads the stubs the caller set on it.
_SEPARATE_BUFFER_METHODS = {
"prealloc_fits_assumes_reclaim": BaseTokenToKVPoolAllocator.prealloc_fits_assumes_reclaim,
"prealloc_ceiling_fits": BaseTokenToKVPoolAllocator.prealloc_ceiling_fits,
"prealloc_fits": BaseTokenToKVPoolAllocator.prealloc_fits,
"reclaim_for_prealloc": SWATokenToKVPoolAllocator.reclaim_for_prealloc,
"swa_capacity_and_available": SWATokenToKVPoolAllocator.swa_capacity_and_available,
}
def bind_separate_buffer_capacity(allocator) -> None:
"""Make `allocator` price capacity like a pool whose sides own their own
buffers. Call on any allocator double a `DecodePreallocQueue` will read."""
for name, impl in _SEPARATE_BUFFER_METHODS.items():
setattr(
allocator,
name,
(lambda impl: lambda *args, **kwargs: impl(allocator, *args, **kwargs))(
impl
),
)
def separate_buffer_allocator_double(**attrs) -> MagicMock:
"""A `MagicMock` allocator that prices capacity as separate buffers."""
allocator = MagicMock(**attrs)
bind_separate_buffer_capacity(allocator)
return allocator
@@ -0,0 +1,127 @@
import unittest
import torch
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
from sglang.srt.runtime_context import publish, reset_context
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.test_utils import CustomTestCase
register_cuda_ci(est_time=20, stage="base-b-kernel-unit", runner_config="1-gpu-large")
class TestUnifiedSWATailAllocation(CustomTestCase):
def setUp(self):
reset_context()
self.addCleanup(reset_context)
publish(
ServerArgs(model_path="dummy", enable_unified_memory=True), role="tokenizer"
)
def test_extend_binds_only_new_tail_pages(self):
"""PD tail allocation must leave new FULL-only pages unbound in SWA,
while preserving an existing partial page and binding the trailing KV."""
for page_size in (4, 16):
for prefix_len, seq_len, tail_len in (
(page_size, 5 * page_size, 0),
(page_size, 5 * page_size, page_size),
(page_size, 5 * page_size, 2 * page_size),
(page_size + 2, 5 * page_size, page_size),
(page_size + 2, 5 * page_size, 4 * page_size - 2),
(page_size + 2, 2 * page_size - 1, page_size - 3),
(page_size + 2, 5 * page_size - 1, page_size),
(0, None, 1),
):
with self.subTest(
page_size=page_size,
prefix_len=prefix_len,
seq_len=seq_len,
tail_len=tail_len,
):
bundle = init_unified_swa_pools(
device="cuda",
kv_cache_dtype=torch.float16,
head_num=1,
head_dim=8,
v_head_dim=8,
swa_head_num=1,
swa_head_dim=8,
swa_v_head_dim=8,
page_size=page_size,
start_layer=0,
end_layer=2,
full_attention_layer_ids=[0],
swa_attention_layer_ids=[1],
total_bytes=1 << 16,
enable_memory_saver=False,
need_sort=False,
)
allocator = bundle.token_to_kv_pool_allocator
if seq_len is None:
seq_len = allocator.available_size() + page_size
self.assertFalse(allocator.can_reserve(seq_len, seq_len))
prefix_capacity = -(-prefix_len // page_size) * page_size
prefix = allocator.alloc(prefix_capacity)[:prefix_len]
prefix_swa = allocator.translate_swa_indices_for_transfer(
prefix
).clone()
prefix_cpu = torch.tensor([prefix_len], dtype=torch.int64)
seq_cpu = torch.tensor([seq_len], dtype=torch.int64)
extended = allocator.alloc_extend_swa_tail(
prefix_lens=prefix_cpu.cuda(),
prefix_lens_cpu=prefix_cpu,
seq_lens=seq_cpu.cuda(),
seq_lens_cpu=seq_cpu,
last_loc=(
prefix[-1:]
if prefix_len
else torch.tensor([-1], device="cuda")
),
extend_num_tokens=seq_len - prefix_len,
swa_tail_len=tail_len,
)
self.assertIsNotNone(extended)
self.assertEqual(extended.numel(), seq_len - prefix_len)
tokens = torch.cat((prefix, extended))
full_phys = allocator.translate_kv_indices_for_transfer(tokens)
swa_phys = allocator.translate_swa_indices_for_transfer(tokens)
self.assertTrue(bool((full_phys > 0).all()))
self.assertTrue(torch.equal(swa_phys[:prefix_len], prefix_swa))
tail_start = seq_len - tail_len
new_pages = torch.unique(tokens[prefix_capacity:] // page_size)
tail_pages = torch.unique(tokens[tail_start:] // page_size)
full_only_pages = new_pages[~torch.isin(new_pages, tail_pages)]
self.assertTrue(
bool(
(allocator.swa_v2p_page_table[full_only_pages] == -1).all()
)
)
if tail_len:
pages = allocator.swa_v2p_page_table[
tokens[tail_start:] // page_size
]
self.assertTrue(bool((pages > 0).all()))
expected = pages * page_size + tokens[tail_start:] % page_size
self.assertTrue(torch.equal(swa_phys[tail_start:], expected))
self.assertEqual(
allocator.swa_attn_allocator.allocated_count(),
prefix_capacity
+ torch.isin(new_pages, tail_pages).sum().item() * page_size,
)
if prefix_len < prefix_capacity:
reused_tokens = min(prefix_capacity, seq_len) - prefix_len
self.assertTrue(
torch.equal(
extended[:reused_tokens],
prefix[-1]
+ torch.arange(1, reused_tokens + 1, device="cuda"),
)
)
allocator.free(tokens)
self.assertEqual(allocator.full_attn_allocator.allocated_count(), 0)
self.assertEqual(allocator.swa_attn_allocator.allocated_count(), 0)
if __name__ == "__main__":
unittest.main()
@@ -17,6 +17,9 @@ from sglang.srt.managers.scheduler import Scheduler
from sglang.srt.runtime_context import get_context, publish, reset_context from sglang.srt.runtime_context import get_context, publish, reset_context
from sglang.srt.server_args import ServerArgs from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.separate_buffer_allocator_double import (
bind_separate_buffer_capacity,
)
from sglang.test.test_utils import CustomTestCase from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=11, suite="base-a-test-cpu") register_cpu_ci(est_time=11, suite="base-a-test-cpu")
@@ -71,7 +74,8 @@ class TestDecodeQueueCleanup(CustomTestCase):
queue.retracted_queue = reqs.copy() queue.retracted_queue = reqs.copy()
queue.num_reserved_decode_tokens = 0 queue.num_reserved_decode_tokens = 0
queue.req_to_token_pool = SimpleNamespace(available_size=lambda: len(reqs)) queue.req_to_token_pool = SimpleNamespace(available_size=lambda: len(reqs))
queue.token_to_kv_pool_allocator = SimpleNamespace(page_size=page_size) queue.token_to_kv_pool_allocator = MagicMock(page_size=page_size)
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue.tree_cache = MagicMock() queue.tree_cache = MagicMock()
queue.scheduler = SimpleNamespace( queue.scheduler = SimpleNamespace(
sliding_window_size=2047, sliding_window_size=2047,
@@ -81,6 +85,9 @@ class TestDecodeQueueCleanup(CustomTestCase):
queue._swa_aware_allocatable_token_budgets = MagicMock( queue._swa_aware_allocatable_token_budgets = MagicMock(
return_value=(physical_available, physical_available) return_value=(physical_available, physical_available)
) )
queue._allocatable_token_budgets = MagicMock(
side_effect=lambda **_: physical_available
)
queue._swa_tail_allocatable_token_budget = MagicMock( queue._swa_tail_allocatable_token_budget = MagicMock(
side_effect=lambda **_: physical_available side_effect=lambda **_: physical_available
) )
@@ -120,6 +127,10 @@ class TestDecodeQueueCleanup(CustomTestCase):
queue.retracted_queue = [] queue.retracted_queue = []
queue._resolve_pending_reqs = MagicMock() queue._resolve_pending_reqs = MagicMock()
queue._uses_swa_tail_prealloc = MagicMock(return_value=False) queue._uses_swa_tail_prealloc = MagicMock(return_value=False)
# `_uses_swa_reservation` consults the allocator once tail prealloc is
# off, so this abort path needs one even though it never allocates.
queue.token_to_kv_pool_allocator = MagicMock()
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue._allocatable_token_budgets = MagicMock(return_value=0) queue._allocatable_token_budgets = MagicMock(return_value=0)
queue._hicache_pending_restore_tokens = MagicMock(return_value=0) queue._hicache_pending_restore_tokens = MagicMock(return_value=0)
@@ -175,6 +186,10 @@ class TestDecodeQueueCleanup(CustomTestCase):
queue._resolve_pending_reqs = MagicMock() queue._resolve_pending_reqs = MagicMock()
queue._update_handshake_waiters = MagicMock() queue._update_handshake_waiters = MagicMock()
queue._uses_swa_tail_prealloc = MagicMock(return_value=False) queue._uses_swa_tail_prealloc = MagicMock(return_value=False)
# `_uses_swa_reservation` consults the allocator once tail prealloc is
# off, so this abort path needs one even though it never allocates.
queue.token_to_kv_pool_allocator = MagicMock()
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue._allocatable_token_budgets = MagicMock(return_value=0) queue._allocatable_token_budgets = MagicMock(return_value=0)
queue._hicache_pending_restore_tokens = MagicMock(return_value=0) queue._hicache_pending_restore_tokens = MagicMock(return_value=0)
@@ -234,6 +249,9 @@ class TestDecodeQueueCleanup(CustomTestCase):
) )
queue._hicache_pending_restore_tokens = MagicMock(return_value=0) queue._hicache_pending_restore_tokens = MagicMock(return_value=0)
queue._pre_alloc = MagicMock() queue._pre_alloc = MagicMock()
queue.token_to_kv_pool_allocator = MagicMock()
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue.tree_cache = MagicMock()
queue.req_to_token_pool = MagicMock() queue.req_to_token_pool = MagicMock()
queue.req_to_token_pool.available_size.return_value = 1 queue.req_to_token_pool.available_size.return_value = 1
# Non-hybrid pools have no mamba allocator; MagicMock would otherwise # Non-hybrid pools have no mamba allocator; MagicMock would otherwise
@@ -7,6 +7,7 @@ from types import SimpleNamespace
import numpy as np import numpy as np
from sglang.srt.disaggregation.ascend.conn import AscendKVManager from sglang.srt.disaggregation.ascend.conn import AscendKVManager
from sglang.srt.disaggregation.base.conn import StateType
from sglang.srt.disaggregation.common.conn import CommonKVManager from sglang.srt.disaggregation.common.conn import CommonKVManager
from sglang.srt.disaggregation.mooncake.conn import MooncakeKVManager from sglang.srt.disaggregation.mooncake.conn import MooncakeKVManager
from sglang.srt.disaggregation.prefill import _transfer_start_layer from sglang.srt.disaggregation.prefill import _transfer_start_layer
@@ -15,6 +16,8 @@ from sglang.srt.disaggregation.utils import (
build_transfer_entry_pairs, build_transfer_entry_pairs,
) )
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
from sglang.srt.runtime_context import get_memory, publish, reset_context
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase from sglang.test.test_utils import CustomTestCase
@@ -71,6 +74,7 @@ class TestTransferStartLayer(CustomTestCase):
class _RecordingKVManager: class _RecordingKVManager:
get_mha_kv_ptrs_with_pp = CommonKVManager.get_mha_kv_ptrs_with_pp get_mha_kv_ptrs_with_pp = CommonKVManager.get_mha_kv_ptrs_with_pp
get_mla_kv_ptrs_with_pp = CommonKVManager.get_mla_kv_ptrs_with_pp
def __init__(self, *, prefill_start_layer: int, pp_size: int): def __init__(self, *, prefill_start_layer: int, pp_size: int):
self.is_mla_backend = False self.is_mla_backend = False
@@ -143,6 +147,50 @@ class TestHybridSendUsesLayerIdPairing(CustomTestCase):
self._run_case(model_full_ids=ids, stage_full_ids=ids[:5], start_offset=0) self._run_case(model_full_ids=ids, stage_full_ids=ids[:5], start_offset=0)
class TestSingleRegionSWATransfer(CustomTestCase):
def test_one_region_full_generates_transfer_block(self):
publish(ServerArgs(model_path="dummy"), role="tokenizer")
self.addCleanup(reset_context)
manager = _RecordingKVManager(prefill_start_layer=0, pp_size=1)
manager.kv_args.kv_data_ptrs = [1000]
manager.kv_args.kv_item_lens = [64]
manager.kv_args.kv_layer_ids = []
manager._validate_envelope_kv_layout = (
MooncakeKVManager._validate_envelope_kv_layout.__get__(manager)
)
manager._send_kvcache_generic = MooncakeKVManager._send_kvcache_generic.__get__(
manager
)
with get_memory().override(enable_unified_memory=True):
rc = MooncakeKVManager.send_kvcache(
manager,
mooncake_session_id="session",
prefill_kv_indices=np.array([3, 4], dtype=np.int32),
dst_kv_ptrs=[2000],
dst_kv_indices=np.array([7, 8], dtype=np.int32),
dst_kv_item_len=64,
executor=None,
)
self.assertEqual(rc, 0)
self.assertEqual(manager.blocks, [(1192, 2448, 128)])
def test_one_region_swa_generates_transfer_block(self):
manager = _RecordingKVManager(prefill_start_layer=0, pp_size=1)
rc = MooncakeKVManager._send_kvcache_generic(
manager,
mooncake_session_id="session",
src_data_ptrs=[1000],
dst_data_ptrs=[2000],
item_lens=[64],
prefill_data_indices=np.array([3, 4], dtype=np.int32),
dst_data_indices=np.array([7, 8], dtype=np.int32),
executor=None,
state_type=StateType.SWA,
)
self.assertEqual(rc, 0)
self.assertEqual(manager.blocks, [(1000 + 3 * 64, 2000 + 7 * 64, 2 * 64)])
class _RecordingAscendManager: class _RecordingAscendManager:
def __init__(self): def __init__(self):
self.is_hybrid_mla_backend = True self.is_hybrid_mla_backend = True
@@ -23,6 +23,9 @@ from sglang.srt.managers.scheduler import Scheduler # noqa: E402
from sglang.srt.runtime_context import get_context, publish, reset_context # noqa: E402 from sglang.srt.runtime_context import get_context, publish, reset_context # noqa: E402
from sglang.srt.server_args import ServerArgs from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.separate_buffer_allocator_double import (
bind_separate_buffer_capacity,
)
register_cpu_ci(est_time=12, suite="base-a-test-cpu") register_cpu_ci(est_time=12, suite="base-a-test-cpu")
@@ -161,11 +164,14 @@ class TestDecodePreallocQueuePriority(unittest.TestCase):
queue.req_to_metadata_buffer_idx_allocator.alloc.side_effect = iter(range(100)) queue.req_to_metadata_buffer_idx_allocator.alloc.side_effect = iter(range(100))
queue.token_to_kv_pool_allocator = MagicMock() queue.token_to_kv_pool_allocator = MagicMock()
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue.token_to_kv_pool_allocator.page_size = 1 queue.token_to_kv_pool_allocator.page_size = 1
queue.token_to_kv_pool_allocator.available_size.return_value = 1000 queue.token_to_kv_pool_allocator.available_size.return_value = 1000
queue.token_to_kv_pool = MagicMock() queue.token_to_kv_pool = MagicMock()
queue.transfer_queue = SimpleNamespace(queue=[], enable_staging=False) queue.transfer_queue = SimpleNamespace(queue=[], enable_staging=False)
queue.kv_manager = SimpleNamespace(kv_args=SimpleNamespace(state_types=[])) queue.kv_manager = SimpleNamespace(
kv_args=SimpleNamespace(state_types=[]),
)
queue.tree_cache = MagicMock() queue.tree_cache = MagicMock()
scheduler = MagicMock() scheduler = MagicMock()
@@ -47,6 +47,9 @@ from sglang.srt.mem_cache.base_prefix_cache import (
from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey
from sglang.srt.mem_cache.unified_cache.component_type import ComponentType from sglang.srt.mem_cache.unified_cache.component_type import ComponentType
from sglang.srt.utils.common import Range from sglang.srt.utils.common import Range
from sglang.test.separate_buffer_allocator_double import (
bind_separate_buffer_capacity,
)
from sglang.test.test_utils import CustomTestCase from sglang.test.test_utils import CustomTestCase
@@ -128,6 +131,7 @@ class TestDecodeLockRefScenarios(CustomTestCase):
server_args=SimpleNamespace(), server_args=SimpleNamespace(),
) )
queue.token_to_kv_pool_allocator = MagicMock(page_size=64) queue.token_to_kv_pool_allocator = MagicMock(page_size=64)
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
tail_len = queue._swa_tail_len(895) tail_len = queue._swa_tail_len(895)
@@ -146,6 +150,7 @@ class TestDecodeLockRefScenarios(CustomTestCase):
queue._need_space_for_single_req = MagicMock(return_value=0) queue._need_space_for_single_req = MagicMock(return_value=0)
queue._active_req_count = MagicMock(return_value=1) queue._active_req_count = MagicMock(return_value=1)
queue.token_to_kv_pool_allocator = MagicMock() queue.token_to_kv_pool_allocator = MagicMock()
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue.token_to_kv_pool_allocator.size_swa = 256 queue.token_to_kv_pool_allocator.size_swa = 256
queue.token_to_kv_pool_allocator.swa_available_size.return_value = 0 queue.token_to_kv_pool_allocator.swa_available_size.return_value = 0
queue.tree_cache = MagicMock() queue.tree_cache = MagicMock()
@@ -162,6 +167,7 @@ class TestDecodeLockRefScenarios(CustomTestCase):
def test_reclaim_swa_tail_capacity_page_rounds(self): def test_reclaim_swa_tail_capacity_page_rounds(self):
queue = DecodePreallocQueue.__new__(DecodePreallocQueue) queue = DecodePreallocQueue.__new__(DecodePreallocQueue)
queue.token_to_kv_pool_allocator = MagicMock(page_size=64) queue.token_to_kv_pool_allocator = MagicMock(page_size=64)
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue.token_to_kv_pool_allocator.swa_available_size.side_effect = [64, 192] queue.token_to_kv_pool_allocator.swa_available_size.side_effect = [64, 192]
queue.tree_cache = MagicMock() queue.tree_cache = MagicMock()
@@ -175,6 +181,7 @@ class TestDecodeLockRefScenarios(CustomTestCase):
def test_reclaim_swa_tail_capacity_fails_before_allocation(self): def test_reclaim_swa_tail_capacity_fails_before_allocation(self):
queue = DecodePreallocQueue.__new__(DecodePreallocQueue) queue = DecodePreallocQueue.__new__(DecodePreallocQueue)
queue.token_to_kv_pool_allocator = MagicMock(page_size=64) queue.token_to_kv_pool_allocator = MagicMock(page_size=64)
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue.token_to_kv_pool_allocator.swa_available_size.side_effect = [64, 128] queue.token_to_kv_pool_allocator.swa_available_size.side_effect = [64, 128]
queue.tree_cache = MagicMock() queue.tree_cache = MagicMock()
@@ -471,6 +478,7 @@ class TestDecodeLockRefScenarios(CustomTestCase):
queue._update_handshake_waiters = MagicMock() queue._update_handshake_waiters = MagicMock()
queue._uses_swa_tail_prealloc = MagicMock(return_value=True) queue._uses_swa_tail_prealloc = MagicMock(return_value=True)
queue._swa_tail_len = MagicMock(return_value=8) queue._swa_tail_len = MagicMock(return_value=8)
queue._prealloc_required_tokens = MagicMock(return_value=(8, 8))
queue._swa_aware_allocatable_token_budgets = MagicMock(return_value=(8, 8)) queue._swa_aware_allocatable_token_budgets = MagicMock(return_value=(8, 8))
queue._swa_tail_allocatable_token_budget = MagicMock(return_value=8) queue._swa_tail_allocatable_token_budget = MagicMock(return_value=8)
queue._match_prefix_and_lock = MagicMock( queue._match_prefix_and_lock = MagicMock(
@@ -497,6 +505,7 @@ class TestDecodeLockRefScenarios(CustomTestCase):
queue.req_to_metadata_buffer_idx_allocator.available_size.return_value = 1 queue.req_to_metadata_buffer_idx_allocator.available_size.return_value = 1
queue.token_to_kv_pool = MagicMock() queue.token_to_kv_pool = MagicMock()
queue.token_to_kv_pool_allocator = MagicMock() queue.token_to_kv_pool_allocator = MagicMock()
bind_separate_buffer_capacity(queue.token_to_kv_pool_allocator)
queue.token_to_kv_pool_allocator.page_size = 4 queue.token_to_kv_pool_allocator.page_size = 4
running_batch = MagicMock() running_batch = MagicMock()
@@ -539,7 +548,7 @@ class TestDecodeLockRefScenarios(CustomTestCase):
skip_swa=True, skip_swa=True,
) )
self.assertFalse(req.swa_prefix_lock_released) self.assertFalse(req.swa_prefix_lock_released)
queue._swa_tail_len.assert_called_once_with(8) queue._swa_tail_len.assert_called_with(8)
queue._allocatable_token_budgets.assert_called_once() queue._allocatable_token_budgets.assert_called_once()
def test_hicache_restore_commit_hands_over_lock_with_receipt(self): def test_hicache_restore_commit_hands_over_lock_with_receipt(self):
@@ -18,7 +18,10 @@ from sglang.srt.mem_cache.kv_cache_configurator import KVCacheConfigurator
from sglang.srt.model_executor.model_runner import ModelRunner from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.runtime_context import get_context from sglang.srt.runtime_context import get_context
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase from sglang.test.separate_buffer_allocator_double import (
separate_buffer_allocator_double,
)
from sglang.test.test_utils import CustomTestCase, enter_override
register_cpu_ci(est_time=12, suite="base-a-test-cpu") register_cpu_ci(est_time=12, suite="base-a-test-cpu")
@@ -102,7 +105,10 @@ def _make_prealloc_queue(
"""Build a minimal DecodePreallocQueue for _check_if_req_exceed_kv_capacity.""" """Build a minimal DecodePreallocQueue for _check_if_req_exceed_kv_capacity."""
queue = DecodePreallocQueue.__new__(DecodePreallocQueue) queue = DecodePreallocQueue.__new__(DecodePreallocQueue)
queue.max_total_num_tokens = max_total_num_tokens queue.max_total_num_tokens = max_total_num_tokens
queue.token_to_kv_pool_allocator = SimpleNamespace(size_swa=10**9) queue.num_reserved_decode_tokens = 0
queue.token_to_kv_pool_allocator = separate_buffer_allocator_double(
page_size=1, size_swa=10**9
)
# Disable the SWA-tail branch; this test only exercises the pool-length gate. # Disable the SWA-tail branch; this test only exercises the pool-length gate.
queue._uses_swa_tail_prealloc = MagicMock(return_value=False) queue._uses_swa_tail_prealloc = MagicMock(return_value=False)
@@ -128,6 +134,10 @@ def _make_req(rid: str, prompt_len: int):
class TestCheckIfReqExceedKvCapacity(CustomTestCase): class TestCheckIfReqExceedKvCapacity(CustomTestCase):
def setUp(self):
super().setUp()
enter_override(self, get_context().override_server_args())
def test_hisparse_admits_beyond_device_pool_up_to_host_backed_size(self): def test_hisparse_admits_beyond_device_pool_up_to_host_backed_size(self):
"""Core regression: request longer than device-only """Core regression: request longer than device-only
`max_total_num_tokens` but within HiSparse host-backed `max_total_num_tokens` but within HiSparse host-backed
@@ -40,6 +40,7 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
MultiEndedAllocator, MultiEndedAllocator,
) )
from sglang.srt.mem_cache.base_prefix_cache import EvictParams from sglang.srt.mem_cache.base_prefix_cache import EvictParams
from sglang.srt.mem_cache.common import kv_to_page_indices
from sglang.srt.mem_cache.prefill_budget import estimate_swa_kv_tokens from sglang.srt.mem_cache.prefill_budget import estimate_swa_kv_tokens
from sglang.srt.mem_cache.unified_cache.components import ComponentType from sglang.srt.mem_cache.unified_cache.components import ComponentType
from sglang.srt.mem_cache.unified_memory_pool import ( from sglang.srt.mem_cache.unified_memory_pool import (
@@ -2522,6 +2523,29 @@ class TestSWACompositeKernelIdSurface(unittest.TestCase):
expected = v2p_swa[v // self.PS] * (self.PS * mult) + v % self.PS expected = v2p_swa[v // self.PS] * (self.PS * mult) + v % self.PS
self.assertTrue(torch.equal(a.translate_loc_from_full_to_swa(v), expected)) self.assertTrue(torch.equal(a.translate_loc_from_full_to_swa(v), expected))
def test_swa_transfer_page_is_physical_not_kernel_scaled(self):
mult = 2 * self.SWA_L
a = self._build()
v = a.alloc(3 * self.PS)
self.assertIsNotNone(v)
physical_pages = a.swa_attn_allocator.virtual_to_physical[
v[:: self.PS] // self.PS
]
physical_tokens = a.swa_attn_allocator.translate_kv_loc(v)
transfer_tokens = a.translate_swa_indices_for_transfer(v)
self.assertTrue(torch.equal(transfer_tokens, physical_tokens))
self.assertEqual(
kv_to_page_indices(transfer_tokens, self.PS).tolist(),
physical_pages.tolist(),
)
kernel_tokens = a.translate_loc_from_full_to_swa(v)
self.assertEqual(
kv_to_page_indices(kernel_tokens, self.PS).tolist(),
(physical_pages * mult).tolist(),
)
def test_swa_kernel_tombstone_still_lands_on_sink(self): def test_swa_kernel_tombstone_still_lands_on_sink(self):
"""The scaled stride must not break the tombstone clamp: a tombstoned """The scaled stride must not break the tombstone clamp: a tombstoned
page's ids (v2p == -1 -> -stride + offset, negative for every in-page page's ids (v2p == -1 -> -stride + offset, negative for every in-page
@@ -1,10 +1,13 @@
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock
import torch import torch
from test_unified_byte_budget_sizing import _swa_factory
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=10, suite="base-a-test-cpu") register_cpu_ci(est_time=10, suite="base-a-test-cpu")
@@ -57,5 +60,38 @@ class TestSWACpuCopyFilter(unittest.TestCase):
) )
class TestUnifiedSWATransfers(CustomTestCase):
def test_cpu_copy_round_trip_with_request_index_and_swa_tombstone(self):
bundle = _swa_factory(page_size=4)
allocator = bundle.token_to_kv_pool_allocator
pool = bundle.token_to_kv_pool
indices = allocator.alloc(12)
allocator.free_swa(indices[:4])
raw = bundle.unified_memory_pool._raw
raw.copy_(torch.arange(raw.numel()).remainder(251).to(torch.uint8))
full_pages = allocator.translate_kv_indices_for_transfer(indices)[::4] // 4
swa_pages = allocator.translate_swa_indices_for_transfer(indices[4:])[::4] // 4
full_buffer, swa_buffer = (
raw[: side.num_pages * side.entry_bytes_per_page].view(
side.num_pages, side.entry_bytes_per_page
)
for side in (allocator.full_attn_allocator, allocator.swa_attn_allocator)
)
expected_full = full_buffer[full_pages].clone()
expected_swa = swa_buffer[swa_pages].clone()
# The buffers are CPU tensors; no device synchronization is needed.
with mock.patch(
"sglang.srt.mem_cache.memory_pool.current_platform.synchronize"
):
saved = pool.get_cpu_copy(indices, req_pool_index=0)
raw.zero_()
pool.load_cpu_copy(saved, indices, req_pool_index=0)
self.assertTrue(torch.equal(full_buffer[full_pages], expected_full))
self.assertTrue(torch.equal(swa_buffer[swa_pages], expected_swa))
self.assertEqual(saved["swa_mask"].tolist(), [False] * 4 + [True] * 8)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -344,17 +344,9 @@ class TestUnifiedMHATokenToKVPool(unittest.TestCase):
"envelope move did not relocate exactly the named pages", "envelope move did not relocate exactly the named pages",
) )
def test_transfer_entry_points_fail_loud(self): def test_prefix_valid_entry_point_fails_loud(self):
"""The entry points that assume per-layer buffers indexed by TOKEN id """Prefix-valid writes still assume token-major buffer indexing."""
would silently mis-index against the row space (or hit a missing-attr
AttributeError), so each must raise. `get_contiguous_buf_infos` is NOT
among them: PD addresses this pool as whole page envelopes, pinned by
`test_pd_registration_is_one_whole_envelope` below."""
_, pool = _make_pool_and_kv(1) _, pool = _make_pool_and_kv(1)
with self.assertRaises(NotImplementedError):
pool.get_cpu_copy(torch.tensor([1]))
with self.assertRaises(NotImplementedError):
pool.load_cpu_copy(None, torch.tensor([1]))
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
pool.set_kv_buffer_prefix_valid() pool.set_kv_buffer_prefix_valid()
@@ -26,9 +26,13 @@ from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=10, suite="base-a-test-cpu") register_cpu_ci(est_time=10, suite="base-a-test-cpu")
import unittest import unittest
from unittest import mock
import torch import torch
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator
from sglang.srt.mem_cache.layout.page_major import build_mla_views from sglang.srt.mem_cache.layout.page_major import build_mla_views
from sglang.srt.mem_cache.unified_memory_pool import ( from sglang.srt.mem_cache.unified_memory_pool import (
@@ -37,6 +41,7 @@ from sglang.srt.mem_cache.unified_memory_pool import (
UnifiedKVPool, UnifiedKVPool,
UnifiedMLATokenToKVPool, UnifiedMLATokenToKVPool,
) )
from sglang.srt.runtime_context import get_parallel
_DEV = "cpu" _DEV = "cpu"
@@ -219,6 +224,48 @@ class TestUnifiedMLATokenToKVPool(unittest.TestCase):
k[7] = 2.5 k[7] = 2.5
self.assertTrue(torch.all(v[7] == 2.5)) self.assertTrue(torch.all(v[7] == 2.5))
def test_cpu_copy_round_trips_through_physical_ids(self):
"""REGRESSION: the host copy for decode retraction is addressed by
PHYSICAL token ids, but this pool's `kv_buffer` views are indexed by
kernel-facing ids. Without the rewrite the parent read a different row
and the restore silently returned other tokens' KV."""
for ps in (1, 4):
with self.subTest(page_size=ps):
pool, kv_pool = self._make(ps=ps)
phys = torch.tensor([0, 1, ps, ps + 1], dtype=torch.int64)
self.assertTrue(
torch.equal(
kv_pool._physical_to_kernel_indices(phys),
torch.tensor(
[_kernel_id(int(t), ps, _L) for t in phys],
dtype=torch.int64,
),
)
)
for layer in range(_L):
kv_pool.get_key_buffer(layer)[
kv_pool._physical_to_kernel_indices(phys)
] = float(layer + 1)
with (
get_parallel().override(dcp_enabled=False),
mock.patch(
"sglang.srt.mem_cache.memory_pool.current_platform.synchronize"
),
):
saved = kv_pool.get_cpu_copy(phys)
pool._raw.zero_()
kv_pool.load_cpu_copy(saved, phys)
for layer in range(_L):
restored = kv_pool.get_key_buffer(layer)[
kv_pool._physical_to_kernel_indices(phys)
]
self.assertTrue(
torch.all(restored == float(layer + 1)),
f"layer {layer} did not round-trip at page_size {ps}",
)
def test_move_kv_cache_moves_page_envelopes(self): def test_move_kv_cache_moves_page_envelopes(self):
"""Whole page envelopes relocate, in raw bytes and (at ps=4) as read """Whole page envelopes relocate, in raw bytes and (at ps=4) as read
back through the per-layer views at the destination kernel ids.""" back through the per-layer views at the destination kernel ids."""
@@ -334,5 +381,58 @@ class TestTranslateKvLocForKernel(unittest.TestCase):
self.assertTrue(torch.all(x == no_out)) self.assertTrue(torch.all(x == no_out))
class _RecordingHybridPool:
"""Stands in for `UnifiedHybridLinearKVPool`, recording the ids it is handed."""
def __init__(self, full_kv_pool, mamba_pool):
self.full_kv_pool = full_kv_pool
self.mamba_pool = mamba_pool
self.seen = None
def get_cpu_copy(self, indices, mamba_indices=None, req_pool_index=None):
self.seen = indices.clone()
return {"full": None}
def load_cpu_copy(
self, kv_cache_cpu, indices, mamba_indices=None, req_pool_index=None
):
self.seen = indices.clone()
class TestMambaAllocatorCpuCopyIsPhysical(unittest.TestCase):
"""REGRESSION: decode retraction calls the allocator's `get_cpu_copy` with
`req_to_token` rows, which hold VIRTUAL ids. This composite inherited the
raising base, and a plain delegate would have been just as wrong -- the
unified pools read those ids as PHYSICAL."""
def _build(self, ps=1):
pool, _, _ = _make_unified(page_size=ps)
kvcache = _RecordingHybridPool(
_FakeKVCache(pool.max_slots("full")),
_FakeKVCache(pool.max_slots("mamba")),
)
with get_parallel().override(dcp_enabled=False, attn_dcp_size=1):
allocator = UnifiedMambaTokenToKVPoolAllocator(
unified_buffer=pool, kvcache=kvcache, device=_DEV, page_size=ps
)
return allocator, kvcache
def test_pool_is_handed_physical_token_ids(self):
alloc, kvcache = self._build()
virtual = alloc.alloc(4)
self.assertIsNotNone(virtual)
virtual = virtual.to(torch.int64)
physical = alloc.full_attn_allocator.translate_kv_loc(virtual)
# Not identity here, so a delegate that passed the virtual ids straight
# through would read and restore other tokens' rows.
self.assertFalse(torch.equal(physical, virtual))
alloc.get_cpu_copy(virtual, req_pool_index=0)
self.assertTrue(torch.equal(kvcache.seen, physical))
alloc.load_cpu_copy({"full": None}, virtual, req_pool_index=0)
self.assertTrue(torch.equal(kvcache.seen, physical))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -202,6 +202,58 @@ class TestUnifiedTriPool(unittest.TestCase):
self.assertEqual(allocator.swa_attn_allocator.allocated_count(), 0) self.assertEqual(allocator.swa_attn_allocator.allocated_count(), 0)
self.assertEqual(allocator.available_size(), before) self.assertEqual(allocator.available_size(), before)
def test_pd_short_tail_fits_beyond_joint_capacity(self):
for lazy in (False, True):
for tail_len in (0, 5):
with self.subTest(lazy=lazy, tail_len=tail_len):
_, allocator, _, _ = self._build(page_size=4, lazy_compaction=lazy)
full = allocator.full_attn_allocator
length = allocator.available_size() + 4
self.assertFalse(allocator.can_reserve(length, length))
self.assertTrue(allocator.can_reserve(length, tail_len))
prefix = torch.tensor([0], dtype=torch.int64)
seq = torch.tensor([length], dtype=torch.int64)
with patch.object(
full,
"alloc_extend",
side_effect=lambda *a, **kw: full.alloc(length),
):
virtual = allocator.alloc_extend_swa_tail(
prefix,
prefix,
seq,
seq,
torch.tensor([-1]),
length,
tail_len,
)
self.assertIsNotNone(virtual)
self.assertEqual(full.allocated_count(), length)
self.assertEqual(
allocator.swa_attn_allocator.allocated_count(),
-(-tail_len // 4) * 4,
)
self.assertEqual(allocator.verify_byte_accounting(), [])
allocator.free(virtual)
self.assertEqual(full.allocated_count(), 0)
self.assertEqual(allocator.swa_attn_allocator.allocated_count(), 0)
def test_pd_tail_rejects_full_capacity_shortfall(self):
_, allocator, _, _ = self._build(page_size=4)
full = allocator.full_attn_allocator
length = full.available_size() + 4
prefix = torch.tensor([0], dtype=torch.int64)
seq = torch.tensor([length], dtype=torch.int64)
with patch.object(full, "alloc_extend") as extend:
self.assertIsNone(
allocator.alloc_extend_swa_tail(
prefix, prefix, seq, seq, torch.tensor([-1]), length, 0
)
)
extend.assert_not_called()
self.assertEqual(full.allocated_count(), 0)
self.assertEqual(allocator.swa_attn_allocator.allocated_count(), 0)
def test_empty_float_is_transparent_to_the_ends(self): def test_empty_float_is_transparent_to_the_ends(self):
_, allocator, _, _ = self._build() _, allocator, _, _ = self._build()
fa = allocator.full_attn_allocator fa = allocator.full_attn_allocator
@@ -1408,5 +1460,64 @@ class TestFloatHoleCreditIsPerSide(unittest.TestCase):
self.assertEqual(flt._byte_accounting_violations(), []) self.assertEqual(flt._byte_accounting_violations(), [])
class TestPreallocIsPricedOnTheSharedGrid(unittest.TestCase):
"""REGRESSION: PD admission compared FULL and SWA against per-side token
budgets, but each side's `available_size` credits the peer's drainable
holes, so a pair that each side can host alone can be jointly infeasible.
Such a pair was admitted and then refused inside `alloc_extend_swa_tail`."""
def _build(self, **kw):
return TestUnifiedTriPool._build(self, **kw)
def test_a_pair_each_side_can_host_alone_is_still_refused(self):
# page_size 1 leaves no slack between the per-side and joint views;
# the double-count only has room to show on a paged grid.
_, allocator, _, _ = self._build(page_size=4)
full_demand = allocator.full_available_size()
swa_demand = allocator.swa_available_size()
self.assertGreater(min(full_demand, swa_demand), 0)
# Each side alone reports room for its own half ...
self.assertLessEqual(full_demand, allocator.full_available_size())
self.assertLessEqual(swa_demand, allocator.swa_available_size())
# ... yet the two draw on the same bytes, so the grid refuses the pair.
self.assertFalse(
allocator._fits_page_demand(
-(-full_demand // allocator.page_size),
-(-swa_demand // allocator.page_size),
)
)
self.assertFalse(
allocator.prealloc_fits(
MagicMock(),
full_demand,
swa_demand,
full_budget_tokens=full_demand,
swa_budget_tokens=swa_demand,
)
)
def test_the_scheduler_budget_still_binds(self):
_, allocator, _, _ = self._build()
page_size = allocator.page_size
self.assertTrue(
allocator.prealloc_fits(
MagicMock(),
page_size,
page_size,
full_budget_tokens=page_size,
swa_budget_tokens=page_size,
)
)
self.assertFalse(
allocator.prealloc_fits(
MagicMock(),
page_size,
page_size,
full_budget_tokens=page_size - 1,
swa_budget_tokens=page_size,
)
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()