[New model][Multimodal] Add DeepSeek-V4-Flash-Vision-Exp support (#54566)

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

Isotr0py committed Sep 3, 2026 at 02:09 UTC 1356635d837c4ef002ec98c1a0296e7ff60be3c1
37 files changed +2918 -152
csrc/libtorch_stable/moe/moe_ops.h
+3 -1
@@ -28,7 +28,9 @@ void topk_softplus_sqrt(
28 const std::optional<torch::stable::Tensor>& correction_bias,
29 const std::optional<torch::stable::Tensor>& input_ids,
30 const std::optional<torch::stable::Tensor>& tid2eid,
31 - const std::optional<torch::stable::Tensor>& is_padding);
31 + const std::optional<torch::stable::Tensor>& is_padding,
32 + const std::optional<torch::stable::Tensor>& bias_vl,
33 + int64_t image_sentinel_lo);
34
35 void moe_sum(torch::stable::Tensor& input, torch::stable::Tensor& output,
36 std::optional<torch::stable::Tensor> topk_ids,
csrc/libtorch_stable/moe/topk_softplus_sqrt_kernels.cu
+172 -28
@@ -81,19 +81,92 @@ __launch_bounds__(128) __global__
81 int num_experts, float routed_scaling_factor,
82 const HashIndType* input_ids,
83 const HashIndType* tid2eid,
84 - const bool* is_padding) {
84 + const bool* is_padding, const float* bias_vl,
85 + int64_t image_sentinel_lo) {
86 const int warp = (blockIdx.x * blockDim.x + threadIdx.x) / 32;
87 const int lane = threadIdx.x % 32;
88 if (warp >= num_rows) return;
89 const int64_t token_id = load_index_as_int64(input_ids, warp);
90 const bool is_pad_row = is_padding != nullptr && is_padding[warp];
91 + // Image tokens carry five consecutive in-vocab sentinel ids starting at
92 + // image_sentinel_lo (0 = disabled); they skip the tid2eid lookup and
93 + // select experts by score + bias_vl instead. Ids above the sentinel block
94 + // are regular special tokens and must not match.
95 + const bool is_image = bias_vl != nullptr && image_sentinel_lo > 0 &&
96 + token_id >= image_sentinel_lo &&
97 + token_id < image_sentinel_lo + 5;
98
99 #if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
100 cudaGridDependencySynchronize();
101 #endif
102 int expert = 0;
103 float weight = 0.f;
96 - if (lane < 6 && !is_pad_row) {
104 + if (is_image && !is_pad_row) {
105 + // Top-6 by (sqrtsoftplus(x) + bias_vl) over the full row; the routing
106 + // weights are the raw sqrtsoftplus scores of the selected experts.
107 + constexpr int MAX_PER_LANE = 12; // supports num_experts <= 384
108 + const int per_lane = num_experts / 32;
109 + float vals[MAX_PER_LANE];
110 + float sels[MAX_PER_LANE];
111 + #pragma unroll
112 + for (int i = 0; i < MAX_PER_LANE; ++i) {
113 + if (i < per_lane) {
114 + const int e = lane + 32 * i;
115 + const float x = input[warp * num_experts + e];
116 + float v = sqrtf(fmaxf(x, 0.f) + __logf(1.f + __expf(-fabsf(x))));
117 + if (isnan(v)) {
118 + v = 0.f;
119 + }
120 + vals[i] = v;
121 + sels[i] = v + bias_vl[e];
122 + } else {
123 + vals[i] = 0.f;
124 + sels[i] = -INFINITY;
125 + }
126 + }
127 + int slot_expert[6];
128 + float slot_weight[6];
129 + #pragma unroll
130 + for (int j = 0; j < 6; ++j) {
131 + float best = -INFINITY;
132 + int best_e = num_experts;
133 + #pragma unroll
134 + for (int i = 0; i < MAX_PER_LANE; ++i) {
135 + if (sels[i] > best) {
136 + best = sels[i];
137 + best_e = lane + 32 * i;
138 + }
139 + }
140 + // Warp argmax reduce; ties go to the lower expert id.
141 + #pragma unroll
142 + for (int mask = 16; mask > 0; mask >>= 1) {
143 + const float other_best = VLLM_SHFL_XOR_SYNC(best, mask);
144 + const int other_e = VLLM_SHFL_XOR_SYNC(best_e, mask);
145 + if (other_best > best || (other_best == best && other_e < best_e)) {
146 + best = other_best;
147 + best_e = other_e;
148 + }
149 + }
150 + float w = 0.f;
151 + #pragma unroll
152 + for (int i = 0; i < MAX_PER_LANE; ++i) {
153 + if (lane + 32 * i == best_e) {
154 + w = vals[i];
155 + sels[i] = -INFINITY;
156 + }
157 + }
158 + #pragma unroll
159 + for (int mask = 16; mask > 0; mask >>= 1) {
160 + w += VLLM_SHFL_XOR_SYNC(w, mask);
161 + }
162 + slot_expert[j] = best_e;
163 + slot_weight[j] = w;
164 + }
165 + if (lane < 6) {
166 + expert = slot_expert[lane];
167 + weight = slot_weight[lane];
168 + }
169 + } else if (lane < 6 && !is_pad_row) {
170 // only load and calculate for 6 experts
171 expert = static_cast<int>(tid2eid[token_id * 6 + lane]);
172 const float x = input[warp * num_experts + expert];
@@ -127,7 +200,8 @@ void launchDsv4HashTopk(const float* input, float* output, OutIndType* indices,
200 double routed_scaling_factor,
201 const HashIndType* input_ids,
202 const HashIndType* tid2eid, cudaStream_t stream,
130 - const bool* is_padding) {
203 + const bool* is_padding, const float* bias_vl,
204 + int64_t image_sentinel_lo) {
205 if (num_rows == 0) return;
206 auto* kernel = &dsv4HashTopkSoftplusSqrt<OutIndType, HashIndType>;
207 cudaLaunchConfig_t config = {};
@@ -141,7 +215,8 @@ void launchDsv4HashTopk(const float* input, float* output, OutIndType* indices,
215 config.numAttrs = 1;
216 const float scale = static_cast<float>(routed_scaling_factor);
217 cudaLaunchKernelEx(&config, kernel, input, output, indices, num_rows,
144 - num_experts, scale, input_ids, tid2eid, is_padding);
218 + num_experts, scale, input_ids, tid2eid, is_padding,
219 + bias_vl, image_sentinel_lo);
220 }
221 #endif
222
@@ -174,7 +249,8 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__
249 const int start_expert, const int end_expert, const bool renormalize,
250 double routed_scaling_factor, const float* correction_bias,
251 const HashIndType* input_ids, const HashIndType* tid2eid,
177 - const bool* is_padding) {
252 + const bool* is_padding, const float* bias_vl,
253 + int64_t image_sentinel_lo) {
254 static_assert(std::is_same_v<InputType, float> ||
255 std::is_same_v<InputType, __nv_bfloat16> ||
256 std::is_same_v<InputType, __half>,
@@ -241,6 +317,19 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__
317 const bool row_is_active = finished ? !finished[thread_row] : true;
318 const bool is_pad_row = is_padding != nullptr && is_padding[thread_row];
319
320 + // Image tokens carry five consecutive in-vocab sentinel ids starting at
321 + // image_sentinel_lo (0 = disabled); they select experts with bias_vl
322 + // instead of correction_bias / the tid2eid hash table. Ids above the
323 + // sentinel block are regular special tokens and must not match.
324 + int64_t token_id = 0;
325 + if (input_ids != nullptr) {
326 + token_id = load_index_as_int64(input_ids, thread_row);
327 + }
328 + const bool use_vl_bias = bias_vl != nullptr && image_sentinel_lo > 0 &&
329 + token_id >= image_sentinel_lo &&
330 + token_id < image_sentinel_lo + 5;
331 + const float* row_bias = use_vl_bias ? bias_vl : correction_bias;
332 +
333 // We finally start setting up the read pointers for each thread. First, each
334 // thread jumps to the start of the row it will read.
335 const InputType* thread_row_ptr = input + thread_row * ELTS_PER_ROW;
@@ -323,9 +412,10 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__
412 constexpr float threshold = 20.0f;
413 constexpr float beta = 1.0f;
414
326 - // Hash MoE path: indices are predetermined from lookup table
327 - if constexpr (USE_HASH) {
328 - const int64_t token_id = load_index_as_int64(input_ids, thread_row);
415 + // Hash MoE path: indices are predetermined from lookup table. Image rows
416 + // (sentinel ids) fall through to the score-based top-k path with bias_vl.
417 + const bool hash_row = USE_HASH && !use_vl_bias;
418 + if (hash_row) {
419 const int64_t token_expert_offset = token_id * static_cast<int64_t>(k);
420 if (!is_pad_row) {
421 #pragma unroll
@@ -417,13 +507,13 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__
507 if (isnan(val)) {
508 val = 0.f;
509 }
420 - if (correction_bias) {
510 + if (row_bias) {
511 const int group_id = ii / ELTS_PER_LDG;
512 const int local_id = ii % ELTS_PER_LDG;
513 const int expert_idx = first_elt_read_by_thread +
514 group_id * THREADS_PER_ROW * ELTS_PER_LDG +
515 local_id;
426 - val = val + correction_bias[expert_idx];
516 + val = val + row_bias[expert_idx];
517 }
518 row_chunk[ii] = val;
519 }
@@ -488,8 +578,8 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__
578 // to global memory. (This will be a single) thread per row of the
579 // input/output matrices.
580 const int idx = k * thread_row + k_idx;
491 - if (correction_bias != nullptr && should_process_row) {
492 - max_val -= correction_bias[expert];
581 + if (row_bias != nullptr && should_process_row) {
582 + max_val -= row_bias[expert];
583 }
584 output[idx] = max_val;
585 indices[idx] =
@@ -576,7 +666,8 @@ void topkGatingSoftplusSqrtLauncherHelper(
666 const int start_expert, const int end_expert, const bool renormalize,
667 double routed_scaling_factor, const float* correction_bias,
668 const bool use_hash, const HashIndType* input_ids,
579 - const HashIndType* tid2eid, cudaStream_t stream, const bool* is_padding) {
669 + const HashIndType* tid2eid, cudaStream_t stream, const bool* is_padding,
670 + const float* bias_vl, int64_t image_sentinel_lo) {
671 static constexpr int BYTES_PER_LDG =
672 MIN(MAX_BYTES_PER_LDG, sizeof(InputType) * EXPERTS);
673 using Constants =
@@ -605,12 +696,13 @@ void topkGatingSoftplusSqrtLauncherHelper(
696 cudaLaunchKernelEx(&config, kernel, input, finished, output, num_rows,
697 indices, source_row, k, start_expert, end_expert,
698 renormalize, routed_scaling_factor, correction_bias,
608 - input_ids, tid2eid, is_padding);
699 + input_ids, tid2eid, is_padding, bias_vl,
700 + image_sentinel_lo);
701 #else
702 kernel<<<num_blocks, block_dim, 0, stream>>>(
703 input, finished, output, num_rows, indices, source_row, k, start_expert,
704 end_expert, renormalize, routed_scaling_factor, correction_bias,
613 - input_ids, tid2eid, is_padding);
705 + input_ids, tid2eid, is_padding, bias_vl, image_sentinel_lo);
706 #endif
707 })
708 }
@@ -624,7 +716,7 @@ void topkGatingSoftplusSqrtLauncherHelper(
716 gating_output, nullptr, topk_weights, topk_indices, \
717 token_expert_indices, num_tokens, topk, 0, num_experts, renormalize, \
718 routed_scaling_factor, correction_bias, use_hash, input_ids, tid2eid, \
627 - stream, is_padding);
719 + stream, is_padding, bias_vl, image_sentinel_lo);
720 #else
721 #define LAUNCH_SOFTPLUS_SQRT(NUM_EXPERTS, WARPS_PER_TB, MAX_BYTES) \
722 if (WARP_SIZE == 64) { \
@@ -633,14 +725,14 @@ void topkGatingSoftplusSqrtLauncherHelper(
725 gating_output, nullptr, topk_weights, topk_indices, \
726 token_expert_indices, num_tokens, topk, 0, num_experts, renormalize, \
727 routed_scaling_factor, correction_bias, use_hash, input_ids, \
636 - tid2eid, stream, is_padding); \
728 + tid2eid, stream, is_padding, bias_vl, image_sentinel_lo); \
729 } else if (WARP_SIZE == 32) { \
730 topkGatingSoftplusSqrtLauncherHelper<NUM_EXPERTS, WARPS_PER_TB, 32, \
731 MAX_BYTES>( \
732 gating_output, nullptr, topk_weights, topk_indices, \
733 token_expert_indices, num_tokens, topk, 0, num_experts, renormalize, \
734 routed_scaling_factor, correction_bias, use_hash, input_ids, \
643 - tid2eid, stream, is_padding); \
735 + tid2eid, stream, is_padding, bias_vl, image_sentinel_lo); \
736 } else { \
737 assert(false && \
738 "Unsupported warp size. Only 32 and 64 are supported for ROCm"); \
@@ -654,14 +746,16 @@ void topkGatingSoftplusSqrtKernelLauncher(
746 const int topk, const bool renormalize, double routed_scaling_factor,
747 const float* correction_bias, const bool use_hash,
748 const HashIndType* input_ids, const HashIndType* tid2eid,
657 - cudaStream_t stream, const bool* is_padding) {
749 + cudaStream_t stream, const bool* is_padding, const float* bias_vl,
750 + int64_t image_sentinel_lo) {
751 #ifndef USE_ROCM
752 if constexpr (std::is_same_v<InputType, float>) {
753 if (use_hash && topk == 6 && renormalize &&
754 (num_experts == 256 || num_experts == 384)) {
755 launchDsv4HashTopk<IndType, HashIndType>(
756 gating_output, topk_weights, topk_indices, num_tokens, num_experts,
664 - routed_scaling_factor, input_ids, tid2eid, stream, is_padding);
757 + routed_scaling_factor, input_ids, tid2eid, stream, is_padding,
758 + bias_vl, image_sentinel_lo);
759 return;
760 }
761 }
@@ -761,12 +855,30 @@ void dispatch_topk_softplus_sqrt_launch(
855 const std::optional<torch::stable::Tensor>& correction_bias,
856 const std::optional<torch::stable::Tensor>& input_ids,
857 const std::optional<torch::stable::Tensor>& tid2eid, cudaStream_t stream,
764 - const std::optional<torch::stable::Tensor>& is_padding) {
858 + const std::optional<torch::stable::Tensor>& is_padding,
859 + const std::optional<torch::stable::Tensor>& bias_vl,
860 + int64_t image_sentinel_lo) {
861 const float* bias_ptr = nullptr;
862 if (correction_bias.has_value()) {
863 bias_ptr = correction_bias.value().const_data_ptr<float>();
864 }
865
866 + const float* bias_vl_ptr = nullptr;
867 + if (bias_vl.has_value()) {
868 + const torch::stable::Tensor& bias_vl_tensor = bias_vl.value();
869 + STD_TORCH_CHECK(input_ids.has_value(),
870 + "input_ids is required when bias_vl is set");
871 + STD_TORCH_CHECK(
872 + bias_vl_tensor.scalar_type() == torch::headeronly::ScalarType::Float,
873 + "bias_vl tensor must be float32");
874 + STD_TORCH_CHECK(bias_vl_tensor.dim() == 1, "bias_vl tensor must be 1D");
875 + STD_TORCH_CHECK(bias_vl_tensor.size(0) == num_experts,
876 + "bias_vl size mismatch, expected: ", num_experts);
877 + STD_TORCH_CHECK(bias_vl_tensor.is_contiguous(),
878 + "bias_vl tensor must be contiguous");
879 + bias_vl_ptr = bias_vl_tensor.const_data_ptr<float>();
880 + }
881 +
882 auto launch = [&](auto* topk_indices_ptr) {
883 using OutIndType =
884 typename std::remove_pointer<decltype(topk_indices_ptr)>::type;
@@ -800,7 +912,8 @@ void dispatch_topk_softplus_sqrt_launch(
912 topk_indices_ptr, token_expert_indices.mutable_data_ptr<int>(),
913 num_tokens, num_experts, topk, renormalize, routed_scaling_factor,
914 bias_ptr, true, input_ids.value().const_data_ptr<int64_t>(),
803 - tid2eid.value().const_data_ptr<int64_t>(), stream, is_padding_ptr);
915 + tid2eid.value().const_data_ptr<int64_t>(), stream, is_padding_ptr,
916 + bias_vl_ptr, image_sentinel_lo);
917 } else {
918 STD_TORCH_CHECK(tid2eid.value().scalar_type() ==
919 torch::headeronly::ScalarType::Int);
@@ -810,7 +923,35 @@ void dispatch_topk_softplus_sqrt_launch(
923 topk_indices_ptr, token_expert_indices.mutable_data_ptr<int>(),
924 num_tokens, num_experts, topk, renormalize, routed_scaling_factor,
925 bias_ptr, true, input_ids.value().const_data_ptr<int>(),
813 - tid2eid.value().const_data_ptr<int>(), stream, is_padding_ptr);
926 + tid2eid.value().const_data_ptr<int>(), stream, is_padding_ptr,
927 + bias_vl_ptr, image_sentinel_lo);
928 + }
929 + } else if (bias_vl_ptr != nullptr) {
930 + // Non-hash rows with bias_vl: image tokens (five sentinel ids starting
931 + // at image_sentinel_lo) select experts by score + bias_vl. input_ids
932 + // drives the per-row branch, so
933 + // dispatch the hash-index type on its dtype.
934 + if (input_ids.value().scalar_type() ==
935 + torch::headeronly::ScalarType::Long) {
936 + vllm::moe::topkGatingSoftplusSqrtKernelLauncher<OutIndType, ComputeType,
937 + int64_t>(
938 + gating_output, topk_weights.mutable_data_ptr<float>(),
939 + topk_indices_ptr, token_expert_indices.mutable_data_ptr<int>(),
940 + num_tokens, num_experts, topk, renormalize, routed_scaling_factor,
941 + bias_ptr, false, input_ids.value().const_data_ptr<int64_t>(),
942 + static_cast<const int64_t*>(nullptr), stream, is_padding_ptr,
943 + bias_vl_ptr, image_sentinel_lo);
944 + } else {
945 + STD_TORCH_CHECK(input_ids.value().scalar_type() ==
946 + torch::headeronly::ScalarType::Int);
947 + vllm::moe::topkGatingSoftplusSqrtKernelLauncher<OutIndType, ComputeType,
948 + int>(
949 + gating_output, topk_weights.mutable_data_ptr<float>(),
950 + topk_indices_ptr, token_expert_indices.mutable_data_ptr<int>(),
951 + num_tokens, num_experts, topk, renormalize, routed_scaling_factor,
952 + bias_ptr, false, input_ids.value().const_data_ptr<int>(),
953 + static_cast<const int*>(nullptr), stream, is_padding_ptr,
954 + bias_vl_ptr, image_sentinel_lo);
955 }
956 } else {
957 vllm::moe::topkGatingSoftplusSqrtKernelLauncher<OutIndType, ComputeType>(
@@ -818,7 +959,8 @@ void dispatch_topk_softplus_sqrt_launch(
959 topk_indices_ptr, token_expert_indices.mutable_data_ptr<int>(),
960 num_tokens, num_experts, topk, renormalize, routed_scaling_factor,
961 bias_ptr, false, static_cast<const OutIndType*>(nullptr),
821 - static_cast<const OutIndType*>(nullptr), stream, is_padding_ptr);
962 + static_cast<const OutIndType*>(nullptr), stream, is_padding_ptr,
963 + nullptr, 0);
964 }
965 };
966
@@ -843,7 +985,9 @@ void topk_softplus_sqrt(
985 const std::optional<torch::stable::Tensor>& correction_bias,
986 const std::optional<torch::stable::Tensor>& input_ids,
987 const std::optional<torch::stable::Tensor>& tid2eid,
846 - const std::optional<torch::stable::Tensor>& is_padding) {
988 + const std::optional<torch::stable::Tensor>& is_padding,
989 + const std::optional<torch::stable::Tensor>& bias_vl,
990 + int64_t image_sentinel_lo) {
991 const int num_experts = gating_output.size(-1);
992 const auto num_tokens = gating_output.numel() / num_experts;
993 const int topk = topk_weights.size(-1);
@@ -857,21 +1001,21 @@ void topk_softplus_sqrt(
1001 gating_output.const_data_ptr<float>(), topk_weights, topk_indices,
1002 token_expert_indices, num_tokens, num_experts, topk, renormalize,
1003 routed_scaling_factor, correction_bias, input_ids, tid2eid, stream,
860 - is_padding);
1004 + is_padding, bias_vl, image_sentinel_lo);
1005 } else if (gating_output.scalar_type() ==
1006 torch::headeronly::ScalarType::Half) {
1007 dispatch_topk_softplus_sqrt_launch<__half>(
1008 reinterpret_cast<const __half*>(gating_output.const_data_ptr()),
1009 topk_weights, topk_indices, token_expert_indices, num_tokens,
1010 num_experts, topk, renormalize, routed_scaling_factor, correction_bias,
867 - input_ids, tid2eid, stream, is_padding);
1011 + input_ids, tid2eid, stream, is_padding, bias_vl, image_sentinel_lo);
1012 } else if (gating_output.scalar_type() ==
1013 torch::headeronly::ScalarType::BFloat16) {
1014 dispatch_topk_softplus_sqrt_launch<__nv_bfloat16>(
1015 reinterpret_cast<const __nv_bfloat16*>(gating_output.const_data_ptr()),
1016 topk_weights, topk_indices, token_expert_indices, num_tokens,
1017 num_experts, topk, renormalize, routed_scaling_factor, correction_bias,
874 - input_ids, tid2eid, stream, is_padding);
1018 + input_ids, tid2eid, stream, is_padding, bias_vl, image_sentinel_lo);
1019 } else {
1020 STD_TORCH_CHECK(false, "Unsupported gating_output data type: ",
1021 gating_output.scalar_type());
csrc/libtorch_stable/moe/torch_bindings.cpp
+2 -1
@@ -20,7 +20,8 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_moe_C, m) {
20 "topk_softplus_sqrt(Tensor! topk_weights, Tensor! topk_indices, Tensor! "
21 "token_expert_indices, Tensor gating_output, bool renormalize, float "
22 "routed_scaling_factor, Tensor? "
23 - "bias, Tensor? input_ids, Tensor? tid2eid, Tensor? is_padding) -> ()");
23 + "bias, Tensor? input_ids, Tensor? tid2eid, Tensor? is_padding, "
24 + "Tensor? bias_vl=None, int image_sentinel_lo=0) -> ()");
25
26 // Calculate the result of moe by summing up the partial results
27 // from all selected experts. topk_ids/expert_map are optional and, when
docs/models/supported_models.md
+1
@@ -528,6 +528,7 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen
528 | `DeepseekVLV2ForCausalLM` | DeepSeek-VL2 | T + I<sup>+</sup> | `deepseek-ai/deepseek-vl2-tiny`, `deepseek-ai/deepseek-vl2-small`, `deepseek-ai/deepseek-vl2`, etc. | | ✅︎ |
529 | `DeepseekOCRForCausalLM` | DeepSeek-OCR | T + I<sup>+</sup> | `deepseek-ai/DeepSeek-OCR`, etc. | ✅︎ | ✅︎ |
530 | `DeepseekOCR2ForCausalLM` | DeepSeek-OCR-2 | T + I<sup>+</sup> | `deepseek-ai/DeepSeek-OCR-2`, etc. | ✅︎ | ✅︎ |
531 +| `DeepseekV4ForConditionalGeneration` | DeepSeek-V4 (vision) | T + I<sup>+</sup> | `deepseek-ai/DeepSeek-V4-Flash-Vision-Exp` | | ✅︎ |
532 | `Eagle2_5_VLForConditionalGeneration` | Eagle2.5-VL | T + I<sup>E+</sup> | `nvidia/Eagle2.5-8B`, etc. | ✅︎ | ✅︎ |
533 | `Ernie4_5_VLMoeForConditionalGeneration` | Ernie4.5-VL | T + I<sup>+</sup>/ V<sup>+</sup> | `baidu/ERNIE-4.5-VL-28B-A3B-PT`, `baidu/ERNIE-4.5-VL-424B-A47B-PT` | | ✅︎ |
534 | `Exaone4_5_ForConditionalGeneration` | EXAONE-4.5 | T + I<sup>E+</sup> | `LGAI-EXAONE/EXAONE-4.5-33B`, etc. | ✅︎ | ✅︎ |
tests/config/test_model_arch_config.py
+31
@@ -465,3 +465,34 @@ def test_draft_model_arch_config(
465 _assert_model_config_methods(
466 model_config, expected, check_head_size=check_head_size
467 )
468 +
469 +
470 +def test_deepseek_v4_convertor_splits_vision_architecture():
471 + """The vision checkpoint shares model_type/architectures with the
472 + text-only DeepSeek-V4; the convertor routes it to the VL wrapper class
473 + by rewriting hf_config.architectures (model-class resolution reads the
474 + raw attribute)."""
475 + from vllm.transformers_utils.configs.deepseek_v4 import DeepseekV4Config
476 + from vllm.transformers_utils.model_arch_config_convertor import (
477 + DeepseekV4ModelArchConfigConvertor,
478 + )
479 +
480 + text_cfg = DeepseekV4Config(architectures=["DeepseekV4ForCausalLM"])
481 + conv = DeepseekV4ModelArchConfigConvertor(text_cfg, text_cfg)
482 + assert conv.get_architectures() == ["DeepseekV4ForCausalLM"]
483 + assert text_cfg.architectures == ["DeepseekV4ForCausalLM"]
484 +
485 + vl_cfg = DeepseekV4Config(
486 + architectures=["DeepseekV4ForCausalLM"], vision_n_layers=32
487 + )
488 + conv = DeepseekV4ModelArchConfigConvertor(vl_cfg, vl_cfg)
489 + assert conv.get_architectures() == ["DeepseekV4ForConditionalGeneration"]
490 + assert vl_cfg.architectures == ["DeepseekV4ForConditionalGeneration"]
491 +
492 + # Speculative-draft configs (set by SpeculativeConfig.hf_config_override)
493 + # keep their own architecture even with a vision tower in the config.
494 + for draft_arch in ("DSparkDraftModel", "DeepSeekV4MTPModel"):
495 + draft_cfg = DeepseekV4Config(architectures=[draft_arch], vision_n_layers=32)
496 + conv = DeepseekV4ModelArchConfigConvertor(draft_cfg, draft_cfg)
497 + assert conv.get_architectures() == [draft_arch]
498 + assert draft_cfg.architectures == [draft_arch]
tests/kernels/moe/test_topk_softplus_sqrt.py
+257 -5
@@ -26,18 +26,46 @@ def _torch_topk_softplus_sqrt(
26 e_score_correction_bias: torch.Tensor | None = None,
27 input_ids: torch.Tensor | None = None,
28 hash_indices_table: torch.Tensor | None = None,
29 + bias_vl: torch.Tensor | None = None,
30 + image_sentinel_lo: int = 0,
31 ):
32 scores = F.softplus(gating_output.float()).sqrt()
33 original_scores = scores
32 - if e_score_correction_bias is not None:
33 - scores_for_choice = scores + e_score_correction_bias.unsqueeze(0)
34 - else:
35 - scores_for_choice = scores
34
35 if hash_indices_table is not None:
36 assert input_ids is not None
39 - topk_ids = hash_indices_table[input_ids.long()]
37 + if bias_vl is None or image_sentinel_lo == 0:
38 + topk_ids = hash_indices_table[input_ids.long()]
39 + else:
40 + image_mask = (input_ids.long() >= image_sentinel_lo) & (
41 + input_ids.long() < image_sentinel_lo + 5
42 + )
43 + topk_ids = hash_indices_table[input_ids.long()]
44 + vl_ids = (scores + bias_vl).topk(topk, dim=-1).indices
45 + topk_ids = torch.where(
46 + image_mask.unsqueeze(-1), vl_ids.to(topk_ids.dtype), topk_ids
47 + )
48 else:
49 + if bias_vl is not None and image_sentinel_lo > 0:
50 + # Image tokens carry five consecutive in-vocab sentinel ids
51 + # starting at image_sentinel_lo and select experts with bias_vl
52 + # instead of the regular bias.
53 + assert input_ids is not None
54 + assert e_score_correction_bias is not None
55 + image_mask = (
56 + (input_ids.long() >= image_sentinel_lo)
57 + & (input_ids.long() < image_sentinel_lo + 5)
58 + ).unsqueeze(-1)
59 + row_bias = torch.where(
60 + image_mask,
61 + bias_vl.unsqueeze(0),
62 + e_score_correction_bias.unsqueeze(0),
63 + )
64 + scores_for_choice = scores + row_bias
65 + elif e_score_correction_bias is not None:
66 + scores_for_choice = scores + e_score_correction_bias.unsqueeze(0)
67 + else:
68 + scores_for_choice = scores
69 # Match the fused kernel's deterministic tie-break: lower expert ids
70 # win when scores are equal. torch.topk does not guarantee which tied
71 # index it selects at the k-th boundary.
@@ -368,3 +396,227 @@ def test_fused_topk_softplus_sqrt_padding(
396 sorted_w_ref = topk_weights_ref[rows_to_compare].gather(1, idx_ref)
397 sorted_w = topk_weights[rows_to_compare].gather(1, idx_ops)
398 torch.testing.assert_close(sorted_w_ref, sorted_w, atol=2e-2, rtol=1e-2)
399 +
400 +
401 +def _make_mixed_input_ids(
402 + num_tokens: int, image_sentinel_lo: int, dtype: torch.dtype = torch.long
403 +) -> torch.Tensor:
404 + """Mix of text, image-sentinel, and above-sentinel ids.
405 +
406 + Every third token is an image sentinel id (cycling through the five slots
407 + [image_sentinel_lo, image_sentinel_lo + 5)); every sixth token carries an
408 + id just above the sentinel block, simulating the named special tokens
409 + that follow it — those must route as regular text tokens.
410 + """
411 + input_ids = torch.randint(
412 + 0, image_sentinel_lo, (num_tokens,), dtype=torch.long, device="cuda"
413 + )
414 + pos = torch.arange(num_tokens, device="cuda")
415 + image_rows = pos % 3 == 0
416 + input_ids[image_rows] = image_sentinel_lo + (pos[image_rows] // 3) % 5
417 + above_rows = pos % 6 == 3
418 + input_ids[above_rows] = image_sentinel_lo + 5
419 + return input_ids.to(dtype)
420 +
421 +
422 +def _assert_topk_matches(
423 + topk_weights: torch.Tensor,
424 + topk_ids: torch.Tensor,
425 + topk_weights_ref: torch.Tensor,
426 + topk_ids_ref: torch.Tensor,
427 +) -> None:
428 + # Different kernels may return the topk experts in different orders when
429 + # scores tie; sort by expert id before comparing.
430 + sorted_ref_ids, idx_ref = topk_ids_ref.sort(dim=-1)
431 + sorted_ids, idx_ops = topk_ids.sort(dim=-1)
432 + torch.testing.assert_close(
433 + sorted_ref_ids, sorted_ids.to(sorted_ref_ids.dtype), atol=0, rtol=0
434 + )
435 +
436 + sorted_w_ref = topk_weights_ref.gather(1, idx_ref)
437 + sorted_w = topk_weights.gather(1, idx_ops.to(torch.long))
438 + torch.testing.assert_close(sorted_w_ref, sorted_w, atol=2e-2, rtol=1e-2)
439 +
440 +
441 +@pytest.mark.skipif(
442 + not current_platform.is_cuda_alike(),
443 + reason="This test is skipped on non-CUDA platform.",
444 +)
445 +@pytest.mark.parametrize(
446 + ("num_experts", "topk", "renormalize", "dtype"),
447 + [
448 + (256, 6, True, torch.float32), # Triton dsv4 fast path
449 + (256, 8, True, torch.bfloat16), # CUDA topk kernel
450 + (384, 6, False, torch.half),
451 + (512, 16, True, torch.float32),
452 + ],
453 +)
454 +def test_fused_topk_softplus_sqrt_bias_vl(
455 + num_experts: int,
456 + topk: int,
457 + renormalize: bool,
458 + dtype: torch.dtype,
459 +):
460 + """Image sentinel tokens must select experts with bias_vl.
461 +
462 + Rows with ids just above the sentinel block (image_sentinel_lo + 5)
463 + simulate the named special tokens that follow it and must route through
464 + the regular bias path; the reference comparison covers them.
465 + """
466 + torch.manual_seed(0)
467 + num_tokens = 64
468 + hidden_size = 1024
469 + vocab_size = 128
470 + image_sentinel_lo = vocab_size - 8
471 + hidden_states = torch.randn((num_tokens, hidden_size), dtype=dtype, device="cuda")
472 + gating_output = torch.randn((num_tokens, num_experts), dtype=dtype, device="cuda")
473 + e_score_correction_bias = torch.randn(
474 + (num_experts,), dtype=torch.float32, device="cuda"
475 + )
476 + bias_vl = torch.randn((num_experts,), dtype=torch.float32, device="cuda")
477 + input_ids = _make_mixed_input_ids(num_tokens, image_sentinel_lo)
478 + assert (input_ids == image_sentinel_lo + 5).any()
479 +
480 + topk_weights_ref, topk_ids_ref = _torch_topk_softplus_sqrt(
481 + gating_output=gating_output,
482 + topk=topk,
483 + renormalize=renormalize,
484 + routed_scaling_factor=1.5,
485 + e_score_correction_bias=e_score_correction_bias,
486 + input_ids=input_ids,
487 + bias_vl=bias_vl,
488 + image_sentinel_lo=image_sentinel_lo,
489 + )
490 +
491 + topk_weights, topk_ids = fused_topk_bias(
492 + hidden_states=hidden_states,
493 + gating_output=gating_output,
494 + scoring_func="sqrtsoftplus",
495 + e_score_correction_bias=e_score_correction_bias,
496 + topk=topk,
497 + renormalize=renormalize,
498 + routed_scaling_factor=1.5,
499 + input_tokens=input_ids,
500 + bias_vl=bias_vl,
501 + image_sentinel_lo=image_sentinel_lo,
502 + )
503 +
504 + _assert_topk_matches(topk_weights, topk_ids, topk_weights_ref, topk_ids_ref)
505 +
506 +
507 +@pytest.mark.skipif(
508 + not current_platform.is_cuda_alike(),
509 + reason="This test is skipped on non-CUDA platform.",
510 +)
511 +@pytest.mark.parametrize(
512 + ("num_experts", "topk", "renormalize", "dtype", "indices_dtype"),
513 + [
514 + (256, 6, True, torch.float32, torch.long), # specialized hash kernel
515 + (384, 6, True, torch.float32, torch.int32), # specialized hash kernel
516 + (384, 8, False, torch.bfloat16, torch.long), # generic hash kernel
517 + (512, 6, True, torch.float32, torch.long), # generic hash kernel
518 + ],
519 +)
520 +def test_fused_topk_softplus_sqrt_hash_bias_vl(
521 + num_experts: int,
522 + topk: int,
523 + renormalize: bool,
524 + dtype: torch.dtype,
525 + indices_dtype: torch.dtype,
526 +):
527 + """Hash MoE: text rows use tid2eid, image rows use topk(score + bias_vl).
528 +
529 + Sentinel ids are in-vocab; rows with ids above the sentinel block
530 + (image_sentinel_lo + 5) must still come straight from the hash table.
531 + """
532 + torch.manual_seed(0)
533 + num_tokens = 64
534 + hidden_size = 1024
535 + vocab_size = 64
536 + image_sentinel_lo = vocab_size - 8
537 + hidden_states = torch.randn((num_tokens, hidden_size), dtype=dtype, device="cuda")
538 + gating_output = torch.randn((num_tokens, num_experts), dtype=dtype, device="cuda")
539 + hash_indices_table = torch.stack(
540 + [torch.randperm(num_experts)[:topk] for _ in range(vocab_size)]
541 + ).to(device="cuda", dtype=indices_dtype)
542 + bias_vl = torch.randn((num_experts,), dtype=torch.float32, device="cuda")
543 + input_ids = _make_mixed_input_ids(num_tokens, image_sentinel_lo)
544 + image_rows = (input_ids >= image_sentinel_lo) & (input_ids < image_sentinel_lo + 5)
545 + assert image_rows.any() and (~image_rows).any()
546 + assert (input_ids == image_sentinel_lo + 5).any()
547 +
548 + topk_weights_ref, topk_ids_ref = _torch_topk_softplus_sqrt(
549 + gating_output=gating_output,
550 + topk=topk,
551 + renormalize=renormalize,
552 + routed_scaling_factor=2.5,
553 + input_ids=input_ids,
554 + hash_indices_table=hash_indices_table,
555 + bias_vl=bias_vl,
556 + image_sentinel_lo=image_sentinel_lo,
557 + )
558 +
559 + topk_weights, topk_ids = fused_topk_bias(
560 + hidden_states=hidden_states,
561 + gating_output=gating_output,
562 + scoring_func="sqrtsoftplus",
563 + e_score_correction_bias=None,
564 + topk=topk,
565 + renormalize=renormalize,
566 + input_tokens=input_ids,
567 + hash_indices_table=hash_indices_table,
568 + routed_scaling_factor=2.5,
569 + bias_vl=bias_vl,
570 + image_sentinel_lo=image_sentinel_lo,
571 + )
572 +
573 + _assert_topk_matches(topk_weights, topk_ids, topk_weights_ref, topk_ids_ref)
574 +
575 + # Text rows must come straight from the hash table, image rows from
576 + # topk(score + bias_vl).
577 + text_ids = hash_indices_table[input_ids[~image_rows].long()]
578 + torch.testing.assert_close(
579 + topk_ids[~image_rows].to(text_ids.dtype), text_ids, atol=0, rtol=0
580 + )
581 +
582 +
583 +@pytest.mark.skipif(
584 + not current_platform.is_cuda(),
585 + reason="The DeepSeek V4 fast path is CUDA-only.",
586 +)
587 +def test_dsv4_fast_topk_bias_vl():
588 + torch.manual_seed(0)
589 + num_tokens = 33
590 + num_experts = 256
591 + vocab_size = 128
592 + image_sentinel_lo = vocab_size - 8
593 + gating_output = torch.randn(
594 + (num_tokens, num_experts), dtype=torch.float32, device="cuda"
595 + )
596 + correction_bias = torch.randn(num_experts, dtype=torch.float32, device="cuda")
597 + bias_vl = torch.randn(num_experts, dtype=torch.float32, device="cuda")
598 + input_ids = _make_mixed_input_ids(num_tokens, image_sentinel_lo)
599 +
600 + topk_weights_ref, topk_ids_ref = _torch_topk_softplus_sqrt(
601 + gating_output=gating_output,
602 + topk=6,
603 + renormalize=True,
604 + routed_scaling_factor=1.5,
605 + e_score_correction_bias=correction_bias,
606 + input_ids=input_ids,
607 + bias_vl=bias_vl,
608 + image_sentinel_lo=image_sentinel_lo,
609 + )
610 + topk_weights, topk_ids = dsv4_topk(
611 + gating_output,
612 + correction_bias,
613 + torch.int64,
614 + 1.5,
615 + input_ids=input_ids,
616 + bias_vl=bias_vl,
617 + image_sentinel_lo=image_sentinel_lo,
618 + )
619 +
620 + assert topk_ids.dtype == torch.int64
621 + torch.testing.assert_close(topk_ids_ref.to(torch.int64), topk_ids, atol=0, rtol=0)
622 + torch.testing.assert_close(topk_weights_ref, topk_weights, atol=2e-5, rtol=2e-5)
tests/models/multimodal/processing/test_tensor_schema.py
+5
@@ -164,6 +164,11 @@ def test_model_tensor_schema(model_id: str):
164 "Kimi-K2.5's offline inference has issues about vision chunks. Fix later."
165 )
166
167 + if model_id == "deepseek-ai/DeepSeek-V4-Flash-Vision-Exp" and not (
168 + current_platform.is_cuda()
169 + ):
170 + pytest.skip("Deepseek V4 is only supported on CUDA")
171 +
172 model_info = HF_EXAMPLE_MODELS.find_hf_info(model_id)
173 model_info.check_available_online(on_fail="skip")
174 model_info.check_transformers_version(
tests/models/registry.py
+3
@@ -833,6 +833,9 @@ _MULTIMODAL_EXAMPLE_MODELS = {
833 "DeepseekOCR2ForCausalLM": _HfExamplesInfo(
834 "deepseek-ai/DeepSeek-OCR-2",
835 ),
836 + "DeepseekV4ForConditionalGeneration": _HfExamplesInfo(
837 + "deepseek-ai/DeepSeek-V4-Flash-Vision-Exp",
838 + ),
839 "Dots3NoteForCausalLM": _HfExamplesInfo(
840 "dots-studio/dots3-note-prev",
841 is_available_online=False,
tests/models/test_initialization.py
+6
@@ -132,6 +132,12 @@ def can_initialize(
132 f"capability {capability.major}.{capability.minor}"
133 )
134
135 + if model_arch == "DeepseekV4ForConditionalGeneration":
136 + from vllm.platforms import current_platform
137 +
138 + if not current_platform.is_cuda():
139 + pytest.skip("Deepseek V4 is only supported on CUDA")
140 +
141 with (
142 patch.object(V1EngineCore, "_initialize_kv_caches", _initialize_kv_caches_v1),
143 monkeypatch.context() as m,
tests/models/test_registry.py
+5
@@ -64,6 +64,11 @@ def test_registry_imports(model_arch):
64 ):
65 pytest.skip("HY V4 is only supported on CUDA")
66
67 + if model_arch == "DeepseekV4ForConditionalGeneration" and (
68 + current_platform.is_cuda()
69 + ):
70 + pytest.skip("Deepseek V4 is only supported on CUDA")
71 +
72 # Ensure all model classes can be imported successfully
73 model_cls = ModelRegistry._try_load_model_cls(model_arch)
74 assert model_cls is not None
tests/tokenizers_/test_deepseek_v4.py
+58
@@ -6,6 +6,7 @@ from pathlib import Path
6 from types import SimpleNamespace
7
8 import pytest
9 +import torch
10
11 from vllm.entrypoints.chat_utils import parse_chat_messages
12 from vllm.renderers.registry import RENDERER_REGISTRY
@@ -427,3 +428,60 @@ def test_deepseek_v4_encode_messages_rejects_invalid_arguments(kwargs):
428
429 with pytest.raises(ValueError):
430 encode_messages([{"role": "user", "content": "Hello"}], **kwargs)
431 +
432 +
433 +def test_deepseek_v4_image_blocks_become_placeholders():
434 + prompt = _tokenizer().apply_chat_template(
435 + [
436 + {
437 + "role": "user",
438 + "content": [
439 + {"type": "text", "text": "first:"},
440 + {"type": "image_url", "image_url": {"url": "file:///a.png"}},
441 + {"type": "text", "text": "second:"},
442 + {"type": "image", "source": {"data": "AAAA"}},
443 + ],
444 + }
445 + ],
446 + tokenize=False,
447 + thinking=False,
448 + )
449 +
450 + assert "<|User|>first:<|deepseek_image|>second:<|deepseek_image|>" in prompt
451 +
452 +
453 +def test_deepseek_v4_image_sentinel_ids_match_tokenizer():
454 + """The borrowed sentinel ids must line up with the reserved
455 + ``<|place_holder_mm_span_XXXX|>`` tokens in the tokenizer."""
456 + from vllm.models.deepseek_v4.common.mm_preprocess import (
457 + IMAGE_SENTINEL_BASE_ID,
458 + IMAGE_SENTINEL_TOKEN_NAMES,
459 + image_sentinel_mask,
460 + validate_image_sentinel_ids,
461 + )
462 +
463 + class FakeTokenizer:
464 + def __init__(self, offset: int = 0) -> None:
465 + self.offset = offset
466 +
467 + def convert_tokens_to_ids(self, token: str) -> int:
468 + return (
469 + IMAGE_SENTINEL_BASE_ID
470 + + self.offset
471 + + (IMAGE_SENTINEL_TOKEN_NAMES.index(token))
472 + )
473 +
474 + validate_image_sentinel_ids(FakeTokenizer()) # no raise
475 + with pytest.raises(ValueError, match="sentinel"):
476 + validate_image_sentinel_ids(FakeTokenizer(offset=1))
477 +
478 + ids = torch.tensor(
479 + [
480 + 1,
481 + IMAGE_SENTINEL_BASE_ID,
482 + IMAGE_SENTINEL_BASE_ID + 4,
483 + IMAGE_SENTINEL_BASE_ID + 5,
484 + 129264,
485 + ]
486 + )
487 + assert image_sentinel_mask(ids).tolist() == [False, True, True, False, False]
tests/v1/attention/test_deepseek_v4_swa_visible.py new
+580
@@ -0,0 +1,580 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for DeepSeek-V4 in-image bidirectional SWA visibility (vision variant).
4 +
5 +The reference semantics are a torch port of the official
6 +``get_image_visible`` / ``get_window_topk_idxs_visible``: inside an image span
7 +[span_start, span_end] (inclusive), a token at ``pos`` sees
8 +``min(pos - span_start, max_image_tokens - 1)`` extra tokens to the left and
9 +``min(span_end - pos, max_image_tokens)`` to the right; the window then starts
10 +at ``max(pos - (window - 1) - max(left - (window - 1), 0), 0)`` and ends at
11 +``pos + right`` (inclusive).
12 +"""
13 +
14 +import pytest
15 +import torch
16 +from typing_extensions import TypedDict
17 +
18 +from tests.v1.attention.utils import create_vllm_config
19 +from vllm.models.deepseek_v4.common.ops.cache_utils import (
20 + build_flashinfer_mixed_sparse_indices,
21 + combine_topk_swa_indices,
22 +)
23 +from vllm.v1.attention.backend import CommonAttentionMetadata
24 +from vllm.v1.attention.backends.mla.sparse_swa import (
25 + DeepseekSparseSWAMetadataBuilder,
26 + _compute_image_visibility_kernel,
27 + _compute_swa_indices_and_lens_kernel,
28 +)
29 +from vllm.v1.kv_cache_interface import SlidingWindowMLASpec
30 +
31 +WINDOW = 8
32 +MAX_IMG = 6
33 +WIDTH = WINDOW + MAX_IMG
34 +BLOCK_SIZE = 64
35 +
36 +
37 +def ref_left_right(
38 + seq_lens: list[int],
39 + query_lens: list[int],
40 + spans_per_req: list[list[tuple[int, int]]],
41 + max_image_tokens: int,
42 +) -> tuple[list[int], list[int]]:
43 + """Per-token (left, right) for the flattened decode-first token stream."""
44 + lefts: list[int] = []
45 + rights: list[int] = []
46 + for seq_len, query_len, spans in zip(seq_lens, query_lens, spans_per_req):
47 + prefix_len = seq_len - query_len
48 + for i in range(query_len):
49 + pos = prefix_len + i
50 + left = right = 0
51 + for span_start, span_end in spans:
52 + if span_start <= pos <= span_end:
53 + left = min(pos - span_start, max_image_tokens - 1)
54 + right = min(span_end - pos, max_image_tokens)
55 + lefts.append(left)
56 + rights.append(right)
57 + return lefts, rights
58 +
59 +
60 +def ref_swa_bounds(pos: int, window: int, left: int, right: int) -> tuple[int, int]:
61 + """Reference [start, end) window bounds for one query token."""
62 + left_add = max(left - (window - 1), 0)
63 + start = max(pos - (window - 1) - left_add, 0)
64 + return start, pos + right + 1
65 +
66 +
67 +def make_batch(
68 + seq_lens: list[int],
69 + query_lens: list[int],
70 + device: torch.device,
71 +):
72 + """Build the kernel inputs for a decode-first batch."""
73 + num_reqs = len(seq_lens)
74 + query_start_loc = torch.zeros(num_reqs + 1, dtype=torch.int32, device=device)
75 + query_start_loc[1:] = torch.tensor(
76 + query_lens, dtype=torch.int32, device=device
77 + ).cumsum(0)
78 + num_tokens = int(query_start_loc[-1])
79 + seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device=device)
80 + token_to_req = torch.repeat_interleave(
81 + torch.arange(num_reqs, dtype=torch.int32, device=device),
82 + torch.tensor(query_lens, dtype=torch.int32, device=device),
83 + )
84 + max_blocks = (max(seq_lens) + BLOCK_SIZE - 1) // BLOCK_SIZE
85 + block_table = torch.arange(
86 + num_reqs * max_blocks, dtype=torch.int32, device=device
87 + ).view(num_reqs, max_blocks)
88 + slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
89 + return query_start_loc, seq_lens_t, token_to_req, slot_mapping, block_table
90 +
91 +
92 +def ref_swa_slot_rows(
93 + seq_lens: list[int],
94 + query_lens: list[int],
95 + spans_per_req: list[list[tuple[int, int]]],
96 + block_table: torch.Tensor,
97 + window: int,
98 + max_image_tokens: int,
99 + width: int,
100 +) -> tuple[list[list[int]], list[int]]:
101 + """Reference paged slot-id rows and lens for every token in the batch."""
102 + block_table_cpu = block_table.cpu()
103 + lefts, rights = ref_left_right(
104 + seq_lens, query_lens, spans_per_req, max_image_tokens
105 + )
106 + rows: list[list[int]] = []
107 + lens: list[int] = []
108 + token = 0
109 + for req, (seq_len, query_len) in enumerate(zip(seq_lens, query_lens)):
110 + prefix_len = seq_len - query_len
111 + for i in range(query_len):
112 + pos = prefix_len + i
113 + start, end = ref_swa_bounds(pos, window, lefts[token], rights[token])
114 + row = []
115 + for p in range(start, end):
116 + blk = int(block_table_cpu[req, p // BLOCK_SIZE])
117 + row.append(blk * BLOCK_SIZE + p % BLOCK_SIZE)
118 + lens.append(len(row))
119 + row.extend([-1] * (width - len(row)))
120 + rows.append(row)
121 + token += 1
122 + return rows, lens
123 +
124 +
125 +def run_swa_kernel(
126 + seq_lens: list[int],
127 + query_lens: list[int],
128 + spans_per_req: list[list[tuple[int, int]]],
129 + window: int = WINDOW,
130 + max_image_tokens: int = MAX_IMG,
131 + with_image: bool = True,
132 +) -> tuple[torch.Tensor, torch.Tensor]:
133 + device = torch.device("cuda")
134 + query_start_loc, seq_lens_t, token_to_req, slot_mapping, block_table = make_batch(
135 + seq_lens, query_lens, device
136 + )
137 + num_tokens = int(query_start_loc[-1])
138 + width = window + (max_image_tokens if with_image else 0)
139 + swa_indices = torch.zeros(num_tokens, 1, width, dtype=torch.int32, device=device)
140 + swa_lens = torch.zeros(num_tokens, dtype=torch.int32, device=device)
141 + is_valid = slot_mapping >= 0
142 +
143 + if with_image:
144 + lefts, rights = ref_left_right(
145 + seq_lens, query_lens, spans_per_req, max_image_tokens
146 + )
147 + left_t = torch.tensor(lefts, dtype=torch.int32, device=device)
148 + right_t = torch.tensor(rights, dtype=torch.int32, device=device)
149 + else:
150 + left_t = right_t = swa_lens # unused dummies
151 +
152 + _compute_swa_indices_and_lens_kernel[(num_tokens,)](
153 + swa_indices,
154 + swa_indices.stride(0),
155 + swa_lens,
156 + window,
157 + width,
158 + left_t,
159 + right_t,
160 + query_start_loc,
161 + seq_lens_t,
162 + token_to_req,
163 + is_valid,
164 + block_table,
165 + block_table.stride(0),
166 + BLOCK_SIZE,
167 + token_offset=0,
168 + HAS_IMAGE=with_image,
169 + TRITON_BLOCK_SIZE=1024,
170 + )
171 + return swa_indices[:, 0], swa_lens
172 +
173 +
174 +# seq_lens, query_lens, spans per request (positions are prompt-absolute,
175 +# inclusive on both ends, matching the reference's sentinel-bracketed spans).
176 +class _Case(TypedDict):
177 + seq_lens: list[int]
178 + query_lens: list[int]
179 + spans: list[list[tuple[int, int]]]
180 +
181 +
182 +CASES: list[_Case] = [
183 + # two image spans in one request, one pure-text request
184 + {
185 + "seq_lens": [30, 12],
186 + "query_lens": [30, 12],
187 + "spans": [[(4, 12), (20, 24)], []],
188 + },
189 + # span larger than max_image_tokens, straddling the window boundary
190 + {
191 + "seq_lens": [26],
192 + "query_lens": [26],
193 + "spans": [[(2, 17)]],
194 + },
195 + # chunked prefill: span fully inside the second chunk's query
196 + {
197 + "seq_lens": [40, 9],
198 + "query_lens": [16, 9],
199 + "spans": [[(30, 38)], []],
200 + },
201 + # span ending exactly at the prompt end; tiny request
202 + {
203 + "seq_lens": [7, 15],
204 + "query_lens": [7, 15],
205 + "spans": [[(0, 6)], [(10, 14)]],
206 + },
207 +]
208 +
209 +
210 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
211 +@pytest.mark.parametrize("case", CASES)
212 +def test_swa_indices_kernel_with_image_spans(case):
213 + rows, lens = ref_swa_slot_rows(
214 + case["seq_lens"],
215 + case["query_lens"],
216 + case["spans"],
217 + make_batch(case["seq_lens"], case["query_lens"], torch.device("cuda"))[4],
218 + WINDOW,
219 + MAX_IMG,
220 + WIDTH,
221 + )
222 + indices, actual_lens = run_swa_kernel(
223 + case["seq_lens"], case["query_lens"], case["spans"], with_image=True
224 + )
225 + assert actual_lens.cpu().tolist() == lens
226 + assert indices.cpu().tolist() == rows
227 +
228 +
229 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
230 +@pytest.mark.parametrize("case", CASES)
231 +def test_swa_indices_kernel_without_image_unchanged(case):
232 + """HAS_IMAGE=False must reproduce the plain causal sliding window."""
233 + rows, lens = ref_swa_slot_rows(
234 + case["seq_lens"],
235 + case["query_lens"],
236 + [[] for _ in case["seq_lens"]],
237 + make_batch(case["seq_lens"], case["query_lens"], torch.device("cuda"))[4],
238 + WINDOW,
239 + MAX_IMG,
240 + WINDOW,
241 + )
242 + indices, actual_lens = run_swa_kernel(
243 + case["seq_lens"], case["query_lens"], case["spans"], with_image=False
244 + )
245 + assert indices.shape[-1] == WINDOW
246 + assert actual_lens.cpu().tolist() == lens
247 + assert indices.cpu().tolist() == rows
248 +
249 +
250 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
251 +def test_image_visibility_kernel():
252 + """The builder's visibility kernel must match get_image_visible."""
253 + device = torch.device("cuda")
254 + seq_lens = [30, 12]
255 + query_lens = [30, 12]
256 + spans = [[(4, 12), (20, 24)], []]
257 + query_start_loc, seq_lens_t, token_to_req, _, _ = make_batch(
258 + seq_lens, query_lens, device
259 + )
260 + num_tokens = int(query_start_loc[-1])
261 +
262 + # CSR span layout: request -> contiguous [start, end) span list.
263 + indptr = [0, 2, 2]
264 + starts = [4, 20]
265 + ends = [12, 24]
266 + left = torch.zeros(num_tokens, dtype=torch.int32, device=device)
267 + right = torch.zeros(num_tokens, dtype=torch.int32, device=device)
268 + _compute_image_visibility_kernel[(num_tokens,)](
269 + left,
270 + right,
271 + torch.tensor(indptr, dtype=torch.int32, device=device),
272 + torch.tensor(starts, dtype=torch.int32, device=device),
273 + torch.tensor(ends, dtype=torch.int32, device=device),
274 + query_start_loc,
275 + seq_lens_t,
276 + token_to_req,
277 + MAX_IMG,
278 + token_offset=0,
279 + )
280 + ref_left, ref_right = ref_left_right(seq_lens, query_lens, spans, MAX_IMG)
281 + assert left.cpu().tolist() == ref_left
282 + assert right.cpu().tolist() == ref_right
283 +
284 +
285 +def combine_case(
286 + compress_ratio: int,
287 + topk: int,
288 + seq_lens: list[int],
289 + query_lens: list[int],
290 + spans: list[list[tuple[int, int]]],
291 + with_image: bool,
292 +):
293 + """Run combine_topk_swa_indices and return (indices, lens, expected)."""
294 + device = torch.device("cuda")
295 + num_reqs = len(seq_lens)
296 + query_start_loc = torch.zeros(num_reqs + 1, dtype=torch.int32, device=device)
297 + query_start_loc[1:] = torch.tensor(
298 + query_lens, dtype=torch.int32, device=device
299 + ).cumsum(0)
300 + num_tokens = int(query_start_loc[-1])
301 + seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device=device)
302 + gather_lens = torch.tensor(
303 + [q + min(s - q, WINDOW - 1) for s, q in zip(seq_lens, query_lens)],
304 + dtype=torch.int32,
305 + device=device,
306 + )
307 + N = (max(seq_lens) + compress_ratio - 1) // compress_ratio
308 + M = N + int(gather_lens.max()) + 8
309 + gen = torch.Generator(device="cpu").manual_seed(0)
310 + topk_indices = torch.randint(
311 + 0, 4096, (num_tokens, max(topk, 1)), generator=gen, dtype=torch.int32
312 + ).to(device)
313 + topk_indices = topk_indices[:, : max(topk, 1)]
314 +
315 + if with_image:
316 + lefts, rights = ref_left_right(seq_lens, query_lens, spans, MAX_IMG)
317 + left_t = torch.tensor(lefts, dtype=torch.int32, device=device)
318 + right_t = torch.tensor(rights, dtype=torch.int32, device=device)
319 + else:
320 + left_t = right_t = None
321 +
322 + combined_indices, combined_lens = combine_topk_swa_indices(
323 + topk_indices,
324 + query_start_loc,
325 + seq_lens_t,
326 + gather_lens,
327 + WINDOW,
328 + compress_ratio,
329 + topk,
330 + M,
331 + N,
332 + left_visible=left_t,
333 + right_visible=right_t,
334 + max_image_tokens=MAX_IMG,
335 + )
336 +
337 + # Reference rows.
338 + lefts, rights = ref_left_right(
339 + seq_lens,
340 + query_lens,
341 + spans if with_image else [[] for _ in seq_lens],
342 + MAX_IMG,
343 + )
344 + topk_cpu = topk_indices.cpu()
345 + width = WINDOW + MAX_IMG
346 + combined_topk = (topk + width + 127) // 128 * 128
347 + rows = []
348 + lens = []
349 + token = 0
350 + for b, (seq_len, query_len) in enumerate(zip(seq_lens, query_lens)):
351 + prefix_len = seq_len - query_len
352 + gather_start = seq_len - int(gather_lens[b])
353 + for i in range(query_len):
354 + pos = prefix_len + i
355 + topk_len = min((pos + 1) // compress_ratio, topk)
356 + start, end = ref_swa_bounds(pos, WINDOW, lefts[token], rights[token])
357 + swa_len = end - start
358 + row = [-1] * combined_topk
359 + for j in range(topk_len):
360 + row[j] = int(topk_cpu[token, j]) + M * b
361 + for j in range(swa_len):
362 + row[topk_len + j] = M * b + N + start + j - gather_start
363 + rows.append(row)
364 + lens.append(topk_len + swa_len)
365 + token += 1
366 + return combined_indices, combined_lens, rows, lens
367 +
368 +
369 +COMBINE_CASES = [
370 + dict(compress_ratio=1, topk=0), # SWA-only layer
371 + dict(compress_ratio=4, topk=16), # C4A layer
372 +]
373 +
374 +
375 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
376 +@pytest.mark.parametrize("cfg", COMBINE_CASES)
377 +def test_combine_topk_swa_with_image_spans(cfg):
378 + case = CASES[0]
379 + indices, lens, rows, exp_lens = combine_case(
380 + cfg["compress_ratio"],
381 + cfg["topk"],
382 + case["seq_lens"],
383 + case["query_lens"],
384 + case["spans"],
385 + with_image=True,
386 + )
387 + assert lens.cpu().tolist() == exp_lens
388 + assert indices.cpu().tolist() == rows
389 +
390 +
391 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
392 +@pytest.mark.parametrize("cfg", COMBINE_CASES)
393 +def test_combine_topk_swa_without_image_unchanged(cfg):
394 + """left_visible=None must reproduce the plain causal combined indices."""
395 + case = CASES[0]
396 + indices, lens, rows, exp_lens = combine_case(
397 + cfg["compress_ratio"],
398 + cfg["topk"],
399 + case["seq_lens"],
400 + case["query_lens"],
401 + case["spans"],
402 + with_image=False,
403 + )
404 + assert lens.cpu().tolist() == exp_lens
405 + assert indices.cpu().tolist() == rows
406 +
407 +
408 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
409 +def test_flashinfer_mixed_sparse_indices_with_image_spans():
410 + device = torch.device("cuda")
411 + # 1 decode token (req 0) + two prefill requests (reqs 1, 2).
412 + seq_lens = [20, 30, 9]
413 + query_lens = [1, 30, 9]
414 + spans = [[], [(4, 12)], [(0, 6)]]
415 + query_start_loc = torch.tensor([0, 1, 31, 40], dtype=torch.int32, device=device)
416 + seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device=device)
417 + token_to_req = torch.tensor(
418 + [0] + [1] * 30 + [2] * 9, dtype=torch.int32, device=device
419 + )
420 + block_table = torch.arange(3, dtype=torch.int32, device=device).view(3, 1)
421 + decode_swa = torch.tensor(
422 + [[100, 101, 102, 103, 104, 105, 106, 107]], dtype=torch.int32, device=device
423 + )
424 + prefill_topk = torch.zeros(39, 0, dtype=torch.int32, device=device)
425 +
426 + lefts, rights = ref_left_right(seq_lens, query_lens, spans, MAX_IMG)
427 + left_t = torch.tensor(lefts, dtype=torch.int32, device=device)
428 + right_t = torch.tensor(rights, dtype=torch.int32, device=device)
429 +
430 + sparse_indices, sparse_lens = build_flashinfer_mixed_sparse_indices(
431 + decode_swa_indices=decode_swa,
432 + decode_compressed_indices=None,
433 + decode_compressed_topk_lens=None,
434 + prefill_topk_indices=prefill_topk,
435 + query_start_loc=query_start_loc,
436 + seq_lens=seq_lens_t,
437 + token_to_req_indices=token_to_req,
438 + swa_block_table=block_table,
439 + swa_block_size=BLOCK_SIZE,
440 + compressed_block_table=None,
441 + compressed_block_size=BLOCK_SIZE,
442 + window_size=WINDOW,
443 + compress_ratio=1,
444 + topk=0,
445 + prefill_left_visible=left_t,
446 + prefill_right_visible=right_t,
447 + max_image_tokens=MAX_IMG,
448 + )
449 +
450 + swa_total = WINDOW + MAX_IMG
451 + assert sparse_indices.shape == (40, swa_total)
452 + assert sparse_lens.cpu().tolist() == [swa_total] * 40
453 + # Decode row: slots copied, image-extension columns padded with -1.
454 + decode_row = sparse_indices[0].cpu().tolist()
455 + assert decode_row[:WINDOW] == list(range(100, 108))
456 + assert decode_row[WINDOW:] == [-1] * MAX_IMG
457 + # Prefill rows: paged slot ids over the widened window.
458 + exp_rows, _ = ref_swa_slot_rows(
459 + seq_lens, query_lens, spans, block_table, WINDOW, MAX_IMG, swa_total
460 + )
461 + actual = sparse_indices[1:].cpu().tolist()
462 + assert actual == exp_rows[1:]
463 +
464 +
465 +def make_builder(vision: bool) -> DeepseekSparseSWAMetadataBuilder:
466 + overrides: dict = {"sliding_window": WINDOW}
467 + if vision:
468 + overrides.update(vision_n_layers=2, vision_max_n_token=MAX_IMG)
469 + vllm_config = create_vllm_config(
470 + max_model_len=4096,
471 + max_num_batched_tokens=64,
472 + max_num_seqs=8,
473 + hf_config_override=overrides,
474 + )
475 + spec = SlidingWindowMLASpec(
476 + block_size=BLOCK_SIZE,
477 + num_kv_heads=1,
478 + head_size=512,
479 + dtype=torch.bfloat16,
480 + sliding_window=WINDOW,
481 + cache_dtype_str="auto",
482 + model_version="deepseek_v4",
483 + )
484 + return DeepseekSparseSWAMetadataBuilder(
485 + kv_cache_spec=spec,
486 + layer_names=["layer0"],
487 + vllm_config=vllm_config,
488 + device=torch.device("cuda"),
489 + )
490 +
491 +
492 +def build_metadata(
493 + builder: DeepseekSparseSWAMetadataBuilder,
494 + seq_lens: list[int],
495 + query_lens: list[int],
496 + mm_req_doc_ranges: dict[int, list[tuple[int, int]]] | None,
497 +):
498 + device = torch.device("cuda")
499 + query_start_loc, seq_lens_t, _, slot_mapping, block_table = make_batch(
500 + seq_lens, query_lens, device
501 + )
502 + return builder.build(
503 + 0,
504 + CommonAttentionMetadata(
505 + query_start_loc=query_start_loc,
506 + query_start_loc_cpu=query_start_loc.cpu(),
507 + seq_lens=seq_lens_t,
508 + seq_lens_cpu_upper_bound=seq_lens_t.cpu(),
509 + num_reqs=len(seq_lens),
510 + num_actual_tokens=int(query_start_loc[-1]),
511 + max_query_len=max(query_lens),
512 + max_seq_len=max(seq_lens),
513 + block_table_tensor=block_table,
514 + slot_mapping=slot_mapping,
515 + causal=True,
516 + mm_req_doc_ranges=mm_req_doc_ranges,
517 + ),
518 + )
519 +
520 +
521 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
522 +def test_builder_in_image_visibility():
523 + seq_lens = [30, 12]
524 + query_lens = [30, 12]
525 + spans = [[(4, 12), (20, 24)], []]
526 + builder = make_builder(vision=True)
527 + md = build_metadata(builder, seq_lens, query_lens, {0: spans[0], 1: spans[1]})
528 + assert md.num_prefills == 2
529 + assert md.num_decode_tokens == 0
530 + assert md.prefill_swa_indices.shape[-1] == WIDTH
531 + assert md.prefill_left_visible is not None
532 + assert md.prefill_right_visible is not None
533 +
534 + ref_left, ref_right = ref_left_right(seq_lens, query_lens, spans, MAX_IMG)
535 + assert md.prefill_left_visible.cpu().tolist() == ref_left
536 + assert md.prefill_right_visible.cpu().tolist() == ref_right
537 +
538 + _, _, _, _, block_table = make_batch(seq_lens, query_lens, torch.device("cuda"))
539 + rows, lens = ref_swa_slot_rows(
540 + seq_lens, query_lens, spans, block_table, WINDOW, MAX_IMG, WIDTH
541 + )
542 + assert md.prefill_swa_lens.cpu().tolist() == lens
543 + assert md.prefill_swa_indices[:, 0].cpu().tolist() == rows
544 +
545 +
546 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
547 +def test_builder_no_image_spans_fast_path():
548 + """Vision model, image-free batch: no visibility tensors, plain window."""
549 + seq_lens = [30, 12]
550 + query_lens = [30, 12]
551 + builder = make_builder(vision=True)
552 + md = build_metadata(builder, seq_lens, query_lens, {0: [], 1: []})
553 + assert md.prefill_left_visible is None
554 + assert md.prefill_right_visible is None
555 +
556 + _, _, _, _, block_table = make_batch(seq_lens, query_lens, torch.device("cuda"))
557 + rows, lens = ref_swa_slot_rows(
558 + seq_lens, query_lens, [[], []], block_table, WINDOW, MAX_IMG, WIDTH
559 + )
560 + assert md.prefill_swa_lens.cpu().tolist() == lens
561 + assert md.prefill_swa_indices[:, 0].cpu().tolist() == rows
562 +
563 +
564 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
565 +def test_builder_text_model_unchanged():
566 + """Text-only model: buffers stay window-sized and spans are ignored."""
567 + seq_lens = [30, 12]
568 + query_lens = [30, 12]
569 + builder = make_builder(vision=False)
570 + assert builder.max_image_tokens == 0
571 + md = build_metadata(builder, seq_lens, query_lens, None)
572 + assert md.prefill_swa_indices.shape[-1] == WINDOW
573 + assert md.prefill_left_visible is None
574 +
575 + _, _, _, _, block_table = make_batch(seq_lens, query_lens, torch.device("cuda"))
576 + rows, lens = ref_swa_slot_rows(
577 + seq_lens, query_lens, [[], []], block_table, WINDOW, MAX_IMG, WINDOW
578 + )
579 + assert md.prefill_swa_lens.cpu().tolist() == lens
580 + assert md.prefill_swa_indices[:, 0].cpu().tolist() == rows
vllm/_custom_ops.py
+4
@@ -2501,6 +2501,8 @@ def topk_hash_softplus_sqrt(
2501 input_tokens: torch.Tensor | None = None,
2502 hash_indices_table: torch.Tensor | None = None,
2503 is_padding: torch.Tensor | None = None,
2504 + bias_vl: torch.Tensor | None = None,
2505 + image_sentinel_lo: int = 0,
2506 ) -> None:
2507 torch.ops._moe_C.topk_softplus_sqrt(
2508 topk_weights,
@@ -2513,6 +2515,8 @@ def topk_hash_softplus_sqrt(
2515 input_tokens,
2516 hash_indices_table,
2517 is_padding,
2518 + bias_vl,
2519 + image_sentinel_lo,
2520 )
2521
2522
vllm/config/model.py
+4 -1
@@ -697,7 +697,10 @@ class ModelConfig:
697 self.tokenizer_mode = "kimi_k3"
698 elif arch == "DeepseekV32ForCausalLM":
699 self.tokenizer_mode = "deepseek_v32"
700 - elif arch == "DeepseekV4ForCausalLM":
700 + elif arch in (
701 + "DeepseekV4ForCausalLM",
702 + "DeepseekV4ForConditionalGeneration",
703 + ):
704 self.tokenizer_mode = "deepseek_v4"
705 elif arch in ("InklingForCausalLM", "InklingForConditionalGeneration"):
706 self.tokenizer_mode = "inkling"
vllm/config/vllm.py
+1
@@ -77,6 +77,7 @@ DEFAULT_BREAKABLE_CUDAGRAPH_ARCHITECTURES = frozenset(
77 "DeepseekV32MTPModel",
78 "DeepseekV32ForCausalLM",
79 "DeepseekV4ForCausalLM",
80 + "DeepseekV4ForConditionalGeneration",
81 "DeepSeekV4MTPModel",
82 "Dots3NoteForCausalLM",
83 "Dots3NoteMTPModel",
vllm/model_executor/layers/fused_moe/layer.py
+7
@@ -131,6 +131,8 @@ def FusedMoEFactory(
131 apply_routed_scale_to_output: bool = False,
132 zero_expert_type: str | None = None,
133 hash_indices_table: torch.Tensor | None = None,
134 + bias_vl: torch.Tensor | None = None,
135 + image_sentinel_lo: int = 0,
136 runner_cls: type[MoERunner] | None = None,
137 runner_args: dict[str, Any] | None = None,
138 routed_experts_cls: type[RoutedExperts] | None = None,
@@ -199,6 +201,9 @@ def FusedMoEFactory(
201 output instead of topk_weights
202 zero_expert_type: Type of zero expert handling
203 hash_indices_table: Hash table for expert indices
204 + bias_vl: Vision routing bias for image tokens (Deepseek V4)
205 + image_sentinel_lo: First of five consecutive in-vocab image sentinel
206 + ids (0 = vision routing disabled)
207 runner_cls: Custom MoERunner class (None = use default MoERunner)
208 runner_args: Additional arguments for runner constructor
209 routed_experts_cls: Custom RoutedExperts class (None = use default)
@@ -313,6 +318,8 @@ def FusedMoEFactory(
318 zero_expert_type=zero_expert_type,
319 num_logical_experts=logical_num_experts,
320 hash_indices_table=hash_indices_table,
321 + bias_vl=bias_vl,
322 + image_sentinel_lo=image_sentinel_lo,
323 )
324
325 if params_dtype is None:
vllm/model_executor/layers/fused_moe/router/dsv4_topk.py
+30
@@ -44,8 +44,12 @@ if current_platform.is_cuda():
44 topk_weights_ptr,
45 topk_ids_ptr,
46 routed_scaling_factor,
47 + input_ids_ptr,
48 + bias_vl_ptr,
49 + image_sentinel_lo,
50 NUM_EXPERTS: tl.constexpr,
51 BLOCK_N: tl.constexpr,
52 + HAS_VL: tl.constexpr,
53 launch_pdl: tl.constexpr,
54 ):
55 row = tl.program_id(0)
@@ -54,6 +58,19 @@ if current_platform.is_cuda():
58 bias = tl.load(
59 correction_bias_ptr + expert_offsets, mask=expert_mask, other=0.0
60 ).to(tl.float32)
61 + if HAS_VL:
62 + # Image tokens carry five consecutive in-vocab sentinel ids
63 + # starting at image_sentinel_lo and use bias_vl for expert
64 + # selection instead of the regular correction bias. Ids above the
65 + # sentinel block are regular special tokens and must not match.
66 + token_id = tl.load(input_ids_ptr + row).to(tl.int64)
67 + bias_vl = tl.load(
68 + bias_vl_ptr + expert_offsets, mask=expert_mask, other=0.0
69 + ).to(tl.float32)
70 + is_image = (token_id >= image_sentinel_lo) & (
71 + token_id < image_sentinel_lo + 5
72 + )
73 + bias = tl.where(is_image, bias_vl, bias)
74
75 if launch_pdl:
76 tl.extra.cuda.gdc_wait()
@@ -101,8 +118,17 @@ def dsv4_topk(
118 correction_bias: torch.Tensor,
119 indices_dtype: torch.dtype,
120 routed_scaling_factor: float,
121 + input_ids: torch.Tensor | None = None,
122 + bias_vl: torch.Tensor | None = None,
123 + image_sentinel_lo: int = 0,
124 ) -> tuple[torch.Tensor, torch.Tensor]:
125 num_tokens, num_experts = gating_output.shape
126 + has_vl = bias_vl is not None and image_sentinel_lo > 0
127 + if bias_vl is not None:
128 + assert input_ids is not None, "bias_vl routing requires input_ids"
129 + assert bias_vl.dtype == torch.float32 and bias_vl.is_contiguous()
130 + assert bias_vl.shape == (num_experts,)
131 + assert input_ids.is_contiguous()
132 shape = (num_tokens, _TOPK)
133 topk_weights = gating_output.new_empty(shape, dtype=torch.float32)
134 topk_ids = gating_output.new_empty(shape, dtype=indices_dtype)
@@ -113,8 +139,12 @@ def dsv4_topk(
139 topk_weights,
140 topk_ids,
141 routed_scaling_factor,
142 + input_ids,
143 + bias_vl,
144 + image_sentinel_lo,
145 NUM_EXPERTS=num_experts,
146 BLOCK_N=triton.next_power_of_2(num_experts),
147 + HAS_VL=has_vl,
148 num_warps=1,
149 launch_pdl=current_platform.is_arch_support_pdl(),
150 )
vllm/model_executor/layers/fused_moe/router/fused_topk_bias_router.py
+68 -7
@@ -81,6 +81,8 @@ def vllm_topk_softplus_sqrt(
81 input_tokens: torch.Tensor | None = None,
82 hash_indices_table: torch.Tensor | None = None,
83 routed_scaling_factor: float = 1.0,
84 + bias_vl: torch.Tensor | None = None,
85 + image_sentinel_lo: int = 0,
86 ) -> tuple[torch.Tensor, ...]:
87 ops.topk_hash_softplus_sqrt(
88 topk_weights,
@@ -93,6 +95,8 @@ def vllm_topk_softplus_sqrt(
95 input_tokens,
96 hash_indices_table,
97 is_padding=_get_padding_mask(topk_indices.shape[0]),
98 + bias_vl=bias_vl,
99 + image_sentinel_lo=image_sentinel_lo,
100 )
101
102 return topk_weights, topk_indices
@@ -122,6 +126,8 @@ def fused_topk_bias(
126 input_tokens: torch.Tensor | None = None,
127 hash_indices_table: torch.Tensor | None = None,
128 routed_scaling_factor: float = 1.0,
129 + bias_vl: torch.Tensor | None = None,
130 + image_sentinel_lo: int = 0,
131 ):
132 if (
133 input_tokens is not None
@@ -130,18 +136,28 @@ def fused_topk_bias(
136 ):
137 input_tokens = input_tokens.to(dtype=hash_indices_table.dtype)
138
139 + if bias_vl is not None:
140 + # Image tokens carry five consecutive in-vocab sentinel ids starting
141 + # at image_sentinel_lo and select experts with bias_vl instead of the
142 + # regular routing path. image_sentinel_lo == 0 disables this.
143 + assert input_tokens is not None, "bias_vl routing requires input_tokens"
144 +
145 if not rocm_aiter_ops.is_fused_moe_enabled():
146 assert hidden_states.size(0) == gating_output.size(0), (
147 "Number of tokens mismatch"
148 )
149
150 output_indices_dtype = torch.int32 if indices_type is None else indices_type
139 - if scoring_func == "sqrtsoftplus" and can_use_dsv4_topk(
140 - gating_output,
141 - e_score_correction_bias,
142 - topk,
143 - renormalize,
144 - output_indices_dtype,
151 + if (
152 + scoring_func == "sqrtsoftplus"
153 + and hash_indices_table is None
154 + and can_use_dsv4_topk(
155 + gating_output,
156 + e_score_correction_bias,
157 + topk,
158 + renormalize,
159 + output_indices_dtype,
160 + )
161 ):
162 assert e_score_correction_bias is not None
163 return dsv4_topk(
@@ -149,6 +165,9 @@ def fused_topk_bias(
165 e_score_correction_bias,
166 output_indices_dtype,
167 routed_scaling_factor,
168 + input_ids=input_tokens,
169 + bias_vl=bias_vl,
170 + image_sentinel_lo=image_sentinel_lo,
171 )
172
173 M, _ = hidden_states.size()
@@ -200,6 +219,8 @@ def fused_topk_bias(
219 input_tokens,
220 hash_indices_table,
221 routed_scaling_factor,
222 + bias_vl=bias_vl,
223 + image_sentinel_lo=image_sentinel_lo,
224 )
225 else:
226 raise ValueError(f"Unsupported scoring function: {scoring_func}")
@@ -255,6 +276,8 @@ def fused_topk_bias(
276 input_tokens,
277 hash_indices_table,
278 routed_scaling_factor,
279 + bias_vl=bias_vl,
280 + image_sentinel_lo=image_sentinel_lo,
281 )
282
283 n_routed_experts = gating_output.shape[-1]
@@ -270,10 +293,39 @@ def fused_topk_bias(
293 ) + e_score_correction_bias.unsqueeze(0)
294 else:
295 scores_for_choice = scores.view(-1, n_routed_experts)
296 + image_mask = None
297 + if bias_vl is not None and image_sentinel_lo > 0:
298 + # Image tokens (five consecutive sentinel ids starting at
299 + # image_sentinel_lo) select experts with bias_vl instead of
300 + # e_score_correction_bias / the hash table. Ids above the sentinel
301 + # block are regular special tokens and must not match.
302 + assert input_tokens is not None, "bias_vl routing requires input_tokens"
303 + image_mask = (
304 + (input_tokens >= image_sentinel_lo) & (input_tokens < image_sentinel_lo + 5)
305 + ).unsqueeze(-1)
306 + text_bias = (
307 + e_score_correction_bias
308 + if e_score_correction_bias is not None
309 + else torch.zeros_like(bias_vl)
310 + )
311 + row_bias = torch.where(image_mask, bias_vl, text_bias)
312 + scores_for_choice = scores.view(-1, n_routed_experts) + row_bias
313 # For batch invariance, use sorted=True to ensure deterministic expert selection
314 if hash_indices_table is not None:
315 assert input_tokens is not None
276 - topk_indices = hash_indices_table[input_tokens]
316 + if image_mask is None:
317 + topk_indices = hash_indices_table[input_tokens]
318 + else:
319 + # Clamp sentinel rows (overwritten below) to keep the table
320 + # lookup well-defined.
321 + safe_ids = torch.where(image_mask.squeeze(-1), 0, input_tokens).to(
322 + hash_indices_table.dtype
323 + )
324 + topk_indices = hash_indices_table[safe_ids]
325 + vl_indices = torch.topk(scores_for_choice, k=topk, dim=-1)[1]
326 + topk_indices = torch.where(
327 + image_mask, vl_indices.to(topk_indices.dtype), topk_indices
328 + )
329 else:
330 use_sorted = envs.VLLM_BATCH_INVARIANT
331 topk_indices = torch.topk(scores_for_choice, k=topk, dim=-1, sorted=use_sorted)[
@@ -306,6 +358,8 @@ class FusedTopKBiasRouter(BaseRouter):
358 hash_indices_table: torch.Tensor | None = None,
359 num_fused_shared_experts: int = 0,
360 shared_expert_weight: float = 1.0,
361 + bias_vl: torch.Tensor | None = None,
362 + image_sentinel_lo: int = 0,
363 ):
364 super().__init__(
365 top_k=top_k,
@@ -318,6 +372,11 @@ class FusedTopKBiasRouter(BaseRouter):
372 self.routed_scaling_factor = routed_scaling_factor
373 self.scoring_func = scoring_func
374 self._hash_indices_table = hash_indices_table
375 + # Vision bias: image sentinel tokens (five consecutive in-vocab ids
376 + # starting at image_sentinel_lo) select experts with bias_vl instead
377 + # of e_score_correction_bias / the hash table.
378 + self.bias_vl = bias_vl
379 + self.image_sentinel_lo = image_sentinel_lo
380 # Fused shared experts: append constant slots (ids immediately after
381 # the routed experts, [global, global+n)) routed to by every token at
382 # ``shared_expert_weight``, AFTER the routed top-k is renormalized.
@@ -357,6 +416,8 @@ class FusedTopKBiasRouter(BaseRouter):
416 input_tokens=input_ids,
417 hash_indices_table=self._hash_indices_table,
418 routed_scaling_factor=self.routed_scaling_factor,
419 + bias_vl=self.bias_vl.data if self.bias_vl is not None else None,
420 + image_sentinel_lo=self.image_sentinel_lo,
421 )
422
423 if self.num_fused_shared_experts > 0:
vllm/model_executor/layers/fused_moe/router/router_factory.py
+5
@@ -60,6 +60,9 @@ def create_fused_moe_router(
60 zero_expert_type: str | None = None,
61 num_logical_experts: int | None = None,
62 hash_indices_table: torch.Tensor | None = None,
63 + # Deepseek V4 vision routing bias parameters
64 + bias_vl: torch.Tensor | None = None,
65 + image_sentinel_lo: int = 0,
66 ) -> FusedMoERouter:
67 """
68 Factory function to create the appropriate FusedMoERouter subclass based on
@@ -211,6 +214,8 @@ def create_fused_moe_router(
214 hash_indices_table=hash_indices_table,
215 num_fused_shared_experts=num_fused_shared_experts,
216 shared_expert_weight=shared_expert_weight,
217 + bias_vl=bias_vl,
218 + image_sentinel_lo=image_sentinel_lo,
219 )
220
221 if (
vllm/model_executor/models/config.py
+1
@@ -1002,6 +1002,7 @@ MODELS_CONFIG_MAP: dict[str, type[VerifyAndUpdateConfig]] = {
1002 "ColBERTJinaRobertaModel": JinaRobertaModelConfig,
1003 "ColQwen3_5": ColQwen3_5Config,
1004 "DeepseekV4ForCausalLM": DeepseekV4ForCausalLMConfig,
1005 + "DeepseekV4ForConditionalGeneration": DeepseekV4ForCausalLMConfig,
1006 "DeepseekV32ForCausalLM": DeepseekV32ForCausalLM,
1007 "DiffusionGemmaForBlockDiffusion": DiffusionGemmaModelForBlockDiffusionConfig, # noqa: E501
1008 "Ernie4_5_VLMoeForConditionalGeneration": Ernie4_5_VLMoeForConditionalGenerationConfig, # noqa: E501
vllm/model_executor/models/registry.py
+4
@@ -362,6 +362,10 @@ _MULTIMODAL_MODELS = {
362 "DeepseekVLV2ForCausalLM": ("deepseek_vl2", "DeepseekVLV2ForCausalLM"),
363 "DeepseekOCRForCausalLM": ("deepseek_ocr", "DeepseekOCRForCausalLM"),
364 "DeepseekOCR2ForCausalLM": ("deepseek_ocr2", "DeepseekOCR2ForCausalLM"),
365 + "DeepseekV4ForConditionalGeneration": (
366 + "vllm.models.deepseek_v4",
367 + "DeepseekV4ForConditionalGeneration",
368 + ),
369 "Dots3NoteForCausalLM": (
370 "vllm.models.dots3_note",
371 "Dots3NoteForCausalLM",
vllm/models/deepseek_v4/__init__.py
+10
@@ -20,7 +20,13 @@ if current_platform.is_rocm():
20 )
21 from .amd.model import DeepseekV4ForCausalLM
22 from .amd.mtp import DeepSeekV4MTP
23 + from .vl_stub import ( # type: ignore[assignment]
24 + DeepseekV4ForConditionalGeneration,
25 + )
26 elif current_platform.is_xpu():
27 + from .vl_stub import ( # type: ignore[assignment]
28 + DeepseekV4ForConditionalGeneration,
29 + )
30 from .xpu.dspark import DSparkDeepseekV4ForCausalLM # type: ignore[assignment]
31 from .xpu.model import DeepseekV4ForCausalLM # type: ignore[assignment]
32 from .xpu.mtp import DeepSeekV4MTP # type: ignore[assignment]
@@ -30,10 +36,14 @@ else:
36 )
37 from .nvidia.model import DeepseekV4ForCausalLM # type: ignore[assignment]
38 from .nvidia.mtp import DeepSeekV4MTP # type: ignore[assignment]
39 + from .nvidia.vl_model import ( # type: ignore[assignment]
40 + DeepseekV4ForConditionalGeneration,
41 + )
42
43 __all__ = [
44 "DSparkDeepseekV4ForCausalLM",
45 "DeepSeekV4MTP",
46 "DeepseekV4FP8Config",
47 "DeepseekV4ForCausalLM",
48 + "DeepseekV4ForConditionalGeneration",
49 ]
vllm/models/deepseek_v4/attention.py
+8
@@ -205,6 +205,13 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
205 self.n_groups = config.o_groups
206 self.n_local_groups = self.n_groups // tp_size
207 self.window_size = config.sliding_window
208 + # Vision variant: image spans are visible bidirectionally, widening
209 + # prefill SWA index rows by up to max_image_tokens columns.
210 + self.max_image_tokens = (
211 + getattr(config, "vision_max_n_token", 0)
212 + if getattr(config, "vision_n_layers", 0) > 0
213 + else 0
214 + )
215 # NOTE(zyongye) Compress ratio can't be 0
216 # we do this for because MTP layer is not included
217 # in the compress ratio list
@@ -363,6 +370,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
370 _COMPUTE_SWA_INDICES_AND_LENS_KERNEL.register_warmup(
371 window_size=self.window_size,
372 block_size=self.swa_cache_layer.block_size,
373 + max_image_tokens=self.max_image_tokens,
374 )
375
376 if self.compress_ratio > 1:
vllm/models/deepseek_v4/common/mm_preprocess.py new
+512
@@ -0,0 +1,512 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Multimodal preprocessing for the DeepSeek-V4 vision variants
4 +(DeepSeek-V4-Flash-Vision-Exp).
5 +
6 +The image transform and sentinel-block construction are ported from the
7 +official repository's ``image_processor.py`` so that token counts bit-match
8 +the reference. Each ``<|deepseek_image|>`` placeholder in the prompt expands
9 +to a variable-length block of sentinel tokens; only positions with
10 +``type == IMAGE`` receive vision embeddings, the other sentinels are looked
11 +up from learned embedding vectors in the model.
12 +
13 +Unlike the reference (which uses out-of-vocab ids ``vocab_size + type``),
14 +the sentinel block borrows five consecutive reserved tokenizer tokens
15 +(``<|place_holder_mm_span_0431|>`` .. ``_0435|>``): they are special tokens
16 +the tokenizer never emits from plain text, so the ids stay in-vocabulary and
17 +work with stock token validation, logprobs and detokenization. The ids are
18 +pure markers — every sentinel position's embedding is overwritten with
19 +vision/sentinel vectors before the decoder layers see it, exactly like the
20 +reference's out-of-vocab scheme.
21 +"""
22 +
23 +import math
24 +from collections.abc import Mapping, Sequence
25 +from typing import Any, cast
26 +
27 +import numpy as np
28 +import torch
29 +from PIL import Image, ImageOps
30 +from transformers import BatchFeature
31 +from typing_extensions import assert_never
32 +
33 +from vllm.config.multimodal import BaseDummyOptions, ImageDummyOptions
34 +from vllm.inputs import MultiModalDataDict
35 +from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems
36 +from vllm.multimodal.parse import ImageSize, MultiModalDataItems
37 +from vllm.multimodal.processing import (
38 + BaseDummyInputsBuilder,
39 + BaseMultiModalProcessor,
40 + BaseProcessingInfo,
41 + PromptReplacement,
42 + PromptUpdate,
43 + PromptUpdateDetails,
44 +)
45 +from vllm.multimodal.processing.processor import (
46 + MultiModalPromptUpdates,
47 + MultiModalPromptUpdatesApplyResult,
48 + PlaceholderFeaturesInfo,
49 + UpdateMode,
50 + _plan_prompt_updates,
51 +)
52 +from vllm.transformers_utils.configs.deepseek_v4 import DeepseekV4Config
53 +
54 +IMAGE_START, IMAGE_PAD, IMAGE, IMAGE_NEW_LINE, IMAGE_END = range(5)
55 +COMPRESS_PAD_TO = 4
56 +
57 +IMAGE_PLACEHOLDER = "<|deepseek_image|>"
58 +
59 +# Sentinel roles borrow five consecutive ``<|place_holder_mm_span_XXXX|>``
60 +# tokens (reserved special tokens, never emitted from plain text). The order
61 +# must match IMAGE_START..IMAGE_END above so that
62 +# ``id == IMAGE_SENTINEL_BASE_ID + type``.
63 +IMAGE_SENTINEL_BASE_ID = 129257
64 +IMAGE_SENTINEL_TOKEN_NAMES = (
65 + "<|place_holder_mm_span_0431|>", # IMAGE_START
66 + "<|place_holder_mm_span_0432|>", # IMAGE_PAD
67 + "<|place_holder_mm_span_0433|>", # IMAGE
68 + "<|place_holder_mm_span_0434|>", # IMAGE_NEW_LINE
69 + "<|place_holder_mm_span_0435|>", # IMAGE_END
70 +)
71 +
72 +
73 +def image_sentinel_mask(token_ids: torch.Tensor) -> torch.Tensor:
74 + """Boolean mask for image-block sentinel positions (in-vocab ids)."""
75 + return (token_ids >= IMAGE_SENTINEL_BASE_ID) & (
76 + token_ids < IMAGE_SENTINEL_BASE_ID + len(IMAGE_SENTINEL_TOKEN_NAMES)
77 + )
78 +
79 +
80 +def validate_image_sentinel_ids(tokenizer) -> None:
81 + """Check the borrowed sentinel ids against the tokenizer."""
82 + for i, name in enumerate(IMAGE_SENTINEL_TOKEN_NAMES):
83 + token_id = tokenizer.convert_tokens_to_ids(name)
84 + if token_id != IMAGE_SENTINEL_BASE_ID + i:
85 + raise ValueError(
86 + f"Image sentinel token {name!r} has id {token_id}, expected "
87 + f"{IMAGE_SENTINEL_BASE_ID + i}; the DeepSeek-V4 vision "
88 + "sentinel block requires these consecutive reserved ids."
89 + )
90 +
91 +
92 +def grid_tokens(best_height, best_width, patch_size, downsample_ratio):
93 + """Number of LLM tokens the aligner grid occupies (N-layout, including
94 + row/align padding)."""
95 + n_llm_h = math.ceil((best_height // patch_size) / downsample_ratio)
96 + n_llm_w = math.ceil((best_width // patch_size) / downsample_ratio)
97 + num_tokens = n_llm_h * (n_llm_w + 1) + 2
98 + if n_llm_h % 2 == 1:
99 + num_tokens += n_llm_w + 1
100 + num_tokens += (n_llm_h + 1) // 2 * (n_llm_w + 1) % 2 * 2
101 + return n_llm_h, n_llm_w, num_tokens
102 +
103 +
104 +def solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token):
105 + r = height / width
106 + max_w_float = math.sqrt((max_n_token - 2) / r + 0.25) - 0.5
107 + max_h_float = max_w_float * r
108 + if max_w_float < 1.0:
109 + max_w = 1
110 + max_h = (max_n_token - 2) // (max_w + 1)
111 + if max_h % 2 == 1:
112 + max_h -= 1
113 + best_width = max_w * patch_size * downsample_ratio
114 + best_height = max_h * patch_size * downsample_ratio
115 + elif max_h_float < 2.0:
116 + max_h = 2
117 + max_w = ((max_n_token - 2) // max_h) - 1
118 + assert max_w > 1
119 + best_width = max_w * patch_size * downsample_ratio
120 + best_height = max_h * patch_size * downsample_ratio
121 + else:
122 + max_w = math.floor(max_w_float)
123 + max_h = math.floor(max_h_float)
124 + if max_h % 2 == 1:
125 + max_h -= 1
126 + beta = min(
127 + max_w * patch_size * downsample_ratio / width,
128 + max_h * patch_size * downsample_ratio / height,
129 + )
130 + best_width = math.floor(width * beta / patch_size) * patch_size
131 + best_height = math.floor(height * beta / patch_size) * patch_size
132 + n_llm_h, n_llm_w, num_tokens = grid_tokens(
133 + best_height, best_width, patch_size, downsample_ratio
134 + )
135 + return n_llm_h, n_llm_w, best_height, best_width, num_tokens
136 +
137 +
138 +def safe_resize(
139 + height, width, best_height, best_width, patch_size, downsample_ratio, max_n_token
140 +):
141 + max_n_token -= COMPRESS_PAD_TO - 1
142 + n_llm_h, n_llm_w, num_tokens = grid_tokens(
143 + best_height, best_width, patch_size, downsample_ratio
144 + )
145 + budget = max_n_token
146 + while num_tokens > max_n_token:
147 + n_llm_h, n_llm_w, best_height, best_width, num_tokens = solve_resize_ratio(
148 + height, width, patch_size, downsample_ratio, budget
149 + )
150 + budget -= 1
151 + return n_llm_h, n_llm_w, best_height, best_width
152 +
153 +
154 +def load_image(
155 + image: Image.Image,
156 + *,
157 + patch_size: int,
158 + downsample_ratio: int,
159 + max_n_token: int,
160 + min_pixels: int,
161 + max_wh_ratio: float | None,
162 +):
163 + """Transform one PIL image into ViT patches.
164 +
165 + Same math as the reference ``load_image``, except the image is already
166 + decoded (vLLM supplies PIL images instead of a record dict).
167 + """
168 + p = patch_size
169 + image = image.convert("RGB")
170 + width, height = image.size
171 + if max_wh_ratio is not None and width > height * max_wh_ratio:
172 + width = height * max_wh_ratio
173 + if 0 < width * height < min_pixels:
174 + ratio = (min_pixels / (width * height)) ** 0.5
175 + width = int(width * ratio)
176 + height = int(height * ratio)
177 + best_width = math.ceil(width / p) * p
178 + best_height = math.ceil(height / p) * p
179 + n_llm_h, n_llm_w, best_height, best_width = safe_resize(
180 + height, width, best_height, best_width, p, downsample_ratio, max_n_token
181 + )
182 + n_vit_h, n_vit_w = best_height // p, best_width // p
183 + if max_wh_ratio is not None and image.width >= max_wh_ratio * image.height:
184 + image = image.resize((best_width, best_height))
185 + else:
186 + image = ImageOps.pad(image, (best_width, best_height), color=(127, 127, 127))
187 + x = torch.from_numpy(np.asarray(image, dtype=np.float32)).permute(2, 0, 1) / 255
188 + x = ((x - 0.5) / 0.5).to(torch.bfloat16)
189 + patches = (
190 + x.reshape(3, n_vit_h, p, n_vit_w, p)
191 + .permute(1, 3, 0, 2, 4)
192 + .reshape(n_vit_h * n_vit_w, 3, p, p)
193 + )
194 + return patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w
195 +
196 +
197 +def build_image_block(n_llm_h: int, n_llm_w: int, start_pos: int):
198 + """Builds the N-layout token types (final order) and the aligner-row order
199 + for IMAGE slots."""
200 + compress_pad = COMPRESS_PAD_TO - 1 - start_pos % COMPRESS_PAD_TO
201 + pad_h = n_llm_h % 2
202 + rows = n_llm_h + pad_h
203 + row_len = n_llm_w + 1
204 + pad_last = rows // 2 * row_len % 2 * 2
205 + types = torch.tensor(
206 + ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h
207 + + [IMAGE_PAD] * (row_len * pad_h),
208 + dtype=torch.int64,
209 + )
210 + order = torch.arange(rows * row_len).view(rows // 2, 2, row_len)
211 + order = order.transpose(1, 2).reshape(-1)
212 + image_idx = torch.full((rows * row_len,), -1, dtype=torch.int64)
213 + image_idx.view(rows, row_len)[:n_llm_h, :n_llm_w] = torch.arange(
214 + n_llm_h * n_llm_w
215 + ).view(n_llm_h, n_llm_w)
216 + perm = image_idx[order]
217 + perm = perm[perm >= 0]
218 + types = torch.cat(
219 + [
220 + torch.full((compress_pad,), IMAGE_PAD, dtype=torch.int64),
221 + torch.tensor([IMAGE_START]),
222 + types[order],
223 + torch.full((pad_last,), IMAGE_PAD, dtype=torch.int64),
224 + torch.tensor([IMAGE_END]),
225 + ]
226 + )
227 + return types, perm
228 +
229 +
230 +def build_image_block_pad_free(n_llm_h: int, n_llm_w: int):
231 + """``build_image_block`` without the position-dependent compressor pad.
232 +
233 + ``start_pos`` is chosen so that ``compress_pad == 0``; the pad is instead
234 + prepended when the block is spliced into the final prompt, where its
235 + position is known.
236 + """
237 + return build_image_block(n_llm_h, n_llm_w, COMPRESS_PAD_TO - 1)
238 +
239 +
240 +class DeepseekV4VLImageProcessor:
241 + """Per-image transform (the PIL-input equivalent of the reference
242 + ``load_image``)."""
243 +
244 + def __init__(self, config: DeepseekV4Config) -> None:
245 + super().__init__()
246 + self.patch_size = config.vision_patch_size
247 + self.downsample_ratio = config.vision_downsample_ratio
248 + self.max_n_token = config.vision_max_n_token
249 + self.min_pixels = config.vision_min_pixels
250 + self.max_wh_ratio = config.vision_max_wh_ratio
251 +
252 + def __call__(self, image: Image.Image):
253 + return load_image(
254 + image,
255 + patch_size=self.patch_size,
256 + downsample_ratio=self.downsample_ratio,
257 + max_n_token=self.max_n_token,
258 + min_pixels=self.min_pixels,
259 + max_wh_ratio=self.max_wh_ratio,
260 + )
261 +
262 +
263 +class DeepseekV4VLProcessor:
264 + """Minimal stand-in for the HF processor of DeepSeek-V4 vision models.
265 +
266 + The official repository ships image preprocessing as plain functions in
267 + ``image_processor.py`` (no ``auto_map`` processor), so this class wraps
268 + their ports directly and the model loads without ``--trust-remote-code``.
269 +
270 + ``__call__`` returns a ``BatchFeature`` with one entry per image
271 + (flattened across images):
272 +
273 + - ``patches``: ``(sum(n_vit_h * n_vit_w), 3, p, p)`` bf16 ViT patches.
274 + - ``vit_grid``: ``(num_images, 2)`` int64 ``[n_vit_h, n_vit_w]``.
275 + - ``llm_grid``: ``(num_images, 2)`` int64 ``[n_llm_h, n_llm_w]``.
276 + - ``perm``: concatenated per-image ``(n_llm_h * n_llm_w,)`` int64 index
277 + selecting aligner outputs into the final N-layout order.
278 + - ``types``: concatenated per-image pad-free sentinel block types;
279 + ``block ids = IMAGE_SENTINEL_BASE_ID + types``.
280 + """
281 +
282 + def __init__(self, config: DeepseekV4Config) -> None:
283 + super().__init__()
284 + self.config = config
285 + self.image_processor = DeepseekV4VLImageProcessor(config)
286 +
287 + def __call__(
288 + self,
289 + text: str | None = None,
290 + images: Sequence[Image.Image] | None = None,
291 + return_tensors: str | None = None,
292 + **kwargs: Any,
293 + ) -> BatchFeature:
294 + patches_list = []
295 + vit_grid = []
296 + llm_grid = []
297 + perm_list = []
298 + types_list = []
299 + for image in images or []:
300 + patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w = self.image_processor(image)
301 + types, perm = build_image_block_pad_free(n_llm_h, n_llm_w)
302 + patches_list.append(patches)
303 + vit_grid.append((n_vit_h, n_vit_w))
304 + llm_grid.append((n_llm_h, n_llm_w))
305 + perm_list.append(perm)
306 + types_list.append(types)
307 +
308 + if not patches_list:
309 + return BatchFeature({})
310 +
311 + return BatchFeature(
312 + {
313 + "patches": torch.cat(patches_list),
314 + "vit_grid": torch.tensor(vit_grid, dtype=torch.int64),
315 + "llm_grid": torch.tensor(llm_grid, dtype=torch.int64),
316 + "perm": torch.cat(perm_list),
317 + "types": torch.cat(types_list),
318 + }
319 + )
320 +
321 +
322 +class DeepseekV4VLProcessingInfo(BaseProcessingInfo):
323 + def get_hf_config(self) -> DeepseekV4Config:
324 + return self.ctx.get_hf_config(DeepseekV4Config)
325 +
326 + def get_hf_processor(self, **kwargs: object) -> DeepseekV4VLProcessor:
327 + if kwargs:
328 + raise ValueError(f"Unexpected processor kwargs: {sorted(kwargs)}")
329 + return DeepseekV4VLProcessor(self.get_hf_config())
330 +
331 + def get_supported_mm_limits(self) -> Mapping[str, int | None]:
332 + return {"image": None}
333 +
334 + def get_mm_max_tokens_per_item(
335 + self,
336 + seq_len: int,
337 + mm_counts: Mapping[str, int],
338 + ) -> Mapping[str, int]:
339 + # ``safe_resize`` reserves COMPRESS_PAD_TO - 1 tokens of
340 + # vision_max_n_token for the compressor-alignment pad, so the full
341 + # sentinel block is bounded by vision_max_n_token; the margin is kept
342 + # in case that reservation changes.
343 + return {
344 + "image": self.get_hf_config().vision_max_n_token + COMPRESS_PAD_TO - 1,
345 + }
346 +
347 + def get_image_placeholder_token_id(self) -> int:
348 + token_id = self.get_tokenizer().convert_tokens_to_ids(IMAGE_PLACEHOLDER)
349 + if token_id is None:
350 + raise ValueError(f"Token not found in tokenizer: {IMAGE_PLACEHOLDER}")
351 + return token_id
352 +
353 + def get_image_size_with_most_features(self) -> ImageSize:
354 + hf_config = self.get_hf_config()
355 + patch_size = hf_config.vision_patch_size
356 + downsample_ratio = hf_config.vision_downsample_ratio
357 + # A square maximizes the ViT patch count (area) within the token
358 + # budget; solve the budget-derived size directly to keep the dummy
359 + # image small.
360 + budget = hf_config.vision_max_n_token - (COMPRESS_PAD_TO - 1)
361 + side = budget * patch_size * downsample_ratio
362 + _, _, best_h, best_w, _ = solve_resize_ratio(
363 + side, side, patch_size, downsample_ratio, budget
364 + )
365 + return ImageSize(width=best_w, height=best_h)
366 +
367 +
368 +class DeepseekV4VLDummyInputsBuilder(
369 + BaseDummyInputsBuilder[DeepseekV4VLProcessingInfo]
370 +):
371 + def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
372 + return IMAGE_PLACEHOLDER * mm_counts.get("image", 0)
373 +
374 + def get_dummy_mm_data(
375 + self,
376 + seq_len: int,
377 + mm_counts: Mapping[str, int],
378 + mm_options: Mapping[str, BaseDummyOptions],
379 + ) -> MultiModalDataDict:
380 + size = self.info.get_image_size_with_most_features()
381 + return {
382 + "image": self._get_dummy_images(
383 + width=size.width,
384 + height=size.height,
385 + num_images=mm_counts.get("image", 0),
386 + overrides=cast(ImageDummyOptions | None, mm_options.get("image")),
387 + ),
388 + }
389 +
390 +
391 +class DeepseekV4VLMultiModalProcessor(
392 + BaseMultiModalProcessor[DeepseekV4VLProcessingInfo]
393 +):
394 + def _get_mm_fields_config(
395 + self,
396 + hf_inputs: BatchFeature,
397 + hf_processor_mm_kwargs: Mapping[str, object],
398 + ) -> Mapping[str, MultiModalFieldConfig]:
399 + vit_grid = hf_inputs.get("vit_grid")
400 + llm_grid = hf_inputs.get("llm_grid")
401 +
402 + if vit_grid is None or llm_grid is None:
403 + empty = torch.empty(0, dtype=torch.long)
404 + patch_sizes = perm_sizes = types_sizes = empty
405 + else:
406 + patch_sizes = vit_grid.prod(-1)
407 + perm_sizes = llm_grid.prod(-1)
408 + n_llm_h, n_llm_w = llm_grid[:, 0], llm_grid[:, 1]
409 + # Pad-free block length; same formula as ``grid_tokens`` given
410 + # the LLM grid.
411 + types_sizes = (
412 + n_llm_h * (n_llm_w + 1)
413 + + 2
414 + + (n_llm_h % 2) * (n_llm_w + 1)
415 + + (n_llm_h + 1) // 2 * (n_llm_w + 1) % 2 * 2
416 + )
417 +
418 + return {
419 + "patches": MultiModalFieldConfig.flat_from_sizes("image", patch_sizes),
420 + "vit_grid": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
421 + "llm_grid": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
422 + "perm": MultiModalFieldConfig.flat_from_sizes("image", perm_sizes),
423 + "types": MultiModalFieldConfig.flat_from_sizes(
424 + "image", types_sizes, keep_on_cpu=True
425 + ),
426 + }
427 +
428 + def _get_prompt_updates(
429 + self,
430 + mm_items: MultiModalDataItems,
431 + hf_processor_mm_kwargs: Mapping[str, object],
432 + out_mm_kwargs: MultiModalKwargsItems,
433 + ) -> Sequence[PromptUpdate]:
434 + image_token_id = self.info.get_image_placeholder_token_id()
435 + validate_image_sentinel_ids(self.info.get_tokenizer())
436 + image_embed_id = IMAGE_SENTINEL_BASE_ID + IMAGE
437 +
438 + def get_image_replacement(item_idx: int) -> PromptUpdateDetails:
439 + types: torch.Tensor = out_mm_kwargs["image"][item_idx]["types"].data
440 + full = (IMAGE_SENTINEL_BASE_ID + types).tolist()
441 + return PromptUpdateDetails.select_token_id(full, image_embed_id)
442 +
443 + return [
444 + PromptReplacement(
445 + modality="image",
446 + target=[image_token_id],
447 + replacement=get_image_replacement,
448 + ),
449 + ]
450 +
451 + def _apply_token_matches_with_placeholders(
452 + self,
453 + token_ids: list[int],
454 + mm_prompt_updates: MultiModalPromptUpdates,
455 + ) -> tuple[
456 + list[int],
457 + MultiModalPromptUpdatesApplyResult,
458 + Mapping[str, list[PlaceholderFeaturesInfo]],
459 + ]:
460 + # Same as the base implementation, except that each image block gets
461 + # its compressor-alignment pad (``3 - start_pos % 4`` IMAGE_PAD
462 + # sentinels) prepended while splicing: the pad depends on the block's
463 + # final position in the prompt, which is unknown when the (cacheable)
464 + # prompt updates are built.
465 + matched_updates, result = _plan_prompt_updates(token_ids, mm_prompt_updates)
466 + placeholders: dict[str, list[PlaceholderFeaturesInfo]] = {
467 + modality: [] for modality in mm_prompt_updates
468 + }
469 +
470 + pad_id = IMAGE_SENTINEL_BASE_ID + IMAGE_PAD
471 +
472 + new_token_ids = list[int]()
473 + prev_end_idx = 0
474 + for matched_update in matched_updates:
475 + update = matched_update.update
476 + match = matched_update.match
477 +
478 + if update.mode == UpdateMode.INSERT:
479 + end_idx_to_insert = match.end_idx
480 + elif update.mode == UpdateMode.REPLACE:
481 + end_idx_to_insert = match.start_idx
482 + else:
483 + assert_never(update.mode)
484 +
485 + new_token_ids.extend(token_ids[prev_end_idx:end_idx_to_insert])
486 + start_idx = len(new_token_ids)
487 +
488 + tokens = list(update.content.full)
489 + if tokens and update.modality == "image":
490 + compress_pad = COMPRESS_PAD_TO - 1 - start_idx % COMPRESS_PAD_TO
491 + tokens = [pad_id] * compress_pad + tokens
492 +
493 + if tokens:
494 + content_is_embed = update.content.is_embed
495 + is_embed = (
496 + content_is_embed(tokens) if content_is_embed is not None else None
497 + )
498 + placeholders[update.modality].append(
499 + PlaceholderFeaturesInfo(
500 + modality=update.modality,
501 + item_idx=update.item_idx,
502 + start_idx=start_idx,
503 + tokens=tokens,
504 + is_embed=is_embed,
505 + )
506 + )
507 + new_token_ids.extend(tokens)
508 +
509 + prev_end_idx = match.end_idx
510 +
511 + new_token_ids.extend(token_ids[prev_end_idx:])
512 + return new_token_ids, result, placeholders
vllm/models/deepseek_v4/common/ops/cache_utils.py
+107 -27
@@ -536,10 +536,16 @@ def combine_topk_swa_indices(
536 M: int,
537 N: int,
538 out: tuple[torch.Tensor, torch.Tensor] | None = None,
539 + left_visible: torch.Tensor | None = None,
540 + right_visible: torch.Tensor | None = None,
541 + max_image_tokens: int = 0,
542 ) -> tuple[torch.Tensor, torch.Tensor]:
543 num_tokens = topk_indices.shape[0]
544 + # max_image_tokens widens the SWA column region for in-image
545 + # bidirectional visibility; the width is fixed per model so the
546 + # caller-provided workspace stays valid on image-free batches.
547 combined_topk = (
542 - (topk + window_size + _SPARSE_PREFILL_TOPK_ALIGNMENT - 1)
548 + (topk + window_size + max_image_tokens + _SPARSE_PREFILL_TOPK_ALIGNMENT - 1)
549 // _SPARSE_PREFILL_TOPK_ALIGNMENT
550 * _SPARSE_PREFILL_TOPK_ALIGNMENT
551 )
@@ -568,6 +574,9 @@ def combine_topk_swa_indices(
574 TOP_K=topk,
575 COMPRESS_RATIO=compress_ratio,
576 WINDOW_SIZE=window_size,
577 + left_visible=left_visible,
578 + right_visible=right_visible,
579 + max_image_tokens=max_image_tokens,
580 )
581 return combined_indices, combined_lens
582
@@ -628,6 +637,7 @@ class CombineTopkSwaIndicesKernel(
637 TOP_K: int
638 COMPRESS_RATIO: int
639 WINDOW_SIZE: int
640 + IMAGE_WIDTH: int
641 PADDED_TOP_K: int
642 input_variant: TritonPointerInputVariant
643
@@ -649,11 +659,14 @@ class CombineTopkSwaIndicesKernel(
659 query_start_loc_ptr,
660 seq_lens_ptr,
661 gather_lens_ptr,
662 + left_visible_ptr,
663 + right_visible_ptr,
664 M,
665 N,
666 TOP_K: tl.constexpr,
667 COMPRESS_RATIO: tl.constexpr,
668 WINDOW_SIZE: tl.constexpr,
669 + IMAGE_WIDTH: tl.constexpr,
670 PADDED_TOP_K: tl.constexpr,
671 ):
672 batch_idx = tl.program_id(0)
@@ -682,7 +695,19 @@ class CombineTopkSwaIndicesKernel(
695 token_idx_in_query = token_idx - query_start
696 pos = start_pos + token_idx_in_query
697 topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
685 - swa_len = tl.minimum(pos + 1, WINDOW_SIZE)
698 + if IMAGE_WIDTH > 0:
699 + # In-image bidirectional visibility: the window starts up to
700 + # max(left - (window - 1), 0) positions earlier and extends
701 + # `right` positions past the query token (both 0 outside
702 + # image spans, reducing to the plain causal window).
703 + left = tl.load(left_visible_ptr + token_idx)
704 + right = tl.load(right_visible_ptr + token_idx)
705 + else:
706 + left = 0
707 + right = 0
708 + left_add = tl.maximum(left - (WINDOW_SIZE - 1), 0)
709 + swa_start = tl.maximum(pos - (WINDOW_SIZE - 1) - left_add, 0)
710 + swa_len = pos + right - swa_start + 1
711
712 offset = tl.arange(0, PADDED_TOP_K)
713 mask = offset < topk_len
@@ -695,18 +720,19 @@ class CombineTopkSwaIndicesKernel(
720 topk_indices + M * batch_idx,
721 mask=mask,
722 )
698 - offset = tl.arange(0, WINDOW_SIZE)
723 # Index into gathered buffer: N + (position - gather_start)
700 - # For positions [pos - swa_len + 1, pos], the buffer indices are:
701 - # [N + pos - swa_len + 1 - gather_start, N + pos - gather_start]
702 - tl.store(
703 - combined_indices_ptr
704 - + token_idx * combined_indices_stride
705 - + topk_len
706 - + offset,
707 - M * batch_idx + N + offset + pos - swa_len + 1 - gather_start,
708 - mask=offset < swa_len,
709 - )
724 + # For positions [swa_start, pos + right], the buffer indices are:
725 + # [N + swa_start - gather_start, N + pos + right - gather_start]
726 + for i in range(0, WINDOW_SIZE + IMAGE_WIDTH, WINDOW_SIZE):
727 + swa_offset = i + tl.arange(0, WINDOW_SIZE)
728 + tl.store(
729 + combined_indices_ptr
730 + + token_idx * combined_indices_stride
731 + + topk_len
732 + + swa_offset,
733 + M * batch_idx + N + swa_start + swa_offset - gather_start,
734 + mask=swa_offset < swa_len,
735 + )
736
737 combined_len = topk_len + swa_len
738 tl.store(combined_lens_ptr + token_idx, combined_len)
@@ -722,6 +748,7 @@ class CombineTopkSwaIndicesKernel(
748 topk: int,
749 compress_ratio: int,
750 WINDOW_SIZE: int,
751 + image_width: int = 0,
752 ) -> CompileKey:
753 padded_topk = next_power_of_2(topk_width)
754 input_variant = TritonPointerInputVariant.from_alignment(
@@ -734,6 +761,7 @@ class CombineTopkSwaIndicesKernel(
761 TOP_K=topk,
762 COMPRESS_RATIO=compress_ratio,
763 WINDOW_SIZE=WINDOW_SIZE,
764 + IMAGE_WIDTH=image_width,
765 PADDED_TOP_K=padded_topk,
766 input_variant=input_variant,
767 )
@@ -743,10 +771,19 @@ class CombineTopkSwaIndicesKernel(
771 return []
772
773 window_size = _hf_config_int(vllm_config, "sliding_window", 128)
774 + image_width = (
775 + _hf_config_int(vllm_config, "vision_max_n_token", 0)
776 + if _hf_config_int(vllm_config, "vision_n_layers", 0) > 0
777 + else 0
778 + )
779 + # Warm both the plain-window variant (batches without image spans)
780 + # and the in-image bidirectional variant.
781 + image_widths = [0, image_width] if image_width > 0 else [0]
782 return self._trace_dispatch(self.dispatch)(
783 _DSV4_COMBINE_TOPK_SWA_WARMUP_INPUTS,
784 _COMBINE_TOPK_SWA_POINTER_INPUTS,
785 WINDOW_SIZE=window_size,
786 + image_width=image_widths,
787 )
788
789 def compile(self, compile_key: CompileKey) -> None:
@@ -763,11 +800,14 @@ class CombineTopkSwaIndicesKernel(
800 input_variant.pointer("query_start_loc", torch.int32),
801 input_variant.pointer("seq_lens", torch.int32),
802 input_variant.pointer("gather_lens", torch.int32),
803 + int32_ptr,
804 + int32_ptr,
805 1, # do not specialize M
806 1, # do not specialize N
807 TOP_K=compile_key.TOP_K,
808 COMPRESS_RATIO=compile_key.COMPRESS_RATIO,
809 WINDOW_SIZE=compile_key.WINDOW_SIZE,
810 + IMAGE_WIDTH=compile_key.IMAGE_WIDTH,
811 PADDED_TOP_K=compile_key.PADDED_TOP_K,
812 grid=(1, _COMBINE_TOPK_SWA_NUM_WORKERS),
813 )
@@ -786,8 +826,13 @@ class CombineTopkSwaIndicesKernel(
826 TOP_K: int,
827 COMPRESS_RATIO: int,
828 WINDOW_SIZE: int,
829 + left_visible: torch.Tensor | None = None,
830 + right_visible: torch.Tensor | None = None,
831 + max_image_tokens: int = 0,
832 ) -> None:
833 num_reqs = seq_lens.shape[0]
834 + has_image = left_visible is not None
835 + image_width = max_image_tokens if has_image else 0
836 self.kernel[(num_reqs, _COMBINE_TOPK_SWA_NUM_WORKERS)](
837 combined_indices,
838 combined_indices.stride(0),
@@ -797,11 +842,14 @@ class CombineTopkSwaIndicesKernel(
842 query_start_loc,
843 seq_lens,
844 gather_lens,
845 + left_visible if has_image else topk_indices,
846 + right_visible if has_image else topk_indices,
847 M,
848 N,
849 TOP_K=TOP_K,
850 COMPRESS_RATIO=COMPRESS_RATIO,
851 WINDOW_SIZE=WINDOW_SIZE,
852 + IMAGE_WIDTH=image_width,
853 PADDED_TOP_K=next_power_of_2(topk_indices.shape[-1]),
854 )
855
@@ -828,20 +876,33 @@ def build_flashinfer_mixed_sparse_indices(
876 decode_is_valid_token: torch.Tensor | None = None,
877 swa_block_span: int | None = None,
878 compressed_block_span: int | None = None,
879 + prefill_left_visible: torch.Tensor | None = None,
880 + prefill_right_visible: torch.Tensor | None = None,
881 + max_image_tokens: int = 0,
882 ) -> tuple[torch.Tensor, torch.Tensor]:
883 """Build the FlashInfer DSV4 sparse-index matrix for decode-first batches.
884
834 - Produces ``sparse_indices`` of shape ``[num_tokens, swa_index_width +
835 - padded_topk]`` (the first ``swa_index_width`` columns are SWA slot ids, the
885 + Produces ``sparse_indices`` of shape ``[num_tokens, swa_total_width +
886 + padded_topk]`` (the first ``swa_total_width`` columns are SWA slot ids, the
887 rest are compressed/top-k slot ids) and ``sparse_topk_lens`` (active length
888 per token). Decode tokens read precomputed SWA/compressed indices; prefill
889 tokens derive their SWA window from the position and translate local
890 compressed indices to global slots via the block tables.
891 +
892 + When ``prefill_left_visible``/``prefill_right_visible`` are given (vision
893 + variant), the SWA column region widens by ``max_image_tokens`` and prefill
894 + tokens inside an image span get a bidirectionally widened window; decode
895 + rows are padded with -1 across the extra columns.
896 """
897 assert decode_swa_indices.dtype == torch.int32
898 assert decode_swa_indices.dim() == 2
899 swa_index_width = decode_swa_indices.shape[-1]
900 assert swa_index_width >= window_size
901 + has_image = prefill_left_visible is not None
902 + image_width = max_image_tokens if has_image else 0
903 + swa_total_width = swa_index_width + image_width
904 + if has_image:
905 + assert prefill_right_visible is not None
906 if decode_compressed_topk_lens is not None:
907 assert decode_compressed_topk_lens.dtype == torch.int32
908 assert prefill_topk_indices.dtype == torch.int32
@@ -890,7 +951,7 @@ def build_flashinfer_mixed_sparse_indices(
951 padded_topk = max(topk, decode_compressed_topk)
952 padded_topk = (padded_topk + 3) // 4 * 4
953 sparse_indices = torch.empty(
893 - (num_tokens, swa_index_width + padded_topk),
954 + (num_tokens, swa_total_width + padded_topk),
955 dtype=torch.int32,
956 device=decode_swa_indices.device,
957 )
@@ -924,6 +985,8 @@ def build_flashinfer_mixed_sparse_indices(
985 decode_is_valid_token,
986 prefill_topk_indices,
987 prefill_topk_indices.stride(0),
988 + prefill_left_visible if has_image else token_to_req_indices,
989 + prefill_right_visible if has_image else token_to_req_indices,
990 query_start_loc,
991 seq_lens,
992 token_to_req_indices,
@@ -938,6 +1001,7 @@ def build_flashinfer_mixed_sparse_indices(
1001 NUM_DECODE_TOKENS=num_decode_tokens,
1002 WINDOW_SIZE=window_size,
1003 SWA_INDEX_WIDTH=swa_index_width,
1004 + IMAGE_WIDTH=image_width,
1005 COMPRESS_RATIO=compress_ratio,
1006 TOP_K=topk,
1007 PADDED_TOP_K=padded_topk,
@@ -992,6 +1056,8 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1056 decode_is_valid_token_ptr,
1057 prefill_topk_indices_ptr,
1058 prefill_topk_stride,
1059 + left_visible_ptr,
1060 + right_visible_ptr,
1061 query_start_loc_ptr,
1062 seq_lens_ptr,
1063 token_to_req_indices_ptr,
@@ -1006,6 +1072,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1072 NUM_DECODE_TOKENS,
1073 WINDOW_SIZE: tl.constexpr,
1074 SWA_INDEX_WIDTH: tl.constexpr,
1075 + IMAGE_WIDTH: tl.constexpr,
1076 COMPRESS_RATIO: tl.constexpr,
1077 TOP_K: tl.constexpr,
1078 PADDED_TOP_K: tl.constexpr,
@@ -1017,14 +1084,18 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1084 TOPK_BLOCK_SIZE: tl.constexpr,
1085 ):
1086 token_idx = tl.program_id(0)
1087 + # Total SWA column count; > SWA_INDEX_WIDTH only for in-image
1088 + # bidirectional visibility (vision variant), where the extra columns are
1089 + # -1-padded for decode rows.
1090 + SWA_TOTAL_WIDTH: tl.constexpr = SWA_INDEX_WIDTH + IMAGE_WIDTH
1091
1092 if token_idx < NUM_DECODE_TOKENS:
1022 - for i in range(0, SWA_INDEX_WIDTH, WINDOW_BLOCK_SIZE):
1093 + for i in range(0, SWA_TOTAL_WIDTH, WINDOW_BLOCK_SIZE):
1094 offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1024 - mask = offset < SWA_INDEX_WIDTH
1095 + mask = offset < SWA_TOTAL_WIDTH
1096 values = tl.load(
1097 decode_swa_indices_ptr + token_idx * decode_swa_stride + offset,
1027 - mask=mask,
1098 + mask=offset < SWA_INDEX_WIDTH,
1099 other=-1,
1100 )
1101 values = _remap_flashinfer_index(values, swa_block_size, swa_block_span)
@@ -1067,7 +1138,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1138 tl.store(
1139 sparse_indices_ptr
1140 + token_idx * sparse_indices_stride
1070 - + SWA_INDEX_WIDTH
1141 + + SWA_TOTAL_WIDTH
1142 + offset,
1143 values,
1144 mask=mask,
@@ -1081,7 +1152,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1152 else:
1153 compressed_len = tl.full((), DECODE_COMPRESSED_TOPK, dtype=tl.int32)
1154
1084 - tl.store(sparse_topk_lens_ptr + token_idx, SWA_INDEX_WIDTH + compressed_len)
1155 + tl.store(sparse_topk_lens_ptr + token_idx, SWA_TOTAL_WIDTH + compressed_len)
1156 return
1157
1158 prefill_idx = token_idx - NUM_DECODE_TOKENS
@@ -1093,13 +1164,22 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1164 start_pos = seq_len - query_len
1165 token_idx_in_query = token_idx - query_start
1166 pos = start_pos + token_idx_in_query
1096 - swa_len = tl.minimum(pos + 1, WINDOW_SIZE)
1097 - swa_start_pos = pos - swa_len + 1
1167 + if IMAGE_WIDTH > 0:
1168 + # In-image bidirectional visibility: window starts up to
1169 + # max(left - (window - 1), 0) earlier and extends `right` past pos.
1170 + left = tl.load(left_visible_ptr + token_idx)
1171 + right = tl.load(right_visible_ptr + token_idx)
1172 + else:
1173 + left = 0
1174 + right = 0
1175 + left_add = tl.maximum(left - (WINDOW_SIZE - 1), 0)
1176 + swa_start_pos = tl.maximum(pos - (WINDOW_SIZE - 1) - left_add, 0)
1177 + swa_len = pos + right - swa_start_pos + 1
1178 topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
1179
1100 - for i in range(0, SWA_INDEX_WIDTH, WINDOW_BLOCK_SIZE):
1180 + for i in range(0, SWA_TOTAL_WIDTH, WINDOW_BLOCK_SIZE):
1181 offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1102 - mask = offset < SWA_INDEX_WIDTH
1182 + mask = offset < SWA_TOTAL_WIDTH
1183 pos_offset = swa_start_pos + offset
1184 block_indices = pos_offset // swa_block_size
1185 block_numbers = tl.load(
@@ -1143,10 +1223,10 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1223 tl.store(
1224 sparse_indices_ptr
1225 + token_idx * sparse_indices_stride
1146 - + SWA_INDEX_WIDTH
1226 + + SWA_TOTAL_WIDTH
1227 + offset,
1228 slot_ids,
1229 mask=mask,
1230 )
1231
1152 - tl.store(sparse_topk_lens_ptr + token_idx, SWA_INDEX_WIDTH + topk_len)
1232 + tl.store(sparse_topk_lens_ptr + token_idx, SWA_TOTAL_WIDTH + topk_len)
vllm/models/deepseek_v4/common/vision.py new
+232
@@ -0,0 +1,232 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek-V4 vision tower (ViT + aligner) with TP-sharded linears.
4 +
5 +Ported from the official reference implementation
6 +(deepseek-ai/DeepSeek-V4-Flash-Vision-Exp). Weight names match the HF
7 +checkpoint so no renaming is needed at load time. Attention and MLP weights
8 +are tensor-parallel sharded (replicated when the vision head count is not
9 +divisible by TP size, or under ``--mm-encoder-tp-mode data``); the patch
10 +embed and norms are replicated, so the residual stream is full-width on
11 +every rank.
12 +"""
13 +
14 +from functools import lru_cache
15 +
16 +import torch
17 +import torch.nn.functional as F
18 +from torch import nn
19 +
20 +from vllm.distributed import get_tensor_model_parallel_world_size
21 +from vllm.model_executor.layers.activation import SiluAndMul
22 +from vllm.model_executor.layers.attention.mm_encoder_attention import (
23 + MMEncoderAttention,
24 +)
25 +from vllm.model_executor.layers.linear import (
26 + ColumnParallelLinear,
27 + MergedColumnParallelLinear,
28 + QKVParallelLinear,
29 + ReplicatedLinear,
30 + RowParallelLinear,
31 +)
32 +from vllm.model_executor.models.vision import is_vit_use_data_parallel
33 +
34 +
35 +@lru_cache(8)
36 +def get_vision_cos_sin(
37 + n_h: int, n_w: int, dim: int, theta: float
38 +) -> tuple[torch.Tensor, torch.Tensor]:
39 + inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
40 + hpos = torch.arange(n_h).unsqueeze(1).expand(n_h, n_w)
41 + wpos = torch.arange(n_w).unsqueeze(0).expand(n_h, n_w)
42 + freqs = torch.stack([hpos, wpos], dim=-1).reshape(-1, 2, 1).float()
43 + freqs = (freqs * inv_freq).flatten(1)
44 + return freqs.cos().unsqueeze(1), freqs.sin().unsqueeze(1)
45 +
46 +
47 +def apply_rotary(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
48 + dtype = x.dtype
49 + x1, x2 = x.float().chunk(2, dim=-1)
50 + return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1).to(dtype)
51 +
52 +
53 +class DeepseekV4RMSNorm(nn.Module):
54 + def __init__(self, dim: int, eps: float = 1e-6):
55 + super().__init__()
56 + self.eps = eps
57 + self.weight = nn.Parameter(torch.ones(dim, dtype=torch.float32))
58 +
59 + def forward(self, x: torch.Tensor) -> torch.Tensor:
60 + dtype = x.dtype
61 + x = x.float()
62 + x = x * torch.rsqrt(x.square().mean(-1, keepdim=True) + self.eps)
63 + return (self.weight * x).to(dtype)
64 +
65 +
66 +class DeepseekV4PatchEmbed(nn.Module):
67 + def __init__(self, config):
68 + super().__init__()
69 + # Replicated: the residual stream is full-width on every rank.
70 + self.proj = ReplicatedLinear(
71 + 3 * config.vision_patch_size**2,
72 + config.vision_dim,
73 + bias=True,
74 + quant_config=None,
75 + )
76 +
77 + def forward(self, x: torch.Tensor) -> torch.Tensor:
78 + out, _ = self.proj(x.flatten(1))
79 + return out
80 +
81 +
82 +class DeepseekV4VisionAttention(nn.Module):
83 + def __init__(self, config, prefix: str = ""):
84 + super().__init__()
85 + # Convention from minimax_m3/siglip: compute the TP/DP choice locally
86 + # instead of threading a flag through every constructor.
87 + use_data_parallel = is_vit_use_data_parallel(config.vision_n_heads)
88 + self.tp_size = (
89 + 1 if use_data_parallel else get_tensor_model_parallel_world_size()
90 + )
91 + self.n_heads = config.vision_n_heads // self.tp_size
92 + self.head_dim = config.vision_dim // config.vision_n_heads
93 + self.hidden_size = config.vision_dim
94 + self.wqkv = QKVParallelLinear(
95 + config.vision_dim,
96 + self.head_dim,
97 + config.vision_n_heads,
98 + bias=True,
99 + quant_config=None,
100 + prefix=f"{prefix}.wqkv",
101 + disable_tp=use_data_parallel,
102 + )
103 + self.wo = RowParallelLinear(
104 + config.vision_dim,
105 + config.vision_dim,
106 + bias=True,
107 + quant_config=None,
108 + prefix=f"{prefix}.wo",
109 + disable_tp=use_data_parallel,
110 + )
111 + self.attn = MMEncoderAttention(
112 + num_heads=self.n_heads,
113 + head_size=self.head_dim,
114 + prefix=f"{prefix}.attn",
115 + )
116 +
117 + def forward(
118 + self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
119 + ) -> torch.Tensor:
120 + n = x.size(0)
121 + qkv, _ = self.wqkv(x)
122 + q, k, v = (t.view(n, self.n_heads, self.head_dim) for t in qkv.chunk(3, -1))
123 + q = apply_rotary(q, cos, sin).unsqueeze(0) # (b=1, n, h, d)
124 + k = apply_rotary(k, cos, sin).unsqueeze(0)
125 + # One image per call: a dense batch, no varlen packing metadata.
126 + o = self.attn(q, k, v.unsqueeze(0))
127 + out, _ = self.wo(o.reshape(n, -1))
128 + return out
129 +
130 +
131 +class DeepseekV4VisionMLP(nn.Module):
132 + def __init__(self, config, prefix: str = ""):
133 + super().__init__()
134 + use_data_parallel = is_vit_use_data_parallel(config.vision_n_heads)
135 + self.w1 = MergedColumnParallelLinear(
136 + config.vision_dim,
137 + [config.vision_inter_dim] * 2,
138 + bias=False,
139 + quant_config=None,
140 + prefix=f"{prefix}.w1",
141 + disable_tp=use_data_parallel,
142 + )
143 + self.w2 = RowParallelLinear(
144 + config.vision_inter_dim,
145 + config.vision_dim,
146 + bias=False,
147 + quant_config=None,
148 + prefix=f"{prefix}.w2",
149 + disable_tp=use_data_parallel,
150 + )
151 + self.act_fn = SiluAndMul()
152 +
153 + def forward(self, x: torch.Tensor) -> torch.Tensor:
154 + gate_up, _ = self.w1(x)
155 + out, _ = self.w2(self.act_fn(gate_up))
156 + return out
157 +
158 +
159 +class DeepseekV4VisionBlock(nn.Module):
160 + def __init__(self, config, prefix: str = ""):
161 + super().__init__()
162 + self.norm1 = DeepseekV4RMSNorm(config.vision_dim)
163 + self.attn = DeepseekV4VisionAttention(config, prefix=f"{prefix}.attn")
164 + self.norm2 = DeepseekV4RMSNorm(config.vision_dim)
165 + self.mlp = DeepseekV4VisionMLP(config, prefix=f"{prefix}.mlp")
166 +
167 + def forward(
168 + self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
169 + ) -> torch.Tensor:
170 + x = x + self.attn(self.norm1(x), cos, sin)
171 + return x + self.mlp(self.norm2(x))
172 +
173 +
174 +class DeepseekV4ViT(nn.Module):
175 + """DeepSeek-V4 ViT: full bidirectional attention per image, 2D RoPE."""
176 +
177 + def __init__(self, config):
178 + super().__init__()
179 + self.rope_dim = config.vision_dim // config.vision_n_heads // 2
180 + self.rope_theta = config.vision_rope_theta
181 + self.patch_embed = DeepseekV4PatchEmbed(config)
182 + self.blocks = nn.ModuleList(
183 + [
184 + DeepseekV4VisionBlock(config, prefix=f"blocks.{i}")
185 + for i in range(config.vision_n_layers)
186 + ]
187 + )
188 + self.norm = DeepseekV4RMSNorm(config.vision_dim)
189 +
190 + def forward(
191 + self, patches: torch.Tensor, n_vit_h: int, n_vit_w: int
192 + ) -> torch.Tensor:
193 + x = self.patch_embed(patches)
194 + cos, sin = get_vision_cos_sin(n_vit_h, n_vit_w, self.rope_dim, self.rope_theta)
195 + cos = cos.to(device=x.device)
196 + sin = sin.to(device=x.device)
197 + for block in self.blocks:
198 + x = block(x, cos, sin)
199 + return self.norm(x)
200 +
201 +
202 +class DeepseekV4Aligner(nn.Module):
203 + """Spatial merge (downsample_ratio x downsample_ratio) + MLP projector."""
204 +
205 + def __init__(self, config):
206 + super().__init__()
207 + use_data_parallel = is_vit_use_data_parallel(config.vision_n_heads)
208 + self.downsample_ratio = config.vision_downsample_ratio
209 + in_dim = config.vision_dim * self.downsample_ratio**2
210 + self.w1 = ColumnParallelLinear(
211 + in_dim,
212 + config.hidden_size,
213 + bias=True,
214 + quant_config=None,
215 + disable_tp=use_data_parallel,
216 + )
217 + self.w2 = RowParallelLinear(
218 + config.hidden_size,
219 + config.hidden_size,
220 + bias=True,
221 + quant_config=None,
222 + disable_tp=use_data_parallel,
223 + )
224 +
225 + def forward(self, x: torch.Tensor, n_vit_h: int, n_vit_w: int) -> torch.Tensor:
226 + r = self.downsample_ratio
227 + x = x.view(n_vit_h, n_vit_w, -1).permute(2, 0, 1)
228 + x = F.pad(x, (0, -n_vit_w % r, 0, -n_vit_h % r))
229 + x = F.unfold(x.unsqueeze(0), r, stride=r).squeeze(0).transpose(0, 1)
230 + hidden, _ = self.w1(x)
231 + out, _ = self.w2(F.gelu(hidden))
232 + return out
vllm/models/deepseek_v4/nvidia/flashinfer_sparse.py
+4
@@ -435,6 +435,10 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
435 decode_is_valid_token=decode_is_valid_token,
436 swa_block_span=swa_block_span,
437 compressed_block_span=compressed_block_span,
438 + prefill_left_visible=swa_metadata.prefill_left_visible,
439 + prefill_right_visible=swa_metadata.prefill_right_visible,
440 + # getattr for tests that bypass __init__ via object.__new__.
441 + max_image_tokens=getattr(self, "max_image_tokens", 0),
442 )
443 if cache_key != "c4a":
444 swa_metadata.flashinfer_sparse_index_cache[cache_key] = (
vllm/models/deepseek_v4/nvidia/flashmla.py
+19 -2
@@ -119,7 +119,9 @@ class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
119 else:
120 assert self.topk_indices_buffer is not None
121 top_k = self.topk_indices_buffer.shape[-1]
122 - combined_topk = round_up(top_k + self.window_size, 128)
122 + combined_topk = round_up(
123 + top_k + self.window_size + self.max_image_tokens, 128
124 + )
125 current_workspace_manager().get_simultaneous(
126 ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
127 ((self.max_num_batched_tokens, combined_topk), torch.int32),
@@ -311,7 +313,7 @@ class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
313 )
314 assert chunk_plan, "prefill chunk plan must be non-empty when num_prefills > 0"
315 workspace_manager = current_workspace_manager()
314 - combined_topk = round_up(top_k + self.window_size, 128)
316 + combined_topk = round_up(top_k + self.window_size + self.max_image_tokens, 128)
317 for chunk_start, chunk_end, chunk_N, chunk_M in chunk_plan:
318 chunk_size = chunk_end - chunk_start
319 workspace = workspace_manager.get_simultaneous(
@@ -369,6 +371,21 @@ class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
371 chunk_M,
372 chunk_N,
373 out=(combined_indices_out, combined_lens_out),
374 + left_visible=(
375 + swa_metadata.prefill_left_visible[
376 + num_decode_tokens + query_start : num_decode_tokens + query_end
377 + ]
378 + if swa_metadata.prefill_left_visible is not None
379 + else None
380 + ),
381 + right_visible=(
382 + swa_metadata.prefill_right_visible[
383 + num_decode_tokens + query_start : num_decode_tokens + query_end
384 + ]
385 + if swa_metadata.prefill_right_visible is not None
386 + else None
387 + ),
388 + max_image_tokens=self.max_image_tokens,
389 )
390 flash_mla_sparse_fwd(
391 q=q[query_start:query_end],
vllm/models/deepseek_v4/nvidia/model.py
+30 -1
@@ -92,6 +92,8 @@ from vllm.utils.math_utils import cdiv
92 from vllm.v1.attention.backends.registry import AttentionBackendEnum
93 from vllm.v1.worker.ubatching import dbo_current_ubatch_id
94
95 +from ..common.mm_preprocess import IMAGE_SENTINEL_BASE_ID
96 +
97 logger = init_logger(__name__)
98
99
@@ -798,6 +800,12 @@ class DeepseekV4MoE(nn.Module):
800
801 self.gate.e_score_correction_bias = None
802 self.gate.tid2eid = None
803 + self.gate.bias_vl = None
804 + # Image tokens borrow five consecutive reserved in-vocab ids starting
805 + # at IMAGE_SENTINEL_BASE_ID; 0 disables vision routing (text model).
806 + self.image_sentinel_lo = (
807 + IMAGE_SENTINEL_BASE_ID if getattr(config, "vision_n_layers", 0) > 0 else 0
808 + )
809 is_hash_moe = extract_layer_index(prefix) < config.num_hash_layers
810 self.hash_indices_dtype = torch.int64 if self.use_mega_moe else torch.int32
811 if is_hash_moe:
@@ -813,12 +821,25 @@ class DeepseekV4MoE(nn.Module):
821 ),
822 requires_grad=False,
823 )
816 - elif getattr(config, "topk_method", None) == "noaux_tc":
824 + if getattr(config, "topk_method", None) == "noaux_tc" and (
825 + not is_hash_moe or getattr(config, "vision_n_layers", 0) > 0
826 + ):
827 + # Vision checkpoints ship a gate bias on hash layers too (it is
828 + # unused for routing there; image tokens use bias_vl instead).
829 self.gate.e_score_correction_bias = nn.Parameter(
830 torch.empty(config.n_routed_experts, dtype=torch.float32),
831 requires_grad=False,
832 )
833
834 + if getattr(config, "vision_n_layers", 0) > 0:
835 + # Vision checkpoints route image sentinel tokens with bias_vl
836 + # instead of e_score_correction_bias / the hash table. Created on
837 + # every MoE layer, hash layers included.
838 + self.gate.bias_vl = nn.Parameter(
839 + torch.empty(config.n_routed_experts, dtype=torch.float32),
840 + requires_grad=False,
841 + )
842 +
843 if config.n_shared_experts is None:
844 self.shared_experts = None
845 else:
@@ -950,6 +971,8 @@ class DeepseekV4MoE(nn.Module):
971 routed_scaling_factor=self.routed_scaling_factor,
972 e_score_correction_bias=self.gate.e_score_correction_bias,
973 hash_indices_table=self.gate.tid2eid,
974 + bias_vl=getattr(self.gate, "bias_vl", None),
975 + image_sentinel_lo=self.image_sentinel_lo,
976 swiglu_limit=self.swiglu_limit,
977 router_logits_dtype=torch.float32,
978 enable_eplb=parallel_config.enable_eplb,
@@ -962,6 +985,10 @@ class DeepseekV4MoE(nn.Module):
985 ) -> torch.Tensor:
986 if self.gate.tid2eid is not None and input_ids is None:
987 raise ValueError("DeepSeek V4 hash MoE routing requires input_ids.")
988 + # getattr for test doubles that fake the gate module.
989 + bias_vl = getattr(self.gate, "bias_vl", None)
990 + if bias_vl is not None and input_ids is None:
991 + raise ValueError("DeepSeek V4 vision MoE routing requires input_ids.")
992
993 if not self.use_mega_moe:
994 return self._forward_fused_moe(hidden_states, input_ids)
@@ -981,6 +1008,8 @@ class DeepseekV4MoE(nn.Module):
1008 input_tokens=input_ids,
1009 hash_indices_table=self.gate.tid2eid,
1010 routed_scaling_factor=self.routed_scaling_factor,
1011 + bias_vl=bias_vl.data if bias_vl is not None else None,
1012 + image_sentinel_lo=self.image_sentinel_lo if bias_vl is not None else 0,
1013 )
1014 activation_clamp = (
1015 float(self.swiglu_limit) if self.swiglu_limit is not None else None
vllm/models/deepseek_v4/nvidia/mtp.py
+1 -1
@@ -178,7 +178,7 @@ class DeepSeekV4MultiTokenPredictorLayer(nn.Module):
178 inputs_embeds
179 ).unsqueeze(-2)
180 hidden_states, residual, post_mix, res_mix = self.mtp_block(
181 - positions=positions, x=hidden_states, input_ids=None
181 + positions=positions, x=hidden_states, input_ids=input_ids
182 )
183 hidden_states = mhc_post_tilelang(hidden_states, residual, post_mix, res_mix)
184 if self.mtp_block.use_sequence_parallel:
vllm/models/deepseek_v4/nvidia/vl_model.py new
+333
@@ -0,0 +1,333 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek-V4 vision variant (e.g. DeepSeek-V4-Flash-Vision-Exp).
4 +
5 +Thin multimodal wrapper around the text-only ``DeepseekV4ForCausalLM``:
6 +
7 +- ``vision`` ViT + ``aligner`` produce per-image embeddings for the IMAGE
8 + sentinel positions; four learned vectors (``image_start`` / ``image_pad`` /
9 + ``image_newline`` / ``image_end``) fill the remaining sentinel positions.
10 +- Image placeholders (``<|deepseek_image|>``) are expanded by the processor
11 + in ``common/mm_preprocess.py`` into sentinel blocks borrowing reserved
12 + in-vocab tokens ``<|place_holder_mm_span_0431|>``..``_0435|>``
13 + (see ``common/vision.py`` for the tower itself).
14 +- Merged embeddings enter the text model via ``inputs_embeds``, i.e. before
15 + its hyper-connection stream expansion. Raw ``input_ids`` still flow into
16 + the model so the MoE router can apply ``bias_vl`` to image tokens
17 + (``requires_raw_input_tokens``).
18 +"""
19 +
20 +from collections.abc import Iterable
21 +
22 +import torch
23 +from torch import nn
24 +
25 +from vllm.model_executor.models.interfaces import (
26 + MultiModalEmbeddings,
27 + SupportsEagle3,
28 + SupportsMultiModal,
29 + SupportsPP,
30 +)
31 +from vllm.model_executor.models.utils import (
32 + AutoWeightsLoader,
33 + WeightsMapper,
34 + init_vllm_registered_model,
35 + maybe_prefix,
36 +)
37 +from vllm.multimodal import MULTIMODAL_REGISTRY
38 +
39 +from ..common.mm_preprocess import (
40 + IMAGE_PLACEHOLDER,
41 + IMAGE_SENTINEL_BASE_ID,
42 + DeepseekV4VLDummyInputsBuilder,
43 + DeepseekV4VLMultiModalProcessor,
44 + DeepseekV4VLProcessingInfo,
45 + image_sentinel_mask,
46 +)
47 +from ..common.vision import DeepseekV4Aligner, DeepseekV4ViT
48 +from .model import _make_deepseek_v4_weights_mapper
49 +
50 +
51 +def _make_deepseek_v4_vl_weights_mapper(
52 + expert_dtype: str, image_enabled: bool
53 +) -> WeightsMapper:
54 + """Text-checkpoint mapping rules re-rooted under ``language_model.``."""
55 + base = _make_deepseek_v4_weights_mapper(expert_dtype)
56 + orig_to_new_prefix: dict[str, str | None] = {
57 + "layers.": "language_model.model.layers.",
58 + "embed.": "language_model.model.embed.",
59 + "norm.": "language_model.model.norm.",
60 + "hc_head": "language_model.model.hc_head",
61 + "mtp.": "language_model.model.mtp.",
62 + }
63 + if not image_enabled:
64 + orig_to_new_prefix.update({"vision.": None, "aligner.": None, "image_": None})
65 + return WeightsMapper(
66 + orig_to_new_prefix=orig_to_new_prefix,
67 + orig_to_new_regex=base.orig_to_new_regex,
68 + orig_to_new_suffix={
69 + "head.weight": "language_model.lm_head.weight",
70 + "embed.weight": "embed_tokens.weight",
71 + ".ffn.gate.bias": ".ffn.gate.e_score_correction_bias",
72 + },
73 + orig_to_new_substr={
74 + ".shared_experts.w2": ".shared_experts.down_proj",
75 + # The MTP/DSpark draft heads are not supported for the vision
76 + # variant; drop their weights.
77 + "mtp.": None,
78 + },
79 + )
80 +
81 +
82 +@MULTIMODAL_REGISTRY.register_processor(
83 + DeepseekV4VLMultiModalProcessor,
84 + info=DeepseekV4VLProcessingInfo,
85 + dummy_inputs=DeepseekV4VLDummyInputsBuilder,
86 +)
87 +class DeepseekV4ForConditionalGeneration(
88 + nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3
89 +):
90 + """Multimodal entry point for DeepSeek-V4 checkpoints with a vision tower.
91 +
92 + ``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters)
93 + delegates through ``language_model`` via the protocol defaults.
94 + """
95 +
96 + # The MoE router needs raw token ids to detect image sentinel tokens
97 + # (borrowed reserved ids, see common/mm_preprocess.py) and apply bias_vl.
98 + requires_raw_input_tokens = True
99 +
100 + @classmethod
101 + def get_placeholder_str(cls, modality: str, i: int) -> str | None:
102 + if modality == "image":
103 + return IMAGE_PLACEHOLDER
104 + raise ValueError(f"Unsupported modality: {modality!r}")
105 +
106 + def __init__(self, *, vllm_config, prefix: str = "") -> None:
107 + super().__init__()
108 + model_config = vllm_config.model_config
109 + config = model_config.hf_config
110 + self.config = config
111 + self.multimodal_config = model_config.multimodal_config
112 + assert self.multimodal_config is not None
113 +
114 + image_enabled = (
115 + config.vision_n_layers > 0
116 + and self.multimodal_config.get_limit_per_prompt("image") > 0
117 + )
118 + with self._mark_tower_model(vllm_config, {"image"}):
119 + self.vision: DeepseekV4ViT | None = None
120 + self.aligner: DeepseekV4Aligner | None = None
121 + self.image_start: nn.Parameter | None = None
122 + self.image_end: nn.Parameter | None = None
123 + self.image_newline: nn.Parameter | None = None
124 + self.image_pad: nn.Parameter | None = None
125 + if image_enabled:
126 + self.vision = DeepseekV4ViT(config)
127 + self.aligner = DeepseekV4Aligner(config)
128 + for name in (
129 + "image_start",
130 + "image_end",
131 + "image_newline",
132 + "image_pad",
133 + ):
134 + setattr(
135 + self,
136 + name,
137 + nn.Parameter(
138 + torch.empty(config.hidden_size, dtype=torch.float32)
139 + ),
140 + )
141 + self.vision.to(dtype=model_config.dtype)
142 + self.aligner.to(dtype=model_config.dtype)
143 +
144 + with self._mark_language_model(vllm_config):
145 + # The arch convertor routes any config with a vision tower to
146 + # this wrapper class; mark the copy handed to the text backbone
147 + # so it resolves to DeepseekV4ForCausalLM instead of recursing
148 + # (with_hf_config deepcopies the config, the marker survives).
149 + config._dsv4_vl_inner = True # type: ignore[attr-defined]
150 + try:
151 + self.language_model = init_vllm_registered_model(
152 + vllm_config=vllm_config,
153 + hf_config=config,
154 + prefix=maybe_prefix(prefix, "language_model"),
155 + architectures=["DeepseekV4ForCausalLM"],
156 + )
157 + finally:
158 + del config._dsv4_vl_inner # type: ignore[attr-defined]
159 + # The outer mapper (see load_weights) fully resolves HF names into
160 + # this wrapper's namespace before AutoWeightsLoader strips the
161 + # "language_model." prefix and delegates to the child's load_weights,
162 + # so the child's own mapper must be a no-op. Its suffix rules are not
163 + # idempotent (e.g. "lm_head.weight".endswith("head.weight") would
164 + # re-fire "head.weight" -> "lm_head.weight").
165 + self.language_model.hf_to_vllm_mapper = WeightsMapper()
166 + self.make_empty_intermediate_tensors = ( # type: ignore[method-assign]
167 + self.language_model.make_empty_intermediate_tensors
168 + )
169 +
170 + expert_dtype = getattr(config, "expert_dtype", "fp4")
171 + self.hf_to_vllm_mapper = _make_deepseek_v4_vl_weights_mapper(
172 + expert_dtype, image_enabled
173 + )
174 +
175 + def _parse_and_validate_image_input(self, **kwargs: object) -> dict | None:
176 + patches = kwargs.pop("patches", None)
177 + if patches is None:
178 + return None
179 + vit_grid = kwargs.pop("vit_grid", None)
180 + llm_grid = kwargs.pop("llm_grid", None)
181 + perm = kwargs.pop("perm", None)
182 + assert vit_grid is not None and llm_grid is not None and perm is not None
183 + return {
184 + "patches": patches,
185 + "vit_grid": vit_grid,
186 + "llm_grid": llm_grid,
187 + "perm": perm,
188 + }
189 +
190 + def _encode_image(
191 + self,
192 + patches: torch.Tensor,
193 + n_vit_h: int,
194 + n_vit_w: int,
195 + perm: torch.Tensor,
196 + ) -> torch.Tensor:
197 + assert self.vision is not None and self.aligner is not None
198 + image_embeds = self.aligner(
199 + self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w
200 + )
201 + # Reorder into the N-layout block order used in the prompt.
202 + return image_embeds[perm.to(image_embeds.device)]
203 +
204 + def _process_image_input(
205 + self,
206 + patches: torch.Tensor,
207 + vit_grid: torch.Tensor,
208 + llm_grid: torch.Tensor,
209 + perm: torch.Tensor,
210 + ) -> tuple[torch.Tensor, ...]:
211 + assert self.vision is not None and self.aligner is not None
212 + patches = patches.to(self.aligner.w1.weight.dtype)
213 +
214 + embeds: list[torch.Tensor] = []
215 + vit_offset = 0
216 + llm_offset = 0
217 + for (n_vit_h, n_vit_w), (n_llm_h, n_llm_w) in zip(
218 + vit_grid.tolist(), llm_grid.tolist(), strict=True
219 + ):
220 + n_vit = n_vit_h * n_vit_w
221 + n_llm = n_llm_h * n_llm_w
222 + embeds.append(
223 + self._encode_image(
224 + patches[vit_offset : vit_offset + n_vit],
225 + n_vit_h,
226 + n_vit_w,
227 + perm[llm_offset : llm_offset + n_llm],
228 + )
229 + )
230 + vit_offset += n_vit
231 + llm_offset += n_llm
232 + return tuple(embeds)
233 +
234 + def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
235 + image_input = self._parse_and_validate_image_input(**kwargs)
236 + if image_input is None or self.vision is None:
237 + return []
238 + return self._process_image_input(
239 + image_input["patches"],
240 + image_input["vit_grid"],
241 + image_input["llm_grid"],
242 + image_input["perm"],
243 + )
244 +
245 + def embed_input_ids(
246 + self,
247 + input_ids: torch.Tensor,
248 + multimodal_embeddings: MultiModalEmbeddings | None = None,
249 + *,
250 + is_multimodal: torch.Tensor | None = None,
251 + ) -> torch.Tensor:
252 + from vllm.model_executor.models.utils import _merge_multimodal_embeddings
253 +
254 + # All ids are in-vocab here: image-block sentinels are borrowed
255 + # reserved tokens (their embedding rows are always overwritten below).
256 + inputs_embeds = self.language_model.embed_input_ids(input_ids)
257 +
258 + if self.image_start is not None:
259 + # Branch-free sentinel overwrite: safe inside compiled/captured
260 + # regions (no data-dependent control flow).
261 + sentinel_mask = image_sentinel_mask(input_ids)
262 + if is_multimodal is not None:
263 + # IMAGE positions get vision embeddings via the merge below.
264 + sentinel_mask = sentinel_mask & ~is_multimodal.to(input_ids.device)
265 + table = torch.stack(
266 + [
267 + self.image_start,
268 + self.image_pad,
269 + self.image_pad,
270 + self.image_newline,
271 + self.image_end,
272 + ]
273 + ).to(inputs_embeds.dtype)
274 + idx = (input_ids - IMAGE_SENTINEL_BASE_ID).clamp(0, 4)
275 + inputs_embeds = torch.where(
276 + sentinel_mask.unsqueeze(-1), table[idx], inputs_embeds
277 + )
278 +
279 + if multimodal_embeddings is None or len(multimodal_embeddings) == 0:
280 + return inputs_embeds
281 +
282 + assert is_multimodal is not None
283 + return _merge_multimodal_embeddings(
284 + inputs_embeds=inputs_embeds,
285 + multimodal_embeddings=multimodal_embeddings,
286 + is_multimodal=is_multimodal,
287 + )
288 +
289 + def forward(
290 + self,
291 + input_ids: torch.Tensor,
292 + positions: torch.Tensor,
293 + intermediate_tensors=None,
294 + inputs_embeds: torch.Tensor | None = None,
295 + **kwargs,
296 + ) -> torch.Tensor:
297 + return self.language_model(
298 + input_ids, positions, intermediate_tensors, inputs_embeds
299 + )
300 +
301 + def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
302 + return self.language_model.compute_logits(hidden_states)
303 +
304 + def compute_logits_local(self, hidden_states: torch.Tensor) -> torch.Tensor:
305 + return self.language_model.compute_logits_local(hidden_states)
306 +
307 + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
308 + return self.language_model.get_expert_mapping()
309 +
310 + def get_mtp_target_hidden_states(self) -> torch.Tensor | None:
311 + """Pre-hc_head residual stream buffer for the MTP/DSpark draft model."""
312 + return self.language_model.get_mtp_target_hidden_states()
313 +
314 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
315 + # Map HF names into this wrapper's namespace up front and sort, so
316 + # the "language_model." group reaches the child loader as one
317 + # contiguous block (AutoWeightsLoader delegates per contiguous group,
318 + # and the child's load_weights finalizes fused expert weights, which
319 + # must not run on a partially loaded model).
320 + mapped = sorted(self.hf_to_vllm_mapper.apply(weights), key=lambda x: x[0])
321 + loader = AutoWeightsLoader(self)
322 + loaded_params = loader.load_weights(mapped)
323 + # The child's load_weights already ran its post-load finalization.
324 + self._weights_finalized = True
325 + return loaded_params
326 +
327 + def process_weights_after_loading(self) -> None:
328 + # Model-level post-load hook (called by the loader after any load
329 + # format). Under DummyModelLoader the child's load_weights — and
330 + # hence its finalize step — is bypassed, so run it here instead.
331 + if getattr(self, "_weights_finalized", False):
332 + return
333 + self.language_model.process_weights_after_loading()
vllm/models/deepseek_v4/vl_stub.py new
+14
@@ -0,0 +1,14 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Stub for platforms where the DeepSeek-V4 vision variant is unsupported."""
4 +
5 +from torch import nn
6 +
7 +
8 +class DeepseekV4ForConditionalGeneration(nn.Module):
9 + def __init__(self, *, vllm_config, prefix: str = ""):
10 + super().__init__()
11 + raise NotImplementedError(
12 + "DeepSeek-V4 vision (DeepseekV4ForConditionalGeneration) is only "
13 + "supported on NVIDIA GPUs for now."
14 + )
vllm/tokenizers/deepseek_v4_encoding.py
+35 -3
@@ -28,6 +28,10 @@ USER_SP_TOKEN = "<|User|>"
28 ASSISTANT_SP_TOKEN = "<|Assistant|>"
29 LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
30
31 +# Placeholder text inlined at each image's position; the multimodal processor
32 +# later expands it into the sentinel token block.
33 +IMAGE_PLACEHOLDER = "<|deepseek_image|>"
34 +
35 # Task special tokens for internal classification tasks
36 DS_TASK_SP_TOKENS = {
37 "action": "<|action|>",
@@ -199,6 +203,30 @@ def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
203 # Message Rendering
204 # ============================================================
205
206 +def flatten_content_blocks(content: Any) -> Any:
207 + """Flatten OpenAI-style content blocks to plain text.
208 +
209 + Image blocks are inlined as IMAGE_PLACEHOLDER at their position. The image
210 + data itself travels out of band (multi_modal_data) and is matched to
211 + placeholders by order. Plain-string content passes through unchanged.
212 + """
213 + if not isinstance(content, list):
214 + return content
215 + parts: List[str] = []
216 + for block in content:
217 + if not isinstance(block, dict):
218 + parts.append(str(block))
219 + elif block.get("type") in ("image", "image_url"):
220 + parts.append(IMAGE_PLACEHOLDER)
221 + elif block.get("type") == "text":
222 + parts.append(block.get("text", ""))
223 + else:
224 + raise ValueError(
225 + f"Unsupported content block type: {block.get('type')!r}"
226 + )
227 + return "".join(parts)
228 +
229 +
230 def render_message(
231 index: int,
232 messages: List[Dict[str, Any]],
@@ -292,6 +320,8 @@ def render_message(
320 block_type = block.get("type")
321 if block_type == "text":
322 parts.append(block.get("text", ""))
323 + elif block_type in ("image", "image_url"):
324 + parts.append(IMAGE_PLACEHOLDER)
325 elif block_type == "tool_result":
326 tool_content = block.get("content", "")
327 if isinstance(tool_content, list):
@@ -307,7 +337,7 @@ def render_message(
337 parts.append(f"[Unsupported {block_type}]")
338 prompt += "\n\n".join(parts)
339 else:
310 - prompt += content or ""
340 + prompt += flatten_content_blocks(content) or ""
341
342 elif role == "latest_reminder":
343 prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)
@@ -447,13 +477,15 @@ def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
477 "content_blocks": [tool_block],
478 })
479 elif role == "user":
450 - text_block = {"type": "text", "text": msg.get("content", "")}
480 + # Flatten OpenAI-style content blocks (images become placeholders)
481 + text_content = flatten_content_blocks(msg.get("content", ""))
482 + text_block = {"type": "text", "text": text_content}
483 if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None:
484 merged[-1]["content_blocks"].append(text_block)
485 else:
486 new_msg = {
487 "role": "user",
456 - "content": msg.get("content", ""),
488 + "content": text_content,
489 "content_blocks": [text_block],
490 }
491 # Preserve extra fields (task, wo_eos, mask, etc.)
vllm/transformers_utils/configs/deepseek_v4.py
+29
@@ -14,10 +14,39 @@ class DeepseekV4Config(PretrainedConfig):
14 rope_scaling: dict[str, Any] | None = None,
15 rope_parameters: dict[str, Any] | None = None,
16 rope_theta: float = 10000.0,
17 + vision_n_layers: int = 0,
18 + vision_dim: int = 1024,
19 + vision_n_heads: int = 16,
20 + vision_inter_dim: int = 2816,
21 + vision_patch_size: int = 14,
22 + vision_rope_theta: float = 10000.0,
23 + vision_downsample_ratio: int = 3,
24 + vision_max_n_token: int = 384,
25 + vision_min_pixels: int = 147456,
26 + vision_max_wh_ratio: float = 8,
27 **kwargs,
28 ):
29 self.max_position_embeddings = max_position_embeddings
30 self.rope_scaling = rope_scaling
31 self.rope_theta = rope_theta
32 self.rope_parameters = rope_scaling or rope_parameters
33 + # Vision tower config; vision_n_layers == 0 means text-only.
34 + self.vision_n_layers = vision_n_layers
35 + self.vision_dim = vision_dim
36 + self.vision_n_heads = vision_n_heads
37 + self.vision_inter_dim = vision_inter_dim
38 + self.vision_patch_size = vision_patch_size
39 + self.vision_rope_theta = vision_rope_theta
40 + self.vision_downsample_ratio = vision_downsample_ratio
41 + self.vision_max_n_token = vision_max_n_token
42 + self.vision_min_pixels = vision_min_pixels
43 + self.vision_max_wh_ratio = vision_max_wh_ratio
44 + # The sparse-SWA index kernels widen the window within image spans
45 + # in-kernel, so mm-prefix ranges longer than sliding_window must be
46 + # kept (they are only consumed by those kernels).
47 + self.mm_prefix_clamp_sliding_window = vision_n_layers > 0
48 + # The visibility span covers the sentinel block [IMAGE_START,
49 + # IMAGE_END]; the mm placeholder additionally carries a leading
50 + # compressor-alignment pad (see mm_preprocess.COMPRESS_PAD_TO).
51 + self.mm_prefix_span_leading_pad_modulus = 4 if vision_n_layers > 0 else 0
52 super().__init__(**kwargs)
vllm/transformers_utils/model_arch_config_convertor.py
+35
@@ -579,6 +579,40 @@ class DeepSeekMTPModelArchConfigConvertor(ModelArchConfigConvertorBase):
579 return getattr(self.hf_text_config, "num_nextn_predict_layers", 0)
580
581
582 +class DeepseekV4ModelArchConfigConvertor(ModelArchConfigConvertorBase):
583 + def __init__(
584 + self,
585 + hf_config: PretrainedConfig,
586 + hf_text_config: PretrainedConfig,
587 + revision: str | None = None,
588 + ):
589 + # DeepSeek-V4-Flash-Vision-Exp ships the same architectures/model_type
590 + # as the text-only DeepSeek-V4-Flash; route to the VL wrapper class
591 + # when the config carries a vision tower. Mutate (not just override
592 + # get_architectures) because model-class resolution reads the raw
593 + # hf_config.architectures (get_model_architecture).
594 + # Only rewrite the stock text architecture: speculative-draft configs
595 + # (DSparkDraftModel / DeepSeekV4MTPModel) keep their own classes, and
596 + # the VL wrapper marks the config copy it hands to the inner text
597 + # backbone with _dsv4_vl_inner so this rewrite does not recurse.
598 + if (
599 + getattr(hf_config, "vision_n_layers", 0) > 0
600 + and getattr(hf_config, "architectures", None) == ["DeepseekV4ForCausalLM"]
601 + and not getattr(hf_config, "_dsv4_vl_inner", False)
602 + ):
603 + hf_config.architectures = ["DeepseekV4ForConditionalGeneration"]
604 + super().__init__(hf_config, hf_text_config, revision)
605 +
606 + def is_mm_prefix_lm(self, supports_multimodal: bool = True) -> bool:
607 + # The vision variant needs the mm-prefix plumbing: it makes the
608 + # scheduler prefill image spans atomically (disable_chunked_mm_input)
609 + # and routes per-request image ranges to the sparse-SWA metadata
610 + # builder, which widens the sliding window bidirectionally in-kernel.
611 + if not supports_multimodal:
612 + return False
613 + return getattr(self.hf_config, "vision_n_layers", 0) > 0
614 +
615 +
616 class MimoMTPModelArchConfigConvertor(ModelArchConfigConvertorBase):
617 def get_num_hidden_layers(self) -> int:
618 return getattr(self.hf_text_config, "num_nextn_predict_layers", 0)
@@ -775,6 +809,7 @@ MODEL_ARCH_CONFIG_CONVERTORS = {
809 "cohere_asr": CohereAsrModelArchConfigConvertor,
810 "dbrx": DbrxModelArchConfigConvertor,
811 "deepseek_mtp": DeepSeekMTPModelArchConfigConvertor,
812 + "deepseek_v4": DeepseekV4ModelArchConfigConvertor,
813 "diffusion_gemma_text": Gemma4ModelArchConfigConvertor,
814 "ernie_mtp": ErnieMTPModelArchConfigConvertor,
815 "falcon": FalconModelArchConfigConvertor,
vllm/v1/attention/backends/mla/sparse_swa.py
+280 -74
@@ -9,6 +9,7 @@ from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
9 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
10 from vllm.model_executor.warmup.jit_warmup import (
11 WarmupIntRange,
12 + zip_inputs,
13 )
14 from vllm.model_executor.warmup.jit_warmup_triton_helper import (
15 LaunchSpec,
@@ -167,9 +168,15 @@ class DeepseekSparseSWAMetadata:
168 decode_swa_width: int = 0
169 # Paged-coordinate prefill SWA indices/lens (FP8 paged-direct prefill).
170 prefill_swa_indices: torch.Tensor | None = (
170 - None # [num_prefill_tokens, 1, window_size]
171 + None # [num_prefill_tokens, 1, prefill_index_width]
172 )
173 prefill_swa_lens: torch.Tensor | None = None # [num_prefill_tokens]
174 + # In-image bidirectional visibility (vision variant): per-token counts of
175 + # extra visible tokens to the left/right inside an image span. Indexed by
176 + # absolute (decode-first) token position; only prefill rows are written.
177 + # None when the model is text-only or the batch has no image spans.
178 + prefill_left_visible: torch.Tensor | None = None
179 + prefill_right_visible: torch.Tensor | None = None
180
181 # Number of decode/prefill requests/tokens (batch is reordered: decodes first)
182 num_decodes: int = 0
@@ -416,6 +423,17 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
423 assert hasattr(hf_config, "sliding_window")
424 self.window_size = hf_config.sliding_window
425
426 + # Vision variant: image spans (up to vision_max_n_token tokens) are
427 + # visible bidirectionally, so prefill index rows widen from
428 + # window_size to window_size + max_image_tokens. Text-only models keep
429 + # max_image_tokens == 0 and take the original code paths everywhere.
430 + self.max_image_tokens = (
431 + getattr(hf_config, "vision_max_n_token", 0)
432 + if getattr(hf_config, "vision_n_layers", 0) > 0
433 + else 0
434 + )
435 + self.prefill_index_width = self.window_size + self.max_image_tokens
436 +
437 # Detect which DeepseekV4 layer types this model uses so we only build a
438 # FlashMLA tile-scheduler plan for types that will actually be called.
439 # Models without compress_ratios (pure SWA) fall back to swaonly.
@@ -447,7 +465,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
465 self.prefill_swa_indices = torch.zeros(
466 max_tokens,
467 1,
450 - self.window_size,
468 + self.prefill_index_width,
469 dtype=torch.int32,
470 device=self.device,
471 )
@@ -456,6 +474,27 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
474 dtype=torch.int32,
475 device=self.device,
476 )
477 + # In-image visibility side buffers (vision variant only). The span
478 + # CSR is uploaded per step from mm_req_doc_ranges; span capacity is
479 + # bounded by max_tokens since each span holds at least one token.
480 + if self.max_image_tokens > 0:
481 + self.left_visible = torch.zeros(
482 + max_tokens, dtype=torch.int32, device=self.device
483 + )
484 + self.right_visible = torch.zeros(
485 + max_tokens, dtype=torch.int32, device=self.device
486 + )
487 + self.span_indptr = torch.zeros(
488 + self.vllm_config.scheduler_config.max_num_seqs + 1,
489 + dtype=torch.int32,
490 + device=self.device,
491 + )
492 + self.span_starts = torch.zeros(
493 + max_tokens, dtype=torch.int32, device=self.device
494 + )
495 + self.span_ends = torch.zeros(
496 + max_tokens, dtype=torch.int32, device=self.device
497 + )
498 self.is_valid_token = torch.zeros(
499 max_tokens,
500 dtype=torch.bool,
@@ -556,6 +595,9 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
595 decode_swa_indices,
596 self.decode_swa_lens,
597 self.window_size,
598 + decode_swa_indices.shape[-1],
599 + self.decode_swa_lens, # unused (HAS_IMAGE=False)
600 + self.decode_swa_lens, # unused (HAS_IMAGE=False)
601 query_start_loc,
602 seq_lens,
603 token_to_req_indices,
@@ -566,16 +608,42 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
608 token_offset=0,
609 )
610
611 + # Vision variant: per-token in-image visibility for prefill tokens.
612 + # Decode tokens are always past the image spans (spans are prefilled
613 + # atomically), so the decode path above never needs them.
614 + prefill_left_visible: torch.Tensor | None = None
615 + prefill_right_visible: torch.Tensor | None = None
616 + mm_ranges = common_attn_metadata.mm_req_doc_ranges
617 + if (
618 + self.max_image_tokens > 0
619 + and num_prefill_tokens > 0
620 + and mm_ranges
621 + and any(mm_ranges.values())
622 + ):
623 + prefill_left_visible, prefill_right_visible = self._build_image_visibility(
624 + common_attn_metadata.num_reqs,
625 + mm_ranges,
626 + num_decode_tokens,
627 + num_prefill_tokens,
628 + seq_lens,
629 + query_start_loc,
630 + token_to_req_indices,
631 + )
632 +
633 # Prefill SWA indices live in paged coordinates. `token_offset` lets
634 # the kernel read is_valid_token / token_to_req_indices at absolute
635 # prefill positions while writing output starting at index 0.
636 if num_prefill_tokens > 0:
637 + has_image = prefill_left_visible is not None
638 prefill_swa_indices = self.prefill_swa_indices[:num_prefill_tokens]
639 prefill_swa_lens = self.prefill_swa_lens[:num_prefill_tokens]
640 _COMPUTE_SWA_INDICES_AND_LENS_KERNEL(
641 prefill_swa_indices,
642 prefill_swa_lens,
643 self.window_size,
644 + self.prefill_index_width,
645 + prefill_left_visible if has_image else prefill_swa_lens,
646 + prefill_right_visible if has_image else prefill_swa_lens,
647 query_start_loc,
648 seq_lens,
649 token_to_req_indices,
@@ -584,6 +652,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
652 self.block_size,
653 num_tokens=num_prefill_tokens,
654 token_offset=num_decode_tokens,
655 + has_image=has_image,
656 )
657
658 # Pre-compute DeepseekV4 prefill metadata shared across all attention layers.
@@ -623,6 +692,8 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
692 if num_prefill_tokens > 0
693 else None
694 ),
695 + prefill_left_visible=prefill_left_visible,
696 + prefill_right_visible=prefill_right_visible,
697 block_size=self.block_size,
698 num_decodes=num_decodes,
699 num_prefills=num_prefills,
@@ -641,6 +712,50 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
712 **deepseek_v4_fields, # type: ignore[arg-type]
713 )
714
715 + def _build_image_visibility(
716 + self,
717 + num_reqs: int,
718 + mm_ranges: dict[int, list[tuple[int, int]]],
719 + num_decode_tokens: int,
720 + num_prefill_tokens: int,
721 + seq_lens: torch.Tensor,
722 + query_start_loc: torch.Tensor,
723 + token_to_req_indices: torch.Tensor,
724 + ) -> tuple[torch.Tensor, torch.Tensor]:
725 + """Upload image spans and compute per-token in-image visibility.
726 +
727 + Returns (left, right) int32 tensors indexed by absolute (decode-first)
728 + token position; only prefill rows are meaningful.
729 + """
730 + indptr = [0] * (num_reqs + 1)
731 + starts: list[int] = []
732 + ends: list[int] = []
733 + for req_idx in range(num_reqs):
734 + for span_start, span_end in mm_ranges.get(req_idx, ()):
735 + starts.append(span_start)
736 + ends.append(span_end)
737 + indptr[req_idx + 1] = len(starts)
738 + num_spans = len(starts)
739 + assert num_spans <= self.span_starts.shape[0]
740 + self.span_indptr[: num_reqs + 1].copy_(torch.tensor(indptr, dtype=torch.int32))
741 + if num_spans > 0:
742 + self.span_starts[:num_spans].copy_(torch.tensor(starts, dtype=torch.int32))
743 + self.span_ends[:num_spans].copy_(torch.tensor(ends, dtype=torch.int32))
744 + _compute_image_visibility_kernel[(num_prefill_tokens,)](
745 + self.left_visible,
746 + self.right_visible,
747 + self.span_indptr,
748 + self.span_starts,
749 + self.span_ends,
750 + query_start_loc,
751 + seq_lens,
752 + token_to_req_indices,
753 + self.max_image_tokens,
754 + token_offset=num_decode_tokens,
755 + )
756 + end = num_decode_tokens + num_prefill_tokens
757 + return self.left_visible[:end], self.right_visible[:end]
758 +
759 def update_draft_decode_metadata(
760 self,
761 metadata: DeepseekSparseSWAMetadata,
@@ -658,6 +773,9 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
773 metadata.decode_swa_indices,
774 metadata.decode_swa_lens,
775 metadata.decode_swa_indices.shape[-1],
776 + metadata.decode_swa_indices.shape[-1],
777 + metadata.decode_swa_lens, # unused (HAS_IMAGE=False)
778 + metadata.decode_swa_lens, # unused (HAS_IMAGE=False)
779 metadata.query_start_loc,
780 metadata.seq_lens,
781 metadata.token_to_req_indices,
@@ -755,90 +873,153 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
873 return result
874
875
876 +@triton.jit(do_not_specialize=["token_offset"])
877 +def _compute_image_visibility_kernel(
878 + left_visible_ptr,
879 + right_visible_ptr,
880 + span_indptr_ptr,
881 + span_starts_ptr,
882 + span_ends_ptr,
883 + query_start_loc_ptr,
884 + seq_lens_ptr,
885 + token_to_req_indices_ptr,
886 + max_image_tokens,
887 + token_offset,
888 +):
889 + """Per-token in-image visible counts (port of `get_image_visible`).
890 +
891 + One program per prefill token. A token at position pos inside a span
892 + [span_start, span_end] sees min(pos - span_start, max_image_tokens - 1)
893 + extra tokens to its left and min(span_end - pos, max_image_tokens) to its
894 + right; tokens outside every span get 0/0 (plain causal window).
895 + """
896 + pid = tl.program_id(0)
897 + token_idx = pid + token_offset
898 + req_idx = tl.load(token_to_req_indices_ptr + token_idx)
899 +
900 + query_start = tl.load(query_start_loc_ptr + req_idx)
901 + query_end = tl.load(query_start_loc_ptr + req_idx + 1)
902 + seq_len = tl.load(seq_lens_ptr + req_idx)
903 + pos = seq_len - (query_end - query_start) + token_idx - query_start
904 +
905 + span_lo = tl.load(span_indptr_ptr + req_idx)
906 + span_hi = tl.load(span_indptr_ptr + req_idx + 1)
907 + left = tl.zeros((), dtype=tl.int32)
908 + right = tl.zeros((), dtype=tl.int32)
909 + for i in range(span_lo, span_hi):
910 + span_start = tl.load(span_starts_ptr + i)
911 + span_end = tl.load(span_ends_ptr + i)
912 + in_span = (pos >= span_start) & (pos <= span_end)
913 + left = tl.where(
914 + in_span, tl.minimum(pos - span_start, max_image_tokens - 1), left
915 + )
916 + right = tl.where(in_span, tl.minimum(span_end - pos, max_image_tokens), right)
917 + tl.store(left_visible_ptr + token_idx, left)
918 + tl.store(right_visible_ptr + token_idx, right)
919 +
920 +
921 # TODO(ben): unify this kernel to reduce duplication
922
923
924 +@triton.jit(
925 + do_not_specialize=[
926 + "swa_indices_stride",
927 + "block_table_stride",
928 + "token_offset",
929 + ]
930 +)
931 +def _compute_swa_indices_and_lens_kernel(
932 + swa_indices_ptr,
933 + swa_indices_stride,
934 + swa_lens_ptr,
935 + window_size,
936 + index_width,
937 + left_visible_ptr,
938 + right_visible_ptr,
939 + query_start_loc_ptr,
940 + seq_lens_ptr,
941 + token_to_req_indices_ptr,
942 + is_valid_token_ptr,
943 + block_table_ptr,
944 + block_table_stride,
945 + block_size,
946 + token_offset,
947 + HAS_IMAGE: tl.constexpr,
948 + TRITON_BLOCK_SIZE: tl.constexpr,
949 +):
950 + pid = tl.program_id(0)
951 + token_idx = pid + token_offset
952 + is_valid = tl.load(is_valid_token_ptr + token_idx)
953 + if not is_valid:
954 + tl.store(swa_lens_ptr + pid, 0)
955 + # Clear the row so a padded token cannot gather through stale indices.
956 + for i in range(0, index_width, TRITON_BLOCK_SIZE):
957 + offset = i + tl.arange(0, TRITON_BLOCK_SIZE)
958 + tl.store(
959 + swa_indices_ptr + pid * swa_indices_stride + offset,
960 + -1,
961 + mask=offset < index_width,
962 + )
963 + return
964 +
965 + req_idx = tl.load(token_to_req_indices_ptr + token_idx)
966 +
967 + query_start = tl.load(query_start_loc_ptr + req_idx)
968 + query_end = tl.load(query_start_loc_ptr + req_idx + 1)
969 + query_len = query_end - query_start
970 +
971 + seq_len = tl.load(seq_lens_ptr + req_idx)
972 + prefix_len = seq_len - query_len
973 +
974 + pos = prefix_len + token_idx - query_start
975 + if HAS_IMAGE:
976 + # In-image bidirectional visibility widens the window: the window
977 + # starts up to max(left - (window - 1), 0) positions earlier and
978 + # extends `right` positions past the query token.
979 + left = tl.load(left_visible_ptr + token_idx)
980 + right = tl.load(right_visible_ptr + token_idx)
981 + else:
982 + left = 0
983 + right = 0
984 + left_add = tl.maximum(left - (window_size - 1), 0)
985 + start_pos = tl.maximum(pos - (window_size - 1) - left_add, 0)
986 + end_pos = pos + right + 1
987 +
988 + swa_len = end_pos - start_pos
989 + tl.store(swa_lens_ptr + pid, swa_len)
990 +
991 + for i in range(0, index_width, TRITON_BLOCK_SIZE):
992 + offset = i + tl.arange(0, TRITON_BLOCK_SIZE)
993 +
994 + pos_offset = start_pos + offset
995 + block_indices = pos_offset // block_size
996 + block_numbers = tl.load(
997 + block_table_ptr + req_idx * block_table_stride + block_indices,
998 + mask=pos_offset < end_pos,
999 + )
1000 + block_offsets = pos_offset % block_size
1001 + slot_ids = block_numbers * block_size + block_offsets
1002 +
1003 + slot_ids = tl.where(offset < swa_len, slot_ids, -1)
1004 + tl.store(
1005 + swa_indices_ptr + pid * swa_indices_stride + offset,
1006 + slot_ids,
1007 + mask=offset < index_width,
1008 + )
1009 +
1010 +
1011 class ComputeSWAIndicesAndLensKernel(
1012 VllmTritonJitKernel["ComputeSWAIndicesAndLensKernel.CompileKey"]
1013 ):
1014 @dataclass(frozen=True)
1015 class CompileKey:
1016 window_size: int
1017 + index_width: int
1018 + has_image: int
1019 block_size: int
1020 triton_block_size: int
1021
770 - @staticmethod
771 - @triton.jit(
772 - do_not_specialize=[
773 - "swa_indices_stride",
774 - "block_table_stride",
775 - "token_offset",
776 - ]
777 - )
778 - def kernel(
779 - swa_indices_ptr,
780 - swa_indices_stride,
781 - swa_lens_ptr,
782 - window_size,
783 - query_start_loc_ptr,
784 - seq_lens_ptr,
785 - token_to_req_indices_ptr,
786 - is_valid_token_ptr,
787 - block_table_ptr,
788 - block_table_stride,
789 - block_size,
790 - token_offset,
791 - TRITON_BLOCK_SIZE: tl.constexpr,
792 - ):
793 - pid = tl.program_id(0)
794 - token_idx = pid + token_offset
795 - is_valid = tl.load(is_valid_token_ptr + token_idx)
796 - if not is_valid:
797 - tl.store(swa_lens_ptr + pid, 0)
798 - # Clear the row so a padded token cannot gather through stale indices.
799 - for i in range(0, window_size, TRITON_BLOCK_SIZE):
800 - offset = i + tl.arange(0, TRITON_BLOCK_SIZE)
801 - tl.store(
802 - swa_indices_ptr + pid * swa_indices_stride + offset,
803 - -1,
804 - mask=offset < window_size,
805 - )
806 - return
807 -
808 - req_idx = tl.load(token_to_req_indices_ptr + token_idx)
809 -
810 - query_start = tl.load(query_start_loc_ptr + req_idx)
811 - query_end = tl.load(query_start_loc_ptr + req_idx + 1)
812 - query_len = query_end - query_start
813 -
814 - seq_len = tl.load(seq_lens_ptr + req_idx)
815 - prefix_len = seq_len - query_len
816 -
817 - pos = prefix_len + token_idx - query_start
818 - start_pos = tl.maximum(pos - window_size + 1, 0)
819 - end_pos = pos + 1
820 -
821 - swa_len = end_pos - start_pos
822 - tl.store(swa_lens_ptr + pid, swa_len)
823 -
824 - for i in range(0, window_size, TRITON_BLOCK_SIZE):
825 - offset = i + tl.arange(0, TRITON_BLOCK_SIZE)
826 -
827 - pos_offset = start_pos + offset
828 - block_indices = pos_offset // block_size
829 - block_numbers = tl.load(
830 - block_table_ptr + req_idx * block_table_stride + block_indices,
831 - mask=pos_offset < end_pos,
832 - )
833 - block_offsets = pos_offset % block_size
834 - slot_ids = block_numbers * block_size + block_offsets
835 -
836 - slot_ids = tl.where(offset < swa_len, slot_ids, -1)
837 - tl.store(
838 - swa_indices_ptr + pid * swa_indices_stride + offset,
839 - slot_ids,
840 - mask=offset < window_size,
841 - )
1022 + kernel = staticmethod(_compute_swa_indices_and_lens_kernel)
1023
1024 def dispatch( # type: ignore[override]
1025 self,
@@ -854,8 +1035,24 @@ class ComputeSWAIndicesAndLensKernel(
1035 *,
1036 window_size: int,
1037 block_size: int,
1038 + max_image_tokens: int = 0,
1039 ) -> list[CompileKey]:
1040 + # Decode rows always use the plain causal window. Vision models widen
1041 + # prefill rows by max_image_tokens and additionally need the in-image
1042 + # bidirectional variant for batches that contain image spans.
1043 + width_has_image = [(window_size, 0)]
1044 + if max_image_tokens > 0:
1045 + width_has_image += [
1046 + (window_size + max_image_tokens, 0),
1047 + (window_size + max_image_tokens, 1),
1048 + ]
1049 return self._trace_dispatch(self.dispatch)(
1050 + zip_inputs(
1051 + *(
1052 + dict(index_width=index_width, has_image=has_image)
1053 + for index_width, has_image in width_has_image
1054 + )
1055 + ),
1056 window_size=window_size,
1057 block_size=block_size,
1058 )
@@ -866,6 +1063,9 @@ class ComputeSWAIndicesAndLensKernel(
1063 swa_indices=TritonWarmupTensor(torch.int32, shape=(1, 1), strides=(1, 1)),
1064 swa_lens=int32_ptr,
1065 window_size=compile_key.window_size,
1066 + index_width=compile_key.index_width,
1067 + left_visible=int32_ptr,
1068 + right_visible=int32_ptr,
1069 query_start_loc=int32_ptr,
1070 seq_lens=int32_ptr,
1071 token_to_req_indices=int32_ptr,
@@ -874,6 +1074,7 @@ class ComputeSWAIndicesAndLensKernel(
1074 block_size=compile_key.block_size,
1075 num_tokens=1,
1076 token_offset=0,
1077 + has_image=bool(compile_key.has_image),
1078 )
1079
1080 @kernel_launcher
@@ -882,6 +1083,9 @@ class ComputeSWAIndicesAndLensKernel(
1083 swa_indices: torch.Tensor,
1084 swa_lens: torch.Tensor,
1085 window_size: int,
1086 + index_width: int,
1087 + left_visible: torch.Tensor,
1088 + right_visible: torch.Tensor,
1089 query_start_loc: torch.Tensor,
1090 seq_lens: torch.Tensor,
1091 token_to_req_indices: torch.Tensor,
@@ -891,10 +1095,12 @@ class ComputeSWAIndicesAndLensKernel(
1095 *,
1096 num_tokens: int,
1097 token_offset: int,
1098 + has_image: bool = False,
1099 ) -> LaunchSpec:
1100 return (num_tokens,), dict(
1101 swa_indices_stride=swa_indices.stride(0),
1102 block_table_stride=block_table.stride(0),
1103 + HAS_IMAGE=has_image,
1104 TRITON_BLOCK_SIZE=1024,
1105 )
1106
vllm/v1/worker/gpu_model_runner.py
+22 -1
@@ -2429,6 +2429,16 @@ class GPUModelRunner(
2429 _bidi_sw = getattr(hf_text_config, "sliding_window", None)
2430 _clamps_in_kernel = getattr(
2431 self.model, "mm_prefix_clamp_sliding_window", False
2432 + ) or getattr(hf_text_config, "mm_prefix_clamp_sliding_window", False)
2433 + # Some models (DeepSeek-V4 vision) define the bidirectional span
2434 + # over the whole sentinel block ([IMAGE_START, IMAGE_END]) rather
2435 + # than the embed tokens, and prepend a position-dependent
2436 + # alignment pad before the first sentinel. For those, derive the
2437 + # span from the full placeholder range and strip the pad.
2438 + # TODO(Isotr0py): Refactor mm_prefix_lm implementation
2439 + # for better readability and maintainability.
2440 + _span_pad_modulus = getattr(
2441 + hf_text_config, "mm_prefix_span_leading_pad_modulus", 0
2442 )
2443 for req_id in self.input_batch.req_ids:
2444 image_doc_ranges = []
@@ -2437,7 +2447,18 @@ class GPUModelRunner(
2447 if mm_feature.modality == "audio":
2448 continue
2449 pos_info = mm_feature.mm_position
2440 - img_doc_range = pos_info.extract_embeds_range()
2450 + if _span_pad_modulus:
2451 + pad = (
2452 + _span_pad_modulus - 1 - pos_info.offset % _span_pad_modulus
2453 + )
2454 + img_doc_range = [
2455 + (
2456 + pos_info.offset + pad,
2457 + pos_info.offset + pos_info.length - 1,
2458 + )
2459 + ]
2460 + else:
2461 + img_doc_range = pos_info.extract_embeds_range()
2462 for r in img_doc_range:
2463 if (
2464 not _clamps_in_kernel