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 7, 07:45 UTC
[Bugfix] Fix EVS implementation for Qwen3 VL (#33607)
Signed-off-by: 2ez4bz <133824995+2ez4bz@users.noreply.github.com>
William Zhang committed
Mar 3, 2026 at 18:18 UTC
70c73df69ee28fec37781d9bc82a994619ab95b1
7 files changed
+895
-272
tests/model_executor/test_qwen3_vl_mrope.py
new
+237
@@ -0,0 +1,237 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import dataclasses
4
+import random
5
+from dataclasses import dataclass
6
+
7
+import pytest
8
+import torch
9
+
10
+from vllm.model_executor.models.qwen3_vl import Qwen3VLForConditionalGeneration
11
+from vllm.multimodal.inputs import (
12
+ MultiModalFeatureSpec,
13
+ MultiModalFieldElem,
14
+ MultiModalKwargsItem,
15
+ PlaceholderRange,
16
+)
17
+
18
+
19
+@pytest.fixture(autouse=True, scope="module")
20
+def _force_cpu_default_device():
21
+ # _get_mrope_input_positions returns CPU tensors (via torch.from_numpy).
22
+ # Ensure the default device is CPU so the rest of the test tensors match.
23
+ original = torch.get_default_device()
24
+ torch.set_default_device("cpu")
25
+ yield
26
+ torch.set_default_device(original)
27
+
28
+
29
+IMAGE_TOKEN_ID = 999
30
+VIDEO_TOKEN_ID = 888
31
+VISION_START_TOKEN_ID = 777
32
+VISION_END_TOKEN_ID = 778
33
+
34
+
35
+@dataclass
36
+class DummyVisionConfig:
37
+ spatial_merge_size: int = 1
38
+
39
+
40
+@dataclass
41
+class DummyConfig:
42
+ image_token_id: int = IMAGE_TOKEN_ID
43
+ video_token_id: int = VIDEO_TOKEN_ID
44
+ vision_start_token_id: int = VISION_START_TOKEN_ID
45
+ vision_end_token_id: int = VISION_END_TOKEN_ID
46
+ vision_config: DummyVisionConfig = dataclasses.field(
47
+ default_factory=DummyVisionConfig
48
+ )
49
+
50
+
51
+def make_video_embedding(
52
+ t, h, w, interleave_text_tokens: tuple[int, int], video_pruning_rate: float = 0.0
53
+):
54
+ """
55
+ Helper function to make a video embedding for a given video size and pruning rate.
56
+
57
+ Args:
58
+ t: Number of frames.
59
+ h: Number of rows.
60
+ w: Number of columns.
61
+ interleave_text_tokens: Tuple of minimum and maximum number of text tokens to
62
+ interleave with the video.
63
+ video_pruning_rate: Pruning rate for the video.
64
+
65
+ Returns:
66
+ Tuple of (unpruned_tokens_sequence, pruned_tokens_sequence, retention_mask)
67
+ """
68
+ unpruned_tokens_sequence = []
69
+ population = list(range(1, 100))
70
+
71
+ for _ in range(t):
72
+ num_prefix_tokens = random.randint(
73
+ interleave_text_tokens[0], interleave_text_tokens[1]
74
+ )
75
+
76
+ prefix_tokens = random.choices(population, k=num_prefix_tokens)
77
+ vision_tokens = (
78
+ [VISION_START_TOKEN_ID] + [VIDEO_TOKEN_ID] * h * w + [VISION_END_TOKEN_ID]
79
+ )
80
+
81
+ unpruned_tokens_sequence.extend(prefix_tokens)
82
+ unpruned_tokens_sequence.extend(vision_tokens)
83
+
84
+ unpruned_tokens_sequence = torch.tensor(unpruned_tokens_sequence, dtype=torch.long)
85
+ video_token_mask = unpruned_tokens_sequence == VIDEO_TOKEN_ID
86
+
87
+ pruning_mask = torch.bernoulli(video_token_mask.float() * video_pruning_rate).bool() # type: ignore[attr-defined]
88
+ # Sanity check that we don't prune what should not be pruned.
89
+ assert not pruning_mask[~video_token_mask].any()
90
+
91
+ retention_mask = ~pruning_mask
92
+ pruned_tokens_sequence = unpruned_tokens_sequence[retention_mask]
93
+ return unpruned_tokens_sequence, pruned_tokens_sequence, retention_mask
94
+
95
+
96
+@pytest.mark.parametrize("spatial_merge_size", [1, 2])
97
+@pytest.mark.parametrize("grid_thw", [[3, 8, 7], [128, 10, 12]])
98
+@pytest.mark.parametrize("num_prefix_tokens", [1, 11])
99
+@pytest.mark.parametrize("num_suffix_tokens", [0, 7])
100
+@pytest.mark.parametrize("video_pruning_rate", [0, 0.25, 0.75])
101
+@pytest.mark.parametrize("interleave_text_tokens", [(0, 0), (1, 4)])
102
+def test_match_qwen3vl_mrope_evs_on(
103
+ spatial_merge_size: int,
104
+ num_prefix_tokens: int,
105
+ grid_thw: tuple[int, int, int],
106
+ num_suffix_tokens: int,
107
+ video_pruning_rate: float,
108
+ interleave_text_tokens: tuple[int, int],
109
+):
110
+ hf_config = DummyConfig()
111
+ hf_config.vision_config.spatial_merge_size = spatial_merge_size
112
+
113
+ t, h, w = grid_thw
114
+ population = list(range(1, 100))
115
+ prefix_tokens = random.choices(population, k=num_prefix_tokens)
116
+ suffix_tokens = random.choices(population, k=num_suffix_tokens)
117
+
118
+ video_tokens, video_tokens_pruned, retention_mask = make_video_embedding(
119
+ t,
120
+ h // spatial_merge_size,
121
+ w // spatial_merge_size,
122
+ interleave_text_tokens=interleave_text_tokens,
123
+ video_pruning_rate=video_pruning_rate,
124
+ )
125
+ assert len(video_tokens) == len(retention_mask)
126
+
127
+ input_tokens = prefix_tokens + video_tokens.tolist() + suffix_tokens
128
+ input_tokens_pruned = prefix_tokens + video_tokens_pruned.tolist() + suffix_tokens
129
+
130
+ whole_sequence_retention_mask = torch.cat(
131
+ [
132
+ torch.ones(len(prefix_tokens), dtype=torch.bool),
133
+ retention_mask,
134
+ torch.ones(len(suffix_tokens), dtype=torch.bool),
135
+ ],
136
+ dim=0,
137
+ )
138
+
139
+ # Build the GT mrope for unpruned input.
140
+ mm_feature = MultiModalFeatureSpec(
141
+ data=MultiModalKwargsItem(
142
+ {
143
+ "video_grid_thw": MultiModalFieldElem(
144
+ data=torch.tensor(grid_thw),
145
+ field=None, # HACK.
146
+ ),
147
+ }
148
+ ),
149
+ modality="video",
150
+ identifier="DUMMY",
151
+ mm_position=PlaceholderRange(offset=0, length=len(input_tokens)),
152
+ )
153
+ expected_mrope, _ = Qwen3VLForConditionalGeneration._get_mrope_input_positions(
154
+ input_tokens=input_tokens,
155
+ mm_features=[mm_feature],
156
+ config=hf_config,
157
+ )
158
+
159
+ # Compute mrope for a video-only media (unpruned).
160
+ mm_feature = MultiModalFeatureSpec(
161
+ data=MultiModalKwargsItem(
162
+ {
163
+ "video_grid_thw": MultiModalFieldElem(
164
+ data=torch.tensor(grid_thw),
165
+ field=None, # HACK.
166
+ ),
167
+ }
168
+ ),
169
+ modality="video",
170
+ identifier="DUMMY",
171
+ mm_position=PlaceholderRange(offset=0, length=video_tokens.numel()),
172
+ )
173
+ video_mrope, _ = Qwen3VLForConditionalGeneration._get_mrope_input_positions(
174
+ input_tokens=video_tokens.tolist(),
175
+ mm_features=[mm_feature],
176
+ config=hf_config,
177
+ )
178
+ video_mrope = video_mrope.permute(1, 0) # [N, 3]
179
+ hidden_size = 16
180
+
181
+ is_video_embed = torch.isin(
182
+ video_tokens_pruned, torch.tensor([VIDEO_TOKEN_ID], dtype=torch.long)
183
+ )
184
+
185
+ expanded_positions = torch.full(
186
+ (len(video_tokens_pruned), 5),
187
+ fill_value=-100,
188
+ device=video_mrope.device,
189
+ dtype=torch.long,
190
+ )
191
+ expanded_positions[is_video_embed, :3] = video_mrope[retention_mask][is_video_embed]
192
+ expanded_positions[~is_video_embed, :3] = video_mrope[retention_mask][
193
+ ~is_video_embed
194
+ ]
195
+
196
+ is_vision_start = video_tokens_pruned == VISION_START_TOKEN_ID
197
+ expanded_positions[..., 3] = is_vision_start
198
+ expanded_positions[..., 4] = is_video_embed
199
+
200
+ # Check that all positions were filled, since we initialized them as negative.
201
+ assert (expanded_positions >= 0).all()
202
+
203
+ video_embeddings = torch.empty(
204
+ (len(video_tokens_pruned), hidden_size), device=video_mrope.device
205
+ )
206
+
207
+ video_embeddings = torch.cat(
208
+ [
209
+ video_embeddings,
210
+ expanded_positions.float(),
211
+ ],
212
+ dim=1,
213
+ )
214
+ multimodal_embeddings = [video_embeddings]
215
+
216
+ expected_mrope_masked = expected_mrope[:, whole_sequence_retention_mask]
217
+
218
+ # Initialize computed_mrope with sequential positions for all prefix tokens
219
+ computed_mrope = torch.empty((3, len(input_tokens_pruned)), dtype=torch.long)
220
+ computed_mrope[:, 0 : len(prefix_tokens)] = expected_mrope[
221
+ :, 0 : len(prefix_tokens)
222
+ ]
223
+
224
+ # Paranoia check that computed_mrope is wrong.
225
+ assert not torch.equal(computed_mrope, expected_mrope_masked)
226
+
227
+ _, actual_mrope, _ = Qwen3VLForConditionalGeneration._recompute_mrope_positions(
228
+ input_ids=input_tokens_pruned,
229
+ multimodal_embeddings=multimodal_embeddings,
230
+ mrope_positions=computed_mrope,
231
+ num_computed_tokens=len(prefix_tokens),
232
+ vision_start_token_id=hf_config.vision_start_token_id,
233
+ image_token_id=hf_config.image_token_id,
234
+ video_token_id=hf_config.video_token_id,
235
+ )
236
+
237
+ assert torch.equal(actual_mrope, expected_mrope_masked)
vllm/model_executor/models/qwen2_5_vl.py
+7
@@ -195,6 +195,8 @@ class Qwen2_5_VLVideoPixelInputs(TensorSchema):
195
- second_per_grid_ts: The video time interval (in seconds) for each
196
grid along the temporal dimension in the 3D position IDs. Returned
197
when `videos` is not `None`.
198
+ - timestamps: List of timestamp values (in seconds) for each frame
199
+ after merging. Length equals the temporal dimension after merging.
200
"""
201
202
type: Literal["pixel_values_videos"]
@@ -214,6 +216,8 @@ class Qwen2_5_VLVideoPixelInputs(TensorSchema):
216
TensorShape("nv"),
217
]
218
219
+ timestamps: list[list[float]] | None = None
220
+
221
222
class Qwen2_5_VLVideoEmbeddingInputs(TensorSchema):
223
"""
@@ -232,6 +236,8 @@ class Qwen2_5_VLVideoEmbeddingInputs(TensorSchema):
236
- second_per_grid_ts: The video time interval (in seconds) for each
237
grid along the temporal dimension in the 3D position IDs. Returned
238
when `videos` is not `None`.
239
+ - timestamps: List of timestamp values (in seconds) for each frame
240
+ after merging. Length equals the temporal dimension after merging.
241
"""
242
243
type: Literal["video_embeds"]
@@ -250,6 +256,7 @@ class Qwen2_5_VLVideoEmbeddingInputs(TensorSchema):
256
torch.Tensor | None,
257
TensorShape("nv"),
258
] = None
259
+ timestamps: list[list[float]] | None = None
260
261
262
Qwen2_5_VLVideoInputs: TypeAlias = (
vllm/model_executor/models/qwen2_vl.py
+1
@@ -755,6 +755,7 @@ def _create_qwen2vl_field_factory(
755
"video", video_embed_grid_sizes
756
),
757
video_grid_thw=MultiModalFieldConfig.batched("video", keep_on_cpu=True),
758
+ timestamps=MultiModalFieldConfig.batched("video", keep_on_cpu=True),
759
)
760
761
return _qwen2vl_field_config
vllm/model_executor/models/qwen3_5.py
+13
-8
@@ -628,6 +628,9 @@ class Qwen3_5MoeForCausalLM(Qwen3_5ForCausalLMBase, QwenNextMixtureOfExperts):
628
dummy_inputs=Qwen3VLDummyInputsBuilder,
629
)
630
class Qwen3_5ForConditionalGeneration(Qwen3VLForConditionalGeneration, IsHybrid):
631
+ # Qwen3.5 does not support multimodal pruning (EVS).
632
+ supports_multimodal_pruning = False
633
+
634
packed_modules_mapping = Qwen3VLForConditionalGeneration.packed_modules_mapping | {
635
"in_proj_qkvz": ["in_proj_qkv", "in_proj_z"],
636
"in_proj_ba": ["in_proj_b", "in_proj_a"],
@@ -643,10 +646,8 @@ class Qwen3_5ForConditionalGeneration(Qwen3VLForConditionalGeneration, IsHybrid)
646
self.config = config
647
self.multimodal_config = multimodal_config
648
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
646
- self.video_pruning_rate = multimodal_config.video_pruning_rate
647
- self.is_multimodal_pruning_enabled = (
648
- multimodal_config.is_multimodal_pruning_enabled()
649
- )
649
+ # Qwen3.5 does not support multimodal pruning (EVS).
650
+ self.is_multimodal_pruning_enabled = False
651
652
with self._mark_tower_model(vllm_config, {"image", "video"}):
653
self.visual = Qwen3_VisionTransformer(
@@ -693,6 +694,12 @@ class Qwen3_5ForConditionalGeneration(Qwen3VLForConditionalGeneration, IsHybrid)
694
695
return inputs_embeds
696
697
+ def recompute_mrope_positions(self, *args, **kwargs):
698
+ raise NotImplementedError(
699
+ "Qwen3.5 does not support multimodal pruning (EVS). "
700
+ "recompute_mrope_positions should never be called."
701
+ )
702
+
703
def forward(
704
self,
705
input_ids: torch.Tensor,
@@ -851,10 +858,8 @@ class Qwen3_5MoeForConditionalGeneration(
858
self.config = config
859
self.multimodal_config = multimodal_config
860
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
854
- self.video_pruning_rate = multimodal_config.video_pruning_rate
855
- self.is_multimodal_pruning_enabled = (
856
- multimodal_config.is_multimodal_pruning_enabled()
857
- )
861
+ # Qwen3.5 does not support multimodal pruning (EVS).
862
+ self.is_multimodal_pruning_enabled = False
863
864
with self._mark_tower_model(vllm_config, {"image", "video"}):
865
self.visual = Qwen3_VisionTransformer(
vllm/model_executor/models/qwen3_vl.py
+565
-256
@@ -79,6 +79,7 @@ from vllm.multimodal.inputs import (
79
MultiModalDataDict,
80
MultiModalFeatureSpec,
81
MultiModalFieldConfig,
82
+ MultiModalFieldElem,
83
MultiModalKwargsItem,
84
MultiModalKwargsItems,
85
PlaceholderRange,
@@ -93,6 +94,8 @@ from vllm.multimodal.processing import (
94
PromptUpdateDetails,
95
)
96
from vllm.sequence import IntermediateTensors
97
+from vllm.tokenizers.protocol import TokenizerLike
98
+from vllm.tokenizers.registry import cached_tokenizer_from_config
99
from vllm.utils.collection_utils import is_list_of
100
from vllm.utils.math_utils import round_up
101
@@ -763,7 +766,6 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
766
def _get_video_second_idx(
767
self,
768
metadata: dict[str, Any],
766
- out_item: MultiModalKwargsItem,
769
do_sample_frames: bool | None = None,
770
sampled_fps: float | None = None,
771
) -> list[int]:
@@ -956,6 +958,7 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
958
if videos := mm_data.pop("videos", []):
959
video_grid_thw_lst = []
960
pixel_values_videos_lst = []
961
+ timestamps_per_video = []
962
963
for item in videos:
964
video_array, metadata = item
@@ -979,6 +982,14 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
982
**{k: metadata[k] for k in metadata if k != "do_sample_frames"}
983
)
984
985
+ # Compute timestamps here where we have access to metadata
986
+ timestamps = self.info._get_video_second_idx(
987
+ metadata=metadata,
988
+ do_sample_frames=video_mm_kwargs["do_sample_frames"],
989
+ sampled_fps=video_mm_kwargs.get("fps"),
990
+ )
991
+ timestamps_per_video.append(timestamps)
992
+
993
video_mm_data = dict()
994
video_mm_data["videos"] = [[video_array]]
995
video_mm_data["video_metadata"] = [[metadata]]
@@ -989,6 +1000,49 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1000
mm_kwargs=video_mm_kwargs,
1001
tok_kwargs=tok_kwargs,
1002
)
1003
+
1004
+ merge_size = processor.video_processor.merge_size
1005
+ # Get video grid info for EVS calculation.
1006
+ video_grid_thw = video_outputs["video_grid_thw"]
1007
+ num_frames = int(video_grid_thw[0, 0])
1008
+ tokens_per_frame_base = int(video_grid_thw[0, 1:].prod()) // (
1009
+ merge_size**2
1010
+ )
1011
+
1012
+ # Apply EVS if enabled.
1013
+ video_pruning_rate = self.info.ctx.get_mm_config().video_pruning_rate
1014
+ if video_pruning_rate is not None and video_pruning_rate > 0.0:
1015
+ num_tokens = compute_retained_tokens_count(
1016
+ tokens_per_frame=tokens_per_frame_base,
1017
+ num_frames=num_frames,
1018
+ q=video_pruning_rate,
1019
+ )
1020
+ # Here we just need placeholders that won't actually be replaced -
1021
+ # we just need to make sure the total number of tokens is correct
1022
+ # assign all tokens to the first frame.
1023
+ tokens_per_frame = [num_tokens] + [0] * (num_frames - 1)
1024
+ select_token_id = False
1025
+ else:
1026
+ tokens_per_frame = [tokens_per_frame_base] * num_frames
1027
+ select_token_id = True
1028
+
1029
+ # Generate the video replacement with EVS-adjusted token counts
1030
+ tokenizer = self.info.get_tokenizer()
1031
+ hf_config = self.info.get_hf_config()
1032
+ video_repl = Qwen3VLMultiModalProcessor.get_video_repl(
1033
+ tokens_per_frame=tokens_per_frame,
1034
+ timestamps=timestamps,
1035
+ tokenizer=tokenizer,
1036
+ vision_start_token_id=hf_config.vision_start_token_id,
1037
+ vision_end_token_id=hf_config.vision_end_token_id,
1038
+ video_token_id=hf_config.video_token_id,
1039
+ select_token_id=select_token_id,
1040
+ )
1041
+
1042
+ # Convert token IDs to text for the HF processor flow
1043
+ video_placeholder = tokenizer.decode(
1044
+ video_repl.full, skip_special_tokens=False
1045
+ )
1046
input_ids = video_outputs.pop("input_ids")
1047
video_placeholder = processor.tokenizer.batch_decode(input_ids)[0]
1048
prompt = prompt.replace(
@@ -1002,6 +1056,7 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1056
video_outputs = dict(
1057
pixel_values_videos=torch.cat(pixel_values_videos_lst),
1058
video_grid_thw=torch.cat(video_grid_thw_lst),
1059
+ timestamps=timestamps_per_video,
1060
)
1061
else:
1062
video_outputs = dict()
@@ -1057,60 +1112,42 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1112
grid_thw = out_item["video_grid_thw"].data
1113
assert isinstance(grid_thw, torch.Tensor)
1114
1060
- video, metadata = mm_items["video"][item_idx]
1061
- do_sample_frames = hf_processor_mm_kwargs.get("do_sample_frames")
1115
sampled_fps = hf_processor_mm_kwargs.get("fps")
1116
if is_list_of(sampled_fps, float):
1117
sampled_fps = sampled_fps[item_idx]
1065
- timestamps = self.info._get_video_second_idx(
1066
- metadata, out_item, do_sample_frames, sampled_fps
1067
- )
1118
1119
+ timestamps = out_item["timestamps"].data
1120
assert len(timestamps) == grid_thw[0], (
1121
f"The timestamps length({len(timestamps)}) should be equal "
1122
f"video length ({grid_thw[0]})."
1123
)
1124
1074
- frames_idx_token = [
1075
- tokenizer.encode(f"<{curr_time:.1f} seconds>", add_special_tokens=False)
1076
- for curr_time in timestamps
1077
- ]
1078
- tokens_per_frame = int(grid_thw[1:].prod()) // merge_length
1079
- per_frame_token_counts = [tokens_per_frame for _ in frames_idx_token]
1125
+ # Compute tokens per frame, with EVS support
1126
+ num_frames = int(grid_thw[0])
1127
+ tokens_per_frame_base = int(grid_thw[1:].prod()) // merge_length
1128
1129
video_pruning_rate = self.info.ctx.get_mm_config().video_pruning_rate
1130
if video_pruning_rate is not None and video_pruning_rate > 0.0:
1083
- total_retained = compute_retained_tokens_count(
1084
- tokens_per_frame,
1085
- len(frames_idx_token),
1086
- video_pruning_rate,
1131
+ num_tokens = compute_retained_tokens_count(
1132
+ tokens_per_frame=tokens_per_frame_base,
1133
+ num_frames=num_frames,
1134
+ q=video_pruning_rate,
1135
)
1088
- if len(frames_idx_token) == 0:
1089
- per_frame_token_counts = []
1090
- elif len(frames_idx_token) == 1:
1091
- per_frame_token_counts = [tokens_per_frame]
1092
- else:
1093
- first_frame_tokens = tokens_per_frame
1094
- remaining_tokens = max(total_retained - first_frame_tokens, 0)
1095
- base = remaining_tokens // (len(frames_idx_token) - 1)
1096
- remainder = remaining_tokens % (len(frames_idx_token) - 1)
1097
- per_frame_token_counts = [first_frame_tokens]
1098
- for frame_idx in range(1, len(frames_idx_token)):
1099
- extra = base + (1 if (frame_idx - 1) < remainder else 0)
1100
- per_frame_token_counts.append(extra)
1101
-
1102
- placeholder = []
1103
- for frame_idx, timestamp_tokens in enumerate(frames_idx_token):
1104
- placeholder.extend(timestamp_tokens)
1105
- tokens_this_frame = per_frame_token_counts[
1106
- frame_idx if frame_idx < len(per_frame_token_counts) else -1
1107
- ]
1108
- placeholder.extend(
1109
- [vision_start_token_id]
1110
- + [video_token_id] * tokens_this_frame
1111
- + [vision_end_token_id]
1112
- )
1113
- return PromptUpdateDetails.select_token_id(placeholder, video_token_id)
1136
+ tokens_per_frame = [num_tokens] + [0] * (num_frames - 1)
1137
+ select_token_id = False
1138
+ else:
1139
+ tokens_per_frame = [tokens_per_frame_base] * num_frames
1140
+ select_token_id = True
1141
+
1142
+ return Qwen3VLMultiModalProcessor.get_video_repl(
1143
+ tokens_per_frame=tokens_per_frame,
1144
+ timestamps=timestamps,
1145
+ tokenizer=tokenizer,
1146
+ vision_start_token_id=vision_start_token_id,
1147
+ vision_end_token_id=vision_end_token_id,
1148
+ video_token_id=video_token_id,
1149
+ select_token_id=select_token_id,
1150
+ )
1151
1152
return [
1153
PromptReplacement(
@@ -1127,6 +1164,69 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1164
),
1165
]
1166
1167
+ @staticmethod
1168
+ def get_video_repl(
1169
+ *,
1170
+ tokens_per_frame: list[int],
1171
+ timestamps: list[float | int],
1172
+ tokenizer: TokenizerLike,
1173
+ vision_start_token_id: int,
1174
+ vision_end_token_id: int,
1175
+ video_token_id: int,
1176
+ select_token_id: bool = False,
1177
+ ) -> PromptUpdateDetails[list[int]]:
1178
+ """Build prompt replacement for a video in Qwen3VL format.
1179
+
1180
+ The replacement structure for each frame is:
1181
+ timestamp_tokens + vision_start_token + video_tokens + vision_end_token
1182
+
1183
+ Args:
1184
+ tokens_per_frame: Number of video tokens per frame (can vary per frame for
1185
+ EVS).
1186
+ timestamps: List of timestamps in seconds for each frame
1187
+ tokenizer: Tokenizer to encode timestamp strings
1188
+ vision_start_token_id: Token ID for vision start marker
1189
+ vision_end_token_id: Token ID for vision end marker
1190
+ video_token_id: Token ID for video content
1191
+
1192
+ Returns:
1193
+ PromptUpdateDetails with full token sequence
1194
+ """
1195
+ assert len(timestamps) == len(tokens_per_frame), (
1196
+ "timestamps and tokens_per_frame must have the same length"
1197
+ )
1198
+
1199
+ # Tokenize timestamp strings independently to avoid tokenizer merging
1200
+ # tokens across boundaries.
1201
+ # TODO: switch to `_seq2tokens` which has some caching.
1202
+ timestamp_token_ids = [
1203
+ tokenizer.encode(f"<{timestamp:.1f} seconds>", add_special_tokens=False)
1204
+ for timestamp in timestamps
1205
+ ]
1206
+
1207
+ # Build the full token sequence
1208
+ all_token_ids = []
1209
+ for frame_timestamp_ids, num_tokens in zip(
1210
+ timestamp_token_ids, tokens_per_frame
1211
+ ):
1212
+ # Add timestamp tokens
1213
+ all_token_ids.extend(frame_timestamp_ids)
1214
+
1215
+ # Add vision tokens: vision_start + video_tokens + vision_end
1216
+ all_token_ids.append(vision_start_token_id)
1217
+ all_token_ids.extend([video_token_id] * num_tokens)
1218
+ all_token_ids.append(vision_end_token_id)
1219
+
1220
+ if select_token_id:
1221
+ return PromptUpdateDetails.select_token_id(all_token_ids, video_token_id)
1222
+
1223
+ # NOTE: we use `from_seq` instead of `select_token_id` because we want all
1224
+ # tokens in the placeholder to be initially marked as candidates. Then
1225
+ # in `get_input_embeddings``, we refine the mask to only replace
1226
+ # `video_token_id` / `image_token_id`` positions with video/image embeddings,
1227
+ # keeping text embeddings for timestamps and structural tokens.
1228
+ return PromptUpdateDetails.from_seq(all_token_ids)
1229
+
1230
1231
@support_torch_compile(
1232
dynamic_arg_dims={
@@ -1280,6 +1380,7 @@ class Qwen3VLForConditionalGeneration(
1380
multimodal_config = vllm_config.model_config.multimodal_config
1381
1382
self.config = config
1383
+ self._tokenizer = cached_tokenizer_from_config(vllm_config.model_config)
1384
self.multimodal_config = multimodal_config
1385
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
1386
self.video_pruning_rate = multimodal_config.video_pruning_rate
@@ -1419,6 +1520,7 @@ class Qwen3VLForConditionalGeneration(
1520
video_embeds = kwargs.pop("video_embeds", None)
1521
video_grid_thw = kwargs.pop("video_grid_thw", None)
1522
second_per_grid_ts = kwargs.pop("second_per_grid_ts", None)
1523
+ timestamps = kwargs.pop("timestamps", None)
1524
1525
if pixel_values_videos is None and video_embeds is None:
1526
return None
@@ -1429,6 +1531,7 @@ class Qwen3VLForConditionalGeneration(
1531
pixel_values_videos=pixel_values_videos,
1532
video_grid_thw=video_grid_thw,
1533
second_per_grid_ts=second_per_grid_ts,
1534
+ timestamps=timestamps,
1535
)
1536
1537
if video_embeds is not None:
@@ -1436,6 +1539,7 @@ class Qwen3VLForConditionalGeneration(
1539
type="video_embeds",
1540
video_embeds=video_embeds,
1541
video_grid_thw=video_grid_thw,
1542
+ timestamps=timestamps,
1543
)
1544
1545
def _process_image_input(
@@ -1502,19 +1606,29 @@ class Qwen3VLForConditionalGeneration(
1606
1607
Returns:
1608
Tuple of image embeddings for each image item.
1505
- Resulting embeddings will have extra 4 channels for
1506
- computed mrope positions.
1609
+ Resulting embeddings will have extra 5 channels for
1610
+ computed mrope positions, consistent with video embeddings.
1611
"""
1508
- merge_size = self.visual.spatial_merge_size
1509
- grid_thw = image_input["image_grid_thw"]
1510
- grid_thw_list = grid_thw.tolist()
1511
- image_embeds_out = []
1512
- for emb, size in zip(image_embeds_split, grid_thw_list):
1513
- positions = compute_mrope_for_media(size, merge_size).to(emb.device)
1514
- emb = torch.cat([emb, positions], dim=1)
1515
- image_embeds_out.append(emb)
1516
- image_embeds_split = image_embeds_out
1517
- return tuple(image_embeds_split)
1612
+ if self.is_multimodal_pruning_enabled:
1613
+ merge_size = self.visual.spatial_merge_size
1614
+ grid_thw = image_input["image_grid_thw"]
1615
+ grid_thw_list = grid_thw.tolist()
1616
+ image_embeds_out = []
1617
+ for emb, size in zip(image_embeds_split, grid_thw_list):
1618
+ positions = compute_mrope_for_media(size, merge_size).to(emb.device)
1619
+ positions = torch.cat(
1620
+ [
1621
+ positions,
1622
+ torch.zeros_like(
1623
+ positions[:, 0:1]
1624
+ ), # Dummy extra fifth channel
1625
+ ],
1626
+ dim=1,
1627
+ )
1628
+ emb = torch.cat([emb, positions], dim=1)
1629
+ image_embeds_out.append(emb)
1630
+ image_embeds_split = tuple(image_embeds_out)
1631
+ return image_embeds_split
1632
1633
def _postprocess_video_embeds_evs(
1634
self,
@@ -1531,62 +1645,218 @@ class Qwen3VLForConditionalGeneration(
1645
1646
Returns:
1647
Tuple of video embeddings for each video item.
1534
- Resulting embeddings will have extra 4 channels for
1535
- computed mrope positions.
1648
+ Resulting embeddings will have extra 5 channels for computed mrope
1649
+ positions, and whether the index corresponds to a video embedding.
1650
"""
1651
grid_thw = video_input["video_grid_thw"]
1652
assert grid_thw.ndim == 2
1653
grid_thw_list = grid_thw.tolist()
1654
merge_size = self.visual.spatial_merge_size
1655
1542
- # Cast to long to match the original code
1543
- # https://github.com/huggingface/transformers/blob/41980ce93e775f6c88500c51c8db7946fc6a2add/src/transformers/models/qwen2_5_vl/modular_qwen2_5_vl.py#L491 # noqa
1544
- second_per_grid_ts = video_input.get("second_per_grid_ts")
1545
- if second_per_grid_ts is None:
1546
- # For Qwen3-VL, second_per_grid_ts might not be available
1547
- # Use default value of 1.0 for each video
1548
- second_per_grid_ts = torch.ones(len(grid_thw_list), dtype=torch.long)
1656
+ # Apply EVS to each video.
1657
+ video_embeds_out = []
1658
+ for video_idx, (emb, size) in enumerate(zip(video_embeds_split, grid_thw_list)):
1659
+ # Compute positions.
1660
+ timestamps = video_input.timestamps[video_idx]
1661
+ num_frames = len(timestamps)
1662
+
1663
+ t, h, w = size
1664
+ if self.is_multimodal_pruning_enabled:
1665
+ # For each video, compute retention mask using EVS.
1666
+ # retention_mask: [11424].
1667
+ retention_mask = compute_retention_mask(
1668
+ emb,
1669
+ size,
1670
+ spatial_merge_size=self.visual.spatial_merge_size,
1671
+ q=self.video_pruning_rate,
1672
+ )
1673
+ # Apply retention mask.
1674
+ emb = emb[retention_mask]
1675
+
1676
+ # Calculate the actual number of retained tokens per frame.
1677
+ num_frames, rows, cols = (
1678
+ t,
1679
+ h // merge_size,
1680
+ w // merge_size,
1681
+ )
1682
+ retention_mask_thw = retention_mask.reshape(num_frames, rows, cols)
1683
+ num_tokens_per_frame = (
1684
+ retention_mask_thw.sum(dim=(1, 2)).long().tolist()
1685
+ )
1686
+ else:
1687
+ feature_size = emb.shape[0] // num_frames
1688
+ num_tokens_per_frame = [feature_size] * num_frames
1689
+ retention_mask = None
1690
+
1691
+ emb = self._create_final_video_embeddings(
1692
+ video_embeddings=emb,
1693
+ num_tokens_per_frame=num_tokens_per_frame,
1694
+ timestamps=timestamps,
1695
+ video_grid_thw=size,
1696
+ retention_mask=retention_mask,
1697
+ )
1698
+
1699
+ video_embeds_out.append(emb)
1700
+
1701
+ return tuple(video_embeds_out)
1702
+
1703
+ def _create_final_video_embeddings(
1704
+ self,
1705
+ video_embeddings: torch.Tensor,
1706
+ num_tokens_per_frame: list[int],
1707
+ timestamps: list[float],
1708
+ video_grid_thw: list[int],
1709
+ retention_mask: torch.Tensor,
1710
+ ) -> torch.Tensor:
1711
+ """Create final embeddings that combine video embeddings with
1712
+ text embeddings of indicator tokens.
1713
+
1714
+ These final embeddings contain:
1715
+ - Actual video embeddings in positions corresponding to video content
1716
+ - Text embeddings for indicator tokens (<img>, </img>, and
1717
+ frame separation text) in their respective positions
1718
+
1719
+ These embeddings will replace the placeholder embeddings to create
1720
+ input_embeds for the LLM.
1721
+ """
1722
+ device = video_embeddings.device
1723
+
1724
+ # Generate video replacement token IDs using get_video_repl
1725
+ # This tokenizes each frame separator independently, then uses pre-tokenized
1726
+ # special tokens to ensure consistent tokenization regardless of
1727
+ # num_tokens_per_frame values.
1728
+ video_repl = Qwen3VLMultiModalProcessor.get_video_repl(
1729
+ tokens_per_frame=num_tokens_per_frame,
1730
+ tokenizer=self._tokenizer,
1731
+ timestamps=timestamps,
1732
+ vision_start_token_id=self.config.vision_start_token_id,
1733
+ vision_end_token_id=self.config.vision_end_token_id,
1734
+ video_token_id=self.config.video_token_id,
1735
+ select_token_id=self.is_multimodal_pruning_enabled,
1736
+ )
1737
+
1738
+ repl_token_ids = torch.tensor(video_repl.full, device=device)
1739
+ embed_token_id = _cached_tensor(self.config.video_token_id, device=device)
1740
+ is_video_embed = torch.isin(repl_token_ids, embed_token_id)
1741
+
1742
+ # Get text embeddings for indicator tokens (has only `visual_dim``).
1743
+ text_embeddings = self.get_language_model().embed_input_ids(repl_token_ids)
1744
+
1745
+ if self.use_deepstack:
1746
+ (
1747
+ deepstack_input_embeds,
1748
+ multimodal_embeddings,
1749
+ ) = self._compute_deepstack_embeds(
1750
+ inputs_embeds=text_embeddings,
1751
+ multimodal_embeddings=[video_embeddings],
1752
+ is_multimodal=is_video_embed,
1753
+ )
1754
else:
1550
- second_per_grid_ts = second_per_grid_ts.long()
1551
- tokens_per_second = getattr(self.config.vision_config, "tokens_per_second", 1.0)
1755
+ deepstack_input_embeds = None
1756
+ multimodal_embeddings = [video_embeddings]
1757
1553
- video_embeds_out = []
1554
- for emb, size, video_second_per_grid_t in zip(
1555
- video_embeds_split, grid_thw_list, second_per_grid_ts
1556
- ):
1557
- # For each video, we compute retention mask using EVS
1558
- retention_mask = compute_retention_mask(
1559
- emb,
1560
- size,
1561
- spatial_merge_size=self.visual.spatial_merge_size,
1562
- q=self.video_pruning_rate,
1758
+ merged_embeddings = _merge_multimodal_embeddings(
1759
+ inputs_embeds=text_embeddings,
1760
+ multimodal_embeddings=multimodal_embeddings,
1761
+ is_multimodal=is_video_embed,
1762
+ )
1763
+
1764
+ to_concat = [merged_embeddings]
1765
+ if deepstack_input_embeds is not None:
1766
+ to_concat.append(
1767
+ deepstack_input_embeds.permute(1, 0, 2).reshape(
1768
+ deepstack_input_embeds.shape[1], -1
1769
+ )
1770
)
1771
1565
- # Debug logging for EVS pruning
1566
- logger.debug(
1567
- "EVS: Video tokens pruned from %d to %d (T=%d,H=%d,W=%d, "
1568
- "pruning_rate=%.2f, reduction=%.1f%%)",
1569
- emb.shape[0],
1570
- retention_mask.sum().item(),
1571
- size[0],
1572
- size[1],
1573
- size[2],
1574
- self.video_pruning_rate,
1575
- (1 - retention_mask.float().mean().item()) * 100,
1772
+ expanded_positions = None
1773
+ if self.is_multimodal_pruning_enabled:
1774
+ is_vision_start = repl_token_ids.eq(self.config.vision_start_token_id)
1775
+ expanded_positions = self._get_expanded_positions(
1776
+ device=merged_embeddings.device,
1777
+ seq_len=merged_embeddings.shape[0],
1778
+ video_grid_thw=video_grid_thw,
1779
+ num_tokens_per_frame=num_tokens_per_frame,
1780
+ timestamps=timestamps,
1781
+ is_video_embed=is_video_embed,
1782
+ is_vision_start=is_vision_start,
1783
+ retention_mask=retention_mask,
1784
)
1785
+ to_concat.append(expanded_positions)
1786
1578
- positions = compute_mrope_for_media(
1579
- size,
1580
- merge_size,
1581
- tokens_per_second=tokens_per_second,
1582
- video_second_per_grid=video_second_per_grid_t.item(),
1583
- ).to(emb.device)
1787
+ final_video_embeddings = torch.cat(to_concat, dim=-1)
1788
1585
- emb = emb[retention_mask]
1586
- positions = positions[retention_mask]
1587
- emb = torch.cat([emb, positions], dim=1)
1588
- video_embeds_out.append(emb)
1589
- return tuple(video_embeds_out)
1789
+ return final_video_embeddings
1790
+
1791
+ def _get_expanded_positions(
1792
+ self,
1793
+ device,
1794
+ seq_len,
1795
+ video_grid_thw,
1796
+ num_tokens_per_frame,
1797
+ timestamps,
1798
+ is_video_embed,
1799
+ is_vision_start,
1800
+ retention_mask,
1801
+ ):
1802
+ embed_token_id = _cached_tensor(self.config.video_token_id, device=device)
1803
+
1804
+ # Expand positions to match the full sequence length
1805
+ # (includes both video tokens and indicator tokens)
1806
+ # Shape: [full_length, 5] where positions are filled for video tokens
1807
+ # and zeros for indicator tokens.
1808
+ # Channel 3 flags VISION_START tokens so that
1809
+ # recompute_mrope_positions can reliably count timestamp tokens
1810
+ # (even when early frames have all video tokens pruned).
1811
+ # Channel 4 flags video-embedding tokens.
1812
+ expanded_positions = torch.zeros(
1813
+ seq_len,
1814
+ 5, # [t_index, h_index, w_index, is_vision_start, is_video]
1815
+ device=device,
1816
+ dtype=torch.long,
1817
+ )
1818
+ _, h, w = video_grid_thw
1819
+ merge_size = self.visual.spatial_merge_size
1820
+ num_frames = len(num_tokens_per_frame)
1821
+ unpruned_token_ids = Qwen3VLMultiModalProcessor.get_video_repl(
1822
+ tokens_per_frame=[(h // merge_size) * (w // merge_size)] * num_frames,
1823
+ tokenizer=self._tokenizer,
1824
+ timestamps=timestamps,
1825
+ vision_start_token_id=self.config.vision_start_token_id,
1826
+ vision_end_token_id=self.config.vision_end_token_id,
1827
+ video_token_id=self.config.video_token_id,
1828
+ ).full
1829
+ unpruned_token_ids_tensor = torch.tensor(unpruned_token_ids, device=device)
1830
+ mm_feature = MultiModalFeatureSpec(
1831
+ data=MultiModalKwargsItem(
1832
+ {
1833
+ "video_grid_thw": MultiModalFieldElem(
1834
+ data=torch.tensor(video_grid_thw),
1835
+ field=None, # HACK.
1836
+ ),
1837
+ }
1838
+ ),
1839
+ modality="video",
1840
+ identifier="DUMMY",
1841
+ mm_position=PlaceholderRange(offset=0, length=len(unpruned_token_ids)),
1842
+ )
1843
+ original_mrope = (
1844
+ self.get_mrope_input_positions(
1845
+ input_tokens=unpruned_token_ids,
1846
+ mm_features=[mm_feature],
1847
+ )[0]
1848
+ .to(device)
1849
+ .permute(1, 0)
1850
+ )
1851
+ full_is_video_embed = unpruned_token_ids_tensor == embed_token_id
1852
+ expanded_positions[is_video_embed, :3] = original_mrope[full_is_video_embed][
1853
+ retention_mask
1854
+ ]
1855
+ expanded_positions[~is_video_embed, :3] = original_mrope[~full_is_video_embed]
1856
+ expanded_positions[..., 3] = is_vision_start
1857
+ expanded_positions[..., 4] = is_video_embed
1858
+
1859
+ return expanded_positions
1860
1861
def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict:
1862
mm_input_by_modality = {}
@@ -1607,66 +1877,77 @@ class Qwen3VLForConditionalGeneration(
1877
)
1878
return mm_input_by_modality
1879
1610
- def iter_mm_grid_hw(
1611
- self, input_tokens: list[int], mm_features: list[MultiModalFeatureSpec]
1612
- ) -> Iterator[tuple[int, int, int]]:
1613
- """
1614
- Iterate over multimodal features and yield grid information.
1615
-
1616
- For videos with EVS (Efficient Video Sampling) enabled, this function
1617
- computes the offset based on the pruned token count rather than relying
1618
- on input_tokens.index(), which would fail when tokens are pruned.
1880
+ @staticmethod
1881
+ def _iter_mm_grid_hw(
1882
+ input_tokens: list[int],
1883
+ mm_features: list[MultiModalFeatureSpec],
1884
+ video_token_id: int,
1885
+ vision_start_token_id: int,
1886
+ vision_end_token_id: int,
1887
+ spatial_merge_size: int,
1888
+ ) -> Iterator[tuple[int, int, int, int]]:
1889
+ """Iterate over multimodal features and yield position info.
1890
1891
Args:
1621
- input_tokens: List of token IDs in the prompt
1622
- mm_features: List of multimodal feature specifications
1892
+ input_tokens: List of token IDs in the input sequence.
1893
+ mm_features: List of multimodal feature specifications containing
1894
+ image/video data and position information.
1895
+ video_token_id: Token ID used for video tokens.
1896
+ vision_start_token_id: Token ID marking the start of a vision sequence.
1897
+ vision_end_token_id: Token ID marking the end of a vision sequence.
1898
+ spatial_merge_size: Size of the spatial merge operation used to
1899
+ compute logical grid dimensions from the original feature grid.
1900
1901
Yields:
1625
- Tuple of (offset, grid_h, grid_w) for each frame/image
1902
+ offset: Position of the first video/image token in the sequence.
1903
+ llm_grid_h: Logical grid height (may not match actual token count with EVS).
1904
+ llm_grid_w: Logical grid width (may not match actual token count with EVS).
1905
+ actual_num_tokens: Actual number of video/image tokens in the placeholder.
1906
"""
1627
- video_token_id = self.config.video_token_id
1628
- spatial_merge_size = self.config.vision_config.spatial_merge_size
1907
for mm_feature in sorted(mm_features, key=lambda f: f.mm_position.offset):
1908
offset = mm_feature.mm_position.offset
1909
if mm_feature.modality == "image":
1910
t, h, w = mm_feature.data["image_grid_thw"].data.tolist()
1911
assert t == 1, f"Image must have 1 frame, got {t}"
1634
- yield offset, h // spatial_merge_size, w // spatial_merge_size
1912
+ llm_grid_h = h // spatial_merge_size
1913
+ llm_grid_w = w // spatial_merge_size
1914
+ yield offset, llm_grid_h, llm_grid_w, llm_grid_h * llm_grid_w
1915
elif mm_feature.modality == "video":
1916
t, h, w = mm_feature.data["video_grid_thw"].data.tolist()
1917
llm_grid_h = h // spatial_merge_size
1918
llm_grid_w = w // spatial_merge_size
1919
1640
- # Check if EVS (Efficient Video Sampling) is enabled
1641
- is_evs_enabled = (
1642
- hasattr(self, "video_pruning_rate")
1643
- and self.video_pruning_rate is not None
1644
- and self.video_pruning_rate > 0.0
1645
- )
1646
-
1647
- if is_evs_enabled:
1648
- frame_offsets = self._extract_frame_offsets_from_mask(
1649
- mm_feature.mm_position, t
1650
- )
1651
- if frame_offsets is not None:
1652
- for rel_offset in frame_offsets:
1653
- yield offset + rel_offset, llm_grid_h, llm_grid_w
1654
- continue
1655
-
1656
- # If EVS is enabled but mask is missing, this indicates a bug
1657
- # in the prompt processing pipeline. The is_embed mask should
1658
- # always be present when video_pruning_rate > 0.
1659
- raise RuntimeError(
1660
- f"EVS is enabled (pruning_rate={self.video_pruning_rate}) "
1661
- "but is_embed mask is missing from mm_position. "
1662
- "This indicates a bug in prompt processing."
1663
- )
1664
- else:
1665
- # Non-EVS mode: Use original logic with input_tokens.index()
1666
- for _ in range(t):
1667
- offset = input_tokens.index(video_token_id, offset)
1668
- yield offset, llm_grid_h, llm_grid_w
1669
- offset += llm_grid_h * llm_grid_w
1920
+ for _ in range(t):
1921
+ # When EVS is enabled, some frames may have 0 video tokens in the
1922
+ # placeholder. We use `vision_start_token_id` to locate each frame
1923
+ # since it is always present for every frame.
1924
+ # We then look for the first `video_token_id` after
1925
+ # `vision_start_token_id` and before `vision_end_token_id`.
1926
+ offset = input_tokens.index(vision_start_token_id, offset)
1927
+ vision_end_offset = input_tokens.index(vision_end_token_id, offset)
1928
+
1929
+ try:
1930
+ actual_num_tokens = 0
1931
+ video_offset = input_tokens.index(
1932
+ video_token_id, offset, vision_end_offset
1933
+ )
1934
+ # NOTE: looking at the
1935
+ # `Qwen3VLMultiModalProcessor.get_video_repl` code, we can
1936
+ # see that we can use the below formula to get the token
1937
+ # count, since everything in between `video_offset` and
1938
+ # `vision_end_offset` is populated as `video_token_id`.
1939
+ # This saves us from manually counting the number tokens
1940
+ # that match `video_token_id` in between.
1941
+ actual_num_tokens += vision_end_offset - video_offset
1942
+ except ValueError:
1943
+ # No `video_token_id` in this frame (EVS with 0 tokens for
1944
+ # this frame) -> use `offset + 1`` to move past
1945
+ # `vision_start_token_id`.
1946
+ video_offset = offset + 1
1947
+
1948
+ yield video_offset, llm_grid_h, llm_grid_w, actual_num_tokens
1949
+ # Move offset past this frame for next iteration.
1950
+ offset = vision_end_offset + 1
1951
else:
1952
raise ValueError(f"Unsupported modality: {mm_feature.modality}")
1953
@@ -1771,13 +2052,100 @@ class Qwen3VLForConditionalGeneration(
2052
2053
return [len(seg) for seg in segments]
2054
2055
+ def get_mrope_input_positions(
2056
+ self,
2057
+ input_tokens: list[int],
2058
+ mm_features: list[MultiModalFeatureSpec],
2059
+ ) -> tuple[torch.Tensor, int]:
2060
+ return self._get_mrope_input_positions(
2061
+ input_tokens=input_tokens,
2062
+ mm_features=mm_features,
2063
+ config=self.config,
2064
+ )
2065
+
2066
+ @staticmethod
2067
+ def _get_mrope_input_positions(
2068
+ input_tokens: list[int],
2069
+ mm_features: list[MultiModalFeatureSpec],
2070
+ config: Qwen3VLConfig,
2071
+ ):
2072
+ llm_pos_ids_list = []
2073
+ st = 0
2074
+ for (
2075
+ offset,
2076
+ llm_grid_h,
2077
+ llm_grid_w,
2078
+ actual_num_tokens,
2079
+ ) in Qwen3VLForConditionalGeneration._iter_mm_grid_hw(
2080
+ input_tokens,
2081
+ mm_features,
2082
+ video_token_id=config.video_token_id,
2083
+ vision_start_token_id=config.vision_start_token_id,
2084
+ vision_end_token_id=config.vision_end_token_id,
2085
+ spatial_merge_size=config.vision_config.spatial_merge_size,
2086
+ ):
2087
+ # Skip frames with 0 tokens (EVS placeholder with tokens lumped elsewhere)
2088
+ if actual_num_tokens == 0:
2089
+ continue
2090
+
2091
+ text_len = offset - st
2092
+ st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
2093
+ llm_pos_ids_list.append(
2094
+ np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx
2095
+ )
2096
+
2097
+ # Check if this is a "lumped placeholder" (all tokens from multiple frames
2098
+ # assigned to the 0-th frame - see
2099
+ # `Qwen3VLMultiModalProcessor.get_video_repl`.
2100
+ expected_tokens_per_frame = llm_grid_h * llm_grid_w
2101
+ if actual_num_tokens > expected_tokens_per_frame:
2102
+ # Lumped placeholder: create grid positions for all "logical" frames
2103
+ # represented.
2104
+ num_logical_frames = actual_num_tokens // expected_tokens_per_frame
2105
+ remainder = actual_num_tokens % expected_tokens_per_frame
2106
+
2107
+ # Create positions for complete frames.
2108
+ for _ in range(num_logical_frames):
2109
+ grid_indices = np.indices((1, llm_grid_h, llm_grid_w)).reshape(
2110
+ 3, -1
2111
+ )
2112
+ llm_pos_ids_list.append(grid_indices + text_len + st_idx)
2113
+ st_idx = llm_pos_ids_list[-1].max() + 1
2114
+ text_len = 0 # No text between frames within the lump
2115
+
2116
+ # Handle remainder tokens if any (partial frame).
2117
+ # NOTE: this should never be the case. Should we have an assert?
2118
+ if remainder > 0:
2119
+ # Create a partial grid - take first 'remainder' positions
2120
+ full_grid = np.indices((1, llm_grid_h, llm_grid_w)).reshape(3, -1)
2121
+ grid_indices = full_grid[:, :remainder]
2122
+ llm_pos_ids_list.append(grid_indices + text_len + st_idx)
2123
+ else:
2124
+ # Normal case: frame has exactly the expected tokens (after actual EVS
2125
+ # pruning).
2126
+ grid_indices = np.indices((1, llm_grid_h, llm_grid_w)).reshape(3, -1)
2127
+ llm_pos_ids_list.append(grid_indices + text_len + st_idx)
2128
+
2129
+ st = offset + actual_num_tokens
2130
+
2131
+ if st < len(input_tokens):
2132
+ st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
2133
+ text_len = len(input_tokens) - st
2134
+ llm_pos_ids_list.append(
2135
+ np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx
2136
+ )
2137
+
2138
+ llm_positions = np.concatenate(llm_pos_ids_list, axis=1).reshape(3, -1)
2139
+ mrope_position_delta = (llm_positions.max() + 1 - len(input_tokens)).item()
2140
+ return torch.from_numpy(llm_positions), mrope_position_delta
2141
+
2142
def recompute_mrope_positions(
2143
self,
2144
input_ids: list[int],
1777
- multimodal_embeddings: tuple[torch.Tensor, ...],
2145
+ multimodal_embeddings: MultiModalEmbeddings,
2146
mrope_positions: torch.LongTensor,
2147
num_computed_tokens: int,
1780
- ) -> tuple[tuple[torch.Tensor, ...], torch.Tensor, int]:
2148
+ ) -> tuple[MultiModalEmbeddings, torch.Tensor, int]:
2149
"""
2150
Update part of input mrope positions (starting with
2151
num_computed_tokens index). Original mrope_positions are computed
@@ -1786,9 +2154,10 @@ class Qwen3VLForConditionalGeneration(
2154
mrope_positions before we feed it to LLM.
2155
2156
Args:
1789
- input_ids: (N,) All input tokens of the prompt (Containing
1790
- entire sequence).
1791
- multimodal_embeddings: Tuple of multimodal embeddings.
2157
+ input_ids: (N,) All input tokens of the prompt containing
2158
+ entire sequence.
2159
+ multimodal_embeddings: Tuple of multimodal embeddings that
2160
+ fits into the prefill chunk that is being processed.
2161
mrope_positions: Existing mrope positions (3, N) for entire
2162
sequence
2163
num_computed_tokens: A number of computed tokens so far.
@@ -1797,10 +2166,26 @@ class Qwen3VLForConditionalGeneration(
2166
Tuple of (multimodal_embeddings, mrope_positions,
2167
mrope_position_delta).
2168
"""
1800
- image_token_id = self.config.image_token_id
1801
- video_token_id = self.config.video_token_id
1802
- vision_start_token_id = self.config.vision_start_token_id
2169
+ return self._recompute_mrope_positions(
2170
+ input_ids=input_ids,
2171
+ multimodal_embeddings=multimodal_embeddings,
2172
+ mrope_positions=mrope_positions,
2173
+ num_computed_tokens=num_computed_tokens,
2174
+ image_token_id=self.config.image_token_id,
2175
+ video_token_id=self.config.video_token_id,
2176
+ vision_start_token_id=self.config.vision_start_token_id,
2177
+ )
2178
2179
+ @staticmethod
2180
+ def _recompute_mrope_positions(
2181
+ input_ids: list[int],
2182
+ multimodal_embeddings: MultiModalEmbeddings,
2183
+ mrope_positions: torch.LongTensor,
2184
+ num_computed_tokens: int,
2185
+ vision_start_token_id: int,
2186
+ image_token_id: int,
2187
+ video_token_id: int,
2188
+ ) -> tuple[MultiModalEmbeddings, torch.Tensor, int]:
2189
# Device
2190
device = (
2191
multimodal_embeddings[0].device
@@ -1811,10 +2196,21 @@ class Qwen3VLForConditionalGeneration(
2196
# Tensors
2197
input_ids_t = torch.as_tensor(input_ids, device=device, dtype=torch.long)
2198
1814
- mm_embeddings_out = [mm[:, :-4] for mm in multimodal_embeddings]
1815
- mm_embeddings_pos = [
1816
- mm[:, -4:].permute(1, 0).long() for mm in multimodal_embeddings
1817
- ]
2199
+ mm_embeddings_out = []
2200
+ mm_embeddings_pos = []
2201
+ # Strip position information from embeddings (last 5 channels)
2202
+ # For Qwen3 VL, handle potentially empty frames (from unpacking)
2203
+ for mm in multimodal_embeddings:
2204
+ if mm.shape[0] > 0: # Only process non-empty frames
2205
+ mm_embeddings_out.append(mm[:, :-5])
2206
+ mm_embeddings_pos.append(mm[:, -5:].permute(1, 0).long())
2207
+ else:
2208
+ # Empty frame - keep as is
2209
+ mm_embeddings_out.append(mm)
2210
+ # Create empty position tensor with correct shape
2211
+ mm_embeddings_pos.append(
2212
+ torch.empty(5, 0, device=device, dtype=torch.long)
2213
+ )
2214
2215
positions, mrope_positions_delta = recompute_mrope_positions(
2216
input_ids_t,
@@ -1828,107 +2224,14 @@ class Qwen3VLForConditionalGeneration(
2224
2225
return tuple(mm_embeddings_out), positions, mrope_positions_delta
2226
1831
- def get_mrope_input_positions(
1832
- self,
1833
- input_tokens: list[int],
1834
- mm_features: list[MultiModalFeatureSpec],
1835
- ) -> tuple[torch.Tensor, int]:
1836
- # Pre-collect actual frame token counts for EVS mode
1837
- frame_token_counts_map = {}
1838
- for mm_feature in mm_features:
1839
- if mm_feature.modality == "video":
1840
- is_evs_enabled = (
1841
- hasattr(self, "video_pruning_rate")
1842
- and self.video_pruning_rate is not None
1843
- and self.video_pruning_rate > 0.0
1844
- )
1845
- if is_evs_enabled:
1846
- t = mm_feature.data["video_grid_thw"].data.tolist()[0]
1847
- token_counts = self._get_actual_frame_token_counts(
1848
- mm_feature.mm_position, t
1849
- )
1850
- assert token_counts is not None, (
1851
- "EVS enabled but failed to extract frame token counts "
1852
- "from is_embed mask"
1853
- )
1854
- frame_token_counts_map[mm_feature.mm_position.offset] = token_counts
1855
-
1856
- llm_pos_ids_list = []
1857
- st = 0
1858
- frame_counts_idx = {}
1859
-
1860
- for offset, llm_grid_h, llm_grid_w in self.iter_mm_grid_hw(
1861
- input_tokens, mm_features
1862
- ):
1863
- text_len = offset - st
1864
- st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
1865
-
1866
- # Determine actual token count for this frame
1867
- base_offset = None
1868
- for feat_offset in frame_token_counts_map:
1869
- if offset >= feat_offset:
1870
- base_offset = feat_offset
1871
-
1872
- if base_offset is not None:
1873
- # EVS mode: use actual token count from is_embed mask
1874
- assert base_offset in frame_token_counts_map, (
1875
- f"Found base_offset {base_offset} but not in frame_token_counts_map"
1876
- )
1877
-
1878
- if base_offset not in frame_counts_idx:
1879
- frame_counts_idx[base_offset] = 0
1880
-
1881
- counts = frame_token_counts_map[base_offset]
1882
- idx = frame_counts_idx[base_offset]
1883
-
1884
- assert idx < len(counts), (
1885
- f"EVS frame index {idx} out of range (total frames: {len(counts)})"
1886
- )
1887
-
1888
- actual_frame_tokens = counts[idx]
1889
- frame_counts_idx[base_offset] += 1
1890
- else:
1891
- # Non-EVS mode (or image): use theoretical grid size
1892
- actual_frame_tokens = llm_grid_h * llm_grid_w
1893
-
1894
- # Add text segment
1895
- text_positions = (
1896
- np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx
1897
- )
1898
- llm_pos_ids_list.append(text_positions)
1899
- st_idx += text_len
1900
-
1901
- # Add frame segment with actual token count (not theoretical)
1902
- grid_indices = np.indices((1, llm_grid_h, llm_grid_w)).reshape(3, -1)
1903
- # Only take the first actual_frame_tokens positions
1904
- frame_positions = grid_indices[:, :actual_frame_tokens] + st_idx
1905
- llm_pos_ids_list.append(frame_positions)
1906
-
1907
- # Update st using actual token count
1908
- st = offset + actual_frame_tokens
1909
-
1910
- # Handle final text segment
1911
- if st < len(input_tokens):
1912
- st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
1913
- text_len = len(input_tokens) - st
1914
- final_text_positions = (
1915
- np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx
1916
- )
1917
- llm_pos_ids_list.append(final_text_positions)
1918
-
1919
- llm_positions = np.concatenate(llm_pos_ids_list, axis=1).reshape(3, -1)
1920
- mrope_position_delta = (llm_positions.max() + 1 - len(input_tokens)).item()
1921
-
1922
- return torch.from_numpy(llm_positions), mrope_position_delta
1923
-
2227
def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings | None:
2228
mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs)
2229
if not mm_input_by_modality:
2230
return None
2231
2232
# The result multimodal_embeddings is tuple of tensors, with each
1930
- # tensor correspoending to a multimodal data item (image or video).
1931
- multimodal_embeddings: tuple[torch.Tensor, ...] = ()
2233
+ # tensor corresponding to a multimodal data item (image or video).
2234
+ multimodal_embeddings: list[torch.Tensor] = []
2235
2236
# NOTE: It is important to iterate over the keys in this dictionary
2237
# to preserve the order of the modalities.
@@ -1936,19 +2239,20 @@ class Qwen3VLForConditionalGeneration(
2239
multimodal_input = mm_input_by_modality[modality]
2240
if modality == "image":
2241
image_embeddings = self._process_image_input(multimodal_input)
1939
- if self.is_multimodal_pruning_enabled:
1940
- image_embeddings = self._postprocess_image_embeds_evs(
1941
- image_embeddings, multimodal_input
1942
- )
1943
- multimodal_embeddings += tuple(image_embeddings)
2242
+ image_embeddings = self._postprocess_image_embeds_evs(
2243
+ image_embeddings, multimodal_input
2244
+ )
2245
+ multimodal_embeddings.extend(image_embeddings)
2246
if modality == "video":
2247
video_embeddings = self._process_video_input(multimodal_input)
2248
if self.is_multimodal_pruning_enabled:
2249
video_embeddings = self._postprocess_video_embeds_evs(
2250
video_embeddings, multimodal_input
2251
)
1950
- multimodal_embeddings += tuple(video_embeddings)
1951
- return multimodal_embeddings
2252
+ multimodal_embeddings.extend(video_embeddings)
2253
+
2254
+ embeddings_tuple = tuple(multimodal_embeddings)
2255
+ return embeddings_tuple
2256
2257
def _compute_deepstack_embeds(
2258
self,
@@ -2128,3 +2432,8 @@ class Qwen3VLForConditionalGeneration(
2432
vision_config = hf_config.vision_config
2433
merge_size = vision_config.spatial_merge_size
2434
return num_vision_tokens // merge_size**2
2435
+
2436
+
2437
+@lru_cache
2438
+def _cached_tensor(x, device) -> torch.Tensor:
2439
+ return torch.tensor(x, device=device)
vllm/model_executor/models/qwen3_vl_moe.py
+2
@@ -45,6 +45,7 @@ from vllm.model_executor.model_loader.weight_utils import (
45
)
46
from vllm.multimodal import MULTIMODAL_REGISTRY
47
from vllm.sequence import IntermediateTensors
48
+from vllm.tokenizers.registry import cached_tokenizer_from_config
49
50
from .interfaces import MixtureOfExperts
51
from .qwen3_moe import (
@@ -415,6 +416,7 @@ class Qwen3VLMoeForConditionalGeneration(
416
multimodal_config = vllm_config.model_config.multimodal_config
417
418
self.config = config
419
+ self._tokenizer = cached_tokenizer_from_config(vllm_config.model_config)
420
self.multimodal_config = multimodal_config
421
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
422
self.video_pruning_rate = multimodal_config.video_pruning_rate
vllm/multimodal/evs.py
+70
-8
@@ -170,9 +170,9 @@ def recompute_mrope_positions(
170
multimodal_embeddings may contain zero, some or even some part of all
171
multimodal_embeddings for a given prompt.
172
173
- Each multimodal_positions has 4 extra channels
174
- (First 3 channels corresponds to original 3 mrope positions, last channel
175
- is the maximum width of the media repeated). Provided multimodal_positions
173
+ Each multimodal_positions has 4 or 5 extra channels
174
+ (first 3 channels correspond to the original 3 mrope positions;
175
+ remaining channels vary by model — see below). Provided multimodal_positions
176
do not reflect location of media position in sequence - they are computed
177
like the media is in the 0-th position in the sequence.
178
@@ -186,6 +186,16 @@ def recompute_mrope_positions(
186
Args:
187
input_ids: (N,) All input tokens of the prompt (entire sequence).
188
multimodal_positions: List of mrope positions for each media.
189
+ If a given element is of shape (4, N), it is assumed to only describe
190
+ positions for video / image embeddings. This is the case of e.g. Qwen2.5 VL,
191
+ where each multimodal input is a contiguous chunk of embeddings.
192
+ The expected channels are [t, h, w, max_width].
193
+ If it is of shape (5, N), it is assumed to possibly describe positions for
194
+ both video / image embeddings, as well as text embeddings. This is the case
195
+ of e.g. Qwen3 VL, where each video inputs are comprised of individual
196
+ frames' embeddings, interleaved with embeddings for timestamp tokens,
197
+ and vision start / end tokens. The expected channels are
198
+ [t, h, w, is_vision_start, is_vision].
199
mrope_positions: Existing mrope positions (4, N) for entire sequence.
200
num_computed_tokens: A number of computed tokens so far.
201
vision_start_token_id: Token indicating start of vision media.
@@ -233,6 +243,21 @@ def recompute_mrope_positions(
243
# - Current prefill chunk has no vision start indexes at all
244
# - Vision start token appeared in previous prefill round
245
# - Regular case
246
+ has_video_tokens = False
247
+ num_timestamp_tokens = 0
248
+ if mm_pos.shape[0] == 5 and mm_pos.shape[1] > 0:
249
+ # mm_pos[4, :] indicates which positions are for video embeddings.
250
+ # If there are no video embeddings, skip timestamp adjustment.
251
+ has_video_tokens = torch.any(mm_pos[4, :]).item()
252
+ if has_video_tokens:
253
+ # Channel 3 flags VISION_START tokens. Timestamp tokens
254
+ # precede the first VISION_START, so its index gives us the
255
+ # exact timestamp count. This is robust even when early
256
+ # frames have all their video tokens pruned (which would
257
+ # push argmax(channel 4) far into a later frame).
258
+ first_vs = (mm_pos[3, :] == 1).nonzero(as_tuple=True)[0]
259
+ num_timestamp_tokens = first_vs[0].item() if len(first_vs) > 0 else 0
260
+
261
seen_vision_start_indices = vision_start_indices[
262
vision_start_indices < num_computed_tokens
263
]
@@ -249,6 +274,18 @@ def recompute_mrope_positions(
274
in_the_middle_of_media = (
275
seen_mm_tokens > seem_mm_tokens_before_last_vision_start
276
)
277
+ # For Qwen3 VL, we can be inside a media segment even before any
278
+ # video tokens appear (timestamp tokens are text). If we've passed
279
+ # the last vision_start token but haven't reached the first video
280
+ # embedding, treat this as "in the middle of media".
281
+ if (
282
+ not in_the_middle_of_media
283
+ and has_video_tokens
284
+ and num_computed_tokens > last_vision_start_token
285
+ and num_computed_tokens
286
+ <= last_vision_start_token + num_timestamp_tokens + 1
287
+ ):
288
+ in_the_middle_of_media = True
289
290
if in_the_middle_of_media:
291
mm_embeddings_seen = (
@@ -274,14 +311,39 @@ def recompute_mrope_positions(
311
mm_embeddings_seen = 0
312
global_mm_start = next_vision_start_token
313
277
- # Offset right after vision_start_token
278
- base = positions[-1, global_mm_start] + 1
279
- local_start = global_mm_start + 1 + mm_embeddings_seen
314
+ # For Qwen3 VL, mm_pos includes timestamp tokens before vision_start
315
+ # when starting a new media. Adjust global_mm_start to point to where
316
+ # the sequence actually begins (before timestamp tokens).
317
+ adjusted_for_timestamps = False
318
+ if mm_pos.shape[0] == 5 and mm_embeddings_seen == 0 and has_video_tokens:
319
+ # NOTE: -1 is because there is a vision start token right after
320
+ # timestamp tokens before any video embeddings appear.
321
+
322
+ # Adjust global_mm_start to point to the first timestamp token
323
+ # instead of the vision_start token.
324
+ global_mm_start -= num_timestamp_tokens
325
+ adjusted_for_timestamps = True
326
+
327
+ # Offset calculation depends on whether we adjusted for timestamp tokens
328
+ if adjusted_for_timestamps:
329
+ # Start from position before the first timestamp token
330
+ base = positions[-1, global_mm_start - 1] + 1
331
+ local_start = global_mm_start + mm_embeddings_seen
332
+ else:
333
+ # Original logic: start after vision_start_token
334
+ base = positions[-1, global_mm_start] + 1
335
+ local_start = global_mm_start + 1 + mm_embeddings_seen
336
+
337
local_end = local_start + mm_pos.shape[1]
338
positions[:, local_start:local_end] = mm_pos[0:3] + base
339
283
- # mm_pos[3, 0] is the max width of the media
284
- offset = mm_pos[3, 0] + base
340
+ # For Qwen3 VL (5-channel), use the maximum position reached across
341
+ # all tokens (both video and text) in all dimensions (t, h, w).
342
+ # For Qwen2.5 VL (4-channel), mm_pos[3, 0] is the max width.
343
+ if mm_pos.shape[0] == 5:
344
+ offset = mm_pos[0:3, :].max() + base + 1
345
+ else:
346
+ offset = mm_pos[3, 0] + base
347
348
text_pos_sum = torch.cumsum(text_mask[local_end:].long(), dim=0)
349