[Bugfix] Make DSV4 sparse MLA work end-to-end for plain decode, MTP, and DSpark (#51538)
Gabriel Wu committed
Aug 15, 2026 at 22:01 UTC
97388c44f9c608f83318e0c9540a536cd7de3e0d
20 files changed
+797
-120
csrc/libtorch_stable/cooperative_topk.cuh
+4
-1
@@ -484,7 +484,10 @@ __device__ void large_topk(const float* __restrict__ row_input,
484
template <uint32_t TopK, uint32_t CS>
485
__device__ void cooperative_topk_body(CooperativeTopKParams<TopK> params) {
486
const auto rank = blockIdx.y, row = blockIdx.x, tx = threadIdx.x;
487
- const auto sl = params.lengths[row];
487
+ // Clamp at 0: `sl` is compared signed here but cast to uint32 below, so a
488
+ // negative length would otherwise emit indices 0..TopK-1 as valid instead
489
+ // of the -1 padding.
490
+ const int32_t sl = params.lengths[row] > 0 ? params.lengths[row] : 0;
491
int32_t* out = params.output + row * TopK;
492
const float* in = params.input + row * params.stride;
493
csrc/libtorch_stable/persistent_topk.cuh
+20
-1
@@ -906,7 +906,26 @@ __global__ void __launch_bounds__(kThreadsPerBlock, 2)
906
uint32_t row_idx = group_id + iter * num_groups;
907
if (row_idx >= params.num_rows) break;
908
909
- const uint32_t seq_len = params.lengths[row_idx];
909
+ // Clamp the row length before any decision is made on it.
910
+ //
911
+ // `lengths` is int32 and is consumed here as uint32, so a negative value
912
+ // (e.g. a padded decode slot whose per-token context length underflowed)
913
+ // would reinterpret as ~4e9 and sail past every threshold below. Any
914
+ // value beyond the row width would also read into the next row.
915
+ //
916
+ // Clamping to max_seq_len additionally keeps this per-row decision
917
+ // consistent with the `cta_in_group != 0` early exit above, which is
918
+ // taken from the host-side scalar: when max_seq_len <= RADIX_THRESHOLD
919
+ // the non-leader CTAs return immediately, so a leader that reached the
920
+ // cooperative radix path would wait on the inter-CTA barrier for peers
921
+ // that no longer exist and spin until the kernel is killed.
922
+ const int32_t raw_len = params.lengths[row_idx];
923
+ const uint32_t row_bound =
924
+ params.stride < params.max_seq_len ? params.stride : params.max_seq_len;
925
+ const uint32_t non_negative_len =
926
+ raw_len > 0 ? static_cast<uint32_t>(raw_len) : 0u;
927
+ const uint32_t seq_len =
928
+ non_negative_len < row_bound ? non_negative_len : row_bound;
929
int32_t* row_output = params.output + row_idx * params.top_k;
930
const float* row_input = params.input + row_idx * params.stride;
931
tests/kernels/attention/test_flashmla_sparse.py
+118
@@ -225,6 +225,7 @@ def test_flashinfer_sparse_indices_cache(monkeypatch):
225
token_to_req_indices=torch.tensor([0, 1, 1], dtype=torch.int32),
226
decode_swa_indices=torch.tensor([[5, 6, -1, -1]], dtype=torch.int32),
227
decode_swa_lens=torch.tensor([2], dtype=torch.int32),
228
+ decode_swa_width=4,
229
is_valid_token=torch.tensor([True], dtype=torch.bool),
230
num_decodes=1,
231
num_prefills=1,
@@ -331,3 +332,120 @@ def test_flashinfer_sparse_indices_cache(monkeypatch):
332
assert builder_calls == 4
333
assert sparse_indices_third is not sparse_indices_fourth
334
assert sparse_lens_third is not sparse_lens_fourth
335
+
336
+
337
+def test_flashinfer_sparse_index_preserves_logical_window(monkeypatch):
338
+ from vllm.models.deepseek_v4.nvidia import flashinfer_sparse as flashinfer_mod
339
+ from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
340
+
341
+ captured_shapes_and_windows: list[tuple[int, int]] = []
342
+
343
+ def fake_build(*args, **kwargs):
344
+ # window_size is the 12th positional arg of
345
+ # build_flashinfer_mixed_sparse_indices.
346
+ captured_shapes_and_windows.append((args[0].shape[-1], args[11]))
347
+ num_tokens = args[0].shape[0] + args[3].shape[0]
348
+ return (
349
+ torch.zeros((num_tokens, 1), dtype=torch.int32),
350
+ torch.zeros((num_tokens,), dtype=torch.int32),
351
+ )
352
+
353
+ monkeypatch.setattr(
354
+ flashinfer_mod, "build_flashinfer_mixed_sparse_indices", fake_build
355
+ )
356
+
357
+ attn = object.__new__(flashinfer_mod.DeepseekV4FlashInferMLAAttention)
358
+ attn.compress_ratio = 1
359
+ attn.window_size = 4
360
+ attn.topk_indices_buffer = torch.zeros((4, 0), dtype=torch.int32)
361
+
362
+ wide_width = 8
363
+ wide_indices = torch.full((1, wide_width), -1, dtype=torch.int32)
364
+ wide_indices[0, :2] = torch.tensor([5, 6], dtype=torch.int32)
365
+ wide_metadata = DeepseekSparseSWAMetadata(
366
+ block_table=torch.tensor([[0, 1]], dtype=torch.int32),
367
+ slot_mapping=torch.tensor([0], dtype=torch.int64),
368
+ block_size=64,
369
+ seq_lens=torch.tensor([8], dtype=torch.int32),
370
+ query_start_loc=torch.tensor([0, 1], dtype=torch.int32),
371
+ query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.int32),
372
+ token_to_req_indices=torch.tensor([0], dtype=torch.int32),
373
+ decode_swa_indices=wide_indices,
374
+ decode_swa_lens=torch.tensor([2], dtype=torch.int32),
375
+ decode_swa_width=wide_width,
376
+ is_valid_token=torch.tensor([True], dtype=torch.bool),
377
+ num_decodes=1,
378
+ num_prefills=0,
379
+ num_decode_tokens=1,
380
+ num_prefill_tokens=0,
381
+ )
382
+ attn._build_sparse_index_metadata(
383
+ kv_cache=None,
384
+ swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16),
385
+ swa_metadata=wide_metadata,
386
+ attn_metadata=None,
387
+ swa_only=True,
388
+ )
389
+ assert captured_shapes_and_windows == [(wide_width, attn.window_size)]
390
+
391
+ empty_width = 8
392
+ empty_metadata = DeepseekSparseSWAMetadata(
393
+ block_table=torch.tensor([[0, 1]], dtype=torch.int32),
394
+ slot_mapping=torch.tensor([0, 1], dtype=torch.int64),
395
+ block_size=64,
396
+ seq_lens=torch.tensor([8], dtype=torch.int32),
397
+ query_start_loc=torch.tensor([0, 2], dtype=torch.int32),
398
+ query_start_loc_cpu=torch.tensor([0, 2], dtype=torch.int32),
399
+ token_to_req_indices=torch.tensor([0, 0], dtype=torch.int32),
400
+ decode_swa_indices=torch.empty((0, 1, empty_width), dtype=torch.int32),
401
+ decode_swa_lens=torch.empty((0,), dtype=torch.int32),
402
+ decode_swa_width=empty_width,
403
+ is_valid_token=torch.tensor([True, True], dtype=torch.bool),
404
+ num_decodes=0,
405
+ num_prefills=1,
406
+ num_decode_tokens=0,
407
+ num_prefill_tokens=2,
408
+ )
409
+ attn._build_sparse_index_metadata(
410
+ kv_cache=None,
411
+ swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16),
412
+ swa_metadata=empty_metadata,
413
+ attn_metadata=None,
414
+ swa_only=True,
415
+ )
416
+ assert captured_shapes_and_windows == [
417
+ (wide_width, attn.window_size),
418
+ (empty_width, attn.window_size),
419
+ ]
420
+
421
+
422
+def test_flashinfer_mixed_sparse_indices_separates_window_and_padded_width():
423
+ from vllm.models.deepseek_v4.common.ops.cache_utils import (
424
+ build_flashinfer_mixed_sparse_indices,
425
+ )
426
+
427
+ device = torch.device("cuda")
428
+ padded_width = 8
429
+ logical_window = 4
430
+ sparse_indices, sparse_lens = build_flashinfer_mixed_sparse_indices(
431
+ decode_swa_indices=torch.empty(
432
+ (0, padded_width), dtype=torch.int32, device=device
433
+ ),
434
+ decode_compressed_indices=None,
435
+ decode_compressed_topk_lens=None,
436
+ prefill_topk_indices=torch.empty((1, 0), dtype=torch.int32, device=device),
437
+ query_start_loc=torch.tensor([0, 1], dtype=torch.int32, device=device),
438
+ seq_lens=torch.tensor([logical_window], dtype=torch.int32, device=device),
439
+ token_to_req_indices=torch.tensor([0], dtype=torch.int32, device=device),
440
+ swa_block_table=torch.tensor([[0]], dtype=torch.int32, device=device),
441
+ swa_block_size=64,
442
+ compressed_block_table=None,
443
+ compressed_block_size=64,
444
+ window_size=logical_window,
445
+ compress_ratio=1,
446
+ topk=0,
447
+ )
448
+
449
+ assert sparse_indices.shape == (1, padded_width)
450
+ assert sparse_indices[0].cpu().tolist() == [0, 1, 2, 3, -1, -1, -1, -1]
451
+ assert sparse_lens.cpu().tolist() == [padded_width]
tests/kernels/moe/test_ocp_mx_moe.py
+95
@@ -1095,6 +1095,101 @@ def test_convert_standard_mxfp4_weights_for_flashinfer_cutlass(
1095
torch.testing.assert_close(interleaved_scales[1], w2_scale)
1096
1097
1098
+def test_gpt_oss_quant_config_supplies_clamped_swiglu_params():
1099
+ from vllm.model_executor.layers.fused_moe.oracle.mxfp4 import Mxfp4MoeBackend
1100
+ from vllm.model_executor.layers.quantization.mxfp4 import GptOssMxfp4MoEMethod
1101
+
1102
+ fake_method = types.SimpleNamespace(
1103
+ mxfp4_backend=Mxfp4MoeBackend.FLASHINFER_CUTLASS_MXFP4_MXFP8,
1104
+ w13_precision_config=None,
1105
+ w2_precision_config=None,
1106
+ )
1107
+ fake_layer = types.SimpleNamespace(
1108
+ w13_weight_scale=torch.zeros(2, 4, 2, dtype=torch.uint8),
1109
+ w2_weight_scale=torch.zeros(2, 4, 2, dtype=torch.uint8),
1110
+ )
1111
+
1112
+ quant_config = GptOssMxfp4MoEMethod.get_fused_moe_quant_config(
1113
+ fake_method, fake_layer
1114
+ )
1115
+
1116
+ assert quant_config is not None
1117
+ assert quant_config.gemm1_alpha == 1.702
1118
+ assert quant_config.gemm1_beta == 1.0
1119
+ assert quant_config.gemm1_clamp_limit == 7.0
1120
+
1121
+
1122
+@pytest.mark.skipif(
1123
+ not current_platform.is_cuda(),
1124
+ reason="CUDA is required for FlashInferExperts parameter storage",
1125
+)
1126
+@pytest.mark.parametrize("supplied", [False, True])
1127
+def test_flashinfer_experts_swiglu_params_follow_quant_config(supplied: bool):
1128
+ from vllm.model_executor.layers.fused_moe.activation import MoEActivation
1129
+ from vllm.model_executor.layers.fused_moe.config import (
1130
+ FusedMoEConfig,
1131
+ FusedMoEParallelConfig,
1132
+ RoutingMethodType,
1133
+ mxfp4_w4a16_moe_quant_config,
1134
+ )
1135
+ from vllm.model_executor.layers.fused_moe.experts.flashinfer_cutlass_moe import (
1136
+ FlashInferExperts,
1137
+ )
1138
+
1139
+ num_experts, intermediate_size, hidden_size = 2, 128, 256
1140
+ w1_scale = torch.zeros(
1141
+ (num_experts, 2 * intermediate_size, hidden_size // 32),
1142
+ dtype=torch.uint8,
1143
+ device="cuda",
1144
+ )
1145
+ w2_scale = torch.zeros(
1146
+ (num_experts, hidden_size, intermediate_size // 32),
1147
+ dtype=torch.uint8,
1148
+ device="cuda",
1149
+ )
1150
+ extra = (
1151
+ {"gemm1_alpha": 0.5, "gemm1_beta": 0.25, "gemm1_clamp_limit": 3.0}
1152
+ if supplied
1153
+ else {}
1154
+ )
1155
+ quant_config = mxfp4_w4a16_moe_quant_config(
1156
+ w1_scale=w1_scale, w2_scale=w2_scale, **extra
1157
+ )
1158
+ moe_config = FusedMoEConfig(
1159
+ num_experts=num_experts,
1160
+ experts_per_token=1,
1161
+ hidden_dim=hidden_size,
1162
+ intermediate_size=intermediate_size,
1163
+ num_local_experts=num_experts,
1164
+ num_logical_experts=num_experts,
1165
+ activation=MoEActivation.SILU,
1166
+ device="cuda",
1167
+ routing_method=RoutingMethodType.TopK,
1168
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
1169
+ in_dtype=torch.bfloat16,
1170
+ )
1171
+ experts = FlashInferExperts(moe_config=moe_config, quant_config=quant_config)
1172
+
1173
+ if supplied:
1174
+ for name, value in (
1175
+ ("gemm1_alpha", 0.5),
1176
+ ("gemm1_beta", 0.25),
1177
+ ("gemm1_clamp_limit", 3.0),
1178
+ ):
1179
+ parameter = getattr(experts, name)
1180
+ assert isinstance(parameter, torch.Tensor)
1181
+ assert parameter.dtype == torch.float32
1182
+ assert parameter.shape == (num_experts,)
1183
+ torch.testing.assert_close(
1184
+ parameter.cpu(),
1185
+ torch.full((num_experts,), value, dtype=torch.float32),
1186
+ )
1187
+ else:
1188
+ assert experts.gemm1_alpha is None
1189
+ assert experts.gemm1_beta is None
1190
+ assert experts.gemm1_clamp_limit is None
1191
+
1192
+
1193
@pytest.mark.parametrize("topk", [1, 4])
1194
@pytest.mark.parametrize("num_experts", [32])
1195
@pytest.mark.parametrize("num_tokens", [1, 128])
tests/kernels/test_compressor_kv_cache.py
+13
@@ -31,10 +31,23 @@ from vllm.models.deepseek_v4.common.ops.fused_compress_quant_cache import (
31
)
32
from vllm.models.deepseek_v4.compressor import _get_c128_boundary
33
from vllm.platforms import current_platform
34
+from vllm.v1.attention.backends.mla.compressor_utils import (
35
+ get_dspark_swa_index_width,
36
+)
37
38
from .test_fused_indexer_q_rope_quant import quantize_to_mxfp4
39
40
41
+@pytest.mark.parametrize(
42
+ ("window_size", "num_speculative_tokens", "expected"),
43
+ [(128, 5, 192), (512, 5, 576), (1024, 0, 1024)],
44
+)
45
+def test_get_dspark_swa_index_width(
46
+ window_size: int, num_speculative_tokens: int, expected: int
47
+):
48
+ assert get_dspark_swa_index_width(window_size, num_speculative_tokens) == expected
49
+
50
+
51
def test_compute_global_topk_reuses_output_buffers():
52
device = "cuda"
53
topk_indices = torch.tensor(
tests/v1/attention/test_flashinfer_sparse_mla_sm120_api.py
+33
@@ -7,6 +7,9 @@ from types import SimpleNamespace
7
import torch
8
9
from vllm.config import set_current_vllm_config
10
+from vllm.models.deepseek_v4.nvidia.flashinfer_sparse import (
11
+ _required_sm120_sparse_topk,
12
+)
13
from vllm.platforms.interface import DeviceCapability
14
from vllm.utils import flashinfer as fi_utils
15
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
@@ -52,3 +55,33 @@ def test_v32_glm_sm120_backend_accepts_glm_block_size(
55
)
56
57
assert invalid_reasons == []
58
+
59
+
60
+def test_sm120_dsv4_capability_checks_exact_dispatch_shape(monkeypatch) -> None:
61
+ fake_module = SimpleNamespace(
62
+ _DECODE_DSV4_DISPATCH=frozenset({(32, 128), (32, 192)})
63
+ )
64
+ monkeypatch.setattr(fi_utils, "has_flashinfer_sparse_mla_sm120", lambda: True)
65
+ monkeypatch.setattr(fi_utils, "_get_submodule", lambda _name: fake_module)
66
+ fi_utils.has_flashinfer_sparse_mla_sm120_config.cache_clear()
67
+
68
+ assert fi_utils.has_flashinfer_sparse_mla_sm120_config(32, 128)
69
+ assert fi_utils.has_flashinfer_sparse_mla_sm120_config(32, 192)
70
+ assert not fi_utils.has_flashinfer_sparse_mla_sm120_config(32, 256)
71
+ assert not fi_utils.has_flashinfer_sparse_mla_sm120_config(16, 192)
72
+
73
+ fi_utils.has_flashinfer_sparse_mla_sm120_config.cache_clear()
74
+
75
+
76
+def test_sm120_dsv4_required_topk_tracks_dspark_width() -> None:
77
+ causal = SimpleNamespace(
78
+ attention_config=SimpleNamespace(use_non_causal=False),
79
+ speculative_config=SimpleNamespace(num_speculative_tokens=5),
80
+ )
81
+ dspark = SimpleNamespace(
82
+ attention_config=SimpleNamespace(use_non_causal=True),
83
+ speculative_config=SimpleNamespace(num_speculative_tokens=5),
84
+ )
85
+
86
+ assert _required_sm120_sparse_topk(causal, 128) == 128
87
+ assert _required_sm120_sparse_topk(dspark, 128) == 192
tests/v1/spec_decode/test_dflash_prepare_inputs.py
new
+152
@@ -0,0 +1,152 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from types import SimpleNamespace
5
+
6
+import numpy as np
7
+import pytest
8
+import torch
9
+
10
+from vllm.v1.attention.backends.utils import PAD_SLOT_ID
11
+from vllm.v1.worker.gpu.spec_decode.dflash.speculator import (
12
+ prepare_dflash_inputs,
13
+)
14
+
15
+pytestmark = pytest.mark.skipif(
16
+ not torch.cuda.is_available(), reason="requires a CUDA device"
17
+)
18
+
19
+
20
+def _run_prepare(*, target_positions: list[int], block_table_values: list[int]):
21
+ device = torch.device("cuda")
22
+ max_num_reqs = 4
23
+ max_num_tokens = 16
24
+ num_speculative_steps = 3
25
+
26
+ input_buffers = SimpleNamespace(
27
+ input_ids=torch.full((max_num_tokens,), -1, dtype=torch.int32, device=device),
28
+ positions=torch.full((max_num_tokens,), -1, dtype=torch.int64, device=device),
29
+ query_start_loc=torch.full(
30
+ (max_num_reqs + 1,), -1, dtype=torch.int32, device=device
31
+ ),
32
+ seq_lens=torch.full((max_num_reqs,), -1, dtype=torch.int32, device=device),
33
+ )
34
+ input_batch = SimpleNamespace(
35
+ num_reqs=1,
36
+ num_scheduled_tokens=np.array([4], dtype=np.int32),
37
+ positions=torch.tensor(target_positions, dtype=torch.int64, device=device),
38
+ query_start_loc=torch.tensor([0, 4], dtype=torch.int32, device=device),
39
+ idx_mapping=torch.tensor([2], dtype=torch.int32, device=device),
40
+ )
41
+ query_slot_mapping = torch.full(
42
+ (max_num_tokens,), -2, dtype=torch.int64, device=device
43
+ )
44
+ context_positions = torch.full(
45
+ (max_num_tokens,), -1, dtype=torch.int64, device=device
46
+ )
47
+ context_slot_mapping = torch.full(
48
+ (max_num_tokens,), -2, dtype=torch.int64, device=device
49
+ )
50
+ sample_indices = torch.full(
51
+ (max_num_reqs * num_speculative_steps,),
52
+ -1,
53
+ dtype=torch.int64,
54
+ device=device,
55
+ )
56
+ sample_pos = torch.full_like(sample_indices, -1)
57
+ sample_idx_mapping = torch.full(
58
+ sample_indices.shape, -1, dtype=torch.int32, device=device
59
+ )
60
+ temperature = torch.zeros(max_num_reqs, dtype=torch.float32, device=device)
61
+ seeds = torch.zeros(max_num_reqs, dtype=torch.int64, device=device)
62
+ input_temperature = torch.tensor(
63
+ [0.0, 0.0, 1.0, 0.0], dtype=torch.float32, device=device
64
+ )
65
+ input_seeds = torch.tensor([0, 0, 17, 0], dtype=torch.int64, device=device)
66
+ last_sampled = torch.tensor([0, 0, 99, 0], dtype=torch.int64, device=device)
67
+ next_prefill_tokens = torch.zeros_like(last_sampled)
68
+ block_table = torch.tensor([block_table_values], dtype=torch.int32, device=device)
69
+
70
+ prepare_dflash_inputs(
71
+ input_buffers,
72
+ query_slot_mapping,
73
+ context_positions,
74
+ context_slot_mapping,
75
+ sample_indices,
76
+ sample_pos,
77
+ sample_idx_mapping,
78
+ temperature,
79
+ seeds,
80
+ input_batch,
81
+ torch.tensor([1], dtype=torch.int32, device=device),
82
+ torch.tensor([2], dtype=torch.int32, device=device),
83
+ last_sampled,
84
+ next_prefill_tokens,
85
+ input_temperature,
86
+ input_seeds,
87
+ block_table,
88
+ 4,
89
+ 123,
90
+ num_speculative_steps,
91
+ num_speculative_steps,
92
+ max_num_reqs,
93
+ max_num_tokens,
94
+ 128,
95
+ sample_from_anchor=True,
96
+ )
97
+ torch.accelerator.synchronize()
98
+ return SimpleNamespace(
99
+ input_buffers=input_buffers,
100
+ query_slot_mapping=query_slot_mapping.cpu(),
101
+ context_positions=context_positions.cpu(),
102
+ context_slot_mapping=context_slot_mapping.cpu(),
103
+ sample_indices=sample_indices.cpu(),
104
+ sample_pos=sample_pos.cpu(),
105
+ sample_idx_mapping=sample_idx_mapping.cpu(),
106
+ temperature=temperature.cpu(),
107
+ seeds=seeds.cpu(),
108
+ )
109
+
110
+
111
+def test_prepare_dflash_inputs_excludes_rejected_context_suffix():
112
+ # Positions 10/11 use physical block 7. Rejected positions 12/13 would use
113
+ # block 8, but must be PAD context rather than contaminating draft KV.
114
+ out = _run_prepare(
115
+ target_positions=[10, 11, 12, 13],
116
+ block_table_values=[0, 0, 7, 8, 9, 10, 11, 12],
117
+ )
118
+
119
+ assert out.context_positions[:4].tolist() == [10, 11, 0, 0]
120
+ assert out.context_slot_mapping[:4].tolist() == [30, 31, PAD_SLOT_ID, PAD_SLOT_ID]
121
+
122
+ # The replacement query starts immediately after the two valid rows and
123
+ # advances from the last accepted position (11).
124
+ assert out.input_buffers.input_ids[:3].cpu().tolist() == [99, 123, 123]
125
+ assert out.input_buffers.positions[:3].cpu().tolist() == [12, 13, 14]
126
+ assert out.query_slot_mapping[:3].tolist() == [32, 33, 34]
127
+ assert out.sample_indices[:3].tolist() == [0, 1, 2]
128
+ assert out.sample_pos[:3].tolist() == [13, 14, 15]
129
+ assert out.sample_idx_mapping[:3].tolist() == [2, 2, 2]
130
+ assert out.temperature[2].item() == 1.0
131
+ assert out.seeds[2].item() == 17
132
+
133
+
134
+def test_prepare_dflash_inputs_never_writes_the_null_block():
135
+ # The valid context uses logical block 0 and the replacement query uses
136
+ # logical block 1. Both map to the null block and must remain unwritable.
137
+ out = _run_prepare(
138
+ target_positions=[2, 3, 4, 5],
139
+ block_table_values=[0, 0, 7, 8, 9, 10, 11, 12],
140
+ )
141
+
142
+ assert out.context_slot_mapping[:4].tolist() == [
143
+ PAD_SLOT_ID,
144
+ PAD_SLOT_ID,
145
+ PAD_SLOT_ID,
146
+ PAD_SLOT_ID,
147
+ ]
148
+ assert out.query_slot_mapping[:3].tolist() == [
149
+ PAD_SLOT_ID,
150
+ PAD_SLOT_ID,
151
+ PAD_SLOT_ID,
152
+ ]
tests/v1/worker/test_workspace.py
new
+102
@@ -0,0 +1,102 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from typing import cast
5
+
6
+import pytest
7
+import torch
8
+
9
+import vllm.v1.worker.workspace as workspace
10
+from vllm.config import VllmConfig
11
+from vllm.v1.worker.gpu_worker import _num_workspace_lanes
12
+
13
+
14
+class _SpecConfig:
15
+ def __init__(self, dspark: bool) -> None:
16
+ self._dspark = dspark
17
+
18
+ def use_dspark(self) -> bool:
19
+ return self._dspark
20
+
21
+
22
+class _VllmConfig:
23
+ def __init__(self, spec_config: _SpecConfig | None) -> None:
24
+ self.speculative_config = spec_config
25
+
26
+
27
+@pytest.mark.parametrize(
28
+ ("use_v2_model_runner", "spec_config", "expected"),
29
+ [
30
+ (True, _SpecConfig(True), 2),
31
+ (False, _SpecConfig(True), 1),
32
+ (True, _SpecConfig(False), 1),
33
+ (True, None, 1),
34
+ ],
35
+)
36
+def test_workspace_lane_count_is_dspark_only(
37
+ use_v2_model_runner: bool,
38
+ spec_config: _SpecConfig | None,
39
+ expected: int,
40
+) -> None:
41
+ config = cast(VllmConfig, _VllmConfig(spec_config))
42
+ assert _num_workspace_lanes(config, use_v2_model_runner) == expected
43
+
44
+
45
+def test_workspace_lanes_do_not_alias_and_restore_context(monkeypatch) -> None:
46
+ monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: 0)
47
+ manager = workspace.WorkspaceManager(
48
+ torch.device("cpu"), num_ubatches=2, num_lanes=2
49
+ )
50
+
51
+ assert manager._current_workspaces == [None, None, None, None]
52
+
53
+ (target,) = manager.get_simultaneous(((512,), torch.uint8))
54
+ with workspace.use_workspace_lane(1):
55
+ (draft,) = manager.get_simultaneous(((256,), torch.uint8))
56
+ (draft_reused,) = manager.get_simultaneous(((8,), torch.uint8))
57
+ (target_reused,) = manager.get_simultaneous(((8,), torch.uint8))
58
+
59
+ assert manager._current_workspaces[0].numel() == 512 # type: ignore[union-attr]
60
+ assert manager._current_workspaces[1].numel() == 256 # type: ignore[union-attr]
61
+ assert manager._current_workspaces[2:] == [None, None]
62
+ assert target.data_ptr() != draft.data_ptr()
63
+ assert draft.data_ptr() == draft_reused.data_ptr()
64
+ assert target.data_ptr() == target_reused.data_ptr()
65
+
66
+
67
+def test_workspace_lanes_compose_with_ubatches(monkeypatch) -> None:
68
+ active_ubatch = [0]
69
+ monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: active_ubatch[0])
70
+ manager = workspace.WorkspaceManager(
71
+ torch.device("cpu"), num_ubatches=2, num_lanes=2
72
+ )
73
+
74
+ pointers = set()
75
+ for ubatch_id in range(2):
76
+ active_ubatch[0] = ubatch_id
77
+ for lane in range(2):
78
+ with workspace.use_workspace_lane(lane):
79
+ (buffer,) = manager.get_simultaneous(((16,), torch.uint8))
80
+ pointers.add(buffer.data_ptr())
81
+
82
+ assert len(pointers) == 4
83
+
84
+
85
+def test_workspace_lane_validation(monkeypatch) -> None:
86
+ monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: 0)
87
+ manager = workspace.WorkspaceManager(torch.device("cpu"), num_lanes=1)
88
+
89
+ with (
90
+ pytest.raises(ValueError, match="non-negative"),
91
+ workspace.use_workspace_lane(-1),
92
+ ):
93
+ pass
94
+
95
+ with (
96
+ workspace.use_workspace_lane(1),
97
+ pytest.raises(RuntimeError, match="is not configured"),
98
+ ):
99
+ manager.get_simultaneous(((1,), torch.uint8))
100
+
101
+ with pytest.raises(ValueError, match="at least one"):
102
+ workspace.WorkspaceManager(torch.device("cpu"), num_lanes=0)
vllm/model_executor/layers/fused_moe/experts/flashinfer_cutlass_moe.py
+9
-18
@@ -111,21 +111,15 @@ class FlashInferExperts(mk.FusedMoEExpertsModular):
111
self.gemm1_alpha = _per_expert(quant_config.gemm1_alpha)
112
self.gemm1_beta = _per_expert(quant_config.gemm1_beta)
113
114
- if quant_config.weight_quant_dtype == "mxfp4":
115
- # This value is used specifically for gpt-oss,
116
- # Need to revisit this for other models
117
- if self.gemm1_alpha is None:
118
- self.gemm1_alpha = _per_expert(1.702)
119
- if self.gemm1_beta is None:
120
- self.gemm1_beta = _per_expert(1.0)
121
- if self.gemm1_clamp_limit is None:
122
- self.gemm1_clamp_limit = _per_expert(7.0)
123
- if quant_config.quant_dtype == "mxfp8":
124
- self.fake_input_scale = torch.ones(
125
- self.num_experts,
126
- device=self.device,
127
- dtype=torch.float32,
128
- )
114
+ if (
115
+ quant_config.weight_quant_dtype == "mxfp4"
116
+ and quant_config.quant_dtype == "mxfp8"
117
+ ):
118
+ self.fake_input_scale = torch.ones(
119
+ self.num_experts,
120
+ device=self.device,
121
+ dtype=torch.float32,
122
+ )
123
124
@property
125
def expects_unquantized_inputs(self) -> bool:
@@ -324,9 +318,6 @@ class FlashInferExperts(mk.FusedMoEExpertsModular):
318
elif self.weight_quant_dtype == "mxfp4":
319
assert self.w1_scale is not None and self.w2_scale is not None
320
assert w1.is_contiguous() and w2.is_contiguous()
327
- assert self.gemm1_alpha is not None
328
- assert self.gemm1_beta is not None
329
- assert self.gemm1_clamp_limit is not None
321
assert topk_ids.is_contiguous()
322
323
fc1_expert_biases = self.w1_bias
vllm/models/deepseek_v4/amd/rocm.py
+4
-4
@@ -414,7 +414,9 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuild
414
and base.decode_swa_lens is not None
415
):
416
ragged_indices, ragged_indptr = build_ragged_indices_from_dense(
417
- base.decode_swa_indices.reshape(base.num_decode_tokens, -1),
417
+ base.decode_swa_indices.reshape(
418
+ base.num_decode_tokens, base.decode_swa_width
419
+ ),
420
base.decode_swa_lens,
421
)
422
ragged_indices, ragged_indptr = _copy_ragged_to_graph_buffers(
@@ -423,9 +425,7 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuild
425
self.decode_swa_ragged_indices_buffer,
426
self.decode_swa_ragged_indptr_buffer,
427
base.num_decode_tokens,
426
- # Actual dense width for this build: window_size (causal) or
427
- # noncausal_index_width (DSpark non-causal draft).
428
- base.decode_swa_indices.shape[-1],
428
+ base.decode_swa_width,
429
)
430
431
return DeepseekV4ROCMAiterSparseSWAMetadata(
vllm/models/deepseek_v4/common/ops/cache_utils.py
+20
-17
@@ -839,16 +839,17 @@ def build_flashinfer_mixed_sparse_indices(
839
) -> tuple[torch.Tensor, torch.Tensor]:
840
"""Build the FlashInfer DSV4 sparse-index matrix for decode-first batches.
841
842
- Produces ``sparse_indices`` of shape ``[num_tokens, window_size +
843
- padded_topk]`` (the first ``window_size`` columns are SWA slot ids, the rest
844
- are compressed/top-k slot ids) and ``sparse_topk_lens`` (active length per
845
- token). Decode tokens read precomputed SWA/compressed indices; prefill tokens
846
- derive their SWA window from the position and translate local compressed
847
- indices to global slots via the block tables.
842
+ Produces ``sparse_indices`` of shape ``[num_tokens, swa_index_width +
843
+ padded_topk]`` (the first ``swa_index_width`` columns are SWA slot ids, the
844
+ rest are compressed/top-k slot ids) and ``sparse_topk_lens`` (active length
845
+ per token). Decode tokens read precomputed SWA/compressed indices; prefill
846
+ tokens derive their SWA window from the position and translate local
847
+ compressed indices to global slots via the block tables.
848
"""
849
assert decode_swa_indices.dtype == torch.int32
850
assert decode_swa_indices.dim() == 2
851
- assert decode_swa_indices.shape[-1] == window_size
851
+ swa_index_width = decode_swa_indices.shape[-1]
852
+ assert swa_index_width >= window_size
853
if decode_compressed_topk_lens is not None:
854
assert decode_compressed_topk_lens.dtype == torch.int32
855
assert prefill_topk_indices.dtype == torch.int32
@@ -897,7 +898,7 @@ def build_flashinfer_mixed_sparse_indices(
898
padded_topk = max(topk, decode_compressed_topk)
899
padded_topk = (padded_topk + 3) // 4 * 4
900
sparse_indices = torch.empty(
900
- (num_tokens, window_size + padded_topk),
901
+ (num_tokens, swa_index_width + padded_topk),
902
dtype=torch.int32,
903
device=decode_swa_indices.device,
904
)
@@ -907,7 +908,7 @@ def build_flashinfer_mixed_sparse_indices(
908
if num_tokens == 0:
909
return sparse_indices, sparse_topk_lens
910
910
- window_block_size = triton.next_power_of_2(max(window_size, 1))
911
+ window_block_size = triton.next_power_of_2(max(swa_index_width, 1))
912
topk_block_size = triton.next_power_of_2(max(padded_topk, 1))
913
max_block_size = max(window_block_size, topk_block_size)
914
num_warps = 4 if max_block_size >= 256 else 1
@@ -944,6 +945,7 @@ def build_flashinfer_mixed_sparse_indices(
945
compressed_span,
946
NUM_DECODE_TOKENS=num_decode_tokens,
947
WINDOW_SIZE=window_size,
948
+ SWA_INDEX_WIDTH=swa_index_width,
949
COMPRESS_RATIO=compress_ratio,
950
TOP_K=topk,
951
PADDED_TOP_K=padded_topk,
@@ -1011,6 +1013,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1013
compressed_block_span,
1014
NUM_DECODE_TOKENS,
1015
WINDOW_SIZE: tl.constexpr,
1016
+ SWA_INDEX_WIDTH: tl.constexpr,
1017
COMPRESS_RATIO: tl.constexpr,
1018
TOP_K: tl.constexpr,
1019
PADDED_TOP_K: tl.constexpr,
@@ -1024,9 +1027,9 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1027
token_idx = tl.program_id(0)
1028
1029
if token_idx < NUM_DECODE_TOKENS:
1027
- for i in range(0, WINDOW_SIZE, WINDOW_BLOCK_SIZE):
1030
+ for i in range(0, SWA_INDEX_WIDTH, WINDOW_BLOCK_SIZE):
1031
offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1029
- mask = offset < WINDOW_SIZE
1032
+ mask = offset < SWA_INDEX_WIDTH
1033
values = tl.load(
1034
decode_swa_indices_ptr + token_idx * decode_swa_stride + offset,
1035
mask=mask,
@@ -1072,7 +1075,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1075
tl.store(
1076
sparse_indices_ptr
1077
+ token_idx * sparse_indices_stride
1075
- + WINDOW_SIZE
1078
+ + SWA_INDEX_WIDTH
1079
+ offset,
1080
values,
1081
mask=mask,
@@ -1086,7 +1089,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1089
else:
1090
compressed_len = tl.full((), DECODE_COMPRESSED_TOPK, dtype=tl.int32)
1091
1089
- tl.store(sparse_topk_lens_ptr + token_idx, WINDOW_SIZE + compressed_len)
1092
+ tl.store(sparse_topk_lens_ptr + token_idx, SWA_INDEX_WIDTH + compressed_len)
1093
return
1094
1095
prefill_idx = token_idx - NUM_DECODE_TOKENS
@@ -1102,9 +1105,9 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1105
swa_start_pos = pos - swa_len + 1
1106
topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
1107
1105
- for i in range(0, WINDOW_SIZE, WINDOW_BLOCK_SIZE):
1108
+ for i in range(0, SWA_INDEX_WIDTH, WINDOW_BLOCK_SIZE):
1109
offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1107
- mask = offset < WINDOW_SIZE
1110
+ mask = offset < SWA_INDEX_WIDTH
1111
pos_offset = swa_start_pos + offset
1112
block_indices = pos_offset // swa_block_size
1113
block_numbers = tl.load(
@@ -1148,10 +1151,10 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1151
tl.store(
1152
sparse_indices_ptr
1153
+ token_idx * sparse_indices_stride
1151
- + WINDOW_SIZE
1154
+ + SWA_INDEX_WIDTH
1155
+ offset,
1156
slot_ids,
1157
mask=mask,
1158
)
1159
1157
- tl.store(sparse_topk_lens_ptr + token_idx, WINDOW_SIZE + topk_len)
1160
+ tl.store(sparse_topk_lens_ptr + token_idx, SWA_INDEX_WIDTH + topk_len)
vllm/models/deepseek_v4/nvidia/flashinfer_sparse.py
+28
-7
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, ClassVar, cast
6
7
import torch
8
9
+from vllm.config import VllmConfig
10
from vllm.config.cache import CacheDType
11
from vllm.forward_context import get_forward_context
12
from vllm.models.deepseek_v4.attention import DeepseekV4Attention
@@ -25,6 +26,9 @@ from vllm.platforms import current_platform
26
from vllm.platforms.interface import DeviceCapability
27
from vllm.utils.flashinfer import flashinfer_trtllm_batch_decode_sparse_mla_dsv4
28
from vllm.v1.attention.backend import MultipleOf
29
+from vllm.v1.attention.backends.mla.compressor_utils import (
30
+ get_dspark_swa_index_width,
31
+)
32
33
if TYPE_CHECKING:
34
from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
@@ -74,6 +78,19 @@ def _pad_to_supported_q_heads(num_heads: int) -> int:
78
)
79
80
81
+def _required_sm120_sparse_topk(vllm_config: VllmConfig, window_size: int) -> int:
82
+ """Return the SM120 DSV4 SWA specialization needed by this model."""
83
+ if not vllm_config.attention_config.use_non_causal:
84
+ return window_size
85
+ speculative_config = vllm_config.speculative_config
86
+ if speculative_config is None:
87
+ return window_size
88
+ return get_dspark_swa_index_width(
89
+ window_size,
90
+ speculative_config.num_speculative_tokens,
91
+ )
92
+
93
+
94
class DeepseekV4FlashInferMLASparseBackend(DeepseekV4SparseMLABackend):
95
"""FlashInfer backend using the DSv4 sparse metadata/cache layout.
96
@@ -309,7 +326,7 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
326
assert swa_metadata.block_table is not None
327
328
decode_swa_indices = swa_metadata.decode_swa_indices.reshape(
312
- num_decode_tokens, self.window_size
329
+ num_decode_tokens, swa_metadata.decode_swa_width
330
)
331
decode_compressed_topk_lens = None
332
decode_compressed_indices_are_local = False
@@ -568,14 +585,18 @@ class DeepseekV4FlashInferSM120Attention(DeepseekV4Attention):
585
tma_aligned_scales=self._tma_aligned_scales,
586
)
587
571
- def __init__(self, *args, **kwargs) -> None:
572
- super().__init__(*args, **kwargs)
573
- from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120
588
+ def __init__(self, vllm_config: VllmConfig, *args, **kwargs) -> None:
589
+ super().__init__(vllm_config, *args, **kwargs)
590
+ from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120_config
591
575
- if not has_flashinfer_sparse_mla_sm120():
592
+ required_topk = _required_sm120_sparse_topk(vllm_config, self.window_size)
593
+ if not has_flashinfer_sparse_mla_sm120_config(self.padded_heads, required_topk):
594
raise RuntimeError(
577
- "FLASHINFER_MLA_SPARSE_DSV4 on SM120 requires FlashInfer's "
578
- "sparse MLA decode API."
595
+ "FLASHINFER_MLA_SPARSE_DSV4 on SM120 requires a FlashInfer "
596
+ "DSV4 sparse MLA decode specialization for "
597
+ f"(num_q_heads={self.padded_heads}, top_k={required_topk}). "
598
+ "Install a FlashInfer build containing "
599
+ "flashinfer-ai/flashinfer#4380."
600
)
601
self._einsum_recipe, self._tma_aligned_scales = compute_fp8_einsum_recipe()
602
# Per-tensor FP8 cache path scales.
vllm/utils/flashinfer.py
+16
@@ -237,6 +237,22 @@ def has_flashinfer_sparse_mla_sm120() -> bool:
237
)
238
239
240
+@functools.cache
241
+def has_flashinfer_sparse_mla_sm120_config(num_q_heads: int, top_k: int) -> bool:
242
+ """Return whether FlashInfer ships an SM120 DSV4 decode specialization.
243
+
244
+ The public sparse MLA API predates some DSV4 shapes, so checking only that
245
+ the callable exists can select a package that later aborts or rejects a
246
+ valid vLLM configuration. Inspect FlashInfer's dispatch table until it
247
+ exposes a public capability query.
248
+ """
249
+ if not has_flashinfer_sparse_mla_sm120():
250
+ return False
251
+ mod = _get_submodule("flashinfer.mla._sparse_mla_sm120")
252
+ dispatch = getattr(mod, "_DECODE_DSV4_DISPATCH", None) if mod else None
253
+ return dispatch is not None and (int(num_q_heads), int(top_k)) in dispatch
254
+
255
+
256
@functools.cache
257
def has_flashinfer_cutedsl() -> bool:
258
"""Return ``True`` if FlashInfer cutedsl module is available."""
vllm/v1/attention/backends/mla/compressor_utils.py
+12
@@ -3,6 +3,18 @@
3
import torch
4
5
from vllm.triton_utils import tl, triton
6
+from vllm.utils.math_utils import cdiv
7
+
8
+_DSPARK_SWA_INDEX_ALIGNMENT = 64
9
+
10
+
11
+def get_dspark_swa_index_width(
12
+ window_size: int,
13
+ num_speculative_tokens: int,
14
+) -> int:
15
+ """Return the padded width of non-causal DSpark SWA indices."""
16
+ width = max(int(window_size), 0) + max(int(num_speculative_tokens), 0)
17
+ return cdiv(width, _DSPARK_SWA_INDEX_ALIGNMENT) * _DSPARK_SWA_INDEX_ALIGNMENT
18
19
20
@triton.jit
vllm/v1/attention/backends/mla/indexer.py
+9
-3
@@ -57,9 +57,12 @@ def _prepare_uniform_decode_kernel(
57
req_id = idx // max_decode_len
58
local_idx = idx % max_decode_len
59
60
- # Compute number of KVs attended to by this token.
60
+ # Compute number of KVs attended to by this token. Padding requests have
61
+ # seq_len == 0, which would otherwise make the first token of each padded
62
+ # request negative (e.g. next_n=2 gives 0-2+0+1 = -1). Downstream kernels
63
+ # read these as uint32, turning -1 into ~4e9.
64
seq_len = tl.load(seq_lens_ptr + req_id)
62
- per_token_seq_len = seq_len - max_decode_len + local_idx + 1
65
+ per_token_seq_len = tl.maximum(seq_len - max_decode_len + local_idx + 1, 0)
66
tl.store(decode_seq_lens_ptr + idx, per_token_seq_len)
67
68
# Copy block table row.
@@ -748,12 +751,15 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
751
seq_lens_buffer = self.decode_seq_lens_buffer[
752
: num_decodes * max_decode_len
753
].view(num_decodes, max_decode_len)
754
+ # Clamp at 0: padding requests have seq_len == 0, which would
755
+ # otherwise make token 0 negative (next_n=2 gives 0-2+1+0 = -1).
756
+ # Downstream kernels read these as uint32, turning -1 into ~4e9.
757
seq_lens_buffer[:] = (
758
seq_lens.unsqueeze(1)
759
- max_decode_len
760
+ 1
761
+ self.offsets_buffer[:max_decode_len]
756
- )
762
+ ).clamp_(min=0)
763
seq_lens = seq_lens_buffer
764
return seq_lens, block_table, decode_lens, num_decodes, requires_padding
765
vllm/v1/attention/backends/mla/sparse_swa.py
+16
-4
@@ -22,6 +22,9 @@ from vllm.v1.attention.backend import (
22
CommonAttentionMetadata,
23
MultipleOf,
24
)
25
+from vllm.v1.attention.backends.mla.compressor_utils import (
26
+ get_dspark_swa_index_width,
27
+)
28
from vllm.v1.attention.backends.utils import split_decodes_and_prefills
29
from vllm.v1.attention.ops.flashmla import FlashMLASchedMeta, get_mla_metadata
30
from vllm.v1.kv_cache_interface import (
@@ -174,8 +177,10 @@ class DeepseekSparseSWAMetadata:
177
178
is_valid_token: torch.Tensor | None = None # [num_tokens]
179
token_to_req_indices: torch.Tensor | None = None # [num_tokens]
177
- decode_swa_indices: torch.Tensor | None = None # [num_decode_tokens, window_size]
180
+ decode_swa_indices: torch.Tensor | None = None # [num_decode_tokens, width]
181
decode_swa_lens: torch.Tensor | None = None # [num_decode_tokens]
182
+ # window_size (causal) or noncausal_index_width (DSpark non-causal).
183
+ decode_swa_width: int = 0
184
# Paged-coordinate prefill SWA indices/lens (FP8 paged-direct prefill).
185
prefill_swa_indices: torch.Tensor | None = (
186
None # [num_prefill_tokens, 1, window_size]
@@ -487,11 +492,14 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
492
# DSpark draft: the block is non-causal (every query attends to the
493
# trailing window of context PLUS all query tokens, including future ones),
494
# so its per-token index list is wider than `window_size`. The kernel pads
490
- # the q-head count to B_TOPK (64/128), which requires the index width to be
491
- # a multiple of 128.
495
+ # the q-head count to B_TOPK. Pad to a kernel-supported width; the logical
496
+ # SWA window remains unchanged when the padded matrix is built.
497
self.is_dspark = spec_config is not None and spec_config.use_dspark()
498
self.noncausal_index_width = (
494
- cdiv(self.window_size + self.num_speculative_tokens, 128) * 128
499
+ get_dspark_swa_index_width(
500
+ self.window_size,
501
+ self.num_speculative_tokens,
502
+ )
503
if self.is_dspark
504
else 0
505
)
@@ -536,6 +544,9 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
544
is_valid_token.copy_(slot_mapping >= 0)
545
546
non_causal = not common_attn_metadata.causal
547
+ decode_swa_width = (
548
+ self.noncausal_index_width if non_causal else self.window_size
549
+ )
550
decode_swa_indices = self.decode_swa_indices
551
if num_decode_tokens > 0:
552
self.decode_swa_lens[num_decode_tokens:] = 0
@@ -634,6 +645,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
645
token_to_req_indices=token_to_req_indices,
646
decode_swa_indices=decode_swa_indices[:num_decode_tokens],
647
decode_swa_lens=self.decode_swa_lens[:num_decode_tokens],
648
+ decode_swa_width=decode_swa_width,
649
prefill_swa_indices=(
650
self.prefill_swa_indices[:num_prefill_tokens]
651
if num_prefill_tokens > 0
vllm/v1/worker/gpu/model_runner.py
+48
-40
@@ -149,6 +149,7 @@ from vllm.v1.worker.utils import (
149
copy_kv_cache_blocks_inplace,
150
get_uniform_decode_token_count,
151
)
152
+from vllm.v1.worker.workspace import use_workspace_lane
153
154
logger = init_logger(__name__)
155
@@ -164,6 +165,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
165
self.parallel_config = vllm_config.parallel_config
166
self.scheduler_config = vllm_config.scheduler_config
167
self.speculative_config = vllm_config.speculative_config
168
+ self._draft_workspace_lane = int(
169
+ self.speculative_config is not None and self.speculative_config.use_dspark()
170
+ )
171
self.observability_config = vllm_config.observability_config
172
173
self.device = device
@@ -364,10 +368,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
368
assert self.speculative_config is not None
369
set_eagle3_aux_hidden_state_layers(self.model, self.speculative_config)
370
if isinstance(self.speculator, DraftModelSpeculator):
367
- self.speculator.load_model(self.model)
368
- eplb_models_added = self.eplb.maybe_register_speculator(
369
- self.speculator, self.speculative_config, load_dummy_weights
370
- )
371
+ with use_workspace_lane(self._draft_workspace_lane):
372
+ self.speculator.load_model(self.model)
373
+ eplb_models_added = self.eplb.maybe_register_speculator(
374
+ self.speculator, self.speculative_config, load_dummy_weights
375
+ )
376
time_after_load = time.perf_counter()
377
378
self.model_memory_usage = m.consumed_memory
@@ -732,27 +737,28 @@ class GPUModelRunner(LoRAModelRunnerMixin):
737
if hasattr(self.model, "get_mtp_target_hidden_states"):
738
pre_hc_hidden_states = self.model.get_mtp_target_hidden_states()
739
spec_hidden_states = pre_hc_hidden_states[: hidden_states.shape[0]] # type: ignore[union-attr]
735
- self.speculator.propose(
736
- input_batch=input_batch,
737
- attn_metadata=attn_metadata,
738
- slot_mappings=slot_mappings_by_layer,
739
- last_hidden_states=spec_hidden_states,
740
- aux_hidden_states=aux_hidden_states,
741
- num_sampled=torch.ones(
742
- input_batch.num_reqs, dtype=torch.int32, device=self.device
743
- ),
744
- num_rejected=torch.zeros(
745
- input_batch.num_reqs, dtype=torch.int32, device=self.device
746
- ),
747
- last_sampled=self.req_states.last_sampled_tokens,
748
- next_prefill_tokens=self.req_states.next_prefill_tokens,
749
- temperature=self.sampler.sampling_states.temperature.gpu,
750
- seeds=self.sampler.sampling_states.seeds.gpu,
751
- dummy_run=True,
752
- skip_attn_for_dummy_run=skip_attn,
753
- mm_inputs=mm_inputs,
754
- is_profile=is_profile,
755
- )
740
+ with use_workspace_lane(self._draft_workspace_lane):
741
+ self.speculator.propose(
742
+ input_batch=input_batch,
743
+ attn_metadata=attn_metadata,
744
+ slot_mappings=slot_mappings_by_layer,
745
+ last_hidden_states=spec_hidden_states,
746
+ aux_hidden_states=aux_hidden_states,
747
+ num_sampled=torch.ones(
748
+ input_batch.num_reqs, dtype=torch.int32, device=self.device
749
+ ),
750
+ num_rejected=torch.zeros(
751
+ input_batch.num_reqs, dtype=torch.int32, device=self.device
752
+ ),
753
+ last_sampled=self.req_states.last_sampled_tokens,
754
+ next_prefill_tokens=self.req_states.next_prefill_tokens,
755
+ temperature=self.sampler.sampling_states.temperature.gpu,
756
+ seeds=self.sampler.sampling_states.seeds.gpu,
757
+ dummy_run=True,
758
+ skip_attn_for_dummy_run=skip_attn,
759
+ mm_inputs=mm_inputs,
760
+ is_profile=is_profile,
761
+ )
762
self.step_timing.drafter_end()
763
764
assert hidden_states is not None # Last PP rank always has hidden_states
@@ -880,7 +886,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
886
lora_capture_hook=create_lora_capture_hook(self.lora_config, self),
887
)
888
if self.speculator is not None:
883
- self.speculator.capture()
889
+ with use_workspace_lane(self._draft_workspace_lane):
890
+ self.speculator.capture()
891
if self.adaptive_verification is not None:
892
with self.step_timing.collect() as timings:
893
for batch in self.adaptive_verification.batches_to_profile(
@@ -1799,20 +1806,21 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1806
if hasattr(self.model, "get_mtp_target_hidden_states"):
1807
pre_hc_hidden_states = self.model.get_mtp_target_hidden_states()
1808
spec_hidden_states = pre_hc_hidden_states[: hidden_states.shape[0]] # type: ignore[union-attr]
1802
- draft_tokens = self.speculator.propose(
1803
- input_batch,
1804
- attn_metadata,
1805
- slot_mappings_by_layer,
1806
- spec_hidden_states,
1807
- aux_hidden_states,
1808
- num_sampled,
1809
- num_rejected,
1810
- self.req_states.last_sampled_tokens,
1811
- self.req_states.next_prefill_tokens,
1812
- self.sampler.sampling_states.temperature.gpu,
1813
- self.sampler.sampling_states.seeds.gpu,
1814
- mm_inputs=mm_inputs,
1815
- )
1809
+ with use_workspace_lane(self._draft_workspace_lane):
1810
+ draft_tokens = self.speculator.propose(
1811
+ input_batch,
1812
+ attn_metadata,
1813
+ slot_mappings_by_layer,
1814
+ spec_hidden_states,
1815
+ aux_hidden_states,
1816
+ num_sampled,
1817
+ num_rejected,
1818
+ self.req_states.last_sampled_tokens,
1819
+ self.req_states.next_prefill_tokens,
1820
+ self.sampler.sampling_states.temperature.gpu,
1821
+ self.sampler.sampling_states.seeds.gpu,
1822
+ mm_inputs=mm_inputs,
1823
+ )
1824
self.req_states.draft_tokens[input_batch.idx_mapping] = draft_tokens
1825
if self.adaptive_verification is not None:
1826
self.adaptive_verification.record_confidences(
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+33
-9
@@ -84,8 +84,11 @@ class DFlashSpeculator(DraftModelSpeculator):
84
self.sample_pos = torch.zeros(
85
max_num_sampled_tokens, dtype=torch.int64, device=device
86
)
87
- self.sample_idx_mapping = torch.zeros(
88
- max_num_sampled_tokens, dtype=torch.int32, device=device
87
+ # -1 marks an inert sampling row. CUDA graph capture can execute the
88
+ # full buffer before a real batch has populated it, so zero would make
89
+ # every padding row scatter into request slot 0.
90
+ self.sample_idx_mapping = torch.full(
91
+ (max_num_sampled_tokens,), -1, dtype=torch.int32, device=device
92
)
93
# [0, 1, ..., N-1, 0, 1, ..., N-1, ...] -> the per-token column index into
94
# draft_logits[req, step, :].
@@ -255,7 +258,6 @@ class DFlashSpeculator(DraftModelSpeculator):
258
num_tokens_across_dp,
259
cudagraph_runtime_mode,
260
)
258
-
261
num_sample = num_reqs * self.num_speculative_steps
262
sample_hidden_states = last_hidden_states[self.sample_indices[:num_sample]]
263
# sample_pos is the predicted token's position Q; verification keys
@@ -520,6 +522,7 @@ def _prepare_dflash_inputs_kernel(
522
523
num_rejected = tl.load(num_rejected_ptr + req_idx)
524
valid_ctx_end = ctx_end - num_rejected
525
+ num_valid_ctx = valid_ctx_end - ctx_start
526
527
num_sampled = tl.load(num_sampled_ptr + req_idx)
528
if num_sampled > 0:
@@ -533,20 +536,34 @@ def _prepare_dflash_inputs_kernel(
536
537
j = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
538
is_ctx = j < num_ctx
536
- is_query = (j >= num_ctx) & (j < num_ctx + num_query_per_req)
537
- query_off = j - num_ctx
539
+ is_valid_ctx = j < num_valid_ctx
540
+ is_query = (j >= num_valid_ctx) & (j < num_valid_ctx + num_query_per_req)
541
+ query_off = j - num_valid_ctx
542
543
# --- Context positions / slots ---
544
ctx_pos_idx = ctx_start + tl.where(is_ctx, j, 0)
541
- ctx_pos = tl.load(target_positions_ptr + ctx_pos_idx, mask=is_ctx, other=0)
545
+ ctx_pos = tl.load(target_positions_ptr + ctx_pos_idx, mask=is_valid_ctx, other=0)
546
ctx_block_num = ctx_pos // block_size
547
ctx_block_num = tl.minimum(ctx_block_num, block_table_stride - 1)
548
ctx_block_id = tl.load(
549
block_table_ptr + req_idx * block_table_stride + ctx_block_num,
546
- mask=is_ctx,
550
+ mask=is_valid_ctx,
551
other=0,
552
).to(tl.int64)
549
- ctx_slot = ctx_block_id * block_size + (ctx_pos % block_size)
553
+ # Block 0 is the null block. Old sliding-window context positions can map
554
+ # to it after eviction; rejected suffix rows are invalid context as well.
555
+ # Neither kind of row may write draft KV into physical block 0.
556
+ ctx_resident = is_valid_ctx & (ctx_block_id != 0)
557
+ ctx_slot = tl.where(
558
+ ctx_resident,
559
+ ctx_block_id * block_size + (ctx_pos % block_size),
560
+ PAD_SLOT_ID,
561
+ )
562
+ # Stored over the full [0, num_ctx) span while the loads above are masked to
563
+ # [0, num_valid_ctx): the rejected suffix rows in between get position 0 and
564
+ # PAD_SLOT_ID. That is intentional — those rows write no KV and their
565
+ # positions are never consumed, but the span must stay fully initialized so
566
+ # a replayed graph cannot observe a stale value from an earlier batch.
567
tl.store(out_context_positions_ptr + ctx_start + j, ctx_pos, mask=is_ctx)
568
tl.store(out_context_slot_mapping_ptr + ctx_start + j, ctx_slot, mask=is_ctx)
569
@@ -563,7 +580,14 @@ def _prepare_dflash_inputs_kernel(
580
mask=is_query,
581
other=0,
582
).to(tl.int64)
566
- q_slot = q_block_id * block_size + (query_pos % block_size)
583
+ # A null block is never a writable cache slot. This can occur when a
584
+ # sliding-window block table contains evicted/global padding entries.
585
+ q_resident = is_query & (q_block_id != 0)
586
+ q_slot = tl.where(
587
+ q_resident,
588
+ q_block_id * block_size + (query_pos % block_size),
589
+ PAD_SLOT_ID,
590
+ )
591
592
tl.store(out_input_ids_ptr + query_idx, input_id, mask=is_query)
593
clamped_query_pos = tl.minimum(query_pos, max_model_len - 1)
vllm/v1/worker/gpu_worker.py
+16
-2
@@ -91,6 +91,16 @@ from .utils import request_memory
91
92
logger = init_logger(__name__)
93
94
+
95
+def _num_workspace_lanes(vllm_config: VllmConfig, use_v2_model_runner: bool) -> int:
96
+ spec_config = vllm_config.speculative_config
97
+ return (
98
+ 2
99
+ if use_v2_model_runner and spec_config is not None and spec_config.use_dspark()
100
+ else 1
101
+ )
102
+
103
+
104
if TYPE_CHECKING:
105
from vllm.device_allocator.sleep_mode_backend import SleepModeBackend
106
from vllm.model_executor.model_loader.tensorizer import TensorizerConfig
@@ -402,9 +412,13 @@ class Worker(WorkerBase):
412
else:
413
raise RuntimeError(f"Unsupported device type: {self.device_config.device}")
414
405
- # Initialize workspace manager
415
+ # DSpark target and draft CUDA graphs retain workspace views concurrently.
416
num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
407
- init_workspace_manager(self.device, num_ubatches)
417
+ init_workspace_manager(
418
+ self.device,
419
+ num_ubatches,
420
+ _num_workspace_lanes(self.vllm_config, self.use_v2_model_runner),
421
+ )
422
423
# Construct the model runner
424
if self.use_v2_model_runner:
vllm/v1/worker/workspace.py
+49
-14
@@ -3,6 +3,9 @@
3
4
import inspect
5
import os
6
+from collections.abc import Iterator
7
+from contextlib import contextmanager
8
+from contextvars import ContextVar
9
from itertools import accumulate
10
from math import prod
11
@@ -26,22 +29,43 @@ _GiB = 1024**3
29
30
# Global workspace manager instance
31
_manager: "WorkspaceManager | None" = None
32
+_workspace_lane: ContextVar[int] = ContextVar("vllm_workspace_lane", default=0)
33
+
34
+
35
+@contextmanager
36
+def use_workspace_lane(lane: int) -> Iterator[None]:
37
+ """Select an independent workspace owner for this execution context."""
38
+ if lane < 0:
39
+ raise ValueError(f"Workspace lane must be non-negative, got {lane}.")
40
+ token = _workspace_lane.set(lane)
41
+ try:
42
+ yield
43
+ finally:
44
+ _workspace_lane.reset(token)
45
46
47
class WorkspaceManager:
48
"""Manager for workspace allocation.
49
34
- Manages one workspace buffer per active ubatch slot.
50
+ Manages one workspace buffer per active ``(ubatch, lane)`` slot.
51
Can be locked to prevent further growth during execution.
52
"""
53
38
- def __init__(self, device: torch.device, num_ubatches: int | None = None):
54
+ def __init__(
55
+ self,
56
+ device: torch.device,
57
+ num_ubatches: int | None = None,
58
+ num_lanes: int = 1,
59
+ ):
60
self._device = device
61
# Cache num ubatches at init based on configuration (default to 1)
62
self._num_ubatches = num_ubatches if num_ubatches is not None else 1
42
- self._current_workspaces: list[torch.Tensor | None] = [
43
- None
44
- ] * self._num_ubatches
63
+ if num_lanes < 1:
64
+ raise ValueError(f"num_lanes must be at least one, got {num_lanes}.")
65
+ self._num_lanes = num_lanes
66
+ self._current_workspaces: list[torch.Tensor | None] = [None] * (
67
+ self._num_ubatches * self._num_lanes
68
+ )
69
self._locked: bool = False
70
71
@staticmethod
@@ -126,7 +150,14 @@ class WorkspaceManager:
150
The current workspace tensor.
151
"""
152
ubatch_id = dbo_current_ubatch_id()
129
- current_workspace = self._current_workspaces[ubatch_id]
153
+ lane = _workspace_lane.get()
154
+ if lane >= self._num_lanes:
155
+ raise RuntimeError(
156
+ f"Workspace lane {lane} is not configured; manager has "
157
+ f"{self._num_lanes} lane(s)."
158
+ )
159
+ workspace_id = ubatch_id * self._num_lanes + lane
160
+ current_workspace = self._current_workspaces[workspace_id]
161
current_size = self._workspace_size_bytes(current_workspace)
162
163
if current_size < required_bytes:
@@ -161,11 +192,11 @@ class WorkspaceManager:
192
"Workspace growth is not allowed after locking."
193
)
194
164
- # Only resize the requesting ubatch's workspace. Other
165
- # ubatches resize lazily on their next get_simultaneous call.
195
+ # Only resize the requesting ubatch/lane workspace. Other slots
196
+ # resize lazily on their next get_simultaneous call.
197
# Resizing all ubatches here would orphan the other ubatch's
198
# old tensor when it still holds views into it (DBO leak).
168
- self._current_workspaces[ubatch_id] = None
199
+ self._current_workspaces[workspace_id] = None
200
del current_workspace
201
# Release the freed segment back to CUDA so the caching
202
# allocator can reuse the GPU memory for the larger
@@ -173,19 +204,20 @@ class WorkspaceManager:
204
# dead segment in reserved memory which can cause higher peak
205
# memory usage.
206
torch.accelerator.empty_cache()
176
- self._current_workspaces[ubatch_id] = torch.empty(
207
+ self._current_workspaces[workspace_id] = torch.empty(
208
(required_bytes,), dtype=torch.uint8, device=self._device
209
)
179
- current_workspace = self._current_workspaces[ubatch_id]
210
+ current_workspace = self._current_workspaces[workspace_id]
211
212
if envs.VLLM_DEBUG_WORKSPACE:
213
logger.info(
214
"[WORKSPACE DEBUG] Resized workspace from '%s': %.2f MB -> "
184
- "%.2f MB (ubatch %d)",
215
+ "%.2f MB (ubatch %d, lane %d)",
216
get_caller_info(),
217
current_size / _MB,
218
required_bytes / _MB,
219
ubatch_id,
220
+ lane,
221
)
222
223
return current_workspace
@@ -214,7 +246,9 @@ def current_workspace_manager() -> "WorkspaceManager":
246
247
248
def init_workspace_manager(
217
- device: torch.device, num_ubatches: int | None = None
249
+ device: torch.device,
250
+ num_ubatches: int | None = None,
251
+ num_lanes: int = 1,
252
) -> None:
253
"""Initialize the workspace manager with a device.
254
@@ -224,6 +258,7 @@ def init_workspace_manager(
258
Args:
259
device: The device to allocate workspace on.
260
num_ubatches: Number of workspace ubatch slots. Defaults to 1.
261
+ num_lanes: Number of independent execution lanes per ubatch. Defaults to 1.
262
"""
263
global _manager
264
if _manager is not None:
@@ -233,7 +268,7 @@ def init_workspace_manager(
268
_manager._device,
269
device,
270
)
236
- _manager = WorkspaceManager(device, num_ubatches)
271
+ _manager = WorkspaceManager(device, num_ubatches, num_lanes)
272
273
274
def lock_workspace() -> None: