# SPDX-License-Identifier: Apache-2.0 # Standard from collections import OrderedDict from copy import deepcopy from unittest.mock import MagicMock, patch import os import random import shlex import subprocess import tempfile import time # Third Party import pytest import torch # First Party from lmcache.utils import ( CacheEngineKey, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) from lmcache.v1.cache_engine import LMCacheEngine, LMCacheEngineBuilder from lmcache.v1.config import LMCacheEngineConfig from lmcache.v1.event_manager import EventStatus, EventType # Local from .utils import ( DummyLMCacheAsyncLookupServer, check_paged_kv_cache_equal, create_gpu_connector, create_test_memory_obj, dumb_metadata, generate_kv_cache_paged_list_tensors, generate_tokens, has_cufile, recover_engine_states, ) # Optional override for tempfile root. In CI we point this at a GDS-capable # host-backed mount (see .buildkite/k3_tests/unit/run.sh); locally it's # unset and tempfile falls back to its default. Direct-I/O-backed paths are # required for GDS tests (cuFile err=5027 on overlayfs/tmpfs). _TEST_TMPDIR = os.environ.get("LMCACHE_TEST_TMPDIR") or None def get_expected_count(token_len, save_unfull_chunk, chunk_size): """Calculate expected token count based on save_unfull_chunk setting. Args: token_len: Total token length save_unfull_chunk: Whether to save partial chunks chunk_size: Chunk size for alignment Returns: If save_unfull_chunk is True, returns token_len as-is. Otherwise, returns chunk-aligned count (rounded down). """ if save_unfull_chunk: return token_len return (token_len // chunk_size) * chunk_size @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_same_retrieve_store(save_unfull_chunk, autorelease_v1): device = "cuda" num_tokens = 2000 num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 chunk_size = 256 kv_shape = (32, 2, chunk_size, 8, 128) connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) original_retrieved_cache = deepcopy(retrieved_cache) slot_mapping = random.sample(range(0, num_blocks * block_size), num_tokens) slot_mapping = torch.tensor(slot_mapping, device=device) # Check the kv cache and the retrieval buffer are not the same check_paged_kv_cache_equal(retrieved_cache, original_retrieved_cache, slot_mapping) with pytest.raises(AssertionError): check_paged_kv_cache_equal(retrieved_cache, kv_cache, slot_mapping) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, remote_url=None, save_unfull_chunk=save_unfull_chunk ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) """ test retrieve empty """ ret_mask = engine.retrieve( tokens, kvcaches=retrieved_cache, slot_mapping=slot_mapping ) recover_engine_states(engine) length = torch.sum(ret_mask) assert length == 0 check_paged_kv_cache_equal(retrieved_cache, original_retrieved_cache, slot_mapping) """ test store """ engine.store(tokens=tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) recover_engine_states(engine) """ Store is async. Need to wait for the store to finish """ expected_count = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) timeout = 1.5 start_time = time.time() while engine.lookup(tokens) < expected_count: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ test retrieve """ ret_mask = engine.retrieve( tokens, kvcaches=retrieved_cache, slot_mapping=slot_mapping ) recover_engine_states(engine) length = torch.sum(ret_mask) assert length == expected_count check_paged_kv_cache_equal(retrieved_cache, kv_cache, slot_mapping[:expected_count]) @pytest.mark.parametrize("chunk_size", [128, 256]) @pytest.mark.parametrize("backend", ["cpu", "local_disk", "remote", "remote_cachegen"]) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.parametrize("lmserver_v1_process", ["cpu"], indirect=True) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_retrieve_prefix( chunk_size, backend, save_unfull_chunk, lmserver_v1_process, autorelease_v1 ): url = None remote_serde = None check_equality = True if "remote" in backend: url = lmserver_v1_process.server_url if backend == "remote_cachegen": backend = "remote" remote_serde = "cachegen" check_equality = False else: remote_serde = "naive" device = "cuda" num_tokens = 2000 new_num_tokens = 1000 kv_shape = (32, 2, chunk_size, 8, 128) num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) new_tokens = generate_tokens(new_num_tokens, device) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) slot_mapping_full = random.sample( range(0, num_blocks * block_size), num_tokens + new_num_tokens ) slot_mapping = torch.tensor(slot_mapping_full[:num_tokens], device=device) new_slot_mapping = torch.tensor(slot_mapping_full[-new_num_tokens:], device=device) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend=backend, remote_url=url, remote_serde=remote_serde, save_unfull_chunk=save_unfull_chunk, ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) """ test store """ t1 = time.perf_counter() engine.store(tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) recover_engine_states(engine) t2 = time.perf_counter() print(f"store {len(tokens)} takes {t2 - t1}") """ Compute expected length """ expected_length = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) """ Store is async. Need to wait for the store to finish """ if backend == "cpu": timeout = 1 search_range = "LocalCPUBackend" elif backend == "local_disk": timeout = 30 search_range = "LocalDiskBackend" elif backend == "remote": timeout = 30 search_range = "RemoteBackend" start_time = time.time() while engine.lookup(tokens, search_range=search_range) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ test retrieve """ # Get actual stored length - may be less than expected if is_last_prefill=False # even when save_unfull_chunk=True actual_stored_tokens = engine.lookup(torch.cat([tokens, new_tokens])) t4 = time.perf_counter() ret_mask = engine.retrieve( torch.cat([tokens, new_tokens]), kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) length = torch.sum(ret_mask) t5 = time.perf_counter() print(f"retrieve {length} takes {t5 - t4}") # Use actual stored length (may be chunk-aligned even if save_unfull_chunk=True # if is_last_prefill=False) assert length == actual_stored_tokens if check_equality: check_paged_kv_cache_equal( kv_cache, retrieved_cache, torch.cat([slot_mapping, new_slot_mapping])[:actual_stored_tokens], ) if backend in ["local_disk"]: subprocess.run(shlex.split("rm -rf local/disk_test/local_disk/")) @pytest.mark.parametrize("chunk_size", [256]) @pytest.mark.parametrize( "backend", ["cpu", "local_disk", "remote"], ) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.parametrize("lmserver_v1_process", ["cpu"], indirect=True) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_store_offset( chunk_size, backend, save_unfull_chunk, lmserver_v1_process, autorelease_v1 ): url = None if backend == "remote": url = lmserver_v1_process.server_url device = "cuda" num_tokens = 2000 num_suffix_tokens = 500 num_total_tokens = 3000 kv_shape = (32, 2, chunk_size, 8, 128) num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_total_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) slot_mapping = random.sample(range(0, num_blocks * block_size), num_total_tokens) slot_mapping = torch.tensor(slot_mapping, device=device) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend=backend, remote_url=url, save_unfull_chunk=save_unfull_chunk, ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) """ test store """ engine.store( tokens[:num_tokens], kvcaches=kv_cache, slot_mapping=slot_mapping[:num_tokens], ) offset_chunk_cnt = num_tokens // chunk_size offset_length = offset_chunk_cnt * chunk_size mask = torch.ones(num_tokens + num_suffix_tokens, device=device) mask[:offset_length] = 0 engine.store( tokens[: num_tokens + num_suffix_tokens], kvcaches=kv_cache, mask=mask, slot_mapping=slot_mapping[: num_tokens + num_suffix_tokens], ) recover_engine_states(engine) """ Compute expected length """ total_tokens = num_tokens + num_suffix_tokens expected_length = (total_tokens // chunk_size) * chunk_size """ Store is async. Need to wait for the store to finish """ if backend == "cpu": timeout = 1 elif backend == "local_disk": timeout = 30 start_time = time.time() while engine.lookup(tokens[: num_tokens + num_suffix_tokens]) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ test retrieve """ t4 = time.perf_counter() ret_mask = engine.retrieve( tokens, kvcaches=retrieved_cache, slot_mapping=slot_mapping ) recover_engine_states(engine) length = torch.sum(ret_mask) t5 = time.perf_counter() print(f"retrieve {length} takes {t5 - t4}") assert length == expected_length check_paged_kv_cache_equal( kv_cache, retrieved_cache, slot_mapping[:expected_length], ) if backend in ["local_disk"]: subprocess.run(shlex.split("rm -rf local/disk_test/local_disk/")) @pytest.mark.parametrize("chunk_size", [128]) # , 256]) @pytest.mark.parametrize( "backend", [ # "cpu", "local_disk" ], ) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_mixed_retrieve(chunk_size, backend, save_unfull_chunk, autorelease_v1): device = "cuda" num_tokens = 2000 new_num_tokens = 1000 num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 kv_shape = (32, 2, chunk_size, 8, 128) connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) new_tokens = generate_tokens(new_num_tokens, device) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) slot_mapping_full = random.sample( range(0, num_blocks * block_size), num_tokens + new_num_tokens ) slot_mapping = torch.tensor(slot_mapping_full[:num_tokens], device=device) new_slot_mapping = torch.tensor(slot_mapping_full[-new_num_tokens:], device=device) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend=backend, save_unfull_chunk=save_unfull_chunk ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) """ test store """ engine.store(tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) engine.store(new_tokens, kvcaches=kv_cache, slot_mapping=new_slot_mapping) recover_engine_states(engine) """ Store is async. Need to wait for the store to finish """ expected_length = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) if backend == "cpu": timeout = 1 search_range = "LocalCPUBackend" elif backend == "local_disk": timeout = 30 search_range = "LocalDiskBackend" start_time = time.time() while engine.lookup(tokens, search_range=search_range) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ test retrieve """ # Check actual stored tokens for the combined tokens # When tokens are stored separately, the total may be chunk-aligned actual_stored_total = engine.lookup( torch.cat([tokens, new_tokens]), search_range=search_range ) ret_mask = engine.retrieve( torch.cat([tokens, new_tokens]), kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) length = torch.sum(ret_mask) # Use actual stored total (may be chunk-aligned even if save_unfull_chunk=True # if is_last_prefill=False) assert length == actual_stored_total check_paged_kv_cache_equal( retrieved_cache, kv_cache, torch.cat([slot_mapping, new_slot_mapping])[:length], ) """Wait for store to finish""" expected_length = get_expected_count(new_num_tokens, save_unfull_chunk, chunk_size) start_time = time.time() while engine.lookup(new_tokens, search_range=search_range) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ test another retrieve """ ret_mask = engine.retrieve( new_tokens, kvcaches=retrieved_cache, slot_mapping=new_slot_mapping ) recover_engine_states(engine) length = torch.sum(ret_mask) assert length == expected_length check_paged_kv_cache_equal( retrieved_cache, kv_cache, new_slot_mapping[:expected_length] ) """ insert the mixed kv cache """ final_tokens = torch.cat([tokens, new_tokens]) engine.store( final_tokens, kvcaches=kv_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) """Wait until store finishes""" expected_length = get_expected_count( num_tokens + new_num_tokens, save_unfull_chunk, chunk_size ) start_time = time.time() while ( engine.lookup(torch.cat([tokens, new_tokens]), search_range=search_range) < expected_length ): if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ should retrieve the mixed version """ retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) ret_mask = engine.retrieve( final_tokens, kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) length = torch.sum(ret_mask) assert length == expected_length # Only check chunk-aligned tokens when save_unfull_chunk=False check_paged_kv_cache_equal( retrieved_cache, kv_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping])[:expected_length], ) """destroy local disk path""" if backend in ["local_disk"]: subprocess.run(shlex.split("rm -rf local/disk_test/local_disk/")) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_store_kv_tensors_mask(save_unfull_chunk, autorelease_v1): device = "cuda" num_tokens = 1000 new_num_tokens = 2000 num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 chunk_size = 256 kv_shape = (32, 2, chunk_size, 8, 128) connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype=dtype ) new_tokens = generate_tokens(new_num_tokens, device) final_tokens = torch.cat([tokens, new_tokens]) slot_mapping_full = random.sample( range(0, num_blocks * block_size), num_tokens + new_num_tokens ) slot_mapping = torch.tensor(slot_mapping_full[:num_tokens], device=device) new_slot_mapping = torch.tensor(slot_mapping_full[-new_num_tokens:], device=device) cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, save_unfull_chunk=save_unfull_chunk ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) """ Store some tokens with mask """ engine.store(tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) recover_engine_states(engine) """Wait until store finishes""" expected_count = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) timeout = 1 start_time = time.time() while engine.lookup(tokens) < expected_count: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) prefix_length = engine.lookup(tokens) assert prefix_length == expected_count, ( f"Expected {expected_count} prefix tokens, but got {prefix_length}" ) """ Store more tokens """ # Re-query prefix_length for final_tokens (original flow) prefix_length = engine.lookup(final_tokens) # Store requires mask False count to be chunk-aligned # When save_unfull_chunk=True, prefix_length may not be chunk-aligned, # so we need to round it down to chunk boundary for the mask num_falses_for_store = (prefix_length // chunk_size) * chunk_size kv_tensor_mask = torch.ones_like(final_tokens, dtype=torch.bool) kv_tensor_mask[:num_falses_for_store] = False engine.store( final_tokens, mask=kv_tensor_mask, kvcaches=kv_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) """Wait until store finishes""" expected_final_count = get_expected_count( num_tokens + new_num_tokens, save_unfull_chunk, chunk_size ) timeout = 1 start_time = time.time() while engine.lookup(final_tokens) < expected_final_count: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) prefix_length = engine.lookup(final_tokens) assert prefix_length == expected_final_count, ( f"Expected {expected_final_count} prefix tokens, but got {prefix_length}" ) """ retrieve the whole cache """ retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype=dtype ) ret_mask = engine.retrieve( final_tokens, kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) length = torch.sum(ret_mask) check_paged_kv_cache_equal( retrieved_cache, kv_cache, torch.cat([slot_mapping, new_slot_mapping])[:length], ) """ retrieve cache with some mask: """ # Retrieve requires mask False count to be chunk-aligned # Original used chunk_size * 3 (768), which is tokens' chunk-aligned length # When save_unfull_chunk=True, we need to ensure chunk alignment num_falses = (num_tokens // chunk_size) * chunk_size mask = torch.ones_like(final_tokens, dtype=torch.bool) mask[:num_falses] = False retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype=dtype ) ret_mask = engine.retrieve( final_tokens, mask=mask, kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) length = torch.sum(ret_mask) full_length = num_tokens + new_num_tokens expected_length = full_length - num_falses # When save_unfull_chunk=False, retrieved length may be chunk-aligned expected_retrieved_length = get_expected_count( expected_length, save_unfull_chunk, chunk_size ) assert length == expected_retrieved_length with pytest.raises(AssertionError): check_paged_kv_cache_equal( retrieved_cache, kv_cache, torch.cat([slot_mapping, new_slot_mapping])[:full_length], ) check_paged_kv_cache_equal( retrieved_cache, kv_cache, torch.cat([slot_mapping, new_slot_mapping])[num_falses : num_falses + length], ) mask[: num_falses + 5] = False with pytest.raises(ValueError): engine.retrieve( final_tokens, mask=mask, kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) @pytest.mark.parametrize("chunk_size", [128]) @pytest.mark.parametrize( "backend", [ "local_cpu_disk_remote", ], ) @pytest.mark.parametrize( "retrieve_from", [ "local_cpu", "local_disk", "remote", ], ) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.parametrize("lmserver_v1_process", ["cpu"], indirect=True) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_hierarchy_retrieve( chunk_size, backend, retrieve_from, save_unfull_chunk, lmserver_v1_process, autorelease_v1, ): url = None if backend == "local_cpu_disk_remote": url = lmserver_v1_process.server_url device = "cuda" num_tokens = 2000 new_num_tokens = 1000 kv_shape = (32, 2, chunk_size, 8, 128) num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype=dtype ) new_tokens = generate_tokens(new_num_tokens, device) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype=dtype ) slot_mapping = random.sample( range(0, num_blocks * block_size), num_tokens + new_num_tokens ) slot_mapping = torch.tensor(slot_mapping[:num_tokens], device=device) new_slot_mapping = torch.tensor(slot_mapping[-new_num_tokens:], device=device) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend=backend, remote_url=url, save_unfull_chunk=save_unfull_chunk, ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) """ test store """ t1 = time.perf_counter() engine.store(tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) recover_engine_states(engine) t2 = time.perf_counter() print(f"store {len(tokens)} takes {t2 - t1}") """ Compute expected length """ expected_length = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) """ Store is async. Need to wait for the store to finish """ timeout = 1 start_time = time.time() while engine.lookup(tokens) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ Wait until disk save is finished """ if retrieve_from in ["local_disk", "remote"]: engine.storage_manager.clear(locations=["LocalCPUBackend"]) timeout = 30 start_time = time.time() while ( engine.lookup(tokens, search_range=["LocalDiskBackend"]) < expected_length ): if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ Wait until remote save is finished """ if retrieve_from == "remote": engine.storage_manager.clear(locations=["LocalCPUBackend"]) # FIXME: change this `clear` engine.storage_manager.storage_backends["LocalDiskBackend"].dict.clear() timeout = 30 start_time = time.time() while engine.lookup(tokens, search_range=["RemoteBackend"]) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ test retrieve """ t4 = time.perf_counter() # Get actual stored length actual_stored = engine.lookup(torch.cat([tokens, new_tokens])) ret_mask = engine.retrieve( torch.cat([tokens, new_tokens]), kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), ) recover_engine_states(engine) length = torch.sum(ret_mask) t5 = time.perf_counter() print(f"retrieve {length} takes {t5 - t4}") # Use actual stored length for assertion assert length == actual_stored check_paged_kv_cache_equal( retrieved_cache, kv_cache, torch.cat([slot_mapping, new_slot_mapping])[:actual_stored], ) """ Wait until disk save is finished before deleting the directory""" if backend in ["local_cpu_disk"]: engine.storage_manager.clear(locations=["LocalCPUBackend"]) timeout = 30 start_time = time.time() while engine.lookup(tokens) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) if backend in ["local_cpu_disk"]: subprocess.run(shlex.split("rm -rf local/disk_test/local_disk/")) @pytest.mark.parametrize( "backend", [ "local_cpu_disk", ], ) @pytest.mark.parametrize( "prefetch_from", [ "local_disk", ], ) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_prefetch_retrieve( backend, prefetch_from, save_unfull_chunk, autorelease_v1 ): device = "cuda" num_tokens = 2000 new_num_tokens = 1000 num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 test_lookup_id = "test_lookup_id" chunk_size = 256 kv_shape = (32, 2, chunk_size, 8, 128) connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype=dtype ) new_tokens = generate_tokens(new_num_tokens, device) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype=dtype ) slot_mapping = random.sample( range(0, num_blocks * block_size), num_tokens + new_num_tokens ) slot_mapping = torch.tensor(slot_mapping[:num_tokens], device=device) new_slot_mapping = torch.tensor(slot_mapping[-new_num_tokens:], device=device) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend=backend, enable_async_loading=True, save_unfull_chunk=save_unfull_chunk, ) async_lookup_server = DummyLMCacheAsyncLookupServer() engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ), async_lookup_server=async_lookup_server, ) """ test store """ t1 = time.perf_counter() engine.store(tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) recover_engine_states(engine) t2 = time.perf_counter() print(f"store {len(tokens)} takes {t2 - t1}") """ Compute expected length """ # For prefetch retrieve, we need to check what was actually stored # Since this test uses async operations, we check the actual lookup result expected_length = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) """ Wait for cpu store to finish """ timeout = 1 start_time = time.time() actual_lookup = engine.lookup(tokens) while actual_lookup < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """ Delete cpu cache and wait until disk save finishes.""" if prefetch_from == "local_disk": engine.storage_manager.clear(locations=["LocalCPUBackend"]) timeout = 30 start_time = time.time() while engine.lookup(tokens) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.1) """ Wait until disk load (prefetch) finishes and delete disk cache""" engine.async_lookup_and_prefetch( lookup_id=test_lookup_id, tokens=torch.cat([tokens, new_tokens]) ) if prefetch_from == "local_disk": timeout = 60 start_time = time.time() while ( engine.event_manager.get_event_status(EventType.LOADING, test_lookup_id) != EventStatus.DONE ): if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) engine.storage_manager.storage_backends["LocalDiskBackend"].dict.clear() """ test retrieve """ t4 = time.perf_counter() # Get actual stored length for retrieve actual_stored = engine.lookup(torch.cat([tokens, new_tokens])) ret_mask = engine.retrieve( torch.cat([tokens, new_tokens])[:actual_stored], kvcaches=retrieved_cache, slot_mapping=torch.cat([slot_mapping, new_slot_mapping]), req_id=test_lookup_id, ) recover_engine_states(engine) length = torch.sum(ret_mask) t5 = time.perf_counter() print(f"retrieve {length} takes {t5 - t4}") assert length == actual_stored check_paged_kv_cache_equal( retrieved_cache, kv_cache, torch.cat([slot_mapping, new_slot_mapping])[:actual_stored], ) if backend in ["local_cpu_disk"]: subprocess.run(shlex.split("rm -rf local/disk_test/local_disk/")) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_async_lookup_and_prefetch_layerwise(autorelease_v1): # Regression: before the fix, async_lookup_and_prefetch sent chunk-level # CacheEngineKey objects into batched_async_contains, but store_layer # populates hot_cache with per-layer LayerCacheEngineKey objects, so every # lookup reported 0 hits. We stage hot_cache the way store_layer would and # assert async_lookup_and_prefetch reports the full token count. chunk_size = 256 num_layers = 4 num_chunks = 3 num_tokens = chunk_size * num_chunks kv_shape = (num_layers, 2, chunk_size, 8, 128) lookup_id = "layerwise-async-1" captured: dict[str, int] = {} class _RecordingAsyncLookupServer: def send_response_to_scheduler( self, lookup_id: str, retrieved_length: int ) -> None: captured[lookup_id] = retrieved_length cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend="cpu", enable_async_loading=True, ) cfg.use_layerwise = True connector = create_gpu_connector(1024, num_layers) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ), async_lookup_server=_RecordingAsyncLookupServer(), ) tokens = generate_tokens(num_tokens, "cuda") chunk_keys = [ key for _, _, key in engine.token_database.process_tokens(tokens=tokens) ] assert len(chunk_keys) == num_chunks # Populate LocalCPUBackend.hot_cache the way store_layer would: one entry # per (chunk, layer) keyed by LayerCacheEngineKey. cpu_backend = engine.storage_manager.storage_backends["LocalCPUBackend"] for chunk_key in chunk_keys: for layer_key in chunk_key.split_layers(num_layers): cpu_backend.submit_put_task(layer_key, create_test_memory_obj()) engine.async_lookup_and_prefetch(lookup_id=lookup_id, tokens=tokens) deadline = time.time() + 10 while ( engine.event_manager.get_event_status(EventType.LOADING, lookup_id) != EventStatus.DONE ): if time.time() > deadline: raise TimeoutError("layerwise async lookup did not finish in time") time.sleep(0.01) assert captured.get(lookup_id) == num_tokens, ( f"Expected retrieved_length={num_tokens}, got {captured.get(lookup_id)}" ) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_async_lookup_and_prefetch_layerwise_partial_layer_missing(autorelease_v1): # When a single per-layer key is missing for one chunk, that chunk must be # rounded down to a miss (the `// keys_per_chunk` round-down path). chunk_size = 256 num_layers = 4 num_chunks = 3 num_tokens = chunk_size * num_chunks kv_shape = (num_layers, 2, chunk_size, 8, 128) lookup_id = "layerwise-async-2" captured: dict[str, int] = {} class _RecordingAsyncLookupServer: def send_response_to_scheduler( self, lookup_id: str, retrieved_length: int ) -> None: captured[lookup_id] = retrieved_length cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend="cpu", enable_async_loading=True, ) cfg.use_layerwise = True connector = create_gpu_connector(1024, num_layers) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ), async_lookup_server=_RecordingAsyncLookupServer(), ) tokens = generate_tokens(num_tokens, "cuda") chunk_keys = [ key for _, _, key in engine.token_database.process_tokens(tokens=tokens) ] # Stage chunks 0 and 1 fully; drop the last per-layer key of chunk 2. cpu_backend = engine.storage_manager.storage_backends["LocalCPUBackend"] for i, chunk_key in enumerate(chunk_keys): per_layer_keys = chunk_key.split_layers(num_layers) if i == len(chunk_keys) - 1: per_layer_keys = per_layer_keys[:-1] for layer_key in per_layer_keys: cpu_backend.submit_put_task(layer_key, create_test_memory_obj()) engine.async_lookup_and_prefetch(lookup_id=lookup_id, tokens=tokens) deadline = time.time() + 10 while ( engine.event_manager.get_event_status(EventType.LOADING, lookup_id) != EventStatus.DONE ): if time.time() > deadline: raise TimeoutError("layerwise async lookup did not finish in time") time.sleep(0.01) # First two chunks are complete; the partially-evicted third chunk is a # miss, so the prefix-match retrieval pattern reports 2 chunks worth. assert captured.get(lookup_id) == 2 * chunk_size, ( f"Expected retrieved_length={2 * chunk_size}, got {captured.get(lookup_id)}" ) @pytest.mark.parametrize("chunk_size", [256]) @pytest.mark.parametrize( "backend", [ "cpu", "local_disk", "remote", "local_disk_remote", "local_cpu_disk_remote", ], ) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.no_shared_allocator @pytest.mark.parametrize("lmserver_v1_process", ["cpu"], indirect=True) @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_mem_leak( chunk_size, backend, save_unfull_chunk, lmserver_v1_process, autorelease_v1 ): url = None if "remote" in backend: url = lmserver_v1_process.server_url device = "cuda" num_tokens = 2000 kv_shape = (32, 2, chunk_size, 8, 128) num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 connector = create_gpu_connector(1024, 32) tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) slot_mapping = random.sample(range(0, num_blocks * block_size), num_tokens) slot_mapping = torch.tensor(slot_mapping, device=device) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend=backend, remote_url=url, save_unfull_chunk=save_unfull_chunk, ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) engine.store(tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) recover_engine_states(engine) expected_length = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) timeout = 30 """Wait until cpu store finishes""" if "cpu" in backend: start_time = time.time() while engine.lookup(tokens, search_range=["LocalCPUBackend"]) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) """Wait until disk store finishes""" if "disk" in backend: start_time = time.time() while ( engine.lookup(tokens, search_range=["LocalDiskBackend"]) < expected_length ): if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) if "remote" in backend: start_time = time.time() while engine.lookup(tokens, search_range=["RemoteBackend"]) < expected_length: if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) tensor_memory_allocator = ( engine.storage_manager.allocator_backend.memory_allocator.pin_allocator ) if "cpu" not in backend: assert tensor_memory_allocator.total_allocated_size == 0 else: assert tensor_memory_allocator.total_allocated_size > 0 if "disk" in backend: subprocess.run(shlex.split("rm -rf local/disk_test/local_disk/")) @pytest.mark.parametrize("chunk_size", [256]) @pytest.mark.parametrize( "backend", [ "cpu", "local_disk", ], ) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.no_shared_allocator @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_paged_retrieve_after_eviction( chunk_size, backend, save_unfull_chunk, autorelease_v1 ): device = "cuda" # NOTE: The default backend cache size is 2 GB. # 10000 tokens ia around 1.3 GB so a second retrieve will cause an eviction. num_tokens = 10000 kv_shape = (32, 2, chunk_size, 8, 128) num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 connector = create_gpu_connector(1024, 32) tokens_1 = generate_tokens(num_tokens, device) tokens_2 = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) slot_mapping_1 = random.sample(range(0, num_blocks * block_size), num_tokens) slot_mapping_1 = torch.tensor(slot_mapping_1, device=device) slot_mapping_2 = random.sample(range(0, num_blocks * block_size), num_tokens) slot_mapping_2 = torch.tensor(slot_mapping_2, device=device) """ initialize the engine """ cfg = LMCacheEngineConfig.from_legacy( chunk_size=chunk_size, backend=backend, save_unfull_chunk=save_unfull_chunk, ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) expected_length = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) engine.store(tokens_1, kvcaches=kv_cache, slot_mapping=slot_mapping_1) recover_engine_states(engine) timeout = 30 if "disk" in backend: start_time = time.time() while ( engine.lookup(tokens_1, search_range=["LocalDiskBackend"]) < expected_length ): if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) engine.store(tokens_2, kvcaches=kv_cache, slot_mapping=slot_mapping_2) recover_engine_states(engine) """Wait until cpu store finishes""" if "cpu" in backend: start_time = time.time() while ( engine.lookup(tokens_2, search_range=["LocalCPUBackend"]) < expected_length ): if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) assert ( engine.lookup(tokens_1, search_range=["LocalCPUBackend"]) < expected_length ) """Wait until disk store finishes""" if "disk" in backend: start_time = time.time() while ( engine.lookup(tokens_2, search_range=["LocalDiskBackend"]) < expected_length ): if time.time() - start_time > timeout: raise TimeoutError(f"Operation timed out after {timeout} seconds.") time.sleep(0.01) assert ( engine.lookup(tokens_1, search_range=["LocalDiskBackend"]) < expected_length ) ret_mask = engine.retrieve( tokens_1, kvcaches=retrieved_cache, slot_mapping=slot_mapping_1, ) recover_engine_states(engine) length = torch.sum(ret_mask) assert length < num_tokens ret_mask = engine.retrieve( tokens_2, kvcaches=retrieved_cache, slot_mapping=slot_mapping_2, ) recover_engine_states(engine) length = torch.sum(ret_mask) assert length == expected_length if backend in ["local_disk"]: subprocess.run(shlex.split("rm -rf local/disk_test/local_disk/")) def test_builder(autorelease_v1): instance_id = "test" cfg = LMCacheEngineConfig.from_legacy(chunk_size=256) cfg2 = LMCacheEngineConfig.from_legacy(chunk_size=512) connector = None should_be_none = LMCacheEngineBuilder.get(instance_id) assert should_be_none is None _engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( instance_id, cfg, dumb_metadata(), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) _engine2 = autorelease_v1(LMCacheEngineBuilder.get(instance_id)) # noqa with pytest.raises(ValueError): LMCacheEngineBuilder.get_or_create( instance_id, cfg2, dumb_metadata(), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) @pytest.mark.no_shared_allocator @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_force_store_wait(autorelease_v1): device = "cuda" num_tokens = 10000 num_blocks = 5000 block_size = 16 dtype = torch.bfloat16 chunk_size = 256 kv_shape = (32, 2, chunk_size, 8, 128) connector = create_gpu_connector(1024, 32) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) num_requests = 8 def generate_random_slot_mapping(num_blocks, block_size, num_tokens, device): slot_mapping = random.sample(range(0, num_blocks * block_size), num_tokens) return torch.tensor(slot_mapping, device=device) list_tokens = [generate_tokens(num_tokens, device) for _ in range(num_requests)] list_slot_mappings = [ generate_random_slot_mapping(num_blocks, block_size, num_tokens, device) for _ in range(num_requests) ] with tempfile.TemporaryDirectory( dir=_TEST_TMPDIR, ignore_cleanup_errors=True ) as temp_dir: cfg = LMCacheEngineConfig.from_defaults( local_cpu=False, max_local_cpu_size=2, # small cpu buffer local_disk=temp_dir, max_local_disk_size=20, extra_config={"force_store_wait": True}, ) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) # Store kv cache into slow devices for t, s in zip(list_tokens, list_slot_mappings, strict=False): engine.store(t, kvcaches=kv_cache, slot_mapping=s) # Sleep 10 seconds for the last request time.sleep(20) # No KV cache should be skipped # With default save_unfull_chunk=False, we expect chunk-aligned count chunk_size = 256 for t in list_tokens: expected_count = (len(t) // chunk_size) * chunk_size assert engine.lookup(t) == expected_count @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_builder_destroy(autorelease_v1): """Test the destroy method of LMCacheEngineBuilder""" instance_id = "test_destroy" cfg = LMCacheEngineConfig.from_legacy(chunk_size=256) connector = create_gpu_connector(1024, 32) # Verify instance doesn't exist initially should_be_none = LMCacheEngineBuilder.get(instance_id) assert should_be_none is None # Create an engine instance engine = LMCacheEngineBuilder.get_or_create( instance_id, cfg, dumb_metadata(), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) # Verify instance exists retrieved_engine = LMCacheEngineBuilder.get(instance_id) assert retrieved_engine is not None assert retrieved_engine is engine # Verify internal state is populated assert instance_id in LMCacheEngineBuilder._instances assert instance_id in LMCacheEngineBuilder._cfgs assert instance_id in LMCacheEngineBuilder._metadatas assert instance_id in LMCacheEngineBuilder._stat_loggers # Destroy the instance LMCacheEngineBuilder.destroy(instance_id) # Verify instance is completely removed should_be_none_after_destroy = LMCacheEngineBuilder.get(instance_id) assert should_be_none_after_destroy is None # Verify all internal state is cleaned up assert instance_id not in LMCacheEngineBuilder._instances assert instance_id not in LMCacheEngineBuilder._cfgs assert instance_id not in LMCacheEngineBuilder._metadatas assert instance_id not in LMCacheEngineBuilder._stat_loggers # Verify destroying non-existent instance doesn't raise error LMCacheEngineBuilder.destroy("non_existent_id") # Should not raise @pytest.mark.skipif( not torch.cuda.is_available(), reason="TODO: Add non-CUDA implementation to VLLMPagedMemGPUConnectorV2", ) def test_builder_destroy_multiple_instances(autorelease_v1): """Test destroying one instance doesn't affect others""" instance_id1 = "test_destroy_1" instance_id2 = "test_destroy_2" cfg = LMCacheEngineConfig.from_legacy(chunk_size=256) connector = create_gpu_connector(1024, 32) # Create two engine instances engine1 = LMCacheEngineBuilder.get_or_create( instance_id1, cfg, dumb_metadata(), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) engine2 = LMCacheEngineBuilder.get_or_create( instance_id2, cfg, dumb_metadata(), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) # Verify both instances exist assert LMCacheEngineBuilder.get(instance_id1) is engine1 assert LMCacheEngineBuilder.get(instance_id2) is engine2 # Destroy only the first instance LMCacheEngineBuilder.destroy(instance_id1) # Verify first instance is destroyed but second remains assert LMCacheEngineBuilder.get(instance_id1) is None assert LMCacheEngineBuilder.get(instance_id2) is engine2 # Verify internal state for first instance is cleaned up assert instance_id1 not in LMCacheEngineBuilder._instances assert instance_id1 not in LMCacheEngineBuilder._cfgs assert instance_id1 not in LMCacheEngineBuilder._metadatas assert instance_id1 not in LMCacheEngineBuilder._stat_loggers # Verify internal state for second instance remains assert instance_id2 in LMCacheEngineBuilder._instances assert instance_id2 in LMCacheEngineBuilder._cfgs assert instance_id2 in LMCacheEngineBuilder._metadatas assert instance_id2 in LMCacheEngineBuilder._stat_loggers # Clean up second instance LMCacheEngineBuilder.destroy(instance_id2) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) @pytest.mark.skipif( not torch.cuda.is_available(), reason="Requires CUDA for test_multi_device_backends", ) @pytest.mark.skipif( not has_cufile(), reason="Requires NVIDIA cuFile (libcufile.so). " "Skipping on systems without GDS/cuFile (e.g., AMD ROCm).", ) def test_multi_device_backends(save_unfull_chunk, autorelease_v1): """Test running GPU-related backend with local CPU backends together """ device = "cuda" num_tokens = 2000 chunk_size = 256 # Default chunk size for this test num_blocks = 1000 block_size = 16 dtype = torch.bfloat16 connector = create_gpu_connector(1024, 32) metadata = dumb_metadata() metadata.model_name = "test-model" # NOTE: Gds does not accept name with '_' tokens = generate_tokens(num_tokens, device) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) retrieved_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype ) original_retrieved_cache = deepcopy(retrieved_cache) slot_mapping = random.sample(range(0, num_blocks * block_size), num_tokens) slot_mapping = torch.tensor(slot_mapping, device=device) # Check the kv cache and the retrieval buffer are not the same check_paged_kv_cache_equal(retrieved_cache, original_retrieved_cache, slot_mapping) with pytest.raises(AssertionError): check_paged_kv_cache_equal(retrieved_cache, kv_cache, slot_mapping) with tempfile.TemporaryDirectory( dir=_TEST_TMPDIR, ignore_cleanup_errors=True ) as temp_dir: cfg = LMCacheEngineConfig.from_dict( { "local_cpu": True, "max_local_cpu_size": 5, "gds_path": temp_dir, "gds_buffer_size": 1024, "save_unfull_chunk": save_unfull_chunk, "extra_config": { "use_direct_io": True, }, } ) connector = create_gpu_connector(1024, 32) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "engine", cfg, metadata, connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) """ test store """ engine.store(tokens=tokens, kvcaches=kv_cache, slot_mapping=slot_mapping) recover_engine_states(engine) time.sleep(3) # wait a bit to finish the store """ Test lookup """ expected_count = get_expected_count(len(tokens), save_unfull_chunk, chunk_size) ret = engine.lookup(tokens) assert ret == expected_count ret_cpu = engine.lookup(tokens, search_range=["LocalCPUBackend"]) assert ret_cpu == expected_count ret_gds = engine.lookup(tokens, search_range=["GdsBackend"]) assert ret_gds == expected_count """ Test retrieve """ ret_mask = engine.retrieve( tokens, kvcaches=retrieved_cache, slot_mapping=slot_mapping ) recover_engine_states(engine) length = torch.sum(ret_mask) assert length == expected_count # Only check chunk-aligned tokens when save_unfull_chunk=False check_paged_kv_cache_equal( retrieved_cache, kv_cache, slot_mapping[:expected_count] ) LMCacheEngineBuilder.destroy("engine") def _make_key(chunk_hash: int) -> CacheEngineKey: """Create a CacheEngineKey for testing.""" return CacheEngineKey("test", 1, 0, chunk_hash, torch.bfloat16) def _make_mock_memory_obj(size: int = 1024) -> MagicMock: """Create a mock MemoryObj that tracks ref_count_down calls.""" mock = MagicMock() mock.get_size.return_value = size return mock def _make_mock_engine( process_tokens_results: list, block_mapping: dict, batched_get_side_effect: list, ) -> MagicMock: """Create a mock engine with the attributes needed by _process_tokens_internal. Args: process_tokens_results: list of (start, end, key) tuples that token_database.process_tokens will yield. block_mapping: dict returned by storage_manager.get_block_mapping. batched_get_side_effect: list of return values for successive storage_manager.batched_get calls (one per location). Returns: A MagicMock configured as a minimal LMCacheEngine. """ engine = MagicMock() engine.token_database.process_tokens.return_value = process_tokens_results engine.storage_manager.get_block_mapping.return_value = block_mapping engine.storage_manager.batched_get.side_effect = batched_get_side_effect engine.lookup_pins = {} return engine def test_process_tokens_single_location_boundary_failure(): """The block whose end equals last_failed_block_start covers [start, last_failed_block_start) — entirely before the gap — and must be kept.""" k0, k1 = _make_key(0), _make_key(1) mem0 = _make_mock_memory_obj() engine = _make_mock_engine( process_tokens_results=[(0, 10, k0), (10, 20, k1)], block_mapping=OrderedDict( [ ("LocationA", [(k0, 0, 10), (k1, 10, 20)]), ] ), batched_get_side_effect=[[mem0, None]], ) ret_mask = torch.zeros(20, dtype=torch.bool) chunks, tot_kv_size = LMCacheEngine._process_tokens_internal( engine, torch.zeros(20, dtype=torch.long), None, ret_mask ) assert len(chunks) == 1 assert chunks[0][0] == k0 assert chunks[0][1] is mem0 assert tot_kv_size == 1024 assert ret_mask[:10].all() assert not ret_mask[10:].any() mem0.ref_count_down.assert_not_called() def test_process_tokens_early_failure_truncates_later_location(): """When an early location fails, blocks successfully retrieved from a later location (covering higher positions) must be discarded and freed because they are past the gap.""" k0, k1 = _make_key(0), _make_key(1) k2, k3 = _make_key(2), _make_key(3) mem0 = _make_mock_memory_obj() mem2 = _make_mock_memory_obj() mem3 = _make_mock_memory_obj() engine = _make_mock_engine( process_tokens_results=[ (0, 10, k0), (10, 20, k1), (20, 30, k2), (30, 40, k3), ], block_mapping=OrderedDict( [ ("LocationA", [(k0, 0, 10), (k1, 10, 20)]), ("LocationB", [(k2, 20, 30), (k3, 30, 40)]), ] ), batched_get_side_effect=[ [mem0, None], [mem2, mem3], ], ) ret_mask = torch.zeros(40, dtype=torch.bool) chunks, tot_kv_size = LMCacheEngine._process_tokens_internal( engine, torch.zeros(40, dtype=torch.long), None, ret_mask ) assert len(chunks) == 1 assert chunks[0][0] == k0 assert tot_kv_size == 1024 assert ret_mask[:10].all() assert not ret_mask[10:].any() mem0.ref_count_down.assert_not_called() mem2.ref_count_down.assert_called_once() mem3.ref_count_down.assert_called_once() def test_process_tokens_multi_location_both_fail_takes_min(): """When failures occur in multiple locations, the earliest failure start (MIN) should be used so that everything after the first gap is discarded.""" k0, k1 = _make_key(0), _make_key(1) k2, k3 = _make_key(2), _make_key(3) mem0 = _make_mock_memory_obj() mem2 = _make_mock_memory_obj() engine = _make_mock_engine( process_tokens_results=[ (0, 10, k0), (10, 20, k1), (20, 30, k2), (30, 40, k3), ], block_mapping=OrderedDict( [ ("LocationA", [(k0, 0, 10), (k1, 10, 20)]), ("LocationB", [(k2, 20, 30), (k3, 30, 40)]), ] ), batched_get_side_effect=[ [mem0, None], [mem2, None], ], ) ret_mask = torch.zeros(40, dtype=torch.bool) chunks, tot_kv_size = LMCacheEngine._process_tokens_internal( engine, torch.zeros(40, dtype=torch.long), None, ret_mask ) assert len(chunks) == 1 assert chunks[0][0] == k0 assert tot_kv_size == 1024 assert ret_mask[:10].all() assert not ret_mask[10:].any() mem0.ref_count_down.assert_not_called() mem2.ref_count_down.assert_called_once() def test_process_tokens_no_failure(): """When all blocks are retrieved successfully, every chunk should be returned and no ref_count_down should be called.""" k0, k1 = _make_key(0), _make_key(1) mem0 = _make_mock_memory_obj() mem1 = _make_mock_memory_obj() engine = _make_mock_engine( process_tokens_results=[(0, 10, k0), (10, 20, k1)], block_mapping=OrderedDict( [ ("LocationA", [(k0, 0, 10), (k1, 10, 20)]), ] ), batched_get_side_effect=[[mem0, mem1]], ) ret_mask = torch.zeros(20, dtype=torch.bool) chunks, tot_kv_size = LMCacheEngine._process_tokens_internal( engine, torch.zeros(20, dtype=torch.long), None, ret_mask ) assert len(chunks) == 2 assert tot_kv_size == 2048 assert ret_mask[:20].all() mem0.ref_count_down.assert_not_called() mem1.ref_count_down.assert_not_called() def test_process_tokens_unused_keys_no_double_free(): """A key returned non-None by batched_get but coming after a None (unused) should be freed exactly once in the per-location cleanup and never again in post-processing.""" k0, k1, k2 = _make_key(0), _make_key(1), _make_key(2) mem0 = _make_mock_memory_obj() mem2 = _make_mock_memory_obj() engine = _make_mock_engine( process_tokens_results=[(0, 10, k0), (10, 20, k1), (20, 30, k2)], block_mapping=OrderedDict( [ ("LocationA", [(k0, 0, 10), (k1, 10, 20), (k2, 20, 30)]), ] ), batched_get_side_effect=[[mem0, None, mem2]], ) ret_mask = torch.zeros(30, dtype=torch.bool) chunks, tot_kv_size = LMCacheEngine._process_tokens_internal( engine, torch.zeros(30, dtype=torch.long), None, ret_mask ) assert len(chunks) == 1 assert chunks[0][0] == k0 assert tot_kv_size == 1024 assert ret_mask[:10].all() assert not ret_mask[10:].any() mem0.ref_count_down.assert_not_called() mem2.ref_count_down.assert_called_once() def test_process_tokens_first_block_fails(): """When the very first block fails, no chunks should be returned and ret_mask should be all False.""" k0, k1 = _make_key(0), _make_key(1) mem1 = _make_mock_memory_obj() engine = _make_mock_engine( process_tokens_results=[(0, 10, k0), (10, 20, k1)], block_mapping=OrderedDict( [ ("LocationA", [(k0, 0, 10), (k1, 10, 20)]), ] ), batched_get_side_effect=[[None, mem1]], ) ret_mask = torch.zeros(20, dtype=torch.bool) chunks, tot_kv_size = LMCacheEngine._process_tokens_internal( engine, torch.zeros(20, dtype=torch.long), None, ret_mask ) assert len(chunks) == 0 assert tot_kv_size == 0 assert not ret_mask.any() mem1.ref_count_down.assert_called_once() def test_compress_decompress_unpin_not_pinned() -> None: """Verify that compress and decompress check is_pinned before calling unpin() This prevents negative pin counts and double unpin warnings on backends like Remote/S3. """ # Create mock memory objects mock_mem_obj = MagicMock() mock_mem_obj.is_pinned = False # Not pinned (e.g. from Remote/S3 backend) mock_compressed_mem_obj = MagicMock() mock_compressed_mem_obj.is_pinned = False # Not pinned # Mock serializer and deserializer mock_serializer = MagicMock() mock_serializer.serialize.return_value = mock_compressed_mem_obj mock_deserializer = MagicMock() mock_deserializer.deserialize.return_value = mock_mem_obj # Create a mock engine engine = MagicMock(spec=LMCacheEngine) engine.metadata = MagicMock() engine.config = MagicMock() engine.lookup_pins = {"event_123": {"remote": ["key1"]}} # Mock engine.lookup to return number of tokens (non-zero) engine.lookup.return_value = 100 # Mock storage_manager methods engine.storage_manager = MagicMock() # For compress: batched_get returns mock_mem_obj engine.storage_manager.batched_get.return_value = [mock_mem_obj] with patch( "lmcache.v1.storage_backend.naive_serde.CreateSerde", return_value=(mock_serializer, mock_deserializer), ): # Call compress on the engine res = LMCacheEngine.compress( engine, tokens=[1, 2, 3], method="cachegen", location="remote", event_id="event_123", ) assert res == 100 # Verify that serialize was called mock_serializer.serialize.assert_called_once_with(mock_mem_obj) # Verify that unpin was NOT called since is_pinned = False mock_mem_obj.unpin.assert_not_called() # Verify batched_remove and batched_put were called on storage_manager engine.storage_manager.batched_remove.assert_called_once_with( ["key1"], locations=["remote"] ) engine.storage_manager.batched_put.assert_called_once_with( keys=["key1"], memory_objs=[mock_compressed_mem_obj], location="remote", ) # Reset storage_manager mock engine.storage_manager.reset_mock() # For decompress: batched_get returns mock_compressed_mem_obj engine.storage_manager.batched_get.return_value = [mock_compressed_mem_obj] with patch( "lmcache.v1.storage_backend.naive_serde.CreateSerde", return_value=(mock_serializer, mock_deserializer), ): res_decomp = LMCacheEngine.decompress( engine, tokens=[1, 2, 3], method="cachegen", location="remote", event_id="event_123", ) assert res_decomp == 100 # Verify that deserialize was called mock_deserializer.deserialize.assert_called_once_with(mock_compressed_mem_obj) # Verify that unpin was NOT called since is_pinned = False mock_compressed_mem_obj.unpin.assert_not_called() # Verify batched_remove and batched_put were called on storage_manager engine.storage_manager.batched_remove.assert_called_once_with( ["key1"], locations=["remote"] ) engine.storage_manager.batched_put.assert_called_once_with( keys=["key1"], memory_objs=[mock_mem_obj], location="remote", ) def test_compress_decompress_unpin_when_pinned() -> None: """Verify that compress and decompress call unpin() if is_pinned is True""" # Create mock memory objects mock_mem_obj = MagicMock() mock_mem_obj.is_pinned = True mock_compressed_mem_obj = MagicMock() mock_compressed_mem_obj.is_pinned = True # Mock serializer and deserializer mock_serializer = MagicMock() mock_serializer.serialize.return_value = mock_compressed_mem_obj mock_deserializer = MagicMock() mock_deserializer.deserialize.return_value = mock_mem_obj # Create a mock engine engine = MagicMock(spec=LMCacheEngine) engine.metadata = MagicMock() engine.config = MagicMock() engine.lookup_pins = {"event_123": {"local_cpu": ["key1"]}} engine.lookup.return_value = 100 engine.storage_manager = MagicMock() # Test compress engine.storage_manager.batched_get.return_value = [mock_mem_obj] with patch( "lmcache.v1.storage_backend.naive_serde.CreateSerde", return_value=(mock_serializer, mock_deserializer), ): LMCacheEngine.compress( engine, tokens=[1, 2, 3], method="cachegen", location="local_cpu", event_id="event_123", ) mock_mem_obj.unpin.assert_called_once() # Test decompress engine.storage_manager.reset_mock() engine.storage_manager.batched_get.return_value = [mock_compressed_mem_obj] with patch( "lmcache.v1.storage_backend.naive_serde.CreateSerde", return_value=(mock_serializer, mock_deserializer), ): LMCacheEngine.decompress( engine, tokens=[1, 2, 3], method="cachegen", location="local_cpu", event_id="event_123", ) mock_compressed_mem_obj.unpin.assert_called_once() def test_retrieve_cleanup_ref_count_and_unpin() -> None: """Verify that retrieve() unpins and ref_count_downs all retrieved chunks. Specifically in the else branch when remove_after_retrieve is False. """ # Create mock memory objects mem_obj_pinned = MagicMock() mem_obj_pinned.is_pinned = True mem_obj_not_pinned = MagicMock() mem_obj_not_pinned.is_pinned = False # Mock engine instance engine = MagicMock(spec=LMCacheEngine) engine.is_healthy.return_value = True engine.remove_after_retrieve = False engine._is_passive.return_value = False engine.save_only_first_rank = False engine._get_req_id.return_value = "req_123" # We want retrieve to process chunks k0 = _make_key(0) k1 = _make_key(1) reordered_chunks = [ (k0, mem_obj_pinned, 0, 10), (k1, mem_obj_not_pinned, 10, 20), ] engine.async_loading = False engine._process_tokens_internal.return_value = (reordered_chunks, 1024) engine._is_sync_pd_backend.return_value = False # Mock stats monitor engine.stats_monitor = MagicMock() mock_stats = MagicMock() mock_stats.time_to_retrieve.return_value = 1.0 engine.stats_monitor.on_retrieve_request.return_value = mock_stats # Mock gpu_connector engine.gpu_connector = MagicMock() # Call retrieve tokens = torch.zeros(20, dtype=torch.long) kvcaches = torch.zeros(20) slot_mapping = torch.zeros(20, dtype=torch.long) LMCacheEngine.retrieve( engine, tokens=tokens, kvcaches=kvcaches, slot_mapping=slot_mapping, ) # Assertions mem_obj_pinned.ref_count_down.assert_called_once() mem_obj_pinned.unpin.assert_called_once() mem_obj_not_pinned.ref_count_down.assert_called_once() mem_obj_not_pinned.unpin.assert_not_called()