| """ |
| Frox AI Morph 1.1 — KV Cache Tests |
| Run with: pytest tests/test_kv_cache.py -v |
| |
| Includes a dedicated regression test for a real bug caught during |
| development: `MorphSessionCache.chat()`-style usage originally called |
| `cache.step(0)` after writing new tokens, which is a no-op — `_seq_len` |
| never advanced, so every write silently landed back at offset 0 and |
| overwrote whatever was written before it. `test_sequential_updates_append_not_overwrite` |
| below fails immediately on that bug and passes with the fix. |
| """ |
| from __future__ import annotations |
|
|
| import pytest |
| import torch |
|
|
| from inference.cache.kv_cache import MorphKVCache, MorphSessionCache |
|
|
|
|
| NUM_LAYERS = 2 |
| NUM_KV_HEADS = 2 |
| HEAD_DIM = 8 |
| BLOCK_SIZE = 4 |
|
|
|
|
| @pytest.fixture |
| def cache() -> MorphKVCache: |
| return MorphKVCache( |
| num_layers=NUM_LAYERS, |
| num_kv_heads=NUM_KV_HEADS, |
| head_dim=HEAD_DIM, |
| block_size=BLOCK_SIZE, |
| dtype=torch.float32, |
| device=torch.device("cpu"), |
| ) |
|
|
|
|
| def _rand_kv(seq_len: int): |
| k = torch.randn(1, NUM_KV_HEADS, seq_len, HEAD_DIM) |
| v = torch.randn(1, NUM_KV_HEADS, seq_len, HEAD_DIM) |
| return k, v |
|
|
|
|
| class TestBasicReadWrite: |
| def test_single_write_round_trips(self, cache): |
| k, v = _rand_kv(3) |
| cache.update(0, k, v) |
| cache.step(3) |
|
|
| k_read, v_read = cache.get(0) |
| assert torch.allclose(k_read, k) |
| assert torch.allclose(v_read, v) |
| assert cache.seq_len == 3 |
|
|
| def test_empty_cache_returns_zero_length(self, cache): |
| k, v = cache.get(0) |
| assert k.shape[2] == 0 |
| assert v.shape[2] == 0 |
| assert cache.seq_len == 0 |
|
|
|
|
| class TestSequentialAppend: |
| """ |
| The core regression test: writing in several separate calls (as |
| happens once per generated token in `MorphInferenceEngine.chat()`) |
| must APPEND, never overwrite. This is exactly the bug that was |
| caught: calling `step(0)` instead of `step(delta_len)` made every |
| write land at offset 0 again. |
| """ |
|
|
| def test_sequential_updates_append_not_overwrite(self, cache): |
| chunks = [_rand_kv(1) for _ in range(6)] |
|
|
| for k, v in chunks: |
| cache.update(0, k, v) |
| cache.step(1) |
|
|
| assert cache.seq_len == 6 |
|
|
| k_read, v_read = cache.get(0) |
| assert k_read.shape[2] == 6, ( |
| f"Expected 6 cached tokens after 6 sequential single-token writes, " |
| f"got {k_read.shape[2]} — writes are overwriting instead of appending " |
| f"(this is exactly the step(0) vs step(delta_len) bug)." |
| ) |
|
|
| expected_k = torch.cat([c[0] for c in chunks], dim=2) |
| expected_v = torch.cat([c[1] for c in chunks], dim=2) |
| assert torch.allclose(k_read, expected_k), "Cached content doesn't match write order" |
| assert torch.allclose(v_read, expected_v) |
|
|
| def test_mixed_batch_and_single_writes(self, cache): |
| """A multi-token prefill followed by several single-token decode steps.""" |
| prefill_k, prefill_v = _rand_kv(5) |
| cache.update(0, prefill_k, prefill_v) |
| cache.step(5) |
|
|
| decode_chunks = [_rand_kv(1) for _ in range(4)] |
| for k, v in decode_chunks: |
| cache.update(0, k, v) |
| cache.step(1) |
|
|
| assert cache.seq_len == 9 |
| k_read, _ = cache.get(0) |
| assert k_read.shape[2] == 9 |
|
|
| expected = torch.cat([prefill_k] + [c[0] for c in decode_chunks], dim=2) |
| assert torch.allclose(k_read, expected) |
|
|
| def test_all_layers_stay_in_sync(self, cache): |
| """ |
| step() is called once per forward pass (not once per layer), so |
| every layer must end up with the same seq_len even though |
| update() is called once per layer per step. |
| """ |
| for _ in range(5): |
| for layer_idx in range(NUM_LAYERS): |
| k, v = _rand_kv(1) |
| cache.update(layer_idx, k, v) |
| cache.step(1) |
|
|
| for layer_idx in range(NUM_LAYERS): |
| k, v = cache.get(layer_idx) |
| assert k.shape[2] == 5, f"Layer {layer_idx} has {k.shape[2]} tokens, expected 5" |
|
|
|
|
| class TestPagedAllocation: |
| def test_grows_across_block_boundary(self, cache): |
| """block_size=4 in the fixture; write more than one block's worth.""" |
| k, v = _rand_kv(10) |
| cache.update(0, k, v) |
| cache.step(10) |
|
|
| k_read, v_read = cache.get(0) |
| assert k_read.shape[2] == 10 |
| assert torch.allclose(k_read, k) |
|
|
| def test_memory_scales_with_allocated_blocks_not_max_seq_len(self, cache): |
| """A short sequence shouldn't allocate memory for a hypothetical long one.""" |
| k, v = _rand_kv(1) |
| cache.update(0, k, v) |
| cache.step(1) |
|
|
| |
| mem = cache.memory_mb() |
| assert mem > 0 |
| |
| |
| bytes_per_block = NUM_KV_HEADS * BLOCK_SIZE * HEAD_DIM * 4 * 2 |
| expected_mb = bytes_per_block / (1024 ** 2) |
| assert mem <= expected_mb * 1.5 |
|
|
|
|
| class TestUtilization: |
| def test_utilization_reflects_fill_ratio(self, cache): |
| k, v = _rand_kv(2) |
| cache.update(0, k, v) |
| cache.step(2) |
| assert cache.utilization() == pytest.approx(0.5, abs=0.01) |
|
|
|
|
| class TestReset: |
| def test_reset_clears_everything(self, cache): |
| k, v = _rand_kv(5) |
| cache.update(0, k, v) |
| cache.step(5) |
| assert cache.seq_len == 5 |
|
|
| cache.reset() |
| assert cache.seq_len == 0 |
| k_read, _ = cache.get(0) |
| assert k_read.shape[2] == 0 |
|
|
|
|
| class TestSessionCache: |
| def test_sessions_are_isolated(self): |
| sessions = MorphSessionCache( |
| num_layers=NUM_LAYERS, num_kv_heads=NUM_KV_HEADS, head_dim=HEAD_DIM, |
| block_size=BLOCK_SIZE, device=torch.device("cpu"), max_sessions=4, |
| ) |
| cache_a = sessions.get_or_create("session-a") |
| cache_b = sessions.get_or_create("session-b") |
|
|
| k, v = _rand_kv(3) |
| cache_a.update(0, k, v) |
| cache_a.step(3) |
|
|
| assert cache_a.seq_len == 3 |
| assert cache_b.seq_len == 0, "Writing to session A must not affect session B" |
|
|
| def test_lru_eviction(self): |
| sessions = MorphSessionCache( |
| num_layers=NUM_LAYERS, num_kv_heads=NUM_KV_HEADS, head_dim=HEAD_DIM, |
| block_size=BLOCK_SIZE, device=torch.device("cpu"), max_sessions=2, |
| ) |
| sessions.get_or_create("a") |
| sessions.get_or_create("b") |
| sessions.get_or_create("a") |
| sessions.get_or_create("c") |
|
|
| assert "a" in sessions._caches |
| assert "b" not in sessions._caches |
| assert "c" in sessions._caches |
|
|
| def test_reset_session_preserves_other_sessions(self): |
| sessions = MorphSessionCache( |
| num_layers=NUM_LAYERS, num_kv_heads=NUM_KV_HEADS, head_dim=HEAD_DIM, |
| block_size=BLOCK_SIZE, device=torch.device("cpu"), |
| ) |
| cache_a = sessions.get_or_create("a") |
| cache_b = sessions.get_or_create("b") |
| for c in (cache_a, cache_b): |
| k, v = _rand_kv(3) |
| c.update(0, k, v) |
| c.step(3) |
|
|
| sessions.reset_session("a") |
| assert sessions.get_or_create("a").seq_len == 0 |
| assert sessions.get_or_create("b").seq_len == 3 |
|
|