| 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 | SupportsLoRA, |
| 29 | SupportsMultiModal, |
| 30 | SupportsPP, |
| 31 | ) |
| 32 | from vllm.model_executor.models.module_mapping import MultiModelKeys |
| 33 | from vllm.model_executor.models.utils import ( |
| 34 | AutoWeightsLoader, |
| 35 | WeightsMapper, |
| 36 | init_vllm_registered_model, |
| 37 | maybe_prefix, |
| 38 | ) |
| 39 | from vllm.multimodal import MULTIMODAL_REGISTRY |
| 40 | from vllm.multimodal.inputs import MultiModalKwargsItem |
| 41 | |
| 42 | from .mm_preprocess import ( |
| 43 | IMAGE_PLACEHOLDER, |
| 44 | IMAGE_SENTINEL_BASE_ID, |
| 45 | DeepseekV4VLDummyInputsBuilder, |
| 46 | DeepseekV4VLMultiModalProcessor, |
| 47 | DeepseekV4VLProcessingInfo, |
| 48 | image_sentinel_mask, |
| 49 | ) |
| 50 | from .vision import DeepseekV4Aligner, DeepseekV4ViT |
| 51 | |
| 52 | |
| 53 | def _make_deepseek_v4_vl_weights_mapper( |
| 54 | text_mapper: WeightsMapper, image_enabled: bool |
| 55 | ) -> WeightsMapper: |
| 56 | """Text-checkpoint mapping rules re-rooted under ``language_model.``.""" |
| 57 | orig_to_new_prefix = { |
| 58 | src: None if dst is None else f"language_model.{dst}" |
| 59 | for src, dst in text_mapper.orig_to_new_prefix.items() |
| 60 | } |
| 61 | if not image_enabled: |
| 62 | orig_to_new_prefix.update({"vision.": None, "aligner.": None, "image_": None}) |
| 63 | return WeightsMapper( |
| 64 | orig_to_new_renaming=text_mapper.orig_to_new_renaming, |
| 65 | orig_to_new_prefix=orig_to_new_prefix, |
| 66 | orig_to_new_regex=text_mapper.orig_to_new_regex, |
| 67 | orig_to_new_stacked=text_mapper.orig_to_new_stacked, |
| 68 | orig_to_new_suffix={ |
| 69 | **text_mapper.orig_to_new_suffix, |
| 70 | "head.weight": "language_model.lm_head.weight", |
| 71 | }, |
| 72 | orig_to_new_substr={ |
| 73 | **text_mapper.orig_to_new_substr, |
| 74 | # Draft models load the checkpoint's MTP weights separately from |
| 75 | # the target model. |
| 76 | "mtp.": None, |
| 77 | }, |
| 78 | ) |
| 79 | |
| 80 | |
| 81 | @MULTIMODAL_REGISTRY.register_processor( |
| 82 | DeepseekV4VLMultiModalProcessor, |
| 83 | info=DeepseekV4VLProcessingInfo, |
| 84 | dummy_inputs=DeepseekV4VLDummyInputsBuilder, |
| 85 | ) |
| 86 | class DeepseekV4ForConditionalGeneration( |
| 87 | nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3, SupportsLoRA |
| 88 | ): |
| 89 | """Multimodal entry point for DeepSeek-V4 checkpoints with a vision tower. |
| 90 | |
| 91 | ``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters) |
| 92 | delegates through ``language_model`` via the protocol defaults. |
| 93 | """ |
| 94 | |
| 95 | packed_modules_mapping = { |
| 96 | "gate_up_proj": ["w1", "w3"], |
| 97 | "fused_wqa_wkv": ["wq_a", "wkv"], |
| 98 | "fused_wkv_wgate": ["wkv", "wgate"], |
| 99 | # for visual encoder |
| 100 | "wqkv": ["wqkv"], |
| 101 | "w1": ["w1"], |
| 102 | } |
| 103 | |
| 104 | # The MoE router needs raw token ids to detect image sentinel tokens |
| 105 | # (borrowed reserved ids, see common/mm_preprocess.py) and apply bias_vl. |
| 106 | requires_raw_input_tokens = True |
| 107 | supports_tower_connector_lora = True |
| 108 | |
| 109 | @classmethod |
| 110 | def get_placeholder_str(cls, modality: str, i: int) -> str | None: |
| 111 | if modality == "image": |
| 112 | return IMAGE_PLACEHOLDER |
| 113 | raise ValueError(f"Unsupported modality: {modality!r}") |
| 114 | |
| 115 | def __init__(self, *, vllm_config, prefix: str = "") -> None: |
| 116 | super().__init__() |
| 117 | model_config = vllm_config.model_config |
| 118 | config = model_config.hf_config |
| 119 | self.config = config |
| 120 | self.multimodal_config = model_config.multimodal_config |
| 121 | assert self.multimodal_config is not None |
| 122 | |
| 123 | image_enabled = ( |
| 124 | config.vision_n_layers > 0 |
| 125 | and self.multimodal_config.get_limit_per_prompt("image") > 0 |
| 126 | ) |
| 127 | with self._mark_tower_model(vllm_config, {"image"}): |
| 128 | self.vision: DeepseekV4ViT | None = None |
| 129 | self.aligner: DeepseekV4Aligner | None = None |
| 130 | self.image_start: nn.Parameter | None = None |
| 131 | self.image_end: nn.Parameter | None = None |
| 132 | self.image_newline: nn.Parameter | None = None |
| 133 | self.image_pad: nn.Parameter | None = None |
| 134 | if image_enabled: |
| 135 | self.vision = DeepseekV4ViT(config) |
| 136 | self.aligner = DeepseekV4Aligner(config) |
| 137 | for name in ( |
| 138 | "image_start", |
| 139 | "image_end", |
| 140 | "image_newline", |
| 141 | "image_pad", |
| 142 | ): |
| 143 | setattr( |
| 144 | self, |
| 145 | name, |
| 146 | nn.Parameter( |
| 147 | torch.empty(config.hidden_size, dtype=torch.float32) |
| 148 | ), |
| 149 | ) |
| 150 | self.vision.to(dtype=model_config.dtype) |
| 151 | self.aligner.to(dtype=model_config.dtype) |
| 152 | |
| 153 | with self._mark_language_model(vllm_config): |
| 154 | # The arch convertor routes any config with a vision tower to |
| 155 | # this wrapper class; mark the copy handed to the text backbone |
| 156 | # so it resolves to DeepseekV4ForCausalLM instead of recursing |
| 157 | # (with_hf_config deepcopies the config, the marker survives). |
| 158 | config._dsv4_vl_inner = True # type: ignore[attr-defined] |
| 159 | try: |
| 160 | self.language_model = init_vllm_registered_model( |
| 161 | vllm_config=vllm_config, |
| 162 | hf_config=config, |
| 163 | prefix=maybe_prefix(prefix, "language_model"), |
| 164 | architectures=["DeepseekV4ForCausalLM"], |
| 165 | ) |
| 166 | finally: |
| 167 | del config._dsv4_vl_inner # type: ignore[attr-defined] |
| 168 | # The outer mapper (see load_weights) fully resolves HF names into |
| 169 | # this wrapper's namespace before AutoWeightsLoader strips the |
| 170 | # "language_model." prefix and delegates to the child's load_weights, |
| 171 | # so the child's own mapper must be a no-op. Its suffix rules are not |
| 172 | # idempotent (e.g. "lm_head.weight".endswith("head.weight") would |
| 173 | # re-fire "head.weight" -> "lm_head.weight"). |
| 174 | text_mapper = self.language_model.hf_to_vllm_mapper |
| 175 | self.language_model.hf_to_vllm_mapper = WeightsMapper() |
| 176 | self.make_empty_intermediate_tensors = ( # type: ignore[method-assign] |
| 177 | self.language_model.make_empty_intermediate_tensors |
| 178 | ) |
| 179 | |
| 180 | self.hf_to_vllm_mapper = _make_deepseek_v4_vl_weights_mapper( |
| 181 | text_mapper, image_enabled |
| 182 | ) |
| 183 | self._weights_finalized = False |
| 184 | |
| 185 | def _parse_and_validate_image_input(self, **kwargs: object) -> dict | None: |
| 186 | patches = kwargs.pop("patches", None) |
| 187 | if patches is None: |
| 188 | return None |
| 189 | vit_grid = kwargs.pop("vit_grid", None) |
| 190 | llm_grid = kwargs.pop("llm_grid", None) |
| 191 | perm = kwargs.pop("perm", None) |
| 192 | assert vit_grid is not None and llm_grid is not None and perm is not None |
| 193 | return { |
| 194 | "patches": patches, |
| 195 | "vit_grid": vit_grid, |
| 196 | "llm_grid": llm_grid, |
| 197 | "perm": perm, |
| 198 | } |
| 199 | |
| 200 | def _encode_image( |
| 201 | self, |
| 202 | patches: torch.Tensor, |
| 203 | n_vit_h: int, |
| 204 | n_vit_w: int, |
| 205 | perm: torch.Tensor, |
| 206 | ) -> torch.Tensor: |
| 207 | assert self.vision is not None and self.aligner is not None |
| 208 | image_embeds = self.aligner( |
| 209 | self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w |
| 210 | ) |
| 211 | # Reorder into the N-layout block order used in the prompt. |
| 212 | return image_embeds[perm.to(image_embeds.device)] |
| 213 | |
| 214 | def _process_image_input( |
| 215 | self, |
| 216 | patches: torch.Tensor, |
| 217 | vit_grid: torch.Tensor, |
| 218 | llm_grid: torch.Tensor, |
| 219 | perm: torch.Tensor, |
| 220 | ) -> tuple[torch.Tensor, ...]: |
| 221 | assert self.vision is not None and self.aligner is not None |
| 222 | patches = patches.to(self.aligner.w1.weight.dtype) |
| 223 | |
| 224 | embeds: list[torch.Tensor] = [] |
| 225 | vit_offset = 0 |
| 226 | llm_offset = 0 |
| 227 | for (n_vit_h, n_vit_w), (n_llm_h, n_llm_w) in zip( |
| 228 | vit_grid.tolist(), llm_grid.tolist(), strict=True |
| 229 | ): |
| 230 | n_vit = n_vit_h * n_vit_w |
| 231 | n_llm = n_llm_h * n_llm_w |
| 232 | embeds.append( |
| 233 | self._encode_image( |
| 234 | patches[vit_offset : vit_offset + n_vit], |
| 235 | n_vit_h, |
| 236 | n_vit_w, |
| 237 | perm[llm_offset : llm_offset + n_llm], |
| 238 | ) |
| 239 | ) |
| 240 | vit_offset += n_vit |
| 241 | llm_offset += n_llm |
| 242 | return tuple(embeds) |
| 243 | |
| 244 | def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings: |
| 245 | image_input = self._parse_and_validate_image_input(**kwargs) |
| 246 | if image_input is None or self.vision is None: |
| 247 | return [] |
| 248 | return self._process_image_input( |
| 249 | image_input["patches"], |
| 250 | image_input["vit_grid"], |
| 251 | image_input["llm_grid"], |
| 252 | image_input["perm"], |
| 253 | ) |
| 254 | |
| 255 | def embed_input_ids( |
| 256 | self, |
| 257 | input_ids: torch.Tensor, |
| 258 | multimodal_embeddings: MultiModalEmbeddings | None = None, |
| 259 | *, |
| 260 | is_multimodal: torch.Tensor | None = None, |
| 261 | ) -> torch.Tensor: |
| 262 | from vllm.model_executor.models.utils import _merge_multimodal_embeddings |
| 263 | |
| 264 | # All ids are in-vocab here: image-block sentinels are borrowed |
| 265 | # reserved tokens (their embedding rows are always overwritten below). |
| 266 | inputs_embeds = self.language_model.embed_input_ids(input_ids) |
| 267 | |
| 268 | if self.image_start is not None: |
| 269 | # Branch-free sentinel overwrite: safe inside compiled/captured |
| 270 | # regions (no data-dependent control flow). |
| 271 | sentinel_mask = image_sentinel_mask(input_ids) |
| 272 | if is_multimodal is not None: |
| 273 | # IMAGE positions get vision embeddings via the merge below. |
| 274 | sentinel_mask = sentinel_mask & ~is_multimodal.to(input_ids.device) |
| 275 | table = torch.stack( |
| 276 | [ |
| 277 | self.image_start, |
| 278 | self.image_pad, |
| 279 | self.image_pad, |
| 280 | self.image_newline, |
| 281 | self.image_end, |
| 282 | ] |
| 283 | ).to(inputs_embeds.dtype) |
| 284 | idx = (input_ids - IMAGE_SENTINEL_BASE_ID).clamp(0, 4) |
| 285 | inputs_embeds = torch.where( |
| 286 | sentinel_mask.unsqueeze(-1), table[idx], inputs_embeds |
| 287 | ) |
| 288 | |
| 289 | if multimodal_embeddings is None or len(multimodal_embeddings) == 0: |
| 290 | return inputs_embeds |
| 291 | |
| 292 | assert is_multimodal is not None |
| 293 | return _merge_multimodal_embeddings( |
| 294 | inputs_embeds=inputs_embeds, |
| 295 | multimodal_embeddings=multimodal_embeddings, |
| 296 | is_multimodal=is_multimodal, |
| 297 | ) |
| 298 | |
| 299 | def forward( |
| 300 | self, |
| 301 | input_ids: torch.Tensor, |
| 302 | positions: torch.Tensor, |
| 303 | intermediate_tensors=None, |
| 304 | inputs_embeds: torch.Tensor | None = None, |
| 305 | **kwargs, |
| 306 | ) -> torch.Tensor: |
| 307 | return self.language_model( |
| 308 | input_ids, positions, intermediate_tensors, inputs_embeds |
| 309 | ) |
| 310 | |
| 311 | def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None: |
| 312 | return self.language_model.compute_logits(hidden_states) |
| 313 | |
| 314 | def compute_logits_local(self, hidden_states: torch.Tensor) -> torch.Tensor: |
| 315 | return self.language_model.compute_logits_local(hidden_states) |
| 316 | |
| 317 | def get_expert_mapping(self) -> list[tuple[str, str, int, str]]: |
| 318 | return self.language_model.get_expert_mapping() |
| 319 | |
| 320 | def get_mtp_target_hidden_states(self) -> torch.Tensor | None: |
| 321 | """Pre-hc_head residual stream buffer for the MTP/DSpark draft model.""" |
| 322 | return self.language_model.get_mtp_target_hidden_states() |
| 323 | |
| 324 | def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: |
| 325 | child_finalizes = getattr( |
| 326 | self.language_model, "finalizes_weights_during_load", True |
| 327 | ) |
| 328 | mapped = self.hf_to_vllm_mapper.apply(weights) |
| 329 | if child_finalizes: |
| 330 | # A child which finalizes inside load_weights must see all of its |
| 331 | # weights in one contiguous delegation from AutoWeightsLoader. |
| 332 | mapped = iter(sorted(mapped, key=lambda x: x[0])) |
| 333 | loader = AutoWeightsLoader(self) |
| 334 | loaded_params = loader.load_weights(mapped) |
| 335 | self._weights_finalized = child_finalizes |
| 336 | return loaded_params |
| 337 | |
| 338 | def process_weights_after_loading(self) -> None: |
| 339 | # Backbones such as the ROCm implementation require this to run only |
| 340 | # after the loader's generic per-layer quantization finalization. |
| 341 | if getattr(self, "_weights_finalized", False): |
| 342 | return |
| 343 | self.language_model.process_weights_after_loading() |
| 344 | self._weights_finalized = True |
| 345 | |
| 346 | def get_mm_mapping(self) -> MultiModelKeys: |
| 347 | """Get the module prefixes in the multimodal model.""" |
| 348 | return MultiModelKeys.from_string_field( |
| 349 | language_model="language_model", |
| 350 | connector="aligner", |
| 351 | tower_model="vision.", |
| 352 | ) |
| 353 | |
| 354 | def get_mm_lora_token_counts( |
| 355 | self, |
| 356 | *, |
| 357 | modality: str, |
| 358 | mm_kwargs: MultiModalKwargsItem | None, |
| 359 | num_mm_embeds: int, |
| 360 | ) -> tuple[int, int | None]: |
| 361 | if modality != "image": |
| 362 | raise ValueError(f"Unsupported modality: {modality!r}") |
| 363 | |
| 364 | patches = mm_kwargs.get("patches") if mm_kwargs else None |
| 365 | if patches is not None and isinstance(patches.data, torch.Tensor): |
| 366 | tower_tokens = patches.data.shape[0] |
| 367 | else: |
| 368 | tower_tokens = num_mm_embeds * self.config.vision_downsample_ratio**2 |
| 369 | |
| 370 | return tower_tokens, num_mm_embeds |