Files
sglang/python/sglang/srt/mem_cache/hicache_storage.py
T
e9d6b9eb2d [HiCache & HybridModel] mooncake backend support DSA & mamba model (#21259)
Signed-off-by: Vladislav Nosivskoy <vladnosiv@gmail.com>
Co-authored-by: hzh0425 <hzh0425@apache.org>
Co-authored-by: pansicheng <sicheng.pan.chn@gmail.com>
Co-authored-by: ispobock <ispobaoke@gmail.com>
Co-authored-by: Vladislav Nosivskoy <vladnosiv@gmail.com>
2026-04-13 18:47:36 -07:00

522 lines
17 KiB
Python

import logging
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Any, List, Optional, Set
import torch
from sglang.srt.environ import envs
from sglang.srt.mem_cache.memory_pool_host import HostKVCache
logger = logging.getLogger(__name__)
@dataclass
class HiCacheStorageConfig:
tp_rank: int
tp_size: int
pp_rank: int
pp_size: int
attn_cp_rank: int
attn_cp_size: int
is_mla_model: bool
enable_storage_metrics: bool
is_page_first_layout: bool
model_name: Optional[str]
tp_lcm_size: Optional[int] = None
should_split_heads: bool = False
extra_config: Optional[dict] = None
@dataclass
class HiCacheStorageExtraInfo:
prefix_keys: Optional[List[str]] = (None,)
extra_info: Optional[dict] = None
class PoolName(str, Enum):
"""Well-known pool names used as PoolTransfer/PoolEntry identifiers."""
KV = "kv"
MAMBA = "mamba"
INDEXER = "indexer"
class PoolHitPolicy(str, Enum):
"""Hit policy for batch_exists_v2 per-pool prefix matching.
ALL_PAGES : every page in [0, kv_hit) must exist (e.g. DSA).
TRAILING_PAGES : only the last N pages must exist (e.g. Mamba/SWA states).
"""
ALL_PAGES = "all_pages"
TRAILING_PAGES = "trailing_pages"
@dataclass
class PoolTransfer:
"""Unified per-pool transfer descriptor for batch v2 interface.
device<->host path : host_indices + device_indices
host<->storage path: host_indices + keys
"""
name: PoolName
host_indices: Optional[torch.Tensor] = None
device_indices: Optional[torch.Tensor] = None
keys: Optional[List[str]] = None
hit_policy: PoolHitPolicy = PoolHitPolicy.ALL_PAGES
@dataclass
class PoolTransferResult:
"""Tracks how many pages were successfully processed per pool."""
kv_hit_pages: int
extra_pool_hit_pages: dict[str, int]
@classmethod
def empty(cls) -> "PoolTransferResult":
return cls(0, {})
def update_kv_hit_pages(self, kv_hit_pages: int) -> None:
"""Accumulate kv_hit_pages across batches (max = last successful batch)."""
self.kv_hit_pages = max(self.kv_hit_pages, kv_hit_pages)
def update_extra_pool_hit_pages(self, results: dict[str, List[bool]]) -> None:
"""Record actual load/write success counts per extra pool."""
self.extra_pool_hit_pages.update(
{name: sum(rs) for name, rs in results.items()}
)
class HiCacheStorage(ABC):
"""
HiCacheStorage is a class that provides a generic key-value interface for storing and retrieving KV cache.
It abstracts the underlying storage mechanism, allowing different implementations to be used.
"""
# todo, the page size of storage backend does not have to be the same as the same as host memory pool
def register_mem_pool_host(self, mem_pool_host: HostKVCache):
self.mem_pool_host = mem_pool_host
def register_mem_host_pool_v2(self, host_pool: HostKVCache, host_pool_name):
if not hasattr(self, "registered_pools"):
self.registered_pools = {}
self.registered_pools[host_pool_name] = host_pool
def batch_exists_v2(
self,
keys: List[str],
pool_transfers: Optional[List[PoolTransfer]] = None,
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> PoolTransferResult:
"""Check which cache pages exist in storage, respecting per-pool hit policies.
Longest-prefix semantics
Extra-pool hit policies (``PoolTransfer.hit_policy``)
------------------------------------------------------
Each ``PoolTransfer`` in ``pool_transfers`` describes a secondary
cache pool (e.g. Mamba SSM states) that must be co-present with the
KV pages. The final ``final_pages`` is the minimum across all pools,
so a missing auxiliary page shrinks the usable prefix.
- ``"all_pages"`` (default): every page in [0, kv_hit) must exist
for this pool. Used for pools that are required for every token
in the prefix (e.g. DeepSeek DSA pool).
- ``"trailing_pages"``: only the *last* ``len(transfer.keys)`` pages
of the KV prefix need to exist. Used for pools whose data covers
only the tail of a prefix (e.g. Mamba/SWA Pool).
Returns
-------
PoolTransferResult
``kv_hit_pages`` = length of the usable KV prefix.
``extra_pool_hit_pages`` maps each pool name to the number of pages
that were found.
"""
raise NotImplementedError()
def batch_get_v2(
self,
transfers: List[PoolTransfer],
extra_info: Optional["HiCacheStorageExtraInfo"] = None,
) -> dict[str, List[bool]]:
"""Read data from storage into host memory for each PoolTransfer.
Returns a dict mapping pool name to a per-entry success list.
"""
raise NotImplementedError()
def batch_set_v2(
self,
transfers: List[PoolTransfer],
extra_info: Optional["HiCacheStorageExtraInfo"] = None,
) -> dict[str, List[bool]]:
"""Write data from host memory to storage for each PoolTransfer.
Returns a dict mapping pool name to a per-entry success list.
"""
raise NotImplementedError()
def batch_get_v1(
self,
keys: List[str],
host_indices: torch.Tensor,
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> List[bool]:
"""
Retrieve values for multiple keys.
Returns a list of booleans indicating success for each key.
"""
pass
def batch_set_v1(
self,
keys: List[str],
host_indices: torch.Tensor,
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> List[bool]:
"""
Store multiple key-value pairs.
Returns a list of booleans indicating success for each key.
"""
pass
@abstractmethod
def get(
self,
key: str,
target_location: Optional[Any] = None,
target_sizes: Optional[Any] = None,
) -> torch.Tensor | None:
"""
Retrieve the value associated with the given key.
Returns None if the key does not exist.
"""
pass
# TODO: Deprecate
@abstractmethod
def batch_get(
self,
keys: List[str],
target_locations: Optional[Any] = None,
target_sizes: Optional[Any] = None,
) -> List[torch.Tensor | None] | int:
"""
Retrieve values for multiple keys.
Returns a list of tensors or None for each key.
"""
pass
@abstractmethod
def set(
self,
key: str,
value: Optional[Any] = None,
target_location: Optional[Any] = None,
target_sizes: Optional[Any] = None,
) -> bool:
"""
Store the value associated with the given key.
Returns True if the operation was successful, False otherwise.
"""
pass
# TODO: Deprecate
@abstractmethod
def batch_set(
self,
keys: List[str],
values: Optional[Any] = None,
target_locations: Optional[Any] = None,
target_sizes: Optional[Any] = None,
) -> bool:
"""
Store multiple key-value pairs.
Returns True if all operations were successful, False otherwise.
"""
pass
@abstractmethod
def exists(self, key: str) -> bool:
"""
Check if the key exists in the storage.
Returns True if the key exists, False otherwise.
"""
pass
# TODO: Use a finer-grained return type (e.g., List[bool])
def batch_exists(
self, keys: List[str], extra_info: Optional[HiCacheStorageExtraInfo] = None
) -> int:
"""
Check if the keys exist in the storage.
return the number of consecutive existing keys from the start.
Can be overridden by subclasses for more efficient implementation.
"""
for i in range(len(keys)):
if not self.exists(keys[i]):
return i
return len(keys)
def clear(self) -> None:
pass
def get_stats(self):
return None
class HiCacheFile(HiCacheStorage):
def __init__(
self, storage_config: HiCacheStorageConfig, file_path: str = "/tmp/hicache"
):
self.file_path = envs.SGLANG_HICACHE_FILE_BACKEND_STORAGE_DIR.get() or file_path
tp_rank, tp_size, pp_rank, pp_size, model_name, is_mla_model = (
storage_config.tp_rank,
storage_config.tp_size,
storage_config.pp_rank,
storage_config.pp_size,
storage_config.model_name,
storage_config.is_mla_model,
)
model_name = "-".join(model_name.split("/")) if model_name else ""
enable_pp = pp_size > 1
self.config_suffix = f"_{model_name}"
if not is_mla_model:
self.config_suffix += f"_{tp_rank}_{tp_size}"
if enable_pp:
self.config_suffix += f"_{pp_size}_{pp_rank}"
if not os.path.exists(self.file_path) and tp_rank == 0:
os.makedirs(self.file_path)
logger.info(f"Created HiCacheFile storage directory at {self.file_path}")
def _get_suffixed_key(self, key: str) -> str:
return key + self.config_suffix
def _get_component_key(self, key: str, component_name: Optional[str] = None) -> str:
if component_name is None or component_name in ("__default__", PoolName.KV):
return self._get_suffixed_key(key)
return self._get_suffixed_key(f"{key}.{component_name}")
def _get_component_path(
self, key: str, component_name: Optional[str] = None
) -> str:
return os.path.join(
self.file_path, f"{self._get_component_key(key, component_name)}.bin"
)
def get(
self,
key: str,
target_location: torch.Tensor,
target_sizes: Optional[Any] = None,
) -> torch.Tensor | None:
key = self._get_suffixed_key(key)
tensor_path = os.path.join(self.file_path, f"{key}.bin")
try:
expected = target_location.numel() * target_location.element_size()
with open(tensor_path, "rb", buffering=0) as f:
buf = memoryview(target_location.view(torch.uint8).contiguous().numpy())
if f.readinto(buf) != expected:
raise IOError(f"Short read for {key}")
return target_location
except FileNotFoundError:
logger.warning(f"Failed to fetch {key} from HiCacheFile storage.")
return None
def batch_get(
self,
keys: List[str],
target_locations: List[torch.Tensor],
target_sizes: Optional[Any] = None,
) -> List[torch.Tensor | None]:
return [
self.get(key, target_location)
for key, target_location in zip(
keys, target_locations or [None] * len(keys)
)
]
def set(
self,
key: str,
value: Optional[Any] = None,
target_location: Optional[Any] = None,
target_sizes: Optional[Any] = None,
) -> bool:
if self.exists(key):
logger.debug(f"Key {key} already exists. Skipped.")
return True
key = self._get_suffixed_key(key)
tensor_path = os.path.join(self.file_path, f"{key}.bin")
try:
value.contiguous().view(dtype=torch.uint8).numpy().tofile(tensor_path)
return True
except Exception as e:
logger.error(f"Failed to save tensor {key}: {e}")
return False
def batch_set(
self,
keys: List[str],
values: Optional[Any] = None,
target_locations: Optional[Any] = None,
target_sizes: Optional[Any] = None,
) -> bool:
for key, value in zip(keys, values):
if not self.set(key, value):
return False
return True
def exists(self, key: str) -> bool:
key = self._get_suffixed_key(key)
tensor_path = os.path.join(self.file_path, f"{key}.bin")
return os.path.exists(tensor_path)
def _collect_existing_component_keys(
self,
keys: List[str],
pool_transfers: Optional[List[PoolTransfer]] = None,
) -> Set[str]:
target_files = {f"{self._get_component_key(key)}.bin" for key in keys}
for transfer in pool_transfers or []:
for key in keys:
target_files.add(f"{self._get_component_key(key, transfer.name)}.bin")
existing_files = set()
with os.scandir(self.file_path) as entries:
for entry in entries:
if entry.is_file() and entry.name in target_files:
existing_files.add(entry.name)
return existing_files
def batch_exists_v2(
self,
keys: List[str],
pool_transfers: Optional[List[PoolTransfer]] = None,
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> PoolTransferResult:
existing_files = self._collect_existing_component_keys(keys, pool_transfers)
def has_component(page_idx: int, name: str) -> bool:
return (
f"{self._get_component_key(keys[page_idx], name)}.bin" in existing_files
)
# Longest contiguous KV prefix present in storage.
kv_pages = next(
(
i
for i in range(len(keys))
if f"{self._get_component_key(keys[i])}.bin" not in existing_files
),
len(keys),
)
hit_count: dict[str, int] = {PoolName.KV: kv_pages} if kv_pages else {}
final_pages = kv_pages
for transfer in pool_transfers or []:
if final_pages == 0:
break
name = transfer.name
if transfer.hit_policy == PoolHitPolicy.ALL_PAGES:
boundary = next(
(i for i in range(kv_pages) if not has_component(i, name)), kv_pages
)
else: # trailing_pages
trailing = max(1, len(transfer.keys) if transfer.keys else 1)
boundary = 0
for prefix_len in range(kv_pages, 0, -1):
if all(
has_component(i, name)
for i in range(max(0, prefix_len - trailing), prefix_len)
):
boundary = prefix_len
break
if boundary:
hit_count[name] = boundary
final_pages = min(final_pages, boundary)
return PoolTransferResult(final_pages, hit_count)
def _log_key(self, pool_name: str, key: str) -> str:
return key if pool_name == PoolName.KV else f"{key}.{pool_name}"
def _read_page(self, pool_name: str, key: str, host_pool, page_offset: int) -> bool:
"""Read one page from storage into host_pool at page_offset."""
storage_key = self._log_key(pool_name, key)
data_page = self.get(storage_key, host_pool.get_dummy_flat_data_page())
if data_page is None:
return False
host_pool.set_from_flat_data_page(page_offset, data_page)
return True
def _write_page(
self, pool_name: str, key: str, host_pool, page_offset: int
) -> bool:
"""Write one page from host_pool at page_offset to storage as raw bytes."""
storage_key = self._log_key(pool_name, key)
data_page = host_pool.get_data_page(page_offset, flat=True)
return self.set(storage_key, data_page)
def _batch_io_v2(self, transfers: List[PoolTransfer], op_fn):
results: dict[str, List[bool]] = {}
for transfer in transfers:
host_pool = self.registered_pools[transfer.name]
keys = transfer.keys or []
page_size = getattr(host_pool, "page_size", 1) or 1
expected = len(keys) * page_size
host_indices = transfer.host_indices
if host_indices is None or host_indices.numel() != expected:
logger.error(
"%s indices length mismatch for %s: expected %s, got %s",
op_fn.__name__,
transfer.name,
expected,
host_indices.numel() if host_indices is not None else 0,
)
results[transfer.name] = [False] * len(keys)
continue
results[transfer.name] = [
op_fn(transfer.name, key, host_pool, host_indices[i * page_size].item())
for i, key in enumerate(keys)
]
return results
def batch_get_v2(
self,
transfers: List[PoolTransfer],
extra_info: Optional["HiCacheStorageExtraInfo"] = None,
) -> dict[str, List[bool]]:
return self._batch_io_v2(transfers, self._read_page)
def batch_set_v2(
self,
transfers: List[PoolTransfer],
extra_info: Optional["HiCacheStorageExtraInfo"] = None,
) -> dict[str, List[bool]]:
return self._batch_io_v2(transfers, self._write_page)
def clear(self) -> bool:
try:
for filename in os.listdir(self.file_path):
file_path = os.path.join(self.file_path, filename)
if os.path.isfile(file_path):
os.remove(file_path)
logger.info("Cleared all entries in HiCacheFile storage.")
return True
except Exception as e:
logger.error(f"Failed to clear HiCacheFile storage: {e}")
return False