A high-throughput and memory-efficient inference and serving engine for LLMs
[Model] Add encoder CUDA graph support to Lfm2VL (#44930)
Signed-off-by: vincentzed <207368749+vincentzed@users.noreply.github.com>
Yi Zhong committed
Jun 12, 2026 at 09:17 UTC
053e7daa79208fa33ec5fb1801520c4f5da4d9ca
1 file changed
+416
-18
vllm/model_executor/models/lfm2_vl.py
+416
-18
@@ -4,7 +4,7 @@
4
import itertools
5
import math
6
from collections.abc import Iterable, Mapping, Sequence
7
-from typing import Annotated, Literal
7
+from typing import Annotated, Any, Literal
8
9
import torch
10
import torch.nn as nn
@@ -49,6 +49,7 @@ from vllm.utils.tensor_schema import TensorSchema, TensorShape
49
from .interfaces import (
50
IsHybrid,
51
MultiModalEmbeddings,
52
+ SupportsEncoderCudaGraph,
53
SupportsLoRA,
54
SupportsMultiModal,
55
SupportsPP,
@@ -63,6 +64,17 @@ from .utils import (
64
from .vision import is_vit_use_data_parallel
65
66
67
+def _pad_cumulative_seqlens_buffer(
68
+ dst: torch.Tensor,
69
+ src: torch.Tensor,
70
+) -> None:
71
+ n = src.shape[0]
72
+ dst.zero_()
73
+ dst[:n].copy_(src)
74
+ if n < dst.shape[0]:
75
+ dst[n:] = src[-1]
76
+
77
+
78
class Lfm2VLImagePixelInputs(TensorSchema):
79
"""
80
Dimensions:
@@ -558,13 +570,26 @@ class Lfm2VLMultiModalProjector(nn.Module):
570
571
if gather_idx_parts:
572
gather_idx = torch.cat(gather_idx_parts).to(device=device)
561
- gathered = vision_features_packed.index_select(0, gather_idx)
562
- unshuffled = gathered.reshape(-1, factor * factor * hidden_size)
573
+ return self.forward_with_gather_idx(vision_features_packed, gather_idx)
574
else:
575
unshuffled = vision_features_packed.new_empty(
576
(0, factor * factor * hidden_size)
577
)
578
579
+ return self.forward_from_unshuffled(unshuffled)
580
+
581
+ def forward_with_gather_idx(
582
+ self,
583
+ vision_features_packed: torch.Tensor,
584
+ gather_idx: torch.Tensor,
585
+ ) -> torch.Tensor:
586
+ hidden_size = vision_features_packed.shape[-1]
587
+ factor = self.factor
588
+ gathered = vision_features_packed.index_select(0, gather_idx)
589
+ unshuffled = gathered.reshape(-1, factor * factor * hidden_size)
590
+ return self.forward_from_unshuffled(unshuffled)
591
+
592
+ def forward_from_unshuffled(self, unshuffled: torch.Tensor) -> torch.Tensor:
593
if self.projector_use_layernorm:
594
unshuffled = self.layer_norm(unshuffled)
595
hidden_states = self.linear_1(unshuffled)
@@ -579,7 +604,12 @@ class Lfm2VLMultiModalProjector(nn.Module):
604
dummy_inputs=Lfm2VLDummyInputsBuilder,
605
)
606
class Lfm2VLForConditionalGeneration(
582
- nn.Module, SupportsMultiModal, SupportsLoRA, SupportsPP, IsHybrid
607
+ nn.Module,
608
+ SupportsMultiModal,
609
+ SupportsEncoderCudaGraph,
610
+ SupportsLoRA,
611
+ SupportsPP,
612
+ IsHybrid,
613
):
614
merge_by_field_config = True
615
@@ -645,6 +675,7 @@ class Lfm2VLForConditionalGeneration(
675
676
self.config = config
677
self.vllm_config = vllm_config
678
+ self.model_config = vllm_config.model_config
679
self.multimodal_config = multimodal_config
680
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
681
@@ -697,7 +728,7 @@ class Lfm2VLForConditionalGeneration(
728
self,
729
pixel_values: torch.FloatTensor,
730
spatial_shapes: torch.Tensor,
700
- ) -> torch.Tensor:
731
+ ) -> list[torch.Tensor]:
732
assert spatial_shapes.device.type == "cpu", (
733
"Expected `spatial_shapes` on CPU to avoid device-to-host sync in "
734
"variable-length packing."
@@ -759,23 +790,13 @@ class Lfm2VLForConditionalGeneration(
790
)
791
vision_features_packed = image_outputs_packed[0]
792
762
- factor = self.multi_modal_projector.factor
763
- projected_lengths_list: list[int] = []
764
- for (height, width), length in zip(spatial_shapes_list, lengths_list):
765
- if length <= 0:
766
- projected_lengths_list.append(0)
767
- continue
768
- if height % factor != 0 or width % factor != 0:
769
- raise ValueError(
770
- "spatial_shapes must be divisible by downsample_factor: "
771
- f"got ({height}, {width}) with factor={factor}."
772
- )
773
- projected_lengths_list.append((height // factor) * (width // factor))
774
-
793
projected_packed = self.multi_modal_projector(
794
vision_features_packed=vision_features_packed,
795
spatial_shapes=spatial_shapes,
796
)
797
+ projected_lengths_list = self._get_lfm2vl_tile_output_lengths(
798
+ spatial_shapes_list
799
+ )
800
801
image_features: list[torch.Tensor] = []
802
offset = 0
@@ -819,6 +840,383 @@ class Lfm2VLForConditionalGeneration(
840
841
return self._process_image_input(image_input)
842
843
+ def get_encoder_cudagraph_config(self):
844
+ from vllm.v1.worker.encoder_cudagraph_defs import (
845
+ EncoderCudaGraphConfig,
846
+ )
847
+
848
+ return EncoderCudaGraphConfig(
849
+ modalities=["image"],
850
+ buffer_keys=[
851
+ "pixel_values_packed",
852
+ "pos_embeds",
853
+ "cu_seqlens",
854
+ "max_seqlen",
855
+ "gather_idx",
856
+ ],
857
+ out_hidden_size=self.config.text_config.hidden_size,
858
+ padding_logics={
859
+ "cu_seqlens": _pad_cumulative_seqlens_buffer,
860
+ },
861
+ )
862
+
863
+ def get_max_frames_per_video(self) -> int:
864
+ return 0
865
+
866
+ def get_encoder_cudagraph_budget_range(
867
+ self,
868
+ vllm_config: VllmConfig,
869
+ ) -> tuple[int, int]:
870
+ min_budget = self._get_lfm2vl_min_image_tokens()
871
+ max_budget = min(
872
+ vllm_config.scheduler_config.max_num_batched_tokens,
873
+ self.model_config.max_model_len,
874
+ )
875
+ return min_budget, max_budget
876
+
877
+ def _get_spatial_shapes_list(
878
+ self,
879
+ spatial_shapes: torch.Tensor,
880
+ ) -> list[list[int]]:
881
+ assert spatial_shapes.device.type == "cpu", (
882
+ "Expected `spatial_shapes` on CPU to avoid device-to-host sync in "
883
+ "variable-length packing."
884
+ )
885
+ return spatial_shapes.tolist()
886
+
887
+ @staticmethod
888
+ def _get_lfm2vl_tile_input_lengths(
889
+ spatial_shapes_list: list[list[int]],
890
+ ) -> list[int]:
891
+ return [height * width for height, width in spatial_shapes_list]
892
+
893
+ def _get_lfm2vl_tile_output_lengths(
894
+ self,
895
+ spatial_shapes_list: list[list[int]],
896
+ ) -> list[int]:
897
+ factor = self.multi_modal_projector.factor
898
+ output_lengths: list[int] = []
899
+ for height, width in spatial_shapes_list:
900
+ if height % factor != 0 or width % factor != 0:
901
+ raise ValueError(
902
+ "spatial_shapes must be divisible by downsample_factor: "
903
+ f"got ({height}, {width}) with factor={factor}."
904
+ )
905
+ output_lengths.append((height // factor) * (width // factor))
906
+ return output_lengths
907
+
908
+ def _get_lfm2vl_mm_processor_kwargs(self) -> Mapping[str, object]:
909
+ return self.multimodal_config.mm_processor_kwargs or {}
910
+
911
+ def _get_lfm2vl_min_image_tokens(self) -> int:
912
+ value = self._get_lfm2vl_mm_processor_kwargs().get(
913
+ "min_image_tokens",
914
+ getattr(self.config, "min_image_tokens", None) or 64,
915
+ )
916
+ return max(1, int(value))
917
+
918
+ def _get_lfm2vl_item_tile_slices(
919
+ self,
920
+ num_patches: torch.Tensor,
921
+ ) -> list[tuple[int, int]]:
922
+ num_patches_list = [int(x) for x in num_patches.tolist()]
923
+ starts = [0]
924
+ for count in num_patches_list:
925
+ starts.append(starts[-1] + count)
926
+ return list(zip(starts[:-1], starts[1:]))
927
+
928
+ def get_encoder_cudagraph_item_specs(
929
+ self,
930
+ mm_kwargs: dict[str, Any],
931
+ ):
932
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
933
+
934
+ spatial_shapes = mm_kwargs["spatial_shapes"]
935
+ num_patches = mm_kwargs["num_patches"]
936
+ spatial_shapes_list = self._get_spatial_shapes_list(spatial_shapes)
937
+ input_lengths = self._get_lfm2vl_tile_input_lengths(spatial_shapes_list)
938
+ output_lengths = self._get_lfm2vl_tile_output_lengths(spatial_shapes_list)
939
+
940
+ return [
941
+ EncoderItemSpec(
942
+ input_size=sum(input_lengths[start:end]),
943
+ output_tokens=sum(output_lengths[start:end]),
944
+ )
945
+ for start, end in self._get_lfm2vl_item_tile_slices(num_patches)
946
+ ]
947
+
948
+ def select_encoder_cudagraph_items(
949
+ self,
950
+ mm_kwargs: dict[str, Any],
951
+ indices: list[int],
952
+ ) -> dict[str, Any]:
953
+ pixel_values = mm_kwargs["pixel_values"]
954
+ spatial_shapes = mm_kwargs["spatial_shapes"]
955
+ num_patches = mm_kwargs["num_patches"]
956
+
957
+ tile_slices = self._get_lfm2vl_item_tile_slices(num_patches)
958
+
959
+ if len(indices) == 0:
960
+ return {
961
+ "pixel_values": pixel_values[:0],
962
+ "spatial_shapes": spatial_shapes[:0],
963
+ "num_patches": num_patches[:0],
964
+ }
965
+
966
+ tile_indices: list[int] = []
967
+ for image_idx in indices:
968
+ start, end = tile_slices[image_idx]
969
+ tile_indices.extend(range(start, end))
970
+
971
+ return {
972
+ "pixel_values": pixel_values[tile_indices],
973
+ "spatial_shapes": spatial_shapes[tile_indices],
974
+ "num_patches": num_patches[indices],
975
+ }
976
+
977
+ def _pack_lfm2vl_pixel_values(
978
+ self,
979
+ pixel_values: torch.Tensor,
980
+ spatial_shapes_list: list[list[int]],
981
+ ) -> torch.Tensor:
982
+ input_lengths = self._get_lfm2vl_tile_input_lengths(spatial_shapes_list)
983
+ total_tokens = sum(input_lengths)
984
+ packed = pixel_values.new_empty((total_tokens, pixel_values.shape[-1]))
985
+
986
+ offset = 0
987
+ for i, length in enumerate(input_lengths):
988
+ if length <= 0:
989
+ continue
990
+ packed[offset : offset + length].copy_(pixel_values[i, :length])
991
+ offset += length
992
+ return packed
993
+
994
+ def _get_lfm2vl_pos_embeds(
995
+ self,
996
+ spatial_shapes: torch.Tensor,
997
+ spatial_shapes_list: list[list[int]],
998
+ ) -> torch.Tensor:
999
+ embeddings = self.vision_tower.vision_model.embeddings
1000
+ positional_embeddings = embeddings.position_embedding.weight.reshape(
1001
+ embeddings.position_embedding_size,
1002
+ embeddings.position_embedding_size,
1003
+ -1,
1004
+ )
1005
+ lengths_list = self._get_lfm2vl_tile_input_lengths(spatial_shapes_list)
1006
+ return embeddings.resize_positional_embeddings_packed(
1007
+ positional_embeddings,
1008
+ spatial_shapes,
1009
+ lengths_list=lengths_list,
1010
+ )
1011
+
1012
+ def _get_lfm2vl_cu_seqlens(
1013
+ self,
1014
+ spatial_shapes_list: list[list[int]],
1015
+ device: torch.device,
1016
+ ) -> torch.Tensor:
1017
+ lengths = torch.tensor(
1018
+ self._get_lfm2vl_tile_input_lengths(spatial_shapes_list),
1019
+ dtype=torch.int32,
1020
+ device=device,
1021
+ )
1022
+ cu_seqlens = torch.zeros(
1023
+ lengths.shape[0] + 1,
1024
+ dtype=torch.int32,
1025
+ device=device,
1026
+ )
1027
+ if lengths.numel() > 0:
1028
+ cu_seqlens[1:] = torch.cumsum(lengths, dim=0)
1029
+ return cu_seqlens
1030
+
1031
+ def _get_lfm2vl_max_seqlen(
1032
+ self,
1033
+ spatial_shapes_list: list[list[int]],
1034
+ ) -> torch.Tensor:
1035
+ input_lengths = self._get_lfm2vl_tile_input_lengths(spatial_shapes_list)
1036
+ max_seqlen = max(input_lengths) if input_lengths else 0
1037
+ return torch.tensor(max_seqlen, dtype=torch.int32)
1038
+
1039
+ def _get_lfm2vl_projector_gather_idx(
1040
+ self,
1041
+ spatial_shapes_list: list[list[int]],
1042
+ device: torch.device,
1043
+ ) -> torch.Tensor:
1044
+ factor = self.multi_modal_projector.factor
1045
+ dh = torch.arange(factor, dtype=torch.int64)
1046
+ dw = torch.arange(factor, dtype=torch.int64)
1047
+ dh_grid, dw_grid = torch.meshgrid(dh, dw, indexing="ij")
1048
+ dh_flat = dh_grid.reshape(-1)
1049
+ dw_flat = dw_grid.reshape(-1)
1050
+
1051
+ gather_idx_parts: list[torch.Tensor] = []
1052
+ offset = 0
1053
+ for height, width in spatial_shapes_list:
1054
+ length = height * width
1055
+ if length <= 0:
1056
+ continue
1057
+ if height % factor != 0 or width % factor != 0:
1058
+ raise ValueError(
1059
+ "spatial_shapes must be divisible by downsample_factor: "
1060
+ f"got ({height}, {width}) with factor={factor}."
1061
+ )
1062
+
1063
+ rows_out = torch.arange(height // factor, dtype=torch.int64)
1064
+ cols_out = torch.arange(width // factor, dtype=torch.int64)
1065
+ rr, cc = torch.meshgrid(rows_out, cols_out, indexing="ij")
1066
+ rr = rr.reshape(-1)
1067
+ cc = cc.reshape(-1)
1068
+ token_idx = (rr[:, None] * factor + dh_flat[None, :]) * width + (
1069
+ cc[:, None] * factor + dw_flat[None, :]
1070
+ )
1071
+ gather_idx_parts.append(token_idx.reshape(-1) + offset)
1072
+ offset += length
1073
+
1074
+ if not gather_idx_parts:
1075
+ return torch.empty(0, dtype=torch.int64, device=device)
1076
+ return torch.cat(gather_idx_parts).to(device=device)
1077
+
1078
+ def _prepare_lfm2vl_cudagraph_values(
1079
+ self,
1080
+ pixel_values: torch.Tensor,
1081
+ spatial_shapes: torch.Tensor,
1082
+ ) -> dict[str, torch.Tensor]:
1083
+ spatial_shapes_list = self._get_spatial_shapes_list(spatial_shapes)
1084
+ pixel_values_packed = self._pack_lfm2vl_pixel_values(
1085
+ pixel_values,
1086
+ spatial_shapes_list,
1087
+ )
1088
+ pos_embeds = self._get_lfm2vl_pos_embeds(spatial_shapes, spatial_shapes_list)
1089
+ device = pixel_values.device
1090
+
1091
+ return {
1092
+ "pixel_values_packed": pixel_values_packed,
1093
+ "pos_embeds": pos_embeds,
1094
+ "cu_seqlens": self._get_lfm2vl_cu_seqlens(spatial_shapes_list, device),
1095
+ "max_seqlen": self._get_lfm2vl_max_seqlen(spatial_shapes_list),
1096
+ "gather_idx": self._get_lfm2vl_projector_gather_idx(
1097
+ spatial_shapes_list,
1098
+ device,
1099
+ ),
1100
+ }
1101
+
1102
+ def _get_lfm2vl_capture_spatial_shapes(
1103
+ self,
1104
+ token_budget: int,
1105
+ ) -> torch.Tensor:
1106
+ factor = self.multi_modal_projector.factor
1107
+ min_image_tokens = self._get_lfm2vl_min_image_tokens()
1108
+ remaining = token_budget
1109
+ shapes: list[list[int]] = []
1110
+
1111
+ while remaining > 0:
1112
+ out_tokens = min(remaining, min_image_tokens)
1113
+ shapes.append([factor, out_tokens * factor])
1114
+ remaining -= out_tokens
1115
+
1116
+ return torch.tensor(shapes, dtype=torch.int64)
1117
+
1118
+ def prepare_encoder_cudagraph_capture_inputs(
1119
+ self,
1120
+ token_budget: int,
1121
+ max_batch_size: int,
1122
+ max_frames_per_batch: int,
1123
+ device: torch.device,
1124
+ dtype: torch.dtype,
1125
+ ):
1126
+ from vllm.v1.worker.encoder_cudagraph_defs import (
1127
+ EncoderCudaGraphCaptureInputs,
1128
+ )
1129
+
1130
+ spatial_shapes = self._get_lfm2vl_capture_spatial_shapes(token_budget)
1131
+ spatial_shapes_list = self._get_spatial_shapes_list(spatial_shapes)
1132
+ input_lengths = self._get_lfm2vl_tile_input_lengths(spatial_shapes_list)
1133
+ total_input_tokens = sum(input_lengths)
1134
+
1135
+ patch_dim = (
1136
+ self.vision_tower.vision_model.embeddings.patch_embedding.weight.shape[1]
1137
+ )
1138
+ dummy_pixel_values = torch.randn(
1139
+ total_input_tokens,
1140
+ patch_dim,
1141
+ device=device,
1142
+ dtype=dtype,
1143
+ )
1144
+ pos_embeds = self._get_lfm2vl_pos_embeds(
1145
+ spatial_shapes,
1146
+ spatial_shapes_list,
1147
+ ).to(device=device, dtype=dtype)
1148
+
1149
+ # max_seqlen.item() is baked into the captured ViT attention graph, so
1150
+ # capture with a budget-level upper bound that covers any replay item.
1151
+ max_tile_input_tokens = token_budget * self.multi_modal_projector.factor**2
1152
+ values = {
1153
+ "pixel_values_packed": dummy_pixel_values,
1154
+ "pos_embeds": pos_embeds,
1155
+ "cu_seqlens": self._get_lfm2vl_cu_seqlens(spatial_shapes_list, device),
1156
+ "max_seqlen": torch.tensor(max_tile_input_tokens, dtype=torch.int32),
1157
+ "gather_idx": self._get_lfm2vl_projector_gather_idx(
1158
+ spatial_shapes_list,
1159
+ device,
1160
+ ),
1161
+ }
1162
+
1163
+ return EncoderCudaGraphCaptureInputs(values=values)
1164
+
1165
+ def prepare_encoder_cudagraph_replay_buffers(
1166
+ self,
1167
+ mm_kwargs: dict[str, Any],
1168
+ max_batch_size: int,
1169
+ max_frames_per_batch: int,
1170
+ ):
1171
+ from vllm.v1.worker.encoder_cudagraph_defs import (
1172
+ EncoderCudaGraphReplayBuffers,
1173
+ )
1174
+
1175
+ values = self._prepare_lfm2vl_cudagraph_values(
1176
+ mm_kwargs["pixel_values"],
1177
+ mm_kwargs["spatial_shapes"],
1178
+ )
1179
+ return EncoderCudaGraphReplayBuffers(values=values)
1180
+
1181
+ def encoder_cudagraph_forward(
1182
+ self,
1183
+ values: dict[str, torch.Tensor],
1184
+ ) -> torch.Tensor:
1185
+ embeddings = self.vision_tower.vision_model.embeddings
1186
+ pixel_values = values["pixel_values_packed"].to(
1187
+ dtype=embeddings.patch_embedding.weight.dtype
1188
+ )
1189
+ patch_embeds = embeddings.patch_embedding(pixel_values)
1190
+ hidden_states = (patch_embeds + values["pos_embeds"]).unsqueeze(0)
1191
+
1192
+ with set_forward_context(None, self.vllm_config):
1193
+ encoder_outputs = self.vision_tower.vision_model.encoder(
1194
+ inputs_embeds=hidden_states,
1195
+ cu_seqlens=values["cu_seqlens"],
1196
+ max_seqlen=values["max_seqlen"],
1197
+ )
1198
+
1199
+ post_layernorm = self.vision_tower.vision_model.post_layernorm
1200
+ if post_layernorm is not None:
1201
+ encoder_outputs = post_layernorm(encoder_outputs)
1202
+
1203
+ return self.multi_modal_projector.forward_with_gather_idx(
1204
+ vision_features_packed=encoder_outputs[0],
1205
+ gather_idx=values["gather_idx"],
1206
+ )
1207
+
1208
+ def encoder_eager_forward(
1209
+ self,
1210
+ mm_kwargs: dict[str, Any],
1211
+ ) -> torch.Tensor:
1212
+ image_input = LFM2VLImageInputs(
1213
+ type="pixel_values",
1214
+ pixel_values=mm_kwargs["pixel_values"],
1215
+ spatial_shapes=mm_kwargs["spatial_shapes"],
1216
+ num_patches=mm_kwargs["num_patches"],
1217
+ )
1218
+ return torch.cat(self._process_image_input(image_input), dim=0)
1219
+
1220
def forward(
1221
self,
1222
input_ids: torch.Tensor | None,