@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 990f49bdcb
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 3, 11:45 UTC

[MM][CG] Enable encoder Cudagraph for Step3VL (#42224)

Signed-off-by: JisoLya <523420504@qq.com> Signed-off-by: Soyaazz <523420504@qq.com>

Soyaazz committed May 18, 2026 at 11:19 UTC 990f49bdcb8ff51c0ceb1d784c3ca16e6c276927
8 files changed +384 -22
docs/design/cuda_graphs_multimodal.md
+2
@@ -77,6 +77,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
77 * `encoder_eager_forward(...)` — fallback eager forward when no graph fits.
78 * `get_input_modality(...)` - return the modality of the inputs.
79 * `get_max_frames_per_video()` - return model-specific max frames per video.
80 +* `postprocess_encoder_output(...)` - post process encoder output, directly call scatter_output_slices by default
81
82 !!! note
83 The `SupportsEncoderCudaGraph` protocol is designed to be model-agnostic. New vision encoder models can opt-in by implementing the protocol methods without modifying the manager.
@@ -89,6 +90,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
90 | `Qwen2_5_VLForConditionalGeneration` | `Qwen2.5-VL` | ✅︎ | ✅︎ |
91 | `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ |
92 | `Qwen3_5ForConditionalGeneration` | `Qwen3.5` | ✅︎ | ✅︎ |
93 +| `Step3VLForConditionalGeneration` | `Step3-VL` | ✅︎ | ❌︎ |
94
95 !!! note
96 Encoder CUDA Graphs have currently been tested with `--mm-encoder-attn-backend=FLASH_ATTN` and `--mm-encoder-attn-backend=FLASHINFER` on Blackwell GPUs.
examples/generate/multimodal/vision_language_offline.py
+1
@@ -2560,6 +2560,7 @@ MODELS_SUPPORT_VIT_CUDA_GRAPH = [
2560 "qwen2_vl",
2561 "qwen3_5",
2562 "qwen3_5_moe",
2563 + "stepvl",
2564 ]
2565
2566
tests/models/multimodal/generation/test_vit_cudagraph.py
+12
@@ -41,6 +41,13 @@ def qwen_vl_chat_template(content: str) -> str:
41 return f"<|im_start|>user\n{content}<|im_end|>\n<|im_start|>assistant\n"
42
43
44 +def step3_vl_chat_template(content: str) -> str:
45 + return (
46 + "<|begin▁of▁sentence|> You are a helpful assistant.<|BOT|>user\n "
47 + f"<im_patch>{content} <|EOT|><|BOT|>assistant\n"
48 + )
49 +
50 +
51 MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
52 "qwen2_5_vl": VitCudagraphTestConfig(
53 model="Qwen/Qwen2.5-VL-3B-Instruct",
@@ -90,6 +97,11 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
97 needs_video_metadata=False,
98 marks=[pytest.mark.core_model],
99 ),
100 + "step3_vl": VitCudagraphTestConfig(
101 + model="stepfun-ai/Step3-VL-10B",
102 + image_prompt=step3_vl_chat_template("What is in this image?"),
103 + video_prompt=None,
104 + ),
105 }
106
107
vllm/model_executor/models/interfaces.py
+21
@@ -1594,6 +1594,27 @@ class SupportsEncoderCudaGraph(Protocol):
1594 """
1595 ...
1596
1597 + def postprocess_encoder_output(
1598 + self,
1599 + output: torch.Tensor,
1600 + indices: list[int],
1601 + per_item_out_tokens: list[int],
1602 + dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
1603 + clone: bool = False,
1604 + batch_mm_kwargs: dict[str, Any] | None = None,
1605 + ) -> None:
1606 + """
1607 + Post-process encoder output, directly call scatter_output_slices by default.
1608 +
1609 + By default, delegates directly to scatter_output_slices.
1610 + Override this for models that require additional processing on the raw
1611 + encoder output prior to scattering, e.g. Step3-VL, which merges features
1612 + according to dynamic patch counts before scattering.
1613 + """
1614 + from vllm.model_executor.models.utils import scatter_output_slices
1615 +
1616 + scatter_output_slices(output, indices, per_item_out_tokens, dest, clone)
1617 +
1618 def prepare_encoder_cudagraph_capture_inputs(
1619 self,
1620 token_budget: int,
vllm/model_executor/models/step3_vl.py
+323 -2
@@ -46,7 +46,12 @@ from vllm.transformers_utils.processors.step3_vl import (
46 )
47 from vllm.utils.tensor_schema import TensorSchema, TensorShape
48
49 -from .interfaces import MultiModalEmbeddings, SupportsMultiModal, SupportsPP
49 +from .interfaces import (
50 + MultiModalEmbeddings,
51 + SupportsEncoderCudaGraph,
52 + SupportsMultiModal,
53 + SupportsPP,
54 +)
55 from .utils import (
56 AutoWeightsLoader,
57 WeightsMapper,
@@ -487,7 +492,9 @@ class Step3VisionTransformer(nn.Module):
492 info=Step3VLProcessingInfo,
493 dummy_inputs=Step3VLDummyInputsBuilder,
494 )
490 -class Step3VLForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP):
495 +class Step3VLForConditionalGeneration(
496 + nn.Module, SupportsMultiModal, SupportsPP, SupportsEncoderCudaGraph
497 +):
498 hf_to_vllm_mapper = WeightsMapper(
499 orig_to_new_prefix={
500 "model.": "language_model.model.",
@@ -510,6 +517,7 @@ class Step3VLForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP)
517 multimodal_config = vllm_config.model_config.multimodal_config
518
519 self.config = config
520 + self.model_config = vllm_config.model_config
521 self.multimodal_config = multimodal_config
522 self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
523
@@ -570,6 +578,17 @@ class Step3VLForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP)
578 def dtype(self):
579 return next(self.parameters()).dtype
580
581 + @staticmethod
582 + def _compute_spatial_tokens(size, patch_size, stride):
583 + # Compute the number of spatial tokens after two rounds of
584 + # downsampling with given patch size and stride.
585 + grid = size // patch_size
586 + vit_tokens = grid * grid
587 + spatial = int(math.sqrt(vit_tokens))
588 + h1 = (spatial - 2) // stride + 1
589 + h2 = (h1 - 1) // 2 + 1
590 + return h2 * h2
591 +
592 def _parse_and_validate_image_input(
593 self, **kwargs: object
594 ) -> Step3VLImageInputs | None:
@@ -676,6 +695,308 @@ class Step3VLForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP)
695 is_multimodal=is_multimodal,
696 )
697
698 + def get_encoder_cudagraph_config(self):
699 + from vllm.v1.worker.encoder_cudagraph_defs import (
700 + EncoderCudaGraphConfig,
701 + )
702 +
703 + return EncoderCudaGraphConfig(
704 + modalities=["image"],
705 + input_key_by_modality={"image": "pixel_values"},
706 + buffer_keys=["patch_pixel_values"],
707 + out_hidden_size=self.config.hidden_size,
708 + )
709 +
710 + def get_input_modality(
711 + self,
712 + mm_kwargs: dict[str, Any],
713 + ) -> str:
714 + return "image"
715 +
716 + def get_max_frames_per_video(
717 + self,
718 + ) -> int:
719 + return 0
720 +
721 + def get_encoder_cudagraph_budget_range(
722 + self,
723 + vllm_config: "VllmConfig",
724 + ) -> tuple[int, int]:
725 + # An image without patches
726 + min_budget = self._compute_spatial_tokens(
727 + self.config.vision_config.image_size,
728 + self.config.vision_config.patch_size,
729 + self.config.understand_projector_stride,
730 + )
731 + max_budget = min(
732 + vllm_config.scheduler_config.max_num_batched_tokens,
733 + self.model_config.max_model_len,
734 + )
735 + return min_budget, max_budget
736 +
737 + def get_encoder_cudagraph_num_items(
738 + self,
739 + mm_kwargs: dict[str, Any],
740 + ) -> int:
741 + return len(mm_kwargs.get("pixel_values", []))
742 +
743 + def get_encoder_cudagraph_per_item_output_tokens(
744 + self,
745 + mm_kwargs: dict[str, Any],
746 + ) -> list[int]:
747 + num_patches = mm_kwargs.get("num_patches")
748 + img_output_tokens = self._compute_spatial_tokens(
749 + self.config.vision_config.image_size,
750 + self.config.vision_config.patch_size,
751 + self.config.understand_projector_stride,
752 + )
753 + patch_output_tokens = self._compute_spatial_tokens(
754 + 504,
755 + self.config.vision_config.patch_size,
756 + self.config.understand_projector_stride,
757 + )
758 + return [
759 + img_output_tokens + num_patch * patch_output_tokens
760 + for num_patch in num_patches
761 + ]
762 +
763 + def get_encoder_cudagraph_per_item_input_sizes(
764 + self,
765 + mm_kwargs: dict[str, Any],
766 + ) -> list[int]:
767 + img_grid = (
768 + self.config.vision_config.image_size // self.config.vision_config.patch_size
769 + )
770 +
771 + # NOTE: 504 is the hard coded size for each patch after processing
772 + # by the vision model, which is determined by the current architecture
773 + # of the vision model and may need to be updated if the architecture changes.
774 + # The number of tokens for each patch is calculated based on this
775 + # size and the patch size.
776 + patch_grid = 504 // self.config.vision_config.patch_size
777 + total_image_pixel = img_grid * img_grid
778 + total_patch_pixel = patch_grid * patch_grid
779 +
780 + num_patches = mm_kwargs.get("num_patches")
781 + return [
782 + total_image_pixel + num_patch * total_patch_pixel
783 + for num_patch in num_patches
784 + ]
785 +
786 + def select_encoder_cudagraph_items(
787 + self,
788 + mm_kwargs: dict[str, Any],
789 + indices: list[int],
790 + ) -> dict[str, Any]:
791 + pixel_values = mm_kwargs["pixel_values"]
792 + patch_pixel_values = mm_kwargs["patch_pixel_values"]
793 + num_patches = mm_kwargs["num_patches"]
794 +
795 + # calcute the accumulated patch counts
796 + cum_patches = [0]
797 + for p in num_patches:
798 + cum_patches.append(cum_patches[-1] + p)
799 +
800 + if len(indices) == 0:
801 + return {
802 + "pixel_values": pixel_values[:0],
803 + "patch_pixel_values": patch_pixel_values[:0],
804 + "num_patches": num_patches[:0],
805 + }
806 +
807 + selected_pv = pixel_values[indices]
808 + selected_np = num_patches[indices]
809 + selected_ppv = torch.cat(
810 + [patch_pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
811 + )
812 +
813 + return {
814 + "pixel_values": selected_pv,
815 + "patch_pixel_values": selected_ppv,
816 + "num_patches": selected_np,
817 + }
818 +
819 + def prepare_encoder_cudagraph_capture_inputs(
820 + self,
821 + token_budget: int,
822 + max_batch_size: int,
823 + max_frames_per_batch: int,
824 + device: torch.device,
825 + dtype: torch.dtype,
826 + ):
827 + from vllm.v1.worker.encoder_cudagraph_defs import (
828 + EncoderCudaGraphCaptureInputs,
829 + )
830 +
831 + # For pixel_value, the max input size is max_batch_size
832 + img_output_tokens = self._compute_spatial_tokens(
833 + self.config.vision_config.image_size,
834 + self.config.vision_config.patch_size,
835 + self.config.understand_projector_stride,
836 + )
837 + patch_output_tokens = self._compute_spatial_tokens(
838 + 504,
839 + self.config.vision_config.patch_size,
840 + self.config.understand_projector_stride,
841 + )
842 + dummy_pixel_values = torch.randn(
843 + max_batch_size,
844 + 3,
845 + self.config.vision_config.image_size,
846 + self.config.vision_config.image_size,
847 + device=device,
848 + dtype=dtype,
849 + )
850 + # max_num_patches is the max total patches across the whole batch.
851 + # token_budget = max_batch_size * img_out + max_num_patches * patch_out
852 + max_num_patches = max(
853 + 0,
854 + (token_budget - max_batch_size * img_output_tokens) // patch_output_tokens,
855 + )
856 + dummy_patch_pixel_values = torch.randn(
857 + max_num_patches,
858 + 3,
859 + 504,
860 + 504,
861 + device=device,
862 + dtype=dtype,
863 + )
864 + # num_patches is NOT in buffers -- the per-item merge is done
865 + # CPU-side by finalize_encoder_cudagraph_output using the actual
866 + # batch's num_patches from mm_kwargs.
867 + mm_kwargs = {
868 + "pixel_values": dummy_pixel_values,
869 + "patch_pixel_values": dummy_patch_pixel_values,
870 + }
871 +
872 + buffers = {
873 + "patch_pixel_values": dummy_patch_pixel_values,
874 + }
875 +
876 + return EncoderCudaGraphCaptureInputs(
877 + mm_kwargs=mm_kwargs,
878 + buffers=buffers,
879 + )
880 +
881 + def encoder_cudagraph_forward(
882 + self,
883 + mm_kwargs: dict[str, Any],
884 + buffers: dict[str, torch.Tensor],
885 + ) -> torch.Tensor:
886 + # Graph captures only the compute (vision model + conv projector).
887 + # Per-item merge happens CPU-side in finalize_encoder_cudagraph_output
888 + # using actual num_patches from the batch data.
889 + pixel_values = mm_kwargs["pixel_values"]
890 + patch_pixel_values = buffers["patch_pixel_values"]
891 +
892 + image_features = self._process_image_features(
893 + self._get_vision_model_output(pixel_values)
894 + )
895 +
896 + has_patches = len(patch_pixel_values) > 0
897 + if has_patches:
898 + patch_features = self._process_image_features(
899 + self._get_vision_model_output(patch_pixel_values)
900 + )
901 +
902 + # Deterministic single cat: [all_img_flat, all_patch_flat]
903 + img_flat = image_features.reshape(-1, image_features.shape[-1])
904 + if has_patches:
905 + patch_flat = patch_features.reshape(-1, patch_features.shape[-1])
906 + return torch.cat([img_flat, patch_flat], dim=0)
907 + return img_flat
908 +
909 + def encoder_eager_forward(
910 + self,
911 + mm_kwargs: dict[str, Any],
912 + ) -> torch.Tensor:
913 + image_input = Step3VLImagePixelInputs(
914 + type="pixel_values",
915 + pixel_values=mm_kwargs["pixel_values"],
916 + patch_pixel_values=mm_kwargs["patch_pixel_values"],
917 + num_patches=mm_kwargs["num_patches"],
918 + )
919 + vision_embeddings = self._process_image_input(image_input)
920 + return torch.cat(vision_embeddings, dim=0)
921 +
922 + def postprocess_encoder_output(
923 + self,
924 + output: torch.Tensor,
925 + indices: list[int],
926 + per_item_out_tokens: list[int],
927 + dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
928 + clone: bool = False,
929 + batch_mm_kwargs: dict[str, Any] | None = None,
930 + ):
931 + """CPU-side per-item merge after graph replay.
932 +
933 + The graph output is ``[all_img_flat, all_patch_flat]``.
934 + This method splits the flat output into image and patch features,
935 + then reassembles per-item embeddings using the *actual* batch
936 + ``num_patches`` from ``batch_mm_kwargs`` (not the capture-time values).
937 + """
938 + num_patches = batch_mm_kwargs["num_patches"]
939 + hidden = output.shape[-1]
940 + bsz = len(indices)
941 +
942 + img_out = self._compute_spatial_tokens(
943 + self.config.vision_config.image_size,
944 + self.config.vision_config.patch_size,
945 + self.config.understand_projector_stride,
946 + )
947 + patch_out = self._compute_spatial_tokens(
948 + 504,
949 + self.config.vision_config.patch_size,
950 + self.config.understand_projector_stride,
951 + )
952 +
953 + # Valid portion: bsz images, actual_total_patches patches
954 + actual_np = [int(np) for np in num_patches]
955 + total_patches = sum(actual_np)
956 + img_tokens = bsz * img_out
957 + patch_tokens = total_patches * patch_out
958 +
959 + img_part = output[:img_tokens].reshape(bsz, img_out, hidden)
960 + if total_patches > 0:
961 + patch_part = output[img_tokens : img_tokens + patch_tokens].reshape(
962 + -1, patch_out, hidden
963 + )
964 + else:
965 + patch_part = None
966 +
967 + merged: dict[int, torch.Tensor] = {}
968 + cur_patch = 0
969 + for i, idx in enumerate(indices):
970 + np = actual_np[i]
971 + parts: list[torch.Tensor] = []
972 + if patch_part is not None and np > 0:
973 + parts.append(patch_part[cur_patch : cur_patch + np].reshape(-1, hidden))
974 + cur_patch += np
975 + parts.append(img_part[i].reshape(-1, hidden))
976 + merged[idx] = torch.cat(parts, dim=0) if len(parts) > 1 else parts[0]
977 +
978 + out = [merged[i] for i in indices]
979 + for i, idx in enumerate(indices):
980 + dest[idx] = out[i]
981 +
982 + def prepare_encoder_cudagraph_replay_buffers(
983 + self,
984 + mm_kwargs: dict[str, Any],
985 + max_batch_size: int,
986 + max_frames_per_batch: int,
987 + ):
988 + from vllm.v1.worker.encoder_cudagraph_defs import (
989 + EncoderCudaGraphReplayBuffers,
990 + )
991 +
992 + # Only patch_pixel_values lives in the buffers dict; num_patches is
993 + # processed CPU-side by finalize_encoder_cudagraph_output.
994 + return EncoderCudaGraphReplayBuffers(
995 + buffers={
996 + "patch_pixel_values": mm_kwargs["patch_pixel_values"],
997 + },
998 + )
999 +
1000 def forward(
1001 self,
1002 input_ids: torch.Tensor | None,
vllm/model_executor/models/step_vl.py
+1
@@ -500,6 +500,7 @@ class StepVLForConditionalGeneration(Step3VLForConditionalGeneration):
500 quant_config = vllm_config.quant_config
501
502 self.config = config
503 + self.model_config = vllm_config.model_config
504 self.multimodal_config = multimodal_config
505 self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
506
vllm/model_executor/models/utils.py
+16
@@ -884,3 +884,19 @@ def get_layer_index(feature_layer_index: int, num_hidden_layers: int) -> int:
884 if feature_layer_index < 0:
885 return num_hidden_layers + feature_layer_index + 1
886 return feature_layer_index
887 +
888 +
889 +def scatter_output_slices(
890 + output: torch.Tensor,
891 + indices: list[int],
892 + per_item_out_tokens: list[int],
893 + dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
894 + clone: bool = False,
895 +) -> None:
896 + """Slice a concatenated output tensor and scatter into dest by index."""
897 + offset = 0
898 + for idx in indices:
899 + n_tok = per_item_out_tokens[idx]
900 + sliced = output[offset : offset + n_tok]
901 + dest[idx] = sliced.clone() if clone else sliced
902 + offset += n_tok
vllm/v1/worker/encoder_cudagraph.py
+8 -20
@@ -14,7 +14,10 @@ from vllm.distributed import (
14 tensor_model_parallel_all_gather,
15 )
16 from vllm.logger import init_logger
17 -from vllm.model_executor.models.interfaces import SupportsEncoderCudaGraph
17 +from vllm.model_executor.models.interfaces import (
18 + SupportsEncoderCudaGraph,
19 +)
20 +from vllm.model_executor.models.utils import scatter_output_slices
21 from vllm.model_executor.models.vision import get_load_balance_assignment
22 from vllm.v1.worker.encoder_cudagraph_defs import (
23 EncoderCudaGraphConfig,
@@ -254,22 +257,6 @@ class EncoderCudaGraphManager:
257 for t in self.model.get_encoder_cudagraph_per_item_output_tokens(mm_kwargs)
258 ]
259
257 - @staticmethod
258 - def _scatter_output_slices(
259 - output: torch.Tensor,
260 - indices: list[int],
261 - per_item_out_tokens: list[int],
262 - dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
263 - clone: bool = False,
264 - ) -> None:
265 - """Slice a concatenated output tensor and scatter into dest by index."""
266 - offset = 0
267 - for idx in indices:
268 - n_tok = per_item_out_tokens[idx]
269 - sliced = output[offset : offset + n_tok]
270 - dest[idx] = sliced.clone() if clone else sliced
271 - offset += n_tok
272 -
260 def _run_budget_graph(
261 self,
262 mm_kwargs: dict[str, Any],
@@ -414,7 +401,7 @@ class EncoderCudaGraphManager:
401 self.graph_misses += len(batch_orig_indices)
402 with torch.inference_mode():
403 raw = self.model.encoder_eager_forward(batch_mm_kwargs)
417 - self._scatter_output_slices(
404 + scatter_output_slices(
405 raw,
406 batch_orig_indices,
407 per_item_out_tokens,
@@ -440,12 +427,13 @@ class EncoderCudaGraphManager:
427 batch_mm_kwargs, token_budget, replay.buffers
428 )
429 assert output is not None
443 - self._scatter_output_slices(
430 + self.model.postprocess_encoder_output(
431 output,
432 batch_orig_indices,
433 per_item_out_tokens,
434 outputs_by_orig_idx,
435 clone=True,
436 + batch_mm_kwargs=batch_mm_kwargs,
437 )
438
439 # Return in original batch order (caller maps outputs to token positions)
@@ -573,7 +561,7 @@ class EncoderCudaGraphManager:
561 count = images_per_rank[rank]
562 if count > 0:
563 rank_items = image_rank_assignment[current_idx : current_idx + count]
576 - self._scatter_output_slices(
564 + scatter_output_slices(
565 rank_outputs[rank],
566 rank_items,
567 per_item_out_tokens,