RLHF Sharded Rdt¶
Source https://github.com/vllm-project/vllm/blob/main/examples/rl/rlhf_sharded_rdt.py.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Minimal RLHF weight sync with the **sharded** RDT (Ray Direct Transport)
weight-transfer backend.
The sharded backend pulls only the *slice* of each weight that the local
worker actually consumes, rather than the full HF-shaped tensor: at
``init_weight_transfer_engine`` the engine dry-runs vLLM's own
``model.load_weights`` against lazy placeholders and BAKES, per checkpoint
name, the op chain the loader applied (the slice) and the destination
param/offsets. Every ``update_weights`` replays that plan: the worker sends
the specs to the trainer in packed chunk pulls (one contiguous NIXL blob per
pull, received into a pre-registered ring of arenas) and scatters/quantizes
on background threads. The trainer side is the shared
:class:`rdt_producer.RDTShardedProducer`.
This is the minimal, single-node (3 GPU: 1 trainer + TP-2 inference),
gather-free variant: the trainer holds the full model resident and serves
slices of the LIVE parameters, so there is no gather plan and the engine's
per-group ``free_gather`` calls are no-ops. See rlhf_sharded_rdt_fsdp_ep.py
(FSDP-sharded trainer, per-group gathers) and rlhf_sharded_rdt_kimi.py
(1T FP8 MoE) for the full-scale variants.
Prerequisites:
pip install nixl
"""
import os
import sys
import ray
import torch
from ray.util.placement_group import placement_group
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
from transformers import AutoModelForCausalLM
from vllm import LLM, SamplingParams
from vllm.config import WeightTransferConfig
from rdt_producer import RDTShardedProducer, layerwise_groups
MODEL_NAME = "Qwen/Qwen3-30B-A3B"
TRAINER_ACTOR_NAME = "sharded_rdt_trainer"
# Explicit namespace so vLLM workers -- which run in an EngineCore
# subprocess that does its own ray.init() -- can resolve the named
# trainer actor.
RAY_NAMESPACE = "sharded_rdt_example"
class MyLLM(LLM):
"""Configure the vLLM worker for Ray placement group execution."""
def __init__(self, *args, **kwargs):
os.environ["VLLM_RAY_BUNDLE_INDICES"] = "0,1"
super().__init__(*args, **kwargs)
@ray.remote(num_gpus=1, max_concurrency=8, enable_tensor_transport=True)
class TrainModel(RDTShardedProducer):
"""Trainer actor: the full HF model resident on one GPU, serving packed
slice pulls of its LIVE parameters (no gather plan needed — the shared
producer's cache is pre-populated once and never freed)."""
def __init__(self, model_name: str):
self.model = AutoModelForCausalLM.from_pretrained(model_name).to("cuda:0")
# M:N: this single trainer serves TP-2 inference (2 consumers), so per-
# consumer serve rings keep their interleaved pulls off each other's slots.
# (gather-free -> free_gather is a no-op, but the ring count still matters.)
self.init_rdt_producer(num_consumers=2) # tensor_parallel_size * data_parallel_size
# Live parameters ARE the serve cache: produce replays each spec's op
# chain on them and packs the slices into the registered serve ring.
# PyTorch parameters mutate in place during training, so the cached
# references stay valid across syncs.
with self._cache_cond:
self._cache.update(dict(self.model.named_parameters()))
self._cache_cond.notify_all()
def get_weight_metadata(self):
"""Weight names/dtypes/shapes for the engine's bake at init."""
names, dtype_names, shapes = [], [], []
for name, p in self.model.named_parameters():
names.append(name)
dtype_names.append(str(p.dtype).split(".")[-1])
shapes.append(list(p.shape))
return names, dtype_names, shapes
# Pin Ray-actor processes to the same Python interpreter as the driver, and
# ship this example directory so actors can import rdt_producer.
_RUNTIME_ENV: dict[str, object] = {
"py_executable": sys.executable,
"working_dir": os.path.dirname(os.path.abspath(__file__)),
}
_FORWARDED_ENV_VARS = {
k: os.environ[k]
for k in ("NCCL_CUMEM_ENABLE", "VLLM_NCCL_SO_PATH", "LD_PRELOAD")
if k in os.environ
}
if _FORWARDED_ENV_VARS:
_RUNTIME_ENV["env_vars"] = _FORWARDED_ENV_VARS
ray.init(runtime_env=_RUNTIME_ENV, namespace=RAY_NAMESPACE)
train_model = TrainModel.options(name=TRAINER_ACTOR_NAME).remote(MODEL_NAME)
pg_inference = placement_group([{"GPU": 1, "CPU": 0}] * 2)
ray.get(pg_inference.ready())
scheduling_inference = PlacementGroupSchedulingStrategy(
placement_group=pg_inference,
placement_group_capture_child_tasks=True,
placement_group_bundle_index=0,
)
# distributed_executor_backend="ray" is REQUIRED: each vLLM worker
# must be a Ray actor so it can call ray.get_actor() and submit
# .remote() tasks against the trainer.
llm = ray.remote(
num_cpus=0,
num_gpus=0,
scheduling_strategy=scheduling_inference,
)(MyLLM).remote(
model=MODEL_NAME,
enforce_eager=True,
tensor_parallel_size=2,
data_parallel_size=1,
distributed_executor_backend="ray",
weight_transfer_config=WeightTransferConfig(backend="sharded_rdt"),
load_format="dummy",
quantization="fp8",
)
prompts = [
"Hello, my name is",
"The president of the United States is",
"The capital of France is",
"The future of AI is",
]
sampling_params = SamplingParams(temperature=0)
# First generation: dummy weights, output is expected to be nonsense.
outputs = ray.get(llm.generate.remote(prompts, sampling_params))
print("-" * 50)
print("Before weight sync (dummy weights):")
for output in outputs:
print(f"Prompt: {output.prompt!r}\nGenerated text: {output.outputs[0].text!r}")
print("-" * 50)
ray.get(llm.sleep.remote(level=0))
# The engine bakes its replay plan over ALL names at init (a meta dry run of
# model.load_weights), so the metadata goes in the INIT info.
names, dtype_names, shapes = ray.get(train_model.get_weight_metadata.remote())
ray.get(
llm.init_weight_transfer_engine.remote(
dict(
init_info=dict(
trainer_actor_name=TRAINER_ACTOR_NAME,
trainer_actor_namespace=RAY_NAMESPACE,
names=names,
dtype_names=dtype_names,
shapes=shapes,
)
)
)
)
# is_checkpoint_format=True is MANDATORY for the sharded backend --
# it requires the layerwise reload path.
ray.get(llm.start_weight_update.remote(is_checkpoint_format=True))
# ONE update_weights for the whole sync. group_lens partitions the names into
# per-layer groups: the group is the packed pull's chunk budget, so the
# receive/serve arenas stay layer-sized instead of ballooning to the whole
# model. (The trainer serves live params, so there is no gather plan and the
# engine's per-group free_gather calls are no-ops.)
groups = layerwise_groups(names)
ray.get(llm.update_weights.remote(dict(update_info=dict(
names=[n for g in groups for n in g],
group_lens=[len(g) for g in groups],
))))
ray.get(llm.finish_weight_update.remote())
ray.get(llm.wake_up.remote(tags=["scheduling"]))
# Second generation: output should now be coherent.
outputs_updated = ray.get(llm.generate.remote(prompts, sampling_params))
print("-" * 50)
print("After weight sync (trainer slices pulled via sharded RDT/NIXL):")
for output in outputs_updated:
print(f"Prompt: {output.prompt!r}\nGenerated text: {output.outputs[0].text!r}")
print("-" * 50)