main
py 1,097 lines 37.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for direct symmetric-memory DCP collectives."""
4
5 import functools
6 from unittest.mock import MagicMock
7
8 import multiprocess as mp
9 import pytest
10 import torch
11 import torch.distributed as dist
12
13 import vllm.v1.attention.ops.cp_common as cp_common
14 import vllm.v1.attention.ops.dcp as dcp
15 from vllm.utils.network_utils import get_open_port
16 from vllm.utils.system_utils import update_environment_variables
17
18 mp.set_start_method("spawn", force=True)
19
20
21 def _has_multicast_support() -> bool:
22 if not torch.cuda.is_available():
23 return False
24 try:
25 from torch._C._autograd import DeviceType
26 from torch._C._distributed_c10d import _SymmetricMemory
27
28 return _SymmetricMemory.has_multicast_support(DeviceType.CUDA, 0)
29 except Exception:
30 return False
31
32
33 def _dtype_from_name(dtype_name: str) -> torch.dtype:
34 return {
35 "float16": torch.float16,
36 "bfloat16": torch.bfloat16,
37 "float8_e4m3fn": torch.float8_e4m3fn,
38 "float32": torch.float32,
39 }[dtype_name]
40
41
42 def _assert_close(
43 actual: torch.Tensor,
44 expected: torch.Tensor,
45 dtype: torch.dtype,
46 ) -> None:
47 torch.testing.assert_close(actual.float(), expected.float(), rtol=3e-2, atol=3e-2)
48
49
50 def _q_gather_reference(
51 local_query: torch.Tensor,
52 world_size: int,
53 padded_num_heads: int | None,
54 ) -> torch.Tensor:
55 num_tokens, heads_per_rank, head_dim = local_query.shape
56 gathered = torch.empty(
57 (world_size * num_tokens, heads_per_rank, head_dim),
58 dtype=local_query.dtype,
59 device=local_query.device,
60 )
61 dist.all_gather_into_tensor(gathered, local_query.contiguous())
62 expected = (
63 gathered.view(world_size, num_tokens, heads_per_rank, head_dim)
64 .movedim(0, 1)
65 .reshape(num_tokens, world_size * heads_per_rank, head_dim)
66 )
67 if padded_num_heads is not None:
68 reserved = expected.new_empty((num_tokens, padded_num_heads, head_dim))
69 reserved.resize_(expected.shape)
70 reserved.copy_(expected)
71 expected = reserved
72 return expected
73
74
75 def _assert_q_gather_matches_reference(
76 actual: torch.Tensor,
77 local_query: torch.Tensor,
78 world_size: int,
79 padded_num_heads: int | None,
80 ) -> None:
81 expected = _q_gather_reference(local_query, world_size, padded_num_heads)
82 assert actual.shape == expected.shape
83 assert actual.dtype == expected.dtype
84 assert actual.is_contiguous()
85 assert actual.stride() == expected.stride()
86 assert torch.equal(actual.view(torch.uint8), expected.view(torch.uint8))
87
88 storage_num_heads = padded_num_heads or world_size * local_query.shape[1]
89 remaining_storage_bytes = (
90 actual.untyped_storage().nbytes()
91 - actual.storage_offset() * actual.element_size()
92 )
93 required_storage_bytes = (
94 local_query.shape[0]
95 * storage_num_heads
96 * local_query.shape[2]
97 * actual.element_size()
98 )
99 assert remaining_storage_bytes >= required_storage_bytes
100
101
102 def _distributed_run(fn, world_size: int, extra_env: dict[str, str]) -> None:
103 port = str(get_open_port())
104 processes: list[mp.Process] = []
105 for rank in range(world_size):
106 env = {
107 "RANK": str(rank),
108 "LOCAL_RANK": str(rank),
109 "WORLD_SIZE": str(world_size),
110 "LOCAL_WORLD_SIZE": str(world_size),
111 "MASTER_ADDR": "localhost",
112 "MASTER_PORT": port,
113 **extra_env,
114 }
115 process = mp.Process(target=fn, args=(env,))
116 processes.append(process)
117 process.start()
118
119 for process in processes:
120 process.join(timeout=120)
121
122 for process in processes:
123 if process.is_alive():
124 process.kill()
125 process.join()
126 assert process.exitcode == 0
127
128
129 class _FakeGroupCoordinator:
130 device_group = None
131 cpu_group = None
132 world_size = 4
133
134
135 class _FakeProcessGroup:
136 def size(self) -> int:
137 return 4
138
139 def rank(self) -> int:
140 return 0
141
142
143 class TestDirectDCPGating:
144 def test_env_disabled_returns_none(self, monkeypatch):
145 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "0")
146 dcp.get_direct_dcp_a2a_workspace.cache_clear()
147 workspace = dcp.get_direct_dcp_a2a_workspace(
148 _FakeGroupCoordinator(), torch.device("cpu"), 16, 2, 32, torch.bfloat16, 1
149 )
150 assert workspace is None
151
152 def test_forced_with_unsupported_dtype_raises(self, monkeypatch):
153 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "1")
154 dcp.get_direct_dcp_a2a_workspace.cache_clear()
155 with pytest.raises(ValueError, match="does not support"):
156 dcp.get_direct_dcp_a2a_workspace(
157 _FakeGroupCoordinator(),
158 torch.device("cpu"),
159 16,
160 2,
161 32,
162 torch.float32,
163 1,
164 )
165
166 def test_zero_ubatches_raises(self):
167 with pytest.raises(ValueError, match="ubatch"):
168 dcp.DirectDCPA2AWorkspace(
169 None, torch.device("cpu"), 16, 2, 32, torch.bfloat16, num_ubatches=0
170 )
171
172 def test_auto_with_unsupported_dtype_returns_none(self, monkeypatch):
173 monkeypatch.delenv("VLLM_USE_DIRECT_DCP_A2A", raising=False)
174 dcp.get_direct_dcp_a2a_workspace.cache_clear()
175 workspace = dcp.get_direct_dcp_a2a_workspace(
176 _FakeGroupCoordinator(), torch.device("cpu"), 16, 2, 32, torch.float32, 1
177 )
178 assert workspace is None
179
180 def test_q_gather_env_disabled_returns_none(self, monkeypatch):
181 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_Q_GATHER", "0")
182 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "1")
183 dcp.get_direct_dcp_q_gather_workspace.cache_clear()
184 workspace = dcp.get_direct_dcp_q_gather_workspace(
185 _FakeGroupCoordinator(),
186 torch.device("cpu"),
187 16,
188 2,
189 32,
190 torch.bfloat16,
191 1,
192 )
193 assert workspace is None
194
195 def test_q_gather_flag_is_independent(self, monkeypatch):
196 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_Q_GATHER", "1")
197 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "0")
198 monkeypatch.setattr(cp_common, "_symm_mem_spans_group", lambda group: True)
199 dcp.get_direct_dcp_q_gather_workspace.cache_clear()
200 workspace = object()
201 init_workspace = MagicMock(return_value=workspace)
202 monkeypatch.setattr(
203 dcp,
204 "DirectDCPQGatherWorkspace",
205 init_workspace,
206 )
207
208 result = dcp.get_direct_dcp_q_gather_workspace(
209 _FakeGroupCoordinator(),
210 torch.device("cpu"),
211 16,
212 2,
213 32,
214 torch.float32,
215 1,
216 )
217
218 assert result is workspace
219 assert init_workspace.call_args.args[5] == torch.float32
220
221 def test_kv_gather_env_disabled_returns_none(self, monkeypatch):
222 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_KV_GATHER", "0")
223 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "1")
224 dcp.get_direct_dcp_kv_gather_workspace.cache_clear()
225 workspace = dcp.get_direct_dcp_kv_gather_workspace(
226 _FakeGroupCoordinator(), torch.device("cpu"), 64, 576, torch.bfloat16, 1
227 )
228 assert workspace is None
229
230 def test_kv_gather_flag_is_independent(self, monkeypatch):
231 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_KV_GATHER", "1")
232 monkeypatch.setenv("VLLM_USE_DIRECT_DCP_A2A", "0")
233 monkeypatch.setattr(cp_common, "_symm_mem_spans_group", lambda group: True)
234 dcp.get_direct_dcp_kv_gather_workspace.cache_clear()
235 workspace = object()
236 init_workspace = MagicMock(return_value=workspace)
237 monkeypatch.setattr(
238 dcp,
239 "DirectDCPKVGatherWorkspace",
240 init_workspace,
241 )
242
243 result = dcp.get_direct_dcp_kv_gather_workspace(
244 _FakeGroupCoordinator(), torch.device("cpu"), 64, 576, torch.bfloat16, 1
245 )
246
247 assert result is workspace
248
249 @pytest.mark.parametrize(
250 ("flag_name", "factory_name", "factory_args"),
251 [
252 (
253 "VLLM_USE_DIRECT_DCP_Q_GATHER",
254 "get_direct_dcp_q_gather_workspace",
255 (16, 2, 32, torch.bfloat16, 1),
256 ),
257 (
258 "VLLM_USE_DIRECT_DCP_KV_GATHER",
259 "get_direct_dcp_kv_gather_workspace",
260 (64, 576, torch.bfloat16, 1),
261 ),
262 ],
263 )
264 def test_gather_requires_multicast(
265 self,
266 monkeypatch,
267 flag_name,
268 factory_name,
269 factory_args,
270 ):
271 factory = getattr(dcp, factory_name)
272 monkeypatch.setenv(flag_name, "1")
273 monkeypatch.setattr(cp_common, "_symm_mem_spans_group", lambda group: False)
274 factory.cache_clear()
275
276 assert (
277 factory(
278 _FakeGroupCoordinator(),
279 torch.device("cpu"),
280 *factory_args,
281 )
282 is None
283 )
284
285 def test_kv_gather_rejects_invalid_workspace_geometry(self):
286 with pytest.raises(ValueError, match="ubatch"):
287 dcp.DirectDCPKVGatherWorkspace(
288 None, torch.device("cpu"), 64, 576, num_ubatches=0
289 )
290 with pytest.raises(ValueError, match="divide evenly"):
291 dcp.DirectDCPKVGatherWorkspace(
292 _FakeProcessGroup(), torch.device("cpu"), 63, 576
293 )
294 with pytest.raises(ValueError, match="16-byte"):
295 dcp.DirectDCPKVGatherWorkspace(
296 _FakeProcessGroup(), torch.device("cpu"), 64, 3
297 )
298
299 def test_q_gather_rejects_invalid_workspace_geometry(self):
300 with pytest.raises(ValueError, match="ubatch"):
301 dcp.DirectDCPQGatherWorkspace(
302 None, torch.device("cpu"), 16, 2, 32, num_ubatches=0
303 )
304 with pytest.raises(ValueError, match="padded heads"):
305 dcp.DirectDCPQGatherWorkspace(
306 _FakeProcessGroup(),
307 torch.device("cpu"),
308 16,
309 2,
310 32,
311 padded_num_heads=7,
312 )
313 with pytest.raises(ValueError, match="16-byte"):
314 dcp.DirectDCPQGatherWorkspace(
315 _FakeProcessGroup(),
316 torch.device("cpu"),
317 16,
318 1,
319 3,
320 )
321
322
323 def _manager_config(dcp_comm_backend: str = "a2a"):
324 config = MagicMock()
325 config.parallel_config.num_ubatches = 1
326 config.parallel_config.dcp_comm_backend = dcp_comm_backend
327 config.scheduler_config.max_num_batched_tokens = 16
328 config.scheduler_config.max_num_seqs = 4
329 config.num_speculative_tokens = 0
330 config.speculative_config = None
331 config.compilation_config.max_cudagraph_capture_size = 0
332 return config
333
334
335 def test_mla_dcp_manager_selects_direct_backends(monkeypatch):
336 import vllm.v1.attention.ops.dcp as dcp_manager
337
338 group = MagicMock(world_size=2)
339 monkeypatch.setattr(dcp_manager, "get_dcp_group", lambda: group)
340 direct_a2a = MagicMock()
341 direct_query = MagicMock()
342 direct_kv = MagicMock()
343 monkeypatch.setattr(
344 dcp_manager, "get_direct_dcp_a2a_workspace", MagicMock(return_value=direct_a2a)
345 )
346 monkeypatch.setattr(
347 dcp_manager,
348 "get_direct_dcp_q_gather_workspace",
349 MagicMock(return_value=direct_query),
350 )
351 monkeypatch.setattr(
352 dcp_manager,
353 "get_direct_dcp_kv_gather_workspace",
354 MagicMock(return_value=direct_kv),
355 )
356
357 manager = dcp_manager.MLADCPManager(
358 vllm_config=_manager_config(),
359 device=torch.device("cpu"),
360 num_heads=2,
361 query_head_dim=8,
362 output_head_dim=4,
363 query_dtype=torch.bfloat16,
364 output_dtype=torch.bfloat16,
365 padded_num_heads=None,
366 is_lse_base_on_e=False,
367 use_pcp=False,
368 )
369 workspace = torch.empty(96, 8)
370
371 assert manager.query_gather == direct_query.gather
372 manager.init_kv_gather(workspace, 64)
373 gathered_kv, local_kv = torch.empty(4, 8), torch.empty(2, 8)
374 manager.kv_gather(gathered_kv, local_kv)
375 direct_kv.gather.assert_called_once_with(gathered_kv, local_kv)
376 output, lse = torch.empty(1), torch.empty(1)
377 seq_lens = torch.ones(1, dtype=torch.int32)
378 query_start_loc = torch.tensor([0, 1], dtype=torch.int32)
379 manager.combine(
380 output,
381 lse,
382 seq_lens=seq_lens,
383 query_start_loc=query_start_loc,
384 )
385 direct_a2a.lse_reduce.assert_called_once_with(
386 output,
387 lse,
388 seq_lens=seq_lens,
389 query_start_loc=query_start_loc,
390 is_lse_base_on_e=False,
391 )
392
393
394 def test_mla_dcp_manager_selects_fallback_backends(monkeypatch):
395 import vllm.v1.attention.ops.dcp as dcp_manager
396
397 group = MagicMock(world_size=2)
398 gathered_query = torch.empty(1, 4, 8)
399 group.all_gather.return_value = gathered_query
400 monkeypatch.setattr(dcp_manager, "get_dcp_group", lambda: group)
401 monkeypatch.setattr(
402 dcp_manager, "get_direct_dcp_a2a_workspace", MagicMock(return_value=None)
403 )
404 monkeypatch.setattr(
405 dcp_manager, "get_direct_dcp_q_gather_workspace", MagicMock(return_value=None)
406 )
407 monkeypatch.setattr(
408 dcp_manager, "get_direct_dcp_kv_gather_workspace", MagicMock(return_value=None)
409 )
410 fallback_combine = MagicMock(return_value=torch.empty(1))
411 monkeypatch.setattr(dcp_manager, "dcp_a2a_lse_reduce", fallback_combine)
412
413 manager = dcp_manager.MLADCPManager(
414 vllm_config=_manager_config(),
415 device=torch.device("cpu"),
416 num_heads=2,
417 query_head_dim=8,
418 output_head_dim=4,
419 query_dtype=torch.bfloat16,
420 output_dtype=torch.bfloat16,
421 padded_num_heads=None,
422 is_lse_base_on_e=True,
423 use_pcp=False,
424 )
425
426 all_gather = MagicMock()
427 monkeypatch.setattr(torch.distributed, "all_gather_into_tensor", all_gather)
428 workspace = torch.empty(96, 8)
429 manager.init_kv_gather(workspace, 64)
430 output, local = torch.empty(4, 8), torch.empty(2, 8)
431 manager.kv_gather(output, local)
432 all_gather.assert_called_once_with(output, local, group=group.device_group)
433
434 query = torch.empty(1, 2, 8)
435 assert manager.query_gather is not None
436 assert manager.query_gather(query) is gathered_query
437 group.all_gather.assert_called_once_with(query, dim=1)
438
439 partial_output, partial_lse = torch.empty(1), torch.empty(1)
440 seq_lens = torch.ones(1, dtype=torch.int32)
441 query_start_loc = torch.tensor([0, 1], dtype=torch.int32)
442 manager.combine(
443 partial_output,
444 partial_lse,
445 seq_lens=seq_lens,
446 query_start_loc=query_start_loc,
447 )
448 fallback_combine.assert_called_once_with(
449 partial_output,
450 partial_lse,
451 seq_lens=seq_lens,
452 query_start_loc=query_start_loc,
453 cp_group=group,
454 is_lse_base_on_e=True,
455 )
456
457
458 @pytest.mark.parametrize(
459 ("parallel_drafting", "use_v2_model_runner", "expected"),
460 [
461 # Model runner V1's parallel drafter widens requests to 1 + 2 * 3.
462 (True, False, 28),
463 (True, True, 16),
464 # A diffusion canvas of 3 without a speculative config.
465 (None, True, 16),
466 ],
467 )
468 def test_dcp_workspace_covers_decode_width(
469 parallel_drafting, use_v2_model_runner, expected
470 ):
471 config = _manager_config()
472 config.scheduler_config.max_num_batched_tokens = 128
473 config.num_speculative_tokens = 3
474 config.use_v2_model_runner = use_v2_model_runner
475 if parallel_drafting is not None:
476 config.speculative_config = MagicMock(
477 num_speculative_tokens=3, parallel_drafting=parallel_drafting
478 )
479
480 assert dcp.get_dcp_workspace_max_num_tokens(config) == expected
481
482
483 def test_mla_dcp_manager_selects_pcp_combine(monkeypatch):
484 import vllm.v1.attention.ops.dcp as dcp_manager
485
486 monkeypatch.setattr(dcp_manager, "get_dcp_group", lambda: MagicMock(world_size=2))
487 manager = dcp_manager.MLADCPManager(
488 vllm_config=_manager_config(dcp_comm_backend="ag_rs"),
489 device=torch.device("cpu"),
490 num_heads=2,
491 query_head_dim=8,
492 output_head_dim=4,
493 query_dtype=torch.bfloat16,
494 output_dtype=torch.bfloat16,
495 padded_num_heads=None,
496 is_lse_base_on_e=True,
497 use_pcp=True,
498 )
499
500 assert isinstance(manager.combine, functools.partial)
501 assert manager.combine.func is dcp_manager.cp_lse_ag_out_ar
502 assert manager.query_gather is None
503
504
505 def test_dcp_chunk_workspace_alignment_covers_interleave():
506 from vllm.model_executor.layers.attention.mla_attention import (
507 align_mla_chunked_context_workspace_size,
508 )
509
510 config = MagicMock()
511 config.cache_config.block_size = 32
512 config.parallel_config.decode_context_parallel_size = 8
513 config.parallel_config.cp_kv_cache_interleave_size = 8
514
515 # Alignment is lcm(block_size, dcp_size * interleave_size) = 64, and the
516 # workspace only has to hold a single aligned chunk step, independent of
517 # max_num_seqs.
518 assert align_mla_chunked_context_workspace_size(config, 100) == 128
519 assert align_mla_chunked_context_workspace_size(config, 8) == 64
520
521
522 def test_sparse_mla_builder_initializes_dcp_manager(monkeypatch):
523 import vllm.model_executor.layers.attention.sparse_mla_attention as sparse_mla
524
525 monkeypatch.setattr(
526 sparse_mla.AttentionMetadataBuilder,
527 "__init__",
528 lambda *args, **kwargs: None,
529 )
530 monkeypatch.setattr(
531 sparse_mla,
532 "get_dcp_group",
533 lambda: MagicMock(world_size=2),
534 )
535 monkeypatch.setattr(
536 sparse_mla,
537 "get_mla_dims",
538 lambda _: MagicMock(kv_lora_rank=8, qk_rope_head_dim=4),
539 )
540
541 manager = object.__new__(dcp.MLADCPManager)
542 manager.init_kv_gather = MagicMock()
543 layer = MagicMock(dcp_manager=manager)
544 config = MagicMock()
545 config.model_config.dtype = torch.bfloat16
546 config.model_config.max_model_len = 64
547 config.model_config.hf_config.index_topk = 8
548 config.scheduler_config.max_num_batched_tokens = 64
549 config.scheduler_config.max_num_seqs = 2
550 config.cache_config.block_size = 4
551 config.parallel_config.prefill_context_parallel_size = 1
552 config.parallel_config.decode_context_parallel_size = 2
553 config.parallel_config.cp_kv_cache_interleave_size = 1
554 config.compilation_config.static_forward_context = {"layer": layer}
555
556 builder = sparse_mla.SparseMLACommonMetadataBuilder(
557 MagicMock(),
558 ["layer"],
559 config,
560 torch.device("cpu"),
561 )
562
563 assert builder.dcp_manager is manager
564 manager.init_kv_gather.assert_called_once_with(
565 builder.chunked_prefill_workspace,
566 builder.chunked_prefill_workspace_size,
567 )
568
569
570 def test_sparse_mla_workspace_preserves_non_dcp_size():
571 from vllm.model_executor.layers.attention.sparse_mla_attention import (
572 SparseMLACommonMetadataBuilder,
573 )
574
575 config = MagicMock()
576 config.model_config.max_model_len = 1
577 config.model_config.hf_config.index_topk = 7
578 config.scheduler_config.max_num_seqs = 3
579 config.cache_config.block_size = 4
580 config.parallel_config.decode_context_parallel_size = 1
581
582 assert (
583 SparseMLACommonMetadataBuilder.determine_chunked_prefill_workspace_size(config)
584 == 21
585 )
586
587
588 def _distributed_direct_q_gather_worker(env: dict[str, str]) -> None:
589 update_environment_variables(env)
590 local_rank = int(env["LOCAL_RANK"])
591 device = torch.device(f"cuda:{local_rank}")
592 torch.accelerator.set_device_index(local_rank)
593 dist.init_process_group(backend="nccl")
594 try:
595 rank = dist.get_rank()
596 world_size = dist.get_world_size()
597 heads_per_rank, head_dim, max_num_tokens = 6, 576, 128
598 padded_num_heads = 128 if world_size == 4 else None
599 active_ubatch = [0]
600 dcp.dbo_current_ubatch_id = lambda: active_ubatch[0]
601 for dtype_idx, dtype_name in enumerate(
602 ("bfloat16", "float8_e4m3fn", "float32")
603 ):
604 dtype = _dtype_from_name(dtype_name)
605 workspace = dcp.DirectDCPQGatherWorkspace(
606 dist.group.WORLD,
607 device,
608 max_num_tokens,
609 heads_per_rank,
610 head_dim,
611 dtype,
612 num_ubatches=2,
613 padded_num_heads=padded_num_heads,
614 )
615
616 cases = (
617 ((1, False), (128, True), (17, True), (5, False))
618 if dtype == torch.bfloat16
619 else ((17, True),)
620 )
621 for iteration, (num_tokens, noncontiguous) in enumerate(cases):
622 generator = torch.Generator(device=device)
623 generator.manual_seed(9000 + rank * 101 + dtype_idx * 1009 + iteration)
624 source_num_heads = (
625 heads_per_rank + 2 if noncontiguous else heads_per_rank
626 )
627 query_storage = torch.randn(
628 num_tokens,
629 source_num_heads,
630 head_dim,
631 device=device,
632 dtype=torch.bfloat16,
633 generator=generator,
634 ).to(dtype)
635 local_query = query_storage[:, :heads_per_rank]
636 assert local_query.is_contiguous() is not noncontiguous
637
638 active_ubatch[0] = iteration % 2
639 actual = workspace.gather(local_query)
640 torch.accelerator.synchronize()
641
642 assert (
643 actual.data_ptr()
644 == workspace.final_query[active_ubatch[0]].data_ptr()
645 )
646 assert workspace.completion[active_ubatch[0]].numel() == 1
647 _assert_q_gather_matches_reference(
648 actual, local_query, world_size, padded_num_heads
649 )
650
651 if env.get("TEST_CUDA_GRAPH") != "1" or dtype != torch.bfloat16:
652 continue
653
654 capture_num_tokens = 17
655 capture_storage = torch.empty(
656 capture_num_tokens,
657 heads_per_rank + 2,
658 head_dim,
659 dtype=dtype,
660 device=device,
661 )
662 captured_input = capture_storage[:, :heads_per_rank]
663 assert not captured_input.is_contiguous()
664 input_pattern = (
665 torch.arange(captured_input.numel(), device=device, dtype=torch.int32)
666 .remainder(31)
667 .view(captured_input.shape)
668 .to(dtype)
669 )
670
671 active_ubatch[0] = 1
672 torch.accelerator.synchronize()
673 graph = torch.cuda.CUDAGraph()
674 with torch.cuda.graph(graph):
675 captured_output = workspace.gather(captured_input)
676 torch.accelerator.synchronize()
677 dist.barrier()
678
679 eager_input = torch.full(
680 captured_input.shape,
681 96 + rank,
682 dtype=dtype,
683 device=device,
684 )
685 eager_output = workspace.gather(eager_input)
686 torch.accelerator.synchronize()
687 _assert_q_gather_matches_reference(
688 eager_output, eager_input, world_size, padded_num_heads
689 )
690 epoch_before_replays = int(workspace.epoch[1].item())
691
692 for replay in range(2):
693 captured_input.copy_(input_pattern + rank * 32 + replay)
694 torch.accelerator.synchronize()
695 graph.replay()
696 torch.accelerator.synchronize()
697 assert int(workspace.epoch[1].item()) == (
698 epoch_before_replays + replay + 1
699 )
700 _assert_q_gather_matches_reference(
701 captured_output,
702 captured_input,
703 world_size,
704 padded_num_heads,
705 )
706
707 interleaved_eager_input = torch.full(
708 captured_input.shape,
709 160 + rank,
710 dtype=dtype,
711 device=device,
712 )
713 interleaved_eager_output = workspace.gather(interleaved_eager_input)
714 torch.accelerator.synchronize()
715 _assert_q_gather_matches_reference(
716 interleaved_eager_output,
717 interleaved_eager_input,
718 world_size,
719 padded_num_heads,
720 )
721
722 captured_input.copy_(input_pattern + rank * 32 + 2)
723 torch.accelerator.synchronize()
724 epoch_before_mixed_replay = int(workspace.epoch[1].item())
725 graph.replay()
726 torch.accelerator.synchronize()
727 assert int(workspace.epoch[1].item()) == epoch_before_mixed_replay + 1
728 _assert_q_gather_matches_reference(
729 captured_output, captured_input, world_size, padded_num_heads
730 )
731 finally:
732 dist.destroy_process_group()
733
734
735 @pytest.mark.parametrize(
736 "world_size",
737 [
738 pytest.param(
739 4,
740 marks=pytest.mark.skipif(
741 torch.accelerator.device_count() < 4 or not _has_multicast_support(),
742 reason="Need 4 GPUs with symmetric-memory multicast.",
743 ),
744 ),
745 ],
746 )
747 def test_distributed_direct_q_gather_cuda_graph_replay(world_size: int):
748 _distributed_run(
749 _distributed_direct_q_gather_worker,
750 world_size=world_size,
751 extra_env={"TEST_CUDA_GRAPH": "1"},
752 )
753
754
755 def _distributed_direct_kv_gather_worker(env: dict[str, str]) -> None:
756 update_environment_variables(env)
757 local_rank = int(env["LOCAL_RANK"])
758 device = torch.device(f"cuda:{local_rank}")
759 torch.accelerator.set_device_index(local_rank)
760 dist.init_process_group(backend="nccl")
761 try:
762 rank = dist.get_rank()
763 world_size = dist.get_world_size()
764 token_dim = 576
765 max_gathered_tokens = 128 * world_size
766 active_ubatch = [0]
767 dcp.dbo_current_ubatch_id = lambda: active_ubatch[0]
768
769 for dtype_idx, dtype_name in enumerate(("bfloat16", "float16")):
770 dtype = _dtype_from_name(dtype_name)
771 workspace = dcp.DirectDCPKVGatherWorkspace(
772 dist.group.WORLD,
773 device,
774 max_gathered_tokens,
775 token_dim,
776 dtype,
777 num_ubatches=2,
778 )
779
780 # Use disjoint slices of one persistent chunked-context workspace.
781 storage = torch.zeros(
782 (world_size + 1) * 128, token_dim, device=device, dtype=dtype
783 )
784 for iteration, num_tokens in enumerate((1, 128, 17)):
785 generator = torch.Generator(device=device)
786 generator.manual_seed(7000 + rank * 101 + dtype_idx * 977 + iteration)
787 local_kv = storage[:num_tokens]
788 local_kv.copy_(
789 torch.randn(
790 num_tokens,
791 token_dim,
792 device=device,
793 dtype=torch.float32,
794 generator=generator,
795 ).to(dtype)
796 )
797 gathered = storage[128 : 128 + num_tokens * world_size]
798 active_ubatch[0] = iteration % 2
799 workspace.gather(gathered, local_kv)
800 torch.accelerator.synchronize()
801
802 expected = torch.empty_like(gathered)
803 dist.all_gather_into_tensor(expected, local_kv.contiguous())
804 assert torch.equal(
805 gathered.view(torch.uint8), expected.view(torch.uint8)
806 )
807 finally:
808 dist.destroy_process_group()
809
810
811 @pytest.mark.parametrize(
812 "world_size",
813 [
814 pytest.param(
815 4,
816 marks=pytest.mark.skipif(
817 torch.accelerator.device_count() < 4 or not _has_multicast_support(),
818 reason="Need 4 GPUs with symmetric-memory multicast.",
819 ),
820 ),
821 ],
822 )
823 def test_distributed_direct_kv_gather_matches_reference(world_size: int):
824 _distributed_run(
825 _distributed_direct_kv_gather_worker,
826 world_size=world_size,
827 extra_env={},
828 )
829
830
831 def _distributed_direct_a2a_worker(env: dict[str, str]) -> None:
832 update_environment_variables(env)
833 local_rank = int(env["LOCAL_RANK"])
834 device = torch.device(f"cuda:{local_rank}")
835 torch.accelerator.set_device_index(local_rank)
836 dist.init_process_group(backend="nccl")
837 try:
838 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
839
840 rank = dist.get_rank()
841 world_size = dist.get_world_size()
842 dtype = _dtype_from_name(env["TEST_DTYPE"])
843 lse_dtype = _dtype_from_name(env["TEST_LSE_DTYPE"])
844 is_lse_base_on_e = env["LSE_BASE_E"] == "1"
845 # Match Kimi-K3's six heads per DCP rank.
846 heads_per_rank, head_dim, max_num_tokens = 6, 512, 128
847 total_heads = world_size * heads_per_rank
848 active_ubatch = [0]
849 dcp.dbo_current_ubatch_id = lambda: active_ubatch[0]
850 workspace = dcp.DirectDCPA2AWorkspace(
851 dist.group.WORLD,
852 device,
853 max_num_tokens,
854 heads_per_rank,
855 head_dim,
856 dtype,
857 num_ubatches=2,
858 )
859
860 def check(num_tokens: int, iteration: int, padded: bool) -> None:
861 generator = torch.Generator(device=device)
862 generator.manual_seed(1234 + rank + iteration * world_size)
863 storage_heads = 128 if padded else total_heads
864 partial_output_storage = torch.randn(
865 num_tokens,
866 storage_heads,
867 head_dim,
868 device=device,
869 dtype=dtype,
870 generator=generator,
871 )
872 partial_lse_storage = torch.randn(
873 num_tokens,
874 storage_heads,
875 device=device,
876 dtype=lse_dtype,
877 generator=generator,
878 )
879 partial_output = partial_output_storage[:, :total_heads, :]
880 partial_lse = partial_lse_storage[:, :total_heads]
881 if padded:
882 assert not partial_output.is_contiguous()
883 assert not partial_lse.is_contiguous()
884 active_ubatch[0] = iteration % 2
885 actual = workspace.lse_reduce(partial_output, partial_lse, is_lse_base_on_e)
886 torch.accelerator.synchronize()
887
888 reference_output = partial_output.contiguous()
889 reference_lse = partial_lse.contiguous()
890 gathered_output = [
891 torch.empty_like(reference_output) for _ in range(world_size)
892 ]
893 gathered_lse = [torch.empty_like(reference_lse) for _ in range(world_size)]
894 dist.all_gather(gathered_output, reference_output)
895 dist.all_gather(gathered_lse, reference_lse)
896 outputs = torch.stack(
897 [
898 value[
899 :,
900 rank * heads_per_rank : (rank + 1) * heads_per_rank,
901 :,
902 ]
903 for value in gathered_output
904 ]
905 ).float()
906 lses = torch.stack(
907 [
908 value[:, rank * heads_per_rank : (rank + 1) * heads_per_rank]
909 for value in gathered_lse
910 ]
911 )
912 expected = _lse_weighted_combine(
913 outputs, lses, is_lse_base_on_e=is_lse_base_on_e
914 )
915 _assert_close(actual, expected, dtype)
916
917 def check_empty_shards(query_lens: list[int], iteration: int) -> None:
918 """Verify empty local shards contribute zero weight."""
919 num_seqs = len(query_lens)
920 num_tokens = sum(query_lens)
921 generator = torch.Generator(device=device)
922 generator.manual_seed(777 + rank + iteration * world_size)
923 partial_output = torch.randn(
924 num_tokens,
925 total_heads,
926 head_dim,
927 device=device,
928 dtype=dtype,
929 generator=generator,
930 )
931 partial_lse = torch.randn(
932 num_tokens,
933 total_heads,
934 device=device,
935 dtype=lse_dtype,
936 generator=generator,
937 )
938
939 # Cover globally empty, rank-local empty, and non-empty sequences.
940 def is_empty(seq_idx: int, source_rank: int) -> bool:
941 return seq_idx == 0 or seq_idx == 1 + source_rank
942
943 seq_lens = torch.tensor(
944 [
945 0 if is_empty(seq_idx, rank) else seq_idx + 3
946 for seq_idx in range(num_seqs)
947 ],
948 dtype=torch.int32,
949 device=device,
950 )
951 query_lens_tensor = torch.tensor(
952 query_lens,
953 dtype=torch.int32,
954 device=device,
955 )
956 query_start_loc = torch.cat(
957 (
958 query_lens_tensor.new_zeros(1),
959 query_lens_tensor.cumsum(0),
960 )
961 )
962 empty_rows = torch.repeat_interleave(seq_lens == 0, query_lens_tensor)
963 # Model undefined attention rows with NaNs.
964 partial_output[empty_rows] = float("nan")
965 partial_lse[empty_rows] = float("nan")
966
967 active_ubatch[0] = iteration % 2
968 actual = workspace.lse_reduce(
969 partial_output,
970 partial_lse,
971 is_lse_base_on_e,
972 seq_lens=seq_lens,
973 query_start_loc=query_start_loc,
974 )
975 torch.accelerator.synchronize()
976
977 gathered_output = [
978 torch.empty_like(partial_output) for _ in range(world_size)
979 ]
980 gathered_lse = [torch.empty_like(partial_lse) for _ in range(world_size)]
981 dist.all_gather(gathered_output, partial_output.contiguous())
982 dist.all_gather(gathered_lse, partial_lse.contiguous())
983 head_slice = slice(rank * heads_per_rank, (rank + 1) * heads_per_rank)
984 outputs = torch.stack(
985 [value[:, head_slice, :] for value in gathered_output]
986 ).float()
987 lses = torch.stack([value[:, head_slice] for value in gathered_lse])
988 for source_rank in range(world_size):
989 source_empty = torch.tensor(
990 [is_empty(seq_idx, source_rank) for seq_idx in range(num_seqs)],
991 device=device,
992 ).repeat_interleave(query_lens_tensor)
993 outputs[source_rank][source_empty] = 0.0
994 lses[source_rank][source_empty] = float("-inf")
995 expected = _lse_weighted_combine(
996 outputs, lses, is_lse_base_on_e=is_lse_base_on_e
997 )
998 all_empty = torch.tensor(
999 [
1000 all(is_empty(seq_idx, r) for r in range(world_size))
1001 for seq_idx in range(num_seqs)
1002 ],
1003 device=device,
1004 ).repeat_interleave(query_lens_tensor)
1005 assert torch.equal(actual[all_empty], torch.zeros_like(actual[all_empty]))
1006 assert not torch.isnan(actual.float()).any()
1007 _assert_close(actual, expected, dtype)
1008
1009 cases = ((1, False), (17, True), (128, True))
1010 for iteration, (num_tokens, padded) in enumerate(cases):
1011 check(num_tokens, iteration, padded)
1012 check_empty_shards(
1013 query_lens=[1, 3, 2, *([1] * (world_size - 1))],
1014 iteration=len(cases),
1015 )
1016 generator = torch.Generator(device=device)
1017 generator.manual_seed(4321 + rank)
1018 partial_output_storage = torch.randn(
1019 128,
1020 128,
1021 head_dim,
1022 device=device,
1023 dtype=dtype,
1024 generator=generator,
1025 )
1026 partial_lse_storage = torch.randn(
1027 128,
1028 128,
1029 device=device,
1030 dtype=lse_dtype,
1031 generator=generator,
1032 )
1033 partial_output = partial_output_storage[:, :total_heads, :]
1034 partial_lse = partial_lse_storage[:, :total_heads]
1035 assert not partial_output.is_contiguous()
1036 assert not partial_lse.is_contiguous()
1037 torch.accelerator.synchronize()
1038 active_ubatch[0] = 1
1039 graph = torch.cuda.CUDAGraph()
1040 with torch.cuda.graph(graph):
1041 actual = workspace.lse_reduce(partial_output, partial_lse, is_lse_base_on_e)
1042 for _ in range(3):
1043 graph.replay()
1044 torch.accelerator.synchronize()
1045
1046 reference_output = partial_output.contiguous()
1047 reference_lse = partial_lse.contiguous()
1048 gathered_output = [
1049 torch.empty_like(reference_output) for _ in range(world_size)
1050 ]
1051 gathered_lse = [torch.empty_like(reference_lse) for _ in range(world_size)]
1052 dist.all_gather(gathered_output, reference_output)
1053 dist.all_gather(gathered_lse, reference_lse)
1054 head_slice = slice(rank * heads_per_rank, (rank + 1) * heads_per_rank)
1055 outputs = torch.stack(
1056 [value[:, head_slice, :] for value in gathered_output]
1057 ).float()
1058 lses = torch.stack([value[:, head_slice] for value in gathered_lse])
1059 expected = _lse_weighted_combine(
1060 outputs, lses, is_lse_base_on_e=is_lse_base_on_e
1061 )
1062 _assert_close(actual, expected, dtype)
1063 finally:
1064 dist.destroy_process_group()
1065
1066
1067 @pytest.mark.parametrize(
1068 "world_size",
1069 [
1070 pytest.param(
1071 2,
1072 marks=pytest.mark.skipif(
1073 torch.accelerator.device_count() < 2, reason="Need at least 2 GPUs."
1074 ),
1075 ),
1076 pytest.param(
1077 4,
1078 marks=pytest.mark.skipif(
1079 torch.accelerator.device_count() < 4, reason="Need at least 4 GPUs."
1080 ),
1081 ),
1082 ],
1083 )
1084 def test_distributed_direct_a2a_matches_reference(world_size: int):
1085 _distributed_run(
1086 _distributed_direct_a2a_worker,
1087 world_size=world_size,
1088 extra_env={
1089 "TEST_DTYPE": "bfloat16",
1090 "TEST_LSE_DTYPE": "bfloat16",
1091 "LSE_BASE_E": "0",
1092 },
1093 )
1094
1095
1096 if __name__ == "__main__":
1097 pytest.main([__file__, "-v"])