|
| 1 | +"""Unit tests for the multi-version KV cache layout detection. |
| 2 | +
|
| 3 | +``attn_kv_views`` must recognize the three flash_attn layouts vLLM has shipped |
| 4 | +(detected from the tensor shape, never from version strings) and reject |
| 5 | +anything else: |
| 6 | +
|
| 7 | +* 4-D packed ``(num_blocks, H, block, 2D)`` -- vLLM >= 0.26.0 |
| 8 | +* 5-D N-first ``(num_blocks, 2, block, H, D)`` -- vLLM 0.23.0 - 0.25.x |
| 9 | +* 5-D KV-first ``(2, num_blocks, block, H, D)`` -- vLLM <= 0.22.1 |
| 10 | +
|
| 11 | +``_build_transfer_group`` must derive the transfer pointers / strides from the |
| 12 | +normalized views, and ``ensure_hybrid_supported`` must fail fast when the |
| 13 | +installed vLLM's scheduler rejects external KV loads for hybrid models |
| 14 | +(vLLM <= 0.22.x). |
| 15 | +
|
| 16 | +Runs without torch: a minimal FakeTensor models the strided-view semantics |
| 17 | +(shape / stride / offset / data_ptr) that the code under test reads. |
| 18 | +""" |
| 19 | + |
| 20 | +import sys |
| 21 | +import types |
| 22 | +import unittest |
| 23 | + |
| 24 | +from kv_cache_manager.py_connector.test.vllm_stubs import make_connector |
| 25 | +from kv_cache_manager.py_connector.vllm.v1_connector import ( |
| 26 | + attn_kv_views, ensure_hybrid_supported, GroupMeta) |
| 27 | + |
| 28 | +ITEMSIZE = 2 # bf16/fp16 |
| 29 | +BASE_PTR = 1 << 20 |
| 30 | + |
| 31 | + |
| 32 | +class FakeTensor: |
| 33 | + """Minimal strided tensor: only what attn_kv_views / _build_transfer_group |
| 34 | + read (dim/shape/stride/permute/indexing/data_ptr).""" |
| 35 | + |
| 36 | + def __init__(self, shape, strides, offset=0, base=BASE_PTR): |
| 37 | + self.shape = tuple(shape) |
| 38 | + self._strides = tuple(strides) |
| 39 | + self._offset = offset |
| 40 | + self._base = base |
| 41 | + |
| 42 | + @classmethod |
| 43 | + def contiguous(cls, shape, base=BASE_PTR): |
| 44 | + strides, acc = [], 1 |
| 45 | + for s in reversed(shape): |
| 46 | + strides.append(acc) |
| 47 | + acc *= s |
| 48 | + return cls(shape, tuple(reversed(strides)), base=base) |
| 49 | + |
| 50 | + def dim(self): |
| 51 | + return len(self.shape) |
| 52 | + |
| 53 | + def stride(self, i=None): |
| 54 | + return self._strides if i is None else self._strides[i] |
| 55 | + |
| 56 | + def data_ptr(self): |
| 57 | + return self._base + self._offset * ITEMSIZE |
| 58 | + |
| 59 | + def permute(self, *dims): |
| 60 | + return FakeTensor([self.shape[d] for d in dims], |
| 61 | + [self._strides[d] for d in dims], |
| 62 | + self._offset, self._base) |
| 63 | + |
| 64 | + def __getitem__(self, idx): |
| 65 | + if isinstance(idx, int): # t[i]: drop dim 0 |
| 66 | + return FakeTensor(self.shape[1:], self._strides[1:], |
| 67 | + self._offset + idx * self._strides[0], self._base) |
| 68 | + if isinstance(idx, tuple) and idx[0] == slice(None) and isinstance(idx[1], int): |
| 69 | + # t[:, i]: drop dim 1 |
| 70 | + return FakeTensor(self.shape[:1] + self.shape[2:], |
| 71 | + self._strides[:1] + self._strides[2:], |
| 72 | + self._offset + idx[1] * self._strides[1], self._base) |
| 73 | + raise TypeError(f"unsupported index {idx!r}") |
| 74 | + |
| 75 | + |
| 76 | +def packed_4d(n=10, h=4, b=16, d2=256, base=BASE_PTR): |
| 77 | + """vLLM >= 0.26.0: NHD memory is (n, b, h, d2) contiguous; the registered |
| 78 | + tensor is its (n, h, b, d2) permuted view.""" |
| 79 | + return FakeTensor.contiguous([n, b, h, d2], base=base).permute(0, 2, 1, 3) |
| 80 | + |
| 81 | + |
| 82 | +def kv_first_5d(n=10, b=16, h=4, d=128, base=BASE_PTR): |
| 83 | + """vLLM <= 0.22.1: (2, n, b, h, d) contiguous.""" |
| 84 | + return FakeTensor.contiguous([2, n, b, h, d], base=base) |
| 85 | + |
| 86 | + |
| 87 | +def n_first_5d(n=10, b=16, h=4, d=128, base=BASE_PTR): |
| 88 | + """vLLM 0.23.0 - 0.25.x: (n, 2, b, h, d) contiguous.""" |
| 89 | + return FakeTensor.contiguous([n, 2, b, h, d], base=base) |
| 90 | + |
| 91 | + |
| 92 | +class TestAttnKvViews(unittest.TestCase): |
| 93 | + def test_packed_4d(self): |
| 94 | + views = attn_kv_views(packed_4d()) |
| 95 | + self.assertEqual(len(views), 1) |
| 96 | + v = views[0] |
| 97 | + self.assertEqual(v.shape, (10, 16, 4, 256)) # (n, b, h, 2d) |
| 98 | + self.assertEqual(v.stride(), (16 * 4 * 256, 4 * 256, 256, 1)) |
| 99 | + self.assertEqual(v.data_ptr(), BASE_PTR) # storage base |
| 100 | + |
| 101 | + def test_kv_first_5d(self): |
| 102 | + views = attn_kv_views(kv_first_5d()) |
| 103 | + self.assertEqual(len(views), 2) |
| 104 | + k, v = views |
| 105 | + for view in (k, v): |
| 106 | + self.assertEqual(view.shape, (10, 16, 4, 128)) |
| 107 | + self.assertEqual(view.stride(), (16 * 4 * 128, 4 * 128, 128, 1)) |
| 108 | + self.assertEqual(k.data_ptr(), BASE_PTR) |
| 109 | + # V base = K base + num_blocks * block * h * d elements. |
| 110 | + self.assertEqual(v.data_ptr() - k.data_ptr(), |
| 111 | + 10 * 16 * 4 * 128 * ITEMSIZE) |
| 112 | + |
| 113 | + def test_n_first_5d(self): |
| 114 | + views = attn_kv_views(n_first_5d()) |
| 115 | + self.assertEqual(len(views), 2) |
| 116 | + k, v = views |
| 117 | + for view in (k, v): |
| 118 | + self.assertEqual(view.shape, (10, 16, 4, 128)) |
| 119 | + # K and V of one block are interleaved: the block stride covers |
| 120 | + # both halves while the inner page stays token-major. |
| 121 | + self.assertEqual(view.stride(), (2 * 16 * 4 * 128, 4 * 128, 128, 1)) |
| 122 | + self.assertEqual(v.data_ptr() - k.data_ptr(), |
| 123 | + 16 * 4 * 128 * ITEMSIZE) |
| 124 | + |
| 125 | + def test_unrecognized_layouts_fail_fast(self): |
| 126 | + bad = [ |
| 127 | + FakeTensor.contiguous([10, 16, 4]), # 3-D |
| 128 | + FakeTensor.contiguous([10, 2, 16, 4, 128, 2]), # 6-D |
| 129 | + FakeTensor.contiguous([10, 16, 2, 4, 128]), # 5-D, K/V dim misplaced |
| 130 | + ] |
| 131 | + for t in bad: |
| 132 | + with self.subTest(shape=t.shape): |
| 133 | + with self.assertRaises(NotImplementedError): |
| 134 | + attn_kv_views(t) |
| 135 | + |
| 136 | + def test_ambiguous_layout_fails_fast(self): |
| 137 | + # num_blocks == 2 in a KV-first shape is indistinguishable from a |
| 138 | + # two-block N-first shape; refusing beats guessing. |
| 139 | + with self.assertRaises(NotImplementedError): |
| 140 | + attn_kv_views(FakeTensor.contiguous([2, 2, 16, 4, 128])) |
| 141 | + |
| 142 | + |
| 143 | +def _make_group_conn(): |
| 144 | + conn = make_connector(manager_block_size=16) |
| 145 | + conn._self_spec_names = ["tp0_g0"] |
| 146 | + conn._device = "cpu" |
| 147 | + return conn |
| 148 | + |
| 149 | + |
| 150 | +def _attn_meta(layer_names, block_size=16): |
| 151 | + return GroupMeta(group_idx=0, is_attention=True, layer_names=layer_names, |
| 152 | + block_size=block_size, per_block_bytes=0) |
| 153 | + |
| 154 | + |
| 155 | +class TestBuildTransferGroup(unittest.TestCase): |
| 156 | + """Pointer construction per layout. Layer tensors get distinct bases so the |
| 157 | + interleaving [K0, V0, K1, V1, ...] is observable. The pointer list is |
| 158 | + captured by patching ``torch.tensor`` (works with both the stubbed and a |
| 159 | + real torch: no tensor math happens on the captured value).""" |
| 160 | + |
| 161 | + def _build(self, kv_caches): |
| 162 | + import unittest.mock as mock |
| 163 | + import kv_cache_manager.py_connector.vllm.v1_connector as v1c |
| 164 | + conn = _make_group_conn() |
| 165 | + captured = [] |
| 166 | + |
| 167 | + def fake_tensor(data, **kw): |
| 168 | + captured[:] = list(data) |
| 169 | + t = mock.MagicMock() |
| 170 | + t.to.return_value = t |
| 171 | + return t |
| 172 | + |
| 173 | + with mock.patch.object(v1c.torch, "tensor", side_effect=fake_tensor): |
| 174 | + g = conn._build_transfer_group( |
| 175 | + _attn_meta(list(kv_caches.keys())), kv_caches) |
| 176 | + return g, captured |
| 177 | + |
| 178 | + def test_packed_one_ptr_per_layer(self): |
| 179 | + kv = {"l0": packed_4d(base=BASE_PTR), "l1": packed_4d(base=2 * BASE_PTR)} |
| 180 | + g, ptrs = self._build(kv) |
| 181 | + self.assertEqual(g.num_kv_ptrs, 2) |
| 182 | + self.assertEqual(g.layer_num, 2) |
| 183 | + self.assertEqual(g.per_token_dim, 4 * 256) |
| 184 | + self.assertEqual(g.kernel_block_size, 16) |
| 185 | + self.assertEqual(g.block_stride, 0) # flat |
| 186 | + self.assertEqual(ptrs, [BASE_PTR, 2 * BASE_PTR]) |
| 187 | + |
| 188 | + def test_kv_first_two_ptrs_per_layer(self): |
| 189 | + kv = {"l0": kv_first_5d(base=BASE_PTR), "l1": kv_first_5d(base=2 * BASE_PTR)} |
| 190 | + g, ptrs = self._build(kv) |
| 191 | + self.assertEqual(g.num_kv_ptrs, 4) |
| 192 | + self.assertEqual(g.layer_num, 2) |
| 193 | + self.assertEqual(g.per_token_dim, 4 * 128) |
| 194 | + self.assertEqual(g.block_stride, 0) # each half is flat token-major |
| 195 | + v_off = 10 * 16 * 4 * 128 * ITEMSIZE |
| 196 | + self.assertEqual(ptrs, [BASE_PTR, BASE_PTR + v_off, |
| 197 | + 2 * BASE_PTR, 2 * BASE_PTR + v_off]) |
| 198 | + |
| 199 | + def test_n_first_strided_blocks(self): |
| 200 | + kv = {"l0": n_first_5d(base=BASE_PTR)} |
| 201 | + g, ptrs = self._build(kv) |
| 202 | + self.assertEqual(g.num_kv_ptrs, 2) |
| 203 | + self.assertEqual(g.per_token_dim, 4 * 128) |
| 204 | + # K/V interleaved per block -> kernel must walk the strided path. |
| 205 | + self.assertEqual(g.block_stride, 2 * 16 * 4 * 128) |
| 206 | + v_off = 16 * 4 * 128 * ITEMSIZE |
| 207 | + self.assertEqual(ptrs, [BASE_PTR, BASE_PTR + v_off]) |
| 208 | + |
| 209 | + def test_unrecognized_layout_fails_fast(self): |
| 210 | + with self.assertRaises(NotImplementedError): |
| 211 | + self._build({"l0": FakeTensor.contiguous([10, 16, 4])}) |
| 212 | + |
| 213 | + |
| 214 | +class _BlockedScheduler: |
| 215 | + """Mimics vLLM <= 0.22.x: external loads are asserted away.""" |
| 216 | + |
| 217 | + def _mamba_block_aligned_split(self, request, num_new_tokens, |
| 218 | + num_new_local_computed_tokens=0, |
| 219 | + num_external_computed_tokens=0): |
| 220 | + assert num_external_computed_tokens == 0, ( |
| 221 | + "External KV connector is not verified yet" |
| 222 | + ) |
| 223 | + |
| 224 | + |
| 225 | +class _OpenScheduler: |
| 226 | + """Mimics vLLM >= 0.23.0: the split handles external tokens.""" |
| 227 | + |
| 228 | + def _mamba_block_aligned_split(self, request, num_new_tokens, |
| 229 | + num_new_local_computed_tokens=0, |
| 230 | + num_external_computed_tokens=0): |
| 231 | + return num_new_tokens |
| 232 | + |
| 233 | + |
| 234 | +class TestHybridGate(unittest.TestCase): |
| 235 | + MOD = "vllm.v1.core.sched.scheduler" |
| 236 | + |
| 237 | + def _with_scheduler(self, cls): |
| 238 | + mod = types.ModuleType(self.MOD) |
| 239 | + mod.Scheduler = cls |
| 240 | + old = sys.modules.get(self.MOD) |
| 241 | + sys.modules[self.MOD] = mod |
| 242 | + self.addCleanup(lambda: (sys.modules.pop(self.MOD, None), |
| 243 | + old and sys.modules.__setitem__(self.MOD, old))) |
| 244 | + |
| 245 | + def test_old_vllm_hybrid_raises_gracefully(self): |
| 246 | + self._with_scheduler(_BlockedScheduler) |
| 247 | + with self.assertRaises(NotImplementedError) as ctx: |
| 248 | + ensure_hybrid_supported() |
| 249 | + # The message must tell the operator what to do. |
| 250 | + self.assertIn("vLLM >= 0.23.0", str(ctx.exception)) |
| 251 | + self.assertIn("hybrid", str(ctx.exception)) |
| 252 | + |
| 253 | + def test_new_vllm_hybrid_passes(self): |
| 254 | + self._with_scheduler(_OpenScheduler) |
| 255 | + ensure_hybrid_supported() # must not raise |
| 256 | + |
| 257 | + def test_unprobeable_scheduler_does_not_block(self): |
| 258 | + self._with_scheduler(object) # no _mamba_block_aligned_split at all |
| 259 | + ensure_hybrid_supported() # must not raise |
| 260 | + |
| 261 | + |
| 262 | +if __name__ == "__main__": |
| 263 | + unittest.main() |
0 commit comments