fix(unified-memory): evict Full KV for Mamba byte shortfalls (#36713)

Co-authored-by: Yangmin Li <yangminl@nvidia.com>
Co-authored-by: YAMY <74099316+YAMY1234@users.noreply.github.com>
This commit is contained in:
SuperSong
2026-09-09 13:46:51 -07:00
committed by GitHub
co-authored by Yangmin Li YAMY
parent 6c1d0b1b29
commit bede776c2a
8 changed files with 969 additions and 22 deletions
+19 -1
View File
@@ -16,7 +16,7 @@ limitations under the License.
from __future__ import annotations
import abc
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Protocol
import torch
@@ -24,6 +24,20 @@ if TYPE_CHECKING:
from sglang.srt.mem_cache.memory_pool import KVCache
class MambaFullCacheDonor(Protocol):
"""Allocator capability for reclaiming Full KV on Mamba byte pressure."""
def flush_deferred_full_frees(self) -> None: ...
def full_tokens_before_mamba_recheck(self, target_size: int) -> int:
"""Lower bound on new Full tokens before preparation can help."""
...
def prepare_mamba_allocation(self, target_size: int) -> None:
"""Expose layout-specific reclaim so Mamba capacity is queryable."""
...
class BaseTokenToKVPoolAllocator(abc.ABC):
@abc.abstractmethod
def __init__(
@@ -74,6 +88,10 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
violation strings, empty when healthy. Static pools have no byte model."""
return []
def mamba_full_cache_donor(self) -> MambaFullCacheDonor | None:
"""Return the shared-pool donor capability, if this allocator has one."""
return None
def debug_print(self) -> str:
return ""
@@ -23,6 +23,7 @@ import torch
from torch.profiler import record_function
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.base import MambaFullCacheDonor
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
FloatMultiEndedAllocator,
@@ -30,6 +31,8 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
_chain_byte_accounting_violations,
_end_pair_chain,
_float_open_short_side,
_flush_deferred_free_group,
_full_tokens_before_mamba_recheck,
_relieve_for_alloc,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
@@ -845,6 +848,29 @@ class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
flt = b
_float_open_short_side(flt, demand)
def mamba_full_cache_donor(self) -> MambaFullCacheDonor:
return self
def flush_deferred_full_frees(self) -> None:
"""Expose grouped composite frees while preserving the group scope."""
_flush_deferred_free_group(
self,
(self.free_group, self.free_page_reps_group, self.full_free_group),
)
def full_tokens_before_mamba_recheck(self, target_size: int) -> int:
return _full_tokens_before_mamba_recheck(
self.full_attn_allocator, self.mamba_allocator, target_size
)
def prepare_mamba_allocation(self, target_size: int) -> None:
"""Expose Full reclaim, then move the SWA float away from Mamba."""
self.flush_deferred_full_frees()
if target_size <= self.mamba_allocator.available_size():
return
self.full_attn_allocator.flush_for_allocation()
_relieve_for_alloc(self.mamba_allocator, target_size)
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."""
@@ -23,10 +23,14 @@ import torch
from torch.profiler import record_function
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.base import MambaFullCacheDonor
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
MultiEndedAllocator,
_chain_byte_accounting_violations,
_end_pair_chain,
_flush_deferred_free_group,
_full_tokens_before_mamba_recheck,
_relieve_for_alloc,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
from sglang.srt.runtime_context import get_parallel
@@ -146,6 +150,26 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def full_available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def mamba_full_cache_donor(self) -> MambaFullCacheDonor:
return self
def flush_deferred_full_frees(self) -> None:
"""Expose grouped Full frees without ending the caller's free group."""
_flush_deferred_free_group(self, (self.free_group, self.free_page_reps_group))
def full_tokens_before_mamba_recheck(self, target_size: int) -> int:
return _full_tokens_before_mamba_recheck(
self.full_attn_allocator, self.mamba_allocator, target_size
)
def prepare_mamba_allocation(self, target_size: int) -> None:
"""Make deferred Full reclaim visible to the Mamba capacity view."""
self.flush_deferred_full_frees()
if target_size > self.mamba_allocator.schedulable_available_size():
return
if target_size > self.mamba_allocator.available_size():
_relieve_for_alloc(self.mamba_allocator, target_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,
@@ -31,6 +31,7 @@ from typing import (
Generic,
List,
Optional,
Sequence,
Set,
Tuple,
TypeVar,
@@ -194,18 +195,50 @@ def _float_open_short_side(flt, demand) -> None:
def _relieve_for_alloc(short_pool, need_tokens: int) -> bool:
"""THE shortfall ladder: every allocation shortfall in the unified pool runs
exactly this, whether a single band's own alloc or a composite's coupled
multi-band alloc. `_flush` is called unconditionally -- an eager END no-ops
multi-band alloc. The urgent flush is called unconditionally -- an eager END no-ops
and a FLOAT always has boundary absorption to do -- so the ladder never
branches on lazy mode, member kind, or layout.
"""
for m in short_pool._flush_targets():
m._flush(urgent=True)
m.flush_for_allocation()
if need_tokens <= short_pool.available_size():
return True
short_pool._ask_float_for_room(need_tokens)
return need_tokens <= short_pool.available_size()
def _flush_deferred_free_group(
allocator: BaseTokenToKVPoolAllocator,
pending_groups: Sequence[Optional[Sequence[torch.Tensor]]],
) -> None:
"""Apply queued frees and reopen the caller's free-group scope."""
if allocator.free_group is None or not any(pending_groups):
return
allocator.free_group_end()
allocator.free_group_begin()
def _full_tokens_before_mamba_recheck(
full_allocator: MultiEndedAllocator,
mamba_allocator: MultiEndedAllocator,
target_size: int,
) -> int:
"""Conservative Full-token lower bound for the next Mamba capacity check.
The current Mamba slot count can hide at most one slot minus one byte of
residual room. Subtract that possible residue so this estimate only skips
checks that cannot succeed from Full bytes alone. Allocator capacity remains
the stop condition after the bound is crossed.
"""
missing_slots = max(0, target_size - mamba_allocator.schedulable_available_size())
if missing_slots == 0:
return 0
mamba_page_bytes = mamba_allocator.entry_bytes_per_page
minimum_missing_bytes = (missing_slots - 1) * mamba_page_bytes + 1
dcp_size = get_parallel().attn_dcp_size if full_allocator.shards_under_dcp else 1
return -(-minimum_missing_bytes * dcp_size // full_allocator.entry_bytes)
class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
"""Allocator for one sub-pool over a `UnifiedKVPool`."""
@@ -1919,6 +1952,11 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
self._pending_reuse[latest_event] = (srcs_copy, src_pages_t)
self._pending_reuse_pages_cpu.update(srcs_copy)
def flush_for_allocation(self) -> int:
"""Public urgent flush used by peer allocation-pressure recovery."""
with record_function("MultiEndedAlloc.flush_for_allocation"):
return self._flush(urgent=True)
def flush_opportunistic(self) -> int:
"""Public, non-urgent flush at quiescent points; never blocks
`schedule_stream`. Fast-path the empty state: the scheduler triggers this
@@ -577,11 +577,62 @@ class UnifiedRadixCache(BasePrefixCache):
request_by_type = self._evict_request_by_type(params)
available_size_targets = {
ct: self._component_available_size(ct) + request_cnt
ct: (ct, self._component_available_size(ct) + request_cnt)
for ct, request_cnt in request_by_type.items()
if request_cnt > 0
}
return self._evict(params, available_size_targets)
allocator = self.token_to_kv_pool_allocator
mamba_full_donor = allocator.mamba_full_cache_donor()
mamba_target = available_size_targets.get(ComponentType.MAMBA)
initial_params = params
if mamba_target is not None and mamba_full_donor is not None:
# Full KV can supply bytes but cannot recycle Mamba virtual IDs.
mamba_id_shortfall = max(
0,
mamba_target[1]
- self.req_to_token_pool.mamba_allocator.available_size(),
)
initial_params = EvictParams(
num_tokens=params.num_tokens,
swa_num_tokens=params.swa_num_tokens,
mamba_num=mamba_id_shortfall,
)
result = self._evict(initial_params, available_size_targets)
if mamba_target is not None and mamba_full_donor is not None:
mamba_full_donor.prepare_mamba_allocation(mamba_target[1])
mamba_free_ids = self.req_to_token_pool.mamba_allocator.available_size()
mamba_capacity = self._component_available_size(ComponentType.MAMBA)
if mamba_free_ids >= mamba_target[1] and mamba_capacity < mamba_target[1]:
full_evictable = self.full_evictable_size()
if full_evictable > 0:
donor_result = self._evict(
EvictParams(num_tokens=full_evictable),
{ComponentType.FULL: mamba_target},
)
result.num_tokens_evicted += donor_result.num_tokens_evicted
result.swa_num_tokens_evicted += donor_result.swa_num_tokens_evicted
result.mamba_num_evicted += donor_result.mamba_num_evicted
# Preserve Mamba-victim recovery if Full cannot fund the target.
if (
self._component_available_size(ComponentType.MAMBA)
< mamba_target[1]
):
mamba_evictable = self.mamba_evictable_size()
if mamba_evictable > 0:
fallback_result = self._evict(
EvictParams(mamba_num=mamba_evictable),
{ComponentType.MAMBA: mamba_target},
)
result.num_tokens_evicted += fallback_result.num_tokens_evicted
result.swa_num_tokens_evicted += (
fallback_result.swa_num_tokens_evicted
)
result.mamba_num_evicted += fallback_result.mamba_num_evicted
return result
@staticmethod
def _evict_request_by_type(params: EvictParams) -> dict[ComponentType, int]:
@@ -611,7 +662,9 @@ class UnifiedRadixCache(BasePrefixCache):
def _evict(
self,
params: EvictParams,
available_size_targets: Optional[dict[ComponentType, int]] = None,
available_size_targets: Optional[
dict[ComponentType, tuple[ComponentType, int]]
] = None,
) -> EvictResult:
if self.disable:
return EvictResult()
@@ -712,22 +765,45 @@ class UnifiedRadixCache(BasePrefixCache):
self,
request_by_type: dict[ComponentType, int],
tracker: dict[ComponentType, int],
available_size_targets: Optional[dict[ComponentType, int]] = None,
available_size_targets: Optional[
dict[ComponentType, tuple[ComponentType, int]]
] = None,
) -> None:
# Buffer mode: eviction always wins over queued backup intents — a
# destroyed victim's intent is stale-swept and the content rewrites
# after its recompute.
last_mamba_donor_check = 0
mamba_donor_prepared = False
def target_reached(component_type: ComponentType) -> bool:
nonlocal last_mamba_donor_check, mamba_donor_prepared
if available_size_targets is None:
return False
target = available_size_targets.get(component_type)
# Do not compact on every eviction step. Shared allocators include
# drainable peer holes here and flush the peer once in alloc().
return (
target is not None
and self._component_available_size(component_type) >= target
)
if target is None:
return False
target_component, target_size = target
# A Full-leaf cascade can release Mamba or SWA state directly.
if self._component_available_size(target_component) >= target_size:
return True
if (
component_type == ComponentType.FULL
and target_component == ComponentType.MAMBA
):
donor = self.token_to_kv_pool_allocator.mamba_full_cache_donor()
assert donor is not None, "Mamba target requires a Full donor"
recheck_after = (
1
if mamba_donor_prepared
else donor.full_tokens_before_mamba_recheck(target_size)
)
if tracker[component_type] - last_mamba_donor_check < recheck_after:
return False
donor.prepare_mamba_allocation(target_size)
last_mamba_donor_check = tracker[component_type]
mamba_donor_prepared = True
# Schedulable capacity includes donor holes that allocation can compact.
return self._component_available_size(target_component) >= target_size
for ct in self.tree_components:
request_cnt = request_by_type[ct]
@@ -737,16 +813,14 @@ class UnifiedRadixCache(BasePrefixCache):
continue
self.tree_core.evict_device_start(ct, request_cnt)
try:
while not target_reached(ct):
while True:
node_id, made_progress = self._evict_device_next_node(ct, tracker)
if node_id is None:
if made_progress:
# Internal tombstone frees are now allocator-visible;
# recheck the allocation target before walking again.
continue
break
backup_kv = self._evict_device_leaf(node_id, tracker)
if backup_kv is not None:
if not made_progress:
break
else:
backup_kv = self._evict_device_leaf(node_id, tracker)
if node_id is not None and backup_kv is not None:
# Deferred demote: run the D->H backup, demote only on success.
written = self._execute_and_commit_kv_backup(
backup_kv, write_back=True
@@ -768,6 +842,8 @@ class UnifiedRadixCache(BasePrefixCache):
"until host space frees",
node_id,
)
if target_reached(ct):
break
finally:
self.tree_core.evict_device_end(ct)