[RL] Add sharding-aware NCCL M2N weight transfer (#51520)
Signed-off-by: Ke Wen <kwen@nvidia.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: aoshen02 <aoshen@inferact.ai>
Ke Wen committed
Sep 25, 2026 at 18:38 UTC
b6d2b4fc2575e89d5be7d1c26b191735932793e7
13 files changed
+2812
-6
.buildkite/test_areas/distributed.yaml
+1
@@ -373,6 +373,7 @@ steps:
373
- VLLM_USE_DEEP_GEMM=1 VLLM_LOGGING_LEVEL=DEBUG python3 examples/features/data_parallel/data_parallel_offline.py --model=Qwen/Qwen1.5-MoE-A2.7B -tp=1 -dp=2 --max-model-len=2048 --all2all-backend=deepep_high_throughput
374
- pytest -v -s tests/v1/distributed/test_dbo.py
375
- VLLM_ALLOW_INSECURE_SERIALIZATION=1 pytest -v -s tests/distributed/test_weight_transfer.py
376
+ - VLLM_ALLOW_INSECURE_SERIALIZATION=1 pytest -v -s tests/distributed/test_weight_transfer_m2n.py
377
- pytest -v -s tests/distributed/test_packed_tensor.py
378
mirror:
379
amd:
docs/training/weight_transfer/README.md
+3
-1
@@ -35,6 +35,7 @@ engine drives on your behalf:
35
| [IPC](ipc.md) | CUDA IPC handles | Colocated training and inference on same GPU |
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
+| [nccl_m2n](m2n.md) | NCCL M2N reshard | Trainer and inference use different sharding layouts |
39
40
## Quickstart
41
@@ -49,7 +50,8 @@ from vllm.config import WeightTransferConfig
50
51
llm = LLM(
52
model="my-model",
52
- weight_transfer_config=WeightTransferConfig(backend="nccl"), # or "ipc", "sparse_nccl", "sharded_rdt"
53
+ # Other backends: "ipc", "sparse_nccl", "sharded_rdt", "nccl_m2n".
54
+ weight_transfer_config=WeightTransferConfig(backend="nccl"),
55
)
56
```
57
docs/training/weight_transfer/m2n.md
new
+101
@@ -0,0 +1,101 @@
1
+# NCCL M2N Engine
2
+
3
+The `nccl_m2n` weight transfer engine uses [NCCL M2N](https://github.com/NVIDIA/nccl-extensions) to **reshard** weights between two disjoint meshes of GPUs — the trainer and the inference workers — in a single collective per parameter. Unlike the broadcast-based [NCCL engine](nccl.md), the trainer sends its local shard as-is: no full tensor is ever materialized on the training side, whatever its parallelism layout.
4
+
5
+## When to Use NCCL M2N
6
+
7
+- The trainer and the inference engine use **different parallelism layouts** (e.g. trainer FSDP/EP, inference TP) and gathering full tensors on the trainer is expensive
8
+- Training and inference are on **separate GPUs**, possibly across nodes
9
+- You want weight sync off the critical path of an async RL loop
10
+
11
+## Requirements
12
+
13
+This backend depends on the `nccl-extensions` Python package (and its `nccl4py` dependency), which vLLM does not install. It needs NCCL 2.30.5 or newer, and `libnccl_m2n.so` is linked against a specific NCCL build, so vLLM must load the same library:
14
+
15
+```bash
16
+export VLLM_NCCL_SO_PATH=/path/to/the/same/libnccl.so
17
+```
18
+
19
+The backend imports the runtime lazily, so vLLM is unaffected unless you select it.
20
+
21
+## How It Works
22
+
23
+1. The trainer ranks and all inference workers join **one** NCCL communicator via `StatelessProcessGroup`. Trainer ranks occupy `[0, T)` and workers `[T, T + N)` — m2n meshes are contiguous rank intervals, which is exactly what vLLM's existing `rank_offset` convention produces.
24
+2. At the init handshake the trainer ships the full transfer plan: both meshes — one per side, shared by every parameter — then each parameter's name, dtype, shape and placement. The workers resolve the matching **destination placements** and publish them back over the shared group; the meshes themselves are declared, not negotiated.
25
+3. Each round, both sides issue one `reshard` per parameter in the same order. Every rank in the communicator participates in every reshard, so — as with the NCCL engine — the worker must be inside `update_weights` while the trainer sends. The trainer engine handles that concurrency itself.
26
+
27
+### Destination resolution
28
+
29
+A parameter is resharded **directly into the live vLLM parameter** when its checkpoint name maps 1:1 onto a model parameter whose shape is either identical to the checkpoint shape (replicated) or differs on exactly one dim by the tensor-parallel factor. That rank then only ever receives its own shard.
30
+
31
+Anything else falls back to receiving the full tensor and letting `load_weights` do the sharding, exactly as the NCCL engine does for every parameter. The fallback covers:
32
+
33
+- fused parameters (`qkv_proj`, `gate_up_proj`, MoE `w13`/`w2`), whose checkpoint names do not name a model parameter
34
+- quantized models, where the weight loader does more than a sharded copy
35
+- pipeline-parallel deployments
36
+
37
+Correctness never depends on a parameter resolving. The engine logs how many parameters took each path at init.
38
+
39
+## Configuration
40
+
41
+```python
42
+from vllm import LLM
43
+from vllm.config import WeightTransferConfig
44
+
45
+llm = LLM(
46
+ model="my-model",
47
+ weight_transfer_config=WeightTransferConfig(backend="nccl_m2n"),
48
+)
49
+```
50
+
51
+```bash
52
+vllm serve my-model --weight-transfer-config '{"backend": "nccl_m2n"}'
53
+```
54
+
55
+## Trainer Side
56
+
57
+The trainer uses the stateful trainer engine. m2n needs each parameter's source layout, which the base `WeightSource` does not carry, so it takes an `M2NWeightSource`. `DTensorModuleSource` covers the common case: it reads each parameter's device mesh and placements and yields **local shards** — it never calls `full_tensor()`.
58
+
59
+```python
60
+from vllm.distributed.weight_transfer import (
61
+ RayVLLMWeightSyncClient,
62
+ WeightTransferTrainerFactory,
63
+)
64
+from vllm.distributed.weight_transfer.m2n_source import DTensorModuleSource
65
+from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
66
+
67
+# Called on every trainer rank; rank 0 is the sender.
68
+engine = WeightTransferTrainerFactory.trainer_init(
69
+ init_info=M2NTrainerInitInfo(
70
+ master_address=master_address,
71
+ master_port=master_port,
72
+ world_size=num_trainer_ranks + num_inference_workers,
73
+ num_trainer_ranks=num_trainer_ranks,
74
+ rank=my_rank,
75
+ ),
76
+ client=RayVLLMWeightSyncClient(llm_handle),
77
+ source=DTensorModuleSource(model, num_trainer_ranks),
78
+)
79
+
80
+# Each round, on every trainer rank:
81
+engine.send_weights()
82
+```
83
+
84
+`send_weights()` drives the whole round — `start_weight_update`, `update_weights`, `finish_weight_update`. Every rank in the communicator must be inside the same reshard, so `update_weights` has to be in flight while the trainer sends; the engine runs it on a helper thread so the trainer loop does not have to open-code that.
85
+
86
+Trainers with a custom producer (a Megatron export, MoE re-fusing) subclass `M2NWeightSource` and supply layouts explicitly.
87
+
88
+See [`examples/rl/rlhf_m2n.py`](../../../examples/rl/rlhf_m2n.py) for a runnable FSDP → TP example.
89
+
90
+## Limitations
91
+
92
+- Tensor rank 1..3; 4-D parameters are rejected.
93
+- dtypes: int8/uint8, fp8 e4m3/e5m2, fp16, bf16, int32/uint32, fp32, int64/uint64, fp64. No fp4.
94
+- The trainer's device mesh must cover a contiguous rank interval starting at 0, and be 1-D or 2-D.
95
+- `Partial` placements are not supported.
96
+- Models with known model-level checkpoint preprocessing use the full-tensor
97
+ fallback for the affected module.
98
+- A reshard cannot be issued from inside a CUDA graph capture (weight updates run outside capture, so this only matters for custom callers).
99
+- The reshard plan is capped per *shard*, not per mesh: at most 16 source shards may feed one destination shard, and one source shard may feed at most 64 destination shards (`MAX_SOURCES` / `MAX_TARGETS` in the m2n build; raising either needs a rebuild).
100
+- A directly-resharded parameter spreads over `tp_size` destination shards, so each one is fed by fewer source shards. A parameter on the fallback path is replicated, i.e. a single destination shard fed by every source shard, so a trainer that **sharded** its parameters is still limited to 16 ranks whenever any parameter falls back — which fused parameters always do. A replicated trainer is unaffected.
101
+- Each communicator holds a cached staging pool (~256 MiB by default: 4 channels x 64 MiB).
examples/rl/rlhf_m2n.py
new
+197
@@ -0,0 +1,197 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""RLHF with FSDP2 training and vLLM tensor-parallel inference using **NCCL M2N**
4
+sharding-aware weight transfer.
5
+
6
+Layout (4 GPUs, no colocation):
7
+ * GPUs 0-1: two FSDP2 training workers, one per GPU.
8
+ * GPUs 2-3: one vLLM ``LLM`` actor with ``tensor_parallel_size=2``.
9
+
10
+The trainer and the inference workers share one NCCL communicator of 4 ranks:
11
+trainer ranks ``[0, 2)`` and inference ranks ``[2, 4)``. Every parameter moves
12
+with a single ``reshard`` that redistributes it from the FSDP layout to the
13
+inference layout — the trainer sends its local shards and never all-gathers a
14
+full tensor, which broadcast-based weight sync would force it to do.
15
+
16
+Every FSDP rank builds an ``M2NTrainerWeightTransferEngine`` and calls
17
+``send_weights()``; all ranks run every reshard, and only rank 0 drives the
18
+inference side through its ``RayVLLMWeightSyncClient``.
19
+
20
+Requires the ``nccl-extensions`` package (NCCL 2.30.5+) and a
21
+``VLLM_NCCL_SO_PATH`` pointing at the same ``libnccl.so`` that
22
+``libnccl_m2n.so`` was linked against.
23
+
24
+This example was written for 4xH100.
25
+"""
26
+
27
+from __future__ import annotations
28
+
29
+import os
30
+
31
+import pybase64 as base64
32
+import ray
33
+import torch
34
+import torch.distributed as dist
35
+from huggingface_hub import snapshot_download
36
+from torch.distributed.fsdp import fully_shard
37
+from transformers import AutoModelForCausalLM
38
+
39
+from vllm import LLM, SamplingParams
40
+from vllm.config import WeightTransferConfig
41
+from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
42
+from vllm.distributed.weight_transfer import (
43
+ RayVLLMWeightSyncClient,
44
+ WeightTransferTrainerFactory,
45
+)
46
+from vllm.distributed.weight_transfer.m2n_source import DTensorModuleSource
47
+from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
48
+from vllm.utils.network_utils import get_ip, get_open_port
49
+
50
+MODEL_NAME = "Qwen/Qwen3-0.6B"
51
+
52
+FSDP_WORLD_SIZE = 2
53
+INFERENCE_TP_SIZE = 2
54
+
55
+
56
+class MyLLM(LLM):
57
+ """LLM subclass that keeps Ray from pinning it to a single device."""
58
+
59
+ def __init__(self, *args, **kwargs):
60
+ os.environ.pop("CUDA_VISIBLE_DEVICES", None)
61
+ super().__init__(*args, **kwargs)
62
+
63
+ def ready(self):
64
+ return True
65
+
66
+
67
+@ray.remote(num_cpus=1, num_gpus=1)
68
+class FSDPTrainWorker:
69
+ """One FSDP2 worker per GPU. Rank 0 is the weight-transfer sender."""
70
+
71
+ def __init__(
72
+ self,
73
+ model_name: str,
74
+ rank: int,
75
+ fsdp_master_addr: str,
76
+ fsdp_master_port: int,
77
+ ):
78
+ self.rank = rank
79
+
80
+ os.environ["MASTER_ADDR"] = fsdp_master_addr
81
+ os.environ["MASTER_PORT"] = str(fsdp_master_port)
82
+
83
+ dist.init_process_group(backend="nccl", rank=rank, world_size=FSDP_WORLD_SIZE)
84
+ torch.accelerator.set_device_index(0)
85
+
86
+ model = AutoModelForCausalLM.from_pretrained(
87
+ model_name, torch_dtype=torch.bfloat16
88
+ ).cuda()
89
+ for layer in model.model.layers:
90
+ fully_shard(layer)
91
+ fully_shard(model)
92
+ self.model = model
93
+
94
+ def ready(self):
95
+ return True
96
+
97
+ def setup_engine(self, llm_handle, nccl_unique_id_b64, world_size, num_workers):
98
+ """Build the trainer engine on every FSDP rank.
99
+
100
+ `DTensorModuleSource` reads each parameter's FSDP device mesh and
101
+ placements, so the engine knows the source layout without gathering
102
+ anything. Rank 0 additionally drives the inference-side handshake.
103
+ """
104
+ self.engine = WeightTransferTrainerFactory.trainer_init(
105
+ init_info=M2NTrainerInitInfo(
106
+ nccl_unique_id_b64=nccl_unique_id_b64,
107
+ world_size=world_size,
108
+ num_trainer_ranks=FSDP_WORLD_SIZE,
109
+ # One DP group of TP=2 workers; declared so both sides
110
+ # describe the destination the same way.
111
+ dst_mesh_dims=(num_workers // INFERENCE_TP_SIZE, INFERENCE_TP_SIZE),
112
+ rank=self.rank, # FSDP rank; sender is 0
113
+ ),
114
+ client=RayVLLMWeightSyncClient(llm_handle),
115
+ source=DTensorModuleSource(self.model, FSDP_WORLD_SIZE),
116
+ )
117
+
118
+ def send_weights(self):
119
+ """Called on all ranks concurrently; every rank runs every reshard."""
120
+ self.engine.send_weights()
121
+
122
+
123
+def main():
124
+ ray.init()
125
+
126
+ local_model_path = snapshot_download(MODEL_NAME)
127
+ print(f"[init] Model downloaded to {local_model_path}")
128
+
129
+ fsdp_master_addr = get_ip()
130
+ fsdp_master_port = get_open_port()
131
+
132
+ fsdp_workers = [
133
+ FSDPTrainWorker.remote(
134
+ local_model_path, rank, fsdp_master_addr, fsdp_master_port
135
+ )
136
+ for rank in range(FSDP_WORLD_SIZE)
137
+ ]
138
+ ray.get([w.ready.remote() for w in fsdp_workers])
139
+ print(f"[init] {FSDP_WORLD_SIZE} FSDP workers ready.")
140
+
141
+ llm = ray.remote(num_cpus=0, num_gpus=0)(MyLLM).remote(
142
+ model=local_model_path,
143
+ enforce_eager=True,
144
+ tensor_parallel_size=INFERENCE_TP_SIZE,
145
+ distributed_executor_backend="ray",
146
+ weight_transfer_config=WeightTransferConfig(backend="nccl_m2n"),
147
+ load_format="dummy",
148
+ )
149
+ ray.get(llm.ready.remote())
150
+ num_workers = ray.get(llm.get_world_size.remote())
151
+ print(f"[init] vLLM ready with {num_workers} inference workers.")
152
+
153
+ prompts = [
154
+ "Hello, my name is",
155
+ "The capital of France is",
156
+ ]
157
+ sampling_params = SamplingParams(temperature=0)
158
+
159
+ outputs = ray.get(llm.generate.remote(prompts, sampling_params))
160
+ print("-" * 60)
161
+ print("BEFORE weight sync (dummy weights):")
162
+ for output in outputs:
163
+ print(f"Prompt: {output.prompt!r}")
164
+ print(f"Generated: {output.outputs[0].text!r}")
165
+ print("-" * 60)
166
+
167
+ # Trainer ranks [0, FSDP_WORLD_SIZE) and inference ranks after them share
168
+ # one communicator, so the two meshes are contiguous rank intervals.
169
+ nccl = NCCLLibrary()
170
+ nccl_unique_id_b64 = base64.b64encode(
171
+ bytes(nccl.ncclGetUniqueId().internal)
172
+ ).decode()
173
+ world_size = FSDP_WORLD_SIZE + num_workers
174
+
175
+ print("[transfer] Initializing nccl_m2n weight transfer (all FSDP ranks)...")
176
+ ray.get(
177
+ [
178
+ w.setup_engine.remote(llm, nccl_unique_id_b64, world_size, num_workers)
179
+ for w in fsdp_workers
180
+ ]
181
+ )
182
+
183
+ print("[sync] Resharding FSDP -> vLLM...")
184
+ ray.get([w.send_weights.remote() for w in fsdp_workers])
185
+ print("[sync] Weight transfer complete.")
186
+
187
+ outputs_updated = ray.get(llm.generate.remote(prompts, sampling_params))
188
+ print("-" * 60)
189
+ print("AFTER weight sync (real weights):")
190
+ for output in outputs_updated:
191
+ print(f"Prompt: {output.prompt!r}")
192
+ print(f"Generated: {output.outputs[0].text!r}")
193
+ print("-" * 60)
194
+
195
+
196
+if __name__ == "__main__":
197
+ main()
tests/distributed/test_weight_transfer_m2n.py
new
+868
@@ -0,0 +1,868 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unit tests for the NCCL M2N weight transfer backend.
4
+
5
+These cover how a transfer is *described* — layout encoding and the wire-type
6
+validation that keeps a bad plan from reaching a collective, since a mismatch
7
+there is a hang rather than an error. The transfer itself needs the m2n runtime
8
+and multiple GPUs, so it is exercised separately.
9
+"""
10
+
11
+import logging
12
+from functools import wraps
13
+from types import MethodType
14
+from unittest.mock import Mock
15
+
16
+import pybase64 as base64
17
+import pytest
18
+import ray
19
+import torch
20
+
21
+from vllm.distributed.weight_transfer import (
22
+ WeightTransferEngineFactory,
23
+ WeightTransferTrainerFactory,
24
+)
25
+from vllm.distributed.weight_transfer.m2n_common import (
26
+ REPLICATE,
27
+ REPLICATED,
28
+ M2NMesh,
29
+ M2NParamMeta,
30
+ check_placements,
31
+ check_transferable,
32
+ publish_destination_placements,
33
+ resolve_layout,
34
+ validate_layout,
35
+)
36
+from vllm.distributed.weight_transfer.m2n_engine import (
37
+ M2NWeightTransferEngine,
38
+ M2NWeightTransferInitInfo,
39
+ M2NWeightTransferUpdateInfo,
40
+)
41
+from vllm.distributed.weight_transfer.m2n_layout import (
42
+ M2NDestination,
43
+ resolve_parameter_destinations,
44
+)
45
+from vllm.distributed.weight_transfer.m2n_source import (
46
+ mesh_from_tensor,
47
+ placements_from_tensor,
48
+)
49
+from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
50
+from vllm.model_executor.layers.linear import (
51
+ ColumnParallelLinear,
52
+ RowParallelLinear,
53
+)
54
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
55
+from vllm.platforms import current_platform
56
+
57
+VALID_UID_B64 = base64.b64encode(b"\x00" * 128).decode()
58
+
59
+
60
+class TestLayout:
61
+ def test_replicated_splits_nothing(self):
62
+ """A replicated tensor imposes no divisibility constraint. The shape
63
+ dims are deliberately coprime with the mesh size: a layout that actually
64
+ split the tensor would reject them."""
65
+ mesh = M2NMesh((2, 2), start_rank=1)
66
+ indivisible_shape = (7, 13) # neither dim divisible by any mesh axis
67
+
68
+ resolved_mesh, placements = resolve_layout(mesh, REPLICATED)
69
+ validate_layout(resolved_mesh, placements, indivisible_shape, "destination")
70
+
71
+ def test_replicated_keeps_the_same_ranks(self):
72
+ """Replication re-factors the mesh to get a size-1 axis for its no-op
73
+ shard. That is only sound if it still covers exactly the same GPUs."""
74
+ mesh = M2NMesh((2, 3), start_rank=4)
75
+ resolved_mesh, _ = resolve_layout(mesh, REPLICATED)
76
+ assert resolved_mesh.size == mesh.size
77
+ assert resolved_mesh.start_rank == mesh.start_rank
78
+
79
+ def test_sharded_keeps_its_own_factorization(self):
80
+ """Rank order decides who owns which shard, so a sharded tensor must
81
+ not be re-factored the way a replicated one is."""
82
+ mesh = M2NMesh((2, 3), start_rank=4)
83
+ resolved_mesh, placements = resolve_layout(mesh, (REPLICATE, 0))
84
+ assert resolved_mesh == mesh
85
+ assert placements == (REPLICATE, 0)
86
+
87
+ def test_two_shard_axes_rejected(self):
88
+ """One axis has to replicate; a 2-D mesh that shards both is not
89
+ something a single reshard can express."""
90
+ with pytest.raises(ValueError, match="shards both"):
91
+ check_placements((0, 1))
92
+
93
+ def test_placement_below_replicate_rejected(self):
94
+ with pytest.raises(ValueError, match=r"parameter 'w'.*-2"):
95
+ check_placements((-2, REPLICATE), "parameter 'w' source placements")
96
+
97
+ def test_shard_dim_must_exist(self):
98
+ with pytest.raises(ValueError, match="rank 2"):
99
+ validate_layout(M2NMesh((1, 2), 0), (REPLICATE, 2), (8, 16), "source")
100
+
101
+ def test_shard_must_divide_evenly(self):
102
+ with pytest.raises(ValueError, match="does not divide evenly"):
103
+ validate_layout(M2NMesh((1, 3), 0), (REPLICATE, 0), (8, 16), "source")
104
+
105
+
106
+class TestSourceLayout:
107
+ def test_plain_tensor_is_replicated_across_trainer_ranks(self):
108
+ """A tensor with no DTensor metadata is the same on every trainer rank,
109
+ so the source describes it as replicated over all of them."""
110
+ assert placements_from_tensor(torch.zeros(4)) is REPLICATED
111
+ assert mesh_from_tensor(torch.zeros(4), 4) == M2NMesh((4, 1), 0)
112
+
113
+
114
+class TestTransferable:
115
+ def test_unsupported_dtype_names_the_parameter(self):
116
+ with pytest.raises(ValueError, match="'w'"):
117
+ check_transferable("w", torch.complex64, (4,))
118
+
119
+ def test_rank_four_rejected(self):
120
+ with pytest.raises(ValueError, match="rank 4"):
121
+ check_transferable("w", torch.bfloat16, (2, 2, 2, 2))
122
+
123
+
124
+class TestWireTypes:
125
+ def _init_info(self, **overrides):
126
+ fields = dict(
127
+ master_address="127.0.0.1",
128
+ master_port=1234,
129
+ rank_offset=1,
130
+ world_size=3,
131
+ src_mesh_dims=[1, 1],
132
+ dst_mesh_dims=[2, 1],
133
+ names=["w"],
134
+ dtype_names=["bfloat16"],
135
+ shapes=[[16, 16]],
136
+ src_placements=[None],
137
+ )
138
+ fields.update(overrides)
139
+ return M2NWeightTransferInitInfo(**fields)
140
+
141
+ def test_accepts_a_consistent_plan(self):
142
+ assert self._init_info().names == ["w"]
143
+
144
+ def test_accepts_pre_shared_nccl_unique_id(self):
145
+ info = self._init_info(
146
+ master_address=None,
147
+ master_port=None,
148
+ nccl_unique_id_b64=VALID_UID_B64,
149
+ )
150
+ assert info.nccl_unique_id_bytes == b"\x00" * 128
151
+ assert VALID_UID_B64 not in repr(info)
152
+
153
+ def test_destination_plan_uses_uid_communicator_without_group(self):
154
+ comm = Mock(rank=0, device=torch.device("cpu"), group=None)
155
+
156
+ def receive_plan(tensor, src):
157
+ assert src == 1
158
+ tensor.copy_(torch.tensor([[-1, 0], [-2, -2]], dtype=torch.int8))
159
+
160
+ comm.broadcast.side_effect = receive_plan
161
+
162
+ placements = publish_destination_placements(comm, 1, None, 2)
163
+
164
+ assert placements == [(REPLICATE, 0), REPLICATED]
165
+
166
+ def test_rejects_both_rendezvous_modes(self):
167
+ with pytest.raises(ValueError, match="not both"):
168
+ self._init_info(nccl_unique_id_b64=VALID_UID_B64)
169
+
170
+ def test_trainer_accepts_pre_shared_nccl_unique_id(self):
171
+ info = M2NTrainerInitInfo(
172
+ nccl_unique_id_b64=VALID_UID_B64,
173
+ world_size=3,
174
+ num_trainer_ranks=1,
175
+ rank=0,
176
+ )
177
+ assert info.nccl_unique_id_bytes == b"\x00" * 128
178
+ assert VALID_UID_B64 not in repr(info)
179
+
180
+ def test_ragged_plan_rejected(self):
181
+ with pytest.raises(ValueError, match="`shapes`"):
182
+ self._init_info(shapes=[])
183
+
184
+ def test_destination_mesh_must_cover_the_workers(self):
185
+ """The trainer declares the inference mesh, so one that does not cover
186
+ the workers is a config error — and it has to fail the init RPC, since
187
+ a mismatched mesh would otherwise surface as a hung collective."""
188
+ with pytest.raises(ValueError, match="dst_mesh_dims"):
189
+ self._init_info(dst_mesh_dims=[3, 1]) # 3 != the 2 workers
190
+
191
+ def test_world_must_hold_a_trainer_and_a_worker(self):
192
+ with pytest.raises(ValueError, match="rank_offset"):
193
+ self._init_info(rank_offset=3, world_size=3)
194
+
195
+ @pytest.mark.parametrize("dtype_name", ["not_a_dtype", "Tensor"])
196
+ def test_invalid_dtype_name_names_parameter(self, monkeypatch, dtype_name):
197
+ monkeypatch.setattr(
198
+ "vllm.distributed.weight_transfer.m2n_engine.import_m2n",
199
+ lambda: object(),
200
+ )
201
+ engine = object.__new__(M2NWeightTransferEngine)
202
+
203
+ with pytest.raises(ValueError, match=r"parameter 'w'.*dtype"):
204
+ engine.init_transfer_engine(self._init_info(dtype_names=[dtype_name]))
205
+
206
+ def _init_32_to_4_plan(self, monkeypatch, destination):
207
+ m2n = Mock()
208
+ m2n.Handle.create.return_value = object()
209
+ monkeypatch.setattr(
210
+ "vllm.distributed.weight_transfer.m2n_engine.import_m2n", lambda: m2n
211
+ )
212
+ monkeypatch.setattr(
213
+ "vllm.distributed.weight_transfer.m2n_engine."
214
+ "resolve_parameter_destinations",
215
+ lambda *args, **kwargs: [destination],
216
+ )
217
+ monkeypatch.setattr(
218
+ "vllm.distributed.weight_transfer.m2n_engine.worker_init_process_group",
219
+ lambda *args, **kwargs: object(),
220
+ )
221
+ monkeypatch.setattr(
222
+ "vllm.distributed.weight_transfer.m2n_engine."
223
+ "publish_destination_placements",
224
+ lambda *args: args[2],
225
+ )
226
+
227
+ engine = object.__new__(M2NWeightTransferEngine)
228
+ engine.parallel_config = Mock(pipeline_parallel_size=1)
229
+ engine.model_config = Mock(quantization=None)
230
+ engine.model = torch.nn.Module()
231
+ engine.init_transfer_engine(
232
+ self._init_info(
233
+ rank_offset=32,
234
+ world_size=36,
235
+ src_mesh_dims=[1, 32],
236
+ dst_mesh_dims=[1, 4],
237
+ shapes=[[32, 8]],
238
+ src_placements=[[REPLICATE, 0]],
239
+ )
240
+ )
241
+
242
+ def test_limits_use_resolved_sharded_destination(self, monkeypatch):
243
+ destination = M2NDestination("w", (REPLICATE, 0), torch.empty(8, 8))
244
+
245
+ self._init_32_to_4_plan(monkeypatch, destination)
246
+
247
+ def test_limits_still_reject_replicated_destination(self, monkeypatch):
248
+ destination = M2NDestination("w", REPLICATED, None)
249
+
250
+ with pytest.raises(ValueError, match=r"32 source shards.*MAX_SOURCES=16"):
251
+ self._init_32_to_4_plan(monkeypatch, destination)
252
+
253
+ def test_update_preflights_all_names_before_reshard(self):
254
+ engine = object.__new__(M2NWeightTransferEngine)
255
+ engine._handle = object()
256
+ engine.model_update_group = object()
257
+ engine._index = {"valid": 0}
258
+ engine._metas = [M2NParamMeta("valid", torch.float32, (4,), REPLICATED)]
259
+ engine._reshard = Mock()
260
+
261
+ with pytest.raises(ValueError, match=r"parameter 'unknown'"):
262
+ engine.receive_weights(
263
+ M2NWeightTransferUpdateInfo(names=["valid", "unknown"])
264
+ )
265
+
266
+ engine._reshard.assert_not_called()
267
+
268
+ def test_fallback_tensors_share_one_load_weights_invocation(self, monkeypatch):
269
+ class CoupledModel:
270
+ def __init__(self):
271
+ self.calls = 0
272
+ self.loaded = []
273
+
274
+ def load_weights(self, weights):
275
+ self.calls += 1
276
+ received = list(weights)
277
+ if [name for name, _ in received] == ["pair.weight", "pair.scale"]:
278
+ self.loaded = [(name, tensor.clone()) for name, tensor in received]
279
+
280
+ model = CoupledModel()
281
+ direct = torch.zeros(1)
282
+ engine = object.__new__(M2NWeightTransferEngine)
283
+ engine._handle = object()
284
+ engine.model_update_group = object()
285
+ engine.device = torch.device("cpu")
286
+ engine.model = model
287
+ engine._index = {"pair.weight": 0, "direct": 1, "pair.scale": 2}
288
+ engine._metas = [
289
+ M2NParamMeta(name, torch.float32, (1,), REPLICATED)
290
+ for name in engine._index
291
+ ]
292
+ engine._parameter_destinations = [
293
+ M2NDestination("pair.weight", REPLICATED, None),
294
+ M2NDestination("direct", REPLICATED, direct),
295
+ M2NDestination("pair.scale", REPLICATED, None),
296
+ ]
297
+ reshard_order = []
298
+
299
+ def reshard(comm, stream, meta, placements, buffer):
300
+ reshard_order.append(meta.name)
301
+ buffer.fill_(len(reshard_order))
302
+
303
+ engine._reshard = reshard
304
+ stream = Mock()
305
+ monkeypatch.setattr(
306
+ "vllm.distributed.weight_transfer.m2n_engine.comm_ptr", lambda group: 0
307
+ )
308
+ monkeypatch.setattr(torch.cuda, "current_stream", lambda: stream)
309
+
310
+ engine.receive_weights(
311
+ M2NWeightTransferUpdateInfo(names=["pair.weight", "direct", "pair.scale"])
312
+ )
313
+
314
+ assert model.calls == 1
315
+ assert [name for name, _ in model.loaded] == ["pair.weight", "pair.scale"]
316
+ assert [tensor.item() for _, tensor in model.loaded] == [1, 3]
317
+ assert direct.item() == 2
318
+ assert reshard_order == ["pair.weight", "direct", "pair.scale"]
319
+ assert stream.synchronize.call_count == 2
320
+
321
+ def test_trainer_rank_must_be_a_trainer_rank(self):
322
+ """A trainer rank must fall within the trainer portion of the group."""
323
+ with pytest.raises(ValueError, match="num_trainer_ranks"):
324
+ M2NTrainerInitInfo(
325
+ master_address="127.0.0.1",
326
+ master_port=1234,
327
+ world_size=4,
328
+ num_trainer_ranks=2,
329
+ rank=2,
330
+ )
331
+
332
+ def test_trainer_destination_mesh_must_cover_the_workers(self):
333
+ """The destination mesh must include every inference worker."""
334
+ with pytest.raises(ValueError, match="dst_mesh_dims"):
335
+ M2NTrainerInitInfo(
336
+ master_address="127.0.0.1",
337
+ master_port=1234,
338
+ world_size=6,
339
+ num_trainer_ranks=2,
340
+ dst_mesh_dims=(3, 1), # 3 != the 4 inference workers
341
+ rank=0,
342
+ )
343
+
344
+ def test_destination_mesh_defaults_to_flat(self):
345
+ """A replicated destination does not care how the mesh is factored, so
346
+ callers that do not shard it need not supply one."""
347
+ info = M2NTrainerInitInfo(
348
+ master_address="127.0.0.1",
349
+ master_port=1234,
350
+ world_size=6,
351
+ num_trainer_ranks=2,
352
+ rank=0,
353
+ )
354
+ assert info.destination_mesh_dims == (4, 1)
355
+
356
+ def test_sender_is_trainer_rank_zero(self):
357
+ """Trainer rank 0 drives the inference control plane."""
358
+ info = M2NTrainerInitInfo(
359
+ master_address="127.0.0.1", master_port=1234, world_size=4, rank=0
360
+ )
361
+ assert info.is_sender
362
+
363
+
364
+class TestRegistration:
365
+ def test_both_registries_expose_the_backend(self):
366
+ """Both worker and trainer factories register nccl_m2n."""
367
+ assert "nccl_m2n" in WeightTransferEngineFactory._registry
368
+ assert "nccl_m2n" in WeightTransferTrainerFactory._registry
369
+
370
+ def test_init_info_dispatches_to_the_backend(self):
371
+ """Trainer init info selects the nccl_m2n factory entry."""
372
+ assert M2NTrainerInitInfo.backend == "nccl_m2n"
373
+
374
+
375
+# ---------------------------------------------------------------------------
376
+# End-to-end transfer
377
+#
378
+# Transport-only, in the style of the NCCL/sparse tests in
379
+# test_weight_transfer.py: two Ray tasks with one GPU each drive the two engines
380
+# directly, so no HTTP server or LLM instance is involved. The worker is not an
381
+# RPC endpoint here, so the trainer engine gets a no-op control-plane client --
382
+# the NCCL rendezvous and the reshard itself are the real thing.
383
+# ---------------------------------------------------------------------------
384
+
385
+SHAPE = [64, 32]
386
+DTYPE = "float32"
387
+
388
+
389
+def _init_ray() -> None:
390
+ if ray.is_initialized():
391
+ return
392
+ ray.init(
393
+ ignore_reinit_error=True,
394
+ runtime_env={
395
+ "env_vars": {
396
+ "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES": "1",
397
+ "RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES": "1",
398
+ "RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES": "1",
399
+ }
400
+ },
401
+ )
402
+
403
+
404
+def _assigned_device() -> "torch.device":
405
+ gpu_ids = ray.get_gpu_ids()
406
+ device = torch.device(f"cuda:{int(gpu_ids[0])}" if gpu_ids else "cuda:0")
407
+ current_platform.set_device(device)
408
+ return device
409
+
410
+
411
+@ray.remote(num_gpus=1)
412
+def _m2n_trainer_send(nccl_unique_id_b64: str, world_size: int) -> bool:
413
+ """Send one parameter through the real trainer engine."""
414
+ device = _assigned_device()
415
+
416
+ from vllm.distributed.weight_transfer import WeightTransferTrainerFactory
417
+ from vllm.distributed.weight_transfer.m2n_source import DTensorModuleSource
418
+ from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
419
+
420
+ class NoopClient:
421
+ def init_weight_transfer_engine(self, init_info):
422
+ pass
423
+
424
+ def start_weight_update(self):
425
+ pass
426
+
427
+ def update_weights(self, update_info):
428
+ pass
429
+
430
+ def finish_weight_update(self, weight_version=None):
431
+ pass
432
+
433
+ class Tiny(torch.nn.Module):
434
+ def __init__(self):
435
+ super().__init__()
436
+ self.weight = torch.nn.Parameter(
437
+ torch.arange(
438
+ SHAPE[0] * SHAPE[1], dtype=torch.float32, device=device
439
+ ).reshape(SHAPE)
440
+ )
441
+
442
+ engine = WeightTransferTrainerFactory.trainer_init(
443
+ init_info=M2NTrainerInitInfo(
444
+ nccl_unique_id_b64=nccl_unique_id_b64,
445
+ world_size=world_size,
446
+ num_trainer_ranks=1,
447
+ dst_mesh_dims=(1, world_size - 1),
448
+ rank=0,
449
+ ),
450
+ client=NoopClient(),
451
+ source=DTensorModuleSource(Tiny(), num_trainer_ranks=1),
452
+ )
453
+ engine.send_weights()
454
+ torch.accelerator.synchronize()
455
+ engine.shutdown()
456
+ return True
457
+
458
+
459
+@ray.remote(num_gpus=1)
460
+def _m2n_worker_receive(
461
+ nccl_unique_id_b64: str,
462
+ world_size: int,
463
+ worker_rank: int = 0,
464
+) -> dict:
465
+ """Receive that parameter through the real worker engine."""
466
+ import contextlib
467
+ from unittest.mock import MagicMock
468
+
469
+ device = _assigned_device()
470
+
471
+ from vllm.config.parallel import ParallelConfig
472
+ from vllm.config.weight_transfer import WeightTransferConfig
473
+ from vllm.distributed.weight_transfer.m2n_engine import (
474
+ M2NWeightTransferEngine,
475
+ M2NWeightTransferInitInfo,
476
+ M2NWeightTransferUpdateInfo,
477
+ )
478
+
479
+ class Recorder(torch.nn.Module):
480
+ def __init__(self):
481
+ super().__init__()
482
+ self.received = []
483
+ if world_size > 2:
484
+ self.weight = torch.nn.Parameter(
485
+ torch.zeros(
486
+ SHAPE[0] // (world_size - 1),
487
+ SHAPE[1],
488
+ dtype=torch.float32,
489
+ device=device,
490
+ )
491
+ )
492
+ self.weight.output_dim = 0
493
+ self.weight.weight_loader = MethodType(
494
+ ColumnParallelLinear.weight_loader,
495
+ Mock(tp_size=world_size - 1),
496
+ )
497
+
498
+ def load_weights(self, weights):
499
+ for name, tensor in weights:
500
+ self.received.append((name, tensor.clone()))
501
+
502
+ parallel_config = MagicMock(spec=ParallelConfig)
503
+ parallel_config.rank = worker_rank
504
+ parallel_config.world_size = world_size - 1
505
+ parallel_config.data_parallel_rank = 0
506
+ parallel_config.data_parallel_index = 0
507
+ parallel_config.tensor_parallel_size = world_size - 1
508
+ parallel_config.pipeline_parallel_size = 1
509
+ vllm_config = MagicMock()
510
+ vllm_config.parallel_config = parallel_config
511
+ vllm_config.model_config = MagicMock()
512
+ vllm_config.model_config.quantization = None
513
+
514
+ recorder = Recorder()
515
+ engine = M2NWeightTransferEngine(
516
+ WeightTransferConfig(backend="nccl_m2n"), vllm_config, device, recorder
517
+ )
518
+ # Transport-only: receive_weights enters set_current_vllm_config, and
519
+ # vllm_config here is a mock.
520
+ import vllm.config as _vllm_config_mod
521
+
522
+ _vllm_config_mod.set_current_vllm_config = lambda cfg: contextlib.nullcontext()
523
+
524
+ engine.init_transfer_engine(
525
+ M2NWeightTransferInitInfo(
526
+ nccl_unique_id_b64=nccl_unique_id_b64,
527
+ rank_offset=1, # trainer occupies rank 0
528
+ world_size=world_size,
529
+ src_mesh_dims=[1, 1],
530
+ dst_mesh_dims=[1, world_size - 1],
531
+ names=["weight"],
532
+ dtype_names=[DTYPE],
533
+ shapes=[SHAPE],
534
+ src_placements=[None], # replicated on the single trainer rank
535
+ )
536
+ )
537
+ engine.receive_weights(M2NWeightTransferUpdateInfo(names=["weight"]))
538
+ torch.accelerator.synchronize()
539
+
540
+ full = torch.arange(
541
+ SHAPE[0] * SHAPE[1], dtype=torch.float32, device=device
542
+ ).reshape(SHAPE)
543
+ direct = world_size > 2
544
+ name: str | None
545
+ got: torch.Tensor | None
546
+ if direct:
547
+ name, got = "weight", recorder.weight
548
+ expected = full.chunk(world_size - 1, dim=0)[worker_rank]
549
+ else:
550
+ name, got = recorder.received[0] if recorder.received else (None, None)
551
+ expected = full
552
+ result = {
553
+ "count": len(recorder.received),
554
+ "name": name,
555
+ "shape": list(got.shape) if got is not None else None,
556
+ "exact": bool(torch.equal(got, expected)) if got is not None else False,
557
+ "direct": direct,
558
+ }
559
+ engine.shutdown()
560
+ return result
561
+
562
+
563
+@pytest.mark.skipif(
564
+ torch.accelerator.device_count() < 2,
565
+ reason="Need at least 2 GPUs: one trainer rank and one inference worker.",
566
+)
567
+def test_m2n_weight_transfer_between_processes():
568
+ """A parameter survives a real reshard from a trainer process to a worker.
569
+
570
+ This is the only test here that moves bytes: it builds both engines, joins
571
+ one NCCL communicator across two processes, and reshards. Everything else
572
+ in this file checks how a transfer is *described*.
573
+ """
574
+ pytest.importorskip("nccl.m2n", reason="nccl_m2n backend needs the m2n runtime")
575
+ _init_ray()
576
+
577
+ from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
578
+
579
+ nccl = NCCLLibrary()
580
+ nccl_unique_id_b64 = base64.b64encode(
581
+ bytes(nccl.ncclGetUniqueId().internal)
582
+ ).decode()
583
+ world_size = 2 # 1 trainer + 1 inference worker
584
+
585
+ worker = _m2n_worker_receive.remote(nccl_unique_id_b64, world_size)
586
+ trainer = _m2n_trainer_send.remote(nccl_unique_id_b64, world_size)
587
+ trainer_ok, result = ray.get([trainer, worker])
588
+
589
+ assert trainer_ok, "trainer engine did not complete"
590
+ assert result["count"] == 1, f"expected one parameter, got {result['count']}"
591
+ assert result["name"] == "weight"
592
+ assert result["shape"] == SHAPE
593
+ assert result["exact"], "resharded tensor does not match what the trainer sent"
594
+
595
+
596
+@pytest.mark.skipif(
597
+ torch.accelerator.device_count() < 3,
598
+ reason="Need at least 3 GPUs: one trainer rank and two inference workers.",
599
+)
600
+def test_m2n_weight_transfer_to_tp2_shards():
601
+ """Two inference workers receive their own TP shard in live model storage."""
602
+ pytest.importorskip("nccl.m2n", reason="nccl_m2n backend needs the m2n runtime")
603
+ _init_ray()
604
+
605
+ from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
606
+
607
+ nccl = NCCLLibrary()
608
+ nccl_unique_id_b64 = base64.b64encode(
609
+ bytes(nccl.ncclGetUniqueId().internal)
610
+ ).decode()
611
+ world_size = 3
612
+
613
+ workers = [
614
+ _m2n_worker_receive.remote(nccl_unique_id_b64, world_size, rank)
615
+ for rank in range(2)
616
+ ]
617
+ trainer = _m2n_trainer_send.remote(nccl_unique_id_b64, world_size)
618
+ trainer_ok, *results = ray.get([trainer, *workers])
619
+
620
+ assert trainer_ok, "trainer engine did not complete"
621
+ for rank, result in enumerate(results):
622
+ assert result["count"] == 0, f"worker {rank} unexpectedly called load_weights"
623
+ assert result["name"] == "weight"
624
+ assert result["shape"] == [SHAPE[0] // 2, SHAPE[1]]
625
+ assert result["direct"]
626
+ assert result["exact"], f"worker {rank} received the wrong shard"
627
+
628
+
629
+class _Model(torch.nn.Module):
630
+ """Stand-in for a TP=2 shard of a tiny model.
631
+
632
+ `column` is sharded on dim 0 and `row` on dim 1 (the two shapes a TP linear
633
+ layer produces), `norm` is replicated, and `fused` has no checkpoint-name
634
+ counterpart — the shape a fused parameter presents to the resolver.
635
+ """
636
+
637
+ def __init__(self) -> None:
638
+ super().__init__()
639
+ self.column = torch.nn.Parameter(torch.zeros(8, 16))
640
+ self.column.output_dim = 0
641
+ self.column.weight_loader = MethodType(
642
+ ColumnParallelLinear.weight_loader, Mock(tp_size=2)
643
+ )
644
+ self.row = torch.nn.Parameter(torch.zeros(16, 8))
645
+ self.row.input_dim = 1
646
+ self.row.weight_loader = MethodType(
647
+ RowParallelLinear.weight_loader, Mock(tp_size=2)
648
+ )
649
+ self.norm = torch.nn.Parameter(torch.zeros(16))
650
+ self.norm.weight_loader = default_weight_loader
651
+ self.fused = torch.nn.Parameter(torch.zeros(24, 16))
652
+
653
+
654
+class _MixedModel(torch.nn.Module):
655
+ def __init__(self) -> None:
656
+ super().__init__()
657
+ self.proj = torch.nn.Module()
658
+ self.proj.weight = torch.nn.Parameter(torch.zeros(16, 8))
659
+ self.proj.weight.input_dim = 1
660
+ self.proj.weight.weight_loader = MethodType(
661
+ RowParallelLinear.weight_loader, Mock(tp_size=2)
662
+ )
663
+ self.proj.bias = torch.nn.Parameter(torch.zeros(16))
664
+
665
+
666
+def _resolve(names, dtypes, shapes, **kwargs):
667
+ defaults = dict(num_workers=2, shard_axis_size=2, allow_direct=True)
668
+ defaults.update(kwargs)
669
+ return resolve_parameter_destinations(_Model(), names, dtypes, shapes, **defaults)
670
+
671
+
672
+class TestDestinationResolution:
673
+ @pytest.mark.parametrize(
674
+ "name,expected_dim",
675
+ [("column", 0), ("row", 1)],
676
+ )
677
+ def test_sharded_parameter_resolves_to_its_tp_dim(self, name, expected_dim):
678
+ [destination] = _resolve([name], [torch.float32], [(16, 16)])
679
+ assert destination.direct
680
+ assert destination.placements == (REPLICATE, expected_dim)
681
+
682
+ def test_replicated_parameter_needs_no_placement(self):
683
+ """A parameter every rank holds in full is REPLICATED, so it works
684
+ whatever way the inference mesh happens to be factored."""
685
+ [destination] = _resolve(["norm"], [torch.float32], [(16,)])
686
+ assert destination.direct
687
+ assert destination.placements is REPLICATED
688
+
689
+ def test_direct_destination_is_the_live_parameter(self):
690
+ model = _Model()
691
+ [destination] = resolve_parameter_destinations(
692
+ model,
693
+ ["column"],
694
+ [torch.float32],
695
+ [(16, 16)],
696
+ num_workers=2,
697
+ shard_axis_size=2,
698
+ allow_direct=True,
699
+ )
700
+ assert destination.tensor.data_ptr() == model.column.data_ptr()
701
+
702
+ def test_known_transforming_model_submodule_falls_back(self):
703
+ class GPT2Model(_Model):
704
+ pass
705
+
706
+ GPT2Model.__module__ = "vllm.model_executor.models.gpt2"
707
+ model = torch.nn.Module()
708
+ model.transforming = GPT2Model()
709
+ [destination] = resolve_parameter_destinations(
710
+ model,
711
+ ["transforming.row"],
712
+ [torch.float32],
713
+ [(16, 16)],
714
+ num_workers=2,
715
+ shard_axis_size=2,
716
+ allow_direct=True,
717
+ )
718
+
719
+ assert not destination.direct
720
+ assert destination.placements is REPLICATED
721
+
722
+ def test_unknown_name_falls_back(self):
723
+ """Fused parameters reach the worker under checkpoint names that do not
724
+ exist in the model; those must take the full-tensor path."""
725
+ [destination] = _resolve(["mlp.gate_proj.weight"], [torch.float32], [(16, 16)])
726
+ assert not destination.direct
727
+ assert destination.placements is REPLICATED
728
+
729
+ def test_fallback_parameter_demotes_direct_sibling_in_same_module(self):
730
+ """Layerwise finalization must not overwrite a directly loaded weight
731
+ when a sibling bias takes the fallback path."""
732
+ destinations = resolve_parameter_destinations(
733
+ _MixedModel(),
734
+ ["proj.weight", "proj.bias"],
735
+ [torch.float32, torch.float32],
736
+ [(16, 16), (16,)],
737
+ num_workers=2,
738
+ shard_axis_size=2,
739
+ allow_direct=True,
740
+ )
741
+
742
+ assert not any(destination.direct for destination in destinations)
743
+
744
+ def test_unknown_loader_falls_back_despite_matching_name_and_shape(self):
745
+ model = _Model()
746
+ model.norm.weight_loader = lambda param, weight: param.data.copy_(weight)
747
+ [destination] = resolve_parameter_destinations(
748
+ model,
749
+ ["norm"],
750
+ [torch.float32],
751
+ [(16,)],
752
+ num_workers=2,
753
+ shard_axis_size=2,
754
+ allow_direct=True,
755
+ )
756
+ assert not destination.direct
757
+
758
+ def test_missing_loader_is_not_assumed_to_be_a_plain_copy(self):
759
+ model = _Model()
760
+ del model.norm.weight_loader
761
+ [destination] = resolve_parameter_destinations(
762
+ model,
763
+ ["norm"],
764
+ [torch.float32],
765
+ [(16,)],
766
+ num_workers=2,
767
+ shard_axis_size=2,
768
+ allow_direct=True,
769
+ )
770
+ assert not destination.direct
771
+
772
+ def test_wrapped_known_loader_falls_back(self):
773
+ model = _Model()
774
+
775
+ @wraps(default_weight_loader)
776
+ def wrapped_loader(param, weight):
777
+ default_weight_loader(param, weight)
778
+
779
+ model.norm.weight_loader = wrapped_loader
780
+ [destination] = resolve_parameter_destinations(
781
+ model,
782
+ ["norm"],
783
+ [torch.float32],
784
+ [(16,)],
785
+ num_workers=2,
786
+ shard_axis_size=2,
787
+ allow_direct=True,
788
+ )
789
+ assert not destination.direct
790
+
791
+ def test_replicated_loader_with_transform_metadata_falls_back(self):
792
+ model = _Model()
793
+ model.norm.is_transposed = True
794
+ [destination] = resolve_parameter_destinations(
795
+ model,
796
+ ["norm"],
797
+ [torch.float32],
798
+ [(16,)],
799
+ num_workers=2,
800
+ shard_axis_size=2,
801
+ allow_direct=True,
802
+ )
803
+ assert not destination.direct
804
+
805
+ def test_packed_dim_zero_falls_back(self):
806
+ model = _Model()
807
+ model.column.packed_dim = 0
808
+ [destination] = resolve_parameter_destinations(
809
+ model,
810
+ ["column"],
811
+ [torch.float32],
812
+ [(16, 16)],
813
+ num_workers=2,
814
+ shard_axis_size=2,
815
+ allow_direct=True,
816
+ )
817
+ assert not destination.direct
818
+
819
+ def test_loader_declared_dim_wins_over_shape_guessing(self):
820
+ model = _Model()
821
+ model.column.output_dim = 1
822
+ [destination] = resolve_parameter_destinations(
823
+ model,
824
+ ["column"],
825
+ [torch.float32],
826
+ [(16, 16)],
827
+ num_workers=2,
828
+ shard_axis_size=2,
829
+ allow_direct=True,
830
+ )
831
+ assert not destination.direct
832
+
833
+ def test_shape_the_tp_factor_cannot_explain_falls_back(self):
834
+ [destination] = _resolve(["fused"], [torch.float32], [(16, 16)])
835
+ assert not destination.direct
836
+
837
+ def test_dtype_mismatch_falls_back(self):
838
+ """A parameter stored in a different dtype than the wire dtype means a
839
+ quantized or otherwise transformed layout — never write into it."""
840
+ [destination] = _resolve(["column"], [torch.bfloat16], [(16, 16)])
841
+ assert not destination.direct
842
+
843
+ def test_allow_direct_false_forces_every_parameter_to_fall_back(self):
844
+ destinations = _resolve(
845
+ ["column", "norm"],
846
+ [torch.float32] * 2,
847
+ [(16, 16), (16,)],
848
+ allow_direct=False,
849
+ )
850
+ assert not any(d.direct for d in destinations)
851
+
852
+ def test_plan_reports_byte_coverage(self, caplog_vllm):
853
+ """A small direct parameter must not hide a large fallback tensor."""
854
+ with caplog_vllm.at_level(
855
+ logging.INFO,
856
+ logger="vllm.distributed.weight_transfer.m2n_layout",
857
+ ):
858
+ _resolve(
859
+ ["norm", "missing_fused_weight"],
860
+ [torch.float32, torch.bfloat16],
861
+ [(16,), (1024, 1024)],
862
+ )
863
+
864
+ assert (
865
+ "1/2 parameters resharded directly into the model, 1 via "
866
+ "full-tensor fallback; direct byte coverage: 64/2097216 bytes "
867
+ "(0.0%)" in caplog_vllm.text
868
+ )
vllm/config/weight_transfer.py
+3
-1
@@ -9,7 +9,9 @@ from vllm.config.utils import config
9
class WeightTransferConfig:
10
"""Configuration for weight transfer during RL training."""
11
12
- backend: Literal["nccl", "ipc", "sparse_nccl", "sharded_rdt"] | str = "nccl"
12
+ backend: Literal["nccl", "ipc", "sparse_nccl", "sharded_rdt", "nccl_m2n"] | str = (
13
+ "nccl"
14
+ )
15
"""The backend to use for weight transfer. Validated against the
16
`WeightTransferEngineFactory` registry at engine creation time.
17
"""
vllm/distributed/weight_transfer/factory.py
+12
@@ -243,6 +243,12 @@ WeightTransferEngineFactory.register_engine(
243
"ShardedRDTWeightTransferEngine",
244
)
245
246
+WeightTransferEngineFactory.register_engine(
247
+ "nccl_m2n",
248
+ "vllm.distributed.weight_transfer.m2n_engine",
249
+ "M2NWeightTransferEngine",
250
+)
251
+
252
253
# Trainer-side engines, parallel to the worker registry above.
254
WeightTransferTrainerFactory.register_engine(
@@ -268,3 +274,9 @@ WeightTransferTrainerFactory.register_engine(
274
"vllm.distributed.weight_transfer.sharded_rdt_trainer",
275
"ShardedRDTTrainerWeightTransferEngine",
276
)
277
+
278
+WeightTransferTrainerFactory.register_engine(
279
+ "nccl_m2n",
280
+ "vllm.distributed.weight_transfer.m2n_trainer",
281
+ "M2NTrainerWeightTransferEngine",
282
+)
vllm/distributed/weight_transfer/m2n_common.py
new
+338
@@ -0,0 +1,338 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Shared helpers for the NCCL M2N (`nccl_m2n`) weight transfer backend.
4
+
5
+M2N reshards a tensor between two disjoint meshes of ranks that live in one
6
+communicator: the trainer occupies ranks `[0, T)` and the inference workers
7
+`[T, T + N)`. This module holds everything both sides need — the optional
8
+runtime import, layout descriptors, and the conversion to `nccl.m2n` types —
9
+so the engine module stays about the transfer itself.
10
+"""
11
+
12
+from collections.abc import Sequence
13
+from dataclasses import dataclass
14
+from typing import TYPE_CHECKING, Any, cast
15
+
16
+import torch
17
+
18
+from vllm.distributed.weight_transfer.base import ParamMeta
19
+
20
+if TYPE_CHECKING:
21
+ from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
22
+
23
+# Placement code for a mesh axis that replicates. Any other (non-negative) code
24
+# is the tensor dim that axis shards. Ints rather than `nccl.m2n` objects so
25
+# layouts survive the JSON init handshake without a custom encoder.
26
+REPLICATE = -1
27
+
28
+# `NCCL_RESHARD_MAX_TENSOR_DIMS`; 4-D and higher are rejected by the library.
29
+MAX_TENSOR_DIMS = 3
30
+
31
+MESH_NDIMS = 2
32
+
33
+# Destination mesh convention. Keeping these roles named avoids scattering
34
+# positional axis assumptions through the worker-side planner.
35
+DESTINATION_REPLICA_AXIS = 0
36
+DESTINATION_SHARD_AXIS = 1
37
+
38
+# m2n bounds how many source shards may feed one destination shard, and how many
39
+# destination shards one source shard may feed, with compile-time arrays in
40
+# `reshard_limits.h`. The bindings do not expose them, so they are mirrored
41
+# here; a build with larger arrays makes these conservative, never wrong.
42
+MAX_SOURCE_SHARDS = 16
43
+MAX_DEST_SHARDS = 64
44
+
45
+# Wire dtypes `ncclReshard` accepts. Notably excludes fp4 and any packed
46
+# sub-byte type, so quantized checkpoints are out of scope for now.
47
+SUPPORTED_DTYPES: frozenset[torch.dtype] = frozenset(
48
+ dtype
49
+ for dtype in (
50
+ getattr(torch, name, None)
51
+ for name in (
52
+ "int8",
53
+ "uint8",
54
+ "float8_e4m3fn",
55
+ "float8_e5m2",
56
+ "float16",
57
+ "bfloat16",
58
+ "int32",
59
+ "uint32",
60
+ "float32",
61
+ "int64",
62
+ "uint64",
63
+ "float64",
64
+ )
65
+ )
66
+ if dtype is not None
67
+)
68
+
69
+_IMPORT_HINT = (
70
+ "The nccl_m2n weight transfer backend requires the `nccl-extensions` "
71
+ "package (and its `nccl4py` dependency), which is not installed by vLLM. "
72
+ "See https://github.com/NVIDIA/nccl-extensions for build "
73
+ "and install instructions. It needs NCCL 2.30.5 or newer, and "
74
+ "VLLM_NCCL_SO_PATH must point at the same libnccl.so that libnccl_m2n.so "
75
+ "was linked against."
76
+)
77
+
78
+
79
+def import_m2n() -> Any:
80
+ """Import `nccl.m2n` lazily, with an actionable error when it is missing.
81
+
82
+ Deferred so that importing vLLM — or any other weight transfer backend —
83
+ never requires the m2n runtime to be present.
84
+ """
85
+ try:
86
+ import nccl.m2n as m2n
87
+ except ImportError as e:
88
+ raise ImportError(f"{_IMPORT_HINT} (import failed: {e})") from e
89
+ return m2n
90
+
91
+
92
+@dataclass(frozen=True)
93
+class M2NMesh:
94
+ """One side's rank topology — pure topology, no tensor placement.
95
+
96
+ Mirrors `ncclMesh_t`: a 2-axis mesh owning the contiguous rank interval
97
+ `[start_rank, start_rank + dims[0] * dims[1])`. There is no 1-D mesh; a
98
+ single-axis topology is spelled with a second axis of size 1.
99
+
100
+ One mesh describes every tensor on its side, so it is exchanged once at the
101
+ init handshake rather than per parameter.
102
+ """
103
+
104
+ dims: tuple[int, int]
105
+ start_rank: int
106
+
107
+ def __post_init__(self) -> None:
108
+ if len(self.dims) != MESH_NDIMS or any(d <= 0 for d in self.dims):
109
+ raise ValueError(f"mesh dims must be {MESH_NDIMS} positive ints")
110
+ if self.start_rank < 0:
111
+ raise ValueError(
112
+ f"mesh start_rank must be non-negative, got {self.start_rank}"
113
+ )
114
+
115
+ @property
116
+ def size(self) -> int:
117
+ return self.dims[0] * self.dims[1]
118
+
119
+
120
+# A tensor's placement over its side's mesh: one code per mesh axis
121
+# (`REPLICATE`, or the tensor dim that axis shards), or `REPLICATED` for a
122
+# tensor every rank holds in full.
123
+Placements = tuple[int, int]
124
+REPLICATED: Placements | None = None
125
+
126
+
127
+def check_placements(placements: Placements, context: str = "placements") -> None:
128
+ """Reject placement pairs m2n cannot express.
129
+
130
+ The header requires exactly one SHARD axis and one REPLICATE axis.
131
+ `{REPLICATE, REPLICATE}` hits a degenerate prepare branch, which is why
132
+ full replication is carried as `REPLICATED` and resolved separately;
133
+ sharding both axes is not expressible at all.
134
+ """
135
+ if len(placements) != MESH_NDIMS:
136
+ raise ValueError(f"{context} must have {MESH_NDIMS} entries")
137
+ invalid = [code for code in placements if code < REPLICATE]
138
+ if invalid:
139
+ raise ValueError(
140
+ f"{context} contains invalid placement code {invalid[0]}; valid "
141
+ f"codes are {REPLICATE} (Replicate) and non-negative tensor dimensions"
142
+ )
143
+ num_sharded = sum(code != REPLICATE for code in placements)
144
+ if num_sharded == 0:
145
+ raise ValueError(
146
+ f"{context}: a fully replicated tensor is carried as REPLICATED, "
147
+ "not as two "
148
+ "REPLICATE axes"
149
+ )
150
+ if num_sharded == MESH_NDIMS:
151
+ raise ValueError(
152
+ f"{context}: nccl_m2n needs one REPLICATE mesh axis, but "
153
+ f"{placements} shards both"
154
+ )
155
+
156
+
157
+def resolve_layout(
158
+ mesh: M2NMesh,
159
+ placements: Placements | None,
160
+ context: str = "placements",
161
+) -> tuple[M2NMesh, Placements]:
162
+ """Pair one tensor's placement with the mesh m2n should see for it.
163
+
164
+ A replicated tensor needs a size-1 mesh axis to carry a no-op shard, since
165
+ m2n has no `{REPLICATE, REPLICATE}`. It is therefore described over the
166
+ *same rank interval* re-factored as `(size, 1)`. That re-factoring is sound
167
+ precisely because replication is order-independent — every rank holds the
168
+ whole tensor, so it does not matter that `(a, b)` and `(size, 1)` walk the
169
+ interval in a different order. A sharded tensor keeps its side's own
170
+ factorization, where rank order decides who owns which shard.
171
+ """
172
+ if placements is None:
173
+ return M2NMesh((mesh.size, 1), mesh.start_rank), (REPLICATE, 0)
174
+ check_placements(placements, context)
175
+ return mesh, placements
176
+
177
+
178
+def shard_count(mesh: M2NMesh, placements: Placements) -> int:
179
+ """How many pieces this layout splits the tensor into."""
180
+ return next(
181
+ (mesh.dims[axis] for axis, code in enumerate(placements) if code != REPLICATE),
182
+ 1,
183
+ )
184
+
185
+
186
+def check_plan_limits(
187
+ src: tuple[M2NMesh, Placements],
188
+ dst: tuple[M2NMesh, Placements],
189
+ name: str,
190
+) -> None:
191
+ """Reject plans that exceed m2n's static per-shard fan-in / fan-out arrays.
192
+
193
+ Only the unambiguous cases are checked: when one side is a single shard it
194
+ is fed by (or feeds) every shard on the other side, so the count is exactly
195
+ the other side's shard count. In the general sharded-to-sharded case the
196
+ overlap depends on the library's chunking, and reproducing that arithmetic
197
+ here would duplicate internals that can change under us.
198
+
199
+ m2n re-checks authoritatively and, because the plan is derived from the
200
+ shared descriptors, fails identically on every rank -- so this is about
201
+ reporting at init with a message that names the parameter, not about
202
+ avoiding a hang.
203
+ """
204
+ src_shards = shard_count(*src)
205
+ dst_shards = shard_count(*dst)
206
+ if dst_shards == 1 and src_shards > MAX_SOURCE_SHARDS:
207
+ raise ValueError(
208
+ f"parameter '{name}' would feed {src_shards} source shards into one "
209
+ f"replicated destination, over m2n's MAX_SOURCES={MAX_SOURCE_SHARDS}. "
210
+ "Shard the destination, replicate the source, or rebuild m2n with "
211
+ "larger arrays."
212
+ )
213
+ if src_shards == 1 and dst_shards > MAX_DEST_SHARDS:
214
+ raise ValueError(
215
+ f"parameter '{name}' would fan one source shard out to {dst_shards} "
216
+ f"destination shards, over m2n's MAX_TARGETS={MAX_DEST_SHARDS}. "
217
+ "Rebuild m2n with larger arrays to raise the bound."
218
+ )
219
+
220
+
221
+def validate_layout(
222
+ mesh: M2NMesh, placements: Placements, shape: Sequence[int], side: str
223
+) -> None:
224
+ """Check a resolved layout can describe `shape` before any collective runs.
225
+
226
+ Called on both sides at init so a bad plan surfaces as an error from the
227
+ init RPC rather than as a hang inside the first reshard.
228
+ """
229
+ for axis, code in enumerate(placements):
230
+ if code == REPLICATE:
231
+ continue
232
+ if code >= len(shape):
233
+ raise ValueError(
234
+ f"{side} layout shards tensor dim {code}, but the tensor "
235
+ f"has rank {len(shape)}"
236
+ )
237
+ factor = mesh.dims[axis]
238
+ if shape[code] % factor:
239
+ raise ValueError(
240
+ f"{side} layout shards dim {code} (size {shape[code]}) over "
241
+ f"{factor} ranks, which does not divide evenly"
242
+ )
243
+
244
+
245
+@dataclass(frozen=True)
246
+class M2NParamMeta(ParamMeta):
247
+ """`ParamMeta` extended with how the trainer places this tensor.
248
+
249
+ The base class carries only name / dtype / full shape, which is not enough
250
+ to plan a reshard. `placements` is relative to its side's `M2NMesh`, or
251
+ `REPLICATED` when every rank holds the whole tensor.
252
+ """
253
+
254
+ placements: Placements | None
255
+
256
+
257
+def check_transferable(name: str, dtype: torch.dtype, shape: Sequence[int]) -> None:
258
+ """Reject tensors m2n cannot move at all, with the parameter named."""
259
+ if dtype not in SUPPORTED_DTYPES:
260
+ raise ValueError(
261
+ f"parameter '{name}' has dtype {dtype}, which nccl_m2n does not "
262
+ f"support. Supported: {sorted(str(d) for d in SUPPORTED_DTYPES)}"
263
+ )
264
+ if not 1 <= len(shape) <= MAX_TENSOR_DIMS:
265
+ raise ValueError(
266
+ f"parameter '{name}' has rank {len(shape)}; nccl_m2n supports "
267
+ f"rank 1..{MAX_TENSOR_DIMS}"
268
+ )
269
+
270
+
271
+def to_mesh(m2n: Any, mesh: M2NMesh) -> Any:
272
+ return m2n.Mesh(mesh.dims, start_rank=mesh.start_rank)
273
+
274
+
275
+def to_placements(m2n: Any, placements: Placements) -> list[Any]:
276
+ return [
277
+ m2n.Replicate() if code == REPLICATE else m2n.Shard(code) for code in placements
278
+ ]
279
+
280
+
281
+def publish_destination_placements(
282
+ comm: "PyNcclCommunicator",
283
+ first_worker_rank: int,
284
+ placements: "Sequence[Placements | None] | None",
285
+ num_parameters: int,
286
+) -> list[Placements | None]:
287
+ """Share the worker-side destination plan with every rank in the group.
288
+
289
+ The trainer must issue each reshard with the same destination the workers
290
+ use, but once destinations are per-parameter they depend on the inference
291
+ model, which only the workers can see. The first worker publishes them here,
292
+ over the shared NCCL communicator; trainer ranks pass `None` and receive
293
+ them, and the other workers pass their own so a disagreement is caught
294
+ rather than deadlocking later.
295
+
296
+ Only placements travel: the destination *mesh* is still derived from the
297
+ rank split, identically on both sides. Use the NCCL communicator directly
298
+ because unique-id rendezvous deliberately has no bootstrap process group.
299
+ """
300
+ replicated_sentinel = REPLICATE - 1
301
+ encoded = torch.full(
302
+ (num_parameters, MESH_NDIMS),
303
+ replicated_sentinel,
304
+ dtype=torch.int8,
305
+ device=comm.device,
306
+ )
307
+ if comm.rank == first_worker_rank:
308
+ if placements is None or len(placements) != num_parameters:
309
+ raise ValueError(
310
+ f"publishing rank needs {num_parameters} destination placements"
311
+ )
312
+ rows = [
313
+ [replicated_sentinel] * MESH_NDIMS if placement is REPLICATED else placement
314
+ for placement in placements
315
+ ]
316
+ if rows:
317
+ encoded.copy_(torch.tensor(rows, dtype=torch.int8, device=comm.device))
318
+
319
+ comm.broadcast(encoded, src=first_worker_rank)
320
+ return [
321
+ REPLICATED
322
+ if all(code == replicated_sentinel for code in row)
323
+ else cast(Placements, tuple(row))
324
+ for row in encoded.tolist()
325
+ ]
326
+
327
+
328
+def comm_ptr(comm: "PyNcclCommunicator") -> int:
329
+ """Raw `ncclComm_t` behind vLLM's `PyNcclCommunicator`.
330
+
331
+ m2n links its own NCCL, so the handle only means anything if vLLM loaded
332
+ the same `libnccl.so` — set `VLLM_NCCL_SO_PATH` accordingly.
333
+ """
334
+ handle = comm.comm
335
+ ptr = getattr(handle, "value", handle)
336
+ if not ptr:
337
+ raise RuntimeError("PyNcclCommunicator has no live NCCL communicator")
338
+ return int(ptr)
vllm/distributed/weight_transfer/m2n_engine.py
new
+453
@@ -0,0 +1,453 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Inference-side weight transfer engine built on NCCL M2N (`nccl_m2n`).
4
+
5
+The trainer and the inference workers share one NCCL communicator: trainer ranks
6
+occupy `[0, T)`, workers `[T, T + N)`. Each parameter is moved with a single
7
+`nccl.m2n.reshard`, which redistributes it from the trainer's layout (FSDP / EP
8
+/ arbitrary DTensor sharding) to the inference layout — so the trainer sends its
9
+local shards and never all-gathers a full tensor, which is what the broadcast
10
+NCCL backend forces it to do.
11
+
12
+When an incoming checkpoint parameter maps directly to a live vLLM parameter,
13
+M2N reshards into that worker's local model storage. Parameters whose names or
14
+layouts cannot be resolved receive a full tensor and fall back to
15
+`load_weights`, preserving the existing backend's loading behavior.
16
+
17
+Both meshes participate in every reshard, in the same order, so this backend has
18
+the same concurrency shape as the broadcast NCCL backend: the worker must be
19
+inside `receive_weights` while the trainer is sending. Driving that from the
20
+trainer is the trainer engine's job.
21
+"""
22
+
23
+from collections.abc import Iterator
24
+from dataclasses import dataclass, field
25
+from typing import TYPE_CHECKING, Any, cast
26
+
27
+import torch
28
+
29
+from vllm.config.weight_transfer import WeightTransferConfig
30
+from vllm.distributed.weight_transfer.base import (
31
+ WeightTransferEngine,
32
+ WeightTransferInitInfo,
33
+ WeightTransferUpdateInfo,
34
+)
35
+from vllm.distributed.weight_transfer.m2n_common import (
36
+ DESTINATION_SHARD_AXIS,
37
+ MESH_NDIMS,
38
+ REPLICATED,
39
+ M2NMesh,
40
+ M2NParamMeta,
41
+ Placements,
42
+ check_plan_limits,
43
+ check_transferable,
44
+ comm_ptr,
45
+ import_m2n,
46
+ publish_destination_placements,
47
+ resolve_layout,
48
+ to_mesh,
49
+ to_placements,
50
+ validate_layout,
51
+)
52
+from vllm.distributed.weight_transfer.m2n_layout import (
53
+ M2NDestination,
54
+ resolve_parameter_destinations,
55
+)
56
+from vllm.distributed.weight_transfer.nccl_common import (
57
+ NCCLWeightTransferInitInfo,
58
+ decode_nccl_unique_id,
59
+ worker_init_process_group,
60
+)
61
+
62
+if TYPE_CHECKING:
63
+ from vllm.config import VllmConfig
64
+ from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
65
+
66
+__all__ = [
67
+ "M2NWeightTransferInitInfo",
68
+ "M2NWeightTransferUpdateInfo",
69
+ "M2NWeightTransferEngine",
70
+]
71
+
72
+
73
+# ---------------------------------------------------------------------------
74
+# Wire types
75
+# ---------------------------------------------------------------------------
76
+
77
+
78
+@dataclass(kw_only=True)
79
+class M2NWeightTransferInitInfo(WeightTransferInitInfo):
80
+ """Worker-side init info: the rendezvous plus the full transfer plan.
81
+
82
+ Layouts are static for the whole run, so they ride the one-time init
83
+ handshake and the per-round update info stays a list of names. Everything is
84
+ plain JSON so the HTTP control plane carries it unchanged.
85
+ """
86
+
87
+ master_address: str | None = None
88
+ master_port: int | None = None
89
+ rank_offset: int
90
+ """First worker rank, i.e. the number of trainer ranks."""
91
+ world_size: int
92
+ """Trainer ranks + all inference workers."""
93
+ src_mesh_dims: list[int]
94
+ """The trainer's mesh, shared by every parameter (`start_rank` is 0: the
95
+ trainer occupies the front of the communicator)."""
96
+ dst_mesh_dims: list[int]
97
+ """The inference mesh, starting at `rank_offset`. Declared rather than
98
+ derived so both sides describe the destination identically."""
99
+ names: list[str]
100
+ dtype_names: list[str]
101
+ shapes: list[list[int]]
102
+ src_placements: list[list[int] | None]
103
+ """Per parameter, relative to `src_mesh_dims`; `None` means replicated."""
104
+ nccl_unique_id_b64: str | None = field(default=None, repr=False)
105
+ max_cta: int | None = None
106
+
107
+ def __post_init__(self) -> None:
108
+ _ = self.nccl_unique_id_bytes
109
+ num_params = len(self.names)
110
+ for label, values in (
111
+ ("dtype_names", self.dtype_names),
112
+ ("shapes", self.shapes),
113
+ ("src_placements", self.src_placements),
114
+ ):
115
+ if len(values) != num_params:
116
+ raise ValueError(
117
+ f"`{label}` should be of the same size as `names`: "
118
+ f"got {len(values)} and {num_params}"
119
+ )
120
+ if self.rank_offset < 1 or self.rank_offset >= self.world_size:
121
+ raise ValueError(
122
+ f"`rank_offset` ({self.rank_offset}) must leave at least one "
123
+ f"trainer rank and one worker in world_size {self.world_size}"
124
+ )
125
+ num_workers = self.world_size - self.rank_offset
126
+ dst = tuple(self.dst_mesh_dims)
127
+ if len(dst) != MESH_NDIMS or dst[0] * dst[1] != num_workers:
128
+ raise ValueError(
129
+ f"`dst_mesh_dims` {dst} must be {MESH_NDIMS} dims covering the "
130
+ f"{num_workers} inference workers"
131
+ )
132
+
133
+ @property
134
+ def nccl_unique_id_bytes(self) -> bytes | None:
135
+ return decode_nccl_unique_id(
136
+ master_address=self.master_address,
137
+ master_port=self.master_port,
138
+ nccl_unique_id_b64=self.nccl_unique_id_b64,
139
+ ctx="M2NWeightTransferInitInfo",
140
+ )
141
+
142
+
143
+@dataclass
144
+class M2NWeightTransferUpdateInfo(WeightTransferUpdateInfo):
145
+ """Per-round update info: which parameters this chunk carries, in order.
146
+
147
+ Shapes, dtypes and layouts were fixed at init, so a round only needs to say
148
+ what is coming and in what order — both sides must issue their reshards in
149
+ exactly that order.
150
+ """
151
+
152
+ names: list[str]
153
+
154
+
155
+# ---------------------------------------------------------------------------
156
+# Worker engine
157
+# ---------------------------------------------------------------------------
158
+
159
+
160
+class M2NWeightTransferEngine(
161
+ WeightTransferEngine[M2NWeightTransferInitInfo, M2NWeightTransferUpdateInfo]
162
+):
163
+ """Inference-side engine: receives each parameter with one reshard.
164
+
165
+ Resolvable parameters are resharded from the trainer layout directly into
166
+ each worker's live local model storage. Unresolvable parameters receive a
167
+ full tensor and use `load_weights`. In both cases, the trainer sends its
168
+ local shards without materializing a full tensor.
169
+ """
170
+
171
+ init_info_cls = M2NWeightTransferInitInfo
172
+ update_info_cls = M2NWeightTransferUpdateInfo
173
+ # The destination plan is resolved against the model this engine was built
174
+ # for, so it cannot be retargeted at a draft model mid-flight.
175
+ supports_draft_weight_update = False
176
+
177
+ def __init__(
178
+ self,
179
+ config: WeightTransferConfig,
180
+ vllm_config: "VllmConfig",
181
+ device: torch.device,
182
+ model: torch.nn.Module,
183
+ ) -> None:
184
+ super().__init__(config, vllm_config, device, model)
185
+ self.model_update_group: PyNcclCommunicator | None = None
186
+ self._m2n: Any = None
187
+ self._handle: Any = None
188
+ self._metas: list[M2NParamMeta] = []
189
+ self._src_mesh: M2NMesh | None = None
190
+ self._dst_mesh: M2NMesh | None = None
191
+ self._parameter_destinations: list[M2NDestination] = []
192
+ self._index: dict[str, int] = {}
193
+ self._uses_load_weights = False
194
+
195
+ def init_transfer_engine(self, init_info: M2NWeightTransferInitInfo) -> None:
196
+ """Join the trainer's communicator and rebuild the transfer plan.
197
+
198
+ Every precondition (dtype, tensor rank, divisibility) is checked here so
199
+ a bad plan fails the init RPC instead of hanging the first collective.
200
+ """
201
+ self._m2n = import_m2n()
202
+ self._metas = []
203
+
204
+ self._src_mesh = M2NMesh(
205
+ cast(tuple[int, int], tuple(init_info.src_mesh_dims)), 0
206
+ )
207
+ if self._src_mesh.size != init_info.rank_offset:
208
+ raise ValueError(
209
+ f"source mesh covers {self._src_mesh.size} ranks, but there are "
210
+ f"{init_info.rank_offset} trainer ranks"
211
+ )
212
+ # The inference topology, as declared by the trainer. This is the mesh
213
+ # a sharded destination is placed over; a replicated one is described
214
+ # by the alternate descriptor `resolve_layout` derives from it, which
215
+ # covers the same ranks but is not this mesh.
216
+ self._dst_mesh = M2NMesh(
217
+ cast(tuple[int, int], tuple(init_info.dst_mesh_dims)),
218
+ init_info.rank_offset,
219
+ )
220
+
221
+ for name, name_dtype, shape, placements in zip(
222
+ init_info.names,
223
+ init_info.dtype_names,
224
+ init_info.shapes,
225
+ init_info.src_placements,
226
+ ):
227
+ dtype = getattr(torch, name_dtype, None)
228
+ if not isinstance(dtype, torch.dtype):
229
+ raise ValueError(
230
+ f"parameter '{name}' has invalid dtype name "
231
+ f"'{name_dtype}'; expected the name of a torch.dtype"
232
+ )
233
+ check_transferable(name, dtype, shape)
234
+ codes = (
235
+ REPLICATED
236
+ if placements is None
237
+ else cast(Placements, tuple(placements))
238
+ )
239
+ src_mesh, src_placements = resolve_layout(
240
+ self._src_mesh, codes, f"parameter '{name}' source placements"
241
+ )
242
+ validate_layout(src_mesh, src_placements, shape, "source")
243
+ # Destination checks are deferred until loader-aware resolution;
244
+ # assuming replication here can reject a valid sharded plan.
245
+ self._metas.append(M2NParamMeta(name, dtype, tuple(shape), codes))
246
+
247
+ parallel_config = self.parallel_config
248
+ shard_axis_size = self._dst_mesh.dims[DESTINATION_SHARD_AXIS]
249
+ quantization = getattr(self.model_config, "quantization", None)
250
+ # A quantized weight loader may dequantize / transpose / requantize, and
251
+ # under PP a rank's local shape is not the checkpoint shape split over
252
+ # the shard axis. Both break shape-derived resolution, so take the
253
+ # fallback.
254
+ allow_direct = (
255
+ quantization is None and parallel_config.pipeline_parallel_size == 1
256
+ )
257
+ # Match every incoming checkpoint parameter to its rank-local target.
258
+ # Resolvable parameters can be resharded directly into live model
259
+ # storage; the rest receive a full tensor for `load_weights`.
260
+ self._parameter_destinations = resolve_parameter_destinations(
261
+ self.model,
262
+ [m.name for m in self._metas],
263
+ [m.dtype for m in self._metas],
264
+ [m.shape for m in self._metas],
265
+ num_workers=self._dst_mesh.size,
266
+ shard_axis_size=shard_axis_size,
267
+ allow_direct=allow_direct,
268
+ )
269
+ # Check the resolved layout rather than a provisional replicated one.
270
+ # For example, 32 source shards feeding 4 destination shards may need
271
+ # only 8 sources per destination, while a replicated plan needs all 32.
272
+ for meta, destination in zip(self._metas, self._parameter_destinations):
273
+ src_mesh, src_placements = resolve_layout(
274
+ self._src_mesh,
275
+ meta.placements,
276
+ f"parameter '{meta.name}' source placements",
277
+ )
278
+ dst_mesh, dst_placements = resolve_layout(
279
+ self._dst_mesh,
280
+ destination.placements,
281
+ f"parameter '{meta.name}' destination placements",
282
+ )
283
+ validate_layout(dst_mesh, dst_placements, meta.shape, "destination")
284
+ check_plan_limits(
285
+ (src_mesh, src_placements),
286
+ (dst_mesh, dst_placements),
287
+ meta.name,
288
+ )
289
+
290
+ # Update requests carry names only, so cache their plan indices. Track
291
+ # whether any fallback entry requires the `load_weights` lifecycle.
292
+ self._index = {meta.name: i for i, meta in enumerate(self._metas)}
293
+ self._uses_load_weights = any(
294
+ not destination.direct for destination in self._parameter_destinations
295
+ )
296
+
297
+ self.model_update_group = worker_init_process_group(
298
+ NCCLWeightTransferInitInfo(
299
+ master_address=init_info.master_address,
300
+ master_port=init_info.master_port,
301
+ nccl_unique_id_b64=init_info.nccl_unique_id_b64,
302
+ rank_offset=init_info.rank_offset,
303
+ world_size=init_info.world_size,
304
+ ),
305
+ parallel_config,
306
+ )
307
+ # Destinations are per-parameter and depend on the inference model, so
308
+ # the trainer cannot derive them. The first worker publishes the plan
309
+ # and every other rank checks its own against it — a silent
310
+ # disagreement between workers would mean mismatched collectives.
311
+ mine = [destination.placements for destination in self._parameter_destinations]
312
+ agreed = publish_destination_placements(
313
+ self.model_update_group, init_info.rank_offset, mine, len(self._metas)
314
+ )
315
+ if agreed != mine:
316
+ mismatched = next(
317
+ meta.name for meta, a, b in zip(self._metas, mine, agreed) if a != b
318
+ )
319
+ raise RuntimeError(
320
+ "inference workers resolved different nccl_m2n destinations "
321
+ f"(first mismatch: '{mismatched}'); all workers must run the "
322
+ "same model and parallel config"
323
+ )
324
+
325
+ self._handle = self._m2n.Handle.create(
326
+ self._m2n.Config(max_cta=init_info.max_cta)
327
+ )
328
+
329
+ def start_weight_update(self) -> None:
330
+ """Set up layerwise reloading, but only if some parameter needs it.
331
+
332
+ Directly-resharded parameters are written in place and never go through
333
+ `load_weights`, so a plan with no fallback entries has nothing to
334
+ reload.
335
+ """
336
+ if not self._uses_load_weights:
337
+ return
338
+ from vllm.model_executor.model_loader.reload import initialize_layerwise_reload
339
+
340
+ initialize_layerwise_reload(self.model)
341
+
342
+ def finish_weight_update(self) -> None:
343
+ """Finalize layerwise reloading when the plan uses fallback entries."""
344
+ if not self._uses_load_weights:
345
+ return
346
+ from vllm.model_executor.model_loader.reload import finalize_layerwise_reload
347
+
348
+ finalize_layerwise_reload(self.model, self.model_config)
349
+
350
+ def receive_weights(self, update_info: M2NWeightTransferUpdateInfo) -> None:
351
+ """Receive each requested parameter using its initialization-time plan."""
352
+ if self._handle is None or self.model_update_group is None:
353
+ raise RuntimeError(
354
+ "nccl_m2n weight transfer not initialized. "
355
+ "Call init_transfer_engine() first."
356
+ )
357
+
358
+ requested = []
359
+ for name in update_info.names:
360
+ index = self._index.get(name)
361
+ if index is None:
362
+ raise ValueError(
363
+ f"parameter '{name}' was not declared at init; the "
364
+ "trainer must send the same parameter set it announced"
365
+ )
366
+ requested.append((name, index))
367
+
368
+ from vllm.model_executor.model_loader.mtp_validation import (
369
+ disable_mtp_completeness_check,
370
+ )
371
+
372
+ comm = comm_ptr(self.model_update_group)
373
+ stream = torch.cuda.current_stream()
374
+
375
+ # Reshard lazily so one `load_weights` invocation sees the complete
376
+ # fallback sequence without staging every full tensor at once. Direct
377
+ # destinations still execute in request order as the iterator advances.
378
+ def reshard_requested_weights() -> Iterator[tuple[str, torch.Tensor]]:
379
+ for name, index in requested:
380
+ meta = self._metas[index]
381
+ destination = self._parameter_destinations[index]
382
+ buffer = destination.tensor
383
+ if buffer is None:
384
+ buffer = torch.empty(
385
+ meta.shape, dtype=meta.dtype, device=self.device
386
+ )
387
+
388
+ self._reshard(comm, stream, meta, destination.placements, buffer)
389
+
390
+ if destination.tensor is None:
391
+ # `load_weights` reads on the host stream, so the transfer
392
+ # has to have landed before it runs.
393
+ stream.synchronize()
394
+ yield name, buffer
395
+
396
+ has_fallback = any(
397
+ self._parameter_destinations[index].tensor is None for _, index in requested
398
+ )
399
+ with disable_mtp_completeness_check():
400
+ received_weights = reshard_requested_weights()
401
+ if has_fallback:
402
+ # Some model loaders keep invocation-local state to combine
403
+ # related checkpoint tensors, so preserve one iterable scope.
404
+ self.model.load_weights(received_weights)
405
+ else:
406
+ # The iterator performs the reshards. With nothing to yield to
407
+ # `load_weights`, exhaust it here to execute direct transfers.
408
+ for _ in received_weights:
409
+ pass
410
+
411
+ def _reshard(
412
+ self,
413
+ comm: int,
414
+ stream: torch.cuda.Stream,
415
+ src_meta: M2NParamMeta,
416
+ dst_placements: Placements | None,
417
+ dst_buffer: torch.Tensor,
418
+ ) -> None:
419
+ """Reshard one parameter from its trainer layout into a worker buffer.
420
+
421
+ `dst_placements` describes the buffer's logical placement over the
422
+ worker mesh; `dst_buffer` is this rank's physical storage.
423
+ """
424
+ assert self._src_mesh is not None
425
+ assert self._dst_mesh is not None
426
+ m2n = self._m2n
427
+ src_mesh, resolved_src_placements = resolve_layout(
428
+ self._src_mesh, src_meta.placements
429
+ )
430
+ dst_mesh, resolved_dst_placements = resolve_layout(
431
+ self._dst_mesh, dst_placements
432
+ )
433
+ m2n.reshard(
434
+ None,
435
+ dst_buffer,
436
+ comm,
437
+ stream,
438
+ src_mesh=to_mesh(m2n, src_mesh),
439
+ src_placements=to_placements(m2n, resolved_src_placements),
440
+ dst_mesh=to_mesh(m2n, dst_mesh),
441
+ dst_placements=to_placements(m2n, resolved_dst_placements),
442
+ handle=self._handle,
443
+ )
444
+
445
+ def shutdown(self) -> None:
446
+ """Finish pending GPU work and release M2N communication state."""
447
+ if self._handle is not None:
448
+ # M2N does not synchronize caller streams on finalize.
449
+ torch.accelerator.synchronize()
450
+ self._handle.destroy()
451
+ self._handle = None
452
+ self.model_update_group = None
453
+ self._parameter_destinations = []
vllm/distributed/weight_transfer/m2n_layout.py
new
+261
@@ -0,0 +1,261 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Destination-layout resolution for the NCCL M2N weight transfer backend.
4
+
5
+For every incoming checkpoint parameter the worker needs a destination buffer
6
+plus its placement over the inference mesh. Two outcomes are possible:
7
+
8
+* **direct** — when the model is not quantized, the parameter maps 1:1 onto a
9
+ live vLLM parameter and its loader is a small, known-safe copy or tensor-
10
+ parallel loader. The loader's declared input/output dimension determines the
11
+ placement; shapes are validation, not an inference mechanism. The reshard
12
+ writes straight into the live parameter, so each rank receives only its own
13
+ shard and nothing is copied afterwards.
14
+* **fallback** — anything else. The reshard delivers the whole tensor to every
15
+ rank and `load_weights` does the sharding, exactly as the broadcast NCCL
16
+ backend does. Fused parameters (`qkv_proj`, `gate_up_proj`, MoE `w13`/`w2`)
17
+ take this path: the checkpoint name does not name a vLLM parameter, so there
18
+ is nothing to resolve against.
19
+
20
+Correctness never depends on a parameter resolving — the fallback is always
21
+available and is the same path the existing backend uses.
22
+"""
23
+
24
+import inspect
25
+import math
26
+from collections.abc import Sequence
27
+from dataclasses import dataclass
28
+from functools import partial
29
+from typing import cast
30
+
31
+import torch
32
+
33
+from vllm.distributed.weight_transfer.m2n_common import (
34
+ DESTINATION_REPLICA_AXIS,
35
+ DESTINATION_SHARD_AXIS,
36
+ MESH_NDIMS,
37
+ REPLICATE,
38
+ REPLICATED,
39
+ Placements,
40
+)
41
+from vllm.logger import init_logger
42
+
43
+logger = init_logger(__name__)
44
+
45
+_DIRECT_WEIGHT_LOADING_BLOCKLIST = {
46
+ "vllm.model_executor.models.gpt2.GPT2Model",
47
+}
48
+
49
+
50
+def _module_type_name(module: torch.nn.Module) -> str:
51
+ cls = type(module)
52
+ return f"{cls.__module__}.{cls.__name__}"
53
+
54
+
55
+def _destination_placements(shard_dim: int) -> Placements:
56
+ """Place one tensor shard on the configured destination mesh axis."""
57
+ placements = [REPLICATE] * MESH_NDIMS
58
+ placements[DESTINATION_REPLICA_AXIS] = REPLICATE
59
+ placements[DESTINATION_SHARD_AXIS] = shard_dim
60
+ return cast(Placements, tuple(placements))
61
+
62
+
63
+@dataclass
64
+class M2NDestination:
65
+ """Where one checkpoint parameter lands on this worker."""
66
+
67
+ name: str
68
+ placements: Placements | None
69
+ """Placement over the inference mesh, or `REPLICATED` for the fallback."""
70
+ tensor: torch.Tensor | None
71
+ """The live parameter view to reshard into, or None for the fallback path
72
+ (the engine allocates a full replica per round and calls `load_weights`)."""
73
+
74
+ @property
75
+ def direct(self) -> bool:
76
+ return self.tensor is not None
77
+
78
+
79
+def _base_loader_shard_dim(
80
+ param: torch.nn.Parameter,
81
+ shard_axis_size: int,
82
+) -> int | None:
83
+ """Return a known-safe loader's declared shard dim, or reject it.
84
+
85
+ Loader identity is deliberately fail-closed. A missing loader is not
86
+ treated as ``default_weight_loader`` because a model-level ``load_weights``
87
+ implementation may transform the tensor before it reaches that default.
88
+ Wrappers and partials are also rejected: recognizing their wrapped callable
89
+ would ignore behavior added by the wrapper.
90
+ """
91
+ loader = getattr(param, "weight_loader", None)
92
+ if loader is None or isinstance(loader, partial):
93
+ return None
94
+
95
+ owner = loader.__self__ if inspect.ismethod(loader) else None
96
+ loader_fn = loader.__func__ if inspect.ismethod(loader) else loader
97
+
98
+ # Lazy imports avoid making the weight-transfer registry import all model
99
+ # executor layers during normal vLLM startup.
100
+ from vllm.model_executor.layers.linear import (
101
+ ColumnParallelLinear,
102
+ ReplicatedLinear,
103
+ RowParallelLinear,
104
+ )
105
+ from vllm.model_executor.model_loader.weight_utils import default_weight_loader
106
+
107
+ replicated_loaders = {
108
+ default_weight_loader,
109
+ ReplicatedLinear.weight_loader,
110
+ }
111
+ column_loaders = {
112
+ ColumnParallelLinear.weight_loader,
113
+ ColumnParallelLinear.weight_loader_v2,
114
+ }
115
+ row_loaders = {
116
+ RowParallelLinear.weight_loader,
117
+ RowParallelLinear.weight_loader_v2,
118
+ }
119
+
120
+ # These attributes signal packing, a transform, or pre-sharded checkpoint
121
+ # storage. Such semantics cannot be reproduced by a plain M2N placement.
122
+ # Check them before accepting replicated loaders too: model-level loading
123
+ # may transform a tensor before handing it to an otherwise plain copier.
124
+ metadata_attrs = ("packed_dim", "packed_factor", "marlin_tile_size")
125
+ flag_attrs = ("needs_scalar_to_array", "is_sharded_weight", "is_transposed")
126
+ if any(getattr(param, attr, None) is not None for attr in metadata_attrs):
127
+ return None
128
+ if any(bool(getattr(param, attr, False)) for attr in flag_attrs):
129
+ return None
130
+
131
+ if loader_fn in replicated_loaders:
132
+ return REPLICATE
133
+ if loader_fn not in column_loaders | row_loaders:
134
+ return None
135
+
136
+ if owner is None or getattr(owner, "tp_size", None) != shard_axis_size:
137
+ return None
138
+
139
+ attr = "output_dim" if loader_fn in column_loaders else "input_dim"
140
+ dim = getattr(param, attr, None)
141
+ return dim if isinstance(dim, int) and dim >= 0 else None
142
+
143
+
144
+def _validated_shard_dim(
145
+ global_shape: Sequence[int],
146
+ local_shape: Sequence[int],
147
+ declared_dim: int,
148
+ shard_axis_size: int,
149
+) -> int | None:
150
+ """Validate a loader-declared shard dimension against both shapes."""
151
+ if len(global_shape) != len(local_shape):
152
+ return None
153
+
154
+ if declared_dim == REPLICATE:
155
+ return REPLICATE if tuple(global_shape) == tuple(local_shape) else None
156
+ if declared_dim >= len(global_shape):
157
+ return None
158
+ for dim, (whole, local) in enumerate(zip(global_shape, local_shape)):
159
+ expected = local * shard_axis_size if dim == declared_dim else local
160
+ if whole != expected:
161
+ return None
162
+ return declared_dim
163
+
164
+
165
+def resolve_parameter_destinations(
166
+ model: torch.nn.Module,
167
+ names: Sequence[str],
168
+ dtypes: Sequence[torch.dtype],
169
+ shapes: Sequence[Sequence[int]],
170
+ *,
171
+ num_workers: int,
172
+ shard_axis_size: int,
173
+ allow_direct: bool,
174
+) -> list[M2NDestination]:
175
+ """Build this worker's destination plan, one entry per checkpoint parameter.
176
+
177
+ Placements are relative to the inference mesh: axis 0 replicates and axis 1
178
+ shards. `allow_direct=False` forces every parameter onto the fallback path;
179
+ the engine sets it for pipeline-parallel or quantized deployments, where a
180
+ parameter's local shape is not simply the checkpoint shape split across the
181
+ shard axis. Parameters below a model type with known model-level checkpoint
182
+ preprocessing also fall back, since bypassing `load_weights` would skip the
183
+ transformation.
184
+ """
185
+ params = dict(model.named_parameters()) if allow_direct else {}
186
+ blocked_param_ids = {
187
+ id(param)
188
+ for module in model.modules()
189
+ if _module_type_name(module) in _DIRECT_WEIGHT_LOADING_BLOCKLIST
190
+ for param in module.parameters()
191
+ }
192
+
193
+ destinations: list[M2NDestination] = []
194
+ for name, dtype, shape in zip(names, dtypes, shapes):
195
+ param = params.get(name)
196
+ dim = None
197
+ if (
198
+ param is not None
199
+ and id(param) not in blocked_param_ids
200
+ and param.dtype == dtype
201
+ and param.data.is_contiguous()
202
+ and num_workers % shard_axis_size == 0
203
+ ):
204
+ declared_dim = _base_loader_shard_dim(param, shard_axis_size)
205
+ if declared_dim is not None:
206
+ dim = _validated_shard_dim(
207
+ shape, param.shape, declared_dim, shard_axis_size
208
+ )
209
+
210
+ if dim is None:
211
+ # A replicated parameter is identical on every rank, so it needs no
212
+ # placement of its own — REPLICATED lets resolve_layout spread it
213
+ # over all the workers regardless of how the mesh is factored.
214
+ destinations.append(M2NDestination(name, REPLICATED, None))
215
+ elif dim == REPLICATE:
216
+ assert param is not None
217
+ destinations.append(M2NDestination(name, REPLICATED, param.data))
218
+ else:
219
+ assert param is not None
220
+ destinations.append(
221
+ M2NDestination(name, _destination_placements(dim), param.data)
222
+ )
223
+
224
+ # Layerwise reload finalizes a module as a unit. Mixing direct and fallback
225
+ # parameters within one module can copy stale storage over the direct load.
226
+ fallback_modules = {
227
+ name.rpartition(".")[0]
228
+ for name, destination in zip(names, destinations)
229
+ if not destination.direct and name in params
230
+ }
231
+ destinations = [
232
+ M2NDestination(destination.name, REPLICATED, None)
233
+ if destination.direct
234
+ and destination.name.rpartition(".")[0] in fallback_modules
235
+ else destination
236
+ for destination in destinations
237
+ ]
238
+
239
+ num_direct = sum(d.direct for d in destinations)
240
+ parameter_bytes = [
241
+ math.prod(shape) * dtype.itemsize for dtype, shape in zip(dtypes, shapes)
242
+ ]
243
+ total_bytes = sum(parameter_bytes)
244
+ direct_bytes = sum(
245
+ nbytes
246
+ for destination, nbytes in zip(destinations, parameter_bytes)
247
+ if destination.direct
248
+ )
249
+ direct_byte_percentage = 100 * direct_bytes / total_bytes if total_bytes else 0
250
+ logger.info(
251
+ "nccl_m2n destination plan: %d/%d parameters resharded directly into "
252
+ "the model, %d via full-tensor fallback; direct byte coverage: "
253
+ "%d/%d bytes (%.1f%%)",
254
+ num_direct,
255
+ len(destinations),
256
+ len(destinations) - num_direct,
257
+ direct_bytes,
258
+ total_bytes,
259
+ direct_byte_percentage,
260
+ )
261
+ return destinations
vllm/distributed/weight_transfer/m2n_source.py
new
+155
@@ -0,0 +1,155 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Trainer-side weight sources for the NCCL M2N backend.
4
+
5
+m2n plans a transfer from both sides' layouts, so the trainer has to say how it
6
+holds each parameter — which the base `WeightSource` / `ParamMeta` pair does not
7
+express. This module supplies the m2n flavor of both, and the DTensor-backed
8
+implementation that covers the common trainer.
9
+
10
+A source declares its mesh once (`mesh()`) and a placement per parameter, since
11
+one topology describes every tensor on the side.
12
+"""
13
+
14
+from collections.abc import Iterator
15
+from typing import Any
16
+
17
+import torch
18
+
19
+from vllm.distributed.weight_transfer.base import ParamMeta, WeightSource
20
+from vllm.distributed.weight_transfer.m2n_common import (
21
+ REPLICATE,
22
+ REPLICATED,
23
+ M2NMesh,
24
+ M2NParamMeta,
25
+ Placements,
26
+)
27
+
28
+__all__ = [
29
+ "M2NWeightSource",
30
+ "DTensorModuleSource",
31
+ "mesh_from_tensor",
32
+ "placements_from_tensor",
33
+]
34
+
35
+
36
+class M2NWeightSource(WeightSource):
37
+ """A `WeightSource` that also describes how the trainer holds its weights.
38
+
39
+ Unlike `ModuleSource`, iteration yields each rank's **local shard**, not a
40
+ materialized full tensor: gathering is exactly the cost m2n removes.
41
+ """
42
+
43
+ def mesh(self) -> M2NMesh:
44
+ """The trainer's rank topology, shared by every parameter."""
45
+ raise NotImplementedError
46
+
47
+ def metadata(self) -> list[ParamMeta]:
48
+ """Name, dtype, full shape, and trainer placement for each parameter."""
49
+ raise NotImplementedError
50
+
51
+ def __iter__(self) -> Iterator[tuple[str, torch.Tensor]]:
52
+ """Yield `(name, local shard)` pairs in the same order as `metadata()`."""
53
+ raise NotImplementedError
54
+
55
+
56
+def _placement_code(placement: Any) -> int:
57
+ """Map a `torch.distributed` placement onto an m2n placement code."""
58
+ name = type(placement).__name__
59
+ if name == "Replicate":
60
+ return REPLICATE
61
+ if name == "Shard":
62
+ return int(placement.dim)
63
+ raise ValueError(
64
+ f"nccl_m2n cannot express the {name} placement; only Replicate and "
65
+ "Shard are supported"
66
+ )
67
+
68
+
69
+def mesh_from_tensor(tensor: torch.Tensor, num_trainer_ranks: int) -> M2NMesh:
70
+ """The mesh a parameter lives on, as an `M2NMesh`.
71
+
72
+ A plain tensor is identical on every trainer rank, so it spans all of them.
73
+ """
74
+ device_mesh = getattr(tensor, "device_mesh", None)
75
+ if device_mesh is None:
76
+ return M2NMesh((num_trainer_ranks, 1), 0)
77
+
78
+ grid = device_mesh.mesh
79
+ ranks = grid.flatten().tolist()
80
+ if ranks != list(range(len(ranks))):
81
+ raise ValueError(
82
+ "nccl_m2n requires the trainer's device mesh to cover the "
83
+ f"contiguous rank interval [0, {len(ranks)}); got {ranks}"
84
+ )
85
+ if grid.ndim > 2:
86
+ raise ValueError(
87
+ f"nccl_m2n supports 1-D and 2-D device meshes, got {grid.ndim}-D"
88
+ )
89
+ dims = tuple(grid.shape)
90
+ return M2NMesh(dims if len(dims) == 2 else (dims[0], 1), 0)
91
+
92
+
93
+def placements_from_tensor(tensor: torch.Tensor) -> Placements | None:
94
+ """How a parameter is placed over its mesh, or `REPLICATED`.
95
+
96
+ `REPLICATED` is returned rather than a `(REPLICATE, REPLICATE)` pair: m2n
97
+ cannot express that, and the size-1-axis encoding it needs instead is
98
+ applied later by `resolve_layout`, which owns that workaround.
99
+ """
100
+ placements = getattr(tensor, "placements", None)
101
+ if placements is None:
102
+ return REPLICATED
103
+ codes = [_placement_code(p) for p in placements]
104
+ if all(code == REPLICATE for code in codes):
105
+ return REPLICATED
106
+ if len(codes) == 1:
107
+ return (codes[0], REPLICATE)
108
+ return (codes[0], codes[1])
109
+
110
+
111
+class DTensorModuleSource(M2NWeightSource):
112
+ """`M2NWeightSource` over `module.named_parameters()`.
113
+
114
+ Covers both the FSDP/DTensor trainer (placement read off each parameter,
115
+ local shard yielded via `to_local()`) and the plain replicated trainer, with
116
+ no special casing. Trainers with a custom producer (a Megatron export, MoE
117
+ re-fusing) subclass `M2NWeightSource` instead.
118
+ """
119
+
120
+ def __init__(self, module: torch.nn.Module, num_trainer_ranks: int) -> None:
121
+ """Wrap `module`; `num_trainer_ranks` sizes the mesh for plain tensors."""
122
+ self._module = module
123
+ self._num_trainer_ranks = num_trainer_ranks
124
+
125
+ def mesh(self) -> M2NMesh:
126
+ """The mesh every sharded parameter agrees on.
127
+
128
+ Replicated parameters span the trainer without a device mesh of their
129
+ own, so they never decide this; a model whose *sharded* parameters
130
+ disagree cannot be described by one side mesh and is rejected.
131
+ """
132
+ meshes = {
133
+ mesh_from_tensor(p, self._num_trainer_ranks)
134
+ for _, p in self._module.named_parameters()
135
+ if placements_from_tensor(p) is not REPLICATED
136
+ }
137
+ if len(meshes) > 1:
138
+ raise ValueError(
139
+ "nccl_m2n needs one mesh per side, but this module's sharded "
140
+ f"parameters span several: {sorted(m.dims for m in meshes)}"
141
+ )
142
+ return meshes.pop() if meshes else M2NMesh((self._num_trainer_ranks, 1), 0)
143
+
144
+ def metadata(self) -> list[ParamMeta]:
145
+ """Read global shape/dtype and placements without gathering shards."""
146
+ return [
147
+ M2NParamMeta(name, p.dtype, tuple(p.shape), placements_from_tensor(p))
148
+ for name, p in self._module.named_parameters()
149
+ ]
150
+
151
+ def __iter__(self) -> Iterator[tuple[str, torch.Tensor]]:
152
+ """Yield each parameter's local shard (`to_local()`), or the tensor itself."""
153
+ for name, param in self._module.named_parameters():
154
+ to_local = getattr(param, "to_local", None)
155
+ yield name, (to_local() if callable(to_local) else param)
vllm/distributed/weight_transfer/m2n_trainer.py
new
+414
@@ -0,0 +1,414 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Trainer-side weight transfer engine for the NCCL M2N backend.
4
+
5
+Symmetric to `M2NWeightTransferEngine` but in the training process. Every
6
+trainer rank joins the shared communicator and runs every reshard, sending its
7
+own local shard; only rank 0 touches the inference control plane.
8
+
9
+Workers join the communicator during init and run reshard from inside
10
+`update_weights`, so those two RPCs overlap with work on this side. Both run on
11
+a helper thread and are joined afterwards, which keeps that overlap inside the
12
+engine -- where the `TrainerWeightTransferEngine` contract puts it -- rather than
13
+in every caller.
14
+"""
15
+
16
+from concurrent.futures import Future, ThreadPoolExecutor
17
+from dataclasses import dataclass, field
18
+from typing import TYPE_CHECKING, Any, ClassVar, cast
19
+
20
+import torch
21
+from typing_extensions import Self
22
+
23
+from vllm.distributed.weight_transfer.base import (
24
+ TrainerInitInfo,
25
+ TrainerWeightTransferEngine,
26
+ VLLMWeightSyncClient,
27
+ WeightSource,
28
+)
29
+from vllm.distributed.weight_transfer.m2n_common import (
30
+ M2NMesh,
31
+ M2NParamMeta,
32
+ Placements,
33
+ check_transferable,
34
+ comm_ptr,
35
+ import_m2n,
36
+ publish_destination_placements,
37
+ resolve_layout,
38
+ to_mesh,
39
+ to_placements,
40
+ validate_layout,
41
+)
42
+from vllm.distributed.weight_transfer.m2n_source import M2NWeightSource
43
+from vllm.distributed.weight_transfer.nccl_common import (
44
+ decode_nccl_unique_id,
45
+ trainer_init,
46
+ uid_init_process_group,
47
+)
48
+from vllm.logger import init_logger
49
+
50
+if TYPE_CHECKING:
51
+ from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
52
+
53
+logger = init_logger(__name__)
54
+
55
+__all__ = ["M2NTrainerInitInfo", "M2NTrainerWeightTransferEngine"]
56
+
57
+
58
+def _dtype_name(dtype: torch.dtype) -> str:
59
+ """Short name for a dtype, e.g. `bfloat16`, for the worker-side init dict."""
60
+ return str(dtype).split(".")[-1]
61
+
62
+
63
+@dataclass(kw_only=True)
64
+class M2NTrainerInitInfo(TrainerInitInfo):
65
+ """Trainer-side init info for nccl_m2n.
66
+
67
+ `rank` (from `TrainerInitInfo`) is this trainer process's rank; it is also
68
+ its rank in the shared communicator, since the trainer occupies
69
+ `[0, num_trainer_ranks)`. Rank 0 drives the control plane.
70
+ """
71
+
72
+ backend: ClassVar[str] = "nccl_m2n"
73
+
74
+ master_address: str | None = None
75
+ master_port: int | None = None
76
+ world_size: int
77
+ """Trainer ranks + all inference workers."""
78
+ num_trainer_ranks: int = 1
79
+ dst_mesh_dims: tuple[int, int] | None = None
80
+ """How the inference ranks are laid out: axis 0 replicates and axis 1
81
+ shards. The trainer declares it so both sides describe the destination
82
+ identically; defaults to a flat `(num_workers, 1)`, which is all a
83
+ replicated destination needs."""
84
+ max_cta: int | None = None
85
+ nccl_unique_id_b64: str | None = field(default=None, repr=False)
86
+
87
+ @property
88
+ def destination_mesh_dims(self) -> tuple[int, int]:
89
+ """`dst_mesh_dims`, or a flat mesh over every inference worker."""
90
+ if self.dst_mesh_dims is not None:
91
+ return self.dst_mesh_dims
92
+ return (self.world_size - self.num_trainer_ranks, 1)
93
+
94
+ def __post_init__(self) -> None:
95
+ """Reject rank counts or destination meshes that cannot form a group."""
96
+ _ = self.nccl_unique_id_bytes
97
+ if not 0 < self.num_trainer_ranks < self.world_size:
98
+ raise ValueError(
99
+ f"`num_trainer_ranks` ({self.num_trainer_ranks}) must leave at "
100
+ f"least one worker in world_size {self.world_size}"
101
+ )
102
+ num_workers = self.world_size - self.num_trainer_ranks
103
+ if self.dst_mesh_dims is not None:
104
+ dims = tuple(self.dst_mesh_dims)
105
+ if len(dims) != 2 or dims[0] * dims[1] != num_workers:
106
+ raise ValueError(
107
+ f"`dst_mesh_dims` {dims} must be two dims covering the "
108
+ f"{num_workers} inference workers"
109
+ )
110
+ if not 0 <= self.rank < self.num_trainer_ranks:
111
+ raise ValueError(
112
+ f"trainer `rank` ({self.rank}) must be below "
113
+ f"`num_trainer_ranks` ({self.num_trainer_ranks})"
114
+ )
115
+
116
+ @property
117
+ def nccl_unique_id_bytes(self) -> bytes | None:
118
+ return decode_nccl_unique_id(
119
+ master_address=self.master_address,
120
+ master_port=self.master_port,
121
+ nccl_unique_id_b64=self.nccl_unique_id_b64,
122
+ ctx="M2NTrainerInitInfo",
123
+ )
124
+
125
+
126
+class M2NTrainerWeightTransferEngine(TrainerWeightTransferEngine[M2NTrainerInitInfo]):
127
+ """Trainer-side engine: sends every rank's local shard, once per parameter.
128
+
129
+ Called on every trainer rank. All ranks join the communicator and run every
130
+ reshard; only rank 0 touches the control plane.
131
+ """
132
+
133
+ init_info_cls = M2NTrainerInitInfo
134
+
135
+ def __init__(
136
+ self,
137
+ *,
138
+ client: VLLMWeightSyncClient,
139
+ source: M2NWeightSource,
140
+ is_controller: bool,
141
+ num_trainer_ranks: int,
142
+ ) -> None:
143
+ """Hold the client and source; `trainer_init` fills in the group."""
144
+ super().__init__(client=client, source=source, is_sender=is_controller)
145
+ self.source: M2NWeightSource = source
146
+ self.is_controller = is_controller
147
+ self.num_trainer_ranks = num_trainer_ranks
148
+ self.group: PyNcclCommunicator | None = None
149
+ self._m2n: Any = None
150
+ self._handle: Any = None
151
+ self._metas: list[M2NParamMeta] = []
152
+ self._src_mesh: M2NMesh | None = None
153
+ self._dst_mesh: M2NMesh | None = None
154
+ self._dst_placements: list[Placements | None] = []
155
+ self._executor: ThreadPoolExecutor | None = None
156
+
157
+ @classmethod
158
+ def trainer_init(
159
+ cls,
160
+ init_info: M2NTrainerInitInfo,
161
+ *,
162
+ client: VLLMWeightSyncClient,
163
+ source: WeightSource | None = None,
164
+ ) -> Self:
165
+ """Build the engine and rendezvous with the inference side.
166
+
167
+ Runs on *every* trainer rank. Rank 0 additionally ships the transfer
168
+ plan to the workers and drives the control plane; the other ranks only
169
+ build local state and join the communicator. Every rank participates
170
+ in every reshard and sends its local shard.
171
+
172
+ The ordering here is not arbitrary -- see the comments inline. In
173
+ short: validate before anything blocks; start the init RPC without
174
+ waiting (workers join the communicator inside that RPC, so it cannot
175
+ return until this side has joined too); then join the NCCL
176
+ communicator; then create the M2N handle; and only then wait for the RPC
177
+ to finish.
178
+ """
179
+ # The base WeightSource carries name/dtype/shape but not layout, which
180
+ # is not enough to plan a reshard. Fail here rather than at the first
181
+ # missing attribute deep inside the send loop.
182
+ if not isinstance(source, M2NWeightSource):
183
+ raise TypeError(
184
+ "nccl_m2n needs per-parameter layouts, so its source must be an "
185
+ f"M2NWeightSource (e.g. DTensorModuleSource); got "
186
+ f"{type(source).__name__}"
187
+ )
188
+
189
+ # Trainer-side weight transfer engine for this rank. `__init__` only
190
+ # holds the client and source; the communicator, meshes, and M2N handle
191
+ # are filled in below.
192
+ engine = cls(
193
+ client=client,
194
+ source=source,
195
+ is_controller=init_info.rank == 0,
196
+ num_trainer_ranks=init_info.num_trainer_ranks,
197
+ )
198
+ engine._m2n = import_m2n()
199
+
200
+ engine._src_mesh = source.mesh()
201
+ if engine._src_mesh.size != init_info.num_trainer_ranks:
202
+ raise ValueError(
203
+ f"source mesh covers {engine._src_mesh.size} ranks, but there "
204
+ f"are {init_info.num_trainer_ranks} trainer ranks"
205
+ )
206
+
207
+ # Validate the whole plan before any rendezvous. A layout m2n cannot
208
+ # express is a mismatched collective at send time, which hangs rather
209
+ # than raises -- so every precondition is checked while an exception can
210
+ # still propagate to the caller.
211
+ engine._metas = cast(list[M2NParamMeta], source.metadata())
212
+ for meta in engine._metas:
213
+ check_transferable(meta.name, meta.dtype, meta.shape)
214
+ mesh, placements = resolve_layout(engine._src_mesh, meta.placements)
215
+ validate_layout(mesh, placements, meta.shape, "source")
216
+
217
+ rendezvous: Future | None = None
218
+ if engine.is_controller:
219
+ engine._executor = ThreadPoolExecutor(
220
+ max_workers=1, thread_name_prefix="m2n-weight-sync"
221
+ )
222
+ # Only trainer rank 0 starts the control-plane RPC. Workers join
223
+ # the communicator inside it, so submit it first, join below, and
224
+ # wait for it at the end.
225
+ rendezvous = engine._executor.submit(
226
+ client.init_weight_transfer_engine,
227
+ engine._worker_init_info(init_info),
228
+ )
229
+
230
+ # Every trainer rank joins -- reshard is collective over the whole
231
+ # communicator. Trainer ranks take
232
+ # [0, num_trainer_ranks) and the workers follow, which is the
233
+ # contiguous-interval layout m2n requires of both meshes.
234
+ unique_id_bytes = init_info.nccl_unique_id_bytes
235
+ if unique_id_bytes is not None:
236
+ engine.group = uid_init_process_group(
237
+ unique_id_bytes,
238
+ rank=init_info.rank,
239
+ world_size=init_info.world_size,
240
+ device=torch.accelerator.current_device_index(),
241
+ )
242
+ else:
243
+ assert init_info.master_address is not None
244
+ assert init_info.master_port is not None
245
+ engine.group = trainer_init(
246
+ {
247
+ "master_address": init_info.master_address,
248
+ "master_port": init_info.master_port,
249
+ "world_size": init_info.world_size,
250
+ },
251
+ rank=init_info.rank,
252
+ )
253
+
254
+ # The trainer declares the inference topology; the worker uses exactly
255
+ # this, so neither side has to infer the other's factorization.
256
+ engine._dst_mesh = M2NMesh(
257
+ init_info.destination_mesh_dims, init_info.num_trainer_ranks
258
+ )
259
+ engine._dst_placements = engine._receive_destination_plan(
260
+ init_info.num_trainer_ranks
261
+ )
262
+ logger.info(
263
+ "nccl_m2n trainer ready: %d parameters, trainer ranks [0, %d), "
264
+ "inference ranks [%d, %d)",
265
+ len(engine._metas),
266
+ init_info.num_trainer_ranks,
267
+ init_info.num_trainer_ranks,
268
+ init_info.world_size,
269
+ )
270
+
271
+ # Created after the group so a failed rendezvous does not leave a live
272
+ # handle behind; shutdown() releases it once caller streams are synced.
273
+ engine._handle = engine._m2n.Handle.create(
274
+ engine._m2n.Config(max_cta=init_info.max_cta)
275
+ )
276
+
277
+ # Now safe to collect: both sides have joined, so the RPC has returned
278
+ # or raised. Doing this last means a worker-side init failure surfaces
279
+ # here, as an exception from trainer_init, rather than at the first send.
280
+ if rendezvous is not None:
281
+ rendezvous.result()
282
+ return engine
283
+
284
+ def _receive_destination_plan(
285
+ self, first_worker_rank: int
286
+ ) -> list[Placements | None]:
287
+ """Receive and validate the inference-side per-parameter placements."""
288
+ if self.group is None or self._dst_mesh is None:
289
+ raise RuntimeError(
290
+ "destination plan requires the communicator and destination mesh"
291
+ )
292
+
293
+ # The first inference worker publishes one placement per parameter.
294
+ # Every trainer rank receives the complete plan in this single,
295
+ # initialization-time broadcast.
296
+ placements = publish_destination_placements(
297
+ self.group, first_worker_rank, None, len(self._metas)
298
+ )
299
+ # The send loop matches placements to metadata by position, so reject
300
+ # an incomplete or oversized plan before entering any M2N collective.
301
+ if len(placements) != len(self._metas):
302
+ raise RuntimeError(
303
+ f"inference side planned {len(placements)} parameters, but "
304
+ f"this trainer announced {len(self._metas)}"
305
+ )
306
+ # Check every worker-selected destination against the corresponding
307
+ # global parameter shape before the first transfer.
308
+ for meta, placement in zip(self._metas, placements):
309
+ mesh, resolved = resolve_layout(self._dst_mesh, placement)
310
+ validate_layout(mesh, resolved, meta.shape, "destination")
311
+ return placements
312
+
313
+ def _worker_init_info(self, init_info: M2NTrainerInitInfo) -> dict[str, Any]:
314
+ """Handshake payload: rendezvous, both meshes, and the transfer plan."""
315
+ assert self._src_mesh is not None
316
+ payload: dict[str, Any] = {
317
+ "master_address": init_info.master_address,
318
+ "master_port": init_info.master_port,
319
+ "nccl_unique_id_b64": init_info.nccl_unique_id_b64,
320
+ "rank_offset": init_info.num_trainer_ranks,
321
+ "world_size": init_info.world_size,
322
+ "src_mesh_dims": list(self._src_mesh.dims),
323
+ "dst_mesh_dims": list(init_info.destination_mesh_dims),
324
+ "names": [m.name for m in self._metas],
325
+ "dtype_names": [_dtype_name(m.dtype) for m in self._metas],
326
+ "shapes": [list(m.shape) for m in self._metas],
327
+ "src_placements": [
328
+ None if m.placements is None else list(m.placements)
329
+ for m in self._metas
330
+ ],
331
+ "max_cta": init_info.max_cta,
332
+ }
333
+ return {key: value for key, value in payload.items() if value is not None}
334
+
335
+ def send_weights(self) -> None:
336
+ """Drive one update round: start, reshard concurrently, then finish."""
337
+ if self._handle is None or self.group is None:
338
+ raise RuntimeError("nccl_m2n trainer engine is not initialized")
339
+
340
+ update: Future | None = None
341
+ if self.is_controller:
342
+ self.client.start_weight_update()
343
+ # The workers reshard from inside `update_weights`, concurrently
344
+ # with the sends below.
345
+ assert self._executor is not None
346
+ update = self._executor.submit(
347
+ self.client.update_weights,
348
+ {"names": [m.name for m in self._metas]},
349
+ )
350
+
351
+ try:
352
+ self._send()
353
+ finally:
354
+ if update is not None:
355
+ update.result()
356
+
357
+ if self.is_controller:
358
+ self.client.finish_weight_update()
359
+
360
+ def _send(self) -> None:
361
+ """Reshard each local shard into its worker-planned destination."""
362
+ assert self.group is not None
363
+ assert self._src_mesh is not None
364
+ assert self._dst_mesh is not None
365
+ m2n = self._m2n
366
+ comm = comm_ptr(self.group)
367
+ stream = torch.cuda.current_stream()
368
+
369
+ for index, (name, tensor) in enumerate(self.source):
370
+ meta = self._metas[index]
371
+ if name != meta.name:
372
+ raise RuntimeError(
373
+ f"weight source yielded '{name}' at position {index}, but "
374
+ f"its metadata declared '{meta.name}'; the source must "
375
+ "iterate in a stable order"
376
+ )
377
+ shard = tensor.detach().contiguous()
378
+ src_mesh, src_placements = resolve_layout(self._src_mesh, meta.placements)
379
+ dst_mesh, dst_placements = resolve_layout(
380
+ self._dst_mesh, self._dst_placements[index]
381
+ )
382
+ m2n.reshard(
383
+ shard,
384
+ None,
385
+ comm,
386
+ stream,
387
+ src_mesh=to_mesh(m2n, src_mesh),
388
+ src_placements=to_placements(m2n, src_placements),
389
+ dst_mesh=to_mesh(m2n, dst_mesh),
390
+ dst_placements=to_placements(m2n, dst_placements),
391
+ handle=self._handle,
392
+ )
393
+ # A custom source may yield temporary storage allocated on another
394
+ # stream. M2N retains only its device pointer for asynchronous work,
395
+ # so tell the allocator not to reuse that storage until this stream
396
+ # has finished with it. For storage allocated on this stream,
397
+ # record_stream is effectively a no-op.
398
+ shard.record_stream(stream)
399
+
400
+ # As in the other weight-transfer backends, wait before returning
401
+ # rather than relying on same-stream ordering. The caller may then
402
+ # safely mutate parameters or continue work on another stream.
403
+ stream.synchronize()
404
+
405
+ def shutdown(self) -> None:
406
+ """Destroy the m2n handle, join the helper thread, and drop the group."""
407
+ if self._handle is not None:
408
+ torch.accelerator.synchronize()
409
+ self._handle.destroy()
410
+ self._handle = None
411
+ if self._executor is not None:
412
+ self._executor.shutdown()
413
+ self._executor = None
414
+ self.group = None
vllm/distributed/weight_transfer/nccl_common.py
+6
-4
@@ -220,11 +220,11 @@ def worker_init_process_group(
220
221
def trainer_init(
222
init_info: NCCLRendezvous | dict,
223
+ rank: int = 0,
224
) -> "PyNcclCommunicator":
225
"""Initialize NCCL process group for trainer-side weight transfer.
226
226
- The trainer is always rank 0 in the process group. Uses the current
227
- CUDA device (torch.accelerator.current_device_index()).
227
+ Uses the current CUDA device (torch.accelerator.current_device_index()).
228
229
Args:
230
init_info: Any object carrying the `NCCLRendezvous` fields (a trainer or
@@ -232,6 +232,9 @@ def trainer_init(
232
- master_address: str
233
- master_port: int
234
- world_size: int
235
+ rank: This trainer process's rank in the group. The broadcast backends
236
+ have a single trainer at rank 0; multi-rank trainers (m2n) occupy
237
+ ranks `[0, num_trainer_ranks)` and the workers start after them.
238
239
Returns:
240
PyNcclCommunicator for weight transfer.
@@ -246,12 +249,11 @@ def trainer_init(
249
master_port = init_info.master_port
250
world_size = init_info.world_size
251
249
- # Trainer is always rank 0
252
device = torch.accelerator.current_device_index()
253
return stateless_init_process_group(
254
master_address,
255
master_port,
254
- 0,
256
+ rank,
257
world_size,
258
device,
259
)