main
py 375 lines 14.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Sparse NCCL weight transfer engine.
4
5 Sparse patches use checkpoint names, shapes, and flat indices. The model's native
6 weight loader maps them to rank-local runtime parameters, including TP shards and
7 packed parameters.
8 """
9
10 from collections.abc import Iterable
11 from concurrent.futures import ThreadPoolExecutor
12 from contextlib import nullcontext
13 from dataclasses import asdict, dataclass
14 from typing import TYPE_CHECKING, ClassVar
15
16 import torch
17 from typing_extensions import Self
18
19 if TYPE_CHECKING:
20 from vllm.config import VllmConfig
21 from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
22
23 from vllm.config.weight_transfer import WeightTransferConfig
24 from vllm.distributed.weight_transfer.base import (
25 TrainerInitInfo,
26 TrainerWeightTransferEngine,
27 VLLMWeightSyncClient,
28 WeightSource,
29 WeightTransferEngine,
30 WeightTransferUpdateInfo,
31 )
32 from vllm.distributed.weight_transfer.nccl_common import (
33 NCCLWeightTransferInitInfo,
34 worker_init_payload,
35 worker_init_process_group,
36 )
37 from vllm.distributed.weight_transfer.nccl_common import (
38 trainer_init as open_trainer_endpoint,
39 )
40 from vllm.model_executor.model_loader.checkpoint_weight_patch import (
41 CheckpointWeightPatch,
42 load_checkpoint_weight_patches,
43 )
44
45 __all__ = [
46 "SparseWeightPatch",
47 "SparseNCCLTrainerInitInfo",
48 "SparseNCCLWeightTransferUpdateInfo",
49 "SparseNCCLWeightTransferEngine",
50 "SparseNCCLTrainerWeightTransferEngine",
51 ]
52
53
54 @dataclass
55 class SparseWeightPatch:
56 """A sparse patch in checkpoint coordinates."""
57
58 name: str
59 indices: torch.Tensor
60 values: torch.Tensor
61 full_shape: tuple[int, ...]
62 """Full checkpoint shape."""
63
64
65 @dataclass
66 class SparseNCCLTrainerInitInfo(TrainerInitInfo):
67 """Trainer-side init info for the sparse NCCL weight transfer backend.
68
69 Same rendezvous shape as the dense NCCL backend (the sender opens its
70 endpoint as NCCL rank 0), but with no packed wire params: sparse transfers
71 are never packed. `backend` is the factory dispatch key."""
72
73 backend: ClassVar[str] = "sparse_nccl"
74
75 master_address: str
76 master_port: int
77 world_size: int
78
79
80 @dataclass
81 class SparseNCCLWeightTransferUpdateInfo(WeightTransferUpdateInfo):
82 """Update info for the sparse NCCL weight transfer backend."""
83
84 names: list[str]
85 dtype_names: list[str]
86 shapes: list[list[int]]
87 num_updates_list: list[int]
88 """Number of sparse entries to receive for each parameter in ``names``."""
89
90 def __post_init__(self) -> None:
91 num_params = len(self.names)
92 if len(self.dtype_names) != num_params:
93 raise ValueError(
94 f"`dtype_names` should be of the same size as `names`: "
95 f"got {len(self.dtype_names)} and {len(self.names)}"
96 )
97 if len(self.shapes) != num_params:
98 raise ValueError(
99 f"`shapes` should be of the same size as `names`: "
100 f"got {len(self.shapes)} and {len(self.names)}"
101 )
102 if len(self.num_updates_list) == 0:
103 raise ValueError("`num_updates_list` cannot be empty for sparse updates")
104 if len(self.num_updates_list) != num_params:
105 raise ValueError(
106 f"`num_updates_list` should be of the same size as `names`: "
107 f"got {len(self.num_updates_list)} and {len(self.names)}"
108 )
109 if any(num_updates < 0 for num_updates in self.num_updates_list):
110 raise ValueError("Sparse `num_updates_list` entries must be non-negative")
111
112
113 class SparseNCCLWeightTransferEngine(
114 WeightTransferEngine[NCCLWeightTransferInitInfo, SparseNCCLWeightTransferUpdateInfo]
115 ):
116 """Sparse weight transfer engine using NCCL.
117
118 Receives checkpoint-coordinate patches broadcast from the trainer and applies
119 them through the model's native weight loader. Sparse updates modify initialized
120 model tensors in place, so the layerwise reload lifecycle is not used.
121 """
122
123 init_info_cls = NCCLWeightTransferInitInfo
124 update_info_cls = SparseNCCLWeightTransferUpdateInfo
125 supports_draft_weight_update = False
126
127 def __init__(
128 self,
129 config: WeightTransferConfig,
130 vllm_config: "VllmConfig",
131 device: torch.device,
132 model: torch.nn.Module,
133 ) -> None:
134 super().__init__(config, vllm_config, device, model)
135 self.model_update_group: PyNcclCommunicator | None = None
136
137 def init_transfer_engine(self, init_info: NCCLWeightTransferInitInfo) -> None:
138 """Initialize the NCCL process group with the trainer."""
139 self.model_update_group = worker_init_process_group(
140 init_info, self.parallel_config
141 )
142
143 def start_weight_update(self) -> None:
144 """No-op: sparse patches are applied in place, no layerwise reload."""
145 pass
146
147 def finish_weight_update(self) -> None:
148 """No-op: sparse patches are applied in place, no layerwise reload."""
149 pass
150
151 def receive_weights(self, update_info: SparseNCCLWeightTransferUpdateInfo) -> None:
152 """Receive sparse flat-index patches from the trainer and apply them."""
153 if self.model_update_group is None:
154 raise RuntimeError(
155 "NCCL weight transfer not initialized. "
156 "Call init_transfer_engine() first."
157 )
158
159 # Receive on the communicator's device rather than relying on the
160 # ambient current device in the RPC thread.
161 device = self.model_update_group.device
162 with torch.accelerator.device_index(device.index):
163 stream = torch.cuda.current_stream(device=device)
164 patches = []
165 for name, dtype_name, shape, num_updates in zip(
166 update_info.names,
167 update_info.dtype_names,
168 update_info.shapes,
169 update_info.num_updates_list,
170 strict=True,
171 ):
172 dtype = getattr(torch, dtype_name)
173 indices = torch.empty(num_updates, dtype=torch.int32, device=device)
174 values = torch.empty(num_updates, dtype=dtype, device=device)
175 self.model_update_group.broadcast(indices, src=0, stream=stream)
176 self.model_update_group.broadcast(values, src=0, stream=stream)
177 patches.append(
178 CheckpointWeightPatch(
179 name=name,
180 shape=tuple(shape),
181 dtype=dtype,
182 values=values,
183 indices=indices,
184 )
185 )
186 load_checkpoint_weight_patches(self.model, patches)
187
188 def shutdown(self) -> None:
189 if self.model_update_group is not None:
190 self.model_update_group = None
191
192
193 class SparseNCCLTrainerWeightTransferEngine(
194 TrainerWeightTransferEngine[SparseNCCLTrainerInitInfo]
195 ):
196 """Trainer-side sparse NCCL weight transfer engine.
197
198 Broadcasts flat-index (indices, values) patches from NCCL rank 0 while the
199 inference-side `update_weights` runs concurrently on a side thread (the
200 worker's recvs rendezvous inside the same NCCL broadcasts). `send_weights`
201 owns a complete one-shot lifecycle. RL infrastructure that owns the generic
202 client lifecycle can call `send_weight_chunk` between one start and finish.
203
204 Sparse patches differ every round, so they are not a stable `WeightSource`:
205 the engine takes no `source`, and patches are passed directly to the send
206 methods. An empty patch list is a no-op.
207
208 Only the designated trainer sender joins the transfer group; other trainer
209 ranks skip sparse sends.
210 """
211
212 init_info_cls = SparseNCCLTrainerInitInfo
213
214 def __init__(
215 self,
216 *,
217 client: VLLMWeightSyncClient,
218 source: WeightSource | None = None,
219 is_sender: bool = True,
220 ) -> None:
221 # Sparse is a delta backend: it takes per-round patches via
222 # send_weights, so a `source` would silently never be sent. The
223 # parameter exists only to match the base/factory signature.
224 if source is not None:
225 raise ValueError(
226 "Sparse NCCL weight transfer takes no WeightSource; pass each "
227 "round's patches to send_weights(patches) instead."
228 )
229 super().__init__(client=client, source=source, is_sender=is_sender)
230 self.model_update_group: PyNcclCommunicator | None = None
231
232 @classmethod
233 def trainer_init(
234 cls,
235 init_info: SparseNCCLTrainerInitInfo,
236 *,
237 client: VLLMWeightSyncClient,
238 source: WeightSource | None = None,
239 ) -> Self:
240 engine = cls(client=client, source=source, is_sender=init_info.is_sender)
241 if not engine.is_sender:
242 return engine
243
244 # Workers sit at rank_offset 1, after the single trainer sender rank 0.
245 # Sparse transfers are never packed, so the worker keeps the unpacked
246 # defaults on its init info.
247 worker_init_info = NCCLWeightTransferInitInfo(
248 master_address=init_info.master_address,
249 master_port=init_info.master_port,
250 rank_offset=1,
251 world_size=init_info.world_size,
252 )
253
254 # The inference workers block inside init_weight_transfer_engine waiting
255 # for the NCCL rendezvous, so we kick that off on a side thread while we
256 # open the trainer endpoint (rank 0); both sides must rendezvous together.
257 with ThreadPoolExecutor(max_workers=1) as exe:
258 future = exe.submit(
259 engine.client.init_weight_transfer_engine,
260 worker_init_payload(worker_init_info),
261 )
262 # Open the trainer endpoint as NCCL rank 0 on the current device
263 # (the init info satisfies the helper's rendezvous protocol).
264 engine.model_update_group = open_trainer_endpoint(init_info)
265 future.result() # surface any inference-side init error
266
267 return engine
268
269 def send_weights(self, patches: Iterable[SparseWeightPatch] | None = None) -> None:
270 """Broadcast one sparse update through a one-shot lifecycle."""
271 patches = self._prepare_patches(patches)
272 if not patches:
273 return
274
275 self.client.start_weight_update()
276 self._broadcast_chunk(patches)
277 self.client.finish_weight_update()
278
279 def send_weight_chunk(
280 self, patches: Iterable[SparseWeightPatch] | None = None
281 ) -> None:
282 """Broadcast one chunk inside a caller-owned weight update lifecycle."""
283 patches = self._prepare_patches(patches)
284 if not patches:
285 return
286 self._broadcast_chunk(patches)
287
288 def _prepare_patches(
289 self, patches: Iterable[SparseWeightPatch] | None
290 ) -> list[SparseWeightPatch]:
291 if not self.is_sender:
292 return []
293
294 prepared = list(patches) if patches is not None else []
295 if not prepared:
296 return []
297 if self.model_update_group is None:
298 raise RuntimeError("trainer_init() must be called before sending weights.")
299 for patch in prepared:
300 self._validate_patch(patch)
301 return prepared
302
303 def _broadcast_chunk(self, patches: list[SparseWeightPatch]) -> None:
304 assert self.model_update_group is not None
305 device = self.model_update_group.device
306 device_context = (
307 torch.accelerator.device_index(device.index)
308 if device.type != "cpu"
309 else nullcontext()
310 )
311 with device_context:
312 update_info = SparseNCCLWeightTransferUpdateInfo(
313 names=[patch.name for patch in patches],
314 dtype_names=[
315 str(patch.values.dtype).split(".")[-1] for patch in patches
316 ],
317 shapes=[list(patch.full_shape) for patch in patches],
318 num_updates_list=[patch.indices.numel() for patch in patches],
319 )
320
321 # update_weights (workers receive) must run concurrently with the
322 # trainer-side broadcasts — both rendezvous inside the same NCCL calls.
323 executor = ThreadPoolExecutor(max_workers=1)
324 try:
325 future = executor.submit(
326 self.client.update_weights, asdict(update_info)
327 )
328 # Surface an RPC that failed before the worker entered NCCL rather
329 # than broadcasting to a peer that will never receive.
330 if future.done():
331 future.result()
332 stream = torch.cuda.current_stream()
333 for patch in patches:
334 self.model_update_group.broadcast(
335 patch.indices, src=0, stream=stream
336 )
337 self.model_update_group.broadcast(
338 patch.values, src=0, stream=stream
339 )
340 future.result() # surface inference-side errors
341 finally:
342 # A failed broadcast can leave the RPC blocked in the matching
343 # receive. Waiting for that thread would hide the original error.
344 executor.shutdown(wait=False, cancel_futures=True)
345 self._post_send_sync()
346
347 @staticmethod
348 def _validate_patch(patch: SparseWeightPatch) -> None:
349 """Reject a malformed patch before starting the NCCL transfer."""
350 if patch.full_shape is None:
351 raise ValueError(f"Sparse patch requires full_shape: {patch.name}")
352 if patch.indices.dtype != torch.int32:
353 raise ValueError(
354 f"Sparse weight updates require int32 indices: {patch.name}"
355 )
356 if patch.indices.ndim != 1 or patch.values.ndim != 1:
357 raise ValueError(
358 f"Sparse weight patches must be 1D flattened updates: {patch.name}"
359 )
360 if patch.indices.numel() != patch.values.numel():
361 raise ValueError(
362 f"`indices` and `values` must have matching lengths for {patch.name}"
363 )
364
365 def _post_send_sync(self) -> None:
366 """Wait for the broadcasts to land before returning, so a caller may
367 rebuild or free the patch tensors as soon as a send method returns rather
368 than relying on same-stream ordering. See
369 `NCCLTrainerWeightTransferEngine._post_send_sync` for why there is no
370 cross-rank barrier."""
371 if torch.cuda.is_available():
372 torch.cuda.current_stream().synchronize()
373
374 def shutdown(self) -> None:
375 self.model_update_group = None