项目文件夹

文件
2026-07-13 12:24:33 +08:00

789 行
26 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from unittest.mock import MagicMock, patch
import asyncio
import mmap
import threading
# Third Party
import pytest
import torch
# First Party
from lmcache.utils import CacheEngineKey
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.memory_management import MemoryFormat, TensorMemoryObj
from lmcache.v1.pin_monitor import PinMonitor
from lmcache.v1.storage_backend.abstract_backend import AllocatorBackendInterface
from tests.v1.utils import (
check_method_signatures,
get_abstract_methods,
get_methods_implemented_in_class,
)
maru = pytest.importorskip("maru", reason="maru package not installed")
maru_lmcache = pytest.importorskip(
"maru_lmcache", reason="maru_lmcache package not installed"
)
# Third Party
from maru_handler.memory import AllocHandle # noqa: E402
from maru_handler.memory.types import MappedRegion, MemoryInfo # noqa: E402
from maru_lmcache.adapter import CxlMemoryAdapter # noqa: E402
# First Party
from lmcache.v1.storage_backend.maru_backend import MaruBackend # noqa: E402
# =========================================================================
# Constants
# =========================================================================
TEST_CHUNK_SIZE = 1024
TEST_DTYPE = torch.float32
TEST_SHAPE = torch.Size([256]) # 256 * 4B = 1024 bytes = chunk_size
# =========================================================================
# Helpers
# =========================================================================
def _make_mock_handler(pool_size=4096, chunk_size=TEST_CHUNK_SIZE):
"""Create a mock MaruHandler with mmap-backed regions."""
handler = MagicMock()
handler._connected = True
region_id = 100
page_count = pool_size // chunk_size
mmap_obj = mmap.mmap(-1, pool_size)
mapped_region = MappedRegion(
region_id=region_id,
handle=MagicMock(region_id=region_id, length=pool_size),
size=pool_size,
_mmap_obj=mmap_obj,
)
handler.get_buffer_view.side_effect = lambda rid, offset, size: (
mapped_region.get_buffer_view(offset, size) if rid == region_id else None
)
handler.get_region_page_count.side_effect = lambda rid: (
page_count if rid == region_id else None
)
handler.get_owned_region_ids.return_value = [region_id]
handler.get_chunk_size.return_value = chunk_size
def mock_set_on_region_added(callback):
if callback is not None:
callback(region_id, page_count)
handler.set_on_region_added.side_effect = mock_set_on_region_added
page_counter = [0]
def mock_alloc(size):
idx = page_counter[0]
page_counter[0] += 1
buf = mapped_region.get_buffer_view(idx * chunk_size, size)
return AllocHandle(buf=buf, _region_id=region_id, _page_index=idx, _size=size)
handler.alloc.side_effect = mock_alloc
handler.free = MagicMock()
handler.connect.return_value = True
handler.close.return_value = None
handler.store.return_value = True
handler.batch_store.return_value = None
handler.retrieve.return_value = None
handler.batch_retrieve.return_value = []
handler.exists.return_value = False
handler.batch_exists.return_value = []
handler.delete.return_value = True
handler.pin.return_value = True
handler.unpin.return_value = True
handler.batch_pin.return_value = []
handler.batch_unpin.return_value = None
return handler
def _make_cache_key(chunk_hash: int = 12345) -> CacheEngineKey:
"""Create a CacheEngineKey for testing."""
return CacheEngineKey(
model_name="test-model",
world_size=1,
worker_id=0,
chunk_hash=chunk_hash,
dtype=torch.float32,
)
def _make_memory_obj(adapter: CxlMemoryAdapter) -> TensorMemoryObj:
"""Allocate a TensorMemoryObj from the adapter."""
obj = adapter.allocate(TEST_SHAPE, TEST_DTYPE)
assert obj is not None
return obj
# =========================================================================
# Fixtures
# =========================================================================
@pytest.fixture(autouse=True)
def _init_pin_monitor():
"""Initialize PinMonitor singleton required by TensorMemoryObj.pin()."""
PinMonitor._instance = None
PinMonitor.GetOrCreate(LMCacheEngineConfig.from_defaults())
yield
PinMonitor._instance = None
@pytest.fixture
def async_loop():
"""Provide an asyncio event loop running in a background thread."""
loop = asyncio.new_event_loop()
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
yield loop
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=5)
loop.close()
@pytest.fixture
def mock_handler():
return _make_mock_handler()
@pytest.fixture
def adapter(mock_handler):
return CxlMemoryAdapter(
handler=mock_handler,
shapes=[TEST_SHAPE],
dtypes=[TEST_DTYPE],
fmt=MemoryFormat.KV_2LTD,
chunk_size=TEST_CHUNK_SIZE,
)
@pytest.fixture
def backend(mock_handler, adapter, async_loop):
"""Create a MaruBackend with mocked internals."""
# Local
with patch.object(MaruBackend, "initialize_allocator", return_value=adapter):
backend = MaruBackend.__new__(MaruBackend)
backend.dst_device = "cpu"
backend.config = MagicMock()
backend.config.maru_pool_size = 4.0
backend.loop = async_loop
backend.memory_allocator = adapter
backend._handler = mock_handler
backend._full_chunk_size_bytes = TEST_CHUNK_SIZE
backend._single_token_size = TEST_CHUNK_SIZE // 256 # 4 bytes per token
backend._mla_worker_id_as0_mode = False
backend.put_lock = threading.Lock()
backend.put_tasks = set()
return backend
def _run_async(loop, coro):
"""Submit a coroutine to a running event loop and wait for result."""
future = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result(timeout=5)
# =========================================================================
# Tests — Init & Interface Compliance
# =========================================================================
class TestMaruBackendInit:
def test_str(self, backend):
assert str(backend) == "MaruBackend"
def test_get_allocator_backend_returns_self(self, backend):
assert backend.get_allocator_backend() is backend
def test_get_memory_allocator_returns_adapter(self, backend, adapter):
assert backend.get_memory_allocator() is adapter
class TestMaruBackendPoolSizeGbToBytes:
"""Test _pool_size_gb_to_bytes static method."""
def test_4gb(self):
assert MaruBackend._pool_size_gb_to_bytes(4.0) == 4 * 1024**3
def test_half_gb(self):
assert MaruBackend._pool_size_gb_to_bytes(0.5) == 512 * 1024**2
def test_1gb(self):
assert MaruBackend._pool_size_gb_to_bytes(1.0) == 1024**3
def test_zero(self):
assert MaruBackend._pool_size_gb_to_bytes(0.0) == 0
class TestMaruBackendInterfaceCompliance:
"""Verify MaruBackend implements all required interface methods."""
def test_implements_all_abstract_methods(self):
abstract = get_abstract_methods(AllocatorBackendInterface)
implemented = get_methods_implemented_in_class(
MaruBackend, AllocatorBackendInterface
)
missing = abstract - implemented
assert not missing, f"Missing abstract methods: {missing}"
def test_method_signatures_match(self):
# Known: batched_submit_put_task uses 'memory_objs' instead of 'objs'
# TODO: Rename to 'objs' for full compliance
known_param_renames = {"batched_submit_put_task"}
mismatches = check_method_signatures(AllocatorBackendInterface, MaruBackend)
unexpected = [m for m in mismatches if m["method"] not in known_param_renames]
assert not unexpected, f"Signature mismatches: {unexpected}"
# =========================================================================
# Tests — Allocate
# =========================================================================
class TestMaruBackendAllocate:
def test_allocate_returns_memory_obj(self, backend):
obj = backend.allocate(TEST_SHAPE, TEST_DTYPE)
assert obj is not None
assert obj.tensor is not None
assert obj.metadata.dtype == TEST_DTYPE
def test_batched_allocate_returns_list(self, backend):
objs = backend.batched_allocate(TEST_SHAPE, TEST_DTYPE, batch_size=3)
assert objs is not None
assert len(objs) == 3
for obj in objs:
assert obj.tensor is not None
# =========================================================================
# Tests — Put (async)
# =========================================================================
class TestMaruBackendPut:
def test_submit_put_task_returns_future(self, backend, adapter):
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
key = _make_cache_key()
future = backend.submit_put_task(key, obj)
assert future is not None
future.result(timeout=5)
backend._handler.store.assert_called_once()
def test_submit_put_task_tracks_in_flight(self, backend, adapter):
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
key = _make_cache_key()
assert not backend.exists_in_put_tasks(key)
future = backend.submit_put_task(key, obj)
future.result(timeout=5)
# After completion, key should be removed from put_tasks
assert not backend.exists_in_put_tasks(key)
def test_exists_in_put_tasks_true_during_store(self, backend, adapter):
"""Verify exists_in_put_tasks returns True while store is in progress."""
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
key = _make_cache_key()
store_entered = threading.Event()
store_proceed = threading.Event()
def blocking_store(*args, **kwargs):
store_entered.set()
store_proceed.wait(timeout=5)
return True
backend._handler.store.side_effect = blocking_store
future = backend.submit_put_task(key, obj)
# Wait until store is actually running
assert store_entered.wait(timeout=5)
assert backend.exists_in_put_tasks(key)
# Let store complete
store_proceed.set()
future.result(timeout=5)
assert not backend.exists_in_put_tasks(key)
def test_batched_submit_put_task(self, backend, adapter):
keys = [_make_cache_key(i) for i in range(3)]
objs = [_make_memory_obj(adapter) for _ in range(3)]
for obj in objs:
obj.parent_allocator = None
backend._handler.batch_store.return_value = [True, True, True]
futures = backend.batched_submit_put_task(keys, objs)
assert futures is not None
for future in futures:
future.result(timeout=5)
backend._handler.batch_store.assert_called_once()
def test_submit_put_calls_callback(self, backend, adapter):
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
key = _make_cache_key()
callback_called = []
def callback(k):
callback_called.append(k)
future = backend.submit_put_task(key, obj, on_complete_callback=callback)
future.result(timeout=5)
assert len(callback_called) == 1
assert callback_called[0] == key
def test_batched_submit_put_calls_callback_per_key(self, backend, adapter):
keys = [_make_cache_key(i) for i in range(3)]
objs = [_make_memory_obj(adapter) for _ in range(3)]
for obj in objs:
obj.parent_allocator = None
backend._handler.batch_store.return_value = [True, True, True]
callback_keys = []
def callback(k):
callback_keys.append(k)
futures = backend.batched_submit_put_task(
keys, objs, on_complete_callback=callback
)
for future in futures:
future.result(timeout=5)
assert set(callback_keys) == set(keys)
def test_submit_put_task_skips_in_mla_mode(self, backend, adapter):
"""In MLA worker_id_as0 mode, submit_put_task should skip store."""
backend._mla_worker_id_as0_mode = True
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
key = _make_cache_key()
future = backend.submit_put_task(key, obj)
assert future.result(timeout=5) is None
backend._handler.store.assert_not_called()
def test_submit_put_task_refcount_down_on_failure(self, backend, adapter):
"""On store failure, ref_count should return to pre-submit level."""
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
key = _make_cache_key()
initial_ref = obj.get_ref_count()
backend._handler.store.side_effect = RuntimeError("store failed")
future = backend.submit_put_task(key, obj)
with pytest.raises(RuntimeError):
future.result(timeout=5)
assert obj.get_ref_count() == initial_ref
assert not backend.exists_in_put_tasks(key)
def test_batched_submit_put_task_refcount_down_on_failure(self, backend, adapter):
"""On batch_store failure, ref_count should return to pre-submit level."""
keys = [_make_cache_key(i) for i in range(3)]
objs = [_make_memory_obj(adapter) for _ in range(3)]
for obj in objs:
obj.parent_allocator = None
initial_refs = [obj.get_ref_count() for obj in objs]
backend._handler.batch_store.side_effect = RuntimeError("batch failed")
futures = backend.batched_submit_put_task(keys, objs)
for future in futures:
with pytest.raises(RuntimeError):
future.result(timeout=5)
for obj, initial_ref in zip(objs, initial_refs, strict=False):
assert obj.get_ref_count() == initial_ref
for key in keys:
assert not backend.exists_in_put_tasks(key)
def test_batched_submit_put_task_skips_in_mla_mode(self, backend, adapter):
"""In MLA worker_id_as0 mode, batched_submit_put_task should skip."""
backend._mla_worker_id_as0_mode = True
keys = [_make_cache_key(i) for i in range(3)]
objs = [_make_memory_obj(adapter) for _ in range(3)]
for obj in objs:
obj.parent_allocator = None
result = backend.batched_submit_put_task(keys, objs)
assert result is None
backend._handler.batch_store.assert_not_called()
# =========================================================================
# Tests — Get (sync)
# =========================================================================
class TestMaruBackendGet:
def test_get_blocking_hit(self, backend, adapter):
key = _make_cache_key()
data_size = TEST_CHUNK_SIZE
data = bytearray(data_size)
mock_info = MemoryInfo(
view=memoryview(data),
region_id=100,
page_index=0,
)
backend._handler.retrieve.return_value = mock_info
result = backend.get_blocking(key)
assert result is not None
backend._handler.retrieve.assert_called_once()
def test_get_blocking_miss(self, backend):
key = _make_cache_key()
backend._handler.retrieve.return_value = None
result = backend.get_blocking(key)
assert result is None
def test_get_blocking_ref_count_increases(self, backend, adapter):
"""After get_blocking, the returned MemoryObj should have ref_count
incremented."""
# Pre-allocate so pool has page 0
_make_memory_obj(adapter)
key = _make_cache_key()
mock_info = MemoryInfo(
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
region_id=100,
page_index=0,
)
backend._handler.retrieve.return_value = mock_info
result = backend.get_blocking(key)
assert result is not None
# Pool objects start with ref_count=1, get_blocking calls ref_count_up
assert result.get_ref_count() >= 2
def test_batched_get_blocking(self, backend, adapter):
"""batched_get_blocking returns list of MemoryObj via batch_retrieve."""
objs = [_make_memory_obj(adapter) for _ in range(2)]
keys = [_make_cache_key(i) for i in range(2)]
infos = []
for obj in objs:
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
infos.append(
MemoryInfo(
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
region_id=rid,
page_index=pid,
)
)
backend._handler.batch_retrieve.return_value = infos
results = backend.batched_get_blocking(keys)
assert len(results) == 2
for r in results:
assert r is not None
def test_batched_get_blocking_with_miss(self, backend, adapter):
"""batched_get_blocking returns None for missing keys."""
obj = _make_memory_obj(adapter)
keys = [_make_cache_key(i) for i in range(2)]
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
info = MemoryInfo(
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
region_id=rid,
page_index=pid,
)
backend._handler.batch_retrieve.return_value = [info, None]
results = backend.batched_get_blocking(keys)
assert len(results) == 2
assert results[0] is not None
assert results[1] is None
# =========================================================================
# Tests — Contains
# =========================================================================
class TestMaruBackendContains:
def test_contains_true(self, backend):
key = _make_cache_key()
backend._handler.exists.return_value = True
assert backend.contains(key) is True
backend._handler.exists.assert_called_once_with(key.to_string())
def test_contains_false(self, backend):
key = _make_cache_key()
backend._handler.exists.return_value = False
assert backend.contains(key) is False
def test_batched_contains_all_hit(self, backend):
keys = [_make_cache_key(i) for i in range(3)]
backend._handler.batch_exists.return_value = [True, True, True]
result = backend.batched_contains(keys)
assert result == 3
def test_batched_contains_partial_prefix(self, backend):
keys = [_make_cache_key(i) for i in range(3)]
backend._handler.batch_exists.return_value = [True, True, False]
result = backend.batched_contains(keys)
assert result == 2
def test_batched_contains_first_miss(self, backend):
keys = [_make_cache_key(i) for i in range(3)]
backend._handler.batch_exists.return_value = [False, True, True]
result = backend.batched_contains(keys)
assert result == 0
def test_contains_with_pin(self, backend):
key = _make_cache_key()
backend._handler.pin.return_value = True
assert backend.contains(key, pin=True) is True
backend._handler.pin.assert_called_once_with(key.to_string())
backend._handler.exists.assert_not_called()
def test_contains_with_pin_false(self, backend):
key = _make_cache_key()
backend._handler.pin.return_value = False
assert backend.contains(key, pin=True) is False
def test_batched_contains_with_pin(self, backend):
keys = [_make_cache_key(i) for i in range(3)]
backend._handler.batch_pin.return_value = [True, True, True]
result = backend.batched_contains(keys, pin=True)
assert result == 3
backend._handler.batch_pin.assert_called_once_with(
[k.to_string() for k in keys]
)
backend._handler.batch_exists.assert_not_called()
def test_batched_contains_with_pin_partial(self, backend):
keys = [_make_cache_key(i) for i in range(3)]
backend._handler.batch_pin.return_value = [True, False, True]
result = backend.batched_contains(keys, pin=True)
assert result == 1
def test_batched_contains_empty(self, backend):
backend._handler.batch_exists.return_value = []
assert backend.batched_contains([]) == 0
# =========================================================================
# Tests — Async Lookup
# =========================================================================
class TestMaruBackendAsyncLookup:
def test_batched_async_contains_all_hit(self, backend, async_loop):
keys = [_make_cache_key(i) for i in range(3)]
backend._handler.batch_exists.return_value = [True, True, True]
result = _run_async(
async_loop, backend.batched_async_contains("lookup-1", keys)
)
assert result == 3
def test_batched_async_contains_partial_prefix(self, backend, async_loop):
keys = [_make_cache_key(i) for i in range(3)]
backend._handler.batch_exists.return_value = [True, False, True]
result = _run_async(
async_loop, backend.batched_async_contains("lookup-2", keys)
)
assert result == 1
def test_batched_async_contains_empty(self, backend, async_loop):
backend._handler.batch_exists.return_value = []
result = _run_async(async_loop, backend.batched_async_contains("lookup-3", []))
assert result == 0
def test_batched_get_non_blocking_all_hit(self, backend, adapter, async_loop):
keys = [_make_cache_key(i) for i in range(2)]
objs = [_make_memory_obj(adapter) for _ in range(2)]
infos = []
for obj in objs:
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
infos.append(
MemoryInfo(
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
region_id=rid,
page_index=pid,
)
)
backend._handler.batch_retrieve.return_value = infos
results = _run_async(
async_loop, backend.batched_get_non_blocking("lookup-4", keys)
)
assert len(results) == 2
for obj in results:
assert obj is not None
def test_batched_get_non_blocking_prefix_stop_on_miss(
self, backend, adapter, async_loop
):
"""Second key is a miss -> only first returned (prefix semantics)."""
keys = [_make_cache_key(i) for i in range(3)]
obj = _make_memory_obj(adapter)
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
info = MemoryInfo(
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
region_id=rid,
page_index=pid,
)
# hit, miss, hit -> should return only [hit]
backend._handler.batch_retrieve.return_value = [info, None, info]
results = _run_async(
async_loop, backend.batched_get_non_blocking("lookup-5", keys)
)
assert len(results) == 1
def test_batched_get_non_blocking_empty(self, backend, async_loop):
backend._handler.batch_retrieve.return_value = []
results = _run_async(
async_loop, backend.batched_get_non_blocking("lookup-6", [])
)
assert results == []
# =========================================================================
# Tests — Pin / Unpin / Remove
# =========================================================================
class TestMaruBackendPinRemove:
def test_pin_delegates_to_handler(self, backend):
key = _make_cache_key()
backend._handler.pin.return_value = True
assert backend.pin(key) is True
backend._handler.pin.assert_called_once_with(key.to_string())
def test_pin_returns_false_on_failure(self, backend):
key = _make_cache_key()
backend._handler.pin.return_value = False
assert backend.pin(key) is False
def test_unpin_delegates_to_handler(self, backend):
key = _make_cache_key()
backend._handler.unpin.return_value = True
assert backend.unpin(key) is True
backend._handler.unpin.assert_called_once_with(key.to_string())
def test_unpin_returns_false_on_failure(self, backend):
key = _make_cache_key()
backend._handler.unpin.return_value = False
assert backend.unpin(key) is False
def test_batched_unpin(self, backend):
keys = [_make_cache_key(i) for i in range(3)]
backend.batched_unpin(keys)
backend._handler.batch_unpin.assert_called_once_with(
[k.to_string() for k in keys]
)
def test_batched_unpin_empty(self, backend):
backend.batched_unpin([])
backend._handler.batch_unpin.assert_not_called()
def test_remove_existing_key(self, backend):
key = _make_cache_key()
backend._handler.delete.return_value = True
result = backend.remove(key)
assert result is True
backend._handler.delete.assert_called_once_with(key.to_string())
def test_remove_nonexistent_key(self, backend):
key = _make_cache_key()
backend._handler.delete.return_value = False
result = backend.remove(key)
assert result is False
# =========================================================================
# Tests — Lifecycle
# =========================================================================
class TestMaruBackendLifecycle:
def test_close_calls_handler_and_allocator(self, backend):
backend.memory_allocator = MagicMock()
backend.close()
backend.memory_allocator.close.assert_called_once()
backend._handler.close.assert_called_once()
def test_close_drains_pending_put_tasks(self, backend, adapter):
"""close() should wait for in-flight put tasks to complete."""
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
key = _make_cache_key()
# Submit a real put task that will complete via the event loop
future = backend.submit_put_task(key, obj)
future.result(timeout=5)
# After drain, close should succeed
backend.close()
backend._handler.close.assert_called_once()
# =========================================================================
# Tests — Store Handle Roundtrip
# =========================================================================
class TestMaruBackendStoreHandle:
def test_store_handle_roundtrip(self, backend, adapter):
"""AllocHandle from create_store_handle should match original."""
obj = _make_memory_obj(adapter)
obj.parent_allocator = None
handle = adapter.create_store_handle(obj)
assert handle.region_id == 100
assert handle.page_index == 0
assert handle._size == obj.metadata.phy_size