[DSV4.1] Add encoder cuda graph support for deepseek-v4.1-flash (#56625)
Signed-off-by: Isotr0py <Isotr0py@outlook.com>
Isotr0py committed
Sep 20, 2026 at 21:51 UTC
27757dde020ecda4f9b0e2c5ca2df1c29badc82f
6 files changed
+599
-63
tests/models/multimodal/generation/test_vit_cudagraph.py
+23
@@ -55,6 +55,10 @@ def kimi_vl_chat_template(content: str) -> str:
55
)
56
57
58
+def deepseek_v41_chat_template(content: str) -> str:
59
+ return f"<|begin▁of▁sentence|><|User|>{content}<|Assistant|></think>"
60
+
61
+
62
def step3_vl_chat_template(content: str) -> str:
63
return (
64
"<|begin▁of▁sentence|> You are a helpful assistant.<|BOT|>user\n "
@@ -221,6 +225,25 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
225
vllm_runner_kwargs={"gpu_memory_utilization": 0.80},
226
marks=[pytest.mark.core_model],
227
),
228
+ "deepseek_v41": VitCudagraphTestConfig(
229
+ model="deepseek-ai/DeepSeek-V4.1-Flash",
230
+ modalities=["image"],
231
+ image_prompt=deepseek_v41_chat_template(
232
+ "<|deepseek_image|>\n\nWhat is in this image?"
233
+ ),
234
+ compilation_config_overrides={
235
+ "encoder_cudagraph_token_budgets": [1024],
236
+ },
237
+ vllm_runner_kwargs={
238
+ "load_format": "dummy",
239
+ "attention_backend": "FLASHMLA_SPARSE_DSV41",
240
+ "hf_overrides": partial(
241
+ dummy_hf_overrides,
242
+ model_arch="DeepseekV41ForCausalLM",
243
+ exist_overrides={"vision_n_layers": 1},
244
+ ),
245
+ },
246
+ ),
247
"step3_vl": VitCudagraphTestConfig(
248
model="stepfun-ai/Step3-VL-10B",
249
modalities=["image"],
tests/models/multimodal/processing/test_tensor_schema.py
+6
@@ -218,8 +218,14 @@ def test_model_tensor_schema(model_id: str):
218
219
factories = model_cls._processor_factory
220
221
+ # Capture helpers return capture-buffer containers (e.g.
222
+ # EncoderCudaGraphCaptureInputs), not TensorSchema mm inputs.
223
+ capture_helpers = {"prepare_encoder_cudagraph_capture_inputs"}
224
+
225
inputs_parse_methods = []
226
for attr_name in dir(model_cls):
227
+ if attr_name in capture_helpers:
228
+ continue
229
attr = getattr(model_cls, attr_name)
230
if hasattr(attr, "__annotations__"):
231
return_type = attr.__annotations__.get("return", None)
vllm/models/deepseek_v4/common/vision.py
+156
-7
@@ -40,8 +40,7 @@ from vllm.model_executor.models.vision import (
40
)
41
42
43
-@lru_cache(8)
44
-def get_vision_cos_sin(
43
+def _compute_vision_cos_sin(
44
n_h: int, n_w: int, dim: int, theta: float
45
) -> tuple[torch.Tensor, torch.Tensor]:
46
inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
@@ -52,6 +51,13 @@ def get_vision_cos_sin(
51
return freqs.cos().unsqueeze(1), freqs.sin().unsqueeze(1)
52
53
54
+@lru_cache(8)
55
+def get_vision_cos_sin(
56
+ n_h: int, n_w: int, dim: int, theta: float
57
+) -> tuple[torch.Tensor, torch.Tensor]:
58
+ return _compute_vision_cos_sin(n_h, n_w, dim, theta)
59
+
60
+
61
def apply_rotary(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
62
dtype = x.dtype
63
x1, x2 = x.float().chunk(2, dim=-1)
@@ -123,15 +129,23 @@ class DeepseekV4VisionAttention(nn.Module):
129
)
130
131
def forward(
126
- self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
132
+ self,
133
+ x: torch.Tensor,
134
+ cos: torch.Tensor,
135
+ sin: torch.Tensor,
136
+ cu_seqlens: torch.Tensor | None = None,
137
+ max_seqlen: torch.Tensor | None = None,
138
) -> torch.Tensor:
139
n = x.size(0)
140
qkv, _ = self.wqkv(x)
141
q, k, v = (t.view(n, self.n_heads, self.head_dim) for t in qkv.chunk(3, -1))
142
q = apply_rotary(q, cos, sin).unsqueeze(0) # (b=1, n, h, d)
143
k = apply_rotary(k, cos, sin).unsqueeze(0)
133
- # One image per call: a dense batch, no varlen packing metadata.
134
- o = self.attn(q, k, v.unsqueeze(0))
144
+ # Dense (cu_seqlens=None): one image per call. Varlen (cu_seqlens
145
+ # set): packed multi-image batch for encoder CUDA graph replay.
146
+ o = self.attn(
147
+ q, k, v.unsqueeze(0), cu_seqlens=cu_seqlens, max_seqlen=max_seqlen
148
+ )
149
out, _ = self.wo(o.reshape(n, -1))
150
return out
151
@@ -173,9 +187,14 @@ class DeepseekV4VisionBlock(nn.Module):
187
self.mlp = DeepseekV4VisionMLP(config, prefix=f"{prefix}.mlp")
188
189
def forward(
176
- self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
190
+ self,
191
+ x: torch.Tensor,
192
+ cos: torch.Tensor,
193
+ sin: torch.Tensor,
194
+ cu_seqlens: torch.Tensor | None = None,
195
+ max_seqlen: torch.Tensor | None = None,
196
) -> torch.Tensor:
178
- x = x + self.attn(self.norm1(x), cos, sin)
197
+ x = x + self.attn(self.norm1(x), cos, sin, cu_seqlens, max_seqlen)
198
return x + self.mlp(self.norm2(x))
199
200
@@ -206,6 +225,115 @@ class DeepseekV4ViT(nn.Module):
225
x = block(x, cos, sin)
226
return self.norm(x)
227
228
+ def forward_packed(
229
+ self,
230
+ patches: torch.Tensor,
231
+ cos: torch.Tensor,
232
+ sin: torch.Tensor,
233
+ cu_seqlens: torch.Tensor,
234
+ max_seqlen: torch.Tensor,
235
+ ) -> torch.Tensor:
236
+ """Varlen path for encoder CUDA graphs: multiple images packed along
237
+ rows, per-image attention via ``cu_seqlens``, RoPE tables precomputed
238
+ on device. Pure-tensor (no host reads, no H2D), safe to capture."""
239
+ x = self.patch_embed(patches)
240
+ for block in self.blocks:
241
+ x = block(x, cos, sin, cu_seqlens, max_seqlen)
242
+ return self.norm(x)
243
+
244
+
245
+def build_packed_vit_metadata(
246
+ grids: list[list[int]] | list[tuple[int, int]],
247
+ *,
248
+ rope_dim: int,
249
+ rope_theta: float,
250
+ device: torch.device,
251
+ max_seqlen_override: int | None = None,
252
+ cached: bool = True,
253
+) -> dict[str, torch.Tensor]:
254
+ """Precompute packed-batch ViT metadata for ``DeepseekV4ViT.forward_packed``.
255
+
256
+ Args:
257
+ grids: ``[n_vit_h, n_vit_w]`` per image, in packing order.
258
+ rope_dim: RoPE embedding dimension of the ViT.
259
+ rope_theta: RoPE base frequency of the ViT.
260
+ device: Device for the returned tensors (except ``max_seqlen``,
261
+ which stays on the host).
262
+ max_seqlen_override: Worst-case value baked in at CUDA graph capture
263
+ (the attention wrapper reads ``max_seqlen`` on the host, so the
264
+ capture-time value becomes a graph constant).
265
+ cached: Use the shared ``lru_cache`` for RoPE tables. Capture-time
266
+ dummy grids pass ``False`` to avoid evicting real entries.
267
+
268
+ """
269
+ cos_sin = get_vision_cos_sin if cached else _compute_vision_cos_sin
270
+ cos_list: list[torch.Tensor] = []
271
+ sin_list: list[torch.Tensor] = []
272
+ cu_seqlens = [0]
273
+ max_seqlen = 0
274
+ for n_h, n_w in grids:
275
+ cos, sin = cos_sin(n_h, n_w, rope_dim, rope_theta)
276
+ cos_list.append(cos)
277
+ sin_list.append(sin)
278
+ n = n_h * n_w
279
+ cu_seqlens.append(cu_seqlens[-1] + n)
280
+ max_seqlen = max(max_seqlen, n)
281
+ if cos_list:
282
+ cos = torch.cat(cos_list).to(device)
283
+ sin = torch.cat(sin_list).to(device)
284
+ else:
285
+ cos = torch.zeros((0, 1, rope_dim), dtype=torch.float32, device=device)
286
+ sin = torch.zeros((0, 1, rope_dim), dtype=torch.float32, device=device)
287
+ if max_seqlen_override is not None:
288
+ max_seqlen = max_seqlen_override
289
+ return {
290
+ "vit_cos": cos,
291
+ "vit_sin": sin,
292
+ "cu_seqlens": torch.tensor(cu_seqlens, dtype=torch.int32, device=device),
293
+ # Read on the host by the attention wrapper; keep on CPU.
294
+ "max_seqlen": torch.tensor(max_seqlen, dtype=torch.int32),
295
+ }
296
+
297
+
298
+def build_packed_merge_metadata(
299
+ grids: list[list[int]] | list[tuple[int, int]],
300
+ downsample_ratio: int,
301
+ *,
302
+ device: torch.device,
303
+ dtype: torch.dtype,
304
+) -> dict[str, torch.Tensor]:
305
+ """Gather indices/mask for ``DeepseekV4Aligner.forward_packed``.
306
+
307
+ For each aligner output row, the ``r x r`` source patch rows in the
308
+ packed ViT output; positions past the image edge (the eager path pads
309
+ them with zeros) get mask 0 and a clamped in-range index.
310
+ """
311
+ r = downsample_ratio
312
+ idx_list: list[torch.Tensor] = []
313
+ mask_list: list[torch.Tensor] = []
314
+ offset = 0
315
+ for n_h, n_w in grids:
316
+ rows = -(-n_h // r)
317
+ cols = -(-n_w // r)
318
+ bi = torch.arange(rows).repeat_interleave(cols)
319
+ bj = torch.arange(cols).repeat(rows)
320
+ di = torch.arange(r).repeat_interleave(r)
321
+ dj = torch.arange(r).repeat(r)
322
+ hi = bi[:, None] * r + di[None, :]
323
+ wj = bj[:, None] * r + dj[None, :]
324
+ valid = (hi < n_h) & (wj < n_w)
325
+ idx = offset + hi.clamp(max=n_h - 1) * n_w + wj.clamp(max=n_w - 1)
326
+ idx_list.append(idx)
327
+ mask_list.append(valid)
328
+ offset += n_h * n_w
329
+ if idx_list:
330
+ merge_idx = torch.cat(idx_list).to(device)
331
+ merge_mask = torch.cat(mask_list).unsqueeze(-1).to(device=device, dtype=dtype)
332
+ else:
333
+ merge_idx = torch.zeros((0, r * r), dtype=torch.int64, device=device)
334
+ merge_mask = torch.zeros((0, r * r, 1), dtype=dtype, device=device)
335
+ return {"merge_idx": merge_idx, "merge_mask": merge_mask}
336
+
337
338
class DeepseekV4Aligner(nn.Module):
339
"""Spatial merge (downsample_ratio x downsample_ratio) + MLP projector."""
@@ -240,6 +368,27 @@ class DeepseekV4Aligner(nn.Module):
368
out, _ = self.w2(F.gelu(hidden))
369
return out
370
371
+ def forward_packed(
372
+ self,
373
+ x: torch.Tensor,
374
+ merge_idx: torch.Tensor,
375
+ merge_mask: torch.Tensor,
376
+ ) -> torch.Tensor:
377
+ """Packed multi-image path for encoder CUDA graphs.
378
+
379
+ ``merge_idx``/``merge_mask`` come from ``build_packed_merge_metadata``;
380
+ gather+mask reproduces the eager ``F.pad`` + ``F.unfold`` merge
381
+ exactly (unfold rows are channel-major, so the gathered patch rows
382
+ are transposed before flattening).
383
+ """
384
+ r2 = self.downsample_ratio**2
385
+ m = merge_idx.shape[0]
386
+ gathered = (x[merge_idx] * merge_mask).view(m, r2, -1)
387
+ gathered = gathered.permute(0, 2, 1).reshape(m, -1)
388
+ hidden, _ = self.w1(gathered)
389
+ out, _ = self.w2(F.gelu(hidden))
390
+ return out
391
+
392
393
def run_dp_sharded_vision_tower(
394
vision_model: DeepseekV4ViT,
vllm/models/deepseek_v41/amd/vl_model.py
+12
-28
@@ -26,6 +26,7 @@ from vllm.distributed import get_tensor_model_parallel_world_size
26
from vllm.model_executor.models.interfaces import (
27
MultiModalEmbeddings,
28
SupportsEagle3,
29
+ SupportsEncoderCudaGraph,
30
SupportsMultiModal,
31
SupportsPP,
32
)
@@ -44,15 +45,12 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
45
from vllm.utils.tensor_schema import TensorSchema, TensorShape
46
47
from ..common.mm_preprocess import (
47
- IMAGE,
48
- IMAGE_END,
49
- IMAGE_NEW_LINE,
48
IMAGE_PLACEHOLDER,
51
- IMAGE_START,
49
DeepseekV4VLDummyInputsBuilder,
50
DeepseekV4VLMultiModalProcessor,
51
DeepseekV4VLProcessingInfo,
52
)
53
+from ..common.vl_cudagraph import DeepseekV4VLEncoderCudaGraphMixin
54
from .model import (
55
DeepseekV41LLMForCausalLM,
56
_linear_scale_param_name,
@@ -115,11 +113,20 @@ def _make_deepseek_v4_vl_weights_mapper(
113
info=DeepseekV4VLProcessingInfo,
114
dummy_inputs=DeepseekV4VLDummyInputsBuilder,
115
)
118
-class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3):
116
+class DeepseekV41ForCausalLM(
117
+ nn.Module,
118
+ DeepseekV4VLEncoderCudaGraphMixin,
119
+ SupportsMultiModal,
120
+ SupportsEncoderCudaGraph,
121
+ SupportsPP,
122
+ SupportsEagle3,
123
+):
124
"""Multimodal entry point for DeepSeek-V4.1 checkpoints with a vision tower.
125
126
``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters)
127
delegates through ``language_model`` via the protocol defaults.
128
+ ``SupportsEncoderCudaGraph`` (ViT CUDA graph capture/replay) is
129
+ implemented by ``DeepseekV4VLEncoderCudaGraphMixin``.
130
"""
131
132
supports_encoder_tp_data = True
@@ -196,29 +203,6 @@ class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, Supports
203
resolve_bindings={"p": self.config.vision_patch_size},
204
)
205
199
- def _encode_image(
200
- self,
201
- patches: torch.Tensor,
202
- n_vit_h: int,
203
- n_vit_w: int,
204
- ) -> torch.Tensor:
205
- # Aligner rows in reading order, one per IMAGE slot.
206
- return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
207
-
208
- def _build_image_span(
209
- self, image_embeds: torch.Tensor, types: torch.Tensor
210
- ) -> torch.Tensor:
211
- """Full image span: aligner rows at IMAGE slots, the learned
212
- delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
213
- types = types.to(image_embeds.device)
214
- span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
215
- dtype = image_embeds.dtype
216
- span[types == IMAGE_START] = self.image_start.to(dtype)
217
- span[types == IMAGE_END] = self.image_end.to(dtype)
218
- span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
219
- span[types == IMAGE] = image_embeds
220
- return span
221
-
206
def _process_image_input(
207
self,
208
image_input: DeepseekV4VLImagePixelInputs,
vllm/models/deepseek_v41/common/vl_cudagraph.py
new
+390
@@ -0,0 +1,390 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Encoder CUDA graph support for the DeepSeek-V4.1 vision tower.
4
+
5
+Mixin implementing the ``SupportsEncoderCudaGraph`` protocol
6
+(``vllm/model_executor/models/interfaces.py``) on top of the shared
7
+``DeepseekV4ViT``/``DeepseekV4Aligner``. The captured graph packs a whole
8
+batch of images into one varlen run: ViT blocks attend per image via
9
+``cu_seqlens``, and the aligner's spatial merge becomes a gather+mask
10
+against precomputed indices. Span assembly (IMAGE_START/IMAGE_NEW_LINE/
11
+IMAGE_END delimiters) stays eager in ``postprocess_encoder_output`` but is
12
+batched over all items of the packed batch.
13
+
14
+Enabled via ``--compilation-config '{"cudagraph_mm_encoder": true}'``; the
15
+manager (``vllm/v1/worker/encoder_cudagraph.py``) handles budget packing, DP
16
+sharding and eager fallback.
17
+"""
18
+
19
+from collections.abc import Hashable
20
+from typing import TYPE_CHECKING, Any
21
+
22
+import torch
23
+
24
+from vllm.models.deepseek_v4.common.vision import (
25
+ build_packed_merge_metadata,
26
+ build_packed_vit_metadata,
27
+)
28
+from vllm.v1.worker.encoder_cudagraph_defs import (
29
+ EncoderCudaGraphCaptureInputs,
30
+ EncoderCudaGraphConfig,
31
+ EncoderCudaGraphReplayBuffers,
32
+ EncoderItemSpec,
33
+)
34
+
35
+from .mm_preprocess import IMAGE, IMAGE_END, IMAGE_NEW_LINE, IMAGE_START
36
+
37
+if TYPE_CHECKING:
38
+ from vllm.config import VllmConfig
39
+ from vllm.config.multimodal import MultiModalConfig
40
+ from vllm.models.deepseek_v4.common.vision import (
41
+ DeepseekV4Aligner,
42
+ DeepseekV4ViT,
43
+ )
44
+ from vllm.transformers_utils.configs.deepseek_v41 import DeepseekV41Config
45
+
46
+
47
+class DeepseekV4VLEncoderCudaGraphMixin:
48
+ """``SupportsEncoderCudaGraph`` for ``DeepseekV41ForCausalLM``.
49
+
50
+ Expects ``self.vision``/``self.aligner``/``self.config``/
51
+ ``self.multimodal_config`` and the ``image_start``/``image_newline``/
52
+ ``image_end`` parameters from the host model.
53
+ """
54
+
55
+ # Provided by the host model; declared here so mixin methods type-check.
56
+ vision: "DeepseekV4ViT"
57
+ aligner: "DeepseekV4Aligner"
58
+ config: "DeepseekV41Config"
59
+ multimodal_config: "MultiModalConfig | None"
60
+ image_start: torch.nn.Parameter
61
+ image_end: torch.nn.Parameter
62
+ image_newline: torch.nn.Parameter
63
+
64
+ # -- helpers shared with the eager multimodal path --
65
+
66
+ def _encode_image(
67
+ self,
68
+ patches: torch.Tensor,
69
+ n_vit_h: int,
70
+ n_vit_w: int,
71
+ ) -> torch.Tensor:
72
+ # Aligner rows in reading order, one per IMAGE slot.
73
+ return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
74
+
75
+ def _build_image_span(
76
+ self, image_embeds: torch.Tensor, types: torch.Tensor
77
+ ) -> torch.Tensor:
78
+ """Full image span: aligner rows at IMAGE slots, the learned
79
+ delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
80
+ types = types.to(image_embeds.device)
81
+ span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
82
+ dtype = image_embeds.dtype
83
+ span[types == IMAGE_START] = self.image_start.to(dtype)
84
+ span[types == IMAGE_END] = self.image_end.to(dtype)
85
+ span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
86
+ span[types == IMAGE] = image_embeds
87
+ return span
88
+
89
+ @staticmethod
90
+ def _get_grid_list(mm_kwargs: dict[str, Any], key: str) -> list[list[int]]:
91
+ grid = mm_kwargs[key]
92
+ if isinstance(grid, torch.Tensor):
93
+ grid = grid.tolist()
94
+ return [[int(v) for v in row] for row in grid]
95
+
96
+ _encoder_cg_pad_totals: dict[int, int] = {}
97
+
98
+ # -- SupportsEncoderCudaGraph protocol --
99
+
100
+ def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
101
+ mm_cfg = self.multimodal_config
102
+ if (
103
+ mm_cfg is not None
104
+ and mm_cfg.mm_encoder_attn_dtype == "fp8"
105
+ and mm_cfg.mm_encoder_fp8_scale_path is None
106
+ ):
107
+ raise ValueError(
108
+ "cudagraph_mm_encoder is incompatible with dynamic FP8 "
109
+ "scaling for the vision encoder (the amax history is "
110
+ "host-side state that cannot be captured). Provide a static "
111
+ "scale file via --mm-encoder-fp8-scale-path or disable "
112
+ "cudagraph_mm_encoder."
113
+ )
114
+
115
+ from vllm.v1.attention.backends.registry import AttentionBackendEnum
116
+
117
+ backend = self.vision.blocks[0].attn.attn.attn_backend
118
+ # FlashInfer reads max_seqlen on the host and TORCH_SDPA calls
119
+ # .tolist() on CUDA cu_seqlens; neither can be captured.
120
+ if backend in (
121
+ AttentionBackendEnum.FLASHINFER,
122
+ AttentionBackendEnum.TORCH_SDPA,
123
+ ):
124
+ raise ValueError(
125
+ f"cudagraph_mm_encoder is not supported with the {backend.name} "
126
+ "ViT attention backend for DeepSeek-V4.1. Set "
127
+ "--mm-encoder-attn-backend FLASH_ATTN or disable "
128
+ "cudagraph_mm_encoder."
129
+ )
130
+
131
+ pad_totals = self._encoder_cg_pad_totals
132
+
133
+ def pad_cu_seqlens(dst: torch.Tensor, src: torch.Tensor) -> None:
134
+ # Varlen attention requires cu_seqlens[-1] to equal the number of
135
+ # rows actually passed in. The captured buffers are sized for the
136
+ # full token budget, so a smaller real batch is completed with one
137
+ # trailing padding sequence; declaring fewer rows than the buffer
138
+ # holds is undefined behaviour and returns NaN on FlashAttn.
139
+ total = pad_totals.get(dst.data_ptr())
140
+ if total is None:
141
+ raise RuntimeError(
142
+ "cu_seqlens replay buffer was not registered at capture "
143
+ "time; the manager must replay into the capture-time "
144
+ "buffers."
145
+ )
146
+ n = min(src.shape[0], dst.shape[0])
147
+ dst[:n].copy_(src[:n])
148
+ dst[n:] = total
149
+
150
+ return EncoderCudaGraphConfig(
151
+ modalities=["image"],
152
+ buffer_keys=[
153
+ "patches",
154
+ "vit_cos",
155
+ "vit_sin",
156
+ "cu_seqlens",
157
+ "max_seqlen",
158
+ "merge_idx",
159
+ "merge_mask",
160
+ ],
161
+ out_hidden_size=self.config.hidden_size,
162
+ padding_logics={"cu_seqlens": pad_cu_seqlens},
163
+ )
164
+
165
+ def get_encoder_cudagraph_budget_range(
166
+ self,
167
+ vllm_config: "VllmConfig",
168
+ ) -> tuple[int, int]:
169
+ # Min: comfortably above the smallest possible image span (4 tokens
170
+ # for a 1x1 grid); also sets max_batch_size = 64 via the manager's
171
+ # auto-inference (max_batch_size <= min_budget invariant).
172
+ min_budget = 64
173
+ max_budget = min(
174
+ vllm_config.scheduler_config.max_num_batched_tokens,
175
+ vllm_config.model_config.max_model_len,
176
+ )
177
+ return (min_budget, max_budget)
178
+
179
+ def get_encoder_cudagraph_item_specs(
180
+ self,
181
+ mm_kwargs: dict[str, Any],
182
+ ) -> list[EncoderItemSpec]:
183
+ vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
184
+ llm_grid = self._get_grid_list(mm_kwargs, "llm_grid")
185
+ return [
186
+ EncoderItemSpec(
187
+ input_size=h * w,
188
+ output_tokens=lh * (lw + 1) + 2,
189
+ )
190
+ for (h, w), (lh, lw) in zip(vit_grid, llm_grid, strict=True)
191
+ ]
192
+
193
+ def select_encoder_cudagraph_items(
194
+ self,
195
+ mm_kwargs: dict[str, Any],
196
+ indices: list[int],
197
+ ) -> dict[str, Any]:
198
+ vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
199
+ llm_grid = self._get_grid_list(mm_kwargs, "llm_grid")
200
+ patches = mm_kwargs["patches"]
201
+ types = mm_kwargs["types"]
202
+
203
+ if len(indices) == 0:
204
+ return {
205
+ "patches": patches[:0],
206
+ "vit_grid": torch.zeros((0, 2), dtype=torch.int64),
207
+ "llm_grid": torch.zeros((0, 2), dtype=torch.int64),
208
+ "types": types[:0],
209
+ }
210
+
211
+ cum_patches = [0]
212
+ for h, w in vit_grid:
213
+ cum_patches.append(cum_patches[-1] + h * w)
214
+ cum_spans = [0]
215
+ for lh, lw in llm_grid:
216
+ cum_spans.append(cum_spans[-1] + lh * (lw + 1) + 2)
217
+
218
+ return {
219
+ "patches": torch.cat(
220
+ [patches[cum_patches[i] : cum_patches[i + 1]] for i in indices]
221
+ ),
222
+ "vit_grid": torch.tensor([vit_grid[i] for i in indices], dtype=torch.int64),
223
+ "llm_grid": torch.tensor([llm_grid[i] for i in indices], dtype=torch.int64),
224
+ "types": torch.cat(
225
+ [types[cum_spans[i] : cum_spans[i + 1]] for i in indices]
226
+ ),
227
+ }
228
+
229
+ def prepare_encoder_cudagraph_capture_inputs(
230
+ self,
231
+ token_budget: int,
232
+ max_batch_size: int,
233
+ max_frames_per_batch: int,
234
+ device: torch.device,
235
+ dtype: torch.dtype,
236
+ path: str = "default",
237
+ axis_keys: tuple[Hashable, ...] | None = None,
238
+ ) -> EncoderCudaGraphCaptureInputs:
239
+ if (
240
+ self.multimodal_config is not None
241
+ and self.multimodal_config.get_limit_per_prompt("image") == 0
242
+ ):
243
+ raise RuntimeError(
244
+ "cudagraph_mm_encoder requires the vision tower, but it was "
245
+ "stubbed out because the image limit is 0. Disable "
246
+ "cudagraph_mm_encoder."
247
+ )
248
+
249
+ config = self.config
250
+ r = config.vision_downsample_ratio
251
+ # Span rows per dummy item (ceiling so the buffers tile the budget).
252
+ # Each dummy image is a single aligner row of per_item_out blocks:
253
+ # r x (r * per_item_out) patches. One real image using the whole
254
+ # budget needs at most r^2 * token_budget patches, which this fits.
255
+ per_item_out = -(-token_budget // max_batch_size)
256
+ grids = [[r, r * per_item_out]] * max_batch_size
257
+ total_patches = r * r * per_item_out * max_batch_size
258
+
259
+ p = config.vision_patch_size
260
+ patches = torch.zeros(total_patches, 3, p, p, device=device, dtype=dtype)
261
+
262
+ metadata = build_packed_vit_metadata(
263
+ grids,
264
+ rope_dim=self.vision.rope_dim,
265
+ rope_theta=self.vision.rope_theta,
266
+ device=device,
267
+ max_seqlen_override=total_patches,
268
+ cached=False,
269
+ )
270
+ # Spare cu_seqlens slots let replay append a padding sequence that
271
+ # covers the rows a smaller real batch does not fill.
272
+ real_cu = metadata.pop("cu_seqlens")
273
+ cu_seqlens = torch.full(
274
+ (max_batch_size + 2,), total_patches, dtype=torch.int32, device=device
275
+ )
276
+ cu_seqlens[: real_cu.numel()] = real_cu
277
+
278
+ merge = build_packed_merge_metadata(grids, r, device=device, dtype=dtype)
279
+
280
+ self._encoder_cg_pad_totals[cu_seqlens.data_ptr()] = total_patches
281
+
282
+ return EncoderCudaGraphCaptureInputs(
283
+ values={
284
+ "patches": patches,
285
+ **metadata,
286
+ "cu_seqlens": cu_seqlens,
287
+ **merge,
288
+ }
289
+ )
290
+
291
+ def prepare_encoder_cudagraph_replay_buffers(
292
+ self,
293
+ mm_kwargs: dict[str, Any],
294
+ max_batch_size: int,
295
+ max_frames_per_batch: int,
296
+ path: str = "default",
297
+ ) -> EncoderCudaGraphReplayBuffers:
298
+ vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
299
+ patches = mm_kwargs["patches"]
300
+ dtype = self.aligner.w1.weight.dtype
301
+ if patches.dtype != dtype:
302
+ patches = patches.to(dtype)
303
+
304
+ # Unpadded: the manager zero-pads patches/cos/sin/merge buffers, and
305
+ # pad_cu_seqlens appends the padding sequence covering the tail rows.
306
+ metadata = build_packed_vit_metadata(
307
+ vit_grid,
308
+ rope_dim=self.vision.rope_dim,
309
+ rope_theta=self.vision.rope_theta,
310
+ device=patches.device,
311
+ )
312
+ merge = build_packed_merge_metadata(
313
+ vit_grid,
314
+ self.config.vision_downsample_ratio,
315
+ device=patches.device,
316
+ dtype=dtype,
317
+ )
318
+ return EncoderCudaGraphReplayBuffers(
319
+ values={"patches": patches, **metadata, **merge}
320
+ )
321
+
322
+ def encoder_cudagraph_forward(
323
+ self,
324
+ inputs: dict[str, torch.Tensor],
325
+ path: str = "default",
326
+ ) -> torch.Tensor:
327
+ vit_out = self.vision.forward_packed(
328
+ inputs["patches"],
329
+ inputs["vit_cos"],
330
+ inputs["vit_sin"],
331
+ inputs["cu_seqlens"],
332
+ inputs["max_seqlen"],
333
+ )
334
+ return self.aligner.forward_packed(
335
+ vit_out, inputs["merge_idx"], inputs["merge_mask"]
336
+ )
337
+
338
+ def encoder_eager_forward(
339
+ self,
340
+ mm_kwargs: dict[str, Any],
341
+ path: str = "default",
342
+ ) -> torch.Tensor:
343
+ patches = mm_kwargs["patches"].to(self.aligner.w1.weight.dtype)
344
+ vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
345
+ outs: list[torch.Tensor] = []
346
+ offset = 0
347
+ for n_vit_h, n_vit_w in vit_grid:
348
+ n_vit = n_vit_h * n_vit_w
349
+ outs.append(
350
+ self._encode_image(patches[offset : offset + n_vit], n_vit_h, n_vit_w)
351
+ )
352
+ offset += n_vit
353
+ if not outs:
354
+ return patches.new_zeros((0, self.config.hidden_size))
355
+ return torch.cat(outs, dim=0)
356
+
357
+ def postprocess_encoder_output(
358
+ self,
359
+ outputs: dict[str, torch.Tensor],
360
+ indices: list[int],
361
+ per_item_out_tokens: list[int],
362
+ dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
363
+ clone: bool = False,
364
+ batch_mm_kwargs: dict[str, Any] | None = None,
365
+ ) -> None:
366
+ if not indices:
367
+ return
368
+ assert batch_mm_kwargs is not None
369
+ aligner_out = outputs["default"]
370
+ r = self.config.vision_downsample_ratio
371
+ vit_grid = self._get_grid_list(batch_mm_kwargs, "vit_grid")
372
+ llm_grid = self._get_grid_list(batch_mm_kwargs, "llm_grid")
373
+ types = batch_mm_kwargs["types"].to(aligner_out.device)
374
+
375
+ n_rows = sum(-(-h // r) * (-(-w // r)) for h, w in vit_grid)
376
+ span_lens = [lh * (lw + 1) + 2 for lh, lw in llm_grid]
377
+
378
+ # Batched span assembly: one masked fill per role across all items.
379
+ dtype = aligner_out.dtype
380
+ span = aligner_out.new_empty(sum(span_lens), self.config.hidden_size)
381
+ span[types == IMAGE] = aligner_out[:n_rows]
382
+ span[types == IMAGE_START] = self.image_start.to(dtype)
383
+ span[types == IMAGE_END] = self.image_end.to(dtype)
384
+ span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
385
+
386
+ # Freshly allocated, so later replays cannot clobber the results.
387
+ offset = 0
388
+ for idx, span_len in zip(indices, span_lens):
389
+ dest[idx] = span[offset : offset + span_len]
390
+ offset += span_len
vllm/models/deepseek_v41/nvidia/vl_model.py
+12
-28
@@ -26,6 +26,7 @@ from vllm.distributed import get_tensor_model_parallel_world_size
26
from vllm.model_executor.models.interfaces import (
27
MultiModalEmbeddings,
28
SupportsEagle3,
29
+ SupportsEncoderCudaGraph,
30
SupportsMultiModal,
31
SupportsPP,
32
)
@@ -44,15 +45,12 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
45
from vllm.utils.tensor_schema import TensorSchema, TensorShape
46
47
from ..common.mm_preprocess import (
47
- IMAGE,
48
- IMAGE_END,
49
- IMAGE_NEW_LINE,
48
IMAGE_PLACEHOLDER,
51
- IMAGE_START,
49
DeepseekV4VLDummyInputsBuilder,
50
DeepseekV4VLMultiModalProcessor,
51
DeepseekV4VLProcessingInfo,
52
)
53
+from ..common.vl_cudagraph import DeepseekV4VLEncoderCudaGraphMixin
54
from .model import (
55
DeepseekV41LLMForCausalLM,
56
_linear_scale_param_name,
@@ -115,11 +113,20 @@ def _make_deepseek_v4_vl_weights_mapper(
113
info=DeepseekV4VLProcessingInfo,
114
dummy_inputs=DeepseekV4VLDummyInputsBuilder,
115
)
118
-class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3):
116
+class DeepseekV41ForCausalLM(
117
+ nn.Module,
118
+ DeepseekV4VLEncoderCudaGraphMixin,
119
+ SupportsMultiModal,
120
+ SupportsEncoderCudaGraph,
121
+ SupportsPP,
122
+ SupportsEagle3,
123
+):
124
"""Multimodal entry point for DeepSeek-V4.1 checkpoints with a vision tower.
125
126
``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters)
127
delegates through ``language_model`` via the protocol defaults.
128
+ ``SupportsEncoderCudaGraph`` (ViT CUDA graph capture/replay) is
129
+ implemented by ``DeepseekV4VLEncoderCudaGraphMixin``.
130
"""
131
132
supports_encoder_tp_data = True
@@ -196,29 +203,6 @@ class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, Supports
203
resolve_bindings={"p": self.config.vision_patch_size},
204
)
205
199
- def _encode_image(
200
- self,
201
- patches: torch.Tensor,
202
- n_vit_h: int,
203
- n_vit_w: int,
204
- ) -> torch.Tensor:
205
- # Aligner rows in reading order, one per IMAGE slot.
206
- return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
207
-
208
- def _build_image_span(
209
- self, image_embeds: torch.Tensor, types: torch.Tensor
210
- ) -> torch.Tensor:
211
- """Full image span: aligner rows at IMAGE slots, the learned
212
- delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
213
- types = types.to(image_embeds.device)
214
- span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
215
- dtype = image_embeds.dtype
216
- span[types == IMAGE_START] = self.image_start.to(dtype)
217
- span[types == IMAGE_END] = self.image_end.to(dtype)
218
- span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
219
- span[types == IMAGE] = image_embeds
220
- return span
221
-
206
def _process_image_input(
207
self,
208
image_input: DeepseekV4VLImagePixelInputs,