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

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

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

Synced Nov 3, 04:15 UTC

[Perf][Gemma4] Batch vision encoder calls for image and video processing (#43169)

Signed-off-by: Luciano Martins <lucianommartins@users.noreply.github.com> Co-authored-by: Luciano Martins <lucianommartins@users.noreply.github.com>

Luciano Martins committed May 21, 2026 at 01:22 UTC 2b75a73b8e23f5df6de92d01a191e059424487e3
1 file changed +180 -86
vllm/model_executor/models/gemma4_mm.py
+180 -86
@@ -61,6 +61,7 @@ from vllm.multimodal.processing.processor import (
61 PromptUpdate,
62 PromptUpdateDetails,
63 )
64 +from vllm.platforms import current_platform
65 from vllm.sequence import IntermediateTensors
66 from vllm.utils.tensor_schema import TensorSchema, TensorShape
67
@@ -960,6 +961,9 @@ class Gemma4ForConditionalGeneration(
961 self.embed_vision = Gemma4MultimodalEmbedder(
962 config.vision_config, config.text_config
963 )
964 + # Lazy-initialized on first encoder call (see _encoder_max_batch).
965 + self._encoder_budget_bytes = 0
966 + self._encoder_bytes_per_patch = 0
967
968 # ---- Audio tower (variants with audio_config) ----
969 if config.audio_config is not None:
@@ -1100,6 +1104,19 @@ class Gemma4ForConditionalGeneration(
1104 )
1105 return mm_input_by_modality
1106
1107 + def _encoder_max_batch(self, patches_per_item: int) -> int:
1108 + """Max items per encoder call given per-item patch count."""
1109 + if self._encoder_budget_bytes == 0:
1110 + total_mem = current_platform.get_device_total_memory()
1111 + self._encoder_budget_bytes = int(total_mem * 0.05)
1112 + logger.info(
1113 + "Encoder memory budget: %.1fGB (total=%.1fGB)",
1114 + self._encoder_budget_bytes / 1024**3,
1115 + total_mem / 1024**3,
1116 + )
1117 + cost = patches_per_item * self._encoder_bytes_per_patch
1118 + return max(1, self._encoder_budget_bytes // cost) if cost > 0 else 1
1119 +
1120 # ------------------------------------------------------------------ #
1121 # Image processing
1122 # ------------------------------------------------------------------ #
@@ -1108,73 +1125,103 @@ class Gemma4ForConditionalGeneration(
1125 self,
1126 image_input: Gemma4ImageInputs,
1127 ) -> list[torch.Tensor]:
1128 + """Batch-encode images through the vision tower.
1129 +
1130 + Groups images by patch count (resolution bucket) so each
1131 + encoder call processes a uniform-shape batch with no
1132 + cross-resolution padding. Pooling and projection are then
1133 + applied over a single concatenated tensor for all images.
1134 + """
1135 pixel_values = image_input["pixel_values"]
1136 pixel_position_ids = image_input["pixel_position_ids"]
1137
1114 - # The HF image processor now outputs pre-patchified data:
1115 - # pixel_values: (num_images, max_patches, patch_pixels)
1116 - # pixel_position_ids: (num_images, max_patches, 2)
1117 - # We call the vision tower's forward() directly, which handles
1118 - # patch embedding, encoding, pooling, padding removal, and
1119 - # optional standardization internally.
1138 vt = self.vision_tower
1139 pooling_k2 = self.config.vision_config.pooling_kernel_size**2
1140
1123 - # TODO: Move this per-image loop into the input processor to
1124 - # reduce dynamism at the model runner / engine core. This
1125 - # requires spatially padding all images to uniform (H_max,
1126 - # W_max) in _call_hf_processor() so they arrive as a single
1127 - # stacked tensor, tracking padded regions via image_sizes
1128 - # metadata, and validating numerical equivalence with the
1129 - # current per-image path.
1130 - #
1141 # Concurrent requests with different image resolutions may
1142 # arrive as a list of per-image tensors, while same-resolution
1133 - # batches may arrive as a stacked tensor. Both forms are
1134 - # iterable over the per-image dimension.
1135 -
1136 - # Process each image individually through the vision tower.
1137 - # The vision tower's forward() strips padding and returns a
1138 - # flat tensor of valid tokens. We process per-image to get
1139 - # variable-length outputs matching the dynamic token count
1140 - # from get_image_repl.
1141 - per_image_features = []
1142 - for pv, pp in zip(pixel_values, pixel_position_ids, strict=True):
1143 - pv = pv.unsqueeze(0) # (1, max_patches, patch_pixels)
1144 - pp = pp.unsqueeze(0) # (1, max_patches, 2)
1145 -
1146 - # Derive the pooler's output_length from the total patch
1147 - # count (including padding). The vision tower encoder
1148 - # processes ALL patches — padding patches get zero hidden
1149 - # states but still occupy sequence positions. The pooler's
1150 - # _avg_pool_by_positions requires:
1151 - # input_seq_len / output_length == k²
1152 - # where k == pooling_kernel_size. The image processor
1153 - # allocates max_patches = max_soft_tokens * k² total slots,
1154 - # so output_length = max_patches / k² == max_soft_tokens.
1155 - # Without this, the pooler falls back to
1156 - # config.image_seq_length (e.g. 280), which fails when a
1157 - # different max_soft_tokens was used at preprocessing time.
1158 - max_patches = pv.shape[1]
1159 - output_length = max_patches // pooling_k2
1160 -
1161 - vt_output = vt(pv, pp, output_length=output_length)
1162 - # last_hidden_state: (num_valid_tokens, hidden_size)
1163 - # — already flat with padding stripped by the vision tower
1164 - per_image_features.append(vt_output.last_hidden_state)
1165 -
1166 - # Project each image's features into LM embedding space.
1167 - # Per-image loop is required because images have variable
1168 - # token counts after padding removal.
1169 - # Cast to match the projection layer's dtype (model may be
1170 - # bf16 while the vision tower outputs fp32).
1171 - target_dtype = self.embed_vision.embedding_projection.weight.dtype
1172 - return [
1173 - self.embed_vision(inputs_embeds=img.unsqueeze(0).to(target_dtype)).squeeze(
1174 - 0
1143 + # batches may arrive as a stacked tensor.
1144 + buckets: dict[int, list[tuple[int, torch.Tensor, torch.Tensor]]] = {}
1145 + total_images = (
1146 + len(pixel_values)
1147 + if isinstance(pixel_values, list)
1148 + else pixel_values.shape[0]
1149 + )
1150 +
1151 + for idx in range(total_images):
1152 + pv = pixel_values[idx]
1153 + pp = pixel_position_ids[idx]
1154 + buckets.setdefault(pv.shape[0], []).append((idx, pv, pp))
1155 +
1156 + # Encode each resolution bucket in memory-safe chunks.
1157 + last_hidden_states_map: dict[int, torch.Tensor] = {}
1158 + for patches, items in buckets.items():
1159 + max_batch_size = min(len(items), self._encoder_max_batch(patches))
1160 +
1161 + for chunk_idx in range(0, len(items), max_batch_size):
1162 + chunk_items = items[chunk_idx : chunk_idx + max_batch_size]
1163 +
1164 + pv_tensor = torch.cat(
1165 + [item[1].unsqueeze(0) for item in chunk_items], dim=0
1166 + )
1167 + pp_tensor = torch.cat(
1168 + [item[2].unsqueeze(0) for item in chunk_items], dim=0
1169 + )
1170 + pad_tensor = (pp_tensor == -1).all(dim=-1)
1171 +
1172 + inputs_embeds = vt.patch_embedder(pv_tensor, pp_tensor, pad_tensor)
1173 + encoder_outputs = vt.encoder(
1174 + inputs_embeds=inputs_embeds,
1175 + attention_mask=~pad_tensor,
1176 + pixel_position_ids=pp_tensor,
1177 + )
1178 + hidden_states = encoder_outputs.last_hidden_state
1179 +
1180 + for i, (orig_idx, _, _) in enumerate(chunk_items):
1181 + last_hidden_states_map[orig_idx] = hidden_states[i]
1182 +
1183 + # Pool per image to strip padding and reduce spatial resolution.
1184 + all_valid_states: list[torch.Tensor] = [None] * total_images # type: ignore[list-item]
1185 + valid_lens = [0] * total_images
1186 +
1187 + for orig_idx in range(total_images):
1188 + chunk_hidden = last_hidden_states_map[orig_idx]
1189 + output_length = chunk_hidden.shape[0] // pooling_k2
1190 +
1191 + single_hidden = chunk_hidden.unsqueeze(0)
1192 + single_pos_ids = pixel_position_ids[orig_idx].unsqueeze(0)
1193 + padding_positions = (single_pos_ids == -1).all(dim=-1)
1194 +
1195 + pooled_states, valid_mask = vt.pooler(
1196 + hidden_states=single_hidden,
1197 + pixel_position_ids=single_pos_ids,
1198 + padding_positions=padding_positions,
1199 + output_length=output_length,
1200 )
1176 - for img in per_image_features
1177 - ]
1201 + valid_states = pooled_states[valid_mask]
1202 +
1203 + if getattr(vt.config, "standardize", False):
1204 + valid_states = (valid_states - vt.std_bias) * vt.std_scale
1205 +
1206 + all_valid_states[orig_idx] = valid_states
1207 + valid_lens[orig_idx] = valid_states.shape[0]
1208 +
1209 + target_dtype = self.embed_vision.embedding_projection.weight.dtype
1210 +
1211 + # Project all images in a single batched call.
1212 + flat_valid_states = torch.cat(all_valid_states, dim=0).to(target_dtype)
1213 + flat_proj_embs = self.embed_vision(
1214 + inputs_embeds=flat_valid_states.unsqueeze(0)
1215 + ).squeeze(0)
1216 +
1217 + # Split back into per-image tensors (slicing returns views).
1218 + per_image_embeddings: list[torch.Tensor] = []
1219 + offset = 0
1220 + for length in valid_lens:
1221 + per_image_embeddings.append(flat_proj_embs[offset : offset + length])
1222 + offset += length
1223 +
1224 + return per_image_embeddings
1225
1226 # ------------------------------------------------------------------ #
1227 # Video processing (frames through vision tower)
@@ -1184,16 +1231,16 @@ class Gemma4ForConditionalGeneration(
1231 self,
1232 video_input: dict[str, torch.Tensor],
1233 ) -> list[torch.Tensor]:
1187 - """Process video frames through the vision tower.
1234 + """Batch-encode video frames through the vision tower.
1235
1189 - Reuses the image processing pipeline — Gemma4 has no separate
1190 - video tower; video frames are just images at lower resolution
1191 - (max_soft_tokens=70).
1236 + Gemma4 has no separate video tower; video frames are images at
1237 + lower resolution (max_soft_tokens=70). All frames across all
1238 + videos in the batch are encoded together in chunks, then pooled
1239 + and projected in a single batched call.
1240
1241 Returns one concatenated embedding tensor per video (not per
1194 - frame), because vLLM treats one video as one multimodal item.
1195 - The flat_from_sizes field config groups all frames of a video
1196 - together, so embed_multimodal must return one tensor per video.
1242 + frame), matching the flat_from_sizes grouping that vLLM expects
1243 + for embed_multimodal.
1244 """
1245 pixel_values = video_input["pixel_values_videos"]
1246 pixel_position_ids = video_input["pixel_position_ids_videos"]
@@ -1203,35 +1250,74 @@ class Gemma4ForConditionalGeneration(
1250 pooling_k2 = self.config.vision_config.pooling_kernel_size**2
1251 target_dtype = self.embed_vision.embedding_projection.weight.dtype
1252
1206 - # Split flat tensors into per-video chunks
1253 if isinstance(frame_counts, torch.Tensor):
1254 fc_list = frame_counts.tolist()
1255 else:
1256 fc_list = list(frame_counts)
1257
1212 - pv_per_video = torch.split(pixel_values, fc_list, dim=0)
1213 - pp_per_video = torch.split(pixel_position_ids, fc_list, dim=0)
1258 + total_frames = pixel_values.shape[0]
1259 + max_batch_size = min(
1260 + total_frames, self._encoder_max_batch(pixel_values.shape[1])
1261 + )
1262
1215 - per_video_embeddings = []
1216 - for pv_chunk, pp_chunk in zip(pv_per_video, pp_per_video):
1217 - frame_embs = []
1218 - for i in range(pv_chunk.shape[0]):
1219 - pv = pv_chunk[i].unsqueeze(0)
1220 - pp = pp_chunk[i].unsqueeze(0)
1263 + padding_positions = (pixel_position_ids == -1).all(dim=-1)
1264
1222 - max_patches = pv.shape[1]
1223 - output_length = max_patches // pooling_k2
1265 + # Encode frames in chunks bounded by _encoder_max_batch.
1266 + last_hidden_states_list: list[torch.Tensor] = []
1267 + for i in range(0, total_frames, max_batch_size):
1268 + pv_chunk = pixel_values[i : i + max_batch_size]
1269 + pp_chunk = pixel_position_ids[i : i + max_batch_size]
1270 + pad_chunk = padding_positions[i : i + max_batch_size]
1271
1225 - vt_output = vt(pv, pp, output_length=output_length)
1226 - frame_emb = self.embed_vision(
1227 - inputs_embeds=(
1228 - vt_output.last_hidden_state.unsqueeze(0).to(target_dtype)
1229 - )
1230 - ).squeeze(0)
1231 - frame_embs.append(frame_emb)
1272 + inputs_embeds = vt.patch_embedder(pv_chunk, pp_chunk, pad_chunk)
1273 + encoder_outputs = vt.encoder(
1274 + inputs_embeds=inputs_embeds,
1275 + attention_mask=~pad_chunk,
1276 + pixel_position_ids=pp_chunk,
1277 + )
1278 + last_hidden_states_list.append(encoder_outputs.last_hidden_state)
1279 +
1280 + last_hidden_states = torch.cat(last_hidden_states_list, dim=0)
1281
1233 - # Concatenate all frames of this video into one tensor.
1234 - per_video_embeddings.append(torch.cat(frame_embs, dim=0))
1282 + # Pool per frame to strip padding and reduce spatial resolution.
1283 + output_length = pixel_values.shape[1] // pooling_k2
1284 + all_frame_valid_states: list[torch.Tensor] = []
1285 + frame_valid_lens: list[int] = []
1286 +
1287 + for i in range(total_frames):
1288 + single_hidden = last_hidden_states[i].unsqueeze(0)
1289 + single_pos_ids = pixel_position_ids[i].unsqueeze(0)
1290 + single_pad_pos = padding_positions[i].unsqueeze(0)
1291 +
1292 + pooled_states, valid_mask = vt.pooler(
1293 + hidden_states=single_hidden,
1294 + pixel_position_ids=single_pos_ids,
1295 + padding_positions=single_pad_pos,
1296 + output_length=output_length,
1297 + )
1298 + valid_states = pooled_states[valid_mask]
1299 +
1300 + if getattr(vt.config, "standardize", False):
1301 + valid_states = (valid_states - vt.std_bias) * vt.std_scale
1302 +
1303 + all_frame_valid_states.append(valid_states)
1304 + frame_valid_lens.append(valid_states.shape[0])
1305 +
1306 + # Project all frames in a single batched call.
1307 + flat_valid_states = torch.cat(all_frame_valid_states, dim=0).to(target_dtype)
1308 + flat_proj_embs = self.embed_vision(
1309 + inputs_embeds=flat_valid_states.unsqueeze(0)
1310 + ).squeeze(0)
1311 +
1312 + # Regroup into per-video tensors (slicing returns views).
1313 + per_video_embeddings: list[torch.Tensor] = []
1314 + frame_idx = 0
1315 + offset = 0
1316 + for count in fc_list:
1317 + video_tokens = sum(frame_valid_lens[frame_idx : frame_idx + count])
1318 + per_video_embeddings.append(flat_proj_embs[offset : offset + video_tokens])
1319 + offset += video_tokens
1320 + frame_idx += count
1321
1322 return per_video_embeddings
1323
@@ -1452,7 +1538,15 @@ class Gemma4ForConditionalGeneration(
1538 self,
1539 ignore_unexpected_prefixes=ignore_prefixes,
1540 )
1455 - return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
1541 + loaded = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
1542 +
1543 + # Per-patch activation cost for dynamic encoder batch sizing.
1544 + vis_cfg = self.config.vision_config
1545 + self._encoder_bytes_per_patch = (
1546 + vis_cfg.hidden_size * 2 * vis_cfg.num_hidden_layers
1547 + )
1548 +
1549 + return loaded
1550
1551 # ------------------------------------------------------------------ #
1552 # LoRA / multimodal mapping