[Bugfix][KV cache] Support sparse-MLA targets with SWA drafts (#48776)
Signed-off-by: mgoin <mgoin64@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com>
Michael Goin committed
Jul 23, 2026 at 14:30 UTC
e18f0037a5d54dc2ead5896af896305f2bf57496
2 files changed
+181
-61
tests/v1/core/test_kv_cache_utils.py
+71
-6
@@ -4,6 +4,7 @@ import copy
4
import hashlib
5
import importlib
6
from collections.abc import Callable
7
+from types import SimpleNamespace
8
from typing import Any
9
10
import pytest
@@ -31,6 +32,7 @@ from vllm.v1.core.kv_cache_utils import (
32
generate_scheduler_kv_cache_config,
33
get_kv_cache_capacity,
34
get_kv_cache_configs,
35
+ get_kv_cache_groups,
36
get_max_concurrency_for_kv_cache_config,
37
get_request_block_hasher,
38
group_and_unify_kv_cache_specs,
@@ -2067,10 +2069,10 @@ def test_generate_scheduler_kv_cache_config():
2069
)
2070
2071
2070
-def new_mla_spec(cache_dtype_str=None):
2072
+def new_mla_spec(cache_dtype_str=None, block_size=16):
2073
# head_size = kv_lora_rank(512) + qk_rope_head_dim(64) = 576
2074
return MLAAttentionSpec(
2073
- block_size=16,
2075
+ block_size=block_size,
2076
num_kv_heads=1,
2077
head_size=576,
2078
dtype=torch.float32,
@@ -2120,6 +2122,66 @@ def test_group_and_unify_kv_cache_specs_mixed_page_size_groups():
2122
assert layer_names == {"mla.0", "mla.1", "swa.0"}
2123
2124
2125
+def new_indexer_mla_spec(block_size=16):
2126
+ # Sparse-attention indexer k_cache: an MLAAttentionSpec with a much smaller
2127
+ # page size than the main MLA attention (uint8, small head), so their pages
2128
+ # cannot be unified.
2129
+ return MLAAttentionSpec(
2130
+ block_size=block_size,
2131
+ num_kv_heads=1,
2132
+ head_size=132,
2133
+ dtype=torch.uint8,
2134
+ )
2135
+
2136
+
2137
+def _grouping_config():
2138
+ return SimpleNamespace(
2139
+ scheduler_config=SimpleNamespace(disable_hybrid_kv_cache_manager=False),
2140
+ speculative_config=None,
2141
+ )
2142
+
2143
+
2144
+def test_mla_draft_prefers_standard_layout_when_pages_can_be_unified():
2145
+ specs = {
2146
+ "target.0.attn": new_mla_spec(),
2147
+ "draft.0": new_sliding_window_spec(num_kv_heads=1, head_size=288),
2148
+ }
2149
+ assert len({spec.page_size_bytes for spec in specs.values()}) == 1
2150
+
2151
+ groups = get_kv_cache_groups(_grouping_config(), specs)
2152
+
2153
+ assert len(groups) == 2
2154
+ assert all(
2155
+ not isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs) for group in groups
2156
+ )
2157
+
2158
+
2159
+def test_mla_with_incompatible_swa_uses_one_full_allocation_group(caplog):
2160
+ # Sparse MLA pages cannot be padded safely. Keeping the draft's attention
2161
+ # compute sliding-window while promoting only its allocation semantics lets
2162
+ # every layer share the target's block table and remain contiguous.
2163
+ draft = new_sliding_window_spec(block_size=16)
2164
+ specs = {
2165
+ "target.0.attn": new_mla_spec(block_size=64),
2166
+ "target.0.indexer": new_indexer_mla_spec(block_size=64),
2167
+ "draft.0": draft,
2168
+ }
2169
+
2170
+ groups = get_kv_cache_groups(_grouping_config(), specs)
2171
+ assert len(groups) == 1
2172
+ assert set(groups[0].layer_names) == set(specs)
2173
+ group_spec = groups[0].kv_cache_spec
2174
+ assert isinstance(group_spec, UniformTypeKVCacheSpecs)
2175
+ assert group_spec.block_size == 64
2176
+ promoted_draft = group_spec.kv_cache_specs["draft.0"]
2177
+ assert isinstance(promoted_draft, FullAttentionSpec)
2178
+ assert not isinstance(promoted_draft, SlidingWindowSpec)
2179
+ assert promoted_draft.block_size == 64
2180
+ assert promoted_draft.sliding_window == draft.sliding_window
2181
+ assert specs["draft.0"] is draft
2182
+ assert "attention compute is unchanged" in caplog.text
2183
+
2184
+
2185
def test_get_kv_cache_spec_kind_prefers_specific_attention_subclasses():
2186
assert get_kv_cache_spec_kind(new_mla_spec()) == KVCacheSpecKind.MLA_ATTENTION
2187
@@ -2619,19 +2681,22 @@ def test_page_size_padded_wins():
2681
2682
def test_unify_hybrid_kv_cache_specs():
2683
# 1. has_full_attention and has_sliding_window
2622
- before_spec_1 = new_kv_cache_spec()
2684
+ before_spec_1 = new_kv_cache_spec(block_size=64)
2685
before_spec_2 = new_sliding_window_spec(
2624
- page_size_padded=32 * 1024, sliding_window=1024
2686
+ block_size=16, page_size_padded=32 * 1024, sliding_window=1024
2687
)
2688
kv_cache_spec = {
2689
"layer_1": before_spec_1,
2690
"layer_2": before_spec_2,
2691
}
2692
kv_cache_utils.unify_hybrid_kv_cache_specs(kv_cache_spec)
2631
- expected_spec_1 = new_kv_cache_spec()
2632
- expected_spec_2 = new_kv_cache_spec(page_size_padded=32 * 1024, sliding_window=1024)
2693
+ expected_spec_1 = new_kv_cache_spec(block_size=64)
2694
+ expected_spec_2 = new_kv_cache_spec(
2695
+ block_size=64, page_size_padded=64 * 1024, sliding_window=1024
2696
+ )
2697
assert kv_cache_spec["layer_1"] == expected_spec_1
2698
assert kv_cache_spec["layer_2"] == expected_spec_2
2699
+ assert kv_cache_spec["layer_2"].page_size_bytes == 64 * 1024
2700
2701
# 2. has_full_attention and has_chunked_local_attention
2702
before_spec_1 = new_kv_cache_spec()
vllm/v1/core/kv_cache_utils.py
+110
-55
@@ -1422,100 +1422,150 @@ def get_kv_cache_config_from_groups(
1422
)
1423
1424
1425
-def unify_hybrid_kv_cache_specs(kv_cache_spec: dict[str, KVCacheSpec]):
1426
- """
1427
- This function tries to convert the KV cache specs to one type if the model
1428
- is a hybrid model with multiple type of KV cache. It will convert all
1429
- SlidingWindowSpec to FullAttentionSpec if both types are present.
1425
+def _promote_local_kv_cache_specs(
1426
+ kv_cache_spec: dict[str, KVCacheSpec],
1427
+) -> dict[str, KVCacheSpec]:
1428
+ """Use full-attention allocation for local-attention cache specs.
1429
1431
- Args:
1432
- kv_cache_spec: The kv cache spec of each attention layer in the model
1430
+ The returned specs affect KV cache management only. Attention modules keep
1431
+ their original sliding-window or chunked-local compute behavior.
1432
"""
1433
+ promoted_specs = kv_cache_spec.copy()
1434
1435
if is_kv_cache_spec_uniform(
1436
- kv_cache_spec
1437
- ) or UniformTypeKVCacheSpecs.is_uniform_type(kv_cache_spec):
1438
- return
1439
-
1440
- logger.warning(
1441
- "Hybrid KV cache manager is disabled for this hybrid model, "
1442
- "This means we do not enable any optimizations for saving KV cache "
1443
- "memory (e.g., dropping the KV cache outside the sliding window). "
1444
- "The compute of layers like sliding window is still saved."
1445
- )
1436
+ promoted_specs
1437
+ ) or UniformTypeKVCacheSpecs.is_uniform_type(promoted_specs):
1438
+ return promoted_specs
1439
1440
has_full_attention = any(
1448
- isinstance(spec, FullAttentionSpec) for spec in kv_cache_spec.values()
1441
+ isinstance(spec, FullAttentionSpec) for spec in promoted_specs.values()
1442
)
1443
has_sliding_window = any(
1451
- isinstance(spec, SlidingWindowSpec) for spec in kv_cache_spec.values()
1444
+ isinstance(spec, SlidingWindowSpec) for spec in promoted_specs.values()
1445
)
1446
has_chunked_local_attention = any(
1454
- isinstance(spec, ChunkedLocalAttentionSpec) for spec in kv_cache_spec.values()
1447
+ isinstance(spec, ChunkedLocalAttentionSpec) for spec in promoted_specs.values()
1448
)
1456
- has_swa_mla = any(
1457
- isinstance(spec, SlidingWindowMLASpec) for spec in kv_cache_spec.values()
1449
+ full_block_sizes = {
1450
+ spec.block_size
1451
+ for spec in promoted_specs.values()
1452
+ if isinstance(spec, FullAttentionSpec)
1453
+ }
1454
+ full_attention_block_size = (
1455
+ next(iter(full_block_sizes)) if len(full_block_sizes) == 1 else None
1456
)
1457
1460
- uniform_block_size: int | None = None
1461
- if has_swa_mla:
1462
- # For DeepseekV4, block sizes can be different for different KV cache groups.
1463
- # E.g., Full MLA: 256; SWA MLA: 64; C4 partial states: 4, C128 states: 8.
1464
- assert has_full_attention
1465
- any_full_spec = next(
1466
- iter(
1467
- spec
1468
- for spec in kv_cache_spec.values()
1469
- if isinstance(spec, FullAttentionSpec)
1470
- )
1458
+ def promoted_page_size_padded(spec: AttentionSpec, block_size: int) -> int | None:
1459
+ if spec.page_size_padded is None:
1460
+ return None
1461
+ unpadded_page_size = (
1462
+ spec.unpadded_page_size_bytes * block_size // spec.block_size
1463
)
1472
- uniform_block_size = any_full_spec.block_size
1464
+ return max(spec.page_size_padded, unpadded_page_size)
1465
1466
if has_full_attention and (has_sliding_window or has_chunked_local_attention):
1467
for layer_name, spec in kv_cache_spec.items():
1468
if isinstance(spec, SlidingWindowMLASpec):
1477
- kv_cache_spec[layer_name] = MLAAttentionSpec(
1478
- block_size=uniform_block_size
1479
- if uniform_block_size is not None
1480
- else spec.block_size,
1469
+ block_size = full_attention_block_size or spec.block_size
1470
+ promoted_specs[layer_name] = MLAAttentionSpec(
1471
+ block_size=block_size,
1472
num_kv_heads=spec.num_kv_heads,
1473
head_size=spec.head_size,
1474
dtype=spec.dtype,
1484
- page_size_padded=spec.page_size_padded,
1475
+ page_size_padded=promoted_page_size_padded(spec, block_size),
1476
cache_dtype_str=spec.cache_dtype_str,
1477
alignment=spec.alignment,
1478
compress_ratio=spec.compress_ratio,
1479
model_version=spec.model_version,
1480
)
1481
elif isinstance(spec, SlidingWindowSpec):
1491
- kv_cache_spec[layer_name] = FullAttentionSpec(
1492
- block_size=spec.block_size,
1482
+ block_size = full_attention_block_size or spec.block_size
1483
+ promoted_specs[layer_name] = FullAttentionSpec(
1484
+ block_size=block_size,
1485
num_kv_heads=spec.num_kv_heads,
1486
head_size=spec.head_size,
1487
head_size_v=spec.head_size_v,
1488
dtype=spec.dtype,
1489
kv_quant_mode=spec.kv_quant_mode,
1490
sliding_window=spec.sliding_window,
1499
- page_size_padded=spec.page_size_padded,
1491
+ page_size_padded=promoted_page_size_padded(spec, block_size),
1492
)
1493
elif isinstance(spec, ChunkedLocalAttentionSpec):
1502
- kv_cache_spec[layer_name] = FullAttentionSpec(
1503
- block_size=spec.block_size,
1494
+ block_size = full_attention_block_size or spec.block_size
1495
+ promoted_specs[layer_name] = FullAttentionSpec(
1496
+ block_size=block_size,
1497
num_kv_heads=spec.num_kv_heads,
1498
head_size=spec.head_size,
1499
dtype=spec.dtype,
1500
attention_chunk_size=spec.attention_chunk_size,
1508
- page_size_padded=spec.page_size_padded,
1501
+ page_size_padded=promoted_page_size_padded(spec, block_size),
1502
)
1503
1504
if not (
1512
- is_kv_cache_spec_uniform(kv_cache_spec)
1513
- or UniformTypeKVCacheSpecs.is_uniform_type(kv_cache_spec)
1505
+ is_kv_cache_spec_uniform(promoted_specs)
1506
+ or UniformTypeKVCacheSpecs.is_uniform_type(promoted_specs)
1507
):
1515
- raise ValueError(
1516
- "Hybrid KV cache manager is disabled but failed to "
1517
- "convert the KV cache specs to one unified type."
1518
- )
1508
+ raise ValueError("Failed to promote local KV cache specs to one unified type.")
1509
+
1510
+ return promoted_specs
1511
+
1512
+
1513
+def _try_get_full_allocation_fallback_groups(
1514
+ kv_cache_spec: dict[str, KVCacheSpec],
1515
+) -> list[KVCacheGroupSpec] | None:
1516
+ """Try a supported full-allocation fallback for local-attention layers."""
1517
+ if any(isinstance(spec, HiddenStateCacheSpec) for spec in kv_cache_spec.values()):
1518
+ return None
1519
+ if any(
1520
+ isinstance(spec, (SlidingWindowMLASpec, ChunkedLocalAttentionSpec))
1521
+ for spec in kv_cache_spec.values()
1522
+ ):
1523
+ return None
1524
+
1525
+ has_mla = any(isinstance(spec, MLAAttentionSpec) for spec in kv_cache_spec.values())
1526
+ has_regular_swa = any(
1527
+ isinstance(spec, SlidingWindowSpec) for spec in kv_cache_spec.values()
1528
+ )
1529
+ if not (has_mla and has_regular_swa):
1530
+ return None
1531
+
1532
+ try:
1533
+ promoted_specs = _promote_local_kv_cache_specs(kv_cache_spec)
1534
+ except ValueError:
1535
+ return None
1536
+ uniform_spec = UniformTypeKVCacheSpecs.from_specs(promoted_specs)
1537
+ if uniform_spec is None:
1538
+ return None
1539
+ logger.warning(
1540
+ "KV cache page sizes cannot be unified; treating sliding-window "
1541
+ "layers as full attention for cache allocation. Sliding-window "
1542
+ "attention compute is unchanged."
1543
+ )
1544
+ return _get_kv_cache_groups_uniform_type(uniform_spec)
1545
+
1546
+
1547
+def unify_hybrid_kv_cache_specs(kv_cache_spec: dict[str, KVCacheSpec]):
1548
+ """
1549
+ This function tries to convert the KV cache specs to one type if the model
1550
+ is a hybrid model with multiple type of KV cache. It will convert all
1551
+ SlidingWindowSpec to FullAttentionSpec if both types are present.
1552
+
1553
+ Args:
1554
+ kv_cache_spec: The kv cache spec of each attention layer in the model
1555
+ """
1556
+
1557
+ if is_kv_cache_spec_uniform(
1558
+ kv_cache_spec
1559
+ ) or UniformTypeKVCacheSpecs.is_uniform_type(kv_cache_spec):
1560
+ return
1561
+
1562
+ logger.warning(
1563
+ "Hybrid KV cache manager is disabled for this hybrid model, "
1564
+ "This means we do not enable any optimizations for saving KV cache "
1565
+ "memory (e.g., dropping the KV cache outside the sliding window). "
1566
+ "The compute of layers like sliding window is still saved."
1567
+ )
1568
+ kv_cache_spec.update(_promote_local_kv_cache_specs(kv_cache_spec))
1569
1570
1571
def group_and_unify_kv_cache_specs(
@@ -1758,10 +1808,15 @@ def get_kv_cache_groups(
1808
if not isinstance(v, HiddenStateCacheSpec)
1809
}
1810
1761
- # As KVCacheManager can only allocate memory of one size, we need to unify
1762
- # the page size of the layers. For cases cannot be unified, this function
1763
- # will raise an error.
1764
- filtered_spec = unify_kv_cache_spec_page_size(filtered_spec)
1811
+ # Prefer preserving each layer's cache semantics. If physical pages cannot
1812
+ # be unified, try a supported allocation-only fallback before failing.
1813
+ try:
1814
+ filtered_spec = unify_kv_cache_spec_page_size(filtered_spec)
1815
+ except NotImplementedError:
1816
+ fallback_groups = _try_get_full_allocation_fallback_groups(kv_cache_spec)
1817
+ if fallback_groups is None:
1818
+ raise
1819
+ return fallback_groups
1820
groups = _get_kv_cache_groups_uniform_page_size(filtered_spec)
1821
1822
# Add hidden-state layers back with page aligned to the common page.