main
py 605 lines 18.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for DCP A2A communication backend (no GPU required).
4
5 Tests cover:
6 1. DCP A2A config validation (--dcp-comm-backend)
7 2. KVP group function exists
8 3. LSE-weighted combination correctness
9 """
10
11 import math
12
13 import multiprocess as mp
14 import pytest
15 import torch
16 import torch.distributed as dist
17
18 import vllm.envs as envs
19 from vllm.config.parallel import ParallelConfig
20 from vllm.utils.network_utils import get_file_store_init_method
21 from vllm.utils.system_utils import update_environment_variables
22
23 mp.set_start_method("spawn", force=True)
24
25
26 class _FakeCPGroup:
27 def __init__(self, world_size: int, device_group: dist.ProcessGroup):
28 self.world_size = world_size
29 self.device_group = device_group
30
31
32 def _dtype_from_name(dtype_name: str) -> torch.dtype:
33 return {
34 "float16": torch.float16,
35 "bfloat16": torch.bfloat16,
36 "float32": torch.float32,
37 }[dtype_name]
38
39
40 def _packed_a2a_reference(
41 cp_attn_out: torch.Tensor,
42 cp_attn_lse: torch.Tensor,
43 world_size: int,
44 h_per_rank: int,
45 is_lse_base_on_e: bool,
46 ) -> tuple[torch.Tensor, torch.Tensor]:
47 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
48
49 B, _H, D = cp_attn_out.shape
50 outputs = (
51 cp_attn_out.view(B, world_size, h_per_rank, D)
52 .permute(1, 0, 2, 3)
53 .contiguous()
54 .float()
55 )
56 lses = cp_attn_lse.view(B, world_size, h_per_rank).permute(1, 0, 2).contiguous()
57 return _lse_weighted_combine(
58 outputs,
59 lses,
60 return_lse=True,
61 is_lse_base_on_e=is_lse_base_on_e,
62 )
63
64
65 def _assert_packed_a2a_close(
66 actual: torch.Tensor,
67 expected: torch.Tensor,
68 dtype: torch.dtype,
69 ) -> None:
70 if dtype == torch.float32:
71 torch.testing.assert_close(actual, expected, rtol=1e-5, atol=1e-5)
72 else:
73 torch.testing.assert_close(
74 actual.float(), expected.float(), rtol=3e-2, atol=3e-2
75 )
76
77
78 def _distributed_run(fn, world_size: int, extra_env: dict[str, str]) -> None:
79 distributed_init_method = get_file_store_init_method()
80 processes: list[mp.Process] = []
81 for rank in range(world_size):
82 env = {
83 "RANK": str(rank),
84 "LOCAL_RANK": str(rank),
85 "WORLD_SIZE": str(world_size),
86 "LOCAL_WORLD_SIZE": str(world_size),
87 "DISTRIBUTED_INIT_METHOD": distributed_init_method,
88 **extra_env,
89 }
90 process = mp.Process(target=fn, args=(env,))
91 processes.append(process)
92 process.start()
93
94 for process in processes:
95 process.join(timeout=120)
96
97 for process in processes:
98 if process.is_alive():
99 process.kill()
100 process.join()
101 assert process.exitcode == 0
102
103
104 class TestDCPCommBackendConfig:
105 """Test --dcp-comm-backend config validation."""
106
107 def test_default_is_ag_rs(self):
108 """Comm backend resolves to ag_rs unless the model asks otherwise."""
109 config = ParallelConfig()
110 config.set_dcp_defaults()
111 assert config.dcp_comm_backend == "ag_rs"
112
113 def test_a2a_with_dcp_valid(self):
114 """A2A backend is valid when DCP > 1."""
115 config = ParallelConfig(
116 dcp_comm_backend="a2a",
117 tensor_parallel_size=4,
118 decode_context_parallel_size=4,
119 )
120 assert config.dcp_comm_backend == "a2a"
121
122 def test_invalid_backend_rejected(self):
123 """Invalid backend values are rejected."""
124 with pytest.raises(ValueError, match="must be one of|Input should be"):
125 ParallelConfig(
126 dcp_comm_backend="invalid",
127 )
128
129 def test_ag_rs_with_dcp_1_valid(self):
130 """ag_rs backend is valid with DCP=1 (no DCP)."""
131 config = ParallelConfig(
132 dcp_comm_backend="ag_rs",
133 decode_context_parallel_size=1,
134 )
135 assert config.dcp_comm_backend == "ag_rs"
136
137
138 class TestLSEWeightedCombine:
139 """Test LSE-weighted combination logic (CPU only, no GPU).
140
141 The _lse_weighted_combine function is the reference implementation
142 that verifies the Triton kernel's correctness. It computes:
143
144 result[b,h,d] = sum_n(w_n * output_n[b,h,d])
145
146 where w_n = softmax(lse_n) = exp(lse_n) / sum_k(exp(lse_k))
147 """
148
149 def test_importable(self):
150 """Verify _lse_weighted_combine is importable."""
151 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
152
153 assert callable(_lse_weighted_combine)
154
155 def test_single_rank(self):
156 """Single rank: output unchanged."""
157 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
158
159 # N=1, B=2, H=4, D=8
160 outputs = torch.randn(1, 2, 4, 8)
161 lses = torch.randn(1, 2, 4)
162
163 result = _lse_weighted_combine(outputs, lses)
164
165 assert result.shape == (2, 4, 8)
166 torch.testing.assert_close(result, outputs.squeeze(0), rtol=1e-5, atol=1e-5)
167
168 def test_equal_lse(self):
169 """Equal LSE values: outputs averaged equally."""
170 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
171
172 _N, B, H, D = 2, 1, 1, 4
173 outputs = torch.tensor(
174 [
175 [[[1.0, 2.0, 3.0, 4.0]]], # Rank 0
176 [[[5.0, 6.0, 7.0, 8.0]]], # Rank 1
177 ]
178 )
179 lses = torch.tensor(
180 [
181 [[0.0]], # Rank 0
182 [[0.0]], # Rank 1
183 ]
184 )
185
186 result = _lse_weighted_combine(outputs, lses)
187
188 expected = (outputs[0] + outputs[1]) / 2
189 assert result.shape == (B, H, D)
190 torch.testing.assert_close(result, expected, rtol=1e-5, atol=1e-5)
191
192 def test_dominant_rank(self):
193 """Different LSE values: larger LSE gets more weight."""
194 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
195
196 B, H, D = 1, 1, 2
197 outputs = torch.tensor(
198 [
199 [[[0.0, 0.0]]], # Rank 0
200 [[[1.0, 1.0]]], # Rank 1
201 ]
202 )
203 lses = torch.tensor(
204 [
205 [[-100.0]], # Rank 0: negligible contribution
206 [[0.0]], # Rank 1: dominant
207 ]
208 )
209
210 result = _lse_weighted_combine(outputs, lses)
211
212 assert result.shape == (B, H, D)
213 torch.testing.assert_close(result, outputs[1], atol=1e-5, rtol=1e-5)
214
215 def test_empty_shard_ignores_undefined_output(self):
216 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
217
218 outputs = torch.tensor([[[[float("nan")]]], [[[3.0]]]])
219 lses = torch.tensor([[[-float("inf")]], [[0.0]]])
220
221 result = _lse_weighted_combine(outputs, lses)
222
223 torch.testing.assert_close(result, outputs[1])
224
225 def test_ag_rs_masks_empty_shard_and_padded_lse(self, monkeypatch):
226 import vllm.v1.attention.ops.dcp as dcp
227
228 class FakeGroup:
229 world_size = 2
230 rank_in_group = 0
231
232 def all_gather(self, tensor, dim):
233 assert dim == 0
234 return torch.cat((tensor, tensor), dim=dim)
235
236 monkeypatch.setattr(
237 dcp,
238 "correct_attn_out",
239 lambda output, lses, *args, **kwargs: (output, lses[0]),
240 )
241 output = torch.ones(7, 1, 1)
242 lse = torch.ones(7, 1)
243 seq_lens = torch.tensor([0, 2], dtype=torch.int32)
244 query_start_loc = torch.tensor([0, 1, 5], dtype=torch.int32)
245
246 _, masked_lse = dcp._cp_lse_common(
247 output,
248 lse,
249 FakeGroup(),
250 seq_lens=seq_lens,
251 query_start_loc=query_start_loc,
252 )
253
254 assert torch.isneginf(masked_lse[:1]).all()
255 torch.testing.assert_close(masked_lse[1:5], torch.ones_like(masked_lse[1:5]))
256 assert torch.isneginf(masked_lse[5:]).all()
257
258 def test_mathematically_correct(self):
259 """Verify mathematical correctness of LSE combination."""
260 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
261
262 outputs = torch.tensor(
263 [
264 [[[2.0, 4.0]]],
265 [[[6.0, 8.0]]],
266 ]
267 )
268 lses = torch.tensor(
269 [
270 [[1.0]], # exp(1) ≈ 2.718
271 [[2.0]], # exp(2) ≈ 7.389
272 ]
273 )
274
275 result = _lse_weighted_combine(outputs, lses)
276
277 w0 = math.exp(1) / (math.exp(1) + math.exp(2))
278 w1 = math.exp(2) / (math.exp(1) + math.exp(2))
279 expected = torch.tensor([[[w0 * 2.0 + w1 * 6.0, w0 * 4.0 + w1 * 8.0]]])
280
281 torch.testing.assert_close(result, expected, rtol=1e-4, atol=1e-4)
282
283 def test_return_lse(self):
284 """return_lse=True returns global LSE (logsumexp of inputs)."""
285 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
286
287 B, H, D = 1, 1, 2
288 outputs = torch.tensor(
289 [
290 [[[1.0, 2.0]]],
291 [[[3.0, 4.0]]],
292 ]
293 )
294 lses = torch.tensor(
295 [
296 [[1.0]],
297 [[2.0]],
298 ]
299 )
300
301 result, global_lse = _lse_weighted_combine(outputs, lses, return_lse=True)
302
303 expected_global_lse = math.log(math.exp(1) + math.exp(2))
304
305 assert result.shape == (B, H, D)
306 assert global_lse.shape == (B, H)
307 assert abs(global_lse.item() - expected_global_lse) < 1e-5
308
309 def test_base2_return_lse(self):
310 """Base-2 LSE mode returns log2-sum-exp2 global LSE."""
311 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
312
313 outputs = torch.tensor(
314 [
315 [[[1.0, 2.0]]],
316 [[[3.0, 4.0]]],
317 ]
318 )
319 lses = torch.tensor(
320 [
321 [[1.0]],
322 [[2.0]],
323 ]
324 )
325
326 result, global_lse = _lse_weighted_combine(
327 outputs,
328 lses,
329 return_lse=True,
330 is_lse_base_on_e=False,
331 )
332
333 expected_global_lse = math.log2(2**1 + 2**2)
334 w0 = 2**1 / (2**1 + 2**2)
335 w1 = 2**2 / (2**1 + 2**2)
336 expected = torch.tensor([[[w0 * 1.0 + w1 * 3.0, w0 * 2.0 + w1 * 4.0]]])
337
338 torch.testing.assert_close(result, expected, rtol=1e-5, atol=1e-5)
339 torch.testing.assert_close(
340 global_lse,
341 torch.tensor([[expected_global_lse]]),
342 rtol=1e-5,
343 atol=1e-5,
344 )
345
346 def test_lse_pack_dim(self):
347 """Packed A2A stores one fp32 LSE in output-dtype lanes."""
348 from vllm.v1.attention.ops.dcp import _dcp_a2a_lse_pack_dim
349
350 assert _dcp_a2a_lse_pack_dim(torch.bfloat16) == 2
351 assert _dcp_a2a_lse_pack_dim(torch.float16) == 2
352 assert _dcp_a2a_lse_pack_dim(torch.float32) == 1
353
354
355 class TestPackedA2AKernels:
356 @pytest.mark.skipif(
357 torch.accelerator.device_count() < 1, reason="CUDA is required."
358 )
359 @pytest.mark.parametrize("dtype_name", ["float16", "bfloat16", "float32"])
360 @pytest.mark.parametrize("return_lse", [False, True])
361 @pytest.mark.parametrize("is_lse_base_on_e", [False, True])
362 def test_pack_unpack_combine_matches_reference(
363 self,
364 dtype_name: str,
365 return_lse: bool,
366 is_lse_base_on_e: bool,
367 ):
368 from vllm.v1.attention.ops.dcp import (
369 _dcp_a2a_lse_pack_dim,
370 _dcp_a2a_pack_send,
371 _dcp_a2a_unpack_combine,
372 )
373
374 torch.manual_seed(0)
375 dtype = _dtype_from_name(dtype_name)
376 device = torch.device("cuda")
377 world_size, B, h_per_rank, D = 4, 7, 2, 32
378 H = world_size * h_per_rank
379 cp_attn_out = torch.randn(B, H, D, device=device, dtype=dtype)
380 cp_attn_lse = torch.randn(B, H, device=device, dtype=dtype)
381 lse_pack_dim = _dcp_a2a_lse_pack_dim(dtype)
382 send_buffer = torch.empty(
383 (world_size, B, h_per_rank, D + lse_pack_dim),
384 device=device,
385 dtype=dtype,
386 )
387
388 _dcp_a2a_pack_send(
389 cp_attn_out,
390 cp_attn_lse,
391 send_buffer,
392 world_size,
393 h_per_rank,
394 D,
395 lse_pack_dim,
396 )
397 actual = _dcp_a2a_unpack_combine(
398 send_buffer, D, lse_pack_dim, return_lse, is_lse_base_on_e
399 )
400 expected_out, expected_lse = _packed_a2a_reference(
401 cp_attn_out, cp_attn_lse, world_size, h_per_rank, is_lse_base_on_e
402 )
403
404 if return_lse:
405 actual_out, actual_lse = actual
406 _assert_packed_a2a_close(actual_out, expected_out, dtype)
407 torch.testing.assert_close(actual_lse, expected_lse, rtol=1e-4, atol=1e-4)
408 else:
409 _assert_packed_a2a_close(actual, expected_out, dtype)
410
411 @pytest.mark.skipif(
412 torch.accelerator.device_count() < 1, reason="CUDA is required."
413 )
414 def test_empty_seq_lens_ignore_undefined_output(self):
415 from vllm.v1.attention.ops.dcp import (
416 _dcp_a2a_lse_pack_dim,
417 _dcp_a2a_pack_send,
418 _dcp_a2a_unpack_combine,
419 )
420
421 device = torch.device("cuda")
422 world_size, num_tokens, h_per_rank, head_dim = 2, 5, 1, 32
423 num_heads = world_size * h_per_rank
424 output = torch.randn(
425 num_tokens,
426 num_heads,
427 head_dim,
428 device=device,
429 dtype=torch.bfloat16,
430 )
431 output[:1] = float("nan")
432 lse = torch.randn(num_tokens, num_heads, device=device, dtype=output.dtype)
433 seq_lens = torch.tensor([0, 2], device=device, dtype=torch.int32)
434 query_start_loc = torch.tensor([0, 1, 5], device=device, dtype=torch.int32)
435 lse_pack_dim = _dcp_a2a_lse_pack_dim(output.dtype)
436 send_buffer = torch.empty(
437 (
438 world_size,
439 num_tokens,
440 h_per_rank,
441 head_dim + lse_pack_dim,
442 ),
443 device=device,
444 dtype=output.dtype,
445 )
446
447 _dcp_a2a_pack_send(
448 output,
449 lse,
450 send_buffer,
451 world_size,
452 h_per_rank,
453 head_dim,
454 lse_pack_dim,
455 seq_lens=seq_lens,
456 query_start_loc=query_start_loc,
457 )
458 actual_output, actual_lse = _dcp_a2a_unpack_combine(
459 send_buffer,
460 head_dim,
461 lse_pack_dim,
462 return_lse=True,
463 is_lse_base_on_e=True,
464 )
465
466 torch.testing.assert_close(
467 actual_output[:1], torch.zeros_like(actual_output[:1])
468 )
469 assert torch.isneginf(actual_lse[:1]).all()
470 assert torch.isfinite(actual_output[1:]).all()
471
472
473 def _distributed_packed_a2a_worker(env: dict[str, str]) -> None:
474 update_environment_variables(env)
475 local_rank = int(env["LOCAL_RANK"])
476 world_size = int(env["WORLD_SIZE"])
477 torch.accelerator.set_device_index(local_rank)
478 if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP:
479 dist.init_process_group(
480 backend="cpu:gloo,cuda:nccl",
481 device_id=torch.device(f"cuda:{local_rank}"),
482 init_method=env["DISTRIBUTED_INIT_METHOD"],
483 rank=local_rank,
484 world_size=world_size,
485 )
486 else:
487 dist.init_process_group(
488 backend="nccl",
489 init_method=env["DISTRIBUTED_INIT_METHOD"],
490 rank=local_rank,
491 world_size=world_size,
492 )
493 use_workspace = env.get("USE_WORKSPACE") == "1"
494 if use_workspace:
495 from vllm.v1.worker.workspace import init_workspace_manager
496
497 init_workspace_manager(torch.device(f"cuda:{local_rank}"))
498 try:
499 from vllm.v1.attention.ops.dcp import dcp_a2a_lse_reduce
500
501 dtype = _dtype_from_name(env["TEST_DTYPE"])
502 return_lse = env["RETURN_LSE"] == "1"
503 is_lse_base_on_e = env["LSE_BASE_E"] == "1"
504 rank = dist.get_rank()
505 world_size = dist.get_world_size()
506 B, h_per_rank, D = 5, 2, 32
507 H = world_size * h_per_rank
508
509 generator = torch.Generator(device=f"cuda:{local_rank}")
510 generator.manual_seed(1234 + rank)
511 cp_attn_out = torch.randn(
512 B,
513 H,
514 D,
515 device=f"cuda:{local_rank}",
516 dtype=dtype,
517 generator=generator,
518 )
519 cp_attn_lse = torch.randn(
520 B,
521 H,
522 device=f"cuda:{local_rank}",
523 dtype=dtype,
524 generator=generator,
525 )
526 actual = dcp_a2a_lse_reduce(
527 cp_attn_out,
528 cp_attn_lse,
529 _FakeCPGroup(world_size, dist.group.WORLD),
530 return_lse=return_lse,
531 is_lse_base_on_e=is_lse_base_on_e,
532 )
533
534 gathered_out = [torch.empty_like(cp_attn_out) for _ in range(world_size)]
535 gathered_lse = [torch.empty_like(cp_attn_lse) for _ in range(world_size)]
536 dist.all_gather(gathered_out, cp_attn_out)
537 dist.all_gather(gathered_lse, cp_attn_lse)
538 outputs = torch.stack(
539 [
540 t[:, rank * h_per_rank : (rank + 1) * h_per_rank, :]
541 for t in gathered_out
542 ],
543 dim=0,
544 ).float()
545 lses = torch.stack(
546 [t[:, rank * h_per_rank : (rank + 1) * h_per_rank] for t in gathered_lse],
547 dim=0,
548 )
549 from vllm.v1.attention.ops.dcp import _lse_weighted_combine
550
551 expected_out, expected_lse = _lse_weighted_combine(
552 outputs,
553 lses,
554 return_lse=True,
555 is_lse_base_on_e=is_lse_base_on_e,
556 )
557
558 if return_lse:
559 actual_out, actual_lse = actual
560 _assert_packed_a2a_close(actual_out, expected_out, dtype)
561 torch.testing.assert_close(actual_lse, expected_lse, rtol=1e-4, atol=1e-4)
562 else:
563 _assert_packed_a2a_close(actual, expected_out, dtype)
564 finally:
565 if use_workspace:
566 from vllm.v1.worker.workspace import reset_workspace_manager
567
568 reset_workspace_manager()
569 dist.destroy_process_group()
570
571
572 @pytest.mark.skipif(
573 torch.accelerator.device_count() < 4, reason="Need at least 4 GPUs."
574 )
575 @pytest.mark.parametrize("dtype_name", ["float16", "bfloat16", "float32"])
576 def test_distributed_packed_a2a_matches_reference(dtype_name: str):
577 _distributed_run(
578 _distributed_packed_a2a_worker,
579 world_size=4,
580 extra_env={
581 "TEST_DTYPE": dtype_name,
582 "RETURN_LSE": "1",
583 "LSE_BASE_E": "1",
584 },
585 )
586
587
588 @pytest.mark.skipif(
589 torch.accelerator.device_count() < 4, reason="Need at least 4 GPUs."
590 )
591 def test_distributed_packed_a2a_with_workspace_matches_reference():
592 _distributed_run(
593 _distributed_packed_a2a_worker,
594 world_size=4,
595 extra_env={
596 "TEST_DTYPE": "bfloat16",
597 "RETURN_LSE": "1",
598 "LSE_BASE_E": "1",
599 "USE_WORKSPACE": "1",
600 },
601 )
602
603
604 if __name__ == "__main__":
605 pytest.main([__file__, "-v"])