[2/2][Model Runner V2] FULL CUDA graph capture for microbatched steps (DBO) (#51700)

Signed-off-by: specture724 <specture724@gmail.com> Co-authored-by: Codex <noreply@openai.com>

Anjie Hou committed Sep 14, 2026 at 13:42 UTC 73d2a8cf86c46e21af723ffd34ce0d56668fdd14
6 files changed +407 -80
tests/v1/worker/test_gpu_ubatch_slicing.py
+159 -4
@@ -20,13 +20,22 @@ import pytest
20 import torch
21
22 from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
23 -from vllm.config import CUDAGraphMode, ModelConfig, ParallelConfig, VllmConfig
23 +from vllm.config import (
24 + CompilationConfig,
25 + CUDAGraphMode,
26 + ModelConfig,
27 + ParallelConfig,
28 + VllmConfig,
29 +)
30 from vllm.forward_context import create_forward_context
31 from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
32 from vllm.v1.kv_cache_interface import KVCacheConfig
33 from vllm.v1.worker.gpu import cp_utils as gpu_cp_utils
28 -from vllm.v1.worker.gpu import dp_utils
29 -from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
34 +from vllm.v1.worker.gpu import cudagraph_utils, dp_utils
35 +from vllm.v1.worker.gpu.cudagraph_utils import (
36 + BatchExecutionDescriptor,
37 + CudaGraphManager,
38 +)
39 from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
40 from vllm.v1.worker.gpu.model_states.interface import ModelState
41 from vllm.v1.worker.gpu.ubatch_utils import (
@@ -272,6 +281,7 @@ def _sync_dp(
281 num_tokens_per_rank: list[int],
282 uniform_token_count_per_rank: list[int] | None = None,
283 allow_ubatching: bool = True,
284 + cudagraph_manager: Any = None,
285 ) -> tuple[BatchExecutionDescriptor, dp_utils.DPSyncState | None]:
286 """Run the DP handshake with the all-reduce stubbed out.
287
@@ -295,7 +305,7 @@ def _sync_dp(
305 patch.object(dp_utils, "get_dp_group", lambda: SimpleNamespace(cpu_group=None)),
306 ):
307 return dp_utils.sync_cudagraph_and_dp_padding(
298 - cudagraph_manager=None,
308 + cudagraph_manager=cudagraph_manager,
309 desired_batch_desc=BatchExecutionDescriptor(
310 cg_mode=CUDAGraphMode.NONE,
311 num_tokens=num_tokens_per_rank[0],
@@ -718,3 +728,148 @@ def test_slicing_drops_stale_dcp_metadata_when_dcp_is_off():
728 ]
729
730 assert all(ubatch.dcp_local_seq_lens is None for ubatch in ubatches)
731 +
732 +
733 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
734 +def test_capturable_run_replays_as_a_cudagraph():
735 + """Microbatched graph replay reads updated persistent input buffers."""
736 + vllm_config = VllmConfig(
737 + model_config=ModelConfig(model="facebook/opt-125m", dtype="float16", seed=0),
738 + parallel_config=ParallelConfig(
739 + enable_dbo=True, all2all_backend="deepep_low_latency"
740 + ),
741 + )
742 + device = torch.device("cuda:0")
743 + runner = _make_execution_runner(vllm_config)
744 +
745 + num_tokens = 16
746 + input_ids = torch.arange(num_tokens, device=device)
747 + positions = torch.arange(num_tokens, device=device)
748 + model_inputs = {
749 + "input_ids": input_ids,
750 + "positions": positions,
751 + "inputs_embeds": None,
752 + "intermediate_tensors": None,
753 + }
754 + ubatch_state = _make_ubatch_state(
755 + vllm_config,
756 + [
757 + UBatchSlice(slice(0, 4), slice(0, 8)),
758 + UBatchSlice(slice(4, 8), slice(8, 16)),
759 + ],
760 + )
761 +
762 + model = _YieldingModel([])
763 + graph = torch.cuda.CUDAGraph()
764 + finish = runner.begin_capturable_run(
765 + model, model_inputs, ubatch_state, for_capture=True
766 + )
767 + with torch.cuda.graph(graph, stream=runner.capture_stream):
768 + captured = finish()
769 +
770 + input_ids.fill_(3)
771 + positions.fill_(5)
772 + graph.replay()
773 + torch.accelerator.synchronize()
774 +
775 + expected = torch.full(
776 + (num_tokens, 1), 3 * 2 + 5, dtype=torch.float32, device=device
777 + )
778 + torch.testing.assert_close(captured, expected)
779 +
780 +
781 +def _request_slices(input_batch: InputBatch) -> list[slice]:
782 + return [s.request_slice for s in create_ubatch_slices(input_batch, 2)]
783 +
784 +
785 +def test_captured_split_survives_a_replay_with_enough_requests():
786 + """Replay preserves the captured split only if real requests reach it."""
787 + buffers = _make_buffers()
788 + captured = _request_slices(InputBatch.make_dummy(16, 16, buffers))
789 +
790 + def replay(num_reqs: int) -> list[slice]:
791 + return _request_slices(
792 + _make_input_batch(
793 + [1] * num_reqs,
794 + [128] * num_reqs,
795 + buffers,
796 + num_reqs_padded=16,
797 + num_tokens_padded=16,
798 + )
799 + )
800 +
801 + assert replay(16) == captured
802 + assert replay(9) == captured
803 + assert replay(4) != captured
804 +
805 +
806 +def _make_cudagraph_manager(capture_sizes: list[int]) -> CudaGraphManager:
807 + vllm_config = VllmConfig(
808 + model_config=ModelConfig(model="facebook/opt-125m", dtype="float16", seed=0),
809 + parallel_config=ParallelConfig(
810 + enable_dbo=True,
811 + all2all_backend="deepep_low_latency",
812 + dbo_decode_token_threshold=DECODE_THRESHOLD,
813 + dbo_prefill_token_threshold=PREFILL_THRESHOLD,
814 + ),
815 + compilation_config=CompilationConfig(
816 + cudagraph_mode=CUDAGraphMode.FULL,
817 + cudagraph_capture_sizes=capture_sizes,
818 + ),
819 + )
820 + with patch.object(
821 + cudagraph_utils,
822 + "get_pp_group",
823 + lambda: SimpleNamespace(is_first_rank=True, is_last_rank=True),
824 + ):
825 + manager = CudaGraphManager(
826 + vllm_config,
827 + torch.device("cuda:0"),
828 + CUDAGraphMode.FULL,
829 + decode_query_len=1,
830 + ubatch_runner=cast(UBatchRunner, object()),
831 + )
832 + manager._graphs_captured = True
833 + return manager
834 +
835 +
836 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a graph pool")
837 +def test_microbatched_graphs_are_only_offered_to_uniform_batches():
838 + """Mixed batches cannot reuse graphs captured with uniform query lengths."""
839 + manager = _make_cudagraph_manager([64, 128])
840 +
841 + desc = manager.dispatch(
842 + num_reqs=64,
843 + num_tokens=64,
844 + uniform_token_count=1,
845 + num_active_loras=0,
846 + num_ubatches=2,
847 + )
848 + assert desc.cg_mode == CUDAGraphMode.FULL
849 + assert desc.num_ubatches == 2
850 +
851 + desc = manager.dispatch(
852 + num_reqs=8,
853 + num_tokens=64,
854 + uniform_token_count=None,
855 + num_active_loras=0,
856 + num_ubatches=2,
857 + )
858 + assert desc.cg_mode == CUDAGraphMode.NONE
859 +
860 +
861 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a graph pool")
862 +def test_microbatched_graph_needs_every_rank_to_reach_the_split():
863 + """Fall back to eager on all ranks if any rank cannot reach the captured split."""
864 + manager = _make_cudagraph_manager([64, 128])
865 + uniform = [1, 1]
866 +
867 + desc, _ = _sync_dp([64, 64], uniform, cudagraph_manager=manager)
868 + assert desc.cg_mode == CUDAGraphMode.FULL
869 + assert desc.num_ubatches == 2
870 +
871 + # Rank 1 pads the group up to a 128-token graph, but rank 0 only reaches 33.
872 + desc, dp_sync = _sync_dp([33, 128], uniform, cudagraph_manager=manager)
873 + assert desc.cg_mode == CUDAGraphMode.NONE
874 + assert desc.num_ubatches == 2
875 + assert dp_sync is not None and dp_sync.eager
vllm/config/vllm.py
+3 -2
@@ -3034,8 +3034,9 @@ class VllmConfig:
3034 unsupported.append("dual batch overlap with multimodal models")
3035 if model_config is not None and model_config.is_hybrid:
3036 unsupported.append("dual batch overlap with hybrid models")
3037 - if self.compilation_config.cudagraph_mode != CUDAGraphMode.NONE:
3038 - unsupported.append("dual batch overlap with CUDA graphs")
3037 + if self.compilation_config.cudagraph_mode == CUDAGraphMode.PIECEWISE:
3038 + # DBO captures FULL graphs only.
3039 + unsupported.append("dual batch overlap with PIECEWISE CUDA graphs")
3040 if self.is_mm_encoder_only:
3041 unsupported.append("dual batch overlap with encoder only models")
3042
vllm/v1/worker/gpu/cudagraph_utils.py
+116 -30
@@ -4,7 +4,8 @@ import gc
4 import itertools
5 from collections import defaultdict
6 from collections.abc import Callable, Iterable
7 -from dataclasses import dataclass
7 +from contextlib import ExitStack
8 +from dataclasses import dataclass, replace
9 from itertools import groupby, product
10 from typing import TYPE_CHECKING, Any, NamedTuple, Protocol
11
@@ -42,11 +43,13 @@ from vllm.v1.worker.gpu.block_table import BlockTables
43 from vllm.v1.worker.gpu.cp_utils import maybe_prepare_dcp_local_seq_lens
44 from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
45 from vllm.v1.worker.gpu.model_states.interface import ModelState
46 +from vllm.v1.worker.ubatch_utils import check_ubatch_thresholds, get_num_ubatches
47 from vllm.v1.worker.utils import AttentionGroup, clear_layer_kv_caches
48
49 if TYPE_CHECKING:
50 from vllm.v1.worker.gpu.model_runner import GPUModelRunner
51 from vllm.v1.worker.gpu.pcp_manager import PCPManager
52 + from vllm.v1.worker.gpu.ubatch_utils import UBatchRunner
53
54 logger = init_logger(__name__)
55
@@ -144,6 +147,7 @@ class CudaGraphManager:
147 decode_query_len: int,
148 lora_capture_cases: list[int] | None = None,
149 varlen_decode: bool = False,
150 + ubatch_runner: "UBatchRunner | None" = None,
151 ):
152 self.vllm_config = vllm_config
153 self.device = device
@@ -153,6 +157,8 @@ class CudaGraphManager:
157 self.cudagraph_mode = cudagraph_mode
158 self.decode_query_len = decode_query_len
159 self.varlen_decode = varlen_decode
160 + # DBO supports FULL CUDA graphs only.
161 + self.ubatch_runner = ubatch_runner
162
163 self.dp_size = vllm_config.parallel_config.data_parallel_size
164 self.tp_size = vllm_config.parallel_config.tensor_parallel_size
@@ -212,6 +218,33 @@ class CudaGraphManager:
218 # Counts above the largest captured case clamp to it.
219 return self._lora_dispatch_map.get(num_active_loras, self._max_lora_case)
220
221 + def _maybe_ubatch_twin(
222 + self, desc: BatchExecutionDescriptor
223 + ) -> BatchExecutionDescriptor | None:
224 + """Return a microbatched capture candidate when eligible.
225 +
226 + Uniform query lengths preserve the captured request split. Use the DP
227 + dispatch thresholds so all ranks generate the same candidates.
228 + """
229 + if self.ubatch_runner is None or desc.cg_mode != CUDAGraphMode.FULL:
230 + return None
231 + if desc.num_reqs is None:
232 + return None
233 + uniform_token_count, remainder = divmod(desc.num_tokens, desc.num_reqs)
234 + if remainder or desc.uniform_token_count not in (None, uniform_token_count):
235 + return None
236 + parallel_config = self.vllm_config.parallel_config
237 + num_ubatches = get_num_ubatches(parallel_config)
238 + if desc.num_tokens < num_ubatches:
239 + return None
240 + if not check_ubatch_thresholds(
241 + parallel_config, desc.num_tokens, uniform_decode=True
242 + ):
243 + return None
244 + return replace(
245 + desc, num_ubatches=num_ubatches, uniform_token_count=uniform_token_count
246 + )
247 +
248 def _init_candidates(self) -> None:
249 """Build priority-ordered candidate lists for each token count."""
250 capture_sizes = self.compilation_config.cudagraph_capture_sizes
@@ -300,6 +333,12 @@ class CudaGraphManager:
333 if desc not in descs_by_mode[decode_mode]:
334 descs_by_mode[decode_mode].append(desc)
335
336 + ubatch_desc = self._maybe_ubatch_twin(desc)
337 + if ubatch_desc is not None and (
338 + ubatch_desc not in descs_by_mode[decode_mode]
339 + ):
340 + descs_by_mode[decode_mode].append(ubatch_desc)
341 +
342 # recoverSSM cannot capture a dummy query wider than its workspace.
343 if mixed_mode and (
344 not self.vllm_config.cache_config.use_kda_recoverssm
@@ -321,6 +360,10 @@ class CudaGraphManager:
360 )
361 descs_by_mode[mixed_mode].append(desc)
362
363 + ubatch_desc = self._maybe_ubatch_twin(desc)
364 + if ubatch_desc is not None:
365 + descs_by_mode[mixed_mode].append(ubatch_desc)
366 +
367 for mode, descs in descs_by_mode.items():
368 descs.sort(key=lambda d: d.num_tokens, reverse=True)
369 self._capture_descs[mode] = descs
@@ -344,6 +387,13 @@ class CudaGraphManager:
387 def needs_capture(self) -> bool:
388 return len(self._capture_descs) > 0
389
390 + def _capture_stream(self, desc: BatchExecutionDescriptor) -> torch.cuda.Stream:
391 + """Capture on the stream used by the microbatch threads."""
392 + if desc.num_ubatches > 1:
393 + assert self.ubatch_runner is not None
394 + return self.ubatch_runner.capture_stream
395 + return current_stream()
396 +
397 @torch.inference_mode()
398 def capture(
399 self,
@@ -359,7 +409,10 @@ class CudaGraphManager:
409 because attention backends may mutate or lazily initialize
410 metadata during warmup.
411 """
362 - with graph_capture(device=self.device):
412 + with graph_capture(device=self.device), ExitStack() as stack:
413 + if self.ubatch_runner is not None:
414 + # Join parked threads on failure to avoid blocking later captures.
415 + stack.callback(self.ubatch_runner.abort_pending_run)
416 # Capture in order: PIECEWISE first, then FULL. PIECEWISE has larger
417 # activations so FULL activations should fit in already allocated
418 # buffers in the graph pool.
@@ -387,7 +440,9 @@ class CudaGraphManager:
440
441 # Capture
442 logger.debug(
390 - "CG Capture: mode=%s, batch_desc=%s", desc.cg_mode.name, desc
443 + "CG Capture: mode=%s, batch_desc=%s",
444 + desc.cg_mode.name,
445 + desc,
446 )
447 if (
448 desc.cg_mode == CUDAGraphMode.PIECEWISE
@@ -415,7 +470,7 @@ class CudaGraphManager:
470 torch.accelerator.synchronize()
471 free_before = torch.accelerator.get_memory_info()[0]
472 with torch.cuda.graph(
418 - graph, self.pool, stream=current_stream()
473 + graph, self.pool, stream=self._capture_stream(desc)
474 ):
475 forward_fn(CUDAGraphMode.NONE)
476 # Join offloader's copy stream after forward to avoid
@@ -429,6 +484,7 @@ class CudaGraphManager:
484 self._capture_mem_samples.append(free_before - free_after)
485 self.graphs[desc] = graph
486 compilation_counter.num_cudagraph_captured += 1
487 +
488 self._graphs_captured = True
489
490 def captured_token_counts(self) -> list[int]:
@@ -510,6 +566,7 @@ class ModelCudaGraphManager(CudaGraphManager):
566 decode_query_len: int,
567 lora_capture_cases: list[int] | None = None,
568 varlen_decode: bool = False,
569 + ubatch_runner: "UBatchRunner | None" = None,
570 ):
571 super().__init__(
572 vllm_config,
@@ -518,6 +575,7 @@ class ModelCudaGraphManager(CudaGraphManager):
575 decode_query_len,
576 lora_capture_cases=lora_capture_cases,
577 varlen_decode=varlen_decode,
578 + ubatch_runner=ubatch_runner,
579 )
580 self.hidden_states: torch.Tensor | None = None
581 self.aux_hidden_states: list[torch.Tensor] = []
@@ -554,6 +612,35 @@ class ModelCudaGraphManager(CudaGraphManager):
612 "Set VLLM_USE_BREAKABLE_CUDAGRAPH=1 or cudagraph_mode=NONE/FULL."
613 )
614
615 + def store_capture_output(num_tokens: int, model_output: Any) -> None:
616 + """Copy outputs to persistent buffers, allocating on first use."""
617 + if self.is_last_pp_rank:
618 + # Last PP rank (common case).
619 + if self.use_aux_hidden_state_outputs:
620 + hidden_states, aux_hidden_states = model_output
621 + else:
622 + hidden_states = model_output
623 + aux_hidden_states = []
624 + if self.hidden_states is None:
625 + self.hidden_states = torch.empty_like(hidden_states)
626 + self.hidden_states[:num_tokens] = hidden_states
627 + if self.use_aux_hidden_state_outputs and not self.aux_hidden_states:
628 + self.aux_hidden_states = [
629 + torch.empty_like(x) for x in aux_hidden_states
630 + ]
631 + for i, aux in enumerate(aux_hidden_states):
632 + self.aux_hidden_states[i][:num_tokens] = aux
633 + else:
634 + # Non-last PP rank.
635 + assert isinstance(model_output, IntermediateTensors)
636 + intermediate_tensors = model_output
637 + if self.intermediate_tensors is None:
638 + self.intermediate_tensors = IntermediateTensors.empty_like(
639 + intermediate_tensors
640 + )
641 + for k, v in intermediate_tensors.tensors.items():
642 + self.intermediate_tensors[k][:num_tokens] = v
643 +
644 def create_forward_fn(
645 desc: BatchExecutionDescriptor,
646 warmup: bool,
@@ -583,6 +670,30 @@ class ModelCudaGraphManager(CudaGraphManager):
670 assert intermediate_tensors is not None
671 model_inputs["intermediate_tensors"] = intermediate_tensors[:num_tokens]
672
673 + if desc.num_ubatches > 1:
674 + # Prepare and park threads before capture; finish runs inside it.
675 + assert self.ubatch_runner is not None
676 + ubatch_state = self.ubatch_runner.prepare(
677 + InputBatch.make_dummy(num_reqs, num_tokens, input_buffers),
678 + block_tables.get_dummy_block_tables(num_reqs),
679 + block_tables.get_dummy_slot_mappings(num_tokens),
680 + cg_mode=CUDAGraphMode.FULL,
681 + for_capture=True,
682 + )
683 + # Capture with dummy rows marked as padding.
684 + input_buffers.is_padding.fill_(True)
685 + finish = self.ubatch_runner.begin_capturable_run(
686 + model, model_inputs, ubatch_state, for_capture=True
687 + )
688 +
689 + def ubatch_forward_fn(cg_mode: CUDAGraphMode) -> None:
690 + assert cg_mode != CUDAGraphMode.PIECEWISE, (
691 + "DBO does not support PIECEWISE cudagraphs"
692 + )
693 + store_capture_output(num_tokens, finish())
694 +
695 + return ubatch_forward_fn
696 +
697 attn_metadata, slot_mappings = prepare_inputs_to_capture(
698 num_reqs,
699 num_tokens,
@@ -629,32 +740,7 @@ class ModelCudaGraphManager(CudaGraphManager):
740 # model outputs. No need to keep track of the hidden states.
741 return None
742
632 - if self.is_last_pp_rank:
633 - # Last PP rank (common case).
634 - if self.use_aux_hidden_state_outputs:
635 - hidden_states, aux_hidden_states = model_output
636 - else:
637 - hidden_states = model_output
638 - aux_hidden_states = []
639 - if self.hidden_states is None:
640 - self.hidden_states = torch.empty_like(hidden_states)
641 - self.hidden_states[:num_tokens] = hidden_states
642 - if self.use_aux_hidden_state_outputs and not self.aux_hidden_states:
643 - self.aux_hidden_states = [
644 - torch.empty_like(x) for x in aux_hidden_states
645 - ]
646 - for i, aux in enumerate(aux_hidden_states):
647 - self.aux_hidden_states[i][:num_tokens] = aux
648 - else:
649 - # Non-last PP rank.
650 - assert isinstance(model_output, IntermediateTensors)
651 - intermediate_tensors = model_output
652 - if self.intermediate_tensors is None:
653 - self.intermediate_tensors = IntermediateTensors.empty_like(
654 - intermediate_tensors
655 - )
656 - for k, v in intermediate_tensors.tensors.items():
657 - self.intermediate_tensors[k][:num_tokens] = v
743 + store_capture_output(num_tokens, model_output)
744
745 return forward_fn
746
vllm/v1/worker/gpu/dp_utils.py
+34 -12
@@ -112,23 +112,45 @@ def sync_cudagraph_and_dp_padding(
112 int(num_tokens_across_dp.min()),
113 uniform_decode=uniform_decode_across_dp,
114 ):
115 - # Microbatching is all-or-nothing: the expert all-to-all is
116 - # collective, so every rank splits and pads to the same token count.
117 - # A rank too small to fill a microbatch does no work in it, like a
118 - # dummy run. Microbatched steps run eager; nothing is captured yet.
115 + # Expert all-to-all requires every rank to split and pad equally.
116 + # Empty microbatches run as dummy batches.
117 ubatch_num_tokens = int(num_tokens_across_dp.max())
120 - return BatchExecutionDescriptor(
121 - cg_mode=CUDAGraphMode.NONE,
122 - num_tokens=ubatch_num_tokens,
123 - num_reqs=num_reqs,
124 - num_ubatches=get_num_ubatches(parallel_config),
125 - ), DPSyncState(
118 + num_ubatches = get_num_ubatches(parallel_config)
119 + ubatch_desc = None
120 + if cudagraph_manager is not None:
121 + # Match a FULL microbatched CUDA graph, falling back to an eager
122 + # descriptor if no graph matches.
123 + ubatch_desc = cudagraph_manager.dispatch(
124 + num_reqs,
125 + ubatch_num_tokens,
126 + synced_uniform_token_count,
127 + num_active_loras=num_active_loras,
128 + num_ubatches=num_ubatches,
129 + )
130 + if 2 * int(num_tokens_across_dp.min()) < ubatch_desc.num_tokens:
131 + # If one rank has an empty second microbatch, run without
132 + # CUDA graphs.
133 + ubatch_desc = None
134 + if ubatch_desc is None:
135 + ubatch_desc = BatchExecutionDescriptor(
136 + cg_mode=CUDAGraphMode.NONE,
137 + num_tokens=ubatch_num_tokens,
138 + num_reqs=num_reqs,
139 + num_ubatches=num_ubatches,
140 + )
141 + # Refresh the token count to include CUDA graph padding.
142 + ubatch_num_tokens = ubatch_desc.num_tokens
143 + num_reqs = int(num_reqs_across_dp.max())
144 + if ubatch_desc.cg_mode == CUDAGraphMode.FULL:
145 + assert ubatch_desc.num_reqs is not None
146 + num_reqs = ubatch_desc.num_reqs
147 + return ubatch_desc, DPSyncState(
148 num_tokens_across_dp=torch.full_like(
149 num_tokens_across_dp, ubatch_num_tokens
150 ),
151 uniform_token_count=synced_uniform_token_count,
130 - eager=True,
131 - num_reqs=int(num_reqs_across_dp.max()),
152 + eager=ubatch_desc.cg_mode == CUDAGraphMode.NONE,
153 + num_reqs=num_reqs,
154 )
155
156 synced_cg_mode = CUDAGraphMode(int(cg_mode_across_dp.min().item()))
vllm/v1/worker/gpu/model_runner.py
+6 -1
@@ -723,6 +723,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
723 decode_query_len=self.decode_query_len,
724 lora_capture_cases=self.lora_capture_cases,
725 varlen_decode=self.adaptive_verification is not None,
726 + ubatch_runner=self.ubatch_runner,
727 )
728 if self.cache_config.kv_sharing_fast_prefill and self.pcp_manager is None:
729 self.fast_prefill = FastPrefillHelper(
@@ -1775,7 +1776,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1776 assert self.ubatch_runner is not None
1777 assert block_tables is not None and slot_mappings is not None
1778 ubatch_state = self.ubatch_runner.prepare(
1778 - input_batch, block_tables, slot_mappings
1779 + input_batch,
1780 + block_tables,
1781 + slot_mappings,
1782 + cg_mode=batch_desc.cg_mode,
1783 + for_capture=dummy_run and batch_desc.cg_mode == CUDAGraphMode.FULL,
1784 )
1785 elif not (dummy_run and skip_attn_for_dummy_run):
1786 assert slot_mappings is not None
vllm/v1/worker/gpu/ubatch_utils.py
+89 -31
@@ -3,6 +3,8 @@
3 """Microbatching (DBO) helpers for the V2 GPU model runner."""
4
5 import threading
6 +from collections.abc import Callable
7 +from contextlib import ExitStack
8 from dataclasses import replace
9 from typing import Any, NamedTuple
10
@@ -317,8 +319,11 @@ class UBatchRunner:
319 for _ in range(self.num_ubatches)
320 ]
321 self.comm_stream = torch.cuda.Stream(device=device)
322 + # Threads and graph capture must use the same compute stream.
323 + self.capture_stream = torch.cuda.Stream(device=device)
324 # The microbatch threads plus the thread that starts them.
325 self.ready_barrier = threading.Barrier(self.num_ubatches + 1)
326 + self._pending_finish: Callable[[], Any] | None = None
327 self.sm_control = create_sm_control_context(self.parallel_config)
328
329 def prepare(
@@ -326,11 +331,16 @@ class UBatchRunner:
331 input_batch: InputBatch,
332 block_tables: tuple[torch.Tensor, ...],
333 slot_mappings: torch.Tensor,
334 + cg_mode: CUDAGraphMode = CUDAGraphMode.NONE,
335 + for_capture: bool = False,
336 ) -> UBatchState:
337 """Split the batch into the microbatches the step will run on.
338
339 Attention metadata is built per microbatch and carried in the forward
340 contexts the threads install, not in the caller's.
341 +
342 + FULL uses padded sizes; `for_capture` refreshes metadata from dummy
343 + block tables.
344 """
345 ubatch_slices = create_ubatch_slices(input_batch, self.num_ubatches)
346
@@ -355,12 +365,13 @@ class UBatchRunner:
365 attn_metadata.append(
366 self.model_state.prepare_attn(
367 ubatch,
358 - CUDAGraphMode.NONE,
368 + cg_mode,
369 ubatch_block_tables,
370 ubatch_slot_mappings,
371 self.attn_groups,
372 self.kv_cache_config,
373 ubatch_idx=i,
374 + for_capture=for_capture,
375 )
376 )
377 slot_mappings_by_layer.append(
@@ -415,14 +426,34 @@ class UBatchRunner:
426 model_inputs: dict[str, Any],
427 ubatch_state: UBatchState,
428 ) -> Any:
429 + finish = self.begin_capturable_run(model, model_inputs, ubatch_state)
430 + return finish()
431 +
432 + def begin_capturable_run(
433 + self,
434 + model: Any,
435 + model_inputs: dict[str, Any],
436 + ubatch_state: UBatchState,
437 + for_capture: bool = False,
438 + ) -> Callable[[], Any]:
439 + """Start microbatch threads and return a callback to run and join them.
440 +
441 + For capture, start threads outside the graph and call the callback inside.
442 + `for_capture` selects the capture stream and initializes cuBLAS workspaces
443 + before capture.
444 +
445 + Call the callback exactly once on this thread, or call `abort_pending_run`
446 + on failure to release parked threads.
447 + """
448 ubatch_slices = ubatch_state.slices
449 assert len(ubatch_slices) == len(ubatch_state.forward_contexts)
450 assert len(ubatch_slices) == self.num_ubatches
451
452 + compute_stream = self.capture_stream if for_capture else current_stream()
453 ubatch_contexts = make_ubatch_contexts(
454 num_micro_batches=self.num_ubatches,
455 comm_stream=self.comm_stream,
425 - compute_stream=current_stream(),
456 + compute_stream=compute_stream,
457 forward_contexts=ubatch_state.forward_contexts,
458 ready_barrier=self.ready_barrier,
459 )
@@ -433,6 +464,13 @@ class UBatchRunner:
464 @torch.inference_mode()
465 def run_ubatch(ubatch_context, inputs: dict[str, Any]) -> None:
466 try:
467 + # New threads must select the worker's device.
468 + torch.accelerator.set_device_index(self.device.index)
469 + if for_capture:
470 + # Allocate cuBLAS workspaces before capture.
471 + for stream in (compute_stream, self.comm_stream):
472 + with torch.cuda.stream(stream):
473 + torch.cuda.current_blas_handle()
474 with ubatch_context:
475 outputs[ubatch_context.id] = model(**inputs)
476 except BaseException as e: # noqa: BLE001
@@ -445,33 +483,56 @@ class UBatchRunner:
483 # is deliberately out of scope here.
484 errors[ubatch_context.id] = e
485
448 - # The threads manage the forward context themselves; clear it here so
449 - # it is restored correctly once they are done.
450 - with override_forward_context(None), self.sm_control:
451 - threads = []
452 - for ubatch_context, ubatch_slice in zip(ubatch_contexts, ubatch_slices):
453 - thread = threading.Thread(
454 - target=run_ubatch,
455 - args=(
456 - ubatch_context,
457 - slice_model_inputs(model_inputs, ubatch_slice.token_slice),
458 - ),
459 - )
460 - threads.append(thread)
461 - thread.start()
486 + # Threads own the forward context; keep it cleared and SMs reserved
487 + # until finish joins them.
488 + stack = ExitStack()
489 + stack.enter_context(override_forward_context(None))
490 + stack.enter_context(self.sm_control)
491 +
492 + threads = []
493 + for ubatch_context, ubatch_slice in zip(ubatch_contexts, ubatch_slices):
494 + thread = threading.Thread(
495 + target=run_ubatch,
496 + args=(
497 + ubatch_context,
498 + slice_model_inputs(model_inputs, ubatch_slice.token_slice),
499 + ),
500 + )
501 + threads.append(thread)
502 + thread.start()
503
463 - # Wait for every thread to reach its context, then start the first.
464 - self.ready_barrier.wait()
465 - ubatch_contexts[0].cpu_wait_event.set()
466 - for thread in threads:
467 - thread.join()
504 + # Park all threads before the caller starts capture.
505 + self.ready_barrier.wait()
506
469 - if errors:
470 - failed = min(errors)
471 - raise RuntimeError(
472 - f"Microbatch {failed} of {self.num_ubatches} failed"
473 - ) from errors[failed]
474 - return merge_ubatch_outputs([outputs[i] for i in range(self.num_ubatches)])
507 + def finish() -> Any:
508 + try:
509 + ubatch_contexts[0].cpu_wait_event.set()
510 + for thread in threads:
511 + thread.join()
512 + finally:
513 + self._pending_finish = None
514 + stack.close()
515 +
516 + if errors:
517 + failed = min(errors)
518 + raise RuntimeError(
519 + f"Microbatch {failed} of {self.num_ubatches} failed"
520 + ) from errors[failed]
521 + return merge_ubatch_outputs([outputs[i] for i in range(self.num_ubatches)])
522 +
523 + self._pending_finish = finish
524 + return finish
525 +
526 + def abort_pending_run(self) -> None:
527 + """Release and join parked threads after a failed capture."""
528 + if self._pending_finish is None:
529 + return
530 + try:
531 + self._pending_finish()
532 + except Exception:
533 + logger.exception("Failed to join microbatch threads after an abort")
534 + finally:
535 + self._pending_finish = None
536
537
538 def maybe_build_ubatch_runner(
@@ -491,10 +552,7 @@ def maybe_build_ubatch_runner(
552 if not parallel_config.use_ubatching or parallel_config.data_parallel_size <= 1:
553 return None
554
494 - logger.info_once(
495 - "Dual batch overlap is enabled. Microbatched steps run without "
496 - "CUDA graphs on the V2 model runner."
497 - )
555 + logger.info_once("Dual batch overlap is enabled.")
556 return UBatchRunner(
557 vllm_config, device, model_state, attn_groups, kv_cache_config, max_num_reqs
558 )