[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>
This commit is contained in:
Ke Wen
2026-09-26 01:38:53 +00:00
committed by GitHub
co-authored by mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> aoshen02
parent 4bb804cc5b
commit b6d2b4fc25
13 changed files with 2812 additions and 6 deletions
+1
View File
@@ -373,6 +373,7 @@ steps:
- 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
- pytest -v -s tests/v1/distributed/test_dbo.py
- VLLM_ALLOW_INSECURE_SERIALIZATION=1 pytest -v -s tests/distributed/test_weight_transfer.py
- VLLM_ALLOW_INSECURE_SERIALIZATION=1 pytest -v -s tests/distributed/test_weight_transfer_m2n.py
- pytest -v -s tests/distributed/test_packed_tensor.py
mirror:
amd:
+3 -1
View File
@@ -35,6 +35,7 @@ engine drives on your behalf:
| [IPC](ipc.md) | CUDA IPC handles | Colocated training and inference on same GPU |
| [sparse_nccl](nccl.md#sparse-nccl) | NCCL broadcast | Checkpoint-coordinate sparse weight patches |
| [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) |
| [nccl_m2n](m2n.md) | NCCL M2N reshard | Trainer and inference use different sharding layouts |
## Quickstart
@@ -49,7 +50,8 @@ from vllm.config import WeightTransferConfig
llm = LLM(
model="my-model",
weight_transfer_config=WeightTransferConfig(backend="nccl"), # or "ipc", "sparse_nccl", "sharded_rdt"
# Other backends: "ipc", "sparse_nccl", "sharded_rdt", "nccl_m2n".
weight_transfer_config=WeightTransferConfig(backend="nccl"),
)
```
+101
View File
@@ -0,0 +1,101 @@
# NCCL M2N Engine
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.
## When to Use NCCL M2N
- 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
- Training and inference are on **separate GPUs**, possibly across nodes
- You want weight sync off the critical path of an async RL loop
## Requirements
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:
```bash
export VLLM_NCCL_SO_PATH=/path/to/the/same/libnccl.so
```
The backend imports the runtime lazily, so vLLM is unaffected unless you select it.
## How It Works
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.
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.
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.
### Destination resolution
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.
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:
- fused parameters (`qkv_proj`, `gate_up_proj`, MoE `w13`/`w2`), whose checkpoint names do not name a model parameter
- quantized models, where the weight loader does more than a sharded copy
- pipeline-parallel deployments
Correctness never depends on a parameter resolving. The engine logs how many parameters took each path at init.
## Configuration
```python
from vllm import LLM
from vllm.config import WeightTransferConfig
llm = LLM(
model="my-model",
weight_transfer_config=WeightTransferConfig(backend="nccl_m2n"),
)
```
```bash
vllm serve my-model --weight-transfer-config '{"backend": "nccl_m2n"}'
```
## Trainer Side
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()`.
```python
from vllm.distributed.weight_transfer import (
RayVLLMWeightSyncClient,
WeightTransferTrainerFactory,
)
from vllm.distributed.weight_transfer.m2n_source import DTensorModuleSource
from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
# Called on every trainer rank; rank 0 is the sender.
engine = WeightTransferTrainerFactory.trainer_init(
init_info=M2NTrainerInitInfo(
master_address=master_address,
master_port=master_port,
world_size=num_trainer_ranks + num_inference_workers,
num_trainer_ranks=num_trainer_ranks,
rank=my_rank,
),
client=RayVLLMWeightSyncClient(llm_handle),
source=DTensorModuleSource(model, num_trainer_ranks),
)
# Each round, on every trainer rank:
engine.send_weights()
```
`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.
Trainers with a custom producer (a Megatron export, MoE re-fusing) subclass `M2NWeightSource` and supply layouts explicitly.
See [`examples/rl/rlhf_m2n.py`](../../../examples/rl/rlhf_m2n.py) for a runnable FSDP → TP example.
## Limitations
- Tensor rank 1..3; 4-D parameters are rejected.
- dtypes: int8/uint8, fp8 e4m3/e5m2, fp16, bf16, int32/uint32, fp32, int64/uint64, fp64. No fp4.
- The trainer's device mesh must cover a contiguous rank interval starting at 0, and be 1-D or 2-D.
- `Partial` placements are not supported.
- Models with known model-level checkpoint preprocessing use the full-tensor
fallback for the affected module.
- A reshard cannot be issued from inside a CUDA graph capture (weight updates run outside capture, so this only matters for custom callers).
- 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).
- 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.
- Each communicator holds a cached staging pool (~256 MiB by default: 4 channels x 64 MiB).
+197
View File
@@ -0,0 +1,197 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""RLHF with FSDP2 training and vLLM tensor-parallel inference using **NCCL M2N**
sharding-aware weight transfer.
Layout (4 GPUs, no colocation):
* GPUs 0-1: two FSDP2 training workers, one per GPU.
* GPUs 2-3: one vLLM ``LLM`` actor with ``tensor_parallel_size=2``.
The trainer and the inference workers share one NCCL communicator of 4 ranks:
trainer ranks ``[0, 2)`` and inference ranks ``[2, 4)``. Every parameter moves
with a single ``reshard`` that redistributes it from the FSDP layout to the
inference layout — the trainer sends its local shards and never all-gathers a
full tensor, which broadcast-based weight sync would force it to do.
Every FSDP rank builds an ``M2NTrainerWeightTransferEngine`` and calls
``send_weights()``; all ranks run every reshard, and only rank 0 drives the
inference side through its ``RayVLLMWeightSyncClient``.
Requires the ``nccl-extensions`` package (NCCL 2.30.5+) and a
``VLLM_NCCL_SO_PATH`` pointing at the same ``libnccl.so`` that
``libnccl_m2n.so`` was linked against.
This example was written for 4xH100.
"""
from __future__ import annotations
import os
import pybase64 as base64
import ray
import torch
import torch.distributed as dist
from huggingface_hub import snapshot_download
from torch.distributed.fsdp import fully_shard
from transformers import AutoModelForCausalLM
from vllm import LLM, SamplingParams
from vllm.config import WeightTransferConfig
from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
from vllm.distributed.weight_transfer import (
RayVLLMWeightSyncClient,
WeightTransferTrainerFactory,
)
from vllm.distributed.weight_transfer.m2n_source import DTensorModuleSource
from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
from vllm.utils.network_utils import get_ip, get_open_port
MODEL_NAME = "Qwen/Qwen3-0.6B"
FSDP_WORLD_SIZE = 2
INFERENCE_TP_SIZE = 2
class MyLLM(LLM):
"""LLM subclass that keeps Ray from pinning it to a single device."""
def __init__(self, *args, **kwargs):
os.environ.pop("CUDA_VISIBLE_DEVICES", None)
super().__init__(*args, **kwargs)
def ready(self):
return True
@ray.remote(num_cpus=1, num_gpus=1)
class FSDPTrainWorker:
"""One FSDP2 worker per GPU. Rank 0 is the weight-transfer sender."""
def __init__(
self,
model_name: str,
rank: int,
fsdp_master_addr: str,
fsdp_master_port: int,
):
self.rank = rank
os.environ["MASTER_ADDR"] = fsdp_master_addr
os.environ["MASTER_PORT"] = str(fsdp_master_port)
dist.init_process_group(backend="nccl", rank=rank, world_size=FSDP_WORLD_SIZE)
torch.accelerator.set_device_index(0)
model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype=torch.bfloat16
).cuda()
for layer in model.model.layers:
fully_shard(layer)
fully_shard(model)
self.model = model
def ready(self):
return True
def setup_engine(self, llm_handle, nccl_unique_id_b64, world_size, num_workers):
"""Build the trainer engine on every FSDP rank.
`DTensorModuleSource` reads each parameter's FSDP device mesh and
placements, so the engine knows the source layout without gathering
anything. Rank 0 additionally drives the inference-side handshake.
"""
self.engine = WeightTransferTrainerFactory.trainer_init(
init_info=M2NTrainerInitInfo(
nccl_unique_id_b64=nccl_unique_id_b64,
world_size=world_size,
num_trainer_ranks=FSDP_WORLD_SIZE,
# One DP group of TP=2 workers; declared so both sides
# describe the destination the same way.
dst_mesh_dims=(num_workers // INFERENCE_TP_SIZE, INFERENCE_TP_SIZE),
rank=self.rank, # FSDP rank; sender is 0
),
client=RayVLLMWeightSyncClient(llm_handle),
source=DTensorModuleSource(self.model, FSDP_WORLD_SIZE),
)
def send_weights(self):
"""Called on all ranks concurrently; every rank runs every reshard."""
self.engine.send_weights()
def main():
ray.init()
local_model_path = snapshot_download(MODEL_NAME)
print(f"[init] Model downloaded to {local_model_path}")
fsdp_master_addr = get_ip()
fsdp_master_port = get_open_port()
fsdp_workers = [
FSDPTrainWorker.remote(
local_model_path, rank, fsdp_master_addr, fsdp_master_port
)
for rank in range(FSDP_WORLD_SIZE)
]
ray.get([w.ready.remote() for w in fsdp_workers])
print(f"[init] {FSDP_WORLD_SIZE} FSDP workers ready.")
llm = ray.remote(num_cpus=0, num_gpus=0)(MyLLM).remote(
model=local_model_path,
enforce_eager=True,
tensor_parallel_size=INFERENCE_TP_SIZE,
distributed_executor_backend="ray",
weight_transfer_config=WeightTransferConfig(backend="nccl_m2n"),
load_format="dummy",
)
ray.get(llm.ready.remote())
num_workers = ray.get(llm.get_world_size.remote())
print(f"[init] vLLM ready with {num_workers} inference workers.")
prompts = [
"Hello, my name is",
"The capital of France is",
]
sampling_params = SamplingParams(temperature=0)
outputs = ray.get(llm.generate.remote(prompts, sampling_params))
print("-" * 60)
print("BEFORE weight sync (dummy weights):")
for output in outputs:
print(f"Prompt: {output.prompt!r}")
print(f"Generated: {output.outputs[0].text!r}")
print("-" * 60)
# Trainer ranks [0, FSDP_WORLD_SIZE) and inference ranks after them share
# one communicator, so the two meshes are contiguous rank intervals.
nccl = NCCLLibrary()
nccl_unique_id_b64 = base64.b64encode(
bytes(nccl.ncclGetUniqueId().internal)
).decode()
world_size = FSDP_WORLD_SIZE + num_workers
print("[transfer] Initializing nccl_m2n weight transfer (all FSDP ranks)...")
ray.get(
[
w.setup_engine.remote(llm, nccl_unique_id_b64, world_size, num_workers)
for w in fsdp_workers
]
)
print("[sync] Resharding FSDP -> vLLM...")
ray.get([w.send_weights.remote() for w in fsdp_workers])
print("[sync] Weight transfer complete.")
outputs_updated = ray.get(llm.generate.remote(prompts, sampling_params))
print("-" * 60)
print("AFTER weight sync (real weights):")
for output in outputs_updated:
print(f"Prompt: {output.prompt!r}")
print(f"Generated: {output.outputs[0].text!r}")
print("-" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,868 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the NCCL M2N weight transfer backend.
These cover how a transfer is *described* — layout encoding and the wire-type
validation that keeps a bad plan from reaching a collective, since a mismatch
there is a hang rather than an error. The transfer itself needs the m2n runtime
and multiple GPUs, so it is exercised separately.
"""
import logging
from functools import wraps
from types import MethodType
from unittest.mock import Mock
import pybase64 as base64
import pytest
import ray
import torch
from vllm.distributed.weight_transfer import (
WeightTransferEngineFactory,
WeightTransferTrainerFactory,
)
from vllm.distributed.weight_transfer.m2n_common import (
REPLICATE,
REPLICATED,
M2NMesh,
M2NParamMeta,
check_placements,
check_transferable,
publish_destination_placements,
resolve_layout,
validate_layout,
)
from vllm.distributed.weight_transfer.m2n_engine import (
M2NWeightTransferEngine,
M2NWeightTransferInitInfo,
M2NWeightTransferUpdateInfo,
)
from vllm.distributed.weight_transfer.m2n_layout import (
M2NDestination,
resolve_parameter_destinations,
)
from vllm.distributed.weight_transfer.m2n_source import (
mesh_from_tensor,
placements_from_tensor,
)
from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
from vllm.model_executor.layers.linear import (
ColumnParallelLinear,
RowParallelLinear,
)
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm.platforms import current_platform
VALID_UID_B64 = base64.b64encode(b"\x00" * 128).decode()
class TestLayout:
def test_replicated_splits_nothing(self):
"""A replicated tensor imposes no divisibility constraint. The shape
dims are deliberately coprime with the mesh size: a layout that actually
split the tensor would reject them."""
mesh = M2NMesh((2, 2), start_rank=1)
indivisible_shape = (7, 13) # neither dim divisible by any mesh axis
resolved_mesh, placements = resolve_layout(mesh, REPLICATED)
validate_layout(resolved_mesh, placements, indivisible_shape, "destination")
def test_replicated_keeps_the_same_ranks(self):
"""Replication re-factors the mesh to get a size-1 axis for its no-op
shard. That is only sound if it still covers exactly the same GPUs."""
mesh = M2NMesh((2, 3), start_rank=4)
resolved_mesh, _ = resolve_layout(mesh, REPLICATED)
assert resolved_mesh.size == mesh.size
assert resolved_mesh.start_rank == mesh.start_rank
def test_sharded_keeps_its_own_factorization(self):
"""Rank order decides who owns which shard, so a sharded tensor must
not be re-factored the way a replicated one is."""
mesh = M2NMesh((2, 3), start_rank=4)
resolved_mesh, placements = resolve_layout(mesh, (REPLICATE, 0))
assert resolved_mesh == mesh
assert placements == (REPLICATE, 0)
def test_two_shard_axes_rejected(self):
"""One axis has to replicate; a 2-D mesh that shards both is not
something a single reshard can express."""
with pytest.raises(ValueError, match="shards both"):
check_placements((0, 1))
def test_placement_below_replicate_rejected(self):
with pytest.raises(ValueError, match=r"parameter 'w'.*-2"):
check_placements((-2, REPLICATE), "parameter 'w' source placements")
def test_shard_dim_must_exist(self):
with pytest.raises(ValueError, match="rank 2"):
validate_layout(M2NMesh((1, 2), 0), (REPLICATE, 2), (8, 16), "source")
def test_shard_must_divide_evenly(self):
with pytest.raises(ValueError, match="does not divide evenly"):
validate_layout(M2NMesh((1, 3), 0), (REPLICATE, 0), (8, 16), "source")
class TestSourceLayout:
def test_plain_tensor_is_replicated_across_trainer_ranks(self):
"""A tensor with no DTensor metadata is the same on every trainer rank,
so the source describes it as replicated over all of them."""
assert placements_from_tensor(torch.zeros(4)) is REPLICATED
assert mesh_from_tensor(torch.zeros(4), 4) == M2NMesh((4, 1), 0)
class TestTransferable:
def test_unsupported_dtype_names_the_parameter(self):
with pytest.raises(ValueError, match="'w'"):
check_transferable("w", torch.complex64, (4,))
def test_rank_four_rejected(self):
with pytest.raises(ValueError, match="rank 4"):
check_transferable("w", torch.bfloat16, (2, 2, 2, 2))
class TestWireTypes:
def _init_info(self, **overrides):
fields = dict(
master_address="127.0.0.1",
master_port=1234,
rank_offset=1,
world_size=3,
src_mesh_dims=[1, 1],
dst_mesh_dims=[2, 1],
names=["w"],
dtype_names=["bfloat16"],
shapes=[[16, 16]],
src_placements=[None],
)
fields.update(overrides)
return M2NWeightTransferInitInfo(**fields)
def test_accepts_a_consistent_plan(self):
assert self._init_info().names == ["w"]
def test_accepts_pre_shared_nccl_unique_id(self):
info = self._init_info(
master_address=None,
master_port=None,
nccl_unique_id_b64=VALID_UID_B64,
)
assert info.nccl_unique_id_bytes == b"\x00" * 128
assert VALID_UID_B64 not in repr(info)
def test_destination_plan_uses_uid_communicator_without_group(self):
comm = Mock(rank=0, device=torch.device("cpu"), group=None)
def receive_plan(tensor, src):
assert src == 1
tensor.copy_(torch.tensor([[-1, 0], [-2, -2]], dtype=torch.int8))
comm.broadcast.side_effect = receive_plan
placements = publish_destination_placements(comm, 1, None, 2)
assert placements == [(REPLICATE, 0), REPLICATED]
def test_rejects_both_rendezvous_modes(self):
with pytest.raises(ValueError, match="not both"):
self._init_info(nccl_unique_id_b64=VALID_UID_B64)
def test_trainer_accepts_pre_shared_nccl_unique_id(self):
info = M2NTrainerInitInfo(
nccl_unique_id_b64=VALID_UID_B64,
world_size=3,
num_trainer_ranks=1,
rank=0,
)
assert info.nccl_unique_id_bytes == b"\x00" * 128
assert VALID_UID_B64 not in repr(info)
def test_ragged_plan_rejected(self):
with pytest.raises(ValueError, match="`shapes`"):
self._init_info(shapes=[])
def test_destination_mesh_must_cover_the_workers(self):
"""The trainer declares the inference mesh, so one that does not cover
the workers is a config error — and it has to fail the init RPC, since
a mismatched mesh would otherwise surface as a hung collective."""
with pytest.raises(ValueError, match="dst_mesh_dims"):
self._init_info(dst_mesh_dims=[3, 1]) # 3 != the 2 workers
def test_world_must_hold_a_trainer_and_a_worker(self):
with pytest.raises(ValueError, match="rank_offset"):
self._init_info(rank_offset=3, world_size=3)
@pytest.mark.parametrize("dtype_name", ["not_a_dtype", "Tensor"])
def test_invalid_dtype_name_names_parameter(self, monkeypatch, dtype_name):
monkeypatch.setattr(
"vllm.distributed.weight_transfer.m2n_engine.import_m2n",
lambda: object(),
)
engine = object.__new__(M2NWeightTransferEngine)
with pytest.raises(ValueError, match=r"parameter 'w'.*dtype"):
engine.init_transfer_engine(self._init_info(dtype_names=[dtype_name]))
def _init_32_to_4_plan(self, monkeypatch, destination):
m2n = Mock()
m2n.Handle.create.return_value = object()
monkeypatch.setattr(
"vllm.distributed.weight_transfer.m2n_engine.import_m2n", lambda: m2n
)
monkeypatch.setattr(
"vllm.distributed.weight_transfer.m2n_engine."
"resolve_parameter_destinations",
lambda *args, **kwargs: [destination],
)
monkeypatch.setattr(
"vllm.distributed.weight_transfer.m2n_engine.worker_init_process_group",
lambda *args, **kwargs: object(),
)
monkeypatch.setattr(
"vllm.distributed.weight_transfer.m2n_engine."
"publish_destination_placements",
lambda *args: args[2],
)
engine = object.__new__(M2NWeightTransferEngine)
engine.parallel_config = Mock(pipeline_parallel_size=1)
engine.model_config = Mock(quantization=None)
engine.model = torch.nn.Module()
engine.init_transfer_engine(
self._init_info(
rank_offset=32,
world_size=36,
src_mesh_dims=[1, 32],
dst_mesh_dims=[1, 4],
shapes=[[32, 8]],
src_placements=[[REPLICATE, 0]],
)
)
def test_limits_use_resolved_sharded_destination(self, monkeypatch):
destination = M2NDestination("w", (REPLICATE, 0), torch.empty(8, 8))
self._init_32_to_4_plan(monkeypatch, destination)
def test_limits_still_reject_replicated_destination(self, monkeypatch):
destination = M2NDestination("w", REPLICATED, None)
with pytest.raises(ValueError, match=r"32 source shards.*MAX_SOURCES=16"):
self._init_32_to_4_plan(monkeypatch, destination)
def test_update_preflights_all_names_before_reshard(self):
engine = object.__new__(M2NWeightTransferEngine)
engine._handle = object()
engine.model_update_group = object()
engine._index = {"valid": 0}
engine._metas = [M2NParamMeta("valid", torch.float32, (4,), REPLICATED)]
engine._reshard = Mock()
with pytest.raises(ValueError, match=r"parameter 'unknown'"):
engine.receive_weights(
M2NWeightTransferUpdateInfo(names=["valid", "unknown"])
)
engine._reshard.assert_not_called()
def test_fallback_tensors_share_one_load_weights_invocation(self, monkeypatch):
class CoupledModel:
def __init__(self):
self.calls = 0
self.loaded = []
def load_weights(self, weights):
self.calls += 1
received = list(weights)
if [name for name, _ in received] == ["pair.weight", "pair.scale"]:
self.loaded = [(name, tensor.clone()) for name, tensor in received]
model = CoupledModel()
direct = torch.zeros(1)
engine = object.__new__(M2NWeightTransferEngine)
engine._handle = object()
engine.model_update_group = object()
engine.device = torch.device("cpu")
engine.model = model
engine._index = {"pair.weight": 0, "direct": 1, "pair.scale": 2}
engine._metas = [
M2NParamMeta(name, torch.float32, (1,), REPLICATED)
for name in engine._index
]
engine._parameter_destinations = [
M2NDestination("pair.weight", REPLICATED, None),
M2NDestination("direct", REPLICATED, direct),
M2NDestination("pair.scale", REPLICATED, None),
]
reshard_order = []
def reshard(comm, stream, meta, placements, buffer):
reshard_order.append(meta.name)
buffer.fill_(len(reshard_order))
engine._reshard = reshard
stream = Mock()
monkeypatch.setattr(
"vllm.distributed.weight_transfer.m2n_engine.comm_ptr", lambda group: 0
)
monkeypatch.setattr(torch.cuda, "current_stream", lambda: stream)
engine.receive_weights(
M2NWeightTransferUpdateInfo(names=["pair.weight", "direct", "pair.scale"])
)
assert model.calls == 1
assert [name for name, _ in model.loaded] == ["pair.weight", "pair.scale"]
assert [tensor.item() for _, tensor in model.loaded] == [1, 3]
assert direct.item() == 2
assert reshard_order == ["pair.weight", "direct", "pair.scale"]
assert stream.synchronize.call_count == 2
def test_trainer_rank_must_be_a_trainer_rank(self):
"""A trainer rank must fall within the trainer portion of the group."""
with pytest.raises(ValueError, match="num_trainer_ranks"):
M2NTrainerInitInfo(
master_address="127.0.0.1",
master_port=1234,
world_size=4,
num_trainer_ranks=2,
rank=2,
)
def test_trainer_destination_mesh_must_cover_the_workers(self):
"""The destination mesh must include every inference worker."""
with pytest.raises(ValueError, match="dst_mesh_dims"):
M2NTrainerInitInfo(
master_address="127.0.0.1",
master_port=1234,
world_size=6,
num_trainer_ranks=2,
dst_mesh_dims=(3, 1), # 3 != the 4 inference workers
rank=0,
)
def test_destination_mesh_defaults_to_flat(self):
"""A replicated destination does not care how the mesh is factored, so
callers that do not shard it need not supply one."""
info = M2NTrainerInitInfo(
master_address="127.0.0.1",
master_port=1234,
world_size=6,
num_trainer_ranks=2,
rank=0,
)
assert info.destination_mesh_dims == (4, 1)
def test_sender_is_trainer_rank_zero(self):
"""Trainer rank 0 drives the inference control plane."""
info = M2NTrainerInitInfo(
master_address="127.0.0.1", master_port=1234, world_size=4, rank=0
)
assert info.is_sender
class TestRegistration:
def test_both_registries_expose_the_backend(self):
"""Both worker and trainer factories register nccl_m2n."""
assert "nccl_m2n" in WeightTransferEngineFactory._registry
assert "nccl_m2n" in WeightTransferTrainerFactory._registry
def test_init_info_dispatches_to_the_backend(self):
"""Trainer init info selects the nccl_m2n factory entry."""
assert M2NTrainerInitInfo.backend == "nccl_m2n"
# ---------------------------------------------------------------------------
# End-to-end transfer
#
# Transport-only, in the style of the NCCL/sparse tests in
# test_weight_transfer.py: two Ray tasks with one GPU each drive the two engines
# directly, so no HTTP server or LLM instance is involved. The worker is not an
# RPC endpoint here, so the trainer engine gets a no-op control-plane client --
# the NCCL rendezvous and the reshard itself are the real thing.
# ---------------------------------------------------------------------------
SHAPE = [64, 32]
DTYPE = "float32"
def _init_ray() -> None:
if ray.is_initialized():
return
ray.init(
ignore_reinit_error=True,
runtime_env={
"env_vars": {
"RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES": "1",
"RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES": "1",
"RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES": "1",
}
},
)
def _assigned_device() -> "torch.device":
gpu_ids = ray.get_gpu_ids()
device = torch.device(f"cuda:{int(gpu_ids[0])}" if gpu_ids else "cuda:0")
current_platform.set_device(device)
return device
@ray.remote(num_gpus=1)
def _m2n_trainer_send(nccl_unique_id_b64: str, world_size: int) -> bool:
"""Send one parameter through the real trainer engine."""
device = _assigned_device()
from vllm.distributed.weight_transfer import WeightTransferTrainerFactory
from vllm.distributed.weight_transfer.m2n_source import DTensorModuleSource
from vllm.distributed.weight_transfer.m2n_trainer import M2NTrainerInitInfo
class NoopClient:
def init_weight_transfer_engine(self, init_info):
pass
def start_weight_update(self):
pass
def update_weights(self, update_info):
pass
def finish_weight_update(self, weight_version=None):
pass
class Tiny(torch.nn.Module):
def __init__(self):
super().__init__()
self.weight = torch.nn.Parameter(
torch.arange(
SHAPE[0] * SHAPE[1], dtype=torch.float32, device=device
).reshape(SHAPE)
)
engine = WeightTransferTrainerFactory.trainer_init(
init_info=M2NTrainerInitInfo(
nccl_unique_id_b64=nccl_unique_id_b64,
world_size=world_size,
num_trainer_ranks=1,
dst_mesh_dims=(1, world_size - 1),
rank=0,
),
client=NoopClient(),
source=DTensorModuleSource(Tiny(), num_trainer_ranks=1),
)
engine.send_weights()
torch.accelerator.synchronize()
engine.shutdown()
return True
@ray.remote(num_gpus=1)
def _m2n_worker_receive(
nccl_unique_id_b64: str,
world_size: int,
worker_rank: int = 0,
) -> dict:
"""Receive that parameter through the real worker engine."""
import contextlib
from unittest.mock import MagicMock
device = _assigned_device()
from vllm.config.parallel import ParallelConfig
from vllm.config.weight_transfer import WeightTransferConfig
from vllm.distributed.weight_transfer.m2n_engine import (
M2NWeightTransferEngine,
M2NWeightTransferInitInfo,
M2NWeightTransferUpdateInfo,
)
class Recorder(torch.nn.Module):
def __init__(self):
super().__init__()
self.received = []
if world_size > 2:
self.weight = torch.nn.Parameter(
torch.zeros(
SHAPE[0] // (world_size - 1),
SHAPE[1],
dtype=torch.float32,
device=device,
)
)
self.weight.output_dim = 0
self.weight.weight_loader = MethodType(
ColumnParallelLinear.weight_loader,
Mock(tp_size=world_size - 1),
)
def load_weights(self, weights):
for name, tensor in weights:
self.received.append((name, tensor.clone()))
parallel_config = MagicMock(spec=ParallelConfig)
parallel_config.rank = worker_rank
parallel_config.world_size = world_size - 1
parallel_config.data_parallel_rank = 0
parallel_config.data_parallel_index = 0
parallel_config.tensor_parallel_size = world_size - 1
parallel_config.pipeline_parallel_size = 1
vllm_config = MagicMock()
vllm_config.parallel_config = parallel_config
vllm_config.model_config = MagicMock()
vllm_config.model_config.quantization = None
recorder = Recorder()
engine = M2NWeightTransferEngine(
WeightTransferConfig(backend="nccl_m2n"), vllm_config, device, recorder
)
# Transport-only: receive_weights enters set_current_vllm_config, and
# vllm_config here is a mock.
import vllm.config as _vllm_config_mod
_vllm_config_mod.set_current_vllm_config = lambda cfg: contextlib.nullcontext()
engine.init_transfer_engine(
M2NWeightTransferInitInfo(
nccl_unique_id_b64=nccl_unique_id_b64,
rank_offset=1, # trainer occupies rank 0
world_size=world_size,
src_mesh_dims=[1, 1],
dst_mesh_dims=[1, world_size - 1],
names=["weight"],
dtype_names=[DTYPE],
shapes=[SHAPE],
src_placements=[None], # replicated on the single trainer rank
)
)
engine.receive_weights(M2NWeightTransferUpdateInfo(names=["weight"]))
torch.accelerator.synchronize()
full = torch.arange(
SHAPE[0] * SHAPE[1], dtype=torch.float32, device=device
).reshape(SHAPE)
direct = world_size > 2
name: str | None
got: torch.Tensor | None
if direct:
name, got = "weight", recorder.weight
expected = full.chunk(world_size - 1, dim=0)[worker_rank]
else:
name, got = recorder.received[0] if recorder.received else (None, None)
expected = full
result = {
"count": len(recorder.received),
"name": name,
"shape": list(got.shape) if got is not None else None,
"exact": bool(torch.equal(got, expected)) if got is not None else False,
"direct": direct,
}
engine.shutdown()
return result
@pytest.mark.skipif(
torch.accelerator.device_count() < 2,
reason="Need at least 2 GPUs: one trainer rank and one inference worker.",
)
def test_m2n_weight_transfer_between_processes():
"""A parameter survives a real reshard from a trainer process to a worker.
This is the only test here that moves bytes: it builds both engines, joins
one NCCL communicator across two processes, and reshards. Everything else
in this file checks how a transfer is *described*.
"""
pytest.importorskip("nccl.m2n", reason="nccl_m2n backend needs the m2n runtime")
_init_ray()
from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
nccl = NCCLLibrary()
nccl_unique_id_b64 = base64.b64encode(
bytes(nccl.ncclGetUniqueId().internal)
).decode()
world_size = 2 # 1 trainer + 1 inference worker
worker = _m2n_worker_receive.remote(nccl_unique_id_b64, world_size)
trainer = _m2n_trainer_send.remote(nccl_unique_id_b64, world_size)
trainer_ok, result = ray.get([trainer, worker])
assert trainer_ok, "trainer engine did not complete"
assert result["count"] == 1, f"expected one parameter, got {result['count']}"
assert result["name"] == "weight"
assert result["shape"] == SHAPE
assert result["exact"], "resharded tensor does not match what the trainer sent"
@pytest.mark.skipif(
torch.accelerator.device_count() < 3,
reason="Need at least 3 GPUs: one trainer rank and two inference workers.",
)
def test_m2n_weight_transfer_to_tp2_shards():
"""Two inference workers receive their own TP shard in live model storage."""
pytest.importorskip("nccl.m2n", reason="nccl_m2n backend needs the m2n runtime")
_init_ray()
from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
nccl = NCCLLibrary()
nccl_unique_id_b64 = base64.b64encode(
bytes(nccl.ncclGetUniqueId().internal)
).decode()
world_size = 3
workers = [
_m2n_worker_receive.remote(nccl_unique_id_b64, world_size, rank)
for rank in range(2)
]
trainer = _m2n_trainer_send.remote(nccl_unique_id_b64, world_size)
trainer_ok, *results = ray.get([trainer, *workers])
assert trainer_ok, "trainer engine did not complete"
for rank, result in enumerate(results):
assert result["count"] == 0, f"worker {rank} unexpectedly called load_weights"
assert result["name"] == "weight"
assert result["shape"] == [SHAPE[0] // 2, SHAPE[1]]
assert result["direct"]
assert result["exact"], f"worker {rank} received the wrong shard"
class _Model(torch.nn.Module):
"""Stand-in for a TP=2 shard of a tiny model.
`column` is sharded on dim 0 and `row` on dim 1 (the two shapes a TP linear
layer produces), `norm` is replicated, and `fused` has no checkpoint-name
counterpart — the shape a fused parameter presents to the resolver.
"""
def __init__(self) -> None:
super().__init__()
self.column = torch.nn.Parameter(torch.zeros(8, 16))
self.column.output_dim = 0
self.column.weight_loader = MethodType(
ColumnParallelLinear.weight_loader, Mock(tp_size=2)
)
self.row = torch.nn.Parameter(torch.zeros(16, 8))
self.row.input_dim = 1
self.row.weight_loader = MethodType(
RowParallelLinear.weight_loader, Mock(tp_size=2)
)
self.norm = torch.nn.Parameter(torch.zeros(16))
self.norm.weight_loader = default_weight_loader
self.fused = torch.nn.Parameter(torch.zeros(24, 16))
class _MixedModel(torch.nn.Module):
def __init__(self) -> None:
super().__init__()
self.proj = torch.nn.Module()
self.proj.weight = torch.nn.Parameter(torch.zeros(16, 8))
self.proj.weight.input_dim = 1
self.proj.weight.weight_loader = MethodType(
RowParallelLinear.weight_loader, Mock(tp_size=2)
)
self.proj.bias = torch.nn.Parameter(torch.zeros(16))
def _resolve(names, dtypes, shapes, **kwargs):
defaults = dict(num_workers=2, shard_axis_size=2, allow_direct=True)
defaults.update(kwargs)
return resolve_parameter_destinations(_Model(), names, dtypes, shapes, **defaults)
class TestDestinationResolution:
@pytest.mark.parametrize(
"name,expected_dim",
[("column", 0), ("row", 1)],
)
def test_sharded_parameter_resolves_to_its_tp_dim(self, name, expected_dim):
[destination] = _resolve([name], [torch.float32], [(16, 16)])
assert destination.direct
assert destination.placements == (REPLICATE, expected_dim)
def test_replicated_parameter_needs_no_placement(self):
"""A parameter every rank holds in full is REPLICATED, so it works
whatever way the inference mesh happens to be factored."""
[destination] = _resolve(["norm"], [torch.float32], [(16,)])
assert destination.direct
assert destination.placements is REPLICATED
def test_direct_destination_is_the_live_parameter(self):
model = _Model()
[destination] = resolve_parameter_destinations(
model,
["column"],
[torch.float32],
[(16, 16)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert destination.tensor.data_ptr() == model.column.data_ptr()
def test_known_transforming_model_submodule_falls_back(self):
class GPT2Model(_Model):
pass
GPT2Model.__module__ = "vllm.model_executor.models.gpt2"
model = torch.nn.Module()
model.transforming = GPT2Model()
[destination] = resolve_parameter_destinations(
model,
["transforming.row"],
[torch.float32],
[(16, 16)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not destination.direct
assert destination.placements is REPLICATED
def test_unknown_name_falls_back(self):
"""Fused parameters reach the worker under checkpoint names that do not
exist in the model; those must take the full-tensor path."""
[destination] = _resolve(["mlp.gate_proj.weight"], [torch.float32], [(16, 16)])
assert not destination.direct
assert destination.placements is REPLICATED
def test_fallback_parameter_demotes_direct_sibling_in_same_module(self):
"""Layerwise finalization must not overwrite a directly loaded weight
when a sibling bias takes the fallback path."""
destinations = resolve_parameter_destinations(
_MixedModel(),
["proj.weight", "proj.bias"],
[torch.float32, torch.float32],
[(16, 16), (16,)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not any(destination.direct for destination in destinations)
def test_unknown_loader_falls_back_despite_matching_name_and_shape(self):
model = _Model()
model.norm.weight_loader = lambda param, weight: param.data.copy_(weight)
[destination] = resolve_parameter_destinations(
model,
["norm"],
[torch.float32],
[(16,)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not destination.direct
def test_missing_loader_is_not_assumed_to_be_a_plain_copy(self):
model = _Model()
del model.norm.weight_loader
[destination] = resolve_parameter_destinations(
model,
["norm"],
[torch.float32],
[(16,)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not destination.direct
def test_wrapped_known_loader_falls_back(self):
model = _Model()
@wraps(default_weight_loader)
def wrapped_loader(param, weight):
default_weight_loader(param, weight)
model.norm.weight_loader = wrapped_loader
[destination] = resolve_parameter_destinations(
model,
["norm"],
[torch.float32],
[(16,)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not destination.direct
def test_replicated_loader_with_transform_metadata_falls_back(self):
model = _Model()
model.norm.is_transposed = True
[destination] = resolve_parameter_destinations(
model,
["norm"],
[torch.float32],
[(16,)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not destination.direct
def test_packed_dim_zero_falls_back(self):
model = _Model()
model.column.packed_dim = 0
[destination] = resolve_parameter_destinations(
model,
["column"],
[torch.float32],
[(16, 16)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not destination.direct
def test_loader_declared_dim_wins_over_shape_guessing(self):
model = _Model()
model.column.output_dim = 1
[destination] = resolve_parameter_destinations(
model,
["column"],
[torch.float32],
[(16, 16)],
num_workers=2,
shard_axis_size=2,
allow_direct=True,
)
assert not destination.direct
def test_shape_the_tp_factor_cannot_explain_falls_back(self):
[destination] = _resolve(["fused"], [torch.float32], [(16, 16)])
assert not destination.direct
def test_dtype_mismatch_falls_back(self):
"""A parameter stored in a different dtype than the wire dtype means a
quantized or otherwise transformed layout — never write into it."""
[destination] = _resolve(["column"], [torch.bfloat16], [(16, 16)])
assert not destination.direct
def test_allow_direct_false_forces_every_parameter_to_fall_back(self):
destinations = _resolve(
["column", "norm"],
[torch.float32] * 2,
[(16, 16), (16,)],
allow_direct=False,
)
assert not any(d.direct for d in destinations)
def test_plan_reports_byte_coverage(self, caplog_vllm):
"""A small direct parameter must not hide a large fallback tensor."""
with caplog_vllm.at_level(
logging.INFO,
logger="vllm.distributed.weight_transfer.m2n_layout",
):
_resolve(
["norm", "missing_fused_weight"],
[torch.float32, torch.bfloat16],
[(16,), (1024, 1024)],
)
assert (
"1/2 parameters resharded directly into the model, 1 via "
"full-tensor fallback; direct byte coverage: 64/2097216 bytes "
"(0.0%)" in caplog_vllm.text
)
+3 -1
View File
@@ -9,7 +9,9 @@ from vllm.config.utils import config
class WeightTransferConfig:
"""Configuration for weight transfer during RL training."""
backend: Literal["nccl", "ipc", "sparse_nccl", "sharded_rdt"] | str = "nccl"
backend: Literal["nccl", "ipc", "sparse_nccl", "sharded_rdt", "nccl_m2n"] | str = (
"nccl"
)
"""The backend to use for weight transfer. Validated against the
`WeightTransferEngineFactory` registry at engine creation time.
"""
@@ -243,6 +243,12 @@ WeightTransferEngineFactory.register_engine(
"ShardedRDTWeightTransferEngine",
)
WeightTransferEngineFactory.register_engine(
"nccl_m2n",
"vllm.distributed.weight_transfer.m2n_engine",
"M2NWeightTransferEngine",
)
# Trainer-side engines, parallel to the worker registry above.
WeightTransferTrainerFactory.register_engine(
@@ -268,3 +274,9 @@ WeightTransferTrainerFactory.register_engine(
"vllm.distributed.weight_transfer.sharded_rdt_trainer",
"ShardedRDTTrainerWeightTransferEngine",
)
WeightTransferTrainerFactory.register_engine(
"nccl_m2n",
"vllm.distributed.weight_transfer.m2n_trainer",
"M2NTrainerWeightTransferEngine",
)
@@ -0,0 +1,338 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Shared helpers for the NCCL M2N (`nccl_m2n`) weight transfer backend.
M2N reshards a tensor between two disjoint meshes of ranks that live in one
communicator: the trainer occupies ranks `[0, T)` and the inference workers
`[T, T + N)`. This module holds everything both sides need — the optional
runtime import, layout descriptors, and the conversion to `nccl.m2n` types —
so the engine module stays about the transfer itself.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
import torch
from vllm.distributed.weight_transfer.base import ParamMeta
if TYPE_CHECKING:
from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
# Placement code for a mesh axis that replicates. Any other (non-negative) code
# is the tensor dim that axis shards. Ints rather than `nccl.m2n` objects so
# layouts survive the JSON init handshake without a custom encoder.
REPLICATE = -1
# `NCCL_RESHARD_MAX_TENSOR_DIMS`; 4-D and higher are rejected by the library.
MAX_TENSOR_DIMS = 3
MESH_NDIMS = 2
# Destination mesh convention. Keeping these roles named avoids scattering
# positional axis assumptions through the worker-side planner.
DESTINATION_REPLICA_AXIS = 0
DESTINATION_SHARD_AXIS = 1
# m2n bounds how many source shards may feed one destination shard, and how many
# destination shards one source shard may feed, with compile-time arrays in
# `reshard_limits.h`. The bindings do not expose them, so they are mirrored
# here; a build with larger arrays makes these conservative, never wrong.
MAX_SOURCE_SHARDS = 16
MAX_DEST_SHARDS = 64
# Wire dtypes `ncclReshard` accepts. Notably excludes fp4 and any packed
# sub-byte type, so quantized checkpoints are out of scope for now.
SUPPORTED_DTYPES: frozenset[torch.dtype] = frozenset(
dtype
for dtype in (
getattr(torch, name, None)
for name in (
"int8",
"uint8",
"float8_e4m3fn",
"float8_e5m2",
"float16",
"bfloat16",
"int32",
"uint32",
"float32",
"int64",
"uint64",
"float64",
)
)
if dtype is not None
)
_IMPORT_HINT = (
"The nccl_m2n weight transfer backend requires the `nccl-extensions` "
"package (and its `nccl4py` dependency), which is not installed by vLLM. "
"See https://github.com/NVIDIA/nccl-extensions for build "
"and install instructions. It needs NCCL 2.30.5 or newer, and "
"VLLM_NCCL_SO_PATH must point at the same libnccl.so that libnccl_m2n.so "
"was linked against."
)
def import_m2n() -> Any:
"""Import `nccl.m2n` lazily, with an actionable error when it is missing.
Deferred so that importing vLLM — or any other weight transfer backend —
never requires the m2n runtime to be present.
"""
try:
import nccl.m2n as m2n
except ImportError as e:
raise ImportError(f"{_IMPORT_HINT} (import failed: {e})") from e
return m2n
@dataclass(frozen=True)
class M2NMesh:
"""One side's rank topology — pure topology, no tensor placement.
Mirrors `ncclMesh_t`: a 2-axis mesh owning the contiguous rank interval
`[start_rank, start_rank + dims[0] * dims[1])`. There is no 1-D mesh; a
single-axis topology is spelled with a second axis of size 1.
One mesh describes every tensor on its side, so it is exchanged once at the
init handshake rather than per parameter.
"""
dims: tuple[int, int]
start_rank: int
def __post_init__(self) -> None:
if len(self.dims) != MESH_NDIMS or any(d <= 0 for d in self.dims):
raise ValueError(f"mesh dims must be {MESH_NDIMS} positive ints")
if self.start_rank < 0:
raise ValueError(
f"mesh start_rank must be non-negative, got {self.start_rank}"
)
@property
def size(self) -> int:
return self.dims[0] * self.dims[1]
# A tensor's placement over its side's mesh: one code per mesh axis
# (`REPLICATE`, or the tensor dim that axis shards), or `REPLICATED` for a
# tensor every rank holds in full.
Placements = tuple[int, int]
REPLICATED: Placements | None = None
def check_placements(placements: Placements, context: str = "placements") -> None:
"""Reject placement pairs m2n cannot express.
The header requires exactly one SHARD axis and one REPLICATE axis.
`{REPLICATE, REPLICATE}` hits a degenerate prepare branch, which is why
full replication is carried as `REPLICATED` and resolved separately;
sharding both axes is not expressible at all.
"""
if len(placements) != MESH_NDIMS:
raise ValueError(f"{context} must have {MESH_NDIMS} entries")
invalid = [code for code in placements if code < REPLICATE]
if invalid:
raise ValueError(
f"{context} contains invalid placement code {invalid[0]}; valid "
f"codes are {REPLICATE} (Replicate) and non-negative tensor dimensions"
)
num_sharded = sum(code != REPLICATE for code in placements)
if num_sharded == 0:
raise ValueError(
f"{context}: a fully replicated tensor is carried as REPLICATED, "
"not as two "
"REPLICATE axes"
)
if num_sharded == MESH_NDIMS:
raise ValueError(
f"{context}: nccl_m2n needs one REPLICATE mesh axis, but "
f"{placements} shards both"
)
def resolve_layout(
mesh: M2NMesh,
placements: Placements | None,
context: str = "placements",
) -> tuple[M2NMesh, Placements]:
"""Pair one tensor's placement with the mesh m2n should see for it.
A replicated tensor needs a size-1 mesh axis to carry a no-op shard, since
m2n has no `{REPLICATE, REPLICATE}`. It is therefore described over the
*same rank interval* re-factored as `(size, 1)`. That re-factoring is sound
precisely because replication is order-independent — every rank holds the
whole tensor, so it does not matter that `(a, b)` and `(size, 1)` walk the
interval in a different order. A sharded tensor keeps its side's own
factorization, where rank order decides who owns which shard.
"""
if placements is None:
return M2NMesh((mesh.size, 1), mesh.start_rank), (REPLICATE, 0)
check_placements(placements, context)
return mesh, placements
def shard_count(mesh: M2NMesh, placements: Placements) -> int:
"""How many pieces this layout splits the tensor into."""
return next(
(mesh.dims[axis] for axis, code in enumerate(placements) if code != REPLICATE),
1,
)
def check_plan_limits(
src: tuple[M2NMesh, Placements],
dst: tuple[M2NMesh, Placements],
name: str,
) -> None:
"""Reject plans that exceed m2n's static per-shard fan-in / fan-out arrays.
Only the unambiguous cases are checked: when one side is a single shard it
is fed by (or feeds) every shard on the other side, so the count is exactly
the other side's shard count. In the general sharded-to-sharded case the
overlap depends on the library's chunking, and reproducing that arithmetic
here would duplicate internals that can change under us.
m2n re-checks authoritatively and, because the plan is derived from the
shared descriptors, fails identically on every rank -- so this is about
reporting at init with a message that names the parameter, not about
avoiding a hang.
"""
src_shards = shard_count(*src)
dst_shards = shard_count(*dst)
if dst_shards == 1 and src_shards > MAX_SOURCE_SHARDS:
raise ValueError(
f"parameter '{name}' would feed {src_shards} source shards into one "
f"replicated destination, over m2n's MAX_SOURCES={MAX_SOURCE_SHARDS}. "
"Shard the destination, replicate the source, or rebuild m2n with "
"larger arrays."
)
if src_shards == 1 and dst_shards > MAX_DEST_SHARDS:
raise ValueError(
f"parameter '{name}' would fan one source shard out to {dst_shards} "
f"destination shards, over m2n's MAX_TARGETS={MAX_DEST_SHARDS}. "
"Rebuild m2n with larger arrays to raise the bound."
)
def validate_layout(
mesh: M2NMesh, placements: Placements, shape: Sequence[int], side: str
) -> None:
"""Check a resolved layout can describe `shape` before any collective runs.
Called on both sides at init so a bad plan surfaces as an error from the
init RPC rather than as a hang inside the first reshard.
"""
for axis, code in enumerate(placements):
if code == REPLICATE:
continue
if code >= len(shape):
raise ValueError(
f"{side} layout shards tensor dim {code}, but the tensor "
f"has rank {len(shape)}"
)
factor = mesh.dims[axis]
if shape[code] % factor:
raise ValueError(
f"{side} layout shards dim {code} (size {shape[code]}) over "
f"{factor} ranks, which does not divide evenly"
)
@dataclass(frozen=True)
class M2NParamMeta(ParamMeta):
"""`ParamMeta` extended with how the trainer places this tensor.
The base class carries only name / dtype / full shape, which is not enough
to plan a reshard. `placements` is relative to its side's `M2NMesh`, or
`REPLICATED` when every rank holds the whole tensor.
"""
placements: Placements | None
def check_transferable(name: str, dtype: torch.dtype, shape: Sequence[int]) -> None:
"""Reject tensors m2n cannot move at all, with the parameter named."""
if dtype not in SUPPORTED_DTYPES:
raise ValueError(
f"parameter '{name}' has dtype {dtype}, which nccl_m2n does not "
f"support. Supported: {sorted(str(d) for d in SUPPORTED_DTYPES)}"
)
if not 1 <= len(shape) <= MAX_TENSOR_DIMS:
raise ValueError(
f"parameter '{name}' has rank {len(shape)}; nccl_m2n supports "
f"rank 1..{MAX_TENSOR_DIMS}"
)
def to_mesh(m2n: Any, mesh: M2NMesh) -> Any:
return m2n.Mesh(mesh.dims, start_rank=mesh.start_rank)
def to_placements(m2n: Any, placements: Placements) -> list[Any]:
return [
m2n.Replicate() if code == REPLICATE else m2n.Shard(code) for code in placements
]
def publish_destination_placements(
comm: "PyNcclCommunicator",
first_worker_rank: int,
placements: "Sequence[Placements | None] | None",
num_parameters: int,
) -> list[Placements | None]:
"""Share the worker-side destination plan with every rank in the group.
The trainer must issue each reshard with the same destination the workers
use, but once destinations are per-parameter they depend on the inference
model, which only the workers can see. The first worker publishes them here,
over the shared NCCL communicator; trainer ranks pass `None` and receive
them, and the other workers pass their own so a disagreement is caught
rather than deadlocking later.
Only placements travel: the destination *mesh* is still derived from the
rank split, identically on both sides. Use the NCCL communicator directly
because unique-id rendezvous deliberately has no bootstrap process group.
"""
replicated_sentinel = REPLICATE - 1
encoded = torch.full(
(num_parameters, MESH_NDIMS),
replicated_sentinel,
dtype=torch.int8,
device=comm.device,
)
if comm.rank == first_worker_rank:
if placements is None or len(placements) != num_parameters:
raise ValueError(
f"publishing rank needs {num_parameters} destination placements"
)
rows = [
[replicated_sentinel] * MESH_NDIMS if placement is REPLICATED else placement
for placement in placements
]
if rows:
encoded.copy_(torch.tensor(rows, dtype=torch.int8, device=comm.device))
comm.broadcast(encoded, src=first_worker_rank)
return [
REPLICATED
if all(code == replicated_sentinel for code in row)
else cast(Placements, tuple(row))
for row in encoded.tolist()
]
def comm_ptr(comm: "PyNcclCommunicator") -> int:
"""Raw `ncclComm_t` behind vLLM's `PyNcclCommunicator`.
m2n links its own NCCL, so the handle only means anything if vLLM loaded
the same `libnccl.so` — set `VLLM_NCCL_SO_PATH` accordingly.
"""
handle = comm.comm
ptr = getattr(handle, "value", handle)
if not ptr:
raise RuntimeError("PyNcclCommunicator has no live NCCL communicator")
return int(ptr)
@@ -0,0 +1,453 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Inference-side weight transfer engine built on NCCL M2N (`nccl_m2n`).
The trainer and the inference workers share one NCCL communicator: trainer ranks
occupy `[0, T)`, workers `[T, T + N)`. Each parameter is moved with a single
`nccl.m2n.reshard`, which redistributes it from the trainer's layout (FSDP / EP
/ arbitrary DTensor sharding) to the inference layout — so the trainer sends its
local shards and never all-gathers a full tensor, which is what the broadcast
NCCL backend forces it to do.
When an incoming checkpoint parameter maps directly to a live vLLM parameter,
M2N reshards into that worker's local model storage. Parameters whose names or
layouts cannot be resolved receive a full tensor and fall back to
`load_weights`, preserving the existing backend's loading behavior.
Both meshes participate in every reshard, in the same order, so this backend has
the same concurrency shape as the broadcast NCCL backend: the worker must be
inside `receive_weights` while the trainer is sending. Driving that from the
trainer is the trainer engine's job.
"""
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
import torch
from vllm.config.weight_transfer import WeightTransferConfig
from vllm.distributed.weight_transfer.base import (
WeightTransferEngine,
WeightTransferInitInfo,
WeightTransferUpdateInfo,
)
from vllm.distributed.weight_transfer.m2n_common import (
DESTINATION_SHARD_AXIS,
MESH_NDIMS,
REPLICATED,
M2NMesh,
M2NParamMeta,
Placements,
check_plan_limits,
check_transferable,
comm_ptr,
import_m2n,
publish_destination_placements,
resolve_layout,
to_mesh,
to_placements,
validate_layout,
)
from vllm.distributed.weight_transfer.m2n_layout import (
M2NDestination,
resolve_parameter_destinations,
)
from vllm.distributed.weight_transfer.nccl_common import (
NCCLWeightTransferInitInfo,
decode_nccl_unique_id,
worker_init_process_group,
)
if TYPE_CHECKING:
from vllm.config import VllmConfig
from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
__all__ = [
"M2NWeightTransferInitInfo",
"M2NWeightTransferUpdateInfo",
"M2NWeightTransferEngine",
]
# ---------------------------------------------------------------------------
# Wire types
# ---------------------------------------------------------------------------
@dataclass(kw_only=True)
class M2NWeightTransferInitInfo(WeightTransferInitInfo):
"""Worker-side init info: the rendezvous plus the full transfer plan.
Layouts are static for the whole run, so they ride the one-time init
handshake and the per-round update info stays a list of names. Everything is
plain JSON so the HTTP control plane carries it unchanged.
"""
master_address: str | None = None
master_port: int | None = None
rank_offset: int
"""First worker rank, i.e. the number of trainer ranks."""
world_size: int
"""Trainer ranks + all inference workers."""
src_mesh_dims: list[int]
"""The trainer's mesh, shared by every parameter (`start_rank` is 0: the
trainer occupies the front of the communicator)."""
dst_mesh_dims: list[int]
"""The inference mesh, starting at `rank_offset`. Declared rather than
derived so both sides describe the destination identically."""
names: list[str]
dtype_names: list[str]
shapes: list[list[int]]
src_placements: list[list[int] | None]
"""Per parameter, relative to `src_mesh_dims`; `None` means replicated."""
nccl_unique_id_b64: str | None = field(default=None, repr=False)
max_cta: int | None = None
def __post_init__(self) -> None:
_ = self.nccl_unique_id_bytes
num_params = len(self.names)
for label, values in (
("dtype_names", self.dtype_names),
("shapes", self.shapes),
("src_placements", self.src_placements),
):
if len(values) != num_params:
raise ValueError(
f"`{label}` should be of the same size as `names`: "
f"got {len(values)} and {num_params}"
)
if self.rank_offset < 1 or self.rank_offset >= self.world_size:
raise ValueError(
f"`rank_offset` ({self.rank_offset}) must leave at least one "
f"trainer rank and one worker in world_size {self.world_size}"
)
num_workers = self.world_size - self.rank_offset
dst = tuple(self.dst_mesh_dims)
if len(dst) != MESH_NDIMS or dst[0] * dst[1] != num_workers:
raise ValueError(
f"`dst_mesh_dims` {dst} must be {MESH_NDIMS} dims covering the "
f"{num_workers} inference workers"
)
@property
def nccl_unique_id_bytes(self) -> bytes | None:
return decode_nccl_unique_id(
master_address=self.master_address,
master_port=self.master_port,
nccl_unique_id_b64=self.nccl_unique_id_b64,
ctx="M2NWeightTransferInitInfo",
)
@dataclass
class M2NWeightTransferUpdateInfo(WeightTransferUpdateInfo):
"""Per-round update info: which parameters this chunk carries, in order.
Shapes, dtypes and layouts were fixed at init, so a round only needs to say
what is coming and in what order — both sides must issue their reshards in
exactly that order.
"""
names: list[str]
# ---------------------------------------------------------------------------
# Worker engine
# ---------------------------------------------------------------------------
class M2NWeightTransferEngine(
WeightTransferEngine[M2NWeightTransferInitInfo, M2NWeightTransferUpdateInfo]
):
"""Inference-side engine: receives each parameter with one reshard.
Resolvable parameters are resharded from the trainer layout directly into
each worker's live local model storage. Unresolvable parameters receive a
full tensor and use `load_weights`. In both cases, the trainer sends its
local shards without materializing a full tensor.
"""
init_info_cls = M2NWeightTransferInitInfo
update_info_cls = M2NWeightTransferUpdateInfo
# The destination plan is resolved against the model this engine was built
# for, so it cannot be retargeted at a draft model mid-flight.
supports_draft_weight_update = False
def __init__(
self,
config: WeightTransferConfig,
vllm_config: "VllmConfig",
device: torch.device,
model: torch.nn.Module,
) -> None:
super().__init__(config, vllm_config, device, model)
self.model_update_group: PyNcclCommunicator | None = None
self._m2n: Any = None
self._handle: Any = None
self._metas: list[M2NParamMeta] = []
self._src_mesh: M2NMesh | None = None
self._dst_mesh: M2NMesh | None = None
self._parameter_destinations: list[M2NDestination] = []
self._index: dict[str, int] = {}
self._uses_load_weights = False
def init_transfer_engine(self, init_info: M2NWeightTransferInitInfo) -> None:
"""Join the trainer's communicator and rebuild the transfer plan.
Every precondition (dtype, tensor rank, divisibility) is checked here so
a bad plan fails the init RPC instead of hanging the first collective.
"""
self._m2n = import_m2n()
self._metas = []
self._src_mesh = M2NMesh(
cast(tuple[int, int], tuple(init_info.src_mesh_dims)), 0
)
if self._src_mesh.size != init_info.rank_offset:
raise ValueError(
f"source mesh covers {self._src_mesh.size} ranks, but there are "
f"{init_info.rank_offset} trainer ranks"
)
# The inference topology, as declared by the trainer. This is the mesh
# a sharded destination is placed over; a replicated one is described
# by the alternate descriptor `resolve_layout` derives from it, which
# covers the same ranks but is not this mesh.
self._dst_mesh = M2NMesh(
cast(tuple[int, int], tuple(init_info.dst_mesh_dims)),
init_info.rank_offset,
)
for name, name_dtype, shape, placements in zip(
init_info.names,
init_info.dtype_names,
init_info.shapes,
init_info.src_placements,
):
dtype = getattr(torch, name_dtype, None)
if not isinstance(dtype, torch.dtype):
raise ValueError(
f"parameter '{name}' has invalid dtype name "
f"'{name_dtype}'; expected the name of a torch.dtype"
)
check_transferable(name, dtype, shape)
codes = (
REPLICATED
if placements is None
else cast(Placements, tuple(placements))
)
src_mesh, src_placements = resolve_layout(
self._src_mesh, codes, f"parameter '{name}' source placements"
)
validate_layout(src_mesh, src_placements, shape, "source")
# Destination checks are deferred until loader-aware resolution;
# assuming replication here can reject a valid sharded plan.
self._metas.append(M2NParamMeta(name, dtype, tuple(shape), codes))
parallel_config = self.parallel_config
shard_axis_size = self._dst_mesh.dims[DESTINATION_SHARD_AXIS]
quantization = getattr(self.model_config, "quantization", None)
# A quantized weight loader may dequantize / transpose / requantize, and
# under PP a rank's local shape is not the checkpoint shape split over
# the shard axis. Both break shape-derived resolution, so take the
# fallback.
allow_direct = (
quantization is None and parallel_config.pipeline_parallel_size == 1
)
# Match every incoming checkpoint parameter to its rank-local target.
# Resolvable parameters can be resharded directly into live model
# storage; the rest receive a full tensor for `load_weights`.
self._parameter_destinations = resolve_parameter_destinations(
self.model,
[m.name for m in self._metas],
[m.dtype for m in self._metas],
[m.shape for m in self._metas],
num_workers=self._dst_mesh.size,
shard_axis_size=shard_axis_size,
allow_direct=allow_direct,
)
# Check the resolved layout rather than a provisional replicated one.
# For example, 32 source shards feeding 4 destination shards may need
# only 8 sources per destination, while a replicated plan needs all 32.
for meta, destination in zip(self._metas, self._parameter_destinations):
src_mesh, src_placements = resolve_layout(
self._src_mesh,
meta.placements,
f"parameter '{meta.name}' source placements",
)
dst_mesh, dst_placements = resolve_layout(
self._dst_mesh,
destination.placements,
f"parameter '{meta.name}' destination placements",
)
validate_layout(dst_mesh, dst_placements, meta.shape, "destination")
check_plan_limits(
(src_mesh, src_placements),
(dst_mesh, dst_placements),
meta.name,
)
# Update requests carry names only, so cache their plan indices. Track
# whether any fallback entry requires the `load_weights` lifecycle.
self._index = {meta.name: i for i, meta in enumerate(self._metas)}
self._uses_load_weights = any(
not destination.direct for destination in self._parameter_destinations
)
self.model_update_group = worker_init_process_group(
NCCLWeightTransferInitInfo(
master_address=init_info.master_address,
master_port=init_info.master_port,
nccl_unique_id_b64=init_info.nccl_unique_id_b64,
rank_offset=init_info.rank_offset,
world_size=init_info.world_size,
),
parallel_config,
)
# Destinations are per-parameter and depend on the inference model, so
# the trainer cannot derive them. The first worker publishes the plan
# and every other rank checks its own against it — a silent
# disagreement between workers would mean mismatched collectives.
mine = [destination.placements for destination in self._parameter_destinations]
agreed = publish_destination_placements(
self.model_update_group, init_info.rank_offset, mine, len(self._metas)
)
if agreed != mine:
mismatched = next(
meta.name for meta, a, b in zip(self._metas, mine, agreed) if a != b
)
raise RuntimeError(
"inference workers resolved different nccl_m2n destinations "
f"(first mismatch: '{mismatched}'); all workers must run the "
"same model and parallel config"
)
self._handle = self._m2n.Handle.create(
self._m2n.Config(max_cta=init_info.max_cta)
)
def start_weight_update(self) -> None:
"""Set up layerwise reloading, but only if some parameter needs it.
Directly-resharded parameters are written in place and never go through
`load_weights`, so a plan with no fallback entries has nothing to
reload.
"""
if not self._uses_load_weights:
return
from vllm.model_executor.model_loader.reload import initialize_layerwise_reload
initialize_layerwise_reload(self.model)
def finish_weight_update(self) -> None:
"""Finalize layerwise reloading when the plan uses fallback entries."""
if not self._uses_load_weights:
return
from vllm.model_executor.model_loader.reload import finalize_layerwise_reload
finalize_layerwise_reload(self.model, self.model_config)
def receive_weights(self, update_info: M2NWeightTransferUpdateInfo) -> None:
"""Receive each requested parameter using its initialization-time plan."""
if self._handle is None or self.model_update_group is None:
raise RuntimeError(
"nccl_m2n weight transfer not initialized. "
"Call init_transfer_engine() first."
)
requested = []
for name in update_info.names:
index = self._index.get(name)
if index is None:
raise ValueError(
f"parameter '{name}' was not declared at init; the "
"trainer must send the same parameter set it announced"
)
requested.append((name, index))
from vllm.model_executor.model_loader.mtp_validation import (
disable_mtp_completeness_check,
)
comm = comm_ptr(self.model_update_group)
stream = torch.cuda.current_stream()
# Reshard lazily so one `load_weights` invocation sees the complete
# fallback sequence without staging every full tensor at once. Direct
# destinations still execute in request order as the iterator advances.
def reshard_requested_weights() -> Iterator[tuple[str, torch.Tensor]]:
for name, index in requested:
meta = self._metas[index]
destination = self._parameter_destinations[index]
buffer = destination.tensor
if buffer is None:
buffer = torch.empty(
meta.shape, dtype=meta.dtype, device=self.device
)
self._reshard(comm, stream, meta, destination.placements, buffer)
if destination.tensor is None:
# `load_weights` reads on the host stream, so the transfer
# has to have landed before it runs.
stream.synchronize()
yield name, buffer
has_fallback = any(
self._parameter_destinations[index].tensor is None for _, index in requested
)
with disable_mtp_completeness_check():
received_weights = reshard_requested_weights()
if has_fallback:
# Some model loaders keep invocation-local state to combine
# related checkpoint tensors, so preserve one iterable scope.
self.model.load_weights(received_weights)
else:
# The iterator performs the reshards. With nothing to yield to
# `load_weights`, exhaust it here to execute direct transfers.
for _ in received_weights:
pass
def _reshard(
self,
comm: int,
stream: torch.cuda.Stream,
src_meta: M2NParamMeta,
dst_placements: Placements | None,
dst_buffer: torch.Tensor,
) -> None:
"""Reshard one parameter from its trainer layout into a worker buffer.
`dst_placements` describes the buffer's logical placement over the
worker mesh; `dst_buffer` is this rank's physical storage.
"""
assert self._src_mesh is not None
assert self._dst_mesh is not None
m2n = self._m2n
src_mesh, resolved_src_placements = resolve_layout(
self._src_mesh, src_meta.placements
)
dst_mesh, resolved_dst_placements = resolve_layout(
self._dst_mesh, dst_placements
)
m2n.reshard(
None,
dst_buffer,
comm,
stream,
src_mesh=to_mesh(m2n, src_mesh),
src_placements=to_placements(m2n, resolved_src_placements),
dst_mesh=to_mesh(m2n, dst_mesh),
dst_placements=to_placements(m2n, resolved_dst_placements),
handle=self._handle,
)
def shutdown(self) -> None:
"""Finish pending GPU work and release M2N communication state."""
if self._handle is not None:
# M2N does not synchronize caller streams on finalize.
torch.accelerator.synchronize()
self._handle.destroy()
self._handle = None
self.model_update_group = None
self._parameter_destinations = []
@@ -0,0 +1,261 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Destination-layout resolution for the NCCL M2N weight transfer backend.
For every incoming checkpoint parameter the worker needs a destination buffer
plus its placement over the inference mesh. Two outcomes are possible:
* **direct** — when the model is not quantized, the parameter maps 1:1 onto a
live vLLM parameter and its loader is a small, known-safe copy or tensor-
parallel loader. The loader's declared input/output dimension determines the
placement; shapes are validation, not an inference mechanism. The reshard
writes straight into the live parameter, so each rank receives only its own
shard and nothing is copied afterwards.
* **fallback** — anything else. The reshard delivers the whole tensor to every
rank and `load_weights` does the sharding, exactly as the broadcast NCCL
backend does. Fused parameters (`qkv_proj`, `gate_up_proj`, MoE `w13`/`w2`)
take this path: the checkpoint name does not name a vLLM parameter, so there
is nothing to resolve against.
Correctness never depends on a parameter resolving — the fallback is always
available and is the same path the existing backend uses.
"""
import inspect
import math
from collections.abc import Sequence
from dataclasses import dataclass
from functools import partial
from typing import cast
import torch
from vllm.distributed.weight_transfer.m2n_common import (
DESTINATION_REPLICA_AXIS,
DESTINATION_SHARD_AXIS,
MESH_NDIMS,
REPLICATE,
REPLICATED,
Placements,
)
from vllm.logger import init_logger
logger = init_logger(__name__)
_DIRECT_WEIGHT_LOADING_BLOCKLIST = {
"vllm.model_executor.models.gpt2.GPT2Model",
}
def _module_type_name(module: torch.nn.Module) -> str:
cls = type(module)
return f"{cls.__module__}.{cls.__name__}"
def _destination_placements(shard_dim: int) -> Placements:
"""Place one tensor shard on the configured destination mesh axis."""
placements = [REPLICATE] * MESH_NDIMS
placements[DESTINATION_REPLICA_AXIS] = REPLICATE
placements[DESTINATION_SHARD_AXIS] = shard_dim
return cast(Placements, tuple(placements))
@dataclass
class M2NDestination:
"""Where one checkpoint parameter lands on this worker."""
name: str
placements: Placements | None
"""Placement over the inference mesh, or `REPLICATED` for the fallback."""
tensor: torch.Tensor | None
"""The live parameter view to reshard into, or None for the fallback path
(the engine allocates a full replica per round and calls `load_weights`)."""
@property
def direct(self) -> bool:
return self.tensor is not None
def _base_loader_shard_dim(
param: torch.nn.Parameter,
shard_axis_size: int,
) -> int | None:
"""Return a known-safe loader's declared shard dim, or reject it.
Loader identity is deliberately fail-closed. A missing loader is not
treated as ``default_weight_loader`` because a model-level ``load_weights``
implementation may transform the tensor before it reaches that default.
Wrappers and partials are also rejected: recognizing their wrapped callable
would ignore behavior added by the wrapper.
"""
loader = getattr(param, "weight_loader", None)
if loader is None or isinstance(loader, partial):
return None
owner = loader.__self__ if inspect.ismethod(loader) else None
loader_fn = loader.__func__ if inspect.ismethod(loader) else loader
# Lazy imports avoid making the weight-transfer registry import all model
# executor layers during normal vLLM startup.
from vllm.model_executor.layers.linear import (
ColumnParallelLinear,
ReplicatedLinear,
RowParallelLinear,
)
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
replicated_loaders = {
default_weight_loader,
ReplicatedLinear.weight_loader,
}
column_loaders = {
ColumnParallelLinear.weight_loader,
ColumnParallelLinear.weight_loader_v2,
}
row_loaders = {
RowParallelLinear.weight_loader,
RowParallelLinear.weight_loader_v2,
}
# These attributes signal packing, a transform, or pre-sharded checkpoint
# storage. Such semantics cannot be reproduced by a plain M2N placement.
# Check them before accepting replicated loaders too: model-level loading
# may transform a tensor before handing it to an otherwise plain copier.
metadata_attrs = ("packed_dim", "packed_factor", "marlin_tile_size")
flag_attrs = ("needs_scalar_to_array", "is_sharded_weight", "is_transposed")
if any(getattr(param, attr, None) is not None for attr in metadata_attrs):
return None
if any(bool(getattr(param, attr, False)) for attr in flag_attrs):
return None
if loader_fn in replicated_loaders:
return REPLICATE
if loader_fn not in column_loaders | row_loaders:
return None
if owner is None or getattr(owner, "tp_size", None) != shard_axis_size:
return None
attr = "output_dim" if loader_fn in column_loaders else "input_dim"
dim = getattr(param, attr, None)
return dim if isinstance(dim, int) and dim >= 0 else None
def _validated_shard_dim(
global_shape: Sequence[int],
local_shape: Sequence[int],
declared_dim: int,
shard_axis_size: int,
) -> int | None:
"""Validate a loader-declared shard dimension against both shapes."""
if len(global_shape) != len(local_shape):
return None
if declared_dim == REPLICATE:
return REPLICATE if tuple(global_shape) == tuple(local_shape) else None
if declared_dim >= len(global_shape):
return None
for dim, (whole, local) in enumerate(zip(global_shape, local_shape)):
expected = local * shard_axis_size if dim == declared_dim else local
if whole != expected:
return None
return declared_dim
def resolve_parameter_destinations(
model: torch.nn.Module,
names: Sequence[str],
dtypes: Sequence[torch.dtype],
shapes: Sequence[Sequence[int]],
*,
num_workers: int,
shard_axis_size: int,
allow_direct: bool,
) -> list[M2NDestination]:
"""Build this worker's destination plan, one entry per checkpoint parameter.
Placements are relative to the inference mesh: axis 0 replicates and axis 1
shards. `allow_direct=False` forces every parameter onto the fallback path;
the engine sets it for pipeline-parallel or quantized deployments, where a
parameter's local shape is not simply the checkpoint shape split across the
shard axis. Parameters below a model type with known model-level checkpoint
preprocessing also fall back, since bypassing `load_weights` would skip the
transformation.
"""
params = dict(model.named_parameters()) if allow_direct else {}
blocked_param_ids = {
id(param)
for module in model.modules()
if _module_type_name(module) in _DIRECT_WEIGHT_LOADING_BLOCKLIST
for param in module.parameters()
}
destinations: list[M2NDestination] = []
for name, dtype, shape in zip(names, dtypes, shapes):
param = params.get(name)
dim = None
if (
param is not None
and id(param) not in blocked_param_ids
and param.dtype == dtype
and param.data.is_contiguous()
and num_workers % shard_axis_size == 0
):
declared_dim = _base_loader_shard_dim(param, shard_axis_size)
if declared_dim is not None:
dim = _validated_shard_dim(
shape, param.shape, declared_dim, shard_axis_size
)
if dim is None:
# A replicated parameter is identical on every rank, so it needs no
# placement of its own — REPLICATED lets resolve_layout spread it
# over all the workers regardless of how the mesh is factored.
destinations.append(M2NDestination(name, REPLICATED, None))
elif dim == REPLICATE:
assert param is not None
destinations.append(M2NDestination(name, REPLICATED, param.data))
else:
assert param is not None
destinations.append(
M2NDestination(name, _destination_placements(dim), param.data)
)
# Layerwise reload finalizes a module as a unit. Mixing direct and fallback
# parameters within one module can copy stale storage over the direct load.
fallback_modules = {
name.rpartition(".")[0]
for name, destination in zip(names, destinations)
if not destination.direct and name in params
}
destinations = [
M2NDestination(destination.name, REPLICATED, None)
if destination.direct
and destination.name.rpartition(".")[0] in fallback_modules
else destination
for destination in destinations
]
num_direct = sum(d.direct for d in destinations)
parameter_bytes = [
math.prod(shape) * dtype.itemsize for dtype, shape in zip(dtypes, shapes)
]
total_bytes = sum(parameter_bytes)
direct_bytes = sum(
nbytes
for destination, nbytes in zip(destinations, parameter_bytes)
if destination.direct
)
direct_byte_percentage = 100 * direct_bytes / total_bytes if total_bytes else 0
logger.info(
"nccl_m2n destination plan: %d/%d parameters resharded directly into "
"the model, %d via full-tensor fallback; direct byte coverage: "
"%d/%d bytes (%.1f%%)",
num_direct,
len(destinations),
len(destinations) - num_direct,
direct_bytes,
total_bytes,
direct_byte_percentage,
)
return destinations
@@ -0,0 +1,155 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Trainer-side weight sources for the NCCL M2N backend.
m2n plans a transfer from both sides' layouts, so the trainer has to say how it
holds each parameter — which the base `WeightSource` / `ParamMeta` pair does not
express. This module supplies the m2n flavor of both, and the DTensor-backed
implementation that covers the common trainer.
A source declares its mesh once (`mesh()`) and a placement per parameter, since
one topology describes every tensor on the side.
"""
from collections.abc import Iterator
from typing import Any
import torch
from vllm.distributed.weight_transfer.base import ParamMeta, WeightSource
from vllm.distributed.weight_transfer.m2n_common import (
REPLICATE,
REPLICATED,
M2NMesh,
M2NParamMeta,
Placements,
)
__all__ = [
"M2NWeightSource",
"DTensorModuleSource",
"mesh_from_tensor",
"placements_from_tensor",
]
class M2NWeightSource(WeightSource):
"""A `WeightSource` that also describes how the trainer holds its weights.
Unlike `ModuleSource`, iteration yields each rank's **local shard**, not a
materialized full tensor: gathering is exactly the cost m2n removes.
"""
def mesh(self) -> M2NMesh:
"""The trainer's rank topology, shared by every parameter."""
raise NotImplementedError
def metadata(self) -> list[ParamMeta]:
"""Name, dtype, full shape, and trainer placement for each parameter."""
raise NotImplementedError
def __iter__(self) -> Iterator[tuple[str, torch.Tensor]]:
"""Yield `(name, local shard)` pairs in the same order as `metadata()`."""
raise NotImplementedError
def _placement_code(placement: Any) -> int:
"""Map a `torch.distributed` placement onto an m2n placement code."""
name = type(placement).__name__
if name == "Replicate":
return REPLICATE
if name == "Shard":
return int(placement.dim)
raise ValueError(
f"nccl_m2n cannot express the {name} placement; only Replicate and "
"Shard are supported"
)
def mesh_from_tensor(tensor: torch.Tensor, num_trainer_ranks: int) -> M2NMesh:
"""The mesh a parameter lives on, as an `M2NMesh`.
A plain tensor is identical on every trainer rank, so it spans all of them.
"""
device_mesh = getattr(tensor, "device_mesh", None)
if device_mesh is None:
return M2NMesh((num_trainer_ranks, 1), 0)
grid = device_mesh.mesh
ranks = grid.flatten().tolist()
if ranks != list(range(len(ranks))):
raise ValueError(
"nccl_m2n requires the trainer's device mesh to cover the "
f"contiguous rank interval [0, {len(ranks)}); got {ranks}"
)
if grid.ndim > 2:
raise ValueError(
f"nccl_m2n supports 1-D and 2-D device meshes, got {grid.ndim}-D"
)
dims = tuple(grid.shape)
return M2NMesh(dims if len(dims) == 2 else (dims[0], 1), 0)
def placements_from_tensor(tensor: torch.Tensor) -> Placements | None:
"""How a parameter is placed over its mesh, or `REPLICATED`.
`REPLICATED` is returned rather than a `(REPLICATE, REPLICATE)` pair: m2n
cannot express that, and the size-1-axis encoding it needs instead is
applied later by `resolve_layout`, which owns that workaround.
"""
placements = getattr(tensor, "placements", None)
if placements is None:
return REPLICATED
codes = [_placement_code(p) for p in placements]
if all(code == REPLICATE for code in codes):
return REPLICATED
if len(codes) == 1:
return (codes[0], REPLICATE)
return (codes[0], codes[1])
class DTensorModuleSource(M2NWeightSource):
"""`M2NWeightSource` over `module.named_parameters()`.
Covers both the FSDP/DTensor trainer (placement read off each parameter,
local shard yielded via `to_local()`) and the plain replicated trainer, with
no special casing. Trainers with a custom producer (a Megatron export, MoE
re-fusing) subclass `M2NWeightSource` instead.
"""
def __init__(self, module: torch.nn.Module, num_trainer_ranks: int) -> None:
"""Wrap `module`; `num_trainer_ranks` sizes the mesh for plain tensors."""
self._module = module
self._num_trainer_ranks = num_trainer_ranks
def mesh(self) -> M2NMesh:
"""The mesh every sharded parameter agrees on.
Replicated parameters span the trainer without a device mesh of their
own, so they never decide this; a model whose *sharded* parameters
disagree cannot be described by one side mesh and is rejected.
"""
meshes = {
mesh_from_tensor(p, self._num_trainer_ranks)
for _, p in self._module.named_parameters()
if placements_from_tensor(p) is not REPLICATED
}
if len(meshes) > 1:
raise ValueError(
"nccl_m2n needs one mesh per side, but this module's sharded "
f"parameters span several: {sorted(m.dims for m in meshes)}"
)
return meshes.pop() if meshes else M2NMesh((self._num_trainer_ranks, 1), 0)
def metadata(self) -> list[ParamMeta]:
"""Read global shape/dtype and placements without gathering shards."""
return [
M2NParamMeta(name, p.dtype, tuple(p.shape), placements_from_tensor(p))
for name, p in self._module.named_parameters()
]
def __iter__(self) -> Iterator[tuple[str, torch.Tensor]]:
"""Yield each parameter's local shard (`to_local()`), or the tensor itself."""
for name, param in self._module.named_parameters():
to_local = getattr(param, "to_local", None)
yield name, (to_local() if callable(to_local) else param)
@@ -0,0 +1,414 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Trainer-side weight transfer engine for the NCCL M2N backend.
Symmetric to `M2NWeightTransferEngine` but in the training process. Every
trainer rank joins the shared communicator and runs every reshard, sending its
own local shard; only rank 0 touches the inference control plane.
Workers join the communicator during init and run reshard from inside
`update_weights`, so those two RPCs overlap with work on this side. Both run on
a helper thread and are joined afterwards, which keeps that overlap inside the
engine -- where the `TrainerWeightTransferEngine` contract puts it -- rather than
in every caller.
"""
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar, cast
import torch
from typing_extensions import Self
from vllm.distributed.weight_transfer.base import (
TrainerInitInfo,
TrainerWeightTransferEngine,
VLLMWeightSyncClient,
WeightSource,
)
from vllm.distributed.weight_transfer.m2n_common import (
M2NMesh,
M2NParamMeta,
Placements,
check_transferable,
comm_ptr,
import_m2n,
publish_destination_placements,
resolve_layout,
to_mesh,
to_placements,
validate_layout,
)
from vllm.distributed.weight_transfer.m2n_source import M2NWeightSource
from vllm.distributed.weight_transfer.nccl_common import (
decode_nccl_unique_id,
trainer_init,
uid_init_process_group,
)
from vllm.logger import init_logger
if TYPE_CHECKING:
from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
logger = init_logger(__name__)
__all__ = ["M2NTrainerInitInfo", "M2NTrainerWeightTransferEngine"]
def _dtype_name(dtype: torch.dtype) -> str:
"""Short name for a dtype, e.g. `bfloat16`, for the worker-side init dict."""
return str(dtype).split(".")[-1]
@dataclass(kw_only=True)
class M2NTrainerInitInfo(TrainerInitInfo):
"""Trainer-side init info for nccl_m2n.
`rank` (from `TrainerInitInfo`) is this trainer process's rank; it is also
its rank in the shared communicator, since the trainer occupies
`[0, num_trainer_ranks)`. Rank 0 drives the control plane.
"""
backend: ClassVar[str] = "nccl_m2n"
master_address: str | None = None
master_port: int | None = None
world_size: int
"""Trainer ranks + all inference workers."""
num_trainer_ranks: int = 1
dst_mesh_dims: tuple[int, int] | None = None
"""How the inference ranks are laid out: axis 0 replicates and axis 1
shards. The trainer declares it so both sides describe the destination
identically; defaults to a flat `(num_workers, 1)`, which is all a
replicated destination needs."""
max_cta: int | None = None
nccl_unique_id_b64: str | None = field(default=None, repr=False)
@property
def destination_mesh_dims(self) -> tuple[int, int]:
"""`dst_mesh_dims`, or a flat mesh over every inference worker."""
if self.dst_mesh_dims is not None:
return self.dst_mesh_dims
return (self.world_size - self.num_trainer_ranks, 1)
def __post_init__(self) -> None:
"""Reject rank counts or destination meshes that cannot form a group."""
_ = self.nccl_unique_id_bytes
if not 0 < self.num_trainer_ranks < self.world_size:
raise ValueError(
f"`num_trainer_ranks` ({self.num_trainer_ranks}) must leave at "
f"least one worker in world_size {self.world_size}"
)
num_workers = self.world_size - self.num_trainer_ranks
if self.dst_mesh_dims is not None:
dims = tuple(self.dst_mesh_dims)
if len(dims) != 2 or dims[0] * dims[1] != num_workers:
raise ValueError(
f"`dst_mesh_dims` {dims} must be two dims covering the "
f"{num_workers} inference workers"
)
if not 0 <= self.rank < self.num_trainer_ranks:
raise ValueError(
f"trainer `rank` ({self.rank}) must be below "
f"`num_trainer_ranks` ({self.num_trainer_ranks})"
)
@property
def nccl_unique_id_bytes(self) -> bytes | None:
return decode_nccl_unique_id(
master_address=self.master_address,
master_port=self.master_port,
nccl_unique_id_b64=self.nccl_unique_id_b64,
ctx="M2NTrainerInitInfo",
)
class M2NTrainerWeightTransferEngine(TrainerWeightTransferEngine[M2NTrainerInitInfo]):
"""Trainer-side engine: sends every rank's local shard, once per parameter.
Called on every trainer rank. All ranks join the communicator and run every
reshard; only rank 0 touches the control plane.
"""
init_info_cls = M2NTrainerInitInfo
def __init__(
self,
*,
client: VLLMWeightSyncClient,
source: M2NWeightSource,
is_controller: bool,
num_trainer_ranks: int,
) -> None:
"""Hold the client and source; `trainer_init` fills in the group."""
super().__init__(client=client, source=source, is_sender=is_controller)
self.source: M2NWeightSource = source
self.is_controller = is_controller
self.num_trainer_ranks = num_trainer_ranks
self.group: PyNcclCommunicator | None = None
self._m2n: Any = None
self._handle: Any = None
self._metas: list[M2NParamMeta] = []
self._src_mesh: M2NMesh | None = None
self._dst_mesh: M2NMesh | None = None
self._dst_placements: list[Placements | None] = []
self._executor: ThreadPoolExecutor | None = None
@classmethod
def trainer_init(
cls,
init_info: M2NTrainerInitInfo,
*,
client: VLLMWeightSyncClient,
source: WeightSource | None = None,
) -> Self:
"""Build the engine and rendezvous with the inference side.
Runs on *every* trainer rank. Rank 0 additionally ships the transfer
plan to the workers and drives the control plane; the other ranks only
build local state and join the communicator. Every rank participates
in every reshard and sends its local shard.
The ordering here is not arbitrary -- see the comments inline. In
short: validate before anything blocks; start the init RPC without
waiting (workers join the communicator inside that RPC, so it cannot
return until this side has joined too); then join the NCCL
communicator; then create the M2N handle; and only then wait for the RPC
to finish.
"""
# The base WeightSource carries name/dtype/shape but not layout, which
# is not enough to plan a reshard. Fail here rather than at the first
# missing attribute deep inside the send loop.
if not isinstance(source, M2NWeightSource):
raise TypeError(
"nccl_m2n needs per-parameter layouts, so its source must be an "
f"M2NWeightSource (e.g. DTensorModuleSource); got "
f"{type(source).__name__}"
)
# Trainer-side weight transfer engine for this rank. `__init__` only
# holds the client and source; the communicator, meshes, and M2N handle
# are filled in below.
engine = cls(
client=client,
source=source,
is_controller=init_info.rank == 0,
num_trainer_ranks=init_info.num_trainer_ranks,
)
engine._m2n = import_m2n()
engine._src_mesh = source.mesh()
if engine._src_mesh.size != init_info.num_trainer_ranks:
raise ValueError(
f"source mesh covers {engine._src_mesh.size} ranks, but there "
f"are {init_info.num_trainer_ranks} trainer ranks"
)
# Validate the whole plan before any rendezvous. A layout m2n cannot
# express is a mismatched collective at send time, which hangs rather
# than raises -- so every precondition is checked while an exception can
# still propagate to the caller.
engine._metas = cast(list[M2NParamMeta], source.metadata())
for meta in engine._metas:
check_transferable(meta.name, meta.dtype, meta.shape)
mesh, placements = resolve_layout(engine._src_mesh, meta.placements)
validate_layout(mesh, placements, meta.shape, "source")
rendezvous: Future | None = None
if engine.is_controller:
engine._executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="m2n-weight-sync"
)
# Only trainer rank 0 starts the control-plane RPC. Workers join
# the communicator inside it, so submit it first, join below, and
# wait for it at the end.
rendezvous = engine._executor.submit(
client.init_weight_transfer_engine,
engine._worker_init_info(init_info),
)
# Every trainer rank joins -- reshard is collective over the whole
# communicator. Trainer ranks take
# [0, num_trainer_ranks) and the workers follow, which is the
# contiguous-interval layout m2n requires of both meshes.
unique_id_bytes = init_info.nccl_unique_id_bytes
if unique_id_bytes is not None:
engine.group = uid_init_process_group(
unique_id_bytes,
rank=init_info.rank,
world_size=init_info.world_size,
device=torch.accelerator.current_device_index(),
)
else:
assert init_info.master_address is not None
assert init_info.master_port is not None
engine.group = trainer_init(
{
"master_address": init_info.master_address,
"master_port": init_info.master_port,
"world_size": init_info.world_size,
},
rank=init_info.rank,
)
# The trainer declares the inference topology; the worker uses exactly
# this, so neither side has to infer the other's factorization.
engine._dst_mesh = M2NMesh(
init_info.destination_mesh_dims, init_info.num_trainer_ranks
)
engine._dst_placements = engine._receive_destination_plan(
init_info.num_trainer_ranks
)
logger.info(
"nccl_m2n trainer ready: %d parameters, trainer ranks [0, %d), "
"inference ranks [%d, %d)",
len(engine._metas),
init_info.num_trainer_ranks,
init_info.num_trainer_ranks,
init_info.world_size,
)
# Created after the group so a failed rendezvous does not leave a live
# handle behind; shutdown() releases it once caller streams are synced.
engine._handle = engine._m2n.Handle.create(
engine._m2n.Config(max_cta=init_info.max_cta)
)
# Now safe to collect: both sides have joined, so the RPC has returned
# or raised. Doing this last means a worker-side init failure surfaces
# here, as an exception from trainer_init, rather than at the first send.
if rendezvous is not None:
rendezvous.result()
return engine
def _receive_destination_plan(
self, first_worker_rank: int
) -> list[Placements | None]:
"""Receive and validate the inference-side per-parameter placements."""
if self.group is None or self._dst_mesh is None:
raise RuntimeError(
"destination plan requires the communicator and destination mesh"
)
# The first inference worker publishes one placement per parameter.
# Every trainer rank receives the complete plan in this single,
# initialization-time broadcast.
placements = publish_destination_placements(
self.group, first_worker_rank, None, len(self._metas)
)
# The send loop matches placements to metadata by position, so reject
# an incomplete or oversized plan before entering any M2N collective.
if len(placements) != len(self._metas):
raise RuntimeError(
f"inference side planned {len(placements)} parameters, but "
f"this trainer announced {len(self._metas)}"
)
# Check every worker-selected destination against the corresponding
# global parameter shape before the first transfer.
for meta, placement in zip(self._metas, placements):
mesh, resolved = resolve_layout(self._dst_mesh, placement)
validate_layout(mesh, resolved, meta.shape, "destination")
return placements
def _worker_init_info(self, init_info: M2NTrainerInitInfo) -> dict[str, Any]:
"""Handshake payload: rendezvous, both meshes, and the transfer plan."""
assert self._src_mesh is not None
payload: dict[str, Any] = {
"master_address": init_info.master_address,
"master_port": init_info.master_port,
"nccl_unique_id_b64": init_info.nccl_unique_id_b64,
"rank_offset": init_info.num_trainer_ranks,
"world_size": init_info.world_size,
"src_mesh_dims": list(self._src_mesh.dims),
"dst_mesh_dims": list(init_info.destination_mesh_dims),
"names": [m.name for m in self._metas],
"dtype_names": [_dtype_name(m.dtype) for m in self._metas],
"shapes": [list(m.shape) for m in self._metas],
"src_placements": [
None if m.placements is None else list(m.placements)
for m in self._metas
],
"max_cta": init_info.max_cta,
}
return {key: value for key, value in payload.items() if value is not None}
def send_weights(self) -> None:
"""Drive one update round: start, reshard concurrently, then finish."""
if self._handle is None or self.group is None:
raise RuntimeError("nccl_m2n trainer engine is not initialized")
update: Future | None = None
if self.is_controller:
self.client.start_weight_update()
# The workers reshard from inside `update_weights`, concurrently
# with the sends below.
assert self._executor is not None
update = self._executor.submit(
self.client.update_weights,
{"names": [m.name for m in self._metas]},
)
try:
self._send()
finally:
if update is not None:
update.result()
if self.is_controller:
self.client.finish_weight_update()
def _send(self) -> None:
"""Reshard each local shard into its worker-planned destination."""
assert self.group is not None
assert self._src_mesh is not None
assert self._dst_mesh is not None
m2n = self._m2n
comm = comm_ptr(self.group)
stream = torch.cuda.current_stream()
for index, (name, tensor) in enumerate(self.source):
meta = self._metas[index]
if name != meta.name:
raise RuntimeError(
f"weight source yielded '{name}' at position {index}, but "
f"its metadata declared '{meta.name}'; the source must "
"iterate in a stable order"
)
shard = tensor.detach().contiguous()
src_mesh, src_placements = resolve_layout(self._src_mesh, meta.placements)
dst_mesh, dst_placements = resolve_layout(
self._dst_mesh, self._dst_placements[index]
)
m2n.reshard(
shard,
None,
comm,
stream,
src_mesh=to_mesh(m2n, src_mesh),
src_placements=to_placements(m2n, src_placements),
dst_mesh=to_mesh(m2n, dst_mesh),
dst_placements=to_placements(m2n, dst_placements),
handle=self._handle,
)
# A custom source may yield temporary storage allocated on another
# stream. M2N retains only its device pointer for asynchronous work,
# so tell the allocator not to reuse that storage until this stream
# has finished with it. For storage allocated on this stream,
# record_stream is effectively a no-op.
shard.record_stream(stream)
# As in the other weight-transfer backends, wait before returning
# rather than relying on same-stream ordering. The caller may then
# safely mutate parameters or continue work on another stream.
stream.synchronize()
def shutdown(self) -> None:
"""Destroy the m2n handle, join the helper thread, and drop the group."""
if self._handle is not None:
torch.accelerator.synchronize()
self._handle.destroy()
self._handle = None
if self._executor is not None:
self._executor.shutdown()
self._executor = None
self.group = None
@@ -220,11 +220,11 @@ def worker_init_process_group(
def trainer_init(
init_info: NCCLRendezvous | dict,
rank: int = 0,
) -> "PyNcclCommunicator":
"""Initialize NCCL process group for trainer-side weight transfer.
The trainer is always rank 0 in the process group. Uses the current
CUDA device (torch.accelerator.current_device_index()).
Uses the current CUDA device (torch.accelerator.current_device_index()).
Args:
init_info: Any object carrying the `NCCLRendezvous` fields (a trainer or
@@ -232,6 +232,9 @@ def trainer_init(
- master_address: str
- master_port: int
- world_size: int
rank: This trainer process's rank in the group. The broadcast backends
have a single trainer at rank 0; multi-rank trainers (m2n) occupy
ranks `[0, num_trainer_ranks)` and the workers start after them.
Returns:
PyNcclCommunicator for weight transfer.
@@ -246,12 +249,11 @@ def trainer_init(
master_port = init_info.master_port
world_size = init_info.world_size
# Trainer is always rank 0
device = torch.accelerator.current_device_index()
return stateless_init_process_group(
master_address,
master_port,
0,
rank,
world_size,
device,
)