Files
2026-07-13 12:24:33 +08:00

557 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Unit tests for L1MemoryManager.
These tests verify the behavior of L1MemoryManager as described in the
interface docstrings. The tests focus on:
1. allocate() - Thread-safe allocation returning (error_code, memory_objs)
- Returns SUCCESS and non-empty list on successful allocation
- Returns OUT_OF_MEMORY and empty list when allocation fails
2. free() - Thread-safe deallocation returning error_code
- Returns SUCCESS when operation succeeds
3. get_l1_memory_desc() - Returns L1MemoryDesc with pointer, size, and
alignment of the L1 buffer
"""
# Standard
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
# Third Party
import pytest
import torch
# First Party
from lmcache.v1.distributed.api import MemoryLayoutDesc
from lmcache.v1.distributed.config import L1MemoryManagerConfig
from lmcache.v1.distributed.error import L1Error
try:
# First Party
from lmcache.v1.distributed.internal_api import L1MemoryDesc
from lmcache.v1.distributed.memory_manager import (
L1MemoryManager,
)
except ImportError:
# Skip the tests if the L1MemoryManager cannot be imported
pytest.skip("L1MemoryManager could not be imported", allow_module_level=True)
# Skip all tests in this module if CUDA is not available
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available(), reason="CUDA is not available"
)
def should_use_lazy_alloc() -> bool:
"""Determine if lazy allocation should be used based on CUDA availability."""
return torch.cuda.is_available()
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def basic_config():
"""Create a basic L1MemoryManagerConfig for testing."""
return L1MemoryManagerConfig(
size_in_bytes=128 * 1024 * 1024, # 128MB
use_lazy=should_use_lazy_alloc(),
init_size_in_bytes=64 * 1024 * 1024, # 64MB
align_bytes=0x1000, # 4KB
)
@pytest.fixture
def small_config():
"""Create a small L1MemoryManagerConfig to test memory exhaustion.
Note: Minimum size is 64MB due to LazyMemoryAllocator's PIN_CHUNK_SIZE.
"""
return L1MemoryManagerConfig(
size_in_bytes=64 * 1024 * 1024, # 64MB
use_lazy=should_use_lazy_alloc(),
init_size_in_bytes=64
* 1024
* 1024, # 64MB (same as final to prevent expansion)
align_bytes=0x1000,
)
@pytest.fixture
def non_lazy_config():
"""Create a config that does not use lazy allocation."""
return L1MemoryManagerConfig(
size_in_bytes=128 * 1024 * 1024, # 128MB
use_lazy=False,
init_size_in_bytes=64 * 1024 * 1024,
align_bytes=0x1000,
)
@pytest.fixture
def basic_layout():
"""Create a basic MemoryLayoutDesc for testing."""
return MemoryLayoutDesc(
shapes=[torch.Size([100, 2, 512])],
dtypes=[torch.bfloat16],
)
@pytest.fixture
def multi_tensor_layout():
"""Create a MemoryLayoutDesc with multiple tensor shapes."""
return MemoryLayoutDesc(
shapes=[torch.Size([100, 2, 512]), torch.Size([100, 2, 512])],
dtypes=[torch.bfloat16, torch.bfloat16],
)
@pytest.fixture
def large_layout():
"""Create a large MemoryLayoutDesc that will exhaust small memory.
Each allocation is 8MB (2M elements * 4 bytes).
"""
return MemoryLayoutDesc(
shapes=[torch.Size([2048, 1024])], # 2M elements * 4 bytes = 8MB
dtypes=[torch.float32],
)
# =============================================================================
# Tests for L1MemoryManager.allocate()
# =============================================================================
class TestAllocate:
"""
Tests for L1MemoryManager.allocate() method.
Per the docstring:
- This function should be thread-safe
- Returns tuple[L1Error, list[MemoryObj]]
- Error code is OUT_OF_MEMORY if allocation fails, otherwise SUCCESS
- If allocation fails, the memory object list will be empty
"""
def test_allocate_returns_success_and_memory_objs(self, basic_config, basic_layout):
"""Test that allocate returns SUCCESS and valid memory objects."""
manager = L1MemoryManager(basic_config)
error, mem_objs = manager.allocate(basic_layout, count=1)
assert error == L1Error.SUCCESS
assert isinstance(mem_objs, list)
assert len(mem_objs) == 1
for obj in mem_objs:
assert obj is not None
assert obj.is_valid()
manager.close()
def test_allocate_returns_correct_count(self, basic_config, basic_layout):
"""Test that allocate returns the requested number of memory objects."""
manager = L1MemoryManager(basic_config)
count = 5
error, mem_objs = manager.allocate(basic_layout, count=count)
assert error == L1Error.SUCCESS
assert len(mem_objs) == count
manager.close()
def test_allocate_with_multi_tensor_layout(self, basic_config, multi_tensor_layout):
"""Test allocation with multiple tensor shapes in the layout."""
manager = L1MemoryManager(basic_config)
error, mem_objs = manager.allocate(multi_tensor_layout, count=2)
assert error == L1Error.SUCCESS
assert len(mem_objs) == 2
manager.close()
def test_allocate_returns_out_of_memory_when_exhausted(
self, small_config, large_layout
):
"""
Test that allocate returns OUT_OF_MEMORY and empty list when memory
is exhausted.
"""
manager = L1MemoryManager(small_config)
# Request more memory than available (8MB * 10 = 80MB > 64MB)
error, mem_objs = manager.allocate(large_layout, count=10)
assert error == L1Error.OUT_OF_MEMORY
assert isinstance(mem_objs, list)
assert len(mem_objs) == 0
manager.close()
def test_allocate_returns_empty_list_on_failure(self, small_config, large_layout):
"""Test that the memory object list is empty when allocation fails."""
manager = L1MemoryManager(small_config)
# Request more memory than available (8MB * 10 = 80MB > 64MB)
error, mem_objs = manager.allocate(large_layout, count=10)
# Verify the docstring contract: "If the allocation fails, the memory
# object list will be empty."
assert error == L1Error.OUT_OF_MEMORY
assert mem_objs == []
manager.close()
def test_allocate_is_thread_safe(self, basic_config, basic_layout):
"""Test that allocate is thread-safe with concurrent allocations."""
manager = L1MemoryManager(basic_config)
num_threads = 10
allocations_per_thread = 5
results = []
errors = []
def allocate_task():
try:
for _ in range(allocations_per_thread):
error, mem_objs = manager.allocate(basic_layout, count=1)
results.append((error, mem_objs))
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=allocate_task) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
# No exceptions should have been raised
assert len(errors) == 0, f"Thread-safety errors: {errors}"
# All results should have valid error codes
for error, mem_objs in results:
assert error in (
L1Error.SUCCESS,
L1Error.OUT_OF_MEMORY,
)
if error == L1Error.SUCCESS:
assert len(mem_objs) == 1
else:
assert len(mem_objs) == 0
manager.close()
def test_allocate_with_non_lazy_config(self, non_lazy_config, basic_layout):
"""Test allocation with non-lazy (MixedMemoryAllocator) configuration."""
manager = L1MemoryManager(non_lazy_config)
error, mem_objs = manager.allocate(basic_layout, count=2)
assert error == L1Error.SUCCESS
assert len(mem_objs) == 2
manager.close()
# =============================================================================
# Tests for L1MemoryManager.free()
# =============================================================================
class TestFree:
"""
Tests for L1MemoryManager.free() method.
Per the docstring:
- This function should be thread-safe
- Returns L1Error indicating the result
- Returns SUCCESS if operation succeeds
"""
def test_free_returns_success(self, basic_config, basic_layout):
"""Test that free returns SUCCESS for valid memory objects."""
manager = L1MemoryManager(basic_config)
error, mem_objs = manager.allocate(basic_layout, count=2)
assert error == L1Error.SUCCESS
free_error = manager.free(mem_objs)
assert free_error == L1Error.SUCCESS
manager.close()
def test_free_invalidates_memory_objects(self, basic_config, basic_layout):
"""Test that free invalidates the memory objects."""
manager = L1MemoryManager(basic_config)
error, mem_objs = manager.allocate(basic_layout, count=1)
assert error == L1Error.SUCCESS
assert mem_objs[0].is_valid()
free_error = manager.free(mem_objs)
assert free_error == L1Error.SUCCESS
# After freeing, the memory objects should be invalidated
for obj in mem_objs:
assert not obj.is_valid()
manager.close()
def test_free_empty_list_returns_success(self, basic_config):
"""Test that freeing an empty list returns SUCCESS."""
manager = L1MemoryManager(basic_config)
free_error = manager.free([])
assert free_error == L1Error.SUCCESS
manager.close()
def test_free_allows_reallocation(self, basic_config, basic_layout):
"""Test that freed memory can be reallocated."""
manager = L1MemoryManager(basic_config)
# Allocate
error1, mem_objs1 = manager.allocate(basic_layout, count=5)
assert error1 == L1Error.SUCCESS
# Free
free_error = manager.free(mem_objs1)
assert free_error == L1Error.SUCCESS
# Reallocate
error2, mem_objs2 = manager.allocate(basic_layout, count=5)
assert error2 == L1Error.SUCCESS
assert len(mem_objs2) == 5
manager.close()
def test_free_is_thread_safe(self, basic_config, basic_layout):
"""Test that free is thread-safe with concurrent operations."""
manager = L1MemoryManager(basic_config)
num_threads = 10
errors_list = []
lock = threading.Lock()
def allocate_and_free_task():
try:
error, mem_objs = manager.allocate(basic_layout, count=1)
if error == L1Error.SUCCESS:
free_error = manager.free(mem_objs)
with lock:
errors_list.append(free_error)
except Exception as e:
with lock:
errors_list.append(e)
threads = [
threading.Thread(target=allocate_and_free_task) for _ in range(num_threads)
]
for t in threads:
t.start()
for t in threads:
t.join()
# All free operations should have succeeded
for err in errors_list:
if isinstance(err, Exception):
pytest.fail(f"Thread-safety error: {err}")
assert err == L1Error.SUCCESS
manager.close()
# =============================================================================
# Tests for L1MemoryManager.get_l1_memory_desc()
# =============================================================================
class TestGetL1MemoryDesc:
"""
Tests for L1MemoryManager.get_l1_memory_desc() method.
Per the docstring:
- Returns an L1MemoryDesc with ptr, size, and align_bytes
- Used by RDMA communication to register the underlying virtual memory space
"""
def test_get_l1_memory_desc_returns_desc(self, basic_config):
"""Test that get_l1_memory_desc returns an L1MemoryDesc."""
manager = L1MemoryManager(basic_config)
desc = manager.get_l1_memory_desc()
assert isinstance(desc, L1MemoryDesc)
manager.close()
def test_get_l1_memory_desc_returns_consistent_ptr(self, basic_config):
"""Test that get_l1_memory_desc returns the same pointer on multiple calls."""
manager = L1MemoryManager(basic_config)
desc1 = manager.get_l1_memory_desc()
desc2 = manager.get_l1_memory_desc()
assert desc1.ptr == desc2.ptr
manager.close()
def test_get_l1_memory_desc_size_matches_config(self, basic_config):
"""Test that the desc size matches the configured size."""
manager = L1MemoryManager(basic_config)
desc = manager.get_l1_memory_desc()
assert desc.size == basic_config.size_in_bytes
assert desc.align_bytes == basic_config.align_bytes
manager.close()
def test_get_l1_memory_desc_with_non_lazy_config(self, non_lazy_config):
"""Test get_l1_memory_desc with non-lazy (MixedMemoryAllocator)
configuration."""
manager = L1MemoryManager(non_lazy_config)
desc = manager.get_l1_memory_desc()
assert isinstance(desc, L1MemoryDesc)
assert desc.ptr != 0
assert desc.size == non_lazy_config.size_in_bytes
manager.close()
# =============================================================================
# Tests for L1MemoryManager integration
# =============================================================================
class TestL1MemoryManagerIntegration:
"""Integration tests for L1MemoryManager."""
def test_allocate_free_cycle(self, basic_config, basic_layout):
"""Test a complete allocate-free cycle."""
manager = L1MemoryManager(basic_config)
# Allocate
error, mem_objs = manager.allocate(basic_layout, count=3)
assert error == L1Error.SUCCESS
assert len(mem_objs) == 3
# Verify objects are valid
for obj in mem_objs:
assert obj.is_valid()
# Free
free_error = manager.free(mem_objs)
assert free_error == L1Error.SUCCESS
# Verify objects are invalidated
for obj in mem_objs:
assert not obj.is_valid()
manager.close()
def test_multiple_allocate_free_cycles(self, basic_config, basic_layout):
"""Test multiple allocate-free cycles."""
manager = L1MemoryManager(basic_config)
for i in range(5):
error, mem_objs = manager.allocate(basic_layout, count=2)
assert error == L1Error.SUCCESS, f"Cycle {i} failed"
assert len(mem_objs) == 2
free_error = manager.free(mem_objs)
assert free_error == L1Error.SUCCESS
manager.close()
def test_interleaved_allocate_and_free(self, basic_config, basic_layout):
"""Test interleaved allocation and free operations."""
manager = L1MemoryManager(basic_config)
# Allocate batch 1
err1, objs1 = manager.allocate(basic_layout, count=2)
assert err1 == L1Error.SUCCESS
# Allocate batch 2
err2, objs2 = manager.allocate(basic_layout, count=2)
assert err2 == L1Error.SUCCESS
# Free batch 1
free_err1 = manager.free(objs1)
assert free_err1 == L1Error.SUCCESS
# Allocate batch 3 (should reuse freed memory)
err3, objs3 = manager.allocate(basic_layout, count=2)
assert err3 == L1Error.SUCCESS
# Free remaining
manager.free(objs2)
manager.free(objs3)
manager.close()
def test_concurrent_allocate_and_free(self, basic_config, basic_layout):
"""
Test concurrent allocate and free operations from multiple threads.
This verifies the thread-safety guarantees of both methods.
"""
manager = L1MemoryManager(basic_config)
num_threads = 8
iterations = 20
exceptions = []
def worker():
try:
for _ in range(iterations):
# Allocate
error, mem_objs = manager.allocate(basic_layout, count=1)
if error == L1Error.SUCCESS:
# Briefly hold the memory
assert len(mem_objs) == 1
# Free
free_error = manager.free(mem_objs)
assert free_error == L1Error.SUCCESS
except Exception as e:
exceptions.append(e)
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(worker) for _ in range(num_threads)]
for future in as_completed(futures):
future.result() # Raises exception if worker failed
assert len(exceptions) == 0, f"Concurrency errors: {exceptions}"
manager.close()
# =============================================================================
# Tests for error code semantics
# =============================================================================
class TestErrorCodeSemantics:
"""Tests verifying error code semantics as documented."""
def test_success_error_code_value(self):
"""Test that SUCCESS is a valid enum member."""
assert L1Error.SUCCESS is not None
assert L1Error.SUCCESS.name == "SUCCESS"
def test_out_of_memory_error_code_value(self):
"""Test that OUT_OF_MEMORY is a valid enum member."""
assert L1Error.OUT_OF_MEMORY is not None
assert L1Error.OUT_OF_MEMORY.name == "OUT_OF_MEMORY"
def test_error_codes_are_distinct(self):
"""Test that error codes are distinct values."""
assert L1Error.SUCCESS != L1Error.OUT_OF_MEMORY