897 lines
31 KiB
Python
897 lines
31 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
Tests for PagedCacheManager and related components.
|
|
|
|
This module tests the block-based paged KV cache management following vLLM's
|
|
architecture, adapted for MLX on Apple Silicon.
|
|
"""
|
|
|
|
import time
|
|
from typing import List
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from omlx.cache.paged_cache import (
|
|
BlockHash,
|
|
BlockHashToBlockMap,
|
|
BlockTable,
|
|
CacheBlock,
|
|
FreeKVCacheBlockQueue,
|
|
PagedCacheManager,
|
|
compute_block_hash,
|
|
resolve_block_extra_keys,
|
|
)
|
|
|
|
|
|
class TestComputeBlockHash:
|
|
"""Tests for compute_block_hash function."""
|
|
|
|
def test_same_tokens_same_hash(self):
|
|
"""Same tokens with same parent should produce same hash."""
|
|
tokens = [1, 2, 3, 4]
|
|
hash1 = compute_block_hash(None, tokens)
|
|
hash2 = compute_block_hash(None, tokens)
|
|
assert hash1 == hash2
|
|
|
|
def test_different_tokens_different_hash(self):
|
|
"""Different tokens should produce different hashes."""
|
|
hash1 = compute_block_hash(None, [1, 2, 3, 4])
|
|
hash2 = compute_block_hash(None, [5, 6, 7, 8])
|
|
assert hash1 != hash2
|
|
|
|
def test_parent_hash_affects_result(self):
|
|
"""Different parent hashes should produce different results."""
|
|
tokens = [1, 2, 3, 4]
|
|
parent1 = b"parent1_hash_bytes"
|
|
parent2 = b"parent2_hash_bytes"
|
|
hash1 = compute_block_hash(BlockHash(parent1), tokens)
|
|
hash2 = compute_block_hash(BlockHash(parent2), tokens)
|
|
assert hash1 != hash2
|
|
|
|
def test_model_name_isolation(self):
|
|
"""Different model names should produce different hashes."""
|
|
tokens = [1, 2, 3, 4]
|
|
hash1 = compute_block_hash(None, tokens, model_name="model_a")
|
|
hash2 = compute_block_hash(None, tokens, model_name="model_b")
|
|
assert hash1 != hash2
|
|
|
|
def test_extra_keys_affect_hash(self):
|
|
"""Extra keys should affect the resulting hash."""
|
|
tokens = [1, 2, 3, 4]
|
|
hash1 = compute_block_hash(None, tokens)
|
|
hash2 = compute_block_hash(None, tokens, extra_keys=("lora_1",))
|
|
assert hash1 != hash2
|
|
|
|
def test_hash_is_bytes(self):
|
|
"""Hash should be bytes type."""
|
|
hash_val = compute_block_hash(None, [1, 2, 3])
|
|
assert isinstance(hash_val, bytes)
|
|
|
|
def test_hash_length(self):
|
|
"""Hash should be 32 bytes (SHA256)."""
|
|
hash_val = compute_block_hash(None, [1, 2, 3])
|
|
assert len(hash_val) == 32
|
|
|
|
def test_chain_hash(self):
|
|
"""Test chain hashing (each block depends on previous)."""
|
|
# Block 1
|
|
hash1 = compute_block_hash(None, [1, 2, 3, 4])
|
|
# Block 2 depends on Block 1
|
|
hash2 = compute_block_hash(hash1, [5, 6, 7, 8])
|
|
# Block 3 depends on Block 2
|
|
hash3 = compute_block_hash(hash2, [9, 10, 11, 12])
|
|
|
|
# Each hash should be unique
|
|
assert len({hash1, hash2, hash3}) == 3
|
|
|
|
|
|
class TestResolveBlockExtraKeys:
|
|
"""Tests for segmented cache-key resolution."""
|
|
|
|
def test_returns_none_before_first_range(self):
|
|
"""Blocks before the first multimodal boundary should be unsalted."""
|
|
ranges = [
|
|
(5, ("image-1",)),
|
|
(9, ("image-1", "image-2")),
|
|
]
|
|
|
|
assert resolve_block_extra_keys(4, extra_key_ranges=ranges) is None
|
|
|
|
def test_selects_latest_matching_range(self):
|
|
"""Blocks should use the latest applicable segmented cache key."""
|
|
ranges = [
|
|
(5, ("image-1",)),
|
|
(9, ("image-1", "image-2")),
|
|
]
|
|
|
|
assert resolve_block_extra_keys(8, extra_key_ranges=ranges) == ("image-1",)
|
|
assert resolve_block_extra_keys(12, extra_key_ranges=ranges) == (
|
|
"image-1",
|
|
"image-2",
|
|
)
|
|
|
|
def test_ranges_take_precedence_over_legacy_extra_keys(self):
|
|
"""Segmented cache keys should override the legacy whole-request salt."""
|
|
ranges = [(5, ("image-1",))]
|
|
|
|
assert resolve_block_extra_keys(
|
|
8,
|
|
extra_keys=("legacy-image",),
|
|
extra_key_ranges=ranges,
|
|
) == ("image-1",)
|
|
|
|
def test_range_start_at_zero_applies_from_first_block(self):
|
|
"""A first-image boundary at token 0 should salt the entire sequence."""
|
|
ranges = [(0, ("image-1",))]
|
|
|
|
assert resolve_block_extra_keys(4, extra_key_ranges=ranges) == ("image-1",)
|
|
|
|
|
|
class TestCacheBlock:
|
|
"""Tests for CacheBlock dataclass."""
|
|
|
|
def test_default_values(self):
|
|
"""Test default values on initialization."""
|
|
block = CacheBlock(block_id=0)
|
|
assert block.block_id == 0
|
|
assert block.ref_count == 0
|
|
assert block.block_hash is None
|
|
assert block.prev_free_block is None
|
|
assert block.next_free_block is None
|
|
assert block.is_null is False
|
|
assert block.token_count == 0
|
|
|
|
def test_is_full(self):
|
|
"""Test is_full method."""
|
|
block = CacheBlock(block_id=0, token_count=32)
|
|
assert not block.is_full(64)
|
|
assert block.is_full(32)
|
|
assert block.is_full(16)
|
|
|
|
def test_is_shared(self):
|
|
"""Test is_shared method."""
|
|
block = CacheBlock(block_id=0, ref_count=1)
|
|
assert not block.is_shared()
|
|
|
|
block.ref_count = 2
|
|
assert block.is_shared()
|
|
|
|
block.ref_count = 5
|
|
assert block.is_shared()
|
|
|
|
def test_reset_hash(self):
|
|
"""Test reset_hash method."""
|
|
block = CacheBlock(block_id=0)
|
|
block.block_hash = BlockHash(b"test_hash")
|
|
assert block.block_hash is not None
|
|
|
|
block.reset_hash()
|
|
assert block.block_hash is None
|
|
|
|
def test_touch(self):
|
|
"""Test touch method updates last_access."""
|
|
block = CacheBlock(block_id=0)
|
|
old_access = block.last_access
|
|
|
|
time.sleep(0.01)
|
|
block.touch()
|
|
|
|
assert block.last_access > old_access
|
|
|
|
def test_repr(self):
|
|
"""Test string representation."""
|
|
block = CacheBlock(block_id=5, ref_count=2, token_count=32)
|
|
repr_str = repr(block)
|
|
assert "id=5" in repr_str
|
|
assert "ref=2" in repr_str
|
|
assert "tokens=32" in repr_str
|
|
|
|
|
|
class TestFreeKVCacheBlockQueue:
|
|
"""Tests for FreeKVCacheBlockQueue (doubly linked list)."""
|
|
|
|
def test_empty_queue(self):
|
|
"""Test creating queue with no blocks."""
|
|
queue = FreeKVCacheBlockQueue([])
|
|
assert queue.num_free_blocks == 0
|
|
|
|
def test_initial_state(self):
|
|
"""Test queue initialization with blocks."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
assert queue.num_free_blocks == 5
|
|
|
|
def test_popleft(self):
|
|
"""Test popping from front (LRU order)."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
popped = queue.popleft()
|
|
assert popped.block_id == 0
|
|
assert queue.num_free_blocks == 4
|
|
|
|
popped = queue.popleft()
|
|
assert popped.block_id == 1
|
|
assert queue.num_free_blocks == 3
|
|
|
|
def test_popleft_empty_raises(self):
|
|
"""Test popleft on empty queue raises ValueError."""
|
|
queue = FreeKVCacheBlockQueue([])
|
|
with pytest.raises(ValueError, match="No free blocks"):
|
|
queue.popleft()
|
|
|
|
def test_popleft_n(self):
|
|
"""Test popping multiple blocks at once."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
popped = queue.popleft_n(3)
|
|
assert len(popped) == 3
|
|
assert [b.block_id for b in popped] == [0, 1, 2]
|
|
assert queue.num_free_blocks == 2
|
|
|
|
def test_popleft_n_zero(self):
|
|
"""Test popping zero blocks returns empty list."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
popped = queue.popleft_n(0)
|
|
assert popped == []
|
|
assert queue.num_free_blocks == 5
|
|
|
|
def test_popleft_n_insufficient_raises(self):
|
|
"""Test popping more blocks than available raises."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(3)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
with pytest.raises(AssertionError):
|
|
queue.popleft_n(5)
|
|
|
|
def test_append(self):
|
|
"""Test appending a block to end (MRU position)."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(3)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
# Pop all
|
|
queue.popleft_n(3)
|
|
assert queue.num_free_blocks == 0
|
|
|
|
# Append a block
|
|
new_block = CacheBlock(block_id=10)
|
|
queue.append(new_block)
|
|
assert queue.num_free_blocks == 1
|
|
|
|
# Pop should return the appended block
|
|
popped = queue.popleft()
|
|
assert popped.block_id == 10
|
|
|
|
def test_append_n(self):
|
|
"""Test appending multiple blocks."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(2)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
# Append multiple
|
|
new_blocks = [CacheBlock(block_id=i) for i in range(10, 13)]
|
|
queue.append_n(new_blocks)
|
|
|
|
assert queue.num_free_blocks == 5
|
|
|
|
def test_remove(self):
|
|
"""Test removing a block from the middle."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
# Remove block 2 from middle
|
|
queue.remove(blocks[2])
|
|
assert queue.num_free_blocks == 4
|
|
|
|
# Verify block 2 is no longer in queue
|
|
all_free = queue.get_all_free_blocks()
|
|
assert blocks[2] not in all_free
|
|
assert len(all_free) == 4
|
|
|
|
def test_remove_not_in_queue_raises(self):
|
|
"""Test removing a block not in queue raises."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
# Pop a block (removes it from queue)
|
|
popped = queue.popleft()
|
|
|
|
# Try to remove again
|
|
with pytest.raises(RuntimeError, match="not in free queue"):
|
|
queue.remove(popped)
|
|
|
|
def test_lru_order_maintained(self):
|
|
"""Test LRU order: front is LRU, back is MRU."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
# Pop 2, append them back in reverse order
|
|
b0 = queue.popleft()
|
|
b1 = queue.popleft()
|
|
|
|
queue.append(b1)
|
|
queue.append(b0)
|
|
|
|
# Now order should be: 2, 3, 4, 1, 0
|
|
all_free = queue.get_all_free_blocks()
|
|
ids = [b.block_id for b in all_free]
|
|
assert ids == [2, 3, 4, 1, 0]
|
|
|
|
def test_get_all_free_blocks(self):
|
|
"""Test getting all free blocks."""
|
|
blocks = [CacheBlock(block_id=i) for i in range(5)]
|
|
queue = FreeKVCacheBlockQueue(blocks)
|
|
|
|
all_free = queue.get_all_free_blocks()
|
|
assert len(all_free) == 5
|
|
assert [b.block_id for b in all_free] == [0, 1, 2, 3, 4]
|
|
|
|
|
|
class TestBlockHashToBlockMap:
|
|
"""Tests for BlockHashToBlockMap (hash-based prefix cache)."""
|
|
|
|
def test_empty_map(self):
|
|
"""Test empty map."""
|
|
cache = BlockHashToBlockMap()
|
|
assert len(cache) == 0
|
|
assert cache.get_block(BlockHash(b"nonexistent")) is None
|
|
|
|
def test_insert_and_get(self):
|
|
"""Test inserting and retrieving a block."""
|
|
cache = BlockHashToBlockMap()
|
|
block = CacheBlock(block_id=1)
|
|
block_hash = BlockHash(b"test_hash_bytes_1234")
|
|
|
|
cache.insert(block_hash, block)
|
|
assert len(cache) == 1
|
|
|
|
retrieved = cache.get_block(block_hash)
|
|
assert retrieved is block
|
|
|
|
def test_insert_same_hash_multiple_blocks(self):
|
|
"""Test inserting multiple blocks with same hash (hybrid models)."""
|
|
cache = BlockHashToBlockMap()
|
|
block1 = CacheBlock(block_id=1)
|
|
block2 = CacheBlock(block_id=2)
|
|
block_hash = BlockHash(b"shared_hash_bytes_12")
|
|
|
|
cache.insert(block_hash, block1)
|
|
cache.insert(block_hash, block2)
|
|
|
|
# get_block returns any block with that hash
|
|
retrieved = cache.get_block(block_hash)
|
|
assert retrieved in (block1, block2)
|
|
|
|
def test_pop(self):
|
|
"""Test popping a specific block."""
|
|
cache = BlockHashToBlockMap()
|
|
block = CacheBlock(block_id=5)
|
|
block_hash = BlockHash(b"test_hash_for_pop_12")
|
|
|
|
cache.insert(block_hash, block)
|
|
assert len(cache) == 1
|
|
|
|
popped = cache.pop(block_hash, block.block_id)
|
|
assert popped is block
|
|
assert len(cache) == 0
|
|
|
|
def test_pop_wrong_block_id(self):
|
|
"""Test popping with wrong block_id returns None."""
|
|
cache = BlockHashToBlockMap()
|
|
block = CacheBlock(block_id=5)
|
|
block_hash = BlockHash(b"test_hash_wrong_id_1")
|
|
|
|
cache.insert(block_hash, block)
|
|
|
|
# Try to pop with wrong block_id
|
|
popped = cache.pop(block_hash, 999)
|
|
assert popped is None
|
|
# Original block should still be in cache
|
|
assert len(cache) == 1
|
|
|
|
def test_pop_from_multiple(self):
|
|
"""Test popping one block when multiple exist for same hash."""
|
|
cache = BlockHashToBlockMap()
|
|
block1 = CacheBlock(block_id=1)
|
|
block2 = CacheBlock(block_id=2)
|
|
block_hash = BlockHash(b"multi_block_hash_123")
|
|
|
|
cache.insert(block_hash, block1)
|
|
cache.insert(block_hash, block2)
|
|
|
|
# Pop block1
|
|
popped = cache.pop(block_hash, 1)
|
|
assert popped is block1
|
|
|
|
# block2 should still be retrievable
|
|
retrieved = cache.get_block(block_hash)
|
|
assert retrieved is block2
|
|
|
|
def test_clear(self):
|
|
"""Test clearing the map."""
|
|
cache = BlockHashToBlockMap()
|
|
for i in range(5):
|
|
block = CacheBlock(block_id=i)
|
|
cache.insert(BlockHash(f"hash_{i}_bytes_padding".encode()), block)
|
|
|
|
assert len(cache) == 5
|
|
|
|
cache.clear()
|
|
assert len(cache) == 0
|
|
|
|
|
|
class TestBlockTable:
|
|
"""Tests for BlockTable (per-request block mapping)."""
|
|
|
|
def test_default_values(self):
|
|
"""Test default values."""
|
|
table = BlockTable(request_id="req-001")
|
|
assert table.request_id == "req-001"
|
|
assert table.block_ids == []
|
|
assert table.num_tokens == 0
|
|
|
|
def test_add_block(self):
|
|
"""Test adding a block."""
|
|
table = BlockTable(request_id="req-001")
|
|
table.add_block(block_id=5, num_tokens=64)
|
|
|
|
assert table.block_ids == [5]
|
|
assert table.num_tokens == 64
|
|
|
|
table.add_block(block_id=10, num_tokens=32)
|
|
assert table.block_ids == [5, 10]
|
|
assert table.num_tokens == 96
|
|
|
|
def test_len(self):
|
|
"""Test length (number of blocks)."""
|
|
table = BlockTable(request_id="req-001", block_ids=[1, 2, 3])
|
|
assert len(table) == 3
|
|
|
|
def test_copy(self):
|
|
"""Test copying with new request ID."""
|
|
table = BlockTable(
|
|
request_id="req-001",
|
|
block_ids=[1, 2, 3],
|
|
num_tokens=192,
|
|
)
|
|
|
|
copied = table.copy("req-002")
|
|
assert copied.request_id == "req-002"
|
|
assert copied.block_ids == [1, 2, 3]
|
|
assert copied.num_tokens == 192
|
|
|
|
# Ensure it's a deep copy of block_ids
|
|
copied.block_ids.append(4)
|
|
assert table.block_ids == [1, 2, 3]
|
|
|
|
|
|
class TestPagedCacheManager:
|
|
"""Tests for PagedCacheManager."""
|
|
|
|
def test_initialization(self):
|
|
"""Test manager initialization."""
|
|
manager = PagedCacheManager(
|
|
block_size=64,
|
|
max_blocks=100,
|
|
model_name="test-model",
|
|
initial_blocks=50,
|
|
)
|
|
|
|
assert manager.block_size == 64
|
|
assert manager.max_blocks == 100
|
|
assert manager.model_name == "test-model"
|
|
# Null block is reserved
|
|
assert manager.free_blocks == 49 # 50 initial - 1 null
|
|
|
|
def test_null_block_reserved(self):
|
|
"""Test that null block is properly reserved."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
assert manager.null_block is not None
|
|
assert manager.null_block.is_null is True
|
|
assert manager.null_block.ref_count == 1
|
|
assert manager.null_block.block_id in manager.allocated_blocks
|
|
|
|
def test_allocate_block(self):
|
|
"""Test allocating a single block."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
initial_free = manager.free_blocks
|
|
|
|
block = manager.allocate_block()
|
|
assert block is not None
|
|
assert block.ref_count == 1
|
|
assert block.block_id in manager.allocated_blocks
|
|
assert manager.free_blocks == initial_free - 1
|
|
|
|
def test_get_new_blocks(self):
|
|
"""Test allocating multiple blocks."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
blocks = manager.get_new_blocks(5)
|
|
assert len(blocks) == 5
|
|
for block in blocks:
|
|
assert block.ref_count == 1
|
|
assert block.block_id in manager.allocated_blocks
|
|
|
|
def test_free_block(self):
|
|
"""Test freeing a block."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
block = manager.allocate_block()
|
|
block_id = block.block_id
|
|
initial_free = manager.free_blocks
|
|
|
|
result = manager.free_block(block_id)
|
|
assert result is True
|
|
assert manager.free_blocks == initial_free + 1
|
|
assert block_id not in manager.allocated_blocks
|
|
|
|
def test_free_block_shared(self):
|
|
"""Test freeing a shared block only decrements ref_count."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
block = manager.allocate_block()
|
|
block.ref_count = 2 # Simulating shared block
|
|
block_id = block.block_id
|
|
initial_free = manager.free_blocks
|
|
|
|
result = manager.free_block(block_id)
|
|
assert result is False # Not actually freed
|
|
assert block.ref_count == 1
|
|
assert manager.free_blocks == initial_free # No change
|
|
|
|
def test_increment_ref(self):
|
|
"""Test incrementing reference count."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
block = manager.allocate_block()
|
|
assert block.ref_count == 1
|
|
|
|
result = manager.increment_ref(block.block_id)
|
|
assert result is True
|
|
assert block.ref_count == 2
|
|
|
|
def test_create_block_table(self):
|
|
"""Test creating a block table for a request."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
table = manager.create_block_table("req-001")
|
|
assert table.request_id == "req-001"
|
|
assert "req-001" in manager.request_tables
|
|
|
|
def test_get_block_table(self):
|
|
"""Test getting an existing block table."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
manager.create_block_table("req-001")
|
|
table = manager.get_block_table("req-001")
|
|
assert table is not None
|
|
assert table.request_id == "req-001"
|
|
|
|
# Non-existent table
|
|
assert manager.get_block_table("req-nonexistent") is None
|
|
|
|
def test_delete_block_table(self):
|
|
"""Test deleting a block table frees associated blocks."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
# Create table and add blocks
|
|
table = manager.create_block_table("req-001")
|
|
blocks = manager.get_new_blocks(3)
|
|
for block in blocks:
|
|
table.block_ids.append(block.block_id)
|
|
|
|
initial_free = manager.free_blocks
|
|
|
|
# Delete table
|
|
manager.delete_block_table("req-001")
|
|
assert "req-001" not in manager.request_tables
|
|
assert manager.free_blocks == initial_free + 3
|
|
|
|
def test_find_shared_prefix(self):
|
|
"""Test finding shared prefix blocks."""
|
|
manager = PagedCacheManager(block_size=4, max_blocks=100, initial_blocks=100)
|
|
|
|
# Create and cache some blocks
|
|
tokens1 = [1, 2, 3, 4, 5, 6, 7, 8] # 2 full blocks
|
|
blocks = manager.get_new_blocks(2)
|
|
|
|
# Cache the blocks with their hashes
|
|
parent_hash = None
|
|
for i, block in enumerate(blocks):
|
|
start = i * 4
|
|
end = start + 4
|
|
block_tokens = tokens1[start:end]
|
|
manager.register_block_hash(block, block_tokens, parent_hash)
|
|
parent_hash = block.block_hash
|
|
|
|
# Find shared prefix for same tokens
|
|
shared_ids, remaining = manager.find_shared_prefix(tokens1)
|
|
assert len(shared_ids) == 2
|
|
assert remaining == []
|
|
|
|
def test_fork_block_table(self):
|
|
"""Test forking a block table (COW)."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
# Create source table with blocks
|
|
source_table = manager.create_block_table("req-source")
|
|
blocks = manager.get_new_blocks(2)
|
|
for block in blocks:
|
|
source_table.block_ids.append(block.block_id)
|
|
|
|
# Fork
|
|
forked = manager.fork_block_table(source_table, "req-forked")
|
|
|
|
assert forked.request_id == "req-forked"
|
|
assert forked.block_ids == source_table.block_ids
|
|
|
|
# Ref counts should be incremented
|
|
for block_id in source_table.block_ids:
|
|
block = manager.allocated_blocks[block_id]
|
|
assert block.ref_count == 2
|
|
|
|
def test_evict_block_permanently(self):
|
|
"""Test permanent block eviction."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
block = manager.allocate_block()
|
|
block_id = block.block_id
|
|
block.ref_count = 0 # Make evictable
|
|
block.block_hash = BlockHash(b"test_hash_for_evict")
|
|
manager.cached_block_hash_to_block.insert(block.block_hash, block)
|
|
|
|
initial_free = manager.free_blocks
|
|
|
|
result = manager.evict_block_permanently(block_id)
|
|
assert result is True
|
|
assert block_id not in manager.allocated_blocks
|
|
assert manager.free_blocks == initial_free + 1
|
|
|
|
def test_evict_block_in_use_fails(self):
|
|
"""Test evicting block in use fails."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
block = manager.allocate_block()
|
|
block_id = block.block_id
|
|
# block.ref_count is already 1 (in use)
|
|
|
|
result = manager.evict_block_permanently(block_id)
|
|
assert result is False
|
|
assert block_id in manager.allocated_blocks
|
|
|
|
def test_dynamic_block_growth(self):
|
|
"""Test dynamic block pool growth."""
|
|
manager = PagedCacheManager(
|
|
block_size=64,
|
|
max_blocks=100,
|
|
initial_blocks=10,
|
|
)
|
|
|
|
# Initially should have 10 blocks (9 free after null block)
|
|
assert manager._current_allocated_count == 10
|
|
assert manager.free_blocks == 9
|
|
|
|
# Allocate more than initial - should grow
|
|
blocks = manager.get_new_blocks(15)
|
|
assert len(blocks) == 15
|
|
|
|
# Pool should have grown
|
|
assert manager._current_allocated_count > 10
|
|
|
|
def test_stats(self):
|
|
"""Test cache statistics."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
# Initial stats
|
|
stats = manager.get_stats()
|
|
assert stats.allocated_blocks == 1 # null block
|
|
assert stats.free_blocks == 99
|
|
|
|
# Allocate some blocks
|
|
manager.get_new_blocks(5)
|
|
stats = manager.get_stats()
|
|
assert stats.allocated_blocks == 6
|
|
|
|
def test_clear(self):
|
|
"""Test clearing all cache data."""
|
|
manager = PagedCacheManager(
|
|
block_size=64,
|
|
max_blocks=100,
|
|
initial_blocks=50,
|
|
)
|
|
|
|
# Allocate blocks and create tables
|
|
manager.get_new_blocks(10)
|
|
manager.create_block_table("req-001")
|
|
|
|
cleared = manager.clear()
|
|
assert cleared > 0
|
|
|
|
# After clear, should be reset to initial state
|
|
assert len(manager.request_tables) == 0
|
|
# Only null block should be allocated
|
|
assert len(manager.allocated_blocks) == 1
|
|
|
|
def test_usage_property(self):
|
|
"""Test usage ratio property."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=10, initial_blocks=10)
|
|
|
|
# Initially low usage (only null block)
|
|
assert manager.usage < 0.2
|
|
|
|
# Allocate more blocks
|
|
manager.get_new_blocks(5)
|
|
# Usage should increase
|
|
assert manager.usage > 0.5
|
|
|
|
def test_cache_manager_interface(self):
|
|
"""Test CacheManager ABC interface implementation."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
# Test fetch (by hash)
|
|
block = manager.allocate_block()
|
|
block_hash = BlockHash(b"test_interface_hash1")
|
|
block.block_hash = block_hash
|
|
manager.cached_block_hash_to_block.insert(block_hash, block)
|
|
|
|
value, hit = manager.fetch(block_hash)
|
|
assert hit is True
|
|
assert value is block
|
|
|
|
# Test fetch miss
|
|
value, hit = manager.fetch(b"nonexistent_hash_byte")
|
|
assert hit is False
|
|
assert value is None
|
|
|
|
# Test size and max_size
|
|
assert manager.size >= 0
|
|
assert manager.max_size == 100
|
|
|
|
def test_get_evictable_blocks(self):
|
|
"""Test getting evictable blocks in LRU order."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
# Allocate blocks then free them (add to free queue)
|
|
blocks = manager.get_new_blocks(5)
|
|
for block in blocks:
|
|
block.ref_count = 0
|
|
|
|
evictable = manager.get_evictable_blocks(3)
|
|
# Should get blocks from free queue in LRU order
|
|
assert len(evictable) <= 3
|
|
|
|
def test_handle_memory_pressure(self):
|
|
"""Test handling memory pressure."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
# Should return True when enough blocks available
|
|
result = manager.handle_memory_pressure(5)
|
|
assert result is True
|
|
|
|
def test_allocate_blocks_for_tokens(self):
|
|
"""Test allocating blocks for a given number of tokens."""
|
|
manager = PagedCacheManager(block_size=64, max_blocks=100, initial_blocks=100)
|
|
|
|
# Need enough blocks for 150 tokens with block_size=64
|
|
# ceil(150/64) = 3 blocks
|
|
blocks = manager.allocate_blocks_for_tokens(150)
|
|
assert len(blocks) == 3
|
|
|
|
def test_get_computed_blocks_ssd_fallback(self):
|
|
"""Test that get_computed_blocks falls back to SSD cache on in-memory miss."""
|
|
manager = PagedCacheManager(
|
|
block_size=4, max_blocks=100, model_name="test-model", initial_blocks=100
|
|
)
|
|
|
|
tokens = [1, 2, 3, 4, 5, 6, 7, 8] # 2 full blocks
|
|
|
|
# Compute block hashes for these tokens
|
|
hash1 = compute_block_hash(None, [1, 2, 3, 4], model_name="test-model")
|
|
hash2 = compute_block_hash(hash1, [5, 6, 7, 8], model_name="test-model")
|
|
|
|
# Mock SSD cache manager that reports both blocks exist
|
|
mock_ssd = MagicMock(spec=[])
|
|
mock_ssd.has_block = MagicMock(side_effect=lambda h: h in (hash1, hash2))
|
|
manager._paged_ssd_cache_manager = mock_ssd
|
|
|
|
# In-memory cache is empty, but SSD has the blocks
|
|
cached_blocks, num_tokens = manager.get_computed_blocks(tokens)
|
|
|
|
assert num_tokens == 8
|
|
assert len(cached_blocks) == 2
|
|
assert mock_ssd.has_block.call_count == 2
|
|
|
|
# Blocks should now be registered in memory
|
|
assert manager.cached_block_hash_to_block.get_block(hash1) is not None
|
|
assert manager.cached_block_hash_to_block.get_block(hash2) is not None
|
|
|
|
def test_get_computed_blocks_ssd_fallback_partial(self):
|
|
"""Test SSD fallback with partial hit (first block on SSD, second not)."""
|
|
manager = PagedCacheManager(
|
|
block_size=4, max_blocks=100, model_name="test-model", initial_blocks=100
|
|
)
|
|
|
|
tokens = [1, 2, 3, 4, 5, 6, 7, 8]
|
|
|
|
hash1 = compute_block_hash(None, [1, 2, 3, 4], model_name="test-model")
|
|
|
|
# Only first block exists on SSD
|
|
mock_ssd = MagicMock(spec=[])
|
|
mock_ssd.has_block = MagicMock(side_effect=lambda h: h == hash1)
|
|
manager._paged_ssd_cache_manager = mock_ssd
|
|
|
|
cached_blocks, num_tokens = manager.get_computed_blocks(tokens)
|
|
|
|
assert num_tokens == 4
|
|
assert len(cached_blocks) == 1
|
|
|
|
def test_get_computed_blocks_ssd_fallback_no_free_blocks(self):
|
|
"""SSD fallback should not raise when no free blocks are available."""
|
|
manager = PagedCacheManager(
|
|
block_size=4, max_blocks=2, model_name="test-model", initial_blocks=2
|
|
)
|
|
|
|
# block 0 is reserved null block; allocate the only remaining free block
|
|
# so fallback has no free blocks left to register SSD hits.
|
|
allocated = manager.allocate_block()
|
|
assert allocated is not None
|
|
assert manager.free_block_queue.num_free_blocks == 0
|
|
|
|
tokens = [1, 2, 3, 4]
|
|
hash1 = compute_block_hash(None, tokens, model_name="test-model")
|
|
|
|
mock_ssd = MagicMock(spec=[])
|
|
mock_ssd.has_block = MagicMock(side_effect=lambda h: h == hash1)
|
|
manager._paged_ssd_cache_manager = mock_ssd
|
|
|
|
# Robust behavior: graceful miss, not ValueError from popleft().
|
|
cached_blocks, num_tokens = manager.get_computed_blocks(tokens)
|
|
|
|
assert num_tokens == 0
|
|
assert cached_blocks == []
|
|
assert manager.stats.misses >= 1
|
|
|
|
def test_get_computed_blocks_ssd_fallback_updates_stats(self):
|
|
"""SSD fallback registration should keep cache stats in sync."""
|
|
manager = PagedCacheManager(
|
|
block_size=4, max_blocks=100, model_name="test-model", initial_blocks=100
|
|
)
|
|
|
|
tokens = [1, 2, 3, 4, 5, 6, 7, 8]
|
|
hash1 = compute_block_hash(None, [1, 2, 3, 4], model_name="test-model")
|
|
hash2 = compute_block_hash(hash1, [5, 6, 7, 8], model_name="test-model")
|
|
|
|
mock_ssd = MagicMock(spec=[])
|
|
mock_ssd.has_block = MagicMock(side_effect=lambda h: h in (hash1, hash2))
|
|
manager._paged_ssd_cache_manager = mock_ssd
|
|
|
|
initial_allocated = manager.stats.allocated_blocks
|
|
initial_free = manager.stats.free_blocks
|
|
|
|
cached_blocks, num_tokens = manager.get_computed_blocks(tokens)
|
|
|
|
assert len(cached_blocks) == 2
|
|
assert num_tokens == 8
|
|
assert manager.stats.allocated_blocks == initial_allocated + 2
|
|
assert manager.stats.free_blocks == initial_free - 2
|
|
|
|
def test_get_computed_blocks_no_ssd_no_regression(self):
|
|
"""Test that without SSD cache manager, behavior is unchanged."""
|
|
manager = PagedCacheManager(
|
|
block_size=4, max_blocks=100, model_name="test-model", initial_blocks=100
|
|
)
|
|
|
|
tokens = [1, 2, 3, 4, 5, 6, 7, 8]
|
|
|
|
# No SSD manager set
|
|
assert manager._paged_ssd_cache_manager is None
|
|
|
|
cached_blocks, num_tokens = manager.get_computed_blocks(tokens)
|
|
|
|
assert num_tokens == 0
|
|
assert len(cached_blocks) == 0
|