[1/2][Model Runner V2] DBO support, eager mode (#50945)

Signed-off-by: specture724 <specture724@gmail.com> Signed-off-by: Anjie Hou <149605198+specture724@users.noreply.github.com> Co-authored-by: jiangkuaixue123 <jiangxiaozhou111@163.com> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com>

Anjie Hou committed Sep 5, 2026 at 05:17 UTC 3f41d102c5b8636f7206a5fd5949dd8ae0b30a0d
17 files changed +1346 -111
tests/model_executor/test_routed_experts_capture.py
+1
@@ -453,6 +453,7 @@ def test_v2_model_runner_accepts_routed_experts(monkeypatch):
453 distributed_executor_backend=None,
454 pipeline_parallel_size=1,
455 enable_dbo=False,
456 + use_ubatching=False,
457 enable_elastic_ep=False,
458 ),
459 compilation_config=SimpleNamespace(
tests/v1/worker/test_gpu_ubatch_slicing.py new
+583
@@ -0,0 +1,583 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Dual batch overlap (DBO) in the V2 model runner.
4 +
5 +The V2 runner slices the batch *before* building attention metadata, where V1
6 +builds metadata for the whole batch and slices it afterwards. Both must describe
7 +the same microbatches, so the main test here pins V2's slicing against V1's
8 +`split_attn_metadata`. The rest covers the decision to microbatch and the
9 +threaded execution of the microbatches.
10 +"""
11 +
12 +import threading
13 +from dataclasses import replace
14 +from types import SimpleNamespace
15 +from typing import Any, cast
16 +from unittest.mock import patch
17 +
18 +import numpy as np
19 +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
24 +from vllm.forward_context import create_forward_context
25 +from vllm.v1.kv_cache_interface import KVCacheConfig
26 +from vllm.v1.worker.gpu import dp_utils
27 +from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
28 +from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
29 +from vllm.v1.worker.gpu.model_states.interface import ModelState
30 +from vllm.v1.worker.gpu.ubatch_utils import (
31 + UBatchRunner,
32 + UBatchState,
33 + _slice_input_batch,
34 + create_ubatch_slices,
35 + slice_model_inputs,
36 +)
37 +from vllm.v1.worker.ubatch_utils import (
38 + UBatchSlice,
39 + maybe_create_ubatch_slices,
40 + split_attn_metadata,
41 +)
42 +from vllm.v1.worker.ubatching import dbo_current_ubatch_id, dbo_yield
43 +
44 +MAX_NUM_REQS = 32
45 +MAX_NUM_TOKENS = 128
46 +
47 +
48 +def _make_buffers() -> InputBuffers:
49 + return InputBuffers(
50 + max_num_reqs=MAX_NUM_REQS,
51 + max_num_tokens=MAX_NUM_TOKENS,
52 + device=torch.device("cpu"),
53 + )
54 +
55 +
56 +def _make_ubatch_buffers(num_ubatches: int = 2) -> list[tuple[torch.Tensor, ...]]:
57 + """The per-microbatch (query_start_loc, seq_lens) buffers UBatchRunner owns."""
58 + return [
59 + (
60 + torch.zeros(MAX_NUM_REQS + 1, dtype=torch.int32),
61 + torch.zeros(MAX_NUM_REQS, dtype=torch.int32),
62 + )
63 + for _ in range(num_ubatches)
64 + ]
65 +
66 +
67 +def _make_input_batch(
68 + query_lens: list[int],
69 + seq_lens: list[int],
70 + buffers: InputBuffers,
71 + num_reqs_padded: int | None = None,
72 + num_tokens_padded: int | None = None,
73 +) -> InputBatch:
74 + """Build an InputBatch shaped like the one `prepare_inputs` produces."""
75 + num_reqs = len(query_lens)
76 + num_tokens = int(sum(query_lens))
77 + num_reqs_padded = num_reqs_padded or num_reqs
78 + num_tokens_padded = num_tokens_padded or num_tokens
79 +
80 + # make_dummy writes the shared buffers, so fill them afterwards.
81 + base = InputBatch.make_dummy(num_reqs, num_tokens, buffers)
82 +
83 + query_start_loc_np = np.zeros(num_reqs_padded + 1, dtype=np.int32)
84 + np.cumsum(query_lens, out=query_start_loc_np[1 : num_reqs + 1])
85 + # Padded entries repeat the token count, as `prepare_inputs` does.
86 + query_start_loc_np[num_reqs + 1 :] = num_tokens
87 + buffers.query_start_loc[: num_reqs_padded + 1] = torch.from_numpy(
88 + query_start_loc_np
89 + )
90 +
91 + buffers.seq_lens[:num_reqs] = torch.tensor(seq_lens, dtype=torch.int32)
92 + buffers.seq_lens[num_reqs:num_reqs_padded] = 0
93 +
94 + seq_lens_upper_bound = np.zeros(num_reqs_padded, dtype=np.int32)
95 + seq_lens_upper_bound[:num_reqs] = seq_lens
96 +
97 + return replace(
98 + base,
99 + req_ids=[f"req_{i}" for i in range(num_reqs)],
100 + num_reqs=num_reqs,
101 + num_reqs_after_padding=num_reqs_padded,
102 + idx_mapping=torch.arange(num_reqs, dtype=torch.int32),
103 + idx_mapping_np=np.arange(num_reqs, dtype=np.int32),
104 + num_scheduled_tokens=np.array(query_lens, dtype=np.int32),
105 + num_tokens=num_tokens,
106 + num_tokens_after_padding=num_tokens_padded,
107 + query_start_loc=buffers.query_start_loc[: num_reqs_padded + 1],
108 + query_start_loc_np=query_start_loc_np,
109 + seq_lens=buffers.seq_lens[:num_reqs_padded],
110 + seq_lens_cpu_upper_bound=torch.from_numpy(seq_lens_upper_bound),
111 + num_computed_tokens_np=np.array(seq_lens, dtype=np.int32)
112 + - np.array(query_lens, dtype=np.int32),
113 + prefill_len_np=np.zeros(num_reqs, dtype=np.int32),
114 + num_computed_prefill_tokens_np=np.zeros(num_reqs, dtype=np.int32),
115 + is_prefilling_np=np.zeros(num_reqs, dtype=np.bool_),
116 + input_ids=buffers.input_ids[:num_tokens_padded],
117 + positions=buffers.positions[:num_tokens_padded],
118 + is_padding=buffers.is_padding[:num_tokens_padded],
119 + )
120 +
121 +
122 +# (query_lens, seq_lens): uniform decode, a boundary that lands inside a
123 +# request, and a boundary that lands exactly on a request start.
124 +BATCHES = {
125 + "uniform_decode": ([1] * 8, [128 + 4 * i for i in range(8)]),
126 + "split_request": ([1, 1, 10, 1, 1], [64, 96, 512, 32, 48]),
127 + "aligned_prefills": ([6, 2, 4, 4], [6, 130, 260, 4]),
128 + "split_first_and_last": ([3, 9, 3], [70, 300, 41]),
129 + # A long prefill trailing a decode: it straddles the boundary and is also
130 + # the last request, so the second microbatch holds nothing else.
131 + "trailing_prefill_spans_boundary": ([2, 20], [64, 300]),
132 +}
133 +
134 +
135 +@pytest.mark.parametrize("batch_name", list(BATCHES))
136 +def test_slicing_matches_v1_split_attn_metadata(batch_name: str):
137 + """V2's pre-sliced inputs describe the same microbatches as V1's."""
138 + query_lens, seq_lens = BATCHES[batch_name]
139 + num_scheduled_tokens = np.array(query_lens, dtype=np.int32)
140 + num_tokens = int(num_scheduled_tokens.sum())
141 +
142 + ubatch_slices, _ = maybe_create_ubatch_slices(
143 + True, num_scheduled_tokens, num_tokens, len(query_lens), num_ubatches=2
144 + )
145 + assert ubatch_slices is not None
146 +
147 + v1_metadata = create_common_attn_metadata(
148 + BatchSpec(seq_lens=seq_lens, query_lens=query_lens),
149 + block_size=16,
150 + device=torch.device("cpu"),
151 + )
152 + v1_ubatches = split_attn_metadata(ubatch_slices, v1_metadata)
153 +
154 + buffers = _make_buffers()
155 + ubatch_buffers = _make_ubatch_buffers()
156 + input_batch = _make_input_batch(query_lens, seq_lens, buffers)
157 +
158 + for i, (ubatch_slice, v1_ubatch) in enumerate(zip(ubatch_slices, v1_ubatches)):
159 + v2_ubatch = _slice_input_batch(input_batch, ubatch_slice, *ubatch_buffers[i])
160 +
161 + assert v2_ubatch.num_reqs == v1_ubatch.num_reqs
162 + assert v2_ubatch.num_tokens == v1_ubatch.num_actual_tokens
163 + assert int(v2_ubatch.num_scheduled_tokens.max()) == v1_ubatch.max_query_len
164 + torch.testing.assert_close(v2_ubatch.query_start_loc, v1_ubatch.query_start_loc)
165 + torch.testing.assert_close(v2_ubatch.seq_lens, v1_ubatch.seq_lens)
166 + torch.testing.assert_close(
167 + torch.from_numpy(v2_ubatch.query_start_loc_np),
168 + v1_ubatch.query_start_loc_cpu,
169 + )
170 + # `prepare_attn` derives max_seq_len from the upper bound. Compare the
171 + # bound itself rather than V1's max_seq_len: V1 floors it with the full
172 + # batch's max_seq_len to keep the value CUDA-graph capture installed,
173 + # which V2 gets from `prepare_attn(for_capture=True)` instead.
174 + torch.testing.assert_close(
175 + v2_ubatch.seq_lens_cpu_upper_bound, v1_ubatch.seq_lens_cpu_upper_bound
176 + )
177 +
178 +
179 +def test_microbatches_do_not_share_buffers():
180 + """Both microbatches are live at once, so their buffers must be distinct."""
181 + query_lens, seq_lens = BATCHES["split_request"]
182 + num_scheduled_tokens = np.array(query_lens, dtype=np.int32)
183 + num_tokens = int(num_scheduled_tokens.sum())
184 +
185 + ubatch_slices, _ = maybe_create_ubatch_slices(
186 + True, num_scheduled_tokens, num_tokens, len(query_lens), num_ubatches=2
187 + )
188 + assert ubatch_slices is not None
189 +
190 + buffers = _make_buffers()
191 + ubatch_buffers = _make_ubatch_buffers()
192 + input_batch = _make_input_batch(query_lens, seq_lens, buffers)
193 +
194 + first = _slice_input_batch(input_batch, ubatch_slices[0], *ubatch_buffers[0])
195 + first_query_start_loc = first.query_start_loc.clone()
196 + first_seq_lens = first.seq_lens.clone()
197 +
198 + # Slicing the second microbatch must not disturb the first.
199 + _slice_input_batch(input_batch, ubatch_slices[1], *ubatch_buffers[1])
200 +
201 + torch.testing.assert_close(first.query_start_loc, first_query_start_loc)
202 + torch.testing.assert_close(first.seq_lens, first_seq_lens)
203 +
204 +
205 +def test_trailing_microbatch_absorbs_cudagraph_padding():
206 + """The padded microbatch keeps padded rows empty and query_start_loc flat."""
207 + query_lens, seq_lens = [1] * 6, [64] * 6
208 + num_tokens_padded = 8
209 +
210 + buffers = _make_buffers()
211 + input_batch = _make_input_batch(
212 + query_lens,
213 + seq_lens,
214 + buffers,
215 + num_reqs_padded=num_tokens_padded,
216 + num_tokens_padded=num_tokens_padded,
217 + )
218 + ubatch_slices_padded = create_ubatch_slices(input_batch, num_ubatches=2)
219 +
220 + last = _slice_input_batch(
221 + input_batch, ubatch_slices_padded[-1], *_make_ubatch_buffers()[1]
222 + )
223 +
224 + # Two real decodes plus two padded rows.
225 + assert last.num_reqs == 2
226 + assert last.num_tokens == 2
227 + assert last.num_reqs_after_padding == 4
228 + assert last.num_tokens_after_padding == 4
229 + torch.testing.assert_close(
230 + last.query_start_loc, torch.tensor([0, 1, 2, 2, 2], dtype=torch.int32)
231 + )
232 + torch.testing.assert_close(
233 + last.seq_lens, torch.tensor([64, 64, 0, 0], dtype=torch.int32)
234 + )
235 +
236 +
237 +DECODE_THRESHOLD = 32
238 +PREFILL_THRESHOLD = 128
239 +DECODE_QUERY_LEN = 1
240 +
241 +
242 +def test_dummy_batch_can_be_microbatched():
243 + """A DP rank with no work still has to split, so its dummy batch must slice.
244 +
245 + Microbatching is all-or-nothing across the group, so a rank whose step is a
246 + dummy batch runs it microbatched like everyone else.
247 + """
248 + num_reqs, num_tokens = 1, 32
249 + buffers = _make_buffers()
250 + input_batch = InputBatch.make_dummy(num_reqs, num_tokens, buffers)
251 +
252 + ubatch_slices_padded = create_ubatch_slices(input_batch, num_ubatches=2)
253 +
254 + ubatch_buffers = _make_ubatch_buffers()
255 + ubatches = [
256 + _slice_input_batch(input_batch, ubatch_slice, *ubatch_buffers[i])
257 + for i, ubatch_slice in enumerate(ubatch_slices_padded)
258 + ]
259 +
260 + assert [u.num_tokens for u in ubatches] == [16, 16]
261 + # The one dummy request spans both microbatches, 16 of its tokens in each.
262 + assert all(u.num_reqs == 1 for u in ubatches)
263 + for ubatch in ubatches:
264 + torch.testing.assert_close(
265 + ubatch.query_start_loc, torch.tensor([0, 16], dtype=torch.int32)
266 + )
267 +
268 +
269 +def _sync_dp(
270 + num_tokens_per_rank: list[int],
271 + uniform_token_count_per_rank: list[int] | None = None,
272 + allow_ubatching: bool = True,
273 +) -> tuple[BatchExecutionDescriptor, dp_utils.DPSyncState | None]:
274 + """Run the DP handshake with the all-reduce stubbed out.
275 +
276 + The microbatching decision lives inside `sync_cudagraph_and_dp_padding`, so
277 + the cases below stand in for the collective rather than for the decision,
278 + and need no process group. Every rank asks for eager, which keeps the
279 + non-microbatched path off the cudagraph manager. This rank is rank 0.
280 + """
281 + dp_size = len(num_tokens_per_rank)
282 + uniform_token_counts = uniform_token_count_per_rank or [0] * dp_size
283 + reduced = torch.zeros(6, dp_size, dtype=torch.int32)
284 + reduced[0] = torch.tensor(num_tokens_per_rank, dtype=torch.int32)
285 + reduced[1] = CUDAGraphMode.NONE.value
286 + reduced[2] = torch.tensor(uniform_token_counts, dtype=torch.int32)
287 + reduced[3] = -1 # max_query_len, -1 means None
288 + reduced[4] = int(allow_ubatching)
289 + reduced[5] = 8 # num_reqs
290 +
291 + with (
292 + patch.object(dp_utils.dist, "all_reduce", lambda t, group: t.copy_(reduced)),
293 + patch.object(dp_utils, "get_dp_group", lambda: SimpleNamespace(cpu_group=None)),
294 + ):
295 + return dp_utils.sync_cudagraph_and_dp_padding(
296 + cudagraph_manager=None,
297 + desired_batch_desc=BatchExecutionDescriptor(
298 + cg_mode=CUDAGraphMode.NONE,
299 + num_tokens=num_tokens_per_rank[0],
300 + num_reqs=8,
301 + ),
302 + num_tokens=num_tokens_per_rank[0],
303 + num_reqs=8,
304 + uniform_token_count=uniform_token_counts[0] or None,
305 + dp_size=dp_size,
306 + dp_rank=0,
307 + parallel_config=ParallelConfig(
308 + enable_dbo=True,
309 + dbo_decode_token_threshold=DECODE_THRESHOLD,
310 + dbo_prefill_token_threshold=PREFILL_THRESHOLD,
311 + ),
312 + allow_ubatching=allow_ubatching,
313 + uniform_decode=uniform_token_counts[0] == DECODE_QUERY_LEN,
314 + )
315 +
316 +
317 +def test_every_dp_rank_must_agree_to_microbatch():
318 + """One rank below the threshold is enough to keep the group on one batch.
319 +
320 + The thresholds are not communicated -- they are checked against the token
321 + counts the all-reduce already carries -- so this also pins that the whole
322 + vector is consulted, not just this rank's own entry.
323 + """
324 + assert _sync_dp([256, 256])[0].num_ubatches == 2
325 + assert _sync_dp([256, 100])[0].num_ubatches == 1
326 + assert _sync_dp([100, 256])[0].num_ubatches == 1
327 +
328 +
329 +def test_decode_threshold_only_applies_when_every_rank_is_a_uniform_decode():
330 + """Otherwise the group is held to the prefill threshold."""
331 + # 64 tokens clears the decode threshold but not the prefill one, so it only
332 + # microbatches when both ranks are uniform decodes.
333 + assert _sync_dp([64, 64], uniform_token_count_per_rank=[1, 1])[0].num_ubatches == 2
334 + assert _sync_dp([64, 64], uniform_token_count_per_rank=[1, 0])[0].num_ubatches == 1
335 +
336 +
337 +def test_microbatching_pads_all_ranks_to_the_largest():
338 + """Ranks must run the same token count so microbatch sizes line up."""
339 + desc, dp_sync = _sync_dp([200, 256])
340 + assert desc.num_tokens == 256
341 + assert dp_sync is not None
342 + assert dp_sync.num_tokens_across_dp.tolist() == [256, 256]
343 +
344 +
345 +def test_microbatching_survives_a_rank_that_cannot_fill_it():
346 + """A rank whose real tokens all land in the first microbatch still splits.
347 +
348 + Rank 0 has 128 real tokens but the split point is at 512 // 2 = 256, so its
349 + second microbatch is pure padding. That microbatch does no work, which is
350 + fine -- it still has to run so the expert all-to-all stays collective.
351 + """
352 + desc, _ = _sync_dp([128, 512])
353 + assert desc.num_tokens == 512
354 + assert desc.num_ubatches == 2
355 +
356 +
357 +def test_all_padding_microbatch_has_no_work_to_do():
358 + """The microbatch past the last real token is well-formed and empty.
359 +
360 + It carries the last request with none of its query tokens, so attention and
361 + sampling see nothing to do while the microbatch still runs.
362 + """
363 + query_lens, seq_lens = [1] * 6, [64] * 6
364 + num_tokens_padded = 16 # split at 8, past all 6 real tokens
365 +
366 + buffers = _make_buffers()
367 + input_batch = _make_input_batch(
368 + query_lens, seq_lens, buffers, num_tokens_padded=num_tokens_padded
369 + )
370 + ubatch_slices_padded = create_ubatch_slices(input_batch, num_ubatches=2)
371 + ubatch_buffers = _make_ubatch_buffers()
372 +
373 + first = _slice_input_batch(input_batch, ubatch_slices_padded[0], *ubatch_buffers[0])
374 + last = _slice_input_batch(input_batch, ubatch_slices_padded[1], *ubatch_buffers[1])
375 +
376 + # The first microbatch keeps every real request; the second gets none.
377 + assert first.num_reqs == 6
378 + assert first.num_tokens == 6
379 + assert last.num_tokens == 0
380 + assert last.num_reqs == 1
381 + assert last.num_reqs_after_padding == 1
382 + assert last.num_tokens_after_padding == 8
383 +
384 + # Zero query tokens for the one request it holds, so no attention work.
385 + torch.testing.assert_close(
386 + last.query_start_loc, torch.tensor([0, 0], dtype=torch.int32)
387 + )
388 + assert last.num_scheduled_tokens.tolist() == [0]
389 + # ...and its sequence still ends where its tokens do: they all live in the
390 + # first microbatch, which computes them before this one runs.
391 + torch.testing.assert_close(last.seq_lens, torch.tensor([64], dtype=torch.int32))
392 + torch.testing.assert_close(
393 + last.seq_lens_cpu_upper_bound, torch.tensor([64], dtype=torch.int32)
394 + )
395 +
396 +
397 +def test_microbatching_off_when_a_rank_does_not_allow_it():
398 + assert _sync_dp([256, 256], allow_ubatching=False)[0].num_ubatches == 1
399 +
400 +
401 +def test_slice_model_inputs_handles_mrope_positions():
402 + model_inputs = {
403 + "input_ids": torch.arange(8),
404 + # M-RoPE positions carry a leading section dim.
405 + "positions": torch.arange(24).view(3, 8),
406 + "inputs_embeds": None,
407 + "intermediate_tensors": None,
408 + }
409 + sliced = slice_model_inputs(model_inputs, slice(4, 8))
410 +
411 + torch.testing.assert_close(sliced["input_ids"], torch.arange(4, 8))
412 + torch.testing.assert_close(sliced["positions"], torch.arange(24).view(3, 8)[:, 4:8])
413 + assert sliced["inputs_embeds"] is None
414 + assert sliced["intermediate_tensors"] is None
415 +
416 +
417 +def _make_dbo_config() -> VllmConfig:
418 + return VllmConfig(
419 + model_config=ModelConfig(model="facebook/opt-125m", dtype="float16", seed=0),
420 + parallel_config=ParallelConfig(
421 + enable_dbo=True, all2all_backend="deepep_low_latency"
422 + ),
423 + )
424 +
425 +
426 +def _make_model_inputs(num_tokens: int, device: torch.device) -> dict[str, Any]:
427 + return {
428 + "input_ids": torch.arange(num_tokens, device=device),
429 + "positions": torch.arange(num_tokens, device=device),
430 + "inputs_embeds": None,
431 + "intermediate_tensors": None,
432 + }
433 +
434 +
435 +@pytest.fixture(autouse=True)
436 +def _no_comm_sm_reservation(monkeypatch):
437 + """CI runs on MIG slices with fewer SMs than the default comm reservation."""
438 + monkeypatch.setenv("VLLM_DBO_COMM_SMS", "0")
439 +
440 +
441 +def _make_execution_runner(vllm_config: VllmConfig) -> UBatchRunner:
442 + """A runner for the execution tests below, which never call `prepare()`.
443 +
444 + The attention-side dependencies are only read while building per-microbatch
445 + metadata, so they are left out here.
446 + """
447 + return UBatchRunner(
448 + vllm_config,
449 + torch.device("cuda:0"),
450 + model_state=cast(ModelState, None),
451 + attn_groups=[],
452 + kv_cache_config=cast(KVCacheConfig, None),
453 + max_num_reqs=MAX_NUM_REQS,
454 + )
455 +
456 +
457 +def _make_ubatch_state(
458 + vllm_config: VllmConfig, ubatch_slices: list[UBatchSlice]
459 +) -> UBatchState:
460 + return UBatchState(
461 + slices=ubatch_slices,
462 + forward_contexts=[
463 + create_forward_context(None, vllm_config) for _ in ubatch_slices
464 + ],
465 + )
466 +
467 +
468 +def test_thresholds_below_the_microbatch_count_are_rejected():
469 + """A batch with fewer tokens than microbatches cannot be split at all.
470 +
471 + Nothing downstream copes with an unsplittable batch, so the thresholds are
472 + what keep one out: a one-token decode split two ways leaves an empty
473 + microbatch.
474 + """
475 + with pytest.raises(ValueError, match="number of microbatches"):
476 + ParallelConfig(enable_dbo=True, dbo_decode_token_threshold=1)
477 +
478 +
479 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
480 +def test_runner_allocates_one_buffer_pair_per_microbatch():
481 + """All microbatches are live at once, so none may share rebase buffers."""
482 + vllm_config = _make_dbo_config()
483 + runner = _make_execution_runner(vllm_config)
484 +
485 + for buffers in (runner.ubatch_query_start_loc, runner.ubatch_seq_lens):
486 + assert len(buffers) == runner.num_ubatches
487 + storages = {b.untyped_storage().data_ptr() for b in buffers}
488 + assert len(storages) == runner.num_ubatches
489 +
490 +
491 +class _YieldingModel(torch.nn.Module):
492 + """Toy model that hands off to the other microbatch mid-forward."""
493 +
494 + def __init__(self, trace: list[tuple[str, int]]):
495 + super().__init__()
496 + self.trace = trace
497 +
498 + def forward(self, input_ids, positions, intermediate_tensors=None, **kwargs):
499 + self.trace.append(("enter", dbo_current_ubatch_id()))
500 + out = input_ids.float().unsqueeze(-1) * 2 + positions.float().unsqueeze(-1)
501 + # Stands in for the expert all-to-all handoff point.
502 + dbo_yield()
503 + self.trace.append(("exit", dbo_current_ubatch_id()))
504 + return out
505 +
506 +
507 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
508 +def test_ubatch_runner_overlaps_and_matches_single_batch():
509 + """Microbatches interleave at the yield point and produce the same output."""
510 + vllm_config = _make_dbo_config()
511 + device = torch.device("cuda:0")
512 + runner = _make_execution_runner(vllm_config)
513 +
514 + model_inputs = _make_model_inputs(16, device)
515 + ubatch_state = _make_ubatch_state(
516 + vllm_config,
517 + [
518 + UBatchSlice(slice(0, 4), slice(0, 8)),
519 + UBatchSlice(slice(4, 8), slice(8, 16)),
520 + ],
521 + )
522 +
523 + trace: list[tuple[str, int]] = []
524 + model = _YieldingModel(trace)
525 + output = runner.run(model, model_inputs, ubatch_state)
526 +
527 + expected = model_inputs["input_ids"].float().unsqueeze(-1) * 2 + model_inputs[
528 + "positions"
529 + ].float().unsqueeze(-1)
530 + torch.testing.assert_close(output, expected)
531 +
532 + # Both microbatches reach the handoff before either finishes.
533 + assert trace == [("enter", 0), ("enter", 1), ("exit", 0), ("exit", 1)]
534 +
535 +
536 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
537 +def test_ubatch_runner_names_the_microbatch_that_failed():
538 + """A failing microbatch surfaces as a named error, not a downstream KeyError.
539 +
540 + The model here never yields, so the microbatches run back to back and no
541 + handoff is left outstanding when one of them dies. A microbatch that fails
542 + *while its sibling is parked at a yield* hangs the step instead -- the
543 + shared handoff protocol in `ubatching.py` has no way to unwind a parked
544 + microbatch, and the V1 runner has the same gap. Fixing that means changing
545 + `ubatching.py`, which is out of scope for the V2 runner.
546 + """
547 + vllm_config = _make_dbo_config()
548 + device = torch.device("cuda:0")
549 + runner = _make_execution_runner(vllm_config)
550 +
551 + class _FailingModel(torch.nn.Module):
552 + def forward(self, input_ids, positions, **kwargs):
553 + if dbo_current_ubatch_id() == 0:
554 + raise ValueError("boom")
555 + return input_ids.float().unsqueeze(-1)
556 +
557 + model_inputs = _make_model_inputs(8, device)
558 + ubatch_state = _make_ubatch_state(
559 + vllm_config,
560 + [
561 + UBatchSlice(slice(0, 2), slice(0, 4)),
562 + UBatchSlice(slice(2, 4), slice(4, 8)),
563 + ],
564 + )
565 +
566 + result: dict[str, BaseException] = {}
567 +
568 + def _call():
569 + try:
570 + runner.run(_FailingModel(), model_inputs, ubatch_state)
571 + except BaseException as e: # noqa: BLE001
572 + result["error"] = e
573 +
574 + # Run behind a watchdog: `UBatchRunner.run` joins without a timeout, so a
575 + # regression here would wedge the suite rather than fail it.
576 + caller = threading.Thread(target=_call, daemon=True)
577 + caller.start()
578 + caller.join(timeout=60.0)
579 + assert not caller.is_alive(), "UBatchRunner.run hung on a failing microbatch"
580 +
581 + assert isinstance(result["error"], RuntimeError)
582 + assert "Microbatch 0" in str(result["error"])
583 + assert isinstance(result["error"].__cause__, ValueError)
vllm/config/parallel.py
+11
@@ -1065,6 +1065,17 @@ class ParallelConfig:
1065 "Unable to use nsight profiling unless workers run with Ray."
1066 )
1067
1068 + # A batch below one token per microbatch cannot be split, so the
1069 + # thresholds have to keep it out rather than the split having to cope.
1070 + if self.use_ubatching and (
1071 + min(self.dbo_decode_token_threshold, self.dbo_prefill_token_threshold)
1072 + < self.num_ubatches
1073 + ):
1074 + raise ValueError(
1075 + "dbo_decode_token_threshold and dbo_prefill_token_threshold must "
1076 + f"be at least the number of microbatches ({self.num_ubatches})."
1077 + )
1078 +
1079 return self
1080
1081 def reconfigure_for_independent_dp_rank(self) -> None:
vllm/config/vllm.py
+43 -2
@@ -2596,8 +2596,8 @@ class VllmConfig:
2596 ):
2597 unsupported.append("EAGLE3 with pipeline parallelism")
2598
2599 - if self.parallel_config.enable_dbo:
2600 - unsupported.append("dual batch overlap")
2599 + if self.parallel_config.use_ubatching:
2600 + unsupported.extend(self._get_dbo_unsupported_features())
2601
2602 if self.parallel_config.enable_elastic_ep:
2603 unsupported.append("elastic expert parallelism")
@@ -2739,6 +2739,47 @@ class VllmConfig:
2739 f"{'; '.join(blockers)}."
2740 )
2741
2742 + def _get_dbo_unsupported_features(self) -> list[str]:
2743 + """Collect what the V2 model runner cannot combine with DBO.
2744 +
2745 + The V2 runner microbatches a plain decoder forward pass. Anything that
2746 + slices or replays the batch differently (drafting, adapters, pipeline
2747 + stages, context parallelism, encoders) is not handled yet.
2748 + """
2749 + # TODO: DBO with model runner V2 is under development.
2750 + # It should be enabled with explicit VLLM_USE_V2_MODEL_RUNNER environ.
2751 + # Remove it when stable.
2752 + if envs.VLLM_USE_V2_MODEL_RUNNER is None:
2753 + return ["dual batch overlap"]
2754 +
2755 + unsupported: list[str] = []
2756 + model_config = self.model_config
2757 + parallel_config = self.parallel_config
2758 +
2759 + if self.lora_config is not None:
2760 + unsupported.append("dual batch overlap with LoRA")
2761 + if self.speculative_config is not None:
2762 + unsupported.append("dual batch overlap with speculative decoding")
2763 + if parallel_config.pipeline_parallel_size > 1:
2764 + unsupported.append("dual batch overlap with pipeline parallelism")
2765 + if (
2766 + parallel_config.decode_context_parallel_size > 1
2767 + or parallel_config.prefill_context_parallel_size > 1
2768 + ):
2769 + unsupported.append("dual batch overlap with context parallelism")
2770 + if model_config is not None and (
2771 + model_config.is_multimodal_model or model_config.is_encoder_decoder
2772 + ):
2773 + unsupported.append("dual batch overlap with multimodal models")
2774 + if model_config is not None and model_config.is_hybrid:
2775 + unsupported.append("dual batch overlap with hybrid models")
2776 + if self.compilation_config.cudagraph_mode != CUDAGraphMode.NONE:
2777 + unsupported.append("dual batch overlap with CUDA graphs")
2778 + if self.is_mm_encoder_only:
2779 + unsupported.append("dual batch overlap with encoder only models")
2780 +
2781 + return unsupported
2782 +
2783 def _validate_v2_model_runner(self) -> None:
2784 """Check for features not yet supported by the V2 model runner."""
2785 if not HAS_TRITON:
vllm/model_executor/models/diffusion_gemma.py
+2
@@ -988,7 +988,9 @@ class DiffusionGemmaModelState(ModelState):
988 attn_groups,
989 kv_cache_config,
990 for_capture=False,
991 + ubatch_idx: int = 0,
992 ) -> dict[str, Any]:
993 + assert ubatch_idx == 0, "DBO is not supported"
994 if cudagraph_mode == CUDAGraphMode.FULL:
995 num_reqs = input_batch.num_reqs_after_padding
996 num_tokens = input_batch.num_tokens_after_padding
vllm/v1/worker/gpu/attn_utils.py
+15 -8
@@ -22,6 +22,7 @@ from vllm.v1.kv_cache_interface import (
22 UniformTypeKVCacheSpecs,
23 )
24 from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata
25 +from vllm.v1.worker.ubatch_utils import get_num_ubatches
26 from vllm.v1.worker.utils import (
27 AttentionGroup,
28 add_kv_sharing_layers_to_kv_cache_groups,
@@ -140,14 +141,19 @@ def init_attn_backend(
141 vllm_config=vllm_config,
142 device=device,
143 kernel_block_size=kernel_block_size,
143 - num_metadata_builders=1,
144 + # Microbatches build attention metadata concurrently, and some
145 + # builders keep the prepared metadata on themselves (MLA stores
146 + # it on the prefill backend), so each ubatch needs its own.
147 + num_metadata_builders=get_num_ubatches(vllm_config.parallel_config),
148 )
145 - builder = group.get_metadata_builder(0)
146 - if attn_backend_workspace is None:
147 - if hasattr(builder, "_get_workspace_buffer"):
148 - attn_backend_workspace = builder._get_workspace_buffer()
149 - else:
150 - if hasattr(builder, "set_workspace_buffer"):
149 + # The microbatches' builders share the workspace: they all issue
150 + # attention on the one compute stream the threads hand off, so the
151 + # buffer is written serially, as it already is across steps.
152 + for builder in group.metadata_builders:
153 + if attn_backend_workspace is None:
154 + if hasattr(builder, "_get_workspace_buffer"):
155 + attn_backend_workspace = builder._get_workspace_buffer()
156 + elif hasattr(builder, "set_workspace_buffer"):
157 builder.set_workspace_buffer(attn_backend_workspace)
158 attn_cg_support_info = get_attn_cg_support(attn_groups, vllm_config)
159 return attn_groups, attn_cg_support_info, kernel_block_sizes
@@ -271,6 +277,7 @@ def build_attn_metadata(
277 for_cudagraph_capture: bool = False,
278 causal: bool | torch.Tensor | Mapping[int, bool] = True,
279 rswa_prefix_lens: torch.Tensor | None = None,
280 + ubatch_idx: int = 0,
281 ) -> dict[str, Any]:
282 seq_lens = seq_lens[:num_reqs]
283 if dcp_local_seq_lens is not None:
@@ -319,7 +326,7 @@ def build_attn_metadata(
326 )
327
328 for attn_group in attn_groups[i]:
322 - attn_metadata_builder = attn_group.get_metadata_builder(0)
329 + attn_metadata_builder = attn_group.get_metadata_builder(ubatch_idx)
330 if for_cudagraph_capture:
331 metadata = attn_metadata_builder.build_for_cudagraph_capture(
332 common_attn_metadata
vllm/v1/worker/gpu/cudagraph_utils.py
+8
@@ -68,6 +68,8 @@ class BatchExecutionDescriptor:
68 # uniform_token_count unset, so this is what keeps a prefill batch out of one.
69 max_query_len: int | None = None
70 num_active_loras: int = 0
71 + # Number of microbatches the batch is split into (DBO). 1 means no splitting.
72 + num_ubatches: int = 1
73
74
75 def make_cudagraph_stats(
@@ -100,11 +102,13 @@ def _is_compatible(
102 uniform_token_count: int | None,
103 num_active_loras: int,
104 max_query_len: int | None,
105 + num_ubatches: int,
106 ) -> bool:
107 # desc.uniform_token_count=None (PIECEWISE) can handle any uniform_token_count
108 # desc.num_reqs=None means no request padding needed (PIECEWISE)
109 # desc.max_query_len=None means the graph does not constrain query length; a
110 # caller that does not track max_query_len must not match one that does
111 + # A graph captured for N microbatches can only serve a batch split N ways.
112 return (
113 (
114 desc.uniform_token_count is None
@@ -117,6 +121,7 @@ def _is_compatible(
121 and (desc.num_reqs is None or desc.num_reqs >= num_reqs)
122 and desc.num_tokens >= num_tokens
123 and desc.num_active_loras == num_active_loras
124 + and desc.num_ubatches == num_ubatches
125 )
126
127
@@ -434,6 +439,7 @@ class CudaGraphManager:
439 uniform_token_count: int | None,
440 num_active_loras: int,
441 max_query_len: int | None = None,
442 + num_ubatches: int = 1,
443 ) -> BatchExecutionDescriptor:
444 """Find matching cudagraph descriptor from priority-ordered candidates."""
445
@@ -448,6 +454,7 @@ class CudaGraphManager:
454 uniform_token_count,
455 effective_loras,
456 max_query_len,
457 + num_ubatches,
458 ):
459 return desc
460 return BatchExecutionDescriptor(
@@ -455,6 +462,7 @@ class CudaGraphManager:
462 num_tokens=num_tokens,
463 num_reqs=num_reqs,
464 num_active_loras=effective_loras,
465 + num_ubatches=num_ubatches,
466 )
467
468 def run_fullgraph(self, desc: BatchExecutionDescriptor):
vllm/v1/worker/gpu/dp_utils.py
+55 -5
@@ -7,12 +7,14 @@ from dataclasses import dataclass, replace
7 import torch
8 import torch.distributed as dist
9
10 +from vllm.config import ParallelConfig
11 from vllm.config.compilation import CUDAGraphMode
12 from vllm.distributed.parallel_state import get_dp_group
13 from vllm.v1.worker.gpu.cudagraph_utils import (
14 BatchExecutionDescriptor,
15 CudaGraphManager,
16 )
17 +from vllm.v1.worker.ubatch_utils import check_ubatch_thresholds, get_num_ubatches
18
19
20 @dataclass(frozen=True)
@@ -48,27 +50,35 @@ def sync_cudagraph_and_dp_padding(
50 dp_rank: int,
51 max_query_len: int | None = None,
52 num_active_loras: int = 0,
53 + parallel_config: ParallelConfig | None = None,
54 + allow_ubatching: bool = False,
55 + uniform_decode: bool = False,
56 ) -> tuple[BatchExecutionDescriptor, DPSyncState | None]:
57 """
58 Coordinates the batch descriptor and DP padding across all ranks.
59
60 + `parallel_config` is only needed to decide whether to microbatch, so callers
61 + that never do (`allow_ubatching=False`) can leave it out.
62 +
63 Returns (synced_batch_desc, sync). `sync` is None when no rank has work.
64 """
65 assert dp_size > 1, "DP size must be greater than 1"
66 group = get_dp_group().cpu_group
59 - tensor = torch.zeros(5, dp_size, dtype=torch.int32, device="cpu")
67 + tensor = torch.zeros(6, dp_size, dtype=torch.int32, device="cpu")
68 tensor[0][dp_rank] = num_tokens
69 tensor[1][dp_rank] = desired_batch_desc.cg_mode.value
70 tensor[2][dp_rank] = uniform_token_count or 0 # (0 means None)
71 tensor[3][dp_rank] = max_query_len or -1 # (-1 means None)
64 - tensor[4][dp_rank] = num_reqs
72 + tensor[4][dp_rank] = int(allow_ubatching)
73 + tensor[5][dp_rank] = num_reqs
74 dist.all_reduce(tensor, group=group)
75
76 num_tokens_across_dp = tensor[0]
77 cg_mode_across_dp = tensor[1]
78 uniform_token_counts_across_dp = tensor[2]
79 max_query_lens_across_dp = tensor[3]
71 - num_reqs_across_dp = tensor[4]
80 + allow_ubatching_across_dp = tensor[4]
81 + num_reqs_across_dp = tensor[5]
82
83 # If ranks disagree on the uniform token count, or its 0 (means None) set to None
84 synced_uniform_token_count: int | None = int(uniform_token_counts_across_dp[0])
@@ -83,6 +93,40 @@ def sync_cudagraph_and_dp_padding(
93 )
94 return synced_desc, None
95
96 + if torch.all(allow_ubatching_across_dp == 1):
97 + # This rank voted too, so its caller passed a config.
98 + assert parallel_config is not None
99 + # A uniform decode only if every rank runs the same uniform query
100 + # length, so every rank reaches the same answer here.
101 + uniform_decode_across_dp = uniform_decode and torch.all(
102 + uniform_token_counts_across_dp == (uniform_token_count or 0)
103 + )
104 + if check_ubatch_thresholds(
105 + parallel_config,
106 + # Thresholds only grow with the token count, so holding the smallest
107 + # rank to them holds every rank to them.
108 + int(num_tokens_across_dp.min()),
109 + uniform_decode=uniform_decode_across_dp,
110 + ):
111 + # Microbatching is all-or-nothing: the expert all-to-all is
112 + # collective, so every rank splits and pads to the same token count.
113 + # A rank too small to fill a microbatch does no work in it, like a
114 + # dummy run. Microbatched steps run eager; nothing is captured yet.
115 + ubatch_num_tokens = int(num_tokens_across_dp.max())
116 + return BatchExecutionDescriptor(
117 + cg_mode=CUDAGraphMode.NONE,
118 + num_tokens=ubatch_num_tokens,
119 + num_reqs=num_reqs,
120 + num_ubatches=get_num_ubatches(parallel_config),
121 + ), DPSyncState(
122 + num_tokens_across_dp=torch.full_like(
123 + num_tokens_across_dp, ubatch_num_tokens
124 + ),
125 + uniform_token_count=synced_uniform_token_count,
126 + eager=True,
127 + num_reqs=int(num_reqs_across_dp.max()),
128 + )
129 +
130 synced_cg_mode = CUDAGraphMode(int(cg_mode_across_dp.min().item()))
131
132 # If any rank wants to run eager, all ranks run eager
@@ -98,7 +142,7 @@ def sync_cudagraph_and_dp_padding(
142 num_tokens_across_dp=num_tokens_across_dp,
143 uniform_token_count=synced_uniform_token_count,
144 eager=True,
101 - num_reqs=int(num_reqs_across_dp.max().item()),
145 + num_reqs=int(num_reqs_across_dp.max()),
146 ),
147 )
148
@@ -136,7 +180,7 @@ def sync_cudagraph_and_dp_padding(
180 synced_desc.num_reqs
181 if synced_desc.cg_mode == CUDAGraphMode.FULL
182 and synced_desc.num_reqs is not None
139 - else int(num_reqs_across_dp.max().item())
183 + else int(num_reqs_across_dp.max())
184 ),
185 )
186
@@ -151,6 +195,9 @@ def dispatch_cg_and_sync_dp(
195 max_query_len: int | None = None,
196 need_eager: bool = False,
197 num_active_loras: int = 0,
198 + parallel_config: ParallelConfig | None = None,
199 + allow_ubatching: bool = False,
200 + uniform_decode: bool = False,
201 dp_sync: DPSyncState | None = None,
202 ) -> tuple[BatchExecutionDescriptor, DPSyncState | None]:
203 """Pick a cudagraph descriptor for this batch, agreeing it across DP ranks.
@@ -239,4 +286,7 @@ def dispatch_cg_and_sync_dp(
286 dp_rank,
287 max_query_len=max_query_len,
288 num_active_loras=num_active_loras,
289 + parallel_config=parallel_config,
290 + allow_ubatching=allow_ubatching,
291 + uniform_decode=uniform_decode,
292 )
vllm/v1/worker/gpu/model_runner.py
+41 -3
@@ -161,6 +161,11 @@ from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
161 from vllm.v1.worker.gpu.spec_decode.utils import DraftTokensHandler
162 from vllm.v1.worker.gpu.states import RequestState
163 from vllm.v1.worker.gpu.structured_outputs import StructuredOutputsWorker
164 +from vllm.v1.worker.gpu.ubatch_utils import (
165 + UBatchRunner,
166 + UBatchState,
167 + maybe_build_ubatch_runner,
168 +)
169 from vllm.v1.worker.lora_model_runner_mixin import LoRAModelRunnerMixin
170 from vllm.v1.worker.utils import (
171 KVBlockZeroer,
@@ -230,6 +235,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
235 self.dp_size = self.parallel_config.data_parallel_size
236 self.dp_rank = self.parallel_config.data_parallel_rank
237
238 + # Dual batch overlap. Created in initialize_kv_cache(), once everything
239 + # it runs the microbatched forward with exists.
240 + self.ubatch_runner: UBatchRunner | None = None
241 +
242 # Detect EP all2all peer faults to prevent emitting corrupted output.
243 # Only meaningful for MoE + DP with an FT-capable all2all backend.
244 self.check_ep_fault = False
@@ -634,6 +643,14 @@ class GPUModelRunner(LoRAModelRunnerMixin):
643 self.block_tables,
644 cls=self.pcp_manager_cls,
645 )
646 + self.ubatch_runner = maybe_build_ubatch_runner(
647 + self.vllm_config,
648 + self.device,
649 + self.model_state,
650 + self.attn_groups,
651 + self.kv_cache_config,
652 + self.max_num_reqs,
653 + )
654 initialize_mamba_ssu_backend(
655 self.vllm_config.mamba_config,
656 self.kv_cache_config,
@@ -1597,6 +1614,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1614 max_query_len=max_query_len,
1615 need_eager=is_profile or skip_compiled,
1616 num_active_loras=num_active_loras,
1617 + parallel_config=self.parallel_config,
1618 + allow_ubatching=(
1619 + self.ubatch_runner is not None and not skip_attn_for_dummy_run
1620 + ),
1621 + uniform_decode=uniform_tok_count == self.decode_query_len,
1622 )
1623
1624 if batch_desc.num_tokens == 0:
@@ -1665,7 +1687,14 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1687
1688 attn_metadata = None
1689 slot_mappings_by_layer = None
1668 - if not (dummy_run and skip_attn_for_dummy_run):
1690 + ubatch_state: UBatchState | None = None
1691 + if batch_desc.num_ubatches > 1:
1692 + assert self.ubatch_runner is not None
1693 + assert block_tables is not None and slot_mappings is not None
1694 + ubatch_state = self.ubatch_runner.prepare(
1695 + input_batch, block_tables, slot_mappings
1696 + )
1697 + elif not (dummy_run and skip_attn_for_dummy_run):
1698 assert slot_mappings is not None
1699 slot_mappings_by_layer = build_slot_mappings_by_layer(
1700 slot_mappings, self.kv_cache_config
@@ -1751,7 +1780,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1780 del intermediate_tensors
1781
1782 # Update the EPLB meta.
1754 - self.eplb.prepare_forward(self.model_config, input_batch.num_tokens)
1783 + ubatch_slices = ubatch_state.slices if ubatch_state is not None else None
1784 + self.eplb.prepare_forward(
1785 + self.model_config, input_batch.num_tokens, ubatch_slices
1786 + )
1787
1788 self.step_timing.record_batch(
1789 input_batch, batch_desc.cg_mode == CUDAGraphMode.FULL
@@ -1783,12 +1815,18 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1815 dp_sync.num_tokens_across_dp if dp_sync is not None else None
1816 ),
1817 batch_descriptor=batch_descriptor,
1818 + ubatch_slices=ubatch_slices,
1819 slot_mapping=slot_mappings_by_layer,
1820 skip_compiled=skip_compiled,
1821 is_padding=input_batch.is_padding,
1822 ):
1823 self.kv_connector.pre_forward(scheduler_output)
1791 - if batch_desc.cg_mode == CUDAGraphMode.PIECEWISE:
1824 + if ubatch_state is not None:
1825 + assert self.ubatch_runner is not None
1826 + model_output = self.ubatch_runner.run(
1827 + self.model, model_inputs, ubatch_state
1828 + )
1829 + elif batch_desc.cg_mode == CUDAGraphMode.PIECEWISE:
1830 # Run the PIECEWISE graph (compiled PW cudagraph or breakable
1831 # cudagraph, chosen inside run_pw_graph). cg_mode is only
1832 # PIECEWISE after the cudagraph manager exists.
vllm/v1/worker/gpu/model_states/default.py
+2
@@ -172,6 +172,7 @@ class DefaultModelState(ModelState):
172 attn_groups: list[list[AttentionGroup]],
173 kv_cache_config: KVCacheConfig,
174 for_capture: bool = False,
175 + ubatch_idx: int = 0,
176 ) -> dict[str, Any]:
177 if cudagraph_mode == CUDAGraphMode.FULL:
178 # Use padded sizes - padding is handled by model_runner.prepare_attn.
@@ -224,5 +225,6 @@ class DefaultModelState(ModelState):
225 mm_req_doc_ranges=req_doc_ranges,
226 for_cudagraph_capture=for_capture,
227 rswa_prefix_lens=input_batch.prompt_lens,
228 + ubatch_idx=ubatch_idx,
229 )
230 return attn_metadata
vllm/v1/worker/gpu/model_states/encoder_decoder.py
+2
@@ -116,7 +116,9 @@ class EncoderDecoderModelState(ModelState):
116 attn_groups: list[list[AttentionGroup]],
117 kv_cache_config: KVCacheConfig,
118 for_capture: bool = False,
119 + ubatch_idx: int = 0,
120 ) -> dict[str, Any]:
121 + assert ubatch_idx == 0, "DBO is not supported"
122 if cudagraph_mode == CUDAGraphMode.FULL:
123 num_reqs = input_batch.num_reqs_after_padding
124 num_tokens = input_batch.num_tokens_after_padding
vllm/v1/worker/gpu/model_states/encoder_only.py
+2
@@ -167,7 +167,9 @@ class EncoderOnlyModelState(DefaultModelState):
167 attn_groups: list[list[AttentionGroup]],
168 kv_cache_config: KVCacheConfig,
169 for_capture: bool = False,
170 + ubatch_idx: int = 0,
171 ) -> dict[str, Any]:
172 + assert ubatch_idx == 0, "DBO is not supported"
173 attn_metadata = super().prepare_attn(
174 input_batch,
175 cudagraph_mode,
vllm/v1/worker/gpu/model_states/interface.py
+1
@@ -221,6 +221,7 @@ class ModelState(ABC):
221 attn_groups: list[list[AttentionGroup]],
222 kv_cache_config: KVCacheConfig,
223 for_capture: bool = False,
224 + ubatch_idx: int = 0,
225 ) -> dict[str, Any]:
226 raise NotImplementedError
227
vllm/v1/worker/gpu/model_states/mamba_hybrid.py
+2
@@ -238,7 +238,9 @@ class MambaHybridModelState(DefaultModelState):
238 attn_groups: list[list[AttentionGroup]],
239 kv_cache_config: KVCacheConfig,
240 for_capture: bool = False,
241 + ubatch_idx: int = 0,
242 ) -> dict[str, Any]:
243 + assert ubatch_idx == 0, "DBO is not supported"
244 if cudagraph_mode == CUDAGraphMode.FULL:
245 num_reqs = input_batch.num_reqs_after_padding
246 num_tokens = input_batch.num_tokens_after_padding
vllm/v1/worker/gpu/ubatch_utils.py new
+473
@@ -0,0 +1,473 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Microbatching (DBO) helpers for the V2 GPU model runner."""
4 +
5 +import threading
6 +from dataclasses import replace
7 +from typing import Any, NamedTuple
8 +
9 +import numpy as np
10 +import torch
11 +
12 +from vllm.config import CUDAGraphMode, VllmConfig
13 +from vllm.forward_context import (
14 + DPMetadata,
15 + ForwardContext,
16 + create_forward_context,
17 + override_forward_context,
18 +)
19 +from vllm.logger import init_logger
20 +from vllm.sequence import IntermediateTensors
21 +from vllm.utils.torch_utils import current_stream
22 +from vllm.v1.kv_cache_interface import KVCacheConfig
23 +from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
24 +from vllm.v1.worker.gpu.input_batch import InputBatch
25 +from vllm.v1.worker.gpu.model_states.interface import ModelState
26 +from vllm.v1.worker.ubatch_utils import (
27 + UBatchSlice,
28 + UBatchSlices,
29 + create_sm_control_context,
30 + maybe_create_ubatch_slices,
31 +)
32 +from vllm.v1.worker.ubatching import make_ubatch_contexts
33 +from vllm.v1.worker.utils import AttentionGroup
34 +
35 +logger = init_logger(__name__)
36 +
37 +
38 +class UBatchState(NamedTuple):
39 + """Per-microbatch inputs for one dual-batch-overlap step."""
40 +
41 + slices: UBatchSlices
42 + forward_contexts: list[ForwardContext]
43 +
44 +
45 +def create_ubatch_slices(input_batch: InputBatch, num_ubatches: int) -> UBatchSlices:
46 + """Split a DP-padded batch into the slices its microbatches run on.
47 +
48 + Splitting at the midpoint of the DP-padded token count leaves the trailing
49 + microbatch anywhere from full to entirely inside the padding. Clamping its
50 + request slice onto the last request leaves it holding that request with
51 + zero query tokens -- the shape a straddling request already takes -- so it
52 + stays well-formed with no work to do, like a dummy run.
53 + """
54 + _, ubatch_slices = maybe_create_ubatch_slices(
55 + True,
56 + input_batch.num_scheduled_tokens,
57 + input_batch.num_tokens_after_padding,
58 + input_batch.num_reqs_after_padding,
59 + num_ubatches,
60 + )
61 + assert ubatch_slices is not None
62 + return [
63 + UBatchSlice(
64 + slice(
65 + min(s.request_slice.start, input_batch.num_reqs - 1),
66 + min(s.request_slice.stop, input_batch.num_reqs_after_padding),
67 + ),
68 + s.token_slice,
69 + )
70 + for s in ubatch_slices
71 + ]
72 +
73 +
74 +def _slice_input_batch(
75 + input_batch: InputBatch,
76 + ubatch_slice: UBatchSlice,
77 + query_start_loc_buf: torch.Tensor,
78 + seq_lens_buf: torch.Tensor,
79 +) -> InputBatch:
80 + """Build the sub-`InputBatch` a single microbatch runs on.
81 +
82 + A request straddling the boundary appears in both microbatches, its query
83 + truncated to the tokens each one owns.
84 +
85 + Everything except `query_start_loc` and `seq_lens` is a view of the full
86 + batch's buffers, so slicing costs no allocation. Those two are rebased onto
87 + the microbatch's token range and written to the caller's buffers, which
88 + keeps them valid under CUDA graph capture and replay.
89 +
90 + The sub-batch describes the forward pass only. Sampling runs once over the
91 + merged batch, so `logits_indices`, `cu_num_logits` and the draft-token
92 + fields are carried over unchanged and must not be read off a sub-batch.
93 + """
94 + assert not ubatch_slice.is_empty(), f"Ubatch slice {ubatch_slice} is empty"
95 +
96 + req_start = ubatch_slice.request_slice.start
97 + req_stop = ubatch_slice.request_slice.stop
98 + tok_start = ubatch_slice.token_slice.start
99 + tok_stop = ubatch_slice.token_slice.stop
100 +
101 + num_reqs_padded = req_stop - req_start
102 + num_tokens_padded = tok_stop - tok_start
103 + # The trailing microbatch may extend past the real batch into CUDA graph
104 + # padding; the unpadded counts stop at the last real request/token.
105 + num_reqs = max(0, min(req_stop, input_batch.num_reqs) - req_start)
106 + num_tokens = max(0, min(tok_stop, input_batch.num_tokens) - tok_start)
107 +
108 + query_start_loc = _slice_query_start_loc(
109 + input_batch.query_start_loc,
110 + query_start_loc_buf[: num_reqs_padded + 1],
111 + req_start,
112 + req_stop,
113 + tok_start,
114 + num_tokens_padded,
115 + )
116 + query_start_loc_np = np.clip(
117 + input_batch.query_start_loc_np[req_start : req_stop + 1] - tok_start,
118 + 0,
119 + num_tokens_padded,
120 + ).astype(np.int32)
121 +
122 + seq_lens = _slice_seq_lens(
123 + input_batch.seq_lens,
124 + seq_lens_buf[:num_reqs_padded],
125 + input_batch.query_start_loc,
126 + req_start,
127 + req_stop,
128 + tok_stop,
129 + )
130 + # Same truncation as `_slice_seq_lens`, on the host-side upper bound.
131 + seq_lens_cpu_upper_bound = input_batch.seq_lens_cpu_upper_bound[
132 + req_start:req_stop
133 + ].clone()
134 + tokens_truncated = max(0, int(input_batch.query_start_loc_np[req_stop]) - tok_stop)
135 + if tokens_truncated:
136 + seq_lens_cpu_upper_bound[-1] -= tokens_truncated
137 +
138 + # Query lengths of the truncated requests, so consumers that derive
139 + # max_query_len from this array see the microbatch's own lengths.
140 + num_scheduled_tokens = np.diff(query_start_loc_np)[:num_reqs]
141 +
142 + dcp_local_seq_lens = input_batch.dcp_local_seq_lens
143 + if dcp_local_seq_lens is not None:
144 + # NOTE: a request split across microbatches keeps its full local
145 + # seq_len here. DCP is not yet validated with DBO.
146 + dcp_local_seq_lens = dcp_local_seq_lens[req_start:req_stop]
147 +
148 + return replace(
149 + input_batch,
150 + req_ids=input_batch.req_ids[req_start : min(req_stop, input_batch.num_reqs)],
151 + num_reqs=num_reqs,
152 + num_reqs_after_padding=num_reqs_padded,
153 + idx_mapping=input_batch.idx_mapping[req_start:req_stop],
154 + idx_mapping_np=input_batch.idx_mapping_np[req_start:req_stop],
155 + num_scheduled_tokens=num_scheduled_tokens,
156 + num_tokens=num_tokens,
157 + num_tokens_after_padding=num_tokens_padded,
158 + query_start_loc=query_start_loc,
159 + query_start_loc_np=query_start_loc_np,
160 + seq_lens=seq_lens,
161 + seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
162 + dcp_local_seq_lens=dcp_local_seq_lens,
163 + num_computed_tokens_np=input_batch.num_computed_tokens_np[req_start:req_stop],
164 + prefill_len_np=input_batch.prefill_len_np[req_start:req_stop],
165 + num_computed_prefill_tokens_np=input_batch.num_computed_prefill_tokens_np[
166 + req_start:req_stop
167 + ],
168 + is_prefilling_np=input_batch.is_prefilling_np[req_start:req_stop],
169 + input_ids=input_batch.input_ids[tok_start:tok_stop],
170 + positions=input_batch.positions[tok_start:tok_stop],
171 + is_padding=input_batch.is_padding[tok_start:tok_stop],
172 + prompt_lens=(
173 + None
174 + if input_batch.prompt_lens is None
175 + else input_batch.prompt_lens[req_start:req_stop]
176 + ),
177 + )
178 +
179 +
180 +def _slice_query_start_loc(
181 + query_start_loc: torch.Tensor,
182 + out: torch.Tensor,
183 + req_start: int,
184 + req_stop: int,
185 + tok_start: int,
186 + num_tokens_padded: int,
187 +) -> torch.Tensor:
188 + """Rebase query_start_loc onto the microbatch's token range.
189 +
190 + The clamp truncates the requests straddling either boundary to the tokens
191 + this microbatch owns.
192 + """
193 + torch.sub(query_start_loc[req_start : req_stop + 1], tok_start, out=out)
194 + return out.clamp_(0, num_tokens_padded)
195 +
196 +
197 +def _slice_seq_lens(
198 + seq_lens: torch.Tensor,
199 + out: torch.Tensor,
200 + query_start_loc: torch.Tensor,
201 + req_start: int,
202 + req_stop: int,
203 + tok_stop: int,
204 +) -> torch.Tensor:
205 + """Slice seq_lens, shortening the request truncated at the boundary.
206 +
207 + Only tokens past the end of this microbatch are missing; tokens it owns in
208 + an *earlier* microbatch are computed before this one reads them, so they
209 + still count. Deriving the truncation from this microbatch's own query
210 + lengths would subtract them twice. It is computed from tensors, not Python
211 + ints, so this stays valid inside a CUDA graph.
212 + """
213 + out.copy_(seq_lens[req_start:req_stop])
214 + last = req_stop - req_start - 1
215 + out[last] -= (query_start_loc[req_stop] - tok_stop).clamp_(min=0)
216 + return out
217 +
218 +
219 +def slice_model_inputs(
220 + model_inputs: dict[str, Any], token_slice: slice
221 +) -> dict[str, Any]:
222 + """Narrow the model's per-token inputs to one microbatch."""
223 + sliced = dict(model_inputs)
224 + for key in ("input_ids", "inputs_embeds"):
225 + value = model_inputs.get(key)
226 + if value is not None:
227 + sliced[key] = value[token_slice]
228 +
229 + positions = model_inputs["positions"]
230 + # M-RoPE carries a leading section dim.
231 + sliced["positions"] = (
232 + positions[:, token_slice] if positions.ndim == 2 else positions[token_slice]
233 + )
234 +
235 + intermediate_tensors = model_inputs.get("intermediate_tensors")
236 + if intermediate_tensors is not None:
237 + sliced["intermediate_tensors"] = intermediate_tensors[token_slice]
238 + return sliced
239 +
240 +
241 +def merge_ubatch_outputs(outputs: list[Any]) -> Any:
242 + """Reassemble the full-batch model output from the per-microbatch ones.
243 +
244 + Models return hidden states, a tuple of those plus auxiliary hidden states
245 + (EAGLE3), or IntermediateTensors on non-final pipeline ranks.
246 + """
247 + first = outputs[0]
248 + if isinstance(first, IntermediateTensors):
249 + return IntermediateTensors(
250 + {
251 + key: torch.cat([out.tensors[key] for out in outputs], dim=0)
252 + for key in first.tensors
253 + }
254 + )
255 + if isinstance(first, tuple):
256 + return tuple(torch.cat(parts, dim=0) for parts in zip(*outputs))
257 + return torch.cat(outputs, dim=0)
258 +
259 +
260 +class UBatchRunner:
261 + """Runs the model on two or more microbatches that overlap each other.
262 +
263 + Each microbatch gets a thread and its own forward context. Only one thread
264 + holds the GPU at a time; they hand off to each other at the communication
265 + points inside the model (see `vllm.v1.worker.ubatching`), so one
266 + microbatch's expert all-to-all overlaps the other's compute.
267 + """
268 +
269 + def __init__(
270 + self,
271 + vllm_config: VllmConfig,
272 + device: torch.device,
273 + model_state: ModelState,
274 + attn_groups: list[list[AttentionGroup]],
275 + kv_cache_config: KVCacheConfig,
276 + max_num_reqs: int,
277 + ):
278 + self.vllm_config = vllm_config
279 + self.parallel_config = vllm_config.parallel_config
280 + self.num_ubatches = self.parallel_config.num_ubatches
281 + self.device = device
282 + self.model_state = model_state
283 + self.attn_groups = attn_groups
284 + self.kv_cache_config = kv_cache_config
285 + # `query_start_loc` and `seq_lens` are rebased onto each microbatch's
286 + # own token range, so they cannot be views of the full batch's buffers.
287 + # Allocating up front keeps their addresses stable across replays.
288 + self.ubatch_query_start_loc = [
289 + torch.zeros(max_num_reqs + 1, dtype=torch.int32, device=device)
290 + for _ in range(self.num_ubatches)
291 + ]
292 + self.ubatch_seq_lens = [
293 + torch.zeros(max_num_reqs, dtype=torch.int32, device=device)
294 + for _ in range(self.num_ubatches)
295 + ]
296 + self.comm_stream = torch.cuda.Stream(device=device)
297 + # The microbatch threads plus the thread that starts them.
298 + self.ready_barrier = threading.Barrier(self.num_ubatches + 1)
299 + self.sm_control = create_sm_control_context(self.parallel_config)
300 +
301 + def prepare(
302 + self,
303 + input_batch: InputBatch,
304 + block_tables: tuple[torch.Tensor, ...],
305 + slot_mappings: torch.Tensor,
306 + ) -> UBatchState:
307 + """Split the batch into the microbatches the step will run on.
308 +
309 + Attention metadata is built per microbatch and carried in the forward
310 + contexts the threads install, not in the caller's.
311 + """
312 + ubatch_slices = create_ubatch_slices(input_batch, self.num_ubatches)
313 +
314 + attn_metadata = []
315 + slot_mappings_by_layer = []
316 + is_padding = []
317 + for i, ubatch_slice in enumerate(ubatch_slices):
318 + ubatch = _slice_input_batch(
319 + input_batch,
320 + ubatch_slice,
321 + self.ubatch_query_start_loc[i],
322 + self.ubatch_seq_lens[i],
323 + )
324 + ubatch_slot_mappings = slot_mappings[:, ubatch_slice.token_slice]
325 + ubatch_block_tables = tuple(
326 + block_table[ubatch_slice.request_slice] for block_table in block_tables
327 + )
328 + attn_metadata.append(
329 + self.model_state.prepare_attn(
330 + ubatch,
331 + CUDAGraphMode.NONE,
332 + ubatch_block_tables,
333 + ubatch_slot_mappings,
334 + self.attn_groups,
335 + self.kv_cache_config,
336 + ubatch_idx=i,
337 + )
338 + )
339 + slot_mappings_by_layer.append(
340 + build_slot_mappings_by_layer(ubatch_slot_mappings, self.kv_cache_config)
341 + )
342 + is_padding.append(ubatch.is_padding)
343 +
344 + return UBatchState(
345 + slices=ubatch_slices,
346 + forward_contexts=self._make_forward_contexts(
347 + ubatch_slices, attn_metadata, slot_mappings_by_layer, is_padding
348 + ),
349 + )
350 +
351 + def _make_forward_contexts(
352 + self,
353 + ubatch_slices: UBatchSlices,
354 + attn_metadata: list[dict[str, Any]],
355 + slot_mappings_by_layer: list[dict[str, torch.Tensor]],
356 + is_padding: list[torch.Tensor | None],
357 + ) -> list[ForwardContext]:
358 + """Build one forward context per microbatch.
359 +
360 + The expert all-to-all runs once per microbatch, so each context's DP
361 + metadata carries that microbatch's token count. Microbatching pads every
362 + DP rank alike, so each rank can assume that count for the others too.
363 + """
364 + dp_size = self.parallel_config.data_parallel_size
365 + forward_contexts = []
366 + for i, ubatch_slice in enumerate(ubatch_slices):
367 + num_tokens = ubatch_slice.num_tokens
368 + num_tokens_across_dp = torch.full(
369 + (dp_size,), num_tokens, dtype=torch.int32, device="cpu"
370 + )
371 + forward_contexts.append(
372 + create_forward_context(
373 + attn_metadata[i],
374 + self.vllm_config,
375 + dp_metadata=DPMetadata.make(
376 + self.parallel_config, num_tokens, num_tokens_across_dp
377 + ),
378 + cudagraph_runtime_mode=CUDAGraphMode.NONE,
379 + slot_mapping=slot_mappings_by_layer[i],
380 + is_padding=is_padding[i],
381 + )
382 + )
383 + return forward_contexts
384 +
385 + def run(
386 + self,
387 + model: Any,
388 + model_inputs: dict[str, Any],
389 + ubatch_state: UBatchState,
390 + ) -> Any:
391 + ubatch_slices = ubatch_state.slices
392 + assert len(ubatch_slices) == len(ubatch_state.forward_contexts)
393 + assert len(ubatch_slices) == self.num_ubatches
394 +
395 + ubatch_contexts = make_ubatch_contexts(
396 + num_micro_batches=self.num_ubatches,
397 + comm_stream=self.comm_stream,
398 + compute_stream=current_stream(),
399 + forward_contexts=ubatch_state.forward_contexts,
400 + ready_barrier=self.ready_barrier,
401 + )
402 +
403 + outputs: dict[int, Any] = {}
404 + errors: dict[int, BaseException] = {}
405 +
406 + @torch.inference_mode()
407 + def run_ubatch(ubatch_context, inputs: dict[str, Any]) -> None:
408 + try:
409 + with ubatch_context:
410 + outputs[ubatch_context.id] = model(**inputs)
411 + except BaseException as e: # noqa: BLE001
412 + # Recorded so the join below can report which microbatch broke.
413 + # Note the siblings are not told to unwind: microbatches hand
414 + # control to each other, so one that dies mid-forward leaves
415 + # the others parked on a handoff that never comes and the step
416 + # hangs. The V1 runner behaves the same way; fixing it means
417 + # changing the shared handoff protocol in ubatching.py, which
418 + # is deliberately out of scope here.
419 + errors[ubatch_context.id] = e
420 +
421 + # The threads manage the forward context themselves; clear it here so
422 + # it is restored correctly once they are done.
423 + with override_forward_context(None), self.sm_control:
424 + threads = []
425 + for ubatch_context, ubatch_slice in zip(ubatch_contexts, ubatch_slices):
426 + thread = threading.Thread(
427 + target=run_ubatch,
428 + args=(
429 + ubatch_context,
430 + slice_model_inputs(model_inputs, ubatch_slice.token_slice),
431 + ),
432 + )
433 + threads.append(thread)
434 + thread.start()
435 +
436 + # Wait for every thread to reach its context, then start the first.
437 + self.ready_barrier.wait()
438 + ubatch_contexts[0].cpu_wait_event.set()
439 + for thread in threads:
440 + thread.join()
441 +
442 + if errors:
443 + failed = min(errors)
444 + raise RuntimeError(
445 + f"Microbatch {failed} of {self.num_ubatches} failed"
446 + ) from errors[failed]
447 + return merge_ubatch_outputs([outputs[i] for i in range(self.num_ubatches)])
448 +
449 +
450 +def maybe_build_ubatch_runner(
451 + vllm_config: VllmConfig,
452 + device: torch.device,
453 + model_state: ModelState,
454 + attn_groups: list[list[AttentionGroup]],
455 + kv_cache_config: KVCacheConfig,
456 + max_num_reqs: int,
457 +) -> UBatchRunner | None:
458 + """Build the microbatch runner, or None when DBO is not in use.
459 +
460 + Microbatching needs the DP handshake to agree on it, so it is only
461 + available with more than one DP rank (as in the V1 runner).
462 + """
463 + parallel_config = vllm_config.parallel_config
464 + if not parallel_config.use_ubatching or parallel_config.data_parallel_size <= 1:
465 + return None
466 +
467 + logger.info_once(
468 + "Dual batch overlap is enabled. Microbatched steps run without "
469 + "CUDA graphs on the V2 model runner."
470 + )
471 + return UBatchRunner(
472 + vllm_config, device, model_state, attn_groups, kv_cache_config, max_num_reqs
473 + )
vllm/v1/worker/gpu_ubatch_wrapper.py
+2 -93
@@ -11,7 +11,6 @@ import torch
11 import vllm.envs as envs
12 from vllm.compilation.cuda_graph import CUDAGraphWrapper
13 from vllm.config import CUDAGraphMode, VllmConfig
14 -from vllm.distributed import get_ep_group
14 from vllm.distributed.device_communicators.pynccl_allocator import set_graph_pool_id
15 from vllm.forward_context import (
16 DPMetadata,
@@ -23,9 +22,7 @@ from vllm.logger import init_logger
22 from vllm.model_executor.offloader.base import get_offloader
23 from vllm.platforms import current_platform
24 from vllm.sequence import IntermediateTensors
26 -from vllm.utils.deep_gemm import set_num_sms as deep_gemm_set_num_sms
27 -from vllm.utils.import_utils import has_deep_gemm
28 -from vllm.utils.platform_utils import num_compute_units
25 +from vllm.v1.worker.ubatch_utils import create_sm_control_context
26 from vllm.v1.worker.ubatching import UBatchContext, make_ubatch_contexts
27
28 logger = init_logger(__name__)
@@ -65,51 +62,6 @@ class CUDAGraphMetaData:
62 outputs: Any | None = None
63
64
68 -class SMControlContextManager:
69 - def __init__(
70 - self,
71 - comm_sms: int,
72 - set_comm_sms: Callable[[int], None],
73 - set_compute_sms: Callable[[int], None],
74 - ):
75 - """
76 - Context manager for controlling SM (Streaming Multiprocessor)
77 - allocation. Upon entering the context, it sets the number of SMs
78 - allocated for communication and computation to comm_sms and
79 - total_sms - comm_sms respectively. Upon exiting, it restores the
80 - allocation to use all available SMs (i.e. total_sms).
81 -
82 - Args:
83 - comm_sms (int): The number of SMs to allocate for communication.
84 - (The remainder will be used for computation.)
85 - set_comm_sms (Callable[[int], None]):
86 - A function that sets the number of SMs for communication.
87 - set_compute_sms (Callable[[int], None]):
88 - A function that sets the number of SMs for computation.
89 - """
90 -
91 - assert current_platform.is_cuda() or current_platform.is_rocm(), (
92 - "SM/CU control is supported on CUDA and ROCm platforms"
93 - )
94 - device = torch.accelerator.current_device_index()
95 - total_sms = num_compute_units(device)
96 -
97 - assert comm_sms < total_sms
98 - self.total_sms = total_sms
99 - self.compute_sms = total_sms - comm_sms
100 - self.comm_sms = comm_sms
101 - self.set_comm_sms = set_comm_sms
102 - self.set_compute_sms = set_compute_sms
103 -
104 - def __enter__(self):
105 - self.set_comm_sms(self.comm_sms)
106 - self.set_compute_sms(self.compute_sms)
107 -
108 - def __exit__(self, exc_type, exc_value, traceback):
109 - self.set_comm_sms(self.total_sms)
110 - self.set_compute_sms(self.total_sms)
111 -
112 -
65 class UBatchWrapper:
66 def __init__(
67 self,
@@ -135,7 +87,7 @@ class UBatchWrapper:
87 runnable, vllm_config, runtime_mode=runtime_mode
88 )
89
138 - self.sm_control = self._create_sm_control_context(vllm_config)
90 + self.sm_control = create_sm_control_context(vllm_config.parallel_config)
91 self.device = device
92 self.is_debugging_mode = envs.VLLM_LOGGING_LEVEL == "DEBUG"
93 self._runnable_str = str(runnable) if self.is_debugging_mode else None
@@ -151,49 +103,6 @@ class UBatchWrapper:
103 if self.cudagraph_wrapper is not None:
104 self.cudagraph_wrapper.clear_graphs()
105
154 - @staticmethod
155 - def _create_sm_control_context(vllm_config: VllmConfig):
156 - comm_sms: int = envs.VLLM_DBO_COMM_SMS
157 - rocm_deepep_ht_dbo = (
158 - current_platform.is_rocm()
159 - and vllm_config.parallel_config.enable_dbo
160 - and vllm_config.parallel_config.all2all_backend == "deepep_high_throughput"
161 - )
162 - if rocm_deepep_ht_dbo:
163 - # On ROCm, reserving CUs for DeepEP HT communication under DBO
164 - # corrupts DP+EP generation accuracy. Keep the backend active, but
165 - # leave all CUs visible to the compute and communication kernels.
166 - comm_sms = 0
167 -
168 - set_comm_sms = lambda sms: None
169 - if vllm_config.parallel_config.enable_expert_parallel:
170 - # Currently only DeepEP highthroughput supports SM control so this
171 - # only affects that case.
172 - ep_group = get_ep_group()
173 - device_communicator = ep_group.device_communicator
174 - all2all_manager = None
175 - if device_communicator is not None:
176 - all2all_manager = device_communicator.all2all_manager
177 -
178 - if all2all_manager is not None:
179 - max_sms_used = all2all_manager.max_sms_used()
180 - if max_sms_used is not None:
181 - comm_sms = min(comm_sms, max_sms_used)
182 -
183 - if comm_sms > 0 and all2all_manager is not None:
184 - set_comm_sms = lambda sms: all2all_manager.set_num_sms(sms)
185 -
186 - # TODO(lucas): support other kernels besides DeepGEMM
187 - set_compute_sms = lambda sms: None
188 - if has_deep_gemm() and comm_sms > 0:
189 - set_compute_sms = lambda sms: deep_gemm_set_num_sms(sms)
190 -
191 - return SMControlContextManager(
192 - comm_sms=comm_sms,
193 - set_comm_sms=set_comm_sms,
194 - set_compute_sms=set_compute_sms,
195 - )
196 -
106 def __getattr__(self, key: str):
107 # allow accessing the attributes of the runnable.
108 if hasattr(self.runnable, key):
vllm/v1/worker/ubatch_utils.py
+103
@@ -1,12 +1,19 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from collections.abc import Callable
4 from dataclasses import dataclass
5 from typing import TypeAlias
6
7 import numpy as np
8 import torch
9
10 +import vllm.envs as envs
11 from vllm.config import ParallelConfig
12 +from vllm.distributed import get_ep_group
13 +from vllm.platforms import current_platform
14 +from vllm.utils.deep_gemm import set_num_sms as deep_gemm_set_num_sms
15 +from vllm.utils.import_utils import has_deep_gemm
16 +from vllm.utils.platform_utils import num_compute_units
17 from vllm.v1.attention.backend import CommonAttentionMetadata
18
19
@@ -29,12 +36,108 @@ class UBatchSlice:
36 UBatchSlices: TypeAlias = list[UBatchSlice]
37
38
39 +class SMControlContextManager:
40 + def __init__(
41 + self,
42 + comm_sms: int,
43 + set_comm_sms: Callable[[int], None],
44 + set_compute_sms: Callable[[int], None],
45 + ):
46 + """
47 + Context manager for controlling SM (Streaming Multiprocessor)
48 + allocation. Upon entering the context, it sets the number of SMs
49 + allocated for communication and computation to comm_sms and
50 + total_sms - comm_sms respectively. Upon exiting, it restores the
51 + allocation to use all available SMs (i.e. total_sms).
52 +
53 + Args:
54 + comm_sms (int): The number of SMs to allocate for communication.
55 + (The remainder will be used for computation.)
56 + set_comm_sms (Callable[[int], None]):
57 + A function that sets the number of SMs for communication.
58 + set_compute_sms (Callable[[int], None]):
59 + A function that sets the number of SMs for computation.
60 + """
61 +
62 + assert current_platform.is_cuda() or current_platform.is_rocm(), (
63 + "SM/CU control is supported on CUDA and ROCm platforms"
64 + )
65 + device = torch.accelerator.current_device_index()
66 + total_sms = num_compute_units(device)
67 +
68 + assert comm_sms < total_sms
69 + self.total_sms = total_sms
70 + self.compute_sms = total_sms - comm_sms
71 + self.comm_sms = comm_sms
72 + self.set_comm_sms = set_comm_sms
73 + self.set_compute_sms = set_compute_sms
74 +
75 + def __enter__(self):
76 + self.set_comm_sms(self.comm_sms)
77 + self.set_compute_sms(self.compute_sms)
78 +
79 + def __exit__(self, exc_type, exc_value, traceback):
80 + self.set_comm_sms(self.total_sms)
81 + self.set_compute_sms(self.total_sms)
82 +
83 +
84 +def create_sm_control_context(
85 + parallel_config: ParallelConfig,
86 +) -> SMControlContextManager:
87 + """Reserve SMs for communication kernels while microbatches overlap."""
88 + comm_sms: int = envs.VLLM_DBO_COMM_SMS
89 + rocm_deepep_ht_dbo = (
90 + current_platform.is_rocm()
91 + and parallel_config.enable_dbo
92 + and parallel_config.all2all_backend == "deepep_high_throughput"
93 + )
94 + if rocm_deepep_ht_dbo:
95 + # On ROCm, reserving CUs for DeepEP HT communication under DBO
96 + # corrupts DP+EP generation accuracy. Keep the backend active, but
97 + # leave all CUs visible to the compute and communication kernels.
98 + comm_sms = 0
99 +
100 + set_comm_sms = lambda sms: None
101 + if parallel_config.enable_expert_parallel:
102 + # Currently only DeepEP highthroughput supports SM control so this
103 + # only affects that case.
104 + ep_group = get_ep_group()
105 + device_communicator = ep_group.device_communicator
106 + all2all_manager = None
107 + if device_communicator is not None:
108 + all2all_manager = device_communicator.all2all_manager
109 +
110 + if all2all_manager is not None:
111 + max_sms_used = all2all_manager.max_sms_used()
112 + if max_sms_used is not None:
113 + comm_sms = min(comm_sms, max_sms_used)
114 +
115 + if comm_sms > 0 and all2all_manager is not None:
116 + set_comm_sms = lambda sms: all2all_manager.set_num_sms(sms)
117 +
118 + # TODO(lucas): support other kernels besides DeepGEMM
119 + set_compute_sms = lambda sms: None
120 + if has_deep_gemm() and comm_sms > 0:
121 + set_compute_sms = lambda sms: deep_gemm_set_num_sms(sms)
122 +
123 + return SMControlContextManager(
124 + comm_sms=comm_sms,
125 + set_comm_sms=set_comm_sms,
126 + set_compute_sms=set_compute_sms,
127 + )
128 +
129 +
130 def is_last_ubatch_empty(
131 orig_num_tokens: int, padded_num_tokens: int, num_ubatches: int
132 ) -> bool:
133 return (padded_num_tokens // num_ubatches) * (num_ubatches - 1) >= orig_num_tokens
134
135
136 +def get_num_ubatches(parallel_config: ParallelConfig) -> int:
137 + """How many microbatches a step is split into; 1 when microbatching is off."""
138 + return parallel_config.num_ubatches if parallel_config.use_ubatching else 1
139 +
140 +
141 def check_ubatch_thresholds(
142 config: ParallelConfig, num_tokens: int, uniform_decode: bool
143 ) -> bool: