[RL] Support checkpoint-coordinate sparse NCCL weight updates (#53751)

Signed-off-by: aoshen02 <aoshen@inferact.ai> Signed-off-by: Shuolei Wang <shuoleiwang123@gmail.com> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: OpenAI Codex <codex@openai.com>

Shuolei Wang committed Aug 27, 2026 at 18:19 UTC 6c4aa8bec3029ec03d32194dc7aa0d1252253eaf
5 files changed +436 -798
docs/training/weight_transfer/README.md
+1 -1
@@ -33,7 +33,7 @@ engine drives on your behalf:
33 | ------- | --------- | -------- |
34 | [NCCL](nccl.md) | NCCL broadcast | Separate GPUs for training and inference |
35 | [IPC](ipc.md) | CUDA IPC handles | Colocated training and inference on same GPU |
36 -| [sparse_nccl](nccl.md#sparse-nccl) | NCCL broadcast | Sparse flat-index weight patches (TP=1/PP=1) |
36 +| [sparse_nccl](nccl.md#sparse-nccl) | NCCL broadcast | Checkpoint-coordinate sparse weight patches |
37 | [sharded_rdt](sharded_rdt.md) | NIXL / Ray Direct Transport (pull-based) | Very large models where each worker needs only its own slice (MoE with expert parallelism) |
38
39 ## Quickstart
docs/training/weight_transfer/nccl.md
+28 -22
@@ -132,17 +132,19 @@ pass — this is the invariant to test first.
132
133 ## Sparse NCCL
134
135 -Sparse, flat-index weight patches use a separate backend,
136 -`WeightTransferConfig(backend="sparse_nccl")`. It shares only NCCL process-group
137 -initialization with the dense engine; patches are applied directly in place to
138 -existing parameters, with no layerwise reload. The current sparse MVP requires
139 -`TP=1` and `PP=1`, and assumes a single-rank trainer.
140 -
141 -Sparse is a **delta** backend: each round's patches are a fresh set of deltas
142 -from the latest optimizer step, not a stable stream of the model's parameters. So
143 -the engine takes **no `WeightSource`** — passing one raises — and each round's
144 -patches go straight to `send_weights(patches)`. A round with no patches is a
145 -no-op.
135 +Sparse, flat-index weight patches use
136 +`WeightTransferConfig(backend="sparse_nccl")`. Names, full shapes, and flat
137 +indices are interpreted in **checkpoint/Hugging Face coordinates**. Every
138 +inference rank receives the same checkpoint-global patch, then the model's native
139 +`load_weights()` maps it to rank-local TP/EP and packed runtime parameters.
140 +
141 +Tensor parallelism is supported and does not have to match the trainer's layout.
142 +Pipeline-parallel behavior is not covered by the current sparse NCCL GPU test.
143 +
144 +Sparse is a **delta** backend: each call supplies replacement patches rather
145 +than a stable stream of the model's parameters. The engine therefore takes no
146 +`WeightSource`; patches go straight to `send_weights(patches)`, and an empty
147 +patch list is a no-op.
148
149 ```python
150 from vllm.distributed.weight_transfer import (
@@ -154,6 +156,7 @@ from vllm.distributed.weight_transfer.sparse_nccl_engine import (
156 SparseWeightPatch,
157 )
158
159 +client = RayVLLMWeightSyncClient(llm)
160 engine = WeightTransferTrainerFactory.trainer_init(
161 init_info=SparseNCCLTrainerInitInfo(
162 master_address=master_address,
@@ -161,7 +164,7 @@ engine = WeightTransferTrainerFactory.trainer_init(
164 world_size=world_size,
165 rank=0,
166 ),
164 - client=RayVLLMWeightSyncClient(llm),
167 + client=client,
168 )
169
170 patches = [
@@ -175,21 +178,24 @@ patches = [
178 engine.send_weights(patches)
179 ```
180
178 -`SparseNCCLTrainerInitInfo` takes the same rendezvous fields as the dense
179 -backend and no packed params — sparse transfers are never packed.
181 +Each `send_weights(patches)` call owns a complete one-shot `start` / `update` / `finish` lifecycle.
182 +
183 +RL infrastructure that owns the generic worker lifecycle can keep one logical update open across bounded chunks without adding trainer-side session state:
184 +
185 +```python
186 +client.start_weight_update()
187 +for patches in patch_chunks:
188 + engine.send_weight_chunk(patches)
189 +client.finish_weight_update()
190 +```
191
181 -Patches are validated on the trainer *before* `start_weight_update`: int32
182 -indices, 1-D, and matching `indices`/`values` lengths, with `full_shape` set. The
183 -worker checks the same invariants when applying, but by then the broadcasts are
184 -under way, where a size mismatch wedges both sides instead of raising.
192 +Sparse NCCL sends only `O(nnz)` indices and values. Checkpoint application still uses `O(N)` staging through the native loader, and the caller owns export/diff state and restart or reseed after a partial failure.
193
186 -A trainer can hold a dense engine and a sparse engine at once — a full resync
187 -through the dense engine, cheap sparse deltas in between — as
188 -[`rlhf_sparse_nccl.py`](../../../examples/rl/rlhf_sparse_nccl.py) demonstrates.
194 +[`rlhf_sparse_nccl.py`](../../../examples/rl/rlhf_sparse_nccl.py) demonstrates per-expert checkpoint patches with a Qwen3 MoE TP2/EP2 engine.
195
196 ## Examples
197
198 - [RLHF with NCCL weight syncing (`vllm serve`, HTTP)](../../../examples/rl/rlhf_http_nccl.py) - **Start here.** Trainer on one GPU, 2x tensor-parallel fp8 server on two others; HTTP control plane, NCCL data plane. Launches and tears down its own server
199 - [RLHF with NCCL + FSDP2 and expert parallelism](../../../examples/rl/rlhf_nccl_fsdp_ep.py) - Multi-rank trainer: every FSDP rank builds an engine and joins the `full_tensor()` gather while only rank 0 touches the wire
194 -- [RLHF with sparse NCCL weight syncing (offline, Ray)](../../../examples/rl/rlhf_sparse_nccl.py) - Dense-vs-sparse equivalence demo with a real model on a 2-GPU trainer/inference setup; sparse patches use `backend="sparse_nccl"` and currently require `TP=1` and `PP=1`
200 +- [RLHF with sparse NCCL weight syncing (offline, Ray)](../../../examples/rl/rlhf_sparse_nccl.py) - Qwen3 MoE per-expert checkpoint updates with one trainer GPU and two TP2/EP2 inference GPUs
201 - [RLHF with async weight syncing (offline, Ray)](../../../examples/rl/rlhf_async_new_apis.py) - Async generation with mid-flight pause, weight sync, resume, and validation against a fresh model; uses an in-process `AsyncLLMEngine` with `RayVLLMWeightSyncClient`
examples/rl/rlhf_sparse_nccl.py
+158 -390
@@ -1,64 +1,45 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""
4 -Demonstrates dense-vs-sparse NCCL weight syncing with a real model.
5 -
6 -This example mirrors the validation story used for the sparse NCCL MVP:
7 -both the dense update path and the sparse patch path start from the same real
8 -checkpoint and apply the same deterministic trainer-side patch. The script then
9 -checks that greedy 1-token outputs match between the dense and sparse vLLM
10 -engines after the update.
11 -
12 -The example performs the following steps:
13 -* Load a training model on one GPU via a Ray actor.
14 -* Launch a vLLM engine with the same real model on a second GPU.
15 -* Verify trainer vs vLLM baseline agreement before any update.
16 -* Apply a deterministic patch to ``model.embed_tokens.weight`` on the trainer.
17 -* Run a dense NCCL update into a fresh vLLM engine and collect post-update
18 - outputs.
19 -* Reset the trainer back to the baseline checkpoint.
20 -* Apply the same deterministic patch again.
21 -* Run a sparse NCCL update into another fresh vLLM engine and collect
22 - post-update outputs.
23 -* Compare dense vs sparse baseline outputs, dense vs sparse post-update
24 - outputs, estimated payload sizes, and trainer-side send times.
25 -
26 -Current sparse weight transfer MVP limitations:
27 -* ``TP=1`` and ``PP=1`` only
28 -* sparse updates use runtime/kernel-format parameter names
29 -* sparse updates are not composable with checkpoint-format or packed updates
30 -
31 -This example assumes a single-node cluster with two GPUs.
3 +"""Demonstrate checkpoint-coordinate sparse NCCL updates with expert parallelism.
4 +
5 +The trainer and vLLM start from the same Qwen3 MoE checkpoint. The trainer
6 +modifies rows from two global experts, converts its fused expert storage back to
7 +per-expert checkpoint coordinates, and sends both patches through one
8 +``send_weights()`` lifecycle. Every vLLM rank receives both patches; the native
9 +loader applies its local expert and skips the foreign expert.
10 +
11 +This example uses three GPUs on one node: one for the full BF16 Hugging Face
12 +trainer model and two for a TP2/EP2 vLLM inference engine. Unspecified checkpoint
13 +elements keep their initialized values, so sparse updates require a known shared
14 +baseline. ``SPARSE_NCCL_MODEL`` may point to a compatible local Qwen3 MoE
15 +checkpoint.
16 """
17
34 -import hashlib
18 import os
36 -import time
37 -from collections.abc import Sequence
19 +from contextlib import suppress
20
21 import ray
22 import torch
23 from ray.util.placement_group import placement_group
24 from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
43 -from transformers import AutoModelForCausalLM, AutoTokenizer
25 +from transformers import AutoModelForCausalLM
26
27 from vllm import LLM, SamplingParams
28 from vllm.config import WeightTransferConfig
29 from vllm.distributed.weight_transfer import (
48 - ModuleSource,
30 RayVLLMWeightSyncClient,
31 WeightTransferTrainerFactory,
32 )
52 -from vllm.distributed.weight_transfer.nccl_engine import NCCLTrainerInitInfo
33 from vllm.distributed.weight_transfer.sparse_nccl_engine import (
34 SparseNCCLTrainerInitInfo,
35 SparseWeightPatch,
36 )
37 from vllm.utils.network_utils import get_ip, get_open_port
38
59 -MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
60 -PATCHED_PARAM_NAME = "model.embed_tokens.weight"
61 -MAX_PATCH_ROWS = 32
39 +MODEL_NAME = os.environ.get("SPARSE_NCCL_MODEL", "Qwen/Qwen3-30B-A3B")
40 +INFERENCE_TP_SIZE = 2
41 +PATCHED_LAYER = 0
42 +PATCHED_ROWS = 2
43 PROMPTS = [
44 "Hello, my name is",
45 "The president of the United States is",
@@ -72,414 +53,201 @@ class MyLLM(LLM):
53 """Configure the vLLM worker for Ray placement group execution."""
54
55 def __init__(self, *args, **kwargs):
75 - os.environ["VLLM_RAY_BUNDLE_INDICES"] = "0"
56 + os.environ["VLLM_RAY_BUNDLE_INDICES"] = ",".join(
57 + str(index) for index in range(INFERENCE_TP_SIZE)
58 + )
59 super().__init__(*args, **kwargs)
60
61
62 @ray.remote(num_gpus=1)
63 class TrainModel:
81 - """Ray actor that owns the trainer-side model and deterministic patch state."""
64 + """Own the trainer model and sparse NCCL sender on one GPU."""
65
66 def __init__(self, model_name: str):
84 - self.model_name = model_name
85 - self.tokenizer = AutoTokenizer.from_pretrained(model_name)
86 - if self.tokenizer.pad_token_id is None:
87 - self.tokenizer.pad_token = self.tokenizer.eos_token
88 -
89 - self.model = None
90 - self.patched_param = None
91 - self.pending_sparse_patches: list[SparseWeightPatch] | None = None
92 - self.dense_engine = None
93 - self.sparse_engine = None
94 - self.master_address = get_ip()
95 - self.port = get_open_port()
96 - self.reset_model()
97 -
98 - def reset_model(self) -> None:
67 self.model = AutoModelForCausalLM.from_pretrained(
100 - self.model_name,
68 + model_name,
69 torch_dtype=torch.bfloat16,
70 ).to("cuda:0")
71 self.model.eval()
72
73 + config = self.model.config
74 + if config.model_type != "qwen3_moe" or config.num_experts % INFERENCE_TP_SIZE:
75 + raise RuntimeError(
76 + "This recipe requires a Qwen3 MoE model whose experts divide "
77 + f"evenly across TP{INFERENCE_TP_SIZE}"
78 + )
79 + self.expert_ids = (0, config.num_experts // 2)
80 + self.expert_intermediate_size = config.moe_intermediate_size
81 + fused_name = f"model.layers.{PATCHED_LAYER}.mlp.experts.gate_up_proj"
82 try:
106 - self.patched_param = self.model.get_parameter(PATCHED_PARAM_NAME)
83 + self.gate_up_proj = self.model.get_parameter(fused_name)
84 except AttributeError as exc:
85 raise RuntimeError(
109 - f"Expected trainer model to expose `{PATCHED_PARAM_NAME}`"
86 + f"Expected trainer model to expose `{fused_name}`"
87 ) from exc
88
112 - self.pending_sparse_patches = None
89 + expected_shape = (
90 + config.num_experts,
91 + 2 * self.expert_intermediate_size,
92 + config.hidden_size,
93 + )
94 + if self.gate_up_proj.shape != expected_shape:
95 + raise RuntimeError(
96 + f"Unexpected fused expert shape: {self.gate_up_proj.shape} "
97 + f"!= {expected_shape}"
98 + )
99
114 - def create_rendezvous(self) -> tuple[str, int]:
115 - self.port = get_open_port()
116 - return self.master_address, self.port
100 + self.master_address = get_ip()
101 + self.master_port = get_open_port()
102 + self.engine = None
103
118 - def init_dense_engine(self, world_size: int, llm_handle) -> None:
119 - """Build the dense NCCL trainer engine"""
120 - self.dense_engine = WeightTransferTrainerFactory.trainer_init(
121 - init_info=NCCLTrainerInitInfo(
104 + def init_sparse_engine(self, world_size: int, llm_handle) -> None:
105 + self.engine = WeightTransferTrainerFactory.trainer_init(
106 + init_info=SparseNCCLTrainerInitInfo(
107 master_address=self.master_address,
123 - master_port=self.port,
108 + master_port=self.master_port,
109 world_size=world_size,
125 - rank=0, # single-GPU trainer is the sole (sender) rank
126 - packed=True,
110 + rank=0,
111 ),
112 client=RayVLLMWeightSyncClient(llm_handle),
129 - source=ModuleSource(self.model),
130 - )
131 -
132 - def shutdown_engines(self) -> None:
133 - """Drop the NCCL communicators before the vLLM engine they point at
134 - goes away, so the next phase rendezvouses from a clean slate."""
135 - for attr in ("dense_engine", "sparse_engine"):
136 - engine = getattr(self, attr)
137 - if engine is not None:
138 - engine.shutdown()
139 - setattr(self, attr, None)
140 -
141 - def dense_payload_bytes(self) -> int:
142 - return sum(
143 - param.numel() * param.element_size() for param in self.model.parameters()
113 )
114
146 - @torch.inference_mode()
147 - def generate(
148 - self,
149 - prompts: Sequence[str],
150 - max_new_tokens: int = 1,
151 - ) -> list[dict[str, object]]:
152 - generations = []
153 - for prompt in prompts:
154 - model_inputs = self.tokenizer(prompt, return_tensors="pt").to("cuda:0")
155 - output = self.model.generate(
156 - **model_inputs,
157 - max_new_tokens=max_new_tokens,
158 - do_sample=False,
159 - pad_token_id=self.tokenizer.pad_token_id,
115 + @torch.no_grad()
116 + def patch_and_send(self) -> tuple[list[str], int]:
117 + if self.engine is None:
118 + raise RuntimeError("Sparse NCCL engine is not initialized")
119 +
120 + patches = []
121 + for expert_id in self.expert_ids:
122 + checkpoint_weight = self.gate_up_proj[
123 + expert_id, : self.expert_intermediate_size
124 + ]
125 + original_rows = checkpoint_weight[:PATCHED_ROWS].clone()
126 + replacement_rows = original_rows.flip(0)
127 + if torch.equal(original_rows, replacement_rows):
128 + raise RuntimeError(f"Expert {expert_id} patch would be a no-op")
129 + checkpoint_weight[:PATCHED_ROWS] = replacement_rows
130 +
131 + hidden_size = checkpoint_weight.shape[1]
132 + flat_indices = torch.arange(
133 + PATCHED_ROWS * hidden_size,
134 + device=checkpoint_weight.device,
135 + dtype=torch.int32,
136 )
161 - new_token_ids = output[0, model_inputs["input_ids"].shape[1] :].tolist()
162 - generations.append(
163 - {
164 - "token_ids": new_token_ids,
165 - "text": self.tokenizer.decode(
166 - new_token_ids,
167 - skip_special_tokens=False,
137 + patches.append(
138 + SparseWeightPatch(
139 + name=(
140 + f"model.layers.{PATCHED_LAYER}.mlp.experts."
141 + f"{expert_id}.gate_proj.weight"
142 ),
169 - }
143 + full_shape=tuple(checkpoint_weight.shape),
144 + indices=flat_indices,
145 + values=replacement_rows.reshape(-1).contiguous(),
146 + )
147 )
171 - return generations
172 -
173 - def prepare_sparse_patch(
174 - self,
175 - prompts: Sequence[str],
176 - max_patch_rows: int = MAX_PATCH_ROWS,
177 - ) -> tuple[list[int], str, int]:
178 - selected_token_ids: list[int] = []
179 - special_ids = set(self.tokenizer.all_special_ids)
180 - for prompt in prompts:
181 - token_ids = self.tokenizer(prompt, add_special_tokens=False)["input_ids"]
182 - for token_id in token_ids:
183 - if token_id in special_ids or token_id in selected_token_ids:
184 - continue
185 - selected_token_ids.append(token_id)
186 - if len(selected_token_ids) == max_patch_rows:
187 - break
188 - if len(selected_token_ids) == max_patch_rows:
189 - break
190 -
191 - if not selected_token_ids:
192 - raise ValueError("Could not derive any non-special token IDs to patch")
193 -
194 - vocab_size = self.patched_param.shape[0]
195 - next_token_id = selected_token_ids[-1]
196 - while len(selected_token_ids) < max_patch_rows:
197 - next_token_id = (next_token_id + 1) % vocab_size
198 - if next_token_id in special_ids or next_token_id in selected_token_ids:
199 - continue
200 - selected_token_ids.append(next_token_id)
201 -
202 - row_ids = torch.tensor(
203 - selected_token_ids,
204 - device=self.patched_param.device,
205 - dtype=torch.long,
206 - )
207 - hidden_size = self.patched_param.shape[1]
208 - column_offsets = torch.arange(
209 - hidden_size,
210 - device=self.patched_param.device,
211 - dtype=torch.long,
212 - )
148
214 - with torch.no_grad():
215 - # Rotate the selected embedding rows instead of zeroing them so the
216 - # patch remains deterministic while avoiding a degenerate collapse
217 - # to the same special token after the update.
218 - replacement_rows = self.patched_param[row_ids].roll(shifts=1, dims=0)
219 - self.patched_param[row_ids] = replacement_rows
220 -
221 - flat_indices = (
222 - row_ids.unsqueeze(1).mul(hidden_size).add(column_offsets).reshape(-1)
149 + self.engine.send_weights(patches)
150 + return [patch.name for patch in patches], sum(
151 + patch.indices.numel() for patch in patches
152 )
224 - flat_values = self.patched_param[row_ids].detach().reshape(-1).contiguous()
225 - self.pending_sparse_patches = [
226 - SparseWeightPatch(
227 - name=PATCHED_PARAM_NAME,
228 - indices=flat_indices.to(torch.int32),
229 - values=flat_values,
230 - full_shape=tuple(self.patched_param.shape),
231 - )
232 - ]
233 - patch_digest = hashlib.sha256(
234 - self.pending_sparse_patches[0].indices.cpu().numpy().tobytes()
235 - + self.pending_sparse_patches[0].values.float().cpu().numpy().tobytes()
236 - ).hexdigest()
237 -
238 - sparse_payload_bytes = (
239 - flat_indices.numel() * torch.tensor([], dtype=torch.int32).element_size()
240 - + flat_values.numel() * flat_values.element_size()
241 - )
242 - return selected_token_ids, patch_digest, sparse_payload_bytes
243 -
244 - def send_dense_weights(self) -> float:
245 - """One call drives start/update/finish on the inference side
246 - concurrently with the NCCL broadcast."""
247 - if self.dense_engine is None:
248 - raise RuntimeError("Dense engine is not initialized")
153
250 - start = time.perf_counter()
251 - self.dense_engine.send_weights()
252 - torch.accelerator.synchronize()
253 - return (time.perf_counter() - start) * 1000.0
154 + def shutdown_engine(self) -> None:
155 + if self.engine is not None:
156 + self.engine.shutdown()
157 + self.engine = None
158
255 - def init_sparse_engine(self, world_size: int, llm_handle) -> None:
256 - """Build the sparse trainer engine"""
257 - self.sparse_engine = WeightTransferTrainerFactory.trainer_init(
258 - init_info=SparseNCCLTrainerInitInfo(
259 - master_address=self.master_address,
260 - master_port=self.port,
261 - world_size=world_size,
262 - rank=0, # single-GPU trainer is the sole (sender) rank
263 - ),
264 - client=RayVLLMWeightSyncClient(llm_handle),
265 - )
159
267 - def send_pending_sparse_patch(self) -> float:
268 - """One call drives start/update/finish on the inference side
269 - concurrently with the patch broadcasts. Sparse deltas differ each round,
270 - so the patches are passed to `send_weights` rather than fixed at init."""
271 - if self.sparse_engine is None:
272 - raise RuntimeError("Sparse engine is not initialized")
273 - if self.pending_sparse_patches is None:
274 - raise RuntimeError("Sparse patch has not been prepared")
275 -
276 - start = time.perf_counter()
277 - self.sparse_engine.send_weights(self.pending_sparse_patches)
278 - torch.accelerator.synchronize()
279 - self.pending_sparse_patches = None
280 - return (time.perf_counter() - start) * 1000.0
281 -
282 -
283 -def launch_llm(
284 - scheduling_inference: PlacementGroupSchedulingStrategy,
285 - backend: str = "nccl",
286 -):
160 +def launch_llm(scheduling_strategy: PlacementGroupSchedulingStrategy):
161 return ray.remote(
162 num_cpus=0,
163 num_gpus=0,
290 - scheduling_strategy=scheduling_inference,
164 + scheduling_strategy=scheduling_strategy,
165 )(MyLLM).remote(
166 model=MODEL_NAME,
167 enforce_eager=True,
294 - tensor_parallel_size=1,
168 + tensor_parallel_size=INFERENCE_TP_SIZE,
169 + enable_expert_parallel=True,
170 + expert_placement_strategy="linear",
171 + moe_backend="triton",
172 distributed_executor_backend="ray",
173 gpu_memory_utilization=0.7,
297 - weight_transfer_config=WeightTransferConfig(backend=backend),
174 + enable_prefix_caching=False,
175 + weight_transfer_config=WeightTransferConfig(backend="sparse_nccl"),
176 )
177
178
301 -def collect_vllm_generations(llm_handle) -> list[dict[str, object]]:
179 +def generate(llm_handle) -> list[dict[str, object]]:
180 outputs = ray.get(llm_handle.generate.remote(PROMPTS, SAMPLING_PARAMS))
303 - generations = []
304 - for output in outputs:
305 - generations.append(
306 - {
307 - "token_ids": output.outputs[0].token_ids,
308 - "text": output.outputs[0].text,
309 - }
310 - )
311 - return generations
312 -
313 -
314 -def token_sequences_match(
315 - left: Sequence[dict[str, object]],
316 - right: Sequence[dict[str, object]],
317 -) -> bool:
318 - return [item["token_ids"] for item in left] == [item["token_ids"] for item in right]
181 + return [
182 + {
183 + "token_ids": output.outputs[0].token_ids,
184 + "text": output.outputs[0].text,
185 + }
186 + for output in outputs
187 + ]
188
189
321 -def print_generations(label: str, prompts: Sequence[str], generations) -> None:
190 +def print_generations(label: str, generations: list[dict[str, object]]) -> None:
191 print(f"\n{label}")
323 - print("-" * 50)
324 - for prompt, generation in zip(prompts, generations):
325 - print(f"Prompt: {prompt!r}")
326 - print(f"Token IDs: {generation['token_ids']}")
327 - print(f"Text: {generation['text']!r}")
328 - print("-" * 50)
329 -
330 -
331 -def run_dense_phase(
332 - train_model,
333 - scheduling_inference: PlacementGroupSchedulingStrategy,
334 -) -> dict[str, object]:
335 - ray.get(train_model.reset_model.remote())
336 - llm = launch_llm(scheduling_inference, backend="nccl")
337 - try:
338 - dense_before = collect_vllm_generations(llm)
339 -
340 - ray.get(llm.sleep.remote(level=0))
341 - ray.get(train_model.create_rendezvous.remote())
342 - world_size = ray.get(llm.get_world_size.remote()) + 1
343 - ray.get(train_model.init_dense_engine.remote(world_size, llm))
344 -
345 - dense_payload_bytes = ray.get(train_model.dense_payload_bytes.remote())
346 - selected_token_ids, patch_digest, _ = ray.get(
347 - train_model.prepare_sparse_patch.remote(PROMPTS)
192 + for prompt, generation in zip(PROMPTS, generations):
193 + print(
194 + f" {prompt!r} -> {generation['text']!r} "
195 + f"(token_ids={generation['token_ids']})"
196 )
197
350 - # One call drives start/update/finish + the NCCL broadcast.
351 - dense_send_ms = ray.get(train_model.send_dense_weights.remote())
352 - ray.get(llm.wake_up.remote(tags=["scheduling"]))
353 -
354 - dense_after = collect_vllm_generations(llm)
198
356 - return {
357 - "dense_before": dense_before,
358 - "dense_after": dense_after,
359 - "selected_token_ids": selected_token_ids,
360 - "patch_digest": patch_digest,
361 - "dense_payload_bytes": dense_payload_bytes,
362 - "dense_send_ms": dense_send_ms,
363 - }
364 - finally:
365 - ray.get(train_model.shutdown_engines.remote())
366 - ray.kill(llm)
367 -
368 -
369 -def run_sparse_phase(
370 - train_model,
371 - scheduling_inference: PlacementGroupSchedulingStrategy,
372 -) -> dict[str, object]:
373 - ray.get(train_model.reset_model.remote())
374 - llm = launch_llm(scheduling_inference, backend="sparse_nccl")
199 +def main() -> None:
200 + ray.init()
201 + train_model = None
202 + pg_inference = None
203 + llm = None
204 try:
376 - sparse_before = collect_vllm_generations(llm)
205 + train_model = TrainModel.remote(MODEL_NAME)
206 + pg_inference = placement_group(
207 + [{"GPU": 1, "CPU": 0}] * INFERENCE_TP_SIZE,
208 + strategy="STRICT_PACK",
209 + )
210 + ray.get(pg_inference.ready())
211 + scheduling_strategy = PlacementGroupSchedulingStrategy(
212 + placement_group=pg_inference,
213 + placement_group_capture_child_tasks=True,
214 + placement_group_bundle_index=0,
215 + )
216 + llm = launch_llm(scheduling_strategy)
217 +
218 + before = generate(llm)
219 + print_generations("BEFORE sparse update", before)
220
221 ray.get(llm.sleep.remote(level=0))
379 - ray.get(train_model.create_rendezvous.remote())
222 world_size = ray.get(llm.get_world_size.remote()) + 1
223 ray.get(train_model.init_sparse_engine.remote(world_size, llm))
382 -
383 - selected_token_ids, patch_digest, sparse_payload_bytes = ray.get(
384 - train_model.prepare_sparse_patch.remote(PROMPTS)
385 - )
386 - sparse_send_ms = ray.get(train_model.send_pending_sparse_patch.remote())
224 + patched_names, num_updates = ray.get(train_model.patch_and_send.remote())
225 ray.get(llm.wake_up.remote(tags=["scheduling"]))
226
389 - sparse_after = collect_vllm_generations(llm)
390 -
391 - return {
392 - "sparse_before": sparse_before,
393 - "sparse_after": sparse_after,
394 - "selected_token_ids": selected_token_ids,
395 - "patch_digest": patch_digest,
396 - "sparse_payload_bytes": sparse_payload_bytes,
397 - "sparse_send_ms": sparse_send_ms,
398 - }
399 - finally:
400 - ray.get(train_model.shutdown_engines.remote())
401 - ray.kill(llm)
402 -
403 -
404 -ray.init()
405 -
406 -try:
407 - train_model = TrainModel.remote(MODEL_NAME)
408 -
409 - pg_inference = placement_group([{"GPU": 1, "CPU": 0}])
410 - ray.get(pg_inference.ready())
411 - scheduling_inference = PlacementGroupSchedulingStrategy(
412 - placement_group=pg_inference,
413 - placement_group_capture_child_tasks=True,
414 - placement_group_bundle_index=0,
415 - )
416 -
417 - dense_results = run_dense_phase(train_model, scheduling_inference)
418 - sparse_results = run_sparse_phase(train_model, scheduling_inference)
419 -
420 - baseline_equal = token_sequences_match(
421 - dense_results["dense_before"],
422 - sparse_results["sparse_before"],
423 - )
424 - patch_selection_equal = (
425 - dense_results["selected_token_ids"] == sparse_results["selected_token_ids"]
426 - )
427 - patch_digest_equal = dense_results["patch_digest"] == sparse_results["patch_digest"]
428 - after_equal = token_sequences_match(
429 - dense_results["dense_after"],
430 - sparse_results["sparse_after"],
431 - )
432 - any_output_changed = any(
433 - before["token_ids"] != after["token_ids"]
434 - for before, after in zip(
435 - dense_results["dense_before"],
436 - dense_results["dense_after"],
227 + after = generate(llm)
228 + print_generations("AFTER sparse update", after)
229 + print(f"patched_checkpoint_names={patched_names}")
230 + print(f"num_sparse_values={num_updates}")
231 + outputs_changed = any(
232 + old["token_ids"] != new["token_ids"]
233 + for old, new in zip(before, after, strict=True)
234 )
438 - )
439 - dense_payload_mb = dense_results["dense_payload_bytes"] / (1024 * 1024)
440 - sparse_payload_mb = sparse_results["sparse_payload_bytes"] / (1024 * 1024)
441 -
442 - print_generations(
443 - "Dense baseline outputs",
444 - PROMPTS,
445 - dense_results["dense_before"],
446 - )
447 - print_generations(
448 - "Sparse baseline outputs", PROMPTS, sparse_results["sparse_before"]
449 - )
450 - print_generations(
451 - "Dense outputs after update", PROMPTS, dense_results["dense_after"]
452 - )
453 - print_generations(
454 - "Sparse outputs after update",
455 - PROMPTS,
456 - sparse_results["sparse_after"],
457 - )
458 -
459 - print(f"patched_token_ids = {dense_results['selected_token_ids']}")
460 - print(f"patch_selection_equal = {patch_selection_equal}")
461 - print(f"dense_patch_digest = {dense_results['patch_digest']}")
462 - print(f"sparse_patch_digest = {sparse_results['patch_digest']}")
463 - print(f"patch_digest_equal = {patch_digest_equal}")
464 - print(f"baseline_equal = {baseline_equal}")
465 - print(f"after_equal = {after_equal}")
466 - print(f"any_output_changed = {any_output_changed}")
467 - print(f"dense_payload_mb = {dense_payload_mb:.2f}")
468 - print(f"sparse_payload_mb = {sparse_payload_mb:.2f}")
469 - print(f"dense_send_ms = {dense_results['dense_send_ms']:.2f}")
470 - print(f"sparse_send_ms = {sparse_results['sparse_send_ms']:.2f}")
471 -
472 - if not baseline_equal:
473 - raise RuntimeError(
474 - "Dense and sparse phases did not start from the same baseline"
475 - )
476 - if not patch_selection_equal:
477 - raise RuntimeError("Dense and sparse phases used different sparse patches")
478 - if not patch_digest_equal:
479 - raise RuntimeError("Dense and sparse phases produced different patch values")
480 - if not after_equal:
481 - raise RuntimeError("Dense and sparse updates produced different outputs")
482 - if not any_output_changed:
483 - raise RuntimeError("Patch did not change the observed outputs")
484 -finally:
485 - ray.shutdown()
235 + print(f"outputs_changed={outputs_changed}")
236 + finally:
237 + if train_model is not None:
238 + with suppress(Exception):
239 + ray.get(train_model.shutdown_engine.remote())
240 + if llm is not None:
241 + with suppress(Exception):
242 + ray.kill(llm)
243 + if train_model is not None:
244 + with suppress(Exception):
245 + ray.kill(train_model)
246 + if pg_inference is not None:
247 + with suppress(Exception):
248 + ray.util.remove_placement_group(pg_inference)
249 + ray.shutdown()
250 +
251 +
252 +if __name__ == "__main__":
253 + main()
tests/distributed/test_weight_transfer.py
+122 -239
@@ -9,6 +9,7 @@ Integration tests for NCCL and IPC weight transfer between processes using Ray.
9 import pickle
10 import threading
11 import time
12 +from contextlib import nullcontext
13 from unittest.mock import MagicMock
14
15 import pybase64 as base64
@@ -313,103 +314,6 @@ class TestEngineRegistry:
314 )
315
316
316 -# --- Unit Tests: Sparse patch application (CPU) ---
317 -
318 -
319 -class TestSparseNCCLPatchApplication:
320 - """Test SparseNCCLWeightTransferEngine._apply_patch on a real param."""
321 -
322 - def _make_engine(self, model):
323 - config = WeightTransferConfig(backend="sparse_nccl")
324 - return SparseNCCLWeightTransferEngine(
325 - config, create_mock_vllm_config(), torch.device("cpu"), model
326 - )
327 -
328 - def _make_model(self, numel: int = 8):
329 - model = torch.nn.Module()
330 - model.register_parameter(
331 - "w", torch.nn.Parameter(torch.zeros(numel), requires_grad=False)
332 - )
333 -
334 - def get_parameter(name):
335 - assert name == "w"
336 - return model.w
337 -
338 - model.get_parameter = get_parameter
339 - return model
340 -
341 - def test_apply_patch_updates_only_selected_entries(self):
342 - model = self._make_model(8)
343 - engine = self._make_engine(model)
344 - engine._apply_patch(
345 - SparseWeightPatch(
346 - name="w",
347 - indices=torch.tensor([1, 3], dtype=torch.int32),
348 - values=torch.tensor([5.0, 7.0], dtype=torch.float32),
349 - )
350 - )
351 - expected = torch.zeros(8)
352 - expected[1] = 5.0
353 - expected[3] = 7.0
354 - assert torch.equal(model.w.data, expected)
355 -
356 - def test_apply_patch_rejects_mismatched_lengths(self):
357 - model = self._make_model(8)
358 - engine = self._make_engine(model)
359 - with pytest.raises(ValueError, match="matching lengths"):
360 - engine._apply_patch(
361 - SparseWeightPatch(
362 - name="w",
363 - indices=torch.tensor([1, 3], dtype=torch.int32),
364 - values=torch.tensor([5.0], dtype=torch.float32),
365 - )
366 - )
367 -
368 - def test_apply_patch_rejects_non_int32_indices(self):
369 - model = self._make_model(8)
370 - engine = self._make_engine(model)
371 - with pytest.raises(ValueError, match="int32 indices"):
372 - engine._apply_patch(
373 - SparseWeightPatch(
374 - name="w",
375 - indices=torch.tensor([1], dtype=torch.int64),
376 - values=torch.tensor([5.0], dtype=torch.float32),
377 - )
378 - )
379 -
380 - def test_apply_patch_rejects_dtype_mismatch(self):
381 - model = self._make_model(8)
382 - engine = self._make_engine(model)
383 - with pytest.raises(ValueError, match="does not match"):
384 - engine._apply_patch(
385 - SparseWeightPatch(
386 - name="w",
387 - indices=torch.tensor([1], dtype=torch.int32),
388 - values=torch.tensor([5.0], dtype=torch.bfloat16),
389 - )
390 - )
391 -
392 - def test_apply_patch_rejects_non_contiguous_param(self):
393 - model = torch.nn.Module()
394 - model.register_parameter(
395 - "w",
396 - torch.nn.Parameter(
397 - torch.arange(12, dtype=torch.float32).view(3, 4).t(),
398 - requires_grad=False,
399 - ),
400 - )
401 - model.get_parameter = lambda name: model.w
402 - engine = self._make_engine(model)
403 - with pytest.raises(NotImplementedError, match="contiguous params"):
404 - engine._apply_patch(
405 - SparseWeightPatch(
406 - name="w",
407 - indices=torch.tensor([1], dtype=torch.int32),
408 - values=torch.tensor([1.0], dtype=torch.float32),
409 - )
410 - )
411 -
412 -
317 # --- Test receive_weights without init raises ---
318
319
@@ -628,162 +532,135 @@ def test_nccl_weight_transfer_between_processes():
532 )
533
534
631 -@ray.remote(num_gpus=1)
632 -def trainer_broadcast_sparse_tensor(
633 - master_address: str,
634 - master_port: int,
635 - world_size: int,
636 -) -> bool:
637 - """Trainer task that broadcasts sparse patches via the trainer engine.
638 -
639 - The worker task drives its own init/receive directly (it is not an RPC
640 - endpoint), so the engine gets a no-op control-plane client; the NCCL
641 - rendezvous and the patch broadcasts are the real thing.
642 - """
643 - import torch
535 +def test_sparse_nccl_checkpoint_chunks_to_ep_local_experts_cpu(monkeypatch):
536 + """Replay global expert patches through two EP-local loaders on CPU."""
537
645 - device = _set_ray_assigned_device()
646 -
647 - from vllm.distributed.weight_transfer import WeightTransferTrainerFactory
648 - from vllm.distributed.weight_transfer.sparse_nccl_engine import (
649 - SparseNCCLTrainerInitInfo,
650 - SparseWeightPatch,
651 - )
652 -
653 - class NoopClient:
654 - def init_weight_transfer_engine(self, init_info):
655 - pass
538 + class CaptureClient:
539 + def __init__(self):
540 + self.order: list[str] = []
541 + self.update_infos: list[dict] = []
542
543 def start_weight_update(self):
658 - pass
544 + self.order.append("start")
545
546 def update_weights(self, update_info):
661 - pass
547 + self.order.append("update")
548 + self.update_infos.append(update_info)
549
550 def finish_weight_update(self):
664 - pass
665 -
666 - patch = SparseWeightPatch(
667 - name="test.weight",
668 - indices=torch.tensor([1, 7, 25], dtype=torch.int32, device=device),
669 - values=torch.tensor([10.0, 20.0, 30.0], dtype=torch.float32, device=device),
670 - full_shape=(10, 10),
671 - )
672 - engine = WeightTransferTrainerFactory.trainer_init(
673 - init_info=SparseNCCLTrainerInitInfo(
674 - master_address=master_address,
675 - master_port=master_port,
676 - world_size=world_size,
677 - rank=0,
678 - ),
679 - client=NoopClient(),
551 + self.order.append("finish")
552 +
553 + monkeypatch.setattr(torch.cuda, "current_stream", lambda *_, **__: None)
554 + monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
555 + monkeypatch.setattr(torch.accelerator, "device_index", lambda *_: nullcontext())
556 +
557 + client = CaptureClient()
558 + sender = SparseNCCLTrainerWeightTransferEngine(client=client)
559 + sender.model_update_group = MagicMock()
560 + sender.model_update_group.device = torch.device("cpu")
561 + wire_payloads = []
562 + sender.model_update_group.broadcast.side_effect = lambda tensor, **_: (
563 + wire_payloads.append(tensor.clone())
564 )
681 - engine.send_weights([patch])
682 - torch.accelerator.synchronize()
683 - engine.shutdown()
684 - return True
685 -
686 -
687 -@ray.remote(num_gpus=1)
688 -def inference_receive_sparse_tensor(
689 - master_address: str,
690 - master_port: int,
691 - world_size: int,
692 -) -> dict:
693 - """Inference task that receives sparse patches via the sparse engine."""
694 - from unittest.mock import MagicMock
695 -
696 - import torch
565
698 - device = _set_ray_assigned_device()
699 -
700 - from vllm.config.parallel import ParallelConfig
701 - from vllm.config.weight_transfer import WeightTransferConfig
702 - from vllm.distributed.weight_transfer.sparse_nccl_engine import (
703 - SparseNCCLWeightTransferEngine,
704 - SparseNCCLWeightTransferUpdateInfo,
705 - )
706 -
707 - config = WeightTransferConfig(backend="sparse_nccl")
708 - vllm_config = MagicMock()
709 - parallel_config = MagicMock(spec=ParallelConfig)
710 - parallel_config.rank = 0
711 - parallel_config.world_size = 1
712 - parallel_config.data_parallel_rank = 0
713 - parallel_config.data_parallel_index = 0
714 - vllm_config.parallel_config = parallel_config
715 - vllm_config.model_config = MagicMock()
716 -
717 - # Real module holding the target parameter the patch will modify.
718 - model = torch.nn.Module()
719 - model.register_parameter(
720 - "w", torch.nn.Parameter(torch.zeros(30, device="cuda"), requires_grad=False)
721 - )
722 - model.get_parameter = lambda name: model.w
566 + expert_names = [
567 + "model.layers.0.mlp.experts.0.gate_proj.weight",
568 + "model.layers.0.mlp.experts.1.gate_proj.weight",
569 + ]
570 + client.start_weight_update()
571 + for name, index, value in zip(
572 + expert_names,
573 + (0, 3),
574 + (5.0, 7.0),
575 + strict=True,
576 + ):
577 + sender.send_weight_chunk(
578 + [
579 + SparseWeightPatch(
580 + name=name,
581 + indices=torch.tensor([index], dtype=torch.int32),
582 + values=torch.tensor([value]),
583 + full_shape=(2, 2),
584 + )
585 + ]
586 + )
587 + client.finish_weight_update()
588
724 - update_info = SparseNCCLWeightTransferUpdateInfo(
725 - names=["w"],
726 - dtype_names=["float32"],
727 - shapes=[[30]],
728 - num_updates_list=[3],
729 - )
589 + assert client.order == ["start", "update", "update", "finish"]
590 + assert [info["names"] for info in client.update_infos] == [
591 + [expert_names[0]],
592 + [expert_names[1]],
593 + ]
594 + assert [info["shapes"] for info in client.update_infos] == [
595 + [[2, 2]],
596 + [[2, 2]],
597 + ]
598 + assert [info["num_updates_list"] for info in client.update_infos] == [
599 + [1],
600 + [1],
601 + ]
602
731 - engine = SparseNCCLWeightTransferEngine(
732 - config, vllm_config, torch.device("cuda"), model
733 - )
734 - from vllm.distributed.weight_transfer.nccl_common import (
735 - NCCLWeightTransferInitInfo,
603 + expected_gates = (
604 + [[5.0, -1.0], [-1.0, -1.0]],
605 + [[-1.0, -1.0], [-1.0, 7.0]],
606 )
737 -
738 - engine.init_transfer_engine(
739 - NCCLWeightTransferInitInfo(
740 - master_address=master_address,
741 - master_port=master_port,
742 - rank_offset=1,
743 - world_size=world_size,
607 + for ep_rank, expected_gate in enumerate(expected_gates):
608 + model = torch.nn.Module()
609 + model.register_parameter(
610 + "w13_weight",
611 + torch.nn.Parameter(torch.full((1, 4, 2), -1.0), requires_grad=False),
612 + )
613 + local_name = expert_names[ep_rank]
614 + seen_names: list[str] = []
615 + loaded_names: set[str] = set()
616 + copy_count = [0]
617 +
618 + def load_weights(
619 + weights,
620 + local_name=local_name,
621 + target=model,
622 + seen=seen_names,
623 + loaded=loaded_names,
624 + count=copy_count,
625 + ):
626 + for name, checkpoint_weight in weights:
627 + seen.append(name)
628 + if name != local_name:
629 + continue
630 + target.w13_weight.data[0, :2].copy_(checkpoint_weight)
631 + loaded.add(name)
632 + count[0] += 1
633 + return loaded
634 +
635 + model.load_weights = load_weights
636 + receiver = SparseNCCLWeightTransferEngine(
637 + WeightTransferConfig(backend="sparse_nccl"),
638 + create_mock_vllm_config(rank=ep_rank, world_size=2),
639 + torch.device("cpu"),
640 + model,
641 + )
642 + receiver.model_update_group = MagicMock()
643 + receiver.model_update_group.device = torch.device("cpu")
644 + payloads = iter(wire_payloads)
645 + receiver.model_update_group.broadcast.side_effect = (
646 + lambda tensor, payloads=payloads, **_: tensor.copy_(next(payloads))
647 )
745 - )
746 - engine.receive_weights(update_info)
747 - torch.accelerator.synchronize()
748 -
749 - expected = torch.zeros(30, dtype=torch.float32, device=device)
750 - expected[[1, 7, 25]] = torch.tensor(
751 - [10.0, 20.0, 30.0], dtype=torch.float32, device=device
752 - )
753 - success = torch.equal(model.w.data, expected)
754 - engine.shutdown()
755 - return {
756 - "success": success,
757 - "selected_values": model.w.data[[1, 7, 25]].cpu().tolist(),
758 - }
759 -
760 -
761 -@pytest.mark.skipif(
762 - torch.accelerator.device_count() < 2,
763 - reason="Need at least 2 GPUs to run NCCL sparse weight transfer test.",
764 -)
765 -def test_nccl_sparse_weight_transfer_between_processes():
766 - """Test NCCL sparse weight transfer from trainer to inference process."""
767 - _init_ray_for_weight_transfer()
768 -
769 - master_address = "127.0.0.1"
770 - master_port = get_open_port()
771 - world_size = 2
648
773 - inference_future = inference_receive_sparse_tensor.remote(
774 - master_address, master_port, world_size
775 - )
776 - trainer_future = trainer_broadcast_sparse_tensor.remote(
777 - master_address, master_port, world_size
778 - )
649 + receiver.start_weight_update()
650 + for update_info in client.update_infos:
651 + receiver.receive_weights(SparseNCCLWeightTransferUpdateInfo(**update_info))
652 + receiver.finish_weight_update()
653
780 - trainer_result, result = ray.get([trainer_future, inference_future])
654 + assert seen_names == expert_names
655 + assert loaded_names == {local_name}
656 + assert copy_count == [1]
657 + assert model.w13_weight[0, :2].tolist() == expected_gate
658 + assert model.w13_weight[0, 2:].eq(-1).all()
659 + foreign_value = 7.0 if ep_rank == 0 else 5.0
660 + assert not model.w13_weight.eq(foreign_value).any()
661 + receiver.shutdown()
662
782 - assert trainer_result, "Trainer should complete successfully"
783 - assert result["success"], (
784 - "Sparse weight transfer failed. "
785 - f"Received selected values: {result['selected_values']}"
786 - )
663 + sender.shutdown()
664
665
666 # --- Unit Tests: IPCWeightTransferUpdateInfo Validation ---
@@ -1995,11 +1872,11 @@ def test_sparse_nccl_trainer_init_ships_worker_init_info(monkeypatch):
1872
1873
1874 def test_sparse_nccl_trainer_send_weights_drives_client_in_order(monkeypatch):
1998 - """send_weights takes the round's patches and ships per-patch metadata
1999 - (names / shapes / num_updates_list) + broadcasts indices + values each."""
1875 + """One-shot send drives start, update, and finish in order."""
1876 client = RecordingClient()
1877 engine = SparseNCCLTrainerWeightTransferEngine(client=client)
1878 engine.model_update_group = MagicMock()
1879 + engine.model_update_group.device = torch.device("cpu")
1880 # The group is a mock, so the stream is just a handle it is handed (and a
1881 # handle _post_send_sync can synchronize).
1882 monkeypatch.setattr(torch.cuda, "current_stream", MagicMock())
@@ -2013,6 +1890,7 @@ def test_sparse_nccl_trainer_send_weights_drives_client_in_order(monkeypatch):
1890 assert client.last_update_info["num_updates_list"] == [2]
1891 # One broadcast for indices + one for values per patch.
1892 assert engine.model_update_group.broadcast.call_count == 2
1893 + engine.shutdown()
1894
1895
1896 def test_sparse_nccl_trainer_send_weights_empty_round_is_noop():
@@ -2024,6 +1902,8 @@ def test_sparse_nccl_trainer_send_weights_empty_round_is_noop():
1902
1903 engine.send_weights([])
1904 engine.send_weights() # no argument is also a no-op round
1905 + engine.send_weight_chunk([])
1906 + engine.send_weight_chunk()
1907
1908 assert client.order == []
1909
@@ -2033,6 +1913,7 @@ def test_sparse_nccl_trainer_send_weights_requires_full_shape():
1913 patch.full_shape = None
1914 engine = SparseNCCLTrainerWeightTransferEngine(client=RecordingClient())
1915 engine.model_update_group = MagicMock()
1916 + engine.model_update_group.device = torch.device("cpu")
1917
1918 with pytest.raises(ValueError, match="full_shape"):
1919 engine.send_weights([patch])
@@ -2054,6 +1935,7 @@ def test_sparse_nccl_trainer_validates_patch_before_any_rpc():
1935 client = RecordingClient()
1936 engine = SparseNCCLTrainerWeightTransferEngine(client=client)
1937 engine.model_update_group = MagicMock()
1938 + engine.model_update_group.device = torch.device("cpu")
1939
1940 mismatched = SparseWeightPatch(
1941 name="w",
@@ -2092,4 +1974,5 @@ def test_sparse_nccl_trainer_non_sender_skips_client():
1974 assert engine.model_update_group is None
1975 assert isinstance(engine, SparseNCCLTrainerWeightTransferEngine)
1976 engine.send_weights([_sparse_patch()])
1977 + engine.send_weight_chunk([_sparse_patch()])
1978 assert client.order == []
vllm/distributed/weight_transfer/sparse_nccl_engine.py
+127 -146
@@ -2,20 +2,14 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Sparse NCCL weight transfer engine.
4
5 -A standalone engine (not a subclass of `NCCLWeightTransferEngine`) for applying
6 -sparse, flat-index weight patches in place. It shares only NCCL process-group
7 -initialization with the dense engine (via `nccl_common`); the update path
8 -applies index/value patches directly to existing model parameters and never runs
9 -layerwise reload.
10 -
11 -MVP limitations:
12 -* TP=1 and PP=1 only
13 -* uses runtime/kernel-format parameter names
14 -* not composable with checkpoint-format or packed updates
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
@@ -42,6 +36,10 @@ from vllm.distributed.weight_transfer.nccl_common import (
36 from vllm.distributed.weight_transfer.nccl_common import (
37 trainer_init as open_trainer_endpoint,
38 )
39 +from vllm.model_executor.model_loader.checkpoint_weight_patch import (
40 + CheckpointWeightPatch,
41 + load_checkpoint_weight_patches,
42 +)
43
44 __all__ = [
45 "SparseWeightPatch",
@@ -54,15 +52,13 @@ __all__ = [
52
53 @dataclass
54 class SparseWeightPatch:
57 - """A sparse in-place patch for one existing parameter."""
55 + """A sparse patch in checkpoint coordinates."""
56
57 name: str
58 indices: torch.Tensor
59 values: torch.Tensor
62 - full_shape: tuple[int, ...] | None = None
63 - """Full shape of the patched parameter. Required when the patch is sent
64 - via `SparseNCCLTrainerWeightTransferEngine` (it ships in the per-round
65 - update info); the worker-side apply path does not read it."""
60 + full_shape: tuple[int, ...]
61 + """Full checkpoint shape."""
62
63
64 @dataclass
@@ -119,10 +115,9 @@ class SparseNCCLWeightTransferEngine(
115 """
116 Sparse weight transfer engine using NCCL.
117
122 - Receives flat-index (indices, values) patches broadcast from the trainer
123 - (rank 0) and applies them in place to existing model parameters. Weights are
124 - applied directly without layerwise reload, so `start_weight_update` and
125 - `finish_weight_update` are no-ops.
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
@@ -147,10 +142,7 @@ class SparseNCCLWeightTransferEngine(
142
143 def start_weight_update(self) -> None:
144 """No-op: sparse patches are applied in place, no layerwise reload."""
150 - if self.parallel_config.world_size != 1:
151 - raise NotImplementedError(
152 - "Sparse weight updates currently require TP=1 and PP=1"
153 - )
145 + pass
146
147 def finish_weight_update(self) -> None:
148 """No-op: sparse patches are applied in place, no layerwise reload."""
@@ -164,64 +156,34 @@ class SparseNCCLWeightTransferEngine(
156 "Call init_transfer_engine() first."
157 )
158
167 - # Use the worker's assigned device rather than the ambient current
168 - # device: the receive path is no longer wrapped in
169 - # `with torch.device(self.device)` by the caller, so the current device
170 - # is not guaranteed to match self.device. The recv buffers must live on
171 - # the same device as the NCCL communicator (created on self.device).
172 - device = self.device
173 - for name, dtype_name, num_updates in zip(
174 - update_info.names,
175 - update_info.dtype_names,
176 - update_info.num_updates_list,
177 - ):
178 - dtype = getattr(torch, dtype_name)
179 - indices = torch.empty(num_updates, dtype=torch.int32, device=device)
180 - values = torch.empty(num_updates, dtype=dtype, device=device)
181 - self.model_update_group.broadcast(
182 - indices, src=0, stream=torch.cuda.current_stream()
183 - )
184 - self.model_update_group.broadcast(
185 - values, src=0, stream=torch.cuda.current_stream()
186 - )
187 - self._apply_patch(
188 - SparseWeightPatch(name=name, indices=indices, values=values)
189 - )
190 - del indices
191 - del values
192 -
193 - def _apply_patch(self, patch: SparseWeightPatch) -> None:
194 - """Apply a single sparse flat-index patch to an existing model param."""
195 - param = self.model.get_parameter(patch.name)
196 - if not param.data.is_contiguous():
197 - raise NotImplementedError(
198 - "Sparse weight updates currently require contiguous params: "
199 - f"{patch.name}"
200 - )
201 - if patch.indices.dtype != torch.int32:
202 - raise ValueError(
203 - f"Sparse weight updates currently require int32 indices: {patch.name}"
204 - )
205 - if patch.indices.ndim != 1 or patch.values.ndim != 1:
206 - raise ValueError(
207 - f"Sparse weight patches must be 1D flattened updates: {patch.name}"
208 - )
209 - if patch.indices.numel() != patch.values.numel():
210 - raise ValueError(
211 - f"`indices` and `values` must have matching lengths for {patch.name}"
212 - )
213 - if patch.values.dtype != param.dtype:
214 - raise ValueError(
215 - f"Sparse values dtype {patch.values.dtype} does not match "
216 - f"parameter dtype {param.dtype} for {patch.name}"
217 - )
218 -
219 - flat_param = param.data.view(-1)
220 - flat_param.index_copy_(
221 - 0,
222 - patch.indices.to(device=flat_param.device, dtype=torch.long),
223 - patch.values.to(device=flat_param.device),
224 - )
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:
@@ -235,15 +197,16 @@ class SparseNCCLTrainerWeightTransferEngine(
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
238 - worker's recvs rendezvous inside the same NCCL broadcasts), then finishes
239 - the update. Unlike the full-resync backends, sparse patches differ every
240 - round (a fresh set of deltas from each optimizer step), so they are not a
241 - stable `WeightSource`: the engine takes no `source`, and each round's
242 - patches are passed straight to `send_weights(patches)`. A round with no
243 - patches is a no-op.
244 -
245 - The sparse backend assumes a single-rank trainer (matching its TP=1 / PP=1
246 - MVP scope), so non-sender ranks skip `send_weights` entirely.
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
@@ -303,70 +266,88 @@ class SparseNCCLTrainerWeightTransferEngine(
266 return engine
267
268 def send_weights(self, patches: Iterable[SparseWeightPatch] | None = None) -> None:
306 - """Broadcast this round's sparse patches. `patches` is the per-round
307 - payload (sparse deltas differ every round), so it is passed here rather
308 - than fixed at init. Every patch must set `full_shape`."""
309 - if not self.is_sender:
269 + """Broadcast one sparse update through a one-shot lifecycle."""
270 + patches = self._prepare_patches(patches)
271 + if not patches:
272 return
273
312 - patches = list(patches) if patches is not None else []
274 + self.client.start_weight_update()
275 + self._broadcast_chunk(patches)
276 + self.client.finish_weight_update()
277 +
278 + def send_weight_chunk(
279 + self, patches: Iterable[SparseWeightPatch] | None = None
280 + ) -> None:
281 + """Broadcast one chunk inside a caller-owned weight update lifecycle."""
282 + patches = self._prepare_patches(patches)
283 if not patches:
284 return
285 + self._broadcast_chunk(patches)
286
316 - shapes = []
317 - for patch in patches:
318 - if patch.full_shape is None:
319 - raise ValueError(
320 - "SparseWeightPatch.full_shape must be set to send via the "
321 - f"trainer engine: {patch.name}"
322 - )
323 - self._validate_patch(patch)
324 - shapes.append(list(patch.full_shape))
287 + def _prepare_patches(
288 + self, patches: Iterable[SparseWeightPatch] | None
289 + ) -> list[SparseWeightPatch]:
290 + if not self.is_sender:
291 + return []
292
326 - update_info = SparseNCCLWeightTransferUpdateInfo(
327 - names=[patch.name for patch in patches],
328 - dtype_names=[str(patch.values.dtype).split(".")[-1] for patch in patches],
329 - shapes=shapes,
330 - num_updates_list=[patch.indices.numel() for patch in patches],
293 + prepared = list(patches) if patches is not None else []
294 + if not prepared:
295 + return []
296 + if self.model_update_group is None:
297 + raise RuntimeError("trainer_init() must be called before sending weights.")
298 + for patch in prepared:
299 + self._validate_patch(patch)
300 + return prepared
301 +
302 + def _broadcast_chunk(self, patches: list[SparseWeightPatch]) -> None:
303 + assert self.model_update_group is not None
304 + device = self.model_update_group.device
305 + device_context = (
306 + torch.accelerator.device_index(device.index)
307 + if device.type != "cpu"
308 + else nullcontext()
309 )
310 + with device_context:
311 + update_info = SparseNCCLWeightTransferUpdateInfo(
312 + names=[patch.name for patch in patches],
313 + dtype_names=[
314 + str(patch.values.dtype).split(".")[-1] for patch in patches
315 + ],
316 + shapes=[list(patch.full_shape) for patch in patches],
317 + num_updates_list=[patch.indices.numel() for patch in patches],
318 + )
319
333 - assert self.model_update_group is not None, (
334 - "trainer_init() must be called before send_weights()."
335 - )
336 - self.client.start_weight_update()
337 - # update_weights (workers receive) must run concurrently with the
338 - # trainer-side broadcasts — both rendezvous inside the same NCCL calls.
339 - exe = ThreadPoolExecutor(max_workers=1)
340 - try:
341 - future = exe.submit(self.client.update_weights, asdict(update_info))
342 - # Cheap best-effort: if update_weights already failed (e.g. a bad
343 - # request rejected before any NCCL call), surface it now instead of
344 - # hanging in broadcast waiting for a peer that will never arrive.
345 - if future.done():
346 - future.result()
347 - stream = torch.cuda.current_stream()
348 - for patch in patches:
349 - self.model_update_group.broadcast(patch.indices, src=0, stream=stream)
350 - self.model_update_group.broadcast(patch.values, src=0, stream=stream)
351 - future.result() # surface inference-side errors
352 - finally:
353 - # Never wait for the RPC thread here: if a broadcast raised, the
354 - # worker is still blocked in the matching NCCL call and will never
355 - # return, so joining would turn the error into a permanent hang.
356 - # See NCCLTrainerWeightTransferEngine.send_weights.
357 - exe.shutdown(wait=False)
358 - self.client.finish_weight_update()
359 - self._post_send_sync()
320 + # update_weights (workers receive) must run concurrently with the
321 + # trainer-side broadcasts — both rendezvous inside the same NCCL calls.
322 + executor = ThreadPoolExecutor(max_workers=1)
323 + try:
324 + future = executor.submit(
325 + self.client.update_weights, asdict(update_info)
326 + )
327 + # Surface an RPC that failed before the worker entered NCCL rather
328 + # than broadcasting to a peer that will never receive.
329 + if future.done():
330 + future.result()
331 + stream = torch.cuda.current_stream()
332 + for patch in patches:
333 + self.model_update_group.broadcast(
334 + patch.indices, src=0, stream=stream
335 + )
336 + self.model_update_group.broadcast(
337 + patch.values, src=0, stream=stream
338 + )
339 + future.result() # surface inference-side errors
340 + finally:
341 + # A failed broadcast can leave the RPC blocked in the matching
342 + # receive. Waiting for that thread would hide the original error.
343 + executor.shutdown(wait=False, cancel_futures=True)
344 + self._post_send_sync()
345
346 @staticmethod
347 def _validate_patch(patch: SparseWeightPatch) -> None:
363 - """Reject a malformed patch before any NCCL call.
364 -
365 - The worker checks the same invariants in `_apply_patch`, but by then the
366 - broadcasts are already under way: a mismatch surfaces as a size mismatch
367 - mid-transfer, which wedges both sides instead of raising. Checking here
368 - keeps the failure on the trainer, before `start_weight_update`.
369 - """
348 + """Reject a malformed patch before starting the NCCL transfer."""
349 + if patch.full_shape is None:
350 + raise ValueError(f"Sparse patch requires full_shape: {patch.name}")
351 if patch.indices.dtype != torch.int32:
352 raise ValueError(
353 f"Sparse weight updates require int32 indices: {patch.name}"
@@ -382,10 +363,10 @@ class SparseNCCLTrainerWeightTransferEngine(
363
364 def _post_send_sync(self) -> None:
365 """Wait for the broadcasts to land before returning, so a caller may
385 - rebuild or free the patch tensors as soon as `send_weights` returns
386 - rather than relying on same-stream ordering. See
366 + rebuild or free the patch tensors as soon as a send method returns rather
367 + than relying on same-stream ordering. See
368 `NCCLTrainerWeightTransferEngine._post_send_sync` for why there is no
388 - cross-rank barrier (and sparse is single-rank on the trainer anyway)."""
369 + cross-rank barrier."""
370 if torch.cuda.is_available():
371 torch.cuda.current_stream().synchronize()
372