Skip to content

Commit 7368e5b

Browse files
[None][perf] Trim vision-tower CPU launches on Qwen2.5/3-VL
Four CPU-dispatch optimizations stacked on top of the prior PR commits, each verified by the qwen3vl unittests and nsys conc=64 OSL=8. 1. get_rope_index: rewrite the per-token loop using numpy (np.arange/ np.indices/np.concatenate) instead of small torch tensor ops. Same algorithm, output exactly equal. ~2x per call on a 512x512 image. 2. Vision attention apply_rope: add a flash_attn Triton fast-path (flash_attn.ops.triton.rotary.apply_rotary, in-place) between the FlashInfer fused path and the PyTorch fallback. Qwen3-VL head_dim=72 and Qwen2.5-VL head_dim=80 both miss FlashInfer's "head_size % 64 == 0" precondition and used to land on the 7-launch PyTorch path; the Triton kernel is a single launch per (q, k). 3. Vision cos/sin buffers materialised in the vision-tower dtype: Qwen3-VL register_buffer stores rope_cos_cache/rope_sin_cache as bf16, Qwen2.5-VL get_rope_and_window_index_by_thw casts cos_thw/sin_thw once per cached (t, h, w). apply_rope guards the per-call .to(dtype=q.dtype) with a dtype check so it becomes a no-op on the hot path. 4. Vision block residual fusion (vLLM pattern, see vllm/model_executor/models/qwen2_5_vl Qwen2_5_VisionBlock.forward): collapse the post-attention residual add into norm2's residual path, which our LayerNorm / RMSNorm already supports as a torch.compile- fused kernel. One fewer elementwise add launch per block. nsys conc=64 OSL=8 (Qwen3-VL-8B, 1x512x512 image): vision-tower wall median 183 -> 107 ms (-42%), launches/iter 1571 -> 1220 (-22%), CPU API time/iter 134 -> 115 ms (-15%). Throughput moves modestly because GPU work is unchanged and the time is largely absorbed at stream-sync points within _forward_step. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
1 parent c7e344b commit 7368e5b

2 files changed

Lines changed: 252 additions & 202 deletions

File tree

tensorrt_llm/_torch/models/modeling_qwen2vl.py

Lines changed: 160 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from functools import lru_cache
77
from typing import Any, Dict, List, Optional, Tuple, Union
88

9+
import numpy as np
910
import torch
1011
import torch.nn as nn
1112
from torch.nn import functional as F
@@ -50,6 +51,18 @@
5051
# the pattern used in `custom_ops` itself.
5152
if IS_FLASHINFER_AVAILABLE:
5253
from ..custom_ops import flashinfer_apply_rope_with_cos_sin_cache_inplace
54+
55+
# Vision RoPE on Qwen2-VL/2.5-VL/3-VL uses head_dim 72/80 which doesn't satisfy
56+
# FlashInfer's "head_size % 64 == 0" precondition. Fall back to flash_attn's
57+
# fused Triton rotary kernel: one launch instead of the ~7 elementwise launches
58+
# of the PyTorch path (mul x4, add/sub x2, cat x1).
59+
try:
60+
from flash_attn.ops.triton.rotary import \
61+
apply_rotary as _flash_attn_apply_rotary # type: ignore
62+
_FLASH_ATTN_ROTARY_AVAILABLE = True
63+
except ImportError: # pragma: no cover - flash_attn is part of the default deps
64+
_flash_attn_apply_rotary = None
65+
_FLASH_ATTN_ROTARY_AVAILABLE = False
5366
from ..modules.gated_mlp import GatedMLP
5467
from ..modules.rotary_embedding import MRotaryEmbedding, RotaryEmbedding
5568
from .modeling_auto import AutoModelForCausalLM
@@ -147,7 +160,6 @@ def get_rope_index(
147160
image_token_id = config.image_token_id
148161
video_token_id = config.video_token_id
149162
vision_start_token_id = config.vision_start_token_id
150-
mrope_position_deltas = []
151163

152164
# Handle case with no vision inputs
153165
if image_grid_thw is None and video_grid_thw is None:
@@ -172,121 +184,124 @@ def get_rope_index(
172184
)
173185
return position_ids, mrope_position_deltas
174186

175-
# Handle case with vision inputs
176-
total_input_ids = input_ids
177-
if attention_mask is None:
178-
attention_mask = torch.ones_like(total_input_ids)
179-
180-
position_ids = torch.ones(
181-
3,
182-
input_ids.shape[0],
183-
input_ids.shape[1],
184-
dtype=input_ids.dtype,
185-
device=input_ids.device,
186-
)
187-
188-
image_index, video_index = 0, 0
189-
attention_mask = attention_mask.to(total_input_ids.device)
190-
191-
for i, input_ids in enumerate(total_input_ids):
192-
input_ids = input_ids[attention_mask[i] == 1]
193-
image_nums, video_nums = 0, 0
194-
vision_start_indices = torch.argwhere(
195-
input_ids == vision_start_token_id).squeeze(1)
196-
vision_tokens = input_ids[vision_start_indices + 1]
197-
image_nums = (vision_tokens == image_token_id).sum()
198-
video_nums = (vision_tokens == video_token_id).sum()
199-
input_tokens = input_ids.tolist()
200-
llm_pos_ids_list: list = []
187+
# numpy-based vectorized impl: ~2-3× faster than the torch loop
188+
# version on CPU since per-image/video work is small tensor
189+
# allocations dominated by Python+dispatch overhead. Output tensors
190+
# are placed back on ``input_ids.device``.
191+
input_device = input_ids.device
192+
input_dtype = input_ids.dtype
193+
ids_np = input_ids.detach().cpu().numpy()
194+
attn_np = (np.ones_like(ids_np) if attention_mask is None else
195+
attention_mask.detach().cpu().numpy())
196+
image_grid_np = (image_grid_thw.detach().cpu().numpy()
197+
if image_grid_thw is not None else None)
198+
video_grid_np = (video_grid_thw.detach().cpu().numpy()
199+
if video_grid_thw is not None else None)
200+
if second_per_grid_ts is not None and isinstance(
201+
second_per_grid_ts, torch.Tensor):
202+
second_per_grid_ts_np = second_per_grid_ts.detach().cpu().numpy()
203+
else:
204+
second_per_grid_ts_np = second_per_grid_ts
205+
tokens_per_second = (config.vision_config.tokens_per_second if hasattr(
206+
config.vision_config, 'tokens_per_second') else None)
207+
B, S = ids_np.shape
208+
position_ids_np = np.ones((3, B, S), dtype=ids_np.dtype)
209+
deltas = []
210+
image_index = 0
211+
video_index = 0
212+
for i in range(B):
213+
mask_i = attn_np[i] == 1
214+
seq = ids_np[i][mask_i]
215+
vision_start_indices = np.flatnonzero(seq == vision_start_token_id)
216+
vision_tokens = (seq[vision_start_indices +
217+
1] if vision_start_indices.size else seq[:0])
218+
image_nums = int((vision_tokens == image_token_id).sum())
219+
video_nums = int((vision_tokens == video_token_id).sum())
220+
seq_list = seq.tolist()
221+
has_image = image_token_id in seq_list
222+
has_video = video_token_id in seq_list
223+
llm_pos_ids_list = []
201224
st = 0
202225
remain_images, remain_videos = image_nums, video_nums
203-
204226
for _ in range(image_nums + video_nums):
205-
if image_token_id in input_tokens and remain_images > 0:
206-
ed_image = input_tokens.index(image_token_id, st)
227+
if has_image and remain_images > 0:
228+
ed_image = seq_list.index(image_token_id, st)
207229
else:
208-
ed_image = len(input_tokens) + 1
209-
if video_token_id in input_tokens and remain_videos > 0:
210-
ed_video = input_tokens.index(video_token_id, st)
230+
ed_image = len(seq_list) + 1
231+
if has_video and remain_videos > 0:
232+
ed_video = seq_list.index(video_token_id, st)
211233
else:
212-
ed_video = len(input_tokens) + 1
213-
234+
ed_video = len(seq_list) + 1
214235
if ed_image < ed_video:
215-
t, h, w = (
216-
image_grid_thw[image_index][0],
217-
image_grid_thw[image_index][1],
218-
image_grid_thw[image_index][2],
219-
)
236+
t = int(image_grid_np[image_index][0])
237+
h = int(image_grid_np[image_index][1])
238+
w = int(image_grid_np[image_index][2])
220239
second_per_grid_t = 0
221240
image_index += 1
222241
remain_images -= 1
223242
ed = ed_image
224243
else:
225-
t, h, w = (
226-
video_grid_thw[video_index][0],
227-
video_grid_thw[video_index][1],
228-
video_grid_thw[video_index][2],
229-
)
230-
if second_per_grid_ts is not None:
231-
second_per_grid_t = second_per_grid_ts[video_index]
244+
t = int(video_grid_np[video_index][0])
245+
h = int(video_grid_np[video_index][1])
246+
w = int(video_grid_np[video_index][2])
247+
if second_per_grid_ts_np is not None:
248+
second_per_grid_t = float(
249+
second_per_grid_ts_np[video_index])
232250
else:
233251
second_per_grid_t = 1.0
234252
video_index += 1
235253
remain_videos -= 1
236254
ed = ed_video
237-
238-
llm_grid_t, llm_grid_h, llm_grid_w = (
239-
t.item(),
240-
h.item() // spatial_merge_size,
241-
w.item() // spatial_merge_size,
242-
)
255+
llm_grid_t = t
256+
llm_grid_h = h // spatial_merge_size
257+
llm_grid_w = w // spatial_merge_size
243258
text_len = ed - st
244-
245-
st_idx = llm_pos_ids_list[-1].max() + 1 if len(
246-
llm_pos_ids_list) > 0 else 0
247-
llm_pos_ids_list.append(
248-
torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
249-
259+
st_idx = llm_pos_ids_list[-1].max(
260+
) + 1 if llm_pos_ids_list else 0
261+
if text_len > 0:
262+
llm_pos_ids_list.append(
263+
np.broadcast_to(np.arange(text_len), (3, text_len)) +
264+
st_idx)
250265
# Calculate temporal position IDs based on model type
251-
if hasattr(config.vision_config, 'tokens_per_second'):
266+
if tokens_per_second is not None:
252267
# Qwen2_5_VL style temporal position calculation
253-
if isinstance(second_per_grid_t, torch.Tensor):
254-
second_per_grid_t = second_per_grid_t.item()
255-
range_tensor = torch.arange(llm_grid_t).view(-1, 1)
256-
expanded_range = range_tensor.expand(
257-
-1, llm_grid_h * llm_grid_w)
258-
time_tensor = expanded_range * second_per_grid_t * config.vision_config.tokens_per_second
259-
t_index = time_tensor.long().flatten()
268+
t_index = (np.arange(llm_grid_t).reshape(-1, 1) *
269+
(second_per_grid_t * tokens_per_second))
270+
t_index = np.broadcast_to(
271+
t_index.astype(np.int64),
272+
(llm_grid_t, llm_grid_h * llm_grid_w),
273+
).reshape(-1)
274+
h_idx = np.broadcast_to(
275+
np.arange(llm_grid_h).reshape(1, -1, 1),
276+
(llm_grid_t, llm_grid_h, llm_grid_w),
277+
).reshape(-1)
278+
w_idx = np.broadcast_to(
279+
np.arange(llm_grid_w).reshape(1, 1, -1),
280+
(llm_grid_t, llm_grid_h, llm_grid_w),
281+
).reshape(-1)
282+
block = np.stack([t_index, h_idx, w_idx])
260283
else:
261284
# Qwen2VL style temporal position calculation
262-
t_index = torch.arange(llm_grid_t).view(-1, 1).expand(
263-
-1, llm_grid_h * llm_grid_w).flatten()
264-
265-
h_index = torch.arange(llm_grid_h).view(1, -1, 1).expand(
266-
llm_grid_t, -1, llm_grid_w).flatten()
267-
w_index = torch.arange(llm_grid_w).view(1, 1, -1).expand(
268-
llm_grid_t, llm_grid_h, -1).flatten()
269-
270-
llm_pos_ids_list.append(
271-
torch.stack([t_index, h_index, w_index]) + text_len +
272-
st_idx)
285+
block = np.indices(
286+
(llm_grid_t, llm_grid_h, llm_grid_w)).reshape(3, -1)
287+
llm_pos_ids_list.append(block + text_len + st_idx)
273288
st = ed + llm_grid_t * llm_grid_h * llm_grid_w
274-
275-
if st < len(input_tokens):
276-
st_idx = llm_pos_ids_list[-1].max() + 1 if len(
277-
llm_pos_ids_list) > 0 else 0
278-
text_len = len(input_tokens) - st
289+
if st < len(seq_list):
290+
st_idx = llm_pos_ids_list[-1].max(
291+
) + 1 if llm_pos_ids_list else 0
292+
text_len = len(seq_list) - st
279293
llm_pos_ids_list.append(
280-
torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
281-
282-
llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1)
283-
position_ids[..., i, attention_mask[i] == 1] = llm_positions.to(
284-
position_ids.device)
285-
mrope_position_deltas.append(llm_positions.max() + 1 -
286-
len(total_input_ids[i]))
287-
288-
mrope_position_deltas = torch.tensor(
289-
mrope_position_deltas, device=input_ids.device).unsqueeze(1)
294+
np.broadcast_to(np.arange(text_len), (3, text_len)) +
295+
st_idx)
296+
llm_positions = np.concatenate(llm_pos_ids_list,
297+
axis=1).reshape(3, -1)
298+
position_ids_np[:, i, mask_i] = llm_positions
299+
deltas.append(
300+
int(llm_positions.max()) + 1 - int(ids_np[i].shape[0]))
301+
position_ids = torch.from_numpy(position_ids_np).to(device=input_device,
302+
dtype=input_dtype)
303+
mrope_position_deltas = torch.tensor(deltas,
304+
device=input_device).unsqueeze(1)
290305
return position_ids, mrope_position_deltas
291306

292307
def _preprocess(self, text: Dict[str, any], mm_data: Dict[str, any],
@@ -645,13 +660,39 @@ def apply_rope(self,
645660
err,
646661
)
647662

648-
cos = cos.to(dtype=q.dtype)
649-
sin = sin.to(dtype=q.dtype)
663+
# cos/sin are pre-cast to ``q.dtype`` upstream (``get_rope_and_window_
664+
# index_by_thw`` for Qwen2.5-VL; ``rope_cos_cache`` buffer dtype for
665+
# Qwen3-VL). When that holds, ``.to(dtype=q.dtype)`` is a no-op; keep
666+
# it as a safety net for callers/quantization paths that didn't pre-cast.
667+
if cos.dtype != q.dtype:
668+
cos = cos.to(dtype=q.dtype)
669+
if sin.dtype != q.dtype:
670+
sin = sin.to(dtype=q.dtype)
650671
q = q.view(seq_len, -1, self.head_dim)
651672
k = k.view(seq_len, -1, self.head_dim)
652673
v = v.view(seq_len, -1, self.head_dim)
653-
q = RotaryEmbedding.apply_rotary_pos_emb(q, cos, sin)
654-
k = RotaryEmbedding.apply_rotary_pos_emb(k, cos, sin)
674+
if _FLASH_ATTN_ROTARY_AVAILABLE:
675+
# flash_attn Triton kernel: single launch per tensor. cos/sin
676+
# are expected as ``[seqlen, head_dim/2]``. The PyTorch path
677+
# built ``RotaryEmbedding.apply_rotary_pos_emb`` with cos/sin
678+
# already in that layout (see ``get_rotary_pos_emb_window_data``).
679+
# The kernel takes 4D ``(batch, seq, nheads, headdim)``; add a
680+
# batch dim, run in-place, then drop it.
681+
q4 = q.unsqueeze(0)
682+
k4 = k.unsqueeze(0)
683+
_flash_attn_apply_rotary(q4,
684+
cos,
685+
sin,
686+
interleaved=False,
687+
inplace=True)
688+
_flash_attn_apply_rotary(k4,
689+
cos,
690+
sin,
691+
interleaved=False,
692+
inplace=True)
693+
else:
694+
q = RotaryEmbedding.apply_rotary_pos_emb(q, cos, sin)
695+
k = RotaryEmbedding.apply_rotary_pos_emb(k, cos, sin)
655696
q, k, v = q.reshape(seq_len, -1), k.reshape(seq_len,
656697
-1), v.reshape(seq_len, -1)
657698
return q, k, v
@@ -732,21 +773,21 @@ def forward(
732773
position_embeddings: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
733774
**kwargs,
734775
) -> torch.Tensor:
735-
736-
residual = hidden_states
737-
hidden_states = self.norm1(hidden_states)
738-
hidden_states = residual + self.attn(
739-
hidden_states=hidden_states,
776+
# vLLM-style residual fusion: collapse the post-attn residual add
777+
# into ``norm2``'s residual path (``RMSNorm.forward`` with ``residual=``
778+
# uses the FlashInfer ``fused_add_rmsnorm`` kernel). Saves one
779+
# ``add<c10::BFloat16>`` launch per vision block (= 32 launches per
780+
# executor iter at full-batch). Mirrors
781+
# ``vllm/model_executor/models/qwen2_5_vl.py::Qwen2_5_VisionBlock``.
782+
x_attn = self.attn(
783+
hidden_states=self.norm1(hidden_states),
740784
attn_metadata=attn_metadata,
741785
position_ids=position_ids,
742786
position_embeddings=position_embeddings,
743787
**kwargs,
744788
)
745-
746-
residual = hidden_states
747-
hidden_states = self.norm2(hidden_states)
748-
hidden_states = residual + self.mlp(hidden_states)
749-
return hidden_states
789+
x_fused_norm, residual = self.norm2(hidden_states, residual=x_attn)
790+
return residual + self.mlp(x_fused_norm)
750791

751792

752793
class Qwen2_5_VLPatchMerger(torch.nn.Module):
@@ -989,6 +1030,18 @@ def get_rope_and_window_index_by_thw(
9891030
sin_thw = sin_thw[window_index_thw_dev, :, :].reshape(
9901031
-1, sin_thw.shape[-1])
9911032

1033+
# Cast once (per cached (t, h, w)) to the vision-tower dtype so the
1034+
# per-block ``cos.to(dtype=q.dtype)`` cast in ``apply_rope`` becomes a
1035+
# no-op. Mirrors vLLM's ``get_rope_by_thw`` which builds the rotary
1036+
# cache directly in the model dtype (see note above).
1037+
target_dtype = (
1038+
self.model_config.pretrained_config.vision_config.torch_dtype
1039+
if hasattr(self.model_config.pretrained_config.vision_config,
1040+
"torch_dtype") else
1041+
self.model_config.pretrained_config.torch_dtype)
1042+
cos_thw = cos_thw.to(target_dtype)
1043+
sin_thw = sin_thw.to(target_dtype)
1044+
9921045
return cos_thw, sin_thw, window_index_thw, tuple(seq_lens_thw)
9931046

9941047
def prepare_attn_metadata(self, batch_size: int, seq_lens: List[int],

0 commit comments

Comments
 (0)