main
py 991 lines 35 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Inference-only Deepseek-OCR model compatible with HuggingFace weights."""
4
5 import math
6 from collections.abc import Hashable, Iterable, Mapping, Sequence
7 from typing import Annotated, Any, Literal
8
9 import torch
10 import torch.nn as nn
11 from transformers import BatchFeature, CLIPVisionConfig
12
13 from vllm.config import VllmConfig
14 from vllm.config.multimodal import MultiModalDummyOptions
15 from vllm.inputs import MultiModalDataDict
16 from vllm.model_executor.models.interfaces import (
17 MultiModalEmbeddings,
18 SupportsEncoderCudaGraph,
19 SupportsLoRA,
20 SupportsMultiModal,
21 SupportsPP,
22 )
23 from vllm.model_executor.models.module_mapping import MultiModelKeys
24 from vllm.model_executor.models.utils import (
25 AutoWeightsLoader,
26 WeightsMapper,
27 init_vllm_registered_model,
28 maybe_prefix,
29 )
30 from vllm.multimodal import MULTIMODAL_REGISTRY
31 from vllm.multimodal.inputs import (
32 MultiModalFieldConfig,
33 MultiModalKwargsItems,
34 NestedTensors,
35 )
36 from vllm.multimodal.parse import (
37 ImageEmbeddingItems,
38 ImageProcessorItems,
39 ImageSize,
40 MultiModalDataItems,
41 )
42 from vllm.multimodal.processing import (
43 BaseDummyInputsBuilder,
44 BaseMultiModalProcessor,
45 BaseProcessingInfo,
46 PromptReplacement,
47 PromptUpdate,
48 )
49 from vllm.multimodal.processing.processor import HFMultiModalInputs
50 from vllm.sampling_params import SamplingParams
51 from vllm.sequence import IntermediateTensors
52 from vllm.tokenizers import cached_tokenizer_from_config
53 from vllm.transformers_utils.configs.deepseek_vl2 import DeepseekVLV2Config
54 from vllm.transformers_utils.processors.deepseek_ocr import (
55 BASE_SIZE,
56 CROP_MODE,
57 IMAGE_SIZE,
58 DeepseekOCRProcessor,
59 count_tiles,
60 )
61 from vllm.utils.tensor_schema import TensorSchema, TensorShape
62 from vllm.v1.sample.logits_processor import (
63 AdapterLogitsProcessor,
64 RequestLogitsProcessor,
65 )
66 from vllm.v1.worker.encoder_cudagraph_defs import (
67 EncoderCudaGraphCaptureInputs,
68 EncoderCudaGraphConfig,
69 EncoderCudaGraphPathConfig,
70 EncoderCudaGraphReplayBuffers,
71 EncoderItemSpec,
72 )
73
74 from .deepencoder import DeepCLIPVisionTransformer, build_sam_vit_b
75 from .deepseek_vl2 import MlpProjector
76
77 # The image token id may be various
78 _IMAGE_TOKEN = "<image>"
79
80
81 class DeepseekOCRImagePixelInputs(TensorSchema):
82 """Dimensions:
83 - b: Batch size
84 - n: Number of images
85 - p: Number of patches
86 - base_size: Base size of the processor
87 - image_size: Image size of the processor
88 """
89
90 type: Literal["pixel_values"]
91 data: Annotated[
92 torch.Tensor,
93 TensorShape("bn", 3, "base_size", "base_size", dynamic_dims={"bnp"}),
94 ]
95 images_crop: Annotated[
96 torch.Tensor,
97 TensorShape("bnp", 3, "image_size", "image_size", dynamic_dims={"bnp"}),
98 ]
99 images_spatial_crop: Annotated[torch.Tensor, TensorShape("bn", 2)]
100
101
102 class NoRepeatNGramLogitsProcessor:
103 def __init__(
104 self,
105 ngram_size: int,
106 window_size: int,
107 whitelist_token_ids: set[int] | None = None,
108 ):
109 self.ngram_size = ngram_size
110 self.window_size = window_size
111 self.whitelist_token_ids = whitelist_token_ids or set()
112
113 def __call__(
114 self,
115 output_ids: list[int],
116 logits: torch.Tensor,
117 ) -> torch.Tensor:
118 if len(output_ids) < self.ngram_size:
119 return logits
120
121 current_prefix = tuple(output_ids[-(self.ngram_size - 1) :])
122
123 search_start = max(0, len(output_ids) - self.window_size)
124 search_end = len(output_ids) - self.ngram_size + 1
125
126 banned_tokens = set()
127 for i in range(search_start, search_end):
128 ngram = tuple(output_ids[i : i + self.ngram_size])
129 if ngram[:-1] == current_prefix:
130 banned_tokens.add(ngram[-1])
131
132 banned_tokens = banned_tokens - self.whitelist_token_ids
133
134 if banned_tokens:
135 logits[list(banned_tokens)] = -float("inf")
136
137 return logits
138
139
140 class NGramPerReqLogitsProcessor(AdapterLogitsProcessor):
141 """Example of overriding the wrapper class `__init__()` in order to utilize
142 info about the device type"""
143
144 @classmethod
145 def validate_params(cls, params: SamplingParams):
146 ngram_size = params.extra_args and params.extra_args.get("ngram_size")
147 window_size = params.extra_args and params.extra_args.get("window_size", 100)
148 whitelist_token_ids = params.extra_args and params.extra_args.get(
149 "whitelist_token_ids", None
150 )
151 # if ngram_size is not provided, skip validation because the processor
152 # will not be used.
153 if ngram_size is None:
154 return None
155
156 if not isinstance(ngram_size, int) or ngram_size <= 0:
157 raise ValueError(
158 f"`ngram_size` has to be a strictly positive integer, got {ngram_size}."
159 )
160 if not isinstance(window_size, int) or window_size <= 0:
161 raise ValueError(
162 "`window_size` has to be a strictly positive integer, "
163 f"got {window_size}."
164 )
165 if whitelist_token_ids is not None and not isinstance(
166 whitelist_token_ids, Iterable
167 ):
168 raise ValueError(
169 "`whitelist_token_ids` has to be a sequence of integers, "
170 f"got {whitelist_token_ids}."
171 )
172
173 def is_argmax_invariant(self) -> bool:
174 return False
175
176 def new_req_logits_processor(
177 self,
178 params: SamplingParams,
179 ) -> RequestLogitsProcessor | None:
180 ngram_size = params.extra_args and params.extra_args.get("ngram_size")
181 window_size = params.extra_args and params.extra_args.get("window_size", 100)
182 whitelist_token_ids = params.extra_args and params.extra_args.get(
183 "whitelist_token_ids", None
184 )
185 if ngram_size is None:
186 return None
187 assert isinstance(ngram_size, int) and isinstance(window_size, int)
188
189 whitelist_token_ids = set(whitelist_token_ids) if whitelist_token_ids else None
190 return NoRepeatNGramLogitsProcessor(
191 ngram_size=ngram_size,
192 window_size=window_size,
193 whitelist_token_ids=whitelist_token_ids,
194 )
195
196
197 class DeepseekOCRProcessingInfo(BaseProcessingInfo):
198 def get_hf_config(self):
199 return self.ctx.get_hf_config(DeepseekVLV2Config)
200
201 def get_hf_processor(self, **kwargs: object):
202 v1_processor_config = dict(
203 image_size=IMAGE_SIZE,
204 base_size=BASE_SIZE,
205 crop_mode=CROP_MODE,
206 strategy="v1",
207 )
208
209 return self.ctx.get_hf_processor(
210 DeepseekOCRProcessor,
211 **{**v1_processor_config, **kwargs},
212 )
213
214 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
215 return {"image": None}
216
217 def get_num_image_tokens(
218 self, *, image_width: int, image_height: int, cropping: bool = True
219 ) -> int:
220 image_size = IMAGE_SIZE
221 base_size = BASE_SIZE
222 patch_size = 16
223 downsample_ratio = 4
224
225 # Use the caller-supplied `cropping` flag so that callers that disable
226 # crop mode for multi-image requests get a consistent token count.
227 if cropping:
228 if image_width <= IMAGE_SIZE and image_height <= IMAGE_SIZE:
229 crop_ratio = [1, 1]
230 else:
231 # find the closest aspect ratio to the target
232 crop_ratio = count_tiles(
233 image_width, image_height, image_size=IMAGE_SIZE
234 )
235
236 num_width_tiles, num_height_tiles = crop_ratio
237 else:
238 num_width_tiles = num_height_tiles = 1
239
240 h = w = math.ceil((base_size // patch_size) / downsample_ratio)
241
242 h2 = w2 = math.ceil((image_size // patch_size) / downsample_ratio)
243
244 global_views_tokens = h * (w + 1)
245 if num_width_tiles > 1 or num_height_tiles > 1:
246 local_views_tokens = (num_height_tiles * h2) * (num_width_tiles * w2 + 1)
247 else:
248 local_views_tokens = 0
249
250 return global_views_tokens + local_views_tokens + 1
251
252 def get_image_size_with_most_features(self) -> ImageSize:
253 if IMAGE_SIZE == 1024 and BASE_SIZE == 1280:
254 return ImageSize(width=1024 * 2, height=1024 * 2)
255 return ImageSize(width=640 * 2, height=640 * 2)
256
257
258 class DeepseekOCRDummyInputsBuilder(BaseDummyInputsBuilder[DeepseekOCRProcessingInfo]):
259 def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
260 num_images = mm_counts.get("image", 0)
261
262 processor = self.info.get_hf_processor()
263 image_token = processor.image_token
264
265 return image_token * num_images
266
267 def get_dummy_mm_data(
268 self,
269 seq_len: int,
270 mm_counts: Mapping[str, int],
271 mm_options: MultiModalDummyOptions,
272 ) -> MultiModalDataDict:
273 max_image_size = self.info.get_image_size_with_most_features()
274
275 return {
276 "image": self._get_dummy_images(
277 width=max_image_size.width,
278 height=max_image_size.height,
279 num_images=mm_counts.get("image", 0),
280 )
281 }
282
283
284 class DeepseekOCRMultiModalProcessor(
285 BaseMultiModalProcessor[DeepseekOCRProcessingInfo]
286 ):
287 def _get_hf_mm_text(self, mm_counts: Mapping[str, int]) -> str:
288 return self.dummy_inputs.get_dummy_text(mm_counts)
289
290 def _get_hf_mm_inputs(
291 self,
292 mm_items: MultiModalDataItems,
293 hf_kwargs: Mapping[str, object],
294 ) -> HFMultiModalInputs:
295 hf_inputs = super()._get_hf_mm_inputs(mm_items, hf_kwargs)
296 if "text" in hf_inputs.hf_data:
297 hf_inputs.hf_data["prompt"] = hf_inputs.hf_data.pop("text")
298
299 return hf_inputs
300
301 def _get_mm_fields_config(
302 self,
303 hf_inputs: BatchFeature,
304 hf_processor_mm_kwargs: Mapping[str, object],
305 ) -> Mapping[str, MultiModalFieldConfig]:
306 images_spatial_crop = hf_inputs.get("images_spatial_crop", torch.empty((0, 2)))
307 is_tiled = (images_spatial_crop[:, 0] > 1) | (images_spatial_crop[:, 1] > 1)
308 patches_per_image = torch.where(is_tiled, images_spatial_crop.prod(dim=-1), 0)
309 return dict(
310 pixel_values=MultiModalFieldConfig.batched("image"),
311 images_spatial_crop=MultiModalFieldConfig.batched(
312 "image", keep_on_cpu=True
313 ),
314 images_crop=MultiModalFieldConfig.flat_from_sizes(
315 "image", patches_per_image
316 ),
317 )
318
319 def _get_prompt_updates(
320 self,
321 mm_items: MultiModalDataItems,
322 hf_processor_mm_kwargs: Mapping[str, object],
323 out_mm_kwargs: MultiModalKwargsItems,
324 ) -> Sequence[PromptUpdate]:
325 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
326
327 image_token_id = hf_processor.image_token_id
328 assert isinstance(image_token_id, int)
329
330 def get_replacement_deepseek_vl2(item_idx: int):
331 images = mm_items.get_items(
332 "image", (ImageEmbeddingItems, ImageProcessorItems)
333 )
334
335 if isinstance(images, ImageEmbeddingItems):
336 num_image_tokens = images.get_feature_size(item_idx)
337 else:
338 assert isinstance(images, ImageProcessorItems)
339 size = images.get_image_size(item_idx)
340
341 num_image_tokens = self.info.get_num_image_tokens(
342 image_width=size.width,
343 image_height=size.height,
344 cropping=CROP_MODE,
345 )
346 return [image_token_id] * num_image_tokens
347
348 return [
349 PromptReplacement(
350 modality="image",
351 target=[image_token_id],
352 replacement=get_replacement_deepseek_vl2,
353 )
354 ]
355
356
357 @MULTIMODAL_REGISTRY.register_processor(
358 DeepseekOCRMultiModalProcessor,
359 info=DeepseekOCRProcessingInfo,
360 dummy_inputs=DeepseekOCRDummyInputsBuilder,
361 )
362 class DeepseekOCRForCausalLM(
363 nn.Module, SupportsMultiModal, SupportsPP, SupportsLoRA, SupportsEncoderCudaGraph
364 ):
365 hf_to_vllm_mapper = WeightsMapper(
366 orig_to_new_prefix={
367 # map prefix for language backbone
368 "model.embed_tokens.": "language_model.model.embed_tokens.",
369 "model.layers.": "language_model.model.layers.",
370 "model.norm.": "language_model.model.norm.",
371 "lm_head.": "language_model.lm_head.",
372 # remove "model." prefix for other components
373 "model.": "",
374 }
375 )
376
377 @classmethod
378 def get_placeholder_str(cls, modality: str, i: int) -> str | None:
379 if modality.startswith("image"):
380 return "<image>"
381
382 raise ValueError("Only image modality is supported")
383
384 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
385 super().__init__()
386
387 config: DeepseekVLV2Config = vllm_config.model_config.hf_config
388 quant_config = vllm_config.quant_config
389 multimodal_config = vllm_config.model_config.multimodal_config
390
391 self.config = config
392 self.model_config = vllm_config.model_config
393 self.multimodal_config = multimodal_config
394
395 self.vision_config = config.vision_config
396 self.projector_config = config.projector_config
397 self.text_config = config.text_config
398
399 model_config = vllm_config.model_config
400 tokenizer = cached_tokenizer_from_config(model_config)
401 self.image_token_id = tokenizer.vocab[_IMAGE_TOKEN]
402
403 with self._mark_tower_model(vllm_config, "image"):
404 self.sam_model = build_sam_vit_b()
405 clip_vision_config = CLIPVisionConfig(
406 hidden_size=1024,
407 intermediate_size=4096,
408 num_attention_heads=16,
409 num_hidden_layers=24,
410 image_size=224,
411 patch_size=14,
412 projection_dim=512,
413 layer_norm_eps=1e-5,
414 )
415 self.vision_model = DeepCLIPVisionTransformer(
416 config=clip_vision_config,
417 quant_config=quant_config,
418 prefix=maybe_prefix(prefix, "vision_model"),
419 )
420
421 self.projector = MlpProjector(self.projector_config)
422 self.tile_tag = config.tile_tag
423 self.global_view_pos = config.global_view_pos
424
425 # special token for image token sequence format
426 n_embed = self.projector_config.n_embed
427 embed_std = 1 / torch.sqrt(torch.tensor(n_embed, dtype=torch.float32))
428 if self.tile_tag == "2D":
429 # <|view_separator|>, <|\n|>
430 self.image_newline = nn.Parameter(torch.randn(n_embed) * embed_std)
431 # This is a typo in original implementation
432 self.view_seperator = nn.Parameter(torch.randn(n_embed) * embed_std)
433 else:
434 raise ValueError(
435 f"Only 2D tile_tag is supported currently, got: {self.tile_tag}"
436 )
437
438 with self._mark_language_model(vllm_config):
439 self.language_model = init_vllm_registered_model(
440 vllm_config=vllm_config,
441 hf_config=self.text_config,
442 prefix=maybe_prefix(prefix, "language_model"),
443 )
444
445 self.make_empty_intermediate_tensors = (
446 self.language_model.make_empty_intermediate_tensors
447 )
448
449 def _parse_and_validate_image_input(
450 self, **kwargs: object
451 ) -> DeepseekOCRImagePixelInputs | None:
452 pixel_values = kwargs.pop("pixel_values", None)
453 images_spatial_crop = kwargs.pop("images_spatial_crop", None)
454 images_crop = kwargs.pop("images_crop", None)
455
456 if pixel_values is None:
457 return None
458 assert isinstance(pixel_values, torch.Tensor)
459 assert images_crop is None or isinstance(images_crop, torch.Tensor)
460
461 # Use actual tensor spatial dim instead of hardcoded
462 # vision_config.image_size (1024). The vision encoders (SAM & CLIP)
463 # support arbitrary resolutions via pos-encoding interpolation,
464 # so Tiny/Small/Base/Large variants all work with the same weights.
465 base_size = pixel_values.shape[-1]
466 image_size = images_crop.shape[-1] if images_crop is not None else base_size
467
468 return DeepseekOCRImagePixelInputs(
469 type="pixel_values",
470 data=pixel_values,
471 images_crop=images_crop,
472 images_spatial_crop=images_spatial_crop,
473 resolve_bindings={
474 "base_size": base_size,
475 "image_size": image_size,
476 },
477 )
478
479 def _encode_global_features(self, image_tensor: torch.Tensor) -> torch.Tensor:
480 global_features_1 = self.sam_model(image_tensor)
481 global_features_2 = self.vision_model(image_tensor, global_features_1)
482 features = torch.cat(
483 (
484 global_features_2[:, 1:],
485 global_features_1.flatten(2).permute(0, 2, 1),
486 ),
487 dim=-1,
488 )
489 features = self.projector(features)
490
491 _, hw, dim = features.shape
492 side = int(hw**0.5)
493
494 features = features.view(side, side, dim)
495 newline = self.image_newline[None, None, :].expand(side, 1, dim)
496 features = torch.cat([features, newline], dim=1)
497 return features.view(-1, dim)
498
499 def _encode_local_features(
500 self, patches: torch.Tensor, crop_shape: torch.Tensor
501 ) -> torch.Tensor | None:
502 if torch.sum(patches).item() == 0:
503 return None
504
505 local_features_1 = self.sam_model(patches)
506 local_features_2 = self.vision_model(patches, local_features_1)
507 features = torch.cat(
508 (
509 local_features_2[:, 1:],
510 local_features_1.flatten(2).permute(0, 2, 1),
511 ),
512 dim=-1,
513 )
514 features = self.projector(features)
515
516 return self._assemble_patch_grid(features, crop_shape)
517
518 def _assemble_patch_grid(
519 self, features: torch.Tensor, crop_shape: torch.Tensor
520 ) -> torch.Tensor:
521 """Assemble projected patches into a 2-D tile grid with newline columns."""
522 _, hw, dim = features.shape
523 patch_side = int(hw**0.5)
524 width_tiles = int(crop_shape[0].item())
525 height_tiles = int(crop_shape[1].item())
526
527 features = (
528 features.view(height_tiles, width_tiles, patch_side, patch_side, dim)
529 .permute(0, 2, 1, 3, 4)
530 .reshape(height_tiles * patch_side, width_tiles * patch_side, dim)
531 )
532 newline = self.image_newline[None, None, :].expand(
533 height_tiles * patch_side, 1, dim
534 )
535 features = torch.cat([features, newline], dim=1)
536 return features.view(-1, dim)
537
538 def _pixel_values_to_embedding(
539 self,
540 pixel_values: torch.Tensor,
541 images_crop: torch.Tensor,
542 images_spatial_crop: torch.Tensor,
543 ) -> NestedTensors:
544 images_in_this_batch = []
545
546 is_tiled = (images_spatial_crop[:, 0] > 1) | (images_spatial_crop[:, 1] > 1)
547 patches_per_image = torch.where(is_tiled, images_spatial_crop.prod(dim=-1), 0)
548 images_crop = images_crop.split(patches_per_image.tolist())
549 for jdx in range(images_spatial_crop.size(0)):
550 patches = images_crop[jdx]
551 image_ori = pixel_values[[jdx]]
552 crop_shape = images_spatial_crop[jdx]
553
554 global_features = self._encode_global_features(image_ori)
555 local_features = self._encode_local_features(patches, crop_shape)
556
557 if local_features is not None:
558 combined = torch.cat(
559 [local_features, global_features, self.view_seperator[None, :]],
560 dim=0,
561 )
562 else:
563 combined = torch.cat(
564 [global_features, self.view_seperator[None, :]], dim=0
565 )
566
567 images_in_this_batch.append(combined)
568
569 return images_in_this_batch
570
571 def _process_image_input(
572 self, image_input: DeepseekOCRImagePixelInputs
573 ) -> torch.Tensor:
574 pixel_values = image_input.data
575 images_crop = image_input.images_crop
576 images_spatial_crop = image_input.images_spatial_crop.to(dtype=torch.long)
577
578 vision_features = self._pixel_values_to_embedding(
579 pixel_values=pixel_values,
580 images_crop=images_crop,
581 images_spatial_crop=images_spatial_crop,
582 )
583
584 return vision_features
585
586 def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings | None:
587 image_input = self._parse_and_validate_image_input(**kwargs)
588 if image_input is None:
589 return None
590 vision_embeddings = self._process_image_input(image_input)
591 return vision_embeddings
592
593 def forward(
594 self,
595 input_ids: torch.Tensor | None,
596 positions: torch.Tensor,
597 intermediate_tensors: IntermediateTensors | None = None,
598 inputs_embeds: torch.Tensor | None = None,
599 **kwargs: object,
600 ):
601 if intermediate_tensors is not None:
602 inputs_embeds = None
603
604 hidden_states = self.language_model(
605 input_ids, positions, intermediate_tensors, inputs_embeds=inputs_embeds
606 )
607
608 return hidden_states
609
610 def compute_logits(
611 self,
612 hidden_states: torch.Tensor,
613 ) -> torch.Tensor | None:
614 return self.language_model.compute_logits(hidden_states)
615
616 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
617 loader = AutoWeightsLoader(self)
618 autoloaded_weights = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
619 return autoloaded_weights
620
621 def get_mm_mapping(self) -> MultiModelKeys:
622 """Get the module prefix in multimodal models."""
623 return MultiModelKeys.from_string_field(
624 language_model="language_model",
625 connector="projector",
626 tower_model=["sam_model", "vision_model"],
627 )
628
629 # -- Fixed spatial constants (computed from BASE_SIZE / IMAGE_SIZE) --
630
631 @property
632 def image_side(self) -> int:
633 """Number of output grid cells per spatial dim for a global image."""
634 return math.ceil((BASE_SIZE // 16) / 4) # 16
635
636 @property
637 def global_image_output_token(self) -> int:
638 """Tokens per global image (grid + one newline per row)."""
639 return self.image_side * (self.image_side + 1) # 272
640
641 @property
642 def patch_side(self) -> int:
643 """Number of output grid cells per spatial dim for a local patch."""
644 return math.ceil((IMAGE_SIZE // 16) / 4) # 10
645
646 @property
647 def single_patch_output_token(self) -> int:
648 """Tokens per local patch (square grid, no newlines)."""
649 return self.patch_side * self.patch_side # 100
650
651 # -- SupportsEncoderCudaGraph protocol methods --
652
653 def _get_num_input_output_tokens(
654 self,
655 image_spatial_crop: torch.Tensor | None = None,
656 ) -> tuple[int, int, int, int]:
657 """Return (num_input_tokens, num_output_tokens, global_output_token,
658 local_output_token) for a single image described by
659 ``image_spatial_crop``.
660 """
661 is_tiled = False
662 if image_spatial_crop is not None:
663 is_tiled = image_spatial_crop[0] > 1 or image_spatial_crop[1] > 1
664
665 # Compute input size:
666 global_input_side = BASE_SIZE // 16 # 64
667 local_input_side = IMAGE_SIZE // 16 # 40
668 num_input_tokens = global_input_side**2
669
670 if is_tiled:
671 assert image_spatial_crop is not None
672 num_patches = int(image_spatial_crop.prod().item())
673 num_input_tokens += num_patches * (local_input_side**2)
674
675 global_output_token = self.global_image_output_token
676 num_output_tokens = global_output_token
677
678 local_output_token = 0
679 if is_tiled:
680 local_output_token = num_patches * self.single_patch_output_token
681 num_output_tokens += local_output_token
682
683 return (
684 num_input_tokens,
685 num_output_tokens,
686 global_output_token,
687 local_output_token,
688 )
689
690 def get_encoder_cudagraph_config(self):
691 return EncoderCudaGraphConfig(
692 modalities=["image"],
693 buffer_keys=["pixel_values"],
694 out_hidden_size=self.projector_config.n_embed,
695 paths={
696 "global": EncoderCudaGraphPathConfig(
697 min_token_budget=self.global_image_output_token
698 ),
699 "local": EncoderCudaGraphPathConfig(
700 min_token_budget=self.single_patch_output_token,
701 allow_zero_tokens=True,
702 ),
703 },
704 )
705
706 def get_encoder_cudagraph_budget_range(
707 self,
708 vllm_config,
709 ) -> tuple[int, int]:
710 # Min budget: at least one global image with newline tokens (without patches).
711 min_budget = self.global_image_output_token
712 max_budget = min(
713 vllm_config.scheduler_config.max_num_batched_tokens,
714 self.model_config.max_model_len,
715 )
716 return (min_budget, max_budget)
717
718 def get_encoder_cudagraph_item_specs(
719 self,
720 mm_kwargs: dict[str, Any],
721 ) -> list[EncoderItemSpec]:
722 item_specs = []
723 for image_spatial_crop in mm_kwargs["images_spatial_crop"]:
724 (
725 num_input_tokens,
726 num_output_tokens,
727 global_output_token,
728 local_output_token,
729 ) = self._get_num_input_output_tokens(image_spatial_crop)
730 item_specs.append(
731 EncoderItemSpec(
732 input_size=num_input_tokens,
733 output_tokens=num_output_tokens,
734 path_output_tokens={
735 "global": global_output_token,
736 "local": local_output_token,
737 },
738 )
739 )
740 return item_specs
741
742 def select_encoder_cudagraph_items(
743 self,
744 mm_kwargs: dict[str, Any],
745 indices: list[int],
746 ) -> dict[str, Any]:
747 pixel_values = mm_kwargs["pixel_values"]
748 images_crop = mm_kwargs["images_crop"]
749 images_spatial_crop = mm_kwargs["images_spatial_crop"]
750
751 if len(indices) == 0:
752 return {
753 "pixel_values": pixel_values[:0],
754 "images_crop": images_crop[:0],
755 "images_spatial_crop": images_spatial_crop[:0],
756 }
757
758 is_tiled = (images_spatial_crop[:, 0] > 1) | (images_spatial_crop[:, 1] > 1)
759 patches_per_image = torch.where(is_tiled, images_spatial_crop.prod(dim=-1), 0)
760 cum_patches = [0]
761 for num_patches in patches_per_image:
762 cum_patches.append(cum_patches[-1] + int(num_patches))
763
764 selected_pv = pixel_values[indices]
765 selected_ic = torch.cat(
766 [images_crop[cum_patches[i] : cum_patches[i + 1]] for i in indices]
767 )
768 selected_sp = images_spatial_crop[indices]
769
770 return {
771 "pixel_values": selected_pv,
772 "images_crop": selected_ic,
773 "images_spatial_crop": selected_sp,
774 }
775
776 def prepare_encoder_cudagraph_capture_inputs(
777 self,
778 token_budget: int,
779 max_batch_size: int,
780 max_frames_per_batch: int,
781 device: torch.device,
782 dtype: torch.dtype,
783 path: str = "default",
784 axis_keys: tuple[Hashable, ...] | None = None,
785 ):
786 assert path in ("global", "local")
787
788 if path == "global":
789 max_num_images = token_budget // self.global_image_output_token
790 max_batch_size = min(max_batch_size, max_num_images)
791 dummy_pixel_values = torch.randn(
792 max_batch_size,
793 3,
794 BASE_SIZE,
795 BASE_SIZE,
796 device=device,
797 dtype=dtype,
798 )
799 values = {"pixel_values": dummy_pixel_values}
800 else:
801 max_num_patches = token_budget // self.single_patch_output_token
802 dummy_images_crop = torch.randn(
803 max_num_patches,
804 3,
805 IMAGE_SIZE,
806 IMAGE_SIZE,
807 device=device,
808 dtype=dtype,
809 )
810 values = {"images_crop": dummy_images_crop}
811
812 return EncoderCudaGraphCaptureInputs(values=values)
813
814 def prepare_encoder_cudagraph_replay_buffers(
815 self,
816 mm_kwargs: dict[str, Any],
817 max_batch_size: int,
818 max_frames_per_batch: int,
819 path: str = "default",
820 ):
821 assert path in ("global", "local")
822
823 if path == "global":
824 values = {"pixel_values": mm_kwargs["pixel_values"]}
825 else:
826 values = {"images_crop": mm_kwargs["images_crop"]}
827
828 return EncoderCudaGraphReplayBuffers(values=values)
829
830 def _batched_encoder_forward_global_path(
831 self,
832 pixel_values: torch.Tensor,
833 ) -> torch.Tensor:
834 """Encode batched global images with newline tokens inserted.
835 Output shape: ``[B * 272, n_embed]``.
836 """
837 bsz = pixel_values.shape[0]
838 global_features_1 = self.sam_model(pixel_values)
839 global_features_2 = self.vision_model(pixel_values, global_features_1)
840 features = torch.cat(
841 (
842 global_features_2[:, 1:],
843 global_features_1.flatten(2).permute(0, 2, 1),
844 ),
845 dim=-1,
846 )
847 features = self.projector(features)
848 side = self.image_side
849 dim = features.shape[-1]
850 features = features.view(bsz, side, side, dim)
851 newline = self.image_newline.view(1, 1, 1, dim).expand(bsz, side, 1, dim)
852 features = torch.cat([features, newline], dim=2)
853 return features.view(-1, dim)
854
855 def _batched_encoder_forward_local_path(
856 self,
857 images_crop: torch.Tensor,
858 ) -> torch.Tensor:
859 """Encode local patches without newline insertion (newlines are added later
860 in ``postprocess_encoder_output`` via ``_assemble_patch_grid``).
861 Output shape: ``[P * 100, n_embed]``.
862 """
863 features_1 = self.sam_model(images_crop)
864 features_2 = self.vision_model(images_crop, features_1)
865 features = torch.cat(
866 (
867 features_2[:, 1:],
868 features_1.flatten(2).permute(0, 2, 1),
869 ),
870 dim=-1,
871 )
872 features = self.projector(features)
873 return features.view(-1, features.shape[-1])
874
875 def encoder_cudagraph_forward(
876 self,
877 values: dict[str, torch.Tensor],
878 path: str = "default",
879 ) -> torch.Tensor:
880 assert path in ("global", "local")
881
882 if path == "global":
883 pixel_values = values["pixel_values"]
884 return self._batched_encoder_forward_global_path(pixel_values)
885 else:
886 images_crop = values["images_crop"]
887 return self._batched_encoder_forward_local_path(images_crop)
888
889 def encoder_eager_forward(
890 self,
891 mm_kwargs: dict[str, Any],
892 path: str = "default",
893 ) -> torch.Tensor:
894 """Eager encoder forward with optional per-path execution.
895
896 ``path="default"``: full forward (global + local + assembly).
897 ``path="global"``: global-only batched forward with newlines.
898 ``path="local"``: local-only batched forward without newlines.
899 """
900 if path == "default":
901 # Original eager implementation: process each image one by one
902 # (with both global and local paths) and concatenate results.
903 image_input = DeepseekOCRImagePixelInputs(
904 type="pixel_values",
905 data=mm_kwargs["pixel_values"],
906 images_crop=mm_kwargs["images_crop"],
907 images_spatial_crop=mm_kwargs["images_spatial_crop"],
908 )
909 vision_embeddings = self._process_image_input(image_input)
910 return torch.cat(vision_embeddings, dim=0)
911
912 assert path in ("global", "local")
913 if path == "global":
914 pixel_values = mm_kwargs["pixel_values"]
915 return self._batched_encoder_forward_global_path(pixel_values)
916 else:
917 images_crop = mm_kwargs["images_crop"]
918 return self._batched_encoder_forward_local_path(images_crop)
919
920 def postprocess_encoder_output(
921 self,
922 outputs: dict[str, torch.Tensor],
923 indices: list[int],
924 per_item_out_tokens: list[int],
925 dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
926 clone: bool = False,
927 batch_mm_kwargs: dict[str, Any] | None = None,
928 ) -> None:
929 """Assemble per-image embeddings from global and local encoder outputs.
930
931 ``output['global']`` contains global-image features with newlines already
932 inserted (from CUDA graph replay or eager fallback):
933 ``[B * 272, n_embed]``.
934
935 ``output['local']`` contains local-patch features without
936 newlines (from CUDA graph replay or eager fallback):
937 ``[P * 100, n_embed]``. May be ``None`` if no patches in batch.
938
939 This method:
940 1. Splits ``output['global']`` into per-image global portions.
941 2. Splits ``output['local']`` into per-image patch groups.
942 3. For each image: assembles patch grid with newlines via
943 ``_assemble_patch_grid``, then concatenates
944 ``[local_tiled, global, view_seperator]``.
945 """
946 output = outputs["global"]
947 local_output = outputs.get("local")
948 assert batch_mm_kwargs is not None
949 bsz = len(indices)
950 n_embed = output.shape[-1]
951
952 images_spatial_crop = batch_mm_kwargs["images_spatial_crop"]
953 is_tiled = (images_spatial_crop[:, 0] > 1) | (images_spatial_crop[:, 1] > 1)
954 num_patches = [
955 int(np) for np in torch.where(is_tiled, images_spatial_crop.prod(dim=-1), 0)
956 ]
957 total_patches = sum(num_patches)
958
959 global_part = output[: bsz * self.global_image_output_token].reshape(
960 bsz, self.global_image_output_token, n_embed
961 )
962
963 # Split local output into per-patch groups.
964 local_flat = None
965 if total_patches > 0 and local_output is not None:
966 local_flat = local_output[: total_patches * self.single_patch_output_token]
967 local_flat = local_flat.reshape(
968 total_patches, self.single_patch_output_token, n_embed
969 )
970
971 cur_patch = 0
972 for i, idx in enumerate(indices):
973 num_patch = num_patches[i]
974 single_image_output: list[torch.Tensor] = []
975
976 # 1. Process local patches: assemble tile grid, add 1 newline per row.
977 if num_patch > 0 and local_flat is not None:
978 patches = local_flat[cur_patch : cur_patch + num_patch]
979 cur_patch += num_patch
980 single_image_output.append(
981 self._assemble_patch_grid(patches, images_spatial_crop[i])
982 )
983
984 # 2. Global image: newlines already inserted.
985 single_image_output.append(global_part[i])
986
987 # 3. Add view separator for each image.
988 single_image_output.append(self.view_seperator[None, :])
989
990 # 4. Save final outputs for each image.
991 dest[idx] = torch.cat(single_image_output, dim=0)