[Feature] Add VidCom2 video token pruning (#47750)
Signed-off-by: Benedikt Falk <bfalk@nvidia.com>
nvbfalk committed
Jul 28, 2026 at 05:20 UTC
60915c972ccf0fa5a1993fef15a8bb667dbaa0f6
17 files changed
+421
-48
docs/design/cuda_graphs_multimodal.md
+1
-1
@@ -101,7 +101,7 @@ When `mm_encoder_tp_mode="data"`, the manager distributes images across TP ranks
101
Following <https://github.com/vllm-project/vllm/pull/35963> (ViT full CUDA graph support for image inference), <https://github.com/vllm-project/vllm/pull/38061> extends the encoder CUDA graph framework to support video inference for Qwen3-VL. Previously, the CUDA graph capture/replay path only handled image inputs (`pixel_values` + `image_grid_thw`). Video inputs use different keys (`pixel_values_videos` + `video_grid_thw`) and require larger `cu_seqlens` buffers because each video item contributes multiple frames (`T` attention sequences). This PR generalizes the protocol and manager to handle both modalities through a single shared graph manager.
102
103
!!! note
104
- Video CUDA graphs are automatically disabled when EVS (Efficient Video Sampling) pruning is enabled, since EVS makes the token count data-dependent and incompatible with CUDA graph capture.
104
+ Video CUDA graphs are automatically disabled when video token pruning (EVS or VidCom2) is enabled, since pruning makes the token count data-dependent and incompatible with CUDA graph capture.
105
106
Mixed inputs (image+video) per prompt are also supported now.
107
docs/features/multimodal_inputs.md
+25
@@ -350,6 +350,31 @@ Instead of NumPy arrays, you can also pass `'torch.Tensor'` instances, as shown
350
351
Full example: [examples/generate/multimodal/vision_language_offline.py](../../examples/generate/multimodal/vision_language_offline.py)
352
353
+#### Video Token Pruning
354
+
355
+For supported models, vLLM can prune video tokens after the vision encoder to
356
+reduce prefill time and KV cache usage, at some cost in accuracy. Set
357
+`--video-pruning-rate <q>` to prune the fraction `q` of video tokens from each
358
+video, and `--video-pruning-method` to choose the training-free algorithm:
359
+
360
+- **`evs`** (Efficient Video Sampling, default): drops the tokens with the
361
+ lowest temporal dissimilarity to the previous frame. The first frame is
362
+ always fully retained.
363
+- **`vidcom2`** (Video Compression Commander): scores tokens by similarity to
364
+ video-level and frame-level feature centers and gives distinctive frames a
365
+ larger share of the budget. At least one token per frame is retained.
366
+
367
+```bash
368
+vllm serve Qwen/Qwen3-VL-8B-Instruct \
369
+ --video-pruning-rate 0.75 --video-pruning-method vidcom2
370
+```
371
+
372
+!!! note
373
+ `evs` is supported by all models implementing multimodal pruning;
374
+ `vidcom2` is currently supported by Qwen3-VL only. Unsupported combinations
375
+ are rejected at startup. Enabling video pruning also disables encoder CUDA
376
+ graphs, since the retained token count becomes data-dependent.
377
+
378
### Audio Inputs
379
380
You can pass a tuple `(array, sampling_rate)` to the `'audio'` field of the multi-modal dictionary.
tests/multimodal/test_vidcom2.py
new
+144
@@ -0,0 +1,144 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+import torch
6
+
7
+from vllm.multimodal.video_prune.vidcom2 import (
8
+ compute_retained_tokens_count,
9
+ compute_retention_mask,
10
+)
11
+
12
+
13
+def _fake_video_embeds(
14
+ num_frames: int,
15
+ rows: int,
16
+ cols: int,
17
+ hidden: int = 64,
18
+ seed: int = 0,
19
+) -> torch.Tensor:
20
+ """Deterministic fake ViT output with a distinct mean per frame."""
21
+ g = torch.Generator().manual_seed(seed)
22
+ frames = []
23
+ for f in range(num_frames):
24
+ base = torch.randn(hidden, generator=g) * (0.1 + 0.05 * f)
25
+ frames.append(
26
+ base[None, :].expand(rows * cols, hidden)
27
+ + 0.01 * torch.randn(rows * cols, hidden, generator=g)
28
+ )
29
+ return torch.cat(frames, dim=0)
30
+
31
+
32
+@pytest.mark.parametrize("q", [0.25, 0.5, 0.75, 0.9])
33
+@pytest.mark.parametrize("num_frames", [1, 4, 16])
34
+def test_mask_shape_and_dtype(q: float, num_frames: int) -> None:
35
+ merge = 2
36
+ rows, cols = 6, 8
37
+ embeds = _fake_video_embeds(num_frames, rows, cols)
38
+ mask = compute_retention_mask(
39
+ embeds,
40
+ (num_frames, rows * merge, cols * merge),
41
+ spatial_merge_size=merge,
42
+ q=q,
43
+ )
44
+ assert mask.dtype == torch.bool
45
+ assert mask.shape == (num_frames * rows * cols,)
46
+
47
+
48
+def test_retained_count_floors_at_one_token_per_frame() -> None:
49
+ """The global minimum is one token per frame (not a full first frame)."""
50
+ assert (
51
+ compute_retained_tokens_count(tokens_per_frame=48, num_frames=4, q=0.999) == 4
52
+ )
53
+ assert (
54
+ compute_retained_tokens_count(tokens_per_frame=48, num_frames=4, q=0.0)
55
+ == 48 * 4
56
+ )
57
+
58
+
59
+@pytest.mark.parametrize("q", [0.25, 0.5, 0.75, 0.9])
60
+@pytest.mark.parametrize("num_frames", [1, 4, 16])
61
+def test_total_retained_matches_target(q: float, num_frames: int) -> None:
62
+ """Mask total must equal the placeholder-sizing helper."""
63
+ merge = 2
64
+ rows, cols = 6, 8
65
+ tpf = rows * cols
66
+ embeds = _fake_video_embeds(num_frames, rows, cols)
67
+ mask = compute_retention_mask(
68
+ embeds,
69
+ (num_frames, rows * merge, cols * merge),
70
+ spatial_merge_size=merge,
71
+ q=q,
72
+ )
73
+ expected = compute_retained_tokens_count(
74
+ tokens_per_frame=tpf, num_frames=num_frames, q=q
75
+ )
76
+ assert int(mask.sum().item()) == expected
77
+
78
+
79
+def test_per_frame_min_one_when_budget_allows() -> None:
80
+ """No frame is fully dropped when the budget allows."""
81
+ merge = 2
82
+ rows, cols = 6, 8
83
+ num_frames = 8
84
+ embeds = _fake_video_embeds(num_frames, rows, cols)
85
+ mask = compute_retention_mask(
86
+ embeds,
87
+ (num_frames, rows * merge, cols * merge),
88
+ spatial_merge_size=merge,
89
+ q=0.25,
90
+ )
91
+ per_frame = mask.view(num_frames, rows * cols).sum(dim=1)
92
+ assert (per_frame >= 1).all(), f"zero-token frame detected: {per_frame.tolist()}"
93
+
94
+
95
+def test_dynamic_per_frame_budget() -> None:
96
+ """A distinctive frame gets more retained tokens than bland ones."""
97
+ merge = 2
98
+ rows, cols = 6, 8
99
+ tpf = rows * cols
100
+ hidden = 64
101
+ torch.manual_seed(0)
102
+ bland = 0.01 * torch.randn(tpf, hidden)
103
+ frames = [torch.randn(tpf, hidden) * 1.0]
104
+ for _ in range(7):
105
+ frames.append(bland + 0.001 * torch.randn(tpf, hidden))
106
+ embeds = torch.cat(frames, dim=0)
107
+ mask = compute_retention_mask(
108
+ embeds,
109
+ (8, rows * merge, cols * merge),
110
+ spatial_merge_size=merge,
111
+ q=0.5,
112
+ )
113
+ per_frame = mask.view(8, tpf).sum(dim=1)
114
+ assert per_frame[0].item() > per_frame[1:].float().mean().item()
115
+
116
+
117
+def test_empty_input_safe() -> None:
118
+ embeds = torch.zeros(0, 32)
119
+ mask = compute_retention_mask(embeds, (0, 0, 0), spatial_merge_size=2, q=0.25)
120
+ assert mask.numel() == 0
121
+
122
+
123
+@pytest.mark.parametrize("q", [0.0, 0.25, 0.5, 0.75])
124
+def test_first_frame_not_privileged(q: float) -> None:
125
+ """A bland first frame is not force-retained (unlike EVS)."""
126
+ merge = 2
127
+ rows, cols = 6, 8
128
+ tpf = rows * cols
129
+ torch.manual_seed(1)
130
+ bland = 0.01 * torch.randn(tpf, 64)
131
+ frames = [bland]
132
+ for f in range(7):
133
+ frames.append(torch.randn(tpf, 64) * (1.0 + 0.1 * f))
134
+ embeds = torch.cat(frames, dim=0)
135
+ mask = compute_retention_mask(
136
+ embeds,
137
+ (8, rows * merge, cols * merge),
138
+ spatial_merge_size=merge,
139
+ q=q,
140
+ )
141
+ per_frame = mask.view(8, tpf).sum(dim=1)
142
+ assert per_frame[0].item() <= tpf
143
+ if q > 0.0:
144
+ assert per_frame[0].item() < int(mask.sum().item())
vllm/config/model.py
+16
@@ -376,6 +376,7 @@ class ModelConfig:
376
interleave_mm_strings: InitVar[bool | None] = None
377
skip_mm_profiling: InitVar[bool | None] = None
378
video_pruning_rate: InitVar[float | None] = None
379
+ video_pruning_method: InitVar[str | None] = None
380
mm_tensor_ipc: InitVar[MMTensorIPC] = None
381
mm_ipc_gpu_memory_gb: InitVar[float | None] = None
382
@@ -504,6 +505,7 @@ class ModelConfig:
505
interleave_mm_strings: bool | None,
506
skip_mm_profiling: bool | None,
507
video_pruning_rate: float | None,
508
+ video_pruning_method: str | None,
509
mm_tensor_ipc: MMTensorIPC,
510
mm_ipc_gpu_memory_gb: float | None,
511
) -> None:
@@ -735,6 +737,7 @@ class ModelConfig:
737
interleave_mm_strings=interleave_mm_strings,
738
skip_mm_profiling=skip_mm_profiling,
739
video_pruning_rate=video_pruning_rate,
740
+ video_pruning_method=video_pruning_method,
741
mm_tensor_ipc=mm_tensor_ipc,
742
mm_ipc_gpu_memory_gb=mm_ipc_gpu_memory_gb,
743
)
@@ -745,6 +748,19 @@ class ModelConfig:
748
749
self.multimodal_config = MultiModalConfig(**mm_config_kwargs) # type: ignore[arg-type]
750
751
+ pruning_spec = self.multimodal_config.get_video_pruning_spec()
752
+ supported_pruning = self._model_info.supported_video_pruning_methods
753
+ if (
754
+ pruning_spec is not None
755
+ and supported_pruning
756
+ and pruning_spec[0] not in supported_pruning
757
+ ):
758
+ raise ValueError(
759
+ f"Video pruning method '{pruning_spec[0]}' is not "
760
+ f"supported by {self._model_info.architecture} "
761
+ f"(supported methods: {supported_pruning})."
762
+ )
763
+
764
if (
765
self.renderer_num_workers > 1
766
and self.multimodal_config.mm_processor_cache_gb > 0
vllm/config/multimodal.py
+17
-4
@@ -61,6 +61,7 @@ class MultiModalDummyOptionsBuiltins(TypedDict, total=False):
61
62
MMEncoderTPMode = Literal["weights", "data"]
63
MMCacheType = Literal["shm", "lru"]
64
+VideoPruningMethod = Literal["evs", "vidcom2"]
65
MMTensorIPC = Literal["direct_rpc", "torch_shm"]
66
MMDummyOptions: TypeAlias = dict[str, BaseDummyOptions]
67
"""
@@ -189,9 +190,14 @@ class MultiModalConfig:
190
estimating the peak memory usage of the activation of multimodal encoder and
191
embedding cache."""
192
video_pruning_rate: float | None = Field(default=None, ge=0.0, lt=1.0)
192
- """Sets pruning rate for video pruning via Efficient Video Sampling.
193
- Value sits in range [0;1) and determines fraction of media tokens
194
- from each video to be pruned.
193
+ """Fraction of video tokens to prune from each video. Value sits in range
194
+ [0;1); pruning is enabled when it is greater than 0. The pruning algorithm
195
+ is selected by `video_pruning_method`.
196
+ """
197
+ video_pruning_method: VideoPruningMethod = "evs"
198
+ """Video token pruning algorithm applied when `video_pruning_rate` > 0:
199
+ - "evs": Efficient Video Sampling.
200
+ - "vidcom2": Video Compression Commander.
201
"""
202
mm_tensor_ipc: MMTensorIPC = "direct_rpc"
203
"""IPC (inter-process communication) method for multimodal tensors.
@@ -360,4 +366,11 @@ class MultiModalConfig:
366
)
367
368
def is_multimodal_pruning_enabled(self):
363
- return self.video_pruning_rate is not None and self.video_pruning_rate > 0
369
+ return self.get_video_pruning_spec() is not None
370
+
371
+ def get_video_pruning_spec(self) -> tuple[VideoPruningMethod, float] | None:
372
+ """Return `(method, rate)` when video pruning is enabled, else None.
373
+ `rate` is the fraction of video tokens to prune."""
374
+ if self.video_pruning_rate is not None and self.video_pruning_rate > 0:
375
+ return (self.video_pruning_method, float(self.video_pruning_rate))
376
+ return None
vllm/engine/arg_utils.py
+6
@@ -586,6 +586,7 @@ class EngineArgs:
586
renderer_num_workers: int = 1
587
skip_mm_profiling: bool = MultiModalConfig.skip_mm_profiling
588
video_pruning_rate: float | None = MultiModalConfig.video_pruning_rate
589
+ video_pruning_method: str = MultiModalConfig.video_pruning_method
590
mm_tensor_ipc: MMTensorIPC = MultiModalConfig.mm_tensor_ipc
591
mm_ipc_gpu_memory_gb: float = MultiModalConfig.mm_ipc_gpu_memory_gb
592
# LoRA fields
@@ -1333,6 +1334,10 @@ class EngineArgs:
1334
multimodal_group.add_argument(
1335
"--video-pruning-rate", **multimodal_kwargs["video_pruning_rate"]
1336
)
1337
+ multimodal_group.add_argument(
1338
+ "--video-pruning-method",
1339
+ **multimodal_kwargs["video_pruning_method"],
1340
+ )
1341
multimodal_group.add_argument(
1342
"--mm-tensor-ipc", **multimodal_kwargs["mm_tensor_ipc"]
1343
)
@@ -1715,6 +1720,7 @@ class EngineArgs:
1720
override_attention_dtype=self.override_attention_dtype,
1721
logits_processors=self.logits_processors,
1722
video_pruning_rate=self.video_pruning_rate,
1723
+ video_pruning_method=self.video_pruning_method,
1724
mm_tensor_ipc=self.mm_tensor_ipc,
1725
mm_ipc_gpu_memory_gb=self.mm_ipc_gpu_memory_gb,
1726
io_processor_plugin=self.io_processor_plugin,
vllm/model_executor/models/interfaces.py
+8
@@ -41,6 +41,7 @@ if TYPE_CHECKING:
41
SpeechToTextParams,
42
VllmConfig,
43
)
44
+ from vllm.config.multimodal import VideoPruningMethod
45
from vllm.inputs import PromptType, TokensPrompt
46
from vllm.lora.model_manager import LoRAModelManager
47
from vllm.model_executor.layers.fused_moe import MoERunner
@@ -424,6 +425,13 @@ class SupportsMultiModalPruning(Protocol):
425
426
supports_multimodal_pruning: ClassVar[Literal[True]] = True
427
428
+ supported_video_pruning_methods: ClassVar[tuple["VideoPruningMethod", ...]] = (
429
+ "evs",
430
+ )
431
+ """Video pruning methods (as reported by
432
+ `MultiModalConfig.get_video_pruning_spec`) implemented by this model.
433
+ Models supporting methods beyond EVS should override this."""
434
+
435
def recompute_mrope_positions(
436
self,
437
input_ids: list[int] | torch.Tensor,
vllm/model_executor/models/interns1_pro.py
+1
-4
@@ -570,10 +570,7 @@ class InternS1ProForConditionalGeneration(
570
self.config = config
571
self.multimodal_config = multimodal_config
572
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
573
- self.video_pruning_rate = multimodal_config.video_pruning_rate
574
- self.is_multimodal_pruning_enabled = (
575
- multimodal_config.is_multimodal_pruning_enabled()
576
- )
573
+ self._init_video_pruning(multimodal_config)
574
575
with self._mark_tower_model(vllm_config, {"image", "video"}):
576
self.visual = Qwen3_VisionTransformer(
vllm/model_executor/models/nano_nemotron_vl.py
+4
-4
@@ -42,10 +42,6 @@ from vllm.model_executor.models.utils import (
42
maybe_prefix,
43
)
44
from vllm.multimodal import MULTIMODAL_REGISTRY
45
-from vllm.multimodal.evs import (
46
- compute_retained_tokens_count,
47
- compute_retention_mask,
48
-)
45
from vllm.multimodal.inputs import (
46
AudioItem,
47
BatchedTensorInputs,
@@ -74,6 +70,10 @@ from vllm.multimodal.processing.processor import (
70
PromptReplacement,
71
PromptUpdate,
72
)
73
+from vllm.multimodal.video_prune.evs import (
74
+ compute_retained_tokens_count,
75
+ compute_retention_mask,
76
+)
77
from vllm.renderers import TokenizeParams
78
from vllm.sequence import IntermediateTensors
79
from vllm.tokenizers import cached_tokenizer_from_config
vllm/model_executor/models/qwen2_5_vl.py
+6
-6
@@ -67,12 +67,6 @@ from vllm.model_executor.layers.rotary_embedding.common import (
67
)
68
from vllm.model_executor.models.module_mapping import MultiModelKeys
69
from vllm.multimodal import MULTIMODAL_REGISTRY
70
-from vllm.multimodal.evs import (
71
- compute_mrope_for_media,
72
- compute_retained_tokens_count,
73
- compute_retention_mask,
74
- recompute_mrope_positions,
75
-)
70
from vllm.multimodal.inputs import (
71
MultiModalFeatureSpec,
72
MultiModalFieldConfig,
@@ -80,6 +74,12 @@ from vllm.multimodal.inputs import (
74
)
75
from vllm.multimodal.parse import MultiModalDataItems
76
from vllm.multimodal.processing import PromptReplacement, PromptUpdate
77
+from vllm.multimodal.video_prune.evs import (
78
+ compute_mrope_for_media,
79
+ compute_retained_tokens_count,
80
+ compute_retention_mask,
81
+ recompute_mrope_positions,
82
+)
83
from vllm.platforms import current_platform
84
from vllm.sequence import IntermediateTensors
85
from vllm.utils.tensor_schema import TensorSchema, TensorShape
vllm/model_executor/models/qwen3_vl.py
+61
-24
@@ -50,7 +50,12 @@ from transformers.video_utils import VideoMetadata
50
51
from vllm.compilation.decorators import support_torch_compile
52
from vllm.config import VllmConfig
53
-from vllm.config.multimodal import BaseDummyOptions, VideoDummyOptions
53
+from vllm.config.multimodal import (
54
+ BaseDummyOptions,
55
+ MultiModalConfig,
56
+ VideoDummyOptions,
57
+ VideoPruningMethod,
58
+)
59
from vllm.distributed import get_pp_group, parallel_state
60
from vllm.inputs import MultiModalDataDict
61
from vllm.logger import init_logger
@@ -69,12 +74,6 @@ from vllm.model_executor.layers.rotary_embedding import get_rope
74
from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
75
from vllm.model_executor.models.module_mapping import MultiModelKeys
76
from vllm.multimodal import MULTIMODAL_REGISTRY
72
-from vllm.multimodal.evs import (
73
- compute_mrope_for_media,
74
- compute_retained_tokens_count,
75
- compute_retention_mask,
76
- recompute_mrope_positions,
77
-)
77
from vllm.multimodal.inputs import (
78
MultiModalFeatureSpec,
79
MultiModalFieldConfig,
@@ -92,6 +91,18 @@ from vllm.multimodal.processing import (
91
PromptUpdate,
92
PromptUpdateDetails,
93
)
94
+from vllm.multimodal.video_prune.evs import (
95
+ compute_mrope_for_media,
96
+ compute_retained_tokens_count,
97
+ compute_retention_mask,
98
+ recompute_mrope_positions,
99
+)
100
+from vllm.multimodal.video_prune.vidcom2 import (
101
+ compute_retained_tokens_count as vidcom2_compute_retained_tokens_count,
102
+)
103
+from vllm.multimodal.video_prune.vidcom2 import (
104
+ compute_retention_mask as vidcom2_compute_retention_mask,
105
+)
106
from vllm.sequence import IntermediateTensors
107
from vllm.tokenizers.protocol import TokenizerLike
108
from vllm.tokenizers.registry import cached_tokenizer_from_config
@@ -1256,7 +1267,7 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1267
hf_config = self.info.get_hf_config()
1268
tokenizer = self.info.get_tokenizer()
1269
merge_size = hf_config.vision_config.spatial_merge_size
1259
- video_pruning_rate = self.info.ctx.get_mm_config().video_pruning_rate
1270
+ pruning_spec = self.info.ctx.get_mm_config().get_video_pruning_spec()
1271
vision_start_token_id = hf_config.vision_start_token_id
1272
vision_end_token_id = hf_config.vision_end_token_id
1273
video_token_id = hf_config.video_token_id
@@ -1339,11 +1350,18 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1350
merge_size**2
1351
)
1352
1342
- if video_pruning_rate is not None and video_pruning_rate > 0.0:
1343
- num_tokens = compute_retained_tokens_count(
1353
+ # Apply video pruning (EVS or VidCom2) if enabled.
1354
+ if pruning_spec is not None:
1355
+ method, prune_q = pruning_spec
1356
+ count_fn = (
1357
+ vidcom2_compute_retained_tokens_count
1358
+ if method == "vidcom2"
1359
+ else compute_retained_tokens_count
1360
+ )
1361
+ num_tokens = count_fn(
1362
tokens_per_frame=tokens_per_frame_base,
1363
num_frames=num_frames,
1346
- q=video_pruning_rate,
1364
+ q=prune_q,
1365
)
1366
tokens_per_frame = [num_tokens] + [0] * (num_frames - 1)
1367
select_token_id = False
@@ -1459,16 +1477,22 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1477
f"video length ({grid_thw[0]})."
1478
)
1479
1462
- # Compute tokens per frame, with EVS support
1480
+ # Compute tokens per frame, with EVS / VidCom2 support
1481
num_frames = int(grid_thw[0])
1482
tokens_per_frame_base = int(grid_thw[1:].prod()) // merge_length
1483
1466
- video_pruning_rate = self.info.ctx.get_mm_config().video_pruning_rate
1467
- if video_pruning_rate is not None and video_pruning_rate > 0.0:
1468
- num_tokens = compute_retained_tokens_count(
1484
+ pruning_spec = self.info.ctx.get_mm_config().get_video_pruning_spec()
1485
+ if pruning_spec is not None:
1486
+ method, prune_q = pruning_spec
1487
+ count_fn = (
1488
+ vidcom2_compute_retained_tokens_count
1489
+ if method == "vidcom2"
1490
+ else compute_retained_tokens_count
1491
+ )
1492
+ num_tokens = count_fn(
1493
tokens_per_frame=tokens_per_frame_base,
1494
num_frames=num_frames,
1471
- q=video_pruning_rate,
1495
+ q=prune_q,
1496
)
1497
tokens_per_frame = [num_tokens] + [0] * (num_frames - 1)
1498
select_token_id = False
@@ -1701,6 +1725,8 @@ class Qwen3VLForConditionalGeneration(
1725
1726
supports_encoder_tp_data = True
1727
1728
+ supported_video_pruning_methods = ("evs", "vidcom2")
1729
+
1730
# To ensure correct weight loading and mapping.
1731
hf_to_vllm_mapper = WeightsMapper(
1732
orig_to_new_prefix={
@@ -1719,6 +1745,17 @@ class Qwen3VLForConditionalGeneration(
1745
1746
raise ValueError("Only image or video modality is supported")
1747
1748
+ def _init_video_pruning(self, multimodal_config: MultiModalConfig) -> None:
1749
+ pruning_spec = multimodal_config.get_video_pruning_spec()
1750
+ if pruning_spec is None:
1751
+ self.video_pruning_method: VideoPruningMethod | None = None
1752
+ self.video_pruning_rate = multimodal_config.video_pruning_rate
1753
+ else:
1754
+ self.video_pruning_method, self.video_pruning_rate = pruning_spec
1755
+ self.is_multimodal_pruning_enabled = (
1756
+ multimodal_config.is_multimodal_pruning_enabled()
1757
+ )
1758
+
1759
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "model"):
1760
super().__init__()
1761
config: Qwen3VLConfig = vllm_config.model_config.hf_config
@@ -1730,10 +1767,7 @@ class Qwen3VLForConditionalGeneration(
1767
self._tokenizer = cached_tokenizer_from_config(vllm_config.model_config)
1768
self.multimodal_config = multimodal_config
1769
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
1733
- self.video_pruning_rate = multimodal_config.video_pruning_rate
1734
- self.is_multimodal_pruning_enabled = (
1735
- multimodal_config.is_multimodal_pruning_enabled()
1736
- )
1770
+ self._init_video_pruning(multimodal_config)
1771
1772
self.use_deepstack = hasattr(config.vision_config, "deepstack_visual_indexes")
1773
self.deepstack_num_level = (
@@ -1848,7 +1882,7 @@ class Qwen3VLForConditionalGeneration(
1882
EncoderCudaGraphConfig,
1883
)
1884
1851
- # When EVS pruning is enabled, embed_multimodal post-processes both
1885
+ # When video pruning is enabled, embed_multimodal post-processes both
1886
# image and video embeddings (mrope positions are appended for image,
1887
# prune+append for video). The encoder CUDA graph path bypasses that
1888
# post-process, producing inconsistent embedding formats vs eager. So
@@ -2291,9 +2325,12 @@ class Qwen3VLForConditionalGeneration(
2325
2326
t, h, w = size
2327
if self.is_multimodal_pruning_enabled:
2294
- # For each video, compute retention mask using EVS.
2295
- # retention_mask: [11424].
2296
- retention_mask = compute_retention_mask(
2328
+ # Compute the retention mask for each video (EVS or VidCom2).
2329
+ if self.video_pruning_method == "vidcom2":
2330
+ mask_fn = vidcom2_compute_retention_mask
2331
+ else:
2332
+ mask_fn = compute_retention_mask
2333
+ retention_mask = mask_fn(
2334
emb,
2335
size,
2336
spatial_merge_size=self.visual.spatial_merge_size,
vllm/model_executor/models/qwen3_vl_moe.py
+1
-4
@@ -220,10 +220,7 @@ class Qwen3VLMoeForConditionalGeneration(
220
self._tokenizer = cached_tokenizer_from_config(vllm_config.model_config)
221
self.multimodal_config = multimodal_config
222
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
223
- self.video_pruning_rate = multimodal_config.video_pruning_rate
224
- self.is_multimodal_pruning_enabled = (
225
- multimodal_config.is_multimodal_pruning_enabled()
226
- )
223
+ self._init_video_pruning(multimodal_config)
224
225
self.use_deepstack = hasattr(config.vision_config, "deepstack_visual_indexes")
226
self.deepstack_num_level = (
vllm/model_executor/models/registry.py
+4
@@ -797,6 +797,7 @@ class _ModelInfo:
797
supports_replayssm: bool
798
supports_transcription: bool
799
supports_transcription_only: bool
800
+ supported_video_pruning_methods: tuple[str, ...]
801
802
@staticmethod
803
def from_model_cls(model: type[nn.Module]) -> "_ModelInfo":
@@ -827,6 +828,9 @@ class _ModelInfo:
828
supports_transcription(model) and model.supports_transcription_only
829
),
830
has_noops=has_noops(model),
831
+ supported_video_pruning_methods=getattr(
832
+ model, "supported_video_pruning_methods", ()
833
+ ),
834
)
835
836
vllm/multimodal/video_prune/__init__.py
new
+2
@@ -0,0 +1,2 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/multimodal/video_prune/evs.py
renamed
vllm/multimodal/video_prune/vidcom2.py
new
+124
@@ -0,0 +1,124 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+# VidCom2 (Video Compression Commander) video token pruning.
5
+# Liu et al., EMNLP 2025 — https://arxiv.org/abs/2505.14454
6
+# Adapted from the reference implementation:
7
+# https://github.com/xuyang-liu16/VidCom2 (Apache-2.0,
8
+# Copyright (c) 2025 the VidCom2 authors).
9
+
10
+import torch
11
+import torch.nn.functional as F
12
+
13
+# Multi-scale Gaussian bandwidths from the reference implementation.
14
+_ALPHAS: tuple[float, ...] = tuple(2.0**k for k in range(-3, 2))
15
+_LOW_VAR_CHANNEL_RATIO: float = 0.5
16
+_SOFTMAX_TEMPERATURE: float = 0.01
17
+
18
+
19
+def compute_retained_tokens_count(
20
+ tokens_per_frame: int, num_frames: int, q: float
21
+) -> int:
22
+ """Number of video tokens retained after VidCom2 pruning.
23
+
24
+ The target is `(1 - q) * total_tokens`, i.e. a retention ratio of
25
+ `1 - q` averaged across frames. Because the per-frame budget is floored
26
+ at one token, the global minimum is `num_frames` (one token per frame).
27
+ """
28
+ total_tokens = tokens_per_frame * num_frames
29
+ base_num = int(total_tokens * (1.0 - q))
30
+ return max(num_frames, min(base_num, total_tokens))
31
+
32
+
33
+def compute_retention_mask(
34
+ video_embeds: torch.Tensor,
35
+ video_size_thw: torch.LongTensor | tuple[int, int, int],
36
+ spatial_merge_size: int,
37
+ q: float,
38
+) -> torch.Tensor:
39
+ """Compute the VidCom2 retention mask for a single video.
40
+
41
+ Args:
42
+ video_embeds: `(T*H*W/merge^2, hidden_size)` post-ViT token features.
43
+ video_size_thw: `(T, H, W)` grid dimensions.
44
+ spatial_merge_size: ViT spatial merge factor (e.g. 2).
45
+ q: Pruning fraction in `[0, 1)`; retention ratio is `1 - q`.
46
+
47
+ Returns:
48
+ Flat bool tensor of shape `(T*H*W/merge^2,)`, True for retained
49
+ tokens. The True count equals `compute_retained_tokens_count` so
50
+ placeholders sized at prompt-processing time match exactly.
51
+ """
52
+ T, H, W = map(int, video_size_thw)
53
+ rows = H // spatial_merge_size
54
+ cols = W // spatial_merge_size
55
+ tokens_per_frame = rows * cols
56
+ total_tokens = T * tokens_per_frame
57
+
58
+ device = video_embeds.device
59
+ if tokens_per_frame == 0 or total_tokens == 0:
60
+ return torch.ones(0, dtype=torch.bool, device=device)
61
+
62
+ target_retained = compute_retained_tokens_count(
63
+ tokens_per_frame=tokens_per_frame, num_frames=T, q=q
64
+ )
65
+ target_retained = min(target_retained, total_tokens)
66
+
67
+ # 1. Score in the lowest-variance half of channels.
68
+ variances = video_embeds.var(dim=0, unbiased=False)
69
+ k_channels = max(1, int(video_embeds.size(-1) * _LOW_VAR_CHANNEL_RATIO))
70
+ _, low_var_idx = torch.topk(variances, k=k_channels, largest=False)
71
+ sel = video_embeds.index_select(-1, low_var_idx)
72
+
73
+ # 2. Multi-scale Gaussian similarity to video and per-frame centers.
74
+ frames = sel.view(T, tokens_per_frame, sel.size(-1))
75
+ frames = F.normalize(frames, dim=-1)
76
+ vid_center = frames.mean(dim=(0, 1), keepdim=True) # (1, 1, C)
77
+ frame_center = frames.mean(dim=1, keepdim=True) # (T, 1, C)
78
+ v_score = _multi_scale_gaussian(frames, vid_center)
79
+ f_score = _multi_scale_gaussian(frames, frame_center)
80
+ # Higher similarity = more redundant; lowest-similarity tokens are kept.
81
+ similarity = v_score + f_score # (T, tpf)
82
+
83
+ # 3. Per-frame dynamic budget: distinctive frames get a larger share.
84
+ base = 1.0 - q
85
+ frame_scores = -v_score.mean(dim=-1) # (T,)
86
+ probs = F.softmax((frame_scores - frame_scores.max()) / _SOFTMAX_TEMPERATURE, dim=0)
87
+ scales = (base * (1.0 + probs - probs.mean())).clamp(max=1.0)
88
+ ks = (scales * tokens_per_frame).round().long().clamp(min=1, max=tokens_per_frame)
89
+
90
+ # 4. Retain the smallest-similarity tokens per frame.
91
+ mask_2d = torch.zeros(T, tokens_per_frame, dtype=torch.bool, device=device)
92
+ for i in range(T):
93
+ k_i = int(ks[i].item())
94
+ if k_i <= 0:
95
+ continue
96
+ _, idx = torch.topk(similarity[i], k=k_i, largest=False, sorted=False)
97
+ mask_2d[i].scatter_(0, idx, True)
98
+
99
+ # 5. Reconcile rounding/clamp drift to the exact target count by score.
100
+ flat_mask = mask_2d.view(-1)
101
+ flat_sim = similarity.view(-1)
102
+ current = int(flat_mask.sum().item())
103
+ if current > target_retained:
104
+ drop_n = current - target_retained
105
+ retained_idx = flat_mask.nonzero(as_tuple=False).squeeze(-1)
106
+ retained_sim = flat_sim[retained_idx]
107
+ _, worst = torch.topk(retained_sim, k=drop_n, largest=True, sorted=False)
108
+ flat_mask[retained_idx[worst]] = False
109
+ elif current < target_retained:
110
+ add_n = target_retained - current
111
+ available_idx = (~flat_mask).nonzero(as_tuple=False).squeeze(-1)
112
+ if available_idx.numel() > 0:
113
+ available_sim = flat_sim[available_idx]
114
+ add_n = min(add_n, available_idx.numel())
115
+ _, best = torch.topk(available_sim, k=add_n, largest=False, sorted=False)
116
+ flat_mask[available_idx[best]] = True
117
+
118
+ return flat_mask
119
+
120
+
121
+def _multi_scale_gaussian(x: torch.Tensor, center: torch.Tensor) -> torch.Tensor:
122
+ """Sum Gaussian kernels over `_ALPHAS`; `(T, N, C) -> (T, N)` scores."""
123
+ dist_sq = ((x - center) ** 2).sum(dim=-1)
124
+ return sum(torch.exp(-dist_sq / (2.0 * a)) for a in _ALPHAS)
vllm/transformers_utils/processors/nano_nemotron_vl.py
+1
-1
@@ -23,9 +23,9 @@ from PIL import Image
23
from transformers import BatchFeature, PretrainedConfig, TensorType
24
25
from vllm.model_executor.models.parakeet import ParakeetExtractor
26
-from vllm.multimodal.evs import compute_retained_tokens_count
26
from vllm.multimodal.inputs import AudioItem
27
from vllm.multimodal.processing.processor import PromptUpdateDetails
28
+from vllm.multimodal.video_prune.evs import compute_retained_tokens_count
29
from vllm.tokenizers.hf import HfTokenizer
30
31
from .internvl import calculate_internvl_targets, get_internvl_target_ratios