[Perf] Avoid more h2d copies from non-pinned tensors (#54660)
Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Nick Hill committed
Sep 1, 2026 at 20:37 UTC
a566ea7e8f5975b4d38c0092d7adb0acbda3a890
5 files changed
+72
-64
vllm/model_executor/models/qwen2_5_vl.py
+7
-6
@@ -805,7 +805,7 @@ class Qwen2_5_VisionTransformer(nn.Module):
805
window_index_thw, cu_seqlens_window_thw = self.get_window_index_thw(t, h, w)
806
cos_thw, sin_thw = self.rotary_pos_emb_thw(t, h, w)
807
808
- window_index_thw_dev = window_index_thw.to(cos_thw.device, non_blocking=True)
808
+ window_index_thw_dev = async_tensor_h2d(window_index_thw, cos_thw.device)
809
cos_thw = cos_thw[window_index_thw_dev, :, :]
810
cos_thw = cos_thw.flatten(start_dim=0, end_dim=1)
811
sin_thw = sin_thw[window_index_thw_dev, :, :]
@@ -1053,7 +1053,7 @@ class Qwen2_5_VisionTransformer(nn.Module):
1053
)
1054
rotary_pos_emb_cos = rotary_pos_emb_cos.to(device=device, non_blocking=True)
1055
rotary_pos_emb_sin = rotary_pos_emb_sin.to(device=device, non_blocking=True)
1056
- window_index = window_index.to(device=device, non_blocking=True)
1056
+ window_index = async_tensor_h2d(window_index, device)
1057
reverse_indices = reverse_indices.to(device=device, non_blocking=True)
1058
1059
metadata["rotary_pos_emb_cos"] = rotary_pos_emb_cos
@@ -1475,8 +1475,8 @@ class Qwen2_5_VLForConditionalGeneration(
1475
grid_thw_list = grid_thw.tolist()
1476
image_embeds_out = []
1477
for emb, size in zip(image_embeds_split, grid_thw_list):
1478
- positions = compute_mrope_for_media(size, merge_size).to(
1479
- emb.device, non_blocking=True
1478
+ positions = async_tensor_h2d(
1479
+ compute_mrope_for_media(size, merge_size), emb.device
1480
)
1481
emb = torch.cat([emb, positions], dim=1)
1482
image_embeds_out.append(emb)
@@ -1558,12 +1558,13 @@ class Qwen2_5_VLForConditionalGeneration(
1558
spatial_merge_size=self.visual.spatial_merge_size,
1559
q=self.video_pruning_rate,
1560
)
1561
- positions = compute_mrope_for_media(
1561
+ positions_cpu = compute_mrope_for_media(
1562
size,
1563
merge_size,
1564
tokens_per_second=tokens_per_second,
1565
video_second_per_grid=video_second_per_grid_t.item(),
1566
- ).to(emb.device, non_blocking=True)
1566
+ )
1567
+ positions = async_tensor_h2d(positions_cpu, emb.device)
1568
1569
with gpu_sync_allowed():
1570
emb = emb[retention_mask]
vllm/model_executor/models/qwen2_vl.py
+2
-1
@@ -85,6 +85,7 @@ from vllm.multimodal.processing import (
85
)
86
from vllm.sequence import IntermediateTensors
87
from vllm.utils.tensor_schema import TensorSchema, TensorShape
88
+from vllm.utils.torch_utils import async_tensor_h2d
89
from vllm.v1.attention.backends.registry import AttentionBackendEnum
90
from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphReplayBuffers
91
@@ -707,7 +708,7 @@ class Qwen2VisionTransformer(nn.Module):
708
return {
709
"rotary_pos_emb_cos": rotary_pos_emb_cos,
710
"rotary_pos_emb_sin": rotary_pos_emb_sin,
710
- "cu_seqlens": cu_seqlens.to(device=device, non_blocking=True),
711
+ "cu_seqlens": async_tensor_h2d(cu_seqlens, device),
712
"max_seqlen": max_seqlen,
713
}
714
vllm/model_executor/models/transformers/multimodal.py
+3
-2
@@ -60,6 +60,7 @@ from vllm.multimodal.processing import (
60
)
61
from vllm.sequence import IntermediateTensors
62
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
63
+from vllm.utils.torch_utils import async_tensor_h2d
64
65
if TYPE_CHECKING:
66
from transformers import BatchFeature, PreTrainedModel
@@ -1180,8 +1181,8 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE):
1181
# grid_thw fields are registered keep_on_cpu; restore the on-device
1182
# placement that HF get_image_features implementations expect.
1183
for key, value in kwargs.items():
1183
- if isinstance(value, torch.Tensor):
1184
- kwargs[key] = value.to(pixel_values.device, non_blocking=True)
1184
+ if isinstance(value, torch.Tensor) and value.is_cpu:
1185
+ kwargs[key] = async_tensor_h2d(value, pixel_values.device)
1186
1187
# The underlying HuggingFace `get_image_features` implementations
1188
# contain model-internal syncs (e.g. Idefics3 filters all-zero
vllm/multimodal/inputs.py
+37
-51
@@ -293,21 +293,39 @@ def _nested_tensors_are_cpu(tensors: NestedTensors) -> bool:
293
)
294
295
296
+def _is_dense(t: torch.Tensor) -> bool:
297
+ """Whether `t`'s elements fill a contiguous storage range, possibly
298
+ permuted (e.g. a transposed matrix). Such layouts can be copied with
299
+ pitched cudaMemcpy2D/3DAsync directly from pinned memory, whereas
300
+ gapped layouts (e.g. strided slices) stage through a pageable temp."""
301
+ if not t.is_contiguous():
302
+ expected_stride = 1
303
+ for stride, size in sorted(zip(t.stride(), t.shape)):
304
+ if size > 1:
305
+ if stride != expected_stride:
306
+ return False
307
+ expected_stride *= size
308
+ return True
309
+
310
+
311
def _nested_tensors_h2d(
312
tensors: NestedTensors,
313
device: torch.types.Device,
314
+ *,
315
+ pin_memory: bool = False,
316
) -> NestedTensors:
317
if device is None:
318
return tensors
319
303
- return json_map_leaves(
304
- (
305
- lambda x: x.to(device=device, non_blocking=True)
306
- if isinstance(x, torch.Tensor)
307
- else x
308
- ),
309
- tensors,
310
- )
320
+ def _h2d(x: torch.Tensor) -> torch.Tensor:
321
+ if not isinstance(x, torch.Tensor):
322
+ return x
323
+ if pin_memory and x.is_cpu and not (x.is_pinned() and _is_dense(x)):
324
+ # Ensure tensor is pinned and dense for non_blocking H2D copy.
325
+ x = x.new_empty(x.shape, pin_memory=True).copy_(x)
326
+ return x.to(device=device, non_blocking=True)
327
+
328
+ return json_map_leaves(_h2d, tensors)
329
330
331
BatchedTensorInputs: TypeAlias = dict[str, NestedTensors]
@@ -452,10 +470,7 @@ class BaseMultiModalField(ABC):
470
471
@abstractmethod
472
def _reduce_data(
455
- self,
456
- batch: list[NestedTensors],
457
- *,
458
- pin_memory: bool,
473
+ self, batch: list[NestedTensors], *, pin_memory: bool
474
) -> NestedTensors:
475
raise NotImplementedError
476
@@ -491,7 +506,7 @@ class BaseMultiModalField(ABC):
506
# concept, and `pin_memory()` rejects device tensors outright.
507
pin_memory = False
508
out = self._reduce_data(batch, pin_memory=pin_memory)
494
- return _nested_tensors_h2d(out, device=device)
509
+ return _nested_tensors_h2d(out, device=device, pin_memory=pin_memory)
510
511
512
@dataclass(frozen=True, kw_only=True)
@@ -511,10 +526,7 @@ class MultiModalBatchedField(BaseMultiModalField):
526
return [field_factory(item) for item in data]
527
528
def _reduce_data(
514
- self,
515
- batch: list[NestedTensors],
516
- *,
517
- pin_memory: bool,
529
+ self, batch: list[NestedTensors], *, pin_memory: bool
530
) -> NestedTensors:
531
if len(batch) > 0 and is_list_of(batch, torch.Tensor, check="all"):
532
batch = cast(list[torch.Tensor], batch)
@@ -522,20 +534,11 @@ class MultiModalBatchedField(BaseMultiModalField):
534
# An optimization when `batch` contains only one tensor:
535
# - produce exactly same result as `torch.stack(batch)`
536
# - will achieve zero-copy if the tensor is contiguous
525
- out = batch[0].unsqueeze(0)
526
- if not pin_memory:
527
- return out.contiguous()
528
- # Avoid extra copy - pinning unpinned memory will make it contiguous
529
- if not out.is_contiguous() and out.is_pinned():
530
- out = out.contiguous()
531
- return out.pin_memory()
537
+ return batch[0].unsqueeze(0)
538
first_shape = batch[0].shape
539
if all(elem.shape == first_shape for elem in batch):
534
- out = torch.empty(
535
- (len(batch), *batch[0].shape),
536
- dtype=batch[0].dtype,
537
- device=batch[0].device,
538
- pin_memory=pin_memory,
540
+ out = batch[0].new_empty(
541
+ (len(batch), *first_shape), pin_memory=pin_memory
542
)
543
return torch.stack(batch, out=out)
544
@@ -567,10 +570,7 @@ class MultiModalFlatField(BaseMultiModalField):
570
return [field_factory(data[cast(slice, s)]) for s in self.slices]
571
572
def _reduce_data(
570
- self,
571
- batch: list[NestedTensors],
572
- *,
573
- pin_memory: bool,
573
+ self, batch: list[NestedTensors], *, pin_memory: bool
574
) -> NestedTensors:
575
if len(batch) > 0 and is_list_of(batch, torch.Tensor, check="all"):
576
batch = cast(list[torch.Tensor], batch)
@@ -578,13 +578,7 @@ class MultiModalFlatField(BaseMultiModalField):
578
# An optimization when `batch` contains only one tensor:
579
# - produce exactly same result as `torch.concat(batch)`
580
# - will achieve zero-copy if the tensor is contiguous
581
- out = batch[0]
582
- if not pin_memory:
583
- return out.contiguous()
584
- # Avoid extra copy - pinning unpinned memory will make it contiguous
585
- if not out.is_contiguous() and out.is_pinned():
586
- out = out.contiguous()
587
- return out.pin_memory()
581
+ return batch[0]
582
583
dim = self.dim + (self.dim < 0) * len(batch[0].shape)
584
@@ -596,11 +590,8 @@ class MultiModalFlatField(BaseMultiModalField):
590
if all(_shape_before_after(elem) == first_shape for elem in batch):
591
shape_before, shape_after = first_shape
592
shape_concat = sum(item.shape[dim] for item in batch)
599
- out = torch.empty(
600
- (*shape_before, shape_concat, *shape_after),
601
- dtype=batch[0].dtype,
602
- device=batch[0].device,
603
- pin_memory=pin_memory,
593
+ out = batch[0].new_empty(
594
+ (*shape_before, shape_concat, *shape_after), pin_memory=pin_memory
595
)
596
return torch.concat(batch, dim=self.dim, out=out)
597
@@ -622,12 +613,7 @@ class MultiModalFlatField(BaseMultiModalField):
613
max_sizes.append(max(t.shape[d] for t in batch))
614
615
# Step 2: Create zero-initialized output tensor
625
- out = torch.zeros(
626
- max_sizes,
627
- dtype=batch[0].dtype,
628
- device=batch[0].device,
629
- pin_memory=pin_memory,
630
- )
616
+ out = batch[0].new_zeros(max_sizes, pin_memory=pin_memory)
617
618
# Step 3: Slice-assign each tensor to its proper position
619
concat_offset = 0
vllm/v1/attention/backends/flashinfer.py
+23
-4
@@ -1569,6 +1569,17 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1569
prefill_start : num_reqs + 1
1570
]
1571
assert paged_kv_indptr_prefill_cpu.shape[0] == num_prefills + 1
1572
+ # plan() copies these to the GPU with non_blocking=True;
1573
+ # stage them in pinned memory so the copies stay async
1574
+ # (the reused buffers themselves are intentionally not pinned).
1575
+ if PIN_MEMORY:
1576
+ qo_indptr_prefill_cpu = qo_indptr_prefill_cpu.pin_memory()
1577
+ paged_kv_indptr_prefill_cpu = (
1578
+ paged_kv_indptr_prefill_cpu.pin_memory()
1579
+ )
1580
+ paged_kv_last_page_len_prefill_cpu = (
1581
+ paged_kv_last_page_len_prefill_cpu.pin_memory()
1582
+ )
1583
if self.use_dcp:
1584
assert isinstance(prefill_wrapper, BatchDCPPrefillWrapper)
1585
prefill_wrapper.plan(
@@ -1695,13 +1706,21 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1706
o_dtype = (
1707
FP8_DTYPE if self.is_kvcache_nvfp4 else self.model_config.dtype
1708
)
1709
+ paged_kv_indptr_cpu = self.paged_kv_indptr.cpu[: num_input_tokens + 1]
1710
+ paged_kv_last_page_len_cpu = self.paged_kv_last_page_len.cpu[
1711
+ :num_input_tokens
1712
+ ]
1713
+ # plan() copies these to the GPU with non_blocking=True;
1714
+ # stage them in pinned memory so the copies stay async
1715
+ # (the reused buffers themselves are intentionally not pinned).
1716
+ if PIN_MEMORY:
1717
+ paged_kv_indptr_cpu = paged_kv_indptr_cpu.pin_memory()
1718
+ paged_kv_last_page_len_cpu = paged_kv_last_page_len_cpu.pin_memory()
1719
fast_plan_decode(
1720
decode_wrapper,
1700
- indptr_cpu=self.paged_kv_indptr.cpu[: num_input_tokens + 1],
1721
+ indptr_cpu=paged_kv_indptr_cpu,
1722
indices=paged_kv_indices,
1702
- last_page_len_cpu=self.paged_kv_last_page_len.cpu[
1703
- :num_input_tokens
1704
- ],
1723
+ last_page_len_cpu=paged_kv_last_page_len_cpu,
1724
num_qo_heads=self.num_qo_heads * self.dcp_world_size,
1725
num_kv_heads=self.num_kv_heads,
1726
head_dim=self.head_dim,