@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 0b6d52629f
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 6, 10:45 UTC

Support temporal compression for Nemotron-3-VL videos (#36808)

Signed-off-by: Collin McCarthy <cmccarthy@nvidia.com>

Collin McCarthy committed Mar 19, 2026 at 01:02 UTC 0b6d52629fe84f0071ce41a954162e59dc98157e
5 files changed +553 -130
vllm/model_executor/models/nano_nemotron_vl.py
+175 -35
@@ -8,6 +8,7 @@
8 # --------------------------------------------------------
9
10 import copy
11 +import math
12 import warnings
13 from abc import abstractmethod
14 from collections.abc import Iterable, Mapping, Sequence
@@ -77,6 +78,7 @@ from vllm.renderers import TokenizeParams
78 from vllm.sequence import IntermediateTensors
79 from vllm.tokenizers import cached_tokenizer_from_config
80 from vllm.transformers_utils.configs.radio import RadioConfig
81 +from vllm.transformers_utils.processors.internvl import get_internvl_target_ratios
82 from vllm.transformers_utils.processors.nano_nemotron_vl import (
83 AUDIO_CONTEXT,
84 IMG_CONTEXT,
@@ -85,7 +87,7 @@ from vllm.transformers_utils.processors.nano_nemotron_vl import (
87 BaseNanoNemotronVLProcessor,
88 DynamicResolutionImageTiler,
89 NanoNemotronVLProcessor,
88 - get_internvl_target_ratios,
90 + get_video_target_size_and_feature_size,
91 )
92 from vllm.utils.tensor_schema import TensorSchema, TensorShape
93
@@ -295,10 +297,13 @@ class NanoNemotronVLProcessingInfo(BaseNanoNemotronVLProcessingInfo):
297 max_videos = mm_counts.get("video", 0)
298
299 processor = self.get_hf_processor() # we get the CustomProcessor here
300 + T = processor.video_temporal_patch_size
301
302 max_image_tokens = self.get_max_image_tokens() * max_images
300 - max_total_frames = (seq_len - max_image_tokens) // processor.num_image_token
301 - max_frames_per_video = max_total_frames // max(max_videos, 1)
303 + tokens_per_tubelet = processor.num_video_token
304 + max_total_tubelets = (seq_len - max_image_tokens) // tokens_per_tubelet
305 + max_tubelets_per_video = max_total_tubelets // max(max_videos, 1)
306 + max_frames_per_video = max_tubelets_per_video * T
307 return max(max_frames_per_video, 1)
308
309 def get_hf_processor(self, **kwargs: object) -> NanoNemotronVLProcessor:
@@ -589,28 +594,49 @@ class NanoNemotronVLMultiModalProcessor(
594 video_num_patches = []
595
596 def get_video_replacement_internvl(item_idx: int):
592 - feature_size = hf_processor.num_image_token
597 video, metadata = mm_items["video"][item_idx]
598 + patch_size = hf_processor.config.patch_size
599 + downsample_ratio = hf_processor.config.downsample_ratio
600 + target_patches = hf_processor.video_target_num_patches
601 +
602 + if target_patches is not None and video is not None and video.shape[0] > 0:
603 + orig_h, orig_w = video.shape[1], video.shape[2]
604 + _, _, feature_size = get_video_target_size_and_feature_size(
605 + orig_w=orig_w,
606 + orig_h=orig_h,
607 + target_patches=target_patches,
608 + maintain_aspect_ratio=hf_processor.video_maintain_aspect_ratio,
609 + patch_size=patch_size,
610 + downsample_ratio=downsample_ratio,
611 + )
612 + else:
613 + feature_size = hf_processor.num_image_token
614 num_patches = video_num_patches[item_idx]
615 if num_patches is not None:
616 assert isinstance(num_patches, int)
617
618 + T = hf_processor.video_temporal_patch_size
619 + if T > 1 and num_patches is not None:
620 + num_tubelets = math.ceil(num_patches / T)
621 + else:
622 + num_tubelets = num_patches
623 +
624 video_pruning_rate = self.info.ctx.get_mm_config().video_pruning_rate
625 if video_pruning_rate is not None and video_pruning_rate > 0.0:
626 # Start of EVS-specific code
627 num_tokens = compute_retained_tokens_count(
628 tokens_per_frame=feature_size,
603 - num_frames=num_patches,
629 + num_frames=num_tubelets,
630 q=video_pruning_rate,
631 )
632 # Here we just need placeholders that won't actually be replaced -
633 # we just need to make sure the total number of tokens is correct
634 # assign all tokens to the first frame
609 - tokens_per_frame = [num_tokens] + [0] * (num_patches - 1)
635 + tokens_per_frame = [num_tokens] + [0] * (num_tubelets - 1)
636
637 # End of EVS-specific code
638 else:
613 - tokens_per_frame = [feature_size] * num_patches
639 + tokens_per_frame = [feature_size] * num_tubelets
640
641 frame_duration_ms = int(1000 / metadata["fps"])
642 return hf_processor.get_video_repl(
@@ -621,6 +647,7 @@ class NanoNemotronVLMultiModalProcessor(
647 img_start_token_ids=hf_processor._img_start_token_ids,
648 img_end_token_ids=hf_processor._img_end_token_ids,
649 img_context_token_ids=hf_processor._img_context_token_ids,
650 + video_temporal_patch_size=T,
651 )
652
653 if self.info.supports_video:
@@ -745,15 +772,39 @@ class NanoNemotronVLDummyInputsBuilder(
772 if self.info.supports_video:
773 config = self.info.get_hf_config()
774 image_size: int = config.force_image_size
775 + processor = self.info.get_hf_processor()
776 +
777 + # When video_target_num_patches is set the per-frame pixel
778 + # resolution can exceed image_size. Use the actual target
779 + # dimensions so that profiling sees the correct upper bound.
780 + if processor.video_target_num_patches is not None:
781 + target_w, target_h, _ = get_video_target_size_and_feature_size(
782 + orig_w=image_size,
783 + orig_h=image_size,
784 + target_patches=processor.video_target_num_patches,
785 + maintain_aspect_ratio=processor.video_maintain_aspect_ratio,
786 + patch_size=config.patch_size,
787 + downsample_ratio=config.downsample_ratio,
788 + )
789 + video_width, video_height = target_w, target_h
790 + else:
791 + video_width, video_height = image_size, image_size
792 +
793 target_num_frames = self.info.get_num_frames_with_most_features(
794 seq_len, mm_counts
795 )
796 + mm_config = self.info.ctx.get_mm_config()
797 + if num_frames := mm_config.media_io_kwargs.get("video", {}).get(
798 + "num_frames"
799 + ):
800 + assert num_frames > 0
801 + target_num_frames = num_frames
802 num_videos = mm_counts.get("video", 0)
803 video_overrides = mm_options.get("video")
804 dummy_video = {
805 "video": self._get_dummy_videos(
755 - width=image_size,
756 - height=image_size,
806 + width=video_width,
807 + height=video_height,
808 num_frames=target_num_frames,
809 num_videos=num_videos,
810 overrides=video_overrides,
@@ -790,6 +841,9 @@ class NanoNemotronVLDummyInputsBuilder(
841 class NemotronH_Nano_VL_V2(
842 nn.Module, HasInnerState, IsHybrid, SupportsMultiModal, SupportsMultiModalPruning
843 ):
844 + requires_sequential_video_encoding = True
845 + """Temporarily needed for dynamic res video w/ conv3d, doesn't support bs>1 yet"""
846 +
847 @classmethod
848 def get_placeholder_str(cls, modality: str, i: int) -> str | None:
849 if modality.startswith("image"):
@@ -817,6 +871,11 @@ class NemotronH_Nano_VL_V2(
871 self.image_tag_type = config.image_tag_type
872 self.video_pruning_rate = multimodal_config.video_pruning_rate
873
874 + vision_config = getattr(config, "vision_config", config)
875 + self.video_temporal_patch_size: int = getattr(
876 + vision_config, "video_temporal_patch_size", 1
877 + )
878 +
879 with self._mark_language_model(vllm_config):
880 self.language_model = init_vllm_registered_model(
881 vllm_config=vllm_config,
@@ -838,11 +897,12 @@ class NemotronH_Nano_VL_V2(
897
898 mlp1 = nn.Sequential(
899 RMSNorm(
841 - hidden_size=vit_hidden_size * int(1 / self.downsample_ratio) ** 2,
900 + hidden_size=vit_hidden_size
901 + * int(round(1 / self.downsample_ratio)) ** 2,
902 eps=1e-5,
903 ),
904 nn.Linear(
845 - vit_hidden_size * int(1 / self.downsample_ratio) ** 2,
905 + vit_hidden_size * int(round(1 / self.downsample_ratio)) ** 2,
906 vision_projection_hidden_size,
907 bias=False,
908 ),
@@ -958,19 +1018,37 @@ class NemotronH_Nano_VL_V2(
1018 vit_embeds = self.mlp1(vit_embeds)
1019 return vit_embeds
1020
961 - def extract_feature(self, pixel_values: torch.Tensor):
1021 + def extract_feature(
1022 + self,
1023 + pixel_values: torch.Tensor,
1024 + num_frames: int | None = None,
1025 + ) -> torch.Tensor:
1026 # Process images in a micro-batch of at most 128 frames per call
963 - # This is done on purpose to ensure peak GPU ram usage of huge batch
964 - # (namely for really long videos with EVS ON) won't cause any problems
965 - # as we don't support chunked prefill for video media
966 - micro_batch_size = 128
967 - n = pixel_values.shape[0]
1027 + # This is done on purpose to ensure peak GPU ram usage of huge batch
1028 + # (namely for really long videos with EVS ON) won't cause any problems
1029 + # as we don't support chunked prefill for video media
1030 + # When num_frames is provided and temporal_patch_size > 1, consecutive
1031 + # frames are grouped into tubelets — the batch size must be a multiple
1032 + # of T so chunk boundaries don't split a tubelet.
1033 + N, _C, H, W = pixel_values.shape
1034 +
1035 + T = self.video_temporal_patch_size if num_frames is not None else 1
1036 + micro_batch_size = 128 - (128 % T)
1037 + patch_size = self.patch_size
1038 + H_patches = H // patch_size
1039 + W_patches = W // patch_size
1040 +
1041 vit_embeds_list = []
969 - for i in range(0, n, micro_batch_size):
970 - _, vit_embeds = self.vision_model(pixel_values[i : i + micro_batch_size])
1042 + for i in range(0, N, micro_batch_size):
1043 + chunk = pixel_values[i : i + micro_batch_size]
1044 + if num_frames is not None and T > 1:
1045 + _, vit_embeds = self.vision_model(chunk, num_frames=chunk.shape[0])
1046 + else:
1047 + _, vit_embeds = self.vision_model(chunk)
1048 vit_embeds = vit_embeds.to(dtype=torch.bfloat16)
972 - h = w = int(vit_embeds.shape[1] ** 0.5)
973 - vit_embeds = vit_embeds.reshape(vit_embeds.shape[0], h, w, -1)
1049 + vit_embeds = vit_embeds.reshape(
1050 + vit_embeds.shape[0], H_patches, W_patches, -1
1051 + )
1052 vit_embeds = self.pixel_shuffle(
1053 vit_embeds, scale_factor=self.downsample_ratio
1054 )
@@ -1042,16 +1120,21 @@ class NemotronH_Nano_VL_V2(
1120 ) -> tuple[torch.Tensor, ...]:
1121 """Process video input and create final embeddings with video content
1122 and indicator tokens."""
1045 - # Get video embeddings using the same processing as images
1046 - video_embeddings = self._process_image_input(video_input)
1123 + T = self.video_temporal_patch_size
1124 +
1125 + if T > 1:
1126 + video_embeddings = self._extract_video_embeddings_temporal(video_input)
1127 + else:
1128 + video_embeddings = self._process_image_input(video_input)
1129
1130 final_video_embeddings: tuple[torch.Tensor, ...] = ()
1131
1050 - image_rows = image_cols = self.config.force_image_size
1132 downsample_ratio = self.config.downsample_ratio
1133 patch_size = self.config.patch_size
1053 - rows = int(image_rows * downsample_ratio // patch_size)
1054 - cols = int(image_cols * downsample_ratio // patch_size)
1134 + pixel_values = video_input["pixel_values_flat"]
1135 + frame_h, frame_w = pixel_values.shape[-2], pixel_values.shape[-1]
1136 + rows = int(frame_h * downsample_ratio // patch_size)
1137 + cols = int(frame_w * downsample_ratio // patch_size)
1138 video_pruning_rate = self.video_pruning_rate
1139 video_num_frames = video_input["num_patches"].tolist()
1140 video_frames_indices = video_input["frames_indices"].split(video_num_frames)
@@ -1062,13 +1145,14 @@ class NemotronH_Nano_VL_V2(
1145 num_frames = video_num_frames[i]
1146 frames_indices = video_frames_indices[i].tolist()
1147 frame_duration_ms = video_input["frame_duration_ms"][i].item()
1065 - assert single_video_embeddings.shape[0] % num_frames == 0
1148 + num_tubelets = math.ceil(num_frames / T) if T > 1 else num_frames
1149 + assert single_video_embeddings.shape[0] % num_tubelets == 0
1150
1151 if video_pruning_rate is not None and video_pruning_rate > 0.0:
1152 # Start of EVS-specific code
1153 retention_mask = compute_retention_mask(
1154 single_video_embeddings,
1071 - video_size_thw=(num_frames, rows, cols),
1155 + video_size_thw=(num_tubelets, rows, cols),
1156 spatial_merge_size=1,
1157 q=video_pruning_rate,
1158 )
@@ -1077,14 +1161,14 @@ class NemotronH_Nano_VL_V2(
1161 single_video_embeddings = single_video_embeddings[retention_mask]
1162
1163 # calculate the actual number of retained tokens per frame
1080 - retention_mask_thw = retention_mask.reshape(num_frames, rows, cols)
1164 + retention_mask_thw = retention_mask.reshape(num_tubelets, rows, cols)
1165 num_tokens_per_frame = (
1166 retention_mask_thw.sum(dim=(1, 2)).long().tolist()
1167 )
1168 # End of EVS-specific code
1169 else:
1086 - feature_size = single_video_embeddings.shape[0] // num_frames
1087 - num_tokens_per_frame = [feature_size] * num_frames
1170 + feature_size = single_video_embeddings.shape[0] // num_tubelets
1171 + num_tokens_per_frame = [feature_size] * num_tubelets
1172
1173 final_video_embeddings += (
1174 self._create_final_video_embeddings(
@@ -1092,11 +1176,36 @@ class NemotronH_Nano_VL_V2(
1176 num_tokens_per_frame,
1177 frames_indices,
1178 frame_duration_ms,
1179 + video_temporal_patch_size=T,
1180 ),
1181 )
1182
1183 return final_video_embeddings
1184
1185 + def _extract_video_embeddings_temporal(
1186 + self, video_input: NanoNemotronVLVideoPixelInputs
1187 + ) -> tuple[torch.Tensor, ...]:
1188 + """Extract per-video embeddings with temporal compression.
1189 +
1190 + Each video is processed separately through extract_feature with
1191 + num_frames, which uses the fixed-resolution temporal path in RADIO
1192 + (no attention mask, flash attention).
1193 + """
1194 + pixel_values = video_input["pixel_values_flat"]
1195 + num_frames_per_video = video_input["num_patches"].tolist()
1196 + hidden_size = self.config.text_config.hidden_size
1197 +
1198 + results: list[torch.Tensor] = []
1199 + frame_offset = 0
1200 + for nf in num_frames_per_video:
1201 + video_frames = pixel_values[frame_offset : frame_offset + nf]
1202 + frame_offset += nf
1203 +
1204 + vit_embeds = self.extract_feature(video_frames, num_frames=nf)
1205 + results.append(vit_embeds.view(-1, hidden_size))
1206 +
1207 + return tuple(results)
1208 +
1209 def _process_audio_input(
1210 self, audio_input: NanoNemotronVLAudioFeatureInputs
1211 ) -> tuple[torch.Tensor, ...]:
@@ -1134,6 +1243,7 @@ class NemotronH_Nano_VL_V2(
1243 num_tokens_per_frame: list[int],
1244 frames_indices: list[int],
1245 frame_duration_ms: int,
1246 + video_temporal_patch_size: int = 1,
1247 ) -> torch.Tensor:
1248 """Create final embeddings that combine video embeddings with
1249 text embeddings of indicator tokens.
@@ -1161,6 +1271,7 @@ class NemotronH_Nano_VL_V2(
1271 img_start_token_ids=self._img_start_token_ids,
1272 img_end_token_ids=self._img_end_token_ids,
1273 img_context_token_ids=self._img_context_token_ids,
1274 + video_temporal_patch_size=video_temporal_patch_size,
1275 )
1276
1277 # video_repl.full is a list of token IDs
@@ -1207,8 +1318,27 @@ class NemotronH_Nano_VL_V2(
1318 else:
1319 frames_indices = torch.cat([f.flatten() for f in frames_indices], dim=0)
1320
1210 - frame_duration_ms = frame_duration_ms.flatten()
1211 - expected_h = expected_w = self.config.force_image_size
1321 + if torch.is_tensor(frame_duration_ms):
1322 + frame_duration_ms = frame_duration_ms.flatten()
1323 + else:
1324 + frame_duration_ms = torch.cat(
1325 + [f.flatten() for f in frame_duration_ms], dim=0
1326 + )
1327 +
1328 + if (
1329 + torch.is_tensor(pixel_values_flat_video)
1330 + and pixel_values_flat_video.ndim == 5
1331 + ):
1332 + # batched._reduce_data stacked same-shape videos into
1333 + # [num_videos, nf, 3, H, W]; unstack back to a list so the
1334 + # same-H,W cat path below handles it uniformly.
1335 + pixel_values_flat_video = list(pixel_values_flat_video)
1336 +
1337 + if not torch.is_tensor(pixel_values_flat_video):
1338 + pixel_values_flat_video = torch.cat(pixel_values_flat_video, dim=0)
1339 +
1340 + expected_h = pixel_values_flat_video.shape[-2]
1341 + expected_w = pixel_values_flat_video.shape[-1]
1342 num_frames = video_num_patches[0].item()
1343 resolve_bindings = {"h": expected_h, "w": expected_w, "f": num_frames}
1344
@@ -1361,8 +1491,7 @@ class NemotronH_Nano_VL_V2(
1491
1492 self.language_model.load_weights(llm_weights)
1493 self.vision_model.load_weights(vision_weights)
1364 - if self.sound_encoder is not None:
1365 - assert len(sound_weights) > 0
1494 + if self.sound_encoder is not None and len(sound_weights) > 0:
1495 self.sound_encoder.load_weights(sound_weights)
1496
1497 def get_vit_model_from_radio_config(self, hf_config):
@@ -1375,12 +1504,23 @@ class NemotronH_Nano_VL_V2(
1504 image_size = preferred_resolution[0] if preferred_resolution else 224
1505 patch_size = getattr(hf_config_vision, "patch_size", 16)
1506
1507 + # video_temporal_patch_size and separate_video_embedder are
1508 + # top-level vision_config attributes, not inside args.
1509 + video_temporal_patch_size = getattr(
1510 + hf_config_vision, "video_temporal_patch_size", 1
1511 + )
1512 + separate_video_embedder = getattr(
1513 + hf_config_vision, "separate_video_embedder", True
1514 + )
1515 +
1516 radio_config = RadioConfig(
1517 model_name=model_name,
1518 image_size=image_size,
1519 patch_size=patch_size,
1520 norm_mean=hf_config.norm_mean,
1521 norm_std=hf_config.norm_std,
1522 + video_temporal_patch_size=video_temporal_patch_size,
1523 + separate_video_embedder=separate_video_embedder,
1524 **hf_config_vision.args,
1525 )
1526
vllm/model_executor/models/radio.py
+148 -65
@@ -123,6 +123,8 @@ class ViTPatchGenerator(nn.Module):
123 register_multiple: int | None = None,
124 num_registers: int | None = None,
125 patch_bias: bool = False,
126 + temporal_patch_size: int = 1,
127 + separate_video_embedder: bool = True,
128 device=None,
129 dtype=None,
130 ):
@@ -148,6 +150,7 @@ class ViTPatchGenerator(nn.Module):
150 self.patch_size = patch_size
151 self.abs_pos = abs_pos
152 self.embed_dim = embed_dim
153 + self.temporal_patch_size = temporal_patch_size
154
155 self.num_rows = max_input_dims[0] // patch_size
156 self.num_cols = max_input_dims[1] // patch_size
@@ -160,6 +163,21 @@ class ViTPatchGenerator(nn.Module):
163 patch_size, embed_dim, bias=patch_bias, **factory
164 )
165
166 + if temporal_patch_size > 1:
167 + if not separate_video_embedder:
168 + raise NotImplementedError(
169 + "Only separate_video_embedder=True is supported for"
170 + " temporal compression (temporal_patch_size > 1)"
171 + )
172 + self.video_embedder = ViTPatchLinear(
173 + patch_size,
174 + embed_dim,
175 + bias=patch_bias,
176 + temporal_patch_size=temporal_patch_size,
177 + **factory,
178 + )
179 + self._video_embedder_loaded = False
180 +
181 if abs_pos:
182 scale = embed_dim**-0.5
183 self.pos_embed = nn.Parameter(
@@ -196,6 +214,60 @@ class ViTPatchGenerator(nn.Module):
214 return patches, pos_enc
215 return patches
216
217 + def forward_video(self, x: torch.Tensor) -> torch.Tensor:
218 + """Process video frames with temporal compression.
219 +
220 + Groups T consecutive frames into tubelets before embedding.
221 +
222 + Args:
223 + x: [num_frames, 3, H, W] tensor of video frames
224 +
225 + Returns:
226 + Embedded patches with temporal compression applied.
227 + """
228 + if not self._video_embedder_loaded:
229 + raise ValueError(
230 + "Temporal compression (video_temporal_patch_size > 1) requires "
231 + "video_embedder weights, but they were never loaded. "
232 + "Ensure the checkpoint was trained with temporal compression."
233 + )
234 + T = self.temporal_patch_size
235 + input_size = x.shape[2:]
236 +
237 + patches = self.im_to_patches(x) # [N, num_patches, 3*P*P]
238 + num_frames, num_spatial, feat_dim = patches.shape
239 +
240 + # Pad to a multiple of T by repeating the last frame so that
241 + # all tubelets have exactly T frames.
242 + num_pad_frames = (-num_frames) % T
243 + if num_pad_frames > 0:
244 + last_frame_dup = patches[-1:].expand(num_pad_frames, -1, -1)
245 + patches = torch.cat([patches, last_frame_dup], dim=0)
246 +
247 + # Group T frames per tubelet: for each spatial position, concatenate
248 + # features across T consecutive frames; order follows Megatron training
249 + num_frames_padded = patches.shape[0]
250 + num_tublets = num_frames_padded // T
251 + patches = rearrange(
252 + patches,
253 + "(tubelets frames) spatial feat -> tubelets spatial (frames feat)",
254 + tubelets=num_tublets,
255 + frames=T,
256 + spatial=num_spatial,
257 + feat=feat_dim,
258 + )
259 +
260 + patches = self.video_embedder(patches)
261 +
262 + patches, pos_enc = self.apply_pos_enc(patches, input_size=input_size)
263 +
264 + patches = self.cls_token(patches)
265 +
266 + patches = self.patch_normalizer(patches)
267 + if self.return_pos_enc:
268 + return patches, pos_enc
269 + return patches
270 +
271 def apply_pos_enc_dynamic(
272 self, patches: torch.Tensor, imgs_sizes: list[tuple[int, int]]
273 ) -> tuple[torch.Tensor, torch.Tensor | None]:
@@ -381,66 +453,21 @@ class ViTPatchGenerator(nn.Module):
453 return pos_embed
454
455 if self.cpe_mode:
384 - if self.training:
385 - min_scale = math.sqrt(0.1)
386 - scale = (
387 - torch.rand(batch_size, 1, 1, device=pos_embed.device)
388 - * (1 - min_scale)
389 - + min_scale
390 - )
391 - aspect_min = math.log(3 / 4)
392 - aspect_max = -aspect_min
393 - aspect = torch.exp(
394 - torch.rand(batch_size, 1, 1, device=pos_embed.device)
395 - * (aspect_max - aspect_min)
396 - + aspect_min
397 - )
398 -
399 - scale_x = scale * aspect
400 - scale_y = scale * (1 / aspect)
401 - scale_xy = torch.stack([scale_x, scale_y], dim=-1).clamp_(0, 1)
402 -
403 - pos_xy = torch.rand(batch_size, 1, 1, 2, device=pos_embed.device) * (
404 - 1 - scale_xy
405 - )
456 + max_dim = max(input_dims)
457 + pos_embed = F.interpolate(
458 + pos_embed.float(),
459 + size=(max_dim, max_dim),
460 + align_corners=False,
461 + mode="bilinear",
462 + ).to(pos_embed.dtype)
463
407 - lin_x = torch.linspace(
408 - 0, 1, steps=input_dims[1], device=pos_embed.device
409 - )[None, None].expand(batch_size, input_dims[0], -1)
410 - lin_y = torch.linspace(
411 - 0, 1, steps=input_dims[0], device=pos_embed.device
412 - )[None, :, None].expand(batch_size, -1, input_dims[1])
413 -
414 - lin_xy = torch.stack([lin_x, lin_y], dim=-1)
415 -
416 - grid_xy = lin_xy * scale_xy + pos_xy
417 -
418 - # Convert to [-1, 1] range
419 - grid_xy.mul_(2).sub_(1)
420 -
421 - pos_embed = F.grid_sample(
422 - pos_embed.float().expand(batch_size, -1, -1, -1),
423 - grid=grid_xy,
424 - mode="bilinear",
425 - padding_mode="zeros",
426 - align_corners=True,
427 - ).to(pos_embed.dtype)
428 - else:
429 - max_dim = max(input_dims)
430 - pos_embed = F.interpolate(
431 - pos_embed.float(),
432 - size=(max_dim, max_dim),
433 - align_corners=True,
434 - mode="bilinear",
435 - ).to(pos_embed.dtype)
436 -
437 - pos_embed = window_select(pos_embed)
464 + pos_embed = window_select(pos_embed)
465 else:
466 pos_embed = window_select(pos_embed)
467
468 if pos_embed.shape[-2:] != input_dims:
469 pos_embed = F.interpolate(
443 - pos_embed.float(), size=input_dims, align_corners=True, mode="bilinear"
470 + pos_embed.float(), size=input_dims, align_corners=False, mode="bilinear"
471 ).to(pos_embed.dtype)
472
473 pos_embed = pos_embed.flatten(2).permute(0, 2, 1)
@@ -473,9 +500,19 @@ class Im2Patches(nn.Module):
500
501
502 class ViTPatchLinear(nn.Linear):
476 - def __init__(self, patch_size: int, embed_dim: int, bias: bool = False, **factory):
477 - super().__init__(3 * (patch_size**2), embed_dim, bias=bias, **factory)
503 + def __init__(
504 + self,
505 + patch_size: int,
506 + embed_dim: int,
507 + bias: bool = False,
508 + temporal_patch_size: int = 1,
509 + **factory,
510 + ):
511 + super().__init__(
512 + 3 * temporal_patch_size * (patch_size**2), embed_dim, bias=bias, **factory
513 + )
514 self.patch_size = patch_size
515 + self.temporal_patch_size = temporal_patch_size
516
517
518 @dataclass(frozen=True, kw_only=True)
@@ -560,6 +597,7 @@ class RadioInternVisionModel(nn.Module):
597 max_img_size = int(
598 round(config.cpe_max_size / config.patch_size) * config.patch_size
599 )
600 + self.temporal_patch_size = config.video_temporal_patch_size
601 unique_teachers = set(t["name"] for t in config.teachers)
602 self.patch_generator = ViTPatchGenerator(
603 config.patch_size,
@@ -569,6 +607,8 @@ class RadioInternVisionModel(nn.Module):
607 cls_token=True,
608 num_cls_tokens=len(unique_teachers) if config.cls_token_per_teacher else 1,
609 register_multiple=config.register_multiple,
610 + temporal_patch_size=self.temporal_patch_size,
611 + separate_video_embedder=config.separate_video_embedder,
612 )
613
614 self.encoder = RadioVisionEncoder(
@@ -593,33 +633,68 @@ class RadioInternVisionModel(nn.Module):
633 def inter_image_mask_metadata(
634 self, imgs_sizes: list[tuple[int, int]], device: torch.device
635 ) -> MaskMetadata:
636 + """Build mask metadata from image pixel sizes. Adds num_skip to each
637 + sequence length (cls/register tokens) to match patch generator output."""
638 patch_size = self.patch_generator.patch_size
639 num_skip = self.patch_generator.num_skip
640
641 seq_lens = calc_seq_lens(imgs_sizes, patch_size)
642 adjusted = [s + num_skip for s in seq_lens]
643 + return self._inter_image_mask_metadata_from_seq_lens(adjusted, device=device)
644 +
645 + def _inter_image_mask_metadata_from_seq_lens(
646 + self, seq_lens: list[int], device: torch.device
647 + ) -> MaskMetadata:
648 + """Build mask metadata from actual sequence lengths (already including
649 + cls/register tokens, i.e. patch_count + num_skip per item).
650 + Use inter_image_mask_metadata() when you only have imgs_sizes."""
651 + assert len(seq_lens) > 0
652 cu_seqlens = torch.tensor(
602 - list(accumulate(adjusted, initial=0)), dtype=torch.int32, device=device
653 + list(accumulate(seq_lens, initial=0)), dtype=torch.int32, device=device
654 )
655 # Keep max_seqlen on CPU to avoid .item() sync
656 # See: https://github.com/vllm-project/vllm/blob/20b6b01/vllm/v1/attention/ops/vit_attn_wrappers.py#L48
606 - max_seqlen = torch.tensor(max(adjusted), dtype=torch.int32)
657 + max_seqlen = torch.tensor(max(seq_lens), dtype=torch.int32)
658 return MaskMetadata(cu_seqlens=cu_seqlens, max_seqlen=max_seqlen)
659
660 def forward(
661 self,
662 x: torch.Tensor,
663 imgs_sizes: list[tuple[int, int]] | None = None,
664 + num_frames: int | None = None,
665 ) -> torch.FloatTensor:
614 - hidden_states = self.patch_generator(x, imgs_sizes=imgs_sizes)
666 + T = self.temporal_patch_size
667 +
668 + # Build packed-sequence metadata for MMEncoderAttention when needed.
669 mask_meta = None
616 - if imgs_sizes is not None:
617 - assert len(imgs_sizes) > 0
618 - # Dynamic resolution: process each image as an independent sequence.
619 - mask_meta = self.inter_image_mask_metadata(
620 - imgs_sizes, device=hidden_states.device
670 + packed_batch_size = None # Original batch size before packing
671 +
672 + if num_frames is not None and T > 1:
673 + # Conv3d video: all tubelets have the same sequence length.
674 + # Pack [num_tubelets, seq_per_tubelet, hidden] → [1, total, hidden]
675 + hidden_states = self.patch_generator.forward_video(x)
676 + packed_batch_size, seq_per_tubelet, hidden_dim = hidden_states.shape
677 + hidden_states = hidden_states.reshape(1, -1, hidden_dim)
678 + mask_meta = self._inter_image_mask_metadata_from_seq_lens(
679 + [seq_per_tubelet] * packed_batch_size, device=hidden_states.device
680 )
681 + else:
682 + # Images for any model, or video for non-conv3d model
683 + hidden_states = self.patch_generator(x, imgs_sizes=imgs_sizes)
684 + if imgs_sizes is not None and len(imgs_sizes) > 1:
685 + # Dynamic resolution w/ > 1 image, create attn mask
686 + mask_meta = self.inter_image_mask_metadata(
687 + imgs_sizes, device=hidden_states.device
688 + )
689 +
690 encoder_outputs = self.encoder(inputs_embeds=hidden_states, mask_meta=mask_meta)
691 +
692 + # Unpack back to original batch shape if we packed for video
693 + if packed_batch_size is not None:
694 + encoder_outputs = encoder_outputs.reshape(
695 + packed_batch_size, seq_per_tubelet, -1
696 + )
697 +
698 return encoder_outputs
699
700
@@ -663,8 +738,13 @@ class RadioModel(nn.Module):
738 pixel_embeds: torch.Tensor | None = None,
739 *,
740 imgs_sizes: list[tuple[int, int]] | None = None,
741 + num_frames: int | None = None,
742 ) -> tuple[torch.FloatTensor, torch.FloatTensor]:
667 - y = self.model(pixel_values, imgs_sizes=imgs_sizes)
743 + y = self.model(
744 + pixel_values,
745 + imgs_sizes=imgs_sizes,
746 + num_frames=num_frames,
747 + )
748 return self._extract_final(y, imgs_sizes=imgs_sizes)
749
750 def load_weights(self, weights) -> set[str]:
@@ -714,6 +794,9 @@ class RadioModel(nn.Module):
794 weight_loader(param, weight)
795 loaded_params.add(vllm_key)
796
797 + if "model.patch_generator.video_embedder.weight" in loaded_params:
798 + self.model.patch_generator._video_embedder_loaded = True
799 +
800 return loaded_params
801
802 def _extract_final(
vllm/transformers_utils/configs/radio.py
+12
@@ -47,6 +47,14 @@ class RadioConfig(PretrainedConfig):
47 teachers: A list of teacher model configurations. Each teacher configuration is
48 a dict with keys like "name" and some may have "use_summary".
49 cls_token_per_teacher: Whether to use a separate CLS token for each teacher.
50 + video_temporal_patch_size: Number of consecutive video frames grouped into
51 + a single tubelet for temporal compression. Default 1 (no compression).
52 + When > 1, a dedicated video_embedder (3*T*P*P -> hidden) is created
53 + alongside the image embedder (3*P*P -> hidden).
54 + separate_video_embedder: When True and video_temporal_patch_size > 1, use a
55 + dedicated video patch embedder (3*T*P*P -> hidden) separate from the
56 + image embedder (3*P*P -> hidden). When False, a single embedder with
57 + input size 3*T*P*P is used for both (images are duplicated T times).
58 """
59
60 model_type = "radio"
@@ -68,6 +76,8 @@ class RadioConfig(PretrainedConfig):
76 register_multiple: int | None = None,
77 teachers: list[dict[str, Any]] | None = None,
78 cls_token_per_teacher: bool = False,
79 + video_temporal_patch_size: int = 1,
80 + separate_video_embedder: bool = True,
81 **kwargs,
82 ):
83 self.model_name = model_name
@@ -95,4 +105,6 @@ class RadioConfig(PretrainedConfig):
105 self.register_multiple = register_multiple
106 self.teachers = teachers if teachers is not None else []
107 self.cls_token_per_teacher = cls_token_per_teacher
108 + self.video_temporal_patch_size = video_temporal_patch_size
109 + self.separate_video_embedder = separate_video_embedder
110 super().__init__(**kwargs)
vllm/transformers_utils/processors/nano_nemotron_vl.py
+205 -27
@@ -11,6 +11,7 @@ import math
11 from abc import ABC, abstractmethod
12 from collections.abc import Sequence
13 from dataclasses import dataclass
14 +from functools import cached_property
15 from typing import Any, TypeVar
16
17 import einops
@@ -43,6 +44,12 @@ AUDIO_CONTEXT = "<so_embedding>"
44 # MAX_FRAMES = 16
45 DEFAULT_NUM_TILES = 12
46
47 +# Configure PIL to handle large images without warnings
48 +# This prevents DecompressionBombWarning for legitimate large images
49 +Image.MAX_IMAGE_PIXELS = None # Disable the limit entirely
50 +# Alternative: Set a specific higher limit
51 +# Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels
52 +
53
54 def calculate_timestamps(
55 indices: list[int] | torch.Tensor,
@@ -138,19 +145,110 @@ def image_to_pixel_values(
145 return pixel_values
146
147
148 +def _compute_aspect_preserving_size(
149 + orig_w: int,
150 + orig_h: int,
151 + target_num_patches: int,
152 + patch_size: int,
153 + downsample_ratio: float,
154 +) -> tuple[int, int]:
155 + """Compute target pixel dimensions that preserve aspect ratio.
156 +
157 + Mirrors Megatron-LM image_processing.py video frame resizing:
158 + target area in patch-grid space is *target_num_patches*, distributed
159 + according to the source aspect ratio, then snapped to a multiple of
160 + the required divisor (2 for pixel-shuffle).
161 + """
162 + aspect_wh = orig_w / max(orig_h, 1)
163 + ph = round(math.sqrt(target_num_patches / aspect_wh))
164 + pw = round(math.sqrt(target_num_patches * aspect_wh))
165 + ph = max(ph, 1)
166 + pw = max(pw, 1)
167 +
168 + reduction_factor = int(round(1 / downsample_ratio))
169 + required_divisor = reduction_factor # 2 for pixel-shuffle
170 + if required_divisor > 1:
171 + rem_h = ph % required_divisor
172 + rem_w = pw % required_divisor
173 + ph_up = ph + (required_divisor - rem_h if rem_h else 0)
174 + ph_down = ph - rem_h
175 + pw_up = pw + (required_divisor - rem_w if rem_w else 0)
176 + pw_down = pw - rem_w
177 + if ph_up * pw_up <= target_num_patches:
178 + ph, pw = ph_up, pw_up
179 + else:
180 + ph = max(required_divisor, ph_down)
181 + pw = max(required_divisor, pw_down)
182 +
183 + return pw * patch_size, ph * patch_size # (width, height) in pixels
184 +
185 +
186 +def get_video_target_size_and_feature_size(
187 + orig_w: int,
188 + orig_h: int,
189 + target_patches: int,
190 + maintain_aspect_ratio: bool,
191 + patch_size: int,
192 + downsample_ratio: float,
193 +) -> tuple[int, int, int]:
194 + """Compute target (width, height) and feature_size for video resize and token count.
195 +
196 + Used by video_to_pixel_values (resize) and get_video_replacement_internvl
197 + (seq length calc) so both use the same dimensions.
198 + """
199 + if maintain_aspect_ratio:
200 + target_w, target_h = _compute_aspect_preserving_size(
201 + orig_w=orig_w,
202 + orig_h=orig_h,
203 + target_num_patches=target_patches,
204 + patch_size=patch_size,
205 + downsample_ratio=downsample_ratio,
206 + )
207 + else:
208 + reduction_factor = int(round(1 / downsample_ratio))
209 + side = int(math.sqrt(target_patches))
210 + side = max(reduction_factor, (side // reduction_factor) * reduction_factor)
211 + target_w = side * patch_size
212 + target_h = side * patch_size
213 +
214 + feature_size = int((target_h // patch_size) * downsample_ratio) * int(
215 + (target_w // patch_size) * downsample_ratio
216 + )
217 + return target_w, target_h, feature_size
218 +
219 +
220 def video_to_pixel_values(
221 video: npt.NDArray,
222 *,
223 input_size: int,
145 - max_num_tiles: int = 1,
146 - use_thumbnail: bool,
224 + video_target_num_patches: int | None = None,
225 + video_maintain_aspect_ratio: bool = False,
226 + patch_size: int = 16,
227 + downsample_ratio: float = 0.5,
228 ) -> torch.Tensor:
148 - assert max_num_tiles == 1, "Video modality always uses one tile"
149 -
229 # (num_frames, H, W, C) -> (num_frames, C, H, W)
230 video_tensor = torch.from_numpy(video).permute(0, 3, 1, 2)
231
153 - if video_tensor.shape[2] != input_size or video_tensor.shape[3] != input_size:
232 + if video_target_num_patches is not None:
233 + # Resize to target patch count (aspect-preserving or square).
234 + orig_h, orig_w = video_tensor.shape[2], video_tensor.shape[3]
235 + target_w, target_h, _ = get_video_target_size_and_feature_size(
236 + orig_w=orig_w,
237 + orig_h=orig_h,
238 + target_patches=video_target_num_patches,
239 + maintain_aspect_ratio=video_maintain_aspect_ratio,
240 + patch_size=patch_size,
241 + downsample_ratio=downsample_ratio,
242 + )
243 + if video_tensor.shape[2] != target_h or video_tensor.shape[3] != target_w:
244 + video_tensor = torch.nn.functional.interpolate(
245 + video_tensor,
246 + size=(target_h, target_w),
247 + mode="bicubic",
248 + align_corners=False,
249 + antialias=True,
250 + )
251 + elif video_tensor.shape[2] != input_size or video_tensor.shape[3] != input_size:
252 video_tensor = torch.nn.functional.interpolate(
253 video_tensor,
254 size=(input_size, input_size),
@@ -645,9 +743,9 @@ class BaseNanoNemotronVLProcessor(ABC):
743 "which should be a single string"
744 )
745 parts = [x for x in re.split(r"(<image>)", text[0]) if x]
648 - assert parts.count("<image>") == len(pixel_values_lst), (
649 - "the number of <image> tokens in the text should be the "
650 - "same as the number of images"
746 + assert parts.count("<image>") == len(num_tokens_per_image), (
747 + f"Expected {len(num_tokens_per_image)} <image> tokens in text "
748 + f"but found {parts.count('<image>')}"
749 )
750
751 for i, (feature_size, num_patches) in enumerate(
@@ -706,6 +804,33 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
804 self.video_token = video_token
805 self.video_pruning_rate = video_pruning_rate
806
807 + # Video params live exclusively in vision_config
808 + vision_config = getattr(config, "vision_config", config)
809 + self.video_temporal_patch_size: int = getattr(
810 + vision_config, "video_temporal_patch_size", 1
811 + )
812 + self.video_maintain_aspect_ratio: bool = getattr(
813 + vision_config, "video_maintain_aspect_ratio", False
814 + )
815 +
816 + # Resolve video frame target size: exactly one of video_target_num_patches
817 + # or video_target_img_size may be set (mirrors Megatron's
818 + # DynamicResolutionImageTilingStrategy validation).
819 + target_num_patches = getattr(vision_config, "video_target_num_patches", None)
820 + target_img_size = getattr(vision_config, "video_target_img_size", None)
821 + if target_num_patches is not None and target_img_size is not None:
822 + raise ValueError(
823 + "Exactly one of video_target_num_patches or "
824 + "video_target_img_size must be set, got both"
825 + )
826 + if target_num_patches is not None:
827 + self.video_target_num_patches: int | None = target_num_patches
828 + elif target_img_size is not None:
829 + base_patches = math.ceil(target_img_size / config.patch_size)
830 + self.video_target_num_patches = base_patches * base_patches
831 + else:
832 + self.video_target_num_patches = None
833 +
834 self.audio_extractor: ParakeetExtractor | None = None
835 raw_sound_config = getattr(config, "sound_config", None)
836 if raw_sound_config is not None:
@@ -721,6 +846,27 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
846 IMG_CONTEXT, add_special_tokens=False
847 )
848
849 + @cached_property
850 + def num_video_token(self) -> int:
851 + """Token count per video frame, accounting for video_target_num_patches.
852 +
853 + When video_target_num_patches is set the per-frame feature count
854 + differs from the image-based num_image_token. We use a square
855 + dummy (1:1) to compute the feature_size because the dummy video is
856 + square and the user confirmed that is acceptable.
857 + """
858 + if self.video_target_num_patches is not None:
859 + _, _, feature_size = get_video_target_size_and_feature_size(
860 + orig_w=self.image_size,
861 + orig_h=self.image_size,
862 + target_patches=self.video_target_num_patches,
863 + maintain_aspect_ratio=self.video_maintain_aspect_ratio,
864 + patch_size=self.config.patch_size,
865 + downsample_ratio=self.config.downsample_ratio,
866 + )
867 + return feature_size
868 + return self.num_image_token
869 +
870 @property
871 def supports_video(self) -> bool:
872 return self.video_token_id is not None
@@ -738,14 +884,15 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
884 def _videos_to_pixel_values_lst(
885 self,
886 videos: list[npt.NDArray],
741 - max_num_tiles: int,
887 ) -> list[torch.Tensor]:
888 return [
889 video_to_pixel_values(
890 video,
891 input_size=self.image_size,
747 - max_num_tiles=max_num_tiles,
748 - use_thumbnail=self.use_thumbnail,
892 + video_target_num_patches=self.video_target_num_patches,
893 + video_maintain_aspect_ratio=self.video_maintain_aspect_ratio,
894 + patch_size=self.config.patch_size,
895 + downsample_ratio=self.config.downsample_ratio,
896 )
897 for video in videos
898 ]
@@ -754,7 +901,6 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
901 self,
902 text: list[str],
903 videos: list[tuple[npt.NDArray, dict[str, Any]]],
757 - max_num_tiles: int,
904 ) -> tuple[list[str], dict[str, Any]]:
905 if len(videos) == 0 or not self.supports_video:
906 return text, {}
@@ -763,7 +909,6 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
909 video_metadata_lst = [v[1] for v in videos]
910 pixel_values_lst_video = self._videos_to_pixel_values_lst(
911 videos_lst,
766 - max_num_tiles=max_num_tiles,
912 )
913
914 # We use frame duration in milliseconds (as integer) to ensure
@@ -788,12 +933,10 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
933 "frame_duration_ms": torch.tensor(frame_duration_ms_lst),
934 }
935
791 - image_size: int = self.config.force_image_size
936 patch_size: int = self.config.patch_size
937 downsample_ratio = self.config.downsample_ratio
794 - tokens_in_single_frame = int(
795 - (image_size * image_size // patch_size**2) * (downsample_ratio**2)
796 - )
938 +
939 + T = self.video_temporal_patch_size
940
941 for pixel_values, video_metadata, frames_indices, frame_duration_ms in zip(
942 pixel_values_lst_video,
@@ -802,23 +945,28 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
945 frame_duration_ms_lst,
946 ):
947 num_frames = pixel_values.shape[0]
948 + frame_h, frame_w = pixel_values.shape[-2], pixel_values.shape[-1]
949 + tokens_in_single_frame = int(
950 + (frame_h * frame_w // patch_size**2) * (downsample_ratio**2)
951 + )
952 + num_tubelets = math.ceil(num_frames / T) if T > 1 else num_frames
953
954 if self.video_pruning_rate is not None and self.video_pruning_rate > 0.0:
955 # Start of EVS-specific code
956 num_tokens = compute_retained_tokens_count(
957 tokens_per_frame=tokens_in_single_frame,
810 - num_frames=num_frames,
958 + num_frames=num_tubelets,
959 q=self.video_pruning_rate,
960 )
961
962 # Here we just need placeholders that won't actually be replaced -
963 # we just need to make sure the total number of tokens is correct
964 # assign all tokens to the first frame
817 - tokens_per_frame = [num_tokens] + [0] * (num_frames - 1)
965 + tokens_per_frame = [num_tokens] + [0] * (num_tubelets - 1)
966
967 # End of EVS-specific code
968 else:
821 - tokens_per_frame = [tokens_in_single_frame] * num_frames
969 + tokens_per_frame = [tokens_in_single_frame] * num_tubelets
970
971 video_repl = self.get_video_repl(
972 tokens_per_frame=tokens_per_frame,
@@ -828,6 +976,7 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
976 img_start_token_ids=self._img_start_token_ids,
977 img_end_token_ids=self._img_end_token_ids,
978 img_context_token_ids=self._img_context_token_ids,
979 + video_temporal_patch_size=T,
980 )
981
982 # video_repl.full is a list of token IDs
@@ -908,7 +1057,6 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
1057 text, video_inputs = self._preprocess_video(
1058 text=text,
1059 videos=videos,
911 - max_num_tiles=1,
1060 )
1061
1062 text, audio_inputs = self._preprocess_audio(
@@ -962,6 +1110,7 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
1110 img_start_token_ids: list[int],
1111 img_end_token_ids: list[int],
1112 img_context_token_ids: list[int],
1113 + video_temporal_patch_size: int = 1,
1114 ) -> PromptUpdateDetails[list[int]]:
1115 """
1116 Build prompt replacement for a video.
@@ -981,31 +1130,60 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
1130 - EVS real (called from get_real_video_repl_for_evs) - different value per frame
1131 Args:
1132 tokens_per_frame (list[int]): number of tokens per frame
984 - frames_indices (list[int]): frame indices
1133 + (one per tubelet when T > 1)
1134 + frames_indices (list[int]): orig. frame indices
1135 + (one per frame, before tubelet subsampling)
1136 frame_duration_ms (int): duration of each frame in milliseconds
986 - tokenizer (HfTokenizer): tokenizer to use for tokenizing frame separators
1137 + tokenizer (TokenizerLike): tokenizer to use for tokenizing frame separators
1138 img_start_token_ids (list[int]): pre-tokenized IMG_START tokens
1139 img_end_token_ids (list[int]): pre-tokenized IMG_END tokens
1140 img_context_token_ids (list[int]): pre-tokenized IMG_CONTEXT tokens
1141 + video_temporal_patch_size (int): temporal patch size for videos
1142 """
1143 # TODO: Add support of frame_duration_ms to be None
1144 # At preprocessing step we should allow absent / metadata without
1145 # frames_indices field.
1146 timestamps_enabled = frame_duration_ms is not None
995 -
996 - if timestamps_enabled:
1147 + T = video_temporal_patch_size
1148 + num_frames = len(frames_indices)
1149 +
1150 + if T > 1 and timestamps_enabled:
1151 + all_timestamps = calculate_timestamps(frames_indices, frame_duration_ms)
1152 +
1153 + frame_separators = []
1154 + for group_idx, i in enumerate(range(0, num_frames, T)):
1155 + group_frames = []
1156 + for j in range(T): # Every frame in the group
1157 + frame_idx = i + j
1158 + if frame_idx < num_frames:
1159 + # Valid idx (haven't padded to mult. of T yet)
1160 + ts = all_timestamps[frame_idx]
1161 + frame_str = "Frame" if j == 0 else "frame"
1162 + group_frames.append(
1163 + f"{frame_str} {frame_idx + 1} sampled at {ts:.2f} seconds"
1164 + )
1165 + if group_frames:
1166 + # Join by `and` if there are >1 frame, otherwise no `and`
1167 + # Prepend \n to match training format (except first group)
1168 + sep = " and ".join(group_frames) + ": "
1169 + if group_idx > 0:
1170 + sep = "\n" + sep
1171 + frame_separators.append(sep)
1172 + elif timestamps_enabled:
1173 timestamps = calculate_timestamps(frames_indices, frame_duration_ms)
1174
1175 assert len(timestamps) == len(tokens_per_frame), (
1176 "timestamps and tokens_per_frame must have the same length"
1177 )
1178 frame_separators = [
1003 - f"Frame {i + 1} sampled at {timestamp:.2f} seconds: "
1179 + ("\n" if i > 0 else "")
1180 + + f"Frame {i + 1} sampled at {timestamp:.2f} seconds: "
1181 for i, timestamp in enumerate(timestamps)
1182 ]
1183 else:
1184 frame_separators = [
1008 - f"Frame {i + 1}: " for i, _ in enumerate(tokens_per_frame)
1185 + ("\n" if i > 0 else "") + f"Frame {i + 1}: "
1186 + for i, _ in enumerate(tokens_per_frame)
1187 ]
1188
1189 # Tokenize frame separator independently
vllm/v1/worker/gpu_model_runner.py
+13 -3
@@ -420,8 +420,9 @@ class GPUModelRunner(
420 self.is_multimodal_raw_input_only_model = (
421 model_config.is_multimodal_raw_input_only_model
422 )
423 - # This will be overridden in load_model()
423 + # These will be overridden in load_model()
424 self.is_multimodal_pruning_enabled = False
425 + self.requires_sequential_video_encoding = False
426 # Set to True after init_routed_experts_capturer() completes.
427 # Prevents routed experts code from running during profiling/dummy run.
428 self.routed_experts_initialized = False
@@ -2625,17 +2626,23 @@ class GPUModelRunner(
2626 ):
2627 batch_outputs: MultiModalEmbeddings
2628
2628 - # EVS-related change.
2629 + # EVS and dynamic res video related change.
2630 # (ekhvedchenia): Temporary hack to limit peak memory usage when
2631 # processing multimodal data. This solves the issue with scheduler
2632 # putting too many video samples into a single batch. Scheduler
2633 # uses pruned vision tokens count to compare it versus compute
2634 # budget which is incorrect (Either input media size or non-pruned
2635 # output vision tokens count should be considered)
2636 + # dynamic res video for nemotron temporarily uses this hack via
2637 + # requires_sequential_video_encoding
2638 + # because it doesn't yet support video batching.
2639 # TODO(ywang96): Fix memory profiling to take EVS into account and
2640 # remove this hack.
2641 if (
2638 - self.is_multimodal_pruning_enabled
2642 + (
2643 + self.is_multimodal_pruning_enabled
2644 + or self.requires_sequential_video_encoding
2645 + )
2646 and modality == "video"
2647 and num_items > 1
2648 ):
@@ -4609,6 +4616,9 @@ class GPUModelRunner(
4616 and mm_config is not None
4617 and mm_config.is_multimodal_pruning_enabled()
4618 )
4619 + self.requires_sequential_video_encoding = hasattr(
4620 + self.get_model(), "requires_sequential_video_encoding"
4621 + ) # Temporary hack for dynamic res video w/o support for bs>1 yet
4622
4623 if (
4624 is_mixture_of_experts(self.model)