[MM][CG] Support ViT CUDA Graph for Gemma-4 (#46837)

Signed-off-by: Anthony Su <xsuanthony@gmail.com> Co-authored-by: Linkun Chen <github@lkchen.net>

anthonsu committed Jul 25, 2026 at 13:02 UTC 70009fb9344d2a7ba642e68369e0a64a6252e8bc
4 files changed +476 -1
docs/design/cuda_graphs_multimodal.md
+1
@@ -129,6 +129,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
129 | `DeepseekOCRForCausalLM` | `DeepSeek-OCR` | ✅︎ | ❌︎ | ✅︎ |
130 | `Gemma3ForConditionalGeneration` | `Gemma3` | ✅︎ | ❌︎ | ❌︎ |
131 | `Glm4vForConditionalGeneration` | `GLM-4.1V, GLM-4.6V-Flash` | ✅︎ | ✅︎ | ❌︎ |
132 +| `Gemma4ForConditionalGeneration` | `Gemma-4` | ✅︎ | ✅︎ | ❌︎ |
133 | `InternVLChatModel` | `InternVL3.5`, `InternVL3`, `InternVL2.5`, `InternVL2` | ✅︎ | ✅︎ | ❌︎ |
134 | `KimiVLForConditionalGeneration` | `Kimi-VL` | ✅︎ | ❌︎ | ❌︎ |
135 | `Llama4ForConditionalGeneration` | `Llama 4` | ✅︎ | ❌︎ | ❌︎ |
examples/generate/multimodal/vision_language_offline.py
+41
@@ -503,6 +503,45 @@ def run_gemma3n(questions: list[str], modality: str) -> ModelRequestData:
503 )
504
505
506 +# Gemma 4
507 +def run_gemma4(questions: list[str], modality: str) -> ModelRequestData:
508 + assert modality in ("image", "video")
509 + model_name = "google/gemma-4-31B-it"
510 +
511 + # NOTE: Gemma-4-31B is a large model. Users running into Out-Of-Memory (OOM)
512 + # errors might need to set `tensor_parallel_size` to > 1.
513 + engine_args = EngineArgs(
514 + model=model_name,
515 + max_model_len=4096,
516 + max_num_seqs=2,
517 + limit_mm_per_prompt={modality: 1},
518 + )
519 +
520 + if modality == "image":
521 + prompts = [
522 + (
523 + "<bos><start_of_turn>user\n"
524 + f"<|image|>\n{question}<end_of_turn>\n"
525 + "<start_of_turn>model\n"
526 + )
527 + for question in questions
528 + ]
529 + else: # video
530 + prompts = [
531 + (
532 + "<bos><start_of_turn>user\n"
533 + f"<|video|>\n{question}<end_of_turn>\n"
534 + "<start_of_turn>model\n"
535 + )
536 + for question in questions
537 + ]
538 +
539 + return ModelRequestData(
540 + engine_args=engine_args,
541 + prompts=prompts,
542 + )
543 +
544 +
545 # GLM-4v
546 def run_glm4v(questions: list[str], modality: str) -> ModelRequestData:
547 assert modality == "image"
@@ -2303,6 +2342,7 @@ model_example_map = {
2342 "exaone4_5": run_exaone4_5,
2343 "gemma3": run_gemma3,
2344 "gemma3n": run_gemma3n,
2345 + "gemma4": run_gemma4,
2346 "glm4v": run_glm4v,
2347 "glm4_1v": run_glm4_1v,
2348 "glm4_5v": run_glm4_5v,
@@ -2374,6 +2414,7 @@ MODELS_NEED_VIDEO_METADATA = [
2414
2415 MODELS_SUPPORT_VIT_CUDA_GRAPH = [
2416 "llama4",
2417 + "gemma4",
2418 "qwen2_vl",
2419 "qwen2_5_vl",
2420 "qwen3_vl",
tests/models/multimodal/generation/test_vit_cudagraph.py
+13
@@ -249,6 +249,19 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
249 },
250 skip=True, # TODO: Re-enable this once OOM issues are resolved on CI.
251 ),
252 + "gemma4": VitCudagraphTestConfig(
253 + model="google/gemma-4-E2B-it",
254 + image_prompt=(
255 + "<bos><start_of_turn>user\n<|image|>\nWhat is in this image?<end_of_turn>\n"
256 + "<start_of_turn>model\n"
257 + ),
258 + video_prompt=(
259 + "<bos><start_of_turn>user\n<|video|>\nDescribe this video in one sentence."
260 + "<end_of_turn>\n<start_of_turn>model\n"
261 + ),
262 + needs_video_metadata=True,
263 + marks=[pytest.mark.core_model],
264 + ),
265 }
266
267
vllm/model_executor/models/gemma4_mm.py
+421 -1
@@ -16,7 +16,7 @@ reason about temporal order.
16
17 import math
18 from collections.abc import Iterable, Mapping, Sequence
19 -from typing import TYPE_CHECKING, Annotated, Any, Literal
19 +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
20
21 import numpy as np
22 import torch
@@ -72,6 +72,7 @@ from vllm.utils.tensor_schema import TensorSchema, TensorShape
72 from .interfaces import (
73 MultiModalEmbeddings,
74 SupportsEagle3,
75 + SupportsEncoderCudaGraph,
76 SupportsLoRA,
77 SupportsMultiModal,
78 SupportsPP,
@@ -86,6 +87,12 @@ from .utils import (
87
88 if TYPE_CHECKING:
89 from vllm.model_executor.layers.quantization import QuantizationConfig
90 + from vllm.v1.worker.encoder_cudagraph_defs import (
91 + EncoderCudaGraphCaptureInputs,
92 + EncoderCudaGraphConfig,
93 + EncoderCudaGraphReplayBuffers,
94 + EncoderItemSpec,
95 + )
96
97 logger = init_logger(__name__)
98
@@ -980,7 +987,9 @@ class Gemma4ForConditionalGeneration(
987 SupportsPP,
988 SupportsLoRA,
989 SupportsEagle3,
990 + SupportsEncoderCudaGraph,
991 ):
992 + supports_encoder_cudagraph: ClassVar[Literal[True]] = True
993 # Gemma4 clamps mm_prefix bidirectional ranges to the sliding window
994 # in-kernel (HF's (causal OR blockwise) AND sliding_window). The model
995 # runner reads this to keep image bidirectional ranges that exceed the
@@ -1025,6 +1034,7 @@ class Gemma4ForConditionalGeneration(
1034 self.quant_config = quant_config
1035 self.multimodal_config = multimodal_config
1036 self.model_dtype = vllm_config.model_config.dtype
1037 + self.vllm_config = vllm_config
1038
1039 # Only quantize towers when the quant method supports their
1040 # dimensions. BNB/torchao handle arbitrary sizes; other methods
@@ -1525,6 +1535,416 @@ class Gemma4ForConditionalGeneration(
1535
1536 return multimodal_embeddings
1537
1538 + # ------------------------------------------------------------------ #
1539 + # EncoderCudaGraph protocol methods
1540 + # ------------------------------------------------------------------ #
1541 +
1542 + def get_encoder_cudagraph_config(self) -> "EncoderCudaGraphConfig":
1543 + from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphConfig
1544 +
1545 + def pad_pixel_values(dst: torch.Tensor, src: torch.Tensor) -> None:
1546 + dst.zero_()
1547 + batch_size, num_patches = src.shape[0], src.shape[1]
1548 + dst[:batch_size, :num_patches].copy_(src)
1549 +
1550 + def pad_pixel_position_ids(dst: torch.Tensor, src: torch.Tensor) -> None:
1551 + dst.fill_(-1)
1552 + batch_size, num_patches = src.shape[0], src.shape[1]
1553 + dst[:batch_size, :num_patches].copy_(src)
1554 +
1555 + return EncoderCudaGraphConfig(
1556 + modalities=["image", "video"],
1557 + buffer_keys=[
1558 + "pixel_values",
1559 + "pixel_position_ids",
1560 + "gather_indices",
1561 + ],
1562 + out_hidden_size=self.config.text_config.hidden_size,
1563 + max_frames_per_video=_VIDEO_MAX_FRAMES,
1564 + padding_logics={
1565 + "pixel_values": pad_pixel_values,
1566 + "pixel_position_ids": pad_pixel_position_ids,
1567 + },
1568 + )
1569 +
1570 + def get_encoder_cudagraph_budget_range(
1571 + self,
1572 + vllm_config: VllmConfig,
1573 + ) -> tuple[int, int]:
1574 + min_budget = _SUPPORTED_SOFT_TOKENS[0]
1575 + max_budget = min(
1576 + vllm_config.scheduler_config.max_num_batched_tokens,
1577 + vllm_config.model_config.max_model_len,
1578 + )
1579 + return (min_budget, max_budget)
1580 +
1581 + def get_input_modality(self, mm_kwargs: dict[str, Any]) -> str:
1582 + if "pixel_values" in mm_kwargs:
1583 + return "image"
1584 + elif "pixel_values_videos" in mm_kwargs:
1585 + return "video"
1586 + raise ValueError("Unsupported modality in mm_kwargs")
1587 +
1588 + def get_max_frames_per_video(self) -> int:
1589 + return _VIDEO_MAX_FRAMES
1590 +
1591 + def get_encoder_cudagraph_item_specs(
1592 + self,
1593 + mm_kwargs: dict[str, Any],
1594 + ) -> list["EncoderItemSpec"]:
1595 + from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
1596 +
1597 + vision_cfg = self.vision_tower.config
1598 + pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
1599 +
1600 + modality = self.get_input_modality(mm_kwargs)
1601 + if modality == "image":
1602 + pixel_values = mm_kwargs["pixel_values"]
1603 + if isinstance(pixel_values, list):
1604 + return [
1605 + EncoderItemSpec(
1606 + input_size=pv.shape[0],
1607 + output_tokens=pv.shape[0] // pool_ratio,
1608 + )
1609 + for pv in pixel_values
1610 + ]
1611 + else:
1612 + return [
1613 + EncoderItemSpec(
1614 + input_size=pixel_values.shape[1],
1615 + output_tokens=pixel_values.shape[1] // pool_ratio,
1616 + )
1617 + for _ in range(pixel_values.shape[0])
1618 + ]
1619 + elif modality == "video":
1620 + pixel_values_videos = mm_kwargs["pixel_values_videos"]
1621 + video_frame_counts = mm_kwargs["video_frame_counts"]
1622 + fc_list = (
1623 + video_frame_counts.tolist()
1624 + if isinstance(video_frame_counts, torch.Tensor)
1625 + else list(video_frame_counts)
1626 + )
1627 + np_patches = pixel_values_videos.shape[1]
1628 + return [
1629 + EncoderItemSpec(
1630 + input_size=fc * np_patches,
1631 + output_tokens=fc * (np_patches // pool_ratio),
1632 + )
1633 + for fc in fc_list
1634 + ]
1635 + raise ValueError(f"Unknown modality: {modality}")
1636 +
1637 + def select_encoder_cudagraph_items(
1638 + self,
1639 + mm_kwargs: dict[str, Any],
1640 + indices: list[int],
1641 + ) -> dict[str, Any]:
1642 + modality = self.get_input_modality(mm_kwargs)
1643 + if modality == "image":
1644 + pixel_values = mm_kwargs["pixel_values"]
1645 + pixel_position_ids = mm_kwargs["pixel_position_ids"]
1646 + if len(indices) == 0:
1647 + is_pv_list = isinstance(pixel_values, list)
1648 + is_pp_list = isinstance(pixel_position_ids, list)
1649 + return {
1650 + "pixel_values": ([] if is_pv_list else pixel_values[:0]),
1651 + "pixel_position_ids": (
1652 + [] if is_pp_list else pixel_position_ids[:0]
1653 + ),
1654 + }
1655 + if isinstance(pixel_values, list):
1656 + return {
1657 + "pixel_values": [pixel_values[i] for i in indices],
1658 + "pixel_position_ids": [pixel_position_ids[i] for i in indices],
1659 + }
1660 + return {
1661 + "pixel_values": pixel_values[indices],
1662 + "pixel_position_ids": pixel_position_ids[indices],
1663 + }
1664 + elif modality == "video":
1665 + pixel_values_videos = mm_kwargs["pixel_values_videos"]
1666 + pixel_position_ids_videos = mm_kwargs["pixel_position_ids_videos"]
1667 + video_frame_counts = mm_kwargs["video_frame_counts"]
1668 +
1669 + if len(indices) == 0:
1670 + is_fc_tensor = isinstance(video_frame_counts, torch.Tensor)
1671 + return {
1672 + "pixel_values_videos": pixel_values_videos[:0],
1673 + "pixel_position_ids_videos": pixel_position_ids_videos[:0],
1674 + "video_frame_counts": (
1675 + video_frame_counts[:0] if is_fc_tensor else []
1676 + ),
1677 + }
1678 +
1679 + fc_list = (
1680 + video_frame_counts.tolist()
1681 + if isinstance(video_frame_counts, torch.Tensor)
1682 + else list(video_frame_counts)
1683 + )
1684 + cum_frames = [0]
1685 + for fc in fc_list:
1686 + cum_frames.append(cum_frames[-1] + fc)
1687 +
1688 + selected_pv = torch.cat(
1689 + [
1690 + pixel_values_videos[cum_frames[i] : cum_frames[i + 1]]
1691 + for i in indices
1692 + ],
1693 + dim=0,
1694 + )
1695 + selected_pp = torch.cat(
1696 + [
1697 + pixel_position_ids_videos[cum_frames[i] : cum_frames[i + 1]]
1698 + for i in indices
1699 + ],
1700 + dim=0,
1701 + )
1702 + selected_fc = (
1703 + video_frame_counts[indices]
1704 + if isinstance(video_frame_counts, torch.Tensor)
1705 + else [video_frame_counts[i] for i in indices]
1706 + )
1707 + return {
1708 + "pixel_values_videos": selected_pv,
1709 + "pixel_position_ids_videos": selected_pp,
1710 + "video_frame_counts": selected_fc,
1711 + }
1712 + raise ValueError(f"Unknown modality: {modality}")
1713 +
1714 + def prepare_encoder_cudagraph_capture_inputs(
1715 + self,
1716 + token_budget: int = 256,
1717 + max_batch_size: int = 4,
1718 + max_frames_per_batch: int = 1,
1719 + device: torch.device | str = "cpu",
1720 + dtype: torch.dtype | None = None,
1721 + path: str = "default",
1722 + **kwargs: Any,
1723 + ) -> "EncoderCudaGraphCaptureInputs":
1724 + from vllm.v1.worker.encoder_cudagraph_defs import (
1725 + EncoderCudaGraphCaptureInputs,
1726 + )
1727 +
1728 + dtype = dtype or torch.float32
1729 +
1730 + max_size = max(max_batch_size, max_frames_per_batch)
1731 +
1732 + vision_cfg = self.vision_tower.config
1733 + pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
1734 +
1735 + # Retrieve the model's actual configured maximum tokens:
1736 + configured_max_tokens = getattr(
1737 + self.config.vision_config,
1738 + "num_soft_tokens",
1739 + _SUPPORTED_SOFT_TOKENS[2],
1740 + )
1741 + # Dynamically compute the slot capacity per item bounded by both the
1742 + # current graph budget and the user's maximum config:
1743 + per_item_output = min(token_budget, configured_max_tokens)
1744 + # Satisfy k^2 * per_item_output = per_item_patches
1745 + per_item_patches = per_item_output * pool_ratio
1746 +
1747 + patch_size = self.vision_tower.config.patch_size
1748 + num_channels = getattr(self.vision_tower.config, "num_channels", 3)
1749 + patch_pixels = (patch_size**2) * num_channels
1750 +
1751 + dummy_pixel_values = torch.zeros(
1752 + (max_size, per_item_patches, patch_pixels),
1753 + device=device,
1754 + dtype=dtype,
1755 + )
1756 + dummy_pixel_position_ids = torch.full(
1757 + (max_size, per_item_patches, 2),
1758 + -1,
1759 + device=device,
1760 + dtype=torch.long,
1761 + )
1762 + dummy_gather_indices = torch.zeros(
1763 + (token_budget,),
1764 + device=device,
1765 + dtype=torch.long,
1766 + )
1767 +
1768 + return EncoderCudaGraphCaptureInputs(
1769 + values={
1770 + "pixel_values": dummy_pixel_values,
1771 + "pixel_position_ids": dummy_pixel_position_ids,
1772 + "gather_indices": dummy_gather_indices,
1773 + }
1774 + )
1775 +
1776 + def prepare_encoder_cudagraph_replay_buffers(
1777 + self,
1778 + mm_kwargs: dict[str, Any],
1779 + max_batch_size: int = 4,
1780 + max_frames_per_batch: int = 1,
1781 + path: str = "default",
1782 + **kwargs: Any,
1783 + ) -> "EncoderCudaGraphReplayBuffers":
1784 + from vllm.v1.worker.encoder_cudagraph_defs import (
1785 + EncoderCudaGraphReplayBuffers,
1786 + )
1787 +
1788 + modality = self.get_input_modality(mm_kwargs)
1789 + if modality == "image":
1790 + pixel_values = mm_kwargs["pixel_values"]
1791 + pixel_position_ids = mm_kwargs["pixel_position_ids"]
1792 + elif modality == "video":
1793 + pixel_values = mm_kwargs["pixel_values_videos"]
1794 + pixel_position_ids = mm_kwargs["pixel_position_ids_videos"]
1795 + else:
1796 + raise ValueError(f"Unsupported modality: {modality}")
1797 +
1798 + if isinstance(pixel_values, list):
1799 + max_patches = max(pv.shape[0] for pv in pixel_values)
1800 + batch_size = len(pixel_values)
1801 + pv_tensor = torch.zeros(
1802 + (batch_size, max_patches, pixel_values[0].shape[1]),
1803 + dtype=pixel_values[0].dtype,
1804 + device=pixel_values[0].device,
1805 + )
1806 + pp_tensor = torch.full(
1807 + (batch_size, max_patches, 2),
1808 + -1,
1809 + dtype=pixel_position_ids[0].dtype,
1810 + device=pixel_position_ids[0].device,
1811 + )
1812 + for i, (pv, pp) in enumerate(zip(pixel_values, pixel_position_ids)):
1813 + pv_tensor[i, : pv.shape[0]].copy_(pv)
1814 + pp_tensor[i, : pp.shape[0]].copy_(pp)
1815 + pixel_values = pv_tensor
1816 + pixel_position_ids = pp_tensor
1817 +
1818 + item_specs = self.get_encoder_cudagraph_item_specs(mm_kwargs)
1819 + per_item_out_tokens = [spec.output_tokens for spec in item_specs]
1820 + total_tokens = sum(per_item_out_tokens)
1821 +
1822 + device = pixel_values.device
1823 + vision_cfg = self.vision_tower.config
1824 + pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
1825 + per_item_output = pixel_values.shape[1] // pool_ratio
1826 +
1827 + # ONLY allocate an array of exact size `total_tokens`.
1828 + # DO NOT pad it. The upstream Graph Manager handles the padding securely.
1829 + gather_indices = torch.zeros((total_tokens,), dtype=torch.long, device=device)
1830 +
1831 + if modality == "image":
1832 + dst_offset = 0
1833 + for i, n_tok in enumerate(per_item_out_tokens):
1834 + safe_n_tok = min(n_tok, per_item_output)
1835 + src_start = i * per_item_output
1836 + src_end = src_start + safe_n_tok
1837 + gather_indices[dst_offset : dst_offset + safe_n_tok] = torch.arange(
1838 + src_start, src_end, dtype=torch.long, device=device
1839 + )
1840 + dst_offset += safe_n_tok
1841 + elif modality == "video":
1842 + video_frame_counts = mm_kwargs["video_frame_counts"]
1843 + fc_list = (
1844 + video_frame_counts.tolist()
1845 + if isinstance(video_frame_counts, torch.Tensor)
1846 + else list(video_frame_counts)
1847 + )
1848 + vision_cfg = self.vision_tower.config
1849 + pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
1850 + np_patches = pixel_values.shape[1]
1851 + frame_output_tokens = np_patches // pool_ratio
1852 + safe_frame_output_tokens = min(frame_output_tokens, per_item_output)
1853 +
1854 + dst_offset = 0
1855 + frame_idx = 0
1856 + for fc in fc_list:
1857 + for f in range(fc):
1858 + src_start = (frame_idx + f) * per_item_output
1859 + src_end = src_start + safe_frame_output_tokens
1860 + gather_indices[
1861 + dst_offset : dst_offset + safe_frame_output_tokens
1862 + ] = torch.arange(
1863 + src_start, src_end, dtype=torch.long, device=device
1864 + )
1865 + dst_offset += safe_frame_output_tokens
1866 + frame_idx += fc
1867 +
1868 + return EncoderCudaGraphReplayBuffers(
1869 + values={
1870 + "pixel_values": pixel_values,
1871 + "pixel_position_ids": pixel_position_ids,
1872 + "gather_indices": gather_indices,
1873 + }
1874 + )
1875 +
1876 + def encoder_cudagraph_forward(
1877 + self,
1878 + inputs: dict[str, torch.Tensor],
1879 + path: str = "default",
1880 + **kwargs: Any,
1881 + ) -> torch.Tensor:
1882 + pixel_values = inputs["pixel_values"]
1883 + pixel_position_ids = inputs["pixel_position_ids"]
1884 + gather_indices = inputs["gather_indices"]
1885 +
1886 + pad_tensor = (pixel_position_ids == -1).all(dim=-1)
1887 +
1888 + vt = self.vision_tower
1889 + inputs_embeds = vt.patch_embedder(
1890 + pixel_values,
1891 + pixel_position_ids,
1892 + pad_tensor,
1893 + ).to(self.model_dtype)
1894 +
1895 + encoder_outputs = vt.encoder(
1896 + inputs_embeds=inputs_embeds,
1897 + attention_mask=~pad_tensor,
1898 + pixel_position_ids=pixel_position_ids,
1899 + )
1900 + hidden_states = encoder_outputs.last_hidden_state
1901 +
1902 + pool_ratio = getattr(vt.config, "pooling_kernel_size", 2) ** 2
1903 + per_item_output = pixel_values.shape[1] // pool_ratio
1904 +
1905 + pooled_states, _ = vt.pooler(
1906 + hidden_states=hidden_states,
1907 + pixel_position_ids=pixel_position_ids,
1908 + padding_positions=pad_tensor,
1909 + output_length=per_item_output,
1910 + )
1911 +
1912 + if getattr(vt.config, "standardize", False):
1913 + pooled_states = (pooled_states - vt.std_bias) * vt.std_scale
1914 +
1915 + flat_pooled = pooled_states.reshape(-1, pooled_states.shape[-1])
1916 + gathered_states = flat_pooled[gather_indices]
1917 +
1918 + # Cast to the projection layer's dtype to resolve mixed-precision crash
1919 + target_dtype = self.embed_vision.embedding_projection.weight.dtype
1920 + gathered_states = gathered_states.to(target_dtype)
1921 +
1922 + flat_proj_embs = self.embed_vision(
1923 + inputs_embeds=gathered_states.unsqueeze(0)
1924 + ).squeeze(0)
1925 +
1926 + return flat_proj_embs
1927 +
1928 + def encoder_eager_forward(
1929 + self,
1930 + mm_kwargs: dict[str, Any],
1931 + path: str = "default",
1932 + **kwargs: Any,
1933 + ) -> torch.Tensor:
1934 + modality = self.get_input_modality(mm_kwargs)
1935 + if modality == "image":
1936 + image_input = self._parse_and_validate_image_input(**mm_kwargs)
1937 + assert image_input is not None
1938 + embeddings = self._process_image_input(image_input)
1939 + elif modality == "video":
1940 + video_input = self._parse_and_validate_video_input(**mm_kwargs)
1941 + assert video_input is not None
1942 + embeddings = self._process_video_input(video_input)
1943 + else:
1944 + raise ValueError(f"Unsupported modality: {modality}")
1945 +
1946 + return torch.cat(embeddings, dim=0)
1947 +
1948 def embed_input_ids(
1949 self,
1950 input_ids: torch.Tensor,