402 lines
14 KiB
Python
402 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Regression guards for supersede-on-extend rotating tip stripping (#1795).
|
|
|
|
On sliding-window models every store of a growing conversation writes one
|
|
tip block carrying the full rotating state of all sliding layers (~205-410MB
|
|
fp16 on a gemma3-class 25-sliding-layer model). Restore only ever consumes
|
|
the newest such block, and the immediate previous tip serves as the
|
|
walk-back fallback — so the tip two generations back is dead weight. Before
|
|
the fix those blocks accumulated one per turn, filled the hot cache after
|
|
~10-20 turns, and LRU eviction broke the prefix chain (multi-turn cache hit
|
|
collapsed permanently to 0%).
|
|
|
|
The fix tracks tip lineage in store_cache and strips the grandparent tip's
|
|
rotating payload down to the standard placeholder, keeping sliceable
|
|
(KVCache) layer slices intact and keeping the hot-cache / shared-budget byte
|
|
counters consistent (rewrite goes through forget_block + save_block).
|
|
|
|
Tests:
|
|
- test_grandparent_tip_stripped_prev_tip_kept — 4-turn chain: tip two
|
|
generations back is stripped, immediate previous tip stays intact.
|
|
- test_stripped_block_keeps_sliceable_layers — KVCache slice survives the
|
|
strip; only the rotating layer becomes a placeholder.
|
|
- test_hot_cache_byte_counter_consistent — _hot_cache_total_bytes matches
|
|
the recomputed entry sizes and decreases after the strip.
|
|
- test_shared_budget_counter_consistent — SharedHotCacheBudget accounting
|
|
matches the owner's hot cache bytes after the strip.
|
|
- test_kvcache_only_model_unaffected — no rotating layers: no lineage
|
|
tracking, no rewrites.
|
|
- test_prefill_ready_rotating_name_stripped — family detection covers the
|
|
PrefillReadyRotatingKVCache subclass name used by warm-restored caches.
|
|
- test_registry_rotating_family — registry-level family matching.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from omlx.cache._rotating_subclass import PrefillReadyRotatingKVCache
|
|
from omlx.cache.paged_cache import BlockTable, PagedCacheManager
|
|
from omlx.cache.paged_ssd_cache import PagedSSDCacheManager, SharedHotCacheBudget
|
|
from omlx.cache.prefix_cache import BlockAwarePrefixCache
|
|
from omlx.cache.type_registry import CacheTypeRegistry
|
|
|
|
try:
|
|
import mlx.core as mx
|
|
|
|
HAS_MLX = True
|
|
except ImportError:
|
|
HAS_MLX = False
|
|
|
|
pytestmark = pytest.mark.skipif(not HAS_MLX, reason="MLX not available")
|
|
|
|
BLOCK_SIZE = 4
|
|
WINDOW = 4
|
|
|
|
|
|
class MockModel:
|
|
def __init__(self, num_layers: int = 2):
|
|
self._num_layers = num_layers
|
|
self.layers = [MagicMock() for _ in range(num_layers)]
|
|
|
|
@property
|
|
def args(self):
|
|
a = MagicMock()
|
|
a.num_hidden_layers = self._num_layers
|
|
return a
|
|
|
|
|
|
def _make_cache(tmp_path, budget=None):
|
|
"""A prefix cache wired to a real hot-cache-only SSD manager."""
|
|
paged_cache = PagedCacheManager(
|
|
block_size=BLOCK_SIZE,
|
|
max_blocks=100,
|
|
model_name="test-model",
|
|
initial_blocks=100,
|
|
)
|
|
ssd = PagedSSDCacheManager(
|
|
cache_dir=tmp_path / "ssd_cache",
|
|
max_size_bytes=100 * 1024**2,
|
|
hot_cache_max_bytes=10 * 1024**2,
|
|
hot_cache_only=True,
|
|
hot_cache_budget=budget,
|
|
)
|
|
cache = BlockAwarePrefixCache(
|
|
model=MockModel(num_layers=2),
|
|
paged_cache_manager=paged_cache,
|
|
paged_ssd_cache_manager=ssd,
|
|
)
|
|
return cache, ssd
|
|
|
|
|
|
def _hybrid_cache_data(seq_len, rotating_type="RotatingKVCache"):
|
|
"""Gemma3-style hybrid: one sliceable KVCache + one rotating layer."""
|
|
return [
|
|
{
|
|
"state": (
|
|
mx.ones((1, 2, seq_len, 8)),
|
|
mx.ones((1, 2, seq_len, 8)),
|
|
),
|
|
"cache_type": "KVCache",
|
|
"class_name": "KVCache",
|
|
"meta_state": (str(seq_len),),
|
|
},
|
|
{
|
|
"state": (
|
|
mx.ones((1, 2, WINDOW, 8)),
|
|
mx.ones((1, 2, WINDOW, 8)),
|
|
),
|
|
"cache_type": rotating_type,
|
|
"class_name": rotating_type,
|
|
"meta_state": ("0", str(WINDOW), str(seq_len), str(WINDOW)),
|
|
},
|
|
]
|
|
|
|
|
|
def _kvcache_only_data(seq_len):
|
|
return [
|
|
{
|
|
"state": (
|
|
mx.ones((1, 2, seq_len, 8)),
|
|
mx.ones((1, 2, seq_len, 8)),
|
|
),
|
|
"cache_type": "KVCache",
|
|
"class_name": "KVCache",
|
|
"meta_state": (str(seq_len),),
|
|
}
|
|
for _ in range(2)
|
|
]
|
|
|
|
|
|
def _store_turn(cache, turn, num_blocks, data_fn=_hybrid_cache_data):
|
|
"""Simulate one conversation turn: store a chain of num_blocks blocks.
|
|
|
|
Each turn uses a fresh request id (as real multi-turn requests do);
|
|
earlier blocks dedup against the existing chain and only the new tail
|
|
is allocated and saved.
|
|
"""
|
|
tokens = list(range(num_blocks * BLOCK_SIZE))
|
|
table = cache.store_cache(f"turn-{turn}", tokens, data_fn(seq_len=len(tokens)))
|
|
assert table is not None
|
|
assert len(table.block_ids) == num_blocks
|
|
return table
|
|
|
|
|
|
def _block_hash(cache, table, idx):
|
|
block = cache.paged_cache.allocated_blocks[table.block_ids[idx]]
|
|
assert block.block_hash is not None
|
|
return block.block_hash
|
|
|
|
|
|
def _rotating_layer_shape(ssd, block_hash):
|
|
data, meta = ssd.load_block_with_metadata(block_hash)
|
|
assert data is not None and meta is not None
|
|
types = meta["layer_cache_types"]
|
|
for i, type_name in enumerate(types):
|
|
if CacheTypeRegistry.is_rotating_family(type_name):
|
|
return tuple(data[i][0].shape)
|
|
raise AssertionError("no rotating layer in block")
|
|
|
|
|
|
def _kvcache_layer_shape(ssd, block_hash):
|
|
data, meta = ssd.load_block_with_metadata(block_hash)
|
|
assert data is not None and meta is not None
|
|
types = meta["layer_cache_types"]
|
|
for i, type_name in enumerate(types):
|
|
if type_name == "KVCache":
|
|
return tuple(data[i][0].shape)
|
|
raise AssertionError("no KVCache layer in block")
|
|
|
|
|
|
PLACEHOLDER_SHAPE = (1,)
|
|
REAL_ROTATING_SHAPE = (1, 2, WINDOW, 8)
|
|
|
|
|
|
def test_grandparent_tip_stripped_prev_tip_kept(tmp_path):
|
|
"""Tip two generations back is stripped; previous tip stays intact."""
|
|
cache, ssd = _make_cache(tmp_path)
|
|
|
|
t1 = _store_turn(cache, 1, num_blocks=2)
|
|
tip1 = _block_hash(cache, t1, -1)
|
|
assert _rotating_layer_shape(ssd, tip1) == REAL_ROTATING_SHAPE
|
|
|
|
t2 = _store_turn(cache, 2, num_blocks=3)
|
|
tip2 = _block_hash(cache, t2, -1)
|
|
# tip1 is now the immediate previous tip: kept as walk-back fallback.
|
|
assert _rotating_layer_shape(ssd, tip1) == REAL_ROTATING_SHAPE
|
|
assert _rotating_layer_shape(ssd, tip2) == REAL_ROTATING_SHAPE
|
|
|
|
t3 = _store_turn(cache, 3, num_blocks=4)
|
|
tip3 = _block_hash(cache, t3, -1)
|
|
# tip1 is two generations back: rotating payload stripped.
|
|
assert _rotating_layer_shape(ssd, tip1) == PLACEHOLDER_SHAPE
|
|
assert _rotating_layer_shape(ssd, tip2) == REAL_ROTATING_SHAPE
|
|
assert _rotating_layer_shape(ssd, tip3) == REAL_ROTATING_SHAPE
|
|
|
|
t4 = _store_turn(cache, 4, num_blocks=5)
|
|
tip4 = _block_hash(cache, t4, -1)
|
|
# Steady state: exactly the two newest tips stay heavy.
|
|
assert _rotating_layer_shape(ssd, tip2) == PLACEHOLDER_SHAPE
|
|
assert _rotating_layer_shape(ssd, tip3) == REAL_ROTATING_SHAPE
|
|
assert _rotating_layer_shape(ssd, tip4) == REAL_ROTATING_SHAPE
|
|
|
|
|
|
def test_stripped_block_keeps_sliceable_layers(tmp_path):
|
|
"""Only the rotating layer is stripped; the KVCache slice survives,
|
|
and the stripped layer reads back as a standard placeholder."""
|
|
cache, ssd = _make_cache(tmp_path)
|
|
|
|
t1 = _store_turn(cache, 1, num_blocks=2)
|
|
tip1 = _block_hash(cache, t1, -1)
|
|
kv_shape_before = _kvcache_layer_shape(ssd, tip1)
|
|
|
|
_store_turn(cache, 2, num_blocks=3)
|
|
_store_turn(cache, 3, num_blocks=4)
|
|
|
|
assert _rotating_layer_shape(ssd, tip1) == PLACEHOLDER_SHAPE
|
|
assert _kvcache_layer_shape(ssd, tip1) == kv_shape_before
|
|
|
|
# The stripped layer must look like the standard last-block-only
|
|
# placeholder so restore's walk-back/reject path handles it natively.
|
|
data, meta = ssd.load_block_with_metadata(tip1)
|
|
rotating_idx = next(
|
|
i
|
|
for i, t in enumerate(meta["layer_cache_types"])
|
|
if CacheTypeRegistry.is_rotating_family(t)
|
|
)
|
|
assert cache._is_placeholder_state(data[rotating_idx])
|
|
|
|
|
|
def test_hot_cache_byte_counter_consistent(tmp_path):
|
|
"""The stripped entry shrinks and the byte counter stays exact."""
|
|
cache, ssd = _make_cache(tmp_path)
|
|
|
|
t1 = _store_turn(cache, 1, num_blocks=2)
|
|
tip1 = _block_hash(cache, t1, -1)
|
|
_store_turn(cache, 2, num_blocks=3)
|
|
tip1_size_before = ssd._hot_cache_entry_size(ssd._hot_cache[tip1])
|
|
|
|
_store_turn(cache, 3, num_blocks=4)
|
|
|
|
# The superseded tip's hot-cache entry must have shrunk in place.
|
|
tip1_size_after = ssd._hot_cache_entry_size(ssd._hot_cache[tip1])
|
|
assert tip1_size_after < tip1_size_before
|
|
# And the counter must equal the recomputed sum of live entry sizes.
|
|
expected = sum(
|
|
ssd._hot_cache_entry_size(entry) for entry in ssd._hot_cache.values()
|
|
)
|
|
assert ssd._hot_cache_total_bytes == expected
|
|
|
|
|
|
def test_shared_budget_counter_consistent(tmp_path):
|
|
"""Shared budget accounting matches the owner's hot-cache bytes."""
|
|
budget = SharedHotCacheBudget(max_bytes=10 * 1024**2)
|
|
cache, ssd = _make_cache(tmp_path, budget=budget)
|
|
|
|
_store_turn(cache, 1, num_blocks=2)
|
|
_store_turn(cache, 2, num_blocks=3)
|
|
_store_turn(cache, 3, num_blocks=4)
|
|
|
|
assert budget.total_bytes == ssd._hot_cache_total_bytes
|
|
expected = sum(
|
|
ssd._hot_cache_entry_size(entry) for entry in ssd._hot_cache.values()
|
|
)
|
|
assert budget.total_bytes == expected
|
|
|
|
|
|
def test_kvcache_only_model_unaffected(tmp_path):
|
|
"""Models without rotating layers never track lineage or rewrite."""
|
|
cache, ssd = _make_cache(tmp_path)
|
|
|
|
t1 = _store_turn(cache, 1, num_blocks=2, data_fn=_kvcache_only_data)
|
|
tip1 = _block_hash(cache, t1, -1)
|
|
kv_before = _kvcache_layer_shape(ssd, tip1)
|
|
|
|
_store_turn(cache, 2, num_blocks=3, data_fn=_kvcache_only_data)
|
|
_store_turn(cache, 3, num_blocks=4, data_fn=_kvcache_only_data)
|
|
|
|
assert cache._rotating_tip_lineage == {}
|
|
assert _kvcache_layer_shape(ssd, tip1) == kv_before
|
|
|
|
|
|
def test_prefill_ready_rotating_name_stripped(tmp_path):
|
|
"""Warm-restored caches serialize as PrefillReadyRotatingKVCache; the
|
|
family match must strip those tips too (exact-name comparison broke
|
|
here historically)."""
|
|
cache, ssd = _make_cache(tmp_path)
|
|
|
|
def data_fn(seq_len):
|
|
return _hybrid_cache_data(seq_len, rotating_type="PrefillReadyRotatingKVCache")
|
|
|
|
t1 = _store_turn(cache, 1, num_blocks=2, data_fn=data_fn)
|
|
tip1 = _block_hash(cache, t1, -1)
|
|
assert _rotating_layer_shape(ssd, tip1) == REAL_ROTATING_SHAPE
|
|
|
|
_store_turn(cache, 2, num_blocks=3, data_fn=data_fn)
|
|
_store_turn(cache, 3, num_blocks=4, data_fn=data_fn)
|
|
|
|
assert _rotating_layer_shape(ssd, tip1) == PLACEHOLDER_SHAPE
|
|
|
|
|
|
def test_prefill_ready_rotating_name_reconstructs_with_rotating_handler():
|
|
"""Restore must route PrefillReadyRotatingKVCache metadata through the
|
|
strict rotating path, not the ArraysCache-style token_count path."""
|
|
paged_cache = PagedCacheManager(
|
|
block_size=BLOCK_SIZE,
|
|
max_blocks=100,
|
|
model_name="test-model",
|
|
initial_blocks=100,
|
|
)
|
|
mock_ssd = MagicMock()
|
|
cache = BlockAwarePrefixCache(
|
|
model=MockModel(num_layers=2),
|
|
paged_cache_manager=paged_cache,
|
|
paged_ssd_cache_manager=mock_ssd,
|
|
)
|
|
|
|
block = paged_cache.allocate_block()
|
|
block.block_hash = b"hash0"
|
|
block.token_count = BLOCK_SIZE
|
|
block.ref_count = 2
|
|
block_table = BlockTable(
|
|
request_id="req-001",
|
|
block_ids=[block.block_id],
|
|
num_tokens=BLOCK_SIZE,
|
|
)
|
|
|
|
kv_slice = (
|
|
mx.ones((1, 2, BLOCK_SIZE, 8)),
|
|
mx.ones((1, 2, BLOCK_SIZE, 8)),
|
|
)
|
|
rotating_real = (
|
|
mx.ones((1, 2, WINDOW, 8)),
|
|
mx.ones((1, 2, WINDOW, 8)),
|
|
)
|
|
metadata = {
|
|
"model_name": "test-model",
|
|
"num_layers": 2,
|
|
"layer_cache_types": ["KVCache", "PrefillReadyRotatingKVCache"],
|
|
"layer_meta_states": [
|
|
(str(BLOCK_SIZE),),
|
|
("0", str(WINDOW), str(BLOCK_SIZE), str(WINDOW)),
|
|
],
|
|
}
|
|
mock_ssd.load_block_with_metadata.return_value = (
|
|
[kv_slice, rotating_real],
|
|
metadata,
|
|
)
|
|
|
|
result = cache.reconstruct_cache(block_table)
|
|
|
|
assert result is not None
|
|
assert isinstance(result[1], PrefillReadyRotatingKVCache)
|
|
assert result[1].max_size == WINDOW
|
|
assert result[1].offset == BLOCK_SIZE
|
|
|
|
|
|
def test_prefill_ready_rotating_name_detects_window_padding():
|
|
paged_cache = PagedCacheManager(
|
|
block_size=BLOCK_SIZE,
|
|
max_blocks=100,
|
|
model_name="test-model",
|
|
initial_blocks=100,
|
|
)
|
|
mock_ssd = MagicMock()
|
|
cache = BlockAwarePrefixCache(
|
|
model=MockModel(num_layers=2),
|
|
paged_cache_manager=paged_cache,
|
|
paged_ssd_cache_manager=mock_ssd,
|
|
)
|
|
block = paged_cache.allocate_block()
|
|
block.block_hash = b"hash0"
|
|
|
|
mock_ssd.load_block_with_metadata.return_value = (
|
|
None,
|
|
{
|
|
"layer_cache_types": ["KVCache", "PrefillReadyRotatingKVCache"],
|
|
"layer_meta_states": [
|
|
(str(BLOCK_SIZE),),
|
|
("0", str(WINDOW), str(BLOCK_SIZE), str(WINDOW)),
|
|
],
|
|
},
|
|
)
|
|
|
|
result = cache._detect_window_padding_from_blocks([block.block_id])
|
|
|
|
assert result is not None
|
|
assert result.has_rotating_layers()
|
|
assert result.get_max_window_size() == WINDOW
|
|
|
|
|
|
def test_registry_rotating_family():
|
|
assert CacheTypeRegistry.is_rotating_family("RotatingKVCache")
|
|
assert CacheTypeRegistry.is_rotating_family("PrefillReadyRotatingKVCache")
|
|
assert CacheTypeRegistry.is_rotating_family("BufferedRotatingKVCache")
|
|
assert CacheTypeRegistry.is_rotating_family("BatchRotatingKVCache")
|
|
assert not CacheTypeRegistry.is_rotating_family("KVCache")
|
|
assert not CacheTypeRegistry.is_rotating_family("TurboQuantKVCache")
|
|
assert not CacheTypeRegistry.is_rotating_family("ArraysCache")
|
|
assert not CacheTypeRegistry.is_rotating_family("SomethingElse")
|