Skip to content

RLHF Http NCCL No vLLM

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

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""NCCL weight sync over HTTP from a trainer that never imports vLLM.

The trainer uses only `torch`, `requests`, `safetensors`, `pybase64` and the
stdlib, and speaks the wire protocol in
`docs/training/weight_transfer/nccl.md` ("Trainer Without vLLM over HTTP"):
`nccl_unique_id_b64` rendezvous, a one-element warm-up `all_reduce`, then
`/start_weight_update` -> `/update_weights` (concurrent with the rank-0
broadcast) -> `/finish_weight_update`.

The NCCL communicator is a plain `torch.distributed.ProcessGroupNCCL` over a
private `TCPStore` that only the trainer joins. Its `ncclUniqueId` is read back
from store key "0", which is a torch implementation detail.

Start a server on GPUs 0-1 (the weights it starts with are discarded):

    VLLM_SERVER_DEV_MODE=1 CUDA_VISIBLE_DEVICES=0,1 vllm serve Qwen/Qwen3-0.6B \
        --tensor-parallel-size 2 --load-format dummy --enforce-eager \
        --weight-transfer-config '{"backend": "nccl"}'

Then run the trainer on GPU 2 (`--packed` for packed broadcasting):

    CUDA_VISIBLE_DEVICES=2 python examples/rl/rlhf_http_nccl_no_vllm.py \
        --checkpoint /path/to/Qwen3-0.6B
"""

import sys


class _BlockVLLM:
    def find_spec(self, name, path=None, target=None):
        if name.split(".")[0] == "vllm":
            raise ImportError(f"this trainer must not import {name!r}")
        return None


sys.meta_path.insert(0, _BlockVLLM())

import argparse  # noqa: E402
import datetime  # noqa: E402
import glob  # noqa: E402
import threading  # noqa: E402

import pybase64 as base64  # noqa: E402
import requests  # noqa: E402
import torch  # noqa: E402
import torch.distributed as dist  # noqa: E402
from safetensors.torch import load_file  # noqa: E402

parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://localhost:8000")
parser.add_argument("--checkpoint", required=True, help="dir with *.safetensors")
parser.add_argument("--packed", action="store_true")
parser.add_argument("--packed-buffer-size-bytes", type=int, default=64 << 20)
parser.add_argument("--store-port", type=int, default=29612)
args = parser.parse_args()


def post(path: str, body: dict | None = None) -> dict:
    r = requests.post(args.url + path, json=body or {}, timeout=600)
    r.raise_for_status()
    return r.json()


class PostInBackground(threading.Thread):
    """An HTTP call that only returns once this process joins the collective."""

    def __init__(self, path: str, body: dict) -> None:
        super().__init__()
        self.path, self.body = path, body
        self.error: BaseException | None = None
        self.start()

    def run(self) -> None:
        try:
            post(self.path, self.body)
        except BaseException as e:
            self.error = e

    def result(self) -> None:
        self.join()
        if self.error is not None:
            raise self.error


def generate(prompts: list[str]) -> list[str]:
    model = requests.get(args.url + "/v1/models", timeout=60).json()["data"][0]["id"]
    body = {"model": model, "prompt": prompts, "max_tokens": 16, "temperature": 0}
    choices = post("/v1/completions", body)["choices"]
    return [c["text"] for c in sorted(choices, key=lambda c: c["index"])]


def main() -> None:
    device = torch.device("cuda:0")
    torch.accelerator.set_device_index(device)
    weights: dict[str, torch.Tensor] = {}
    for path in sorted(glob.glob(f"{args.checkpoint}/*.safetensors")):
        weights.update(load_file(path, device="cuda:0"))
    names = sorted(weights)

    prompts = ["The capital of France is", "def fibonacci(n):"]
    print("before:", generate(prompts))

    # Trainer is NCCL rank 0; vLLM worker i is rank 1 + i.
    world_size = 1 + requests.get(args.url + "/get_world_size").json()["world_size"]
    store = dist.TCPStore(
        "127.0.0.1", args.store_port, is_master=True, wait_for_workers=False
    )
    pg = dist.ProcessGroupNCCL(store, 0, world_size, dist.ProcessGroupNCCL.Options())
    connect = threading.Thread(target=pg.eager_connect_single_device, args=(device,))
    connect.start()  # publishes the unique id, then waits in ncclCommInitRank
    reader = dist.TCPStore("127.0.0.1", args.store_port, is_master=False)
    reader.wait(["0"], datetime.timedelta(seconds=60))
    unique_id = reader.get("0")
    assert len(unique_id) == 128

    init = PostInBackground(
        "/init_weight_transfer_engine",
        {
            "init_info": {
                "nccl_unique_id_b64": base64.b64encode(unique_id).decode(),
                "rank_offset": 1,
                "world_size": world_size,
                "packed": args.packed,
                "packed_buffer_size_bytes": args.packed_buffer_size_bytes,
            }
        },
    )
    connect.join()
    pg.allreduce([torch.zeros(1, device=device)]).wait()  # vLLM's warm-up
    init.result()

    def broadcast(t: torch.Tensor) -> None:
        opts = dist.BroadcastOptions()
        opts.rootRank = 0
        pg.broadcast([t], opts).wait()

    post("/start_weight_update")
    update_info = {
        "names": names,
        "dtype_names": [str(weights[n].dtype).removeprefix("torch.") for n in names],
        "shapes": [list(weights[n].shape) for n in names],
    }
    update = PostInBackground("/update_weights", {"update_info": update_info})
    if not args.packed:
        for n in names:
            broadcast(weights[n].contiguous())
    else:
        # Same chunking as the worker: a chunk closes right after the tensor
        # that pushes it past the buffer size.
        chunk: list[torch.Tensor] = []
        size = 0
        for n in names:
            chunk.append(weights[n].contiguous().view(-1).view(torch.uint8))
            size += chunk[-1].numel()
            if size > args.packed_buffer_size_bytes:
                broadcast(torch.cat(chunk))
                chunk, size = [], 0
        if chunk:
            broadcast(torch.cat(chunk))
    torch.accelerator.synchronize()
    update.result()
    post("/finish_weight_update", {"weight_version": None})

    print("after: ", generate(prompts))
    post("/shutdown_weight_transfer_engine")
    pg.shutdown()

    assert not [m for m in sys.modules if m.split(".")[0] == "vllm"]


if __name__ == "__main__":
    main()