[Bugfix][Model] Restore causal image SWA for DeepSeek V4.1 (#57152)

Signed-off-by: Juntian Liu <Juntianl777@gmail.com> Signed-off-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Isotr0py <Isotr0py@outlook.com>

Juntian Liu committed Sep 16, 2026 at 11:09 UTC 9f9e1dac26ff0379651dd7e8ca409b573ccfce54
8 files changed +122 -174
tests/v1/attention/test_deepseek_v4_swa_visible.py
+81 -6
@@ -462,13 +462,31 @@ def test_flashinfer_mixed_sparse_indices_with_image_spans():
462 assert actual == exp_rows[1:]
463
464
465 -def make_builder(vision: bool) -> DeepseekSparseSWAMetadataBuilder:
466 - overrides: dict = {"sliding_window": WINDOW}
465 +def make_builder(
466 + vision: bool,
467 + window: int = WINDOW,
468 + max_image_tokens: int = MAX_IMG,
469 + max_num_batched_tokens: int = 64,
470 + model_name: str = "meta-llama/Meta-Llama-3-8B",
471 + builder_cls: type[
472 + DeepseekSparseSWAMetadataBuilder
473 + ] = DeepseekSparseSWAMetadataBuilder,
474 + mm_prefix_clamp_sliding_window: bool = True,
475 +) -> DeepseekSparseSWAMetadataBuilder:
476 + overrides: dict = {"sliding_window": window}
477 if vision:
468 - overrides.update(vision_n_layers=2, vision_max_n_token=MAX_IMG)
478 + # Emulate the V4 vision config, whose mm_prefix_clamp_sliding_window
479 + # gates the in-kernel SWA widening. V4.1 (causal image tokens) leaves
480 + # it off.
481 + overrides.update(
482 + vision_n_layers=2,
483 + vision_max_n_token=max_image_tokens,
484 + mm_prefix_clamp_sliding_window=mm_prefix_clamp_sliding_window,
485 + )
486 vllm_config = create_vllm_config(
487 + model_name=model_name,
488 max_model_len=4096,
471 - max_num_batched_tokens=64,
489 + max_num_batched_tokens=max_num_batched_tokens,
490 max_num_seqs=8,
491 hf_config_override=overrides,
492 )
@@ -477,11 +495,11 @@ def make_builder(vision: bool) -> DeepseekSparseSWAMetadataBuilder:
495 num_kv_heads=1,
496 head_size=512,
497 dtype=torch.bfloat16,
480 - sliding_window=WINDOW,
498 + sliding_window=window,
499 cache_dtype_str="auto",
500 model_version="deepseek_v4",
501 )
484 - return DeepseekSparseSWAMetadataBuilder(
502 + return builder_cls(
503 kv_cache_spec=spec,
504 layer_names=["layer0"],
505 vllm_config=vllm_config,
@@ -578,3 +596,60 @@ def test_builder_text_model_unchanged():
596 )
597 assert md.prefill_swa_lens.cpu().tolist() == lens
598 assert md.prefill_swa_indices[:, 0].cpu().tolist() == rows
599 +
600 +
601 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
602 +@pytest.mark.parametrize("compress_ratio", [0, 1, 2])
603 +@pytest.mark.parametrize("query_len", [100, 4000])
604 +def test_v41_image_prefill_uses_causal_swa(compress_ratio, query_len):
605 + """V4.1 image tokens use the same causal SWA as text, including in chunks."""
606 + from vllm.models.deepseek_v41.common.ops.cache_utils import (
607 + combine_topk_swa_indices as combine_v41,
608 + )
609 + from vllm.models.deepseek_v41.sparse_mla import DeepseekV41SparseSWAMetadataBuilder
610 +
611 + seq_len, window, max_image_tokens = 4000, 128, 2048
612 + spans = [(1900, 3947)]
613 + builder = make_builder(
614 + True,
615 + window,
616 + max_image_tokens,
617 + query_len,
618 + builder_cls=DeepseekV41SparseSWAMetadataBuilder,
619 + mm_prefix_clamp_sliding_window=False,
620 + )
621 + assert builder.max_image_tokens == 0
622 + md = build_metadata(builder, [seq_len], [query_len], {0: spans})
623 + assert md.prefill_left_visible is None
624 + assert md.prefill_right_visible is None
625 + assert md.prefill_swa_indices.shape[-1] == window
626 + plan = md.get_prefill_chunk_plan(
627 + compress_ratio, prefill_chunk_size=4, has_compressed=compress_ratio > 0
628 + )
629 + [(start, end, n, m)] = plan
630 + gather_len = int(md.prefill_gather_lens[0])
631 + assert (start, end, m - n) == (0, 1, gather_len)
632 + assert gather_len == query_len + min(seq_len - query_len, window - 1)
633 + indices, lens = combine_v41(
634 + torch.empty(query_len, 0, dtype=torch.int32, device="cuda"),
635 + md.query_start_loc,
636 + md.prefill_seq_lens,
637 + md.prefill_gather_lens,
638 + window,
639 + compress_ratio,
640 + 0,
641 + m,
642 + n,
643 + )
644 + indices, lens = indices.cpu(), lens.cpu().tolist()
645 + paged_indices = md.prefill_swa_indices.cpu()
646 + paged_lens = md.prefill_swa_lens.cpu().tolist()
647 + for token, pos in enumerate(range(seq_len - query_len, seq_len)):
648 + lo, hi = max(0, pos - window + 1), pos + 1
649 + row = indices[token, : lens[token]]
650 + assert lens[token] == hi - lo
651 + assert torch.all((row >= n) & (row < m))
652 + absolute_keys = row - n + seq_len - gather_len
653 + assert absolute_keys.tolist() == list(range(lo, hi))
654 + assert paged_lens[token] == hi - lo
655 + assert paged_indices[token, 0, : hi - lo].tolist() == list(range(lo, hi))
vllm/models/deepseek_v41/attention.py
-8
@@ -288,13 +288,6 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
288 self.n_groups = config.o_groups
289 self.n_local_groups = self.n_groups // tp_size
290 self.window_size = config.sliding_window
291 - # Vision variant: image spans are visible bidirectionally, widening
292 - # prefill SWA index rows by up to max_image_tokens columns.
293 - self.max_image_tokens = (
294 - getattr(config, "vision_max_n_token", 0)
295 - if getattr(config, "vision_n_layers", 0) > 0
296 - else 0
297 - )
291 # ---- v4.1 sparse-attention topology ----
292 # compress_ratios has one entry per layer (MTP layers included):
293 # 0 = pure sliding window, 1 = full-length compressed cache,
@@ -576,7 +569,6 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
569 _COMPUTE_SWA_INDICES_AND_LENS_KERNEL.register_warmup(
570 window_size=self.window_size,
571 block_size=self.swa_cache_layer.block_size,
579 - max_image_tokens=self.max_image_tokens,
572 )
573
574 if self.compress_ratio > 1:
vllm/models/deepseek_v41/common/ops/cache_utils.py
+31 -108
@@ -815,16 +815,12 @@ def combine_topk_swa_indices(
815 M: int,
816 N: int,
817 out: tuple[torch.Tensor, torch.Tensor] | None = None,
818 - left_visible: torch.Tensor | None = None,
819 - right_visible: torch.Tensor | None = None,
820 - max_image_tokens: int = 0,
818 ) -> tuple[torch.Tensor, torch.Tensor]:
819 num_tokens = topk_indices.shape[0]
823 - # max_image_tokens widens the SWA column region for in-image
824 - # bidirectional visibility; the width is fixed per model so the
825 - # caller-provided workspace stays valid on image-free batches.
820 + # The width is fixed per model so the caller-provided workspace stays
821 + # valid on every batch.
822 combined_topk = (
827 - (topk + window_size + max_image_tokens + _SPARSE_PREFILL_TOPK_ALIGNMENT - 1)
823 + (topk + window_size + _SPARSE_PREFILL_TOPK_ALIGNMENT - 1)
824 // _SPARSE_PREFILL_TOPK_ALIGNMENT
825 * _SPARSE_PREFILL_TOPK_ALIGNMENT
826 )
@@ -853,9 +849,6 @@ def combine_topk_swa_indices(
849 TOP_K=topk,
850 COMPRESS_RATIO=compress_ratio,
851 WINDOW_SIZE=window_size,
856 - left_visible=left_visible,
857 - right_visible=right_visible,
858 - max_image_tokens=max_image_tokens,
852 )
853 return combined_indices, combined_lens
854
@@ -924,7 +917,6 @@ class CombineTopkSwaIndicesKernel(
917 TOP_K: int
918 COMPRESS_RATIO: int
919 WINDOW_SIZE: int
927 - IMAGE_WIDTH: int
920 PADDED_TOP_K: int
921 input_variant: TritonPointerInputVariant
922
@@ -946,14 +938,11 @@ class CombineTopkSwaIndicesKernel(
938 query_start_loc_ptr,
939 seq_lens_ptr,
940 gather_lens_ptr,
949 - left_visible_ptr,
950 - right_visible_ptr,
941 M,
942 N,
943 TOP_K: tl.constexpr,
944 COMPRESS_RATIO: tl.constexpr,
945 WINDOW_SIZE: tl.constexpr,
956 - IMAGE_WIDTH: tl.constexpr,
946 PADDED_TOP_K: tl.constexpr,
947 ):
948 batch_idx = tl.program_id(0)
@@ -988,19 +977,8 @@ class CombineTopkSwaIndicesKernel(
977 # (integer div by constexpr 0 is UB and yields garbage, which
978 # turns the store index below negative).
979 topk_len = 0
991 - if IMAGE_WIDTH > 0:
992 - # In-image bidirectional visibility: the window starts up to
993 - # max(left - (window - 1), 0) positions earlier and extends
994 - # `right` positions past the query token (both 0 outside
995 - # image spans, reducing to the plain causal window).
996 - left = tl.load(left_visible_ptr + token_idx)
997 - right = tl.load(right_visible_ptr + token_idx)
998 - else:
999 - left = 0
1000 - right = 0
1001 - left_add = tl.maximum(left - (WINDOW_SIZE - 1), 0)
1002 - swa_start = tl.maximum(pos - (WINDOW_SIZE - 1) - left_add, 0)
1003 - swa_len = pos + right - swa_start + 1
980 + swa_start = tl.maximum(pos - (WINDOW_SIZE - 1), 0)
981 + swa_len = pos - swa_start + 1
982
983 offset = tl.arange(0, PADDED_TOP_K)
984 mask = offset < topk_len
@@ -1014,18 +992,17 @@ class CombineTopkSwaIndicesKernel(
992 mask=mask,
993 )
994 # Index into gathered buffer: N + (position - gather_start)
1017 - # For positions [swa_start, pos + right], the buffer indices are:
1018 - # [N + swa_start - gather_start, N + pos + right - gather_start]
1019 - for i in range(0, WINDOW_SIZE + IMAGE_WIDTH, WINDOW_SIZE):
1020 - swa_offset = i + tl.arange(0, WINDOW_SIZE)
1021 - tl.store(
1022 - combined_indices_ptr
1023 - + token_idx * combined_indices_stride
1024 - + topk_len
1025 - + swa_offset,
1026 - M * batch_idx + N + swa_start + swa_offset - gather_start,
1027 - mask=swa_offset < swa_len,
1028 - )
995 + # For positions [swa_start, pos], the buffer indices are:
996 + # [N + swa_start - gather_start, N + pos - gather_start]
997 + swa_offset = tl.arange(0, WINDOW_SIZE)
998 + tl.store(
999 + combined_indices_ptr
1000 + + token_idx * combined_indices_stride
1001 + + topk_len
1002 + + swa_offset,
1003 + M * batch_idx + N + swa_start + swa_offset - gather_start,
1004 + mask=swa_offset < swa_len,
1005 + )
1006
1007 combined_len = topk_len + swa_len
1008 tl.store(combined_lens_ptr + token_idx, combined_len)
@@ -1041,7 +1018,6 @@ class CombineTopkSwaIndicesKernel(
1018 topk: int,
1019 compress_ratio: int,
1020 WINDOW_SIZE: int,
1044 - image_width: int = 0,
1021 ) -> CompileKey:
1022 padded_topk = next_power_of_2(topk_width)
1023 input_variant = TritonPointerInputVariant.from_alignment(
@@ -1054,7 +1030,6 @@ class CombineTopkSwaIndicesKernel(
1030 TOP_K=topk,
1031 COMPRESS_RATIO=compress_ratio,
1032 WINDOW_SIZE=WINDOW_SIZE,
1057 - IMAGE_WIDTH=image_width,
1033 PADDED_TOP_K=padded_topk,
1034 input_variant=input_variant,
1035 )
@@ -1064,19 +1039,10 @@ class CombineTopkSwaIndicesKernel(
1039 return []
1040
1041 window_size = _hf_config_int(vllm_config, "sliding_window", 128)
1067 - image_width = (
1068 - _hf_config_int(vllm_config, "vision_max_n_token", 0)
1069 - if _hf_config_int(vllm_config, "vision_n_layers", 0) > 0
1070 - else 0
1071 - )
1072 - # Warm both the plain-window variant (batches without image spans)
1073 - # and the in-image bidirectional variant.
1074 - image_widths = [0, image_width] if image_width > 0 else [0]
1042 return self._trace_dispatch(self.dispatch)(
1043 _combine_topk_swa_warmup_inputs(vllm_config),
1044 _COMBINE_TOPK_SWA_POINTER_INPUTS,
1045 WINDOW_SIZE=window_size,
1079 - image_width=image_widths,
1046 )
1047
1048 def compile(self, compile_key: CompileKey) -> None:
@@ -1093,14 +1059,11 @@ class CombineTopkSwaIndicesKernel(
1059 input_variant.pointer("query_start_loc", torch.int32),
1060 input_variant.pointer("seq_lens", torch.int32),
1061 input_variant.pointer("gather_lens", torch.int32),
1096 - int32_ptr,
1097 - int32_ptr,
1062 1, # do not specialize M
1063 1, # do not specialize N
1064 TOP_K=compile_key.TOP_K,
1065 COMPRESS_RATIO=compile_key.COMPRESS_RATIO,
1066 WINDOW_SIZE=compile_key.WINDOW_SIZE,
1103 - IMAGE_WIDTH=compile_key.IMAGE_WIDTH,
1067 PADDED_TOP_K=compile_key.PADDED_TOP_K,
1068 grid=(1, _COMBINE_TOPK_SWA_NUM_WORKERS),
1069 )
@@ -1119,13 +1082,8 @@ class CombineTopkSwaIndicesKernel(
1082 TOP_K: int,
1083 COMPRESS_RATIO: int,
1084 WINDOW_SIZE: int,
1122 - left_visible: torch.Tensor | None = None,
1123 - right_visible: torch.Tensor | None = None,
1124 - max_image_tokens: int = 0,
1085 ) -> None:
1086 num_reqs = seq_lens.shape[0]
1127 - has_image = left_visible is not None
1128 - image_width = max_image_tokens if has_image else 0
1087 self.kernel[(num_reqs, _COMBINE_TOPK_SWA_NUM_WORKERS)](
1088 combined_indices,
1089 combined_indices.stride(0),
@@ -1135,14 +1093,11 @@ class CombineTopkSwaIndicesKernel(
1093 query_start_loc,
1094 seq_lens,
1095 gather_lens,
1138 - left_visible if has_image else topk_indices,
1139 - right_visible if has_image else topk_indices,
1096 M,
1097 N,
1098 TOP_K=TOP_K,
1099 COMPRESS_RATIO=COMPRESS_RATIO,
1100 WINDOW_SIZE=WINDOW_SIZE,
1145 - IMAGE_WIDTH=image_width,
1101 PADDED_TOP_K=next_power_of_2(topk_indices.shape[-1]),
1102 )
1103
@@ -1169,9 +1124,6 @@ def build_flashinfer_mixed_sparse_indices(
1124 decode_is_valid_token: torch.Tensor | None = None,
1125 swa_block_span: int | None = None,
1126 compressed_block_span: int | None = None,
1172 - prefill_left_visible: torch.Tensor | None = None,
1173 - prefill_right_visible: torch.Tensor | None = None,
1174 - max_image_tokens: int = 0,
1127 ) -> tuple[torch.Tensor, torch.Tensor]:
1128 """Build the FlashInfer DSV4 sparse-index matrix for decode-first batches.
1129
@@ -1181,21 +1133,11 @@ def build_flashinfer_mixed_sparse_indices(
1133 per token). Decode tokens read precomputed SWA/compressed indices; prefill
1134 tokens derive their SWA window from the position and translate local
1135 compressed indices to global slots via the block tables.
1184 -
1185 - When ``prefill_left_visible``/``prefill_right_visible`` are given (vision
1186 - variant), the SWA column region widens by ``max_image_tokens`` and prefill
1187 - tokens inside an image span get a bidirectionally widened window; decode
1188 - rows are padded with -1 across the extra columns.
1136 """
1137 assert decode_swa_indices.dtype == torch.int32
1138 assert decode_swa_indices.dim() == 2
1192 - swa_index_width = decode_swa_indices.shape[-1]
1193 - assert swa_index_width >= window_size
1194 - has_image = prefill_left_visible is not None
1195 - image_width = max_image_tokens if has_image else 0
1196 - swa_total_width = swa_index_width + image_width
1197 - if has_image:
1198 - assert prefill_right_visible is not None
1139 + swa_total_width = decode_swa_indices.shape[-1]
1140 + assert swa_total_width >= window_size
1141 if decode_compressed_topk_lens is not None:
1142 assert decode_compressed_topk_lens.dtype == torch.int32
1143 assert prefill_topk_indices.dtype == torch.int32
@@ -1254,7 +1196,7 @@ def build_flashinfer_mixed_sparse_indices(
1196 if num_tokens == 0:
1197 return sparse_indices, sparse_topk_lens
1198
1257 - window_block_size = triton.next_power_of_2(max(swa_index_width, 1))
1199 + window_block_size = triton.next_power_of_2(max(swa_total_width, 1))
1200 topk_block_size = triton.next_power_of_2(max(padded_topk, 1))
1201 max_block_size = max(window_block_size, topk_block_size)
1202 num_warps = 4 if max_block_size >= 256 else 1
@@ -1278,8 +1220,6 @@ def build_flashinfer_mixed_sparse_indices(
1220 decode_is_valid_token,
1221 prefill_topk_indices,
1222 prefill_topk_indices.stride(0),
1281 - prefill_left_visible if has_image else token_to_req_indices,
1282 - prefill_right_visible if has_image else token_to_req_indices,
1223 query_start_loc,
1224 seq_lens,
1225 token_to_req_indices,
@@ -1293,8 +1233,7 @@ def build_flashinfer_mixed_sparse_indices(
1233 compressed_span,
1234 NUM_DECODE_TOKENS=num_decode_tokens,
1235 WINDOW_SIZE=window_size,
1296 - SWA_INDEX_WIDTH=swa_index_width,
1297 - IMAGE_WIDTH=image_width,
1236 + SWA_INDEX_WIDTH=swa_total_width,
1237 COMPRESS_RATIO=compress_ratio,
1238 TOP_K=topk,
1239 PADDED_TOP_K=padded_topk,
@@ -1349,8 +1288,6 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1288 decode_is_valid_token_ptr,
1289 prefill_topk_indices_ptr,
1290 prefill_topk_stride,
1352 - left_visible_ptr,
1353 - right_visible_ptr,
1291 query_start_loc_ptr,
1292 seq_lens_ptr,
1293 token_to_req_indices_ptr,
@@ -1365,7 +1302,6 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1302 NUM_DECODE_TOKENS,
1303 WINDOW_SIZE: tl.constexpr,
1304 SWA_INDEX_WIDTH: tl.constexpr,
1368 - IMAGE_WIDTH: tl.constexpr,
1305 COMPRESS_RATIO: tl.constexpr,
1306 TOP_K: tl.constexpr,
1307 PADDED_TOP_K: tl.constexpr,
@@ -1377,18 +1313,14 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1313 TOPK_BLOCK_SIZE: tl.constexpr,
1314 ):
1315 token_idx = tl.program_id(0)
1380 - # Total SWA column count; > SWA_INDEX_WIDTH only for in-image
1381 - # bidirectional visibility (vision variant), where the extra columns are
1382 - # -1-padded for decode rows.
1383 - SWA_TOTAL_WIDTH: tl.constexpr = SWA_INDEX_WIDTH + IMAGE_WIDTH
1316
1317 if token_idx < NUM_DECODE_TOKENS:
1386 - for i in range(0, SWA_TOTAL_WIDTH, WINDOW_BLOCK_SIZE):
1318 + for i in range(0, SWA_INDEX_WIDTH, WINDOW_BLOCK_SIZE):
1319 offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1388 - mask = offset < SWA_TOTAL_WIDTH
1320 + mask = offset < SWA_INDEX_WIDTH
1321 values = tl.load(
1322 decode_swa_indices_ptr + token_idx * decode_swa_stride + offset,
1391 - mask=offset < SWA_INDEX_WIDTH,
1323 + mask=mask,
1324 other=-1,
1325 )
1326 values = _remap_flashinfer_index(values, swa_block_size, swa_block_span)
@@ -1431,7 +1363,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1363 tl.store(
1364 sparse_indices_ptr
1365 + token_idx * sparse_indices_stride
1434 - + SWA_TOTAL_WIDTH
1366 + + SWA_INDEX_WIDTH
1367 + offset,
1368 values,
1369 mask=mask,
@@ -1445,7 +1377,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1377 else:
1378 compressed_len = tl.full((), DECODE_COMPRESSED_TOPK, dtype=tl.int32)
1379
1448 - tl.store(sparse_topk_lens_ptr + token_idx, SWA_TOTAL_WIDTH + compressed_len)
1380 + tl.store(sparse_topk_lens_ptr + token_idx, SWA_INDEX_WIDTH + compressed_len)
1381 return
1382
1383 prefill_idx = token_idx - NUM_DECODE_TOKENS
@@ -1457,26 +1389,17 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1389 start_pos = seq_len - query_len
1390 token_idx_in_query = token_idx - query_start
1391 pos = start_pos + token_idx_in_query
1460 - if IMAGE_WIDTH > 0:
1461 - # In-image bidirectional visibility: window starts up to
1462 - # max(left - (window - 1), 0) earlier and extends `right` past pos.
1463 - left = tl.load(left_visible_ptr + token_idx)
1464 - right = tl.load(right_visible_ptr + token_idx)
1465 - else:
1466 - left = 0
1467 - right = 0
1468 - left_add = tl.maximum(left - (WINDOW_SIZE - 1), 0)
1469 - swa_start_pos = tl.maximum(pos - (WINDOW_SIZE - 1) - left_add, 0)
1470 - swa_len = pos + right - swa_start_pos + 1
1392 + swa_start_pos = tl.maximum(pos - (WINDOW_SIZE - 1), 0)
1393 + swa_len = pos - swa_start_pos + 1
1394 if COMPRESS_RATIO > 0:
1395 topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
1396 else:
1397 # SWA-only layers (ratio 0): no indexer topk; div by constexpr 0 is UB.
1398 topk_len = 0
1399
1477 - for i in range(0, SWA_TOTAL_WIDTH, WINDOW_BLOCK_SIZE):
1400 + for i in range(0, SWA_INDEX_WIDTH, WINDOW_BLOCK_SIZE):
1401 offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1479 - mask = offset < SWA_TOTAL_WIDTH
1402 + mask = offset < SWA_INDEX_WIDTH
1403 pos_offset = swa_start_pos + offset
1404 block_indices = pos_offset // swa_block_size
1405 block_numbers = tl.load(
@@ -1520,10 +1443,10 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1443 tl.store(
1444 sparse_indices_ptr
1445 + token_idx * sparse_indices_stride
1523 - + SWA_TOTAL_WIDTH
1446 + + SWA_INDEX_WIDTH
1447 + offset,
1448 slot_ids,
1449 mask=mask,
1450 )
1451
1529 - tl.store(sparse_topk_lens_ptr + token_idx, SWA_TOTAL_WIDTH + topk_len)
1452 + tl.store(sparse_topk_lens_ptr + token_idx, SWA_INDEX_WIDTH + topk_len)
vllm/models/deepseek_v41/nvidia/flash_mla_mega_attn.py
+2 -17
@@ -311,7 +311,7 @@ class DeepseekV4MegaAttnAttention(DeepseekV4FlashMLAAttention):
311 else:
312 assert self.topk_indices_buffer is not None
313 top_k = self.topk_indices_buffer.shape[-1]
314 - combined_topk = round_up(top_k + self.window_size + self.max_image_tokens, 128)
314 + combined_topk = round_up(top_k + self.window_size, 128)
315 current_workspace_manager().get_simultaneous(
316 ((self.PREFILL_CHUNK_SIZE, m, q.shape[-1]), torch.bfloat16),
317 ((self.max_num_batched_tokens, combined_topk), torch.int32),
@@ -411,7 +411,7 @@ class DeepseekV4MegaAttnAttention(DeepseekV4FlashMLAAttention):
411 )
412 assert chunk_plan, "prefill chunk plan must be non-empty when num_prefills > 0"
413 workspace_manager = current_workspace_manager()
414 - combined_topk = round_up(top_k + self.window_size + self.max_image_tokens, 128)
414 + combined_topk = round_up(top_k + self.window_size, 128)
415 for chunk_start, chunk_end, chunk_n, chunk_m in chunk_plan:
416 chunk_size = chunk_end - chunk_start
417 kv_ws, idx_ws, lens_ws = workspace_manager.get_simultaneous(
@@ -457,21 +457,6 @@ class DeepseekV4MegaAttnAttention(DeepseekV4FlashMLAAttention):
457 chunk_m,
458 chunk_n,
459 out=(idx_ws[: qe - qs], lens_ws[: qe - qs]),
460 - left_visible=(
461 - swa_metadata.prefill_left_visible[
462 - num_decode_tokens + qs : num_decode_tokens + qe
463 - ]
464 - if swa_metadata.prefill_left_visible is not None
465 - else None
466 - ),
467 - right_visible=(
468 - swa_metadata.prefill_right_visible[
469 - num_decode_tokens + qs : num_decode_tokens + qe
470 - ]
471 - if swa_metadata.prefill_right_visible is not None
472 - else None
473 - ),
474 - max_image_tokens=self.max_image_tokens,
460 )
461 chunk_out = _token_slice(out, token_base + qs, token_base + qe)
462 # Mega attention over the gathered non-paged bf16 KV (RoPE already
vllm/models/deepseek_v41/nvidia/flashinfer_sparse.py
-4
@@ -417,10 +417,6 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
417 decode_is_valid_token=decode_is_valid_token,
418 swa_block_span=swa_block_span,
419 compressed_block_span=compressed_block_span,
420 - prefill_left_visible=swa_metadata.prefill_left_visible,
421 - prefill_right_visible=swa_metadata.prefill_right_visible,
422 - # getattr for tests that bypass __init__ via object.__new__.
423 - max_image_tokens=getattr(self, "max_image_tokens", 0),
420 )
421 if swa_only:
422 swa_metadata.flashinfer_sparse_index_cache["swa_only"] = (
vllm/models/deepseek_v41/nvidia/flashmla.py
+2 -19
@@ -120,9 +120,7 @@ class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
120 else:
121 assert self.topk_indices_buffer is not None
122 top_k = self.topk_indices_buffer.shape[-1]
123 - combined_topk = round_up(
124 - top_k + self.window_size + self.max_image_tokens, 128
125 - )
123 + combined_topk = round_up(top_k + self.window_size, 128)
124 current_workspace_manager().get_simultaneous(
125 ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
126 ((self.max_num_batched_tokens, combined_topk), torch.int32),
@@ -303,7 +301,7 @@ class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
301 )
302 assert chunk_plan, "prefill chunk plan must be non-empty when num_prefills > 0"
303 workspace_manager = current_workspace_manager()
306 - combined_topk = round_up(top_k + self.window_size + self.max_image_tokens, 128)
304 + combined_topk = round_up(top_k + self.window_size, 128)
305 for chunk_start, chunk_end, chunk_N, chunk_M in chunk_plan:
306 chunk_size = chunk_end - chunk_start
307 workspace = workspace_manager.get_simultaneous(
@@ -361,21 +359,6 @@ class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
359 chunk_M,
360 chunk_N,
361 out=(combined_indices_out, combined_lens_out),
364 - left_visible=(
365 - swa_metadata.prefill_left_visible[
366 - num_decode_tokens + query_start : num_decode_tokens + query_end
367 - ]
368 - if swa_metadata.prefill_left_visible is not None
369 - else None
370 - ),
371 - right_visible=(
372 - swa_metadata.prefill_right_visible[
373 - num_decode_tokens + query_start : num_decode_tokens + query_end
374 - ]
375 - if swa_metadata.prefill_right_visible is not None
376 - else None
377 - ),
378 - max_image_tokens=self.max_image_tokens,
362 )
363 flash_mla_sparse_fwd(
364 q=q[query_start:query_end],
vllm/transformers_utils/configs/deepseek_v41.py
-8
@@ -60,11 +60,3 @@ class DeepseekV41Config(PretrainedConfig):
60 self.vision_max_n_token = vision_config.get("max_image_tokens", 1024)
61 self.vision_min_pixels = vision_config.get("min_pixels", 295936)
62 self.vision_max_wh_ratio = vision_config.get("max_wh_ratio")
63 - # The vision variant needs the mm-prefix plumbing (atomic image-span
64 - # prefill + sparse-SWA window widening); the base arch-config
65 - # convertor reads this config-level attribute.
66 - self.is_mm_prefix_lm = vision_n_layers > 0
67 - # The sparse-SWA index kernels widen the window within image spans
68 - # in-kernel, so mm-prefix ranges longer than sliding_window must be
69 - # kept (they are only consumed by those kernels).
70 - self.mm_prefix_clamp_sliding_window = vision_n_layers > 0
vllm/v1/attention/backends/mla/sparse_swa.py
+6 -4
@@ -452,13 +452,15 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
452 assert hasattr(hf_config, "sliding_window")
453 self.window_size = hf_config.sliding_window
454
455 - # Vision variant: image spans (up to vision_max_n_token tokens) are
455 + # V4 vision variant: image spans (up to vision_max_n_token tokens) are
456 # visible bidirectionally, so prefill index rows widen from
457 - # window_size to window_size + max_image_tokens. Text-only models keep
458 - # max_image_tokens == 0 and take the original code paths everywhere.
457 + # window_size to window_size + max_image_tokens. The V4 config sets
458 + # mm_prefix_clamp_sliding_window exactly for these in-kernel-widened
459 + # ranges; V4.1 image tokens use the plain causal window, and text-only
460 + # models keep max_image_tokens == 0 everywhere.
461 self.max_image_tokens = (
462 getattr(hf_config, "vision_max_n_token", 0)
461 - if getattr(hf_config, "vision_n_layers", 0) > 0
463 + if getattr(hf_config, "mm_prefix_clamp_sliding_window", False)
464 else 0
465 )
466 self.prefill_index_width = self.window_size + self.max_image_tokens