[Perf] Avoid more GPU<->CPU syncs in multimodal encoders (#52369)

Signed-off-by: Nick Hill <nickhill123@gmail.com>

Nick Hill committed Aug 14, 2026 at 10:40 UTC 3e3ceb1961d44252ff4a4f8e22364186d70c54f7
9 files changed +81 -72
vllm/model_executor/models/diffusion_gemma.py
+5 -2
@@ -867,7 +867,7 @@ class DiffusionGemmaModelState(ModelState):
867 self.diffusion_states.add_request(req_index)
868 if not new_req_data.req_id.startswith("_warmup_"):
869 prompt_len = len(new_req_data.prompt_token_ids)
870 - self.diffusion_states.prompt_len[req_index] = prompt_len
870 + self.diffusion_states.prompt_len[req_index].fill_(prompt_len)
871
872 def remove_request(self, req_id: str) -> None:
873 idx = self._req_id_to_index.pop(req_id, None)
@@ -1276,7 +1276,10 @@ class DiffusionSampler:
1276 # before canvas padding so phantom positions stay uniform.
1277 if num_decode > 0:
1278 top_k, top_p = self.sampling_states.get_top_k_top_p(
1279 - decode_slots.repeat_interleave(valid_canvas_len), decode_slots_np
1279 + decode_slots.repeat_interleave(
1280 + valid_canvas_len, output_size=int(valid_canvas_len_np.sum())
1281 + ),
1282 + decode_slots_np,
1283 )
1284 if top_k is not None or top_p is not None:
1285 logits = apply_top_k_top_p(logits.float(), top_k, top_p)
vllm/model_executor/models/ernie45_vl.py
+9 -5
@@ -72,6 +72,7 @@ from vllm.multimodal.processing import (
72 )
73 from vllm.sequence import IntermediateTensors
74 from vllm.utils.tensor_schema import TensorSchema, TensorShape
75 +from vllm.utils.torch_utils import async_tensor_h2d
76 from vllm.v1.attention.backends.registry import AttentionBackendEnum
77
78 from .ernie45_vl_moe import Ernie4_5_VLMoeForCausalLM
@@ -446,6 +447,9 @@ class Ernie4_5_VisionTransformer(nn.Module):
447 pos_ids = torch.cat(pos_ids, dim=0)
448 max_grid_size = grid_thw[:, 1:].max()
449 rotary_pos_emb_full = self.rotary_pos_emb(max_grid_size)
450 + # `pos_ids` is built on the host; stage it over non-blocking so the
451 + # gather below doesn't index a device tensor with a CPU one.
452 + pos_ids = pos_ids.to(rotary_pos_emb_full.device, non_blocking=True)
453 rotary_pos_emb = rotary_pos_emb_full[pos_ids].flatten(1)
454 return rotary_pos_emb
455
@@ -530,7 +534,7 @@ class Ernie4_5_VisionTransformer(nn.Module):
534 else:
535 max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
536
533 - cu_seqlens = cu_seqlens.to(device)
537 + cu_seqlens = cu_seqlens.to(device, non_blocking=True)
538
539 return {
540 "rotary_pos_emb": rotary_pos_emb,
@@ -806,8 +810,8 @@ class VariableResolutionResamplerModel(nn.Module):
810 b_offset + (temp_offset + 1) * spatial_size,
811 )
812 )
809 - slice_offsets = torch.tensor(np.concatenate(slice_offsets, axis=-1)).to(
810 - x.device
813 + slice_offsets = async_tensor_h2d(
814 + np.concatenate(slice_offsets, axis=-1), device=x.device
815 )
816
817 slice_offsets2 = []
@@ -823,8 +827,8 @@ class VariableResolutionResamplerModel(nn.Module):
827 b_offset + (temp_offset + 1) * spatial_size,
828 )
829 )
826 - slice_offsets2 = torch.tensor(np.concatenate(slice_offsets2, axis=-1)).to(
827 - x.device
830 + slice_offsets2 = async_tensor_h2d(
831 + np.concatenate(slice_offsets2, axis=-1), device=x.device
832 )
833
834 x_timestep_1 = torch.index_select(x, dim=0, index=slice_offsets)
vllm/model_executor/models/glm4_1v.py
+18 -37
@@ -546,14 +546,6 @@ class Glm4vVisionEmbeddings(nn.Module):
546 0, hidden_size, device=device, dtype=pos_embed_weight.dtype
547 )
548 else:
549 - # Convert inputs to tensors if needed
550 - if isinstance(lengths, list):
551 - lengths = async_tensor_h2d(lengths, device=device, dtype=torch.long)
552 - if not isinstance(image_shapes, torch.Tensor):
553 - image_shapes = async_tensor_h2d(
554 - image_shapes, device=device, dtype=torch.long
555 - )
556 -
549 # Prepare 2D position embedding
550 orig_size_sq = pos_embed_weight.shape[0]
551 orig_size = int(orig_size_sq**0.5)
@@ -564,32 +556,23 @@ class Glm4vVisionEmbeddings(nn.Module):
556 .to(device=device, dtype=torch.float32)
557 )
558
567 - # Calculate target dimensions for each patch
568 - # Add bounds checking for data parallel mode
569 - if len(lengths) > image_shapes.shape[0]:
570 - # In data parallel mode, some GPUs might not have all
571 - # image shapes
572 - # Use available image shapes, cycling if necessary
573 - target_h_list = []
574 - target_w_list = []
575 - for i in range(len(lengths)):
576 - # Cycle through available shapes
577 - shape_idx = i % image_shapes.shape[0]
578 - target_h_list.append(image_shapes[shape_idx, 1].repeat(lengths[i]))
579 - target_w_list.append(image_shapes[shape_idx, 2].repeat(lengths[i]))
580 - target_h = torch.cat(target_h_list).to(
581 - device=device, dtype=torch.float32
582 - )
583 - target_w = torch.cat(target_w_list).to(
584 - device=device, dtype=torch.float32
585 - )
586 - else:
587 - target_h = torch.cat(
588 - [image_shapes[i, 1].repeat(lengths[i]) for i in range(len(lengths))]
589 - ).to(device=device, dtype=torch.float32)
590 - target_w = torch.cat(
591 - [image_shapes[i, 2].repeat(lengths[i]) for i in range(len(lengths))]
592 - ).to(device=device, dtype=torch.float32)
559 + # Calculate target dimensions for each patch. `lengths` and
560 + # `image_shapes` are host data, so expand them with numpy and move
561 + # the result across once rather than per-image.
562 + # Shapes are cycled: in data parallel mode some GPUs might not
563 + # have all image shapes.
564 + shapes_np = np.asarray(image_shapes)
565 + shape_idx = np.arange(len(lengths)) % shapes_np.shape[0]
566 + target_h = async_tensor_h2d(
567 + np.repeat(shapes_np[shape_idx, 1], lengths),
568 + device=device,
569 + dtype=torch.float32,
570 + )
571 + target_w = async_tensor_h2d(
572 + np.repeat(shapes_np[shape_idx, 2], lengths),
573 + device=device,
574 + dtype=torch.float32,
575 + )
576
577 # Normalize coordinates to [-1, 1] range for grid_sample
578 h_coords = h_coords.to(device=device, dtype=torch.float32)
@@ -818,9 +801,7 @@ class Glm4vVisionTransformer(nn.Module):
801 )
802
803 lengths = [h * w] * t
821 - image_shapes = async_tensor_h2d(
822 - [[t, h, w]], dtype=torch.long, device=device
823 - )
804 + image_shapes = [[t, h, w]]
805
806 # Build the coordinates on the host (cheap integer math) but move
807 # them across pinned + non-blocking, so the consumer's
vllm/model_executor/models/interns1.py
+2 -2
@@ -424,7 +424,7 @@ class InternS1MultiModalProcessor(BaseMultiModalProcessor[InternS1ProcessingInfo
424 pixel_values=MultiModalFieldConfig.flat_from_sizes(
425 "image", image_num_patches
426 ),
427 - image_num_patches=MultiModalFieldConfig.batched("image"),
427 + image_num_patches=MultiModalFieldConfig.batched("image", keep_on_cpu=True),
428 image_embeds=MultiModalFieldConfig.batched("image"),
429 image_token_id=MultiModalFieldConfig.shared(
430 "image", num_images, keep_on_cpu=True
@@ -432,7 +432,7 @@ class InternS1MultiModalProcessor(BaseMultiModalProcessor[InternS1ProcessingInfo
432 pixel_values_videos=MultiModalFieldConfig.flat_from_sizes(
433 "video", video_num_patches
434 ),
435 - video_num_patches=MultiModalFieldConfig.batched("video"),
435 + video_num_patches=MultiModalFieldConfig.batched("video", keep_on_cpu=True),
436 video_token_id=MultiModalFieldConfig.shared(
437 "video", num_videos, keep_on_cpu=True
438 ),
vllm/model_executor/models/keye.py
+25 -17
@@ -59,6 +59,7 @@ from vllm.multimodal.processing import (
59 )
60 from vllm.sequence import IntermediateTensors
61 from vllm.utils.tensor_schema import TensorSchema, TensorShape
62 +from vllm.utils.torch_utils import async_tensor_h2d
63
64 from .interfaces import (
65 MultiModalEmbeddings,
@@ -608,7 +609,9 @@ class KeyeSiglipEncoder(nn.Module):
609 [height_position_ids, width_position_ids],
610 dim=-1,
611 )
611 - max_grid_size = pids.max() + 1
612 + # The ids are built from the grids above, so `h`/`w` bound them
613 + # and the table size is known on the host.
614 + max_grid_size = max(max(h, w) for _, h, w in flatten_image_grid_thw)
615 rope_emb_max_grid = self.rotary_pos_emb(max_grid_size)
616 rope_emb = rope_emb_max_grid[pids].flatten(1)
617 rope_emb = rope_emb.repeat(1, 2)
@@ -694,19 +697,19 @@ class KeyeSiglipVisionTransformer(nn.Module):
697
698 last_hidden_state = self.post_layernorm(last_hidden_state)
699
697 - sample_hidden_state = list()
700 if cu_seqlens is None:
701 raise ValueError(
702 "cu_seqlens cannot be None for "
703 "SiglipVisionTransformer output processing."
704 )
703 - for i in range(cu_seqlens.shape[0] - 1):
704 - start = cu_seqlens[i]
705 - end = cu_seqlens[i + 1]
706 - tensor = last_hidden_state[:, start:end, :].squeeze(0)
707 - sample_hidden_state.append(tensor)
708 -
709 - return sample_hidden_state
705 + # `cu_seqlens` is the running sum of the per-image `t * h * w`, so the
706 + # split sizes are known on the host and slicing needs no device read.
707 + split_sizes = [
708 + int(np.prod(thw)) for thw in self.encoder.flatten_list(image_grid_thw)
709 + ]
710 + return [
711 + tensor.squeeze(0) for tensor in last_hidden_state.split(split_sizes, dim=1)
712 + ]
713
714
715 class KeyeSiglipVisionModel(nn.Module):
@@ -1307,13 +1310,17 @@ class BaseKeyeModule(nn.Module, SupportsMultiModal):
1310 )
1311 else:
1312 pixel_values = image_input["pixel_values"].type(self.visual.dtype)
1313 + # These are all built on the host, so stage them over
1314 + # non-blocking rather than forcing a sync per transfer.
1315 siglip_position_ids = torch.concat(siglip_position_ids, dim=0).to(
1311 - pixel_values.device
1316 + pixel_values.device, non_blocking=True
1317 + )
1318 + cu_seqlens = async_tensor_h2d(
1319 + cu_seqlens, dtype=torch.int32, device=pixel_values.device
1320 )
1313 - cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int32).to(
1314 - pixel_values.device
1321 + sample_indices = torch.concat(sample_indices, dim=0).to(
1322 + pixel_values.device, non_blocking=True
1323 )
1316 - sample_indices = torch.concat(sample_indices, dim=0).to(pixel_values.device)
1324
1325 image_embeds = self.visual(
1326 pixel_values=pixel_values,
@@ -1357,14 +1364,15 @@ class BaseKeyeModule(nn.Module, SupportsMultiModal):
1364 )
1365 else:
1366 pixel_values_videos = pixel_values_videos.type(self.visual.dtype)
1367 + # These are host-built; stage them over non-blocking.
1368 siglip_position_ids = torch.concat(siglip_position_ids, dim=0).to(
1361 - pixel_values_videos.device
1369 + pixel_values_videos.device, non_blocking=True
1370 )
1363 - cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int32).to(
1364 - pixel_values_videos.device
1371 + cu_seqlens = async_tensor_h2d(
1372 + cu_seqlens, dtype=torch.int32, device=pixel_values_videos.device
1373 )
1374 sample_indices = torch.concat(sample_indices, dim=0).to(
1367 - pixel_values_videos.device
1375 + pixel_values_videos.device, non_blocking=True
1376 )
1377
1378 video_embeds = self.visual(
vllm/model_executor/models/paddleocr_vl.py
+11 -5
@@ -68,6 +68,7 @@ from vllm.multimodal.processing import (
68 )
69 from vllm.sequence import IntermediateTensors
70 from vllm.utils.tensor_schema import TensorSchema, TensorShape
71 +from vllm.utils.torch_utils import async_tensor_h2d
72 from vllm.v1.attention.backends.registry import AttentionBackendEnum
73
74 from .ernie45 import Ernie4_5ForCausalLM
@@ -800,16 +801,18 @@ class SiglipEncoder(nn.Module):
801 [height_position_ids, width_position_ids],
802 dim=-1,
803 )
803 - max_grid_size = pids.max() + 1
804 + # The ids are built from the grids above, so `h`/`w` bound them
805 + # and the table size is known on the host.
806 + max_grid_size = max(max(h, w) for _, h, w in flatten_image_grid_thw)
807 rope_emb_max_grid = self.rotary_pos_emb(max_grid_size)
808 rotary_pos_emb = rope_emb_max_grid[pids].flatten(1)
809
810 if cu_seqlens is None:
811 raise ValueError("cu_seqlens cannot be None for SiglipEncoder.")
812 if not isinstance(cu_seqlens, torch.Tensor):
810 - cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int32, device=device)
813 + cu_seqlens = async_tensor_h2d(cu_seqlens, dtype=torch.int32, device=device)
814 else:
812 - cu_seqlens = cu_seqlens.to(device=device)
815 + cu_seqlens = cu_seqlens.to(device=device, non_blocking=True)
816
817 max_seqlen = None
818 if self.attn_backend in {
@@ -1131,10 +1134,13 @@ class PaddleOCRVLForConditionalGeneration(nn.Module, SupportsMultiModal, Support
1134 siglip_position_ids.append(image_position_ids)
1135 cu_seqlens.append(cu_seqlens[-1] + numel)
1136
1137 + # Both are built on the host; stage them over non-blocking.
1138 siglip_position_ids = torch.concat(siglip_position_ids, dim=0).to(
1135 - pixel_values.device
1139 + pixel_values.device, non_blocking=True
1140 + )
1141 + cu_seqlens = async_tensor_h2d(
1142 + cu_seqlens, dtype=torch.int32, device=pixel_values.device
1143 )
1137 - cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int32).to(pixel_values.device)
1144
1145 vision_outputs = self.visual(
1146 pixel_values=pixel_values,
vllm/model_executor/models/phi4mm.py
+1 -1
@@ -905,7 +905,7 @@ class Phi4MMMultiModalProcessor(BaseMultiModalProcessor[Phi4MMProcessingInfo]):
905 return dict(
906 input_image_embeds=MultiModalFieldConfig.batched("image"),
907 image_attention_mask=MultiModalFieldConfig.batched("image"),
908 - image_sizes=MultiModalFieldConfig.batched("image"),
908 + image_sizes=MultiModalFieldConfig.batched("image", keep_on_cpu=True),
909 num_img_tokens=MultiModalFieldConfig.batched("image"),
910 input_audio_embeds=MultiModalFieldConfig.batched("audio"),
911 )
vllm/model_executor/models/ultravox.py
+5 -1
@@ -244,7 +244,11 @@ class UltravoxMultiModalProcessor(BaseMultiModalProcessor[UltravoxProcessingInfo
244 # higher than the number of audio samples
245 audio_features=MultiModalFieldConfig.flat_from_sizes("audio", num_chunks),
246 audio_token_len=MultiModalFieldConfig.flat_from_sizes("audio", num_chunks),
247 - audio_lens=MultiModalFieldConfig.flat_from_sizes("audio", num_chunks),
247 + # Only ever used to derive the encoder attention metadata on the
248 + # host, so keep it there.
249 + audio_lens=MultiModalFieldConfig.flat_from_sizes(
250 + "audio", num_chunks, keep_on_cpu=True
251 + ),
252 # num_chunks can convert audio_chunked to audio batch dimension
253 audio_num_chunks=MultiModalFieldConfig.batched("audio"),
254 audio_embeds=MultiModalFieldConfig.batched("audio"),
vllm/third_party/flash_linear_attention/ops/index.py
+5 -2
@@ -27,11 +27,14 @@ def prepare_chunk_indices(cu_seqlens: torch.Tensor, chunk_size: int) -> torch.Te
27 for n in triton.cdiv(prepare_lens(cu_seqlens), chunk_size).tolist()
28 ]
29 )
30 - return torch.stack([indices.eq(0).cumsum(0) - 1, indices], 1).to(cu_seqlens)
30 + chunk_indices = torch.stack([indices.eq(0).cumsum(0) - 1, indices], 1)
31 + return chunk_indices.to(
32 + device=cu_seqlens.device, dtype=cu_seqlens.dtype, non_blocking=True
33 + )
34
35
36 @tensor_cache
37 def prepare_chunk_offsets(cu_seqlens: torch.Tensor, chunk_size: int) -> torch.Tensor:
38 return torch.cat(
36 - [cu_seqlens.new_tensor([0]), triton.cdiv(prepare_lens(cu_seqlens), chunk_size)]
39 + [cu_seqlens.new_zeros(1), triton.cdiv(prepare_lens(cu_seqlens), chunk_size)]
40 ).cumsum(-1)