[Spec Decode] Enable Gemma4 DSpark adaptive verification with FlashInfer (#57263)
Signed-off-by: zixi-qi <zixi@inferact.ai> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
qizixi committed
Sep 26, 2026 at 02:01 UTC
a4eb3f25d6f9b3cad7ecf5390423d853935fcaeb
24 files changed
+894
-135
docs/design/cuda_graphs.md
+2
@@ -172,6 +172,8 @@ class AttentionCGSupport(enum.Enum):
172
173
Suppose we have hybrid attention backends (e.g., in mamba mixer models). In that case, we seek the minimum capability of all backends to determine the final capability of the model, and we might resolve the incompatible CUDA Graphs mode by downgrading the mode to the best fit one. For example, downgrading `FULL` mode to `FULL_AND_PIECEWISE` mode if the minimum capability is `UNIFORM_BATCH`, or `PIECEWISE` mode if the minimum capability is `NEVER` for -O3 compilation mode. For the complete fallback policy, please see the code for [this][vllm.v1.worker.gpu_model_runner.GPUModelRunner._check_and_update_cudagraph_mode].
174
175
+Variable-length decode batches, where each request carries a different number of query tokens read from the device `query_start_loc` (as in adaptive verification), are declared separately and not ordered against the enum: `AttentionMetadataBuilder.get_varlen_cudagraph_max_query_len()` returns the largest per-request query length a FULL decode graph can replay. It returns `None` for builders reporting `ALWAYS`, which replay any batch, and for builders that cannot replay variable-length batches; FlashInfer returns `1 + num_speculative_tokens` when its TRTLLM-GEN varlen decode path is active. Batches with a prefill never replay these graphs.
176
+
177
The following table lists backends that support full CUDA Graphs at the time of writing.
178
179
| Attention Backend | cudagraph_support | Comments |
tests/distributed/test_dcp_direct_a2a_lse_reduce.py
+19
-3
@@ -455,13 +455,29 @@ def test_mla_dcp_manager_selects_fallback_backends(monkeypatch):
455
)
456
457
458
-def test_dcp_workspace_covers_parallel_drafting():
458
+@pytest.mark.parametrize(
459
+ ("parallel_drafting", "use_v2_model_runner", "expected"),
460
+ [
461
+ # Model runner V1's parallel drafter widens requests to 1 + 2 * 3.
462
+ (True, False, 28),
463
+ (True, True, 16),
464
+ # A diffusion canvas of 3 without a speculative config.
465
+ (None, True, 16),
466
+ ],
467
+)
468
+def test_dcp_workspace_covers_decode_width(
469
+ parallel_drafting, use_v2_model_runner, expected
470
+):
471
config = _manager_config()
472
config.scheduler_config.max_num_batched_tokens = 128
473
config.num_speculative_tokens = 3
462
- config.speculative_config = MagicMock(parallel_drafting=True)
474
+ config.use_v2_model_runner = use_v2_model_runner
475
+ if parallel_drafting is not None:
476
+ config.speculative_config = MagicMock(
477
+ num_speculative_tokens=3, parallel_drafting=parallel_drafting
478
+ )
479
464
- assert dcp.get_dcp_workspace_max_num_tokens(config) == 28
480
+ assert dcp.get_dcp_workspace_max_num_tokens(config) == expected
481
482
483
def test_mla_dcp_manager_selects_pcp_combine(monkeypatch):
tests/models/language/generation/test_gemma.py
+67
-2
@@ -7,7 +7,8 @@ import numpy as np
7
import pytest
8
import torch
9
10
-from vllm.config import VllmConfig
10
+from vllm.config import CompilationConfig, VllmConfig
11
+from vllm.config.compilation import CompilationMode
12
from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
13
from vllm.model_executor.models import gemma
14
from vllm.model_executor.models.gemma3n import (
@@ -18,10 +19,72 @@ from vllm.model_executor.models.gemma4 import (
19
Gemma4ForCausalLM,
20
_gemma4_layer_weights_mapper,
21
)
22
+from vllm.model_executor.models.gemma4_dspark import (
23
+ Gemma4DSparkForCausalLM,
24
+ Gemma4DSparkModel,
25
+)
26
27
MODELS = ["google/gemma-2b", "google/gemma-2-2b", "google/gemma-3-4b-it"]
28
29
30
+@pytest.mark.usefixtures("dist_init")
31
+@pytest.mark.parametrize(
32
+ "enabled,has_weights,with_markov",
33
+ [
34
+ pytest.param(True, True, False, id="hidden-only"),
35
+ pytest.param(True, True, True, id="with-markov"),
36
+ pytest.param(True, False, True, id="missing-weights"),
37
+ pytest.param(False, True, True, id="disabled-head"),
38
+ ],
39
+)
40
+def test_gemma4_dspark_loads_confidence_head(
41
+ monkeypatch, enabled, has_weights, with_markov
42
+) -> None:
43
+ """Use checkpoint confidence parameters, or disable an unavailable head."""
44
+ config = SimpleNamespace(
45
+ vocab_size=64,
46
+ hidden_size=8,
47
+ target_layer_ids=[0, 1],
48
+ num_hidden_layers=0,
49
+ rms_norm_eps=1e-6,
50
+ markov_rank=4,
51
+ enable_confidence_head=enabled,
52
+ confidence_head_with_markov=with_markov,
53
+ )
54
+ vllm_config = SimpleNamespace(
55
+ compilation_config=CompilationConfig(mode=CompilationMode.NONE),
56
+ model_config=SimpleNamespace(dtype=torch.bfloat16),
57
+ speculative_config=SimpleNamespace(
58
+ draft_model_config=SimpleNamespace(hf_config=config),
59
+ ),
60
+ )
61
+ monkeypatch.setattr(Gemma4DSparkModel, "_build_fused_kv_buffers", lambda _: None)
62
+ model = Gemma4DSparkForCausalLM(vllm_config=cast(VllmConfig, vllm_config))
63
+ width = config.hidden_size + (config.markov_rank if with_markov else 0)
64
+ weight = torch.arange(width, dtype=torch.bfloat16).reshape(1, -1) / 16
65
+ bias = torch.tensor([-0.5], dtype=torch.bfloat16)
66
+ loaded = model.load_weights(
67
+ [("confidence_head.proj.weight", weight), ("confidence_head.proj.bias", bias)]
68
+ if has_weights
69
+ else []
70
+ )
71
+ if not (enabled and has_weights):
72
+ assert model.model.confidence_head is None
73
+ assert not loaded
74
+ return
75
+
76
+ assert loaded == {
77
+ "model.confidence_head.proj.weight",
78
+ "model.confidence_head.proj.bias",
79
+ }
80
+ assert model.model.confidence_head.proj.weight.dtype == torch.float32
81
+ hidden = torch.full((3, config.hidden_size), 0.25, dtype=torch.bfloat16)
82
+ markov = torch.full((3, config.markov_rank), -0.5, dtype=torch.bfloat16)
83
+ inputs = torch.cat([hidden, markov], dim=-1) if with_markov else hidden
84
+ expected = (inputs.float() @ weight.float().T + bias.float()).sigmoid().squeeze(-1)
85
+ torch.testing.assert_close(model.compute_confidence(hidden, markov), expected)
86
+
87
+
88
@pytest.mark.cpu_test
89
@pytest.mark.usefixtures("dist_init")
90
def test_checkpoint_lm_head_can_override_tied_config(monkeypatch) -> None:
@@ -164,7 +227,9 @@ def test_dummy_loader(vllm_runner, monkeypatch, model: str) -> None:
227
) as llm:
228
if model == "google/gemma-3-4b-it":
229
normalizers = llm.llm.collective_rpc(
167
- lambda self: self.model_runner.model.language_model.model.normalizer.cpu().item() # noqa: E501
230
+ lambda self: (
231
+ self.model_runner.model.language_model.model.normalizer.cpu().item()
232
+ ) # noqa: E501
233
)
234
config = llm.llm.llm_engine.model_config.hf_config.text_config
235
else:
tests/test_config.py
+12
@@ -92,6 +92,16 @@ def test_nested_rope_validation_patch_preserves_flat_rope_parameters(monkeypatch
92
assert len(calls) == 2
93
94
95
+def test_dspark_adaptive_verification_separates_graph_cache():
96
+ config = object.__new__(SpeculativeConfig)
97
+ config.method = "dspark"
98
+ config.draft_model_config = None
99
+ config.enable_adaptive_verification = False
100
+ fixed_hash = config.compute_hash()
101
+ config.enable_adaptive_verification = True
102
+ assert config.compute_hash() != fixed_hash
103
+
104
+
105
def _write_json(path: Path, value: object) -> None:
106
path.parent.mkdir(parents=True, exist_ok=True)
107
path.write_text(json.dumps(value), encoding="utf-8")
@@ -878,6 +888,8 @@ def test_resolve_cudagraph_mode_adjusts_spec_decode_sizes_only_for_v1(
888
("FULL_AND_PIECEWISE", False, "ALWAYS", "FULL_DECODE_ONLY"),
889
("FULL_DECODE_ONLY", False, "ALWAYS", "FULL_DECODE_ONLY"),
890
("FULL_DECODE_ONLY", False, "NEVER", "NONE"),
891
+ ("FULL_AND_PIECEWISE", True, "UNIFORM_BATCH", "FULL_AND_PIECEWISE"),
892
+ ("FULL", True, "UNIFORM_BATCH", "FULL_DECODE_ONLY"),
893
],
894
)
895
def test_resolve_cudagraph_mode_uses_loaded_piecewise_provider(
tests/transformers_utils/test_config.py
+45
@@ -5,6 +5,7 @@ only get the `eos_token_id` from the tokenizer as defined by
5
`BaseRenderer.get_eos_token_id`.
6
"""
7
8
+import json
9
import math
10
from types import SimpleNamespace
11
from typing import cast
@@ -31,6 +32,50 @@ from vllm.transformers_utils.configs.glm5_next import (
32
from vllm.transformers_utils.configs.mistral import adapt_config_dict
33
34
35
+@pytest.mark.parametrize("layout", ["mixed", "flat"])
36
+def test_gemma4_dspark_rope_config_preserves_parameters(tmp_path, layout):
37
+ """Remove redundant shared entries while preserving per-layer and flat RoPE."""
38
+ from transformers import Gemma4TextConfig
39
+
40
+ per_layer = {
41
+ "full_attention": {
42
+ "rope_type": "proportional",
43
+ "partial_rotary_factor": 0.25,
44
+ "rope_theta": 1000000.0,
45
+ },
46
+ "sliding_attention": {"rope_type": "default", "rope_theta": 10000.0},
47
+ }
48
+ rope_parameters: dict[str, object] = dict(per_layer)
49
+ if layout == "mixed":
50
+ rope_parameters.update(rope_type="default", rope_theta=None)
51
+ else:
52
+ rope_parameters = {"rope_type": "default", "rope_theta": 12345.0}
53
+ (tmp_path / "config.json").write_text(
54
+ json.dumps(
55
+ {
56
+ "model_type": "gemma4_text",
57
+ "architectures": ["Gemma4DSparkModel"],
58
+ "num_hidden_layers": 1,
59
+ "layer_types": ["full_attention"],
60
+ "rope_parameters": rope_parameters,
61
+ }
62
+ )
63
+ )
64
+ _, config = config_module.HFConfigParser().parse(
65
+ tmp_path, trust_remote_code=False, max_position_embeddings=8192
66
+ )
67
+ assert isinstance(config, Gemma4TextConfig)
68
+ assert config.name_or_path == str(tmp_path)
69
+ assert config.max_position_embeddings == 8192
70
+ if layout == "flat":
71
+ assert config.rope_parameters["rope_theta"] == 12345.0
72
+ else:
73
+ for layer_type, expected in per_layer.items():
74
+ for key, value in expected.items():
75
+ assert config.rope_parameters[layer_type][key] == value
76
+ assert set(config.rope_parameters) == set(per_layer)
77
+
78
+
79
def test_patch_legacy_rope_type_preserves_nope_layers():
80
"""NoPE layers stay disabled while later RoPE layers are normalized."""
81
rope_parameters = {
tests/v1/attention/test_attention_backends.py
+376
@@ -2,12 +2,14 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Tests for v1 attention backends without GPUModelRunner dependency."""
4
5
+from dataclasses import replace
6
from functools import partial
7
from types import SimpleNamespace
8
9
import pytest
10
import torch
11
from torch.nn.attention.flex_attention import create_block_mask, flex_attention
12
+from transformers import LlamaConfig
13
14
from tests.v1.attention.utils import (
15
BatchSpec,
@@ -28,8 +30,10 @@ from vllm.utils.torch_utils import (
30
)
31
from vllm.v1.attention.backend import (
32
AttentionCGSupport,
33
+ AttentionMetadataBuilder,
34
AttentionType,
35
CommonAttentionMetadata,
36
+ max_decode_query_len,
37
)
38
from vllm.v1.attention.backends.registry import AttentionBackendEnum
39
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheLayout
@@ -1066,6 +1070,378 @@ def test_flashinfer_trtllm_gen_padded_decode_uses_varlen_offsets(
1070
assert q_cu_seq_lens.tolist() == qo_indptr_values
1071
1072
1073
+@pytest.mark.skipif(
1074
+ AttentionBackendEnum.FLASHINFER not in BACKENDS_TO_TEST,
1075
+ reason="FlashInfer is not available.",
1076
+)
1077
+@pytest.mark.parametrize(
1078
+ "adaptive,decode_kernel,dcp_size,expected_bound",
1079
+ [
1080
+ (None, "TRTLLM_GEN", 1, None),
1081
+ (False, "TRTLLM_GEN", 1, None),
1082
+ (True, "TRTLLM_GEN", 1, 8),
1083
+ (True, "XQA", 1, None),
1084
+ (True, "TRTLLM_GEN", 2, None),
1085
+ ],
1086
+)
1087
+def test_flashinfer_varlen_cudagraph_capability(
1088
+ monkeypatch, adaptive, decode_kernel, dcp_size, expected_bound
1089
+):
1090
+ """Only the trtllm-gen path that adaptive verification enables replays
1091
+ varlen decode graphs, up to the decode width; the uniform level is fixed."""
1092
+ from vllm.model_executor.layers.attention.chunked_local_attention import (
1093
+ create_chunked_local_attention_backend,
1094
+ )
1095
+ from vllm.v1.attention.backends import flashinfer as fi
1096
+
1097
+ config = SimpleNamespace(
1098
+ attention_config=SimpleNamespace(use_non_causal=False),
1099
+ speculative_config=SimpleNamespace(
1100
+ enable_adaptive_verification=adaptive,
1101
+ num_speculative_tokens=7,
1102
+ parallel_drafting=True,
1103
+ )
1104
+ if adaptive is not None
1105
+ else None,
1106
+ parallel_config=SimpleNamespace(decode_context_parallel_size=dcp_size),
1107
+ model_config=SimpleNamespace(get_num_attention_heads=lambda _: 32),
1108
+ use_v2_model_runner=True,
1109
+ )
1110
+ monkeypatch.setattr(fi, "can_use_trtllm_attention", lambda *_, **__: True)
1111
+ monkeypatch.setattr(
1112
+ fi.FlashInferMetadataBuilder,
1113
+ "_get_flashinfer_trtllm_api_decode_kernel",
1114
+ staticmethod(lambda: fi.FlashInferDecodeKernel[decode_kernel]),
1115
+ )
1116
+ spec = FullAttentionSpec(
1117
+ block_size=16,
1118
+ num_kv_heads=4,
1119
+ head_size=64,
1120
+ dtype=torch.bfloat16,
1121
+ )
1122
+ builder_cls = fi.FlashInferMetadataBuilder
1123
+ assert builder_cls.get_cudagraph_support(config, spec) == (
1124
+ AttentionCGSupport.UNIFORM_BATCH
1125
+ if dcp_size == 1
1126
+ else AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE
1127
+ )
1128
+ bound = builder_cls.get_varlen_cudagraph_max_query_len(config, spec)
1129
+ assert bound == expected_bound
1130
+ if bound is not None:
1131
+ # A wrapper forcing NEVER inherits the override but not the bound.
1132
+ chunked_local = create_chunked_local_attention_backend(fi.FlashInferBackend, 16)
1133
+ wrapped_cls = chunked_local.get_builder_cls()
1134
+ assert wrapped_cls.get_varlen_cudagraph_max_query_len(config, spec) is None
1135
+
1136
+
1137
+@pytest.mark.parametrize(
1138
+ "use_v2_model_runner,parallel_drafting,expected",
1139
+ [
1140
+ (True, None, 1),
1141
+ (True, False, 8),
1142
+ (True, True, 8),
1143
+ (False, False, 8),
1144
+ (False, True, 15),
1145
+ ],
1146
+)
1147
+def test_spec_as_decode_width_follows_model_runner(
1148
+ use_v2_model_runner, parallel_drafting, expected
1149
+):
1150
+ """Only model runner V1's parallel drafter widens decode requests."""
1151
+
1152
+ class Builder(AttentionMetadataBuilder):
1153
+ def __init__(self, vllm_config):
1154
+ self.vllm_config = vllm_config
1155
+
1156
+ def build(self, common_prefix_len, common_attn_metadata, fast_build=False):
1157
+ raise NotImplementedError
1158
+
1159
+ config = SimpleNamespace(
1160
+ speculative_config=None
1161
+ if parallel_drafting is None
1162
+ else SimpleNamespace(
1163
+ num_speculative_tokens=7, parallel_drafting=parallel_drafting
1164
+ ),
1165
+ parallel_config=SimpleNamespace(decode_context_parallel_size=1),
1166
+ use_v2_model_runner=use_v2_model_runner,
1167
+ )
1168
+ assert max_decode_query_len(config) == expected
1169
+ builder = Builder(config)
1170
+ builder._init_reorder_batch_threshold(1, supports_spec_as_decode=True)
1171
+ assert builder.reorder_batch_threshold == expected
1172
+
1173
+
1174
+@pytest.mark.skipif(
1175
+ AttentionBackendEnum.FLASHINFER not in BACKENDS_TO_TEST
1176
+ or not current_platform.is_device_capability_family(100),
1177
+ reason="TRTLLM-GEN varlen decode graphs require FlashInfer on SM100-family GPUs",
1178
+)
1179
+@pytest.mark.parametrize(
1180
+ "head_size,sliding_window,num_tokens,num_reqs,num_heads,num_kv_heads",
1181
+ [
1182
+ pytest.param(64, None, 8, 8, 32, 4, id="single-token-capture"),
1183
+ pytest.param(
1184
+ 256,
1185
+ 1024,
1186
+ 40,
1187
+ 32,
1188
+ 16,
1189
+ 8,
1190
+ id="gemma4-sliding-gqa",
1191
+ ),
1192
+ pytest.param(
1193
+ 512,
1194
+ None,
1195
+ 40,
1196
+ 32,
1197
+ 16,
1198
+ 1,
1199
+ id="gemma4-global-mqa",
1200
+ ),
1201
+ ],
1202
+)
1203
+def test_flashinfer_varlen_decode_graph_replays_changed_layout(
1204
+ tmp_path,
1205
+ monkeypatch,
1206
+ head_size,
1207
+ sliding_window,
1208
+ num_tokens,
1209
+ num_reqs,
1210
+ num_heads,
1211
+ num_kv_heads,
1212
+):
1213
+ """Replay changes device offsets, active slots, context, and KV writes.
1214
+
1215
+ The graph is captured with evenly spread dummy queries and an explicit
1216
+ bound of eight. CPU offsets then remain stale while device offsets change.
1217
+ Compare real output rows with independent FP32 causal/windowed attention,
1218
+ and verify cache writes including preservation of the null padding block.
1219
+ """
1220
+ from vllm.v1.attention.backends import flashinfer as fi
1221
+ from vllm.v1.attention.backends.utils import PerLayerParameters
1222
+
1223
+ set_random_seed(0)
1224
+ monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
1225
+ block_size = 16
1226
+ dtype = torch.bfloat16
1227
+ LlamaConfig(
1228
+ architectures=["LlamaForCausalLM"],
1229
+ hidden_size=num_heads * head_size,
1230
+ head_dim=head_size,
1231
+ num_attention_heads=num_heads,
1232
+ num_key_value_heads=num_kv_heads,
1233
+ num_hidden_layers=1,
1234
+ max_position_embeddings=4096,
1235
+ ).save_pretrained(tmp_path)
1236
+ num_blocks = 1 + num_reqs * cdiv(2112, block_size)
1237
+ config = create_vllm_config(
1238
+ model_name=str(tmp_path),
1239
+ max_model_len=4096,
1240
+ dtype=dtype,
1241
+ num_gpu_blocks=num_blocks,
1242
+ block_size=block_size,
1243
+ max_num_seqs=32,
1244
+ max_num_batched_tokens=256,
1245
+ )
1246
+ config.cache_config.kv_cache_layout = "BLHNC"
1247
+ config.speculative_config = SimpleNamespace(
1248
+ num_speculative_tokens=7,
1249
+ enable_adaptive_verification=True,
1250
+ parallel_drafting=False,
1251
+ )
1252
+ spec = create_standard_kv_cache_spec(config)
1253
+ scale = head_size**-0.5
1254
+ monkeypatch.setattr(
1255
+ fi,
1256
+ "get_per_layer_parameters",
1257
+ lambda *_: {
1258
+ "layer": PerLayerParameters(
1259
+ window_left=-1 if sliding_window is None else sliding_window - 1,
1260
+ logits_soft_cap=0.0,
1261
+ sm_scale=scale,
1262
+ has_sinks=False,
1263
+ ),
1264
+ },
1265
+ )
1266
+ device = torch.device("cuda")
1267
+ layout = KVCacheLayout.BLHNC
1268
+ query = torch.empty(num_tokens, num_heads, head_size, dtype=dtype, device=device)
1269
+ key = torch.empty(num_tokens, num_kv_heads, head_size, dtype=dtype, device=device)
1270
+ value = torch.empty_like(key)
1271
+ output = torch.empty_like(query)
1272
+ dummy_lens = [
1273
+ num_tokens // num_reqs + (i < num_tokens % num_reqs) for i in range(num_reqs)
1274
+ ]
1275
+ common = create_common_attn_metadata(
1276
+ BatchSpec(seq_lens=[2112] * num_reqs, query_lens=dummy_lens),
1277
+ block_size,
1278
+ device,
1279
+ )
1280
+ common.max_query_len = 8
1281
+ cache = None
1282
+
1283
+ def prepare(query_lens):
1284
+ nonlocal cache
1285
+ query_lens = query_lens + [0] * (num_reqs - len(query_lens))
1286
+ query.normal_()
1287
+ key.normal_()
1288
+ value.normal_()
1289
+ # Alternating short prefills, nonempty decode contexts, and contexts
1290
+ # beyond the sliding window; inactive requests have zero sequence length.
1291
+ contexts = [
1292
+ ([0, 17, 2079, 39][i % 4] if q else 0) for i, q in enumerate(query_lens)
1293
+ ]
1294
+ ks = [
1295
+ torch.randn(c, num_kv_heads, head_size, dtype=dtype, device=device)
1296
+ for c in contexts
1297
+ ]
1298
+ vs = [torch.randn_like(k) for k in ks]
1299
+ actual = create_common_attn_metadata(
1300
+ BatchSpec(
1301
+ seq_lens=[c + q for c, q in zip(contexts, query_lens)],
1302
+ query_lens=query_lens,
1303
+ ),
1304
+ block_size,
1305
+ device,
1306
+ )
1307
+ fresh_cache = create_and_prepopulate_kv_cache(
1308
+ ks,
1309
+ vs,
1310
+ block_size,
1311
+ num_kv_heads,
1312
+ head_size,
1313
+ dtype,
1314
+ device,
1315
+ num_blocks,
1316
+ actual,
1317
+ layout,
1318
+ randomize_blocks=True,
1319
+ )
1320
+ if cache is None:
1321
+ cache = fresh_cache
1322
+ else:
1323
+ cache.copy_(fresh_cache)
1324
+ common.query_start_loc.copy_(actual.query_start_loc)
1325
+ common.seq_lens.copy_(actual.seq_lens)
1326
+ common.block_table_tensor.zero_()
1327
+ common.block_table_tensor[:, : actual.block_table_tensor.shape[1]].copy_(
1328
+ actual.block_table_tensor
1329
+ )
1330
+ common.slot_mapping.fill_(-1)
1331
+ common.slot_mapping[: sum(query_lens)].copy_(actual.slot_mapping)
1332
+ return contexts, ks, vs
1333
+
1334
+ prepare(dummy_lens)
1335
+ with set_current_vllm_config(config):
1336
+ builder = fi.FlashInferMetadataBuilder(spec, ["layer"], config, device)
1337
+ metadata = builder.build(0, common)
1338
+ assert metadata.num_prefills == 0
1339
+ assert metadata.decode.q_len_per_req == 8
1340
+ impl = fi.FlashInferImpl(
1341
+ num_heads=num_heads,
1342
+ num_kv_heads=num_kv_heads,
1343
+ head_size=head_size,
1344
+ scale=scale,
1345
+ alibi_slopes=None,
1346
+ sliding_window=sliding_window,
1347
+ kv_cache_dtype="auto",
1348
+ attn_type=AttentionType.DECODER,
1349
+ )
1350
+ layer = MockAttentionLayer(device)
1351
+
1352
+ def forward(attn_metadata=metadata):
1353
+ impl.do_kv_cache_update(layer, key, value, cache, common.slot_mapping)
1354
+ impl.forward(layer, query, key, value, cache, attn_metadata, output=output)
1355
+
1356
+ for _ in range(3):
1357
+ forward()
1358
+ torch.accelerator.synchronize()
1359
+ graph = torch.cuda.CUDAGraph()
1360
+ with torch.cuda.graph(graph):
1361
+ forward()
1362
+
1363
+ layouts = [[8], [1] * num_reqs]
1364
+ if num_tokens >= 16:
1365
+ layouts.append([8, 4, 3, 1])
1366
+ if num_tokens >= 40:
1367
+ layouts.append([8] * 5)
1368
+
1369
+ def check(query_lens, run):
1370
+ contexts, ks, vs = prepare(query_lens)
1371
+ assert cache is not None
1372
+ output.fill_(float("nan"))
1373
+ run()
1374
+ start = 0
1375
+ for i, q_len in enumerate(query_lens):
1376
+ if q_len == 0:
1377
+ continue
1378
+ end = start + q_len
1379
+ full_k = (
1380
+ torch.cat((ks[i], key[start:end]))
1381
+ .float()
1382
+ .repeat_interleave(num_heads // num_kv_heads, dim=1)
1383
+ )
1384
+ full_v = (
1385
+ torch.cat((vs[i], value[start:end]))
1386
+ .float()
1387
+ .repeat_interleave(num_heads // num_kv_heads, dim=1)
1388
+ )
1389
+ scores = (
1390
+ torch.einsum("qhd,khd->hqk", query[start:end].float(), full_k) * scale
1391
+ )
1392
+ q_pos = torch.arange(q_len, device=device) + contexts[i]
1393
+ k_pos = torch.arange(full_k.shape[0], device=device)
1394
+ allowed = k_pos[None, :] <= q_pos[:, None]
1395
+ if sliding_window is not None:
1396
+ allowed &= k_pos[None, :] > q_pos[:, None] - sliding_window
1397
+ probs = scores.masked_fill(~allowed[None], float("-inf")).softmax(-1)
1398
+ reference = torch.einsum("hqk,khd->qhd", probs, full_v)
1399
+ torch.testing.assert_close(
1400
+ output[start:end].float(),
1401
+ reference,
1402
+ atol=2e-2,
1403
+ rtol=2e-2,
1404
+ )
1405
+ slots = common.slot_mapping[start:end]
1406
+ written = cache[slots // block_size, :, slots % block_size, :]
1407
+ torch.testing.assert_close(written[..., :head_size], key[start:end])
1408
+ torch.testing.assert_close(written[..., head_size:], value[start:end])
1409
+ start = end
1410
+ assert torch.count_nonzero(cache[0]) == 0
1411
+
1412
+ for query_lens in layouts:
1413
+ check(query_lens, graph.replay)
1414
+
1415
+ if num_tokens == 40:
1416
+ # A longer prefill forces a separate launch in eager/piecewise mode.
1417
+ # Its length must not become the decode kernel's query bound, and
1418
+ # compacted CPU placeholders still need not match the GPU allocation.
1419
+ common.num_reqs = 5
1420
+ common.max_query_len = 24
1421
+ common.query_start_loc_cpu[:6] = torch.tensor([0, 4, 8, 12, 16, 40])
1422
+ common.query_start_loc_cpu[6:] = 40
1423
+
1424
+ def mixed_forward():
1425
+ with set_current_vllm_config(config):
1426
+ metadata = builder.build(
1427
+ 0,
1428
+ replace(
1429
+ common,
1430
+ query_start_loc=common.query_start_loc[:6],
1431
+ query_start_loc_cpu=common.query_start_loc_cpu[:6],
1432
+ seq_lens=common.seq_lens[:5],
1433
+ seq_lens_cpu_upper_bound=common.seq_lens_cpu_upper_bound[:5],
1434
+ block_table_tensor=common.block_table_tensor[:5],
1435
+ ),
1436
+ )
1437
+ assert metadata.num_decodes == 4
1438
+ assert metadata.num_prefills == 1
1439
+ assert metadata.decode.q_len_per_req == 8
1440
+ forward(metadata)
1441
+
1442
+ check([8, 4, 3, 1, 24], mixed_forward)
1443
+
1444
+
1445
@pytest.mark.skipif(
1446
AttentionBackendEnum.FLASHINFER not in BACKENDS_TO_TEST,
1447
reason="FlashInfer is not available.",
tests/v1/cudagraph/test_cudagraph_manager.py
+24
@@ -294,6 +294,7 @@ def _make_spec_decode_manager(
294
dynamic_spec_schedule: list[tuple[int, int, int]] | None = None,
295
max_num_seqs: int = 8,
296
use_kda_recoverssm: bool = False,
297
+ varlen_decode: bool = False,
298
) -> gpu_cudagraph_utils.CudaGraphManager:
299
monkeypatch.setattr(
300
gpu_cudagraph_utils,
@@ -316,6 +317,7 @@ def _make_spec_decode_manager(
317
device=torch.device("cpu"),
318
cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE,
319
decode_query_len=decode_query_len,
320
+ varlen_decode=varlen_decode,
321
)
322
manager._graphs_captured = True
323
return manager
@@ -373,6 +375,28 @@ def test_mixed_batch_never_selects_a_uniform_decode_graph(monkeypatch):
375
assert desc.num_tokens == 16
376
377
378
+def test_varlen_decode_graph_takes_bounded_decode_batches_only(monkeypatch):
379
+ """A varlen decode graph replays any decode batch within its query-length
380
+ bound. The runner passes no bound for a batch with a prefill, which must not
381
+ replay one however short the prefill is."""
382
+ manager = _make_spec_decode_manager(monkeypatch, varlen_decode=True)
383
+
384
+ def dispatch(max_query_len):
385
+ return manager.dispatch(
386
+ num_reqs=3,
387
+ num_tokens=6,
388
+ uniform_token_count=None,
389
+ num_active_loras=0,
390
+ max_query_len=max_query_len,
391
+ )
392
+
393
+ desc = dispatch(_DECODE_QUERY_LEN)
394
+ assert desc.cg_mode == CUDAGraphMode.FULL
395
+ assert desc.max_query_len == _DECODE_QUERY_LEN
396
+ assert dispatch(None).cg_mode == CUDAGraphMode.PIECEWISE
397
+ assert dispatch(_DECODE_QUERY_LEN + 1).cg_mode == CUDAGraphMode.PIECEWISE
398
+
399
+
400
def test_mixed_batch_at_decode_only_token_count_still_gets_a_graph(monkeypatch):
401
"""A mixed batch must not fall to eager where only decode graphs are staged.
402
tests/v1/spec_decode/test_adaptive_verification.py
+86
-34
@@ -1,15 +1,15 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from contextlib import nullcontext
5
from types import SimpleNamespace
6
7
import numpy as np
8
import pytest
9
10
from vllm.config.compilation import CUDAGraphMode
10
-from vllm.v1.attention.backend import AttentionCGSupport
11
+from vllm.v1.attention.backend import AttentionCGSupport, AttentionMetadataBuilder
12
from vllm.v1.worker.gpu.async_utils import StepTimingSample
12
-from vllm.v1.worker.gpu.attn_utils import AttentionCGSupportInfo
13
from vllm.v1.worker.gpu.spec_decode import adaptive_verification as adaptive_module
14
from vllm.v1.worker.gpu.spec_decode.adaptive_verification import (
15
AdaptiveVerificationManager,
@@ -58,37 +58,90 @@ def test_resolve_adaptive_cudagraph_mode(mode, piecewise_capture_available, expe
58
)
59
60
61
-def test_manager_scopes_varlen_check_without_weakening_runner_cg_mode(monkeypatch):
62
- class Backend:
63
- @classmethod
64
- def supports_device_cpu_query_lens_mismatch(cls):
65
- return True
61
+@pytest.mark.parametrize(
62
+ "target_support,target_bound,device_offsets,additional,error",
63
+ [
64
+ pytest.param(AttentionCGSupport.ALWAYS, None, True, None, None, id="always"),
65
+ pytest.param(
66
+ AttentionCGSupport.UNIFORM_BATCH, 8, True, None, None, id="bounded"
67
+ ),
68
+ pytest.param(
69
+ AttentionCGSupport.UNIFORM_BATCH,
70
+ None,
71
+ True,
72
+ None,
73
+ "TargetBackend allows none",
74
+ id="unsupported",
75
+ ),
76
+ pytest.param(
77
+ AttentionCGSupport.UNIFORM_BATCH,
78
+ 7,
79
+ True,
80
+ None,
81
+ "up to 8, but TargetBackend allows at most 7",
82
+ id="too-narrow",
83
+ ),
84
+ pytest.param(
85
+ AttentionCGSupport.UNIFORM_BATCH,
86
+ 8,
87
+ False,
88
+ None,
89
+ "trims verification requests",
90
+ id="host-query-lens",
91
+ ),
92
+ pytest.param(
93
+ AttentionCGSupport.ALWAYS,
94
+ None,
95
+ True,
96
+ (AttentionCGSupport.UNIFORM_BATCH, "EncoderBackend"),
97
+ "EncoderBackend allows none",
98
+ id="additional-group",
99
+ ),
100
+ ],
101
+)
102
+def test_manager_checks_target_varlen_cudagraph_bound(
103
+ monkeypatch, target_support, target_bound, device_offsets, additional, error
104
+):
105
+ """Target builders must replay varlen decode graphs of the verification
106
+ width; draft-only groups are not checked."""
107
+
108
+ def group(backend_name, layer_name, support, bound=None, mismatch=True):
109
+ class Builder(AttentionMetadataBuilder):
110
+ _cudagraph_support = support
111
+
112
+ def __init__(self):
113
+ pass
114
67
- class Builder:
68
- def __init__(self, support):
69
- self.support = support
115
+ def build(self, common_prefix_len, common_attn_metadata, fast_build=False):
116
+ raise NotImplementedError
117
71
- def get_cudagraph_support(self, *_args):
72
- return self.support
118
+ if bound is not None:
119
74
- def group(layer_name, support):
75
- builder = Builder(support)
120
+ @classmethod
121
+ def get_varlen_cudagraph_max_query_len(cls, *_args):
122
+ return bound
123
+
124
+ backend = type(
125
+ backend_name,
126
+ (),
127
+ {"supports_device_cpu_query_lens_mismatch": staticmethod(lambda: mismatch)},
128
+ )
129
+ builder = Builder()
130
return SimpleNamespace(
131
layer_names=[layer_name],
78
- backend=Backend,
132
+ backend=backend,
133
kv_cache_spec=None,
134
get_metadata_builder=lambda _index: builder,
135
)
136
137
groups = [
138
[
85
- group("target", AttentionCGSupport.ALWAYS),
86
- group("draft", AttentionCGSupport.UNIFORM_BATCH),
139
+ group(
140
+ "TargetBackend", "target", target_support, target_bound, device_offsets
141
+ ),
142
+ group("DraftBackend", "draft", AttentionCGSupport.UNIFORM_BATCH),
143
]
144
]
89
- runner_support = AttentionCGSupportInfo(
90
- AttentionCGSupport.UNIFORM_BATCH, "DraftBackend"
91
- )
145
created = object()
146
monkeypatch.setattr(
147
adaptive_module,
@@ -96,20 +149,19 @@ def test_manager_scopes_varlen_check_without_weakening_runner_cg_mode(monkeypatc
149
lambda *_args, **_kwargs: created,
150
)
151
99
- manager = maybe_create_adaptive_verification_manager(
100
- enable_adaptive_verification=True,
101
- attn_groups=groups,
102
- attn_cg_support=runner_support,
103
- req_states=object(),
104
- query_start_loc=object(),
105
- num_bonus_tokens=1,
106
- max_total_logits=1,
107
- vllm_config=None,
108
- target_layer_names={"target"},
109
- )
110
-
111
- assert manager is created
112
- assert runner_support.min_cg_support == AttentionCGSupport.UNIFORM_BATCH
152
+ with pytest.raises(ValueError, match=error) if error else nullcontext():
153
+ manager = maybe_create_adaptive_verification_manager(
154
+ enable_adaptive_verification=True,
155
+ attn_groups=groups,
156
+ req_states=SimpleNamespace(num_speculative_steps=7),
157
+ query_start_loc=object(),
158
+ num_bonus_tokens=1,
159
+ max_total_logits=1,
160
+ vllm_config=None,
161
+ target_layer_names={"target"},
162
+ additional_attn_cg_support=additional,
163
+ )
164
+ assert manager is created
165
166
167
def test_budget_stops_where_marginal_drafts_stop_paying_for_themselves():
tests/v1/worker/test_attn_utils.py
+42
-1
@@ -8,6 +8,7 @@ never addressed by the logical view.
8
"""
9
10
from types import SimpleNamespace
11
+from typing import Any
12
13
import numpy as np
14
import pytest
@@ -99,12 +100,16 @@ def test_get_kv_cache_spec_resolves_hisparse_block_size(
100
101
102
class _FakeMetadataBuilder:
102
- def __init__(self, support: AttentionCGSupport):
103
+ def __init__(self, support: AttentionCGSupport, varlen_bound: int | None = None):
104
self.support = support
105
+ self.varlen_bound = varlen_bound
106
107
def get_cudagraph_support(self, *_args):
108
return self.support
109
110
+ def get_varlen_cudagraph_max_query_len(self, *_args):
111
+ return self.varlen_bound
112
+
113
114
class _TargetBackend:
115
@classmethod
@@ -183,6 +188,42 @@ def test_attention_checks_preserve_global_and_target_scoped_support():
188
)
189
190
191
+def test_varlen_cudagraph_unsupported_backend_checks_scoped_bounds():
192
+ """ALWAYS passes without a bound, other builders need one at least as wide as
193
+ the requested length, and NEVER fails whatever bound a builder reports."""
194
+ config: Any = SimpleNamespace()
195
+ spec = FullAttentionSpec(
196
+ block_size=16,
197
+ num_kv_heads=1,
198
+ head_size=128,
199
+ dtype=torch.bfloat16,
200
+ )
201
+
202
+ def group(
203
+ backend: Any,
204
+ layer_name: str,
205
+ support: AttentionCGSupport,
206
+ bound: int | None = None,
207
+ ):
208
+ builder: Any = _FakeMetadataBuilder(support, bound)
209
+ attn_group = AttentionGroup(backend, [layer_name], spec, 0)
210
+ attn_group.metadata_builders = [builder]
211
+ return attn_group
212
+
213
+ target = group(_TargetBackend, "target", AttentionCGSupport.ALWAYS)
214
+ draft = group(_DraftBackend, "draft", AttentionCGSupport.UNIFORM_BATCH, 8)
215
+ never = group(_DraftBackend, "never", AttentionCGSupport.NEVER, 8)
216
+
217
+ unsupported = attn_utils.get_varlen_cudagraph_unsupported_backend
218
+ assert unsupported([[target, draft]], config, 8) is None
219
+ assert unsupported([[target, draft]], config, 9) == ("_DraftBackend", 8)
220
+ assert (
221
+ unsupported([[target, draft]], config, 9, checked_layer_names={"target"})
222
+ is None
223
+ )
224
+ assert unsupported([[target, never]], config, 1) == ("_DraftBackend", None)
225
+
226
+
227
def test_get_kv_sharing_fast_prefill_eligible_layers(monkeypatch: pytest.MonkeyPatch):
228
"""Fast prefill applies to the contiguous suffix of KV-sharing layers.
229
vllm/config/speculative.py
+2
@@ -631,6 +631,8 @@ class SpeculativeConfig:
631
"dspark",
632
)
633
factors.append(uses_aux_hidden_states)
634
+ if self.method == "dspark":
635
+ factors.append(self.enable_adaptive_verification)
636
637
if self.draft_model_config is not None:
638
factors.append(self.draft_model_config.compute_hash())
vllm/model_executor/models/gemma4_dspark.py
+22
-3
@@ -24,7 +24,11 @@ from vllm.transformers_utils.configs.gemma4 import gemma4_layer_config
24
25
from .gemma4_mtp import Gemma4MTPAttention, Gemma4MTPDecoderLayer
26
from .qwen3_dflash import DFlashQwen3Model, _dflash_layer_causal
27
-from .qwen3_dspark import DSparkMarkovHead, Qwen3DSparkForCausalLM
27
+from .qwen3_dspark import (
28
+ DSparkConfidenceHead,
29
+ DSparkMarkovHead,
30
+ Qwen3DSparkForCausalLM,
31
+)
32
from .utils import extract_layer_index, maybe_prefix
33
34
@@ -187,6 +191,18 @@ class Gemma4DSparkModel(DFlashQwen3Model):
191
config.markov_rank,
192
prefix=maybe_prefix(prefix, "markov_head"),
193
)
194
+ self.confidence_head: DSparkConfidenceHead | None = None
195
+ if getattr(config, "enable_confidence_head", False):
196
+ with_markov = getattr(config, "confidence_head_with_markov", False)
197
+ input_dim = config.hidden_size
198
+ if with_markov:
199
+ input_dim += config.markov_rank
200
+ self.confidence_head = DSparkConfidenceHead(
201
+ input_dim,
202
+ prefix=maybe_prefix(prefix, "confidence_head"),
203
+ bias=True,
204
+ with_markov=with_markov,
205
+ )
206
207
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
208
return self.embed_tokens(input_ids) * self.normalizer
@@ -295,9 +311,8 @@ class Gemma4DSparkForCausalLM(Qwen3DSparkForCausalLM):
311
params = dict(self.named_parameters())
312
params.update(dict(self.named_buffers()))
313
loaded: set[str] = set()
314
+ includes_confidence_head = False
315
for name, w in weights:
299
- if "confidence_head" in name:
300
- continue
316
if "lm_head" not in name:
317
name = "model." + name
318
for pn, wn, shard in stacked:
@@ -310,5 +325,9 @@ class Gemma4DSparkForCausalLM(Qwen3DSparkForCausalLM):
325
p = params[name]
326
getattr(p, "weight_loader", default_weight_loader)(p, w)
327
loaded.add(name)
328
+ if "confidence_head" in name:
329
+ includes_confidence_head = True
330
+ if not includes_confidence_head:
331
+ self.model.confidence_head = None
332
self.model._build_fused_kv_buffers()
333
return loaded
vllm/models/minimax_m3/amd/indexer_aiter.py
+6
-5
@@ -52,7 +52,11 @@ from vllm.models.minimax_m3.common.sparse_attention import (
52
_minimax_m3_aiter_sparse_pa_requested,
53
)
54
from vllm.platforms import current_platform
55
-from vllm.v1.attention.backend import AttentionBackend, CommonAttentionMetadata
55
+from vllm.v1.attention.backend import (
56
+ AttentionBackend,
57
+ CommonAttentionMetadata,
58
+ max_decode_query_len,
59
+)
60
from vllm.v1.attention.backends.utils import split_decodes_and_prefills
61
from vllm.v1.kv_cache_interface import AttentionSpec
62
@@ -97,10 +101,7 @@ def aiter_indexer_max_decode_query_len(vllm_config: VllmConfig) -> int:
101
since that is what the builder splits the batch on and therefore what the
102
decode kernel will actually be handed.
103
"""
100
- spec = vllm_config.speculative_config
101
- if spec is None or spec.num_speculative_tokens is None:
102
- return 1
103
- return 1 + (2 if spec.parallel_drafting else 1) * spec.num_speculative_tokens
104
+ return max_decode_query_len(vllm_config)
105
106
107
@cache
vllm/transformers_utils/config.py
+14
-1
@@ -387,7 +387,20 @@ class HFConfigParser(ConfigParserBase):
387
if extra_layer_types := _PATCH_HF_ALLOWED_LAYER_TYPES.get(model_type):
388
_patch_hf_transformers_allowed_layer_types(extra_layer_types)
389
390
- if model_type in _SPECULATIVE_DECODING_CONFIGS:
390
+ rope_parameters = config_dict.get("rope_parameters") or {}
391
+ if model_type == "gemma4_text" and "full_attention" in rope_parameters:
392
+ from transformers import Gemma4TextConfig
393
+
394
+ # Published DSpark configs mix redundant scalar entries with per-layer
395
+ # RoPE dicts. Remove them before Transformers' constructor validates.
396
+ config_dict["rope_parameters"] = {
397
+ k: v
398
+ for k, v in rope_parameters.items()
399
+ if k not in ("rope_type", "rope_theta")
400
+ }
401
+ kwargs.setdefault("name_or_path", str(model))
402
+ config = Gemma4TextConfig.from_dict(config_dict, **kwargs)
403
+ elif model_type in _SPECULATIVE_DECODING_CONFIGS:
404
config_class = _CONFIG_REGISTRY[model_type]
405
config = config_class.from_pretrained(
406
model,
vllm/v1/attention/backend.py
+45
-6
@@ -573,6 +573,26 @@ class AttentionCGSupport(Enum):
573
"""NO cudagraph support"""
574
575
576
+def max_decode_query_len(vllm_config: "VllmConfig") -> int:
577
+ """Widest request a spec-as-decode builder treats as a decode.
578
+
579
+ On model runner V2 this is a verification request, 1 +
580
+ num_speculative_tokens: no V2 speculator builds a wider query. The reorder
581
+ threshold and the code that mirrors the builders' decode/prefill split all
582
+ read this, so they cannot drift apart.
583
+ """
584
+ speculative_config = getattr(vllm_config, "speculative_config", None)
585
+ if speculative_config is None or speculative_config.num_speculative_tokens is None:
586
+ return 1
587
+ num_speculative_tokens = speculative_config.num_speculative_tokens
588
+ max_query_len = 1 + num_speculative_tokens
589
+ if speculative_config.parallel_drafting and not vllm_config.use_v2_model_runner:
590
+ # Model runner V1 only; remove with it. Its parallel drafter appends up
591
+ # to num_speculative_tokens mask slots to each verified request.
592
+ max_query_len += num_speculative_tokens
593
+ return max_query_len
594
+
595
+
596
class AttentionMetadataBuilder(ABC, Generic[M]):
597
# Does this backend/builder support CUDA Graphs for attention (default: no).
598
# Do not access directly. Call get_cudagraph_support() instead.
@@ -616,6 +636,30 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
636
"""Get the cudagraph support level of this builder class."""
637
return cls._cudagraph_support
638
639
+ @classmethod
640
+ def get_varlen_cudagraph_max_query_len(
641
+ cls: type["AttentionMetadataBuilder"],
642
+ vllm_config: "VllmConfig",
643
+ kv_cache_spec: "KVCacheSpec",
644
+ ) -> int | None:
645
+ """Get the largest per-request query length L of the variable-length
646
+ decode batches a FULL cudagraph of this builder class can replay.
647
+
648
+ The graph must replay any decode batch in which every real request has
649
+ between 1 and L query tokens and every padding request has 0. Lengths
650
+ come from the device query_start_loc; host metadata carries only the
651
+ token count and an upper bound on the per-request length. Batches with
652
+ a prefill never replay these graphs. Whether host metadata may
653
+ understate device query lengths at all is a separate backend question;
654
+ see supports_device_cpu_query_lens_mismatch().
655
+
656
+ Returns:
657
+ L, or None for builders reporting ALWAYS, which replay any batch,
658
+ and for builders that cannot replay variable-length batches.
659
+
660
+ """
661
+ return None
662
+
663
def _init_reorder_batch_threshold(
664
self,
665
reorder_batch_threshold: int | None = 1,
@@ -632,14 +676,9 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
676
speculative_config is not None
677
and speculative_config.num_speculative_tokens is not None
678
):
635
- max_num_queries_for_spec = (
636
- 1
637
- + (2 if speculative_config.parallel_drafting else 1)
638
- * speculative_config.num_speculative_tokens
639
- )
679
self.reorder_batch_threshold = max(
680
self.reorder_batch_threshold,
642
- max_num_queries_for_spec,
681
+ max_decode_query_len(self.vllm_config),
682
)
683
684
if (
vllm/v1/attention/backends/flashinfer.py
+56
-18
@@ -67,6 +67,7 @@ from vllm.v1.attention.backend import (
67
AttentionType,
68
CommonAttentionMetadata,
69
MultipleOf,
70
+ max_decode_query_len,
71
)
72
from vllm.v1.attention.backends.utils import (
73
get_dcp_local_seq_lens,
@@ -856,15 +857,8 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
857
self.use_xqa = (
858
self.flashinfer_trtllm_api_decode_kernel == FlashInferDecodeKernel.XQA
859
)
859
- # Adaptive verification trims drafts on device, so decode query lengths
860
- # must come from the device qo_indptr; only trtllm-gen supports that
861
- # (the selector already rejects the other configurations).
862
- self.use_trtllm_gen_varlen_decode = (
863
- speculative_config is not None
864
- and speculative_config.enable_adaptive_verification
865
- and self.flashinfer_trtllm_api_decode_kernel
866
- == FlashInferDecodeKernel.TRTLLM_GEN
867
- and not self.use_dcp
860
+ self.use_trtllm_gen_varlen_decode = self._uses_trtllm_gen_varlen_decode(
861
+ vllm_config, self.flashinfer_trtllm_api_decode_kernel, self.use_dcp
862
)
863
self._init_reorder_batch_threshold(
864
1,
@@ -1011,6 +1005,31 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1005
else:
1006
return AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE
1007
1008
+ @override # type: ignore[misc]
1009
+ @classmethod
1010
+ def get_varlen_cudagraph_max_query_len(
1011
+ cls: type["FlashInferMetadataBuilder"],
1012
+ vllm_config: VllmConfig,
1013
+ kv_cache_spec: KVCacheSpec,
1014
+ ) -> int | None:
1015
+ # The uniform gate already covers DCP, head geometry, the XQA head-dim
1016
+ # limit and causality, and yields None for subclasses that force NEVER.
1017
+ if (
1018
+ cls.get_cudagraph_support(vllm_config, kv_cache_spec)
1019
+ != AttentionCGSupport.UNIFORM_BATCH
1020
+ ):
1021
+ return None
1022
+ # The kernel selector asserts off the trtllm-capable architectures, so
1023
+ # call it only past the gate, as get_cudagraph_support does.
1024
+ if not cls._uses_trtllm_gen_varlen_decode(
1025
+ vllm_config,
1026
+ cls._get_flashinfer_trtllm_api_decode_kernel(),
1027
+ use_dcp=vllm_config.parallel_config.decode_context_parallel_size > 1,
1028
+ ):
1029
+ return None
1030
+ # Longer requests are split off as prefills.
1031
+ return max_decode_query_len(vllm_config)
1032
+
1033
def _get_workspace_buffer(self):
1034
if self._workspace_buffer is None:
1035
buffer_size = envs.VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE
@@ -1039,6 +1058,24 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1058
def set_workspace_buffer(self, workspace_buffer: torch.Tensor):
1059
self._workspace_buffer = workspace_buffer
1060
1061
+ @staticmethod
1062
+ def _uses_trtllm_gen_varlen_decode(
1063
+ vllm_config: VllmConfig,
1064
+ decode_kernel: FlashInferDecodeKernel | None,
1065
+ use_dcp: bool,
1066
+ ) -> bool:
1067
+ """Whether decode reads per-request query lengths from the device
1068
+ qo_indptr. Adaptive verification trims drafts on device, and only
1069
+ trtllm-gen supports that (the selector already rejects the other
1070
+ configurations)."""
1071
+ speculative_config = vllm_config.speculative_config
1072
+ return (
1073
+ speculative_config is not None
1074
+ and speculative_config.enable_adaptive_verification
1075
+ and decode_kernel == FlashInferDecodeKernel.TRTLLM_GEN
1076
+ and not use_dcp
1077
+ )
1078
+
1079
@staticmethod
1080
def _get_flashinfer_trtllm_api_decode_kernel() -> FlashInferDecodeKernel:
1081
if current_platform.is_device_capability(
@@ -1302,7 +1339,9 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1339
split_decodes_and_prefills(
1340
common_attn_metadata,
1341
decode_threshold=self.reorder_batch_threshold,
1305
- require_uniform=not self.use_xqa,
1342
+ require_uniform=not (
1343
+ self.use_xqa or self.use_trtllm_gen_varlen_decode
1344
+ ),
1345
)
1346
)
1347
else:
@@ -1704,15 +1743,14 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1743
bool(causal),
1744
)
1745
elif self.use_trtllm_gen_varlen_decode:
1707
- # CPU lens are only an upper bound, device qo_indptr is
1708
- # the truth.
1709
- decode_q_lens = (
1710
- qo_indptr_cpu[1 : num_decodes + 1] - qo_indptr_cpu[:num_decodes]
1746
+ # CPU placeholders may underestimate query lengths;
1747
+ # use a safe upper bound.
1748
+ q_len_per_req = min(
1749
+ common_attn_metadata.max_query_len, self.reorder_batch_threshold
1750
)
1712
- max_q_upper = int(decode_q_lens.max())
1713
- if max_q_upper > 1:
1714
- q_len_per_req = max_q_upper
1715
- q_cu_seq_lens = qo_indptr[: num_decodes + 1]
1751
+ # Always pass GPU offsets so a graph captured with uniform
1752
+ # query lengths can replay batches with variable query lengths.
1753
+ q_cu_seq_lens = qo_indptr[: num_decodes + 1]
1754
attn_metadata.decode = FlashInferTrtllmAPIDecode(
1755
kernel=self.flashinfer_trtllm_api_decode_kernel,
1756
block_tables=block_table_tensor[:num_decodes],
vllm/v1/attention/backends/mla/index_group.py
+2
-10
@@ -12,6 +12,7 @@ from vllm import _custom_ops as ops
12
from vllm.config import VllmConfig
13
from vllm.forward_context import in_piecewise_cudagraph
14
from vllm.utils.torch_utils import current_stream
15
+from vllm.v1.attention.backend import max_decode_query_len
16
from vllm.v1.attention.backends.mla.sparse_utils import (
17
triton_convert_req_index_to_global_index,
18
)
@@ -496,17 +497,8 @@ class SparseMLAIndexGroupBuilder:
497
498
499
def get_sparse_mla_index_group_max_rows(vllm_config: VllmConfig) -> int:
499
- max_query_len = 1
500
- speculative_config = vllm_config.speculative_config
501
- if (
502
- speculative_config is not None
503
- and speculative_config.num_speculative_tokens is not None
504
- ):
505
- max_query_len += speculative_config.num_speculative_tokens * (
506
- 2 if speculative_config.parallel_drafting else 1
507
- )
500
scheduler_config = vllm_config.scheduler_config
501
return min(
502
scheduler_config.max_num_batched_tokens,
511
- scheduler_config.max_num_seqs * max_query_len,
503
+ scheduler_config.max_num_seqs * max_decode_query_len(vllm_config),
504
)
vllm/v1/attention/backends/mla/sparse_swa.py
+4
-7
@@ -26,6 +26,7 @@ from vllm.v1.attention.backend import (
26
AttentionMetadataBuilder,
27
CommonAttentionMetadata,
28
MultipleOf,
29
+ max_decode_query_len,
30
)
31
from vllm.v1.attention.backends.mla.compressor_utils import (
32
get_dspark_swa_index_width,
@@ -456,14 +457,10 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
457
self.num_speculative_tokens = (
458
spec_config.num_speculative_tokens if spec_config else 0
459
)
459
- # Decode can have query_len up to
460
- # 1 + (2 if parallel drafting else 1) * num_speculative_tokens.
460
# sparse_swa has no MQA-vs-dense-MHA routing, so multi-token queries take
462
- # the prefill path and the decode/prefill split stays at that width.
463
- spec_mult = (
464
- 2 if (spec_config is not None and spec_config.parallel_drafting) else 1
465
- )
466
- self.decode_threshold = 1 + spec_mult * self.num_speculative_tokens
461
+ # the prefill path and the decode/prefill split stays at the widest
462
+ # decode.
463
+ self.decode_threshold = max_decode_query_len(self.vllm_config)
464
self.reorder_batch_threshold = None
465
466
hf_config = self.vllm_config.model_config.hf_config
vllm/v1/attention/ops/dcp.py
+4
-10
@@ -27,6 +27,7 @@ from vllm.model_executor.warmup.jit_warmup_triton_helper import (
27
triton_scalar_specialization_rep,
28
)
29
from vllm.triton_utils import tl, triton
30
+from vllm.v1.attention.backend import max_decode_query_len
31
from vllm.v1.attention.ops.cp_common import (
32
DirectCPWorkspace,
33
direct_cp_enabled,
@@ -1011,16 +1012,9 @@ def dcp_a2a_lse_reduce(
1012
1013
def get_dcp_workspace_max_num_tokens(vllm_config: VllmConfig) -> int:
1014
scheduler_config = vllm_config.scheduler_config
1014
- speculative_config = vllm_config.speculative_config
1015
- speculative_tokens = vllm_config.num_speculative_tokens
1016
- tokens_per_seq = (
1017
- 1
1018
- + (
1019
- 2
1020
- if speculative_config is not None and speculative_config.parallel_drafting
1021
- else 1
1022
- )
1023
- * speculative_tokens
1015
+ # num_speculative_tokens also counts a diffusion canvas.
1016
+ tokens_per_seq = max(
1017
+ 1 + vllm_config.num_speculative_tokens, max_decode_query_len(vllm_config)
1018
)
1019
return min(
1020
scheduler_config.max_num_batched_tokens,
vllm/v1/hisparse/runtime.py
+3
-14
@@ -21,6 +21,7 @@ from vllm.logger import init_logger
21
from vllm.platforms import current_platform
22
from vllm.utils.math_utils import round_up
23
from vllm.utils.torch_utils import current_stream
24
+from vllm.v1.attention.backend import max_decode_query_len
25
from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
26
from vllm.v1.simple_kv_offload.cuda_mem_ops import pin_tensor
27
@@ -46,20 +47,8 @@ def is_hisparse_decode_batch(attn_metadata: Any | None) -> bool:
47
)
48
49
49
-def _get_max_decode_query_len(vllm_config: VllmConfig) -> int:
50
- speculative_config = getattr(vllm_config, "speculative_config", None)
51
- if (
52
- speculative_config is not None
53
- and speculative_config.num_speculative_tokens is not None
54
- ):
55
- return 1 + speculative_config.num_speculative_tokens * (
56
- 2 if speculative_config.parallel_drafting else 1
57
- )
58
- return 1
59
-
60
-
50
def _get_max_swap_rows(vllm_config: VllmConfig) -> int:
62
- max_query_len = _get_max_decode_query_len(vllm_config)
51
+ max_query_len = max_decode_query_len(vllm_config)
52
scheduler_config = vllm_config.scheduler_config
53
return min(
54
scheduler_config.max_num_batched_tokens,
@@ -120,7 +109,7 @@ class ResolvedHiSparseConfig:
109
if config is None:
110
return None
111
123
- max_query_len = _get_max_decode_query_len(vllm_config)
112
+ max_query_len = max_decode_query_len(vllm_config)
113
min_device_buffer_size = max_query_len * model_top_k
114
# Retain the worst-case union of every speculative step's top-k and
115
# leave one additional top-k of LRU slack.
vllm/v1/worker/gpu/attn_utils.py
+34
@@ -328,6 +328,40 @@ def get_query_lens_mismatch_unsupported_backend(
328
return None
329
330
331
+def get_varlen_cudagraph_unsupported_backend(
332
+ attn_groups: list[list[AttentionGroup]],
333
+ vllm_config: VllmConfig,
334
+ max_query_len: int,
335
+ checked_layer_names: set[str] | None = None,
336
+) -> tuple[str, int | None] | None:
337
+ """Name the first backend whose FULL cudagraphs cannot replay decode batches
338
+ of 1 to max_query_len tokens per request, with its bound, if any.
339
+
340
+ ALWAYS builders replay any batch; the others need a bound of at least
341
+ max_query_len. See AttentionMetadataBuilder.get_varlen_cudagraph_max_query_len().
342
+ """
343
+ for groups in attn_groups:
344
+ for group in groups:
345
+ if checked_layer_names is not None and checked_layer_names.isdisjoint(
346
+ group.layer_names
347
+ ):
348
+ continue
349
+ builder = group.get_metadata_builder(0)
350
+ spec = group.kv_cache_spec
351
+ support = builder.get_cudagraph_support(vllm_config, spec)
352
+ if support == AttentionCGSupport.ALWAYS:
353
+ continue
354
+ bound = (
355
+ # NEVER means never, whatever an inherited override says.
356
+ None
357
+ if support == AttentionCGSupport.NEVER
358
+ else builder.get_varlen_cudagraph_max_query_len(vllm_config, spec)
359
+ )
360
+ if bound is None or bound < max_query_len:
361
+ return group.backend.__name__, bound
362
+ return None
363
+
364
+
365
def init_kv_cache(
366
forward_context: dict[str, Any],
367
kv_cache_config: KVCacheConfig,
vllm/v1/worker/gpu/cudagraph_utils.py
+2
-1
@@ -69,7 +69,8 @@ class BatchExecutionDescriptor:
69
num_reqs: int | None # None means no request padding is needed (PIECEWISE graphs)
70
uniform_token_count: int | None = None
71
# Upper bound on per-request query length. Varlen decode graphs leave
72
- # uniform_token_count unset, so this is what keeps a prefill batch out of one.
72
+ # uniform_token_count unset, so this is what keeps a prefill batch out of one:
73
+ # the runner passes None for any batch with a prefill.
74
max_query_len: int | None = None
75
num_active_loras: int = 0
76
# Number of microbatches the batch is split into (DBO). 1 means no splitting.
vllm/v1/worker/gpu/dp_utils.py
+2
-1
@@ -243,7 +243,8 @@ def dispatch_cg_and_sync_dp(
243
dp_size: Data-parallel world size. 1 skips all cross-rank work.
244
dp_rank: This rank's index in the DP group.
245
max_query_len: Upper bound on per-request query length, for selecting
246
- varlen decode graphs. None means the graph must not constrain it.
246
+ varlen decode graphs. None, as for a batch with a prefill, keeps
247
+ the batch out of graphs that constrain it.
248
need_eager: Force `CUDAGraphMode.NONE` instead of dispatching.
249
num_active_loras: Active LoRA count for this rank. Does not need
250
cross-rank agreement; it never changes a bucket's token count.
vllm/v1/worker/gpu/model_runner.py
+5
-2
@@ -640,7 +640,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
640
self.speculator, "enable_adaptive_verification", False
641
),
642
attn_groups=self.attn_groups,
643
- attn_cg_support=attn_cg_support,
643
req_states=self.req_states,
644
query_start_loc=self.input_buffers.query_start_loc,
645
num_bonus_tokens=self.model_state.num_new_sampled_tokens_per_step,
@@ -1646,12 +1645,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1645
# Get batch descriptor and sync across DP ranks.
1646
num_reqs = len(scheduler_output.num_scheduled_tokens)
1647
num_toks = scheduler_output.total_num_scheduled_tokens
1649
- max_query_len = max(scheduler_output.num_scheduled_tokens.values())
1648
+ max_query_len: int | None = max(scheduler_output.num_scheduled_tokens.values())
1649
batch_req_state, uniform_tok_count = self.gather_batch_req_state(
1650
scheduler_output, dummy_run
1651
)
1652
if batch_req_state is not None:
1653
num_toks = batch_req_state.num_tokens
1654
+ if batch_req_state.has_prefill:
1655
+ # Varlen decode graphs replay decodes only, and their bound
1656
+ # alone would admit a short prefill.
1657
+ max_query_len = None
1658
if self.pcp_manager is not None:
1659
num_toks = self.pcp_manager.get_num_tokens_for_dispatch(
1660
batch_req_state.num_scheduled_tokens,
vllm/v1/worker/gpu/spec_decode/adaptive_verification.py
+20
-17
@@ -19,8 +19,8 @@ from vllm.v1.attention.backend import AttentionCGSupport
19
from vllm.v1.utils import CpuGpuBuffer
20
from vllm.v1.worker.gpu.async_utils import StepTimingSample, stream
21
from vllm.v1.worker.gpu.attn_utils import (
22
- get_attn_cg_support,
22
get_query_lens_mismatch_unsupported_backend,
23
+ get_varlen_cudagraph_unsupported_backend,
24
)
25
26
logger = init_logger(__name__)
@@ -28,7 +28,6 @@ _PROFILE_REPLAYS = 5
28
29
if TYPE_CHECKING:
30
from vllm.config import VllmConfig
31
- from vllm.v1.worker.gpu.attn_utils import AttentionCGSupportInfo
31
from vllm.v1.worker.gpu.input_batch import InputBatch
32
from vllm.v1.worker.gpu.states import RequestState
33
from vllm.v1.worker.utils import AttentionGroup
@@ -452,7 +451,6 @@ def maybe_create_adaptive_verification_manager(
451
*,
452
enable_adaptive_verification: bool,
453
attn_groups: list[list["AttentionGroup"]],
455
- attn_cg_support: "AttentionCGSupportInfo",
454
req_states: "RequestState",
455
query_start_loc: torch.Tensor,
456
num_bonus_tokens: int,
@@ -477,25 +475,30 @@ def maybe_create_adaptive_verification_manager(
475
"use a backend that does."
476
)
477
480
- target_attn_cg_support = attn_cg_support
481
- if target_layer_names is not None:
482
- target_attn_cg_support = get_attn_cg_support(
478
+ # The runner's decode_query_len, the width varlen decode graphs capture.
479
+ max_query_len = req_states.num_speculative_steps + num_bonus_tokens
480
+ unsupported: tuple[str | None, int | None] | None = (
481
+ get_varlen_cudagraph_unsupported_backend(
482
attn_groups,
483
vllm_config,
484
+ max_query_len,
485
checked_layer_names=target_layer_names,
486
)
487
- if additional_attn_cg_support is not None:
488
- target_attn_cg_support = target_attn_cg_support.narrow(
489
- *additional_attn_cg_support
490
- )
491
- if target_attn_cg_support.min_cg_support != AttentionCGSupport.ALWAYS:
487
+ )
488
+ if unsupported is None and additional_attn_cg_support is not None:
489
+ # Groups built outside init_attn_backend report only their support
490
+ # level, and without a bound only ALWAYS replays varlen batches.
491
+ additional_support, additional_backend = additional_attn_cg_support
492
+ if additional_support != AttentionCGSupport.ALWAYS:
493
+ unsupported = additional_backend, None
494
+ if unsupported is not None:
495
+ backend, bound = unsupported
496
+ allowed = "none" if bound is None else f"at most {bound}"
497
raise ValueError(
493
- "Adaptive verification captures varlen decode cudagraphs, so every"
494
- " target attention builder must report AttentionCGSupport.ALWAYS, but "
495
- f"{target_attn_cg_support.min_cg_attn_backend} reports "
496
- f"{target_attn_cg_support.min_cg_support}. Pass "
497
- "enable_adaptive_verification=false in the speculative config, or "
498
- "use a backend that does."
498
+ "Adaptive verification replays decode cudagraphs whose per-request "
499
+ f"query lengths vary up to {max_query_len}, but {backend} allows "
500
+ f"{allowed}. Pass enable_adaptive_verification=false in the "
501
+ "speculative config, or use a backend that does."
502
)
503
504
return AdaptiveVerificationManager(