fix(hicache/umbp): support DeepSeek-V4 hybrid HostPoolGroup (multi-po… (#30762)
Co-authored-by: Zhangheng <hzh0425@apache.org>
This commit is contained in:
co-authored by
Zhangheng
parent
b7f87a2513
commit
a34f81251f
@@ -671,6 +671,10 @@ class LogicalHostPool:
|
||||
def clear(self):
|
||||
self.free_slots = torch.arange(self.size, dtype=torch.int64)
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Logical anchors own no backing buffers or registrations to release."""
|
||||
return None
|
||||
|
||||
def available_size(self):
|
||||
return len(self.free_slots)
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ from sglang.srt.mem_cache.hicache_storage import (
|
||||
HiCacheStorage,
|
||||
HiCacheStorageConfig,
|
||||
HiCacheStorageExtraInfo,
|
||||
PoolHitPolicy,
|
||||
PoolName,
|
||||
PoolTransfer,
|
||||
PoolTransferResult,
|
||||
)
|
||||
from sglang.srt.mem_cache.memory_pool_host import HostKVCache
|
||||
|
||||
@@ -573,6 +577,15 @@ class UMBPStore(HiCacheStorage):
|
||||
# .default_dram_page_size (2 MiB by default). The
|
||||
# partial-tail safety net in PoolClient handles any
|
||||
# size mismatch.
|
||||
#
|
||||
# Logical-anchor host pools (the DeepSeek-V4 HiCache HostPoolGroup
|
||||
# whose KV anchor is a LogicalHostPool that owns only page indices
|
||||
# and no physical KV tensor) return None from get_page_buffer_meta()
|
||||
# by design — the real per-page byte sizes live in the v2 side pools
|
||||
# (SWA / compressed KV / indexer / state), which each carry their own
|
||||
# dimensions. There is no single page size that fits all of them, so
|
||||
# we leave dram_page_size at 0 and let the mori master use its
|
||||
# default with the PoolClient partial-tail safety net.
|
||||
page_byte_size = None
|
||||
if "dram_page_size" in extra:
|
||||
page_byte_size = int(extra["dram_page_size"])
|
||||
@@ -584,14 +597,17 @@ class UMBPStore(HiCacheStorage):
|
||||
# would over-count by the indexer buffer that is never put to UMBP).
|
||||
dummy = torch.zeros(mem_pool_host.page_size, dtype=torch.int64)
|
||||
if self.is_mla_backend:
|
||||
_, esz = mem_pool_host.get_page_buffer_meta(dummy)
|
||||
meta = mem_pool_host.get_page_buffer_meta(dummy)
|
||||
elif storage_config is not None and getattr(
|
||||
storage_config, "should_split_heads", False
|
||||
):
|
||||
sf = storage_config.tp_lcm_size // storage_config.tp_size
|
||||
_, esz = mem_pool_host.get_split_heads_page_buffer_meta(dummy, sf)
|
||||
meta = mem_pool_host.get_split_heads_page_buffer_meta(dummy, sf)
|
||||
else:
|
||||
_, esz = mem_pool_host.get_page_buffer_meta(dummy)
|
||||
meta = mem_pool_host.get_page_buffer_meta(dummy)
|
||||
# meta is None for a logical-anchor group (see note above);
|
||||
# esz is the per-page element-size list otherwise.
|
||||
esz = meta[1] if meta else None
|
||||
page_byte_size = int(esz[0]) if esz else 0
|
||||
|
||||
if (
|
||||
@@ -787,6 +803,12 @@ class UMBPStore(HiCacheStorage):
|
||||
safe_cap = int(cfg.ssd.capacity_bytes * 0.95)
|
||||
cfg.ssd.spdk_proxy_tenant_quota_bytes = max(1, safe_cap // dp_size_hint)
|
||||
|
||||
# Initialize registration state before the optional constructor-time
|
||||
# register_mem_pool_host() call below. In particular, do not overwrite
|
||||
# the logical-anchor flag after that call has detected a LogicalHostPool.
|
||||
self.registered_pools: dict = {}
|
||||
self._kv_anchor_is_logical = False
|
||||
|
||||
self.client = UMBPClient(cfg)
|
||||
if mem_pool_host is not None:
|
||||
self.register_mem_pool_host(mem_pool_host)
|
||||
@@ -867,23 +889,44 @@ class UMBPStore(HiCacheStorage):
|
||||
"page_head",
|
||||
], "UMBP store only supports page_first, page_first_direct, or page_head layout"
|
||||
|
||||
# Hybrid logical anchors (e.g. DeepSeek-V4's KV anchor LogicalHostPool)
|
||||
# own only allocation indices and hold no physical KV tensor. Compute
|
||||
# this once and reuse: there is nothing to register for RDMA here, v1
|
||||
# I/O no-ops on it, and the real per-pool buffers are registered through
|
||||
# register_mem_host_pool_v2().
|
||||
self._kv_anchor_is_logical = self.mem_pool_host.kv_buffer is None
|
||||
|
||||
self._zero_copy_registered = False
|
||||
if self._kv_anchor_is_logical:
|
||||
return
|
||||
|
||||
# In distributed mode, pre-register the entire host KV buffer with the
|
||||
# underlying RDMA IOEngine so PoolClient can take the zero-copy path
|
||||
# for batch_get_into_ptr / batch_put_from_ptr (skips the staging
|
||||
# buffer memcpy + lock and removes the per-call `staging_buffer_size`
|
||||
# cap). Standalone returns true as no-op by IUMBPClient contract;
|
||||
# we still gate on is_distributed() below to avoid a pointless call.
|
||||
self._zero_copy_registered = False
|
||||
if self._register_host_buffer_for_zero_copy(mem_pool_host):
|
||||
self._zero_copy_registered = True
|
||||
|
||||
def _register_host_buffer_for_zero_copy(self, host_pool: HostKVCache) -> bool:
|
||||
"""Register a host pool's KV buffer with the RDMA IOEngine for zero-copy.
|
||||
|
||||
Shared by the single-pool path (register_mem_pool_host) and the
|
||||
multi-pool path (register_mem_host_pool_v2). Returns True when the
|
||||
buffer was successfully registered, False on any skip/failure (the
|
||||
caller then transparently falls back to the staging-buffer path).
|
||||
"""
|
||||
if self.client is None:
|
||||
return
|
||||
return False
|
||||
try:
|
||||
is_distributed = bool(self.client.is_distributed())
|
||||
except Exception:
|
||||
is_distributed = False
|
||||
if not is_distributed:
|
||||
return
|
||||
return False
|
||||
if not hasattr(self.client, "register_memory"):
|
||||
return
|
||||
return False
|
||||
if getattr(self, "_disable_zero_copy_register", False):
|
||||
logger.info(
|
||||
"UMBPStore: skipping host KV buffer RDMA registration because "
|
||||
@@ -891,9 +934,20 @@ class UMBPStore(HiCacheStorage):
|
||||
"Falling back to the staging-buffer transfer path; per-transfer "
|
||||
"size is capped by distributed.staging_buffer_size."
|
||||
)
|
||||
return
|
||||
return False
|
||||
# NOTE(layer_first): this only handles the page_first layout, where a
|
||||
# host pool exposes a single contiguous `kv_buffer` that we can register
|
||||
# for RDMA in one shot. If UMBP later supports a layer_first layout, or
|
||||
# side pools that expose multiple buffers via get_hybrid_pool_buffer()
|
||||
# (e.g. DSAIndexerPoolHost, whose buffer lives in
|
||||
# index_k_with_scale_buffer rather than kv_buffer), this branch must be
|
||||
# extended to register every per-layer / per-buffer region. Otherwise
|
||||
# such pools bypass zero-copy and silently fall back to the slower
|
||||
# staging-buffer path.
|
||||
kv_buffer = getattr(host_pool, "kv_buffer", None)
|
||||
if kv_buffer is None:
|
||||
return False
|
||||
try:
|
||||
kv_buffer = mem_pool_host.kv_buffer
|
||||
host_ptr = int(kv_buffer.data_ptr())
|
||||
host_size = int(kv_buffer.numel() * kv_buffer.element_size())
|
||||
# When the buffer is backed by hugepages the mmap region is
|
||||
@@ -901,7 +955,7 @@ class UMBPStore(HiCacheStorage):
|
||||
# some NICs (AINIC / ROCm) requires the registered region to
|
||||
# cover complete hugepages, so use the full mapped_size
|
||||
# instead of the logical tensor size.
|
||||
allocator = getattr(mem_pool_host, "allocator", None)
|
||||
allocator = getattr(host_pool, "allocator", None)
|
||||
mapped_size_fn = getattr(allocator, "mapped_size_for", None)
|
||||
if mapped_size_fn is not None:
|
||||
mapped_size = mapped_size_fn(host_ptr)
|
||||
@@ -917,20 +971,37 @@ class UMBPStore(HiCacheStorage):
|
||||
"distributed.staging_buffer_size.",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
return False
|
||||
if ok:
|
||||
self._zero_copy_registered = True
|
||||
logger.info(
|
||||
"UMBPStore: registered host KV buffer for RDMA zero-copy "
|
||||
"(ptr=0x%x, size=%d MB)",
|
||||
host_ptr,
|
||||
host_size // (1024 * 1024),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"UMBPStore: register_memory returned false; staying on staging "
|
||||
"buffer fallback path."
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
"UMBPStore: register_memory returned false; staying on staging "
|
||||
"buffer fallback path."
|
||||
)
|
||||
return False
|
||||
|
||||
def register_mem_host_pool_v2(self, host_pool: HostKVCache, host_pool_name):
|
||||
"""Register an additional hybrid side pool (DeepSeek-V4 HostPoolGroup).
|
||||
|
||||
The controller calls this once per PoolEntry in the group, including the
|
||||
KV anchor. The KV anchor is logical (no physical tensor) so we skip it;
|
||||
its allocation-index role is unrelated to storage I/O. Every other pool
|
||||
(SWA / compressed KV / indexer / state) carries a real page_first KV
|
||||
buffer that must be (a) resolvable by name at v2 I/O time and (b)
|
||||
registered with the RDMA IOEngine for zero-copy transfers.
|
||||
"""
|
||||
# KV anchor is either already registered via register_mem_pool_host()
|
||||
# (non-hybrid single pool) or purely logical (hybrid group). Skip it.
|
||||
if host_pool_name == PoolName.KV:
|
||||
return
|
||||
self.registered_pools[host_pool_name] = host_pool
|
||||
self._register_host_buffer_for_zero_copy(host_pool)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key suffix generation — mirrors MooncakeStore
|
||||
@@ -1007,6 +1078,11 @@ class UMBPStore(HiCacheStorage):
|
||||
host_indices: torch.Tensor,
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> List[bool]:
|
||||
if self._kv_anchor_is_logical:
|
||||
# DeepSeek-V4's KV anchor is logical only; the physical KV data is
|
||||
# carried by the v2 side pools, so there is nothing to read here.
|
||||
return [True] * len(keys)
|
||||
|
||||
key_strs, buffer_ptrs, buffer_sizes = self._batch_preprocess(keys, host_indices)
|
||||
|
||||
# Normalize sizes to list of per-key sizes
|
||||
@@ -1076,6 +1152,11 @@ class UMBPStore(HiCacheStorage):
|
||||
page_count = len(host_indices) // self.mem_pool_host.page_size
|
||||
return [True] * page_count
|
||||
|
||||
if self._kv_anchor_is_logical:
|
||||
# DeepSeek-V4's KV anchor is logical only; the physical KV data is
|
||||
# written by the v2 side pools, so there is nothing to write here.
|
||||
return [True] * len(keys)
|
||||
|
||||
key_strs, buffer_ptrs, buffer_sizes = self._batch_preprocess(keys, host_indices)
|
||||
|
||||
if isinstance(buffer_sizes, int):
|
||||
@@ -1138,6 +1219,187 @@ class UMBPStore(HiCacheStorage):
|
||||
hit_count = self.client.batch_exists_consecutive(query_keys)
|
||||
return hit_count // key_multiplier
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Multi-pool v2 interface (DeepSeek-V4 hybrid HiCache HostPoolGroup)
|
||||
#
|
||||
# The DeepSeek-V4 HiCache stack splits KV state across several page_first
|
||||
# side pools (SWA / compressed KV / indexer / state), coordinated by a
|
||||
# logical KV anchor that owns only page indices. The controller registers
|
||||
# each real pool through register_mem_host_pool_v2() and drives storage
|
||||
# via these _v2 methods, one PoolTransfer per pool. This mirrors the proven
|
||||
# MooncakeStore / HiCacheHF3FS design, specialized for UMBP's page_first,
|
||||
# single-object-per-page layout (each page -> exactly one storage object).
|
||||
# ------------------------------------------------------------------
|
||||
def _get_hybrid_page_component_keys(self, page_keys, transfer: PoolTransfer):
|
||||
"""Map per-page logical keys to per-object storage keys for a side pool.
|
||||
|
||||
For UMBP every registered side pool is page_first and stores one object
|
||||
per page (MLA: a single K object; MHA: a K and a V object), so the
|
||||
component-key count is an exact multiple of the page count. The pool
|
||||
name is embedded in the suffix so pages that share a hash across pools
|
||||
never collide.
|
||||
"""
|
||||
pool_name = transfer.name
|
||||
host_pool = self.registered_pools.get(pool_name)
|
||||
if host_pool is None:
|
||||
raise ValueError(f"Unregistered UMBP hybrid pool: {pool_name}")
|
||||
|
||||
if self.is_mla_backend:
|
||||
# Single compressed object per page.
|
||||
suffixes = [f"_{self.mla_suffix}_{pool_name}"]
|
||||
elif getattr(host_pool, "v_buffer", None) is not None:
|
||||
# Ordinary MHA side pool mirrors a K/V pool.
|
||||
suffixes = [
|
||||
f"_{self.mha_suffix}_{pool_name}_k",
|
||||
f"_{self.mha_suffix}_{pool_name}_v",
|
||||
]
|
||||
else:
|
||||
suffixes = [f"_{self.mha_suffix}_{pool_name}"]
|
||||
|
||||
key_multiplier = len(suffixes)
|
||||
component_keys = [
|
||||
f"{page_key}{suffix}" for page_key in page_keys for suffix in suffixes
|
||||
]
|
||||
return component_keys, key_multiplier
|
||||
|
||||
def batch_exists_v2(
|
||||
self,
|
||||
keys: List[str],
|
||||
pool_transfers: Optional[List[PoolTransfer]] = None,
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> PoolTransferResult:
|
||||
if self._kv_anchor_is_logical:
|
||||
# Logical KV anchor: no physical KV object exists in UMBP, so the
|
||||
# usable prefix is bounded entirely by the required side pools.
|
||||
kv_pages = len(keys)
|
||||
else:
|
||||
kv_pages = self.batch_exists(keys, extra_info)
|
||||
|
||||
hit_count: dict = {PoolName.KV: kv_pages} if kv_pages else {}
|
||||
final_pages = kv_pages
|
||||
|
||||
for transfer in pool_transfers or []:
|
||||
if final_pages == 0:
|
||||
break
|
||||
component_keys, key_multiplier = self._get_hybrid_page_component_keys(
|
||||
keys[:final_pages], transfer
|
||||
)
|
||||
exists = list(self.client.batch_exists(component_keys))
|
||||
if len(exists) != len(component_keys):
|
||||
logger.error(
|
||||
"UMBP v2 batch_exists result-size mismatch for pool %s: "
|
||||
"expected=%d actual=%d; treating the storage prefix as a miss",
|
||||
transfer.name,
|
||||
len(component_keys),
|
||||
len(exists),
|
||||
)
|
||||
final_pages = 0
|
||||
break
|
||||
# Collapse per-object results into per-page presence.
|
||||
page_exists = [
|
||||
all(exists[i * key_multiplier : (i + 1) * key_multiplier])
|
||||
for i in range(final_pages)
|
||||
]
|
||||
|
||||
boundary = 0
|
||||
if transfer.hit_policy == PoolHitPolicy.ALL_PAGES:
|
||||
try:
|
||||
boundary = page_exists.index(False)
|
||||
except ValueError:
|
||||
boundary = final_pages
|
||||
elif transfer.hit_policy == PoolHitPolicy.TRAILING_PAGES:
|
||||
trailing = max(1, len(transfer.keys) if transfer.keys else 1)
|
||||
for prefix_len in range(final_pages, 0, -1):
|
||||
if all(
|
||||
page_exists[i]
|
||||
for i in range(max(0, prefix_len - trailing), prefix_len)
|
||||
):
|
||||
boundary = prefix_len
|
||||
break
|
||||
if boundary:
|
||||
hit_count[transfer.name] = boundary
|
||||
final_pages = min(final_pages, boundary)
|
||||
|
||||
return PoolTransferResult(final_pages, hit_count)
|
||||
|
||||
def _batch_io_v2(self, transfers: List[PoolTransfer], is_set: bool) -> dict:
|
||||
"""Unified per-pool zero-copy I/O. Returns {pool_name: per-page bools}."""
|
||||
results: dict = {}
|
||||
for transfer in transfers:
|
||||
host_pool = self.registered_pools.get(transfer.name)
|
||||
if host_pool is None:
|
||||
raise ValueError(f"Unregistered UMBP hybrid pool: {transfer.name}")
|
||||
keys = transfer.keys or []
|
||||
host_indices = transfer.host_indices
|
||||
page_size = getattr(host_pool, "page_size", 1) or 1
|
||||
if not keys or host_indices is None:
|
||||
results[transfer.name] = [False] * len(keys)
|
||||
continue
|
||||
assert len(keys) == len(host_indices) // page_size
|
||||
|
||||
key_strs, key_multiplier = self._get_hybrid_page_component_keys(
|
||||
keys, transfer
|
||||
)
|
||||
ptr_list, element_size_list = host_pool.get_page_buffer_meta(host_indices)
|
||||
# page_first side pools emit exactly one (ptr, size) per component
|
||||
# key; assert the invariant so any future layout change is caught
|
||||
# loudly instead of silently corrupting the key<->buffer zip.
|
||||
assert len(key_strs) == len(ptr_list) == len(element_size_list), (
|
||||
f"UMBP v2 buffer-meta mismatch for pool {transfer.name}: "
|
||||
f"keys={len(key_strs)} ptrs={len(ptr_list)} sizes={len(element_size_list)}"
|
||||
)
|
||||
|
||||
if is_set:
|
||||
# UMBP performs its own key-level deduplication, so skip the
|
||||
# extra batch_exists round-trip and put directly (mirrors
|
||||
# batch_set_v1).
|
||||
io_results = [
|
||||
bool(r)
|
||||
for r in self.client.batch_put_from_ptr(
|
||||
key_strs, list(ptr_list), list(element_size_list)
|
||||
)
|
||||
]
|
||||
else:
|
||||
io_results = [
|
||||
bool(r)
|
||||
for r in self.client.batch_get_into_ptr(
|
||||
key_strs, list(ptr_list), list(element_size_list)
|
||||
)
|
||||
]
|
||||
|
||||
if len(io_results) != len(key_strs):
|
||||
logger.error(
|
||||
"UMBP v2 %s result-size mismatch for pool %s: "
|
||||
"expected=%d actual=%d; treating every page as failed",
|
||||
"set" if is_set else "get",
|
||||
transfer.name,
|
||||
len(key_strs),
|
||||
len(io_results),
|
||||
)
|
||||
results[transfer.name] = [False] * len(keys)
|
||||
continue
|
||||
|
||||
# Collapse per-object results back to per-page results.
|
||||
results[transfer.name] = [
|
||||
all(io_results[i * key_multiplier : (i + 1) * key_multiplier])
|
||||
for i in range(len(keys))
|
||||
]
|
||||
return results
|
||||
|
||||
def batch_get_v2(
|
||||
self,
|
||||
transfers: List[PoolTransfer],
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> dict:
|
||||
return self._batch_io_v2(transfers, is_set=False)
|
||||
|
||||
def batch_set_v2(
|
||||
self,
|
||||
transfers: List[PoolTransfer],
|
||||
extra_info: Optional[HiCacheStorageExtraInfo] = None,
|
||||
) -> dict:
|
||||
return self._batch_io_v2(transfers, is_set=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy ABC interface (required by HiCacheStorage)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user