RLHF Http IPC No vLLM¶
Source https://github.com/vllm-project/vllm/blob/main/examples/rl/rlhf_http_ipc_no_vllm.py.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CUDA IPC 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/ipc.md` ("Trainer Without vLLM over HTTP"):
it exports its tensors with `torch.multiprocessing.reductions.reduce_tensor`
and sends the handles, pickled and base64-encoded, in `ipc_handles_pickled`.
IPC requires colocation: this process holds a full copy of the weights on every
GPU the server uses, and keys each handle by that GPU's UUID. A multi-process
trainer would instead gather the per-rank `{uuid: args}` dicts to one rank.
Start a server; IPC handles are pickled, so it needs
`VLLM_ALLOW_INSECURE_SERIALIZATION=1` (the weights it starts with are discarded):
VLLM_SERVER_DEV_MODE=1 VLLM_ALLOW_INSECURE_SERIALIZATION=1 \
CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen3-0.6B \
--load-format dummy --enforce-eager --gpu-memory-utilization 0.4 \
--weight-transfer-config '{"backend": "ipc"}'
Then run the trainer on the same GPU(s) (`--packed` for chunked transfer):
CUDA_VISIBLE_DEVICES=0 python examples/rl/rlhf_http_ipc_no_vllm.py \
--checkpoint /path/to/Qwen3-0.6B
For a TP=2 server on GPUs 0-1, use `CUDA_VISIBLE_DEVICES=0,1` on both sides and
pass `--num-gpus 2` to the trainer.
"""
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 glob # noqa: E402
import pickle # noqa: E402
import pybase64 as base64 # noqa: E402
import requests # noqa: E402
import torch # noqa: E402
from safetensors.torch import load_file # noqa: E402
from torch.multiprocessing.reductions import reduce_tensor # 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("--num-gpus", type=int, default=1)
parser.add_argument("--packed", action="store_true")
parser.add_argument("--packed-buffer-size-bytes", type=int, default=512 << 20)
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()
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"])]
# Weights are {gpu_uuid: {name: tensor}}: one full copy per colocated GPU. The
# UUID is str(torch.cuda.get_device_properties(i).uuid), with no "GPU-" prefix.
Weights = dict[str, dict[str, torch.Tensor]]
def update_weights(
weights: Weights,
names: list[str],
ipc_handles: list[dict[str, tuple]] | dict[str, tuple],
tensor_sizes: list[int] | None = None,
) -> None:
w = next(iter(weights.values()))
update_info = {
"names": names,
"dtype_names": [str(w[n].dtype).removeprefix("torch.") for n in names],
"shapes": [list(w[n].shape) for n in names],
"ipc_handles_pickled": base64.b64encode(pickle.dumps(ipc_handles)).decode(),
}
if tensor_sizes is not None:
update_info["tensor_sizes"] = tensor_sizes
post("/update_weights", {"update_info": update_info})
def send_unpacked(weights: Weights, names: list[str]) -> list[torch.Tensor]:
"""One /update_weights call; one {uuid: args} dict per parameter."""
copies = {u: {n: w[n].contiguous() for n in names} for u, w in weights.items()}
handles = [{u: reduce_tensor(copies[u][n])[1] for u in copies} for n in names]
update_weights(weights, names, handles)
return [t for c in copies.values() for t in c.values()]
def send_packed(weights: Weights, names: list[str], cap: int) -> list[torch.Tensor]:
"""One /update_weights call per chunk; one {uuid: args} dict per chunk."""
buffers = {
u: torch.empty(cap, dtype=torch.uint8, device=w[names[0]].device)
for u, w in weights.items()
}
handle = {u: reduce_tensor(b)[1] for u, b in buffers.items()} # export once
chunk: list[str] = []
sizes: list[int] = []
def flush() -> None:
for b in buffers.values():
torch.accelerator.synchronize(b.device) # copies must land before the read
update_weights(weights, chunk, handle, sizes)
chunk.clear()
sizes.clear()
for n in names:
nbytes = next(iter(weights.values()))[n].nbytes
assert nbytes <= cap, f"{n} ({nbytes} B) exceeds the buffer"
if chunk and sum(sizes) + nbytes > cap: # close *before* overflowing
flush()
offset = sum(sizes)
for u, w in weights.items():
flat = w[n].contiguous().view(-1).view(torch.uint8)
buffers[u][offset : offset + nbytes].copy_(flat)
chunk.append(n)
sizes.append(nbytes)
if chunk:
flush()
return list(buffers.values())
def main() -> None:
weights: Weights = {}
for i in range(args.num_gpus):
uuid = str(torch.cuda.get_device_properties(i).uuid)
weights[uuid] = {}
for path in sorted(glob.glob(f"{args.checkpoint}/*.safetensors")):
weights[uuid].update(load_file(path, device=f"cuda:{i}"))
names = sorted(next(iter(weights.values())))
prompts = ["The capital of France is", "def fibonacci(n):"]
print("before:", generate(prompts))
post("/init_weight_transfer_engine", {"init_info": {"packed": args.packed}})
post("/start_weight_update")
if args.packed:
exported = send_packed(weights, names, args.packed_buffer_size_bytes)
else:
exported = send_unpacked(weights, names)
post("/finish_weight_update", {"weight_version": None})
# The worker reads the exported memory until finish; only then release it.
del exported
torch.cuda.ipc_collect()
print("after: ", generate(prompts))
assert not [m for m in sys.modules if m.split(".")[0] == "vllm"]
if __name__ == "__main__":
main()