"""vLLM FastMTP plugin for Jina-OCR-v1. Call ``register()`` once before constructing ``vllm.LLM``. This file lives in the Hugging Face snapshot so serving does not depend on any private training repo. """ from __future__ import annotations import copy import os import sys from collections.abc import Iterable from pathlib import Path import torch import torch.nn as nn from typing_extensions import override from vllm import ModelRegistry from vllm.compilation.decorators import support_torch_compile from vllm.config import VllmConfig from vllm.config.speculative import SpeculativeConfig from vllm.model_executor.layers.layernorm import RMSNorm from vllm.model_executor.layers.logits_processor import LogitsProcessor from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding from vllm.model_executor.models.deepseek_mtp import ( DeepSeekMTP, DeepSeekMultiTokenPredictor, DeepSeekMultiTokenPredictorLayer, SharedHead, ) from vllm.model_executor.models.deepseek_ocr import DeepseekOCRForCausalLM from vllm.model_executor.models.deepseek_v2 import DeepseekV2DecoderLayer, get_spec_layer_idx_from_weight_name from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper, maybe_prefix from vllm.platforms import current_platform IMAGE_TOKEN_INDEX = 128815 _MODULE_QUALNAME = "deepseek_ocr_mtp" _registered = False _STACKED_PARAMS = [ ("gate_up_proj", "gate_proj", 0), ("gate_up_proj", "up_proj", 1), ("qkv_proj", "q_proj", "q"), ("qkv_proj", "k_proj", "k"), ("qkv_proj", "v_proj", "v"), ] class DeepSeekOCRMultiTokenPredictorLayer(DeepSeekMultiTokenPredictorLayer): """MTP layer with dense MLP (no MoE). ``n_routed_experts=None`` forces MLP.""" def __init__(self, vllm_config: VllmConfig, prefix: str) -> None: # Skip parent __init__ to intercept decoder layer creation nn.Module.__init__(self) config = vllm_config.speculative_config.draft_model_config.hf_config self.config = config quant_config = vllm_config.quant_config self.enorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) self.hnorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) self.eh_proj = nn.Linear(config.hidden_size * 2, config.hidden_size, bias=False) self.device = current_platform.device_type # V3.2 topk handling (our model doesn't have index_topk) self.is_v32 = hasattr(config, "index_topk") self.shared_head = SharedHead(config=config, prefix=prefix, quant_config=quant_config) # Patch config for MTP decoder block: force dense MLP (no MoE) mtp_config = copy.deepcopy(config) mtp_config.n_routed_experts = None self.mtp_block = DeepseekV2DecoderLayer( vllm_config, prefix, config=mtp_config, ) def forward( self, input_ids: torch.Tensor, positions: torch.Tensor, previous_hidden_states: torch.Tensor, inputs_embeds: torch.Tensor | None = None, spec_step_index: int = 0, ) -> torch.Tensor: """Return post-norm hidden states so FastMTP step k+1 matches training.""" assert inputs_embeds is not None # masking inputs at position 0, as not needed by MTP inputs_embeds = torch.where(positions.unsqueeze(-1) == 0, 0, inputs_embeds) inputs_embeds = self.enorm(inputs_embeds) previous_hidden_states = self.hnorm(previous_hidden_states) hidden_states = self.eh_proj(torch.cat([inputs_embeds, previous_hidden_states], dim=-1)) hidden_states, residual = self.mtp_block(positions=positions, hidden_states=hidden_states, residual=None) hidden_states = residual + hidden_states return self.shared_head(hidden_states) class DeepSeekOCRMultiTokenPredictor(DeepSeekMultiTokenPredictor): """Single shared dense MTP head; K draft steps reuse the same weights.""" def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): # Skip parent __init__ to use our custom layer class nn.Module.__init__(self) config = vllm_config.model_config.hf_config self.mtp_start_layer_idx = config.num_hidden_layers # FastMTP recursive mode requires a single shared head; the K draft # steps are driven by speculative_config.num_speculative_tokens. self.recursive = getattr(config, "mtp_recursive", False) num_mtp_layers = getattr(config, "num_nextn_predict_layers", 1) if self.recursive: assert num_mtp_layers == 1, ( "FastMTP recursive mode (mtp_recursive=True) requires " "num_nextn_predict_layers=1 (single shared head); got " f"{num_mtp_layers}. Use num_speculative_tokens=K for draft depth." ) self.num_mtp_layers = num_mtp_layers self.layers = torch.nn.ModuleDict( { str(idx): DeepSeekOCRMultiTokenPredictorLayer(vllm_config, f"{prefix}.layers.{idx}") for idx in range( self.mtp_start_layer_idx, self.mtp_start_layer_idx + self.num_mtp_layers, ) } ) self.embed_tokens = VocabParallelEmbedding( config.vocab_size, config.hidden_size, prefix=maybe_prefix(prefix, "embed_tokens"), ) self.logits_processor = LogitsProcessor(config.vocab_size) def compute_logits( self, hidden_states: torch.Tensor, spec_step_idx: int = 0, ) -> torch.Tensor: """Skip shared_head.norm — layer forward already returned post-norm states.""" current_step_idx = spec_step_idx % self.num_mtp_layers mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)] logits = self.logits_processor(mtp_layer.shared_head.head, hidden_states) return logits # This file postpones annotations, so vLLM cannot infer dims from live # types. Pass them explicitly or class import raises. @support_torch_compile( dynamic_arg_dims={ "input_ids": 0, "positions": 0, "hidden_states": 0, "intermediate_tensors": 0, "inputs_embeds": 0, } ) class DeepSeekOCRMTP(DeepSeekMTP): """Dense-MLP FastMTP draft model. vLLM's ``method="mtp"`` proposer re-grounds every draft step on the target hidden state. FastMTP was trained with recursive feedback, so callers must set ``speculative_config.method="eagle"`` and this ``forward`` returns ``(output, output)`` for (logits, next-step hidden state). """ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): # Bypass DeepSeekMTP.__init__: it builds a MoE predictor whose # attention names collide with ours. The compile decorator sets # vllm_config / do_not_compile after this body returns. nn.Module.__init__(self) self.config = vllm_config.model_config.hf_config self.model = DeepSeekOCRMultiTokenPredictor(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) self.set_moe_parameters() def set_moe_parameters(self) -> None: """Override: MTP blocks use dense MLP; skip MoE loop and n_group access. Pre-initialise the list attributes the parent sets before its loop, then delegate to extract_moe_parameters(None) for the num_* scalar fields. """ self.expert_weights = [] self.moe_layers = [] self.moe_mlp_layers = [] self.extract_moe_parameters(None) @override def forward( self, input_ids: torch.Tensor | None, positions: torch.Tensor, hidden_states: torch.Tensor, intermediate_tensors=None, inputs_embeds: torch.Tensor | None = None, spec_step_idx: int = 0, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: output = self.model(input_ids, positions, hidden_states, inputs_embeds, spec_step_idx) if self.model.recursive: return output, output return output def _build_weights_mapper(self) -> WeightsMapper: """Build a WeightsMapper that translates checkpoint names to vLLM's format. Insertion order matters — substr rules are applied in sequence by _map_name, so more-specific patterns (mtp_block., shared_head._norm) must come before the catch-all mtp_module.heads.{i}. entry for each head. Note: _rewrite_spec_layer_name() runs AFTER this mapper and re-inserts .mtp_block. for transformer-block weights, so we strip it here. """ N = self.config.num_hidden_layers num_mtp = self.model.num_mtp_layers # Self-contained heads carry their own lm_head / norm / input embedding # (mtp_share_*=False, the newer format). Shared heads (older format) omit # those tensors and reuse the main model's lm_head / model.norm / # model.embed_tokens. Default to shared for backward compatibility. share_lm_head = getattr(self.config, "mtp_share_lm_head", True) share_norm = getattr(self.config, "mtp_share_norm", True) share_embed = getattr(self.config, "mtp_share_embedding_weights", True) substr: dict[str, str | None] = {} for i in range(num_mtp): L = N + i # Strip mtp_block. prefix (_rewrite_spec_layer_name adds it back) substr[f"mtp_module.heads.{i}.mtp_block."] = f"model.layers.{L}." # Rename _norm/_head attrs (must precede the catch-all below) substr[f"mtp_module.heads.{i}.shared_head._norm"] = f"model.layers.{L}.shared_head.norm" substr[f"mtp_module.heads.{i}.shared_head._head"] = f"model.layers.{L}.shared_head.head" # Self-contained head: its own lm_head is stored as shared_head.local_head # (must precede the catch-all below). shared_head.norm falls through to # the catch-all unchanged. Absent in the shared format -> harmless. substr[f"mtp_module.heads.{i}.shared_head.local_head"] = f"model.layers.{L}.shared_head.head" # Catch-all: enorm, hnorm, eh_proj, shared_head.norm, and anything else substr[f"mtp_module.heads.{i}."] = f"model.layers.{L}." # Shared weights — 1:1 while num_mtp==1; first head gets all shared params # (_rewrite_spec_layer_name routes embed_tokens back to model.embed_tokens.weight). # Only wire the main model's tensors into the head when the head shares them; # otherwise the head's own local_head / shared_head.norm / mtp_embed_tokens win. if share_lm_head: substr["lm_head.weight"] = f"model.layers.{N}.shared_head.head.weight" if share_norm: substr["model.norm.weight"] = f"model.layers.{N}.shared_head.norm.weight" if share_embed: substr["model.embed_tokens.weight"] = f"model.layers.{N}.embed_tokens.weight" else: substr["mtp_embed_tokens.weight"] = f"model.layers.{N}.embed_tokens.weight" return WeightsMapper( orig_to_new_substr=substr, # Drop mtp_module.shared_head.* (duplicates main model's norm/lm_head) orig_to_new_prefix={"mtp_module.shared_head.": None}, ) @override def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: """Load weights with name remapping from our checkpoint format. Cannot call super().load_weights() — parent raises if any MTP layer index is missing from loaded_params. Remap checkpoint names, fuse QKV/gate_up, then mark remaining params loaded so the tracker does not fail. """ mapper = self._build_weights_mapper() params_dict = dict(self.named_parameters()) loaded: set[str] = set() remaining: list[tuple[str, torch.Tensor]] = [] for name, w in mapper.apply(weights): if "rotary_emb.inv_freq" in name: continue spec_layer = get_spec_layer_idx_from_weight_name(self.config, name) if spec_layer is None: continue name = self._rewrite_spec_layer_name(spec_layer, name) for fused, shard, shard_id in _STACKED_PARAMS: if shard not in name: continue mapped = name.replace(shard, fused) if mapped not in params_dict: continue params_dict[mapped].weight_loader(params_dict[mapped], w, shard_id) loaded.add(mapped) break else: remaining.append((name, w)) loaded |= AutoWeightsLoader(self).load_weights(iter(remaining)) # MTP block params (enorm, hnorm, eh_proj, layernorms, etc.) are at default # init — mark all as handled so vLLM's weight tracker doesn't raise ValueError. loaded.update(n for n, _ in self.named_parameters()) return loaded class DeepseekOCRForCausalLMOCR(DeepseekOCRForCausalLM): """Main model: drop MTP-only tensors so AutoWeightsLoader does not reject them.""" _MTP_PREFIXES = ("mtp_module.", "mtp_embed_tokens") @override def load_weights(self, weights): def _drop_mtp(it): for name, tensor in it: if not name.startswith(self._MTP_PREFIXES): yield name, tensor return super().load_weights(_drop_mtp(weights)) _OURS = { "DeepseekOCRForCausalLM", "DeepseekOCRForCausalLMOCR", "DeepSeekMTPModel", "EagleDeepSeekMTPModel", } _NESTED_TEXT_CONFIG_KEYS = ("text_config", "language_config") _NESTED_TEXT_SKIP = frozenset((*_NESTED_TEXT_CONFIG_KEYS, "vision_config", "projector_config", "auto_map")) def _flatten_nested_text_config(hf_config) -> None: """Promote decoder fields; drop nested text configs. EAGLEConfig copies ``to_dict()`` onto itself, turning nested text/language configs into dicts that vLLM then rejects. """ for name in _NESTED_TEXT_CONFIG_KEYS: nested = getattr(hf_config, name, None) if nested is None or nested is hf_config: continue src = nested.to_dict() if hasattr(nested, "to_dict") else nested if isinstance(src, dict): for key, value in src.items(): if key in _NESTED_TEXT_SKIP: continue if getattr(hf_config, key, None) is None: setattr(hf_config, key, value) try: delattr(hf_config, name) except Exception: setattr(hf_config, name, None) def _ensure_importable() -> None: """Put this snapshot directory on sys.path / PYTHONPATH for vLLM workers.""" root = str(Path(__file__).resolve().parent) if root not in sys.path: sys.path.insert(0, root) parts = [p for p in os.environ.get("PYTHONPATH", "").split(os.pathsep) if p] if root not in parts: os.environ["PYTHONPATH"] = os.pathsep.join([root, *parts]) def register() -> None: """Register FastMTP architectures with vLLM. Call once before ``LLM(...)``. Registers ``DeepseekOCRForCausalLMOCR``, ``DeepSeekMTPModel``, and ``EagleDeepSeekMTPModel`` (vLLM ≥ 0.21 prefixes ``Eagle`` when ``speculative_config.method="eagle"``). Safe to call more than once. """ global _registered _ensure_importable() ModelRegistry.register_model( "DeepseekOCRForCausalLMOCR", f"{_MODULE_QUALNAME}:DeepseekOCRForCausalLMOCR", ) ModelRegistry.register_model( "DeepSeekMTPModel", f"{_MODULE_QUALNAME}:DeepSeekOCRMTP", ) ModelRegistry.register_model( "EagleDeepSeekMTPModel", f"{_MODULE_QUALNAME}:DeepSeekOCRMTP", ) if _registered: return _orig_hf_config_override = SpeculativeConfig.hf_config_override @staticmethod def _mtp_hf_config_override(hf_config): archs = getattr(hf_config, "architectures", None) or [] if not any(a in _OURS for a in archs): return _orig_hf_config_override(hf_config) hf_config.model_type = "deepseek_mtp" result = _orig_hf_config_override(hf_config) result.architectures = ["DeepSeekMTPModel"] _flatten_nested_text_config(result) if result is not hf_config: hf_config.architectures = ["DeepSeekMTPModel"] _flatten_nested_text_config(hf_config) return result SpeculativeConfig.hf_config_override = _mtp_hf_config_override _registered = True def _default_repetition_detection(): """Built-in n-gram loop stopper; works with FastMTP (custom logits processors do not).""" try: from vllm.sampling_params import RepetitionDetectionParams except ImportError: return None return RepetitionDetectionParams( max_pattern_size=35, min_pattern_size=35, min_count=10, ) def vllm_sampling_params(**kwargs): """``SamplingParams`` with OCR defaults. Pass ``repetition_detection=None`` to disable.""" from vllm import SamplingParams kwargs.setdefault("temperature", 0.0) kwargs.setdefault("repetition_penalty", 1.05) if "repetition_detection" not in kwargs: detection = _default_repetition_detection() if detection is not None: kwargs["repetition_detection"] = detection try: return SamplingParams(**kwargs) except TypeError as exc: if "repetition_detection" in kwargs and "repetition_detection" in str(exc): kwargs.pop("repetition_detection") return SamplingParams(**kwargs) raise def vllm_llm_kwargs( model: str, *, num_speculative_tokens: int = 3, mtp_heads: int = 1, mtp_recursive: bool = True, tensor_parallel_size: int = 1, dtype: str = "bfloat16", **extra, ) -> dict: """Keyword arguments for ``vllm.LLM`` after ``register()``. FastMTP (``mtp_recursive=True``) sets ``speculative_config.method="eagle"`` so each draft step consumes the previous step's hidden state. Use ``vllm_sampling_params()`` for n-gram repetition detection; vLLM rejects custom logits processors while speculative decoding is enabled. """ kwargs = { "model": model, "trust_remote_code": True, "dtype": dtype, "tensor_parallel_size": tensor_parallel_size, "hf_overrides": { "architectures": ["DeepseekOCRForCausalLMOCR"], "num_nextn_predict_layers": mtp_heads, "mtp_recursive": mtp_recursive, "image_token_index": IMAGE_TOKEN_INDEX, }, } if num_speculative_tokens > 0: kwargs["disable_log_stats"] = False spec = { "model": model, "num_speculative_tokens": num_speculative_tokens, } if mtp_recursive: spec["method"] = "eagle" kwargs["speculative_config"] = spec kwargs.update(extra) return kwargs def log_spec_stats(llm) -> None: """Flush vLLM's ``SpecDecoding metrics`` line after a short generate. The engine only prints acceptance on its stats interval (~10s). One-shot ``LLM.generate`` exits before that unless this is called. """ engine = getattr(llm, "llm_engine", None) or getattr(llm, "engine", None) do_log = getattr(engine, "do_log_stats", None) if do_log is None: return try: do_log() except Exception as exc: print(f"[WARN] do_log_stats() failed: {exc}", flush=True) DEFAULT_OCR_PROMPT = ( "Transcribe the provided document image into a clean Markdown format, preserving the natural reading order." ) def prepare_vllm_input(tokenizer, image, prompt: str | None = None) -> dict: """Build one ``llm.generate`` item: chat-templated prompt plus pixels.""" prompt = DEFAULT_OCR_PROMPT if prompt is None else prompt messages = [ { "role": "user", "content": [ {"type": "image", "image": image}, {"type": "text", "text": prompt}, ], } ] text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) return {"prompt": text, "multi_modal_data": {"image": image}}