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

1501 lines
52 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Unit tests for PrefetchController.
Tests verify the end-to-end prefetch flow: submit request → lookup in L2 →
compute prefix-trimmed load plan → reserve L1 buffers → load from L2 →
transition to read-locked → report prefix hits.
Uses a real L1Manager and MockL2Adapter (with debug methods) to exercise
the full integration without mocking internals.
"""
# Standard
import time
# Third Party
import pytest
import torch
# First Party
from lmcache.native_storage_ops import Bitmap
from lmcache.v1.distributed.api import (
MemoryLayoutDesc,
ObjectKey,
PrefetchMode,
TrimPolicy,
)
from lmcache.v1.distributed.config import L1ManagerConfig, L1MemoryManagerConfig
from lmcache.v1.distributed.error import L1Error
from lmcache.v1.distributed.l1_manager import L1Manager
from lmcache.v1.distributed.l2_adapters.fault_inject_l2_adapter import (
FaultInjectL2Adapter,
)
from lmcache.v1.distributed.l2_adapters.mock_l2_adapter import (
MockL2Adapter,
MockL2AdapterConfig,
)
from lmcache.v1.distributed.storage_controllers.prefetch_controller import (
PrefetchController,
build_trim_mask,
merge_bitmaps,
)
from lmcache.v1.distributed.storage_controllers.prefetch_policy import (
DefaultPrefetchPolicy,
)
from lmcache.v1.distributed.storage_controllers.store_policy import (
AdapterDescriptor,
)
from lmcache.v1.memory_management import MemoryObjMetadata, TensorMemoryObj
# 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"
)
# =============================================================================
# Helpers
# =============================================================================
def make_object_key(chunk_id: int) -> ObjectKey:
"""Create a test ObjectKey with the given chunk ID."""
return ObjectKey(
chunk_hash=ObjectKey.IntHash2Bytes(chunk_id),
model_name="test_model",
kv_rank=0,
)
def make_layout() -> MemoryLayoutDesc:
"""Create a small MemoryLayoutDesc for testing."""
return MemoryLayoutDesc(
shapes=[torch.Size([100, 2, 512])],
dtypes=[torch.bfloat16],
)
def should_use_lazy_alloc() -> bool:
return torch.cuda.is_available()
def wait_for_condition(
predicate,
timeout: float = 5.0,
poll_interval: float = 0.05,
) -> bool:
"""Poll until a predicate returns True or timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(poll_interval)
return False
def wait_for_prefetch_result(
ctrl: PrefetchController,
req_id: int,
timeout: float = 5.0,
poll_interval: float = 0.05,
) -> int | None:
"""Poll query_prefetch_result until it returns a non-None value."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = ctrl.query_prefetch_result(req_id)
if result is not None:
return result.count_leading_ones()
time.sleep(poll_interval)
return None
def wait_for_lookup_result(
ctrl: PrefetchController,
req_id: int,
timeout: float = 5.0,
poll_interval: float = 0.05,
) -> int | None:
"""Poll query_lookup_result until it returns a non-None value."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = ctrl.query_lookup_result(req_id)
if result is not None:
return result
time.sleep(poll_interval)
return None
def wait_for_prefetch_result_bitmap(
ctrl: PrefetchController,
req_id: int,
timeout: float = 5.0,
poll_interval: float = 0.05,
):
"""Poll query_prefetch_result, returning the raw retained Bitmap.
Unlike :func:`wait_for_prefetch_result`, this keeps the full bitmap so a
caller can inspect non-contiguous retained sets (e.g. SEGMENTED_PREFIX).
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = ctrl.query_prefetch_result(req_id)
if result is not None:
return result
time.sleep(poll_interval)
return None
def make_adapter() -> MockL2Adapter:
"""Create a MockL2Adapter with fast bandwidth."""
config = MockL2AdapterConfig(max_size_gb=0.01, mock_bandwidth_gb=10.0)
return MockL2Adapter(config)
def make_descriptor(index: int) -> AdapterDescriptor:
"""Create an AdapterDescriptor for testing."""
config = MockL2AdapterConfig(max_size_gb=0.01, mock_bandwidth_gb=10.0)
return AdapterDescriptor(index=index, config=config)
def store_keys_in_l2(
adapter: MockL2Adapter,
keys: list[ObjectKey],
layout: MemoryLayoutDesc,
) -> None:
"""Store test data directly in L2 adapter and wait for completion."""
if not keys:
return
objs = []
for _ in keys:
tensor = torch.randn(layout.shapes[0], dtype=layout.dtypes[0])
metadata = MemoryObjMetadata(
shape=layout.shapes[0],
dtype=layout.dtypes[0],
address=0,
phy_size=tensor.nelement() * tensor.element_size(),
ref_count=0,
)
obj = TensorMemoryObj(raw_data=tensor, metadata=metadata, parent_allocator=None)
objs.append(obj)
adapter.submit_store_task(keys, objs) # type: ignore
ok = wait_for_condition(
lambda: all(adapter.debug_has_key(k) for k in keys),
timeout=5.0,
)
assert ok, "Failed to store test data in L2 adapter"
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def l1_manager():
"""Create an L1Manager with a reasonable memory config."""
config = L1ManagerConfig(
memory_config=L1MemoryManagerConfig(
size_in_bytes=128 * 1024 * 1024,
use_lazy=should_use_lazy_alloc(),
init_size_in_bytes=64 * 1024 * 1024,
align_bytes=0x1000,
),
write_ttl_seconds=600,
read_ttl_seconds=300,
)
mgr = L1Manager(config)
yield mgr
mgr.close()
# =============================================================================
# Lifecycle Tests
# =============================================================================
class TestPrefetchControllerLifecycle:
"""Test PrefetchController start/stop behavior."""
def test_start_stop(self, l1_manager):
"""Controller should start and stop cleanly."""
adapter = make_adapter()
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
ctrl.stop()
adapter.close()
def test_start_stop_no_adapters(self, l1_manager):
"""Controller should start and stop cleanly with no adapters."""
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[],
adapter_descriptors=[],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
ctrl.stop()
# =============================================================================
# Single Adapter Prefetch
# =============================================================================
class TestSingleAdapterPrefetch:
"""Test PrefetchController with one MockL2Adapter."""
def test_full_prefix_hit(self, l1_manager):
"""All keys in L2 → all loaded, prefix hits = total keys."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(5)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 5, f"Expected 5 prefix hits, got {result}"
# Verify prefix keys are read-locked in L1
read_results = l1_manager.unsafe_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.SUCCESS
# Cleanup: release read locks
l1_manager.finish_read(keys)
ctrl.stop()
adapter.close()
def test_prefix_with_gap(self, l1_manager):
"""L2 has keys {0,1,3,4} but not 2 → only prefix {0,1} loaded."""
adapter = make_adapter()
layout = make_layout()
all_keys = [make_object_key(i) for i in range(5)]
# Store only keys 0, 1, 3, 4 (gap at index 2)
stored_keys = [all_keys[i] for i in [0, 1, 3, 4]]
store_keys_in_l2(adapter, stored_keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(all_keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 2, f"Expected 2 prefix hits (gap at index 2), got {result}"
# Verify prefix keys {0,1} are read-locked in L1
prefix_keys = all_keys[:2]
read_results = l1_manager.unsafe_read(prefix_keys)
for key in prefix_keys:
assert read_results[key][0] == L1Error.SUCCESS
# Verify keys beyond prefix {2,3,4} are NOT in L1
non_prefix_keys = all_keys[2:]
read_results = l1_manager.reserve_read(non_prefix_keys)
for key in non_prefix_keys:
assert read_results[key][0] == L1Error.KEY_NOT_EXIST
l1_manager.finish_read(prefix_keys)
ctrl.stop()
adapter.close()
def test_segmented_prefix_with_gap(self, l1_manager):
"""SEGMENTED_PREFIX retains the post-gap keys: L2 {0,1,3,4} -> {0,1,3,4}.
The PREFIX counterpart (test_prefix_with_gap) truncates the same gap to
{0,1}; SEGMENTED_PREFIX keeps the post-gap chunks L1-resident so only
the hole (index 2) needs recomputing.
"""
adapter = make_adapter()
layout = make_layout()
all_keys = [make_object_key(i) for i in range(5)]
# Store only keys 0, 1, 3, 4 (gap at index 2).
store_keys_in_l2(adapter, [all_keys[i] for i in [0, 1, 3, 4]], layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(
all_keys, layout, policy=TrimPolicy.SEGMENTED_PREFIX
)
retained = wait_for_prefetch_result_bitmap(ctrl, req_id)
assert retained is not None
assert retained.get_indices_list() == [0, 1, 3, 4], (
"SEGMENTED_PREFIX should retain post-gap keys, got "
f"{retained.get_indices_list()}"
)
# Retained keys {0,1,3,4} are read-locked in L1; the gap (2) is absent.
retained_keys = [all_keys[i] for i in [0, 1, 3, 4]]
read = l1_manager.unsafe_read(retained_keys)
for key in retained_keys:
assert read[key][0] == L1Error.SUCCESS
gap_read = l1_manager.reserve_read([all_keys[2]])
assert gap_read[all_keys[2]][0] == L1Error.KEY_NOT_EXIST
l1_manager.finish_read(retained_keys)
ctrl.stop()
adapter.close()
def test_fault_inject_load_gap_segmented_vs_prefix(self, l1_manager):
"""fault_inject (load fails at index 2) drives the segmented path.
Lookup reports all 5 keys present; the *load* of index 2 fails (the L2
retrieve error the adapter simulates). SEGMENTED_PREFIX retains the
post-gap keys {0,1,3,4}; PREFIX truncates at the hole to {0,1}. Distinct
key ranges per policy keep the shared L1 from serving the second pass.
"""
layout = make_layout()
for base, trim, expected in (
(0, TrimPolicy.SEGMENTED_PREFIX, [0, 1, 3, 4]),
(10, TrimPolicy.PREFIX, [0, 1]),
):
keys = [make_object_key(base + i) for i in range(5)]
inner = make_adapter()
store_keys_in_l2(inner, keys, layout) # all 5 present at lookup
# Drop task-position 2 at load -> a mid-prefix L2 retrieve failure.
fault = FaultInjectL2Adapter(inner, rate=0.0, seed=0, gap_indices=(2,))
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[fault],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout, policy=trim)
retained = wait_for_prefetch_result_bitmap(ctrl, req_id)
assert retained is not None
assert retained.get_indices_list() == expected, (
f"{trim.name}: expected {expected}, got {retained.get_indices_list()}"
)
held = [keys[i] for i in retained.get_indices_list()]
if held:
l1_manager.finish_read(held)
ctrl.stop()
fault.close()
def test_key0_missing(self, l1_manager):
"""L2 has keys {1,2,3} but not 0 → prefix = 0, nothing loaded."""
adapter = make_adapter()
layout = make_layout()
all_keys = [make_object_key(i) for i in range(4)]
# Store keys 1, 2, 3 but not 0
stored_keys = all_keys[1:]
store_keys_in_l2(adapter, stored_keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(all_keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 0, f"Expected 0 prefix hits (key 0 missing), got {result}"
# Verify no keys are in L1
read_results = l1_manager.reserve_read(all_keys)
for key in all_keys:
assert read_results[key][0] == L1Error.KEY_NOT_EXIST
ctrl.stop()
adapter.close()
# =============================================================================
# Multi Adapter Prefetch
# =============================================================================
class TestMultiAdapterPrefetch:
"""Test PrefetchController with multiple MockL2Adapters."""
def test_disjoint_adapters(self, l1_manager):
"""Adapter 0 has {0,1}, adapter 1 has {2,3} → full prefix of 4."""
adapters = [make_adapter(), make_adapter()]
descriptors = [make_descriptor(i) for i in range(2)]
layout = make_layout()
keys = [make_object_key(i) for i in range(4)]
store_keys_in_l2(adapters[0], keys[:2], layout)
store_keys_in_l2(adapters[1], keys[2:], layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=adapters,
adapter_descriptors=descriptors,
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 4, f"Expected 4 prefix hits, got {result}"
read_results = l1_manager.unsafe_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.SUCCESS
l1_manager.finish_read(keys)
ctrl.stop()
for a in adapters:
a.close()
def test_overlap_first_wins(self, l1_manager):
"""Both adapters have key 1. Adapter 0 (lower index) loads it."""
adapters = [make_adapter(), make_adapter()]
descriptors = [make_descriptor(i) for i in range(2)]
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
# Adapter 0 has keys {0, 1}, adapter 1 has keys {1, 2}
store_keys_in_l2(adapters[0], keys[:2], layout)
store_keys_in_l2(adapters[1], keys[1:], layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=adapters,
adapter_descriptors=descriptors,
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3, f"Expected 3 prefix hits, got {result}"
read_results = l1_manager.unsafe_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.SUCCESS
l1_manager.finish_read(keys)
ctrl.stop()
for a in adapters:
a.close()
# =============================================================================
# No Hits
# =============================================================================
class TestNoHits:
"""Test PrefetchController when no keys are found in L2."""
def test_no_keys_in_l2(self, l1_manager):
"""Prefetch keys not in any L2 → 0 prefix hits."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
# Don't store anything in L2
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 0, f"Expected 0 prefix hits, got {result}"
ctrl.stop()
adapter.close()
def test_no_adapters(self, l1_manager):
"""No adapters → 0 prefix hits immediately."""
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[],
adapter_descriptors=[],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 0, f"Expected 0 prefix hits, got {result}"
ctrl.stop()
# =============================================================================
# Query Result
# =============================================================================
class TestQueryResult:
"""Test query_prefetch_result semantics."""
def test_query_returns_int_then_none(self, l1_manager):
"""Result is consumed on first query; second query returns None."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(2)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 2
# Second query should return None (already consumed)
assert ctrl.query_prefetch_result(req_id) is None
l1_manager.finish_read(keys)
ctrl.stop()
adapter.close()
def test_query_before_completion_returns_none(self, l1_manager):
"""Querying a nonexistent request ID returns None."""
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[],
adapter_descriptors=[],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
assert ctrl.query_prefetch_result(999) is None
ctrl.stop()
# =============================================================================
# L2 Lock Release
# =============================================================================
class TestPrefetchL2LockRelease:
"""Test that L2 locks are properly released after prefetch."""
def test_locks_released_after_full_hit(self, l1_manager):
"""All L2 locks should be released after a successful prefetch."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3
# Wait for L2 unlock operations to be processed
ok = wait_for_condition(
lambda: adapter.debug_get_locked_key_count() == 0,
timeout=5.0,
)
assert ok, "L2 locks should be released after prefetch completion"
l1_manager.finish_read(keys)
ctrl.stop()
adapter.close()
def test_locks_released_after_prefix_trim(self, l1_manager):
"""L2 locks released for both prefix and non-prefix keys."""
adapter = make_adapter()
layout = make_layout()
all_keys = [make_object_key(i) for i in range(5)]
# Store keys 0, 1, 3, 4 (gap at index 2)
stored_keys = [all_keys[i] for i in [0, 1, 3, 4]]
store_keys_in_l2(adapter, stored_keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(all_keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 2
# All L2 locks should be released (both prefix and trimmed keys)
ok = wait_for_condition(
lambda: adapter.debug_get_locked_key_count() == 0,
timeout=5.0,
)
assert ok, "All L2 locks should be released after prefix-trimmed prefetch"
l1_manager.finish_read(all_keys[:2])
ctrl.stop()
adapter.close()
def test_locks_released_after_no_hits(self, l1_manager):
"""L2 locks released even when nothing is found."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
# Don't store anything
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 0
ok = wait_for_condition(
lambda: adapter.debug_get_locked_key_count() == 0,
timeout=5.0,
)
assert ok, "L2 locks should be 0 when nothing was found"
ctrl.stop()
adapter.close()
def test_multi_adapter_locks_released(self, l1_manager):
"""Both adapters' L2 locks released after overlapping prefetch."""
adapters = [make_adapter(), make_adapter()]
descriptors = [make_descriptor(i) for i in range(2)]
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
# Adapter 0 has {0, 1}, adapter 1 has {1, 2}
store_keys_in_l2(adapters[0], keys[:2], layout)
store_keys_in_l2(adapters[1], keys[1:], layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=adapters,
adapter_descriptors=descriptors,
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3
# Both adapters should have all locks released
for i, adapter in enumerate(adapters):
ok = wait_for_condition(
lambda a=adapter: a.debug_get_locked_key_count() == 0,
timeout=5.0,
)
assert ok, f"Adapter {i} should have all L2 locks released"
l1_manager.finish_read(keys)
ctrl.stop()
for a in adapters:
a.close()
# =============================================================================
# Max In-Flight
# =============================================================================
class TestMaxInFlight:
"""Test PrefetchController max in-flight request limiting."""
def test_queuing_beyond_max_in_flight(self, l1_manager):
"""Submit more requests than max_in_flight → all eventually complete."""
adapter = make_adapter()
layout = make_layout()
# Store keys for 4 separate requests (2 keys each)
all_keys = [make_object_key(i) for i in range(8)]
store_keys_in_l2(adapter, all_keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
max_in_flight=2,
)
ctrl.start()
# Submit 4 requests (max_in_flight=2, so 2 queued)
req_ids = []
for i in range(4):
batch_keys = all_keys[i * 2 : (i + 1) * 2]
req_id = ctrl.submit_prefetch_request(batch_keys, layout)
req_ids.append(req_id)
# All 4 requests should eventually complete
results = []
for req_id in req_ids:
result = wait_for_prefetch_result(ctrl, req_id, timeout=10.0)
assert result is not None, f"Request {req_id} should complete"
results.append(result)
assert results == [2, 2, 2, 2]
# Release read locks for all keys
l1_manager.finish_read(all_keys)
ctrl.stop()
adapter.close()
# =============================================================================
# Multiple Sequential Requests
# =============================================================================
class TestMultipleRequests:
"""Test multiple sequential prefetch requests."""
def test_two_sequential_requests(self, l1_manager):
"""Two back-to-back requests should both complete correctly."""
adapter = make_adapter()
layout = make_layout()
keys1 = [make_object_key(i) for i in range(3)]
keys2 = [make_object_key(i) for i in range(10, 14)]
store_keys_in_l2(adapter, keys1, layout)
store_keys_in_l2(adapter, keys2, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req1 = ctrl.submit_prefetch_request(keys1, layout)
result1 = wait_for_prefetch_result(ctrl, req1)
assert result1 == 3
req2 = ctrl.submit_prefetch_request(keys2, layout)
result2 = wait_for_prefetch_result(ctrl, req2)
assert result2 == 4
l1_manager.finish_read(keys1)
l1_manager.finish_read(keys2)
ctrl.stop()
adapter.close()
# =============================================================================
# extra_count Path
# =============================================================================
class TestExtraCountPrefetch:
"""Test that extra_count is correctly propagated through the prefetch path.
When extra_count=N is passed to submit_prefetch_request, the controller
must acquire 1 + N read locks per key (one for the prefetch controller
itself, plus N for additional TP workers). Each consumer must call
finish_read (or finish_read_prefetched) once to release its lock.
These tests verify:
1. Keys remain accessible after the first finish_read when extra_count > 0.
2. Keys are evictable only after ALL 1 + N locks are released.
3. extra_count=0 (default) behaves identically to the original single-lock
path.
4. Prefix trimming still works correctly with extra_count > 0.
5. Non-prefix loaded keys have all extra locks released by _finalize_load.
"""
def test_extra_count_zero_default_behavior(self, l1_manager):
"""extra_count=0 (default): single read lock, key freed after one
finish_read."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=0)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3
# Keys should be readable immediately after prefetch
read_results = l1_manager.unsafe_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.SUCCESS
# Release the single read lock — keys should become unlocked
finish_results = l1_manager.finish_read(keys, extra_count=0)
for key in keys:
assert finish_results[key] == L1Error.SUCCESS
ctrl.stop()
adapter.close()
def test_extra_count_one_requires_two_finish_reads(self, l1_manager):
"""extra_count=1: two read locks acquired; key stays locked after
first finish_read and is released after second."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
# extra_count=1 → 2 read locks per key
req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=1)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3
# Keys must be readable right after prefetch
read_results = l1_manager.unsafe_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.SUCCESS
# Release lock #1 (the "prefetch controller" lock, extra_count=0)
finish_results = l1_manager.finish_read(keys, extra_count=0)
for key in keys:
assert finish_results[key] == L1Error.SUCCESS
# Keys must STILL be readable — lock #2 (the TP worker lock) is held
read_results2 = l1_manager.unsafe_read(keys)
for key in keys:
assert read_results2[key][0] == L1Error.SUCCESS, (
f"Key {key} should still be read-locked after first finish_read"
)
# Release lock #2 (the TP worker lock, extra_count=0)
finish_results2 = l1_manager.finish_read(keys, extra_count=0)
for key in keys:
assert finish_results2[key] == L1Error.SUCCESS
ctrl.stop()
adapter.close()
def test_extra_count_three_requires_four_finish_reads(self, l1_manager):
"""extra_count=3 (TP=4): four read locks; key stays locked until all
four are released."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(2)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
# extra_count=3 → 4 read locks per key
req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=3)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 2
# Release locks one by one; key must remain readable until the last
for release_idx in range(3):
finish_results = l1_manager.finish_read(keys, extra_count=0)
for key in keys:
assert finish_results[key] == L1Error.SUCCESS
# Still readable — remaining locks are held
read_results = l1_manager.unsafe_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.SUCCESS, (
f"Key {key} should still be locked after {release_idx + 1} "
f"finish_read calls"
)
# Release the final lock
finish_results = l1_manager.finish_read(keys, extra_count=0)
for key in keys:
assert finish_results[key] == L1Error.SUCCESS
ctrl.stop()
adapter.close()
def test_extra_count_with_prefix_trim(self, l1_manager):
"""extra_count=1 with a gap in L2: only prefix keys get 2 locks;
non-prefix keys are never loaded."""
adapter = make_adapter()
layout = make_layout()
all_keys = [make_object_key(i) for i in range(5)]
# Store keys 0, 1, 3, 4 — gap at index 2
stored_keys = [all_keys[i] for i in [0, 1, 3, 4]]
store_keys_in_l2(adapter, stored_keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(all_keys, layout, extra_count=1)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 2, f"Expected 2 prefix hits (gap at index 2), got {result}"
prefix_keys = all_keys[:2]
# Prefix keys must be readable
read_results = l1_manager.unsafe_read(prefix_keys)
for key in prefix_keys:
assert read_results[key][0] == L1Error.SUCCESS
# Release lock #1 — prefix keys still held by lock #2
l1_manager.finish_read(prefix_keys, extra_count=0)
read_results2 = l1_manager.unsafe_read(prefix_keys)
for key in prefix_keys:
assert read_results2[key][0] == L1Error.SUCCESS, (
f"Prefix key {key} should still be locked after first finish_read"
)
# Release lock #2
l1_manager.finish_read(prefix_keys, extra_count=0)
# Non-prefix keys must NOT be in L1
non_prefix_keys = all_keys[2:]
reserve_results = l1_manager.reserve_read(non_prefix_keys)
for key in non_prefix_keys:
assert reserve_results[key][0] == L1Error.KEY_NOT_EXIST, (
f"Non-prefix key {key} should not be in L1"
)
ctrl.stop()
adapter.close()
def test_extra_count_non_prefix_loaded_keys_fully_released(self, l1_manager):
"""Keys loaded beyond the prefix (due to partial load failure) must
have ALL extra locks released by _finalize_load so they can be evicted.
We simulate this by storing keys {0, 1, 2} in L2 but making key 1
fail to reserve in L1 (by pre-occupying it), creating a gap so that
key 2 is loaded but lies beyond the prefix. _finalize_load must
release 1 + extra_count locks for key 2.
"""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
# Pre-occupy key[1] in L1 with a write lock so reserve_write fails for it.
# This forces a gap: key 0 is prefix (hit), key 1 fails reservation
# (gap), key 2 is loaded but beyond the prefix.
pre_write_results = l1_manager.reserve_write(
keys=[keys[1]],
is_temporary=[False],
layout_desc=layout,
mode="new",
)
assert pre_write_results[keys[1]][0] == L1Error.SUCCESS
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=1)
result = wait_for_prefetch_result(ctrl, req_id)
# Only key 0 is in the prefix (key 1 reservation failed → gap)
assert result == 1, f"Expected 1 prefix hit, got {result}"
# key[0] should be in L1 with 2 read locks (1 + extra_count=1)
read_results = l1_manager.unsafe_read([keys[0]])
assert read_results[keys[0]][0] == L1Error.SUCCESS
# key[2] was loaded but is beyond the prefix; _finalize_load must have
# released all 1 + extra_count=2 locks, so it should be gone from L1
# (it's a temporary object and its lock count should be 0).
reserve_results = l1_manager.reserve_read([keys[2]])
assert reserve_results[keys[2]][0] == L1Error.KEY_NOT_EXIST, (
"Non-prefix loaded key[2] should have all locks released and be "
"evicted from L1"
)
# Clean up: release key[0]'s 2 locks and key[1]'s write lock
l1_manager.finish_read([keys[0]], extra_count=0)
l1_manager.finish_read([keys[0]], extra_count=0)
l1_manager.finish_write([keys[1]])
l1_manager.delete([keys[1]])
ctrl.stop()
adapter.close()
# =============================================================================
# Query Lookup Result
# =============================================================================
class TestQueryLookupResult:
"""Test query_lookup_result semantics."""
def test_lookup_result_available_before_prefetch_completes(self, l1_manager):
"""Lookup result is available while load is still in progress."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
# Lookup result should be available before or at the same time as
# the full prefetch result.
lookup_hits = wait_for_lookup_result(ctrl, req_id)
assert lookup_hits is not None
assert lookup_hits == 3
# Wait for full prefetch to complete and clean up
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3
l1_manager.finish_read(keys)
ctrl.stop()
adapter.close()
def test_lookup_result_with_prefix_gap(self, l1_manager):
"""Lookup result reflects prefix-only hits (gap breaks prefix)."""
adapter = make_adapter()
layout = make_layout()
# Store keys 0 and 2 (gap at 1)
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, [keys[0], keys[2]], layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
lookup_hits = wait_for_lookup_result(ctrl, req_id)
assert lookup_hits is not None
# Only key 0 is in the prefix (gap at key 1 breaks it)
assert lookup_hits == 1
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 1
l1_manager.finish_read([keys[0]])
ctrl.stop()
adapter.close()
def test_lookup_result_zero_hits(self, l1_manager):
"""Lookup result is 0 when no keys are found in L2."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
# Don't store anything in L2
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
lookup_hits = wait_for_lookup_result(ctrl, req_id)
assert lookup_hits is not None
assert lookup_hits == 0
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 0
ctrl.stop()
adapter.close()
def test_lookup_result_not_popped_by_query(self, l1_manager):
"""query_lookup_result does not consume the result (idempotent reads)."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(2)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
lookup_hits = wait_for_lookup_result(ctrl, req_id)
assert lookup_hits == 2
# Second query should still return the same value (not consumed)
assert ctrl.query_lookup_result(req_id) == 2
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 2
l1_manager.finish_read(keys)
ctrl.stop()
adapter.close()
def test_lookup_result_cleaned_up_by_prefetch_result(self, l1_manager):
"""query_prefetch_result cleans up the lookup result entry."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(2)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
lookup_hits = wait_for_lookup_result(ctrl, req_id)
assert lookup_hits == 2
# Consume the prefetch result (should also clean up lookup entry)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 2
# Lookup result should now be gone
assert ctrl.query_lookup_result(req_id) is None
l1_manager.finish_read(keys)
ctrl.stop()
adapter.close()
def test_lookup_result_nonexistent_request(self, l1_manager):
"""Querying a nonexistent request ID returns None."""
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[],
adapter_descriptors=[],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
assert ctrl.query_lookup_result(999) is None
ctrl.stop()
class TestBuildTrimMask:
"""build_trim_mask picks the retained subset per policy: PREFIX trims at
the first gap; SEGMENTED_PREFIX and SPARSE keep every set bit (gaps and
all). The retained bitmap is consumed unchanged at the controller's load
sites, so testing the mask directly covers the policy semantics."""
@staticmethod
def _bm(n, idxs):
bm = Bitmap(n)
for i in idxs:
bm.set(i)
return bm
def test_prefix_trims_at_first_gap(self):
found = self._bm(5, [0, 1, 3, 4]) # gap at index 2
assert build_trim_mask(found, 5, TrimPolicy.PREFIX).get_indices_list() == [
0,
1,
]
def test_segmented_prefix_keeps_gaps(self):
# Models an L2 hit whose L1 load failed mid-prefix (e.g. OOM at index
# 2): the keys that did load are kept, not trimmed to the first gap.
found = self._bm(5, [0, 1, 3, 4])
assert build_trim_mask(
found, 5, TrimPolicy.SEGMENTED_PREFIX
).get_indices_list() == [0, 1, 3, 4]
def test_sparse_keeps_all_found(self):
found = self._bm(5, [0, 2, 4])
assert build_trim_mask(found, 5, TrimPolicy.SPARSE).get_indices_list() == [
0,
2,
4,
]
class TestMergeBitmaps:
"""merge_bitmaps always returns a num_keys-sized bitmap."""
def test_empty_input_returns_sized_bitmap(self):
"""Empty input -> num_keys-sized all-zeros bitmap (not Bitmap(0)), so a
downstream ``&`` with a same-sized mask never hits a size mismatch."""
merged = merge_bitmaps([], 5)
assert merged.popcount() == 0
mask = Bitmap(5)
mask.set(2)
assert (merged & mask).popcount() == 0 # would raise on size mismatch
def test_empty_generator_returns_sized_bitmap(self):
"""A generator is truthy even when empty; the result is still size-5."""
merged = merge_bitmaps((b for b in []), 5)
assert merged.popcount() == 0
assert (merged & Bitmap(5)).popcount() == 0
def test_union_of_bitmaps(self):
"""Non-empty inputs are OR-merged into one num_keys-sized bitmap."""
a, b = Bitmap(5), Bitmap(5)
a.set(0)
b.set(3)
assert merge_bitmaps([a, b], 5).get_indices_list() == [0, 3]
class TestWaitPrefetchResult:
"""Test the blocking wait_prefetch_result interface."""
def test_wait_blocks_until_result_ready(self, l1_manager):
"""wait_prefetch_result blocks until the background result is published,
returns True, and does not consume the result."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(5)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
# Blocks until the background thread publishes the result.
assert ctrl.wait_prefetch_result(req_id, timeout=10.0) is True
# wait_prefetch_result must not consume the result.
result = ctrl.query_prefetch_result(req_id)
assert result is not None
assert result.count_leading_ones() == 5
l1_manager.finish_read(keys)
ctrl.stop()
adapter.close()
def test_wait_times_out_for_unknown_request(self, l1_manager):
"""wait_prefetch_result returns False, after genuinely waiting, when no
result arrives within the timeout."""
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[],
adapter_descriptors=[],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
start = time.monotonic()
assert ctrl.wait_prefetch_result(999999, timeout=0.2) is False
assert time.monotonic() - start >= 0.2
ctrl.stop()
# =============================================================================
# Retention Policy
# =============================================================================
class TestPrefetchMode:
"""``mode=WARM`` (the warm path) loads keys **permanent** and
**without a read lock**, vs ``LOOKUP`` which read-locks temporary
objects that vanish on release.
Both tests use ``DefaultPrefetchPolicy`` so the only difference is the
per-request ``mode`` argument.
"""
def test_warm_loads_unlocked_and_permanent(self, l1_manager):
"""WARM loads keys permanent and with NO read lock: immediately ready
(reserve_read SUCCEEDS), holding no lock (unsafe_read NOT_READABLE), and
not deleted on a reserve_read/finish_read cycle (permanent, not temp)."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout, mode=PrefetchMode.WARM)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3
# No warming lock: unsafe_read (which needs an active read lock) reports
# NOT_READABLE even though the keys are present and ready.
unsafe = l1_manager.unsafe_read(keys)
for key in keys:
assert unsafe[key][0] == L1Error.KEY_NOT_READABLE
# They are ready and re-lookupable: reserve_read SUCCEEDS...
read_results = l1_manager.reserve_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.SUCCESS
# ...and releasing that probe lock does NOT delete them (permanent).
l1_manager.finish_read(keys)
again = l1_manager.reserve_read(keys)
for key in keys:
assert again[key][0] == L1Error.SUCCESS
l1_manager.finish_read(keys)
l1_manager.delete(keys)
ctrl.stop()
adapter.close()
def test_warm_sparse_skips_write_locked_prefix_and_loads_later_keys(
self, l1_manager
):
"""SPARSE WARM does not let a write-locked earlier key suppress later
keys that can be reserved and loaded."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
existing = l1_manager.reserve_write(
[keys[0]], is_temporary=[False], layout_desc=layout, mode="new"
)
assert existing[keys[0]][0] == L1Error.SUCCESS
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(
keys,
layout,
policy=TrimPolicy.SPARSE,
mode=PrefetchMode.WARM,
)
result = wait_for_prefetch_result_bitmap(ctrl, req_id)
assert result is not None
assert result.get_indices_list() == [1, 2]
l1_manager.finish_write([keys[0]])
read_results = l1_manager.reserve_read(keys[1:])
for key in keys[1:]:
assert read_results[key][0] == L1Error.SUCCESS
l1_manager.finish_read(keys[1:])
l1_manager.delete(keys)
ctrl.stop()
adapter.close()
def test_default_deletes_keys_after_finish_read(self, l1_manager):
"""LOOKUP defers to ``DefaultPrefetchPolicy`` (temporary), so
the keys are deleted from L1 once the read-lock is released."""
adapter = make_adapter()
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
store_keys_in_l2(adapter, keys, layout)
ctrl = PrefetchController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultPrefetchPolicy(),
)
ctrl.start()
req_id = ctrl.submit_prefetch_request(keys, layout)
result = wait_for_prefetch_result(ctrl, req_id)
assert result == 3
# Releasing the read-lock deletes the temporary objects, so a
# subsequent lookup (reserve_read) misses with KEY_NOT_EXIST.
l1_manager.finish_read(keys)
read_results = l1_manager.reserve_read(keys)
for key in keys:
assert read_results[key][0] == L1Error.KEY_NOT_EXIST
ctrl.stop()
adapter.close()