File size: 7,754 Bytes
296a506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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