Skip to content

RLHF M2N

Source https://github.com/vllm-project/vllm/blob/main/examples/rl/rlhf_m2n.py.

# 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()