[Perf] Avoid more GPU<->CPU syncs on the model execution path (#51738)
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Nick Hill committed
Aug 11, 2026 at 19:30 UTC
f97e5029698cb9b4b63dcb53e2e7140a5035804b
15 files changed
+116
-56
vllm/model_executor/models/audioflamingo3.py
+2
-2
@@ -248,13 +248,13 @@ def _audioflamingo3_field_config(hf_inputs: Mapping[str, torch.Tensor]):
248
feature_attention_mask=MultiModalFieldConfig.flat_from_sizes(
249
"audio", chunk_counts, dim=0
250
),
251
- chunk_counts=MultiModalFieldConfig.batched("audio"),
251
+ chunk_counts=MultiModalFieldConfig.batched("audio", keep_on_cpu=True),
252
)
253
return dict(
254
audio_embeds=MultiModalFieldConfig.batched("audio"),
255
input_features=MultiModalFieldConfig.batched("audio"),
256
feature_attention_mask=MultiModalFieldConfig.batched("audio"),
257
- chunk_counts=MultiModalFieldConfig.batched("audio"),
257
+ chunk_counts=MultiModalFieldConfig.batched("audio", keep_on_cpu=True),
258
)
259
260
vllm/model_executor/models/diffusion_gemma.py
+20
-12
@@ -48,6 +48,7 @@ from vllm.model_executor.models.transformers.utils import recursive_replace_line
48
from vllm.model_executor.models.utils import WeightsMapper, maybe_prefix
49
from vllm.multimodal import MULTIMODAL_REGISTRY
50
from vllm.platforms import current_platform
51
+from vllm.utils.torch_utils import async_tensor_h2d
52
from vllm.v1.outputs import LogprobsTensors
53
from vllm.v1.sample.ops.topk_topp_sampler import apply_top_k_top_p
54
from vllm.v1.worker.gpu.attn_utils import build_attn_metadata
@@ -728,10 +729,13 @@ class DiffusionGemmaRequestStates:
729
max_num_reqs, canvas_length, hidden_size, dtype=torch.float32, device=device
730
)
731
731
- def init_canvas(self, slot_indices_np: np.ndarray) -> None:
732
- """Initialize canvas with random tokens for the given slots."""
733
- n = slot_indices_np.shape[0]
734
- self.canvas[slot_indices_np] = torch.randint(
732
+ def init_canvas(self, slot_indices: torch.Tensor) -> None:
733
+ """Initialize canvas with random tokens for the given slots.
734
+
735
+ `slot_indices` must already be on device to avoid a cpu->gpu sync.
736
+ """
737
+ n = slot_indices.shape[0]
738
+ self.canvas[slot_indices] = torch.randint(
739
0,
740
self.vocab_size,
741
(n, self.canvas_length),
@@ -740,15 +744,15 @@ class DiffusionGemmaRequestStates:
744
)
745
746
def add_request(self, slot_idx: int) -> None:
743
- self.is_encoder_phase[slot_idx] = True
744
- self.init_canvas(torch.tensor([slot_idx], device=self.device))
745
- self.step[slot_idx] = 0
746
- self.accepted_canvas_history_len[slot_idx] = 0
747
+ self.is_encoder_phase[slot_idx].fill_(True)
748
+ self.init_canvas(async_tensor_h2d([slot_idx], device=self.device))
749
+ self.step[slot_idx].fill_(0)
750
+ self.accepted_canvas_history_len[slot_idx].fill_(0)
751
self.self_conditioning_embeds[slot_idx] = 0
752
753
def remove_request(self, slot_idx: int) -> None:
750
- self.is_encoder_phase[slot_idx] = False
751
- self.accepted_canvas_history_len[slot_idx] = 0
754
+ self.is_encoder_phase[slot_idx].fill_(False)
755
+ self.accepted_canvas_history_len[slot_idx].fill_(0)
756
self.self_conditioning_embeds[slot_idx] = 0
757
758
@@ -1148,11 +1152,15 @@ class DiffusionSampler:
1152
ps = input_batch.idx_mapping_np[prefill_indices_np[done_prefill_np]]
1153
if len(ps) == 0:
1154
return
1151
- states.init_canvas(ps)
1152
- self.req_states.draft_tokens[ps, : self.canvas_length] = states.canvas[ps]
1155
+ # Move the slot indices across once, up front: indexing a device
1156
+ # tensor with a numpy array copies them over synchronously each time.
1157
ps_gpu = async_copy_to_gpu(
1158
ps.astype(np.int64), device=states.is_encoder_phase.device
1159
)
1160
+ states.init_canvas(ps_gpu)
1161
+ self.req_states.draft_tokens[ps_gpu, : self.canvas_length] = states.canvas[
1162
+ ps_gpu
1163
+ ]
1164
states.is_encoder_phase.index_fill_(0, ps_gpu, False)
1165
1166
def _handle_prefill(
vllm/model_executor/models/gemma4_mm.py
+15
-1
@@ -69,6 +69,7 @@ from vllm.multimodal.processing.processor import (
69
)
70
from vllm.sequence import IntermediateTensors
71
from vllm.utils.tensor_schema import TensorSchema, TensorShape
72
+from vllm.utils.torch_utils import async_tensor_h2d
73
74
from .interfaces import (
75
MultiModalEmbeddings,
@@ -2039,7 +2040,20 @@ class Gemma4ForConditionalGeneration(
2040
) -> torch.Tensor | None:
2041
logits = self.language_model.compute_logits(hidden_states)
2042
if logits is not None and self._suppress_token_ids:
2042
- logits[:, self._suppress_token_ids] = -float("inf")
2043
+ # Cache a per-device index tensor for the (static) suppressed-token
2044
+ # set and use `index_fill_`, so neither the Python-list indices nor
2045
+ # the scalar fill value take a host roundtrip per call.
2046
+ cache = getattr(self, "_suppress_token_ids_cache", None)
2047
+ if cache is None:
2048
+ cache = {}
2049
+ self._suppress_token_ids_cache = cache
2050
+ suppress_idx = cache.get(logits.device)
2051
+ if suppress_idx is None:
2052
+ suppress_idx = async_tensor_h2d(
2053
+ self._suppress_token_ids, dtype=torch.long, device=logits.device
2054
+ )
2055
+ cache[logits.device] = suppress_idx
2056
+ logits.index_fill_(1, suppress_idx, -float("inf"))
2057
return logits
2058
2059
# ------------------------------------------------------------------ #
vllm/model_executor/models/glm4_1v.py
+2
-2
@@ -548,9 +548,9 @@ class Glm4vVisionEmbeddings(nn.Module):
548
else:
549
# Convert inputs to tensors if needed
550
if isinstance(lengths, list):
551
- lengths = torch.tensor(lengths, device=device, dtype=torch.long)
551
+ lengths = async_tensor_h2d(lengths, device=device, dtype=torch.long)
552
if not isinstance(image_shapes, torch.Tensor):
553
- image_shapes = torch.tensor(
553
+ image_shapes = async_tensor_h2d(
554
image_shapes, device=device, dtype=torch.long
555
)
556
vllm/model_executor/models/interns1.py
+6
-2
@@ -426,12 +426,16 @@ class InternS1MultiModalProcessor(BaseMultiModalProcessor[InternS1ProcessingInfo
426
),
427
image_num_patches=MultiModalFieldConfig.batched("image"),
428
image_embeds=MultiModalFieldConfig.batched("image"),
429
- image_token_id=MultiModalFieldConfig.shared("image", num_images),
429
+ image_token_id=MultiModalFieldConfig.shared(
430
+ "image", num_images, keep_on_cpu=True
431
+ ),
432
pixel_values_videos=MultiModalFieldConfig.flat_from_sizes(
433
"video", video_num_patches
434
),
435
video_num_patches=MultiModalFieldConfig.batched("video"),
434
- video_token_id=MultiModalFieldConfig.shared("video", num_videos),
436
+ video_token_id=MultiModalFieldConfig.shared(
437
+ "video", num_videos, keep_on_cpu=True
438
+ ),
439
)
440
441
def _get_prompt_updates(
vllm/model_executor/models/internvl.py
+3
-1
@@ -478,7 +478,9 @@ class InternVLMultiModalProcessor(
478
"video", video_num_patches
479
),
480
video_num_patches=MultiModalFieldConfig.batched("video", keep_on_cpu=True),
481
- video_token_id=MultiModalFieldConfig.shared("video", num_videos),
481
+ video_token_id=MultiModalFieldConfig.shared(
482
+ "video", num_videos, keep_on_cpu=True
483
+ ),
484
)
485
486
def _get_mm_fields_config(
vllm/model_executor/models/keye.py
+5
-4
@@ -871,12 +871,12 @@ def _keye_field_config(
871
return dict(
872
pixel_values=MultiModalFieldConfig.flat_from_sizes("image", image_grid_sizes),
873
image_embeds=MultiModalFieldConfig.flat_from_sizes("image", image_grid_sizes),
874
- image_grid_thw=MultiModalFieldConfig.batched("image"),
874
+ image_grid_thw=MultiModalFieldConfig.batched("image", keep_on_cpu=True),
875
pixel_values_videos=MultiModalFieldConfig.flat_from_sizes(
876
"video", video_grid_sizes
877
),
878
video_embeds=MultiModalFieldConfig.flat_from_sizes("video", video_grid_sizes),
879
- video_grid_thw=MultiModalFieldConfig.batched("video"),
879
+ video_grid_thw=MultiModalFieldConfig.batched("video", keep_on_cpu=True),
880
)
881
882
@@ -1291,8 +1291,9 @@ class BaseKeyeModule(nn.Module, SupportsMultiModal):
1291
image_grid_thw = image_input["image_grid_thw"]
1292
assert image_grid_thw.ndim == 2
1293
1294
- for idx, thaw in enumerate(image_grid_thw):
1295
- thw_tuple = tuple(thaw.detach().cpu().numpy().tolist())
1294
+ image_grid_thw_list = image_grid_thw.tolist()
1295
+ for idx, thw in enumerate(image_grid_thw_list):
1296
+ thw_tuple = tuple(thw)
1297
numel = np.prod(thw_tuple)
1298
image_grid_hws.append(thw_tuple)
1299
image_position_ids = torch.arange(numel) % np.prod(thw_tuple[1:])
vllm/model_executor/models/phi3v.py
+3
-2
@@ -266,9 +266,10 @@ class Phi3HDImageEmbedding(nn.Module):
266
)
267
268
batch_image_features_proj = []
269
+ image_sizes_list = image_sizes.tolist()
270
# need a for loop to process each image because of different image sizes
271
# (patch arrangement is different for each image)
271
- for i, img_size in enumerate(image_sizes):
272
+ for i, img_size in enumerate(image_sizes_list):
273
h, w = img_size
274
h_crop = h // 336
275
w_crop = w // 336
@@ -426,7 +427,7 @@ class Phi3VMultiModalProcessor(BaseMultiModalProcessor[Phi3VProcessingInfo]):
427
) -> Mapping[str, MultiModalFieldConfig]:
428
return dict(
429
pixel_values=MultiModalFieldConfig.batched("image"),
429
- image_sizes=MultiModalFieldConfig.batched("image"),
430
+ image_sizes=MultiModalFieldConfig.batched("image", keep_on_cpu=True),
431
image_embeds=MultiModalFieldConfig.batched("image"),
432
)
433
vllm/model_executor/models/phi4mm.py
+1
-2
@@ -270,8 +270,7 @@ class Phi4MMImageEncoder(nn.Module):
270
pixel_values = pixel_values.flatten(0, 1)
271
272
img_features = self.get_img_features(
273
- pixel_values,
274
- image_attention_mask.type(torch.BoolTensor).flatten(0, 1).to(target_device),
273
+ pixel_values, image_attention_mask.bool().flatten(0, 1)
274
)
275
276
base_feat_height_target = self.base_feat_height_target
vllm/model_executor/models/step3_vl.py
+3
-2
@@ -200,7 +200,7 @@ class Step3VLMultiModalProcessor(BaseMultiModalProcessor[Step3VLProcessingInfo])
200
patch_pixel_values=MultiModalFieldConfig.flat_from_sizes(
201
"image", num_patches
202
),
203
- num_patches=MultiModalFieldConfig.batched("image"),
203
+ num_patches=MultiModalFieldConfig.batched("image", keep_on_cpu=True),
204
patch_newline_mask=MultiModalFieldConfig.flat_from_sizes(
205
"image", num_patches
206
),
@@ -682,7 +682,8 @@ class Step3VLForConditionalGeneration(
682
683
merged_image_features = []
684
cur_patch_idx = 0
685
- for i, num_patch in enumerate(num_patches):
685
+ num_patches_list = num_patches.tolist()
686
+ for i, num_patch in enumerate(num_patches_list):
687
cur_feature = []
688
if num_patch > 0:
689
patch_slice = patch_image_features[
vllm/v1/attention/backend.py
+2
@@ -442,6 +442,7 @@ class CommonAttentionMetadata:
442
# Needed by FastPrefillAttentionBuilder
443
logits_indices_padded: torch.Tensor | None = None
444
num_logits_indices: int | None = None
445
+ max_logits_per_req: int | None = None
446
447
# Needed by CrossAttentionBuilder
448
encoder_seq_lens: torch.Tensor | None = None
@@ -590,6 +591,7 @@ class CommonAttentionMetadata:
591
else self.causal,
592
logits_indices_padded=self.logits_indices_padded,
593
num_logits_indices=self.num_logits_indices,
594
+ max_logits_per_req=self.max_logits_per_req,
595
encoder_seq_lens=maybe_slice_reqs(self.encoder_seq_lens),
596
encoder_seq_lens_cpu=maybe_slice_reqs(self.encoder_seq_lens_cpu),
597
dcp_local_seq_lens=maybe_slice_reqs(self.dcp_local_seq_lens),
vllm/v1/attention/backends/mamba2_attn.py
+9
-5
@@ -150,11 +150,15 @@ class Mamba2AttentionMetadataBuilder(
150
151
# Compute seq_idx for prefill only
152
if common.num_prefills > 0:
153
- prep_initial_states = (
154
- torch.any(common.has_initial_states_p).item()
155
- if common.has_initial_states_p is not None
156
- else False
157
- )
153
+ prep_initial_states = False
154
+ if common.has_initial_states_p is not None:
155
+ # Same condition as `has_initial_states_p`, but derived from CPU
156
+ # data so it needs no D2H. `seq_lens_cpu_upper_bound` is precise
157
+ # for prefill rows, which is all this slice covers.
158
+ num_computed_tokens_p_cpu, _ = self._prefill_cpu_metadata(
159
+ common, common_attn_metadata
160
+ )
161
+ prep_initial_states = bool((num_computed_tokens_p_cpu > 0).any())
162
163
cu_chunk_seqlen_p, seq_idx_p, last_chunk_indices_p = (
164
self._build_chunk_metadata_tensors(
vllm/v1/attention/backends/mamba_attn.py
+29
-15
@@ -324,34 +324,48 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
324
325
return cu_chunk_seqlen, seq_idx, last_chunk_indices
326
327
- def _build_chunk_metadata_tensors(
327
+ def _prefill_cpu_metadata(
328
self,
329
- chunk_size: int,
329
common: M,
330
common_attn_metadata: CommonAttentionMetadata,
332
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
333
- """
334
- Compute chunk metadata and return as device tensors.
335
- Returns (cu_chunk_seqlen_p, seq_idx_p, last_chunk_indices_p).
336
- """
337
- num_reqs = common.num_reqs
338
- num_prefills = common.num_prefills
339
- num_decode_tokens = common.num_decode_tokens
331
+ ) -> tuple[torch.Tensor, torch.Tensor]:
332
+ """Prefill context lengths and query offsets, from CPU data only.
333
341
- # Derive prefill context lengths from CPU data only.
342
- # `seq_lens_cpu_upper_bound` is precise for prefill rows in all modes
343
- # (including async spec decode), so this avoids the D2H sync that
344
- # `compute_num_computed_tokens().cpu()` would force.
334
+ `seq_lens_cpu_upper_bound` is precise for prefill rows in all modes
335
+ (including async spec decode), so this avoids the D2H sync that
336
+ `compute_num_computed_tokens().cpu()` would force.
337
+
338
+ Returns (num_computed_tokens_p_cpu, query_start_loc_p_cpu).
339
+ """
340
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
341
assert seq_lens_cpu is not None
342
+ num_reqs = common.num_reqs
343
+ num_prefills = common.num_prefills
344
query_start_loc_p_cpu = (
345
common_attn_metadata.query_start_loc_cpu[-num_prefills - 1 :]
349
- - num_decode_tokens
346
+ - common.num_decode_tokens
347
)
348
prefill_query_lens_cpu = query_start_loc_p_cpu[1:] - query_start_loc_p_cpu[:-1]
349
num_computed_tokens_p_cpu = (
350
seq_lens_cpu[num_reqs - num_prefills : num_reqs] - prefill_query_lens_cpu
351
)
352
+ return num_computed_tokens_p_cpu, query_start_loc_p_cpu
353
+
354
+ def _build_chunk_metadata_tensors(
355
+ self,
356
+ chunk_size: int,
357
+ common: M,
358
+ common_attn_metadata: CommonAttentionMetadata,
359
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
360
+ """
361
+ Compute chunk metadata and return as device tensors.
362
+ Returns (cu_chunk_seqlen_p, seq_idx_p, last_chunk_indices_p).
363
+ """
364
+ num_prefills = common.num_prefills
365
+
366
+ num_computed_tokens_p_cpu, query_start_loc_p_cpu = self._prefill_cpu_metadata(
367
+ common, common_attn_metadata
368
+ )
369
370
cu_chunk_seqlen, seq_idx, last_chunk_indices = self._compute_chunk_metadata(
371
chunk_size,
vllm/v1/attention/backends/utils.py
+8
-2
@@ -539,8 +539,14 @@ def make_kv_sharing_fast_prefill_common_attn_metadata(
539
540
decode_query_start_loc[:1].fill_(0) # Avoid sync from scalar assignment.
541
decode_query_start_loc[1:] = torch.cumsum(num_decode_tokens, dim=0)
542
- decode_max_query_len = int(num_decode_tokens.max().item())
543
- total_num_decode_tokens = int(num_decode_tokens.sum().item())
542
+
543
+ # `num_decode_tokens` is a histogram over `logits_indices`, so its total is
544
+ # just how many there were -- already known as a Python int.
545
+ total_num_decode_tokens = num_logits_indices
546
+
547
+ # Largest per-request logits count.
548
+ decode_max_query_len = common_attn_metadata.max_logits_per_req
549
+ assert decode_max_query_len is not None
550
551
common_attn_metadata = CommonAttentionMetadata(
552
query_start_loc=decode_query_start_loc,
vllm/v1/worker/gpu_model_runner.py
+8
-4
@@ -2009,10 +2009,11 @@ class GPUModelRunner(
2009
) -> tuple[
2010
torch.Tensor,
2011
SpecDecodeMetadata | None,
2012
+ int,
2013
]:
2014
"""
2015
Returns:
2015
- tuple[logits_indices, spec_decode_metadata]
2016
+ tuple[logits_indices, spec_decode_metadata, max_num_sampled_tokens]
2017
"""
2018
total_num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens
2019
assert total_num_scheduled_tokens > 0
@@ -2324,6 +2325,7 @@ class GPUModelRunner(
2325
return (
2326
logits_indices,
2327
spec_decode_metadata,
2328
+ int(num_sampled_tokens.max()),
2329
)
2330
2331
def _build_attention_metadata(
@@ -2335,6 +2337,7 @@ class GPUModelRunner(
2337
num_reqs_padded: int | None = None,
2338
ubatch_slices: UBatchSlices | None = None,
2339
logits_indices: torch.Tensor | None = None,
2340
+ max_num_sampled_tokens: int | None = None,
2341
use_spec_decode: bool = False,
2342
for_cudagraph_capture: bool = False,
2343
num_scheduled_tokens: dict[str, int] | None = None,
@@ -2509,6 +2512,7 @@ class GPUModelRunner(
2512
2513
if logits_indices is not None and self.cache_config.kv_sharing_fast_prefill:
2514
cm_base.num_logits_indices = logits_indices.size(0)
2515
+ cm_base.max_logits_per_req = max_num_sampled_tokens
2516
cm_base.logits_indices_padded = self._prepare_kv_sharing_fast_prefill(
2517
logits_indices
2518
)
@@ -4341,9 +4345,8 @@ class GPUModelRunner(
4345
max_num_scheduled_tokens = int(num_scheduled_tokens_np.max())
4346
num_tokens_unpadded = scheduler_output.total_num_scheduled_tokens
4347
4344
- logits_indices, spec_decode_metadata = self._prepare_inputs(
4345
- scheduler_output,
4346
- num_scheduled_tokens_np,
4348
+ logits_indices, spec_decode_metadata, max_num_sampled_tokens = (
4349
+ self._prepare_inputs(scheduler_output, num_scheduled_tokens_np)
4350
)
4351
4352
cascade_attn_prefix_lens = None
@@ -4481,6 +4484,7 @@ class GPUModelRunner(
4484
max_query_len=max_num_scheduled_tokens,
4485
ubatch_slices=ubatch_slices_attn,
4486
logits_indices=logits_indices,
4487
+ max_num_sampled_tokens=max_num_sampled_tokens,
4488
use_spec_decode=use_spec_decode,
4489
num_scheduled_tokens=scheduler_output.num_scheduled_tokens,
4490
cascade_attn_prefix_lens=cascade_attn_prefix_lens,