main
py 464 lines 18.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Gemma 4 Unified multimodal model (encoder-free image + audio + video).
4
5 The Unified Gemma4 variant has no SigLIP vision tower and no audio tower.
6 Raw pixel patches are projected directly to LM space via a Dense+LayerNorm
7 pipeline with factorized 2D positional embeddings (Gemma4UnifiedVisionEmbedder),
8 then routed through the same Gemma4MultimodalEmbedder used by the tower-based
9 variant. Audio inputs are raw waveform frames projected directly through the
10 multimodal embedder.
11
12 This module subclasses Gemma4ForConditionalGeneration from gemma4_mm rather
13 than reimplementing it from scratch. Only the multimodal pipeline differs;
14 the language model, MTP integration, bidirectional attention helpers,
15 embedding/forward path, and LoRA support are all inherited unchanged.
16 """
17
18 import math
19 from collections.abc import Iterable, Mapping
20
21 import torch
22 from torch import nn
23 from transformers.models.gemma4_unified.configuration_gemma4_unified import (
24 Gemma4UnifiedConfig,
25 )
26 from transformers.models.gemma4_unified.processing_gemma4_unified import (
27 Gemma4UnifiedProcessor,
28 )
29
30 from vllm.config import VllmConfig
31 from vllm.model_executor.layers.linear import ColumnParallelLinear
32 from vllm.model_executor.models.gemma3n_mm import batch_audio_features
33 from vllm.model_executor.models.gemma4 import Gemma4ForCausalLM
34 from vllm.model_executor.models.gemma4_mm import (
35 _SUPPORTED_SOFT_TOKENS,
36 _VIDEO_MAX_FRAMES,
37 _VIDEO_MAX_SOFT_TOKENS,
38 Gemma4AudioInputs,
39 Gemma4DummyInputsBuilder,
40 Gemma4ForConditionalGeneration,
41 Gemma4ImageInputs,
42 Gemma4ImagePixelInputs,
43 Gemma4MultimodalEmbedder,
44 Gemma4MultiModalProcessor,
45 Gemma4ProcessingInfo,
46 Gemma4VideoInputs,
47 _get_max_soft_tokens,
48 )
49 from vllm.model_executor.models.module_mapping import MultiModelKeys
50 from vllm.multimodal import MULTIMODAL_REGISTRY
51
52 from .utils import (
53 AutoWeightsLoader,
54 WeightsMapper,
55 init_vllm_registered_model,
56 maybe_prefix,
57 )
58
59 # Re-export so tests/code targeting the unified variant can import from here
60 # rather than reaching into gemma4_mm.
61 __all__ = [
62 "Gemma4ImagePixelInputs",
63 "Gemma4UnifiedVisionEmbedder",
64 "Gemma4UnifiedProcessingInfo",
65 "Gemma4UnifiedForConditionalGeneration",
66 ]
67
68
69 # ---------------------------------------------------------------------------
70 # Encoder-free vision embedder
71 # ---------------------------------------------------------------------------
72
73
74 class Gemma4UnifiedVisionEmbedder(nn.Module):
75 """Encoder-free vision embedder for Gemma4 Unified variants.
76
77 Projects raw pixel patches to LM space via dense projection and
78 factorized 2D positional embeddings. Replaces the SigLIP vision
79 tower used by the tower-based Gemma4 variant.
80
81 Pipeline: raw patches → LN₁ → Dense → LN₂ → +factorized_posemb → LN₃.
82 """
83
84 def __init__(self, config, quant_config=None, prefix=""):
85 super().__init__()
86 patch_dim = config.model_patch_size**2 * 3
87 mm_embed_dim = config.mm_embed_dim
88
89 self.patch_ln1 = nn.LayerNorm(patch_dim)
90 self.patch_dense = ColumnParallelLinear(
91 patch_dim,
92 mm_embed_dim,
93 bias=True,
94 quant_config=quant_config,
95 prefix=f"{prefix}.patch_dense",
96 gather_output=True,
97 )
98 self.patch_ln2 = nn.LayerNorm(mm_embed_dim)
99
100 self.pos_embedding = nn.Parameter(
101 torch.zeros(config.mm_posemb_size, 2, mm_embed_dim)
102 )
103 self.pos_norm = nn.LayerNorm(mm_embed_dim)
104
105 def _factorized_posemb(self, positions_xy: torch.Tensor) -> torch.Tensor:
106 clamped_pos = positions_xy.clamp(min=0).long()
107 valid_mask = positions_xy != -1
108
109 pos_embs = torch.zeros(
110 *positions_xy.shape[:-1],
111 self.pos_embedding.shape[-1],
112 device=positions_xy.device,
113 dtype=self.pos_embedding.dtype,
114 )
115 for i in range(2):
116 axis_pe = self.pos_embedding[:, i, :][clamped_pos[..., i]]
117 mask = valid_mask[..., i].unsqueeze(-1).to(axis_pe.dtype)
118 pos_embs = pos_embs + (axis_pe * mask)
119 return pos_embs
120
121 def forward(
122 self,
123 pixel_values: torch.Tensor,
124 pixel_position_ids: torch.Tensor,
125 ) -> torch.Tensor:
126 hidden_states = self.patch_ln1(pixel_values.to(self.pos_embedding.dtype))
127 hidden_states, _ = self.patch_dense(hidden_states)
128 hidden_states = self.patch_ln2(hidden_states)
129
130 pos_embs = self._factorized_posemb(pixel_position_ids)
131 hidden_states = hidden_states + pos_embs
132 hidden_states = self.pos_norm(hidden_states)
133 return hidden_states
134
135
136 # ---------------------------------------------------------------------------
137 # Processing info
138 # ---------------------------------------------------------------------------
139
140
141 class Gemma4UnifiedProcessingInfo(Gemma4ProcessingInfo):
142 """ProcessingInfo for the Gemma4 Unified variant.
143
144 Two field-name differences from the tower-based parent:
145 * config → ``Gemma4UnifiedConfig`` (not ``Gemma4Config``)
146 * vision_config.``num_soft_tokens`` (not ``default_output_length``)
147
148 Everything else (token sequencing, audio limits, video frame budget,
149 parser construction) is inherited unchanged.
150 """
151
152 def get_hf_config(self):
153 return self.ctx.get_hf_config(Gemma4UnifiedConfig)
154
155 def get_hf_processor(self, **kwargs: object) -> Gemma4UnifiedProcessor:
156 return self.ctx.get_hf_processor(
157 Gemma4UnifiedProcessor,
158 **kwargs,
159 )
160
161 def get_mm_max_tokens_per_item(
162 self, seq_len: int, mm_counts: Mapping[str, int]
163 ) -> Mapping[str, int] | None:
164 config = self.get_hf_config()
165 # Unified field is `num_soft_tokens`. Tower-based parent uses
166 # `default_output_length`, hence the override.
167 tokens_per_image = config.vision_config.num_soft_tokens
168 merged_kwargs = self.ctx.get_merged_mm_kwargs({})
169 val, _ = _get_max_soft_tokens(merged_kwargs)
170 if isinstance(val, int) and val in _SUPPORTED_SOFT_TOKENS:
171 tokens_per_image = val
172 tokens: dict[str, int] = {"image": tokens_per_image}
173 if config.audio_config is not None:
174 processor = self.get_hf_processor()
175 tokens["audio"] = processor.audio_seq_length
176 num_frames = _VIDEO_MAX_FRAMES
177 mm_config = self.ctx.model_config.get_multimodal_config()
178 video_opts = mm_config.limit_per_prompt.get("video")
179 if video_opts is not None and video_opts.num_frames is not None:
180 num_frames = min(num_frames, video_opts.num_frames)
181 tokens["video"] = num_frames * (_VIDEO_MAX_SOFT_TOKENS + 2 + 6)
182 return tokens
183
184 def _compute_num_soft_tokens(
185 self,
186 image_width: int,
187 image_height: int,
188 max_soft_tokens: int | None = None,
189 ) -> int:
190 vision_cfg = self.get_hf_config().vision_config
191 patch_size = vision_cfg.patch_size
192 pooling_kernel_size = vision_cfg.pooling_kernel_size
193
194 if max_soft_tokens is None:
195 max_soft_tokens = vision_cfg.num_soft_tokens
196
197 unit = patch_size * pooling_kernel_size
198 max_patches = max_soft_tokens * pooling_kernel_size**2
199 num_patches_orig = (image_height / patch_size) * (image_width / patch_size)
200 scale = math.sqrt(max_patches / num_patches_orig)
201 target_h = max(unit, int(math.floor(image_height * scale / unit)) * unit)
202 target_w = max(unit, int(math.floor(image_width * scale / unit)) * unit)
203 num_patches = (target_h // patch_size) * (target_w // patch_size)
204 num_soft_tokens = num_patches // (pooling_kernel_size**2)
205 return min(num_soft_tokens, max_soft_tokens)
206
207
208 # ---------------------------------------------------------------------------
209 # Main model
210 # ---------------------------------------------------------------------------
211
212
213 @MULTIMODAL_REGISTRY.register_processor(
214 Gemma4MultiModalProcessor,
215 info=Gemma4UnifiedProcessingInfo,
216 dummy_inputs=Gemma4DummyInputsBuilder,
217 )
218 class Gemma4UnifiedForConditionalGeneration(Gemma4ForConditionalGeneration):
219 """Encoder-free Gemma4 (Unified) for conditional generation.
220
221 Inherits multimodal embedding routing, PLE handling, bidirectional
222 attention helpers, language-model forward, LoRA, and pipeline-parallel
223 support from :class:`Gemma4ForConditionalGeneration`. Overrides only:
224
225 * ``__init__`` — builds the encoder-free vision embedder instead of
226 SigLIP/audio towers (LightOnOCR-style: ``nn.Module.__init__`` +
227 full rebuild, no ``super().__init__()``).
228 * ``hf_to_vllm_mapper`` — adds the ``model.vision_embedder.`` prefix.
229 * ``_process_image_input`` / ``_process_video_input`` /
230 ``_process_audio_input`` — encoder-free projection paths.
231 * ``load_weights`` — ignore-prefix list excludes the absent towers.
232 * ``get_mm_mapping`` — no tower entries.
233 """
234
235 hf_to_vllm_mapper = WeightsMapper(
236 orig_to_new_prefix={
237 "model.embed_audio.": "embed_audio.",
238 "model.embed_vision.": "embed_vision.",
239 "model.language_model.": "language_model.model.",
240 "model.vision_embedder.": "vision_embedder.",
241 "lm_head.": "language_model.lm_head.",
242 "model": "language_model.model",
243 }
244 )
245
246 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
247 # LightOnOCR-style rebuild: do NOT call super().__init__ — that
248 # would build a SigLIP vision tower and an audio tower we don't
249 # need. Initialize nn.Module directly and assemble the
250 # encoder-free pipeline below.
251 nn.Module.__init__(self)
252 config = vllm_config.model_config.hf_config
253 quant_config = vllm_config.quant_config
254 multimodal_config = vllm_config.model_config.multimodal_config
255 self.config = config
256 self.quant_config = quant_config
257 self.multimodal_config = multimodal_config
258
259 # No towers — set to None so inherited load_weights / get_mm_mapping
260 # and any tower-aware logic short-circuits.
261 self.vision_tower = None
262 self.audio_tower = None
263
264 # ---- Encoder-free vision embedder ----
265 if config.vision_config is None:
266 raise ValueError("Gemma4 Unified requires a vision configuration")
267 self.vision_embedder = Gemma4UnifiedVisionEmbedder(
268 config.vision_config,
269 quant_config=quant_config,
270 prefix=maybe_prefix(prefix, "vision_embedder"),
271 )
272 self.embed_vision = Gemma4MultimodalEmbedder(
273 config.vision_config,
274 config.text_config,
275 )
276
277 # ---- Encoder-free audio embedder ----
278 self.embed_audio = (
279 Gemma4MultimodalEmbedder(
280 config.audio_config,
281 config.text_config,
282 )
283 if config.audio_config is not None
284 else None
285 )
286
287 # ---- Language model (vLLM optimised) ----
288 with self._mark_language_model(vllm_config):
289 self.language_model: Gemma4ForCausalLM = init_vllm_registered_model(
290 vllm_config=vllm_config,
291 hf_config=config.text_config,
292 prefix=maybe_prefix(prefix, "language_model"),
293 architectures=["Gemma4ForCausalLM"],
294 )
295
296 # PLE is disabled for the unified variant (text config defaults
297 # hidden_size_per_layer_input to 0). Skip the buffer.
298 ple_dim = getattr(
299 config.text_config,
300 "hidden_size_per_layer_input",
301 None,
302 )
303 if ple_dim is not None and ple_dim > 0:
304 embed = self.language_model.model.embed_tokens
305 self.per_layer_embeddings = torch.zeros(
306 vllm_config.scheduler_config.max_num_batched_tokens,
307 config.text_config.num_hidden_layers,
308 ple_dim,
309 device=next(embed.parameters()).device,
310 dtype=vllm_config.model_config.dtype,
311 )
312 else:
313 self.per_layer_embeddings = None
314
315 self.make_empty_intermediate_tensors = (
316 self.language_model.make_empty_intermediate_tensors
317 )
318
319 # --- Precompute full-attention layer indices for bidi clearing ---
320 self._full_attn_layer_idxs: frozenset[int] = frozenset()
321 text_config = config.text_config
322 if getattr(text_config, "use_bidirectional_attention", None) == "vision":
323 layer_types = getattr(text_config, "layer_types", None)
324 if layer_types:
325 self._full_attn_layer_idxs = frozenset(
326 i for i, lt in enumerate(layer_types) if lt != "sliding_attention"
327 )
328
329 # --- MixtureOfExperts delegation to language_model ---
330 self.moe_layers = self.language_model.moe_layers
331 self.num_moe_layers = self.language_model.num_moe_layers
332 self.num_logical_experts = self.language_model.num_logical_experts
333 self.num_physical_experts = self.language_model.num_physical_experts
334 self.num_local_physical_experts = self.language_model.num_local_physical_experts
335 self.num_routed_experts = self.language_model.num_routed_experts
336 self.num_expert_groups = self.language_model.num_expert_groups
337 self.num_shared_experts = self.language_model.num_shared_experts
338 self.num_redundant_experts = self.language_model.num_redundant_experts
339 self.set_eplb_state = self.language_model.set_eplb_state
340
341 gen_cfg = vllm_config.model_config.try_get_generation_config()
342 self._suppress_token_ids = gen_cfg.get("suppress_tokens") if gen_cfg else None
343
344 # ------------------------------------------------------------------ #
345 # Multimodal processing (encoder-free overrides)
346 # ------------------------------------------------------------------ #
347
348 def _process_image_input(
349 self,
350 image_input: Gemma4ImageInputs,
351 ) -> list[torch.Tensor]:
352 """Project raw image patches directly to LM space.
353
354 No vision tower: each image's pre-patchified pixel values are
355 embedded via Gemma4UnifiedVisionEmbedder, projected through
356 Gemma4MultimodalEmbedder, and padding patches (pp == -1) are
357 stripped per image.
358 """
359 pixel_values = image_input["pixel_values"]
360 pixel_position_ids = image_input["pixel_position_ids"]
361 target_dtype = self.embed_vision.embedding_projection.weight.dtype
362
363 per_image_features: list[torch.Tensor] = []
364 for pv, pp in zip(pixel_values, pixel_position_ids, strict=True):
365 pv = pv.unsqueeze(0)
366 pp = pp.unsqueeze(0)
367 embedded = self.vision_embedder(pv, pp)
368 projected = self.embed_vision(embedded.to(target_dtype))
369 padding_mask = (pp.squeeze(0) == -1).all(dim=-1)
370 valid_features = projected.squeeze(0)[~padding_mask]
371 per_image_features.append(valid_features)
372 return per_image_features
373
374 def _process_video_input(
375 self,
376 video_input: Gemma4VideoInputs,
377 ) -> list[torch.Tensor]:
378 """Project video frames to LM space, one frame at a time.
379
380 Frames are split per video, each frame is embedded + projected,
381 and per-frame valid embeddings are concatenated per video.
382 """
383 pixel_values = video_input["pixel_values_videos"]
384 pixel_position_ids = video_input["pixel_position_ids_videos"]
385 frame_counts = video_input["video_frame_counts"]
386 target_dtype = self.embed_vision.embedding_projection.weight.dtype
387
388 if isinstance(frame_counts, torch.Tensor):
389 fc_list = frame_counts.tolist()
390 else:
391 fc_list = list(frame_counts)
392
393 pv_per_video = torch.split(pixel_values, fc_list, dim=0)
394 pp_per_video = torch.split(pixel_position_ids, fc_list, dim=0)
395
396 per_video_embeddings: list[torch.Tensor] = []
397 for pv_chunk, pp_chunk in zip(pv_per_video, pp_per_video):
398 frame_embs: list[torch.Tensor] = []
399 for i in range(pv_chunk.shape[0]):
400 pv = pv_chunk[i].unsqueeze(0)
401 pp = pp_chunk[i].unsqueeze(0)
402 embedded = self.vision_embedder(pv, pp)
403 projected = self.embed_vision(embedded.to(target_dtype))
404 padding_mask = (pp.squeeze(0) == -1).all(dim=-1)
405 frame_embs.append(projected.squeeze(0)[~padding_mask])
406 per_video_embeddings.append(torch.cat(frame_embs, dim=0))
407 return per_video_embeddings
408
409 def _process_audio_input(
410 self,
411 audio_input: Gemma4AudioInputs,
412 ) -> list[torch.Tensor]:
413 """Project raw waveform-frame features directly to LM space.
414
415 No audio tower: the per-frame raw features are passed straight
416 through the multimodal embedder, then padding is stripped.
417 """
418 input_features, input_features_mask = batch_audio_features(
419 audio_input["input_features_padded"],
420 audio_input["input_features_mask"],
421 )
422
423 assert self.embed_audio is not None
424 target_dtype = self.embed_audio.embedding_projection.weight.dtype
425 audio_features = self.embed_audio(input_features.to(target_dtype))
426 per_audio: list[torch.Tensor] = []
427 for enc, mask in zip(audio_features, input_features_mask, strict=True):
428 per_audio.append(enc[mask])
429 return per_audio
430
431 # ------------------------------------------------------------------ #
432 # Weight loading
433 # ------------------------------------------------------------------ #
434
435 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
436 ignore_prefixes = [
437 # Vestigial Gemma3n-style embedding tables not used by
438 # Gemma4MultimodalEmbedder (which has only projection + norm).
439 "embed_vision.embedding.",
440 "embed_audio.embedding.",
441 ]
442 if self.embed_audio is None:
443 ignore_prefixes.append("embed_audio.")
444
445 loader = AutoWeightsLoader(
446 self,
447 ignore_unexpected_prefixes=ignore_prefixes,
448 )
449 return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
450
451 # ------------------------------------------------------------------ #
452 # LoRA / multimodal mapping
453 # ------------------------------------------------------------------ #
454
455 def get_mm_mapping(self) -> MultiModelKeys:
456 """Module prefix mapping for the encoder-free model (no towers)."""
457 connectors = ["embed_vision"]
458 if self.embed_audio is not None:
459 connectors.append("embed_audio")
460 return MultiModelKeys.from_string_field(
461 language_model="language_model",
462 connector=connectors,
463 tower_model=[],
464 )