main
py 465 lines 15.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the pre-shared ncclUniqueId weight-transfer rendezvous.
4
5 The rendezvous lets a peer that cannot join a TCPStore (e.g. a torch-free JAX
6 trainer) initialize NCCL weight transfer from a unique id minted out of band.
7
8 * Unit tests for the mutually-exclusive rendezvous-mode validation on the NCCL
9 init infos.
10 * Two-GPU integration tests driving the real worker engine against a peer: one
11 via vLLM's own ``PyNcclCommunicator``, one via a genuinely torch-free trainer
12 (cupy buffer + raw ``ncclBroadcast``).
13
14 The shared Ray helpers come from the sibling module (unchanged); the worker task
15 is local so the sibling's TCP test and its worker stay untouched.
16 """
17
18 from unittest.mock import MagicMock
19
20 import pybase64 as base64
21 import pytest
22 import ray
23 import torch
24
25 from tests.distributed.test_weight_transfer import (
26 _init_ray_for_weight_transfer,
27 _set_ray_assigned_device,
28 create_mock_vllm_config,
29 )
30 from vllm.config.weight_transfer import WeightTransferConfig
31 from vllm.distributed.weight_transfer.nccl_engine import (
32 NCCLTrainerInitInfo,
33 NCCLWeightTransferEngine,
34 NCCLWeightTransferInitInfo,
35 )
36
37 # Standard base64 of exactly 128 zero bytes: a well-formed (dummy) ncclUniqueId.
38 VALID_UID_B64 = base64.b64encode(b"\x00" * 128).decode()
39
40
41 class TestNCCLRendezvousValidation:
42 """Test the xor-of-rendezvous-modes validation on the NCCL init infos."""
43
44 def test_worker_init_info_is_keyword_only(self):
45 # kw_only so a stale positional call fails loudly rather than silently
46 # swapping fields once the rendezvous fields became optional.
47 with pytest.raises(TypeError):
48 NCCLWeightTransferInitInfo(1, 3, "127.0.0.1", 12345) # type: ignore[call-arg]
49
50 def test_trainer_init_info_accepts_positional_fields(self):
51 # NCCLTrainerInitInfo keeps positional subclass fields; only `rank`
52 # (from TrainerInitInfo) is keyword-only.
53 trainer_positional = NCCLTrainerInitInfo("127.0.0.1", 12345, 3, rank=0)
54 trainer_keyword = NCCLTrainerInitInfo(
55 master_address="127.0.0.1",
56 master_port=12345,
57 world_size=3,
58 rank=0,
59 )
60 assert trainer_positional == trainer_keyword
61
62 def test_tcp_mode_valid(self):
63 info = NCCLWeightTransferInitInfo(
64 rank_offset=1,
65 world_size=3,
66 master_address="127.0.0.1",
67 master_port=12345,
68 )
69 assert info.master_address == "127.0.0.1"
70 assert info.nccl_unique_id_b64 is None
71
72 def test_uid_mode_valid(self):
73 info = NCCLWeightTransferInitInfo(
74 rank_offset=1,
75 world_size=3,
76 nccl_unique_id_b64=VALID_UID_B64,
77 )
78 assert info.nccl_unique_id_b64 == VALID_UID_B64
79 assert info.master_address is None
80 assert info.master_port is None
81 assert VALID_UID_B64 not in repr(info)
82
83 def test_both_modes_raises(self):
84 with pytest.raises(ValueError, match="not both"):
85 NCCLWeightTransferInitInfo(
86 rank_offset=1,
87 world_size=3,
88 master_address="127.0.0.1",
89 master_port=12345,
90 nccl_unique_id_b64=VALID_UID_B64,
91 )
92
93 def test_neither_mode_raises(self):
94 with pytest.raises(ValueError, match="need nccl_unique_id_b64"):
95 NCCLWeightTransferInitInfo(rank_offset=1, world_size=3)
96
97 def test_half_specified_tcp_raises(self):
98 with pytest.raises(ValueError, match="master_port"):
99 NCCLWeightTransferInitInfo(
100 rank_offset=1,
101 world_size=3,
102 master_address="127.0.0.1",
103 )
104
105 def test_bad_base64_uid_raises(self):
106 with pytest.raises(ValueError, match="not valid standard base64"):
107 NCCLWeightTransferInitInfo(
108 rank_offset=1,
109 world_size=3,
110 nccl_unique_id_b64="!!!!",
111 )
112
113 def test_wrong_length_uid_raises(self):
114 short_uid = base64.b64encode(b"\x00" * 64).decode()
115 with pytest.raises(ValueError, match="128"):
116 NCCLWeightTransferInitInfo(
117 rank_offset=1,
118 world_size=3,
119 nccl_unique_id_b64=short_uid,
120 )
121
122 def test_parse_init_info_uid_valid(self):
123 engine = NCCLWeightTransferEngine(
124 WeightTransferConfig(backend="nccl"),
125 create_mock_vllm_config(),
126 torch.device("cuda"),
127 MagicMock(spec=torch.nn.Module),
128 )
129 init_info = engine.parse_init_info(
130 {
131 "nccl_unique_id_b64": VALID_UID_B64,
132 "rank_offset": 1,
133 "world_size": 3,
134 }
135 )
136 assert isinstance(init_info, NCCLWeightTransferInitInfo)
137 assert init_info.nccl_unique_id_b64 == VALID_UID_B64
138 assert init_info.master_address is None
139
140 def test_parse_init_info_both_modes_raises(self):
141 engine = NCCLWeightTransferEngine(
142 WeightTransferConfig(backend="nccl"),
143 create_mock_vllm_config(),
144 torch.device("cuda"),
145 MagicMock(spec=torch.nn.Module),
146 )
147 with pytest.raises(ValueError, match="not both"):
148 engine.parse_init_info(
149 {
150 "master_address": "127.0.0.1",
151 "master_port": 12345,
152 "nccl_unique_id_b64": VALID_UID_B64,
153 "rank_offset": 1,
154 "world_size": 3,
155 }
156 )
157
158 @pytest.mark.parametrize(
159 ("unique_id_bytes", "rank", "world_size", "match"),
160 [
161 (b"\x00" * 64, 0, 2, "128-byte"),
162 (b"\x00" * 128, -1, 2, "out of range"),
163 (b"\x00" * 128, 2, 2, "out of range"),
164 ],
165 )
166 def test_from_unique_id_bytes_validation(
167 self, unique_id_bytes, rank, world_size, match
168 ):
169 from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
170
171 with pytest.raises(ValueError, match=match):
172 PyNcclCommunicator.from_unique_id_bytes(
173 unique_id_bytes,
174 rank=rank,
175 world_size=world_size,
176 device=0,
177 )
178
179
180 @ray.remote(num_gpus=1)
181 def inference_receive_tensor(
182 init_info_dict: dict,
183 tensor_shape: list[int],
184 tensor_dtype: str,
185 ) -> dict:
186 """Worker that joins via the given init dict and records the tensor it gets.
187
188 Local to this module (rather than the sibling's TCP-only worker) so it can
189 take a UID init payload -- the same dict boundary an external trainer POSTs.
190 """
191 import contextlib
192
193 import torch
194
195 _set_ray_assigned_device()
196
197 from vllm.config.parallel import ParallelConfig
198 from vllm.config.weight_transfer import WeightTransferConfig
199 from vllm.distributed.weight_transfer.nccl_engine import (
200 NCCLWeightTransferEngine,
201 NCCLWeightTransferInitInfo,
202 NCCLWeightTransferUpdateInfo,
203 )
204
205 class Recorder(torch.nn.Module):
206 def __init__(self):
207 super().__init__()
208 self.received = []
209
210 def load_weights(self, weights):
211 for name, tensor in weights:
212 self.received.append((name, tensor.clone()))
213
214 config = WeightTransferConfig(backend="nccl")
215 vllm_config = MagicMock()
216 parallel_config = MagicMock(spec=ParallelConfig)
217 parallel_config.rank = 0
218 parallel_config.world_size = 1
219 parallel_config.data_parallel_rank = 0
220 parallel_config.data_parallel_index = 0
221 vllm_config.parallel_config = parallel_config
222 vllm_config.model_config = MagicMock()
223
224 recorder = Recorder()
225 engine = NCCLWeightTransferEngine(
226 config, vllm_config, torch.device("cuda"), recorder
227 )
228 # Transport-only test: bypass the set_current_vllm_config context that
229 # receive_weights enters, since vllm_config here is a mock.
230 import vllm.config as _vllm_config_mod
231
232 _vllm_config_mod.set_current_vllm_config = lambda cfg: contextlib.nullcontext()
233
234 try:
235 engine.init_transfer_engine(NCCLWeightTransferInitInfo(**init_info_dict))
236 engine.receive_weights(
237 NCCLWeightTransferUpdateInfo(
238 names=["test.weight"],
239 dtype_names=[tensor_dtype],
240 shapes=[tensor_shape],
241 )
242 )
243 torch.accelerator.synchronize()
244
245 received_shape = None
246 received_sum = None
247 if len(recorder.received) == 1:
248 _name, tensor = recorder.received[0]
249 received_shape = list(tensor.shape)
250 received_sum = tensor.sum().item()
251
252 return {
253 "success": received_shape == tensor_shape
254 and abs(received_sum - torch.tensor(tensor_shape).prod().item()) < 0.01,
255 "received_shape": received_shape,
256 "received_sum": received_sum,
257 }
258 finally:
259 engine.shutdown()
260
261
262 @ray.remote(num_gpus=1)
263 def trainer_broadcast_tensor_uid(
264 uid_b64: str,
265 world_size: int,
266 tensor_shape: list[int],
267 tensor_dtype: str,
268 ) -> dict:
269 """Drive rank 0 via vLLM's own communicator from a pre-shared unique id.
270
271 A real torch-free trainer would mint the id and drive rank 0 itself; here we
272 reuse the real send protocol by injecting the communicator into a sender
273 engine, exercising the worker-side UID path end to end.
274 """
275 import pybase64 as base64
276 import torch
277
278 _set_ray_assigned_device()
279
280 from vllm.distributed.weight_transfer import ModuleSource
281 from vllm.distributed.weight_transfer.nccl_common import uid_init_process_group
282 from vllm.distributed.weight_transfer.nccl_engine import (
283 NCCLTrainerWeightTransferEngine,
284 )
285
286 class NoopClient:
287 def init_weight_transfer_engine(self, init_info):
288 pass
289
290 def start_weight_update(self):
291 pass
292
293 def update_weights(self, update_info):
294 pass
295
296 def finish_weight_update(self, weight_version=None):
297 pass
298
299 dtype = getattr(torch, tensor_dtype)
300 model = torch.nn.Module()
301 test_module = torch.nn.Module()
302 test_module.register_parameter(
303 "weight",
304 torch.nn.Parameter(
305 torch.ones(tensor_shape, dtype=dtype, device="cuda"), requires_grad=False
306 ),
307 )
308 model.add_module("test", test_module)
309
310 engine = NCCLTrainerWeightTransferEngine(
311 client=NoopClient(),
312 source=ModuleSource(model),
313 is_sender=True,
314 packed=False,
315 )
316 engine.model_update_group = uid_init_process_group(
317 base64.b64decode(uid_b64),
318 rank=0,
319 world_size=world_size,
320 device=torch.accelerator.current_device_index(),
321 )
322 try:
323 engine.send_weights()
324 torch.accelerator.synchronize()
325 return {"success": True}
326 finally:
327 engine.shutdown()
328
329
330 @ray.remote(num_gpus=1)
331 def trainer_broadcast_tensor_torch_free(
332 uid_b64: str,
333 world_size: int,
334 tensor_shape: list[int],
335 tensor_dtype: str,
336 ) -> dict:
337 """Trainer peer that uses no torch and no PyNcclCommunicator (a JAX stand-in).
338
339 Drives NCCL through the raw ctypes wrapper with a cupy send buffer, so it
340 exercises interop with a foreign NCCL peer rather than vLLM against itself.
341 ``PyNcclCommunicator.__init__`` runs a one-element warm-up all_reduce right
342 after ``ncclCommInitRank``; that is a collective, so this peer must issue the
343 matching all_reduce before the broadcast or both ranks deadlock.
344 """
345 import cupy as cp
346 import numpy as np
347 import pybase64 as base64
348
349 device = _set_ray_assigned_device()
350 cp.cuda.Device(device.index).use()
351
352 from vllm.distributed.device_communicators.pynccl_wrapper import (
353 NCCLLibrary,
354 buffer_type,
355 cudaStream_t,
356 ncclDataTypeEnum,
357 ncclRedOpTypeEnum,
358 )
359
360 assert tensor_dtype == "float32", "harness only wires float32"
361 # Fill on the host and copy up (a plain H2D memcpy) so we neither use torch
362 # nor make cupy JIT-compile a fill kernel (which needs NVRTC). All-ones lets
363 # the worker verify by sum == numel.
364 sendbuff = cp.asarray(np.ones(tensor_shape, dtype=np.float32))
365 warmup = cp.asarray(np.zeros(1, dtype=np.float32))
366 numel = int(sendbuff.size)
367 # The copies above run on the default stream; drain them before the NCCL
368 # collectives read the buffers on our own stream.
369 cp.cuda.Device(device.index).synchronize()
370 stream = cp.cuda.Stream()
371
372 nccl = NCCLLibrary()
373 comm = nccl.ncclCommInitRank(
374 world_size, nccl.unique_id_from_bytes(base64.b64decode(uid_b64)), 0
375 )
376 try:
377 # Mirror the worker's init-time warm-up all_reduce (rank 0 must join it).
378 nccl.ncclAllReduce(
379 buffer_type(warmup.data.ptr),
380 buffer_type(warmup.data.ptr),
381 1,
382 ncclDataTypeEnum.ncclFloat32,
383 ncclRedOpTypeEnum.ncclSum,
384 comm,
385 cudaStream_t(stream.ptr),
386 )
387 nccl.ncclBroadcast(
388 buffer_type(sendbuff.data.ptr),
389 buffer_type(sendbuff.data.ptr),
390 numel,
391 ncclDataTypeEnum.ncclFloat32,
392 0, # root
393 comm,
394 cudaStream_t(stream.ptr),
395 )
396 stream.synchronize()
397 return {"success": True}
398 finally:
399 # Abort (not the collective destroy) for an uncoordinated teardown.
400 nccl.ncclCommAbort(comm)
401
402
403 def _run_uid_transfer(trainer_task) -> None:
404 """Mint a unique id, run the worker and the given trainer peer concurrently,
405 and assert the tensor round-trips. Both ranks must enter init together --
406 there is no store barrier on the UID path."""
407 from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
408
409 _init_ray_for_weight_transfer()
410
411 nccl = NCCLLibrary()
412 uid_b64 = base64.b64encode(bytes(nccl.ncclGetUniqueId().internal)).decode()
413
414 world_size = 2 # 1 trainer + 1 inference worker
415 tensor_shape = [100, 100]
416 tensor_dtype = "float32"
417
418 inference_future = inference_receive_tensor.remote(
419 {
420 "rank_offset": 1,
421 "world_size": world_size,
422 "nccl_unique_id_b64": uid_b64,
423 "packed": False,
424 },
425 tensor_shape,
426 tensor_dtype,
427 )
428 trainer_future = trainer_task.remote(
429 uid_b64, world_size, tensor_shape, tensor_dtype
430 )
431
432 try:
433 trainer_result, result = ray.get(
434 [trainer_future, inference_future], timeout=120
435 )
436 finally:
437 ray.shutdown()
438
439 assert trainer_result["success"], "Trainer should complete successfully"
440 assert result["success"], (
441 f"Weight transfer failed. "
442 f"Received shape: {result['received_shape']}, "
443 f"Received sum: {result['received_sum']}"
444 )
445
446
447 @pytest.mark.skipif(
448 torch.accelerator.device_count() < 2,
449 reason="Need at least 2 GPUs to run NCCL weight transfer test.",
450 )
451 def test_nccl_weight_transfer_between_processes_uid():
452 """Weight transfer over a pre-shared ncclUniqueId (no TCPStore), with both
453 ranks on vLLM's ``PyNcclCommunicator``."""
454 _run_uid_transfer(trainer_broadcast_tensor_uid)
455
456
457 @pytest.mark.skipif(
458 torch.accelerator.device_count() < 2,
459 reason="Need at least 2 GPUs to run NCCL weight transfer test.",
460 )
461 def test_nccl_weight_transfer_torch_free_trainer():
462 """UID weight transfer against a foreign peer that uses no torch and no vLLM
463 communicator, only a cupy buffer and raw ``ncclBroadcast``."""
464 pytest.importorskip("cupy")
465 _run_uid_transfer(trainer_broadcast_tensor_torch_free)