Skip to content

Commit 0e5a1c5

Browse files
cspadesPhlip79
andauthored
[Inference] Fix an unsafe fork for frontend replicas and support Nemotron VL vision preprocessing. (#7556)
Signed-off-by: Cory Ye <cye@nvidia.com> Co-authored-by: Philip Petrakian <ppetrakian@nvidia.com>
1 parent 85cda92 commit 0e5a1c5

20 files changed

Lines changed: 993 additions & 116 deletions

‎megatron/core/inference/config.py‎

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22

33
import warnings
4-
from dataclasses import InitVar, dataclass, field
4+
from dataclasses import InitVar, dataclass, field, replace
55
from enum import Enum
66
from typing import List, Literal, Optional, Tuple
77

@@ -282,6 +282,31 @@ class ImageProcessingConfig:
282282
max_num_tiles: int = 1
283283
use_thumbnail: bool = False
284284
num_img_embeddings_per_tile: int = 0
285+
dynamic_resolution_model_length: Optional[int] = None
286+
"""Model-length budget used by processors that divide capacity across images."""
287+
dynamic_resolution_rounding_mode: Literal["ceil", "round_plus_half"] = "ceil"
288+
"""Patch-grid rounding contract: ``ceil`` or ``round_plus_half``."""
289+
dynamic_resolution_resize_mode: Literal["pil", "torch_bicubic_antialias"] = "pil"
290+
"""Resize contract: ``pil`` or ``torch_bicubic_antialias``."""
291+
292+
def __post_init__(self):
293+
if self.dynamic_resolution_rounding_mode not in ("ceil", "round_plus_half"):
294+
raise ValueError(
295+
"ImageProcessingConfig.dynamic_resolution_rounding_mode must be "
296+
"'ceil' or 'round_plus_half'."
297+
)
298+
if self.dynamic_resolution_resize_mode not in ("pil", "torch_bicubic_antialias"):
299+
raise ValueError(
300+
"ImageProcessingConfig.dynamic_resolution_resize_mode must be "
301+
"'pil' or 'torch_bicubic_antialias'."
302+
)
303+
if (
304+
self.dynamic_resolution_model_length is not None
305+
and self.dynamic_resolution_model_length <= 4
306+
):
307+
raise ValueError(
308+
"ImageProcessingConfig.dynamic_resolution_model_length must be " "greater than 4."
309+
)
285310

286311

287312
@dataclass
@@ -304,6 +329,24 @@ class MediaPromptSpec:
304329
prefix: str = ""
305330
suffix: str = ""
306331
input_marker: Optional[str] = None
332+
content_part_separator: str = ""
333+
expansion_mode: Literal["single", "temporal_patch"] = "single"
334+
include_frame_timestamps_for_nemotron_vl: bool = False
335+
336+
def __post_init__(self):
337+
if self.expansion_mode not in ("single", "temporal_patch"):
338+
raise ValueError(
339+
"MediaPromptSpec.expansion_mode must be 'single' or "
340+
f"'temporal_patch', got {self.expansion_mode!r}."
341+
)
342+
if (
343+
self.include_frame_timestamps_for_nemotron_vl
344+
and self.expansion_mode != "temporal_patch"
345+
):
346+
raise ValueError(
347+
"MediaPromptSpec.include_frame_timestamps_for_nemotron_vl requires "
348+
"expansion_mode='temporal_patch'."
349+
)
307350

308351

309352
@dataclass(frozen=True)
@@ -322,13 +365,14 @@ def get_spec(self, modality: str) -> MediaPromptSpec:
322365
raise ValueError(f"Unsupported media modality: {modality!r}")
323366

324367
@classmethod
325-
def from_dict(cls, value):
326-
"""Build from image and video specs."""
368+
def from_dict(cls, value, defaults=None):
369+
"""Build from image and video overrides, preserving optional defaults."""
327370
if not value:
328-
return cls()
371+
return defaults or cls()
372+
defaults = defaults or cls()
329373
return cls(
330-
image_spec=MediaPromptSpec(**value.get("image_spec", {})),
331-
video_spec=MediaPromptSpec(**value.get("video_spec", {})),
374+
image_spec=replace(defaults.image_spec, **dict(value.get("image_spec", {}))),
375+
video_spec=replace(defaults.video_spec, **dict(value.get("video_spec", {}))),
332376
)
333377

334378

@@ -478,6 +522,9 @@ class InferenceConfig:
478522
video_preprocessing_config: Optional[VideoProcessingConfig] = None
479523
"""Configuration for decoding and preprocessing raw video payloads."""
480524

525+
multimodal_prompt_config: Optional[MultimodalPromptConfig] = None
526+
"""Optional per-engine overrides for the inference wrapper's media prompt contract."""
527+
481528
use_flashinfer_fused_rope: Optional[bool] = False
482529
"""
483530
If True, use flashinfer's fused rope implementation.

‎megatron/core/inference/engines/dynamic_engine.py‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ class _VisionCacheEntry:
182182
num_img_embeddings_per_tile: int
183183
imgs_sizes: Optional[Tensor]
184184
num_frames: Optional[Tensor]
185+
video_frame_indices: Optional[List[List[int]]] = None
186+
video_fps: Optional[List[float]] = None
185187

186188

187189
def format_mem_bytes(mem_bytes):
@@ -733,6 +735,14 @@ def _refresh_vlm_request_data(self, request: DynamicVLMInferenceRequest) -> None
733735
expansion_kwargs = {"num_tiles": num_tiles, "imgs_sizes": imgs_sizes}
734736
if num_frames is not None:
735737
expansion_kwargs["num_frames"] = num_frames
738+
prompt_config = wrapper.multimodal_prompt_config
739+
if (
740+
prompt_config is not None
741+
and prompt_config.video_spec.expansion_mode == "temporal_patch"
742+
):
743+
expansion_kwargs["tokenizer"] = self.controller.tokenizer
744+
expansion_kwargs["video_frame_indices"] = request.video_frame_indices
745+
expansion_kwargs["video_fps"] = request.video_fps
736746
_, mask_list = wrapper.expand_image_tokens(
737747
[request.compact_prompt_tokens.tolist()],
738748
image_token_id=media_token_id,
@@ -776,6 +786,8 @@ def _refresh_vlm_request_data(self, request: DynamicVLMInferenceRequest) -> None
776786
num_img_embeddings_per_tile=request.num_img_embeddings_per_tile,
777787
imgs_sizes=imgs_sizes,
778788
num_frames=num_frames,
789+
video_frame_indices=request.video_frame_indices,
790+
video_fps=request.video_fps,
779791
)
780792
self.context.add_vlm_request_data(
781793
request.request_id, image_embeddings=embeddings, image_token_mask=mask
@@ -812,6 +824,8 @@ def _cache_vision_embedding(
812824
num_img_embeddings_per_tile: int = 0,
813825
imgs_sizes: Optional[Tensor] = None,
814826
num_frames: Optional[Tensor] = None,
827+
video_frame_indices: Optional[List[List[int]]] = None,
828+
video_fps: Optional[List[float]] = None,
815829
) -> None:
816830
if not cache_key or self.vision_embedding_cache_max_bytes == 0:
817831
return
@@ -823,6 +837,8 @@ def _cache_vision_embedding(
823837
num_img_embeddings_per_tile=num_img_embeddings_per_tile,
824838
imgs_sizes=imgs_sizes,
825839
num_frames=num_frames,
840+
video_frame_indices=video_frame_indices,
841+
video_fps=video_fps,
826842
)
827843
cache_entry_bytes = self._vision_cache_entry_nbytes(entry)
828844
if cache_entry_bytes > self.vision_embedding_cache_max_bytes:
@@ -1570,12 +1586,27 @@ def _serialize_finished_request(
15701586
finished_metadata=finished_metadata,
15711587
offload_params=request.offload_params,
15721588
)
1573-
return request.serialize(
1589+
serialized = request.serialize(
15741590
payload_offloaded=stage_result is not None,
15751591
payload_stage_metadata=(
15761592
stage_result.response_metadata if stage_result is not None else None
15771593
),
15781594
)
1595+
if isinstance(request, DynamicVLMInferenceRequest):
1596+
# Active VLM requests retain these inputs for refresh/cache bookkeeping,
1597+
# but completion replies must not echo large input-only tensors.
1598+
for key in (
1599+
"imgs",
1600+
"num_tiles",
1601+
"imgs_sizes",
1602+
"num_frames",
1603+
"video_frame_indices",
1604+
"video_fps",
1605+
"image_embeddings",
1606+
"image_token_mask",
1607+
):
1608+
serialized[key] = None
1609+
return serialized
15791610

15801611
def _handle_failed_request(self, request_id: int):
15811612
"""Handle a failed request by sending the reply immediately.
@@ -1842,6 +1873,8 @@ def add_request(
18421873
num_img_embeddings_per_tile: int = 0,
18431874
imgs_sizes: Optional[Tensor] = None,
18441875
num_frames: Optional[Tensor] = None,
1876+
video_frame_indices: Optional[List[List[int]]] = None,
1877+
video_fps: Optional[List[float]] = None,
18451878
media_tokens_preexpanded: bool = False,
18461879
offload_params: Optional[Dict] = None,
18471880
media_cache_key: Optional[str] = None,
@@ -1875,6 +1908,9 @@ def add_request(
18751908
imgs_sizes (Optional[Tensor]): Per-image sizes [N, 2] with [H, W].
18761909
Dynamic resolution.
18771910
num_frames (Optional[Tensor]): Number of frames per image/video item.
1911+
video_frame_indices (Optional[List[List[int]]]): Source indices for
1912+
each sampled video frame.
1913+
video_fps (Optional[List[float]]): Source FPS for each video item.
18781914
media_tokens_preexpanded (bool): Whether prompt token IDs already contain
18791915
one model token per projected media embedding.
18801916
offload_params (Optional[Dict]): Opaque metadata forwarded to the payload stager.
@@ -1952,6 +1988,8 @@ def add_request(
19521988
imgs_sizes=imgs_sizes,
19531989
precomputed_block_hashes=precomputed_block_hashes,
19541990
num_frames=num_frames,
1991+
video_frame_indices=video_frame_indices,
1992+
video_fps=video_fps,
19551993
media_tokens_preexpanded=media_tokens_preexpanded,
19561994
offload_params=offload_params,
19571995
media_cache_key=media_cache_key,
@@ -2016,6 +2054,8 @@ def _build_vlm_request(
20162054
imgs_sizes: Optional[Tensor],
20172055
precomputed_block_hashes: Optional[List[int]] = None,
20182056
num_frames: Optional[Tensor] = None,
2057+
video_frame_indices: Optional[List[List[int]]] = None,
2058+
video_fps: Optional[List[float]] = None,
20192059
media_tokens_preexpanded: bool = False,
20202060
offload_params: Optional[Dict] = None,
20212061
media_cache_key: Optional[str] = None,
@@ -2031,6 +2071,8 @@ def _build_vlm_request(
20312071
num_img_embeddings_per_tile = cached_vision_entry.num_img_embeddings_per_tile
20322072
imgs_sizes = cached_vision_entry.imgs_sizes
20332073
num_frames = cached_vision_entry.num_frames
2074+
video_frame_indices = cached_vision_entry.video_frame_indices
2075+
video_fps = cached_vision_entry.video_fps
20342076
elif num_frames is not None:
20352077
modality = "video"
20362078
missing = [
@@ -2084,6 +2126,8 @@ def _build_vlm_request(
20842126
("num_tiles", num_tiles),
20852127
("imgs_sizes", imgs_sizes),
20862128
("num_frames", num_frames),
2129+
("video_frame_indices", video_frame_indices),
2130+
("video_fps", video_fps),
20872131
):
20882132
if value is not None:
20892133
media_inputs[name] = value
@@ -2132,6 +2176,14 @@ def _build_vlm_request(
21322176
expansion_kwargs = {"num_tiles": num_tiles, "imgs_sizes": imgs_sizes}
21332177
if num_frames is not None:
21342178
expansion_kwargs["num_frames"] = num_frames
2179+
prompt_config = inference_wrapper.multimodal_prompt_config
2180+
if (
2181+
prompt_config is not None
2182+
and prompt_config.video_spec.expansion_mode == "temporal_patch"
2183+
):
2184+
expansion_kwargs["tokenizer"] = self.controller.tokenizer
2185+
expansion_kwargs["video_frame_indices"] = video_frame_indices
2186+
expansion_kwargs["video_fps"] = video_fps
21352187
expanded_tokens_list, mask_list = inference_wrapper.expand_image_tokens(
21362188
token_list, image_token_id=media_token_id, **expansion_kwargs
21372189
)
@@ -2188,6 +2240,8 @@ def _build_vlm_request(
21882240
num_img_embeddings_per_tile=num_img_embeddings_per_tile,
21892241
imgs_sizes=imgs_sizes,
21902242
num_frames=num_frames,
2243+
video_frame_indices=video_frame_indices,
2244+
video_fps=video_fps,
21912245
)
21922246

21932247
self.context.add_vlm_request_data(
@@ -2223,6 +2277,8 @@ def _build_vlm_request(
22232277
num_tiles=num_tiles,
22242278
imgs_sizes=imgs_sizes,
22252279
num_frames=num_frames,
2280+
video_frame_indices=video_frame_indices,
2281+
video_fps=video_fps,
22262282
media_tokens_preexpanded=media_tokens_preexpanded,
22272283
media_cache_key=media_cache_key,
22282284
decoder_seq_length=0,

‎megatron/core/inference/inference_client.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ def _pack_submit_frames(
269269
list: The frames to send, in wire order.
270270
"""
271271
media_meta, media_payload = split_multimodal_data(
272+
# If multi_modal_data is already serialized, then this is an identity function.
272273
serialize_multimodal_data(multi_modal_data)
273274
)
274275
return [

‎megatron/core/inference/inference_request.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ def serialize_multimodal_data(multi_modal_data: Any) -> Optional[Dict[str, Any]]
176176
Video:
177177
``"video"`` accepts raw video bytes, a list of raw video bytes, or a
178178
preprocessed tensor dictionary containing ``imgs``, ``imgs_sizes``,
179-
and ``num_frames``.
179+
and ``num_frames``. Preprocessed video dictionaries may also include
180+
``video_frame_indices`` and ``video_fps`` timing metadata.
180181
Audio:
181182
Audio does not yet have any supported data preprocessing or modeling
182183
formats.
@@ -236,6 +237,10 @@ def serialize_multimodal_data(multi_modal_data: Any) -> Optional[Dict[str, Any]]
236237
wire[key] = serialize_tensor(value)
237238
if "num_img_embeddings_per_tile" in modality_data:
238239
wire["num_img_embeddings_per_tile"] = int(modality_data["num_img_embeddings_per_tile"])
240+
if modality == "video":
241+
for key in ("video_frame_indices", "video_fps"):
242+
if key in modality_data:
243+
wire[key] = copy.deepcopy(modality_data[key])
239244
return {modality: wire, "media_cache_key": media_cache_key, **metadata} if wire else None
240245
else:
241246
raise TypeError(
@@ -403,6 +408,10 @@ def resolve_multimodal_data_for_engine(
403408
kwargs[key] = value if isinstance(value, torch.Tensor) else deserialize_tensor(value)
404409
if "num_img_embeddings_per_tile" in modality_data:
405410
kwargs["num_img_embeddings_per_tile"] = int(modality_data["num_img_embeddings_per_tile"])
411+
if modality == "video":
412+
for key in ("video_frame_indices", "video_fps"):
413+
if key in modality_data:
414+
kwargs[key] = copy.deepcopy(modality_data[key])
406415

407416
if modality == "image":
408417
# Reject incomplete static-tiling payloads. Static tiling (imgs +
@@ -1451,5 +1460,7 @@ class DynamicVLMInferenceRequest(DynamicInferenceRequest, VLMInferenceRequest):
14511460
image_token_mask: Optional[torch.Tensor] = None # 1D, -1=text, >=0=image index
14521461
imgs_sizes: Optional[torch.Tensor] = None
14531462
num_frames: Optional[torch.Tensor] = None
1463+
video_frame_indices: Optional[List[List[int]]] = None
1464+
video_fps: Optional[List[float]] = None
14541465
media_tokens_preexpanded: bool = False
14551466
media_cache_key: Optional[str] = None

‎megatron/core/inference/model_inference_wrappers/abstract_model_inference_wrapper.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ def __init__(
6868
self.sequence_parallel = self.config.sequence_parallel
6969

7070
self.inference_context = inference_context
71+
configured_prompt_contract = getattr(
72+
self.inference_context.config, "multimodal_prompt_config", None
73+
)
74+
if configured_prompt_contract is not None:
75+
self.multimodal_prompt_config = configured_prompt_contract
7176

7277
# Get the inference pg_collection from the config if it exists; otherwise the training
7378
# pg_collection might be used during RL

0 commit comments

Comments
 (0)