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

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

[MM][Perf][CG] Support ViT full CUDA graph for Kimi-VL (#41992)

Signed-off-by: oguz <oguzhankir17@gmail.com>

Oğuzhan KIR committed Jun 17, 2026 at 15:14 UTC fa85ead2f378621fc412ac3679eae88a5604ebe8
5 files changed +498 -39
docs/design/cuda_graphs_multimodal.md
+1
@@ -129,6 +129,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
129 | `DeepseekOCRForCausalLM` | `DeepSeek-OCR` | ✅︎ | ❌︎ | ✅︎ |
130 | `Glm4vForConditionalGeneration` | `GLM-4.1V, GLM-4.6V-Flash` | ✅︎ | ✅︎ | ❌︎ |
131 | `InternVLChatModel` | `InternVL3.5`, `InternVL3`, `InternVL2.5`, `InternVL2` | ✅︎ | ✅︎ | ❌︎ |
132 +| `KimiVLForConditionalGeneration` | `Kimi-VL` | ✅︎ | ❌︎ | ❌︎ |
133 | `Llama4ForConditionalGeneration` | `Llama 4` | ✅︎ | ❌︎ | ❌︎ |
134 | `Qwen2VLForConditionalGeneration` | `Qwen2-VL` | ✅︎ | ✅︎ | ❌︎ |
135 | `Qwen2_5_VLForConditionalGeneration` | `Qwen2.5-VL` | ✅︎ | ✅︎ | ❌︎ |
examples/generate/multimodal/vision_language_offline.py
+1
@@ -2537,6 +2537,7 @@ MODELS_SUPPORT_VIT_CUDA_GRAPH = [
2537 "qwen2_5_vl",
2538 "qwen3_vl",
2539 "qwen3_vl_moe",
2540 + "kimi_vl",
2541 "qwen3_5",
2542 "qwen3_5_moe",
2543 "internvl_chat",
tests/models/multimodal/generation/test_vit_cudagraph.py
+35
@@ -48,6 +48,13 @@ def internvl_chat_template(content: str) -> str:
48 return f"<|im_start|>user\n{content}<|im_end|>\n<|im_start|>assistant\n"
49
50
51 +def kimi_vl_chat_template(content: str) -> str:
52 + return (
53 + f"<|im_user|>user<|im_middle|>{content}<|im_end|>"
54 + "<|im_assistant|>assistant<|im_middle|>"
55 + )
56 +
57 +
58 def step3_vl_chat_template(content: str) -> str:
59 return (
60 "<|begin▁of▁sentence|> You are a helpful assistant.<|BOT|>user\n "
@@ -100,6 +107,34 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
107 needs_video_metadata=False,
108 marks=[pytest.mark.core_model],
109 ),
110 + "kimi_vl": VitCudagraphTestConfig(
111 + model="moonshotai/Kimi-VL-A3B-Instruct",
112 + modalities=["image"],
113 + image_prompt=kimi_vl_chat_template(
114 + "<|media_start|>image<|media_content|><|media_pad|><|media_end|>"
115 + "What is in this image?"
116 + ),
117 + needs_video_metadata=False,
118 + # Single bucket sized to cover the test images' output tokens.
119 + # The default auto-inferred range fans out into multiple power-of-2
120 + # buckets, each holding a full ViT capture pool.
121 + compilation_config_overrides={
122 + "encoder_cudagraph_token_budgets": [1024],
123 + },
124 + # Shrink to 1 text + 1 vision layer with random weights so the
125 + # test runs on any CI GPU (incl. L4) and skips the multi-GiB
126 + # weight download. The test only validates that encoder CG
127 + # capture/replay functions correctly, not output quality.
128 + vllm_runner_kwargs={
129 + "trust_remote_code": True,
130 + "load_format": "dummy",
131 + "hf_overrides": partial(
132 + dummy_hf_overrides,
133 + model_arch="KimiVLForConditionalGeneration",
134 + ),
135 + },
136 + marks=[pytest.mark.core_model],
137 + ),
138 "qwen3_vl": VitCudagraphTestConfig(
139 model="Qwen/Qwen3-VL-2B-Instruct",
140 image_prompt=qwen_vl_chat_template(
vllm/model_executor/models/kimi_vl.py
+195 -2
@@ -56,7 +56,11 @@ from vllm.config import VllmConfig
56 from vllm.config.multimodal import BaseDummyOptions
57 from vllm.inputs import MultiModalDataDict
58 from vllm.model_executor.layers.linear import ReplicatedLinear
59 -from vllm.model_executor.models.interfaces import SupportsMultiModal, SupportsPP
59 +from vllm.model_executor.models.interfaces import (
60 + SupportsEncoderCudaGraph,
61 + SupportsMultiModal,
62 + SupportsPP,
63 +)
64 from vllm.model_executor.models.moonvit import MoonVitPretrainedModel
65 from vllm.multimodal import MULTIMODAL_REGISTRY
66 from vllm.multimodal.inputs import (
@@ -79,6 +83,7 @@ from vllm.multimodal.processing import (
83 from vllm.sequence import IntermediateTensors
84 from vllm.transformers_utils.configs.kimi_vl import KimiVLConfig, MoonViTConfig
85 from vllm.utils.tensor_schema import TensorSchema, TensorShape
86 +from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphReplayBuffers
87
88 from .utils import AutoWeightsLoader, init_vllm_registered_model, maybe_prefix
89 from .vision import is_vit_use_data_parallel, run_dp_sharded_mrope_vision_model
@@ -287,7 +292,9 @@ class KimiVLMultiModalProcessor(BaseMultiModalProcessor[KimiVLProcessingInfo]):
292 info=KimiVLProcessingInfo,
293 dummy_inputs=KimiVLDummyInputsBuilder,
294 )
290 -class KimiVLForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP):
295 +class KimiVLForConditionalGeneration(
296 + nn.Module, SupportsMultiModal, SupportsEncoderCudaGraph, SupportsPP
297 +):
298 supports_encoder_tp_data = True
299
300 @classmethod
@@ -340,6 +347,192 @@ class KimiVLForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP):
347
348 self.media_placeholder: int = self.config.media_placeholder_token_id
349
350 + self.model_config = model_config
351 +
352 + # -- SupportsEncoderCudaGraph protocol methods --
353 +
354 + def get_encoder_cudagraph_config(self):
355 + from vllm.v1.worker.encoder_cudagraph_defs import (
356 + EncoderCudaGraphConfig,
357 + )
358 +
359 + return EncoderCudaGraphConfig(
360 + modalities=["image"],
361 + buffer_keys=[
362 + "pixel_values",
363 + "pos_embeds",
364 + "rope_freqs_cis",
365 + "cu_seqlens",
366 + "max_seqlen",
367 + "merge_gather_idx",
368 + ],
369 + out_hidden_size=self.hidden_size,
370 + )
371 +
372 + def get_encoder_cudagraph_budget_range(
373 + self,
374 + vllm_config,
375 + ) -> tuple[int, int]:
376 + # Min: estimated smallest possible encoder input.
377 + # 224x224 image with patch_size=14 -> 16x16 patches, then merge
378 + # kernel (2,2) -> 8x8 = 64 output tokens.
379 + min_budget = 64
380 + max_budget = min(
381 + vllm_config.scheduler_config.max_num_batched_tokens,
382 + self.model_config.max_model_len,
383 + )
384 + return (min_budget, max_budget)
385 +
386 + def _get_grid_hws(
387 + self,
388 + mm_kwargs: dict[str, Any],
389 + ) -> list[tuple[int, int]]:
390 + grid_hws = mm_kwargs["image_grid_hws"]
391 + if not isinstance(grid_hws, list):
392 + grid_hws = grid_hws.tolist()
393 + return grid_hws
394 +
395 + def get_encoder_cudagraph_item_specs(
396 + self,
397 + mm_kwargs: dict[str, Any],
398 + ):
399 + from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
400 +
401 + kh, kw = self.config.vision_config.merge_kernel_size
402 + return [
403 + EncoderItemSpec(
404 + input_size=h * w,
405 + output_tokens=(h // kh) * (w // kw),
406 + )
407 + for h, w in self._get_grid_hws(mm_kwargs)
408 + ]
409 +
410 + def select_encoder_cudagraph_items(
411 + self,
412 + mm_kwargs: dict[str, Any],
413 + indices: list[int],
414 + ) -> dict[str, Any]:
415 + grid_hws = self._get_grid_hws(mm_kwargs)
416 + pixel_values = mm_kwargs["pixel_values"]
417 +
418 + if len(indices) == 0:
419 + return {
420 + "pixel_values": pixel_values[:0],
421 + "image_grid_hws": pixel_values.new_zeros((0, 2), dtype=torch.long),
422 + }
423 +
424 + patches_per_item = [h * w for h, w in grid_hws]
425 + cum_patches = [0]
426 + for p in patches_per_item:
427 + cum_patches.append(cum_patches[-1] + p)
428 +
429 + selected_pv = torch.cat(
430 + [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
431 + )
432 + selected_grid = torch.tensor(
433 + [grid_hws[i] for i in indices],
434 + dtype=torch.long,
435 + device=pixel_values.device,
436 + )
437 + return {
438 + "pixel_values": selected_pv,
439 + "image_grid_hws": selected_grid,
440 + }
441 +
442 + def prepare_encoder_cudagraph_capture_inputs(
443 + self,
444 + token_budget: int,
445 + max_batch_size: int,
446 + max_frames_per_batch: int,
447 + device: torch.device,
448 + dtype: torch.dtype,
449 + path: str = "default",
450 + ):
451 + from vllm.v1.worker.encoder_cudagraph_defs import (
452 + EncoderCudaGraphCaptureInputs,
453 + )
454 +
455 + kh, kw = self.config.vision_config.merge_kernel_size
456 + # Ceil so the buffer fits the worst case of one item using the full
457 + # budget. Floor under-allocates when budget is not a multiple of
458 + # max_batch_size.
459 + per_mm_item_output = (token_budget + max_batch_size - 1) // max_batch_size
460 +
461 + # Shape the synthetic grid so neither dimension exceeds Rope2DPosEmb's
462 + # precomputed range. Pack as wide a row as fits, then add rows.
463 + rope = self.vision_tower.encoder.rope_2d
464 + max_wo = rope.max_width // kw
465 + wo = min(per_mm_item_output, max_wo)
466 + ho = (per_mm_item_output + wo - 1) // wo
467 + assert ho * kh <= rope.max_height, (
468 + f"per_mm_item_output={per_mm_item_output} exceeds RoPE grid capacity "
469 + f"(max {(rope.max_height // kh) * (rope.max_width // kw)} tokens)"
470 + )
471 + grid_hws_list = [(ho * kh, wo * kw) for _ in range(max_batch_size)]
472 +
473 + patch_size = self.config.vision_config.patch_size
474 + if isinstance(patch_size, int):
475 + patch_size = (patch_size, patch_size)
476 +
477 + total_patches = sum(h * w for h, w in grid_hws_list)
478 + in_channels = 3
479 + dummy_pixel_values = torch.randn(
480 + total_patches,
481 + in_channels,
482 + patch_size[0],
483 + patch_size[1],
484 + device=device,
485 + dtype=dtype,
486 + )
487 +
488 + buffers = self.vision_tower.prepare_encoder_metadata(
489 + grid_hws_list,
490 + max_batch_size=max_batch_size,
491 + max_seqlen_override=token_budget,
492 + device=device,
493 + )
494 + values = buffers | {"pixel_values": dummy_pixel_values}
495 +
496 + return EncoderCudaGraphCaptureInputs(values=values)
497 +
498 + def prepare_encoder_cudagraph_replay_buffers(
499 + self,
500 + mm_kwargs: dict[str, Any],
501 + max_batch_size: int,
502 + max_frames_per_batch: int,
503 + path: str = "default",
504 + ):
505 + grid_hws_list = self._get_grid_hws(mm_kwargs)
506 + buffers = self.vision_tower.prepare_encoder_metadata(
507 + grid_hws_list,
508 + max_batch_size=max_batch_size,
509 + device=mm_kwargs["pixel_values"].device,
510 + )
511 + values = buffers | {"pixel_values": mm_kwargs["pixel_values"]}
512 + return EncoderCudaGraphReplayBuffers(values=values)
513 +
514 + def encoder_cudagraph_forward(
515 + self,
516 + values: dict[str, torch.Tensor],
517 + path: str = "default",
518 + ) -> torch.Tensor:
519 + pixel_values = values.pop("pixel_values")
520 + metadata = values
521 + image_features = self.vision_tower(
522 + pixel_values, grid_hw=None, encoder_metadata=metadata
523 + )
524 + return self.multi_modal_projector(image_features)
525 +
526 + def encoder_eager_forward(
527 + self,
528 + mm_kwargs: dict[str, Any],
529 + path: str = "default",
530 + ) -> torch.Tensor:
531 + pixel_values = mm_kwargs["pixel_values"]
532 + image_grid_hws = mm_kwargs["image_grid_hws"]
533 + image_features = self.vision_tower(pixel_values, image_grid_hws)
534 + return self.multi_modal_projector(torch.cat(image_features))
535 +
536 def _parse_and_validate_image_input(
537 self, **kwargs: object
538 ) -> KimiVLImageInputs | None:
vllm/model_executor/models/moonvit.py
+266 -37
@@ -45,7 +45,9 @@
45 from collections.abc import Sequence
46 from copy import deepcopy
47 from functools import cached_property
48 +from typing import Any
49
50 +import numpy as np
51 import torch
52 import torch.nn as nn
53 import torch.nn.functional as F
@@ -110,23 +112,42 @@ class Learnable2DInterpPosEmb(nn.Module):
112 def reset_parameters(self):
113 nn.init.normal_(self.weight)
114
113 - def forward(self, x: torch.Tensor, grid_hws: torch.Tensor) -> torch.Tensor:
114 - pos_embs = []
115 - for shape in grid_hws.tolist():
116 - if shape == self.weight.shape[:-1]:
115 + def get_pos_embeds(
116 + self,
117 + grid_hws_list: list[list[int]] | list[tuple[int, int]],
118 + ) -> torch.Tensor:
119 + """Build packed per-token positional embeddings for a list of grids.
120 +
121 + Returns a tensor of shape ``(sum(h * w), dim)`` formed by interpolating
122 + the learned ``(height, width, dim)`` weight to each ``(h, w)`` grid and
123 + concatenating the flattened results in the same order as
124 + ``grid_hws_list``. Lives outside the captured CUDA graph so the
125 + per-grid Python iteration is safe.
126 + """
127 + weight_shape = list(self.weight.shape[:-1])
128 + pos_embs: list[torch.Tensor] = []
129 + for shape in grid_hws_list:
130 + shape_list = [int(shape[0]), int(shape[1])]
131 + if shape_list == weight_shape:
132 pos_embs.append(self.weight.flatten(end_dim=1))
133 else:
134 pos_embs.append(
135 F.interpolate(
136 self.weight.permute((2, 0, 1)).unsqueeze(0),
122 - size=shape,
137 + size=tuple(shape_list),
138 mode=self.interpolation_mode,
139 )
140 .squeeze(0)
141 .permute((1, 2, 0))
142 .flatten(end_dim=1)
143 )
129 - out = x + torch.cat(pos_embs)
144 + if not pos_embs:
145 + return self.weight.new_zeros((0, self.weight.shape[-1]))
146 + return torch.cat(pos_embs)
147 +
148 + def forward(self, x: torch.Tensor, grid_hws: torch.Tensor) -> torch.Tensor:
149 + pos_embs = self.get_pos_embeds(grid_hws.tolist())
150 + out = x + pos_embs
151 return out
152
153
@@ -158,19 +179,29 @@ class MoonVisionPatchEmbed(nn.Module):
179 height=pos_emb_height, width=pos_emb_width, dim=out_dim
180 )
181
161 - def forward(self, x: torch.Tensor, grid_hw: torch.Tensor) -> torch.Tensor:
182 + def forward(
183 + self,
184 + x: torch.Tensor,
185 + grid_hw: torch.Tensor | None = None,
186 + *,
187 + pos_embeds: torch.Tensor | None = None,
188 + ) -> torch.Tensor:
189 """
190 Args:
191 x (L, Channels): input tensor
192 grid_hw (N, 2): grid height and width
193 + pos_embeds: precomputed positional embeddings of shape
194 + ``(L, Cout)``. When provided, ``grid_hw`` is unused and the
195 + CUDA-graph-incompatible interpolation in ``self.pos_emb`` is
196 + skipped.
197
198 Returns:
199 (L, Cout) tensor
200 """
201 x = self.proj(x).view(x.size(0), -1)
171 - # apply positional embedding
172 - x = self.pos_emb(x, grid_hw)
173 - return x
202 + if pos_embeds is not None:
203 + return x + pos_embeds
204 + return self.pos_emb(x, grid_hw)
205
206
207 class Rope2DPosEmb(nn.Module):
@@ -243,30 +274,44 @@ class Rope2DPosEmb(nn.Module):
274 freqs_cis = freqs_cis.reshape(self.max_height, self.max_width, -1)
275 return freqs_cis
276
246 - def get_freqs_cis_by_seqlens(self, grid_hws: torch.Tensor) -> torch.Tensor:
247 - """
248 - Args:
249 - grid_hws (torch.Tensor): containing list of (height, width) or (t, height, width) tuples.
250 - Returns:
251 - freqs_cis: tensor of shape (sum(t * height * width), dim//2)
277 + def get_freqs_cis_by_seqlens_list(
278 + self,
279 + grid_hws_list: list[list[int]] | list[tuple[int, int]],
280 + ) -> torch.Tensor:
281 + """List-based variant of :meth:`get_freqs_cis_by_seqlens`.
282 +
283 + Accepts a Python list of ``(h, w)`` pairs so callers that already
284 + operate outside the captured CUDA graph can avoid materializing a
285 + tensor + ``.tolist()`` round-trip.
286 """
253 - shapes = grid_hws.tolist()
287 assert all(
255 - 1 <= h <= self.max_height and 1 <= w <= self.max_width for h, w in shapes
288 + 1 <= h <= self.max_height and 1 <= w <= self.max_width
289 + for h, w in grid_hws_list
290 ), (
257 - shapes,
291 + grid_hws_list,
292 self.max_height,
293 self.max_width,
294 )
295 + if not grid_hws_list:
296 + return self.precomputed_freqs_cis.new_zeros((0, self.dim // 2))
297 freqs_cis = torch.cat(
298 [
299 self.precomputed_freqs_cis[:h, :w].reshape(-1, self.dim // 2)
264 - for h, w in shapes
300 + for h, w in grid_hws_list
301 ],
302 dim=0,
303 )
304 return freqs_cis
305
306 + def get_freqs_cis_by_seqlens(self, grid_hws: torch.Tensor) -> torch.Tensor:
307 + """
308 + Args:
309 + grid_hws (torch.Tensor): containing list of (height, width) or (t, height, width) tuples.
310 + Returns:
311 + freqs_cis: tensor of shape (sum(t * height * width), dim//2)
312 + """
313 + return self.get_freqs_cis_by_seqlens_list(grid_hws.tolist())
314 +
315 def get_freqs_cis_by_idx(
316 self, pos_idx: torch.Tensor, pos_idx_mask: torch.Tensor
317 ) -> torch.Tensor:
@@ -392,11 +437,15 @@ class MoonVitEncoderLayer(nn.Module):
437 x: torch.Tensor,
438 cu_seqlens: torch.Tensor,
439 rope_freqs_cis: torch.Tensor | None = None,
440 + max_seqlen: torch.Tensor | None = None,
441 ):
442 """
443 Args:
444 x (torch.Tensor): (seqlen, hidden_dim)
445 cu_seqlens (torch.Tensor):
446 + max_seqlen: Optional precomputed scalar tensor. When omitted it
447 + is derived from ``cu_seqlens``, which produces a GPU scalar
448 + that breaks CUDA graph capture.
449 """
450 seq_length = x.size(0)
451 xqkv, _ = self.wqkv(x)
@@ -412,7 +461,8 @@ class MoonVitEncoderLayer(nn.Module):
461
462 xq, xk = apply_rope(xq, xk, rope_freqs_cis)
463
415 - max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max()
464 + if max_seqlen is None:
465 + max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max()
466 attn_out = self.attn(
467 xq.unsqueeze(0),
468 xk.unsqueeze(0),
@@ -433,10 +483,12 @@ class MoonVitEncoderLayer(nn.Module):
483 hidden_states: torch.Tensor,
484 cu_seqlens: torch.Tensor,
485 rope_freqs_cis: torch.Tensor | None = None,
486 + max_seqlen: torch.Tensor | None = None,
487 ) -> torch.Tensor:
488 """
489 Args:
490 hidden_states: non-packed (B, N, D) or packed (L, D). if non-packed, seqlens should be None, if packed, seqlens should be set
491 + max_seqlen: optional precomputed max-sequence-length scalar.
492
493 Returns:
494 output: same shape of input, non-packed (B, N, D) for non-packed input, (L, D) for packed input
@@ -444,7 +496,10 @@ class MoonVitEncoderLayer(nn.Module):
496 residual = hidden_states
497 hidden_states = self.norm0(hidden_states)
498 attn_out = self.attention_qkvpacked(
447 - hidden_states, cu_seqlens, rope_freqs_cis=rope_freqs_cis
499 + hidden_states,
500 + cu_seqlens,
501 + rope_freqs_cis=rope_freqs_cis,
502 + max_seqlen=max_seqlen,
503 )
504 hidden_states = residual + attn_out
505
@@ -478,22 +533,39 @@ class MoonVitEncoder(nn.Module):
533 )
534 self.final_layernorm = nn.LayerNorm(hidden_dim)
535
481 - def forward(
482 - self, hidden_states: torch.Tensor, grid_hw: torch.Tensor
536 + def get_rope_freqs_cis(
537 + self,
538 + grid_hws_list: list[list[int]] | list[tuple[int, int]],
539 ) -> torch.Tensor:
484 - rope_freqs_cis = self.rope_2d.get_freqs_cis_by_seqlens(grid_hws=grid_hw)
540 + return self.rope_2d.get_freqs_cis_by_seqlens_list(grid_hws_list)
541
486 - lengths = torch.cat(
487 - (
488 - torch.zeros(1, device=hidden_states.device, dtype=grid_hw.dtype),
489 - (grid_hw[:, 0] * grid_hw[:, 1]).to(hidden_states.device),
542 + def forward(
543 + self,
544 + hidden_states: torch.Tensor,
545 + grid_hw: torch.Tensor | None = None,
546 + *,
547 + cu_seqlens: torch.Tensor | None = None,
548 + rope_freqs_cis: torch.Tensor | None = None,
549 + max_seqlen: torch.Tensor | None = None,
550 + ) -> torch.Tensor:
551 + if rope_freqs_cis is None:
552 + rope_freqs_cis = self.rope_2d.get_freqs_cis_by_seqlens(grid_hws=grid_hw)
553 +
554 + if cu_seqlens is None:
555 + lengths = torch.cat(
556 + (
557 + torch.zeros(1, device=hidden_states.device, dtype=grid_hw.dtype),
558 + (grid_hw[:, 0] * grid_hw[:, 1]).to(hidden_states.device),
559 + )
560 )
491 - )
492 - cu_seqlens = lengths.cumsum(dim=0, dtype=torch.int32)
561 + cu_seqlens = lengths.cumsum(dim=0, dtype=torch.int32)
562
563 for _, block in enumerate(self.blocks):
564 hidden_states = block(
496 - hidden_states, cu_seqlens, rope_freqs_cis=rope_freqs_cis
565 + hidden_states,
566 + cu_seqlens,
567 + rope_freqs_cis=rope_freqs_cis,
568 + max_seqlen=max_seqlen,
569 )
570
571 hidden_states = self.final_layernorm(hidden_states)
@@ -530,6 +602,54 @@ def patch_merger(
602 return outputs
603
604
605 +def patch_merger_packed(
606 + x: torch.Tensor,
607 + gather_idx: torch.Tensor,
608 + merge_kernel_size: tuple[int, int],
609 +) -> torch.Tensor:
610 + """CUDA-graph-safe equivalent of :func:`patch_merger`.
611 +
612 + Uses a precomputed index tensor to gather the per-token reshape +
613 + permute that ``patch_merger`` does inside a Python loop. The output is
614 + the concatenated 3D tensor ``(sum(new_h * new_w), kh * kw, d_model)``,
615 + matching what ``torch.cat(patch_merger(...))`` would produce.
616 + """
617 + kh, kw = merge_kernel_size
618 + d_model = x.size(-1)
619 + return x.index_select(0, gather_idx).view(-1, kh * kw, d_model)
620 +
621 +
622 +def _build_merge_gather_idx(
623 + grid_hws_list: list[list[int]] | list[tuple[int, int]],
624 + merge_kernel_size: tuple[int, int],
625 +) -> np.ndarray:
626 + """Build the per-token gather indices used by :func:`patch_merger_packed`.
627 +
628 + For each item with grid (h, w) and merge kernel (kh, kw), the output
629 + block at position (nh, nw) gathers the kh*kw input tokens at rows
630 + (nh*kh + ih, nw*kw + iw) of that item, in (ih, iw) row-major order.
631 + """
632 + kh, kw = merge_kernel_size
633 + parts: list[np.ndarray] = []
634 + pre_sum = 0
635 + for h, w in grid_hws_list:
636 + new_h, new_w = h // kh, w // kw
637 + nh = np.arange(new_h, dtype=np.int64).reshape(new_h, 1, 1, 1)
638 + nw = np.arange(new_w, dtype=np.int64).reshape(1, new_w, 1, 1)
639 + ih = np.arange(kh, dtype=np.int64).reshape(1, 1, kh, 1)
640 + iw = np.arange(kw, dtype=np.int64).reshape(1, 1, 1, kw)
641 + # Linearized input row = (nh*kh + ih) * w + (nw*kw + iw), offset by
642 + # the per-item base ``pre_sum``. Output is laid out as
643 + # (new_h, new_w, kh, kw) which patch_merger flattens to
644 + # (new_h*new_w, kh*kw).
645 + idx = pre_sum + (nh * kh + ih) * w + (nw * kw + iw)
646 + parts.append(idx.reshape(-1))
647 + pre_sum += h * w
648 + if not parts:
649 + return np.zeros(0, dtype=np.int64)
650 + return np.concatenate(parts)
651 +
652 +
653 class MoonVitPretrainedModel(PreTrainedModel):
654 config_class = MoonViTConfig
655 model_type = "moonvit"
@@ -570,17 +690,126 @@ class MoonVitPretrainedModel(PreTrainedModel):
690 prefix=f"{prefix}.encoder",
691 )
692
693 + def prepare_encoder_metadata(
694 + self,
695 + grid_hws_list: list[list[int]] | list[tuple[int, int]],
696 + *,
697 + max_batch_size: int | None = None,
698 + max_seqlen_override: int | None = None,
699 + device: torch.device | None = None,
700 + ) -> dict[str, Any]:
701 + """Precompute every grid-dependent input the encoder needs.
702 +
703 + Used by the CUDA graph capture and replay paths to precompute
704 + every grid-dependent input outside the captured graph, so per-grid
705 + Python iteration and ``.tolist()`` round-trips are fine; the
706 + values are then copied into fixed-shape buffers for replay.
707 +
708 + Args:
709 + grid_hws_list: List of ``(h, w)`` patch-grid sizes per image.
710 + max_batch_size: When set, ``cu_seqlens`` is right-padded with
711 + its last value so the buffer covers up to this many
712 + sequences. Required at CUDA graph capture/replay so the
713 + buffer shape matches what was recorded; padding entries
714 + are zero-length sequences and are ignored by varlen
715 + attention.
716 + max_seqlen_override: Override the per-replay max sequence
717 + length scalar. At capture this must be a safe upper bound
718 + (worst case: a single image consuming the full token
719 + budget) because the value is baked into the captured
720 + graph.
721 + device: Device for the metadata tensors. Defaults to the
722 + model's parameter device.
723 + """
724 + if device is None:
725 + device = next(self.parameters()).device
726 +
727 + # Normalize to a list of plain Python int pairs so the helpers
728 + # below never need ``.tolist()`` on a tensor.
729 + grid_pairs: list[tuple[int, int]] = [(int(h), int(w)) for h, w in grid_hws_list]
730 +
731 + metadata: dict[str, Any] = {}
732 +
733 + pos_embeds = self.patch_embed.pos_emb.get_pos_embeds(grid_pairs)
734 + metadata["pos_embeds"] = pos_embeds.to(device=device)
735 +
736 + rope_freqs_cis = self.encoder.get_rope_freqs_cis(grid_pairs)
737 + metadata["rope_freqs_cis"] = rope_freqs_cis.to(device=device)
738 +
739 + grid_arr = np.array(grid_pairs, dtype=np.int64)
740 + seq_lens = (grid_arr[:, 0] * grid_arr[:, 1]).astype(np.int32)
741 + cu_seqlens_np = np.concatenate(
742 + [
743 + np.zeros(1, dtype=np.int32),
744 + seq_lens.cumsum(dtype=np.int32),
745 + ]
746 + )
747 +
748 + if max_batch_size is not None:
749 + num_seqs = len(cu_seqlens_np) - 1
750 + if num_seqs < max_batch_size:
751 + cu_seqlens_np = np.concatenate(
752 + [
753 + cu_seqlens_np,
754 + np.full(
755 + max_batch_size - num_seqs,
756 + cu_seqlens_np[-1],
757 + dtype=np.int32,
758 + ),
759 + ]
760 + )
761 + metadata["cu_seqlens"] = torch.from_numpy(cu_seqlens_np).to(device)
762 +
763 + if max_seqlen_override is not None:
764 + max_seqlen_val = int(max_seqlen_override)
765 + else:
766 + max_seqlen_val = int(seq_lens.max()) if len(seq_lens) > 0 else 0
767 + # Keep on CPU: attention wrappers may call .item() on this scalar
768 + # and we want that materialization to happen outside the captured
769 + # graph (the value is constant per capture anyway).
770 + metadata["max_seqlen"] = torch.tensor(max_seqlen_val, dtype=torch.int32)
771 +
772 + gather_idx_np = _build_merge_gather_idx(grid_pairs, self.merge_kernel_size)
773 + metadata["merge_gather_idx"] = torch.from_numpy(gather_idx_np).to(device)
774 +
775 + return metadata
776 +
777 def forward(
574 - self, pixel_values: torch.Tensor, grid_hw: torch.Tensor
575 - ) -> torch.Tensor:
778 + self,
779 + pixel_values: torch.Tensor,
780 + grid_hw: torch.Tensor,
781 + *,
782 + encoder_metadata: dict[str, Any] | None = None,
783 + ) -> torch.Tensor | list[torch.Tensor]:
784 """
785 Args:
786 pixel_values (torch.Tensor): The input pixel values.
787 grid_hw (torch.Tensor): The grid height and width.
580 -
581 - Returns:
582 - torch.Tensor: The output tokens.
788 + encoder_metadata: Optional precomputed metadata produced by
789 + :meth:`prepare_encoder_metadata`. When provided every
790 + ``.tolist()`` call in the forward path is skipped, the
791 + returned tensor is the packed
792 + ``(sum(new_h*new_w), kh*kw, hidden_size)`` form (suitable
793 + for CUDA graph capture/replay), and ``grid_hw`` is unused.
794 + When ``None`` the legacy path runs and returns a list of
795 + per-image tensors.
796 """
797 + if encoder_metadata is not None:
798 + hidden_states = self.patch_embed(
799 + pixel_values, pos_embeds=encoder_metadata["pos_embeds"]
800 + )
801 + hidden_states = self.encoder(
802 + hidden_states,
803 + cu_seqlens=encoder_metadata["cu_seqlens"],
804 + rope_freqs_cis=encoder_metadata["rope_freqs_cis"],
805 + max_seqlen=encoder_metadata["max_seqlen"],
806 + )
807 + return patch_merger_packed(
808 + hidden_states,
809 + encoder_metadata["merge_gather_idx"],
810 + merge_kernel_size=self.merge_kernel_size,
811 + )
812 +
813 hidden_states = self.patch_embed(pixel_values, grid_hw)
814 hidden_states = self.encoder(hidden_states, grid_hw)
815 hidden_states = patch_merger(