[Perf][Qwen3-Omni] Capture the thinker image encoder with CUDA graphs (part of #7452) (#8215)

Signed-off-by: MrlixiangWE <mrdanaer@gmail.com>
Co-authored-by: zijianc2 <157244773+zijianc2@users.noreply.github.com>
Co-authored-by: Yifan Tian <221685243+Sendoh-code@users.noreply.github.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
authored and GitHub committed 2026-10-07 09:29:59 +08:00
1 parent ed6bc5ae0c
commit 712b90de9c
8 files changed
+968 -55

No files matched your search

@@ -26,6 +26,7 @@ from vllm.model_executor.models.interfaces import (
SupportsPP,
SupportsQuant,
SupportsRealtime,
supports_encoder_cudagraph,
)
from vllm.model_executor.models.qwen3_asr_realtime import Qwen3ASRRealtimeBuffer
from vllm.model_executor.models.qwen3_omni_moe_thinker import (
@@ -90,6 +91,24 @@ TALKER_CODEC_THINK_EOS_ID = 4205 # Think mode end
logger = init_logger(__name__)
# The encoder CUDA graph protocol, forwarded from the thinker to the stage
# wrapper that the runner inspects.
_THINKER_ENCODER_CUDAGRAPH_MEMBERS = (
"supports_encoder_cudagraph",
"encoder_cudagraph_single_replay",
"get_encoder_cudagraph_config",
"get_input_modality",
"get_max_frames_per_video",
"get_encoder_cudagraph_budget_range",
"get_encoder_cudagraph_item_specs",
"select_encoder_cudagraph_items",
"prepare_encoder_cudagraph_capture_inputs",
"prepare_encoder_cudagraph_replay_buffers",
"encoder_cudagraph_forward",
"encoder_eager_forward",
"postprocess_encoder_output",
)
def _cpu_long(values: Any) -> torch.Tensor:
"""Token ids as a host tensor (payload ids arrive as lists or CPU tensors)."""
@@ -221,6 +240,12 @@ class Qwen3OmniMoeForConditionalGeneration(
self.model = self.thinker
self.talker = None
self.code2wav = None
if supports_encoder_cudagraph(self.thinker):
# Expose the protocol on this stage instance only. Defining
# these methods on the shared class also advertises an encoder
# for talker/code2wav under runtime structural protocol checks.
for name in _THINKER_ENCODER_CUDAGRAPH_MEMBERS:
setattr(self, name, getattr(self.thinker, name))
self.tts_tokens = torch.tensor(
[[self.config.tts_bos_token_id, self.config.tts_eos_token_id, self.config.tts_pad_token_id]],
device=self._module_device(self.thinker),
@@ -117,6 +117,7 @@ from vllm.multimodal.processing.processor import (
from vllm.multimodal.utils import set_mm_embedding_modality
from vllm.sequence import IntermediateTensors
from vllm.transformers_utils.processor import cached_processor_from_config
from vllm.utils.torch_utils import async_tensor_h2d
from vllm_omni.data_entry_keys import OmniPayload
from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import (
@@ -129,6 +130,7 @@ from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import (
from vllm_omni.model_executor.models.qwen3_omni.quantization import (
Qwen3OmniNestedSupportsQuant,
)
from vllm_omni.model_executor.models.qwen3_omni.vision_encoder_cudagraph import Qwen3OmniVisionEncoderCudaGraphMixin
from vllm_omni.quantization.component_config import (
PRE_QUANTIZED_METHODS,
ComponentQuantizationConfig,
@@ -167,79 +169,74 @@ class Qwen3Omni_VisionTransformer(_Qwen3Omni_VisionTransformer):
super().__init__(*args, **kwargs)
self.tp_size = get_tensor_model_parallel_world_size()
def forward(
self,
x: torch.Tensor,
grid_thw,
) -> torch.Tensor:
hidden_states = x.to(device=self.device, dtype=self.dtype)
hidden_states = self.patch_embed(hidden_states)
def prepare_encoder_metadata(self, grid_thw) -> dict[str, torch.Tensor]:
"""Prepare positions and attention metadata outside CUDA graph capture."""
if not isinstance(grid_thw, torch.Tensor):
grid_thw = torch.as_tensor(grid_thw, dtype=torch.int32)
metadata = {}
if self.apply_vit_abs_pos_embed:
pos_embeds = self.fast_pos_embed_interpolate(grid_thw)
hidden_states = hidden_states + pos_embeds
metadata["pos_embeds"] = self.fast_pos_embed_interpolate(grid_thw)
rotary_pos_emb_cos, rotary_pos_emb_sin = self.rot_pos_emb(grid_thw)
if isinstance(grid_thw, torch.Tensor):
grid_thw_tensor = grid_thw.to(self.device)
else:
grid_thw_tensor = torch.as_tensor(grid_thw, dtype=torch.int32, device=self.device)
rotary_pos_emb_cos = rotary_pos_emb_cos.to(self.device)
rotary_pos_emb_sin = rotary_pos_emb_sin.to(self.device)
try:
cu_seqlens = torch.repeat_interleave(
grid_thw_tensor[:, 1] * grid_thw_tensor[:, 2],
grid_thw_tensor[:, 0],
).cumsum(
dim=0,
dtype=grid_thw_tensor.dtype if torch.jit.is_tracing() else torch.int32,
)
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
except RuntimeError:
logger.warning(
"torch.repeat_interleave not executable, switching to vectorized searchsorted implementation."
)
repeat_counts = grid_thw_tensor[:, 0]
values = grid_thw_tensor[:, 1] * grid_thw_tensor[:, 2]
repeat_cumsum = repeat_counts.cumsum(0)
total_items = repeat_cumsum[-1].item()
indices = torch.searchsorted(
repeat_cumsum,
torch.arange(total_items, device=grid_thw_tensor.device),
right=True,
)
cu_seqlens = values[indices].cumsum(
dim=0,
dtype=grid_thw_tensor.dtype if torch.jit.is_tracing() else torch.int32,
)
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
hidden_states = hidden_states.unsqueeze(1)
rotary_pos_emb_cos = rotary_pos_emb_cos.to(hidden_states.device)
rotary_pos_emb_sin = rotary_pos_emb_sin.to(hidden_states.device)
max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
grid_thw_np = grid_thw_tensor.cpu().numpy().astype(np.int32)
# Sequence boundaries come from the host grid, so neither the eager
# path nor a replay reads a device value back.
grid_thw_np = grid_thw.cpu().numpy().astype(np.int32)
cu_seqlens_np = np.repeat(grid_thw_np[:, 1] * grid_thw_np[:, 2], grid_thw_np[:, 0]).cumsum(
axis=0, dtype=np.int32
)
cu_seqlens_np = np.concatenate([np.zeros(1, dtype=np.int32), cu_seqlens_np])
cu_seqlens_host = torch.from_numpy(cu_seqlens_np)
if self.device.type == "cpu":
cu_seqlens = cu_seqlens_host
else:
cu_seqlens = async_tensor_h2d(cu_seqlens_host, device=self.device)
# Attention reads this scalar on the host. Replay keeps the capture
# budget's bound instead.
max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens_host)
sequence_lengths = MMEncoderAttention.maybe_compute_seq_lens(
self.attn_backend,
cu_seqlens_np,
self.device,
)
metadata.update(
rotary_pos_emb_cos=rotary_pos_emb_cos,
rotary_pos_emb_sin=rotary_pos_emb_sin,
cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen,
)
if sequence_lengths is not None:
metadata["sequence_lengths"] = sequence_lengths
return metadata
def forward(
self, x: torch.Tensor, grid_thw, *, encoder_metadata: dict[str, torch.Tensor] | None = None
) -> torch.Tensor:
if encoder_metadata is None:
encoder_metadata = self.prepare_encoder_metadata(grid_thw)
return self.forward_with_encoder_metadata(x, encoder_metadata)
def forward_with_encoder_metadata(self, x: torch.Tensor, metadata: dict[str, torch.Tensor]) -> torch.Tensor:
"""Run patch embedding, vision blocks and all DeepStack mergers."""
hidden_states = self.patch_embed(x.to(device=self.device, dtype=self.dtype))
if self.apply_vit_abs_pos_embed:
hidden_states = hidden_states + metadata["pos_embeds"]
hidden_states = hidden_states.unsqueeze(1)
hidden_states_list = []
deepstack_visual_indexes = self.deepstack_visual_indexes
for layer_num, blk in enumerate(self.blocks):
hidden_states = blk(
hidden_states,
cu_seqlens=cu_seqlens,
rotary_pos_emb_cos=rotary_pos_emb_cos,
rotary_pos_emb_sin=rotary_pos_emb_sin,
max_seqlen=max_seqlen,
sequence_lengths=sequence_lengths,
cu_seqlens=metadata["cu_seqlens"],
rotary_pos_emb_cos=metadata["rotary_pos_emb_cos"],
rotary_pos_emb_sin=metadata["rotary_pos_emb_sin"],
max_seqlen=metadata["max_seqlen"],
sequence_lengths=metadata.get("sequence_lengths"),
)
if deepstack_visual_indexes is not None and layer_num in deepstack_visual_indexes:
@@ -440,8 +437,6 @@ class Qwen3OmniMoeAudioEncoder(_Qwen3OmniMoeAudioEncoder):
feature_lens: torch.Tensor,
aftercnn_lens: torch.Tensor,
):
import torch.nn.functional as F
chunk_num = torch.ceil(feature_lens / (self.n_window * 2)).long()
chunk_lengths = torch.tensor(
@@ -1024,6 +1019,7 @@ class Qwen3OmniMoeConditionalGenerationMixin(Qwen2_5OmniConditionalGenerationMix
)
class Qwen3OmniMoeThinkerForConditionalGeneration(
nn.Module,
Qwen3OmniVisionEncoderCudaGraphMixin,
SupportsMultiModal,
SupportsPP,
SupportsLoRA,
@@ -1159,6 +1155,8 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
]
self.deepstack_input_embeds_num_tokens = 0
self._enable_image_encoder_cudagraph()
with self._mark_language_model(vllm_config):
lm_vllm_config = vllm_config.with_hf_config(
thinker_config.text_config,
@@ -0,0 +1,182 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Qwen3-Omni image encoder contract for the upstream CUDA graph manager."""
from collections.abc import Hashable
from typing import Any
import torch
from vllm.distributed import get_pp_group
from vllm.model_executor.models.utils import scatter_output_slices
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.worker.encoder_cudagraph_defs import (
EncoderCudaGraphCaptureInputs,
EncoderCudaGraphConfig,
EncoderCudaGraphReplayBuffers,
EncoderItemSpec,
)
def _pad_cu_seqlens(dst: torch.Tensor, src: torch.Tensor) -> None:
# Unused attention sequences must be empty, not run backwards to zero.
dst.copy_(src[-1].expand_as(dst))
dst[: src.shape[0]].copy_(src)
class Qwen3OmniVisionEncoderCudaGraphMixin:
"""Image-only protocol methods; video and audio retain embed_multimodal.
The capability attribute is set on eligible model instances after tower
construction. It is deliberately absent from this mixin's class: the
runtime protocol checks attribute presence, not the value of a false flag.
"""
# The eager tower is launch-bound, so a group that needs several replays
# or per-image eager calls runs as one eager call instead; see
# SingleReplayEncoderCudaGraphManager.
encoder_cudagraph_single_replay = True
def _enable_image_encoder_cudagraph(self) -> None:
# Only the first pipeline rank runs the encoder. Dynamic-scale FP8 ViT
# attention advances a host-side amax slot per call, which a graph
# would freeze. The pipeline-group query comes last so a thinker built
# without a vision tower or distributed state never reaches it.
if (
not self.multimodal_config.enable_mm_embeds
and self.multimodal_config.mm_encoder_attn_dtype is None
and self.multimodal_config.get_limit_per_prompt("image") > 0
and getattr(self.visual, "attn_backend", None)
in {
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.ROCM_AITER_FA,
AttentionBackendEnum.TRITON_ATTN,
}
and get_pp_group().is_first_rank
):
self.supports_encoder_cudagraph = True
def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
keys = ["pixel_values", "rotary_pos_emb_cos", "rotary_pos_emb_sin", "cu_seqlens", "max_seqlen"]
if self.visual.apply_vit_abs_pos_embed:
keys.append("pos_embeds")
return EncoderCudaGraphConfig(
modalities=["image"],
buffer_keys=keys,
out_hidden_size=self.visual_dim + self.multiscale_dim,
padding_logics={"cu_seqlens": _pad_cu_seqlens},
)
def get_input_modality(self, mm_kwargs: dict[str, Any]) -> str:
return "image"
def get_max_frames_per_video(self) -> int:
return 1
def get_encoder_cudagraph_budget_range(self, vllm_config) -> tuple[int, int]:
# Replay pays off while the eager tower is launch-bound: on an A800 a
# 196-token image replays 32% faster, while 280-400 tokens replay
# 4-10% slower and 560-784 tokens padded to the 1024 budget 12-51%
# slower. Every budget also adds captured graph memory. Auto-inferred
# budgets therefore stop at 256 tokens; larger images run eagerly
# unless encoder_cudagraph_token_budgets asks for more.
maximum = min(vllm_config.scheduler_config.max_num_batched_tokens, vllm_config.model_config.max_model_len, 256)
return min(64, maximum), maximum
def _image_grid(self, mm_kwargs: dict[str, Any]) -> list[list[int]]:
grid = mm_kwargs["image_grid_thw"]
return grid if isinstance(grid, list) else grid.tolist()
def get_encoder_cudagraph_item_specs(self, mm_kwargs: dict[str, Any]) -> list[EncoderItemSpec]:
merge = self.visual.spatial_merge_size
result = []
for t, h, w in self._image_grid(mm_kwargs):
if t != 1 or h <= 0 or w <= 0 or h % merge or w % merge:
raise ValueError(f"Invalid Qwen3-Omni image grid: {(t, h, w)}")
result.append(EncoderItemSpec(input_size=h * w, output_tokens=(h // merge) * (w // merge)))
return result
def select_encoder_cudagraph_items(self, mm_kwargs: dict[str, Any], indices: list[int]) -> dict[str, Any]:
grid = self._image_grid(mm_kwargs)
pixels = mm_kwargs["pixel_values"]
offsets = [0]
for spec in self.get_encoder_cudagraph_item_specs(mm_kwargs):
offsets.append(offsets[-1] + spec.input_size)
return {
"pixel_values": torch.cat([pixels[offsets[i] : offsets[i + 1]] for i in indices])
if indices
else pixels[:0],
"image_grid_thw": [grid[i] for i in indices],
}
def prepare_encoder_cudagraph_capture_inputs(
self,
token_budget: int,
max_batch_size: int,
max_frames_per_batch: int,
device: torch.device,
dtype: torch.dtype,
path: str = "default",
axis_keys: tuple[Hashable, ...] | None = None,
) -> EncoderCudaGraphCaptureInputs:
if path != "default" or axis_keys:
raise ValueError("Qwen3-Omni images use one encoder path without capture axes")
visual = self.visual
merge = visual.spatial_merge_size
patches = token_budget * merge**2
# Infer the RoPE layout from one real minimal grid. No large dummy
# aspect ratio is needed, and no dummy grid indexes past the RoPE cache.
small = visual.prepare_encoder_metadata([[1, merge, merge]])
values = {
"rotary_pos_emb_cos": torch.ones(
(patches, *small["rotary_pos_emb_cos"].shape[1:]),
device=device,
dtype=small["rotary_pos_emb_cos"].dtype,
),
"rotary_pos_emb_sin": torch.zeros(
(patches, *small["rotary_pos_emb_sin"].shape[1:]),
device=device,
dtype=small["rotary_pos_emb_sin"].dtype,
),
"cu_seqlens": torch.full((max_batch_size + 1,), patches, device=device, dtype=torch.int32),
# This host scalar is baked into the captured attention launch.
# Any single image in this graph has at most `patches` patches.
"max_seqlen": torch.tensor(patches, dtype=torch.int32),
}
values["cu_seqlens"][0] = 0
patch_embed = visual.patch_embed
patch_width = patch_embed.proj.in_channels * patch_embed.temporal_patch_size * patch_embed.patch_size**2
values["pixel_values"] = torch.zeros((patches, patch_width), device=device, dtype=dtype)
if visual.apply_vit_abs_pos_embed:
values["pos_embeds"] = torch.zeros((patches, visual.hidden_size), device=device, dtype=visual.dtype)
return EncoderCudaGraphCaptureInputs(values=values)
def prepare_encoder_cudagraph_replay_buffers(
self,
mm_kwargs: dict[str, Any],
max_batch_size: int,
max_frames_per_batch: int,
path: str = "default",
) -> EncoderCudaGraphReplayBuffers:
values = self.visual.prepare_encoder_metadata(self._image_grid(mm_kwargs))
# Replaying a smaller request must not lower the scalar bound retained
# by the capture. Tensor positions and sequence boundaries do change.
values.pop("max_seqlen")
values["pixel_values"] = mm_kwargs["pixel_values"]
return EncoderCudaGraphReplayBuffers(values=values)
def encoder_cudagraph_forward(self, inputs: dict[str, torch.Tensor], path: str = "default") -> torch.Tensor:
return self.visual(inputs["pixel_values"], None, encoder_metadata=inputs)
def encoder_eager_forward(self, mm_kwargs: dict[str, Any], path: str = "default") -> torch.Tensor:
return self.visual(mm_kwargs["pixel_values"], self._image_grid(mm_kwargs))
def postprocess_encoder_output(
self,
outputs: dict[str, torch.Tensor],
indices: list[int],
per_item_out_tokens: list[int],
dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
clone: bool = False,
batch_mm_kwargs: dict[str, Any] | None = None,
) -> None:
scatter_output_slices(outputs["default"], indices, per_item_out_tokens, dest, clone)
+32
View File
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Encoder CUDA graph manager that replays a group only when one graph holds it."""
from typing import Any
import torch
from vllm.v1.worker.encoder_cudagraph import EncoderCudaGraphManager
class SingleReplayEncoderCudaGraphManager(EncoderCudaGraphManager):
"""Replay an encoder group only when one captured graph holds all of it.
The upstream manager splits a group across several replays and gives each
item above the largest budget its own eager call. For a launch-bound vision
tower that is usually slower than one eager call over the whole group, so a
group that does not fit one graph goes back to the runner, which then
encodes it with a single ``embed_multimodal`` call. Models opt in by
setting ``encoder_cudagraph_single_replay``.
"""
def execute(self, mm_kwargs: dict[str, Any]) -> list[torch.Tensor] | None: # type: ignore[override]
if not self.use_dp:
specs = self._get_item_specs(mm_kwargs)
fits = len(specs) <= self.max_batch_size and all(
sum(spec.get_path_output_tokens(path) for spec in specs) <= max(budgets)
for path, budgets in self.path_token_budgets.items()
)
if not fits:
self.graph_misses += len(specs)
return None
return super().execute(mm_kwargs)
+19
View File
@@ -283,6 +283,25 @@ class OmniGPUModelRunner(PrefixCacheRunnerMixin, GPUModelRunner):
capture_sizes=capture_sizes,
)
@torch.inference_mode()
def _create_encoder_cudagraph_manager(self):
raw_model = self.get_model()
if not getattr(raw_model, "encoder_cudagraph_single_replay", False):
return super()._create_encoder_cudagraph_manager()
from vllm.model_executor.models.interfaces import supports_encoder_cudagraph
from vllm_omni.worker.encoder_cudagraph import SingleReplayEncoderCudaGraphManager
if not (
self.compilation_config.cudagraph_mm_encoder
and self.supports_mm_inputs
and supports_encoder_cudagraph(raw_model)
):
return None
return SingleReplayEncoderCudaGraphManager(
vllm_config=self.vllm_config, device=self.device, dtype=self.dtype, model=raw_model
)
def _maybe_attach_attention_metadata_extensions(
self,
*,