fix(hicache/umbp): support DeepSeek-V4 hybrid HostPoolGroup (multi-po… (#30762)

Co-authored-by: Zhangheng <hzh0425@apache.org>
This commit is contained in:
AMD-yanfeiwang
2026-08-13 15:40:07 +08:00
committed by GitHub
co-authored by Zhangheng
parent b7f87a2513
commit a34f81251f
6 changed files with 706 additions and 30 deletions
@@ -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)
# ------------------------------------------------------------------
@@ -0,0 +1,214 @@
"""E2E test for DeepSeek-V4 HiCache storage with the UMBP backend.
The first request writes the hybrid HostPoolGroup side pools to UMBP. After
flushing the device and host radix caches, the same prompt must be restored
from UMBP and report a storage-tier cache hit.
Usage:
python3 -m pytest \
test/registered/hicache/test_hicache_storage_umbp_backend.py -v
"""
import json
import os
import subprocess
import time
import unittest
import requests
from sglang.srt.utils import is_hip, kill_process_tree
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import (
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
register_amd_ci(
est_time=3600,
suite="nightly-amd-8-gpu-mi35x-deepseek-v4-flash",
nightly=True,
)
DEEPSEEK_V4_FLASH_FP8_MODEL_PATH = os.environ.get(
"DEEPSEEK_V4_FP8_MODEL_PATH", "sgl-project/DeepSeek-V4-Flash-FP8"
)
SERVER_LAUNCH_TIMEOUT = 3600
PAGE_SIZE = 256
TP_SIZE = 8
@unittest.skipUnless(is_hip(), "UMBP HiCache requires ROCm.")
@unittest.skipUnless(
os.environ.get("SGLANG_HACK_FLASHMLA_BACKEND", "unified_kv_triton")
== "unified_kv_triton",
"UMBP HiCache E2E only runs in the unified_kv_triton DSV4 nightly leg.",
)
class TestHiCacheStorageUMBPBackend(CustomTestCase):
"""DeepSeek-V4 hybrid HostPoolGroup round trip through local UMBP L3."""
input_ids = list(range(4000, 5024))
@classmethod
def setUpClass(cls):
cls.model = DEEPSEEK_V4_FLASH_FP8_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = None
try:
cls._launch_server()
except Exception:
cls._stop_server()
raise
@classmethod
def tearDownClass(cls):
cls._stop_server()
@classmethod
def _launch_server(cls):
storage_config = {
"dram_capacity_bytes": 1 * 1024 * 1024 * 1024,
"ssd_enabled": True,
"ssd_storage_dir": "/tmp/umbp_dsv4_local",
"ssd_capacity_bytes": 20 * 1024 * 1024 * 1024,
}
other_args = [
"--trust-remote-code",
"--tp-size",
str(TP_SIZE),
"--attention-backend",
"dsv4",
"--kv-cache-dtype",
"fp8_e4m3",
"--page-size",
str(PAGE_SIZE),
"--chunked-prefill-size",
"8192",
"--mem-fraction-static",
"0.85",
"--disable-cuda-graph",
"--disable-shared-experts-fusion",
"--enable-hierarchical-cache",
"--hicache-ratio",
"2",
"--hicache-write-policy",
"write_through",
"--hicache-storage-prefetch-policy",
"wait_complete",
"--hicache-io-backend",
"direct",
"--hicache-mem-layout",
"page_first",
"--hicache-storage-backend",
"mori",
"--hicache-storage-backend-extra-config",
json.dumps(storage_config),
"--enable-cache-report",
"--enable-metrics",
"--swa-full-tokens-ratio",
"0.1",
"--max-total-tokens",
"20000",
"--max-running-requests",
"4",
"--watchdog-timeout",
"1200",
]
env = os.environ.copy()
# An absent master address keeps every TP rank in standalone local mode,
# so this E2E does not require an RDMA-capable CI runner.
env.pop("UMBP_MASTER_ADDRESS", None)
env.update(
{
"SGLANG_ENABLE_DETERMINISTIC_INFERENCE": "1",
"SGLANG_ENABLE_UNIFIED_RADIX_TREE": "1",
"SGLANG_DSV4_FP4_EXPERTS": "0",
"SGLANG_HACK_FLASHMLA_BACKEND": "unified_kv_triton",
"SGLANG_USE_ROCM700A": "0",
"AITER_BF16_FP8_MOE_BOUND": "0",
# Correctness does not depend on pre-reserved hugepages, and
# disabling them makes the E2E portable across MI35x runners.
"SGLANG_HICACHE_HOST_HUGEPAGE": "0",
"UMBP_DRAM_USE_HUGEPAGES": "0",
}
)
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=SERVER_LAUNCH_TIMEOUT,
other_args=other_args,
env=env,
)
@classmethod
def _stop_server(cls):
process = getattr(cls, "process", None)
if process is None:
return
if process.poll() is None:
# Give UMBP clients a chance to close their local tiers before the
# process tree is force-killed.
process.terminate()
try:
process.wait(timeout=60)
except subprocess.TimeoutExpired:
kill_process_tree(process.pid)
cls.process = None
def _flush_device_and_host_cache(self):
response = requests.post(
self.base_url + "/flush_cache",
params={"timeout": 60},
timeout=90,
)
response.raise_for_status()
def _generate(self):
response = requests.post(
self.base_url + "/generate",
json={
"input_ids": self.input_ids,
"sampling_params": {
"temperature": 0,
"max_new_tokens": 8,
"ignore_eos": True,
},
},
timeout=1200,
)
self.assertEqual(
response.status_code,
200,
f"Request failed: {response.status_code} - {response.text}",
)
return response.json()
def test_hybrid_host_pool_round_trip_from_umbp(self):
self._flush_device_and_host_cache()
first = self._generate()
self.assertEqual(first["meta_info"]["cached_tokens"], 0)
# Writes are asynchronous below the request path. This mirrors the
# Mooncake E2E drain before forcing the next request to use L3.
time.sleep(15)
self._flush_device_and_host_cache()
second = self._generate()
cached_details = second["meta_info"].get("cached_tokens_details") or {}
storage_cached_tokens = int(cached_details.get("storage", 0))
self.assertGreaterEqual(
storage_cached_tokens,
PAGE_SIZE,
"Expected DeepSeek-V4 side-pool KV to load from UMBP storage, "
f"got {cached_details=}",
)
self.assertEqual(cached_details.get("storage_backend"), "UMBPStore")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -684,6 +684,22 @@ class TestHiCacheStagedWriteBackDispatch(unittest.TestCase):
self.assertEqual(group.layout, "page_first")
self.assertTrue(group.can_use_write_back_jit)
def test_host_pool_group_destroys_logical_anchor(self):
logical_host_pool = LogicalHostPool(8, 2, layout="page_first")
group = HostPoolGroup(
[
PoolEntry(
name=PoolName.KV,
host_pool=logical_host_pool,
device_pool=None,
layer_mapper=lambda _: 0,
is_primary_index_anchor=True,
)
]
)
self.assertIsNone(group.destroy())
def test_write_back_jit_hybrid_write_keeps_extra_host_indices_on_cpu(self):
captured = {}
+192 -13
View File
@@ -2,25 +2,20 @@
"""Unit tests for UMBPStore with mocked HostKVCache."""
import ctypes
import importlib
import sys
import tempfile
import unittest
from dataclasses import dataclass
from types import ModuleType, SimpleNamespace
from typing import Optional
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from sglang.test.ci.ci_register import register_cpu_ci
import mori.umbp # noqa: F401
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
from sglang.test.ci.ci_register import register_amd_ci
# UMBPStore wraps mori's UMBP client (AMD/ROCm only). On machines without mori
# (e.g. NVIDIA / CPU CI) the whole TestCase is skipped instead of failing at
# import time, so the CI runner (`python3 <file> -f`) exits cleanly.
try:
import mori.umbp # noqa: F401
HAS_MORI = True
except ImportError:
HAS_MORI = False
register_amd_ci(est_time=30, suite="stage-a-test-1-gpu-small-amd")
@dataclass
@@ -90,12 +85,39 @@ class MockHostKVCache:
return bytes(ctypes.string_at(self._buffer_ptr + v_offset, self.element_size))
class MockLogicalHostPool:
layout = "page_first"
page_size = 1
kv_buffer = None
class MockHybridSidePool:
page_size = 1
def get_page_buffer_meta(self, indices):
return [1000 + i * 8 for i in range(len(indices))], [8] * len(indices)
def import_umbp_store_module():
"""Import UMBPStore without pulling GPU-only memory-pool dependencies."""
module_name = "sglang.srt.mem_cache.storage.umbp.umbp_store"
if module_name in sys.modules:
return sys.modules[module_name]
fake_memory_pool_host = ModuleType("sglang.srt.mem_cache.memory_pool_host")
fake_memory_pool_host.HostKVCache = object
with patch.dict(
sys.modules,
{"sglang.srt.mem_cache.memory_pool_host": fake_memory_pool_host},
):
return importlib.import_module(module_name)
def make_indices(indices):
"""Create a list that acts like a torch.Tensor of indices."""
return indices
@unittest.skipUnless(HAS_MORI, "mori.umbp not available (AMD/ROCm only)")
class TestUMBPStore(unittest.TestCase):
def test_basic_set_get(self):
from sglang.srt.mem_cache.storage.umbp.umbp_store import UMBPStore
@@ -290,5 +312,162 @@ class TestUMBPStore(unittest.TestCase):
store.clear()
class TestUMBPStoreDefensiveSemantics(unittest.TestCase):
@staticmethod
def _make_v2_store():
from sglang.srt.mem_cache.hicache_storage import PoolName
UMBPStore = import_umbp_store_module().UMBPStore
store = UMBPStore.__new__(UMBPStore)
store.client = MagicMock()
store.client.is_distributed.return_value = False
store.registered_pools = {}
store._kv_anchor_is_logical = True
store.is_mla_backend = True
store.mla_suffix = ""
store.mha_suffix = "0"
store.register_mem_host_pool_v2(MockHybridSidePool(), PoolName.DEEPSEEK_V4_C4)
return store
def test_constructor_preserves_logical_anchor_detection(self):
umbp_module = import_umbp_store_module()
class FakeUMBPConfig:
def __init__(self):
self.role = None
self.dram = SimpleNamespace(capacity_bytes=0)
self.ssd = SimpleNamespace(
enabled=False,
storage_dir="/tmp",
capacity_bytes=0,
ssd_backend="file",
spdk_proxy_tenant_id=0,
spdk_proxy_tenant_quota_bytes=0,
)
self.distributed = None
@classmethod
def from_environment(cls):
return cls()
class FakeUMBPClient:
def __init__(self, _config):
pass
fake_role = SimpleNamespace(
Standalone="standalone",
SharedSSDLeader="leader",
SharedSSDFollower="follower",
)
imported = (
FakeUMBPClient,
FakeUMBPConfig,
fake_role,
None,
None,
None,
)
config = MockStorageConfig(
extra_config={"dram_capacity_bytes": 1024, "ssd_enabled": False}
)
with patch.object(umbp_module, "_import_umbp_client", return_value=imported):
store = umbp_module.UMBPStore(config, MockLogicalHostPool())
self.assertTrue(store._kv_anchor_is_logical)
self.assertEqual(store.batch_set_v1(["page0"], [0]), [True])
def test_short_batch_exists_result_fails_closed(self):
from sglang.srt.mem_cache.hicache_storage import PoolName, PoolTransfer
store = self._make_v2_store()
store.client.batch_exists.return_value = [True]
transfer = PoolTransfer(
name=PoolName.DEEPSEEK_V4_C4,
keys=["page0", "page1"],
host_indices=[0, 1],
)
result = store.batch_exists_v2(["page0", "page1"], [transfer])
self.assertEqual(result.kv_hit_pages, 0)
def test_batch_exists_v2_narrows_queries_across_side_pools(self):
from sglang.srt.mem_cache.hicache_storage import PoolName, PoolTransfer
store = self._make_v2_store()
store.register_mem_host_pool_v2(MockHybridSidePool(), PoolName.DEEPSEEK_V4_C128)
page_keys = [f"page{i}" for i in range(4)]
store.client.batch_exists.side_effect = [
[True, True, False, True],
[True, False],
]
transfers = [
PoolTransfer(
name=PoolName.DEEPSEEK_V4_C4,
keys=page_keys,
host_indices=[0, 1, 2, 3],
),
PoolTransfer(
name=PoolName.DEEPSEEK_V4_C128,
keys=page_keys,
host_indices=[0, 1, 2, 3],
),
]
result = store.batch_exists_v2(page_keys, transfers)
queried_keys = [
invocation.args[0]
for invocation in store.client.batch_exists.call_args_list
]
self.assertEqual(
queried_keys,
[
[f"{key}__{PoolName.DEEPSEEK_V4_C4}" for key in page_keys],
[f"{key}__{PoolName.DEEPSEEK_V4_C128}" for key in page_keys[:2]],
],
)
self.assertEqual(result.kv_hit_pages, 1)
self.assertEqual(
result.extra_pool_hit_pages,
{
PoolName.KV: 4,
PoolName.DEEPSEEK_V4_C4: 2,
PoolName.DEEPSEEK_V4_C128: 1,
},
)
def test_short_batch_get_result_marks_every_page_failed(self):
from sglang.srt.mem_cache.hicache_storage import PoolName, PoolTransfer
store = self._make_v2_store()
store.client.batch_get_into_ptr.return_value = [True]
transfer = PoolTransfer(
name=PoolName.DEEPSEEK_V4_C4,
keys=["page0", "page1"],
host_indices=[0, 1],
)
result = store.batch_get_v2([transfer])
self.assertEqual(result[PoolName.DEEPSEEK_V4_C4], [False, False])
def test_short_batch_set_result_marks_every_page_failed(self):
from sglang.srt.mem_cache.hicache_storage import PoolName, PoolTransfer
store = self._make_v2_store()
store.client.batch_put_from_ptr.return_value = [True]
transfer = PoolTransfer(
name=PoolName.DEEPSEEK_V4_C4,
keys=["page0", "page1"],
host_indices=[0, 1],
)
result = store.batch_set_v2([transfer])
self.assertEqual(result[PoolName.DEEPSEEK_V4_C4], [False, False])
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -143,6 +143,7 @@ NIGHTLY_SUITES = {
"nightly-amd-4-gpu",
"nightly-amd-8-gpu",
"nightly-amd-vlm",
"nightly-amd-8-gpu-mi35x-deepseek-v4-flash",
# MI35x 8-GPU suite (different model configs)
"nightly-amd-8-gpu-mi35x",
],