Files
jundot--omlx/tests/test_prefix_cache_rotating_tip_strip.py
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

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")