[5/N][KV-Cache Layout Refactor] Backend-published KV packing via customize_spec (#51704)
Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Claude <noreply@anthropic.com>
Lucas Wilkinson committed
Aug 14, 2026 at 09:47 UTC
57bd0ed441095b5c546707cabe25d3fa08b7f161
18 files changed
+204
-219
tests/quantization/test_turboquant.py
+15
-6
@@ -281,7 +281,10 @@ class TestTurboQuantKVCacheSpec:
281
@pytest.mark.parametrize("preset", ALL_PRESETS)
282
def test_kv_cache_spec_sets_kv_quant_mode(self, preset):
283
from vllm.model_executor.layers.attention.attention import Attention
284
- from vllm.v1.kv_cache_interface import KVQuantMode, TQFullAttentionSpec
284
+ from vllm.v1.attention.backends.turboquant_attn import (
285
+ TurboQuantAttentionBackend,
286
+ )
287
+ from vllm.v1.kv_cache_interface import FullAttentionSpec
288
289
layer = SimpleNamespace(
290
attn_type="decoder",
@@ -294,10 +297,16 @@ class TestTurboQuantKVCacheSpec:
297
)
298
vllm_config = SimpleNamespace(cache_config=SimpleNamespace(block_size=32))
299
300
+ # The layer builds an unpacked spec; the worker's spec-collection
301
+ # loop applies TQ slot packing via the backend's customize_spec hook.
302
spec = Attention.get_kv_cache_spec(layer, vllm_config)
303
+ assert isinstance(spec, FullAttentionSpec)
304
+ assert spec.kv_quant_mode.is_turboquant
305
+ assert spec.state_content_bytes is None
306
299
- assert isinstance(spec, TQFullAttentionSpec)
300
- assert spec.kv_quant_mode == KVQuantMode.TURBOQUANT
307
+ spec = TurboQuantAttentionBackend.customize_spec(spec)
308
+ expected_slot = TurboQuantConfig.from_cache_dtype(preset, 128).slot_size_aligned
309
+ assert spec.state_content_bytes == expected_slot
310
311
312
class TestTurboQuantWorkspaceReservation:
@@ -333,15 +342,15 @@ class TestTurboQuantWorkspaceReservation:
342
343
@staticmethod
344
def _fake_kv_cache_spec():
336
- from vllm.v1.kv_cache_interface import TQFullAttentionSpec
345
+ from vllm.v1.kv_cache_interface import FullAttentionSpec
346
338
- return TQFullAttentionSpec(
347
+ return FullAttentionSpec(
348
block_size=32,
349
num_kv_heads=4,
350
head_size=128,
351
head_size_v=128,
352
dtype=torch.uint8,
344
- tq_slot_size=102,
353
+ state_content_bytes=102,
354
)
355
356
def test_metadata_builder_reserves_decode_and_continuation_prefill_workspace(
tests/v1/core/test_kv_cache_utils.py
+10
-11
@@ -2766,21 +2766,20 @@ def test_unify_kv_cache_page_size_padding_requires_backend_support():
2766
kv_cache_utils.unify_kv_cache_spec_page_size(specs)
2767
2768
2769
-def test_unpadded_page_size_without_quant_matches_real_page():
2770
- # Without quantization the offload transfer width is just the raw page.
2771
- spec = new_kv_cache_spec()
2772
- assert spec.unpadded_page_size_bytes == spec.real_page_size_bytes
2773
- assert spec.page_size_bytes == spec.unpadded_page_size_bytes
2774
-
2775
-
2769
def test_unpadded_page_size_includes_per_token_head_scales():
2770
# Per-token-head quant carries inline fp32 scales that are carved from the
2778
- # raw KV allocation, so they must be budgeted into the offload width.
2779
- spec = new_kv_cache_spec(
2780
- dtype=torch.uint8, kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD
2771
+ # raw KV allocation, so they must be budgeted into the offload width. The
2772
+ # packing is published by the owning backend's customize_spec hook.
2773
+ from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
2774
+
2775
+ dense = new_kv_cache_spec(dtype=torch.uint8)
2776
+ spec = TritonAttentionBackend.customize_spec(
2777
+ new_kv_cache_spec(
2778
+ dtype=torch.uint8, kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD
2779
+ )
2780
)
2781
scales = 2 * spec.block_size * spec.num_kv_heads * 4
2783
- assert spec.unpadded_page_size_bytes == spec.real_page_size_bytes + scales
2782
+ assert spec.unpadded_page_size_bytes == dense.unpadded_page_size_bytes + scales
2783
assert spec.page_size_bytes == spec.unpadded_page_size_bytes
2784
2785
tests/v1/test_kv_cache_spec_registry.py
-11
@@ -34,7 +34,6 @@ from vllm.v1.kv_cache_interface import (
34
SinkFullAttentionSpec,
35
SlidingWindowMLASpec,
36
SlidingWindowSpec,
37
- TQFullAttentionSpec,
37
UniformTypeKVCacheSpecs,
38
get_kv_cache_spec_kind,
39
)
@@ -85,7 +84,6 @@ class _TrulyUnregisteredSpec(KVCacheSpec):
84
85
spec_manager_map: dict[type[KVCacheSpec], type[SingleTypeKVCacheManager]] = {
86
FullAttentionSpec: FullAttentionManager,
88
- TQFullAttentionSpec: FullAttentionManager,
87
MLAAttentionSpec: FullAttentionManager,
88
HiddenStateCacheSpec: FullAttentionManager,
89
SlidingWindowSpec: SlidingWindowManager,
@@ -98,7 +96,6 @@ spec_manager_map: dict[type[KVCacheSpec], type[SingleTypeKVCacheManager]] = {
96
97
spec_uniform_base_map: dict[type[KVCacheSpec], type[KVCacheSpec]] = {
98
FullAttentionSpec: FullAttentionSpec,
101
- TQFullAttentionSpec: FullAttentionSpec,
99
MLAAttentionSpec: FullAttentionSpec,
100
HiddenStateCacheSpec: FullAttentionSpec,
101
SlidingWindowSpec: SlidingWindowSpec,
@@ -113,13 +110,6 @@ spec_args_map: dict[type[KVCacheSpec], dict[str, Any]] = {
110
FullAttentionSpec: dict(
111
block_size=64, num_kv_heads=8, head_size=128, dtype=torch.bfloat16
112
),
116
- TQFullAttentionSpec: dict(
117
- block_size=64,
118
- num_kv_heads=8,
119
- head_size=128,
120
- dtype=torch.bfloat16,
121
- tq_slot_size=256,
122
- ),
113
MLAAttentionSpec: dict(
114
block_size=64, num_kv_heads=1, head_size=128, dtype=torch.bfloat16
115
),
@@ -262,7 +252,6 @@ class TestKVCacheSpecRegistry:
252
def test_full_attention_family_specs_are_uniform(self):
253
specs = [
254
make_spec(FullAttentionSpec),
265
- make_spec(TQFullAttentionSpec),
255
make_spec(MLAAttentionSpec),
256
make_spec(HiddenStateCacheSpec),
257
make_spec(SinkFullAttentionSpec),
tests/v1/worker/test_dsv4_packed_zeroer_geometry.py
+1
@@ -93,6 +93,7 @@ def test_dsv4_packed_zeroer_geometry():
93
cache_dtype_str="fp8_ds_mla",
94
alignment=576,
95
model_version="deepseek_v4",
96
+ state_content_bytes=584, # >576 to allocate room for scales at back of page
97
)
98
for _ in layer_names
99
]
vllm/model_executor/layers/attention/attention.py
+11
-26
@@ -623,14 +623,17 @@ class Attention(nn.Module, AttentionLayerBase):
623
# bytes per block. Otherwise (page_size_padded is None) the smallest
624
# block is fine — ``unify`` scales it up by an integer ratio.
625
shared_page = vllm_config.cache_config.skip_page_size_padded
626
- sw_per_token = SlidingWindowSpec(
627
- block_size=1,
628
- num_kv_heads=self.num_kv_heads,
629
- head_size=self.head_size,
630
- head_size_v=self.head_size_v,
631
- dtype=self.kv_cache_torch_dtype,
632
- kv_quant_mode=quant_mode,
633
- sliding_window=self.sliding_window,
626
+ # The backend owns its packing
627
+ sw_per_token = self.attn_backend.customize_spec(
628
+ SlidingWindowSpec(
629
+ block_size=1,
630
+ num_kv_heads=self.num_kv_heads,
631
+ head_size=self.head_size,
632
+ head_size_v=self.head_size_v,
633
+ dtype=self.kv_cache_torch_dtype,
634
+ kv_quant_mode=quant_mode,
635
+ sliding_window=self.sliding_window,
636
+ )
637
).real_page_size_bytes
638
sw_block_size = _largest_kernel_block_within(
639
self.attn_backend,
@@ -649,24 +652,6 @@ class Attention(nn.Module, AttentionLayerBase):
652
sliding_window=self.sliding_window,
653
page_size_padded=shared_page,
654
)
652
- elif self.kv_cache_dtype.startswith("turboquant_"):
653
- from vllm.model_executor.layers.quantization.turboquant.config import (
654
- TurboQuantConfig,
655
- )
656
- from vllm.v1.kv_cache_interface import TQFullAttentionSpec
657
-
658
- tq_config = TurboQuantConfig.from_cache_dtype(
659
- self.kv_cache_dtype, self.head_size
660
- )
661
- return TQFullAttentionSpec(
662
- block_size=block_size,
663
- num_kv_heads=self.num_kv_heads,
664
- head_size=self.head_size,
665
- head_size_v=self.head_size,
666
- dtype=self.kv_cache_torch_dtype,
667
- kv_quant_mode=quant_mode,
668
- tq_slot_size=tq_config.slot_size_aligned,
669
- )
655
else:
656
return FullAttentionSpec(
657
block_size=block_size,
vllm/model_executor/layers/attention/mla_attention.py
+20
-1
@@ -204,7 +204,7 @@ import itertools
204
import math
205
from abc import abstractmethod
206
from collections.abc import Callable
207
-from dataclasses import dataclass
207
+from dataclasses import dataclass, replace
208
from enum import Enum
209
from math import lcm
210
from typing import ClassVar, Generic, TypeVar, cast
@@ -270,6 +270,7 @@ from vllm.utils.torch_utils import (
270
_encode_layer_name,
271
_resolve_layer_name,
272
direct_register_custom_op,
273
+ get_dtype_size,
274
is_quantized_kv_cache,
275
kv_cache_dtype_str_to_dtype,
276
np_to_pinned_tensor,
@@ -298,6 +299,7 @@ from vllm.v1.attention.selector import get_attn_backend
299
from vllm.v1.kv_cache_interface import (
300
AttentionSpec,
301
KVCacheSpec,
302
+ KVQuantMode,
303
MLAAttentionSpec,
304
SlidingWindowMLASpec,
305
get_kv_quant_mode,
@@ -1152,6 +1154,9 @@ class MLAAttention(nn.Module, AttentionLayerBase):
1154
dtype=kv_cache_dtype,
1155
cache_dtype_str=self.kv_cache_dtype,
1156
kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
1157
+ # fp8_ds_mla: 656-byte custom layout (kv_lora_rank=512 +
1158
+ # qk_rope_head_dim=64, head_size=576). See flashmla_sparse.py.
1159
+ state_content_bytes=656 if self.kv_cache_dtype == "fp8_ds_mla" else None,
1160
)
1161
if self.sliding_window is not None:
1162
return SlidingWindowMLASpec(
@@ -1370,6 +1375,20 @@ class _DecodeConcatQuantFP8(QuantFP8):
1375
1376
1377
class MLACommonBackend(AttentionBackend):
1378
+ @classmethod
1379
+ def customize_spec(cls, spec: "AttentionSpec") -> "AttentionSpec":
1380
+ """Per-token-head modes pack an inline fp32 scale pair after the
1381
+ latent data (single-sided: ``head_size_v == 0`` for MLA)."""
1382
+ mode = spec.kv_quant_mode
1383
+ if spec.state_content_bytes is not None or not mode.is_per_token_head:
1384
+ return spec
1385
+ head_size = spec.head_size
1386
+ if mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
1387
+ head_size //= 2
1388
+ scale_bytes = get_dtype_size(torch.float32)
1389
+ content = head_size * get_dtype_size(spec.dtype) + 2 * scale_bytes
1390
+ return replace(spec, state_content_bytes=content)
1391
+
1392
@staticmethod
1393
def get_name() -> str:
1394
return "TRITON_MLA"
vllm/models/deepseek_v4/attention.py
+3
@@ -670,6 +670,9 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
670
alignment=576 if uses_fp8_ds_mla_layout else 512,
671
model_version="deepseek_v4",
672
kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
673
+ # DeepseekV4: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B per token;
674
+ # head_size stays semantic (512).
675
+ state_content_bytes=584 if uses_fp8_ds_mla_layout else None,
676
)
677
678
vllm/models/kimi_k3/nvidia/mla.py
+2
@@ -400,6 +400,8 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
400
dtype=kv_cache_dtype,
401
cache_dtype_str=self.kv_cache_dtype,
402
kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
403
+ # fp8_ds_mla: 656-byte custom layout; see flashmla_sparse.py.
404
+ state_content_bytes=656 if self.kv_cache_dtype == "fp8_ds_mla" else None,
405
non_causal_multi_token_decode=self.non_causal_multi_token_decode,
406
)
407
vllm/platforms/interface.py
+13
-13
@@ -687,13 +687,15 @@ class Platform:
687
688
def per_token_page_bytes(dtype: "torch.dtype", cache_dtype: str) -> int:
689
"""Bytes one token occupies in one layer, for the given dtype."""
690
- return FullAttentionSpec(
690
+ spec = FullAttentionSpec(
691
block_size=1,
692
num_kv_heads=model_config.get_num_kv_heads(parallel_config),
693
head_size=model_config.get_head_size(),
694
dtype=dtype,
695
kv_quant_mode=get_kv_quant_mode(cache_dtype),
696
- ).page_size_bytes
696
+ )
697
+ # The backend owns its packing
698
+ return backend_cls.customize_spec(spec).page_size_bytes
699
700
primary_dtype = (
701
STR_DTYPE_TO_TORCH_DTYPE[cache_config.cache_dtype]
@@ -809,23 +811,18 @@ class Platform:
811
# when all attention layers are TQ. With mixed skip+TQ the skip
812
# layers still use the standard layout — take max so mamba
813
# padding covers the largest actual page.
812
- from vllm.model_executor.layers.quantization.turboquant.config import (
813
- TurboQuantConfig,
814
+ from vllm.v1.attention.backends.turboquant_attn import (
815
+ TurboQuantAttentionBackend,
816
)
815
- from vllm.v1.kv_cache_interface import TQFullAttentionSpec
817
817
- tq_cfg = TurboQuantConfig.from_cache_dtype(
818
- cache_config.cache_dtype, model_config.get_head_size()
819
- )
820
- tq_page = TQFullAttentionSpec(
818
+ tq_spec = FullAttentionSpec(
819
block_size=1,
820
num_kv_heads=model_config.get_num_kv_heads(parallel_config),
821
head_size=model_config.get_head_size(),
824
- head_size_v=model_config.get_head_size(),
822
dtype=kv_cache_dtype,
823
kv_quant_mode=kv_quant_mode,
827
- tq_slot_size=tq_cfg.slot_size_aligned,
828
- ).page_size_bytes
824
+ )
825
+ tq_page = TurboQuantAttentionBackend.customize_spec(tq_spec).page_size_bytes
826
if cache_config.kv_cache_dtype_skip_layers:
827
skip_page = FullAttentionSpec(
828
block_size=1,
@@ -840,12 +837,15 @@ class Platform:
837
else:
838
attn_page_size_1_token = tq_page
839
else:
843
- attn_page_size_1_token = FullAttentionSpec(
840
+ attn_spec = FullAttentionSpec(
841
block_size=1,
842
num_kv_heads=model_config.get_num_kv_heads(parallel_config),
843
head_size=model_config.get_head_size(),
844
dtype=kv_cache_dtype,
845
kv_quant_mode=kv_quant_mode,
846
+ )
847
+ attn_page_size_1_token = backend_cls.customize_spec(
848
+ attn_spec
849
).page_size_bytes
850
851
# Compute mamba page size
vllm/v1/attention/backend.py
+15
-1
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
24
from vllm.model_executor.layers.quantization.utils.quant_utils import QuantKey
25
from vllm.platforms.interface import DeviceCapability
26
from vllm.v1.attention.backends.utils import KVCacheLayoutType
27
- from vllm.v1.kv_cache_interface import KVCacheSpec, KVQuantMode
27
+ from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheSpec, KVQuantMode
28
29
from vllm.v1.kv_cache_interface import get_kv_quant_mode
30
@@ -197,6 +197,20 @@ class AttentionBackend(ABC):
197
return True
198
return False
199
200
+ @classmethod
201
+ def customize_spec(cls, spec: "AttentionSpec") -> "AttentionSpec":
202
+ """Adjust the layer's KV cache spec for this backend's kernels. Used when the
203
+ kernels want KV packed in a specific way.
204
+
205
+ NOTE: temporary compatibility API. Today the Attention layer builds the spec
206
+ from the model config and the backend only gets to adjust it post-hoc; the end
207
+ state is for the backend to build and return the spec directly, at which point
208
+ this hook goes away.
209
+
210
+ (see: https://github.com/vllm-project/vllm/issues/42449)
211
+ """
212
+ return spec
213
+
214
@classmethod
215
def get_preferred_block_size(cls, default_block_size: int) -> int:
216
supported_sizes = cls.get_supported_kernel_block_sizes()
vllm/v1/attention/backends/flashinfer.py
+17
-1
@@ -2,7 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Attention layer with FlashInfer."""
4
5
-from dataclasses import dataclass
5
+from dataclasses import dataclass, replace
6
from enum import Enum
7
from functools import partial
8
from typing import ClassVar
@@ -50,6 +50,7 @@ from vllm.utils.math_utils import cdiv
50
from vllm.utils.torch_utils import (
51
PIN_MEMORY,
52
canonicalize_singleton_dim_strides,
53
+ get_dtype_size,
54
is_quantized_kv_cache,
55
is_strictly_contiguous,
56
nvfp4_kv_cache_full_dim,
@@ -387,6 +388,21 @@ class BatchDCPPrefillWrapper:
388
389
390
class FlashInferBackend(AttentionBackend):
391
+ @classmethod
392
+ def customize_spec(cls, spec: "AttentionSpec") -> "AttentionSpec":
393
+ """NVFP4 stores K and V as separate per-head slots of packed fp4 data
394
+ plus fp8 block scales."""
395
+ if spec.state_content_bytes is not None or not spec.kv_quant_mode.is_nvfp4:
396
+ return spec
397
+ hs_k = nvfp4_kv_cache_full_dim(spec.head_size)
398
+ hs_v = nvfp4_kv_cache_full_dim(spec.head_size_v)
399
+ assert hs_k == hs_v, "nvfp4 with asymmetric K/V head sizes not yet supported"
400
+ return replace(
401
+ spec,
402
+ num_head_slots=2 * spec.num_kv_heads,
403
+ state_content_bytes=hs_k * get_dtype_size(spec.dtype),
404
+ )
405
+
406
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16]
407
supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
408
"auto",
vllm/v1/attention/backends/mla/sparse_swa.py
+2
@@ -97,6 +97,8 @@ class DeepseekV4SWACache(torch.nn.Module, AttentionLayerBase):
97
dtype=self.dtype,
98
sliding_window=self.window_size,
99
cache_dtype_str=self.cache_config.cache_dtype,
100
+ # DeepseekV4 fp8_ds_mla: 584B per token (448B NoPE + 128B RoPE + 8B scales)
101
+ state_content_bytes=584 if uses_fp8_ds_mla_layout else None,
102
# 576B for FlashMLA packing; 512B for FlashInfer sparse (#44577).
103
alignment=576 if uses_fp8_ds_mla_layout else 512,
104
model_version="deepseek_v4",
vllm/v1/attention/backends/triton_attn.py
+16
-2
@@ -2,7 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""High-Performance Triton-only Attention layer."""
4
5
-from dataclasses import dataclass
5
+from dataclasses import dataclass, replace
6
from typing import ClassVar
7
8
import torch
@@ -19,7 +19,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
19
from vllm.platforms import current_platform
20
from vllm.platforms.interface import DeviceCapability
21
from vllm.utils.math_utils import next_power_of_2
22
-from vllm.utils.torch_utils import is_quantized_kv_cache
22
+from vllm.utils.torch_utils import get_dtype_size, is_quantized_kv_cache
23
from vllm.v1.attention.backend import (
24
AttentionBackend,
25
AttentionCGSupport,
@@ -274,6 +274,20 @@ class TritonAttentionMetadataBuilder(AttentionMetadataBuilder[TritonAttentionMet
274
275
276
class TritonAttentionBackend(AttentionBackend):
277
+ @classmethod
278
+ def customize_spec(cls, spec: "AttentionSpec") -> "AttentionSpec":
279
+ """Per-token-head modes pack inline fp32 scales after each head's
280
+ data, so the content is (data + one scale) per K/V side."""
281
+ mode = spec.kv_quant_mode
282
+ if spec.state_content_bytes is not None or not mode.is_per_token_head:
283
+ return spec
284
+ hs_k, hs_v = spec.head_size, spec.head_size_v
285
+ if mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
286
+ hs_k, hs_v = hs_k // 2, hs_v // 2
287
+ scale_bytes = get_dtype_size(torch.float32)
288
+ content = (hs_k + hs_v) * get_dtype_size(spec.dtype) + 2 * scale_bytes
289
+ return replace(spec, state_content_bytes=content)
290
+
291
supported_dtypes: ClassVar[list[torch.dtype]] = [
292
torch.float16,
293
torch.bfloat16,
vllm/v1/attention/backends/turboquant_attn.py
+16
-1
@@ -19,7 +19,7 @@ Per-head per-position slot layout:
19
import contextlib
20
import functools
21
import math
22
-from dataclasses import dataclass
22
+from dataclasses import dataclass, replace
23
from typing import Any, ClassVar
24
25
import torch
@@ -137,6 +137,21 @@ class TurboQuantAttentionBackend(AttentionBackend):
137
"turboquant_3bit_nc",
138
]
139
140
+ @classmethod
141
+ def customize_spec(cls, spec: AttentionSpec) -> AttentionSpec:
142
+ """TurboQuant packs K+V into one slot per head."""
143
+ if spec.state_content_bytes is not None or not spec.kv_quant_mode.is_turboquant:
144
+ return spec
145
+ from vllm.model_executor.layers.quantization.turboquant.config import (
146
+ TurboQuantConfig,
147
+ )
148
+
149
+ # KVQuantMode member names mirror the preset strings.
150
+ tq = TurboQuantConfig.from_cache_dtype(
151
+ spec.kv_quant_mode.name.lower(), spec.head_size
152
+ )
153
+ return replace(spec, state_content_bytes=tq.slot_size_aligned)
154
+
155
@staticmethod
156
def get_name() -> str:
157
return "TURBOQUANT"
vllm/v1/core/single_type_kv_cache_manager.py
-6
@@ -28,7 +28,6 @@ from vllm.v1.kv_cache_interface import (
28
SinkFullAttentionSpec,
29
SlidingWindowMLASpec,
30
SlidingWindowSpec,
31
- TQFullAttentionSpec,
31
)
32
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
33
from vllm.v1.request import Request
@@ -1929,11 +1928,6 @@ def register_all_kvcache_specs(vllm_config):
1928
)
1929
1930
# FullAttentionSpec subclasses — grouped with FullAttentionSpec
1932
- KVCacheSpecRegistry.register(
1933
- TQFullAttentionSpec,
1934
- FullAttentionManager,
1935
- uniform_type_base_spec=FullAttentionSpec,
1936
- )
1931
KVCacheSpecRegistry.register(
1932
MLAAttentionSpec, FullAttentionManager, uniform_type_base_spec=FullAttentionSpec
1933
)
vllm/v1/kv_cache_interface.py
+61
-140
@@ -16,7 +16,7 @@ from typing_extensions import Self
16
17
from vllm.logger import init_logger
18
from vllm.utils.math_utils import cdiv, round_up
19
-from vllm.utils.torch_utils import get_dtype_size, nvfp4_kv_cache_full_dim
19
+from vllm.utils.torch_utils import get_dtype_size
20
from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
21
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
22
@@ -46,7 +46,11 @@ class KVQuantMode(IntEnum):
46
FP8_PER_TOKEN_HEAD = 3 # per-token-head dynamic scales for fp8
47
INT4_PER_TOKEN_HEAD = 4 # packed 2×int4/byte, RHT + asymmetric zp
48
NVFP4 = 5 # packed fp4 data + fp8 block scales
49
- TURBOQUANT = 6 # Hadamard-rotated Lloyd-Max quant, packed K+V per slot
49
+ # Hadamard-rotated Lloyd-Max quant, packed K+V per slot.
50
+ TURBOQUANT_K8V4 = 6
51
+ TURBOQUANT_4BIT_NC = 7
52
+ TURBOQUANT_K3V4_NC = 8
53
+ TURBOQUANT_3BIT_NC = 9
54
55
@property
56
def is_per_token_head(self) -> bool:
@@ -64,8 +68,13 @@ class KVQuantMode(IntEnum):
68
69
@property
70
def is_turboquant(self) -> bool:
67
- """True for turboquant quantization mode."""
68
- return self == KVQuantMode.TURBOQUANT
71
+ """True for any turboquant quantization mode."""
72
+ return self in (
73
+ KVQuantMode.TURBOQUANT_K8V4,
74
+ KVQuantMode.TURBOQUANT_4BIT_NC,
75
+ KVQuantMode.TURBOQUANT_K3V4_NC,
76
+ KVQuantMode.TURBOQUANT_3BIT_NC,
77
+ )
78
79
80
def get_kv_quant_mode(kv_cache_dtype: str) -> KVQuantMode:
@@ -79,7 +88,7 @@ def get_kv_quant_mode(kv_cache_dtype: str) -> KVQuantMode:
88
if kv_cache_dtype.startswith("nvfp4"):
89
return KVQuantMode.NVFP4
90
if isinstance(kv_cache_dtype, str) and kv_cache_dtype.startswith("turboquant_"):
82
- return KVQuantMode.TURBOQUANT
91
+ return KVQuantMode[kv_cache_dtype.upper()]
92
if isinstance(kv_cache_dtype, str) and kv_cache_dtype.startswith("fp8"):
93
return KVQuantMode.FP8_PER_TENSOR
94
return KVQuantMode.NONE
@@ -210,21 +219,37 @@ class AttentionSpec(KVCacheSpec):
219
num_kv_heads: int
220
head_size: int
221
dtype: torch.dtype
222
+ head_size_v: int = None # type: ignore[assignment]
223
kv_quant_mode: KVQuantMode = KVQuantMode.NONE
224
page_size_padded: int | None = None
225
indexes_kv_by_block_stride: bool = False
226
+ num_head_slots: int | None = None
227
+ """H of the logical ``[B, H, N, C]`` page when packing diverges from one
228
+ slot per KV head. None means one slot per KV head. Published by the backend.
229
+ """
230
+ state_content_bytes: int | None = None
231
+ """C in bytes when packed; None means dense K/V content."""
232
+
233
+ def __post_init__(self):
234
+ if self.head_size_v is None:
235
+ object.__setattr__(self, "head_size_v", self.head_size)
236
+
237
+ @property
238
+ def num_heads(self) -> int:
239
+ if self.num_head_slots is not None:
240
+ return self.num_head_slots
241
+ return self.num_kv_heads
242
+
243
+ @property
244
+ def state_content_size_bytes(self) -> int:
245
+ """Bytes per (head slot, stored state) cell of the page."""
246
+ if self.state_content_bytes is not None:
247
+ return self.state_content_bytes
248
+ return (self.head_size + self.head_size_v) * get_dtype_size(self.dtype)
249
250
@property
251
def unpadded_page_size_bytes(self) -> int:
219
- unpadded = self.real_page_size_bytes
220
- # Per-token-head scales are stored in separate tensors managed
221
- # by the attention backend, but the memory is carved from the
222
- # raw KV cache allocation so it must be budgeted here.
223
- if self.kv_quant_mode.is_per_token_head:
224
- unpadded += (
225
- 2 * self.block_size * self.num_kv_heads * get_dtype_size(torch.float32)
226
- )
227
- return unpadded
252
+ return self.num_heads * self.storage_block_size * self.state_content_size_bytes
253
254
@property
255
def page_size_bytes(self) -> int:
@@ -235,20 +260,10 @@ class AttentionSpec(KVCacheSpec):
260
261
@property
262
def real_page_size_bytes(self) -> int:
238
- if self.kv_quant_mode.is_nvfp4:
239
- # Packed layout: fp4 data + fp8 block scales per head.
240
- head_dim = nvfp4_kv_cache_full_dim(self.head_size)
241
- elif self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
242
- head_dim = self.head_size // 2
243
- else:
244
- head_dim = self.head_size
245
- return (
246
- 2
247
- * self.block_size
248
- * self.num_kv_heads
249
- * head_dim
250
- * get_dtype_size(self.dtype)
251
- )
263
+ """Alias of ``unpadded_page_size_bytes``
264
+ TODO(lucas): follow up with TPU backend to see if we can remove this property.
265
+ """
266
+ return self.unpadded_page_size_bytes
267
268
def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
269
parallel_config = vllm_config.parallel_config
@@ -267,8 +282,6 @@ class FullAttentionSpec(AttentionSpec):
282
In this case, we use FullAttentionSpec and record the sliding window size.
283
"""
284
270
- head_size_v: int = None # type: ignore[assignment]
271
-
285
sliding_window: int | None = None
286
"""
287
Default to None for not using sliding window attention.
@@ -284,10 +297,6 @@ class FullAttentionSpec(AttentionSpec):
297
cache layout itself.
298
"""
299
287
- def __post_init__(self):
288
- if self.head_size_v is None:
289
- object.__setattr__(self, "head_size_v", self.head_size)
290
-
300
def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
301
max_model_len = vllm_config.model_config.max_model_len
302
dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
@@ -337,6 +346,8 @@ class FullAttentionSpec(AttentionSpec):
346
kv_quant_mode=specs[0].kv_quant_mode,
347
page_size_padded=specs[0].page_size_padded,
348
indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride,
349
+ num_head_slots=specs[0].num_head_slots,
350
+ state_content_bytes=specs[0].state_content_bytes,
351
sliding_window=cls.merge_window_sizes(sliding_window),
352
attention_chunk_size=cls.merge_window_sizes(attention_chunk_size),
353
# If any layer in the group is non-causal, treat the group as
@@ -357,23 +368,6 @@ class FullAttentionSpec(AttentionSpec):
368
)
369
return merged_spec
370
360
- @property
361
- def real_page_size_bytes(self) -> int:
362
- if self.kv_quant_mode.is_nvfp4:
363
- # Packed layout per head: fp4 data + fp8 block scales.
364
- # fp4 data: head_size//2 bytes (2 fp4 values per byte)
365
- # fp8 block scale: head_size//16 bytes (1 scale per 16 elements)
366
- last_dim = nvfp4_kv_cache_full_dim(
367
- self.head_size
368
- ) + nvfp4_kv_cache_full_dim(self.head_size_v)
369
- elif self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
370
- last_dim = self.head_size // 2 + self.head_size_v // 2
371
- else:
372
- last_dim = self.head_size + self.head_size_v
373
- return (
374
- self.block_size * self.num_kv_heads * last_dim * get_dtype_size(self.dtype)
375
- )
376
-
371
372
def _apply_alignment_padding(spec: MLAAttentionSpec | SlidingWindowMLASpec):
373
if spec.alignment is None:
@@ -384,32 +378,6 @@ def _apply_alignment_padding(spec: MLAAttentionSpec | SlidingWindowMLASpec):
378
object.__setattr__(spec, "page_size_padded", padded_page_size)
379
380
387
-@dataclass(frozen=True, kw_only=True)
388
-class TQFullAttentionSpec(FullAttentionSpec):
389
- """FullAttentionSpec with TQ-aware page size.
390
-
391
- Python equivalent of the C++ TQ4FullAttentionSpec. Overrides
392
- real_page_size_bytes to use TQ slot bytes instead of the raw
393
- head_size * dtype formula.
394
- """
395
-
396
- tq_slot_size: int = 0
397
-
398
- @property
399
- def real_page_size_bytes(self) -> int:
400
- if self.tq_slot_size > 0:
401
- return self.block_size * self.num_kv_heads * self.tq_slot_size
402
- return super().real_page_size_bytes
403
-
404
- @classmethod
405
- def merge(cls, specs: list[Self]) -> Self:
406
- merged = super().merge(specs)
407
- assert all(s.tq_slot_size == specs[0].tq_slot_size for s in specs), (
408
- "All TQ layers in the same KV cache group must use the same tq_slot_size."
409
- )
410
- return replace(merged, tq_slot_size=specs[0].tq_slot_size)
411
-
412
-
381
@dataclass(frozen=True, kw_only=True)
382
class MLAAttentionSpec(FullAttentionSpec):
383
# TODO(Lucas/Chen): less hacky way to do this
@@ -420,6 +388,8 @@ class MLAAttentionSpec(FullAttentionSpec):
388
model_version: str | None = None
389
# Marks draft groups that flatten a non-causal query block into decode rows.
390
non_causal_multi_token_decode: bool = False
391
+ # MLA stores a single latent vector per state; there is no separate V.
392
+ head_size_v: int = 0
393
394
def __post_init__(self):
395
super().__post_init__()
@@ -429,27 +399,6 @@ class MLAAttentionSpec(FullAttentionSpec):
399
def storage_block_size(self) -> int:
400
return self.block_size // self.compress_ratio
401
432
- @property
433
- def real_page_size_bytes(self) -> int:
434
- if self.cache_dtype_str == "fp8_ds_mla":
435
- if self.model_version == "deepseek_v4":
436
- # DeepseekV4: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B per token.
437
- # head_size stays semantic (512); bytes are determined here.
438
- return self.storage_block_size * 584
439
- # V3.2 main MLA: 656-byte custom layout (kv_lora_rank=512 +
440
- # qk_rope_head_dim=64, head_size=576). See flashmla_sparse.py.
441
- return self.block_size * 656
442
- if self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
443
- head_dim = self.head_size // 2
444
- else:
445
- head_dim = self.head_size
446
- return (
447
- self.storage_block_size
448
- * self.num_kv_heads
449
- * head_dim
450
- * get_dtype_size(self.dtype)
451
- )
452
-
402
@classmethod
403
def merge(cls, specs: list[Self]) -> Self:
404
assert all(isinstance(spec, MLAAttentionSpec) for spec in specs), (
@@ -476,6 +425,8 @@ class MLAAttentionSpec(FullAttentionSpec):
425
dtype=specs[0].dtype,
426
kv_quant_mode=specs[0].kv_quant_mode,
427
page_size_padded=specs[0].page_size_padded,
428
+ num_head_slots=specs[0].num_head_slots,
429
+ state_content_bytes=specs[0].state_content_bytes,
430
indexes_kv_by_block_stride=block_stride_set.pop(),
431
cache_dtype_str=cache_dtype_str_set.pop(),
432
compress_ratio=compress_ratio_set.pop(),
@@ -533,6 +484,8 @@ class RSWASpec(FullAttentionSpec):
484
kv_quant_mode=base.kv_quant_mode,
485
page_size_padded=base.page_size_padded,
486
indexes_kv_by_block_stride=base.indexes_kv_by_block_stride,
487
+ num_head_slots=base.num_head_slots,
488
+ state_content_bytes=base.state_content_bytes,
489
sliding_window=base.sliding_window,
490
attention_chunk_size=base.attention_chunk_size,
491
non_causal=base.non_causal,
@@ -583,7 +536,6 @@ class ChunkedLocalAttentionSpec(AttentionSpec):
536
@dataclass(frozen=True, kw_only=True)
537
class SlidingWindowSpec(AttentionSpec):
538
sliding_window: int
586
- head_size_v: int = None # type: ignore[assignment]
539
# The trailing edge of the window is extended by ``extra_retained_tokens``
540
# so that those extra trailing tokens' blocks are retained (but not
541
# attended). This is needed for multi-module spec decoding which can
@@ -591,30 +543,6 @@ class SlidingWindowSpec(AttentionSpec):
543
# of the sequence, and thus needs to delay freeing/caching of blocks.
544
extra_retained_tokens: int = 0
545
594
- def __post_init__(self):
595
- if self.head_size_v is None:
596
- object.__setattr__(self, "head_size_v", self.head_size)
597
-
598
- @property
599
- def real_page_size_bytes(self) -> int:
600
- # Mirror ``FullAttentionSpec.real_page_size_bytes`` for NVFP4 KV cache.
601
- if self.kv_quant_mode.is_nvfp4:
602
- last_dim = nvfp4_kv_cache_full_dim(
603
- self.head_size
604
- ) + nvfp4_kv_cache_full_dim(self.head_size_v)
605
- return (
606
- self.block_size
607
- * self.num_kv_heads
608
- * last_dim
609
- * get_dtype_size(self.dtype)
610
- )
611
- return (
612
- self.block_size
613
- * self.num_kv_heads
614
- * (self.head_size + self.head_size_v)
615
- * get_dtype_size(self.dtype)
616
- )
617
-
546
def max_admission_blocks_per_request(
547
self, max_in_flight_tokens: int, max_model_len: int
548
) -> int:
@@ -672,31 +600,20 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
600
alignment: int | None = None # Default to None for no padding.
601
compress_ratio: int = 1
602
model_version: str | None = None
603
+ # MLA stores a single latent vector per state; there is no separate V.
604
+ head_size_v: int = 0
605
606
def __post_init__(self):
607
+ assert self.model_version in (None, "deepseek_v4"), (
608
+ f"Unsupported model version: {self.model_version}"
609
+ )
610
+ super().__post_init__()
611
_apply_alignment_padding(self)
612
613
@property
614
def storage_block_size(self) -> int:
615
return self.block_size // self.compress_ratio
616
683
- @property
684
- def real_page_size_bytes(self) -> int:
685
- if self.model_version == "deepseek_v4" and self.cache_dtype_str == "fp8_ds_mla":
686
- # DeepseekV4 FlashMLA: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B
687
- # per token. FlashInfer's contiguous bf16/fp8 cache falls through to
688
- # the element-size formula below.
689
- return self.storage_block_size * 584
690
- assert self.model_version in (None, "deepseek_v4"), (
691
- f"Unsupported model version: {self.model_version}"
692
- )
693
- return (
694
- self.storage_block_size
695
- * self.num_kv_heads
696
- * self.head_size
697
- * get_dtype_size(self.dtype)
698
- )
699
-
617
@classmethod
618
def merge(cls, specs: list[Self]) -> Self:
619
assert all(isinstance(spec, SlidingWindowMLASpec) for spec in specs), (
@@ -727,6 +644,8 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
644
head_size=specs[0].head_size,
645
dtype=specs[0].dtype,
646
page_size_padded=specs[0].page_size_padded,
647
+ num_head_slots=specs[0].num_head_slots,
648
+ state_content_bytes=specs[0].state_content_bytes,
649
indexes_kv_by_block_stride=block_stride_set.pop(),
650
sliding_window=sliding_window_set.pop(),
651
extra_retained_tokens=extra_retained_set.pop(),
@@ -853,6 +772,8 @@ class SinkFullAttentionSpec(FullAttentionSpec):
772
kv_quant_mode=specs[0].kv_quant_mode,
773
page_size_padded=specs[0].page_size_padded,
774
indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride,
775
+ num_head_slots=specs[0].num_head_slots,
776
+ state_content_bytes=specs[0].state_content_bytes,
777
sliding_window=cls.merge_window_sizes(sliding_window),
778
attention_chunk_size=cls.merge_window_sizes(attention_chunk_size),
779
non_causal=any(spec.non_causal for spec in specs),
vllm/v1/worker/gpu/attn_utils.py
+1
@@ -75,6 +75,7 @@ def get_kv_cache_spec(vllm_config: VllmConfig) -> dict[str, KVCacheSpec]:
75
with set_current_vllm_config(vllm_config):
76
indexes = backend.indexes_kv_by_block_stride()
77
spec = replace(spec, indexes_kv_by_block_stride=indexes)
78
+ spec = backend.customize_spec(spec)
79
kv_cache_spec[layer_name] = spec
80
return kv_cache_spec
81
vllm/v1/worker/gpu_model_runner.py
+1
@@ -7925,6 +7925,7 @@ class GPUModelRunner(
7925
with set_current_vllm_config(self.vllm_config):
7926
indexes = backend.indexes_kv_by_block_stride()
7927
spec = replace(spec, indexes_kv_by_block_stride=indexes)
7928
+ spec = backend.customize_spec(spec)
7929
kv_cache_spec[layer_name] = spec
7930
7931
return kv_cache_spec