[Perf] Avoid h2d copies from non-pinned CPU tensors (#54299)
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Nick Hill committed
Aug 28, 2026 at 21:06 UTC
6c18a5464852d9ddf4c59df8ec278ce1683a917e
14 files changed
+81
-84
vllm/model_executor/layers/pooler/tokwise/methods.py
+2
-1
@@ -11,6 +11,7 @@ from vllm.config import get_current_vllm_config
11
from vllm.config.pooler import TokenPoolingType
12
from vllm.model_executor.layers.pooler import PoolingParamsUpdate
13
from vllm.tasks import PoolingTask
14
+from vllm.utils.torch_utils import async_tensor_h2d
15
from vllm.v1.pool.metadata import PoolingMetadata
16
17
TokenPoolingMethodOutputItem: TypeAlias = torch.Tensor | None
@@ -131,7 +132,7 @@ class StepPool(AllPool):
132
133
if step_tag_id is not None:
134
idx_cpu = (token_id_cpu == step_tag_id).nonzero(as_tuple=True)[0]
134
- idx = idx_cpu.to(data.device, non_blocking=True)
135
+ idx = async_tensor_h2d(idx_cpu, data.device)
136
data = data[idx]
137
138
pooled_data.append(data)
vllm/model_executor/model_loader/utils.py
+11
-10
@@ -158,13 +158,13 @@ def device_loading_context(module: torch.nn.Module, target_device: torch.device)
158
yield module
159
return
160
161
- original_device_states: dict[str, torch.device] = {}
161
+ cpu_params: set[str] = set()
162
uva_offloaded_parameters: list[str] = []
163
164
- # Store original device states and move parameters to GPU if they're on CPU
164
+ # Store which parameters are on CPU and move them to the GPU
165
for name, p in module.named_parameters():
166
if p.device.type == "cpu":
167
- original_device_states[name] = p.device
167
+ cpu_params.add(name)
168
p.data = p.data.to(target_device)
169
if getattr(p, "_vllm_is_uva_offloaded", False):
170
uva_offloaded_parameters.append(name)
@@ -178,20 +178,21 @@ def device_loading_context(module: torch.nn.Module, target_device: torch.device)
178
is_pin_memory_available()
179
and not envs.VLLM_WEIGHT_OFFLOADING_DISABLE_PIN_MEMORY
180
)
181
- # Restore parameters to their original devices, ignoring new parameters
181
+ # Restore the CPU-resident parameters, ignoring new parameters.
182
for name, p in module.named_parameters():
183
- if name in original_device_states:
184
- original_device: torch.device = original_device_states[name]
185
- p.data = p.data.to(original_device)
183
+ if name in cpu_params:
184
+ p.data = torch.empty_like(
185
+ p.data, device="cpu", pin_memory=use_pin_memory
186
+ ).copy_(p.data)
187
188
# parameter is UVA offloaded, but was replaced with a new device tensor
189
# re-offload it to CPU using UVA
190
if name in uva_offloaded_parameters and not getattr(
191
p, "_vllm_is_uva_offloaded", False
192
):
192
- cpu_data = p.data.to(device="cpu")
193
- if use_pin_memory:
194
- cpu_data = cpu_data.pin_memory()
193
+ cpu_data = torch.empty_like(
194
+ p.data, device="cpu", pin_memory=use_pin_memory
195
+ ).copy_(p.data)
196
p.data = get_accelerator_view_from_cpu_tensor(cpu_data)
197
p._vllm_is_uva_offloaded = True
198
vllm/model_executor/models/ernie45_vl.py
+3
-5
@@ -425,10 +425,11 @@ class Ernie4_5_VisionTransformer(nn.Module):
425
return self.patch_embed.proj.weight.device
426
427
def rot_pos_emb(self, grid_thw: torch.Tensor) -> torch.Tensor:
428
+ device = self.device
429
pos_ids = []
430
for t, h, w in grid_thw:
430
- hpos_ids = torch.arange(h).unsqueeze(1).expand(-1, w)
431
- wpos_ids = torch.arange(w).unsqueeze(0).expand(h, -1)
431
+ hpos_ids = torch.arange(h, device=device).unsqueeze(1).expand(-1, w)
432
+ wpos_ids = torch.arange(w, device=device).unsqueeze(0).expand(h, -1)
433
hpos_ids = (
434
hpos_ids.reshape(
435
h // self.spatial_merge_size,
@@ -453,9 +454,6 @@ class Ernie4_5_VisionTransformer(nn.Module):
454
pos_ids = torch.cat(pos_ids, dim=0)
455
max_grid_size = grid_thw[:, 1:].max()
456
rotary_pos_emb_full = self.rotary_pos_emb(max_grid_size)
456
- # `pos_ids` is built on the host; stage it over non-blocking so the
457
- # gather below doesn't index a device tensor with a CPU one.
458
- pos_ids = pos_ids.to(rotary_pos_emb_full.device, non_blocking=True)
457
rotary_pos_emb = rotary_pos_emb_full[pos_ids].flatten(1)
458
return rotary_pos_emb
459
vllm/model_executor/models/glm4_1v.py
+3
-3
@@ -709,10 +709,11 @@ class Glm4vVisionTransformer(nn.Module):
709
def rot_pos_emb(
710
self, grid_thw: list[list[int]]
711
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
712
+ device = self.device
713
pos_ids = []
714
for t, h, w in grid_thw:
714
- hpos_ids = torch.arange(h).unsqueeze(1).expand(-1, w)
715
- wpos_ids = torch.arange(w).unsqueeze(0).expand(h, -1)
715
+ hpos_ids = torch.arange(h, device=device).unsqueeze(1).expand(-1, w)
716
+ wpos_ids = torch.arange(w, device=device).unsqueeze(0).expand(h, -1)
717
hpos_ids = (
718
hpos_ids.reshape(
719
h // self.spatial_merge_size,
@@ -740,7 +741,6 @@ class Glm4vVisionTransformer(nn.Module):
741
# Use pre-computed cos_sin_cache from RotaryEmbedding
742
cos, sin = self.rotary_pos_emb.get_cos_sin(max_grid_size)
743
743
- pos_ids = pos_ids.to(cos.device, non_blocking=True)
744
cos_combined = cos[pos_ids].flatten(1)
745
sin_combined = sin[pos_ids].flatten(1)
746
return cos_combined, sin_combined, pos_ids
vllm/model_executor/models/idefics2_vision_model.py
+4
-1
@@ -39,6 +39,7 @@ from vllm.model_executor.layers.linear import (
39
)
40
from vllm.model_executor.layers.quantization import QuantizationConfig
41
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
42
+from vllm.utils.torch_utils import PIN_MEMORY
43
44
from .utils import AutoWeightsLoader, WeightsMapper
45
from .vision import is_vit_use_data_parallel, run_dp_sharded_vision_model
@@ -85,7 +86,9 @@ class Idefics2VisionEmbeddings(nn.Module):
86
1 / self.num_patches_per_side, 1.0, 1 / self.num_patches_per_side
87
)
88
position_ids = torch.full(
88
- size=(batch_size, max_nb_patches_h * max_nb_patches_w), fill_value=0
89
+ size=(batch_size, max_nb_patches_h * max_nb_patches_w),
90
+ fill_value=0,
91
+ pin_memory=PIN_MEMORY,
92
)
93
94
with gpu_sync_allowed():
vllm/model_executor/models/phi4mm_audio.py
+3
-6
@@ -32,6 +32,7 @@ from vllm.model_executor.models.phi4mm_utils import (
32
get_offset,
33
unfold_tensor,
34
)
35
+from vllm.utils.torch_utils import async_tensor_h2d
36
37
38
class ConformerEncoderLayer(nn.Module):
@@ -586,9 +587,7 @@ class TransformerEncoderBase(abc.ABC, nn.Module):
587
seq_len, batch_size, self.chunk_size, self.left_chunk
588
)
589
device = xs_pad.device
589
- enc_streaming_mask = enc_streaming_mask.contiguous().to(
590
- device, non_blocking=True
591
- )
590
+ enc_streaming_mask = async_tensor_h2d(enc_streaming_mask.contiguous(), device)
591
xs_pad = xs_pad.to(device)
592
593
input_tensor = xs_pad
@@ -921,9 +920,7 @@ class ConformerEncoder(TransformerEncoderBase):
920
enc_streaming_mask = self._streaming_mask(
921
max_audio_length, batch_size, self.chunk_size, self.left_chunk
922
)
924
- enc_streaming_mask = enc_streaming_mask.contiguous().to(
925
- device, non_blocking=True
926
- )
923
+ enc_streaming_mask = async_tensor_h2d(enc_streaming_mask.contiguous(), device)
924
if mask is None:
925
return enc_streaming_mask
926
vllm/model_executor/models/qwen2_5_vl.py
+5
-7
@@ -715,8 +715,11 @@ class Qwen2_5_VisionTransformer(nn.Module):
715
return self.patch_embed.proj.weight.device
716
717
def rotary_pos_emb_thw(self, t, h, w):
718
- hpos_ids = torch.arange(h).unsqueeze(1).expand(-1, w)
719
- wpos_ids = torch.arange(w).unsqueeze(0).expand(h, -1)
718
+ # Use pre-computed cos_sin_cache from RotaryEmbedding
719
+ cos, sin = self.rotary_pos_emb.get_cos_sin(max(h, w))
720
+
721
+ hpos_ids = torch.arange(h, device=cos.device).unsqueeze(1).expand(-1, w)
722
+ wpos_ids = torch.arange(w, device=cos.device).unsqueeze(0).expand(h, -1)
723
hpos_ids = (
724
hpos_ids.reshape(
725
h // self.spatial_merge_size,
@@ -738,12 +741,7 @@ class Qwen2_5_VisionTransformer(nn.Module):
741
.flatten()
742
)
743
pos_ids = torch.stack([hpos_ids, wpos_ids], dim=-1).repeat(t, 1)
741
- max_size = max(h, w)
742
-
743
- # Use pre-computed cos_sin_cache from RotaryEmbedding
744
- cos, sin = self.rotary_pos_emb.get_cos_sin(max_size)
744
746
- pos_ids = pos_ids.to(cos.device, non_blocking=True)
745
cos_combined = cos[pos_ids].flatten(1)
746
sin_combined = sin[pos_ids].flatten(1)
747
vllm/model_executor/models/qwen2_vl.py
+3
-3
@@ -609,11 +609,12 @@ class Qwen2VisionTransformer(nn.Module):
609
def rot_pos_emb(
610
self, grid_thw: list[list[int]]
611
) -> tuple[torch.Tensor, torch.Tensor]:
612
+ device = self.device
613
pos_ids = []
614
max_grid_size = 0
615
for t, h, w in grid_thw:
615
- hpos_ids = torch.arange(h).unsqueeze(1).expand(-1, w)
616
- wpos_ids = torch.arange(w).unsqueeze(0).expand(h, -1)
616
+ hpos_ids = torch.arange(h, device=device).unsqueeze(1).expand(-1, w)
617
+ wpos_ids = torch.arange(w, device=device).unsqueeze(0).expand(h, -1)
618
hpos_ids = (
619
hpos_ids.reshape(
620
h // self.spatial_merge_size,
@@ -641,7 +642,6 @@ class Qwen2VisionTransformer(nn.Module):
642
# Use pre-computed cos_sin_cache from RotaryEmbedding
643
cos, sin = self.rotary_pos_emb.get_cos_sin(max_grid_size)
644
644
- pos_ids = pos_ids.to(cos.device, non_blocking=True)
645
cos_combined = cos[pos_ids].flatten(1)
646
sin_combined = sin[pos_ids].flatten(1)
647
return cos_combined, sin_combined
vllm/model_executor/models/qwen3_asr.py
+3
-2
@@ -94,6 +94,7 @@ from vllm.transformers_utils.processors.qwen3_asr import (
94
Qwen3ASRProcessor,
95
)
96
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
97
+from vllm.utils.torch_utils import async_tensor_h2d
98
99
logger = init_logger(__name__)
100
_ASR_TEXT_TAG = "<asr_text>"
@@ -465,8 +466,8 @@ class Qwen3ASRForConditionalGeneration(
466
input_features = audio_input["input_features"]
467
# audio_feature_lengths is keep_on_cpu; the audio tower derives
468
# device placement from feature_lens, so move it explicitly.
468
- audio_feature_lengths = audio_input["audio_feature_lengths"].to(
469
- input_features.device, non_blocking=True
469
+ audio_feature_lengths = async_tensor_h2d(
470
+ audio_input["audio_feature_lengths"], input_features.device
471
)
472
473
audio_output_lengths = _get_feat_extract_output_lengths(audio_feature_lengths)
vllm/model_executor/models/qwen3_omni_moe_thinker.py
+7
-7
@@ -842,9 +842,14 @@ class Qwen3Omni_VisionTransformer(nn.Module):
842
return self.patch_embed.proj.weight.device
843
844
def rot_pos_emb(self, grid_thw):
845
+ max_grid_size = grid_thw[:, 1:].max()
846
+
847
+ # Use pre-computed cos_sin_cache from RotaryEmbedding
848
+ cos, sin = self.rotary_pos_emb.get_cos_sin(max_grid_size)
849
+
850
pos_ids = []
851
for t, h, w in grid_thw:
847
- hpos_ids = torch.arange(h).unsqueeze(1).expand(-1, w)
852
+ hpos_ids = torch.arange(h, device=cos.device).unsqueeze(1).expand(-1, w)
853
hpos_ids = hpos_ids.reshape(
854
h // self.spatial_merge_size,
855
self.spatial_merge_size,
@@ -854,7 +859,7 @@ class Qwen3Omni_VisionTransformer(nn.Module):
859
hpos_ids = hpos_ids.permute(0, 2, 1, 3)
860
hpos_ids = hpos_ids.flatten()
861
857
- wpos_ids = torch.arange(w).unsqueeze(0).expand(h, -1)
862
+ wpos_ids = torch.arange(w, device=cos.device).unsqueeze(0).expand(h, -1)
863
wpos_ids = wpos_ids.reshape(
864
h // self.spatial_merge_size,
865
self.spatial_merge_size,
@@ -865,12 +870,7 @@ class Qwen3Omni_VisionTransformer(nn.Module):
870
wpos_ids = wpos_ids.flatten()
871
pos_ids.append(torch.stack([hpos_ids, wpos_ids], dim=-1).repeat(t, 1))
872
pos_ids = torch.cat(pos_ids, dim=0)
868
- max_grid_size = grid_thw[:, 1:].max()
869
-
870
- # Use pre-computed cos_sin_cache from RotaryEmbedding
871
- cos, sin = self.rotary_pos_emb.get_cos_sin(max_grid_size)
873
873
- pos_ids = pos_ids.to(cos.device, non_blocking=True)
874
cos_combined = cos[pos_ids].flatten(1)
875
sin_combined = sin[pos_ids].flatten(1)
876
vllm/model_executor/models/siglip.py
+2
-1
@@ -56,6 +56,7 @@ from vllm.multimodal.processing import (
56
from vllm.sequence import IntermediateTensors
57
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
58
from vllm.utils.tensor_schema import TensorSchema, TensorShape
59
+from vllm.utils.torch_utils import async_tensor_h2d
60
61
from .clip import dual_encoder_has_text_tokens, merge_dual_encoder_text_and_vision
62
from .interfaces import MultiModalEmbeddings, SupportsMultiModal, SupportsQuant
@@ -1010,7 +1011,7 @@ class SiglipEmbeddingModel(nn.Module, SupportsMultiModal, SupportsQuant):
1011
flip_indices_cpu = (
1012
starts_cpu[sequence_ids_cpu] + ends_cpu[sequence_ids_cpu]
1013
) - (1 + current_positions_cpu)
1013
- flip_indices = flip_indices_cpu.to(features.device, non_blocking=True)
1014
+ flip_indices = async_tensor_h2d(flip_indices_cpu, features.device)
1015
1016
return features[flip_indices]
1017
vllm/v1/attention/backends/flashinfer.py
+19
-33
@@ -917,16 +917,15 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
917
arch,
918
)
919
# Preparing persistent buffers
920
- # Since we do not have explicit synchronization in ModelRunnerV2, we do not pin
921
- # reused CPU buffers to avoid a race condition between step N async copies to
922
- # GPU and step N+1 buffer updates.
923
- self.pin_memory = not vllm_config.use_v2_model_runner and PIN_MEMORY
924
- self.paged_kv_indptr = self._make_buffer(max_num_reqs + 1)
925
- self.paged_kv_indptr_cpu_buffer = torch.zeros_like(
926
- self.paged_kv_indptr.cpu, pin_memory=self.pin_memory
927
- ) # Extra buffer for mutable paged_kv_indptr.cpu in cuda graph mode
928
- self.paged_kv_indices = self._make_buffer(max_num_pages)
929
- self.paged_kv_last_page_len = self._make_buffer(max_num_reqs)
920
+ self.paged_kv_indptr = CpuGpuBuffer(
921
+ max_num_reqs + 1, dtype=torch.int32, device=self.device, pin_memory=False
922
+ )
923
+ self.paged_kv_indices = torch.zeros(
924
+ max_num_pages, dtype=torch.int32, device=self.device
925
+ )
926
+ self.paged_kv_last_page_len = CpuGpuBuffer(
927
+ max_num_reqs, dtype=torch.int32, device=self.device, pin_memory=False
928
+ )
929
930
@property
931
def kv_cache_layout(self) -> KVCacheLayout:
@@ -970,17 +969,6 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
969
return FlashInferBackend.get_dtype_for_flashinfer("fp8_e4m3")
970
return self.kv_cache_spec.dtype
971
973
- def _make_buffer(
974
- self, *size: int | torch.SymInt, dtype: torch.dtype = torch.int32
975
- ) -> CpuGpuBuffer:
976
- return CpuGpuBuffer(
977
- *size,
978
- dtype=dtype,
979
- device=self.device,
980
- pin_memory=self.pin_memory,
981
- with_numpy=True,
982
- )
983
-
972
@override # type: ignore[misc]
973
@classmethod
974
def get_cudagraph_support(
@@ -1197,7 +1185,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1185
if decode_wrapper is None:
1186
if use_cudagraph:
1187
paged_kv_indptr = self.paged_kv_indptr.gpu[: batch_size + 1]
1200
- paged_kv_indices = self.paged_kv_indices.gpu
1188
+ paged_kv_indices = self.paged_kv_indices
1189
paged_kv_last_page_len = self.paged_kv_last_page_len.gpu[:batch_size]
1190
else:
1191
paged_kv_indptr = None
@@ -1260,20 +1248,15 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1248
dtype=np.int32,
1249
out=self.paged_kv_indptr.np[1 : num_reqs + 1],
1250
)
1263
- # NOTE(woosuk): Because self.paged_kv_indptr_cpu can be modified
1264
- # after this line (e.g., for cuda graphs), we need to copy the data to
1265
- # self.paged_kv_indptr_buffer to avoid race condition.
1266
- self.paged_kv_indptr_cpu_buffer[: num_reqs + 1] = self.paged_kv_indptr.cpu[
1267
- : num_reqs + 1
1268
- ]
1251
paged_kv_indptr = self.paged_kv_indptr.gpu[: num_reqs + 1]
1270
- paged_kv_indptr.copy_(
1271
- self.paged_kv_indptr_cpu_buffer[: num_reqs + 1], non_blocking=True
1272
- )
1252
+ paged_kv_indptr_cpu = self.paged_kv_indptr.cpu[: num_reqs + 1]
1253
+ if PIN_MEMORY:
1254
+ paged_kv_indptr_cpu = paged_kv_indptr_cpu.pin_memory()
1255
+ paged_kv_indptr.copy_(paged_kv_indptr_cpu, non_blocking=True)
1256
1257
# write self.paged_kv_indices inplace
1258
num_actual_pages = self.paged_kv_indptr.np[num_reqs]
1276
- paged_kv_indices = self.paged_kv_indices.gpu[:num_actual_pages]
1259
+ paged_kv_indices = self.paged_kv_indices[:num_actual_pages]
1260
_copy_page_indices_kernel[(num_reqs,)](
1261
paged_kv_indices,
1262
block_table_tensor,
@@ -1289,8 +1272,11 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1272
page_size,
1273
paged_kv_last_page_len_np,
1274
)
1275
+ paged_kv_last_page_len_cpu = self.paged_kv_last_page_len.cpu[:num_reqs]
1276
+ if PIN_MEMORY:
1277
+ paged_kv_last_page_len_cpu = paged_kv_last_page_len_cpu.pin_memory()
1278
self.paged_kv_last_page_len.gpu[:num_reqs].copy_(
1293
- self.paged_kv_last_page_len.cpu[:num_reqs], non_blocking=True
1279
+ paged_kv_last_page_len_cpu, non_blocking=True
1280
)
1281
return paged_kv_indices
1282
vllm/v1/outputs.py
+12
-4
@@ -11,6 +11,7 @@ import numpy as np
11
import torch
12
13
from vllm.compilation.cuda_graph import CUDAGraphStat
14
+from vllm.utils.torch_utils import PIN_MEMORY
15
from vllm.v1.core.sched.output import SchedulerOutput
16
17
if TYPE_CHECKING:
@@ -182,11 +183,18 @@ class LogprobsTensors(NamedTuple):
183
"""Create empty LogprobsTensors on CPU."""
184
185
logprob_token_ids = torch.empty(
185
- (num_positions, num_tokens_per_position), dtype=torch.int32, device="cpu"
186
+ (num_positions, num_tokens_per_position),
187
+ dtype=torch.int32,
188
+ device="cpu",
189
+ pin_memory=PIN_MEMORY,
190
)
187
- logprobs = torch.empty_like(logprob_token_ids, dtype=torch.float32)
188
- selected_token_ranks = torch.empty(
189
- num_positions, dtype=torch.int32, device="cpu"
191
+ logprobs = logprob_token_ids.new_empty(
192
+ (num_positions, num_tokens_per_position),
193
+ dtype=torch.float32,
194
+ pin_memory=PIN_MEMORY,
195
+ )
196
+ selected_token_ranks = logprob_token_ids.new_empty(
197
+ num_positions, pin_memory=PIN_MEMORY
198
)
199
return LogprobsTensors(
200
logprob_token_ids=logprob_token_ids,
vllm/v1/worker/gpu/mm/encoder_runner.py
+4
-1
@@ -215,7 +215,10 @@ class EncoderRunner:
215
num_computed_tokens = num_computed_tokens + draft_lookahead
216
217
is_mm_embed = torch.zeros(
218
- total_num_scheduled_tokens, dtype=torch.bool, device="cpu"
218
+ total_num_scheduled_tokens,
219
+ dtype=torch.bool,
220
+ device="cpu",
221
+ pin_memory=PIN_MEMORY,
222
)
223
224
# Whether to gather media embeddings this step.