[mem_cache] Move the unified-memory allocators into allocator/ and split the composites out (#38072)

This commit is contained in:
Liangsheng Yin
2026-09-04 19:53:15 -07:00
committed by GitHub
parent d6e0a8cbf4
commit 0645398a32
29 changed files with 1660 additions and 1974 deletions
@@ -53,6 +53,12 @@ from sglang.srt.mem_cache.allocator.swa import (
PureSWATokenToKVPoolAllocator, PureSWATokenToKVPoolAllocator,
SWATokenToKVPoolAllocator, SWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.base_prefix_cache import ( from sglang.srt.mem_cache.base_prefix_cache import (
BasePrefixCache, BasePrefixCache,
InitLoadBackParams, InitLoadBackParams,
@@ -60,10 +66,6 @@ from sglang.srt.mem_cache.base_prefix_cache import (
MatchPrefixParams, MatchPrefixParams,
zero_match_result, zero_match_result,
) )
from sglang.srt.mem_cache.multi_ended_allocator import (
UnifiedMambaSWATokenToKVPoolAllocator,
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey, TreeNode from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey, TreeNode
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -21,11 +21,11 @@ from sglang.srt.managers.scheduler_components.pool_stats_observer import (
SchedulerPoolStatsObserver, SchedulerPoolStatsObserver,
) )
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.mem_cache.multi_ended_allocator import (
UnifiedMambaSWATokenToKVPoolAllocator, UnifiedMambaSWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.observability.scheduler_stage_metrics import ( from sglang.srt.observability.scheduler_stage_metrics import (
SCHEDULER_STAGE_SANITY_CHECK_CACHE, SCHEDULER_STAGE_SANITY_CHECK_CACHE,
SchedulerStageMetricsRecorder, SchedulerStageMetricsRecorder,
@@ -11,7 +11,7 @@ from typing import (
Tuple, Tuple,
) )
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator, UnifiedMambaSWATokenToKVPoolAllocator,
) )
+19 -37
View File
@@ -52,36 +52,26 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
return self.size return self.size
# -- scheduler-facing capacity hooks -- # -- scheduler-facing capacity hooks --
# The scheduler calls these UNCONDITIONALLY (zero feature branches on its # The scheduler calls these unconditionally, with no allocator-type branches
# side); the defaults reproduce the historical token behavior exactly, and # on its side; byte-accounted composites override the token-count defaults.
# unified composites override them with byte-denominated logic.
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> None: def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> None:
"""Ask the prefix cache to evict unlocked entries until this allocator """Evict unlocked prefix-cache entries until this allocator can serve
can serve ``num_tokens`` (or nothing evictable remains). Default = the ``num_tokens`` or nothing evictable remains."""
shared token-count eviction; joint-byte composites override (evicting
one multi-lifetime tree node frees bytes on several sides at once).
"""
from sglang.srt.mem_cache.common import evict_from_tree_cache from sglang.srt.mem_cache.common import evict_from_tree_cache
evict_from_tree_cache(tree_cache, num_tokens) evict_from_tree_cache(tree_cache, num_tokens)
def check_decode_capacity(self, *, num_tokens: int, tree_cache) -> bool: def check_decode_capacity(self, *, num_tokens: int, tree_cache) -> bool:
"""Whether the NEXT decode step's ``num_tokens`` allocation fits, """Whether the next decode step's ``num_tokens`` allocation fits after
evicting reclaimable cache first. The retract loop converges on this evicting reclaimable cache. The retract loop converges on this same
same check, so allocator-side shortfalls retract gracefully instead of check, so a shortfall here retracts instead of failing in alloc."""
tripping fail-loud alloc errors. Default reproduces the historical
``ScheduleBatch.check_decode_mem`` body; unified composites override
with byte gates + per-step reservations of their own.
"""
self.evict_to_free_tokens(tree_cache, num_tokens) self.evict_to_free_tokens(tree_cache, num_tokens)
return self.available_size() >= num_tokens return self.available_size() >= num_tokens
def verify_byte_accounting(self) -> list: def verify_byte_accounting(self) -> list:
"""Idle-time conservation diagnostic: recompute this allocator's """Idle-time diagnostic: recompute byte/slot accounting and return
byte/slot accounting and return human-readable violation strings violation strings, empty when healthy. Static pools have no byte model."""
(empty == healthy). Default: static pools have no byte model.
"""
return [] return []
def debug_print(self) -> str: def debug_print(self) -> str:
@@ -126,11 +116,9 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
def translate_kv_indices_for_transfer( def translate_kv_indices_for_transfer(
self, kv_indices: torch.Tensor self, kv_indices: torch.Tensor
) -> torch.Tensor: ) -> torch.Tensor:
"""Token ids as the PD-disaggregation transfer engine addresses them. """Token ids as the PD transfer engine addresses them. Identity here
because a static pool's ids index its registered buffers directly;
Identity here: a static pool's token ids index its registered buffers virtual-id pools must override."""
directly. Virtual-id pools must override.
"""
return kv_indices return kv_indices
def get_cpu_copy(self, indices, mamba_indices=None): def get_cpu_copy(self, indices, mamba_indices=None):
@@ -166,11 +154,8 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
raise NotImplementedError() raise NotImplementedError()
def free_full(self, free_index: torch.Tensor): def free_full(self, free_index: torch.Tensor):
"""Free slots whose SWA peers the caller already released. """Free full-attention slots whose paired SWA slots the caller already
released. A single pool has no SWA peer, so this is a plain free()."""
A hybrid SWA allocator pairs each full-attention slot with an SWA slot
that can die first; this releases the full side alone. A single pool has
no peer, so it is a plain free()."""
self.free(free_index) self.free(free_index)
def free_segment(self, free_index: torch.Tensor, *, start_pos: int): def free_segment(self, free_index: torch.Tensor, *, start_pos: int):
@@ -178,7 +163,7 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
In page units the segment is ``[start_pos // ps, ceil(end / ps))``: In page units the segment is ``[start_pos // ps, ceil(end / ps))``:
``start_pos`` sits on a page boundary, the end may fall mid-page, and ``start_pos`` sits on a page boundary, the end may fall mid-page, and
the whole last page is released. Default: plain free().""" the whole last page is released."""
assert start_pos % self.page_size == 0, ( assert start_pos % self.page_size == 0, (
f"segment start {start_pos} is not page-aligned" f"segment start {start_pos} is not page-aligned"
) )
@@ -186,18 +171,15 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
def free_segments(self, segments): def free_segments(self, segments):
"""Free several ``(free_index, start_pos)`` segments of one request's """Free several ``(free_index, start_pos)`` segments of one request's
kv row. kv row. Each covers pages ``[start_pos // ps, ceil(end / ps))``; starts
are page-aligned and consecutive page ranges do not overlap, so every
Each segment covers the pages ``[start_pos // ps, ceil(end / ps))``. page is released exactly once."""
Starts sit on page boundaries, ends may fall mid-page, and the page
ranges of consecutive segments do not overlap -- so in page units the
segments are aligned and disjoint, and every page is released once."""
for free_index, start_pos in self._page_disjoint(segments): for free_index, start_pos in self._page_disjoint(segments):
self.free_segment(free_index, start_pos=start_pos) self.free_segment(free_index, start_pos=start_pos)
def free_full_segment(self, free_index: torch.Tensor, *, start_pos: int): def free_full_segment(self, free_index: torch.Tensor, *, start_pos: int):
"""free_full() for a kv-row segment; same start-alignment contract as """free_full() for a kv-row segment; same start-alignment contract as
free_segment(). Default: plain free_full().""" free_segment()."""
assert start_pos % self.page_size == 0, ( assert start_pos % self.page_size == 0, (
f"segment start {start_pos} is not page-aligned" f"segment start {start_pos} is not page-aligned"
) )
@@ -112,11 +112,7 @@ class HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
last_loc: torch.Tensor, last_loc: torch.Tensor,
extend_num_tokens: int, extend_num_tokens: int,
): ):
"""Allocate only logical indices without hisparse device indices. """Allocate only logical indices without hisparse device indices."""
Used in the direct-to-host transfer path where KV data is written
directly to host memory by the prefill node, skipping GPU staging.
"""
return self.logical_attn_allocator.alloc_extend( return self.logical_attn_allocator.alloc_extend(
prefix_lens, prefix_lens,
prefix_lens_cpu, prefix_lens_cpu,
@@ -131,9 +127,7 @@ class HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
# clear original reference and isolate the buffer from outside addressing, allocate new buffer if needed # clear original reference and isolate the buffer from outside addressing, allocate new buffer if needed
hisparse_indices = self.full_to_hisparse_device_index_mapping[allocated_indices] hisparse_indices = self.full_to_hisparse_device_index_mapping[allocated_indices]
self.full_to_hisparse_device_index_mapping[allocated_indices] = 0 self.full_to_hisparse_device_index_mapping[allocated_indices] = 0
# Filter valid (non-zero) hisparse indices. # Zero means unmapped; after alloc_logical_only the mapping is all zeros.
# In the direct-to-host path, mapping is all zeros since no hisparse
# device indices were pre-allocated.
hisparse_indices = hisparse_indices[hisparse_indices > 0] hisparse_indices = hisparse_indices[hisparse_indices > 0]
if len(hisparse_indices) >= need_size: if len(hisparse_indices) >= need_size:
buffer_indices = hisparse_indices[:need_size] buffer_indices = hisparse_indices[:need_size]
@@ -241,7 +235,7 @@ class HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def clear(self): def clear(self):
self.logical_attn_allocator.clear() self.logical_attn_allocator.clear()
self.hisparse_attn_allocator.clear() self.hisparse_attn_allocator.clear()
# Note: the last item is -1, we don't clear it, see the comment in __init__ # Keep the trailing -1: it is what a last_loc of -1 translates to.
self.full_to_hisparse_device_index_mapping[:-1].fill_(0) self.full_to_hisparse_device_index_mapping[:-1].fill_(0)
self.free_group = None self.free_group = None
+5 -14
View File
@@ -28,20 +28,13 @@ import torch
class MambaSlotAllocator: class MambaSlotAllocator:
"""Manages the free-list of Mamba pool slot indices. """Free-list of Mamba pool slot indices. Deliberately not a subclass of
``BaseTokenToKVPoolAllocator``: slots are per request, not per token."""
Unlike ``BaseTokenToKVPoolAllocator`` which is designed for per-token KV
pages, Mamba slots are request-level (typically 1 slot per request).
We keep the interface minimal and do NOT inherit the KV base class.
"""
def __init__(self, size: int, device: str): def __init__(self, size: int, device: str):
self.size = size self.size = size
self.device = device self.device = device
# Active preallocated batch for `alloc_group_begin` / `alloc_group_end`. # Set by alloc_group_begin(); alloc(1) drains it until alloc_group_end().
# When non-None, `alloc(1)` consumes the next slot from this iterator
# instead of calling `_do_alloc(1)` per request. Reset to None outside
# a group window so `alloc` falls through to the per-call path.
self._alloc_iter: Optional[Iterator] = None self._alloc_iter: Optional[Iterator] = None
self.clear() self.clear()
@@ -49,10 +42,8 @@ class MambaSlotAllocator:
return len(self.free_slots) return len(self.free_slots)
def schedulable_available_size(self) -> int: def schedulable_available_size(self) -> int:
"""Planner-facing free count. Identity to ``available_size`` for the """Planner-facing free count. Same as ``available_size`` for a static pool;
static pool (slot-count and byte-coordinated views coincide); the shared byte-coordinated allocators return their byte-limited view instead."""
``UnifiedMambaSlotAllocator`` overrides it with the byte-coordinated view.
Lets ``alloc_req_slots`` call it uniformly without a getattr fallback."""
return self.available_size() return self.available_size()
def alloc_group_begin(self, num_reqs: int): def alloc_group_begin(self, num_reqs: int):
+6 -24
View File
@@ -119,11 +119,8 @@ def alloc_extend_naive(
class PagedTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator): class PagedTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
""" """Same interface as `TokenToKVPoolAllocator`, but the indices handed to one
An allocator managing the indices to kv cache data. request are always page-aligned.
This class has the same interface as `TokenToKVPoolAllocator` but the output
of one request is always page-aligned.
TODO: fuse last_loc into the kernel. TODO: fuse last_loc into the kernel.
""" """
@@ -141,18 +138,8 @@ class PagedTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.num_pages = size // page_size self.num_pages = size // page_size
self.debug_mode = get_bool_env_var("SGLANG_DEBUG_MEMORY_POOL") self.debug_mode = get_bool_env_var("SGLANG_DEBUG_MEMORY_POOL")
# Pre-warm the torch.unique HIP kernel used in free(). When a request # Pre-warm the torch.unique used by free(): on ROCm the first call
# finishes with a prompt that already exists in the radix tree (e.g. # JIT-compiles rocPRIM sort/unique kernels and costs ~200ms.
# bench_serving sending the same warmup+measured prompt), the radix
# cache's _insert_helper frees the duplicate KV indices via
# token_to_kv_pool_allocator.free(value[start:prefix_len]). That call
# path runs `torch.unique(free_index // self.page_size)` on a
# ~prompt_len-sized int64 tensor. The first such call on AMD ROCm
# JIT-compiles rocPRIM sort/unique kernels and costs ~200ms, which
# shows up as a mysterious "second-request slow" (Run 1) for
# repeated-prompt benchmarks. Running it once at init time moves
# that JIT cost to startup. This is a ROCm-only JIT cost, so the
# warm-up is gated on _is_hip and skipped on other platforms.
if _is_hip and torch.cuda.is_available(): if _is_hip and torch.cuda.is_available():
try: try:
_warmup = torch.arange(1024, dtype=torch.int64, device=device) _warmup = torch.arange(1024, dtype=torch.int64, device=device)
@@ -298,13 +285,8 @@ class PagedTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self._debug_check_no_duplicate_pages() self._debug_check_no_duplicate_pages()
def free_segment(self, free_index: torch.Tensor, *, start_pos: int): def free_segment(self, free_index: torch.Tensor, *, start_pos: int):
"""Fixed-shape counterpart of free(). """Fixed-shape free(): page-aligned start plus contiguous per-page tokens
make ``free_index[::page_size]`` hit each page once; no torch.unique sync."""
The segment starts on a page boundary and a page's tokens sit
consecutively in the kv row, so ``free_index[::page_size]`` is one
token from each page the segment covers -- including a partial last
page. No torch.unique, whose data-dependent output shape forces a
device sync. Contract: see base."""
if free_index.numel() == 0: if free_index.numel() == 0:
return return
+3 -13
View File
@@ -84,9 +84,8 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
swa_kv_pool, swa_kv_pool,
need_sort, need_sort,
) )
# Note: append one more item of value -1 in the end so -1 maps to -1. # Trailing -1: a last_loc of -1 (no prefix) indexes it, so alloc_extend and
# It is needed for the last_loc in alloc_extend, where the first full_last_loc # alloc_decode see -1 on the SWA side as well.
# is -1, and we need to map it to swa_last_loc -1 as well.
self.full_to_swa_index_mapping = torch.cat( self.full_to_swa_index_mapping = torch.cat(
[ [
torch.zeros( torch.zeros(
@@ -233,12 +232,7 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
extend_num_tokens: int, extend_num_tokens: int,
swa_tail_len: int, swa_tail_len: int,
): ):
"""Allocate full KV for the whole extend and SWA KV only for the tail. """Allocate full KV for the whole extend and SWA KV only for the tail."""
This is used by disaggregated decode preallocation: decode receives full
prompt KV for full-attention layers, but only the sliding-window state is
transferred for SWA layers.
"""
assert self.page_size > 1 assert self.page_size > 1
assert len(seq_lens_cpu) == 1, "SWA tail allocation currently supports bs=1" assert len(seq_lens_cpu) == 1, "SWA tail allocation currently supports bs=1"
assert len(prefix_lens_cpu) == 1 assert len(prefix_lens_cpu) == 1
@@ -334,10 +328,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def set_full_to_swa_mapping( def set_full_to_swa_mapping(
self, full_indices: torch.Tensor, swa_indices: torch.Tensor self, full_indices: torch.Tensor, swa_indices: torch.Tensor
) -> None: ) -> None:
"""Write full_to_swa_index_mapping[full_indices[i]] = swa_indices[i].
Used by HiCache load-back path to rebuild the mapping after FULL and SWA device alloc.
"""
if full_indices.numel() == 0: if full_indices.numel() == 0:
return return
assert full_indices.numel() == swa_indices.numel() assert full_indices.numel() == swa_indices.numel()
@@ -0,0 +1,918 @@
# 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.
# ==============================================================================
"""Unified-memory composites for hybrid SWA models: the full-attention and SWA
sub-pools of one `UnifiedKVPool`, and the tri-pool variant that adds mamba state."""
from __future__ import annotations
import logging
from typing import List, Optional, Sequence
import torch
from torch.profiler import record_function
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
FloatMultiEndedAllocator,
MultiEndedAllocator,
_chain_byte_accounting_violations,
_end_pair_chain,
_float_open_short_side,
_relieve_for_alloc,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
from sglang.srt.utils.common import get_num_new_pages
logger = logging.getLogger(__name__)
class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
"""Composite allocator for the hybrid SWA pair (full + swa MHA sub-pools).
One alloc(N) binds N pages on BOTH sides under the same virtual id, so
`available_size()` (joint bytes, in TOKENS) is the only safe alloc pre-check.
"""
# Parent's `size` property has no setter but base init does `self.size = size`;
# the no-op setter below absorbs that write.
@property
def size(self) -> int:
return min(self._size_full, self._size_swa)
@size.setter
def size(self, value) -> None:
pass
def __init__(
self,
*,
unified_buffer: UnifiedKVPool,
kvcache, # UnifiedSWAKVPool
device: str,
full_max_total_num_tokens: int,
swa_max_total_num_tokens: int,
page_size: int = 1,
need_sort: bool = False,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
):
# Set _size_full / _size_swa BEFORE base init (read during it). STATIC
# partition caps -- the slot-conservation value the leak invariant expects.
self._size_full = full_max_total_num_tokens
self._size_swa = swa_max_total_num_tokens
self._full_max_total_num_tokens = full_max_total_num_tokens
self._swa_max_total_num_tokens = swa_max_total_num_tokens
self.page_size = page_size
# The parent is inherited only for the isinstance contract: skip its
# static-partition sub-pool allocation, which the unified pool replaces.
BaseTokenToKVPoolAllocator.__init__(
self,
size=full_max_total_num_tokens,
page_size=page_size,
dtype=unified_buffer.mha_spec("full").store_dtype,
device=device,
kvcache=kvcache,
need_sort=need_sort,
)
self.unified_buffer = unified_buffer
self._kvcache = kvcache
self.lazy_compaction = lazy_compaction
self.full_attn_allocator = MultiEndedAllocator(
kvcache=kvcache.full_kv_pool,
unified_buffer=unified_buffer,
sub_pool_name="full",
device=device,
is_id_owner=True,
page_size=page_size,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
self.swa_attn_allocator = self._build_swa_attn_allocator(
kvcache=kvcache.swa_kv_pool,
unified_buffer=unified_buffer,
device=device,
page_size=page_size,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
# swa binds the virtual pages full mints, so it must address
# full's whole id space.
virtual_num_pages=self.full_attn_allocator.num_virtual_ids,
)
self._wire_peers()
# Epoch-keyed memo for the joint capacity view (any chain member's
# mutation invalidates -- see `MultiEndedAllocator._chain_capacity_epoch`).
self._joint_avail_memo_epoch: Optional[int] = None
self._joint_avail_memo_tokens: int = 0
# The full/SWA KV pools need no allocator wiring (write locations resolved
# in attention metadata); the composite keeps allocators for read-path translates.
kvcache.attach_allocators(
full_allocator=self.full_attn_allocator,
swa_allocator=self.swa_attn_allocator,
)
self.free_group = None
self.free_page_reps_group: Optional[List[torch.Tensor]] = None
self.full_free_group: List[torch.Tensor] = []
# Empty (not None) for the leak checker.
self.free_pages = torch.empty(0, dtype=torch.int64, device=device)
self.release_pages = torch.empty(0, dtype=torch.int64, device=device)
logger.info(
"[unified-memory-pool] UnifiedSWATokenToKVPoolAllocator ready: "
"full max_slots=%d (min_slot_index=%d, entry_bytes=%d), "
"swa max_slots=%d (min_slot_index=%d, entry_bytes=%d), "
"static caps full=%d swa=%d, joint available=%d",
self.full_attn_allocator.max_slots,
self.full_attn_allocator.min_slot_index,
self.full_attn_allocator.entry_bytes,
self.swa_attn_allocator.max_slots,
self.swa_attn_allocator.min_slot_index,
self.swa_attn_allocator.entry_bytes,
self._full_max_total_num_tokens,
self._swa_max_total_num_tokens,
self.available_size(),
)
# -- construction hooks (the tri-pool subclass overrides both) --
def _build_swa_attn_allocator(self, **kwargs) -> MultiEndedAllocator:
"""The swa sub-allocator: an END pool in the 2-pool pair."""
return MultiEndedAllocator(
sub_pool_name="swa",
is_id_owner=False, # non-owner; consumes virtuals minted by full
**kwargs,
)
def _wire_peers(self) -> None:
self.full_attn_allocator.bind_peer(self.swa_attn_allocator)
self.swa_attn_allocator.bind_peer(self.full_attn_allocator)
# -- capacity reporting (three-way split) --
def available_size(self) -> int:
"""Tokens available for `alloc(N)` / `alloc_extend(N)` (TOKENS)."""
epoch = self.full_attn_allocator._chain_capacity_epoch()
if self._joint_avail_memo_epoch != epoch:
self._joint_avail_memo_tokens = self._compute_available_size()
self._joint_avail_memo_epoch = epoch
return self._joint_avail_memo_tokens
def _compute_available_size(self) -> int:
"""Joint byte budget in TOKENS: each composite alloc(1) consumes one
full-side AND one swa-side page under the same virtual id."""
fa, sa = self.full_attn_allocator, self.swa_attn_allocator
e_f = fa.entry_bytes_per_page
e_s = sa.entry_bytes_per_page
# Direction-agnostic shared gap: the free byte band between the two pools.
if fa.grow_direction == "up":
gap_bytes = max(0, sa._byte_low_frontier() - fa._byte_high_frontier())
else:
gap_bytes = max(0, fa._byte_low_frontier() - sa._byte_high_frontier())
R_f = fa.num_pages - fa.min_page_index - fa._allocated_pages()
R_s = sa.num_pages - sa.min_page_index - sa._allocated_pages()
if not self.lazy_compaction:
pages_by_bytes = gap_bytes // (e_f + e_s)
return min(pages_by_bytes, R_f, R_s) * self.page_size
H_f = len(fa._free_phys_pages)
H_s = len(sa._free_phys_pages)
K1 = min(H_f, H_s) # Phase 1: both drain
# Phase 2: fewer-holes side extends; more-holes side keeps draining.
if H_f <= H_s:
e_phase2 = e_f
K_phase2_max = H_s
else:
e_phase2 = e_s
K_phase2_max = H_f
K2_room = K_phase2_max - K1
K2 = min(K2_room, gap_bytes // e_phase2) if e_phase2 > 0 else K2_room
gap_bytes -= K2 * e_phase2
K3 = gap_bytes // (e_f + e_s) # Phase 3: both extend
K_total = K1 + K2 + K3
K_total = min(K_total, H_f + R_f, H_s + R_s) # index-space caps
return K_total * self.page_size
# Slot-conservation views for the leak invariant only; the byte-coordinated
# value would flag spurious leaks. `allocated_count()` is in TOKENS.
def _conserve_full_available_size(self) -> int:
return (
self._full_max_total_num_tokens - self.full_attn_allocator.allocated_count()
)
def _conserve_swa_available_size(self) -> int:
return (
self._swa_max_total_num_tokens - self.swa_attn_allocator.allocated_count()
)
# Per-side views read by scheduling / eviction: the static-conserve cap bounds
# the lending side, `schedulable_*` the side grown into the shared gap.
def full_available_size(self) -> int:
return min(
self._conserve_full_available_size(),
self.schedulable_full_available_size(),
)
def swa_available_size(self) -> int:
return min(
self._conserve_swa_available_size(),
self.schedulable_swa_available_size(),
)
# Leak-invariant aliases; schedulers take the `min(...)` views above, whose
# byte term dips below the conserve cap when bytes are lent to a peer.
def conserve_full_available_size(self) -> int:
return self._conserve_full_available_size()
def conserve_swa_available_size(self) -> int:
return self._conserve_swa_available_size()
# Byte-coordinated, realizable-with-compaction views (peer drainable holes
# credited -- see `MultiEndedAllocator.schedulable_available_size`).
def schedulable_full_available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def schedulable_swa_available_size(self) -> int:
return self.swa_attn_allocator.schedulable_available_size()
def _flush_targets(self):
"""Flush ALL members, including ones that are not short themselves: a
one-sided hole is unusable, and compacting it yields SHARED gap."""
return (self.full_attn_allocator, self.swa_attn_allocator)
def _ask_float_for_room(self, need_tokens: int) -> None:
"""No float in a two-END chain -- nothing can slide."""
return None
# `size_full` / `size_swa` are inherited and read the static caps; reporting
# `max_slots - 1` here would be ~= full_max + swa_max and over-promise.
@property
def draft_virtual_id_space(self) -> int:
return self.full_attn_allocator.max_slots - 1
def debug_print(self) -> str:
return (
f"#full-available={self.full_attn_allocator.available_size()}, "
f"#swa-available={self.swa_attn_allocator.available_size()}, "
f"#joint-available={self.available_size()}"
)
def get_kvcache(self):
return self._kvcache
def translate_kv_loc(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Full-layer read path: virtual TOKEN ids -> full-physical TOKEN ids.
``out=`` writes in place, for cuda-graph buffer stability."""
result = self.full_attn_allocator.translate_kv_loc(loc, out=out)
return result
def translate_loc_from_full_to_swa(
self,
kv_indices: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""SWA-layer read path: virtual TOKEN ids -> swa kernel-facing ids."""
return self.swa_attn_allocator.translate_kv_loc_for_kernel(kv_indices, out=out)
@property
def kernel_page_multiplier(self) -> int:
return self.full_attn_allocator.kernel_page_multiplier
@property
def full_v2p_page_table(self) -> torch.Tensor:
"""Page-level virtual->physical table of the full sub-pool."""
return self.full_attn_allocator.virtual_to_physical
@property
def full_p2v_page_table(self) -> torch.Tensor:
"""Page-level physical->virtual table of the full sub-pool."""
return self.full_attn_allocator.physical_to_virtual
def translate_kv_loc_for_kernel(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Full-pool virtual TOKEN ids -> kernel-facing ids."""
return self.full_attn_allocator.translate_kv_loc_for_kernel(loc, out=out)
def translate_write_loc_for_kernel(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Widened virtual WRITE loc -> kernel-facing id. DCP is rejected for this
composite at argument validation, so it coincides with the read translate."""
return self.full_attn_allocator.translate_write_loc_for_kernel(loc, out=out)
@property
def swa_kernel_page_multiplier(self) -> int:
return self.swa_attn_allocator.kernel_page_multiplier
@property
def swa_v2p_page_table(self) -> torch.Tensor:
"""Page-level virtual->physical table of the SWA sub-pool."""
return self.swa_attn_allocator.virtual_to_physical
# -- alloc --
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
with record_function("UnifiedSWAAlloc.alloc"):
# Joint pre-check. Both sides are mutual peers (each side's compaction
# opens gap for the other), so flush BOTH on shortfall.
if need_size > self.available_size():
if not _relieve_for_alloc(self, need_size):
return None
# Snapshot the virtual PAGES full will consume, to bind them on swa too.
num_pages = need_size // self.page_size
fa = self.full_attn_allocator
new_virtual_pages = fa.free_virtual_ids[:num_pages].clone()
v_tokens = fa.alloc(need_size)
# Post-pre-check failure can only be internal-state inconsistency.
assert v_tokens is not None, (
"UnifiedSWA.alloc: full.alloc returned None after joint "
"pre-check passed — internal-state inconsistency"
)
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return v_tokens
def alloc_extend(
self,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
) -> Optional[torch.Tensor]:
"""Paged extend; returns virtual TOKEN ids. The same virtual page maps to
full- and swa-physical, so swa binds exactly what the full kernel consumed."""
with record_function("UnifiedSWAAlloc.alloc_extend"):
num_new_pages = get_num_new_pages(
seq_lens=seq_lens_cpu,
page_size=self.page_size,
prefix_lens=prefix_lens_cpu,
)
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not _relieve_for_alloc(self, need_tokens):
return None
# Snapshot the virtual PAGES the kernel will consume; clone so swa keeps
# its view after the slice is consumed.
fa = self.full_attn_allocator
new_virtual_pages = fa.free_virtual_ids[:num_new_pages].clone()
out_indices = fa.alloc_extend(
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc,
extend_num_tokens,
num_new_pages=num_new_pages,
)
assert out_indices is not None, (
"UnifiedSWA.alloc_extend: full.alloc_extend returned None "
"after joint pre-check passed — internal-state inconsistency"
)
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return out_indices # virtual TOKEN ids
def alloc_decode(
self,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
) -> Optional[torch.Tensor]:
"""Paged decode: one new token per request, consuming a page only when the
decode wraps."""
with record_function("UnifiedSWAAlloc.alloc_decode"):
num_new_pages = get_num_new_pages(
seq_lens=seq_lens_cpu, page_size=self.page_size, decode=True
)
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not _relieve_for_alloc(self, need_tokens):
return None
fa = self.full_attn_allocator
new_virtual_pages = fa.free_virtual_ids[:num_new_pages].clone()
out_indices = fa.alloc_decode(seq_lens, seq_lens_cpu, last_loc)
assert out_indices is not None, (
"UnifiedSWA.alloc_decode: full.alloc_decode returned None "
"after joint pre-check passed — internal-state inconsistency"
)
if new_virtual_pages.numel() > 0:
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return out_indices # virtual TOKEN ids
def is_slot_allocated(self, slot: int) -> bool:
"""Token-slot surface = the full side (which owns the virtual ids)."""
return self.full_attn_allocator.is_slot_allocated(slot)
def allocator_state_str(self) -> str:
return self.full_attn_allocator.allocator_state_str()
# -- free --
def free(self, free_index: torch.Tensor) -> None:
with record_function("UnifiedSWAAlloc.free"):
if free_index is None or free_index.numel() == 0:
return
if self.free_group is not None:
self.free_group.append(self._copy_for_free_group(free_index))
return
# Order is not load-bearing: the per-sub-pool v2p IS the mapping. Only
# swa needs the tombstone filter; full owns the ids, so all are bound.
v = free_index.detach().to(torch.int64)
v_pages = v // self.page_size
swa_v2p_pages = self.swa_attn_allocator.virtual_to_physical[v_pages]
# `> 0` strict: -1 = tombstoned, 0 = padding-sink page; both skipped.
live_token_mask = swa_v2p_pages > 0
live_tokens = v[live_token_mask]
if live_tokens.numel() > 0:
self.swa_attn_allocator.free(live_tokens)
self.full_attn_allocator.free(v)
self.full_attn_allocator.clear_inverse_history()
self.swa_attn_allocator.clear_inverse_history()
def free_swa(
self, free_index: torch.Tensor, *, start_pos: Optional[int] = None
) -> None:
"""SWA tombstone path: release swa-physical, keep the virtual id and
full-physical live; `swa.v2p_page[v_page] = -1` IS the tombstone."""
if free_index is None or free_index.numel() == 0:
return
v = free_index.detach().to(torch.int64)
ps = self.page_size
# `start_pos` promises a contiguous ascending range starting at that prefix
# position, so page reps come from stride arithmetic, not `torch.unique`.
if start_pos is not None and ps > 1:
reps = self.swa_attn_allocator._page_reps(v, start_pos)
# Keep only pages still bound on swa; freeing a tombstoned one would
# corrupt the hole list. `> 0` strict: -1 tombstoned, 0 padding sink.
rep_pages = reps // ps
swa_v2p_pages = self.swa_attn_allocator.virtual_to_physical[rep_pages]
live_reps = reps[swa_v2p_pages > 0]
if live_reps.numel() == 0:
return
self.swa_attn_allocator.free(live_reps, _pages=live_reps // ps)
self.swa_attn_allocator.clear_inverse_history()
return
v_pages = v // ps
# `> 0` strict: -1 = tombstoned, page 0 = padding sink (never freeable).
swa_v2p_pages = self.swa_attn_allocator.virtual_to_physical[v_pages]
live = v[swa_v2p_pages > 0]
if live.numel() == 0:
return
if ps == 1:
# token == page and the live filter just deduped against the v2p
# table, so these ARE unique page ids -- same skip as `_free_lazy`.
self.swa_attn_allocator.free(live, _pages=live)
else:
self.swa_attn_allocator.free(live)
self.swa_attn_allocator.clear_inverse_history()
def free_full(self, free_index: torch.Tensor) -> None:
"""Release the full-physical page and the virtual id, leaving the swa
side alone -- the caller already tombstoned it (`swa.v2p_page == -1`)."""
if free_index is None or free_index.numel() == 0:
return
if self.free_group is not None:
self.full_free_group.append(self._copy_for_free_group(free_index))
return
self.full_attn_allocator.free(free_index.detach().to(torch.int64))
self.full_attn_allocator.clear_inverse_history()
def free_full_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
if free_index is None or free_index.numel() == 0:
return
if self.page_size == 1:
# token == page: free_full already frees by exact ids, no dedup.
self.free_full(free_index)
return
# The swa v2p is the mapping, so a tombstoned swa page drops out of the
# two-sided segment path by itself; full-only is the same call.
self.free_segment(free_index, start_pos=start_pos)
def set_full_to_swa_mapping(
self, full_indices: torch.Tensor, swa_indices: torch.Tensor
) -> None:
"""No-op stub for HiCache load-back: in shared mode the swa v2p IS the
mapping, and HiCache for shared SWA is out of scope."""
return
def clear_full_to_swa_mapping(self, full_indices: torch.Tensor) -> None:
# Paired with set_full_to_swa_mapping: shared mode has no mapping tensor.
return
# -- free-group --
# Not the SWA parent's hooks: those open the parent's paged full allocator
# as a free group, and this composite's sub-pools defer on their own.
def free_group_begin(self) -> None:
BaseTokenToKVPoolAllocator.free_group_begin(self)
self.free_page_reps_group = []
self.full_free_group = []
def free_group_end(self) -> None:
pending, self.free_page_reps_group = self.free_page_reps_group, None
full_free_group, self.full_free_group = self.full_free_group, []
BaseTokenToKVPoolAllocator.free_group_end(self)
if full_free_group:
self.full_attn_allocator.free(torch.cat(full_free_group))
self.full_attn_allocator.clear_inverse_history()
if pending:
self._release_page_reps(pending)
def free_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
"""Fixed-shape counterpart of `free()`; see `MultiEndedAllocator._page_reps`.
Both sides share one page-rep derivation instead of dedup'ing twice."""
if free_index is None or free_index.numel() == 0:
return
if self.page_size == 1:
self.free(free_index)
return
reps = self.full_attn_allocator._page_reps(
free_index.detach().to(torch.int64), start_pos
)
if self.free_page_reps_group is None:
self._release_page_reps((reps,))
else:
self.free_page_reps_group.append(reps)
def _release_page_reps(self, pieces: Sequence[torch.Tensor]) -> None:
reps = pieces[0] if len(pieces) == 1 else torch.cat(tuple(pieces))
v_pages = reps // self.page_size
# Same tombstone filter as `free`, but at PAGE granularity (page_size
# times smaller): `> 0` strict -- -1 = tombstoned, 0 = padding sink.
swa_v2p_pages = self.swa_attn_allocator.virtual_to_physical[v_pages]
live_pages = v_pages[swa_v2p_pages > 0]
if live_pages.numel() > 0:
self.swa_attn_allocator.free(live_pages * self.page_size, _pages=live_pages)
self.full_attn_allocator.free(reps, _pages=v_pages)
self.full_attn_allocator.clear_inverse_history()
self.swa_attn_allocator.clear_inverse_history()
def verify_byte_accounting(self) -> List[str]:
return (
_chain_byte_accounting_violations(
_end_pair_chain(self.full_attn_allocator, self.swa_attn_allocator)
)
+ self._joint_capacity_memo_violations()
)
def _joint_capacity_memo_violations(self) -> List[str]:
"""Idle-time twin of `MultiEndedAllocator._capacity_memo_violations`
for the composite joint view. Empty == healthy."""
if (
self._joint_avail_memo_epoch
!= self.full_attn_allocator._chain_capacity_epoch()
):
return []
actual = self._compute_available_size()
if self._joint_avail_memo_tokens == actual:
return []
return [
f"[joint] stale available_size memo: "
f"cached={self._joint_avail_memo_tokens}, actual={actual}"
]
def clear(self) -> None:
self.full_attn_allocator.clear()
self.swa_attn_allocator.clear()
self.free_group = None
self.free_page_reps_group = None
self.full_free_group = []
# -- Lazy compaction hooks --
def set_latest_forward_done_event(self, event: Optional[torch.cuda.Event]) -> None:
"""Forward the per-batch `forward_done` event to BOTH sub-allocators."""
with record_function("UnifiedSWAAlloc.set_latest_forward_done_event"):
self.full_attn_allocator.set_latest_forward_done_event(event)
self.swa_attn_allocator.set_latest_forward_done_event(event)
def set_inflight_forward(
self,
forward_done: torch.cuda.Event,
out_cache_loc_virtual: Optional[torch.Tensor],
) -> None:
"""Hand the forward's metadata to BOTH sub-pools; each materializes its own
write-set via its OWN v2p, and the forward writes both sides per token."""
with record_function("UnifiedSWAAlloc.set_inflight_forward"):
self.full_attn_allocator.set_inflight_forward(
forward_done, out_cache_loc_virtual
)
self.swa_attn_allocator.set_inflight_forward(
forward_done, out_cache_loc_virtual
)
def flush_opportunistic(self) -> int:
"""Non-urgent flush of BOTH sub-allocators; sync-free."""
with record_function("UnifiedSWAAlloc.flush_opportunistic"):
fa = self.full_attn_allocator
sa = self.swa_attn_allocator
if (
fa._free_phys_pages.numel() == 0
and not fa._pending_reuse
and sa._free_phys_pages.numel() == 0
and not sa._pending_reuse
):
return 0
return fa.flush_opportunistic() + sa.flush_opportunistic()
class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
"""Tri-pool composite for models with full KV + SWA KV + mamba/conv state
(both `mambaish_config` and `is_hybrid_swa`).
Chain (low byte -> high byte):
[ mamba/conv (grow-up END) | swa (FLOAT middle) | full (grow-down END) ]
The ends never relocate, so they take the per-request state pool and the
unbounded per-step grower; SWA is window-capped with the cheapest slots to
move, and its out-of-window tombstones become float holes recycled in place.
Per-request state is served through `mamba_allocator`, wrapped by
`UnifiedMambaSlotAllocator`.
"""
def __init__(
self,
*,
unified_buffer: UnifiedKVPool,
kvcache, # UnifiedSWAKVPool
mamba_kvcache, # UnifiedMambaPool (req_to_token_pool.mamba_pool)
device: str,
full_max_total_num_tokens: int,
swa_max_total_num_tokens: int,
page_size: int = 1,
need_sort: bool = False,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
):
super().__init__(
unified_buffer=unified_buffer,
kvcache=kvcache,
device=device,
full_max_total_num_tokens=full_max_total_num_tokens,
swa_max_total_num_tokens=swa_max_total_num_tokens,
page_size=page_size,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
# Per-request state END pool (grow-up; page_size=1 -- state is
# per-request, orthogonal to KV paging).
self.mamba_allocator = MultiEndedAllocator(
kvcache=mamba_kvcache,
unified_buffer=unified_buffer,
sub_pool_name="mamba",
device=device,
is_id_owner=True,
page_size=1,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
# Chain wiring: mamba <-> swa(float) <-> full.
self.mamba_allocator.bind_high_peer(self.swa_attn_allocator)
self.swa_attn_allocator.bind_low_peer(self.mamba_allocator)
self.swa_attn_allocator.bind_high_peer(self.full_attn_allocator)
self.full_attn_allocator.bind_low_peer(self.swa_attn_allocator)
# None, not empty: `free_pages is None` is the leak checker's documented
# skip contract; its mamba census would mix physical and virtual ids.
self.free_pages = None
self.release_pages = None
logger.info(
"[unified-memory-pool] UnifiedMambaSWATokenToKVPoolAllocator ready: "
"chain=[mamba(up) | swa(float) | full(down)], "
"mamba max_slots=%d (entry_bytes=%d), joint available=%d",
self.mamba_allocator.max_slots,
self.mamba_allocator.entry_bytes,
self.available_size(),
)
# -- construction hooks --
def _build_swa_attn_allocator(self, **kwargs) -> MultiEndedAllocator:
# The swa side is the FLOAT middle: it never runs the lazy event pipeline
# regardless of the composite's flag (frees mark holes, allocs reuse them).
kwargs["lazy_compaction"] = False
return FloatMultiEndedAllocator(
sub_pool_name="swa",
is_id_owner=False, # non-owner; consumes virtuals minted by full
**kwargs,
)
def _wire_peers(self) -> None:
# Chain wired in __init__ once the mamba end exists.
return
# -- capacity --
def _compute_available_size(self) -> int:
"""Joint TOKENS for `alloc(N)`: N costs N full pages AND N swa pages, drawn
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
e_f, e_s = fa.entry_bytes_per_page, sa.entry_bytes_per_page
# full is grow-down: its chain gap IS the high band.
b_high = fa._current_gap_bytes()
if sa._is_frontier_transparent():
b_low = 0
else:
b_low = max(
0,
sa._byte_low_frontier() - sa._chain_high_frontier_below_bytes(),
)
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()
def feasible(n: int) -> bool:
if n > h_f + r_f or n > h_s + r_s:
return False
ext_f = max(0, n - h_f)
if ext_f * e_f > b_high:
return False
ext_s = max(0, n - h_s)
# On the float's page grid, never in raw bytes: a byte budget
# 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(),
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)
lo_n, hi_n = 0, min(h_f + r_f, h_s + r_s)
while lo_n < hi_n:
mid = (lo_n + hi_n + 1) // 2
if feasible(mid):
lo_n = mid
else:
hi_n = mid - 1
return lo_n * self.page_size
def _flush_targets(self):
"""All three members, float FIRST: its zero-copy boundary absorption must
land before the deficit math prices a relocation it already covered."""
return (
self.swa_attn_allocator,
self.full_attn_allocator,
self.mamba_allocator,
)
def _alloc_demand(self, need_tokens: int):
"""Demand VECTOR for one composite allocation, in PAGES per band. A token
never draws a state slot, so mamba is an explicit 0, not an omission."""
need_n = -(-need_tokens // self.page_size)
return {
self.full_attn_allocator: need_n,
self.swa_attn_allocator: need_n,
self.mamba_allocator: 0,
}
def _ask_float_for_room(self, need_tokens: int) -> None:
"""Composite shortfall: hand the demand vector to the shared policy;
the float is whichever demanded band floats."""
demand = self._alloc_demand(need_tokens)
flt = None
for b in demand:
if isinstance(b, FloatMultiEndedAllocator):
flt = b
_float_open_short_side(flt, demand)
def mamba_slot_full_token_cost(self) -> int:
"""Full-token-equivalents one mamba/conv slot removes from the shared buffer:
a tri-pool token costs e_f + e_s bytes, and the quotient is rounded UP."""
e_tok = (
self.full_attn_allocator.entry_bytes + self.swa_attn_allocator.entry_bytes
)
return -(-self.mamba_allocator.entry_bytes_per_page // e_tok)
def debug_print(self) -> str:
sa = self.swa_attn_allocator
return (
super().debug_print()
+ f", #mamba-available={self.mamba_allocator.available_size()}"
+ f", swa-float span=[{sa.low_wm_page},{sa.high_wm_page}) "
+ f"holes={sa._hole_pages()}"
)
# -- lifecycle fanout (adds the mamba end) --
def clear(self) -> None:
super().clear()
self.mamba_allocator.clear()
def set_latest_forward_done_event(self, event: Optional[torch.cuda.Event]) -> None:
super().set_latest_forward_done_event(event)
self.mamba_allocator.set_latest_forward_done_event(event)
def set_inflight_forward(
self,
forward_done: torch.cuda.Event,
out_cache_loc_virtual: Optional[torch.Tensor],
) -> None:
# The mamba state is written by the conv kernels, not through
# `out_cache_loc`, so its in-flight write-set is None.
super().set_inflight_forward(forward_done, out_cache_loc_virtual)
self.mamba_allocator.set_inflight_forward(forward_done, None)
def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> None:
"""Joint-aware eviction: one tri-lifetime node frees bytes on several sides
at once, so re-check the JOINT gate instead of the per-side shortfall."""
from sglang.srt.mem_cache.common import evict_from_tree_cache
for _ in range(4):
before = self.available_size()
if before >= num_tokens:
return
evict_from_tree_cache(tree_cache, num_tokens)
if self.available_size() <= before:
return # no progress
def verify_byte_accounting(self) -> List[str]:
return (
_chain_byte_accounting_violations(
[
self.mamba_allocator,
self.swa_attn_allocator,
self.full_attn_allocator,
]
)
+ self._joint_capacity_memo_violations()
)
def flush_opportunistic(self) -> int:
"""Per-step reclaim across the whole chain. The float participates for its
deferred boundary absorption, which is where its single D2H is paid."""
fa, ma = self.full_attn_allocator, self.mamba_allocator
sa = self.swa_attn_allocator
if (
fa._free_phys_pages.numel() == 0
and not fa._pending_reuse
and ma._free_phys_pages.numel() == 0
and not ma._pending_reuse
and sa._free_phys_pages.numel() == 0
):
return 0
return (
fa.flush_opportunistic()
+ ma.flush_opportunistic()
+ sa.flush_opportunistic()
)
@@ -0,0 +1,390 @@
# 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.
# ==============================================================================
"""Unified-memory composite for hybrid Mamba models: the full-attention and
mamba-state end pools of one `UnifiedKVPool`."""
from __future__ import annotations
import logging
from typing import Callable, List, Optional, Sequence
import torch
from torch.profiler import record_function
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
MultiEndedAllocator,
_chain_byte_accounting_violations,
_end_pair_chain,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
from sglang.srt.runtime_context import get_parallel
logger = logging.getLogger(__name__)
class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
"""Composite allocator for the MHA (full-attn) + Mamba hybrid pair.
The token-slot surface is the full-attn side; the mamba sub-pool's per-request
`alloc(1)` is driven separately by `UnifiedHybridReqToTokenPool`. The two
sub-allocators own independent virtual-id spaces.
"""
def __init__(
self,
*,
unified_buffer: UnifiedKVPool,
kvcache, # HybridLinearKVPool
device: str,
page_size: int = 1,
need_sort: bool = False,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
):
full_max = unified_buffer.max_slots("full")
dcp_size = get_parallel().attn_dcp_size
super().__init__(
size=(full_max - 1) * dcp_size,
page_size=page_size * dcp_size,
dtype=unified_buffer.spec("full").get_dtype(),
device=device,
kvcache=kvcache,
need_sort=need_sort,
)
self.unified_buffer = unified_buffer
self._kvcache = kvcache
# Widened under DCP, matching the full sub-allocator; see its __init__.
self.page_size = page_size * dcp_size
self.lazy_compaction = lazy_compaction
# Only FULL shards under DCP; the mamba state is replicated on every rank
# and stays page_size=1, orthogonal to the full side's per-token paging.
self.full_attn_allocator = MultiEndedAllocator(
kvcache=kvcache.full_kv_pool,
unified_buffer=unified_buffer,
sub_pool_name="full",
device=device,
is_id_owner=True,
page_size=page_size,
shards_under_dcp=True,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
self.mamba_allocator = MultiEndedAllocator(
kvcache=kvcache.mamba_pool,
unified_buffer=unified_buffer,
sub_pool_name="mamba",
device=device,
is_id_owner=True,
page_size=1, # Mamba state stays slot-granular (1-per-req)
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
self.full_attn_allocator.bind_peer(self.mamba_allocator)
self.mamba_allocator.bind_peer(self.full_attn_allocator)
# `init_unified_mamba_pools` later wraps `self.mamba_allocator` in a
# `UnifiedMambaSlotAllocator` owning the v2p translate; the KV pools get no
# allocator (write locations resolve in the attention metadata).
self.free_group = None
self.free_page_reps_group: Optional[List[torch.Tensor]] = None
# Base init left these None; we use watermark math, not free-lists.
self.free_pages = torch.empty(0, dtype=torch.int64, device=device)
self.release_pages = torch.empty(0, dtype=torch.int64, device=device)
logger.info(
"[unified-memory-pool] UnifiedMambaTokenToKVPoolAllocator ready: "
"full max_slots=%d (min_slot_index=%d, page_size=%d, "
"num_pages=%d), mamba max_slots=%d (min_slot_index=%d), "
"full_available=%d, mamba_available=%d",
self.full_attn_allocator.max_slots,
self.full_attn_allocator.min_slot_index,
self.full_attn_allocator.page_size,
self.full_attn_allocator.num_pages,
self.mamba_allocator.max_slots,
self.mamba_allocator.min_slot_index,
self.full_attn_allocator.available_size(),
self.mamba_allocator.available_size(),
)
# -- size: dynamic --
@property
def size(self) -> int:
# TOKENS. MUST use the SAME available view as `available_size()`, so the
# available term cancels out of the leak invariant.
return (
self.full_attn_allocator.schedulable_available_size()
+ self.full_attn_allocator.allocated_count()
)
@size.setter
def size(self, value) -> None:
pass # base init writes here; computed dynamically
# -- token-slot surface: MHA side --
# Realizable-with-compaction view, so the retract gate / evict / schedule_policy
# do not over-retract while the mamba peer holds drainable holes.
def available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def full_available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def mamba_slot_full_token_cost(self) -> int:
"""Full-token-equivalents of shared-gap bytes ONE mamba state consumes; the
prefill planner reserves this so admission stays inside the JOINT budget,
rounded UP. The `dcp_size` factor is there because that budget is in widened
tokens, one of which is `entry_bytes / dcp_size` local bytes.
"""
return -(
-self.mamba_allocator.entry_bytes_per_page
* get_parallel().attn_dcp_size
// self.full_attn_allocator.entry_bytes
)
@property
def size_full(self) -> int:
# Widened like `size`: a logical token capacity, not a row count.
return (self.full_attn_allocator.max_slots - 1) * get_parallel().attn_dcp_size
@property
def draft_virtual_id_space(self) -> int:
return self.size_full
@property
def size_mamba(self) -> int:
return self.mamba_allocator.max_slots - 1
def debug_print(self) -> str:
return (
f"#full-available={self.full_attn_allocator.available_size()}, "
f"#mamba-available={self.mamba_allocator.available_size()}"
)
def get_kvcache(self):
return self._kvcache
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
with record_function("UnifiedMambaAlloc.alloc"):
return self.full_attn_allocator.alloc(need_size)
def alloc_extend(
self,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
num_new_pages: Optional[int] = None,
) -> Optional[torch.Tensor]:
"""Paged extend. Mamba state is per-request (doesn't advance per-token),
so forward only to the full sub-allocator."""
with record_function("UnifiedMambaAlloc.alloc_extend"):
return self.full_attn_allocator.alloc_extend(
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc,
extend_num_tokens,
num_new_pages=num_new_pages,
)
def alloc_decode(
self,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
) -> Optional[torch.Tensor]:
"""Paged decode. Mamba side stays untouched per-decode."""
with record_function("UnifiedMambaAlloc.alloc_decode"):
return self.full_attn_allocator.alloc_decode(
seq_lens, seq_lens_cpu, last_loc
)
def translate_kv_loc(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Full-pool virtual TOKEN ids -> physical TOKEN ids; `-1` passes through as
`-1` (padding downstream). ``out=`` supports cuda-graph buffer stability."""
result = self.full_attn_allocator.translate_kv_loc(loc, out=out)
return result
@property
def kernel_page_multiplier(self) -> int:
return self.full_attn_allocator.kernel_page_multiplier
@property
def full_v2p_page_table(self) -> torch.Tensor:
"""Page-level virtual->physical table of the full sub-pool. Kernels that
build the MLA block table straight from req_to_token gather through this,
then scale by `kernel_page_multiplier` to reach the per-page block."""
return self.full_attn_allocator.virtual_to_physical
@property
def full_p2v_page_table(self) -> torch.Tensor:
"""Page-level physical->virtual table of the full sub-pool."""
return self.full_attn_allocator.physical_to_virtual
def translate_kv_loc_for_kernel(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Full-pool virtual TOKEN ids -> kernel-facing ids."""
return self.full_attn_allocator.translate_kv_loc_for_kernel(loc, out=out)
def translate_write_loc_for_kernel(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Widened virtual WRITE loc -> DENSE id; see the sub-allocator's copy."""
return self.full_attn_allocator.translate_write_loc_for_kernel(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 kernel-facing: the transfer registers page ENVELOPES (see
`UnifiedMLATokenToKVPool.get_contiguous_buf_infos`)."""
# Defensive: `_validate_unified_memory_dcp` rejects this pairing at
# argument validation, so reaching it means a config path got past that.
assert get_parallel().attn_dcp_size == 1, (
"PD-disaggregation transfer with the unified memory pool does not "
"support decode context parallelism: the transfer ships whole page "
"envelopes, which hold only this rank's shard of each widened page."
)
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)
def allocator_state_str(self) -> str:
return self.full_attn_allocator.allocator_state_str()
def free(self, free_index: torch.Tensor) -> None:
with record_function("UnifiedMambaAlloc.free"):
if free_index is None or free_index.numel() == 0:
return
if self.free_group is not None:
self.free_group.append(self._copy_for_free_group(free_index))
return
self.full_attn_allocator.free(free_index)
self.full_attn_allocator.clear_inverse_history()
self.mamba_allocator.clear_inverse_history()
def clear(self) -> None:
self.full_attn_allocator.clear()
self.mamba_allocator.clear()
self.free_group = None
def free_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
"""Fixed-shape counterpart of `free()`; see `MultiEndedAllocator._page_reps`.
The mamba sub-pool is slot-granular and untouched by a token free."""
if free_index is None or free_index.numel() == 0:
return
if self.page_size == 1:
self.free(free_index)
return
reps = self.full_attn_allocator._page_reps(
free_index.detach().to(torch.int64), start_pos
)
if self.free_page_reps_group is None:
self._release_page_reps((reps,))
else:
self.free_page_reps_group.append(reps)
def _release_page_reps(self, pieces: Sequence[torch.Tensor]) -> None:
reps = pieces[0] if len(pieces) == 1 else torch.cat(tuple(pieces))
self.full_attn_allocator.free(reps, _pages=reps // self.page_size)
self.full_attn_allocator.clear_inverse_history()
self.mamba_allocator.clear_inverse_history()
def verify_byte_accounting(self) -> List[str]:
return _chain_byte_accounting_violations(
_end_pair_chain(self.mamba_allocator, self.full_attn_allocator)
)
def free_group_begin(self) -> None:
super().free_group_begin()
self.free_page_reps_group = []
def free_group_end(self) -> None:
pending, self.free_page_reps_group = self.free_page_reps_group, None
super().free_group_end()
if pending:
self._release_page_reps(pending)
def clear(self) -> None:
self.full_attn_allocator.clear()
self.mamba_allocator.clear()
self.free_group = None
self.free_page_reps_group = None
# -- Lazy compaction hooks --
def set_latest_forward_done_event(self, event: Optional[torch.cuda.Event]) -> None:
"""Forward the per-batch `forward_done` event to BOTH sub-allocators."""
with record_function("UnifiedMambaAlloc.set_latest_forward_done_event"):
self.full_attn_allocator.set_latest_forward_done_event(event)
self.mamba_allocator.set_latest_forward_done_event(event)
def set_inflight_forward(
self,
forward_done: torch.cuda.Event,
out_cache_loc_virtual: Optional[torch.Tensor],
) -> None:
"""Hand the forward's metadata to BOTH sub-pools; the mamba state is written
by mamba kernels, not `set_kv_buffer`, so its write-set is `None`."""
with record_function("UnifiedMambaAlloc.set_inflight_forward"):
self.full_attn_allocator.set_inflight_forward(
forward_done, out_cache_loc_virtual
)
self.mamba_allocator.set_inflight_forward(forward_done, None)
def flush_opportunistic(self) -> int:
"""Non-urgent flush of BOTH sub-allocators; sync-free."""
with record_function("UnifiedMambaAlloc.flush_opportunistic"):
fa = self.full_attn_allocator
ma = self.mamba_allocator
if (
fa._free_phys_pages.numel() == 0
and not fa._pending_reuse
and ma._free_phys_pages.numel() == 0
and not ma._pending_reuse
):
return 0
return fa.flush_opportunistic() + ma.flush_opportunistic()
+2 -2
View File
@@ -101,9 +101,9 @@ def free_swa_out_of_window_slots(
free_slots = req_to_token_pool.req_to_token[ free_slots = req_to_token_pool.req_to_token[
req.kv.req_pool_idx, req.kv.swa_evicted_seqlen : new_swa_evicted_seqlen req.kv.req_pool_idx, req.kv.swa_evicted_seqlen : new_swa_evicted_seqlen
] ]
# Local import: multi_ended_allocator imports this module lazily for # Local import: the unified allocators import this module lazily for
# eviction; a module-level import here would be a cycle hazard. # eviction; a module-level import here would be a cycle hazard.
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
@@ -47,6 +47,12 @@ from sglang.srt.mem_cache.allocator.swa import (
PureSWATokenToKVPoolAllocator, PureSWATokenToKVPoolAllocator,
SWATokenToKVPoolAllocator, SWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.hisparse_memory_pool import HiSparseDSATokenToKVPool from sglang.srt.mem_cache.hisparse_memory_pool import HiSparseDSATokenToKVPool
from sglang.srt.mem_cache.memory_pool import ( from sglang.srt.mem_cache.memory_pool import (
@@ -64,10 +70,6 @@ from sglang.srt.mem_cache.memory_pool import (
PageMajorMHATokenToKVPool, PageMajorMHATokenToKVPool,
ReqToTokenPool, ReqToTokenPool,
) )
from sglang.srt.mem_cache.multi_ended_allocator import (
UnifiedMambaTokenToKVPoolAllocator,
UnifiedSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
from sglang.srt.platforms import current_platform from sglang.srt.platforms import current_platform
from sglang.srt.runtime_context import ( from sglang.srt.runtime_context import (
@@ -64,11 +64,13 @@ from sglang.kernels.ops.kvcache.kv_read_table import (
build_kv_read_table, build_kv_read_table,
build_kv_read_table_packed, build_kv_read_table_packed,
) )
from sglang.srt.mem_cache.base_swa_memory_pool import BaseSWAKVPool from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
from sglang.srt.mem_cache.multi_ended_allocator import (
UnifiedMambaTokenToKVPoolAllocator,
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.base_swa_memory_pool import BaseSWAKVPool
from sglang.srt.runtime_context import get_parallel from sglang.srt.runtime_context import get_parallel
@@ -31,6 +31,9 @@ from sglang.srt.mem_cache.allocator import (
PagedTokenToKVPoolAllocator, PagedTokenToKVPoolAllocator,
TokenToKVPoolAllocator, TokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.base_prefix_cache import ( from sglang.srt.mem_cache.base_prefix_cache import (
BasePrefixCache, BasePrefixCache,
DecLockRefParams, DecLockRefParams,
@@ -45,9 +48,6 @@ from sglang.srt.mem_cache.base_prefix_cache import (
) )
from sglang.srt.mem_cache.events import KVCacheEventRecorder from sglang.srt.mem_cache.events import KVCacheEventRecorder
from sglang.srt.mem_cache.memory_pool import HybridReqToTokenPool from sglang.srt.mem_cache.memory_pool import HybridReqToTokenPool
from sglang.srt.mem_cache.multi_ended_allocator import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.radix_cache import RadixKey from sglang.srt.mem_cache.radix_cache import RadixKey
from sglang.srt.mem_cache.utils import split_node_hash_value from sglang.srt.mem_cache.utils import split_node_hash_value
from sglang.srt.runtime_context import ( from sglang.srt.runtime_context import (
@@ -182,7 +182,7 @@ class SWAComponent(TreeComponent):
def _unified_allocator(self): def _unified_allocator(self):
"""The unified SWA composite, or None when running on the static pool.""" """The unified SWA composite, or None when running on the static pool."""
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
@@ -1193,7 +1193,7 @@ def init_unified_mamba_pools(
unified_total_bytes: Optional[int] = None, unified_total_bytes: Optional[int] = None,
) -> UnifiedPoolBundle: ) -> UnifiedPoolBundle:
"""Build the Mamba-hybrid unified-memory-pool stack.""" """Build the Mamba-hybrid unified-memory-pool stack."""
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator, UnifiedMambaTokenToKVPoolAllocator,
) )
@@ -1676,7 +1676,7 @@ def init_unified_swa_pools(
sliding_window_size: Optional[int] = None, sliding_window_size: Optional[int] = None,
) -> UnifiedSWAPoolBundle: ) -> UnifiedSWAPoolBundle:
"""Build the SWA-hybrid unified-memory-pool stack.""" """Build the SWA-hybrid unified-memory-pool stack."""
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
@@ -1860,7 +1860,7 @@ def init_unified_mamba_swa_pools(
fed until the byte configurator lands); the buffer budget is their byte fed until the byte configurator lands); the buffer budget is their byte
sum and the runtime split floats. sum and the runtime split floats.
""" """
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator, UnifiedMambaSWATokenToKVPoolAllocator,
) )
@@ -17,7 +17,7 @@ from sglang.srt.disaggregation.utils import (
DisaggregationMode, DisaggregationMode,
unified_memory_disagg_move_gate, unified_memory_disagg_move_gate,
) )
from sglang.srt.mem_cache.multi_ended_allocator import MultiEndedAllocator from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator
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
@@ -7,7 +7,7 @@ so a backend that forgets a translate -- or does one twice -- reads the wrong
rows and nothing crashes. This scan makes both unrepresentable. rows and nothing crashes. This scan makes both unrepresentable.
Out of scope, deliberately: the allocator-internal implementations Out of scope, deliberately: the allocator-internal implementations
(`multi_ended_allocator` / `unified_memory_pool`), which ARE the mechanism the (`allocator/unified_*` / `unified_memory_pool`), which ARE the mechanism the
translator calls; the PD transfer plane's `translate_kv_indices_for_transfer`, translator calls; the PD transfer plane's `translate_kv_indices_for_transfer`,
which stages for RDMA outside the forward path; and the STATIC SWA pool's which stages for RDMA outside the forward path; and the STATIC SWA pool's
legacy full->swa slot map, a different mapping kind with no virtual/physical legacy full->swa slot map, a different mapping kind with no virtual/physical
@@ -170,7 +170,7 @@ class TestUnifiedSWATombstoneClamp(unittest.TestCase):
""" """
def _make_bare_pool(self, page_size, v2p, multiplier=1): def _make_bare_pool(self, page_size, v2p, multiplier=1):
from sglang.srt.mem_cache.multi_ended_allocator import MultiEndedAllocator from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
# A real sub-allocator (not a stand-in): the translation reads its v2p # A real sub-allocator (not a stand-in): the translation reads its v2p
@@ -48,10 +48,10 @@ from types import SimpleNamespace
import torch import torch
from test_multi_ended_allocator import _FakeUnifiedSWAKVPool from test_multi_ended_allocator import _FakeUnifiedSWAKVPool
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator, KVReadTables from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
from sglang.srt.mem_cache.multi_ended_allocator import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator, KVReadTables
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec, UnifiedKVPool from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec, UnifiedKVPool
@@ -30,11 +30,15 @@ import unittest
import torch import torch
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
FloatMultiEndedAllocator, FloatMultiEndedAllocator,
MultiEndedAllocator, MultiEndedAllocator,
UnifiedMambaTokenToKVPoolAllocator,
UnifiedSWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.unified_memory_pool import ( from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec, MambaSubPoolSpec,
@@ -1263,7 +1267,7 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
# 7. SWA composite joint byte-budget in page units. # 7. SWA composite joint byte-budget in page units.
def test_paged_swa_joint_byte_budget(self): def test_paged_swa_joint_byte_budget(self):
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
@@ -1334,7 +1338,7 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
# stays -1 and `translate_kv_loc(virt_token)` returns negative token # stays -1 and `translate_kv_loc(virt_token)` returns negative token
# ids → CUDA OOB in the Triton attention kernel. # ids → CUDA OOB in the Triton attention kernel.
def test_paged_alloc_extend_binds_v2p_p2v(self): def test_paged_alloc_extend_binds_v2p_p2v(self):
from sglang.srt.mem_cache import multi_ended_allocator as mea_mod from sglang.srt.mem_cache.allocator import unified_sub_pool as mea_mod
_, full_alloc, _, _, _ = self._build() _, full_alloc, _, _, _ = self._build()
PS = self.PAGE_SIZE PS = self.PAGE_SIZE
@@ -1417,7 +1421,7 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
# prefix's tail page (num_new_pages == 0), but the page-wrapping case # prefix's tail page (num_new_pages == 0), but the page-wrapping case
# must update tables. # must update tables.
def test_paged_alloc_decode_binds_v2p_p2v_on_page_wrap(self): def test_paged_alloc_decode_binds_v2p_p2v_on_page_wrap(self):
from sglang.srt.mem_cache import multi_ended_allocator as mea_mod from sglang.srt.mem_cache.allocator import unified_sub_pool as mea_mod
_, full_alloc, _, _, _ = self._build() _, full_alloc, _, _, _ = self._build()
PS = self.PAGE_SIZE PS = self.PAGE_SIZE
@@ -1496,7 +1500,7 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
# (the common case — the decode token reuses the prefix's tail page) # (the common case — the decode token reuses the prefix's tail page)
# must NOT advance the watermark and NOT touch v2p / p2v. # must NOT advance the watermark and NOT touch v2p / p2v.
def test_paged_alloc_decode_no_op_when_no_new_page(self): def test_paged_alloc_decode_no_op_when_no_new_page(self):
from sglang.srt.mem_cache import multi_ended_allocator as mea_mod from sglang.srt.mem_cache.allocator import unified_sub_pool as mea_mod
_, full_alloc, _, _, _ = self._build() _, full_alloc, _, _, _ = self._build()
PS = self.PAGE_SIZE PS = self.PAGE_SIZE
@@ -1699,7 +1703,7 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
# `full_available_size() + allocated_tokens == static_cap` must hold for # `full_available_size() + allocated_tokens == static_cap` must hold for
# the SWA composite. # the SWA composite.
def test_paged_swa_full_available_size_in_tokens(self): def test_paged_swa_full_available_size_in_tokens(self):
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
@@ -1785,7 +1789,7 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
# (`#full token`, `full token usage`) and would have crashed Mamba+radix # (`#full token`, `full token usage`) and would have crashed Mamba+radix
# if radix weren't auto-downgraded to page=1. # if radix weren't auto-downgraded to page=1.
def test_paged_mamba_size_in_tokens(self): def test_paged_mamba_size_in_tokens(self):
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator, UnifiedMambaTokenToKVPoolAllocator,
) )
@@ -1887,7 +1891,7 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
# The instance methods in production wrap this helper, so the same # The instance methods in production wrap this helper, so the same
# math is covered. # math is covered.
def test_paged_pool_translate_helper_returns_physical_tokens(self): def test_paged_pool_translate_helper_returns_physical_tokens(self):
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
@@ -3440,7 +3444,7 @@ class TestDcpWidening(unittest.TestCase):
self.assertTrue(bool((written[owned] > 0).all())) self.assertTrue(bool((written[owned] > 0).all()))
def _build_composite(self, *, page_size): def _build_composite(self, *, page_size):
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator, UnifiedMambaTokenToKVPoolAllocator,
) )
@@ -43,7 +43,9 @@ import unittest
import torch import torch
from test_multi_ended_allocator import _FakeUnifiedSWAKVPool # sibling fixture from test_multi_ended_allocator import _FakeUnifiedSWAKVPool # sibling fixture
from sglang.srt.mem_cache.multi_ended_allocator import UnifiedSWATokenToKVPoolAllocator from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.unified_cache.cache_action import RecoverSWAWithLockedFull from sglang.srt.mem_cache.unified_cache.cache_action import RecoverSWAWithLockedFull
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.mem_cache.unified_cache.components.swa_component import SWAComponent from sglang.srt.mem_cache.unified_cache.components.swa_component import SWAComponent
@@ -42,7 +42,7 @@ from test_multi_ended_allocator import (
TestUnifiedSWATokenToKVPoolAllocator as _SwaFixture, TestUnifiedSWATokenToKVPoolAllocator as _SwaFixture,
) )
from sglang.srt.mem_cache import multi_ended_allocator as mea from sglang.srt.mem_cache.allocator import unified_sub_pool as mea
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=15, suite="base-a-test-cpu") register_cpu_ci(est_time=15, suite="base-a-test-cpu")
@@ -44,7 +44,8 @@ from unittest import mock
import torch import torch
from test_multi_ended_allocator import TestPagedMultiEndedAllocator as _PagedFixture from test_multi_ended_allocator import TestPagedMultiEndedAllocator as _PagedFixture
from sglang.srt.mem_cache import multi_ended_allocator as mea from sglang.srt.mem_cache.allocator import unified_hybrid_swa, unified_mamba
from sglang.srt.mem_cache.allocator import unified_sub_pool as mea
from sglang.srt.mem_cache.allocator.base import BaseTokenToKVPoolAllocator from sglang.srt.mem_cache.allocator.base import BaseTokenToKVPoolAllocator
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
@@ -87,14 +88,18 @@ _TOMBSTONE_METHODS = [
_NO_SYNC_TOMBSTONE_FORMS = ("index_fill_", "free_unbind_inplace") _NO_SYNC_TOMBSTONE_FORMS = ("index_fill_", "free_unbind_inplace")
_UNIFIED_MODULES = (mea, unified_mamba, unified_hybrid_swa)
def _allocators_in_module(): def _allocators_in_module():
"""Every allocator class DEFINED in multi_ended_allocator (not imported).""" """Every allocator class DEFINED in the unified allocator modules (not imported)."""
return sorted( return sorted(
( (
c c
for c in vars(mea).values() for mod in _UNIFIED_MODULES
for c in vars(mod).values()
if isinstance(c, type) if isinstance(c, type)
and c.__module__ == mea.__name__ and c.__module__ == mod.__name__
and "Allocator" in c.__name__ and "Allocator" in c.__name__
), ),
key=lambda c: c.__name__, key=lambda c: c.__name__,
@@ -380,8 +385,8 @@ class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
def test_all_overridden(self): def test_all_overridden(self):
for cls in ( for cls in (
mea.MultiEndedAllocator, mea.MultiEndedAllocator,
mea.UnifiedMambaTokenToKVPoolAllocator, unified_mamba.UnifiedMambaTokenToKVPoolAllocator,
mea.UnifiedSWATokenToKVPoolAllocator, unified_hybrid_swa.UnifiedSWATokenToKVPoolAllocator,
): ):
with self.subTest(cls=cls.__name__): with self.subTest(cls=cls.__name__):
self.assertIsNot( self.assertIsNot(
@@ -399,8 +404,8 @@ class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
segment free, or `free_segment` raises inside a group.""" segment free, or `free_segment` raises inside a group."""
for cls in ( for cls in (
mea.MultiEndedAllocator, mea.MultiEndedAllocator,
mea.UnifiedMambaTokenToKVPoolAllocator, unified_mamba.UnifiedMambaTokenToKVPoolAllocator,
mea.UnifiedSWATokenToKVPoolAllocator, unified_hybrid_swa.UnifiedSWATokenToKVPoolAllocator,
): ):
with self.subTest(cls=cls.__name__): with self.subTest(cls=cls.__name__):
self.assertIn("free_page_reps_group", inspect.getsource(cls)) self.assertIn("free_page_reps_group", inspect.getsource(cls))
@@ -462,7 +467,7 @@ class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
def attach_allocators(self, **kwargs): def attach_allocators(self, **kwargs):
pass pass
return mea.UnifiedSWATokenToKVPoolAllocator( return unified_hybrid_swa.UnifiedSWATokenToKVPoolAllocator(
unified_buffer=pool, unified_buffer=pool,
kvcache=_KV(pool), kvcache=_KV(pool),
device="cpu", device="cpu",
@@ -6,7 +6,7 @@ import unittest
import torch import torch
from sglang.srt.mem_cache.multi_ended_allocator import MultiEndedAllocator from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator
from sglang.srt.mem_cache.unified_memory_pool import ( from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec, MambaSubPoolSpec,
MLASubPoolSpec, MLASubPoolSpec,
@@ -41,11 +41,11 @@ import unittest
import torch import torch
from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator
from sglang.srt.mem_cache.layout.page_major import ( from sglang.srt.mem_cache.layout.page_major import (
build_mla_views, build_mla_views,
mla_entry_bytes, mla_entry_bytes,
) )
from sglang.srt.mem_cache.multi_ended_allocator import MultiEndedAllocator
from sglang.srt.mem_cache.unified_memory_pool import ( from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec, MambaSubPoolSpec,
MLASubPoolSpec, MLASubPoolSpec,
@@ -30,7 +30,7 @@ import unittest
import torch import torch
from test_swa_locked_full_recover_unified import _DEV, _FakeUnifiedSWAKVPool from test_swa_locked_full_recover_unified import _DEV, _FakeUnifiedSWAKVPool
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedSWATokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec, UnifiedKVPool from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec, UnifiedKVPool
@@ -39,11 +39,11 @@ import unittest
import torch import torch
import sglang.srt.mem_cache.multi_ended_allocator as mea import sglang.srt.mem_cache.allocator.unified_sub_pool as mea
from sglang.srt.mem_cache.multi_ended_allocator import ( from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
FloatMultiEndedAllocator,
UnifiedMambaSWATokenToKVPoolAllocator, UnifiedMambaSWATokenToKVPoolAllocator,
) )
from sglang.srt.mem_cache.allocator.unified_sub_pool import FloatMultiEndedAllocator
from sglang.srt.mem_cache.unified_memory_pool import ( from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec, MambaSubPoolSpec,
MHASubPoolSpec, MHASubPoolSpec,
@@ -349,7 +349,7 @@ class TestUnifiedTriPool(unittest.TestCase):
sa = allocator.swa_attn_allocator sa = allocator.swa_attn_allocator
holes = sa._hole_pages() holes = sa._hole_pages()
self.assertGreater(holes, 0) self.assertGreater(holes, 0)
from sglang.srt.mem_cache.multi_ended_allocator import _relieve_for_alloc from sglang.srt.mem_cache.allocator.unified_sub_pool import _relieve_for_alloc
_relieve_for_alloc(allocator, 1) _relieve_for_alloc(allocator, 1)
self.assertEqual(sa._hole_pages(), holes) # holes are assets, not backlog self.assertEqual(sa._hole_pages(), holes) # holes are assets, not backlog
@@ -864,7 +864,7 @@ class TestTriDeferredAbsorption(unittest.TestCase):
alloc.free_swa(v[6 * self.PS :], start_pos=6 * self.PS) alloc.free_swa(v[6 * self.PS :], start_pos=6 * self.PS)
self.assertGreater(sa._hole_pages(), 0) self.assertGreater(sa._hole_pages(), 0)
moves_before = len(sa._inverse_history) moves_before = len(sa._inverse_history)
from sglang.srt.mem_cache.multi_ended_allocator import _relieve_for_alloc from sglang.srt.mem_cache.allocator.unified_sub_pool import _relieve_for_alloc
_relieve_for_alloc(alloc, 1) # the ladder _relieve_for_alloc(alloc, 1) # the ladder
self.assertEqual(sa._hole_pages(), 0) # rung 0 ran self.assertEqual(sa._hole_pages(), 0) # rung 0 ran