Spaces:
Running on Zero
Running on Zero
File size: 7,754 Bytes
bd97ee9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | """
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 # small on purpose to force multiple blocks quickly
@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)] # simulate 6 generated tokens, one at a time
for k, v in chunks:
cache.update(0, k, v)
cache.step(1) # <- the fix: must advance by the actual delta length
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) # called once, after all layers for this step
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) # spans 3 blocks (4+4+2)
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)
# Only layer 0 was written; layer 1 should have zero blocks allocated
mem = cache.memory_mb()
assert mem > 0
# Rough sanity: memory should be proportional to ~1 block for 1 layer,
# not proportional to some large fixed max_seq_len.
bytes_per_block = NUM_KV_HEADS * BLOCK_SIZE * HEAD_DIM * 4 * 2 # k+v, float32
expected_mb = bytes_per_block / (1024 ** 2)
assert mem <= expected_mb * 1.5 # generous tolerance
class TestUtilization:
def test_utilization_reflects_fill_ratio(self, cache):
k, v = _rand_kv(2) # half of one 4-token block
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") # touch "a" again — "b" is now the LRU
sessions.get_or_create("c") # should evict "b", not "a"
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
|