| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- """Vision-Exp wrapper around vLLM DeepseekV4ForCausalLM.
- Loads official `vision.*` / `aligner.*` / image sentinel tensors and merges
- ViT embeddings on prefill. Language decode stays on the B12X DeepSeek V4 path.
- The OpenAI multimodal branch (processor trio + embed-merge hooks) is gated
- behind VLLM_DSV4_MM=1: the fork's V2 "always-embed" mm arm routes through
- this override with a [B, T]-ranks ids slice and currently degenerates
- logits, so with VLLM_DSV4_MM unset (the default) the lane boots text-only —
- the verified state — and VLLM_DSV4_MM=1 opts into the experimental path.
- """
- from __future__ import annotations
- import os
- from types import SimpleNamespace
- from typing import Any
- import torch
- import torch.nn as nn
- from vllm.model_executor.models.utils import AutoWeightsLoader
- from vllm.models.deepseek_v4 import DeepseekV4ForCausalLM
- from vllm.multimodal import MULTIMODAL_REGISTRY
- from .processor import (
- DSV4VisionDummyInputsBuilder,
- DSV4VisionMultiModalProcessor,
- DSV4VisionProcessingInfo,
- )
- from .vision import Aligner, ViT
- _MM_ON = os.environ.get("VLLM_DSV4_MM", "0") == "1"
- def _maybe_mm(cls):
- """Register the OpenAI multimodal processor trio only when mm is on."""
- if not _MM_ON:
- return cls
- return MULTIMODAL_REGISTRY.register_processor(
- DSV4VisionMultiModalProcessor,
- info=DSV4VisionProcessingInfo,
- dummy_inputs=DSV4VisionDummyInputsBuilder,
- )(cls)
- def _vision_args(hf: Any) -> SimpleNamespace:
- return SimpleNamespace(
- vision_n_layers=int(getattr(hf, "vision_n_layers", 0) or 0),
- vision_dim=int(getattr(hf, "vision_dim", 1024)),
- vision_n_heads=int(getattr(hf, "vision_n_heads", 16)),
- vision_inter_dim=int(getattr(hf, "vision_inter_dim", 2816)),
- vision_patch_size=int(getattr(hf, "vision_patch_size", 14)),
- vision_rope_theta=float(getattr(hf, "vision_rope_theta", 10000.0)),
- vision_downsample_ratio=int(getattr(hf, "vision_downsample_ratio", 3)),
- vision_max_n_token=int(getattr(hf, "vision_max_n_token", 384)),
- vision_min_pixels=int(getattr(hf, "vision_min_pixels", 147456)),
- vision_max_wh_ratio=int(getattr(hf, "vision_max_wh_ratio", 8)),
- dim=int(getattr(hf, "hidden_size", 4096)),
- num_hash_layers=int(getattr(hf, "num_hash_layers", 0) or 0),
- )
- @_maybe_mm
- class DeepseekV4VisionForCausalLM(DeepseekV4ForCausalLM):
- # ModelConfig.is_multimodal_model keys off this class attribute
- # (interfaces.supports_multimodal) — without it the OpenAI layer
- # answers "not a multimodal model" for image_url requests. Gated by
- # _maybe_mm so the text-only default lane stays verified.
- supports_multimodal = _MM_ON
- # The fork's hash MoE routes by token id (tid2eid gather), so the merged
- # mm path must keep input_ids alongside the merged embeddings.
- requires_raw_input_tokens = _MM_ON
- supports_multimodal_raw_input_only = False
- supports_encoder_tp_data = False
- supports_mm_device_do_normalize = False
- @classmethod
- def get_placeholder_str(cls, modality: str, i: int) -> str | None:
- # chat_utils asks the resolved model class for the per-image prompt
- # placeholder; the tokenizer encodes it to <|deepseek_image|> (id 129264).
- if _MM_ON and modality.startswith("image"):
- from .encoding_dsv4 import IMAGE_PLACEHOLDER
- return IMAGE_PLACEHOLDER
- return None
- def __init__(self, *, vllm_config, prefix: str = ""):
- super().__init__(vllm_config=vllm_config, prefix=prefix)
- hf = vllm_config.model_config.hf_config
- args = _vision_args(hf)
- self._vision_args = args
- if args.vision_n_layers <= 0:
- self.vision = None
- self.aligner = None
- return
- self.vision = ViT(args)
- self.aligner = Aligner(args)
- self.image_start = nn.Parameter(torch.empty(args.dim))
- self.image_end = nn.Parameter(torch.empty(args.dim))
- self.image_newline = nn.Parameter(torch.empty(args.dim))
- self.image_pad = nn.Parameter(torch.empty(args.dim))
- def encode_image(self, patches: torch.Tensor, n_vit_h: int, n_vit_w: int) -> torch.Tensor:
- return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
- def embed_input_ids(
- self,
- input_ids: torch.Tensor,
- multimodal_embeddings=None,
- *,
- is_multimodal: torch.Tensor | None = None,
- ) -> torch.Tensor:
- # The fork's embed output follows the input rank: [B, T, H] from the
- # speculator's [B, T] ids, [T, H] from plain [T] ids. mm rows must
- # land on the same rows either way (they never exceed inner dims).
- if is_multimodal is not None:
- # Sentinel block ids may exceed vocab; zero them before lookup.
- input_ids = input_ids.masked_fill(
- is_multimodal.to(input_ids.device).unsqueeze(-1), 0
- )
- out = super().embed_input_ids(input_ids)
- if multimodal_embeddings:
- mm_stack = torch.cat(multimodal_embeddings) # [N, H]
- if out.dim() == 3:
- out[is_multimodal] = mm_stack.to(out.dtype)
- else:
- out.masked_scatter_(
- is_multimodal.to(out.device).unsqueeze(-1),
- mm_stack.reshape(-1),
- )
- return out
- def embed_multimodal(self, **kwargs) -> tuple:
- patches = kwargs.get("image_patches")
- if patches is None or self.vision is None:
- return ()
- grids = kwargs["image_grids"].data
- types_all = kwargs["image_types"].data
- perm_all = kwargs["image_perm"].data
- t_counts = kwargs["image_type_counts"].data.tolist()
- perm_counts = kwargs["image_perm_counts"].data.tolist()
- n_items = int(grids.shape[0])
- out: list[torch.Tensor] = []
- p_off = t_off = q_off = 0
- start = self.image_start
- pad = self.image_pad
- newline = self.image_newline
- end = self.image_end
- for i in range(n_items):
- n_vit_h, n_vit_w, n_llm_h, n_llm_w = grids[i].tolist()
- p_count = n_vit_h * n_vit_w
- t_count = int(t_counts[i])
- q_count = int(perm_counts[i])
- item_types = types_all[t_off : t_off + t_count].tolist()
- item_perm = perm_all[q_off : q_off + q_count].to(torch.long)
- if q_count != sum(t == 2 for t in item_types):
- raise ValueError(
- "DSV4 vision perm/sentinel layout mismatch "
- f"({q_count} != {sum(t == 2 for t in item_types)})"
- )
- t_off += t_count
- q_off += q_count
- vit = self.encode_image(patches[p_off : p_off + p_count], n_vit_h, n_vit_w)
- p_off += p_count
- rows: list[torch.Tensor] = []
- vi = 0
- for t in item_types:
- if t == 0: # IMAGE_START
- rows.append(start)
- elif t == 1: # IMAGE_PAD
- rows.append(pad)
- elif t == 2: # IMAGE
- rows.append(vit[int(item_perm[vi])])
- vi += 1
- elif t == 3: # IMAGE_NEW_LINE
- rows.append(newline)
- else: # IMAGE_END
- rows.append(end)
- out.append(torch.stack(rows))
- return tuple(out)
- def forward(
- self,
- input_ids,
- positions,
- intermediate_tensors=None,
- inputs_embeds=None,
- **kwargs,
- ):
- # The runner embeds via embed_input_ids (mm merge) and hands us
- # merged inputs_embeds; lm_head and get_mtp_target_hidden_states
- # stay on the parent class.
- if not _MM_ON and inputs_embeds is not None:
- # text-only belt-and-braces: without the mm gate the parent never
- # sees runner-generated embeds, but if one sneaks in, sync the
- # token path instead so hidden shapes stay native.
- inputs_embeds = None
- return super().forward(
- input_ids,
- positions,
- intermediate_tensors,
- inputs_embeds,
- )
- def load_weights(self, weights):
- if self.vision is None:
- return super().load_weights(weights)
- leftover = []
- stacked = {
- "vision.": self.vision,
- "aligner.": self.aligner,
- }
- params = {
- "image_start": self.image_start,
- "image_end": self.image_end,
- "image_newline": self.image_newline,
- "image_pad": self.image_pad,
- }
- for name, tensor in weights:
- if name in params:
- params[name].data.copy_(tensor)
- continue
- routed = False
- for prefix, module in stacked.items():
- if name.startswith(prefix):
- AutoWeightsLoader(module).load_weights(
- [(name[len(prefix) :], tensor)]
- )
- routed = True
- break
- if not routed:
- leftover.append((name, tensor))
- leftover = [
- pair
- for pair in leftover
- if not _skip_unmapped_gate(pair[0], self._vision_args)
- ]
- return super().load_weights(leftover)
- def _skip_unmapped_gate(name: str, args: SimpleNamespace) -> bool:
- # Hash MoE layers register tid2eid and leave e_score_correction_bias as
- # None. The B12X mapper still rewrites ffn.gate.bias onto that missing
- # name. bias_vl has no mapper at all.
- if name.endswith("ffn.gate.bias_vl"):
- return True
- if name.endswith("ffn.gate.bias"):
- parts = name.split(".")
- if (
- len(parts) >= 5
- and parts[0] == "layers"
- and parts[1].isdigit()
- and int(parts[1]) < args.num_hash_layers
- ):
- return True
- return False
|