Skip to content

Commit 9dc5961

Browse files
committed
[py_connector] restore pre-0.26 vLLM KV layouts via shape-based detection
PR #257's rewrite only accepted vLLM >= 0.26.0's packed 4-D KV layout (num_blocks, H, block, 2D), regressing full-attention support on older vLLM. Detect all three flash_attn layouts from the tensor shape itself (never from version strings) and normalize each into token-major per-pointer views: * 4-D packed (vllm >= 0.26.0): one pointer per layer, as before. * 5-D N-first (num_blocks, 2, block, H, D) (vllm 0.23.0 - 0.25.x): two pointers per layer, K/V interleaved per block -> kernel strided path. * 5-D KV-first (2, num_blocks, block, H, D) (vllm <= 0.22.1): two flat pointers per layer. The Triton gather/scatter kernel already addresses through a flat pointer array ([K0, V0, K1, V1, ...] for non-MLA), so it needs zero changes; TransferGroup grows num_kv_ptrs (pointer count, = layer_num for packed, 2x for split layouts) and the staging buffer views in data_transfer use it. per_block_bytes is identical across layouts, so the manager, storage and transfer protocol are unaffected. Unrecognized layouts still fail fast at startup. Hybrid (mamba) models on vllm <= 0.22.x are rejected with a clear NotImplementedError: those schedulers assert num_external_computed_tokens == 0 in _mamba_block_aligned_split, so the first external hit would crash mid-flight. The gate probes the installed scheduler for that blocking assert (capability check, not a version comparison). Note the saved byte layout differs between the packed and split-K/V eras, so KV cache is not portable across vLLM upgrades; instance_id isolation already prevents such mixing in practice. New unit tests cover view construction for all three layouts (shape / stride / pointer math on stub tensors, no torch required), fail-fast on unrecognized and ambiguous shapes, and the hybrid gate on old/new/ unprobeable schedulers. The e2e VerifyingConnector reuses attn_kv_views so its captures follow the same normalization.
1 parent 729e95f commit 9dc5961

7 files changed

Lines changed: 399 additions & 38 deletions

File tree

integration_test/vllm_e2e/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ End-to-end integration tests for the KVCM vLLM connector
66
through the OpenAI API and verifies that the KV cache data saved to / loaded
77
from KVCM is correct.
88

9-
Requires 1-2 GPUs and vLLM >= 0.26.0.
9+
Requires 1-2 GPUs and vLLM (0.22.1, 0.23.0 and 0.26.0 are e2e-verified; the
10+
connector detects the KV cache layout of each era from the tensor shape).
1011

1112
## What is verified
1213

@@ -98,7 +99,7 @@ All environment variables used by the e2e harness:
9899
| Variable | Required | Meaning |
99100
|---|---|---|
100101
| `KVCM_E2E_MODEL` | yes | Path to a local HF model directory (`config.json` + weights). Full-attention coverage needs a plain attention model (e.g. Qwen2.5-7B-Instruct); hybrid coverage needs a mamba/linear + attention model (e.g. Qwen3.5-4B). Hybrid models are auto-detected from `config.json`. |
101-
| `KVCM_E2E_PYTHON` | yes | Python interpreter of a venv with vLLM >= 0.26.0 and both KVCM wheels (`kvcm_py_client`, `kvcm_vllm_connector`) installed. |
102+
| `KVCM_E2E_PYTHON` | yes | Python interpreter of a venv with vLLM (any supported version, see above) and both KVCM wheels (`kvcm_py_client`, `kvcm_vllm_connector`) installed. |
102103
| `KVCM_E2E_CAPTURE_DIR` | internal | Set by the driver for the vLLM subprocess; tells `VerifyingConnector` where to write `.pt` captures. Do not set manually. |
103104

104105
The driver also sets vLLM knobs for the spawned server (`VLLM_KV_CACHE_LAYOUT=NHD`,

integration_test/vllm_e2e/test_connector.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464

6565
from kv_cache_manager.py_connector.common.logger import logger
6666
from kv_cache_manager.py_connector.vllm.metadata import TairKvCacheConnectorMetadata
67-
from kv_cache_manager.py_connector.vllm.v1_connector import TairKvCacheConnector
67+
from kv_cache_manager.py_connector.vllm.v1_connector import (
68+
TairKvCacheConnector, attn_kv_views)
6869

6970
CAPTURE_DIR_ENV = "KVCM_E2E_CAPTURE_DIR"
7071

@@ -89,7 +90,7 @@ def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
8990
for meta in self._group_metas:
9091
if meta.is_attention:
9192
ref = kv_caches[meta.layer_names[0]]
92-
kernel_bs = ref.shape[2]
93+
kernel_bs = attn_kv_views(ref)[0].shape[1]
9394
else:
9495
kernel_bs = 0
9596
self._cap_groups.append(
@@ -259,13 +260,20 @@ def _capture_block(self, kind, token_ids, block_ids_per_group, manager_block_idx
259260
slot_tensor = torch.tensor(slots, dtype=torch.long, device=self._device)
260261
for layer_name in layer_names:
261262
kv_cache = self._kv_caches[layer_name]
262-
# vLLM >= 0.26.0: (num_blocks, num_kv_heads, kernel_bs,
263-
# 2*head_size) packed, NHD memory order is token-major. Flatten
264-
# the (block, token) dims and gather the whole per-token vector
265-
# (K and V packed) -- the packing is opaque to verification.
266-
per_token = kv_cache.shape[1] * kv_cache.shape[3]
267-
flat = kv_cache.permute(0, 2, 1, 3).reshape(-1, per_token)
268-
gathered = flat[slot_tensor, :].contiguous() # [n_tok, per_token]
263+
# Normalize the layout (packed 4-D or split K/V 5-D) into
264+
# token-major views via the production helper and gather the
265+
# whole per-token vector by (block, token) advanced indexing
266+
# -- split K/V views are non-contiguous, so flattening them
267+
# first would copy the entire cache tensor. Split views are
268+
# concatenated on the content dim, so a capture is
269+
# comparable across save/load within one run.
270+
parts = []
271+
for v in attn_kv_views(kv_cache):
272+
blk = slot_tensor // kernel_bs
273+
tok = slot_tensor % kernel_bs
274+
parts.append(v[blk, tok].reshape(len(slots), -1))
275+
gathered = (parts[0] if len(parts) == 1
276+
else torch.cat(parts, dim=-1)).contiguous()
269277
kv_by_layer[layer_name] = gathered.cpu()
270278
else:
271279
# State stored once per group block; the manager block's last

kv_cache_manager/py_connector/common/types.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,19 @@ class TransferGroup:
3030
layer_num: int = 0
3131

3232
# --- Attention-only fields (is_attention == True) ---
33-
# int64 tensor of [K0, V0, K1, V1, ...] data ptrs on the compute device.
33+
# int64 tensor of transfer-pointer bases on the compute device. For packed
34+
# K/V layouts (vllm >= 0.26.0) one pointer per layer [L0, L1, ...]; for
35+
# split K/V layouts (vllm <= 0.25.x) two per layer [K0, V0, K1, V1, ...].
3436
kvcache_ptr_tensor_gpu: Optional[torch.Tensor] = None
35-
per_token_dim: int = 0 # num_kv_heads * head_size
36-
kernel_block_size: int = 0 # tensor.shape[2]
37-
kv_stride: int = 0 # tensor.stride(0), 0 => contiguous flat layout
38-
block_stride: int = 0 # tensor.stride(1), 0 => contiguous flat layout
37+
# Number of transfer pointers (rows of the staging buffer view per block).
38+
# layer_num for packed layouts, 2 * layer_num for split K/V layouts.
39+
num_kv_ptrs: int = 0
40+
per_token_dim: int = 0 # heads * content dim per pointer
41+
kernel_block_size: int = 0 # tokens per kernel page
42+
kv_stride: int = 0 # K->V element offset added by the kernel; 0
43+
# because every pointer is its own base
44+
block_stride: int = 0 # element stride between kernel pages of one
45+
# pointer, 0 => contiguous flat layout
3946

4047
# --- State-only fields (is_attention == False) ---
4148
# Per layer (num_blocks, page_size_bytes) uint8 views into the state storage.

kv_cache_manager/py_connector/test/BUILD

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ py_test(
2828
deps = [":vllm_stubs"],
2929
)
3030

31+
py_test(
32+
name = "test_kv_layouts",
33+
srcs = ["test_kv_layouts.py"],
34+
tags = ["no-remote-exec"],
35+
deps = [":vllm_stubs"],
36+
)
37+
3138
py_test(
3239
name = "test_scheduler_state",
3340
srcs = ["test_scheduler_state.py"],
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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()

kv_cache_manager/py_connector/vllm/data_transfer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def save_task(self, multi_result: MultiResult, task_idx, group: TransferGroup,
147147
dtype=torch.uint8, device=self._device)
148148
if group.is_attention:
149149
view = gpu_buffer.view(self._info.dtype).view(
150-
len(valid), group.layer_num,
150+
len(valid), group.num_kv_ptrs,
151151
self._manager_block_size, group.per_token_dim)
152152
batch_gather_scatter_helper.batch_gather_kv_caches(
153153
group.kvcache_ptr_tensor_gpu, view, block_token_indices,
@@ -226,7 +226,7 @@ def load_task(self, multi_result: MultiResult, task_idx, group: TransferGroup,
226226
gpu_buffer = cpu_buffer.to(self._device, non_blocking=True)
227227
if group.is_attention:
228228
view = gpu_buffer.view(self._info.dtype).view(
229-
n, group.layer_num, self._manager_block_size, group.per_token_dim)
229+
n, group.num_kv_ptrs, self._manager_block_size, group.per_token_dim)
230230
batch_gather_scatter_helper.batch_scatter_kv_caches(
231231
group.kvcache_ptr_tensor_gpu, view, block_token_indices,
232232
list(range(n)), self._manager_block_size, group.per_token_dim,

0 commit comments

Comments
 (0)