Files
vLLM/vllm/model_executor/models/gemma4_mm.py
T

2367 lines
94 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Gemma 4 multimodal model (image + audio + video support).
Adds vision tower, audio tower, and multimodal embedders on top of the
text-only Gemma4ForCausalLM. The vision/audio encoders are loaded via
AutoModel.from_config and run in eager mode while the language model uses
the vLLM-optimized path.
Video support: Gemma4 does **not** have a native video tower. Videos are
decomposed into timestamped image frames (up to 32 frames at 70 soft tokens
each) and fed through the same vision tower as regular images. The
processor inserts ``mm:ss`` timestamps between frames so the model can
reason about temporal order.
"""
import math
from collections.abc import Hashable, Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
import numpy as np
import torch
from PIL import Image as PILImage
from torch import nn
from transformers import AutoModel, BatchFeature
from transformers.models.gemma4 import (
Gemma4AudioFeatureExtractor,
Gemma4Config,
Gemma4Processor,
Gemma4VisionConfig,
)
from transformers.models.gemma4.configuration_gemma4 import (
Gemma4AudioConfig,
Gemma4TextConfig,
)
from vllm.config import VllmConfig
from vllm.config.model import get_served_model_name
from vllm.config.multimodal import MultiModalDummyOptions, VideoDummyOptions
from vllm.inputs import MultiModalDataDict
from vllm.logger import init_logger
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.layers.linear import ReplicatedLinear
from vllm.model_executor.models.gemma3n_mm import batch_audio_features
from vllm.model_executor.models.gemma4 import Gemma4ForCausalLM
from vllm.model_executor.models.module_mapping import MultiModelKeys
from vllm.model_executor.models.transformers.utils import recursive_replace_linear
from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.multimodal.inputs import (
MultiModalFieldConfig,
MultiModalKwargsItem,
MultiModalKwargsItems,
VideoItem,
)
from vllm.multimodal.parse import (
AudioProcessorItems,
ImageProcessorItems,
MultiModalDataItems,
MultiModalDataParser,
)
from vllm.multimodal.processing import BaseDummyInputsBuilder
from vllm.multimodal.processing.processor import (
BaseMultiModalProcessor,
BaseProcessingInfo,
PromptReplacement,
PromptUpdate,
PromptUpdateDetails,
)
from vllm.sequence import IntermediateTensors
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
from vllm.utils.tensor_schema import TensorSchema, TensorShape
from vllm.utils.torch_utils import async_tensor_h2d
from .interfaces import (
MultiModalEmbeddings,
SupportsEagle3,
SupportsEncoderCudaGraph,
SupportsLoRA,
SupportsMultiModal,
SupportsPP,
SupportsQuant,
)
from .utils import (
AutoWeightsLoader,
WeightsMapper,
init_vllm_registered_model,
maybe_prefix,
)
if TYPE_CHECKING:
from vllm.model_executor.layers.quantization import QuantizationConfig
from vllm.v1.worker.encoder_cudagraph_defs import (
EncoderCudaGraphCaptureInputs,
EncoderCudaGraphConfig,
EncoderCudaGraphReplayBuffers,
EncoderItemSpec,
)
logger = init_logger(__name__)
def _get_tower_quant_config(
tower_config: Any,
quant_config: "QuantizationConfig | None",
) -> "QuantizationConfig | None":
# BNB/torchao/compressed-tensors handle arbitrary sizes; other methods
# (Marlin, FP8, …) require dimensions divisible by 64.
# TODO(mgoin): remove this by fixing kernel padding.
if quant_config and quant_config.get_name() in [
"bitsandbytes",
"torchao",
"compressed-tensors",
]:
return quant_config
# The audio config has no intermediate_size; treat it as always divisible.
intermediate_size = getattr(tower_config, "intermediate_size", None)
quantizable = tower_config.hidden_size % 64 == 0 and (
intermediate_size is None or intermediate_size % 64 == 0
)
return quant_config if quantizable else None
# Video constants — match transformers Gemma4VideoProcessor defaults.
_SUPPORTED_SOFT_TOKENS = (70, 140, 280, 560, 1120)
_VIDEO_MAX_SOFT_TOKENS = 70 # soft tokens per video frame (vs 280 for images)
_VIDEO_MAX_FRAMES = 32 # max sampled frames per video
def _get_max_soft_tokens(
merged_kwargs: Mapping[str, object],
) -> tuple[object | None, bool]:
"""Return configured image max_soft_tokens and whether it is top-level."""
val = merged_kwargs.get("max_soft_tokens")
if val is not None:
return val, True
images_kwargs = merged_kwargs.get("images_kwargs")
if isinstance(images_kwargs, Mapping):
return images_kwargs.get("max_soft_tokens"), False
return None, False
# ---------------------------------------------------------------------------
# Input schema
# ---------------------------------------------------------------------------
class Gemma4ImagePixelInputs(TensorSchema):
"""Pre-patchified image inputs from the Gemma4 image processor.
Dimensions:
- bn: Batch size * number of images
- np: Number of patches (max_patches = max_soft_tokens * pooling_kernel_size²)
- pp: Patch pixels (patch_size² * 3)
The Gemma4 image processor outputs pixel_values as
(batch, max_patches, patch_pixels) — already patchified with
zero-padding for patches beyond the real image content.
pixel_position_ids provides (x, y) coordinates per patch,
with (-1, -1) for padding patches.
"""
type: Literal["pixel_values"] = "pixel_values"
pixel_values: Annotated[
torch.Tensor | list[torch.Tensor],
TensorShape("bn", "np", "pp", dynamic_dims={"np"}),
]
pixel_position_ids: Annotated[
torch.Tensor | list[torch.Tensor],
TensorShape("bn", "np", 2, dynamic_dims={"np"}),
]
class Gemma4AudioInputs(TensorSchema):
"""Dimensions:
- bn: Batch size * number of audios
- s: Sequence length (MEL spectrogram frames)
- f: Number of features (MEL bins)
"""
type: Literal["audio"] = "audio"
input_features_padded: Annotated[
torch.Tensor, TensorShape("bn", "s", "f", dynamic_dims={"s"})
]
input_features_mask: Annotated[
torch.Tensor, TensorShape("bn", "s", dynamic_dims={"s"})
]
Gemma4ImageInputs = Gemma4ImagePixelInputs
class Gemma4VideoInputs(TensorSchema):
"""Video frame inputs — same tensor format as image inputs.
Gemma4 has no separate video tower; video frames are processed
through the vision tower at lower resolution (max_soft_tokens=70).
"""
type: Literal["pixel_values_videos"] = "pixel_values_videos"
pixel_values_videos: Annotated[
torch.Tensor,
TensorShape("bn", "np", "pp"),
]
pixel_position_ids_videos: Annotated[
torch.Tensor,
TensorShape("bn", "np", 2),
]
video_frame_counts: Annotated[torch.Tensor, TensorShape("nv")]
# ---------------------------------------------------------------------------
# Processing info
# ---------------------------------------------------------------------------
class Gemma4ProcessingInfo(BaseProcessingInfo):
def get_hf_config(self):
return self.ctx.get_hf_config(Gemma4Config)
def get_default_tok_params(self):
"""Gemma4's chat template already embeds a literal ``<bos>`` token in
the rendered text. If ``add_special_tokens=True`` (the base-class
default), the tokenizer prepends *another* BOS, producing a
``[2, 2, ...]`` double-BOS sequence that the model was not trained on.
Setting ``add_special_tokens=False`` here prevents the duplicate and
ensures both ``llm.generate()`` and the chat/completions API behave
correctly for IT models. For PT models (without chat template), we
keep the default (True) to ensure BOS is added for raw prompts.
"""
tokenizer = self.ctx.get_tokenizer()
has_chat_template = getattr(tokenizer, "chat_template", None) is not None
params = super().get_default_tok_params()
if has_chat_template:
params = params.with_kwargs(add_special_tokens=False)
return params
def get_hf_processor(self, **kwargs: object) -> Gemma4Processor:
return self.ctx.get_hf_processor(
Gemma4Processor,
**kwargs,
)
def has_audio(self) -> bool:
return self.get_hf_config().audio_config is not None
def validate_num_items(self, modality: str, num_items: int) -> None:
if modality == "audio" and num_items > 0 and not self.has_audio():
model_config = self.ctx.model_config
model = get_served_model_name(
model_config.model, model_config.served_model_name
)
raise ValueError(
f"Audio input was provided but the model "
f"'{model}' does not have an audio tower. "
f"Audio inference is only supported for Gemma4 "
f"models that include an audio_config "
f"(i.e., models that include an audio_config)."
)
super().validate_num_items(modality, num_items)
def get_supported_mm_limits(self) -> Mapping[str, int | None]:
limits: dict[str, int | None] = {"image": None}
if self.has_audio():
limits["audio"] = None
limits["video"] = None
return limits
def get_mm_max_tokens_per_item(
self, seq_len: int, mm_counts: Mapping[str, int]
) -> Mapping[str, int] | None:
config = self.get_hf_config()
# Upper bound: the pooler outputs max_soft_tokens slots per image.
# After padding is stripped the actual count is ≤ this value, but
# vLLM needs the max for memory planning.
tokens_per_image = config.vision_config.default_output_length
merged_kwargs = self.ctx.get_merged_mm_kwargs({})
val, _ = _get_max_soft_tokens(merged_kwargs)
if isinstance(val, int) and val in _SUPPORTED_SOFT_TOKENS:
tokens_per_image = val
tokens: dict[str, int] = {"image": tokens_per_image}
if self.has_audio():
# Audio max tokens from the processor's audio_seq_length.
processor = self.get_hf_processor()
tokens["audio"] = processor.audio_seq_length
# Video: each frame ≤ 70 soft tokens + boi + eoi + ~6 ts tokens.
num_frames = _VIDEO_MAX_FRAMES
mm_config = self.ctx.model_config.get_multimodal_config()
video_opts = mm_config.limit_per_prompt.get("video")
if video_opts is not None and video_opts.num_frames is not None:
num_frames = min(num_frames, video_opts.num_frames)
tokens["video"] = num_frames * (_VIDEO_MAX_SOFT_TOKENS + 2 + 6)
return tokens
def get_data_parser(self) -> MultiModalDataParser:
kwargs: dict[str, Any] = {"video_needs_metadata": True}
if self.has_audio():
processor = self.get_hf_processor()
kwargs["target_sr"] = processor.feature_extractor.sampling_rate
return MultiModalDataParser(**kwargs)
def _compute_num_soft_tokens(
self,
image_width: int,
image_height: int,
max_soft_tokens: int | None = None,
) -> int:
"""Compute the number of soft tokens the vision tower produces
for an image of the given dimensions, after padding is stripped.
Args:
image_width: Width of the image in pixels.
image_height: Height of the image in pixels.
max_soft_tokens: Override for the vision config's
``default_output_length``. When *None*, the value from
the model config is used.
"""
vision_cfg = self.get_hf_config().vision_config
patch_size = vision_cfg.patch_size
pooling_kernel_size = vision_cfg.pooling_kernel_size
if max_soft_tokens is None:
max_soft_tokens = vision_cfg.default_output_length
unit = patch_size * pooling_kernel_size
max_patches = max_soft_tokens * pooling_kernel_size**2
num_patches_orig = (image_height / patch_size) * (image_width / patch_size)
scale = math.sqrt(max_patches / num_patches_orig)
target_h = max(unit, int(math.floor(image_height * scale / unit)) * unit)
target_w = max(unit, int(math.floor(image_width * scale / unit)) * unit)
num_patches = (target_h // patch_size) * (target_w // patch_size)
# Clamp to ``max_soft_tokens``: extreme aspect ratios (e.g. 3x900)
# cause the floor() above to round one dim up to ``unit`` while the
# other scales freely, which over-shoots ``max_patches``. The HF
# Gemma 4 image processor caps its vision-tower output at
# ``max_soft_tokens``, so without this clamp the prompt-side
# placeholder count exceeds the encoder output and
# ``_merge_multimodal_embeddings`` crashes.
return min(num_patches // (pooling_kernel_size**2), max_soft_tokens)
def get_image_repl(
self,
*,
image_width: int,
image_height: int,
processor: Gemma4Processor | None,
max_soft_tokens: int | None = None,
) -> PromptUpdateDetails:
"""Return the dynamic image token sequence for this image.
Computes the exact number of soft tokens the vision tower will
produce after stripping padding.
Args:
image_width: Width of the image in pixels.
image_height: Height of the image in pixels.
processor: The Gemma4 processor, when already constructed.
max_soft_tokens: Override for the default token budget.
When *None*, falls back to the model config value.
"""
if processor is None:
processor = self.get_hf_processor()
num_soft = self._compute_num_soft_tokens(
image_width,
image_height,
max_soft_tokens=max_soft_tokens,
)
config = self.get_hf_config()
token_ids = (
[config.boi_token_id]
+ [processor.image_token_id] * num_soft
+ [config.eoi_token_id]
)
return PromptUpdateDetails.select_token_id(token_ids, processor.image_token_id)
@staticmethod
def _compute_audio_num_tokens(
num_samples: int, sampling_rate: int, audio_seq_length: int
) -> int:
"""Replicate the audio encoder's sequence-length arithmetic.
Mirrors: mel framing (_unfold in Gemma4AudioFeatureExtractor)
followed by two Conv2d subsampling layers (kernel=3, stride=2,
semicausal padding top=1, bottom=1), capped at audio_seq_length.
"""
frame_length = int(round(sampling_rate * 20.0 / 1000.0))
hop_length = int(round(sampling_rate * 10.0 / 1000.0))
frame_size_for_unfold = frame_length + 1
pad_left = frame_length // 2
padded_samples = num_samples + pad_left
num_mel_frames = (padded_samples - frame_size_for_unfold) // hop_length + 1
if num_mel_frames <= 0:
return 0
t = num_mel_frames
for _ in range(2):
t = (t + 2 - 3) // 2 + 1
return min(t, audio_seq_length)
def get_audio_repl(
self,
*,
audio_len: int,
processor: Gemma4Processor | None,
) -> PromptUpdateDetails:
"""Return the dynamic audio token sequence for this audio.
Computes the number of soft tokens from the audio waveform
length by replicating the audio encoder's sequence-length
arithmetic (mel framing + two Conv2d subsampling layers).
"""
if processor is None:
processor = self.get_hf_processor()
sampling_rate = processor.feature_extractor.sampling_rate
num_tokens = self._compute_audio_num_tokens(
audio_len, sampling_rate, processor.audio_seq_length
)
config = self.get_hf_config()
token_ids = (
[config.boa_token_id]
+ [processor.audio_token_id] * num_tokens
+ [getattr(config, "eoa_token_id", config.eoa_token_index)]
)
return PromptUpdateDetails.select_token_id(token_ids, processor.audio_token_id)
def get_video_repl(
self,
*,
timestamps: list[float],
num_soft_tokens_per_frame: list[int],
processor: Gemma4Processor,
) -> PromptUpdateDetails:
"""Build the full token replacement for one video.
Produces the same interleaved sequence as the HF Gemma4Processor:
mm:ss <boi><|video|>*N<eoi> mm:ss <boi><|video|>*N<eoi> ...
"""
tokenizer = self.ctx.get_tokenizer()
config = self.get_hf_config()
boi_token_id = config.boi_token_id
eoi_token_id = config.eoi_token_id
video_token_id = processor.video_token_id
all_token_ids: list[int] = []
for i, (ts, n_tokens) in enumerate(zip(timestamps, num_soft_tokens_per_frame)):
# mm:ss timestamp — matches transformers: int-truncated,
# zero-padded.
minutes = int(ts // 60)
seconds = int(ts % 60)
ts_str = f"{minutes:02d}:{seconds:02d}"
prefix = f" {ts_str} " if i > 0 else f"{ts_str} "
ts_token_ids = tokenizer.encode(prefix, add_special_tokens=False)
all_token_ids.extend(ts_token_ids)
all_token_ids.append(boi_token_id)
all_token_ids.extend([video_token_id] * n_tokens)
all_token_ids.append(eoi_token_id)
return PromptUpdateDetails.select_token_id(all_token_ids, video_token_id)
# ---------------------------------------------------------------------------
# Dummy inputs builder
# ---------------------------------------------------------------------------
def _dummy_audio_num_samples(processor: Any) -> int:
"""Length in samples of a worst-case dummy audio item.
``fft_length`` is set in ``Gemma4AudioFeatureExtractor.__init__`` and is not
defined by ``Gemma4UnifiedAudioFeatureExtractor``, which the encoder-free
Gemma 4 variant uses. Reading it unconditionally therefore raises
``AttributeError`` during memory profiling on that variant, which is reached
whenever audio is the modality with the largest per-item token count.
For any other extractor, fall back to the audio budget the processor itself
declares -- the same pair of values used to warn about over-long audio
elsewhere in this file.
"""
feature_extractor = processor.feature_extractor
if isinstance(feature_extractor, Gemma4AudioFeatureExtractor):
return feature_extractor.fft_length
sampling_rate = feature_extractor.sampling_rate
max_duration_s = processor.audio_seq_length * processor.audio_ms_per_token / 1000.0
return int(max_duration_s * sampling_rate)
class Gemma4DummyInputsBuilder(BaseDummyInputsBuilder[Gemma4ProcessingInfo]):
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
num_images = mm_counts.get("image", 0)
num_audios = mm_counts.get("audio", 0)
num_videos = mm_counts.get("video", 0)
processor = self.info.get_hf_processor()
# Use image_token (<|image|>) with tab prefix — this is what the
# Gemma4 chat template inserts per image (\t<|image|>).
# _get_prompt_updates targets image_token and expands it to the
# full_image_sequence.
text = ("\t" + processor.image_token) * num_images
if num_audios > 0 and processor.audio_token:
text += processor.audio_token * num_audios
if num_videos > 0:
text += processor.video_token * num_videos
return text
def get_dummy_mm_data(
self,
seq_len: int,
mm_counts: Mapping[str, int],
mm_options: MultiModalDummyOptions | None = None,
) -> MultiModalDataDict:
num_audios = mm_counts.get("audio", 0)
num_videos = mm_counts.get("video", 0)
processor = self.info.get_hf_processor()
image_processor = processor.image_processor
# Use processor's configured image size for dummies.
# Gemma4ImageProcessor sets size=None (it uses patch_size /
# max_soft_tokens instead of the standard size dict), so we
# guard against None with `or {}`.
size = getattr(image_processor, "size", None) or {}
img_width = size.get("width", 224)
img_height = size.get("height", 224)
data: dict[str, Any] = {
"image": self._get_dummy_images(
width=img_width,
height=img_height,
num_images=mm_counts.get("image", 0),
overrides=mm_options.get("image") if mm_options else None,
),
}
if num_audios > 0:
audio_len = _dummy_audio_num_samples(processor)
data["audio"] = self._get_dummy_audios(
length=audio_len,
num_audios=num_audios,
overrides=mm_options.get("audio") if mm_options else None,
)
if num_videos > 0:
data["video"] = self._get_dummy_videos(
width=img_width,
height=img_height,
num_frames=_VIDEO_MAX_FRAMES,
num_videos=num_videos,
overrides=mm_options.get("video") if mm_options else None,
)
return data
def _get_dummy_videos(
self,
*,
width: int,
height: int,
num_frames: int,
num_videos: int,
overrides: VideoDummyOptions | None = None,
) -> list[VideoItem]:
num_frames = max(num_frames, 2)
videos = super()._get_dummy_videos(
width=width,
height=height,
num_frames=num_frames,
num_videos=num_videos,
overrides=overrides,
)
videos = [v.copy() for v in videos]
video_items: list[VideoItem] = []
for video in videos:
video_num_frames = video.shape[0]
video_metadata = {
"fps": 2.0,
"duration": video_num_frames / 2.0,
"total_num_frames": video_num_frames,
"frames_indices": list(range(video_num_frames)),
"video_backend": "opencv",
"do_sample_frames": False,
}
video_items.append((video, video_metadata))
return video_items
# ---------------------------------------------------------------------------
# Multimodal processor
# ---------------------------------------------------------------------------
class Gemma4MultiModalProcessor(BaseMultiModalProcessor[Gemma4ProcessingInfo]):
def _get_hf_mm_text(self, mm_counts: Mapping[str, int]) -> str:
return self.dummy_inputs.get_dummy_text(mm_counts)
def _apply_hf_processor_main(
self,
mm_items: MultiModalDataItems,
hf_kwargs: Mapping[str, object],
) -> BatchFeature:
hf_data, hf_kwargs, passthrough_data = self._get_hf_mm_inputs(
mm_items, hf_kwargs
)
if not hf_data:
return self._finalize_hf_mm_data(hf_data, hf_kwargs, passthrough_data)
prompt_text = hf_data.pop("text")
assert isinstance(prompt_text, str)
merged_kwargs = self.info.ctx.get_merged_mm_kwargs(hf_kwargs)
val, is_top_level_max_soft_tokens = _get_max_soft_tokens(merged_kwargs)
if val is not None and val not in _SUPPORTED_SOFT_TOKENS:
raise ValueError(
f"Unsupported max_soft_tokens value: {val}. "
f"Valid values are {_SUPPORTED_SOFT_TOKENS}."
)
# ---- VIDEO HANDLING ----
# Gemma4 decomposes video into timestamped image frames.
# Each frame is processed with max_soft_tokens=70 through the
# same vision tower, matching transformers processing_gemma4.py.
video_outputs: dict[str, Any] = {}
if videos := hf_data.pop("videos", []):
assert isinstance(videos, list)
processor = self.info.get_hf_processor()
all_video_pixel_values: list[torch.Tensor] = []
all_video_position_ids: list[torch.Tensor] = []
video_num_soft_tokens_per_video: list[list[int]] = []
video_timestamps_per_video: list[list[float]] = []
video_frame_counts: list[int] = []
video_replacements: list[str] = []
for item in videos:
video_array, metadata = item
# Convert frames to PIL images
if isinstance(video_array, np.ndarray):
frames = [
PILImage.fromarray(video_array[i])
for i in range(video_array.shape[0])
]
else:
frames = list(video_array)
# Compute timestamps from metadata (same as transformers)
fps = metadata.get("fps") or 24
frame_indices = metadata.get("frames_indices", list(range(len(frames))))
timestamps = [idx / fps for idx in frame_indices]
# Process frames as images with max_soft_tokens=70
video_mm_kwargs = dict(hf_kwargs)
# Override configured image options too: these inputs are video
# frames, whose cache keys exclude images_kwargs.
video_mm_kwargs["images_kwargs"] = {}
video_mm_kwargs["max_soft_tokens"] = _VIDEO_MAX_SOFT_TOKENS
dummy_prompt = ("\t" + processor.image_token) * len(frames)
frame_outputs = self.info.ctx.call_hf_processor(
self.info.get_hf_processor(**video_mm_kwargs),
dict(text=dummy_prompt, **{"images": frames}),
video_mm_kwargs,
)
# Remap HF key name
if "image_position_ids" in frame_outputs:
frame_outputs["pixel_position_ids"] = frame_outputs.pop(
"image_position_ids"
)
all_video_pixel_values.append(frame_outputs["pixel_values"])
all_video_position_ids.append(frame_outputs["pixel_position_ids"])
# Compute soft tokens per frame
num_soft_per_frame = []
for img in frames:
w, h = img.size
n = self.info._compute_num_soft_tokens(
w, h, max_soft_tokens=_VIDEO_MAX_SOFT_TOKENS
)
num_soft_per_frame.append(n)
video_num_soft_tokens_per_video.append(num_soft_per_frame)
video_timestamps_per_video.append(timestamps)
video_frame_counts.append(len(frames))
# Build expanded replacement text for this video.
ts_strs = [f"{int(s // 60):02d}:{int(s % 60):02d}" for s in timestamps]
replacement = " ".join(
f"{t} {processor.boi_token}"
f"{processor.video_token * n}"
f"{processor.eoi_token}"
for t, n in zip(ts_strs, num_soft_per_frame)
)
video_replacements.append(replacement)
# Replace all <|video|> placeholders at once. We split on
# video_token to get N+1 parts, then interleave with the
# N replacement strings. This avoids the iterative
# split-replace bug where replacement text (which itself
# contains <|video|> tokens) collides with later splits.
vt = processor.video_token
parts = prompt_text.split(vt, len(video_replacements))
# NOTE: len(parts) <= len(video_replacements) + 1
parts_with_repl: list[str] = []
for part, repl in zip(parts, video_replacements):
parts_with_repl.extend([part, repl])
parts_with_repl.extend(parts[len(video_replacements) :])
prompt_text = "".join(parts_with_repl)
video_outputs = {
"pixel_values_videos": torch.cat(all_video_pixel_values, dim=0),
"pixel_position_ids_videos": torch.cat(all_video_position_ids, dim=0),
"video_frame_counts": torch.tensor(video_frame_counts),
"video_num_soft_tokens": video_num_soft_tokens_per_video,
"video_timestamps": video_timestamps_per_video,
}
# Warn if any audio waveform exceeds the model's max duration.
if "audio" in hf_data:
processor = self.info.get_hf_processor()
sr = processor.feature_extractor.sampling_rate
max_tokens = processor.audio_seq_length
ms_per_tok = processor.audio_ms_per_token
max_duration_s = max_tokens * ms_per_tok / 1000.0
audios = hf_data["audio"]
if not isinstance(audios, (list, tuple)):
audios = [audios]
for i, waveform in enumerate(audios):
duration_s = len(waveform) / sr
if duration_s > max_duration_s:
logger.warning(
"Audio duration exceeds max: %f > %f seconds",
duration_s,
max_duration_s,
)
# vLLM's call_hf_processor (context.py) re-merges
# mm_processor_kwargs from the model config on every call via:
# config_kwargs | incoming_kwargs (right side wins)
#
# If we strip max_soft_tokens from incoming, the re-merge puts
# back the config's global default (e.g. 280), ignoring any
# per-prompt override. Instead, we keep it in the kwargs with
# the validated per-prompt value so it wins during the merge.
#
# NOTE: This requires a corresponding type annotation on the
# HF side (Gemma4ProcessorKwargs.images_kwargs) so that
# _merge_kwargs routes max_soft_tokens into images_kwargs.
patched_mm_kwargs = dict(hf_kwargs)
if val is not None and is_top_level_max_soft_tokens:
patched_mm_kwargs["max_soft_tokens"] = val
processed_data = self.info.ctx.call_hf_processor(
self.info.get_hf_processor(**patched_mm_kwargs),
dict(text=prompt_text, **hf_data),
patched_mm_kwargs,
)
# HF uses 'image_position_ids'; vLLM uses 'pixel_position_ids'.
# Remap here to keep a single translation point.
if "image_position_ids" in processed_data:
processed_data["pixel_position_ids"] = processed_data.pop(
"image_position_ids"
)
if "input_features" in processed_data:
# Unpad per-item so each item's cache entry is
# self-contained. The batched() field config in
# _get_mm_fields_config stacks equal-length items and leaves
# the rest as a list; _process_audio_input re-pads that list
# to the batch's max length.
masks = processed_data["input_features_mask"]
unpadded_features = [
f[mask]
for f, mask in zip(
processed_data["input_features"],
masks,
)
]
unpadded_masks = [mask[mask] for mask in masks]
processed_data["input_features"] = unpadded_features
processed_data["input_features_padded"] = unpadded_features
processed_data["input_features_mask"] = unpadded_masks
processed_data.update(video_outputs)
return self._finalize_hf_mm_data(
hf_data, hf_kwargs, passthrough_data, processed_data
)
def _get_mm_fields_config(
self,
hf_inputs: BatchFeature,
hf_processor_mm_kwargs: Mapping[str, object],
) -> Mapping[str, MultiModalFieldConfig]:
fields = dict(
pixel_values=MultiModalFieldConfig.batched("image"),
pixel_position_ids=MultiModalFieldConfig.batched("image"),
input_features_padded=MultiModalFieldConfig.batched("audio"),
input_features_mask=MultiModalFieldConfig.batched("audio"),
)
# Video fields: frames stored flat, split per video by
# video_frame_counts.
video_frame_counts = hf_inputs.get("video_frame_counts")
if video_frame_counts is not None:
vfc = video_frame_counts
if not isinstance(vfc, torch.Tensor):
vfc = torch.tensor(vfc)
fields.update(
pixel_values_videos=(
MultiModalFieldConfig.flat_from_sizes("video", vfc)
),
pixel_position_ids_videos=(
MultiModalFieldConfig.flat_from_sizes("video", vfc)
),
video_frame_counts=MultiModalFieldConfig.batched(
"video", keep_on_cpu=True
),
video_num_soft_tokens=MultiModalFieldConfig.batched(
"video", keep_on_cpu=True
),
video_timestamps=MultiModalFieldConfig.batched(
"video", keep_on_cpu=True
),
)
return fields
def _get_prompt_updates(
self,
mm_items: MultiModalDataItems,
hf_processor_mm_kwargs: Mapping[str, Any],
out_mm_kwargs: MultiModalKwargsItems,
) -> Sequence[PromptUpdate]:
hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
prompt_updates = []
if "image" in mm_items:
# Target image_token (<|image|>) — the single placeholder the
# Gemma4 chat template inserts once per image in the prompt.
# vLLM tokenizes the prompt without token expansion, so only
# one image_token exists per image in the token stream.
# The replacement expands it to the full image sequence
# (boi + N×image_token + eoi, where N = max_soft_tokens).
image_token_id = hf_processor.image_token_id
def get_replacement_image(item_idx: int):
images = mm_items.get_items("image", ImageProcessorItems)
image_size = images.get_image_size(item_idx)
# Resolve the effective max_soft_tokens by merging
# per-prompt kwargs with the config-level defaults,
# consistent with how _apply_hf_processor_main resolves it.
# Without this merge, a missing per-prompt override
# would fall back to vision_cfg.default_output_length
# instead of the config's mm_processor_kwargs default.
merged_kwargs = self.info.ctx.get_merged_mm_kwargs(
hf_processor_mm_kwargs,
)
val, _ = _get_max_soft_tokens(merged_kwargs)
max_soft_tokens = (
val
if isinstance(val, int) and val in _SUPPORTED_SOFT_TOKENS
else None
)
return self.info.get_image_repl(
image_width=image_size.width,
image_height=image_size.height,
processor=hf_processor,
max_soft_tokens=max_soft_tokens,
)
prompt_updates.append(
PromptReplacement(
modality="image",
target=[image_token_id],
replacement=get_replacement_image,
)
)
if "video" in mm_items:
video_token_id = hf_processor.video_token_id
def get_replacement_video(item_idx: int):
out_item = out_mm_kwargs["video"][item_idx]
timestamps_data = out_item["video_timestamps"].data
if isinstance(timestamps_data, torch.Tensor):
timestamps_data = timestamps_data.tolist()
if not isinstance(timestamps_data, list):
raise TypeError("video timestamps must be a list")
timestamps: list[float] = []
for timestamp in timestamps_data:
if not isinstance(timestamp, (int, float)):
raise TypeError("each video timestamp must be numeric")
timestamps.append(float(timestamp))
num_soft_data = out_item["video_num_soft_tokens"].data
if isinstance(num_soft_data, torch.Tensor):
num_soft_data = num_soft_data.tolist()
if not isinstance(num_soft_data, list):
raise TypeError("video soft-token counts must be a list")
num_soft: list[int] = []
for count in num_soft_data:
if not isinstance(count, int):
raise TypeError(
"each video soft-token count must be an integer"
)
num_soft.append(count)
return self.info.get_video_repl(
timestamps=timestamps,
num_soft_tokens_per_frame=num_soft,
processor=hf_processor,
)
prompt_updates.append(
PromptReplacement(
modality="video",
target=[video_token_id],
replacement=get_replacement_video,
)
)
if "audio" in mm_items:
audio_token_id = hf_processor.audio_token_id
def get_replacement_audio(item_idx: int):
audios = mm_items.get_items("audio", AudioProcessorItems)
audio_len = audios.get_audio_length(item_idx)
return self.info.get_audio_repl(
audio_len=audio_len,
processor=hf_processor,
)
prompt_updates.append(
PromptReplacement(
modality="audio",
target=[audio_token_id],
replacement=get_replacement_audio,
)
)
return prompt_updates
# NOTE: Gemma3/Gemma3n override _apply_token_matches and
# _find_mm_placeholders to merge adjacent newline tokens that arise
# when full_image_sequence contains "\n\n" wrappers. Gemma4's
# full_image_sequence has NO newlines (just BOI + 280×image_token +
# EOI), so the base class implementations work correctly as-is.
# ---------------------------------------------------------------------------
# Multimodal embedder
# ---------------------------------------------------------------------------
class Gemma4MultimodalEmbedder(nn.Module):
"""Projects vision/audio soft tokens into LM embedding space.
Architecture:
inputs_embeds → embedding_projection → embedding_post_projection_norm
Unlike Gemma3n which has separate hard/soft embedding paths with
per-path normalization and a learned embedding table, Gemma4 uses a
simplified 2-layer design: a linear projection followed by RMSNorm
(without learnable scale). The checkpoint confirms this — only
``embedding_projection.weight`` exists; there is no embedding table
or pre-projection norm weights.
"""
def __init__(
self,
multimodal_config: Gemma4VisionConfig | Gemma4AudioConfig,
text_config: Gemma4TextConfig,
*,
quant_config: "QuantizationConfig | None" = None,
prefix: str = "",
):
super().__init__()
self.eps = multimodal_config.rms_norm_eps
self.text_hidden_size = text_config.hidden_size
# Audio tower uses output_proj_dims (1536) rather than hidden_size
# (1024); vision uses hidden_size (768) directly.
embedding_dim = (
getattr(multimodal_config, "output_proj_dims", None)
or multimodal_config.hidden_size
)
self.embedding_pre_projection_norm = RMSNorm(
embedding_dim,
eps=self.eps,
has_weight=False,
)
self.embedding_projection = ReplicatedLinear(
embedding_dim,
self.text_hidden_size,
bias=False,
quant_config=quant_config,
prefix=maybe_prefix(prefix, "embedding_projection"),
)
def forward(self, inputs_embeds: torch.Tensor) -> torch.Tensor:
"""Project soft tokens from a multimodal tower into LM space."""
embs_normed = self.embedding_pre_projection_norm(inputs_embeds)
embs_proj, _ = self.embedding_projection(embs_normed)
return embs_proj
# ---------------------------------------------------------------------------
# Main model
# ---------------------------------------------------------------------------
@MULTIMODAL_REGISTRY.register_processor(
Gemma4MultiModalProcessor,
info=Gemma4ProcessingInfo,
dummy_inputs=Gemma4DummyInputsBuilder,
)
class Gemma4ForConditionalGeneration(
nn.Module,
SupportsMultiModal,
SupportsQuant,
SupportsPP,
SupportsLoRA,
SupportsEagle3,
SupportsEncoderCudaGraph,
):
supports_encoder_cudagraph: ClassVar[Literal[True]] = True
# Gemma4 clamps mm_prefix bidirectional ranges to the sliding window
# in-kernel (HF's (causal OR blockwise) AND sliding_window). The model
# runner reads this to keep image bidirectional ranges that exceed the
# window instead of dropping them (which would make image attention
# causal-only for images larger than the sliding window).
mm_prefix_clamp_sliding_window: bool = True
supports_tower_connector_lora = True
packed_modules_mapping: dict[str, list[str]] = {
"qkv_proj": [
"q_proj",
"k_proj",
"v_proj",
],
"gate_up_proj": [
"gate_proj",
"up_proj",
],
}
# Maps checkpoint prefixes to vLLM module paths.
hf_to_vllm_mapper = WeightsMapper(
orig_to_new_prefix={
# vision tower
"model.vision_tower": "vision_tower",
"model.embed_vision": "embed_vision",
# audio tower
"model.audio_tower.": "audio_tower.",
"model.embed_audio.": "embed_audio.",
# backbone
"model.language_model.": "language_model.model.",
"lm_head.": "language_model.lm_head.",
"model": "language_model.model",
},
)
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
config = vllm_config.model_config.hf_config
quant_config = vllm_config.quant_config
multimodal_config = vllm_config.model_config.multimodal_config
self.config = config
self.quant_config = quant_config
self.multimodal_config = multimodal_config
self.model_dtype = vllm_config.model_config.dtype
self.vllm_config = vllm_config
lora_config = vllm_config.lora_config
self._enable_mm_lora = bool(
lora_config is not None and lora_config.enable_tower_connector_lora
)
# Apply the dimension check independently because the vision and audio
# towers do not necessarily have the same hidden and intermediate sizes.
vision_tower_quant = _get_tower_quant_config(config.vision_config, quant_config)
# ---- Vision tower (shared by image and video) ----
with self._mark_tower_model(vllm_config, {"image", "video"}):
self.vision_tower = AutoModel.from_config(config=config.vision_config)
self.embed_vision = Gemma4MultimodalEmbedder(
config.vision_config,
config.text_config,
quant_config=vision_tower_quant,
prefix=maybe_prefix(prefix, "embed_vision"),
)
recursive_replace_linear(
self.vision_tower,
vision_tower_quant,
prefix=maybe_prefix(prefix, "vision_tower"),
)
# ---- Audio tower (variants with audio_config) ----
self.embed_audio: Gemma4MultimodalEmbedder | None
if config.audio_config is not None:
audio_tower_quant = _get_tower_quant_config(
config.audio_config, quant_config
)
with self._mark_tower_model(vllm_config, "audio"):
self.audio_tower = AutoModel.from_config(config=config.audio_config)
# AutoModel.from_config does NOT call post_init(),
# which is needed to initialize buffers that are absent
# from the checkpoint (e.g. inv_timescales for relative
# position embeddings, softcap, gradient_clipping).
self.audio_tower.post_init()
self.embed_audio = Gemma4MultimodalEmbedder(
config.audio_config,
config.text_config,
quant_config=audio_tower_quant,
prefix=maybe_prefix(prefix, "embed_audio"),
)
recursive_replace_linear(
self.audio_tower,
audio_tower_quant,
prefix=maybe_prefix(prefix, "audio_tower"),
)
else:
self.audio_tower = None
self.embed_audio = None
# ---- Language model (vLLM optimised) ----
with self._mark_language_model(vllm_config):
self.language_model: Gemma4ForCausalLM = init_vllm_registered_model(
vllm_config=vllm_config,
hf_config=config.text_config,
prefix=maybe_prefix(prefix, "language_model"),
architectures=["Gemma4ForCausalLM"],
)
# Pre-allocate PLE buffer for CUDA graph compatibility.
# Some variants have hidden_size_per_layer_input=None (no PLE).
ple_dim = config.text_config.hidden_size_per_layer_input
if ple_dim is not None and ple_dim > 0:
embed = self.language_model.model.embed_tokens
self.per_layer_embeddings = torch.zeros(
vllm_config.scheduler_config.max_num_batched_tokens,
config.text_config.num_hidden_layers,
ple_dim,
device=next(embed.parameters()).device,
dtype=vllm_config.model_config.dtype,
)
else:
self.per_layer_embeddings = None
self.make_empty_intermediate_tensors = (
self.language_model.make_empty_intermediate_tensors
)
# --- Precompute full-attention layer indices for bidi clearing ---
self._full_attn_layer_idxs: frozenset[int] = frozenset()
text_config = config.text_config
if getattr(text_config, "use_bidirectional_attention", None) == "vision":
layer_types = getattr(text_config, "layer_types", None)
if layer_types:
self._full_attn_layer_idxs = frozenset(
i for i, lt in enumerate(layer_types) if lt != "sliding_attention"
)
# --- MixtureOfExperts delegation to language_model ---
self.moe_layers = self.language_model.moe_layers
self.num_moe_layers = self.language_model.num_moe_layers
self.num_logical_experts = self.language_model.num_logical_experts
self.num_physical_experts = self.language_model.num_physical_experts
self.num_local_physical_experts = self.language_model.num_local_physical_experts
self.num_routed_experts = self.language_model.num_routed_experts
self.num_expert_groups = self.language_model.num_expert_groups
self.num_shared_experts = self.language_model.num_shared_experts
self.num_redundant_experts = self.language_model.num_redundant_experts
gen_cfg = vllm_config.model_config.try_get_generation_config()
self._suppress_token_ids = gen_cfg.get("suppress_tokens") if gen_cfg else None
# ------------------------------------------------------------------ #
# Input parsing
# ------------------------------------------------------------------ #
def _parse_and_validate_image_input(
self, **kwargs: object
) -> Gemma4ImageInputs | None:
pixel_values = kwargs.pop("pixel_values", None)
pixel_position_ids = kwargs.pop("pixel_position_ids", None)
image_embeds = kwargs.pop("image_embeds", None)
assert image_embeds is None, "Gemma4 does not support image_embeds."
if pixel_values is None:
return None
return Gemma4ImagePixelInputs(
pixel_values=pixel_values,
pixel_position_ids=pixel_position_ids,
)
def _parse_and_validate_audio_input(
self, **kwargs: object
) -> Gemma4AudioInputs | None:
input_features_padded = kwargs.pop("input_features_padded", None)
if input_features_padded is None:
return None
input_features_mask = kwargs.pop("input_features_mask", None)
if input_features_mask is None:
return None
return Gemma4AudioInputs(
input_features_padded=input_features_padded,
input_features_mask=input_features_mask,
)
def _parse_and_validate_video_input(
self, **kwargs: object
) -> Gemma4VideoInputs | None:
pixel_values_videos = kwargs.pop("pixel_values_videos", None)
pixel_position_ids_videos = kwargs.pop("pixel_position_ids_videos", None)
video_frame_counts = kwargs.pop("video_frame_counts", None)
if pixel_values_videos is None:
return None
return Gemma4VideoInputs(
pixel_values_videos=pixel_values_videos,
pixel_position_ids_videos=pixel_position_ids_videos,
video_frame_counts=video_frame_counts,
)
def _parse_and_validate_multimodal_inputs(
self, **kwargs: object
) -> dict[str, Gemma4ImageInputs | Gemma4AudioInputs | Gemma4VideoInputs | None]:
mm_input_by_modality: dict[
str, Gemma4ImageInputs | Gemma4AudioInputs | Gemma4VideoInputs | None
] = {}
for input_key in list(kwargs):
if (
input_key in ("pixel_values", "image_embeds")
and "image" not in mm_input_by_modality
):
mm_input_by_modality["image"] = self._parse_and_validate_image_input(
**kwargs
)
if (
input_key == "pixel_values_videos"
and "video" not in mm_input_by_modality
):
mm_input_by_modality["video"] = self._parse_and_validate_video_input(
**kwargs
)
if (
input_key == "input_features_padded"
and "audio" not in mm_input_by_modality
):
mm_input_by_modality["audio"] = self._parse_and_validate_audio_input(
**kwargs
)
return mm_input_by_modality
@staticmethod
def _encoder_chunk(
patches_per_item: int,
free_bytes: int,
total_bytes: int,
position_embedding_size: int,
) -> int:
"""Max chunk size whose F.one_hot transient fits in the budget.
The dominant transient inside HF's ``Gemma4VisionPatchEmbedder.
_position_embeddings`` is
``F.one_hot(clamped_positions, num_classes=position_embedding_size)``
with shape ``(chunk, patches, 2, position_embedding_size)``,
int64, plus its simultaneous cast to the position embedding
table dtype. That, not the encoder residual stream, sets peak
memory.
"""
if patches_per_item <= 0:
return 1
# Half of currently-free, capped at 10% of total so we leave room
# for the rest of profile_run / the subsequent encoder + pooler.
budget = min(free_bytes // 2, total_bytes // 10)
if budget <= 0:
return 1
# F.one_hot allocates (chunk, patches, 2, pos_emb_size) int64
# (the inner 2 is the (x, y) coordinate axis, 8 is sizeof(int64)).
# Outer 2x covers the int64 buffer and its concurrent bf16 cast
# plus the matmul output that live alongside it at peak.
cost = patches_per_item * 4 * position_embedding_size * 8
return max(1, budget // cost) if cost > 0 else 1
# ------------------------------------------------------------------ #
# Image processing
# ------------------------------------------------------------------ #
def _process_image_input(
self,
image_input: Gemma4ImageInputs,
) -> list[torch.Tensor]:
"""Batch-encode images through the vision tower.
Groups images by patch count (resolution bucket) so each
encoder call processes a uniform-shape batch with no
cross-resolution padding. With MM LoRA enabled, all images are
padded into one batch so the encoder call matches the tower mapping.
"""
pixel_values = image_input["pixel_values"]
pixel_position_ids = image_input["pixel_position_ids"]
vt = self.vision_tower
vision_cfg = self.config.vision_config
pooling_k2 = vision_cfg.pooling_kernel_size**2
# Concurrent requests with different image resolutions may
# arrive as a list of per-image tensors, while same-resolution
# batches may arrive as a stacked tensor.
buckets: dict[int, list[tuple[int, torch.Tensor, torch.Tensor]]] = {}
total_images = (
len(pixel_values)
if isinstance(pixel_values, list)
else pixel_values.shape[0]
)
pool_position_ids = pixel_position_ids
if self._enable_mm_lora:
max_soft_tokens = vision_cfg.default_output_length
mm_processor_kwargs = getattr(
getattr(self, "multimodal_config", None),
"mm_processor_kwargs",
None,
)
if isinstance(mm_processor_kwargs, Mapping):
value, _ = _get_max_soft_tokens(mm_processor_kwargs)
if isinstance(value, int) and value in _SUPPORTED_SOFT_TOKENS:
max_soft_tokens = value
max_patches = max_soft_tokens * pooling_k2
padded_position_ids: list[torch.Tensor] = []
for idx in range(total_images):
pv = pixel_values[idx]
pp = pixel_position_ids[idx]
num_patches = pv.shape[0]
if num_patches > max_patches:
raise ValueError(
f"Image {idx} has {num_patches} patches, which exceeds "
f"the MM LoRA patch limit of {max_patches}."
)
pad_len = max_patches - num_patches
pv = torch.cat(
(pv, pv.new_zeros((pad_len, *pv.shape[1:]))),
dim=0,
)
pp = torch.cat(
(pp, pp.new_full((pad_len, *pp.shape[1:]), -1)),
dim=0,
)
buckets.setdefault(max_patches, []).append((idx, pv, pp))
padded_position_ids.append(pp)
pool_position_ids = padded_position_ids
else:
for idx in range(total_images):
pv = pixel_values[idx]
pp = pixel_position_ids[idx]
buckets.setdefault(pv.shape[0], []).append((idx, pv, pp))
# Encode each resolution bucket in memory-safe chunks. Re-read
# free memory per bucket because the previous bucket's encoder
# pass has already allocated activations we should account for.
last_hidden_states_map: dict[int, torch.Tensor] = {}
for patches, items in buckets.items():
if self._enable_mm_lora:
max_batch_size = len(items)
else:
free, total = torch.accelerator.get_memory_info()
max_batch_size = min(
len(items),
self._encoder_chunk(
patches, free, total, vision_cfg.position_embedding_size
),
)
for chunk_idx in range(0, len(items), max_batch_size):
chunk_items = items[chunk_idx : chunk_idx + max_batch_size]
pv_tensor = torch.cat(
[item[1].unsqueeze(0) for item in chunk_items], dim=0
)
pp_tensor = torch.cat(
[item[2].unsqueeze(0) for item in chunk_items], dim=0
)
pad_tensor = (pp_tensor == -1).all(dim=-1)
inputs_embeds = vt.patch_embedder(
pv_tensor,
pp_tensor,
pad_tensor,
).to(self.model_dtype)
# HuggingFace's mask builder probes `padding_mask.all()` to
# decide whether the mask can be skipped, which syncs.
with gpu_sync_allowed():
encoder_outputs = vt.encoder(
inputs_embeds=inputs_embeds,
attention_mask=~pad_tensor,
pixel_position_ids=pp_tensor,
)
hidden_states = encoder_outputs.last_hidden_state
for i, (orig_idx, _, _) in enumerate(chunk_items):
last_hidden_states_map[orig_idx] = hidden_states[i]
# Pool per image to strip padding and reduce spatial resolution.
all_valid_states: list[torch.Tensor] = [None] * total_images # type: ignore[list-item]
valid_lens = [0] * total_images
for orig_idx in range(total_images):
chunk_hidden = last_hidden_states_map[orig_idx]
output_length = chunk_hidden.shape[0] // pooling_k2
single_hidden = chunk_hidden.unsqueeze(0)
single_pos_ids = pool_position_ids[orig_idx].unsqueeze(0)
padding_positions = (single_pos_ids == -1).all(dim=-1)
# The pooler goes through HuggingFace's mask builder, which probes
# `padding_mask.all()`, and the mask indexing below needs the
# selected count on the host.
with gpu_sync_allowed():
pooled_states, valid_mask = vt.pooler(
hidden_states=single_hidden,
pixel_position_ids=single_pos_ids,
padding_positions=padding_positions,
output_length=output_length,
)
valid_states = pooled_states[valid_mask]
if getattr(vt.config, "standardize", False):
valid_states = (valid_states - vt.std_bias) * vt.std_scale
all_valid_states[orig_idx] = valid_states
valid_lens[orig_idx] = valid_states.shape[0]
# Project all images in a single batched call.
flat_valid_states = torch.cat(all_valid_states, dim=0).to(self.model_dtype)
flat_proj_embs = self.embed_vision(
inputs_embeds=flat_valid_states.unsqueeze(0)
).squeeze(0)
# Split back into per-image tensors (slicing returns views).
per_image_embeddings: list[torch.Tensor] = []
offset = 0
for length in valid_lens:
per_image_embeddings.append(flat_proj_embs[offset : offset + length])
offset += length
return per_image_embeddings
# ------------------------------------------------------------------ #
# Video processing (frames through vision tower)
# ------------------------------------------------------------------ #
def _process_video_input(
self,
video_input: Gemma4VideoInputs,
) -> list[torch.Tensor]:
"""Batch-encode video frames through the vision tower.
Gemma4 has no separate video tower; video frames are images at
lower resolution (max_soft_tokens=70). All frames across all
videos in the batch are encoded together in chunks, then pooled
and projected in a single batched call.
Returns one concatenated embedding tensor per video (not per
frame), matching the flat_from_sizes grouping that vLLM expects
for embed_multimodal.
"""
pixel_values = video_input["pixel_values_videos"]
pixel_position_ids = video_input["pixel_position_ids_videos"]
frame_counts = video_input["video_frame_counts"]
vt = self.vision_tower
vision_cfg = self.config.vision_config
pooling_k2 = vision_cfg.pooling_kernel_size**2
if isinstance(frame_counts, torch.Tensor):
# Per-video frame counts drive the Python-level batching below.
with gpu_sync_allowed():
fc_list = frame_counts.tolist()
else:
fc_list = list(frame_counts)
total_frames = pixel_values.shape[0]
free, total = torch.accelerator.get_memory_info()
max_batch_size = min(
total_frames,
self._encoder_chunk(
pixel_values.shape[1],
free,
total,
vision_cfg.position_embedding_size,
),
)
padding_positions = (pixel_position_ids == -1).all(dim=-1)
# Encode frames in chunks bounded by _encoder_chunk.
last_hidden_states_list: list[torch.Tensor] = []
for i in range(0, total_frames, max_batch_size):
pv_chunk = pixel_values[i : i + max_batch_size]
pp_chunk = pixel_position_ids[i : i + max_batch_size]
pad_chunk = padding_positions[i : i + max_batch_size]
inputs_embeds = vt.patch_embedder(
pv_chunk,
pp_chunk,
pad_chunk,
).to(self.model_dtype)
# HuggingFace's mask builder probes `padding_mask.all()`.
with gpu_sync_allowed():
encoder_outputs = vt.encoder(
inputs_embeds=inputs_embeds,
attention_mask=~pad_chunk,
pixel_position_ids=pp_chunk,
)
last_hidden_states_list.append(encoder_outputs.last_hidden_state)
last_hidden_states = torch.cat(last_hidden_states_list, dim=0)
# Pool per frame to strip padding and reduce spatial resolution.
output_length = pixel_values.shape[1] // pooling_k2
all_frame_valid_states: list[torch.Tensor] = []
frame_valid_lens: list[int] = []
for i in range(total_frames):
single_hidden = last_hidden_states[i].unsqueeze(0)
single_pos_ids = pixel_position_ids[i].unsqueeze(0)
single_pad_pos = padding_positions[i].unsqueeze(0)
# As above, plus mask indexing that needs the count on the host.
with gpu_sync_allowed():
pooled_states, valid_mask = vt.pooler(
hidden_states=single_hidden,
pixel_position_ids=single_pos_ids,
padding_positions=single_pad_pos,
output_length=output_length,
)
valid_states = pooled_states[valid_mask]
if getattr(vt.config, "standardize", False):
valid_states = (valid_states - vt.std_bias) * vt.std_scale
all_frame_valid_states.append(valid_states)
frame_valid_lens.append(valid_states.shape[0])
# Project all frames in a single batched call.
flat_valid_states = torch.cat(all_frame_valid_states, dim=0).to(
self.model_dtype
)
flat_proj_embs = self.embed_vision(
inputs_embeds=flat_valid_states.unsqueeze(0)
).squeeze(0)
# Regroup into per-video tensors (slicing returns views).
per_video_embeddings: list[torch.Tensor] = []
frame_idx = 0
offset = 0
for count in fc_list:
video_tokens = sum(frame_valid_lens[frame_idx : frame_idx + count])
per_video_embeddings.append(flat_proj_embs[offset : offset + video_tokens])
offset += video_tokens
frame_idx += count
return per_video_embeddings
# ------------------------------------------------------------------ #
# Audio processing
# ------------------------------------------------------------------ #
def _process_audio_input(
self,
audio_input: Gemma4AudioInputs,
) -> list[torch.Tensor]:
input_features, input_features_mask = batch_audio_features(
audio_input["input_features_padded"],
audio_input["input_features_mask"],
)
assert self.audio_tower is not None
assert self.embed_audio is not None
# Run audio tower — mask convention: True=valid, False=padding.
audio_outputs = self.audio_tower(input_features, input_features_mask)
if isinstance(audio_outputs, tuple):
audio_encodings, audio_mask = audio_outputs
else:
audio_encodings = audio_outputs.last_hidden_state
audio_mask = audio_outputs.attention_mask
# Project into LM embedding space.
audio_features = self.embed_audio(inputs_embeds=audio_encodings)
# Strip padding per-batch element: only keep valid (non-padding)
# tokens.
# Boolean-mask indexing needs the selected count on the host.
per_audio = []
with gpu_sync_allowed():
for enc, mask in zip(audio_features, audio_mask, strict=True):
per_audio.append(enc[mask]) # [num_real, hidden_size]
return per_audio
# ------------------------------------------------------------------ #
# MultiModalEmbeddings interface
# ------------------------------------------------------------------ #
def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs)
multimodal_embeddings: list[torch.Tensor] = []
for modality, multimodal_input in mm_input_by_modality.items():
if multimodal_input is None:
continue
if modality == "image":
assert isinstance(multimodal_input, Gemma4ImageInputs)
multimodal_embeddings.extend(
self._process_image_input(multimodal_input)
)
elif modality == "video":
assert isinstance(multimodal_input, Gemma4VideoInputs)
multimodal_embeddings.extend(
self._process_video_input(multimodal_input)
)
elif modality == "audio":
assert isinstance(multimodal_input, Gemma4AudioInputs)
multimodal_embeddings.extend(
self._process_audio_input(multimodal_input)
)
return multimodal_embeddings
# ------------------------------------------------------------------ #
# EncoderCudaGraph protocol methods
# ------------------------------------------------------------------ #
def get_encoder_cudagraph_config(self) -> "EncoderCudaGraphConfig":
from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphConfig
def pad_pixel_values(dst: torch.Tensor, src: torch.Tensor) -> None:
dst.zero_()
batch_size, num_patches = src.shape[0], src.shape[1]
dst[:batch_size, :num_patches].copy_(src)
def pad_pixel_position_ids(dst: torch.Tensor, src: torch.Tensor) -> None:
dst.fill_(-1)
batch_size, num_patches = src.shape[0], src.shape[1]
dst[:batch_size, :num_patches].copy_(src)
configured_max_tokens = self.config.vision_config.default_output_length
# One graph per row width (soft tokens of one image or video frame),
# so a budget's rows are not all padded to the widest image.
row_widths = tuple(
t for t in _SUPPORTED_SOFT_TOKENS if t <= configured_max_tokens
)
return EncoderCudaGraphConfig(
modalities=["image", "video"],
buffer_keys=[
"pixel_values",
"pixel_position_ids",
"gather_indices",
],
out_hidden_size=self.config.text_config.hidden_size,
max_frames_per_video=_VIDEO_MAX_FRAMES,
padding_logics={
"pixel_values": pad_pixel_values,
"pixel_position_ids": pad_pixel_position_ids,
},
capture_axes=(row_widths,),
)
def get_encoder_cudagraph_budget_range(
self,
vllm_config: VllmConfig,
) -> tuple[int, int]:
min_budget = _SUPPORTED_SOFT_TOKENS[0]
max_budget = min(
vllm_config.scheduler_config.max_num_batched_tokens,
vllm_config.model_config.max_model_len,
)
return (min_budget, max_budget)
def get_input_modality(self, mm_kwargs: dict[str, Any]) -> str:
if "pixel_values" in mm_kwargs:
return "image"
elif "pixel_values_videos" in mm_kwargs:
return "video"
raise ValueError("Unsupported modality in mm_kwargs")
def get_max_frames_per_video(self) -> int:
return _VIDEO_MAX_FRAMES
def get_encoder_cudagraph_item_specs(
self,
mm_kwargs: dict[str, Any],
) -> list["EncoderItemSpec"]:
from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
vision_cfg = self.vision_tower.config
pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
modality = self.get_input_modality(mm_kwargs)
if modality == "image":
pixel_values = mm_kwargs["pixel_values"]
if isinstance(pixel_values, list):
# A batch pads its images to the widest one, so pack by that.
row_tokens = (
max((pv.shape[0] for pv in pixel_values), default=0) // pool_ratio
)
return [
EncoderItemSpec(
input_size=pv.shape[0],
output_tokens=pv.shape[0] // pool_ratio,
path_output_tokens={"default": row_tokens},
)
for pv in pixel_values
]
else:
return [
EncoderItemSpec(
input_size=pixel_values.shape[1],
output_tokens=pixel_values.shape[1] // pool_ratio,
)
for _ in range(pixel_values.shape[0])
]
elif modality == "video":
pixel_values_videos = mm_kwargs["pixel_values_videos"]
video_frame_counts = mm_kwargs["video_frame_counts"]
fc_list = (
video_frame_counts.tolist()
if isinstance(video_frame_counts, torch.Tensor)
else list(video_frame_counts)
)
np_patches = pixel_values_videos.shape[1]
return [
EncoderItemSpec(
input_size=fc * np_patches,
output_tokens=fc * (np_patches // pool_ratio),
)
for fc in fc_list
]
raise ValueError(f"Unknown modality: {modality}")
def select_encoder_cudagraph_items(
self,
mm_kwargs: dict[str, Any],
indices: list[int],
) -> dict[str, Any]:
from vllm.v1.worker.encoder_cudagraph_defs import (
ENCODER_CUDAGRAPH_AXIS_KEYS_KWARG,
)
vision_cfg = self.vision_tower.config
pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
modality = self.get_input_modality(mm_kwargs)
if modality == "image":
pixel_values = mm_kwargs["pixel_values"]
pixel_position_ids = mm_kwargs["pixel_position_ids"]
if isinstance(pixel_values, list):
row_patches = max(
(pixel_values[i].shape[0] for i in indices), default=0
)
else:
row_patches = pixel_values.shape[1]
axis_keys = (row_patches // pool_ratio,)
if len(indices) == 0:
is_pv_list = isinstance(pixel_values, list)
is_pp_list = isinstance(pixel_position_ids, list)
return {
"pixel_values": ([] if is_pv_list else pixel_values[:0]),
"pixel_position_ids": (
[] if is_pp_list else pixel_position_ids[:0]
),
ENCODER_CUDAGRAPH_AXIS_KEYS_KWARG: axis_keys,
}
if isinstance(pixel_values, list):
return {
"pixel_values": [pixel_values[i] for i in indices],
"pixel_position_ids": [pixel_position_ids[i] for i in indices],
ENCODER_CUDAGRAPH_AXIS_KEYS_KWARG: axis_keys,
}
return {
"pixel_values": pixel_values[indices],
"pixel_position_ids": pixel_position_ids[indices],
ENCODER_CUDAGRAPH_AXIS_KEYS_KWARG: axis_keys,
}
elif modality == "video":
pixel_values_videos = mm_kwargs["pixel_values_videos"]
pixel_position_ids_videos = mm_kwargs["pixel_position_ids_videos"]
video_frame_counts = mm_kwargs["video_frame_counts"]
axis_keys = (pixel_values_videos.shape[1] // pool_ratio,)
if len(indices) == 0:
is_fc_tensor = isinstance(video_frame_counts, torch.Tensor)
return {
"pixel_values_videos": pixel_values_videos[:0],
"pixel_position_ids_videos": pixel_position_ids_videos[:0],
"video_frame_counts": (
video_frame_counts[:0] if is_fc_tensor else []
),
ENCODER_CUDAGRAPH_AXIS_KEYS_KWARG: axis_keys,
}
fc_list = (
video_frame_counts.tolist()
if isinstance(video_frame_counts, torch.Tensor)
else list(video_frame_counts)
)
cum_frames = [0]
for fc in fc_list:
cum_frames.append(cum_frames[-1] + fc)
selected_pv = torch.cat(
[
pixel_values_videos[cum_frames[i] : cum_frames[i + 1]]
for i in indices
],
dim=0,
)
selected_pp = torch.cat(
[
pixel_position_ids_videos[cum_frames[i] : cum_frames[i + 1]]
for i in indices
],
dim=0,
)
selected_fc = (
video_frame_counts[indices]
if isinstance(video_frame_counts, torch.Tensor)
else [video_frame_counts[i] for i in indices]
)
return {
"pixel_values_videos": selected_pv,
"pixel_position_ids_videos": selected_pp,
"video_frame_counts": selected_fc,
ENCODER_CUDAGRAPH_AXIS_KEYS_KWARG: axis_keys,
}
raise ValueError(f"Unknown modality: {modality}")
def prepare_encoder_cudagraph_capture_inputs(
self,
token_budget: int = 256,
max_batch_size: int = 4,
max_frames_per_batch: int = 1,
device: torch.device | str = "cpu",
dtype: torch.dtype | None = None,
path: str = "default",
axis_keys: tuple[Hashable, ...] | None = None,
**kwargs: Any,
) -> "EncoderCudaGraphCaptureInputs":
from vllm.v1.worker.encoder_cudagraph_defs import (
EncoderCudaGraphCaptureInputs,
)
dtype = dtype or torch.float32
assert axis_keys is not None
(per_item_output,) = axis_keys
assert isinstance(per_item_output, int)
# Every row of a batch replayed on this graph is an image or video
# frame of per_item_output tokens, so a batch that fits token_budget
# never needs more rows than this. Capture needs at least one row.
max_size = min(
max(max_batch_size, max_frames_per_batch),
max(1, token_budget // per_item_output),
)
vision_cfg = self.vision_tower.config
pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
# Satisfy k^2 * per_item_output = per_item_patches
per_item_patches = per_item_output * pool_ratio
patch_size = self.vision_tower.config.patch_size
num_channels = getattr(self.vision_tower.config, "num_channels", 3)
patch_pixels = (patch_size**2) * num_channels
dummy_pixel_values = torch.zeros(
(max_size, per_item_patches, patch_pixels),
device=device,
dtype=dtype,
)
dummy_pixel_position_ids = torch.full(
(max_size, per_item_patches, 2),
-1,
device=device,
dtype=torch.long,
)
dummy_gather_indices = torch.zeros(
(token_budget,),
device=device,
dtype=torch.long,
)
return EncoderCudaGraphCaptureInputs(
values={
"pixel_values": dummy_pixel_values,
"pixel_position_ids": dummy_pixel_position_ids,
"gather_indices": dummy_gather_indices,
}
)
def prepare_encoder_cudagraph_replay_buffers(
self,
mm_kwargs: dict[str, Any],
max_batch_size: int = 4,
max_frames_per_batch: int = 1,
path: str = "default",
**kwargs: Any,
) -> "EncoderCudaGraphReplayBuffers":
from vllm.v1.worker.encoder_cudagraph_defs import (
EncoderCudaGraphReplayBuffers,
)
modality = self.get_input_modality(mm_kwargs)
if modality == "image":
pixel_values = mm_kwargs["pixel_values"]
pixel_position_ids = mm_kwargs["pixel_position_ids"]
elif modality == "video":
pixel_values = mm_kwargs["pixel_values_videos"]
pixel_position_ids = mm_kwargs["pixel_position_ids_videos"]
else:
raise ValueError(f"Unsupported modality: {modality}")
if isinstance(pixel_values, list):
max_patches = max(pv.shape[0] for pv in pixel_values)
batch_size = len(pixel_values)
pv_tensor = torch.zeros(
(batch_size, max_patches, pixel_values[0].shape[1]),
dtype=pixel_values[0].dtype,
device=pixel_values[0].device,
)
pp_tensor = torch.full(
(batch_size, max_patches, 2),
-1,
dtype=pixel_position_ids[0].dtype,
device=pixel_position_ids[0].device,
)
for i, (pv, pp) in enumerate(zip(pixel_values, pixel_position_ids)):
pv_tensor[i, : pv.shape[0]].copy_(pv)
pp_tensor[i, : pp.shape[0]].copy_(pp)
pixel_values = pv_tensor
pixel_position_ids = pp_tensor
item_specs = self.get_encoder_cudagraph_item_specs(mm_kwargs)
per_item_out_tokens = [spec.output_tokens for spec in item_specs]
total_tokens = sum(per_item_out_tokens)
device = pixel_values.device
vision_cfg = self.vision_tower.config
pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
per_item_output = pixel_values.shape[1] // pool_ratio
# ONLY allocate an array of exact size `total_tokens`.
# DO NOT pad it. The upstream Graph Manager handles the padding securely.
gather_indices = torch.zeros((total_tokens,), dtype=torch.long, device=device)
if modality == "image":
dst_offset = 0
for i, n_tok in enumerate(per_item_out_tokens):
safe_n_tok = min(n_tok, per_item_output)
src_start = i * per_item_output
src_end = src_start + safe_n_tok
gather_indices[dst_offset : dst_offset + safe_n_tok] = torch.arange(
src_start, src_end, dtype=torch.long, device=device
)
dst_offset += safe_n_tok
elif modality == "video":
video_frame_counts = mm_kwargs["video_frame_counts"]
fc_list = (
video_frame_counts.tolist()
if isinstance(video_frame_counts, torch.Tensor)
else list(video_frame_counts)
)
vision_cfg = self.vision_tower.config
pool_ratio = getattr(vision_cfg, "pooling_kernel_size", 2) ** 2
np_patches = pixel_values.shape[1]
frame_output_tokens = np_patches // pool_ratio
safe_frame_output_tokens = min(frame_output_tokens, per_item_output)
dst_offset = 0
frame_idx = 0
for fc in fc_list:
for f in range(fc):
src_start = (frame_idx + f) * per_item_output
src_end = src_start + safe_frame_output_tokens
gather_indices[
dst_offset : dst_offset + safe_frame_output_tokens
] = torch.arange(
src_start, src_end, dtype=torch.long, device=device
)
dst_offset += safe_frame_output_tokens
frame_idx += fc
return EncoderCudaGraphReplayBuffers(
values={
"pixel_values": pixel_values,
"pixel_position_ids": pixel_position_ids,
"gather_indices": gather_indices,
}
)
def encoder_cudagraph_forward(
self,
inputs: dict[str, torch.Tensor],
path: str = "default",
**kwargs: Any,
) -> torch.Tensor:
pixel_values = inputs["pixel_values"]
pixel_position_ids = inputs["pixel_position_ids"]
gather_indices = inputs["gather_indices"]
pad_tensor = (pixel_position_ids == -1).all(dim=-1)
vt = self.vision_tower
inputs_embeds = vt.patch_embedder(
pixel_values,
pixel_position_ids,
pad_tensor,
).to(self.model_dtype)
encoder_outputs = vt.encoder(
inputs_embeds=inputs_embeds,
attention_mask=~pad_tensor,
pixel_position_ids=pixel_position_ids,
)
hidden_states = encoder_outputs.last_hidden_state
pool_ratio = getattr(vt.config, "pooling_kernel_size", 2) ** 2
per_item_output = pixel_values.shape[1] // pool_ratio
pooled_states, _ = vt.pooler(
hidden_states=hidden_states,
pixel_position_ids=pixel_position_ids,
padding_positions=pad_tensor,
output_length=per_item_output,
)
if getattr(vt.config, "standardize", False):
pooled_states = (pooled_states - vt.std_bias) * vt.std_scale
flat_pooled = pooled_states.reshape(-1, pooled_states.shape[-1])
gathered_states = flat_pooled[gather_indices]
# Cast to the projection layer's dtype to resolve mixed-precision crash
target_dtype = self.embed_vision.embedding_projection.weight.dtype
gathered_states = gathered_states.to(target_dtype)
flat_proj_embs = self.embed_vision(
inputs_embeds=gathered_states.unsqueeze(0)
).squeeze(0)
return flat_proj_embs
def encoder_eager_forward(
self,
mm_kwargs: dict[str, Any],
path: str = "default",
**kwargs: Any,
) -> torch.Tensor:
modality = self.get_input_modality(mm_kwargs)
if modality == "image":
image_input = self._parse_and_validate_image_input(**mm_kwargs)
assert image_input is not None
embeddings = self._process_image_input(image_input)
elif modality == "video":
video_input = self._parse_and_validate_video_input(**mm_kwargs)
assert video_input is not None
embeddings = self._process_video_input(video_input)
else:
raise ValueError(f"Unsupported modality: {modality}")
return torch.cat(embeddings, dim=0)
def embed_input_ids(
self,
input_ids: torch.Tensor,
multimodal_embeddings: MultiModalEmbeddings | None = None,
*,
is_multimodal: torch.Tensor | None = None,
) -> torch.Tensor:
# Cache per-layer embeddings (PLE) for the language model's
# forward pass. During profiling embed_input_ids is not called,
# so the pre-allocated zeros are used instead.
if self.per_layer_embeddings is not None:
# Mask multimodal tokens (image/audio) to 0 for PLE
# computation (using token_type_ids == 0 as text_mask).
# Replicate this: map image token positions to token 0.
if is_multimodal is not None:
ple_input_ids = torch.where(
is_multimodal.to(input_ids.device, non_blocking=True),
torch.zeros_like(input_ids),
input_ids,
)
else:
ple_input_ids = input_ids
per_layer_inputs = self.language_model.model.get_per_layer_inputs(
ple_input_ids
)
if per_layer_inputs is not None:
per_layer_inputs = per_layer_inputs.reshape(
-1,
self.config.text_config.num_hidden_layers,
self.config.text_config.hidden_size_per_layer_input,
)
self.per_layer_embeddings[: per_layer_inputs.shape[0]].copy_(
per_layer_inputs
)
if multimodal_embeddings is None or is_multimodal is None:
return super().embed_input_ids(input_ids)
return super().embed_input_ids(
input_ids,
multimodal_embeddings=multimodal_embeddings,
is_multimodal=is_multimodal,
)
# ------------------------------------------------------------------ #
# Forward
# ------------------------------------------------------------------ #
def forward(
self,
input_ids: torch.Tensor,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
**kwargs: object,
) -> IntermediateTensors:
if intermediate_tensors is not None:
inputs_embeds = None
# Select the pre-cached PLEs for this batch (None when PLE
# is disabled for variants without PLE).
per_layer_inputs = (
self.per_layer_embeddings[: inputs_embeds.shape[0]]
if self.per_layer_embeddings is not None and inputs_embeds is not None
else None
)
# Gemma4 bidi: clear mm_prefix_range for full_attention layers.
# Must run here (outside @support_torch_compile boundary) because
# _run_decoder_layers is inside a compiled graph where Python
# side effects are eliminated.
self._clear_mm_prefix_for_full_attn_layers()
hidden_states = self.language_model.model(
input_ids,
positions,
per_layer_inputs=per_layer_inputs,
intermediate_tensors=intermediate_tensors,
inputs_embeds=inputs_embeds,
**kwargs,
)
return hidden_states
def compute_logits(
self,
hidden_states: torch.Tensor,
) -> torch.Tensor | None:
logits = self.language_model.compute_logits(hidden_states)
if logits is not None and self._suppress_token_ids:
# Cache a per-device index tensor for the (static) suppressed-token
# set and use `index_fill_`, so neither the Python-list indices nor
# the scalar fill value take a host roundtrip per call.
cache = getattr(self, "_suppress_token_ids_cache", None)
if cache is None:
cache = {}
self._suppress_token_ids_cache = cache
suppress_idx = cache.get(logits.device)
if suppress_idx is None:
suppress_idx = async_tensor_h2d(
self._suppress_token_ids, dtype=torch.long, device=logits.device
)
cache[logits.device] = suppress_idx
logits.index_fill_(1, suppress_idx, -float("inf"))
return logits
# ------------------------------------------------------------------ #
# Bidirectional attention helpers
# ------------------------------------------------------------------ #
def _clear_mm_prefix_for_full_attn_layers(self) -> None:
"""Clear mm_prefix_range for non-sliding layers.
Gemma4 with use_bidirectional_attention='vision' applies
bidirectional attention only to sliding_attention layers.
Full attention layers use plain causal masking.
Uses _full_attn_layer_idxs (precomputed in __init__) for O(1)
lookup instead of per-call regex parsing.
"""
if not self._full_attn_layer_idxs:
return
from vllm.forward_context import get_forward_context
attn_metadata = get_forward_context().attn_metadata
if attn_metadata is None:
return
def _process(metadata_dict: dict) -> None:
for layer_name, metadata in metadata_dict.items():
if ".layers." not in layer_name:
continue
try:
layer_idx = int(layer_name.split(".layers.")[1].split(".")[0])
except (ValueError, IndexError):
continue
if layer_idx in self._full_attn_layer_idxs:
if hasattr(metadata, "mm_prefix_range"):
metadata.mm_prefix_range = None
if hasattr(metadata, "mm_prefix_range_tensor"):
metadata.mm_prefix_range_tensor = None
if hasattr(metadata, "mm_prefix_query_range_tensor"):
metadata.mm_prefix_query_range_tensor = None
if isinstance(attn_metadata, list):
for ub_metadata in attn_metadata:
_process(ub_metadata)
elif isinstance(attn_metadata, dict):
_process(attn_metadata)
# ------------------------------------------------------------------ #
# Weight loading
# ------------------------------------------------------------------ #
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
# Some checkpoints have vestigial embed_vision.embedding and
# embed_audio.embedding weights from the Gemma3n architecture
# that are not used by Gemma4's MultimodalEmbedder (which only
# has embedding_projection + embedding_post_projection_norm).
ignore_prefixes = [
"embed_vision.embedding.",
"embed_audio.embedding.",
]
# Models without audio tower should skip audio weights entirely.
if self.audio_tower is None:
ignore_prefixes.extend(
[
"audio_tower.",
"embed_audio.",
]
)
loader = AutoWeightsLoader(
self,
ignore_unexpected_prefixes=ignore_prefixes,
)
return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
# ------------------------------------------------------------------ #
# LoRA / multimodal mapping
# ------------------------------------------------------------------ #
def get_mm_mapping(self) -> MultiModelKeys:
"""Get the module prefix mapping for multimodal models."""
connectors = ["embed_vision"]
tower_models = ["vision_tower"]
if self.audio_tower is not None:
connectors.append("embed_audio")
tower_models.append("audio_tower")
return MultiModelKeys.from_string_field(
language_model="language_model",
connector=connectors,
tower_model=tower_models,
)
def get_mm_lora_token_counts(
self,
*,
modality: str,
mm_kwargs: MultiModalKwargsItem | None,
num_mm_embeds: int,
) -> tuple[int, int | None]:
tower_tokens: int | None = None
connector_tokens: int | None = None
if modality in ("image", "video"):
vision_config = self.config.vision_config
pooling_k2 = vision_config.pooling_kernel_size**2
if modality == "image":
pixel_values_key = "pixel_values"
max_soft_tokens = int(vision_config.default_output_length)
mm_processor_kwargs = getattr(
getattr(self, "multimodal_config", None),
"mm_processor_kwargs",
None,
)
if isinstance(mm_processor_kwargs, Mapping):
val, _ = _get_max_soft_tokens(mm_processor_kwargs)
if isinstance(val, int) and val in _SUPPORTED_SOFT_TOKENS:
max_soft_tokens = val
else:
pixel_values_key = "pixel_values_videos"
max_soft_tokens = _VIDEO_MAX_SOFT_TOKENS
tower_tokens = max_soft_tokens * pooling_k2 if modality == "image" else None
connector_tokens = num_mm_embeds
if tower_tokens is None and mm_kwargs is not None:
field = mm_kwargs.get(pixel_values_key)
if field is not None:
data = field.data
if isinstance(data, torch.Tensor) and data.ndim >= 2:
tower_tokens = int(math.prod(data.shape[:-1]))
if tower_tokens is None:
min_soft_tokens = min(_SUPPORTED_SOFT_TOKENS)
tower_tokens = (
math.ceil(num_mm_embeds / min_soft_tokens)
* max_soft_tokens
* pooling_k2
)
elif modality == "audio":
tower_tokens = num_mm_embeds
connector_tokens = num_mm_embeds
if mm_kwargs is not None:
field = mm_kwargs.get("input_features_padded")
if field is not None:
data = field.data
if isinstance(data, torch.Tensor) and data.ndim >= 2:
batch_size = math.prod(data.shape[:-2])
audio_tokens = batch_size * math.ceil(data.shape[-2] / 4)
tower_tokens = audio_tokens
connector_tokens = audio_tokens
else:
raise ValueError(f"Unsupported modality: {modality}")
assert tower_tokens is not None
return tower_tokens, connector_tokens
@classmethod
def get_placeholder_str(cls, modality: str, i: int) -> str | None:
if modality == "image":
return "<image_soft_token>"
if modality == "audio":
return "<audio_soft_token>"
if modality == "video":
return "<|video|>"
raise ValueError(f"Unsupported modality: {modality}")