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

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

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

Synced Nov 11, 12:30 UTC

[Feature]Add EVS (Efficient Video Sampling) Support for Qwen3-VL (#29752)

Signed-off-by: zitian.zhao <zitian.zhao@tencentmusic.com> Co-authored-by: deitxfge <huhaibo1990@126.com>

ZiTian Zhao committed Dec 14, 2025 at 21:24 UTC ae88aada38eca50f6b7e3c9caf2ac410e76964c9
1 file changed +424 -12
vllm/model_executor/models/qwen3_vl.py
+424 -12
@@ -67,12 +67,19 @@ from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
67 from vllm.model_executor.model_loader.weight_utils import default_weight_loader
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 +)
76 from vllm.multimodal.inputs import (
77 MultiModalDataDict,
78 MultiModalFeatureSpec,
79 MultiModalFieldConfig,
80 MultiModalKwargsItem,
81 MultiModalKwargsItems,
82 + PlaceholderRange,
83 VideoItem,
84 )
85 from vllm.multimodal.parse import ImageSize, MultiModalDataItems, MultiModalDataParser
@@ -92,6 +99,7 @@ from .interfaces import (
99 SupportsLoRA,
100 SupportsMRoPE,
101 SupportsMultiModal,
102 + SupportsMultiModalPruning,
103 SupportsPP,
104 _require_is_multimodal,
105 )
@@ -1043,13 +1051,39 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1051 tokenizer.encode(f"<{curr_time:.1f} seconds>", add_special_tokens=False)
1052 for curr_time in timestamps
1053 ]
1046 - num_tokens_per_frame = int(grid_thw[1:].prod()) // merge_length
1054 + tokens_per_frame = int(grid_thw[1:].prod()) // merge_length
1055 + per_frame_token_counts = [tokens_per_frame for _ in frames_idx_token]
1056 +
1057 + video_pruning_rate = self.info.ctx.get_mm_config().video_pruning_rate
1058 + if video_pruning_rate is not None and video_pruning_rate > 0.0:
1059 + total_retained = compute_retained_tokens_count(
1060 + tokens_per_frame,
1061 + len(frames_idx_token),
1062 + video_pruning_rate,
1063 + )
1064 + if len(frames_idx_token) == 0:
1065 + per_frame_token_counts = []
1066 + elif len(frames_idx_token) == 1:
1067 + per_frame_token_counts = [tokens_per_frame]
1068 + else:
1069 + first_frame_tokens = tokens_per_frame
1070 + remaining_tokens = max(total_retained - first_frame_tokens, 0)
1071 + base = remaining_tokens // (len(frames_idx_token) - 1)
1072 + remainder = remaining_tokens % (len(frames_idx_token) - 1)
1073 + per_frame_token_counts = [first_frame_tokens]
1074 + for frame_idx in range(1, len(frames_idx_token)):
1075 + extra = base + (1 if (frame_idx - 1) < remainder else 0)
1076 + per_frame_token_counts.append(extra)
1077 +
1078 placeholder = []
1048 - for frame_idx in frames_idx_token:
1049 - placeholder.extend(frame_idx)
1079 + for frame_idx, timestamp_tokens in enumerate(frames_idx_token):
1080 + placeholder.extend(timestamp_tokens)
1081 + tokens_this_frame = per_frame_token_counts[
1082 + frame_idx if frame_idx < len(per_frame_token_counts) else -1
1083 + ]
1084 placeholder.extend(
1085 [vision_start_token_id]
1052 - + [video_token_id] * num_tokens_per_frame
1086 + + [video_token_id] * tokens_this_frame
1087 + [vision_end_token_id]
1088 )
1089 return PromptUpdateDetails.select_token_id(placeholder, video_token_id)
@@ -1190,6 +1224,7 @@ class Qwen3VLForConditionalGeneration(
1224 SupportsPP,
1225 SupportsMRoPE,
1226 SupportsEagle3,
1227 + SupportsMultiModalPruning,
1228 ):
1229 packed_modules_mapping = {
1230 "qkv_proj": [
@@ -1232,6 +1267,11 @@ class Qwen3VLForConditionalGeneration(
1267 self.config = config
1268 self.multimodal_config = multimodal_config
1269 self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
1270 + self.video_pruning_rate = multimodal_config.video_pruning_rate
1271 + self.is_multimodal_pruning_enabled = (
1272 + multimodal_config.is_multimodal_pruning_enabled()
1273 + )
1274 +
1275 if not multimodal_config.get_limit_per_prompt(
1276 "image"
1277 ) and not multimodal_config.get_limit_per_prompt("video"):
@@ -1418,6 +1458,109 @@ class Qwen3VLForConditionalGeneration(
1458 sizes = (grid_thw.prod(-1) // merge_size // merge_size).tolist()
1459 return video_embeds.split(sizes)
1460
1461 + def _postprocess_image_embeds_evs(
1462 + self,
1463 + image_embeds_split: tuple[torch.Tensor, ...],
1464 + image_input: Qwen2_5_VLImageInputs,
1465 + ) -> tuple[torch.Tensor, ...]:
1466 + """
1467 + Append mrope positions for each for images.
1468 + This is necessary to recover correct mrope
1469 + positions after video pruning
1470 +
1471 + Args:
1472 + image_embeds_split: Tuple of image embeddings for
1473 + each image item.
1474 + image_input: Image input data.
1475 +
1476 + Returns:
1477 + Tuple of image embeddings for each image item.
1478 + Resulting embeddings will have extra 4 channels for
1479 + computed mrope positions.
1480 + """
1481 + merge_size = self.visual.spatial_merge_size
1482 + grid_thw = image_input["image_grid_thw"]
1483 + grid_thw_list = grid_thw.tolist()
1484 + image_embeds_out = []
1485 + for emb, size in zip(image_embeds_split, grid_thw_list):
1486 + positions = compute_mrope_for_media(size, merge_size).to(emb.device)
1487 + emb = torch.cat([emb, positions], dim=1)
1488 + image_embeds_out.append(emb)
1489 + image_embeds_split = image_embeds_out
1490 + return tuple(image_embeds_split)
1491 +
1492 + def _postprocess_video_embeds_evs(
1493 + self,
1494 + video_embeds_split: tuple[torch.Tensor, ...],
1495 + video_input: Qwen2_5_VLVideoInputs,
1496 + ) -> tuple[torch.Tensor, ...]:
1497 + """
1498 + Prunes video embeddings via Efficient Video Sampling (EVS)
1499 + and then appends mrope positions for each retained embeddings
1500 +
1501 + Args:
1502 + video_embeds_split: Tuple of video embeddings for each video item.
1503 + video_input: Video input data.
1504 +
1505 + Returns:
1506 + Tuple of video embeddings for each video item.
1507 + Resulting embeddings will have extra 4 channels for
1508 + computed mrope positions.
1509 + """
1510 + grid_thw = video_input["video_grid_thw"]
1511 + assert grid_thw.ndim == 2
1512 + grid_thw_list = grid_thw.tolist()
1513 + merge_size = self.visual.spatial_merge_size
1514 +
1515 + # Cast to long to match the original code
1516 + # https://github.com/huggingface/transformers/blob/41980ce93e775f6c88500c51c8db7946fc6a2add/src/transformers/models/qwen2_5_vl/modular_qwen2_5_vl.py#L491 # noqa
1517 + second_per_grid_ts = video_input.get("second_per_grid_ts")
1518 + if second_per_grid_ts is None:
1519 + # For Qwen3-VL, second_per_grid_ts might not be available
1520 + # Use default value of 1.0 for each video
1521 + second_per_grid_ts = torch.ones(len(grid_thw_list), dtype=torch.long)
1522 + else:
1523 + second_per_grid_ts = second_per_grid_ts.long()
1524 + tokens_per_second = getattr(self.config.vision_config, "tokens_per_second", 1.0)
1525 +
1526 + video_embeds_out = []
1527 + for emb, size, video_second_per_grid_t in zip(
1528 + video_embeds_split, grid_thw_list, second_per_grid_ts
1529 + ):
1530 + # For each video, we compute retention mask using EVS
1531 + retention_mask = compute_retention_mask(
1532 + emb,
1533 + size,
1534 + spatial_merge_size=self.visual.spatial_merge_size,
1535 + q=self.video_pruning_rate,
1536 + )
1537 +
1538 + # Debug logging for EVS pruning
1539 + logger.debug(
1540 + "EVS: Video tokens pruned from %d to %d (T=%d,H=%d,W=%d, "
1541 + "pruning_rate=%.2f, reduction=%.1f%%)",
1542 + emb.shape[0],
1543 + retention_mask.sum().item(),
1544 + size[0],
1545 + size[1],
1546 + size[2],
1547 + self.video_pruning_rate,
1548 + (1 - retention_mask.float().mean().item()) * 100,
1549 + )
1550 +
1551 + positions = compute_mrope_for_media(
1552 + size,
1553 + merge_size,
1554 + tokens_per_second=tokens_per_second,
1555 + video_second_per_grid=video_second_per_grid_t.item(),
1556 + ).to(emb.device)
1557 +
1558 + emb = emb[retention_mask]
1559 + positions = positions[retention_mask]
1560 + emb = torch.cat([emb, positions], dim=1)
1561 + video_embeds_out.append(emb)
1562 + return tuple(video_embeds_out)
1563 +
1564 def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict:
1565 mm_input_by_modality = {}
1566 for input_key in kwargs:
@@ -1440,6 +1583,20 @@ class Qwen3VLForConditionalGeneration(
1583 def iter_mm_grid_hw(
1584 self, input_tokens: list[int], mm_features: list[MultiModalFeatureSpec]
1585 ) -> Iterator[tuple[int, int, int]]:
1586 + """
1587 + Iterate over multimodal features and yield grid information.
1588 +
1589 + For videos with EVS (Efficient Video Sampling) enabled, this function
1590 + computes the offset based on the pruned token count rather than relying
1591 + on input_tokens.index(), which would fail when tokens are pruned.
1592 +
1593 + Args:
1594 + input_tokens: List of token IDs in the prompt
1595 + mm_features: List of multimodal feature specifications
1596 +
1597 + Yields:
1598 + Tuple of (offset, grid_h, grid_w) for each frame/image
1599 + """
1600 video_token_id = self.config.video_token_id
1601 spatial_merge_size = self.config.vision_config.spatial_merge_size
1602 for mm_feature in sorted(mm_features, key=lambda f: f.mm_position.offset):
@@ -1452,42 +1609,289 @@ class Qwen3VLForConditionalGeneration(
1609 t, h, w = mm_feature.data["video_grid_thw"].data.tolist()
1610 llm_grid_h = h // spatial_merge_size
1611 llm_grid_w = w // spatial_merge_size
1455 - for _ in range(t):
1456 - offset = input_tokens.index(video_token_id, offset)
1457 - yield offset, llm_grid_h, llm_grid_w
1458 - offset += llm_grid_h * llm_grid_w
1612 +
1613 + # Check if EVS (Efficient Video Sampling) is enabled
1614 + is_evs_enabled = (
1615 + hasattr(self, "video_pruning_rate")
1616 + and self.video_pruning_rate is not None
1617 + and self.video_pruning_rate > 0.0
1618 + )
1619 +
1620 + if is_evs_enabled:
1621 + frame_offsets = self._extract_frame_offsets_from_mask(
1622 + mm_feature.mm_position, t
1623 + )
1624 + if frame_offsets is not None:
1625 + for rel_offset in frame_offsets:
1626 + yield offset + rel_offset, llm_grid_h, llm_grid_w
1627 + continue
1628 +
1629 + # If EVS is enabled but mask is missing, this indicates a bug
1630 + # in the prompt processing pipeline. The is_embed mask should
1631 + # always be present when video_pruning_rate > 0.
1632 + raise RuntimeError(
1633 + f"EVS is enabled (pruning_rate={self.video_pruning_rate}) "
1634 + "but is_embed mask is missing from mm_position. "
1635 + "This indicates a bug in prompt processing."
1636 + )
1637 + else:
1638 + # Non-EVS mode: Use original logic with input_tokens.index()
1639 + for _ in range(t):
1640 + offset = input_tokens.index(video_token_id, offset)
1641 + yield offset, llm_grid_h, llm_grid_w
1642 + offset += llm_grid_h * llm_grid_w
1643 else:
1644 raise ValueError(f"Unsupported modality: {mm_feature.modality}")
1645
1646 + def _get_evs_mask_segments(
1647 + self, mm_position: PlaceholderRange, expected_frames: int
1648 + ) -> list[torch.Tensor] | None:
1649 + """Extract contiguous segments from EVS is_embed mask.
1650 +
1651 + The EVS (Efficient Video Sampling) mask marks which placeholder
1652 + positions should be filled with video embeddings. This method splits
1653 + the mask into contiguous segments, where each segment represents one
1654 + retained frame.
1655 +
1656 + This is a pure function - it does not modify any state and always
1657 + returns the same output for the same input (idempotent).
1658 +
1659 + Args:
1660 + mm_position: MultiModal position containing the is_embed mask
1661 + expected_frames: Expected number of frame segments
1662 +
1663 + Returns:
1664 + List of tensors, each containing indices for one frame segment,
1665 + or None if EVS is not enabled or validation fails.
1666 + """
1667 + is_embed_mask = getattr(mm_position, "is_embed", None)
1668 + if is_embed_mask is None:
1669 + return None
1670 +
1671 + # Find all True positions in the mask
1672 + mask_tensor = torch.as_tensor(is_embed_mask, dtype=torch.bool).view(-1)
1673 + true_indices = torch.nonzero(mask_tensor, as_tuple=False).flatten()
1674 + if true_indices.numel() == 0:
1675 + return None
1676 +
1677 + # Split into contiguous segments (where diff > 1 indicates a gap)
1678 + if true_indices.numel() == 1:
1679 + segments = [true_indices]
1680 + else:
1681 + diffs = torch.diff(true_indices)
1682 + split_points = torch.nonzero(diffs != 1, as_tuple=False).flatten()
1683 + if split_points.numel() == 0:
1684 + segments = [true_indices]
1685 + else:
1686 + segments = torch.tensor_split(
1687 + true_indices, split_points.add(1).tolist()
1688 + )
1689 +
1690 + # Validate segment count matches expected frames
1691 + if len(segments) < expected_frames:
1692 + logger.debug(
1693 + "EVS mask segments (%d) do not match expected frames (%d)",
1694 + len(segments),
1695 + expected_frames,
1696 + )
1697 + return None
1698 +
1699 + return segments[:expected_frames]
1700 +
1701 + def _extract_frame_offsets_from_mask(
1702 + self, mm_position: PlaceholderRange, expected_frames: int
1703 + ) -> list[int] | None:
1704 + """Return relative offsets for each EVS-retained frame.
1705 +
1706 + The prompt processor stores a boolean mask inside ``mm_position`` that
1707 + marks which placeholder locations should be populated with video
1708 + embeddings. By splitting that mask into contiguous runs we can recover
1709 + the start of every retained frame without probing ``input_tokens``.
1710 +
1711 + Args:
1712 + mm_position: MultiModal position containing the is_embed mask
1713 + expected_frames: Expected number of frames
1714 +
1715 + Returns:
1716 + List of starting offsets (relative to mm_position) for each frame,
1717 + or None if EVS is not enabled.
1718 + """
1719 + segments = self._get_evs_mask_segments(mm_position, expected_frames)
1720 + if segments is None:
1721 + return None
1722 +
1723 + return [int(segment[0].item()) for segment in segments]
1724 +
1725 + def _get_actual_frame_token_counts(
1726 + self, mm_position: PlaceholderRange, expected_frames: int
1727 + ) -> list[int] | None:
1728 + """Return actual token count for each EVS-retained frame.
1729 +
1730 + This function calculates the actual number of tokens per frame by
1731 + analyzing the is_embed mask, accounting for EVS pruning. Each frame
1732 + may have a different token count due to content-aware pruning.
1733 +
1734 + Args:
1735 + mm_position: MultiModal position containing the is_embed mask
1736 + expected_frames: Expected number of frames
1737 +
1738 + Returns:
1739 + List of token counts for each frame, or None if EVS is not enabled.
1740 + """
1741 + segments = self._get_evs_mask_segments(mm_position, expected_frames)
1742 + if segments is None:
1743 + return None
1744 +
1745 + return [len(seg) for seg in segments]
1746 +
1747 + def recompute_mrope_positions(
1748 + self,
1749 + input_ids: list[int],
1750 + multimodal_embeddings: tuple[torch.Tensor, ...],
1751 + mrope_positions: torch.LongTensor,
1752 + num_computed_tokens: int,
1753 + ) -> tuple[tuple[torch.Tensor, ...], torch.Tensor, int]:
1754 + """
1755 + Update part of input mrope positions (starting with
1756 + num_computed_tokens index). Original mrope_positions are computed
1757 + for unpruned sequence and becomes incorrect once pruning occurs,
1758 + so once we prune media tokens we should reflect this in the
1759 + mrope_positions before we feed it to LLM.
1760 +
1761 + Args:
1762 + input_ids: (N,) All input tokens of the prompt (Containing
1763 + entire sequence).
1764 + multimodal_embeddings: Tuple of multimodal embeddings.
1765 + mrope_positions: Existing mrope positions (3, N) for entire
1766 + sequence
1767 + num_computed_tokens: A number of computed tokens so far.
1768 +
1769 + Returns:
1770 + Tuple of (multimodal_embeddings, mrope_positions,
1771 + mrope_position_delta).
1772 + """
1773 + image_token_id = self.config.image_token_id
1774 + video_token_id = self.config.video_token_id
1775 + vision_start_token_id = self.config.vision_start_token_id
1776 +
1777 + # Device
1778 + device = (
1779 + multimodal_embeddings[0].device
1780 + if len(multimodal_embeddings)
1781 + else mrope_positions.device
1782 + )
1783 +
1784 + # Tensors
1785 + input_ids_t = torch.as_tensor(input_ids, device=device, dtype=torch.long)
1786 +
1787 + mm_embeddings_out = [mm[:, :-4] for mm in multimodal_embeddings]
1788 + mm_embeddings_pos = [
1789 + mm[:, -4:].permute(1, 0).long() for mm in multimodal_embeddings
1790 + ]
1791 +
1792 + positions, mrope_positions_delta = recompute_mrope_positions(
1793 + input_ids_t,
1794 + mm_embeddings_pos,
1795 + mrope_positions,
1796 + num_computed_tokens,
1797 + vision_start_token_id,
1798 + image_token_id,
1799 + video_token_id,
1800 + )
1801 +
1802 + return tuple(mm_embeddings_out), positions, mrope_positions_delta
1803 +
1804 def get_mrope_input_positions(
1805 self,
1806 input_tokens: list[int],
1807 mm_features: list[MultiModalFeatureSpec],
1808 ) -> tuple[torch.Tensor, int]:
1809 + # Pre-collect actual frame token counts for EVS mode
1810 + frame_token_counts_map = {}
1811 + for mm_feature in mm_features:
1812 + if mm_feature.modality == "video":
1813 + is_evs_enabled = (
1814 + hasattr(self, "video_pruning_rate")
1815 + and self.video_pruning_rate is not None
1816 + and self.video_pruning_rate > 0.0
1817 + )
1818 + if is_evs_enabled:
1819 + t = mm_feature.data["video_grid_thw"].data.tolist()[0]
1820 + token_counts = self._get_actual_frame_token_counts(
1821 + mm_feature.mm_position, t
1822 + )
1823 + assert token_counts is not None, (
1824 + "EVS enabled but failed to extract frame token counts "
1825 + "from is_embed mask"
1826 + )
1827 + frame_token_counts_map[mm_feature.mm_position.offset] = token_counts
1828 +
1829 llm_pos_ids_list = []
1830 st = 0
1831 + frame_counts_idx = {}
1832 +
1833 for offset, llm_grid_h, llm_grid_w in self.iter_mm_grid_hw(
1834 input_tokens, mm_features
1835 ):
1836 text_len = offset - st
1837 st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
1474 - llm_pos_ids_list.append(
1838 +
1839 + # Determine actual token count for this frame
1840 + base_offset = None
1841 + for feat_offset in frame_token_counts_map:
1842 + if offset >= feat_offset:
1843 + base_offset = feat_offset
1844 +
1845 + if base_offset is not None:
1846 + # EVS mode: use actual token count from is_embed mask
1847 + assert base_offset in frame_token_counts_map, (
1848 + f"Found base_offset {base_offset} but not in frame_token_counts_map"
1849 + )
1850 +
1851 + if base_offset not in frame_counts_idx:
1852 + frame_counts_idx[base_offset] = 0
1853 +
1854 + counts = frame_token_counts_map[base_offset]
1855 + idx = frame_counts_idx[base_offset]
1856 +
1857 + assert idx < len(counts), (
1858 + f"EVS frame index {idx} out of range (total frames: {len(counts)})"
1859 + )
1860 +
1861 + actual_frame_tokens = counts[idx]
1862 + frame_counts_idx[base_offset] += 1
1863 + else:
1864 + # Non-EVS mode (or image): use theoretical grid size
1865 + actual_frame_tokens = llm_grid_h * llm_grid_w
1866 +
1867 + # Add text segment
1868 + text_positions = (
1869 np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx
1870 )
1871 + llm_pos_ids_list.append(text_positions)
1872 + st_idx += text_len
1873
1874 + # Add frame segment with actual token count (not theoretical)
1875 grid_indices = np.indices((1, llm_grid_h, llm_grid_w)).reshape(3, -1)
1479 - llm_pos_ids_list.append(grid_indices + text_len + st_idx)
1480 - st = offset + llm_grid_h * llm_grid_w
1876 + # Only take the first actual_frame_tokens positions
1877 + frame_positions = grid_indices[:, :actual_frame_tokens] + st_idx
1878 + llm_pos_ids_list.append(frame_positions)
1879 +
1880 + # Update st using actual token count
1881 + st = offset + actual_frame_tokens
1882
1883 + # Handle final text segment
1884 if st < len(input_tokens):
1885 st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
1886 text_len = len(input_tokens) - st
1485 - llm_pos_ids_list.append(
1887 + final_text_positions = (
1888 np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx
1889 )
1890 + llm_pos_ids_list.append(final_text_positions)
1891
1892 llm_positions = np.concatenate(llm_pos_ids_list, axis=1).reshape(3, -1)
1893 mrope_position_delta = (llm_positions.max() + 1 - len(input_tokens)).item()
1894 +
1895 return torch.from_numpy(llm_positions), mrope_position_delta
1896
1897 def get_language_model(self) -> torch.nn.Module:
@@ -1508,9 +1912,17 @@ class Qwen3VLForConditionalGeneration(
1912 multimodal_input = mm_input_by_modality[modality]
1913 if modality == "image":
1914 image_embeddings = self._process_image_input(multimodal_input)
1915 + if self.is_multimodal_pruning_enabled:
1916 + image_embeddings = self._postprocess_image_embeds_evs(
1917 + image_embeddings, multimodal_input
1918 + )
1919 multimodal_embeddings += tuple(image_embeddings)
1920 if modality == "video":
1921 video_embeddings = self._process_video_input(multimodal_input)
1922 + if self.is_multimodal_pruning_enabled:
1923 + video_embeddings = self._postprocess_video_embeds_evs(
1924 + video_embeddings, multimodal_input
1925 + )
1926 multimodal_embeddings += tuple(video_embeddings)
1927 return multimodal_embeddings
1928