[XPU] Add XPU device support for LMCache radix cache integration (#23534)

Co-authored-by: Christopher Manteuffel <christopher.manteuffel@intel.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ma Mingfei <mingfei.ma@intel.com>
This commit is contained in:
Libin Tang
2026-07-24 08:41:01 +08:00
committed by GitHub
co-authored by Christopher Manteuffel Claude Opus 4.8 Ma Mingfei
parent 2f823a2eee
commit 1e10ec93b3
6 changed files with 633 additions and 16 deletions
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
timeout-minutes: 60
run: |
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip install --upgrade pip
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip install pytest expecttest ray huggingface_hub tabulate
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip install pytest expecttest ray huggingface_hub tabulate "lmcache>=0.3.9"
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip uninstall -y flashinfer-python sgl-kernel sglang
docker exec ci_sglang_xpu cp /sglang-checkout/python/pyproject_xpu.toml /sglang-checkout/python/pyproject.toml
docker exec -w /sglang-checkout/python ci_sglang_xpu /opt/venv/bin/python3 -m pip install --no-cache-dir . --extra-index-url https://download.pytorch.org/whl/xpu
@@ -179,7 +179,7 @@ jobs:
timeout-minutes: 60
run: |
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip install --upgrade pip
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip install pytest expecttest ray huggingface_hub tabulate
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip install pytest expecttest ray huggingface_hub tabulate "lmcache>=0.3.9"
docker exec ci_sglang_xpu /opt/venv/bin/python3 -m pip uninstall -y flashinfer-python sgl-kernel sglang
docker exec ci_sglang_xpu cp /sglang-checkout/python/pyproject_xpu.toml /sglang-checkout/python/pyproject.toml
docker exec -w /sglang-checkout/python ci_sglang_xpu /opt/venv/bin/python3 -m pip install --no-cache-dir . --extra-index-url https://download.pytorch.org/whl/xpu
@@ -17,6 +17,7 @@ from sglang.srt.mem_cache.base_prefix_cache import (
)
from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey, TreeNode
from sglang.srt.runtime_context import get_server_args
from sglang.srt.utils import create_device_stream, device_stream_context
try:
from lmcache.integration.sglang.multi_process_adapter import LMCacheMPConnector
@@ -61,13 +62,13 @@ class LayerTransferCounter:
The KV pool calls `wait_until(layer_id)` after finishing a layer, which we
translate into a `load_kv_layerwise(layer_id)` call on the LMCache connector
within the provided CUDA stream.
within the provided device stream.
"""
def __init__(
self,
num_layers: int,
load_stream: torch.cuda.Stream,
load_stream: torch.Stream,
lmc_connector: LMCacheLayerwiseConnector,
printable: bool = False,
):
@@ -78,7 +79,7 @@ class LayerTransferCounter:
def wait_until(self, layer_id: int):
# Ensure ordering of the async loads wrt compute stream(s).
self.load_stream.synchronize()
with self.load_stream:
with device_stream_context(self.load_stream):
self.lmc_connector.load_kv_layerwise(layer_id)
@@ -131,12 +132,13 @@ class LMCRadixCache(RadixCache):
tp_group=tp_group.device_group if tp_group is not None else None,
)
self.load_stream = torch.cuda.Stream()
self.store_stream = torch.cuda.Stream()
self.load_stream = create_device_stream(self.device)
self.store_stream = create_device_stream(self.device)
# MP is the default. To use the in-process layerwise connector,
# set ``self._mode = LMCacheMode.IP`` here.
self._mode = LMCacheMode.MP
# MP (multi-process) is the default. XPU defaults to IP (in-process
# layerwise) because the MP connector shares the KV cache via CUDA IPC
# (``Tensor._share_cuda_``), which is unavailable on XPU.
self._mode = LMCacheMode.IP if self.device.type == "xpu" else LMCacheMode.MP
if self._mode is LMCacheMode.MP:
if not cli_lmc_cfg:
raise ValueError(
@@ -351,6 +353,8 @@ class LMCRadixCache(RadixCache):
slot_mapping[:value_numel].fill_(-1)
slot_mapping[value_numel:].copy_(token_slots)
# Dispatch to the mode-specific loader (IP: start_load_kv, MP:
# retrieve_kv). Each loader manages its own load_stream context.
num_retrieved = load_fn(slot_mapping, prefix_pad)
logger.debug("num_retrieved_tokens: %s", num_retrieved)
@@ -392,8 +396,9 @@ class LMCRadixCache(RadixCache):
"""MP non-layerwise loader: fire ``retrieve_kv`` and wait for the
load_stream so the compute stream observes the writes.
"""
self.load_stream.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(self.load_stream):
current_stream = torch.get_device_module(self.device).current_stream()
self.load_stream.wait_stream(current_stream)
with device_stream_context(self.load_stream):
n = self.lmcache_connector.retrieve_kv(
LoadMetadata(
token_ids=marker.key.token_ids,
@@ -403,7 +408,7 @@ class LMCRadixCache(RadixCache):
request_id=request_id,
)
)
torch.cuda.current_stream().wait_stream(self.load_stream)
current_stream.wait_stream(self.load_stream)
return n
def _ip_load_back(
@@ -419,7 +424,7 @@ class LMCRadixCache(RadixCache):
``start_load_kv`` enqueues the first layer's transfer; the
``LayerTransferCounter`` hook drives the rest during forward.
"""
with torch.cuda.stream(self.load_stream):
with device_stream_context(self.load_stream):
return self.lmcache_connector.start_load_kv(
LoadMetadata(
token_ids=token_ids,
@@ -472,14 +477,15 @@ class LMCRadixCache(RadixCache):
offset=0,
request_id=req.rid,
)
with torch.cuda.stream(self.store_stream):
self.lmcache_connector.store_kv(store_md)
if self._mode is LMCacheMode.MP:
self.lmcache_connector.store_kv(store_md)
# MP store_kv blocks until the daemon's signal event fires, so the slots are safe to evict immediately.
self._mp_load_back_markers.pop(req.rid, None)
self.dec_lock_ref(new_last_node)
self.lmcache_connector.end_session(req.rid)
elif self._mode is LMCacheMode.IP:
with device_stream_context(self.store_stream):
self.lmcache_connector.store_kv(store_md)
# Layerwise store is async on store_stream; defer the unlock to evict()'s store_stream.synchronize().
with self._node_lock:
self._in_flight_nodes.append(new_last_node)
+12
View File
@@ -545,6 +545,18 @@ def get_device_module():
return torch.get_device_module()
def create_device_stream(device):
"""Create a device stream for the given device type."""
if not isinstance(device, torch.device):
device = torch.device(device)
return torch.get_device_module(device).Stream(device=device)
def device_stream_context(stream):
"""Return the appropriate stream context manager for ``stream``."""
return torch.get_device_module(stream.device).stream(stream)
def get_amdgpu_memory_capacity():
try:
# Run rocm-smi and capture the output
@@ -0,0 +1,361 @@
"""
XPU integration tests for LMCache connector in SGLang.
Tests store/retrieve round-trip on Intel XPU using pure PyTorch ops
(index_copy_, index_select) instead of CUDA lmc_ops kernels.
Uses a single shared connector to avoid LMCacheEngineBuilder singleton
issues (close() does not remove from _instances, so re-creating a
connector returns a dead engine).
Usage:
python3 -m unittest registered.xpu.test_lmcache_connector
"""
import os
import unittest
import torch
from sglang.test.ci.ci_register import register_xpu_ci
# Must be set before lmcache imports. Save prior values so tearDownModule can
# restore them and avoid leaking into other tests in the same process.
_PATCHED_ENV = {
"LMCACHE_USE_EXPERIMENTAL": "True",
"LMCACHE_CONFIG_FILE": os.path.join(
os.path.dirname(__file__), "test_lmcache_connector_config.yaml"
),
}
_OLD_ENV = {k: os.environ.get(k) for k in _PATCHED_ENV}
os.environ["LMCACHE_USE_EXPERIMENTAL"] = _PATCHED_ENV["LMCACHE_USE_EXPERIMENTAL"]
os.environ.setdefault("LMCACHE_CONFIG_FILE", _PATCHED_ENV["LMCACHE_CONFIG_FILE"])
def tearDownModule():
for key, old_value in _OLD_ENV.items():
if old_value is None:
os.environ.pop(key, None)
else:
os.environ[key] = old_value
try:
from lmcache.integration.sglang.sglang_adapter import (
LMCacheLayerwiseConnector,
LoadMetadata,
StoreMetadata,
)
except ImportError:
raise RuntimeError(
"LMCache is not installed. "
"Install with: NO_CUDA_EXT=1 pip install -e . --no-build-isolation"
)
from sglang.srt.configs.model_config import ModelConfig
XPU_AVAILABLE = hasattr(torch, "xpu") and torch.xpu.is_available()
register_xpu_ci(est_time=60, suite="stage-b-test-1-gpu-xpu")
@unittest.skipUnless(XPU_AVAILABLE, "Intel XPU not available")
class TestLMCacheXPUConnector(unittest.TestCase):
"""Test LMCache layerwise connector store/retrieve on XPU.
All tests share a single connector instance to avoid the
LMCacheEngineBuilder singleton issue where close() does not
remove the engine from _instances.
"""
DEVICE = "xpu:0"
BUFFER_SIZE = 256
INPUT_LEN = 16
@classmethod
def setUpClass(cls):
cls.model_config = ModelConfig(model_path="Qwen/Qwen3-4B")
cls.head_num = cls.model_config.num_key_value_heads
cls.head_dim = cls.model_config.head_dim
cls.layer_num = cls.model_config.num_hidden_layers
cls.vocab_size = cls.model_config.vocab_size
# Shared KV buffers and connector (created once)
cls.k_buffer = [
torch.randn(
cls.BUFFER_SIZE,
cls.head_num,
cls.head_dim,
dtype=torch.bfloat16,
device=cls.DEVICE,
)
for _ in range(cls.layer_num)
]
cls.v_buffer = [
torch.randn(
cls.BUFFER_SIZE,
cls.head_num,
cls.head_dim,
dtype=torch.bfloat16,
device=cls.DEVICE,
)
for _ in range(cls.layer_num)
]
cls.connector = LMCacheLayerwiseConnector(
cls.model_config,
tp_size=1,
rank=0,
k_pool=cls.k_buffer,
v_pool=cls.v_buffer,
config_file=os.environ["LMCACHE_CONFIG_FILE"],
)
@classmethod
def tearDownClass(cls):
cls.connector.close()
def setUp(self):
"""Re-randomize buffers before each test for isolation."""
for i in range(self.layer_num):
self.k_buffer[i].normal_()
self.v_buffer[i].normal_()
def _unique_tokens(self, length=None, salt=0):
"""Generate unique token ids unlikely to collide across tests."""
n = length or self.INPUT_LEN
base = torch.randint(0, self.vocab_size, (n,))
return [(t.item() + salt) % self.vocab_size for t in base]
def test_store_then_retrieve(self):
"""Basic: store KV, clear buffers, retrieve and verify match."""
token_ids = self._unique_tokens(salt=100)
kv_indices = torch.randint(0, self.BUFFER_SIZE, (self.INPUT_LEN,))
# First retrieve should return 0 (cold cache)
load_meta = LoadMetadata(
token_ids=token_ids,
slot_mapping=kv_indices,
offset=0,
)
self.assertEqual(self.connector.start_load_kv(load_meta), 0)
# Store
store_meta = StoreMetadata(
last_node=None,
token_ids=token_ids,
kv_indices=kv_indices,
offset=0,
)
self.connector.store_kv(store_meta)
torch.xpu.synchronize()
# Save ground truth before clearing
gt_k = [self.k_buffer[i][kv_indices].clone() for i in range(self.layer_num)]
gt_v = [self.v_buffer[i][kv_indices].clone() for i in range(self.layer_num)]
# Clear buffers
for i in range(self.layer_num):
self.k_buffer[i].zero_()
self.v_buffer[i].zero_()
# Retrieve
ret = self.connector.start_load_kv(load_meta)
self.assertEqual(ret, self.INPUT_LEN)
for i in range(self.layer_num):
torch.xpu.synchronize()
self.connector.load_kv_layerwise(i)
torch.xpu.synchronize()
# Verify
for i in range(self.layer_num):
actual_k = self.k_buffer[i][kv_indices]
actual_v = self.v_buffer[i][kv_indices]
self.assertTrue(
torch.allclose(actual_k, gt_k[i]),
f"Layer {i}: K mismatch (max diff {(actual_k - gt_k[i]).abs().max():.6f})",
)
self.assertTrue(
torch.allclose(actual_v, gt_v[i]),
f"Layer {i}: V mismatch (max diff {(actual_v - gt_v[i]).abs().max():.6f})",
)
def test_retrieve_cold_cache_returns_zero(self):
"""Retrieve from empty cache should return 0 tokens."""
token_ids = self._unique_tokens(salt=200)
kv_indices = torch.randint(0, self.BUFFER_SIZE, (self.INPUT_LEN,))
load_meta = LoadMetadata(
token_ids=token_ids,
slot_mapping=kv_indices,
offset=0,
)
self.assertEqual(self.connector.start_load_kv(load_meta), 0)
def test_slot_mapping_with_negative_indices(self):
"""slot_mapping may contain -1 for an already-cached prefix.
The -1 filtering fix must (a) not crash on XPU and (b) leave the -1
slots untouched while correctly restoring the valid slots.
"""
# Distinct index for every token so -1 slots and valid slots never
# alias each other, keeping the untouched/restored assertions exact.
kv_indices = torch.randperm(self.BUFFER_SIZE)[: self.INPUT_LEN]
token_ids = self._unique_tokens(salt=400)
# Store first
store_meta = StoreMetadata(
last_node=None,
token_ids=token_ids,
kv_indices=kv_indices,
offset=0,
)
self.connector.store_kv(store_meta)
torch.xpu.synchronize()
# Build slot_mapping with -1 prefix (simulating already-cached tokens).
num_cached = 8
slot_mapping_with_neg = kv_indices.clone()
slot_mapping_with_neg[:num_cached] = -1
cached_slots = kv_indices[:num_cached]
valid_slots = kv_indices[num_cached:]
# Ground truth for the valid tail (what retrieve must restore).
gt_k = [self.k_buffer[i][valid_slots].clone() for i in range(self.layer_num)]
gt_v = [self.v_buffer[i][valid_slots].clone() for i in range(self.layer_num)]
# Clear buffers: untouched -1 slots stay zero, valid slots get restored.
for i in range(self.layer_num):
self.k_buffer[i].zero_()
self.v_buffer[i].zero_()
load_meta = LoadMetadata(
token_ids=token_ids,
slot_mapping=slot_mapping_with_neg,
# offset marks how many leading tokens are already cached (the
# -1 prefix in slot_mapping); this mirrors how lmc_radix_cache's
# _ip_load_back derives offset from the already-matched prefix.
offset=num_cached,
)
# Should not crash on XPU (the -1 filtering fix is critical here).
ret = self.connector.start_load_kv(load_meta)
self.assertEqual(ret, self.INPUT_LEN - num_cached)
for i in range(self.layer_num):
torch.xpu.synchronize()
self.connector.load_kv_layerwise(i)
torch.xpu.synchronize()
for i in range(self.layer_num):
# -1 positions must be untouched (still zero).
self.assertTrue(
torch.all(self.k_buffer[i][cached_slots] == 0),
f"Layer {i}: -1 K slots were written",
)
self.assertTrue(
torch.all(self.v_buffer[i][cached_slots] == 0),
f"Layer {i}: -1 V slots were written",
)
# Valid positions must be restored.
self.assertTrue(
torch.allclose(self.k_buffer[i][valid_slots], gt_k[i]),
f"Layer {i}: valid K slots not restored",
)
self.assertTrue(
torch.allclose(self.v_buffer[i][valid_slots], gt_v[i]),
f"Layer {i}: valid V slots not restored",
)
def test_multiple_store_retrieve_cycles(self):
"""Multiple store/retrieve cycles should not leak or corrupt."""
for cycle in range(3):
token_ids = self._unique_tokens(salt=500 + cycle * 1000)
kv_indices = torch.randint(0, self.BUFFER_SIZE, (self.INPUT_LEN,))
store_meta = StoreMetadata(
last_node=None,
token_ids=token_ids,
kv_indices=kv_indices,
offset=0,
)
self.connector.store_kv(store_meta)
torch.xpu.synchronize()
gt_k = [self.k_buffer[i][kv_indices].clone() for i in range(self.layer_num)]
gt_v = [self.v_buffer[i][kv_indices].clone() for i in range(self.layer_num)]
for i in range(self.layer_num):
self.k_buffer[i].zero_()
self.v_buffer[i].zero_()
load_meta = LoadMetadata(
token_ids=token_ids,
slot_mapping=kv_indices,
offset=0,
)
ret = self.connector.start_load_kv(load_meta)
self.assertEqual(
ret,
self.INPUT_LEN,
f"Cycle {cycle}: expected {self.INPUT_LEN}, got {ret}",
)
for i in range(self.layer_num):
torch.xpu.synchronize()
self.connector.load_kv_layerwise(i)
torch.xpu.synchronize()
for i in range(self.layer_num):
actual_k = self.k_buffer[i][kv_indices]
actual_v = self.v_buffer[i][kv_indices]
self.assertTrue(
torch.allclose(actual_k, gt_k[i]),
f"Cycle {cycle}, Layer {i}: K mismatch",
)
self.assertTrue(
torch.allclose(actual_v, gt_v[i]),
f"Cycle {cycle}, Layer {i}: V mismatch",
)
def test_bf16_dtype_preserved(self):
"""Verify bf16 dtype is preserved through store->retrieve cycle.
TODO: once XPU LMCache supports KV quantization, extend the dtype
coverage here to the quantized store/retrieve dtypes (e.g. fp8).
"""
token_ids = self._unique_tokens(salt=600)
kv_indices = torch.randint(0, self.BUFFER_SIZE, (self.INPUT_LEN,))
store_meta = StoreMetadata(
last_node=None,
token_ids=token_ids,
kv_indices=kv_indices,
offset=0,
)
self.connector.store_kv(store_meta)
torch.xpu.synchronize()
for i in range(self.layer_num):
self.k_buffer[i].zero_()
self.v_buffer[i].zero_()
load_meta = LoadMetadata(
token_ids=token_ids,
slot_mapping=kv_indices,
offset=0,
)
ret = self.connector.start_load_kv(load_meta)
self.assertEqual(ret, self.INPUT_LEN)
for i in range(self.layer_num):
torch.xpu.synchronize()
self.connector.load_kv_layerwise(i)
torch.xpu.synchronize()
for i in range(self.layer_num):
self.assertEqual(self.k_buffer[i].dtype, torch.bfloat16)
self.assertEqual(self.v_buffer[i].dtype, torch.bfloat16)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,7 @@
# LMCache config for XPU unit tests
# save_unfull_chunk enables storing sequences shorter than chunk_size
chunk_size: 8
local_cpu: true
use_layerwise: true
max_local_cpu_size: 1
save_unfull_chunk: true
@@ -0,0 +1,231 @@
"""
XPU integration tests for LMCRadixCache (IP mode).
Unlike test_lmcache_connector.py, which drives LMCacheLayerwiseConnector
directly, this test builds a real ReqToTokenPool + MHATokenToKVPool +
TokenToKVPoolAllocator and drives LMCRadixCache itself through the request
lifecycle (match_prefix -> cache_finished_req, then evict a fresh cache to
force a real LMCache load-back through match_prefix again). This exercises
LayerTransferCounter.wait_until, _load_back's slot/offset math, and the
store/load stream synchronization that the connector-level tests never touch.
Usage:
python3 -m unittest registered.xpu.test_lmcache_radix_cache
"""
import os
import unittest
from types import SimpleNamespace
import torch
from sglang.test.ci.ci_register import register_xpu_ci
# Must be set before lmcache imports. Save prior values so tearDownModule can
# restore them and avoid leaking into other tests in the same process.
_PATCHED_ENV = {
"LMCACHE_USE_EXPERIMENTAL": "True",
"LMCACHE_CONFIG_FILE": os.path.join(
os.path.dirname(__file__), "test_lmcache_connector_config.yaml"
),
}
_OLD_ENV = {k: os.environ.get(k) for k in _PATCHED_ENV}
os.environ["LMCACHE_USE_EXPERIMENTAL"] = _PATCHED_ENV["LMCACHE_USE_EXPERIMENTAL"]
os.environ.setdefault("LMCACHE_CONFIG_FILE", _PATCHED_ENV["LMCACHE_CONFIG_FILE"])
def tearDownModule():
for key, old_value in _OLD_ENV.items():
if old_value is None:
os.environ.pop(key, None)
else:
os.environ[key] = old_value
try:
import lmcache.integration.sglang.sglang_adapter # noqa: F401
except ImportError:
raise RuntimeError("LMCache is not installed. Install with: pip install lmcache")
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.mem_cache.allocator.token import TokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import EvictParams, MatchPrefixParams
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool, ReqToTokenPool
from sglang.srt.mem_cache.radix_cache import RadixKey
from sglang.srt.mem_cache.storage.lmcache.lmc_radix_cache import LMCRadixCache
from sglang.srt.runtime_context import get_context
XPU_AVAILABLE = hasattr(torch, "xpu") and torch.xpu.is_available()
register_xpu_ci(est_time=60, suite="stage-b-test-1-gpu-xpu")
def _make_req(rid, req_pool_idx, token_ids, tree):
"""Fake Req with the fields LMCRadixCache/RadixCache read (mirrors the
SimpleNamespace pattern in test_swa_eviction_boundary.py)."""
req = SimpleNamespace(
rid=rid,
req_pool_idx=req_pool_idx,
origin_input_ids=token_ids,
output_ids=[],
extra_key=None,
last_node=tree.root_node,
cache_protected_len=0,
priority=0,
kv_committed_freed=False,
kv_committed_len=len(token_ids),
)
req.pop_committed_kv_cache = lambda: len(token_ids)
return req
@unittest.skipUnless(XPU_AVAILABLE, "Intel XPU not available")
class TestLMCRadixCacheXPU(unittest.TestCase):
"""Drive LMCRadixCache (IP mode) through match_prefix/cache_finished_req
with a real KV pool, to cover the code path test_lmcache_connector.py
(which talks to the connector directly) never exercises."""
DEVICE = "xpu:0"
BUFFER_SIZE = 256
MAX_CONTEXT_LEN = 64
INPUT_LEN = 16
@classmethod
def setUpClass(cls):
cls.model_config = ModelConfig(model_path="Qwen/Qwen3-4B")
cls._override = get_context().override_server_args(
lmcache_config_file=os.environ["LMCACHE_CONFIG_FILE"],
speculative_eagle_topk=None,
)
cls._override.install()
@classmethod
def tearDownClass(cls):
cls._override.restore()
def _build_tree(self):
model_config = self.model_config
kv_pool = MHATokenToKVPool(
size=self.BUFFER_SIZE,
page_size=1,
dtype=torch.bfloat16,
head_num=model_config.num_key_value_heads,
head_dim=model_config.head_dim,
layer_num=model_config.num_hidden_layers,
device=self.DEVICE,
enable_memory_saver=False,
)
allocator = TokenToKVPoolAllocator(
size=self.BUFFER_SIZE,
dtype=torch.bfloat16,
device=self.DEVICE,
kvcache=kv_pool,
need_sort=False,
)
req_to_token_pool = ReqToTokenPool(
size=8,
max_context_len=self.MAX_CONTEXT_LEN,
device=self.DEVICE,
enable_memory_saver=False,
)
tree = LMCRadixCache(
params=CacheInitParams(
disable=False,
req_to_token_pool=req_to_token_pool,
token_to_kv_pool_allocator=allocator,
page_size=1,
),
model_config=model_config,
tp_size=1,
rank=0,
)
return tree, allocator, req_to_token_pool, kv_pool
def test_store_then_load_back_through_match_prefix(self):
"""Full lifecycle: match_prefix (miss) -> cache_finished_req (store to
LMCache + insert into radix) -> evict the radix entry -> match_prefix
again must retrieve from LMCache via LayerTransferCounter and restore
the original KV content, proving _load_back's offset/slot math and
the load_stream synchronization are correct end-to-end."""
tree, allocator, req_to_token_pool, kv_pool = self._build_tree()
try:
token_ids = torch.randint(
0, self.model_config.vocab_size, (self.INPUT_LEN,)
).tolist()
# No prior entries: match_prefix should be a full miss.
miss_res = tree.match_prefix(MatchPrefixParams(key=RadixKey(token_ids)))
self.assertEqual(miss_res.device_indices.numel(), 0)
# Allocate KV slots for the request, write ground-truth K/V, then
# commit it as a finished request (inserts into radix + stores to
# LMCache on tree.store_stream).
req_pool_idx = req_to_token_pool.alloc(
[
SimpleNamespace(
req_pool_idx=None, inflight_middle_chunks=0, kv_committed_len=0
)
]
)[0]
kv_slots = allocator.alloc(self.INPUT_LEN)
self.assertIsNotNone(kv_slots)
req_to_token_pool.write((req_pool_idx, slice(0, self.INPUT_LEN)), kv_slots)
gt_k = []
gt_v = []
for layer_id in range(self.model_config.num_hidden_layers):
k = torch.randn(
self.INPUT_LEN,
self.model_config.num_key_value_heads,
self.model_config.head_dim,
dtype=torch.bfloat16,
device=self.DEVICE,
)
v = torch.randn_like(k)
kv_pool.k_buffer[layer_id][kv_slots] = k
kv_pool.v_buffer[layer_id][kv_slots] = v
gt_k.append(k.clone())
gt_v.append(v.clone())
req = _make_req("req-0", req_pool_idx, token_ids, tree)
tree.cache_finished_req(req, kv_len_to_handle=len(token_ids))
# IP-mode store is async on tree.store_stream; evict()'s
# synchronize() is what the real scheduler relies on to make the
# store visible before slots are reused.
tree.evict(EvictParams(num_tokens=0))
# Evict everything from the radix tree so the only remaining copy
# of this KV is inside LMCache, forcing a real load-back on the
# next match_prefix.
tree.evict(EvictParams(num_tokens=self.INPUT_LEN))
self.assertEqual(tree.total_size(), 0)
for layer_id in range(self.model_config.num_hidden_layers):
kv_pool.k_buffer[layer_id].zero_()
kv_pool.v_buffer[layer_id].zero_()
reload_res = tree.match_prefix(MatchPrefixParams(key=RadixKey(token_ids)))
self.assertEqual(reload_res.device_indices.numel(), self.INPUT_LEN)
new_slots = reload_res.device_indices
for layer_id in range(self.model_config.num_hidden_layers):
# get_key_buffer/get_value_buffer (not the raw k_buffer/v_buffer
# list) is what invokes layer_transfer_counter.wait_until —
# the real per-layer forward hook this test exists to cover.
actual_k = kv_pool.get_key_buffer(layer_id)[new_slots]
actual_v = kv_pool.get_value_buffer(layer_id)[new_slots]
self.assertTrue(
torch.allclose(actual_k, gt_k[layer_id]),
f"Layer {layer_id}: K not restored via LMCache load-back",
)
self.assertTrue(
torch.allclose(actual_v, gt_v[layer_id]),
f"Layer {layer_id}: V not restored via LMCache load-back",
)
finally:
tree.lmcache_connector.close()
if __name__ == "__main__":
unittest.main()