Skip to content

Commit f9a8eab

Browse files
shen-shanshanIsotr0py
authored andcommitted
[Bugfix][MM][CG] Enable dual-path ViT CUDA graph for Step3-VL (vllm-project#46034)
Signed-off-by: shen-shanshan <467638484@qq.com> Signed-off-by: Isotr0py <mozf@mail2.sysu.edu.cn> Co-authored-by: Isotr0py <mozf@mail2.sysu.edu.cn>
1 parent d9aed32 commit f9a8eab

2 files changed

Lines changed: 87 additions & 125 deletions

File tree

docs/design/cuda_graphs_multimodal.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
136136
| `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ | ❌︎ |
137137
| `Qwen3_5ForConditionalGeneration` | `Qwen3.5`, `Qwen3.6` | ✅︎ | ✅︎ | ❌︎ |
138138
| `Qwen3_5MoeForConditionalGeneration` | `Qwen3.5-MoE`, `Qwen3.6-MoE` | ✅︎ | ✅︎ | ❌︎ |
139-
| `Step3VLForConditionalGeneration` | `Step3-VL` | ✅︎ | ❌︎ | |
139+
| `Step3VLForConditionalGeneration` | `Step3-VL` | ✅︎ | ❌︎ | |
140140

141141
!!! note
142142
Encoder CUDA Graphs have currently been tested with `--mm-encoder-attn-backend=FLASH_ATTN` and `--mm-encoder-attn-backend=FLASHINFER` on Blackwell GPUs.

vllm/model_executor/models/step3_vl.py

Lines changed: 86 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,31 @@ def _compute_spatial_tokens(size, patch_size, stride):
589589
h2 = (h1 - 1) // 2 + 1
590590
return h2 * h2
591591

592+
@property
593+
def img_output_tokens(self) -> int:
594+
return self._compute_spatial_tokens(
595+
self.config.vision_config.image_size,
596+
self.config.vision_config.patch_size,
597+
self.config.understand_projector_stride,
598+
)
599+
600+
@property
601+
def patch_output_tokens(self) -> int:
602+
return self._compute_spatial_tokens(
603+
504,
604+
self.config.vision_config.patch_size,
605+
self.config.understand_projector_stride,
606+
)
607+
608+
def _batched_encoder_forward(
609+
self,
610+
pixel_values: torch.Tensor,
611+
) -> torch.Tensor:
612+
image_features = self._process_image_features(
613+
self._get_vision_model_output(pixel_values)
614+
)
615+
return image_features.reshape(-1, image_features.shape[-1])
616+
592617
def _parse_and_validate_image_input(
593618
self, **kwargs: object
594619
) -> Step3VLImageInputs | None:
@@ -695,6 +720,8 @@ def embed_input_ids(
695720
is_multimodal=is_multimodal,
696721
)
697722

723+
# -- SupportsEncoderCudaGraph protocol methods --
724+
698725
def get_encoder_cudagraph_config(self):
699726
from vllm.v1.worker.encoder_cudagraph_defs import (
700727
EncoderCudaGraphConfig,
@@ -707,18 +734,16 @@ def get_encoder_cudagraph_config(self):
707734
"patch_pixel_values",
708735
],
709736
out_hidden_size=self.config.hidden_size,
737+
enable_dual_path_graph=True,
738+
global_token_per_image=self.img_output_tokens,
739+
local_token_per_patch=self.patch_output_tokens,
710740
)
711741

712742
def get_encoder_cudagraph_budget_range(
713743
self,
714744
vllm_config: "VllmConfig",
715745
) -> tuple[int, int]:
716-
# An image without patches
717-
min_budget = self._compute_spatial_tokens(
718-
self.config.vision_config.image_size,
719-
self.config.vision_config.patch_size,
720-
self.config.understand_projector_stride,
721-
)
746+
min_budget = self.img_output_tokens
722747
max_budget = min(
723748
vllm_config.scheduler_config.max_num_batched_tokens,
724749
self.model_config.max_model_len,
@@ -732,22 +757,6 @@ def get_encoder_cudagraph_item_specs(
732757
from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
733758

734759
num_patches = mm_kwargs.get("num_patches")
735-
img_output_tokens = self._compute_spatial_tokens(
736-
self.config.vision_config.image_size,
737-
self.config.vision_config.patch_size,
738-
self.config.understand_projector_stride,
739-
)
740-
741-
# NOTE: 504 is the hard coded size for each patch after processing
742-
# by the vision model, which is determined by the current architecture
743-
# of the vision model and may need to be updated if the architecture changes.
744-
# The number of tokens for each patch is calculated based on this
745-
# size and the patch size.
746-
patch_output_tokens = self._compute_spatial_tokens(
747-
504,
748-
self.config.vision_config.patch_size,
749-
self.config.understand_projector_stride,
750-
)
751760

752761
img_grid = (
753762
self.config.vision_config.image_size // self.config.vision_config.patch_size
@@ -759,7 +768,11 @@ def get_encoder_cudagraph_item_specs(
759768
return [
760769
EncoderItemSpec(
761770
input_size=(total_image_pixel + num_patch * total_patch_pixel),
762-
output_tokens=(img_output_tokens + num_patch * patch_output_tokens),
771+
output_tokens=(
772+
self.img_output_tokens + num_patch * self.patch_output_tokens
773+
),
774+
global_output_tokens=self.img_output_tokens,
775+
local_output_tokens=num_patch * self.patch_output_tokens,
763776
)
764777
for num_patch in num_patches
765778
]
@@ -810,46 +823,30 @@ def prepare_encoder_cudagraph_capture_inputs(
810823
EncoderCudaGraphCaptureInputs,
811824
)
812825

813-
# For pixel_value, the max input size is max_batch_size
814-
img_output_tokens = self._compute_spatial_tokens(
815-
self.config.vision_config.image_size,
816-
self.config.vision_config.patch_size,
817-
self.config.understand_projector_stride,
818-
)
819-
patch_output_tokens = self._compute_spatial_tokens(
820-
504,
821-
self.config.vision_config.patch_size,
822-
self.config.understand_projector_stride,
823-
)
824-
dummy_pixel_values = torch.randn(
825-
max_batch_size,
826-
3,
827-
self.config.vision_config.image_size,
828-
self.config.vision_config.image_size,
829-
device=device,
830-
dtype=dtype,
831-
)
832-
# max_num_patches is the max total patches across the whole batch.
833-
# token_budget = max_batch_size * img_out + max_num_patches * patch_out
834-
max_num_patches = max(
835-
0,
836-
(token_budget - max_batch_size * img_output_tokens) // patch_output_tokens,
837-
)
838-
dummy_patch_pixel_values = torch.randn(
839-
max_num_patches,
840-
3,
841-
504,
842-
504,
843-
device=device,
844-
dtype=dtype,
845-
)
846-
# num_patches is NOT in values -- the per-item merge is done
847-
# CPU-side by finalize_encoder_cudagraph_output using the actual
848-
# batch's num_patches from mm_kwargs.
849-
values = {
850-
"pixel_values": dummy_pixel_values,
851-
"patch_pixel_values": dummy_patch_pixel_values,
852-
}
826+
assert path in ("global", "local")
827+
if path == "global":
828+
max_num_images = token_budget // self.img_output_tokens
829+
max_batch_size = min(max_batch_size, max_num_images)
830+
dummy_pixel_values = torch.randn(
831+
max_batch_size,
832+
3,
833+
self.config.vision_config.image_size,
834+
self.config.vision_config.image_size,
835+
device=device,
836+
dtype=dtype,
837+
)
838+
values = {"pixel_values": dummy_pixel_values}
839+
else:
840+
max_num_patches = token_budget // self.patch_output_tokens
841+
dummy_patch_pixel_values = torch.randn(
842+
max_num_patches,
843+
3,
844+
504,
845+
504,
846+
device=device,
847+
dtype=dtype,
848+
)
849+
values = {"patch_pixel_values": dummy_patch_pixel_values}
853850

854851
return EncoderCudaGraphCaptureInputs(
855852
values=values,
@@ -860,42 +857,22 @@ def encoder_cudagraph_forward(
860857
values: dict[str, torch.Tensor],
861858
path: str = "default",
862859
) -> torch.Tensor:
863-
# Graph captures only the compute (vision model + conv projector).
864-
# Per-item merge happens CPU-side in finalize_encoder_cudagraph_output
865-
# using actual num_patches from the batch data.
866-
pixel_values = values["pixel_values"]
867-
patch_pixel_values = values["patch_pixel_values"]
868-
869-
image_features = self._process_image_features(
870-
self._get_vision_model_output(pixel_values)
871-
)
872-
873-
has_patches = len(patch_pixel_values) > 0
874-
if has_patches:
875-
patch_features = self._process_image_features(
876-
self._get_vision_model_output(patch_pixel_values)
877-
)
878-
879-
# Deterministic single cat: [all_img_flat, all_patch_flat]
880-
img_flat = image_features.reshape(-1, image_features.shape[-1])
881-
if has_patches:
882-
patch_flat = patch_features.reshape(-1, patch_features.shape[-1])
883-
return torch.cat([img_flat, patch_flat], dim=0)
884-
return img_flat
860+
assert path in ("global", "local")
861+
if path == "global":
862+
return self._batched_encoder_forward(values["pixel_values"])
863+
else:
864+
return self._batched_encoder_forward(values["patch_pixel_values"])
885865

886866
def encoder_eager_forward(
887867
self,
888868
mm_kwargs: dict[str, Any],
889869
path: str = "default",
890870
) -> torch.Tensor:
891-
image_input = Step3VLImagePixelInputs(
892-
type="pixel_values",
893-
pixel_values=mm_kwargs["pixel_values"],
894-
patch_pixel_values=mm_kwargs["patch_pixel_values"],
895-
num_patches=mm_kwargs["num_patches"],
896-
)
897-
vision_embeddings = self._process_image_input(image_input)
898-
return torch.cat(vision_embeddings, dim=0)
871+
assert path in ("global", "local")
872+
if path == "global":
873+
return self._batched_encoder_forward(mm_kwargs["pixel_values"])
874+
else:
875+
return self._batched_encoder_forward(mm_kwargs["patch_pixel_values"])
899876

900877
def postprocess_encoder_output(
901878
self,
@@ -907,38 +884,24 @@ def postprocess_encoder_output(
907884
batch_mm_kwargs: dict[str, Any] | None = None,
908885
local_output: torch.Tensor | None = None,
909886
):
910-
"""CPU-side per-item merge after graph replay.
887+
"""CPU-side per-item merge after dual-path graph replay.
911888
912-
The graph output is ``[all_img_flat, all_patch_flat]``.
913-
This method splits the flat output into image and patch features,
914-
then reassembles per-item embeddings using the *actual* batch
915-
``num_patches`` from ``batch_mm_kwargs`` (not the capture-time values).
889+
``output`` contains global-image features and ``local_output``
890+
contains local-patch features (or ``None`` when there are no patches).
916891
"""
917892
num_patches = batch_mm_kwargs["num_patches"]
918893
hidden = output.shape[-1]
919894
bsz = len(indices)
920895

921-
img_out = self._compute_spatial_tokens(
922-
self.config.vision_config.image_size,
923-
self.config.vision_config.patch_size,
924-
self.config.understand_projector_stride,
925-
)
926-
patch_out = self._compute_spatial_tokens(
927-
504,
928-
self.config.vision_config.patch_size,
929-
self.config.understand_projector_stride,
930-
)
931-
932-
# Valid portion: bsz images, actual_total_patches patches
933896
actual_np = [int(np) for np in num_patches]
934897
total_patches = sum(actual_np)
935-
img_tokens = bsz * img_out
936-
patch_tokens = total_patches * patch_out
898+
img_tokens = bsz * self.img_output_tokens
899+
patch_tokens = total_patches * self.patch_output_tokens
937900

938-
img_part = output[:img_tokens].reshape(bsz, img_out, hidden)
901+
global_part = output[:img_tokens].reshape(bsz, self.img_output_tokens, hidden)
939902
if total_patches > 0:
940-
patch_part = output[img_tokens : img_tokens + patch_tokens].reshape(
941-
-1, patch_out, hidden
903+
patch_part = local_output[:patch_tokens].reshape(
904+
-1, self.patch_output_tokens, hidden
942905
)
943906
else:
944907
patch_part = None
@@ -951,7 +914,7 @@ def postprocess_encoder_output(
951914
if patch_part is not None and np > 0:
952915
parts.append(patch_part[cur_patch : cur_patch + np].reshape(-1, hidden))
953916
cur_patch += np
954-
parts.append(img_part[i].reshape(-1, hidden))
917+
parts.append(global_part[i].reshape(-1, hidden))
955918
merged[idx] = torch.cat(parts, dim=0) if len(parts) > 1 else parts[0]
956919

957920
out = [merged[i] for i in indices]
@@ -969,14 +932,13 @@ def prepare_encoder_cudagraph_replay_buffers(
969932
EncoderCudaGraphReplayBuffers,
970933
)
971934

972-
# Only patch_pixel_values lives in the values dict; num_patches is
973-
# processed CPU-side by finalize_encoder_cudagraph_output.
974-
return EncoderCudaGraphReplayBuffers(
975-
values={
976-
"pixel_values": mm_kwargs["pixel_values"],
977-
"patch_pixel_values": mm_kwargs["patch_pixel_values"],
978-
},
979-
)
935+
assert path in ("global", "local")
936+
if path == "global":
937+
values = {"pixel_values": mm_kwargs["pixel_values"]}
938+
else:
939+
values = {"patch_pixel_values": mm_kwargs["patch_pixel_values"]}
940+
941+
return EncoderCudaGraphReplayBuffers(values=values)
980942

981943
def forward(
982944
self,

0 commit comments

Comments
 (0)