[MM][CG] Support ViT full CUDA graph for Kimi-K2.5 (#50929)
Signed-off-by: lkchen <anthropic@lkchen.net> Co-authored-by: lkchen <anthropic@lkchen.net>
Linkun committed
Aug 4, 2026 at 10:15 UTC
7cab4368f22271dd562e3cef1d59232ebf07e8fb
1 file changed
+247
-1
vllm/model_executor/models/kimi_k25.py
+247
-1
@@ -8,7 +8,7 @@ Kimi-K2.5 extends Kimi-K2 with vision support.
8
9
from collections.abc import Iterable, Mapping, Sequence
10
from dataclasses import dataclass
11
-from typing import Annotated, Any, Literal
11
+from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
12
13
import torch
14
from torch import nn
@@ -25,10 +25,19 @@ from vllm.model_executor.layers.quantization.compressed_tensors import (
25
from vllm.model_executor.models.interfaces import (
26
SupportsEagle,
27
SupportsEagle3,
28
+ SupportsEncoderCudaGraph,
29
SupportsMultiModal,
30
SupportsPP,
31
SupportsQuant,
32
)
33
+
34
+if TYPE_CHECKING:
35
+ from vllm.v1.worker.encoder_cudagraph_defs import (
36
+ EncoderCudaGraphCaptureInputs,
37
+ EncoderCudaGraphConfig,
38
+ EncoderCudaGraphReplayBuffers,
39
+ EncoderItemSpec,
40
+ )
41
from vllm.model_executor.models.kimi_k25_vit import (
42
KimiK25MultiModalProjector,
43
MoonViT3dPretrainedModel,
@@ -294,6 +303,7 @@ class KimiK25ForConditionalGeneration(
303
SupportsQuant,
304
SupportsEagle,
305
SupportsEagle3,
306
+ SupportsEncoderCudaGraph,
307
):
308
"""Kimi-K2.5 model for conditional generation.
309
@@ -303,6 +313,7 @@ class KimiK25ForConditionalGeneration(
313
"""
314
315
supports_encoder_tp_data = True
316
+ supports_encoder_cudagraph: ClassVar[Literal[True]] = True
317
318
hf_to_vllm_mapper = WeightsMapper(
319
orig_to_new_prefix={
@@ -470,6 +481,241 @@ class KimiK25ForConditionalGeneration(
481
def get_eagle3_aux_hidden_state_layers(self) -> tuple[int, ...]:
482
return self.language_model.get_eagle3_aux_hidden_state_layers()
483
484
+ # ------------------------------------------------------------------ #
485
+ # SupportsEncoderCudaGraph protocol #
486
+ # Image-only (t == 1). Video chunks (t > 1) fall back to eager. #
487
+ # ------------------------------------------------------------------ #
488
+
489
+ @property
490
+ def _encoder_cudagraph_pad_totals(self) -> dict[int, int]:
491
+ """Row count of each captured buffer set, keyed by cu_seqlens ptr."""
492
+ totals = self.__dict__.get("_encoder_cg_pad_totals")
493
+ if totals is None:
494
+ totals = {}
495
+ self.__dict__["_encoder_cg_pad_totals"] = totals
496
+ return totals
497
+
498
+ def get_encoder_cudagraph_config(self) -> "EncoderCudaGraphConfig":
499
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphConfig
500
+
501
+ pad_totals = self._encoder_cudagraph_pad_totals
502
+
503
+ def pad_cu_seqlens(dst: torch.Tensor, src: torch.Tensor) -> None:
504
+ # Varlen attention requires cu_seqlens[-1] to equal the number of
505
+ # rows actually passed in. The captured buffers are sized for the
506
+ # full token budget, so a smaller real batch has to be completed
507
+ # with one trailing padding sequence; declaring fewer rows than the
508
+ # buffer holds is undefined behaviour and returns NaN on FlashAttn.
509
+ total = pad_totals.get(dst.data_ptr())
510
+ n = min(src.shape[0], dst.shape[0])
511
+ dst[:n].copy_(src[:n])
512
+ dst[n:] = total if total is not None else src[-1]
513
+
514
+ return EncoderCudaGraphConfig(
515
+ modalities=["vision_chunk"],
516
+ buffer_keys=[
517
+ "pixel_values",
518
+ "pos_embeds",
519
+ "rope_freqs_cis",
520
+ "cu_seqlens",
521
+ "max_seqlen",
522
+ "merge_gather_idx",
523
+ ],
524
+ out_hidden_size=self.config.text_config.hidden_size,
525
+ padding_logics={"cu_seqlens": pad_cu_seqlens},
526
+ )
527
+
528
+ def get_encoder_cudagraph_budget_range(
529
+ self,
530
+ vllm_config: VllmConfig,
531
+ ) -> tuple[int, int]:
532
+ # Min: 64 output tokens (e.g. ~128×128 image with patch 14 + merge 2×2).
533
+ min_budget = 64
534
+ max_budget = min(
535
+ vllm_config.scheduler_config.max_num_batched_tokens,
536
+ vllm_config.model_config.max_model_len,
537
+ )
538
+ return (min_budget, max_budget)
539
+
540
+ def _get_grid_thw_list(
541
+ self,
542
+ mm_kwargs: dict[str, Any],
543
+ ) -> list[list[int]]:
544
+ grid_thws = mm_kwargs["grid_thws"]
545
+ if isinstance(grid_thws, torch.Tensor):
546
+ return [[int(x) for x in row] for row in grid_thws.tolist()]
547
+ return [[int(x) for x in row] for row in grid_thws]
548
+
549
+ def get_encoder_cudagraph_item_specs(
550
+ self,
551
+ mm_kwargs: dict[str, Any],
552
+ ) -> list["EncoderItemSpec"]:
553
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
554
+
555
+ kh, kw = self.vision_tower.merge_kernel_size
556
+ specs = []
557
+ for t, h, w in self._get_grid_thw_list(mm_kwargs):
558
+ if t != 1:
559
+ # Video chunks not supported in encoder CUDA graph;
560
+ # sentinel forces eager fallback via the manager.
561
+ specs.append(EncoderItemSpec(input_size=t * h * w, output_tokens=2**30))
562
+ else:
563
+ specs.append(
564
+ EncoderItemSpec(
565
+ input_size=h * w,
566
+ output_tokens=(h // kh) * (w // kw),
567
+ )
568
+ )
569
+ return specs
570
+
571
+ def select_encoder_cudagraph_items(
572
+ self,
573
+ mm_kwargs: dict[str, Any],
574
+ indices: list[int],
575
+ ) -> dict[str, Any]:
576
+ grid_thw_list = self._get_grid_thw_list(mm_kwargs)
577
+ pixel_values = mm_kwargs["pixel_values"]
578
+
579
+ if len(indices) == 0:
580
+ return {
581
+ "pixel_values": pixel_values[:0],
582
+ "grid_thws": pixel_values.new_zeros((0, 3), dtype=torch.long),
583
+ }
584
+
585
+ patch_counts = [t * h * w for t, h, w in grid_thw_list]
586
+ cum = [0]
587
+ for pc in patch_counts:
588
+ cum.append(cum[-1] + pc)
589
+
590
+ selected_pv = torch.cat(
591
+ [pixel_values[cum[i] : cum[i + 1]] for i in indices], dim=0
592
+ )
593
+ selected_grid = torch.tensor(
594
+ [grid_thw_list[i] for i in indices],
595
+ dtype=torch.long,
596
+ device=pixel_values.device,
597
+ )
598
+ return {"pixel_values": selected_pv, "grid_thws": selected_grid}
599
+
600
+ def prepare_encoder_cudagraph_capture_inputs(
601
+ self,
602
+ token_budget: int,
603
+ max_batch_size: int,
604
+ max_frames_per_batch: int,
605
+ device: torch.device,
606
+ dtype: torch.dtype,
607
+ path: str = "default",
608
+ ) -> "EncoderCudaGraphCaptureInputs":
609
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphCaptureInputs
610
+
611
+ kh, kw = self.vision_tower.merge_kernel_size
612
+ # Output tokens per item in the dummy grid (ceiling so total >= budget).
613
+ per_item_out = (token_budget + max_batch_size - 1) // max_batch_size
614
+
615
+ # Fit within RoPE precomputed max dimensions.
616
+ rope = self.vision_tower.encoder.rope_2d
617
+ max_wo = rope.max_width // kw
618
+ wo = min(per_item_out, max_wo)
619
+ ho = (per_item_out + wo - 1) // wo
620
+ assert ho * kh <= rope.max_height, (
621
+ f"per_item_out={per_item_out} exceeds RoPE grid capacity "
622
+ f"(max {(rope.max_height // kh) * (rope.max_width // kw)} tokens)"
623
+ )
624
+
625
+ grid_thw_list = [[1, ho * kh, wo * kw] for _ in range(max_batch_size)]
626
+
627
+ ps = self.vision_tower.patch_size
628
+ if isinstance(ps, int):
629
+ ps = (ps, ps)
630
+ total_patches = max_batch_size * ho * kh * wo * kw
631
+ dummy_pixel_values = torch.zeros(
632
+ total_patches, 3, ps[0], ps[1], device=device, dtype=dtype
633
+ )
634
+
635
+ # max_seqlen must cover the worst case: one item consuming the full
636
+ # budget, i.e. token_budget * kh * kw patches.
637
+ # max_batch_size + 1 leaves a spare cu_seqlens slot so replay can append
638
+ # a padding sequence covering rows the real batch does not fill.
639
+ metadata = self.vision_tower.prepare_encoder_cudagraph_metadata(
640
+ grid_thw_list,
641
+ max_batch_size=max_batch_size + 1,
642
+ max_seqlen_override=token_budget * kh * kw,
643
+ device=device,
644
+ )
645
+
646
+ values: dict[str, torch.Tensor] = {"pixel_values": dummy_pixel_values}
647
+ values.update({k: v for k, v in metadata.items() if v is not None})
648
+
649
+ cu_seqlens = values.get("cu_seqlens")
650
+ if cu_seqlens is not None:
651
+ self._encoder_cudagraph_pad_totals[cu_seqlens.data_ptr()] = total_patches
652
+
653
+ return EncoderCudaGraphCaptureInputs(values=values)
654
+
655
+ def prepare_encoder_cudagraph_replay_buffers(
656
+ self,
657
+ mm_kwargs: dict[str, Any],
658
+ max_batch_size: int,
659
+ max_frames_per_batch: int,
660
+ path: str = "default",
661
+ ) -> "EncoderCudaGraphReplayBuffers":
662
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphReplayBuffers
663
+
664
+ grid_thw_list = self._get_grid_thw_list(mm_kwargs)
665
+ pixel_values = mm_kwargs["pixel_values"]
666
+
667
+ # Unpadded: pad_cu_seqlens completes the tail with the padding sequence
668
+ # so cu_seqlens[-1] matches the captured buffer's row count.
669
+ metadata = self.vision_tower.prepare_encoder_cudagraph_metadata(
670
+ grid_thw_list,
671
+ max_batch_size=None,
672
+ device=pixel_values.device,
673
+ )
674
+
675
+ values: dict[str, torch.Tensor | None] = {"pixel_values": pixel_values}
676
+ values.update(metadata)
677
+ return EncoderCudaGraphReplayBuffers(values=values)
678
+
679
+ def encoder_cudagraph_forward(
680
+ self,
681
+ inputs: dict[str, torch.Tensor],
682
+ path: str = "default",
683
+ ) -> torch.Tensor:
684
+ pixel_values = inputs.pop("pixel_values")
685
+ # Remaining keys (pos_embeds, rope_freqs_cis, cu_seqlens, max_seqlen,
686
+ # merge_gather_idx, sequence_lengths) are consumed as encoder_metadata.
687
+ encoder_metadata = inputs
688
+
689
+ # Fast path: uses precomputed pos_embeds + merge_gather_idx.
690
+ vt_output = self.vision_tower(
691
+ pixel_values, grid_thws=None, encoder_metadata=encoder_metadata
692
+ )
693
+ # vt_output: (total_output_tokens, kh*kw, vit_hidden_dim)
694
+
695
+ proj_dtype = next(self.mm_projector.parameters()).dtype
696
+ if vt_output.dtype != proj_dtype:
697
+ vt_output = vt_output.to(proj_dtype)
698
+
699
+ projected = self.mm_projector(vt_output)
700
+ return projected.view(-1, self.config.text_config.hidden_size)
701
+
702
+ def encoder_eager_forward(
703
+ self,
704
+ mm_kwargs: dict[str, Any],
705
+ path: str = "default",
706
+ ) -> torch.Tensor:
707
+ media_input = self._parse_and_validate_media_input(**mm_kwargs)
708
+ if media_input is None:
709
+ proj_dtype = next(self.mm_projector.parameters()).dtype
710
+ return torch.zeros(
711
+ 0,
712
+ self.config.text_config.hidden_size,
713
+ device=self.device,
714
+ dtype=proj_dtype,
715
+ )
716
+ embeddings = self._process_media_input(media_input)
717
+ return torch.cat(embeddings, dim=0)
718
+
719
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
720
loader = AutoWeightsLoader(self)
721
return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)