[Bugfix][AMD] Annotate draft KV cache groups on the hybrid grouping path (#52047)
Signed-off-by: okorzh-amd <okorzh-amd@users.noreply.github.com> Co-authored-by: okorzh-amd <okorzh-amd@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Douglas Lehr <91553416+dllehr-amd@users.noreply.github.com>
Oxana Korzh committed
Aug 28, 2026 at 20:53 UTC
93ab92be0cde1f33a0532e0096d5a42d3007f244
2 files changed
+227
-6
tests/v1/core/test_kv_cache_utils.py
+143
-1
@@ -4,6 +4,7 @@ import copy
4
import hashlib
5
import importlib
6
from collections.abc import Callable
7
+from dataclasses import replace
8
from types import SimpleNamespace
9
from typing import Any
10
@@ -2214,13 +2215,14 @@ def new_mla_spec(cache_dtype_str=None, block_size=16):
2215
)
2216
2217
2217
-def new_swa_mla_spec(head_size=576, sliding_window=128):
2218
+def new_swa_mla_spec(head_size=576, sliding_window=128, model_version=None):
2219
return SlidingWindowMLASpec(
2220
block_size=16,
2221
num_kv_heads=1,
2222
head_size=head_size,
2223
dtype=torch.float32,
2224
sliding_window=sliding_window,
2225
+ model_version=model_version,
2226
)
2227
2228
@@ -3277,3 +3279,143 @@ def test_iter_layer_specs_returns_group_members():
3279
block_size=4, kv_cache_specs={"a": full, "b": mla}
3280
)
3281
assert list(iter_layer_specs(wrapped)) == [full, mla]
3282
+
3283
+
3284
+def _spec_decode_grouping_config(method="dspark"):
3285
+ """Grouping config with an EAGLE-family speculative method enabled."""
3286
+ return SimpleNamespace(
3287
+ scheduler_config=SimpleNamespace(disable_hybrid_kv_cache_manager=False),
3288
+ speculative_config=SimpleNamespace(
3289
+ method=method,
3290
+ use_eagle=lambda: True,
3291
+ ),
3292
+ )
3293
+
3294
+
3295
+def _hybrid_specs_with_draft(draft: bool, draft_shares_target_spec: bool = False):
3296
+ """A K3-shaped hybrid: MLA full attention + Mamba, optionally plus a
3297
+ DSpark-style draft MLA layer marked non_causal_multi_token_decode.
3298
+
3299
+ The target's fp8 KV dtype is what keeps the draft in its own bucket, as it
3300
+ does on Kimi-K3 (target `--kv-cache-dtype fp8_e4m3`, draft `auto`). Pass
3301
+ draft_shares_target_spec to collapse them into one group instead.
3302
+ """
3303
+ target_dtype = None if draft_shares_target_spec else "fp8_e4m3"
3304
+ specs = {
3305
+ "target.attn.0": new_mla_spec(block_size=64, cache_dtype_str=target_dtype),
3306
+ "target.attn.1": new_mla_spec(block_size=64, cache_dtype_str=target_dtype),
3307
+ "target.mamba.0": new_mamba_spec(block_size=64, mamba_cache_mode="align"),
3308
+ "target.mamba.1": new_mamba_spec(block_size=64, mamba_cache_mode="align"),
3309
+ }
3310
+ if draft:
3311
+ draft_spec = new_mla_spec(block_size=64)
3312
+ specs["draft.attn.0"] = replace(draft_spec, non_causal_multi_token_decode=True)
3313
+ return specs
3314
+
3315
+
3316
+def test_draft_group_annotated_on_hybrid_general_path():
3317
+ # A drafter's MLA layer carries non_causal_multi_token_decode, so its group
3318
+ # is identifiable without keying off a model version.
3319
+ groups = get_kv_cache_groups(
3320
+ _spec_decode_grouping_config(), _hybrid_specs_with_draft(draft=True)
3321
+ )
3322
+
3323
+ flagged = [g for g in groups if g.is_eagle_group]
3324
+ assert len(flagged) == 1
3325
+ assert flagged[0].layer_names == ["draft.attn.0"]
3326
+
3327
+
3328
+def test_mamba_groups_never_flagged_even_when_draft_shares_a_group():
3329
+ # MLAAttentionSpec.merge ORs non_causal_multi_token_decode rather than
3330
+ # requiring equality, so a draft layer can share a group with target
3331
+ # layers; that combined group still holds volatile draft KV and must be
3332
+ # flagged. What must never happen is a Mamba group being flagged: that
3333
+ # widens its lookup window to two consecutive chunks, which align-mode
3334
+ # checkpointing never produces, zeroing every lookup.
3335
+ groups = get_kv_cache_groups(
3336
+ _spec_decode_grouping_config(),
3337
+ _hybrid_specs_with_draft(draft=True, draft_shares_target_spec=True),
3338
+ )
3339
+
3340
+ for group in groups:
3341
+ if "draft.attn.0" in group.layer_names:
3342
+ assert group.is_eagle_group
3343
+ if isinstance(group.kv_cache_spec, MambaSpec):
3344
+ assert not group.is_eagle_group
3345
+
3346
+
3347
+def test_draft_group_not_annotated_without_spec_decode():
3348
+ # The marker alone must not flag anything; the eagle semantics only apply
3349
+ # when a speculative method is actually enabled.
3350
+ config = SimpleNamespace(
3351
+ scheduler_config=SimpleNamespace(disable_hybrid_kv_cache_manager=False),
3352
+ speculative_config=None,
3353
+ )
3354
+ groups = get_kv_cache_groups(config, _hybrid_specs_with_draft(draft=True))
3355
+
3356
+ assert not any(g.is_eagle_group for g in groups)
3357
+
3358
+
3359
+def test_unidentifiable_draft_with_mamba_warns(caplog_vllm):
3360
+ # No group carries the draft marker, so every consumer falls back to
3361
+ # flagging all groups -- including Mamba ones, which then can never report
3362
+ # a hit. That is silent today; it must at least be visible.
3363
+ groups = get_kv_cache_groups(
3364
+ _spec_decode_grouping_config(), _hybrid_specs_with_draft(draft=False)
3365
+ )
3366
+
3367
+ assert not any(g.is_eagle_group for g in groups)
3368
+ assert "no KV cache group could be identified as the draft model's" in (
3369
+ caplog_vllm.text
3370
+ )
3371
+ assert "Mamba groups" in caplog_vllm.text
3372
+
3373
+
3374
+def test_no_warning_when_draft_group_is_identified(caplog_vllm):
3375
+ get_kv_cache_groups(
3376
+ _spec_decode_grouping_config(), _hybrid_specs_with_draft(draft=True)
3377
+ )
3378
+
3379
+ assert "could be identified as the draft model's" not in caplog_vllm.text
3380
+
3381
+
3382
+def _deepseek_v4_specs(model_version="deepseek_v4"):
3383
+ """DeepseekV4-shaped specs: full MLA layers plus sliding-window MLA layers
3384
+ at differing page sizes, with the MTP draft layer registered last."""
3385
+ return {
3386
+ "model.layers.0.self_attn.attn": new_mla_spec(),
3387
+ "model.layers.1.self_attn.attn": new_mla_spec(),
3388
+ "model.layers.2.self_attn.attn": new_swa_mla_spec(
3389
+ head_size=1024, model_version=model_version
3390
+ ),
3391
+ # The MTP block registers last, and its sliding-window size differs, so
3392
+ # it lands in a group of its own.
3393
+ "model.layers.3.self_attn.attn": new_swa_mla_spec(
3394
+ head_size=1024, sliding_window=256, model_version=model_version
3395
+ ),
3396
+ }
3397
+
3398
+
3399
+def test_deepseek_v4_draft_group_annotated_on_group_and_unify_path():
3400
+ # DeepseekV4's MTP block reuses the target's decoder layer, so its spec
3401
+ # carries no draft marker and only the positional rule can find it. This
3402
+ # pins the pre-existing behaviour that the unified annotator must preserve.
3403
+ groups = get_kv_cache_groups(
3404
+ _spec_decode_grouping_config(method="mtp"), _deepseek_v4_specs()
3405
+ )
3406
+
3407
+ flagged = [g for g in groups if g.is_eagle_group]
3408
+ assert len(flagged) == 1
3409
+ assert "model.layers.3.self_attn.attn" in flagged[0].layer_names
3410
+
3411
+
3412
+def test_deepseek_v4_annotation_requires_model_version():
3413
+ # The positional rule is only sound for DeepseekV4, where the draft layer
3414
+ # is known to be registered last. Without that model gate nothing may be
3415
+ # flagged, however the grouping happens to fall out.
3416
+ groups = get_kv_cache_groups(
3417
+ _spec_decode_grouping_config(method="mtp"),
3418
+ _deepseek_v4_specs(model_version=None),
3419
+ )
3420
+
3421
+ assert not any(g.is_eagle_group for g in groups)
vllm/v1/core/kv_cache_utils.py
+84
-5
@@ -1742,23 +1742,56 @@ def _get_kv_cache_groups_uniform_groups(
1742
return [full_mla_group, *swa_mla_groups]
1743
1744
1745
-def _annotate_eagle_groups_deepseek_v4(
1745
+def _annotate_eagle_groups(
1746
vllm_config: VllmConfig,
1747
kv_cache_spec: dict[str, KVCacheSpec],
1748
kv_cache_groups: list[KVCacheGroupSpec],
1749
+ use_deepseek_v4_fallback: bool = False,
1750
) -> None:
1751
+ """Flag the KV cache groups that hold drafter attention layers.
1752
+
1753
+ Two detection rules, in order of preference:
1754
+
1755
+ 1. Spec-driven. ``non_causal_multi_token_decode`` is declared on
1756
+ MLAAttentionSpec and set by drafter attention layers that run a
1757
+ non-causal multi-token decode (today only Kimi-K3 DSpark). It survives
1758
+ MLAAttentionSpec.merge, so it still identifies a group after per-group
1759
+ spec merging, wherever grouping happens to land. It is sufficient but
1760
+ not necessary: a drafter whose spec is indistinguishable from the
1761
+ target's cannot be found this way.
1762
+ 2. Model-scoped positional fallback for DeepseekV4, whose MTP block reuses
1763
+ the target's own decoder layer and so carries no spec marker. Its draft
1764
+ attention layer is always the last registered layer, so flag whichever
1765
+ group holds it. This rule is only valid where the groups partition
1766
+ exactly the layers of ``kv_cache_spec``, which is true on the
1767
+ group_and_unify path and not in general; other callers must leave
1768
+ ``use_deepseek_v4_fallback`` False.
1769
+ FIXME(yifan): avoid/generalize this hacky check.
1770
+
1771
+ Args:
1772
+ vllm_config: Config supplying the speculative method, if any.
1773
+ kv_cache_spec: The kv cache spec of each attention layer, in layer
1774
+ registration order. Only read by rule 2.
1775
+ kv_cache_groups: Groups to annotate in place.
1776
+ use_deepseek_v4_fallback: Enable rule 2. Only the group_and_unify path
1777
+ may set this.
1778
+ """
1779
spec_config = vllm_config.speculative_config
1780
if spec_config is None or not spec_config.use_eagle():
1781
return
1782
+
1783
+ for group in kv_cache_groups:
1784
+ if getattr(group.kv_cache_spec, "non_causal_multi_token_decode", False):
1785
+ group.is_eagle_group = True
1786
+
1787
+ if not use_deepseek_v4_fallback:
1788
+ return
1789
# Detection uses the merged MLA spec's model_version.
1790
if not any(
1791
getattr(spec, "model_version", None) == "deepseek_v4"
1792
for spec in kv_cache_spec.values()
1793
):
1794
return
1759
- # DeepseekV4's MTP attention layer is always the last layer, and we flag whichever
1760
- # group contains it.
1761
- # FIXME(yifan): avoid/generalize this hacky check.
1795
last_layer = next(reversed(kv_cache_spec))
1796
for group in kv_cache_groups:
1797
if last_layer in group.layer_names:
@@ -1766,6 +1799,45 @@ def _annotate_eagle_groups_deepseek_v4(
1799
break
1800
1801
1802
+def _warn_if_unannotated_eagle_mamba(
1803
+ vllm_config: VllmConfig,
1804
+ kv_cache_groups: list[KVCacheGroupSpec],
1805
+) -> None:
1806
+ """Warn when the flag-all eagle fallback will silently disable reuse.
1807
+
1808
+ With no group annotated, consumers flag every group as a draft group. That
1809
+ widens a Mamba group's required lookup window to two consecutive chunks,
1810
+ which align-mode checkpointing never produces, so reuse drops to zero with
1811
+ no error and no metric to show it.
1812
+
1813
+ Args:
1814
+ vllm_config: Config supplying the speculative method, if any.
1815
+ kv_cache_groups: Groups as they will be handed to consumers.
1816
+ """
1817
+ spec_config = vllm_config.speculative_config
1818
+ if spec_config is None or not spec_config.use_eagle():
1819
+ return
1820
+ if any(group.is_eagle_group for group in kv_cache_groups):
1821
+ return
1822
+ mamba_groups = [
1823
+ idx
1824
+ for idx, group in enumerate(kv_cache_groups)
1825
+ if isinstance(group.kv_cache_spec, MambaSpec)
1826
+ ]
1827
+ if not mamba_groups:
1828
+ return
1829
+ logger.warning(
1830
+ "Speculative decoding (method=%s) is enabled but no KV cache group "
1831
+ "could be identified as the draft model's, so every group -- "
1832
+ "including Mamba groups %s -- will be treated as a draft group. A "
1833
+ "Mamba group cannot satisfy the widened lookup window that implies, "
1834
+ "so prefix-cache reuse across requests will be disabled and any "
1835
+ "external KV offload tier will store without ever serving a hit.",
1836
+ spec_config.method,
1837
+ mamba_groups,
1838
+ )
1839
+
1840
+
1841
def _largest_divisor_at_most(value: int, limit: int) -> int:
1842
for candidate in range(min(value, limit), 0, -1):
1843
if value % candidate == 0:
@@ -1810,7 +1882,12 @@ def get_kv_cache_groups(
1882
# attention in different sizes. Need to group layers into multiple
1883
# UniformTypeKVCacheSpecs.
1884
kv_cache_groups = _get_kv_cache_groups_uniform_groups(grouped_specs)
1813
- _annotate_eagle_groups_deepseek_v4(vllm_config, kv_cache_spec, kv_cache_groups)
1885
+ _annotate_eagle_groups(
1886
+ vllm_config,
1887
+ kv_cache_spec,
1888
+ kv_cache_groups,
1889
+ use_deepseek_v4_fallback=True,
1890
+ )
1891
return kv_cache_groups
1892
1893
# Pull HiddenStateCacheSpec layers out before the general multi-group
@@ -1855,6 +1932,8 @@ def get_kv_cache_groups(
1932
aligned = replace(spec, block_size=new_bs, page_size_padded=common_page)
1933
groups.append(KVCacheGroupSpec([name], aligned))
1934
1935
+ _annotate_eagle_groups(vllm_config, kv_cache_spec, groups)
1936
+ _warn_if_unannotated_eagle_mamba(vllm_config, groups)
1937
return groups
1938
1939