[MM][V2] Enable encoder-only ViT CUDA graph capture (#56922)

Signed-off-by: jiangkuaixue123 <jiangxiaozhou111@163.com> Co-authored-by: Codex <noreply@openai.com>

jiangkuaixue123 committed Sep 17, 2026 at 16:12 UTC de24e5190820cc6640f2d55295405f228fffbc41
7 files changed +362 -5
.buildkite/test_areas/disaggregated_mooncake.yaml
+10
@@ -40,7 +40,14 @@ steps:
40 - vllm/v1/worker/gpu_worker.py
41 - vllm/v1/worker/gpu/ec_connector.py
42 - vllm/v1/worker/gpu/model_runner.py
43 + - vllm/v1/worker/mm_encoder_model_runner.py
44 + - vllm/v1/worker/encoder_cudagraph.py
45 + - vllm/v1/worker/encoder_cudagraph_defs.py
46 + - vllm/v1/attention/ops/vit_attn_wrappers.py
47 + - vllm/v1/worker/gpu/model_states/
48 - vllm/v1/worker/gpu/mm/
49 + - tests/v1/cudagraph/test_encoder_cudagraph.py
50 + - tests/models/multimodal/generation/test_vit_cudagraph.py
51 - examples/disaggregated/disaggregated_encoder/
52 - tests/v1/ec_connector/
53 - requirements/kv_connectors.txt
@@ -49,6 +56,9 @@ steps:
56 PYTHONUNBUFFERED: "1"
57 VLLM_HOST_IP: "127.0.0.1"
58 VLLM_USE_V2_MODEL_RUNNER: "1"
59 + E_CUDAGRAPH_MM_ENCODER: "1"
60 + # FA padding NaNs are tracked separately in #57136.
61 + E_MM_ENCODER_ATTN_BACKEND: "TRITON_ATTN"
62 MOONCAKE_EC_PROTOCOL: "tcp"
63 USE_MM_PROMPTS: "1"
64 SKIP_BASELINE: "0"
tests/models/multimodal/generation/test_vit_cudagraph.py
+115
@@ -499,3 +499,118 @@ def test_vit_cudagraph_video(model_id, vllm_runner, video_assets):
499
500 # Ensure the output is a string
501 assert isinstance(output_text, str)
502 +
503 +
504 +def _check_eonly_encoder_outputs(worker, batches):
505 + """Compare real E-only replay with eager, retaining outputs across replay."""
506 + import torch
507 +
508 + from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
509 +
510 + runner = worker.model_runner
511 + assert isinstance(runner, MMEncoderModelRunner)
512 + manager = runner.model_state.encoder_runner.cudagraph_manager
513 + assert manager is not None and manager.is_captured()
514 + retained: list[tuple[torch.Tensor, torch.Tensor]] = []
515 + max_error = 0.0
516 + stats = []
517 + with torch.inference_mode():
518 + for batch in batches:
519 + kwargs = {key: value.to(runner.device) for key, value in batch.items()}
520 + actual = manager.execute(kwargs)
521 + expected = runner.model.embed_multimodal(**kwargs)
522 + assert len(actual) == len(expected)
523 + for output, reference in zip(actual, expected):
524 + assert torch.isfinite(output).all()
525 + # Two BF16 ulps at unit scale, fixed before model validation.
526 + torch.testing.assert_close(output, reference, rtol=0.016, atol=0.016)
527 + max_error = max(max_error, (output - reference).abs().max().item())
528 + for output, snapshot in retained:
529 + torch.testing.assert_close(output, snapshot, rtol=0, atol=0)
530 + retained.extend((output, output.clone()) for output in actual)
531 + stats.append(manager.get_cumulative_stats())
532 + assert stats[0]["graph_hits"] > 0
533 + assert stats[1]["graph_hits"] > stats[0]["graph_hits"]
534 + assert stats[2]["graph_misses"] > stats[1]["graph_misses"]
535 + assert stats[3]["graph_hits"] > stats[2]["graph_hits"]
536 + assert stats[4]["graph_hits"] > stats[3]["graph_hits"]
537 + assert stats[5]["graph_hits"] > stats[4]["graph_hits"]
538 + assert stats[6]["graph_hits"] > stats[5]["graph_hits"]
539 + return {"max_abs_error": max_error, "stats": stats}
540 +
541 +
542 +@pytest.mark.parametrize(
543 + "model_id",
544 + params_with_marks({key: MODEL_CONFIGS[key] for key in ("qwen3_vl", "qwen3_5")}),
545 +)
546 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA graphs")
547 +def test_eonly_vit_cudagraph_outputs(
548 + model_id, vllm_runner, image_assets, video_assets, monkeypatch
549 +):
550 + """Exercise startup capture, image/video replay, fallback and output ownership."""
551 + import numpy as np
552 + from PIL import Image
553 + from transformers import AutoProcessor
554 +
555 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
556 + monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
557 + config = MODEL_CONFIGS[model_id]
558 + token_budgets = [64, 256, 2048]
559 + processor = AutoProcessor.from_pretrained(config.model)
560 + first, second = [asset.pil_image for asset in image_assets]
561 + batches = [
562 + [first.resize((224, 224))],
563 + [second.resize((448, 224)), first.resize((224, 224))],
564 + [second.resize((1792, 1792))],
565 + [second.resize((224, 224))],
566 + [first.resize((1280, 720))],
567 + [first.resize((224, 224))],
568 + ]
569 + frames = sample_frames_from_video(video_assets[0].np_ndarrays, 2)
570 + video = np.stack(
571 + [np.asarray(Image.fromarray(frame).resize((224, 224))) for frame in frames]
572 + )
573 + with vllm_runner(
574 + config.model,
575 + dtype=config.dtype,
576 + mm_encoder_only=True,
577 + # FA padding NaNs are tracked separately in #57136.
578 + mm_encoder_attn_backend="FLASHINFER",
579 + enable_prefix_caching=False,
580 + max_model_len=4096,
581 + max_num_seqs=2,
582 + limit_mm_per_prompt={"image": 2, "video": 1},
583 + compilation_config={
584 + "cudagraph_mode": "NONE",
585 + "cudagraph_mm_encoder": True,
586 + "encoder_cudagraph_token_budgets": token_budgets,
587 + "encoder_cudagraph_max_vision_items_per_batch": 2,
588 + "encoder_cudagraph_max_frames_per_batch": 2,
589 + },
590 + ) as model:
591 + mm_config = model.llm.llm_engine.vllm_config.model_config.multimodal_config
592 + processor_kwargs = mm_config.merge_mm_processor_kwargs({})
593 + inputs = [
594 + dict(
595 + processor.image_processor(
596 + images=images, return_tensors="pt", **processor_kwargs
597 + )
598 + )
599 + for images in batches
600 + ]
601 + inputs.append(
602 + dict(
603 + processor.video_processor(
604 + videos=[video],
605 + do_sample_frames=False,
606 + return_tensors="pt",
607 + **processor_kwargs,
608 + )
609 + )
610 + )
611 + results = model.llm.collective_rpc(
612 + partial(_check_eonly_encoder_outputs, batches=inputs)
613 + )
614 + for result in results:
615 + assert result["stats"][0]["token_budgets"] == token_budgets
616 + print(f"E-only {model_id}: {results}")
tests/v1/cudagraph/test_encoder_cudagraph.py
+95
@@ -13,7 +13,9 @@ Test organization:
13 """
14
15 from collections.abc import Hashable
16 +from types import SimpleNamespace
17 from typing import Any
18 +from unittest.mock import MagicMock, patch
19
20 import pytest
21 import torch
@@ -575,6 +577,99 @@ class TestEncoderCudaGraphCaptureReplay:
577 assert out.shape == (4, _HIDDEN)
578
579
580 +# ---------------------------------------------------------------------------
581 +# E-only capture and output lifecycle
582 +# ---------------------------------------------------------------------------
583 +
584 +
585 +@pytest.mark.skipif(
586 + not current_platform.is_cuda_alike(), reason="Skip if not cuda or rocm"
587 +)
588 +@pytest.mark.usefixtures("dist_init", "workspace_init")
589 +@pytest.mark.parametrize("profile_only", [False, True])
590 +@torch.inference_mode()
591 +def test_eonly_capture_preserves_outputs_across_replay_and_fallback(profile_only):
592 + """The E-only entry captures only the encoder and preserves cached outputs."""
593 + from vllm.distributed.ec_transfer.ec_connector.base import (
594 + ECConnectorBase,
595 + ECConnectorMetadata,
596 + )
597 + from vllm.v1.worker.gpu.ec_connector import ActiveECConnector
598 + from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
599 + from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
600 + from vllm.v1.worker.workspace import lock_workspace
601 +
602 + device = torch.device("cuda:0")
603 + dtype = torch.float16
604 + model = SimpleMockViTModel().to(device).half()
605 + manager = _make_manager_for_gpu(model, _BUDGETS, _MAX_BATCH, device, dtype)
606 + encoder = object.__new__(EncoderRunner)
607 + encoder.device = device
608 + encoder.cudagraph_manager = manager
609 + runner = object.__new__(MMEncoderModelRunner)
610 + runner.model_state = SimpleNamespace(encoder_runner=encoder)
611 + # No decoder manager is installed: capture must be encoder-only.
612 + with patch(
613 + "vllm.v1.worker.mm_encoder_model_runner.lock_workspace", wraps=lock_workspace
614 + ) as lock:
615 + runner.capture_model(profile_only=profile_only)
616 + assert lock.call_count == int(not profile_only)
617 + assert len(manager.budget_graphs["default"]) == len(_BUDGETS)
618 +
619 + cache: dict[str, torch.Tensor] = {}
620 + pending_sends: dict[str, torch.Tensor] = {}
621 + connector = MagicMock(spec=ECConnectorBase)
622 + connector.is_producer = True
623 + connector.is_consumer = False
624 + connector.get_finished.return_value = (None, None)
625 + connector.save_caches.side_effect = lambda *, encoder_cache, mm_hash: (
626 + pending_sends.update({mm_hash: encoder_cache[mm_hash]})
627 + )
628 + with patch(
629 + "vllm.v1.worker.gpu.ec_connector.get_ec_transfer", return_value=connector
630 + ):
631 + ec = ActiveECConnector(SimpleNamespace(), cache)
632 + scheduled = SimpleNamespace(
633 + ec_connector_metadata=ECConnectorMetadata(), finished_req_ids=frozenset()
634 + )
635 + saved_outputs: list[tuple[torch.Tensor, torch.Tensor]] = []
636 + # Mixed sizes exercise packing order; 64 is the boundary, 81 falls back.
637 + for grids in ([[1, 8, 8], [1, 4, 4]], [[1, 16, 16]], [[1, 18, 18]], [[1, 4, 4]]):
638 + inputs = _make_mm_kwargs(grids, device, dtype)
639 + expected = model.encoder_eager_forward(inputs).split(
640 + [t * (h // 2) * (w // 2) for t, h, w in grids]
641 + )
642 + with ec.maybe_get_output(scheduled) as ec_output:
643 + outputs = manager.execute(inputs)
644 + assert outputs is not None
645 + cache[str(len(cache))] = outputs[0]
646 + assert ec_output.finished_sending is None
647 + assert outputs is not None
648 + for actual, eager in zip(outputs, expected):
649 + torch.testing.assert_close(actual, eager)
650 + for previous, snapshot in saved_outputs:
651 + torch.testing.assert_close(previous, snapshot, rtol=0, atol=0)
652 + saved_outputs.extend((output, output.clone()) for output in outputs)
653 + assert manager.graph_hits == 4
654 + assert manager.graph_misses == 1
655 + assert pending_sends.keys() == cache.keys()
656 + connector.get_finished.return_value = (set(cache), None)
657 + with ec.maybe_get_output(scheduled) as ec_output:
658 + pass
659 + assert ec_output.finished_sending == set(cache)
660 +
661 +
662 +def test_eonly_without_encoder_graph_skips_capture():
663 + from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
664 + from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
665 +
666 + encoder = object.__new__(EncoderRunner)
667 + encoder.cudagraph_manager = None
668 + runner = object.__new__(MMEncoderModelRunner)
669 + runner.model_state = SimpleNamespace(encoder_runner=encoder)
670 + assert runner.capture_model() == 0
671 +
672 +
673 # ---------------------------------------------------------------------------
674 # SimpleMockViTVideoModel — extends SimpleMockViTModel with video support
675 # ---------------------------------------------------------------------------
tests/v1/ec_connector/integration/run_epd_mooncake_ec_full_pipeline.sh
+35 -1
@@ -20,6 +20,8 @@
20 # SKIP_BASELINE set to 1 to reuse existing BASELINE_FILE
21 # CONCURRENCY / REPEAT concurrent requests / rounds (defaults 3 / 2)
22 # MAX_MODEL_LEN context length (default 16384)
23 +# E_CUDAGRAPH_MM_ENCODER enable encoder CUDA graphs (default 0)
24 +# E_MM_ENCODER_ATTN_BACKEND optional encoder attention backend override
25
26 set -euo pipefail
27
@@ -35,6 +37,15 @@ if [[ "$USE_MM_PROMPTS" == "1" ]]; then
37 TEST_ARGS+=(--use_mm_prompts --mm_smoke_test)
38 fi
39 MAX_MODEL_LEN="${MAX_MODEL_LEN:-16384}"
40 +E_ATTN_ARGS=()
41 +if [[ -n "${E_MM_ENCODER_ATTN_BACKEND:-}" ]]; then
42 + E_ATTN_ARGS+=(--mm-encoder-attn-backend "$E_MM_ENCODER_ATTN_BACKEND")
43 +fi
44 +case "${E_CUDAGRAPH_MM_ENCODER:-0}" in
45 + 0) E_COMPILATION_CONFIG='{"cudagraph_mm_encoder":false}' ;;
46 + 1) E_COMPILATION_CONFIG='{"cudagraph_mm_encoder":true}' ;;
47 + *) echo "E_CUDAGRAPH_MM_ENCODER must be 0 or 1"; exit 1 ;;
48 +esac
49
50 GPU_SINGLE="${GPU_SINGLE:-0}"
51 GPU_E="${GPU_E:-0}"
@@ -203,7 +214,7 @@ run_epd_mooncake() {
214 cleanup_instances
215
216 echo "Starting ENCODER on GPU $GPU_E port $ENCODE_PORT"
206 - CUDA_VISIBLE_DEVICES="$GPU_E" "${VLLM_SERVE[@]}" "$MODEL" \
217 + VLLM_SERVER_DEV_MODE=1 CUDA_VISIBLE_DEVICES="$GPU_E" "${VLLM_SERVE[@]}" "$MODEL" \
218 --port "$ENCODE_PORT" \
219 --gpu-memory-utilization 0.35 \
220 --mm-tensor-ipc torch_shm \
@@ -215,6 +226,9 @@ run_epd_mooncake() {
226 --limit-mm-per-prompt '{"image":2,"video":0}' \
227 --allowed-local-media-path "${GIT_ROOT}/tests/v1/ec_connector/integration" \
228 --ec-transfer-config "$ENC_EC_JSON" \
229 + --compilation-config "$E_COMPILATION_CONFIG" \
230 + "${E_ATTN_ARGS[@]}" \
231 + --worker-extension-cls tests.v1.ec_connector.integration.test_epd_correctness.EncoderGraphTestWorkerExtension \
232 >"${LOG_PATH}/mooncake_epd_encoder.log" 2>&1 &
233 local ENCODER_PID=$!
234 PIDS+=("$ENCODER_PID")
@@ -266,6 +280,26 @@ run_epd_mooncake() {
280 --baseline_file "$BASELINE_FILE" \
281 "${TEST_ARGS[@]}"
282
283 + curl -fsS "http://localhost:${ENCODE_PORT}/collective_rpc" \
284 + -H 'Content-Type: application/json' \
285 + -d '{"method":"encoder_graph_stats"}' \
286 + >"${LOG_PATH}/encoder_graph_stats.json"
287 + "$PYTHON_BIN" - "${LOG_PATH}/encoder_graph_stats.json" "${E_CUDAGRAPH_MM_ENCODER:-0}" <<'PY'
288 +import json
289 +import sys
290 +
291 +with open(sys.argv[1]) as f:
292 + results = json.load(f)["results"]
293 +assert results, "No encoder workers returned graph statistics"
294 +for stats in results:
295 + if sys.argv[2] == "1":
296 + assert stats["is_captured"], stats
297 + assert stats["graph_hits"] > 0, stats
298 + else:
299 + assert stats["graph_hits"] == 0, stats
300 +print("Encoder graph statistics:", results)
301 +PY
302 +
303 cleanup_instances
304 }
305
tests/v1/ec_connector/integration/test_epd_correctness.py
+21
@@ -26,6 +26,7 @@ import json
26 import os
27 import time
28 from concurrent.futures import ThreadPoolExecutor
29 +from typing import TYPE_CHECKING
30
31 import openai
32 import requests
@@ -33,6 +34,26 @@ import requests
34 from vllm.assets.image import ImageAsset
35 from vllm.multimodal.utils import encode_image_url
36
37 +if TYPE_CHECKING:
38 + from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
39 +
40 +
41 +class EncoderGraphTestWorkerExtension:
42 + """Expose encoder replay evidence through the existing test RPC endpoint."""
43 +
44 + model_runner: "MMEncoderModelRunner"
45 +
46 + def encoder_graph_stats(self):
47 + from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
48 +
49 + runner = self.model_runner
50 + assert isinstance(runner, MMEncoderModelRunner)
51 + manager = runner.model_state.encoder_runner.cudagraph_manager
52 + if manager is None:
53 + return {"is_captured": False, "graph_hits": 0, "graph_misses": 0}
54 + return {"is_captured": manager.is_captured(), **manager.get_cumulative_stats()}
55 +
56 +
57 MAX_OUTPUT_LEN = 256
58
59 # Sample prompts with multimodal content
tests/v1/worker/test_encoder_runner.py
+54 -1
@@ -9,12 +9,15 @@ and tolerated (token-embedding fallback) when it is not, while a miss within
9 the processed range still fails loudly.
10 """
11
12 -from unittest.mock import MagicMock
12 +from types import SimpleNamespace
13 +from unittest.mock import MagicMock, patch
14
15 import numpy as np
16 import pytest
17 import torch
18
19 +from vllm.config.compilation import CUDAGraphMode
20 +from vllm.model_executor.models.interfaces import SupportsEncoderCudaGraph
21 from vllm.multimodal.inputs import (
22 MultiModalFeatureSpec,
23 MultiModalFieldElem,
@@ -31,6 +34,56 @@ pytestmark = pytest.mark.cpu_test
34 HIDDEN = 4
35
36
37 +@pytest.mark.parametrize(
38 + "enabled,enforce_eager,supported,expected",
39 + [
40 + (True, False, True, True),
41 + (False, False, True, False),
42 + (True, True, True, False),
43 + (True, False, False, False),
44 + ],
45 +)
46 +def test_encoder_graph_configuration(enabled, enforce_eager, supported, expected):
47 + """Encoder graph eligibility is independent of decoder graph mode."""
48 + config = MagicMock()
49 + config.model_config.enforce_eager = enforce_eager
50 + config.model_config.dtype = torch.float32
51 + config.model_config.get_inputs_embeds_size.return_value = HIDDEN
52 + config.scheduler_config.max_num_batched_tokens = 8
53 + config.compilation_config.cudagraph_mode = CUDAGraphMode.NONE
54 + config.compilation_config.cudagraph_mm_encoder = enabled
55 + model = MagicMock(spec=SupportsEncoderCudaGraph) if supported else torch.nn.Module()
56 + state = _model_state(EncoderCache())
57 + with patch("vllm.v1.worker.gpu.model_states.interface.EncoderCudaGraphManager"):
58 + ModelState.__init__(state, config, model, state.encoder_cache, state.device)
59 + assert state.encoder_runner.has_cudagraph() is expected
60 +
61 +
62 +def test_encoder_graph_unsupported_modality_uses_eager_output():
63 + """An image graph must not intercept another modality's encoder output."""
64 + from vllm.v1.worker.encoder_cudagraph import EncoderCudaGraphManager
65 +
66 + runner = _make_runner([], [])
67 + output = torch.ones(2, HIDDEN)
68 + runner.model = MagicMock()
69 + runner.model.embed_multimodal.return_value = [output]
70 + manager = MagicMock(spec=EncoderCudaGraphManager)
71 + manager.config = SimpleNamespace(modalities=["image"])
72 + manager.is_captured.return_value = True
73 + manager.supports_modality.side_effect = lambda modality: (
74 + EncoderCudaGraphManager.supports_modality(manager, modality)
75 + )
76 + runner.cudagraph_manager = manager
77 + with patch(
78 + "vllm.v1.worker.gpu.mm.encoder_runner.group_and_batch_mm_kwargs",
79 + return_value=[("audio", 1, {"audio_values": torch.zeros(2)})],
80 + ):
81 + result = runner.execute_mm_encoder([])
82 + assert len(result) == 1
83 + assert result[0] is output
84 + manager.execute.assert_not_called()
85 +
86 +
87 def _model_state(cache: EncoderCache) -> MagicMock:
88 """A mock ModelState backed by a real EncoderCache."""
89 state = MagicMock()
vllm/v1/worker/mm_encoder_model_runner.py
+32 -3
@@ -4,9 +4,10 @@
4
5 An encoder-only instance -- `--mm-encoder-only`, or the producer side of
6 encoder-cache disaggregation -- encodes the multi-modal items and publishes the
7 -embeddings. It runs no language model: no KV cache, no sampler, no CUDA graphs.
7 +embeddings. It runs no language model: no KV cache, sampler, or decoder graphs.
8 """
9
10 +import time
11 from collections.abc import Iterator
12 from contextlib import contextmanager
13 from typing import TYPE_CHECKING, Any
@@ -15,7 +16,9 @@ import torch
16
17 from vllm.config import VllmConfig
18 from vllm.config.compilation import CUDAGraphMode
19 +from vllm.logger import init_logger
20 from vllm.sequence import IntermediateTensors
21 +from vllm.utils.gc_utils import freeze_gc_for_cudagraph_capture
22 from vllm.v1.kv_cache_interface import KVCacheSpec
23 from vllm.v1.outputs import (
24 ModelRunnerOutput,
@@ -24,10 +27,13 @@ from vllm.v1.outputs import (
27 from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
28 from vllm.v1.worker.gpu.mm.lora import set_active_mm_loras
29 from vllm.v1.worker.gpu.model_runner import GPUModelRunner
30 +from vllm.v1.worker.workspace import lock_workspace
31
32 if TYPE_CHECKING:
33 from vllm.v1.core.sched.output import SchedulerOutput
34
35 +logger = init_logger(__name__)
36 +
37
38 class MMEncoderModelRunner(GPUModelRunner):
39 """Encoder-only variant of the V2 GPU model runner."""
@@ -67,8 +73,31 @@ class MMEncoderModelRunner(GPUModelRunner):
73 def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]:
74 return {}
75
76 + @torch.inference_mode()
77 def capture_model(self, *, profile_only: bool = False) -> int:
71 - return 0
78 + encoder_runner = self.model_state.encoder_runner
79 + if not encoder_runner.has_cudagraph():
80 + return 0
81 +
82 + start_time = time.perf_counter()
83 + with freeze_gc_for_cudagraph_capture():
84 + torch.accelerator.empty_cache()
85 + start_free_memory = torch.accelerator.get_memory_info()[0]
86 + encoder_runner.capture()
87 + graph_memory = start_free_memory - torch.accelerator.get_memory_info()[0]
88 +
89 + if not profile_only:
90 + lock_workspace()
91 +
92 + manager = encoder_runner.cudagraph_manager
93 + assert manager is not None
94 + logger.info(
95 + "Encoder graph capturing finished in %.0f secs, took %.2f GiB (%d graphs)",
96 + time.perf_counter() - start_time,
97 + graph_memory / (1 << 30),
98 + manager.get_num_graphs_to_capture(),
99 + )
100 + return graph_memory
101
102 def needs_cudagraph_capture(self) -> bool:
103 return False
@@ -114,7 +143,7 @@ class MMEncoderModelRunner(GPUModelRunner):
143
144 batch_req_state, _ = self.gather_batch_req_state(scheduler_output, False)
145 assert batch_req_state is not None
117 - # No CUDA graph, and no DP peer to agree a padded shape with.
146 + # No decoder graph or DP padding; encoder graphs run independently.
147 batch_desc = BatchExecutionDescriptor(
148 cg_mode=CUDAGraphMode.NONE,
149 num_tokens=batch_req_state.num_tokens,