Files
sglang/test/registered/unit/mem_cache/test_embedding_cache_controller.py
T

434 lines
14 KiB
Python

"""Unit tests for EmbeddingCacheController paged host pool behavior."""
import threading
import unittest
from queue import Queue
from unittest.mock import MagicMock
import torch
from sglang.srt.managers.schedule_batch import Modality
from sglang.srt.mem_cache.storage.mooncake_store.embedding_cache_controller import (
EmbeddingCacheController,
EmbeddingCacheEntry,
EmbeddingPool,
EntryState,
EvictableLRU,
PageRun,
RangePageAllocator,
build_transfer_buffers,
)
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def _make_pool(num_pages=16, dim=4, page_size=2, modality="vision"):
total_tokens = num_pages * page_size
tensor = torch.empty((total_tokens, dim), dtype=torch.float32)
return EmbeddingPool(
modality=modality,
dim=dim,
dtype=torch.float32,
page_size=page_size,
tensor=tensor,
num_pages=num_pages,
allocator=RangePageAllocator(num_pages),
page_bytes=page_size * dim * torch.float32.itemsize,
pool_size_bytes=total_tokens * dim * torch.float32.itemsize,
pin_memory=False,
)
def _make_controller(num_pages=16, dim=4, page_size=2, enable_eviction=True):
ctrl = EmbeddingCacheController.__new__(EmbeddingCacheController)
ctrl.tp_world_size = 1
ctrl.tp_group = None
ctrl.tp_rank = 0
ctrl.all_rank_get = False
ctrl.hidden_dims = {
Modality.IMAGE: dim,
Modality.VIDEO: dim,
Modality.AUDIO: dim,
}
ctrl.dtype = torch.float32
ctrl.element_size = torch.float32.itemsize
ctrl.enable_eviction = enable_eviction
ctrl.max_eviction_batch = 10
ctrl.mooncake_store = MagicMock()
ctrl.total_pool_size_bytes = num_pages * page_size * dim * torch.float32.itemsize
ctrl.vision_pool = _make_pool(num_pages, dim, page_size)
ctrl.audio_pool = _make_pool(num_pages, dim, page_size, modality="audio")
ctrl.pools = {"vision": ctrl.vision_pool, "audio": ctrl.audio_pool}
ctrl.entries = {}
ctrl.vision_pool.evictable = EvictableLRU()
ctrl.audio_pool.evictable = EvictableLRU()
ctrl.stats = {
"total_allocated": 0,
"total_evicted": 0,
"eviction_count": 0,
"allocation_failures": 0,
}
ctrl.ongoing_prefetch = {}
ctrl.prefetch_queue = Queue()
ctrl.insert_queue = Queue()
ctrl.lock = threading.Lock()
ctrl.stop_event = threading.Event()
ctrl.io_thread = MagicMock()
ctrl.prefetch_tp_group = None
ctrl._copy_streams = {}
return ctrl
class TestRangePageAllocator(unittest.TestCase):
def test_prefers_single_contiguous_run(self):
allocator = RangePageAllocator(num_pages=8)
runs = allocator.allocate(num_tokens=6, page_size=2)
self.assertEqual(runs, [PageRun(start=0, length=3)])
self.assertEqual(allocator.free_ranges, [(3, 5)])
def test_free_merges_adjacent_ranges(self):
allocator = RangePageAllocator(num_pages=8)
first = allocator.allocate(num_tokens=4, page_size=2)
second = allocator.allocate(num_tokens=4, page_size=2)
allocator.free(first)
allocator.free(second)
self.assertEqual(allocator.free_ranges, [(0, 8)])
def test_scatter_fallback_returns_physical_order(self):
allocator = RangePageAllocator(num_pages=4)
a = allocator.allocate(num_tokens=2, page_size=2)
b = allocator.allocate(num_tokens=2, page_size=2)
c = allocator.allocate(num_tokens=2, page_size=2)
d = allocator.allocate(num_tokens=2, page_size=2)
allocator.free(a)
allocator.free(c)
runs = allocator.allocate(num_tokens=4, page_size=2)
self.assertEqual(runs, [PageRun(start=0, length=1), PageRun(start=2, length=1)])
self.assertEqual([run.start for run in runs], sorted(run.start for run in runs))
self.assertEqual(b, [PageRun(start=1, length=1)])
self.assertEqual(d, [PageRun(start=3, length=1)])
def test_allocate_fails_when_total_free_pages_are_insufficient(self):
allocator = RangePageAllocator(num_pages=2)
allocator.allocate(num_tokens=4, page_size=2)
self.assertIsNone(allocator.allocate(num_tokens=2, page_size=2))
self.assertEqual(allocator.free_pages, 0)
class TestEntryStateAndPins(unittest.TestCase):
def test_ready_entry_with_no_pins_is_evictable(self):
entry = EmbeddingCacheEntry(
hash="h",
modality=Modality.IMAGE,
num_tokens=2,
dim=4,
page_runs=[PageRun(0, 1)],
state=EntryState.READY,
)
self.assertTrue(entry.is_evictable())
def test_ready_entry_with_pin_is_not_evictable(self):
entry = EmbeddingCacheEntry(
hash="h",
modality=Modality.IMAGE,
num_tokens=2,
dim=4,
page_runs=[PageRun(0, 1)],
state=EntryState.READY,
)
entry.pin()
self.assertFalse(entry.is_evictable())
entry.unpin()
self.assertTrue(entry.is_evictable())
def test_multiple_pins_require_all_unpins(self):
entry = EmbeddingCacheEntry(
hash="h",
modality=Modality.IMAGE,
num_tokens=2,
dim=4,
page_runs=[PageRun(0, 1)],
state=EntryState.READY,
)
entry.pin()
entry.pin()
self.assertEqual(entry.ref_count, 2)
self.assertFalse(entry.is_evictable())
class TestEvictableLruInvariant(unittest.TestCase):
def _insert_entry(
self,
ctrl,
mm_hash,
modality=Modality.IMAGE,
state=EntryState.READY,
):
pool = ctrl._get_pool(modality)
page_runs = pool.allocator.allocate(2, pool.page_size)
entry = EmbeddingCacheEntry(
hash=mm_hash,
modality=modality,
num_tokens=2,
dim=pool.dim,
page_runs=page_runs,
state=state,
)
ctrl.entries[mm_hash] = entry
return entry
def test_filling_entry_is_not_in_evictable_lru_until_ready(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
entry = self._insert_entry(
ctrl,
"h",
state=EntryState.FILLING,
)
self.assertNotIn("h", ctrl.vision_pool.evictable)
with ctrl.lock:
ctrl._mark_ready(entry)
self.assertEqual(list(ctrl.vision_pool.evictable.keys()), ["h"])
def test_first_read_pin_removes_candidate_and_last_release_reinserts(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
entry = self._insert_entry(ctrl, "h")
with ctrl.lock:
ctrl._lru_touch("h")
with ctrl.lock:
ctrl._pin_read(entry)
ctrl._pin_read(entry)
self.assertNotIn("h", ctrl.vision_pool.evictable)
with ctrl.lock:
ctrl._unpin_read(entry)
self.assertNotIn("h", ctrl.vision_pool.evictable)
with ctrl.lock:
ctrl._unpin_read(entry)
self.assertEqual(list(ctrl.vision_pool.evictable.keys()), ["h"])
def test_evict_for_pool_pops_only_that_pool_candidates(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
self._insert_entry(ctrl, "vision_h", modality=Modality.IMAGE)
self._insert_entry(ctrl, "audio_h", modality=Modality.AUDIO)
with ctrl.lock:
ctrl._lru_touch("vision_h")
ctrl._lru_touch("audio_h")
required_pages = ctrl.vision_pool.allocator.free_pages + 1
with ctrl.lock:
ctrl._evict_for_pool(ctrl.vision_pool, required_pages)
self.assertNotIn("vision_h", ctrl.entries)
self.assertIn("audio_h", ctrl.entries)
self.assertEqual(list(ctrl.audio_pool.evictable.keys()), ["audio_h"])
class TestStoreToPool(unittest.TestCase):
def test_store_to_pool_async_raises_on_cpu_tensor(self):
ctrl = _make_controller(num_pages=8, dim=4, page_size=2)
tensor = torch.empty((2, 4), dtype=torch.float32)
with self.assertRaises(ValueError):
ctrl.store_to_pool_async(["h"], [tensor], Modality.IMAGE)
def _insert_ready_entry(ctrl, mm_hash, tensor, modality=Modality.IMAGE):
"""Manually write tensor into pool pages and create a READY entry."""
pool = ctrl._get_pool(modality)
if tensor.ndim != 2:
tensor = tensor.reshape(-1, tensor.shape[-1])
num_tokens = int(tensor.shape[0])
page_runs = pool.allocator.allocate(num_tokens, pool.page_size)
copied = 0
for run in page_runs:
valid = min(pool.page_size * run.length, num_tokens - copied)
start = run.start * pool.page_size
pool.tensor[start : start + valid].copy_(tensor[copied : copied + valid])
copied += valid
entry = EmbeddingCacheEntry(
hash=mm_hash,
modality=modality,
num_tokens=num_tokens,
dim=int(tensor.shape[1]),
page_runs=page_runs,
state=EntryState.READY,
)
ctrl.entries[mm_hash] = entry
pool.evictable.touch(mm_hash)
return entry
class TestMooncakeLifecycle(unittest.TestCase):
def test_prefetch_creates_filling_entry_and_get_success_marks_ready(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
ctrl.prefetch("req", ["h"], [2], Modality.IMAGE)
op = ctrl.ongoing_prefetch["req"]
ctrl._finish_get(op, [True])
entry = ctrl.entries["h"]
self.assertEqual(entry.state, EntryState.READY)
def test_prefetch_get_failure_frees_entry(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
ctrl.prefetch("req", ["h"], [2], Modality.IMAGE)
op = ctrl.ongoing_prefetch["req"]
ctrl._finish_get(op, [False])
self.assertNotIn("h", ctrl.entries)
self.assertEqual(ctrl.vision_pool.allocator.free_pages, 4)
def test_insert_batch_pins_and_releases_on_put(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
tensor = torch.arange(8, dtype=torch.float32).reshape(2, 4)
_insert_ready_entry(ctrl, "h", tensor)
ctrl.insert_batch(["h"], Modality.IMAGE)
op = ctrl.insert_queue.get_nowait()
entry = ctrl.entries["h"]
self.assertEqual(entry.ref_count, 1)
self.assertNotIn("h", ctrl.vision_pool.evictable)
ctrl._finish_put(op, [True])
self.assertEqual(entry.ref_count, 0)
self.assertEqual(entry.state, EntryState.READY)
self.assertIn("h", ctrl.vision_pool.evictable)
class TestGetPoolViews(unittest.TestCase):
def test_get_pool_views_returns_none_for_filling_entry(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
ctrl.entries["h"] = EmbeddingCacheEntry(
hash="h",
modality=Modality.IMAGE,
num_tokens=2,
dim=4,
page_runs=[PageRun(0, 1)],
state=EntryState.FILLING,
)
views = ctrl.get_pool_views(["h"])
self.assertIsNone(views[0])
def test_get_pool_views_returns_slices_and_release_unpins(self):
ctrl = _make_controller(num_pages=4, dim=4, page_size=2)
tensor = torch.arange(8, dtype=torch.float32).reshape(2, 4)
_insert_ready_entry(ctrl, "h", tensor)
views = ctrl.get_pool_views(["h"])
self.assertIsNotNone(views[0])
entry = ctrl.entries["h"]
self.assertEqual(entry.ref_count, 1)
flat = torch.cat(views[0], dim=0)
self.assertTrue(torch.equal(flat, tensor))
ctrl.release_pool_views(["h"])
self.assertEqual(entry.ref_count, 0)
self.assertIn("h", ctrl.vision_pool.evictable)
class TestTransferBuffers(unittest.TestCase):
def test_build_transfer_buffers_for_single_run(self):
pool = _make_pool(num_pages=8, dim=4, page_size=2)
entry = EmbeddingCacheEntry(
hash="h",
modality=Modality.IMAGE,
num_tokens=5,
dim=4,
page_runs=[PageRun(2, 3)],
state=EntryState.READY,
)
ptrs, sizes = build_transfer_buffers(entry, pool)
self.assertEqual(ptrs, [pool.tensor[4].data_ptr()])
self.assertEqual(sizes, [5 * 4 * torch.float32.itemsize])
def test_build_transfer_buffers_for_multiple_runs(self):
pool = _make_pool(num_pages=8, dim=4, page_size=2)
entry = EmbeddingCacheEntry(
hash="h",
modality=Modality.IMAGE,
num_tokens=5,
dim=4,
page_runs=[PageRun(0, 1), PageRun(3, 2)],
state=EntryState.READY,
)
ptrs, sizes = build_transfer_buffers(entry, pool)
self.assertEqual(ptrs, [pool.tensor[0].data_ptr(), pool.tensor[6].data_ptr()])
self.assertEqual(
sizes,
[
2 * 4 * torch.float32.itemsize,
3 * 4 * torch.float32.itemsize,
],
)
class TestMooncakeEmbeddingStoreWrappers(unittest.TestCase):
def test_batch_put_multi_buffers_deduplicates_existing_keys(self):
from sglang.srt.mem_cache.storage.mooncake_store.mooncake_embedding_store import (
MooncakeEmbeddingStore,
)
store = MooncakeEmbeddingStore.__new__(MooncakeEmbeddingStore)
store.store = MagicMock()
store.store.batch_is_exist.return_value = [1, 0]
store.store.batch_put_from_multi_buffers.return_value = [0]
results = store.batch_put_from_multi_buffers(
["a", "b"],
[[11], [22]],
[[4], [4]],
)
self.assertEqual(results, [True, True])
store.store.batch_put_from_multi_buffers.assert_called_once_with(
["emb_b"], [[22]], [[4]]
)
def test_batch_get_multi_buffers_maps_positive_result_to_true(self):
from sglang.srt.mem_cache.storage.mooncake_store.mooncake_embedding_store import (
MooncakeEmbeddingStore,
)
store = MooncakeEmbeddingStore.__new__(MooncakeEmbeddingStore)
store.store = MagicMock()
store.store.batch_get_into_multi_buffers.return_value = [8, -1]
results = store.batch_get_into_multi_buffers(
["a", "b"],
[[11], [22]],
[[4], [4]],
)
self.assertEqual(results, [True, False])
if __name__ == "__main__":
unittest.main()