[Spec Decode] Support mixed KV page sizes for DFlash (#45181)
Signed-off-by: Alex Steiner <asteiner@nvidia.com> Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai>
Alex Steiner committed
Jun 21, 2026 at 07:45 UTC
2cac89f9da865dfaceb6d337d97aaff5c9195e48
8 files changed
+511
-141
tests/v1/core/test_kv_cache_utils.py
+102
-7
@@ -117,6 +117,7 @@ def new_kv_cache_spec(
117
page_size_padded=None,
118
sliding_window=None,
119
attention_chunk_size=None,
120
+ indexes_kv_by_block_stride=False,
121
):
122
return FullAttentionSpec(
123
block_size=block_size,
@@ -126,6 +127,7 @@ def new_kv_cache_spec(
127
page_size_padded=page_size_padded,
128
sliding_window=sliding_window,
129
attention_chunk_size=attention_chunk_size,
130
+ indexes_kv_by_block_stride=indexes_kv_by_block_stride,
131
)
132
133
@@ -136,6 +138,7 @@ def new_sliding_window_spec(
138
dtype=torch.float32,
139
page_size_padded=None,
140
sliding_window=1,
141
+ indexes_kv_by_block_stride=False,
142
):
143
return SlidingWindowSpec(
144
block_size=block_size,
@@ -144,6 +147,7 @@ def new_sliding_window_spec(
147
dtype=dtype,
148
page_size_padded=page_size_padded,
149
sliding_window=sliding_window,
150
+ indexes_kv_by_block_stride=indexes_kv_by_block_stride,
151
)
152
153
@@ -1799,16 +1803,38 @@ def test_get_kv_cache_config_one_worker():
1803
],
1804
)
1805
1802
- # different hidden size that cannot be aligned by using different block size
1806
+ # different hidden size that cannot be aligned by using different block size,
1807
+ # but can be aligned by padding the smaller physical page.
1808
+ swa_spec = new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True)
1809
kv_cache_specs_hybrid = {
1804
- "layer_1": new_kv_cache_spec(head_size=64),
1805
- "layer_2": new_sliding_window_spec(head_size=96),
1810
+ "layer_1": new_kv_cache_spec(head_size=64, indexes_kv_by_block_stride=True),
1811
+ "layer_2": swa_spec,
1812
}
1813
1808
- with pytest.raises(NotImplementedError):
1809
- get_kv_cache_configs(
1810
- vllm_config, [kv_cache_specs_hybrid], [mem_per_block_per_layer * 2 * 32]
1811
- )[0]
1814
+ kv_cache_config_hybrid = get_kv_cache_configs(
1815
+ vllm_config, [kv_cache_specs_hybrid], [mem_per_block_per_layer * 2 * 32]
1816
+ )[0]
1817
+ padded_page_size = swa_spec.page_size_bytes
1818
+ assert kv_cache_config_hybrid == KVCacheConfig(
1819
+ num_blocks=42,
1820
+ kv_cache_tensors=[
1821
+ KVCacheTensor(size=padded_page_size * 42, shared_by=["layer_1", "layer_2"]),
1822
+ ],
1823
+ kv_cache_groups=[
1824
+ KVCacheGroupSpec(
1825
+ ["layer_1"],
1826
+ new_kv_cache_spec(
1827
+ head_size=64,
1828
+ page_size_padded=padded_page_size,
1829
+ indexes_kv_by_block_stride=True,
1830
+ ),
1831
+ ),
1832
+ KVCacheGroupSpec(
1833
+ ["layer_2"],
1834
+ new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True),
1835
+ ),
1836
+ ],
1837
+ )
1838
1839
# Test num_gpu_blocks_override
1840
vllm_config.cache_config.num_gpu_blocks_override = 16
@@ -2322,6 +2348,75 @@ def test_check_enough_kv_cache_memory_respects_num_gpu_blocks_override():
2348
get_kv_cache_configs(vllm_config, [kv_cache_specs], [large_available_memory])
2349
2350
2351
+def test_unify_kv_cache_page_size_uses_padding_for_non_divisible_sizes():
2352
+ """DFlash drafters can have a smaller head size than the target model.
2353
+
2354
+ For example, MiMo uses 192-dim target KV heads while its DFlash draft uses
2355
+ 128-dim KV heads. The resulting page sizes are 3:2 rather than an integer
2356
+ block-size multiple, so the smaller page must be padded instead.
2357
+ """
2358
+ # Both layers' backends opt into the padded-page strided view (e.g.
2359
+ # FlashAttention / its DiffKV subclass), so padding is allowed.
2360
+ target_spec = new_kv_cache_spec(
2361
+ block_size=16,
2362
+ num_kv_heads=1,
2363
+ head_size=192,
2364
+ dtype=torch.bfloat16,
2365
+ indexes_kv_by_block_stride=True,
2366
+ )
2367
+ draft_spec = new_sliding_window_spec(
2368
+ block_size=16,
2369
+ num_kv_heads=1,
2370
+ head_size=128,
2371
+ dtype=torch.bfloat16,
2372
+ sliding_window=1024,
2373
+ indexes_kv_by_block_stride=True,
2374
+ )
2375
+
2376
+ unified_specs = kv_cache_utils.unify_kv_cache_spec_page_size(
2377
+ {
2378
+ "target_attn": target_spec,
2379
+ "draft_attn": draft_spec,
2380
+ }
2381
+ )
2382
+
2383
+ assert unified_specs["target_attn"] == target_spec
2384
+ unified_draft_spec = unified_specs["draft_attn"]
2385
+ assert unified_draft_spec.block_size == draft_spec.block_size
2386
+ assert unified_draft_spec.real_page_size_bytes == draft_spec.real_page_size_bytes
2387
+ assert unified_draft_spec.page_size_padded == target_spec.page_size_bytes
2388
+ assert unified_draft_spec.page_size_bytes == target_spec.page_size_bytes
2389
+
2390
+
2391
+def test_unify_kv_cache_page_size_padding_requires_backend_support():
2392
+ """Padding is gated on the backend declaring ``indexes_kv_by_block_stride``.
2393
+
2394
+ A backend that does not support the strided padded-page view must raise
2395
+ rather than silently padding (and misreading KV at runtime).
2396
+ """
2397
+ target_spec = new_kv_cache_spec(
2398
+ block_size=16,
2399
+ num_kv_heads=1,
2400
+ head_size=192,
2401
+ dtype=torch.bfloat16,
2402
+ indexes_kv_by_block_stride=True,
2403
+ )
2404
+ # The non-divisible draft layer needs padding but its backend does not
2405
+ # support the strided padded-page view -> must raise, not silently pad.
2406
+ draft_spec = new_sliding_window_spec(
2407
+ block_size=16,
2408
+ num_kv_heads=1,
2409
+ head_size=128,
2410
+ dtype=torch.bfloat16,
2411
+ sliding_window=1024,
2412
+ indexes_kv_by_block_stride=False,
2413
+ )
2414
+ specs = {"target_attn": target_spec, "draft_attn": draft_spec}
2415
+
2416
+ with pytest.raises(NotImplementedError):
2417
+ kv_cache_utils.unify_kv_cache_spec_page_size(specs)
2418
+
2419
+
2420
def test_unify_hybrid_kv_cache_specs():
2421
# 1. has_full_attention and has_sliding_window
2422
before_spec_1 = new_kv_cache_spec()
tests/v1/worker/test_attn_utils.py
new
+242
@@ -0,0 +1,242 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import torch
5
+
6
+from vllm.v1.kv_cache_interface import FullAttentionSpec, KVQuantMode
7
+from vllm.v1.worker.gpu.attn_utils import _reshape_kv_cache
8
+from vllm.v1.worker.utils import AttentionGroup
9
+
10
+
11
+class FakeFlashAttentionBackend:
12
+ @staticmethod
13
+ def get_kv_cache_shape(
14
+ num_blocks: int,
15
+ block_size: int,
16
+ num_kv_heads: int,
17
+ head_size: int,
18
+ cache_dtype_str: str = "auto",
19
+ ) -> tuple[int, ...]:
20
+ return (num_blocks, 2, block_size, num_kv_heads, head_size)
21
+
22
+ @staticmethod
23
+ def get_kv_cache_stride_order(
24
+ include_num_layers_dimension: bool = False,
25
+ ) -> tuple[int, ...]:
26
+ assert not include_num_layers_dimension
27
+ return (0, 1, 2, 3, 4)
28
+
29
+
30
+class FakeHNDFlashAttentionBackend(FakeFlashAttentionBackend):
31
+ @staticmethod
32
+ def get_kv_cache_stride_order(
33
+ include_num_layers_dimension: bool = False,
34
+ ) -> tuple[int, ...]:
35
+ assert not include_num_layers_dimension
36
+ return (0, 1, 3, 2, 4)
37
+
38
+
39
+def test_reshape_padded_flash_attention_kv_cache_strides_by_page():
40
+ num_blocks = 3
41
+ spec = FullAttentionSpec(
42
+ block_size=16,
43
+ num_kv_heads=1,
44
+ head_size=2,
45
+ dtype=torch.float32,
46
+ page_size_padded=384,
47
+ )
48
+ assert spec.real_page_size_bytes == 256
49
+
50
+ raw_tensors = {
51
+ "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
52
+ }
53
+ attn_groups = [
54
+ AttentionGroup(
55
+ backend=FakeFlashAttentionBackend,
56
+ layer_names=["layer"],
57
+ kv_cache_spec=spec,
58
+ kv_cache_group_id=0,
59
+ )
60
+ ]
61
+
62
+ kv_cache = _reshape_kv_cache(
63
+ attn_groups,
64
+ raw_tensors,
65
+ "auto",
66
+ [spec.block_size],
67
+ {},
68
+ )["layer"]
69
+
70
+ assert kv_cache.shape == (num_blocks, 2, 16, 1, 2)
71
+ assert kv_cache.stride(0) == spec.page_size_bytes // 4
72
+ assert kv_cache.stride(1) == spec.real_page_size_bytes // 2 // 4
73
+ assert kv_cache[1, 0].storage_offset() == spec.page_size_bytes // 4
74
+ assert (
75
+ kv_cache[1, 1].storage_offset()
76
+ == (spec.page_size_bytes + spec.real_page_size_bytes // 2) // 4
77
+ )
78
+
79
+
80
+def test_reshape_padded_hnd_flash_attention_kv_cache_strides_by_page():
81
+ num_blocks = 3
82
+ spec = FullAttentionSpec(
83
+ block_size=16,
84
+ num_kv_heads=3,
85
+ head_size=2,
86
+ dtype=torch.float32,
87
+ page_size_padded=1024,
88
+ )
89
+ assert spec.real_page_size_bytes == 768
90
+
91
+ raw_tensors = {
92
+ "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
93
+ }
94
+ attn_groups = [
95
+ AttentionGroup(
96
+ backend=FakeHNDFlashAttentionBackend,
97
+ layer_names=["layer"],
98
+ kv_cache_spec=spec,
99
+ kv_cache_group_id=0,
100
+ )
101
+ ]
102
+
103
+ kv_cache = _reshape_kv_cache(
104
+ attn_groups,
105
+ raw_tensors,
106
+ "auto",
107
+ [spec.block_size],
108
+ {},
109
+ )["layer"]
110
+
111
+ assert kv_cache.shape == (num_blocks, 2, 16, 3, 2)
112
+ assert kv_cache.stride(0) == spec.page_size_bytes // 4
113
+ assert kv_cache.stride(1) == spec.real_page_size_bytes // 2 // 4
114
+ assert kv_cache.stride(2) == 2
115
+ assert kv_cache.stride(3) == spec.block_size * spec.head_size
116
+ assert kv_cache[1, 0].storage_offset() == spec.page_size_bytes // 4
117
+ assert (
118
+ kv_cache[1, 1].storage_offset()
119
+ == (spec.page_size_bytes + spec.real_page_size_bytes // 2) // 4
120
+ )
121
+ assert (
122
+ kv_cache[1, 1, 3, 2].storage_offset()
123
+ == (
124
+ spec.page_size_bytes
125
+ + spec.real_page_size_bytes // 2
126
+ + 3 * spec.head_size * 4
127
+ + 2 * spec.block_size * spec.head_size * 4
128
+ )
129
+ // 4
130
+ )
131
+
132
+
133
+class FakeDiffKVBackend:
134
+ @staticmethod
135
+ def get_kv_cache_shape(
136
+ num_blocks: int,
137
+ block_size: int,
138
+ num_kv_heads: int,
139
+ head_size: int,
140
+ cache_dtype_str: str = "auto",
141
+ ) -> tuple[int, ...]:
142
+ return (num_blocks, block_size, num_kv_heads, head_size * 2)
143
+
144
+ @staticmethod
145
+ def get_kv_cache_stride_order(
146
+ include_num_layers_dimension: bool = False,
147
+ ) -> tuple[int, ...]:
148
+ assert not include_num_layers_dimension
149
+ return (0, 1, 2, 3)
150
+
151
+
152
+def test_reshape_padded_diff_kv_cache_does_not_infer_kv_dim():
153
+ num_blocks = 3
154
+ spec = FullAttentionSpec(
155
+ block_size=16,
156
+ num_kv_heads=1,
157
+ head_size=2,
158
+ dtype=torch.float32,
159
+ page_size_padded=384,
160
+ )
161
+
162
+ raw_tensors = {
163
+ "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
164
+ }
165
+ attn_groups = [
166
+ AttentionGroup(
167
+ backend=FakeDiffKVBackend,
168
+ layer_names=["layer"],
169
+ kv_cache_spec=spec,
170
+ kv_cache_group_id=0,
171
+ )
172
+ ]
173
+
174
+ kv_cache = _reshape_kv_cache(
175
+ attn_groups,
176
+ raw_tensors,
177
+ "auto",
178
+ [spec.block_size],
179
+ {},
180
+ )["layer"]
181
+
182
+ assert kv_cache.shape == (num_blocks, 16, 1, 4)
183
+ assert kv_cache.stride(0) == spec.page_size_bytes // 4
184
+ assert kv_cache.stride(1) == 4
185
+
186
+
187
+class FakePerTokenScaleBackend:
188
+ @staticmethod
189
+ def get_kv_cache_shape(
190
+ num_blocks: int,
191
+ block_size: int,
192
+ num_kv_heads: int,
193
+ head_size: int,
194
+ cache_dtype_str: str = "auto",
195
+ ) -> tuple[int, ...]:
196
+ return (num_blocks, 2, block_size, num_kv_heads, head_size + 4)
197
+
198
+ @staticmethod
199
+ def get_kv_cache_stride_order(
200
+ include_num_layers_dimension: bool = False,
201
+ ) -> tuple[int, ...]:
202
+ assert not include_num_layers_dimension
203
+ return (0, 1, 2, 3, 4)
204
+
205
+
206
+def test_reshape_padded_quantized_kv_cache_preserves_scale_stride():
207
+ num_blocks = 3
208
+ spec = FullAttentionSpec(
209
+ block_size=16,
210
+ num_kv_heads=1,
211
+ head_size=4,
212
+ dtype=torch.int8,
213
+ kv_quant_mode=KVQuantMode.INT8_PER_TOKEN_HEAD,
214
+ page_size_padded=384,
215
+ )
216
+ assert spec.real_page_size_bytes == 128
217
+ assert spec.page_size_bytes == 384
218
+
219
+ raw_tensors = {
220
+ "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
221
+ }
222
+ attn_groups = [
223
+ AttentionGroup(
224
+ backend=FakePerTokenScaleBackend,
225
+ layer_names=["layer"],
226
+ kv_cache_spec=spec,
227
+ kv_cache_group_id=0,
228
+ )
229
+ ]
230
+
231
+ kv_cache = _reshape_kv_cache(
232
+ attn_groups,
233
+ raw_tensors,
234
+ "int8_per_token_head",
235
+ [spec.block_size],
236
+ {},
237
+ )["layer"]
238
+
239
+ assert kv_cache.shape == (num_blocks, 2, 16, 1, 8)
240
+ assert kv_cache.stride(0) == spec.page_size_bytes
241
+ assert kv_cache.stride(1) == 16 * 1 * 8
242
+ assert kv_cache[1, 1].storage_offset() == spec.page_size_bytes + 16 * 1 * 8
vllm/v1/attention/backend.py
+32
@@ -201,6 +201,38 @@ class AttentionBackend(ABC):
201
202
return min(s.base if isinstance(s, MultipleOf) else s for s in supported_sizes)
203
204
+ @classmethod
205
+ def indexes_kv_by_block_stride(cls) -> bool:
206
+ """Whether the backend reads KV pages by the runtime block stride.
207
+
208
+ True when ``num_blocks`` is the outermost physical dimension of the KV
209
+ cache, so the backend tolerates a non-contiguous block dim. This gates
210
+ page size padding and cross-layer uniform KV layout.
211
+
212
+ Returns:
213
+ True if the backend's physical KV layout is num-blocks-first. False
214
+ otherwise, including when the backend does not define a layered
215
+ stride order.
216
+ """
217
+ try:
218
+ kv_cache_stride_order = cls.get_kv_cache_stride_order(
219
+ include_num_layers_dimension=False
220
+ )
221
+ layered_kv_cache_stride_order = cls.get_kv_cache_stride_order(
222
+ include_num_layers_dimension=True
223
+ )
224
+ except (AttributeError, NotImplementedError):
225
+ return False
226
+
227
+ # Check that attention backend includes a layers dimension.
228
+ if len(layered_kv_cache_stride_order) != len(kv_cache_stride_order) + 1:
229
+ return False
230
+
231
+ # stride_order[0] == 0 means num_layers stays first in physical
232
+ # layout (identity permutation), so indexing by block stride is
233
+ # not supported.
234
+ return layered_kv_cache_stride_order[0] != 0
235
+
236
@classmethod
237
def is_mla(cls) -> bool:
238
return False
vllm/v1/core/kv_cache_utils.py
+24
-9
@@ -20,6 +20,7 @@ from vllm.utils.math_utils import cdiv, round_up
20
from vllm.utils.mem_utils import format_gib
21
from vllm.utils.torch_utils import get_dtype_size
22
from vllm.v1.kv_cache_interface import (
23
+ AttentionSpec,
24
ChunkedLocalAttentionSpec,
25
FullAttentionSpec,
26
HiddenStateCacheSpec,
@@ -1029,9 +1030,14 @@ def unify_kv_cache_spec_page_size(
1030
) -> dict[str, KVCacheSpec]:
1031
"""
1032
Unify the page size of the given KVCacheSpec. If the page size of all layers
1032
- are the same, return the original KVCacheSpec. If not same, unify the page
1033
- size by increasing the block size of layers with smaller page size. Raise
1034
- NotImplementedError if failed to unify the page size.
1033
+ are the same, return the original KVCacheSpec. If not same, first try to
1034
+ unify page size by increasing the block size of layers with smaller page
1035
+ size. If a smaller attention page does not evenly divide the maximum page
1036
+ size, keep its logical block size and pad its physical page instead --- but
1037
+ only for attention layers whose backend opts in via
1038
+ ``AttentionSpec.indexes_kv_by_block_stride`` (the padded page is read through
1039
+ a strided view, which not every backend handles). Raise NotImplementedError
1040
+ if failed to unify the page size.
1041
1042
Args:
1043
kv_cache_spec: The KVCacheSpec of each attention layer in the model
@@ -1051,14 +1057,23 @@ def unify_kv_cache_spec_page_size(
1057
new_kv_cache_spec[layer_name] = layer_spec
1058
else:
1059
layer_page_size = layer_spec.page_size_bytes
1054
- if max_page_size % layer_page_size != 0:
1060
+ if max_page_size % layer_page_size == 0:
1061
+ ratio = max_page_size // layer_page_size
1062
+ new_block_size = layer_spec.block_size * ratio
1063
+ new_spec = replace(layer_spec, block_size=new_block_size)
1064
+ elif (
1065
+ isinstance(layer_spec, AttentionSpec)
1066
+ and layer_spec.indexes_kv_by_block_stride
1067
+ ):
1068
+ new_spec = replace(layer_spec, page_size_padded=max_page_size)
1069
+ else:
1070
raise NotImplementedError(
1056
- "The page size of the layer is not divisible by the "
1057
- "maximum page size. Cannot unify by adjusting block_size."
1071
+ f"Layer {layer_name}: page size is not divisible by the "
1072
+ "maximum page size and cannot be padded. Padding is only "
1073
+ "supported for attention layers whose backend indexes KV "
1074
+ "pages by the block stride (indexes_kv_by_block_stride is "
1075
+ "True)."
1076
)
1059
- ratio = max_page_size // layer_page_size
1060
- new_block_size = layer_spec.block_size * ratio
1061
- new_spec = replace(layer_spec, block_size=new_block_size)
1077
assert new_spec.page_size_bytes == max_page_size
1078
new_kv_cache_spec[layer_name] = new_spec
1079
return new_kv_cache_spec
vllm/v1/kv_cache_interface.py
+13
-3
@@ -163,6 +163,7 @@ class AttentionSpec(KVCacheSpec):
163
dtype: torch.dtype
164
kv_quant_mode: KVQuantMode = KVQuantMode.NONE
165
page_size_padded: int | None = None
166
+ indexes_kv_by_block_stride: bool = False
167
168
@property
169
def page_size_bytes(self) -> int:
@@ -283,6 +284,7 @@ class FullAttentionSpec(AttentionSpec):
284
dtype=specs[0].dtype,
285
kv_quant_mode=specs[0].kv_quant_mode,
286
page_size_padded=specs[0].page_size_padded,
287
+ indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride,
288
sliding_window=cls.merge_window_sizes(sliding_window),
289
attention_chunk_size=cls.merge_window_sizes(attention_chunk_size),
290
# If any layer in the group is non-causal, treat the group as
@@ -403,13 +405,16 @@ class MLAAttentionSpec(FullAttentionSpec):
405
cache_dtype_str_set = set(spec.cache_dtype_str for spec in specs)
406
compress_ratio_set = set(spec.compress_ratio for spec in specs)
407
model_version_set = set(spec.model_version for spec in specs)
408
+ block_stride_set = set(spec.indexes_kv_by_block_stride for spec in specs)
409
assert (
410
len(cache_dtype_str_set) == 1
411
and len(compress_ratio_set) == 1
412
and len(model_version_set) == 1
413
+ and len(block_stride_set) == 1
414
), (
415
"All attention layers in the same KV cache group must use the same "
412
- "quantization method, compress ratio, and model version."
416
+ "quantization method, compress ratio, model version, and KV block "
417
+ "stride indexing."
418
)
419
return cls(
420
block_size=specs[0].block_size,
@@ -418,6 +423,7 @@ class MLAAttentionSpec(FullAttentionSpec):
423
dtype=specs[0].dtype,
424
kv_quant_mode=specs[0].kv_quant_mode,
425
page_size_padded=specs[0].page_size_padded,
426
+ indexes_kv_by_block_stride=block_stride_set.pop(),
427
cache_dtype_str=cache_dtype_str_set.pop(),
428
compress_ratio=compress_ratio_set.pop(),
429
model_version=model_version_set.pop(),
@@ -584,15 +590,17 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
590
compress_ratio_set = set(spec.compress_ratio for spec in specs)
591
model_version_set = set(spec.model_version for spec in specs)
592
sliding_window_set = set(spec.sliding_window for spec in specs)
593
+ block_stride_set = set(spec.indexes_kv_by_block_stride for spec in specs)
594
assert (
595
len(cache_dtype_str_set) == 1
596
and len(compress_ratio_set) == 1
597
and len(model_version_set) == 1
598
and len(sliding_window_set) == 1
599
+ and len(block_stride_set) == 1
600
), (
601
"All attention layers in the same KV cache group must use the same "
594
- "quantization method, compress ratio, model version and sliding "
595
- "window size."
602
+ "quantization method, compress ratio, model version, sliding "
603
+ "window size, and KV block stride indexing."
604
)
605
return cls(
606
block_size=specs[0].block_size,
@@ -600,6 +608,7 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
608
head_size=specs[0].head_size,
609
dtype=specs[0].dtype,
610
page_size_padded=specs[0].page_size_padded,
611
+ indexes_kv_by_block_stride=block_stride_set.pop(),
612
sliding_window=sliding_window_set.pop(),
613
cache_dtype_str=cache_dtype_str_set.pop(),
614
compress_ratio=compress_ratio_set.pop(),
@@ -711,6 +720,7 @@ class SinkFullAttentionSpec(FullAttentionSpec):
720
dtype=specs[0].dtype,
721
kv_quant_mode=specs[0].kv_quant_mode,
722
page_size_padded=specs[0].page_size_padded,
723
+ indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride,
724
sliding_window=cls.merge_window_sizes(sliding_window),
725
attention_chunk_size=cls.merge_window_sizes(attention_chunk_size),
726
non_causal=any(spec.non_causal for spec in specs),
vllm/v1/worker/gpu/attn_utils.py
+77
-41
@@ -1,13 +1,17 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from collections.abc import Iterable, Sequence
4
-from dataclasses import dataclass
4
+from dataclasses import dataclass, replace
5
from math import prod
6
from typing import Any, cast
7
8
import torch
9
10
-from vllm.config import VllmConfig, get_layers_from_vllm_config
10
+from vllm.config import (
11
+ VllmConfig,
12
+ get_layers_from_vllm_config,
13
+ set_current_vllm_config,
14
+)
15
from vllm.model_executor.layers.attention import Attention
16
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
17
from vllm.utils.torch_utils import get_dtype_size
@@ -47,6 +51,13 @@ def get_kv_cache_spec(vllm_config: VllmConfig) -> dict[str, KVCacheSpec]:
51
continue
52
# Skip modules that don't need KV cache (eg encoder-only attention)
53
if spec := attn_module.get_kv_cache_spec(vllm_config):
54
+ if isinstance(spec, AttentionSpec):
55
+ backend = attn_module.get_attn_backend()
56
+ # indexes_kv_by_block_stride() -> get_kv_cache_stride_order() ->
57
+ # get_kv_cache_layout() needs the current vLLM config.
58
+ with set_current_vllm_config(vllm_config):
59
+ indexes = backend.indexes_kv_by_block_stride()
60
+ spec = replace(spec, indexes_kv_by_block_stride=indexes)
61
kv_cache_spec[layer_name] = spec
62
return kv_cache_spec
63
@@ -180,6 +191,62 @@ def _allocate_kv_cache(
191
return kv_cache_raw_tensors
192
193
194
+def _reshape_attention_kv_cache(
195
+ kv_raw_tensor: torch.Tensor,
196
+ kv_cache_spec: AttentionSpec,
197
+ kv_cache_shape: tuple[int, ...],
198
+ kv_cache_stride_order: tuple[int, ...],
199
+ num_blocks: int,
200
+ packing: tuple[int, int] | None,
201
+) -> torch.Tensor:
202
+ permuted_kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
203
+ inv_order = [
204
+ kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
205
+ ]
206
+ dtype = kv_cache_spec.dtype
207
+
208
+ if packing is not None:
209
+ offset, block_stride = packing
210
+ assert inv_order[0] == 0
211
+ page_bytes = prod(kv_cache_shape[1:]) * get_dtype_size(dtype)
212
+ kv_cache = (
213
+ kv_raw_tensor.view(-1, block_stride)[:, offset : offset + page_bytes]
214
+ .view(dtype)
215
+ .view(kv_cache_shape)
216
+ )
217
+ elif kv_cache_spec.page_size_padded is not None:
218
+ # Use a strided view to skip the padding between physical pages.
219
+ #
220
+ # Only num-blocks-first layouts are supported (the block dimension is
221
+ # dim 0 of the unpermuted shape). kv-first layouts such as ROCm's
222
+ # ``(2, num_blocks, ...)`` are intentionally not supported here. For a
223
+ # num-blocks-first layout the only stride that must change is the block
224
+ # stride: every other (contiguous) stride already steps within the
225
+ # unpadded region of a page, so no further adjustment is needed.
226
+ assert kv_cache_shape[0] == num_blocks, (
227
+ "Padded KV pages require a num-blocks-first KV cache layout (got "
228
+ f"shape {kv_cache_shape} with num_blocks={num_blocks}); "
229
+ "kv-first layouts are not supported."
230
+ )
231
+ dtype_size = get_dtype_size(kv_cache_spec.dtype)
232
+ page_stride = kv_cache_spec.page_size_bytes // dtype_size
233
+
234
+ num_blocks_dim = inv_order[0]
235
+ strides = list(torch.empty(permuted_kv_cache_shape).stride())
236
+ strides[num_blocks_dim] = page_stride
237
+
238
+ kv_cache = torch.as_strided(
239
+ kv_raw_tensor.view(dtype),
240
+ size=permuted_kv_cache_shape,
241
+ stride=tuple(strides),
242
+ )
243
+ else:
244
+ # No padding — safe to use a contiguous view.
245
+ kv_cache = kv_raw_tensor.view(dtype).view(permuted_kv_cache_shape)
246
+
247
+ return kv_cache.permute(*inv_order)
248
+
249
+
250
def _reshape_kv_cache(
251
attn_groups: Sequence[AttentionGroup],
252
kv_cache_raw_tensors: dict[str, torch.Tensor],
@@ -248,45 +315,14 @@ def _reshape_kv_cache(
315
except (AttributeError, NotImplementedError):
316
kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
317
251
- kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
252
- inv_order = [
253
- kv_cache_stride_order.index(i)
254
- for i in range(len(kv_cache_stride_order))
255
- ]
256
-
257
- dtype = kv_cache_spec.dtype
258
- if packing is not None:
259
- offset, block_stride = packing
260
- assert inv_order[0] == 0
261
- page_bytes = prod(kv_cache_shape[1:]) * get_dtype_size(dtype)
262
- kv_cache = (
263
- kv_raw_tensor.view(-1, block_stride)[
264
- :, offset : offset + page_bytes
265
- ]
266
- .view(dtype)
267
- .view(kv_cache_shape)
268
- )
269
- elif kv_cache_spec.page_size_padded is not None:
270
- # Use strided view to handle page_size_bytes that
271
- # include padding. This follows the same pattern as
272
- # MambaSpec handling in gpu_model_runner.py.
273
- # NOTE: This assumes kv_cache_shape[0] == num_blocks
274
- # (i.e. the first physical dimension is the block
275
- # index), which holds for all current backends
276
- # (MLA, FlashAttention, TritonAttention, etc.).
277
- dtype_size = get_dtype_size(dtype)
278
- page_stride = kv_cache_spec.page_size_bytes // dtype_size
279
- strides = list(torch.empty(kv_cache_shape).stride())
280
- strides[inv_order[0]] = page_stride
281
- kv_cache = torch.as_strided(
282
- kv_raw_tensor.view(dtype),
283
- size=kv_cache_shape,
284
- stride=tuple(strides),
285
- )
286
- else:
287
- # No padding — safe to use a contiguous view.
288
- kv_cache = kv_raw_tensor.view(dtype).view(kv_cache_shape)
289
- kv_caches[layer_name] = kv_cache.permute(*inv_order)
318
+ kv_caches[layer_name] = _reshape_attention_kv_cache(
319
+ kv_raw_tensor,
320
+ kv_cache_spec,
321
+ kv_cache_shape,
322
+ kv_cache_stride_order,
323
+ kernel_num_blocks,
324
+ packing,
325
+ )
326
327
elif isinstance(kv_cache_spec, MambaSpec):
328
has_mamba = True
vllm/v1/worker/gpu_model_runner.py
+17
-52
@@ -12,7 +12,6 @@ from contextlib import contextmanager
12
from copy import copy, deepcopy
13
from dataclasses import dataclass, replace
14
from functools import reduce
15
-from math import prod
15
from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias, cast
16
17
import numpy as np
@@ -203,6 +202,7 @@ from vllm.v1.worker.cp_utils import (
202
)
203
from vllm.v1.worker.dp_utils import coordinate_batch_across_dp
204
from vllm.v1.worker.ec_connector_model_runner_mixin import ECConnectorModelRunnerMixin
205
+from vllm.v1.worker.gpu.attn_utils import _reshape_attention_kv_cache
206
from vllm.v1.worker.gpu.pool.late_interaction_runner import LateInteractionRunner
207
from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch
208
from vllm.v1.worker.gpu_ubatch_wrapper import UBatchWrapper
@@ -7125,62 +7125,20 @@ class GPUModelRunner(
7125
kv_cache_spec.head_size,
7126
cache_dtype_str=self.cache_config.cache_dtype,
7127
)
7128
- dtype = kv_cache_spec.dtype
7128
try:
7129
kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
7130
assert len(kv_cache_stride_order) == len(kv_cache_shape)
7131
except (AttributeError, NotImplementedError):
7132
kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
7134
- # The allocation respects the backend-defined stride order
7135
- # to ensure the semantic remains consistent for each
7136
- # backend. We first obtain the generic kv cache shape and
7137
- # then permute it according to the stride order which could
7138
- # result in a non-contiguous tensor.
7139
- kv_cache_shape = tuple(
7140
- kv_cache_shape[i] for i in kv_cache_stride_order
7133
+ raw_tensor = kv_cache_raw_tensors[layer_name]
7134
+ kv_caches[layer_name] = _reshape_attention_kv_cache(
7135
+ raw_tensor,
7136
+ kv_cache_spec,
7137
+ kv_cache_shape,
7138
+ kv_cache_stride_order,
7139
+ kernel_num_blocks,
7140
+ packing,
7141
)
7142
- # Maintain original KV shape view.
7143
- inv_order = [
7144
- kv_cache_stride_order.index(i)
7145
- for i in range(len(kv_cache_stride_order))
7146
- ]
7147
-
7148
- if packing is not None:
7149
- offset, block_stride = packing
7150
- assert inv_order[0] == 0
7151
- page_bytes = prod(kv_cache_shape[1:]) * get_dtype_size(dtype)
7152
- kv_cache = (
7153
- kv_cache_raw_tensors[layer_name]
7154
- .view(-1, block_stride)[:, offset : offset + page_bytes]
7155
- .view(dtype)
7156
- .view(kv_cache_shape)
7157
- )
7158
- elif kv_cache_spec.page_size_padded is not None:
7159
- # Use strided view to handle page_size_bytes that
7160
- # include padding. This follows
7161
- # the same pattern as MambaSpec handling below.
7162
- # NOTE: This assumes kv_cache_shape[0] == num_blocks
7163
- # (i.e. the first physical dimension is the block
7164
- # index), which holds for MLA backends but NOT for
7165
- # standard attention backends whose shape starts with
7166
- # a K/V dimension of size 2.
7167
- dtype_size = get_dtype_size(dtype)
7168
- page_stride = kv_cache_spec.page_size_bytes // dtype_size
7169
- strides = list(torch.empty(kv_cache_shape).stride())
7170
- strides[inv_order[0]] = page_stride
7171
- kv_cache = torch.as_strided(
7172
- kv_cache_raw_tensors[layer_name].view(dtype),
7173
- size=kv_cache_shape,
7174
- stride=tuple(strides),
7175
- )
7176
- else:
7177
- # No padding — safe to use a contiguous view.
7178
- kv_cache = (
7179
- kv_cache_raw_tensors[layer_name]
7180
- .view(dtype)
7181
- .view(kv_cache_shape)
7182
- )
7183
- kv_caches[layer_name] = kv_cache.permute(*inv_order)
7142
7143
elif isinstance(kv_cache_spec, MambaSpec):
7144
has_mamba = True
@@ -7265,7 +7223,7 @@ class GPUModelRunner(
7223
7224
# Try creating KV caches optimized for kv-connector transfers
7225
cache_dtype = self.cache_config.cache_dtype
7268
- if self.use_uniform_kv_cache(self.attn_groups, cache_dtype):
7226
+ if self.use_uniform_kv_cache(self.attn_groups):
7227
kv_caches, cross_layers_kv_cache, attn_backend = (
7228
self.allocate_uniform_kv_caches(
7229
kv_cache_config,
@@ -7515,6 +7473,13 @@ class GPUModelRunner(
7473
continue
7474
# Skip modules that don't need KV cache (eg encoder-only attention)
7475
if spec := attn_module.get_kv_cache_spec(self.vllm_config):
7476
+ if isinstance(spec, AttentionSpec):
7477
+ backend = attn_module.get_attn_backend()
7478
+ # indexes_kv_by_block_stride() -> get_kv_cache_stride_order()
7479
+ # -> get_kv_cache_layout() needs the current vLLM config.
7480
+ with set_current_vllm_config(self.vllm_config):
7481
+ indexes = backend.indexes_kv_by_block_stride()
7482
+ spec = replace(spec, indexes_kv_by_block_stride=indexes)
7483
kv_cache_spec[layer_name] = spec
7484
7485
return kv_cache_spec
vllm/v1/worker/kv_connector_model_runner_mixin.py
+4
-29
@@ -114,7 +114,6 @@ class KVConnectorModelRunnerMixin:
114
@staticmethod
115
def use_uniform_kv_cache(
116
attn_groups: list[list[AttentionGroup]],
117
- cache_dtype: CacheDType,
117
) -> bool:
118
"""
119
Determines whether a uniform KV layout should be used.
@@ -128,9 +127,9 @@ class KVConnectorModelRunnerMixin:
127
have the same page size.
128
2. A KV connector is configured, and the KV connector instance prefers
129
to use this layout (prefer_cross_layer_blocks() returns True)
131
- 2. The flash attention backend supports this layout
132
- (get_kv_cache_stride_order(True) includes a placement for a
133
- num_layers dimension)
130
+ 3. The attention backend indexes KV by the block stride
131
+ (kv_cache_spec.indexes_kv_by_block_stride), i.e. num_blocks is the
132
+ outermost physical dim so per-block all-layers data is contiguous.
133
134
Note that the actual placement of the num_layers dimensions
135
in the unified layers tensors will be determined by the attention
@@ -140,7 +139,6 @@ class KVConnectorModelRunnerMixin:
139
140
Args:
141
attn_groups: The list of attention groups for this model
143
- cache_dtype: The KV cache dtype
142
Returns:
143
True if we should use a uniform KV cache layout.
144
"""
@@ -157,30 +155,7 @@ class KVConnectorModelRunnerMixin:
155
kv_cache_spec = attn_group.kv_cache_spec
156
if not isinstance(kv_cache_spec, AttentionSpec):
157
return False
160
-
161
- attn_backend = attn_group.backend
162
- kv_cache_shape = attn_backend.get_kv_cache_shape(
163
- 1234,
164
- kv_cache_spec.block_size,
165
- kv_cache_spec.num_kv_heads,
166
- kv_cache_spec.head_size,
167
- cache_dtype_str=cache_dtype,
168
- )
169
-
170
- try:
171
- kv_cache_stride_order = attn_backend.get_kv_cache_stride_order(
172
- include_num_layers_dimension=True
173
- )
174
- except (AttributeError, NotImplementedError):
175
- return False
176
-
177
- # check that attention backend includes a layers dimension
178
- if len(kv_cache_stride_order) != len(kv_cache_shape) + 1:
179
- return False
180
-
181
- # stride_order[0] == 0 means num_layers stays first in physical
182
- # layout (identity permutation), so cross-layer is unsupported.
183
- return kv_cache_stride_order[0] != 0
158
+ return kv_cache_spec.indexes_kv_by_block_stride
159
160
@staticmethod
161
def allocate_uniform_kv_caches(