main
py 965 lines 35.3 KB
Raw
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 contextlib import nullcontext
14 from dataclasses import replace
15 from types import SimpleNamespace
16 from typing import Any, cast
17 from unittest.mock import patch
18
19 import numpy as np
20 import pytest
21 import torch
22
23 from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
24 from vllm.config import (
25 CompilationConfig,
26 CUDAGraphMode,
27 ModelConfig,
28 ParallelConfig,
29 VllmConfig,
30 )
31 from vllm.forward_context import create_forward_context
32 from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
33 from vllm.v1.kv_cache_interface import KVCacheConfig
34 from vllm.v1.worker import gpu_ubatch_wrapper as legacy_gpu_ubatch_wrapper
35 from vllm.v1.worker.gpu import cp_utils as gpu_cp_utils
36 from vllm.v1.worker.gpu import cudagraph_utils, dp_utils
37 from vllm.v1.worker.gpu.cudagraph_utils import (
38 BatchExecutionDescriptor,
39 CudaGraphManager,
40 )
41 from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
42 from vllm.v1.worker.gpu.model_states.interface import ModelState
43 from vllm.v1.worker.gpu.ubatch_utils import (
44 UBatchRunner,
45 UBatchState,
46 _slice_input_batch,
47 create_ubatch_slices,
48 slice_model_inputs,
49 )
50 from vllm.v1.worker.ubatch_utils import (
51 UBatchSlice,
52 maybe_create_ubatch_slices,
53 split_attn_metadata,
54 )
55 from vllm.v1.worker.ubatching import dbo_current_ubatch_id, dbo_yield
56
57 MAX_NUM_REQS = 32
58 MAX_NUM_TOKENS = 128
59
60
61 def _make_buffers() -> InputBuffers:
62 return InputBuffers(
63 max_num_reqs=MAX_NUM_REQS,
64 max_num_tokens=MAX_NUM_TOKENS,
65 device=torch.device("cpu"),
66 )
67
68
69 def _make_ubatch_buffers(num_ubatches: int = 2) -> list[tuple[torch.Tensor, ...]]:
70 """The per-microbatch (query_start_loc, seq_lens) buffers UBatchRunner owns."""
71 return [
72 (
73 torch.zeros(MAX_NUM_REQS + 1, dtype=torch.int32),
74 torch.zeros(MAX_NUM_REQS, dtype=torch.int32),
75 )
76 for _ in range(num_ubatches)
77 ]
78
79
80 def _make_input_batch(
81 query_lens: list[int],
82 seq_lens: list[int],
83 buffers: InputBuffers,
84 num_reqs_padded: int | None = None,
85 num_tokens_padded: int | None = None,
86 ) -> InputBatch:
87 """Build an InputBatch shaped like the one `prepare_inputs` produces."""
88 num_reqs = len(query_lens)
89 num_tokens = int(sum(query_lens))
90 num_reqs_padded = num_reqs_padded or num_reqs
91 num_tokens_padded = num_tokens_padded or num_tokens
92
93 # make_dummy writes the shared buffers, so fill them afterwards.
94 base = InputBatch.make_dummy(num_reqs, num_tokens, buffers)
95
96 query_start_loc_np = np.zeros(num_reqs_padded + 1, dtype=np.int32)
97 np.cumsum(query_lens, out=query_start_loc_np[1 : num_reqs + 1])
98 # Padded entries repeat the token count, as `prepare_inputs` does.
99 query_start_loc_np[num_reqs + 1 :] = num_tokens
100 buffers.query_start_loc[: num_reqs_padded + 1] = torch.from_numpy(
101 query_start_loc_np
102 )
103
104 buffers.seq_lens[:num_reqs] = torch.tensor(seq_lens, dtype=torch.int32)
105 buffers.seq_lens[num_reqs:num_reqs_padded] = 0
106
107 seq_lens_upper_bound = np.zeros(num_reqs_padded, dtype=np.int32)
108 seq_lens_upper_bound[:num_reqs] = seq_lens
109
110 return replace(
111 base,
112 req_ids=[f"req_{i}" for i in range(num_reqs)],
113 num_reqs=num_reqs,
114 num_reqs_after_padding=num_reqs_padded,
115 idx_mapping=torch.arange(num_reqs, dtype=torch.int32),
116 idx_mapping_np=np.arange(num_reqs, dtype=np.int32),
117 num_scheduled_tokens=np.array(query_lens, dtype=np.int32),
118 num_tokens=num_tokens,
119 num_tokens_after_padding=num_tokens_padded,
120 query_start_loc=buffers.query_start_loc[: num_reqs_padded + 1],
121 query_start_loc_np=query_start_loc_np,
122 seq_lens=buffers.seq_lens[:num_reqs_padded],
123 seq_lens_cpu_upper_bound=torch.from_numpy(seq_lens_upper_bound),
124 num_computed_tokens_np=np.array(seq_lens, dtype=np.int32)
125 - np.array(query_lens, dtype=np.int32),
126 prefill_len_np=np.zeros(num_reqs, dtype=np.int32),
127 num_computed_prefill_tokens_np=np.zeros(num_reqs, dtype=np.int32),
128 is_prefilling_np=np.zeros(num_reqs, dtype=np.bool_),
129 input_ids=buffers.input_ids[:num_tokens_padded],
130 positions=buffers.positions[:num_tokens_padded],
131 is_padding=buffers.is_padding[:num_tokens_padded],
132 )
133
134
135 # (query_lens, seq_lens): uniform decode, a boundary that lands inside a
136 # request, and a boundary that lands exactly on a request start.
137 BATCHES = {
138 "uniform_decode": ([1] * 8, [128 + 4 * i for i in range(8)]),
139 "split_request": ([1, 1, 10, 1, 1], [64, 96, 512, 32, 48]),
140 "aligned_prefills": ([6, 2, 4, 4], [6, 130, 260, 4]),
141 "split_first_and_last": ([3, 9, 3], [70, 300, 41]),
142 # A long prefill trailing a decode: it straddles the boundary and is also
143 # the last request, so the second microbatch holds nothing else.
144 "trailing_prefill_spans_boundary": ([2, 20], [64, 300]),
145 }
146
147
148 @pytest.mark.parametrize("batch_name", list(BATCHES))
149 def test_slicing_matches_v1_split_attn_metadata(batch_name: str):
150 """V2's pre-sliced inputs describe the same microbatches as V1's."""
151 query_lens, seq_lens = BATCHES[batch_name]
152 num_scheduled_tokens = np.array(query_lens, dtype=np.int32)
153 num_tokens = int(num_scheduled_tokens.sum())
154
155 ubatch_slices, _ = maybe_create_ubatch_slices(
156 True, num_scheduled_tokens, num_tokens, len(query_lens), num_ubatches=2
157 )
158 assert ubatch_slices is not None
159
160 v1_metadata = create_common_attn_metadata(
161 BatchSpec(seq_lens=seq_lens, query_lens=query_lens),
162 block_size=16,
163 device=torch.device("cpu"),
164 )
165 v1_ubatches = split_attn_metadata(ubatch_slices, v1_metadata)
166
167 buffers = _make_buffers()
168 ubatch_buffers = _make_ubatch_buffers()
169 input_batch = _make_input_batch(query_lens, seq_lens, buffers)
170
171 for i, (ubatch_slice, v1_ubatch) in enumerate(zip(ubatch_slices, v1_ubatches)):
172 v2_ubatch = _slice_input_batch(input_batch, ubatch_slice, *ubatch_buffers[i])
173
174 assert v2_ubatch.num_reqs == v1_ubatch.num_reqs
175 assert v2_ubatch.num_tokens == v1_ubatch.num_actual_tokens
176 assert int(v2_ubatch.num_scheduled_tokens.max()) == v1_ubatch.max_query_len
177 torch.testing.assert_close(v2_ubatch.query_start_loc, v1_ubatch.query_start_loc)
178 torch.testing.assert_close(v2_ubatch.seq_lens, v1_ubatch.seq_lens)
179 torch.testing.assert_close(
180 torch.from_numpy(v2_ubatch.query_start_loc_np),
181 v1_ubatch.query_start_loc_cpu,
182 )
183 # `prepare_attn` derives max_seq_len from the upper bound. Compare the
184 # bound itself rather than V1's max_seq_len: V1 floors it with the full
185 # batch's max_seq_len to keep the value CUDA-graph capture installed,
186 # which V2 gets from `prepare_attn(for_capture=True)` instead.
187 torch.testing.assert_close(
188 v2_ubatch.seq_lens_cpu_upper_bound, v1_ubatch.seq_lens_cpu_upper_bound
189 )
190
191
192 def test_microbatches_do_not_share_buffers():
193 """Both microbatches are live at once, so their buffers must be distinct."""
194 query_lens, seq_lens = BATCHES["split_request"]
195 num_scheduled_tokens = np.array(query_lens, dtype=np.int32)
196 num_tokens = int(num_scheduled_tokens.sum())
197
198 ubatch_slices, _ = maybe_create_ubatch_slices(
199 True, num_scheduled_tokens, num_tokens, len(query_lens), num_ubatches=2
200 )
201 assert ubatch_slices is not None
202
203 buffers = _make_buffers()
204 ubatch_buffers = _make_ubatch_buffers()
205 input_batch = _make_input_batch(query_lens, seq_lens, buffers)
206
207 first = _slice_input_batch(input_batch, ubatch_slices[0], *ubatch_buffers[0])
208 first_query_start_loc = first.query_start_loc.clone()
209 first_seq_lens = first.seq_lens.clone()
210
211 # Slicing the second microbatch must not disturb the first.
212 _slice_input_batch(input_batch, ubatch_slices[1], *ubatch_buffers[1])
213
214 torch.testing.assert_close(first.query_start_loc, first_query_start_loc)
215 torch.testing.assert_close(first.seq_lens, first_seq_lens)
216
217
218 def test_trailing_microbatch_absorbs_cudagraph_padding():
219 """The padded microbatch keeps padded rows empty and query_start_loc flat."""
220 query_lens, seq_lens = [1] * 6, [64] * 6
221 num_tokens_padded = 8
222
223 buffers = _make_buffers()
224 input_batch = _make_input_batch(
225 query_lens,
226 seq_lens,
227 buffers,
228 num_reqs_padded=num_tokens_padded,
229 num_tokens_padded=num_tokens_padded,
230 )
231 ubatch_slices_padded = create_ubatch_slices(input_batch, num_ubatches=2)
232
233 last = _slice_input_batch(
234 input_batch, ubatch_slices_padded[-1], *_make_ubatch_buffers()[1]
235 )
236
237 # Two real decodes plus two padded rows.
238 assert last.num_reqs == 2
239 assert last.num_tokens == 2
240 assert last.num_reqs_after_padding == 4
241 assert last.num_tokens_after_padding == 4
242 torch.testing.assert_close(
243 last.query_start_loc, torch.tensor([0, 1, 2, 2, 2], dtype=torch.int32)
244 )
245 torch.testing.assert_close(
246 last.seq_lens, torch.tensor([64, 64, 0, 0], dtype=torch.int32)
247 )
248
249
250 DECODE_THRESHOLD = 32
251 PREFILL_THRESHOLD = 128
252 DECODE_QUERY_LEN = 1
253
254
255 def test_dummy_batch_can_be_microbatched():
256 """A DP rank with no work still has to split, so its dummy batch must slice.
257
258 Microbatching is all-or-nothing across the group, so a rank whose step is a
259 dummy batch runs it microbatched like everyone else.
260 """
261 num_reqs, num_tokens = 1, 32
262 buffers = _make_buffers()
263 input_batch = InputBatch.make_dummy(num_reqs, num_tokens, buffers)
264
265 ubatch_slices_padded = create_ubatch_slices(input_batch, num_ubatches=2)
266
267 ubatch_buffers = _make_ubatch_buffers()
268 ubatches = [
269 _slice_input_batch(input_batch, ubatch_slice, *ubatch_buffers[i])
270 for i, ubatch_slice in enumerate(ubatch_slices_padded)
271 ]
272
273 assert [u.num_tokens for u in ubatches] == [16, 16]
274 # The one dummy request spans both microbatches, 16 of its tokens in each.
275 assert all(u.num_reqs == 1 for u in ubatches)
276 for ubatch in ubatches:
277 torch.testing.assert_close(
278 ubatch.query_start_loc, torch.tensor([0, 16], dtype=torch.int32)
279 )
280
281
282 def _sync_dp(
283 num_tokens_per_rank: list[int],
284 uniform_token_count_per_rank: list[int] | None = None,
285 allow_ubatching: bool = True,
286 cudagraph_manager: Any = None,
287 ) -> tuple[BatchExecutionDescriptor, dp_utils.DPSyncState | None]:
288 """Run the DP handshake with the all-reduce stubbed out.
289
290 The microbatching decision lives inside `sync_cudagraph_and_dp_padding`, so
291 the cases below stand in for the collective rather than for the decision,
292 and need no process group. Every rank asks for eager, which keeps the
293 non-microbatched path off the cudagraph manager. This rank is rank 0.
294 """
295 dp_size = len(num_tokens_per_rank)
296 uniform_token_counts = uniform_token_count_per_rank or [0] * dp_size
297 reduced = torch.zeros(6, dp_size, dtype=torch.int32)
298 reduced[0] = torch.tensor(num_tokens_per_rank, dtype=torch.int32)
299 reduced[1] = CUDAGraphMode.NONE.value
300 reduced[2] = torch.tensor(uniform_token_counts, dtype=torch.int32)
301 reduced[3] = -1 # max_query_len, -1 means None
302 reduced[4] = int(allow_ubatching)
303 reduced[5] = 8 # num_reqs
304
305 with (
306 patch.object(dp_utils.dist, "all_reduce", lambda t, group: t.copy_(reduced)),
307 patch.object(dp_utils, "get_dp_group", lambda: SimpleNamespace(cpu_group=None)),
308 ):
309 return dp_utils.sync_cudagraph_and_dp_padding(
310 cudagraph_manager=cudagraph_manager,
311 desired_batch_desc=BatchExecutionDescriptor(
312 cg_mode=CUDAGraphMode.NONE,
313 num_tokens=num_tokens_per_rank[0],
314 num_reqs=8,
315 ),
316 num_tokens=num_tokens_per_rank[0],
317 num_reqs=8,
318 uniform_token_count=uniform_token_counts[0] or None,
319 dp_size=dp_size,
320 dp_rank=0,
321 parallel_config=ParallelConfig(
322 enable_dbo=True,
323 dbo_decode_token_threshold=DECODE_THRESHOLD,
324 dbo_prefill_token_threshold=PREFILL_THRESHOLD,
325 ),
326 allow_ubatching=allow_ubatching,
327 uniform_decode=uniform_token_counts[0] == DECODE_QUERY_LEN,
328 )
329
330
331 def test_every_dp_rank_must_agree_to_microbatch():
332 """One rank below the threshold is enough to keep the group on one batch.
333
334 The thresholds are not communicated -- they are checked against the token
335 counts the all-reduce already carries -- so this also pins that the whole
336 vector is consulted, not just this rank's own entry.
337 """
338 assert _sync_dp([256, 256])[0].num_ubatches == 2
339 assert _sync_dp([256, 100])[0].num_ubatches == 1
340 assert _sync_dp([100, 256])[0].num_ubatches == 1
341
342
343 def test_decode_threshold_only_applies_when_every_rank_is_a_uniform_decode():
344 """Otherwise the group is held to the prefill threshold."""
345 # 64 tokens clears the decode threshold but not the prefill one, so it only
346 # microbatches when both ranks are uniform decodes.
347 assert _sync_dp([64, 64], uniform_token_count_per_rank=[1, 1])[0].num_ubatches == 2
348 assert _sync_dp([64, 64], uniform_token_count_per_rank=[1, 0])[0].num_ubatches == 1
349
350
351 def test_microbatching_pads_all_ranks_to_the_largest():
352 """Ranks must run the same token count so microbatch sizes line up."""
353 desc, dp_sync = _sync_dp([200, 256])
354 assert desc.num_tokens == 256
355 assert dp_sync is not None
356 assert dp_sync.num_tokens_across_dp.tolist() == [256, 256]
357
358
359 def test_microbatching_survives_a_rank_that_cannot_fill_it():
360 """A rank whose real tokens all land in the first microbatch still splits.
361
362 Rank 0 has 128 real tokens but the split point is at 512 // 2 = 256, so its
363 second microbatch is pure padding. That microbatch does no work, which is
364 fine -- it still has to run so the expert all-to-all stays collective.
365 """
366 desc, _ = _sync_dp([128, 512])
367 assert desc.num_tokens == 512
368 assert desc.num_ubatches == 2
369
370
371 def test_all_padding_microbatch_has_no_work_to_do():
372 """The microbatch past the last real token is well-formed and empty.
373
374 It carries the last request with none of its query tokens, so attention and
375 sampling see nothing to do while the microbatch still runs.
376 """
377 query_lens, seq_lens = [1] * 6, [64] * 6
378 num_tokens_padded = 16 # split at 8, past all 6 real tokens
379
380 buffers = _make_buffers()
381 input_batch = _make_input_batch(
382 query_lens, seq_lens, buffers, num_tokens_padded=num_tokens_padded
383 )
384 ubatch_slices_padded = create_ubatch_slices(input_batch, num_ubatches=2)
385 ubatch_buffers = _make_ubatch_buffers()
386
387 first = _slice_input_batch(input_batch, ubatch_slices_padded[0], *ubatch_buffers[0])
388 last = _slice_input_batch(input_batch, ubatch_slices_padded[1], *ubatch_buffers[1])
389
390 # The first microbatch keeps every real request; the second gets none.
391 assert first.num_reqs == 6
392 assert first.num_tokens == 6
393 assert last.num_tokens == 0
394 assert last.num_reqs == 1
395 assert last.num_reqs_after_padding == 1
396 assert last.num_tokens_after_padding == 8
397
398 # Zero query tokens for the one request it holds, so no attention work.
399 torch.testing.assert_close(
400 last.query_start_loc, torch.tensor([0, 0], dtype=torch.int32)
401 )
402 assert last.num_scheduled_tokens.tolist() == [0]
403 # ...and its sequence still ends where its tokens do: they all live in the
404 # first microbatch, which computes them before this one runs.
405 torch.testing.assert_close(last.seq_lens, torch.tensor([64], dtype=torch.int32))
406 torch.testing.assert_close(
407 last.seq_lens_cpu_upper_bound, torch.tensor([64], dtype=torch.int32)
408 )
409
410
411 def test_microbatching_off_when_a_rank_does_not_allow_it():
412 assert _sync_dp([256, 256], allow_ubatching=False)[0].num_ubatches == 1
413
414
415 def test_slice_model_inputs_handles_mrope_positions():
416 model_inputs = {
417 "input_ids": torch.arange(8),
418 # M-RoPE positions carry a leading section dim.
419 "positions": torch.arange(24).view(3, 8),
420 "inputs_embeds": None,
421 "intermediate_tensors": None,
422 }
423 sliced = slice_model_inputs(model_inputs, slice(4, 8))
424
425 torch.testing.assert_close(sliced["input_ids"], torch.arange(4, 8))
426 torch.testing.assert_close(sliced["positions"], torch.arange(24).view(3, 8)[:, 4:8])
427 assert sliced["inputs_embeds"] is None
428 assert sliced["intermediate_tensors"] is None
429
430
431 @pytest.mark.parametrize(
432 ("cudagraph_runtime_mode", "execution_method"),
433 [
434 (CUDAGraphMode.FULL, "_capture_ubatches"),
435 (CUDAGraphMode.NONE, "_run_ubatches"),
436 ],
437 )
438 def test_legacy_ubatch_contexts_slice_padding_mask(
439 cudagraph_runtime_mode: CUDAGraphMode, execution_method: str
440 ):
441 """Replacement contexts preserve each microbatch's padding rows."""
442 ubatch_slices = [
443 UBatchSlice(slice(0, 2), slice(0, 3)),
444 UBatchSlice(slice(2, 4), slice(3, 6)),
445 ]
446 is_padding = torch.tensor([False, True, False, True, False, True])
447 forward_context = SimpleNamespace(
448 batch_descriptor=object(),
449 ubatch_slices=ubatch_slices,
450 cudagraph_runtime_mode=cudagraph_runtime_mode,
451 attn_metadata=[{}, {}],
452 slot_mapping={},
453 dp_metadata=object(),
454 is_padding=is_padding,
455 )
456
457 wrapper = object.__new__(legacy_gpu_ubatch_wrapper.UBatchWrapper)
458 wrapper.vllm_config = SimpleNamespace(
459 parallel_config=SimpleNamespace(data_parallel_size=2)
460 )
461 wrapper.comm_stream = object()
462 wrapper.ready_barrier = object()
463 wrapper.cudagraphs = {}
464 wrapper.sm_control = nullcontext()
465 wrapper.runnable = object()
466
467 def make_contexts(*, forward_contexts, **kwargs):
468 return [
469 SimpleNamespace(id=i, forward_context=context)
470 for i, context in enumerate(forward_contexts)
471 ]
472
473 def create_context(attn_metadata, vllm_config, **kwargs):
474 return SimpleNamespace(attn_metadata=attn_metadata, **kwargs)
475
476 model_inputs = {
477 "input_ids": torch.arange(6),
478 "positions": torch.arange(6),
479 "inputs_embeds": None,
480 "intermediate_tensors": None,
481 }
482 expected = object()
483 with (
484 patch.object(
485 legacy_gpu_ubatch_wrapper,
486 "get_forward_context",
487 return_value=forward_context,
488 ),
489 patch.object(
490 legacy_gpu_ubatch_wrapper.torch.cuda,
491 "current_stream",
492 return_value=object(),
493 ),
494 patch.object(
495 legacy_gpu_ubatch_wrapper.DPMetadata, "make", return_value=object()
496 ),
497 patch.object(
498 legacy_gpu_ubatch_wrapper,
499 "create_forward_context",
500 side_effect=create_context,
501 ),
502 patch.object(
503 legacy_gpu_ubatch_wrapper,
504 "make_ubatch_contexts",
505 side_effect=make_contexts,
506 ),
507 patch.object(wrapper, execution_method, return_value=expected) as execute,
508 ):
509 result = wrapper(**model_inputs)
510
511 assert result is expected
512 contexts = [
513 metadata.context.forward_context for metadata in execute.call_args.args[0]
514 ]
515 torch.testing.assert_close(contexts[0].is_padding, is_padding[:3])
516 torch.testing.assert_close(contexts[1].is_padding, is_padding[3:])
517
518
519 def _make_dbo_config() -> VllmConfig:
520 return VllmConfig(
521 model_config=ModelConfig(model="facebook/opt-125m", dtype="float16", seed=0),
522 parallel_config=ParallelConfig(
523 enable_dbo=True, all2all_backend="deepep_low_latency"
524 ),
525 )
526
527
528 def _make_model_inputs(num_tokens: int, device: torch.device) -> dict[str, Any]:
529 return {
530 "input_ids": torch.arange(num_tokens, device=device),
531 "positions": torch.arange(num_tokens, device=device),
532 "inputs_embeds": None,
533 "intermediate_tensors": None,
534 }
535
536
537 @pytest.fixture(autouse=True)
538 def _no_comm_sm_reservation(monkeypatch):
539 """CI runs on MIG slices with fewer SMs than the default comm reservation."""
540 monkeypatch.setenv("VLLM_DBO_COMM_SMS", "0")
541
542
543 def _make_execution_runner(vllm_config: VllmConfig) -> UBatchRunner:
544 """A runner for the execution tests below, which never call `prepare()`.
545
546 The attention-side dependencies are only read while building per-microbatch
547 metadata, so they are left out here.
548 """
549 return UBatchRunner(
550 vllm_config,
551 torch.device("cuda:0"),
552 model_state=cast(ModelState, None),
553 attn_groups=[],
554 kv_cache_config=cast(KVCacheConfig, None),
555 max_num_reqs=MAX_NUM_REQS,
556 )
557
558
559 def _make_ubatch_state(
560 vllm_config: VllmConfig, ubatch_slices: list[UBatchSlice]
561 ) -> UBatchState:
562 return UBatchState(
563 slices=ubatch_slices,
564 forward_contexts=[
565 create_forward_context(None, vllm_config) for _ in ubatch_slices
566 ],
567 )
568
569
570 def test_thresholds_below_the_microbatch_count_are_rejected():
571 """A batch with fewer tokens than microbatches cannot be split at all.
572
573 Nothing downstream copes with an unsplittable batch, so the thresholds are
574 what keep one out: a one-token decode split two ways leaves an empty
575 microbatch.
576 """
577 with pytest.raises(ValueError, match="number of microbatches"):
578 ParallelConfig(enable_dbo=True, dbo_decode_token_threshold=1)
579
580
581 @pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
582 def test_runner_allocates_one_buffer_pair_per_microbatch():
583 """All microbatches are live at once, so none may share rebase buffers."""
584 vllm_config = _make_dbo_config()
585 runner = _make_execution_runner(vllm_config)
586
587 for buffers in (runner.ubatch_query_start_loc, runner.ubatch_seq_lens):
588 assert len(buffers) == runner.num_ubatches
589 storages = {b.untyped_storage().data_ptr() for b in buffers}
590 assert len(storages) == runner.num_ubatches
591
592
593 class _YieldingModel(torch.nn.Module):
594 """Toy model that hands off to the other microbatch mid-forward."""
595
596 def __init__(self, trace: list[tuple[str, int]]):
597 super().__init__()
598 self.trace = trace
599
600 def forward(self, input_ids, positions, intermediate_tensors=None, **kwargs):
601 self.trace.append(("enter", dbo_current_ubatch_id()))
602 out = input_ids.float().unsqueeze(-1) * 2 + positions.float().unsqueeze(-1)
603 # Stands in for the expert all-to-all handoff point.
604 dbo_yield()
605 self.trace.append(("exit", dbo_current_ubatch_id()))
606 return out
607
608
609 @pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
610 def test_ubatch_runner_overlaps_and_matches_single_batch():
611 """Microbatches interleave at the yield point and produce the same output."""
612 vllm_config = _make_dbo_config()
613 device = torch.device("cuda:0")
614 runner = _make_execution_runner(vllm_config)
615
616 model_inputs = _make_model_inputs(16, device)
617 ubatch_state = _make_ubatch_state(
618 vllm_config,
619 [
620 UBatchSlice(slice(0, 4), slice(0, 8)),
621 UBatchSlice(slice(4, 8), slice(8, 16)),
622 ],
623 )
624
625 trace: list[tuple[str, int]] = []
626 model = _YieldingModel(trace)
627 output = runner.run(model, model_inputs, ubatch_state)
628
629 expected = model_inputs["input_ids"].float().unsqueeze(-1) * 2 + model_inputs[
630 "positions"
631 ].float().unsqueeze(-1)
632 torch.testing.assert_close(output, expected)
633
634 # Both microbatches reach the handoff before either finishes.
635 assert trace == [("enter", 0), ("enter", 1), ("exit", 0), ("exit", 1)]
636
637
638 @pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
639 def test_ubatch_runner_names_the_microbatch_that_failed():
640 """A failing microbatch surfaces as a named error, not a downstream KeyError.
641
642 The model here never yields, so the microbatches run back to back and no
643 handoff is left outstanding when one of them dies. A microbatch that fails
644 *while its sibling is parked at a yield* hangs the step instead -- the
645 shared handoff protocol in `ubatching.py` has no way to unwind a parked
646 microbatch, and the V1 runner has the same gap. Fixing that means changing
647 `ubatching.py`, which is out of scope for the V2 runner.
648 """
649 vllm_config = _make_dbo_config()
650 device = torch.device("cuda:0")
651 runner = _make_execution_runner(vllm_config)
652
653 class _FailingModel(torch.nn.Module):
654 def forward(self, input_ids, positions, **kwargs):
655 if dbo_current_ubatch_id() == 0:
656 raise ValueError("boom")
657 return input_ids.float().unsqueeze(-1)
658
659 model_inputs = _make_model_inputs(8, device)
660 ubatch_state = _make_ubatch_state(
661 vllm_config,
662 [
663 UBatchSlice(slice(0, 2), slice(0, 4)),
664 UBatchSlice(slice(2, 4), slice(4, 8)),
665 ],
666 )
667
668 result: dict[str, BaseException] = {}
669
670 def _call():
671 try:
672 runner.run(_FailingModel(), model_inputs, ubatch_state)
673 except BaseException as e: # noqa: BLE001
674 result["error"] = e
675
676 # Run behind a watchdog: `UBatchRunner.run` joins without a timeout, so a
677 # regression here would wedge the suite rather than fail it.
678 caller = threading.Thread(target=_call, daemon=True)
679 caller.start()
680 caller.join(timeout=60.0)
681 assert not caller.is_alive(), "UBatchRunner.run hung on a failing microbatch"
682
683 assert isinstance(result["error"], RuntimeError)
684 assert "Microbatch 0" in str(result["error"])
685 assert isinstance(result["error"].__cause__, ValueError)
686
687
688 # DCP x DBO: each microbatch recomputes DCP-local seq_lens from its own
689 # truncated seq_lens into its own persistent buffer (the parent's values for
690 # a straddling request are wrong for the leading microbatch).
691
692 DCP_SIZE = 2
693 CP_INTERLEAVE = 1
694
695
696 def _make_cuda_input_batch(
697 query_lens: list[int], seq_lens: list[int]
698 ) -> tuple[InputBatch, InputBuffers]:
699 buffers = InputBuffers(
700 max_num_reqs=MAX_NUM_REQS,
701 max_num_tokens=MAX_NUM_TOKENS,
702 device=torch.device("cuda:0"),
703 )
704 return _make_input_batch(query_lens, seq_lens, buffers), buffers
705
706
707 def _make_dcp_ubatch_buffers(
708 num_ubatches: int = 2,
709 ) -> list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
710 """Per-microbatch (query_start_loc, seq_lens, dcp_local_seq_lens)."""
711 return [
712 (
713 torch.zeros(MAX_NUM_REQS + 1, dtype=torch.int32, device="cuda:0"),
714 torch.zeros(MAX_NUM_REQS, dtype=torch.int32, device="cuda:0"),
715 torch.zeros(MAX_NUM_REQS, dtype=torch.int32, device="cuda:0"),
716 )
717 for _ in range(num_ubatches)
718 ]
719
720
721 def _slice_with_dcp(
722 input_batch: InputBatch, dcp_rank: int, num_ubatches: int = 2
723 ) -> tuple[list[InputBatch], list[torch.Tensor]]:
724 dcp_buffers = [
725 torch.zeros(MAX_NUM_REQS, dtype=torch.int32, device="cuda:0")
726 for _ in range(num_ubatches)
727 ]
728 ubatch_buffers = _make_dcp_ubatch_buffers(num_ubatches)
729 ubatches = [
730 _slice_input_batch(
731 input_batch,
732 ubatch_slice,
733 ubatch_buffers[i][0],
734 ubatch_buffers[i][1],
735 dcp_buffers[i],
736 dcp_size=DCP_SIZE,
737 dcp_rank=dcp_rank,
738 cp_interleave=CP_INTERLEAVE,
739 )
740 for i, ubatch_slice in enumerate(
741 create_ubatch_slices(input_batch, num_ubatches)
742 )
743 ]
744 return ubatches, dcp_buffers
745
746
747 @pytest.mark.skipif(not torch.cuda.is_available(), reason="triton kernel needs CUDA")
748 @pytest.mark.parametrize("dcp_rank", [0, 1])
749 def test_microbatches_recompute_dcp_lens_from_truncated_seq_lens(dcp_rank: int):
750 """A boundary-split request keeps a truncated seq_len on the leading
751 microbatch, whose DCP-local length may differ (in parity) from the full
752 batch's. Microbatches must therefore derive DCP lengths from their own
753 seq_lens, not reuse the parent's row.
754 """
755 input_batch, buffers = _make_cuda_input_batch(
756 [1, 1, 10, 1, 1], [64, 96, 512, 32, 48]
757 )
758 # execute_model has populated the merged batch's DCP metadata already.
759 input_batch.dcp_local_seq_lens = gpu_cp_utils.prepare_dcp_local_seq_lens(
760 buffers.dcp_local_seq_lens,
761 input_batch.seq_lens,
762 input_batch.num_reqs,
763 DCP_SIZE,
764 dcp_rank,
765 CP_INTERLEAVE,
766 num_reqs_padded=input_batch.num_reqs_after_padding,
767 )
768 parent_lens = input_batch.dcp_local_seq_lens.clone()
769
770 ubatches, _ = _slice_with_dcp(input_batch, dcp_rank)
771
772 for ubatch in ubatches:
773 assert ubatch.dcp_local_seq_lens is not None
774 expected = get_dcp_local_seq_lens(
775 ubatch.seq_lens.cpu(), DCP_SIZE, dcp_rank, CP_INTERLEAVE
776 )
777 assert torch.equal(
778 ubatch.dcp_local_seq_lens[: ubatch.num_reqs].cpu(),
779 expected.to(torch.int32),
780 )
781
782 # The straddling request (512 tokens, 5 of its 10 query tokens truncated)
783 # lands on this rank with a different local length than the full batch
784 # reports (507 vs 512 -> 254 vs 256 for dcp_size=2, interleave=1); copying
785 # the parent's row would ship the stale value.
786 assert ubatches[0].num_reqs == 3
787 assert ubatches[0].seq_lens[2].item() == 507
788 assert ubatches[0].dcp_local_seq_lens[2].item() != parent_lens[2].item()
789
790
791 @pytest.mark.skipif(not torch.cuda.is_available(), reason="triton kernel needs CUDA")
792 def test_each_microbatch_owns_its_dcp_buffer():
793 """Microbatch DCP lengths live in per-microbatch persistent buffers so
794 CPU-visible shapes stay valid under CUDA graph capture and replay."""
795 input_batch, _ = _make_cuda_input_batch([4, 4, 4, 4], [8, 9, 10, 11])
796
797 ubatches, dcp_buffers = _slice_with_dcp(input_batch, dcp_rank=0)
798
799 for ubatch, dcp_buffer in zip(ubatches, dcp_buffers):
800 assert ubatch.dcp_local_seq_lens is not None
801 assert ubatch.dcp_local_seq_lens.data_ptr() == dcp_buffer.data_ptr()
802 assert dcp_buffers[0].data_ptr() != dcp_buffers[1].data_ptr()
803
804
805 def test_slicing_drops_stale_dcp_metadata_when_dcp_is_off():
806 """dcp_size == 1 yields None even when the parent carries a value, so a
807 microbatch can never consume DCP metadata this deployment did not
808 enable.
809 """
810 buffers = _make_buffers()
811 input_batch = _make_input_batch([4, 4, 4, 4], [8, 9, 10, 11], buffers)
812 input_batch.dcp_local_seq_lens = buffers.dcp_local_seq_lens[:4]
813
814 ubatch_buffers = _make_ubatch_buffers()
815 ubatches = [
816 _slice_input_batch(input_batch, ubatch_slice, *ubatch_buffers[i])
817 for i, ubatch_slice in enumerate(create_ubatch_slices(input_batch, 2))
818 ]
819
820 assert all(ubatch.dcp_local_seq_lens is None for ubatch in ubatches)
821
822
823 @pytest.mark.skipif(not torch.cuda.is_available(), reason="DBO needs a GPU")
824 def test_capturable_run_replays_as_a_cudagraph():
825 """Microbatched graph replay reads updated persistent input buffers."""
826 vllm_config = VllmConfig(
827 model_config=ModelConfig(model="facebook/opt-125m", dtype="float16", seed=0),
828 parallel_config=ParallelConfig(
829 enable_dbo=True, all2all_backend="deepep_low_latency"
830 ),
831 )
832 device = torch.device("cuda:0")
833 runner = _make_execution_runner(vllm_config)
834
835 num_tokens = 16
836 input_ids = torch.arange(num_tokens, device=device)
837 positions = torch.arange(num_tokens, device=device)
838 model_inputs = {
839 "input_ids": input_ids,
840 "positions": positions,
841 "inputs_embeds": None,
842 "intermediate_tensors": None,
843 }
844 ubatch_state = _make_ubatch_state(
845 vllm_config,
846 [
847 UBatchSlice(slice(0, 4), slice(0, 8)),
848 UBatchSlice(slice(4, 8), slice(8, 16)),
849 ],
850 )
851
852 model = _YieldingModel([])
853 graph = torch.cuda.CUDAGraph()
854 finish = runner.begin_capturable_run(
855 model, model_inputs, ubatch_state, for_capture=True
856 )
857 with torch.cuda.graph(graph, stream=runner.capture_stream):
858 captured = finish()
859
860 input_ids.fill_(3)
861 positions.fill_(5)
862 graph.replay()
863 torch.accelerator.synchronize()
864
865 expected = torch.full(
866 (num_tokens, 1), 3 * 2 + 5, dtype=torch.float32, device=device
867 )
868 torch.testing.assert_close(captured, expected)
869
870
871 def _request_slices(input_batch: InputBatch) -> list[slice]:
872 return [s.request_slice for s in create_ubatch_slices(input_batch, 2)]
873
874
875 def test_captured_split_survives_a_replay_with_enough_requests():
876 """Replay preserves the captured split only if real requests reach it."""
877 buffers = _make_buffers()
878 captured = _request_slices(InputBatch.make_dummy(16, 16, buffers))
879
880 def replay(num_reqs: int) -> list[slice]:
881 return _request_slices(
882 _make_input_batch(
883 [1] * num_reqs,
884 [128] * num_reqs,
885 buffers,
886 num_reqs_padded=16,
887 num_tokens_padded=16,
888 )
889 )
890
891 assert replay(16) == captured
892 assert replay(9) == captured
893 assert replay(4) != captured
894
895
896 def _make_cudagraph_manager(capture_sizes: list[int]) -> CudaGraphManager:
897 vllm_config = VllmConfig(
898 model_config=ModelConfig(model="facebook/opt-125m", dtype="float16", seed=0),
899 parallel_config=ParallelConfig(
900 enable_dbo=True,
901 all2all_backend="deepep_low_latency",
902 dbo_decode_token_threshold=DECODE_THRESHOLD,
903 dbo_prefill_token_threshold=PREFILL_THRESHOLD,
904 ),
905 compilation_config=CompilationConfig(
906 cudagraph_mode=CUDAGraphMode.FULL,
907 cudagraph_capture_sizes=capture_sizes,
908 ),
909 )
910 with patch.object(
911 cudagraph_utils,
912 "get_pp_group",
913 lambda: SimpleNamespace(is_first_rank=True, is_last_rank=True),
914 ):
915 manager = CudaGraphManager(
916 vllm_config,
917 torch.device("cuda:0"),
918 CUDAGraphMode.FULL,
919 decode_query_len=1,
920 ubatch_runner=cast(UBatchRunner, object()),
921 )
922 manager._graphs_captured = True
923 return manager
924
925
926 @pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a graph pool")
927 def test_microbatched_graphs_are_only_offered_to_uniform_batches():
928 """Mixed batches cannot reuse graphs captured with uniform query lengths."""
929 manager = _make_cudagraph_manager([64, 128])
930
931 desc = manager.dispatch(
932 num_reqs=64,
933 num_tokens=64,
934 uniform_token_count=1,
935 num_active_loras=0,
936 num_ubatches=2,
937 )
938 assert desc.cg_mode == CUDAGraphMode.FULL
939 assert desc.num_ubatches == 2
940
941 desc = manager.dispatch(
942 num_reqs=8,
943 num_tokens=64,
944 uniform_token_count=None,
945 num_active_loras=0,
946 num_ubatches=2,
947 )
948 assert desc.cg_mode == CUDAGraphMode.NONE
949
950
951 @pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a graph pool")
952 def test_microbatched_graph_needs_every_rank_to_reach_the_split():
953 """Fall back to eager on all ranks if any rank cannot reach the captured split."""
954 manager = _make_cudagraph_manager([64, 128])
955 uniform = [1, 1]
956
957 desc, _ = _sync_dp([64, 64], uniform, cudagraph_manager=manager)
958 assert desc.cg_mode == CUDAGraphMode.FULL
959 assert desc.num_ubatches == 2
960
961 # Rank 1 pads the group up to a 128-token graph, but rank 0 only reaches 33.
962 desc, dp_sync = _sync_dp([33, 128], uniform, cudagraph_manager=manager)
963 assert desc.cg_mode == CUDAGraphMode.NONE
964 assert desc.num_ubatches == 2
965 assert dp_sync is not None and dp_sync.eager