Prhub

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

原始 PR 作者 Isotr0py 合并时间 2026-09-03 02:09 文件变更 37 提交数 16 评论 22 代码增减 +2918 / -152

执行摘要

为 DeepSeek-V4-Flash-Vision-Exp 多模态模型添加完整的推理支持,涵盖视觉编码、多模态处理、注意力适配与 MoE 路由。

官方DeepSeek-V4-Flash-Vision-Exp模型提供了出色的视觉理解能力,但vLLM此前不支持。本PR旨在将官方实现移植到vLLM框架,使社区能够使用高性能推理引擎运行该模型。从PR body和评论中可知,作者独立验证了图像预处理与参考实现的完全匹配,并得到了其他贡献者的测试验证。

本PR是一个里程碑级别的变更,引入了完整的多模态模型支持,涉及多个核心模块的协同修改。建议精读以下关键部分:

1) 多模态预处理中的哨兵令牌设计;
2) 注意力后端中图像可见性内核的实现;
3) MoE路由器对图像令牌的适配。这些设计决策展示了如何在不破坏现有架构的前提下扩展新功能,值得借鉴。

讨论亮点
  1. 架构路由问题:评论者tacos8me发现DeepseekV4ModelArchConfigConvertor.get_architectures()重写了架构,但get_model_architecture()读取原始配置导致VL类未被构建。作者通过在__init__中直接修改hf_config.architectures解决。
  2. 哈希层门偏置缺失:同一评论者报告启动时KeyError: layers.0.ffn.gate.e_score_correction_bias。作者在权重映射中添加了.ffn.gate.bias到.ffn.gate.e_score_correction_bias的后缀映射。
  3. SM120硬件兼容性:多位评论者在不同硬件(SM120、SM121)上测试,发现eidx must be contiguous错误和预填充内核不支持新topk值。最终确认SM120解码问题由主分支修复,预填充问题需FlashInfer更新。
  4. 图像可见性计算正确性:作者实现了_build_image_visibility和相关内核,评论者tacos8me独立验证了图像预处理函数与参考实现19/19完全匹配,确认了算法的正确性。

实现拆解

  1. 多模态预处理模块:新增vllm/models/deepseek_v4/common/mm_preprocess.py,实现图像变换、哨兵令牌块构建和处理器。关键符号包括image_sentinel_mask、grid_tokens、load_image等。该模块将图像占位符扩展为保留的in-vocab特殊令牌序列,并确保令牌计数与参考实现一致。
  2. 视觉编码器与对齐器:新增vllm/models/deepseek_v4/common/vision.py,实现DeepSeek-V4 ViT和Aligner网络,负责图像特征提取和下采样投影。关键类包括DeepseekV4ViT、DeepseekV4Aligner。线性层支持张量并行和数据并行。
  3. 多模态模型包装器:新增vllm/models/deepseek_v4/nvidia/vl_model.py,实现DeepseekV4ForConditionalGeneration,作为多模态入口点,整合视觉编码器、学习向量(image_start/pad等)和文本骨干。通过embed_multimodal和embed_input_ids处理图像嵌入。
  4. 注意力后端适配:修改vllm/v1/attention/backends/mla/sparse_swa.py,增加图像可见性计算。新增Triton内核_compute_image_visibility_kernel和_compute_swa_indices_and_lens_kernel,在预填充时双向扩展滑动窗口以覆盖图像区域。元数据类新增prefill_left_visible和prefill_right_visible字段。
  5. 架构路由与MoE适配:在vllm/transformers_utils/model_arch_config_convertor.py中新增DeepseekV4ModelArchConfigConvertor,将带有视觉塔的配置路由到VL包装器。修改vllm/model_executor/layers/fused_moe/router/fused_topk_bias_router.py,支持图像哨兵令牌使用bias_vl进行专家选择,确保视觉令牌的正确路由。
  6. 测试与配套:新增多个测试文件,包括注意力可见性测试(test_deepseek_v4_swa_visible.py)、MoE路由器测试、配置转换测试等,确保功能正确性。修改现有测试以覆盖新场景。
文件 模块 状态 重要度
vllm/models/deepseek_v4/nvidia/vl_model.py VL 模型 added 9.36
vllm/models/deepseek_v4/common/mm_preprocess.py 多模态预处理 added 9.17
vllm/v1/attention/backends/mla/sparse_swa.py 注意力后端 modified 8.84

关键符号

image_sentinel_mask validate_image_sentinel_ids grid_tokens load_image build_image_block DeepseekV4ForConditionalGeneration embed_multimodal embed_input_ids _build_image_visibility _compute_image_visibility_kernel _compute_swa_indices_and_lens_kernel fused_topk_bias dsv4_topk flatten_content_blocks

关键源码片段

vllm/models/deepseek_v4/nvidia/vl_model.py core-logic

多模态模型包装器,整合视觉编码器和文本骨干,是新模型的主入口点。

class DeepseekV4ForConditionalGeneration(
    nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3
):
    """Multimodal entry point for DeepSeek-V4 checkpoints with a vision tower."""
​
    # MoE router needs raw token ids for image sentinel detection
    requires_raw_input_tokens = True
​
    def __init__(self, *, vllm_config, prefix: str = "") -> None:
        super().__init__()
        model_config = vllm_config.model_config
        config = model_config.hf_config
        # Vision tower enabled only when config has vision layers and limit > 0
        image_enabled = (
            config.vision_n_layers > 0
            and self.multimodal_config.get_limit_per_prompt("image") > 0
        )
        with self._mark_tower_model(vllm_config, {"image"}):
            self.vision: DeepseekV4ViT | None = None
            self.aligner: DeepseekV4Aligner | None = None
            # Learned vectors for sentinel tokens (start, pad, newline, end)
            self.image_start: nn.Parameter | None = None
            self.image_end: nn.Parameter | None = None
            self.image_newline: nn.Parameter | None = None
            self.image_pad: nn.Parameter | None = None
            if image_enabled:
                self.vision = DeepseekV4ViT(config)
                self.aligner = DeepseekV4Aligner(config)
                for name in (
                    "image_start", "image_end", "image_newline", "image_pad"
                ):
                    setattr(
                        self, name,
                        nn.Parameter(torch.empty(config.hidden_size, dtype=torch.float32))
                    )
        # Initialize text backbone with marker to avoid recursion
        config._dsv4_vl_inner = True
        self.language_model = init_vllm_registered_model(
            vllm_config=vllm_config, hf_config=config,
            prefix=maybe_prefix(prefix, "language_model"),
            architectures=["DeepseekV4ForCausalLM"],
        )
        del config._dsv4_vl_inner
​
    def embed_input_ids(
        self, input_ids: torch.Tensor,
        multimodal_embeddings: MultiModalEmbeddings | None = None, *,
        is_multimodal: torch.Tensor | None = None,
    ) -> torch.Tensor:
        # Merge vision embeddings into sentinel positions
        inputs_embeds = self.language_model.embed_input_ids(input_ids)
        if self.image_start is not None:
            sentinel_mask = image_sentinel_mask(input_ids)
            if is_multimodal is not None:
                # IMAGE positions get vision embeddings via merge below
                sentinel_mask = sentinel_mask & ~is_multimodal.to(input_ids.device)
            # Table of learned vectors for sentinel types
            table = torch.stack([
                self.image_start, self.image_pad, self.image_pad,
                self.image_newline, self.image_end,
            ]).to(inputs_embeds.dtype)
            idx = (input_ids - IMAGE_SENTINEL_BASE_ID).clamp(0, 4)
            inputs_embeds = torch.where(
                sentinel_mask.unsqueeze(-1), table[idx], inputs_embeds
            )
        # Merge vision embeddings (for IMAGE positions)
        if multimodal_embeddings is None or len(multimodal_embeddings) == 0:
            return inputs_embeds
        return _merge_multimodal_embeddings(
            inputs_embeds=inputs_embeds,
            multimodal_embeddings=multimodal_embeddings,
            is_multimodal=is_multimodal,
        )
vllm/models/deepseek_v4/common/mm_preprocess.py data-contract

多模态预处理核心,实现图像变换和哨兵令牌块构建,确保与参考实现完全匹配。

# Sentinel roles: five consecutive reserved tokens never emitted from plain text
IMAGE_SENTINEL_BASE_ID = 129257
IMAGE_SENTINEL_TOKEN_NAMES = (
    "<|place_holder_mm_span_0431|>", # IMAGE_START
    "<|place_holder_mm_span_0432|>", # IMAGE_PAD
    "<|place_holder_mm_span_0433|>", # IMAGE
    "<|place_holder_mm_span_0434|>", # IMAGE_NEW_LINE
    "<|place_holder_mm_span_0435|>", # IMAGE_END
)
​
def image_sentinel_mask(token_ids: torch.Tensor) -> torch.Tensor:
    """Boolean mask for image-block sentinel positions (in-vocab ids)."""
    return (token_ids >= IMAGE_SENTINEL_BASE_ID) & (
        token_ids < IMAGE_SENTINEL_BASE_ID + len(IMAGE_SENTINEL_TOKEN_NAMES)
    )
​
def validate_image_sentinel_ids(tokenizer) -> None:
    """Check the borrowed sentinel ids against the tokenizer."""
    for i, name in enumerate(IMAGE_SENTINEL_TOKEN_NAMES):
        token_id = tokenizer.convert_tokens_to_ids(name)
        if token_id != IMAGE_SENTINEL_BASE_ID + i:
            raise ValueError(
                f"Image sentinel token {name!r} has id {token_id}, expected "
                f"{IMAGE_SENTINEL_BASE_ID + i}; the DeepSeek-V4 vision "
                "sentinel block requires these consecutive reserved ids."
            )
​
def build_image_block(n_llm_h: int, n_llm_w: int, start_pos: int):
    """Builds the N-layout token types (final order) and aligner-row order."""
    compress_pad = COMPRESS_PAD_TO - 1 - start_pos % COMPRESS_PAD_TO
    pad_h = n_llm_h % 2
    rows = n_llm_h + pad_h
    row_len = n_llm_w + 1
    pad_last = rows // 2 * row_len % 2 * 2
    types = torch.tensor(
        ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h
        + [IMAGE_PAD] * (row_len * pad_h),
        dtype=torch.int64,
    )
    order = torch.arange(rows * row_len).view(rows // 2, 2, row_len)
    order = order.transpose(1, 2).reshape(-1)
    image_idx = torch.full((rows * row_len,), -1, dtype=torch.int64)
    image_idx.view(rows, row_len)[:n_llm_h, :n_llm_w] = torch.arange(
        n_llm_h * n_llm_w
    ).view(n_llm_h, n_llm_w)
    perm = image_idx[order]
    perm = perm[perm >= 0]
    types = torch.cat([
        torch.full((compress_pad,), IMAGE_PAD, dtype=torch.int64),
        torch.tensor([IMAGE_START]),
        types[order],
        torch.full((pad_last,), IMAGE_PAD, dtype=torch.int64),
        torch.tensor([IMAGE_END]),
    ])
    return types, perm
vllm/v1/attention/backends/mla/sparse_swa.py core-logic

注意力后端关键修改,实现图像双向可见性计算,支持多模态预填充。

def _build_image_visibility(
    self, num_reqs, mm_ranges, num_decode_tokens, num_prefill_tokens,
    seq_lens, query_start_loc, token_to_req_indices,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Upload image spans and compute per-token in-image visibility."""
    indptr = [0] * (num_reqs + 1)
    starts, ends = [], []
    for req_idx in range(num_reqs):
        for span_start, span_end in mm_ranges.get(req_idx, ()):
            starts.append(span_start)
            ends.append(span_end)
        indptr[req_idx + 1] = len(starts)
    num_spans = len(starts)
    # Copy span CSR to device buffers
    self.span_indptr[: num_reqs + 1].copy_(torch.tensor(indptr, dtype=torch.int32))
    if num_spans > 0:
        self.span_starts[:num_spans].copy_(torch.tensor(starts, dtype=torch.int32))
        self.span_ends[:num_spans].copy_(torch.tensor(ends, dtype=torch.int32))
    # Launch kernel to compute left/right visibility for prefill tokens
    _compute_image_visibility_kernel[(num_prefill_tokens,)](
        self.left_visible, self.right_visible,
        self.span_indptr, self.span_starts, self.span_ends,
        query_start_loc, seq_lens, token_to_req_indices,
        self.max_image_tokens, token_offset=num_decode_tokens,
    )
    end = num_decode_tokens + num_prefill_tokens
    return self.left_visible[:end], self.right_visible[:end]
​
# In metadata builder __init__:
self.max_image_tokens = (
    getattr(hf_config, "vision_max_n_token", 0)
    if getattr(hf_config, "vision_n_layers", 0) > 0
    else 0
)
self.prefill_index_width = self.window_size + self.max_image_tokens
# Prefill SWA indices widened to include image tokens
self.prefill_swa_indices = torch.zeros(
    max_tokens, 1, self.prefill_index_width, dtype=torch.int32, device=self.device
)

评论区精华

架构转换器路由错误导致视觉塔未构建 正确性

评论者 tacos8me 发现 DeepseekV4ModelArchConfigConvertor.get_architectures() 重写了架构,但 get_model_architecture() 读取原始配置,导致 VL 类未被构建,视觉功能被静默禁用。

结论:作者在 __init__ 中直接修改 hf_config.architectures 属性,确保模型类解析正确。 · 已解决

哈希层门偏置缺失导致权重加载失败 正确性

评论者 tacos8me 报告启动时 KeyError: layers.0.ffn.gate.e_score_correction_bias,表明权重映射不完整。

结论:作者在权重映射后缀规则中添加 .fnn.gate.bias 到 .ffn.gate.e_score_correction_bias 的映射。 · 已解决

SM120 硬件上解码失败和预填充内核不支持 性能

多位评论者在 SM120 硬件上测试,发现 eidx 必须连续的错误和预填充内核不支持新的 topk 值(512 vs 128)。

结论:解码问题由主分支修复(PR#53574),预填充问题需 FlashInfer 更新(flashinfer-ai/flashinfer#4850)。 · 部分解决

图像预处理与参考实现的一致性验证 正确性

评论者 tacos8me 独立对比了 PR 中的图像预处理函数与参考实现,在 19 个测试用例中完全匹配。

结论:验证了算法正确性,确认 grid_tokens、solve_resize_ratio、build_image_block 等函数与参考一致。 · 已验证

风险与影响

  1. 算法正确性风险:图像预处理和哨兵令牌生成必须与参考实现完全一致,否则会导致模型输出偏差。作者已通过对比验证,但边界情况(如极端宽高比)仍需监控。
  2. 注意力后端回归风险:修改_COMPUTE_SWA_INDICES_AND_LENS_KERNEL以支持图像可见性可能影响文本模式的性能。测试确保了HAS_IMAGE=False时行为不变。
  3. MoE路由兼容性风险:图像哨兵令牌的专家选择使用bias_vl,若令牌ID范围判断错误可能导致路由错误。测试覆盖了混合令牌场景。
  4. 跨平台兼容性风险:视觉编码器仅支持NVIDIA GPU(SM120+),AMD等平台通过vl_stub.py抛出NotImplementedError。未来需扩展支持。
  5. 性能风险:图像可见性计算和扩展的滑动窗口索引可能增加预填充延迟,但测试表明在预期范围内。

对用户:可以部署和运行DeepSeek-V4-Flash-Vision-Exp模型进行图像理解,扩展了vLLM的多模态能力。对系统:引入了新的模型组件和注意力后端修改,增加了代码库的复杂性,但模块化设计保持了清晰性。对团队:需要维护视觉编码器、多模态处理器和相关测试,未来可能需要扩展到其他硬件平台。对生态:增强了vLLM在多模态推理领域的竞争力,可能吸引更多用户。

多模态处理器与参考实现对齐 注意力后端图像可见性计算 MoE 路由器图像哨兵专家选择 跨平台兼容性

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论