Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

1116 lines
47 KiB
Python

# Copyright (c) ModelScope Contributors. All rights reserved.
# Code partially sourced from Hugging Face TRL
# fmt: off
# apply patch before importing trl, which may internally reference GuidedDecodingParams
try:
import vllm
try:
from vllm.sampling_params import GuidedDecodingParams
except ImportError:
import vllm.sampling_params
# removed in https://github.com/vllm-project/vllm/pull/22772
vllm.sampling_params.GuidedDecodingParams = vllm.sampling_params.StructuredOutputsParams
except ImportError:
pass
# fmt: on
import asyncio
import inspect
import multiprocessing
import os
import time
import torch
import torch.distributed.distributed_c10d as c10d
import traceback
import uvicorn
from aiohttp import ClientConnectorError
from collections.abc import Sequence
from contextlib import asynccontextmanager, contextmanager
from dataclasses import asdict
from fastapi import FastAPI
from itertools import chain
from multiprocessing import Pipe, Process
from multiprocessing.connection import Connection
from transformers.utils import is_torch_npu_available
from typing import Any, Dict, List, Optional, Union
from swift.arguments import RolloutArguments
from swift.infer_engine import GRPOVllmEngine, InferClient
from swift.infer_engine.protocol import (InitCommunicatorRequest, RequestConfig, RolloutInferRequest,
UpdateWeightsRequest)
from swift.rlhf_trainers.utils import (VLLM_LORA_INT_ID, VLLM_LORA_NAME, VLLM_LORA_PATH, FlattenedTensorBucket,
FlattenedTensorMetadata, TensorLoRARequest, UpdateAdapterRequest,
UpdateFlattenedAdapterRequest, UpdateFlattenedParamsRequest,
broadcast_tensor_for_vllm_weight_sync, check_vllm_version_ge, chunk_list,
finish_vllm_weight_reload, patch_vllm_load_adapter,
patch_vllm_moe_model_weight_loader, vllm_supports_lora_load_inplace)
from swift.rollout import RolloutScheduler, multi_turns
from swift.utils import (gc_collect, get_logger, get_physical_device_count, get_seed, ipc_collect, is_torch_rocm,
is_vllm_ascend_available, is_vllm_metax_available, synchronize)
from ..base import SwiftPipeline
try:
if check_vllm_version_ge('0.11.1'):
from vllm.utils.network_utils import get_open_port
else:
from vllm.utils import get_open_port
from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
from vllm.distributed.parallel_state import get_world_group
from vllm.distributed.utils import StatelessProcessGroup
if is_vllm_ascend_available():
from vllm_ascend.distributed.device_communicators.pyhccl import PyHcclCommunicator as PyNcclCommunicator # noqa
if is_vllm_metax_available():
import vllm_metax.patch
except ImportError:
pass
"""
This module defines the execution logic for `swift rollout`.
It adds weight synchronization logic based on `vLLMEngine`.
Usage:
swift rollout \
--model xxx \
--vllm_tensor_parallel_size xxx \
--vllm_data_parallel_size xxx \
--vllm_use_async_engine true/false \
--other_vllm_arguments
Note:
- Rollout is intended solely for GRPO training sampling.
- For inference or deployment, please use the `swift infer` or `swift deploy` commands.
"""
patch_vllm_load_adapter()
def _set_death_signal():
"""Ensure this process is killed when its parent exits.
Prevents orphan vLLM TP worker processes from leaking GPU memory
when the parent Ray actor dies unexpectedly.
"""
import ctypes
import platform
import signal
if platform.system() != 'Linux':
return
libc = ctypes.CDLL('libc.so.6')
libc.prctl(1, signal.SIGKILL)
if os.getppid() == 1:
os.kill(os.getpid(), signal.SIGKILL)
def _patch_full_weight_reload_loader(model) -> None:
if is_vllm_ascend_available():
from swift.model.npu_patch.vllm_ascend_moe import configure_vllm_ascend_moe_preprocessed_weight_sync
configure_vllm_ascend_moe_preprocessed_weight_sync(model)
patch_vllm_moe_model_weight_loader(model)
class WeightSyncWorkerExtension:
# The following attributes are initialized when `init_communicator` method is called.
communicator = None # Communicator for weight updates
client_rank = None # Source rank for broadcasting updated weights
def __new__(cls, **kwargs):
_set_death_signal()
return super().__new__(cls)
def init_communicator(self, host: str, port: int, world_size: int) -> None:
"""
Initializes the weight update communicator using a stateless process group.
This method creates a `StatelessProcessGroup` that allows external training processes to communicate with vLLM
workers without interfering with the global torch distributed group.
Args:
host (`str`):
Hostname or IP address of the master node.
port (`int`):
Port number to be used for communication.
world_size (`int`):
Total number of participating processes in the update group.
"""
if self.communicator is not None:
return
parallel_config = getattr(getattr(self, 'vllm_config', None), 'parallel_config', None)
dp_index = int(getattr(parallel_config, 'data_parallel_index', 0)) if parallel_config is not None else 0
if dp_index > 0:
dp_rank = dp_index
tp_size = int(parallel_config.tensor_parallel_size)
else:
dp_rank = int(os.environ.get('SWIFT_ROLLOUT_DP_RANK', '0'))
tp_size = int(os.environ.get('SWIFT_ROLLOUT_TP_RANK', '1'))
rank = get_world_group().rank + dp_rank * tp_size
# Create a stateless process group to manage communication between training processes and vLLM workers.
# Initialize the NCCL-based communicator for weight synchronization.
pg = StatelessProcessGroup.create(host=host, port=port, rank=rank, world_size=world_size)
if is_vllm_ascend_available():
# https://github.com/modelscope/ms-swift/issues/5920
device = get_world_group().local_rank
import torch_npu
torch_npu.npu.set_device(device)
else:
device = self.device
self.communicator = PyNcclCommunicator(pg, device=device)
# The client process that sends updated weights has the highest rank (world_size - 1).
self.client_rank = world_size - 1
def get_state_keys(self) -> List[str]:
"""Return runtime model parameter names for exact weight-name mapping."""
return list(dict(self.model_runner.model.named_parameters()).keys())
def update_named_param(self, name: str, dtype: str, shape: Sequence[int]) -> None:
"""
Receives updated weights from the client process and updates the named parameter in the model.
Args:
name (`str`):
Name of the weight tensor being updated.
dtype (`str`):
Data type of the weight tensor as a string (e.g., `"torch.float32"`).
shape (`Sequence[int]`):
Shape of the weight tensor.
"""
if self.communicator is None:
raise RuntimeError('Communicator not initialized. Call `init_communicator` first.')
dtype = getattr(torch, dtype.split('.')[-1])
# Allocate memory for the incoming weight tensor on the correct device.
weight = torch.empty(shape, dtype=dtype, device=self.communicator.device)
# Use NCCL to broadcast the updated weights from the client (src) to all workers.
broadcast_tensor_for_vllm_weight_sync(self.communicator, weight, src=self.client_rank)
synchronize()
self.communicator.group.barrier()
# Patch MoE weight_loader if needed. This endpoint updates base weights
# one by one, so use the same full-reload layout setup as flattened
# base-weight sync before the final post-load processing request.
_patch_full_weight_reload_loader(self.model_runner.model)
# Load the received weights into the model.
self.model_runner.model.load_weights(weights=[(name, weight)])
def update_adapter_flattened_param(self, peft_config: Dict, metadatas: list[Dict]) -> None:
"""
Receives and applies a flattened LoRA adapter to the model.
"""
metadatas = [FlattenedTensorMetadata(**metadata) for metadata in metadatas]
if self.communicator is None:
raise RuntimeError('Communicator not initialized. Call `init_communicator` first.')
total_bytes = metadatas[-1].end_idx
flatten_tensor = torch.empty(total_bytes, dtype=torch.uint8, device=self.communicator.device)
broadcast_tensor_for_vllm_weight_sync(self.communicator, flatten_tensor, src=self.client_rank)
synchronize()
self.communicator.group.barrier()
named_params = FlattenedTensorBucket(metadata=metadatas, flattened_tensor=flatten_tensor).reconstruct_tensors()
req_kw = dict(
lora_name=VLLM_LORA_NAME,
lora_int_id=VLLM_LORA_INT_ID,
lora_path=VLLM_LORA_PATH,
peft_config=peft_config,
lora_tensors=named_params,
)
if vllm_supports_lora_load_inplace():
req_kw['load_inplace'] = True
else:
self.remove_lora(VLLM_LORA_INT_ID)
lora_request = TensorLoRARequest(**req_kw)
self.add_lora(lora_request)
def update_adapter_param(self, peft_config: Dict, lora_tensors_metadata: list[Dict]) -> None:
"""
Receives and applies a LoRA adapter to the model without flattening.
Each tensor is broadcast individually.
Args:
peft_config: PEFT configuration dictionary.
lora_tensors_metadata: List of metadata dictionaries for each tensor.
"""
if self.communicator is None:
raise RuntimeError('Communicator not initialized. Call `init_communicator` first.')
# Receive each tensor individually
named_params = {}
for metadata in lora_tensors_metadata:
name = metadata['name']
dtype = getattr(torch, metadata['dtype'].split('.')[-1])
shape = tuple(metadata['shape'])
tensor = torch.empty(shape, dtype=dtype, device=self.communicator.device)
broadcast_tensor_for_vllm_weight_sync(self.communicator, tensor, src=self.client_rank)
named_params[name] = tensor
synchronize()
self.communicator.group.barrier()
req_kw = dict(
lora_name=VLLM_LORA_NAME,
lora_int_id=VLLM_LORA_INT_ID,
lora_path=VLLM_LORA_PATH,
peft_config=peft_config,
lora_tensors=named_params,
)
if vllm_supports_lora_load_inplace():
req_kw['load_inplace'] = True
else:
self.remove_lora(VLLM_LORA_INT_ID)
lora_request = TensorLoRARequest(**req_kw)
self.add_lora(lora_request)
def update_flattened_params(self, metadatas: list[Dict]) -> None:
"""
Receives updated flattened weights from the client process and updates the model parameters.
Args:
metadatas (list[Dict]): List of metadata dictionaries for the flattened tensors.
"""
metadatas = [FlattenedTensorMetadata(**metadata) for metadata in metadatas]
if self.communicator is None:
raise RuntimeError('Communicator not initialized. Call `init_communicator` first.')
total_bytes = metadatas[-1].end_idx
flatten_tensor = torch.empty(total_bytes, dtype=torch.uint8, device=self.communicator.device)
broadcast_tensor_for_vllm_weight_sync(self.communicator, flatten_tensor, src=self.client_rank)
synchronize()
self.communicator.group.barrier()
named_params = FlattenedTensorBucket(metadata=metadatas, flattened_tensor=flatten_tensor).reconstruct_tensors()
_patch_full_weight_reload_loader(self.model_runner.model)
self.model_runner.model.load_weights(weights=list(named_params.items()))
def process_weights_after_loading(self) -> None:
"""Re-run process_weights_after_loading once after ALL weight
buckets have been loaded, so the kernel-format layout is rebuilt
on complete weights rather than partial ones.
Uses vLLM's built-in ``process_weights_after_loading`` when
*model_config* and *target_device* are available (same as verl);
falls back to FusedMoE-only path otherwise.
"""
model_config = self.model_runner.model_config
finish_vllm_weight_reload(self.model_runner.model, model_config=model_config, target_device=self.device)
def close_communicator(self) -> None:
"""
Closes the communicator when weight synchronization is no longer needed.
This method deletes the NCCL communicator to release associated resources.
"""
if self.communicator is not None:
del self.communicator
self.communicator = None # Ensure attribute is reset to None
self.client_rank = None # Ensure attribute is reset to None
# ------------------------------------------------------------------
# ZMQ + CUDA-IPC / shared-memory bucketed weight sync
# ------------------------------------------------------------------
#
# Counterpart of ``VllmServer.update_weights_ipc`` (see
# ``swift/ray/megatron/rollout/vllm_server.py``). Avoids the extra
# NCCL broadcast hop of ``update_flattened_params`` and reuses the
# sender's bucket buffer via CUDA IPC (same node, same device) or
# shared memory (CPU / cross-device fallback).
#
# TP>1:
# Only the TP driver (rank 0 in the TP group) talks to the ZMQ
# socket; the IPC handle / shm name and every bucket metadata is
# broadcast via vLLM's TP cpu_group so all ranks can rebuild the
# same buffer and load their own TP shard.
#
# LoRA / chunked tensors / FP8-QAT re-packing:
# Intentionally NOT implemented here. GRPO / GKD in swift Ray
# uses full-parameter sync; LoRA / QAT paths keep going through
# ``update_flattened_params`` + ``init_communicator``.
@staticmethod
def _ipc_handle_signature(handle) -> tuple | None:
"""Derive a stable signature for a CUDA IPC handle.
Two handles map the same CUDA memory region when their inner
storage-handle bytes and metadata match. We hash only the
picklable / comparable parts to detect reuse.
"""
try:
_, args = handle
except Exception:
return None
sig = []
for v in args:
if isinstance(v, (bytes, bytearray)):
sig.append(('bytes', bytes(v)))
elif isinstance(v, (int, float, bool, str)) or v is None:
sig.append(('scalar', v))
else:
try:
sig.append(('repr', repr(v)))
except Exception:
return None
return tuple(sig)
def update_weights_from_ipc(
self,
use_shm: bool = False,
zmq_handle: Optional[str] = None,
timeout_s: int = 300,
peft_config: Optional[Dict] = None,
base_sync_done: bool = False,
) -> None:
"""Receive and load weights via ZMQ + CUDA-IPC / SHM.
Called via ``collective_rpc('update_weights_from_ipc', kwargs=...)``
from ``VllmServer.update_weights_ipc``. The sender binds a ZMQ
REQ socket on ``zmq_handle`` and sends
1. a CUDA IPC reduce_tensor handle (or ``{name, size}`` for shm),
2. per-bucket ``{bucket_meta, is_last}`` payloads,
with a sync ``recv()`` on our side so the sender can safely reuse
the shared buffer between buckets.
IPC buffer reuse: when the sender reuses the same CUDA IPC handle
across sync rounds (same ``BucketedWeightSender`` buffer), we skip
``rebuild_cuda_tensor`` and reuse the cached mapping. This avoids
accumulating IPC mappings that the CUDA driver releases lazily,
which is the root cause of apparent GPU memory growth under
frequent syncs.
When ``peft_config`` is provided and ``base_sync_done`` is True,
the received weights are loaded as a LoRA adapter via
``TensorLoRARequest`` instead of ``model.load_weights``.
"""
if zmq_handle is None:
raise ValueError('update_weights_from_ipc: zmq_handle is required')
import gc as _gc
import torch as _torch
import torch.distributed as _dist
import zmq
from torch.multiprocessing.reductions import rebuild_cuda_tensor
device = getattr(self, 'device', None)
if device is None:
local_rank = getattr(self, 'local_rank', 0)
device = _torch.device(f'cuda:{local_rank}' if _torch.cuda.is_available() else 'cpu')
self.device = device
tp_rank = getattr(self, 'rank', 0)
tp_size = 1
try:
tp_size = self.model_runner.parallel_config.tensor_parallel_size
except Exception: # noqa: BLE001
pass
is_driver = (tp_rank == 0)
cpu_group = None
broadcast_src = 0
if tp_size > 1:
from vllm.distributed import get_tp_group
tp_coord = get_tp_group()
cpu_group = tp_coord.cpu_group
broadcast_src = tp_coord.ranks[0]
def _broadcast_obj(obj):
if cpu_group is None:
return obj
obj_list = [obj]
_dist.broadcast_object_list(obj_list, src=broadcast_src, group=cpu_group)
return obj_list[0]
socket = None
if is_driver:
ctx = zmq.Context.instance()
socket = ctx.socket(zmq.REP)
socket.setsockopt(zmq.RCVTIMEO, timeout_s * 1000)
socket.setsockopt(zmq.SNDTIMEO, timeout_s * 1000)
socket.setsockopt(zmq.LINGER, 0)
socket.connect(zmq_handle)
# ── Step 1: receive + rebuild IPC handle (with reuse) ────────
comm_metadata = socket.recv_pyobj() if is_driver else None
comm_metadata = _broadcast_obj(comm_metadata)
buffer = None
shm = None
if not use_shm:
handle = comm_metadata
handle_sig = self._ipc_handle_signature(handle)
cached_buf = getattr(self, '_swift_ipc_buffer', None)
cached_sig = getattr(self, '_swift_ipc_handle_signature', None)
if cached_buf is not None and cached_sig == handle_sig:
buffer = cached_buf
else:
if cached_buf is not None:
self._swift_ipc_buffer = None
self._swift_ipc_handle_signature = None
del cached_buf
_gc.collect()
ipc_collect()
func, args = handle
list_args = list(args)
dev_idx = device.index if device.type == 'cuda' else 0
list_args[6] = dev_idx
buffer = func(*list_args) if callable(func) else rebuild_cuda_tensor(*list_args)
assert buffer.dtype == _torch.uint8
self._swift_ipc_buffer = buffer
self._swift_ipc_handle_signature = handle_sig
else:
from multiprocessing import shared_memory
shm = shared_memory.SharedMemory(name=comm_metadata['name'])
buffer = _torch.frombuffer(shm.buf[:comm_metadata['size']], dtype=_torch.uint8)
if is_driver:
socket.send(b'') # ready for buckets
# ── Step 2: stream buckets and load_weights per bucket ──────
is_lora_sync = (peft_config is not None and base_sync_done)
all_lora_weights: Dict[str, Any] = {} if is_lora_sync else None
if not is_lora_sync:
_patch_full_weight_reload_loader(self.model_runner.model)
while True:
metadata = socket.recv_pyobj() if is_driver else None
metadata = _broadcast_obj(metadata)
bucket_meta = metadata['bucket_meta']
entries = list(bucket_meta.values()) if isinstance(bucket_meta, dict) else list(bucket_meta)
weights: List[tuple] = []
for meta in entries:
name = meta['name']
dtype = meta['dtype']
shape = meta['shape']
shape = shape if isinstance(shape, _torch.Size) else _torch.Size(shape)
offset = int(meta['offset'])
size = int(dtype.itemsize * shape.numel())
raw = buffer[offset:offset + size]
tensor = raw.view(dtype=dtype).view(shape)
if use_shm:
tensor = tensor.to(device)
else:
tensor = tensor.clone()
weights.append((name, tensor))
if _torch.cuda.is_available():
_torch.cuda.synchronize()
if is_driver:
socket.send(b'') # bucket received
if tp_size > 1:
_dist.barrier(group=cpu_group)
if is_lora_sync:
for name, tensor in weights:
all_lora_weights[name] = tensor
else:
self.model_runner.model.load_weights(weights=weights)
del weights
if metadata.get('is_last'):
break
# Re-run process_weights_after_loading so the kernel-format
# layout is rebuilt after the in-place reload (vLLM issue
# #42821). Skipped for LoRA sync because the adapter path
# doesn't call ``load_weights``.
if not is_lora_sync:
model_config = self.model_runner.model_config
finish_vllm_weight_reload(self.model_runner.model, model_config=model_config, target_device=self.device)
if is_lora_sync and all_lora_weights:
req_kw = dict(
lora_name=VLLM_LORA_NAME,
lora_int_id=VLLM_LORA_INT_ID,
lora_path=VLLM_LORA_PATH,
peft_config=peft_config,
lora_tensors=all_lora_weights,
)
if vllm_supports_lora_load_inplace():
req_kw['load_inplace'] = True
else:
self.remove_lora(VLLM_LORA_INT_ID)
lora_request = TensorLoRARequest(**req_kw)
self.add_lora(lora_request)
del all_lora_weights
if is_driver and socket is not None:
socket.close()
del buffer
if shm is not None:
try:
shm.close()
except BufferError:
pass
shm = None
_gc.collect()
ipc_collect()
if _torch.cuda.is_available():
_torch.cuda.empty_cache()
logger = get_logger()
def safe_set_start_method():
if multiprocessing.get_start_method(allow_none=True) is None:
multiprocessing.set_start_method('spawn')
def get_rollout_engine_type(args: RolloutArguments, engine: GRPOVllmEngine):
if args.multi_turn_scheduler:
if args.multi_turn_scheduler not in multi_turns:
raise ValueError(f"Multi-turn scheduler '{args.multi_turn_scheduler}' not found in multi_turns.")
scheduler_cls = multi_turns[args.multi_turn_scheduler]
kwargs = {}
if 'tokenizer' in list(inspect.signature(scheduler_cls.__init__).parameters):
kwargs['tokenizer'] = engine.template.tokenizer
# gym kwargs
if args.use_gym_env:
kwargs.update({
'use_gym_env': args.use_gym_env,
'gym_env': args.gym_env,
})
rollout_engine: RolloutScheduler = scheduler_cls(infer_engine=engine, max_turns=args.max_turns, **kwargs)
if not rollout_engine:
raise ValueError(f"Failed to initialize multi-turn scheduler '{args.multi_turn_scheduler}'.")
else:
rollout_engine = engine
return rollout_engine
def _set_visible_devices_for_dp_rank(data_parallel_rank: int, tensor_parallel_size: int):
def _get_device_env_var():
if is_torch_npu_available():
return 'ASCEND_RT_VISIBLE_DEVICES'
return 'CUDA_VISIBLE_DEVICES'
env_var = _get_device_env_var()
current = os.environ.get(env_var)
if current:
all_devices = current.split(',')
else:
from swift.utils import get_device_count
all_devices = [str(i) for i in range(get_device_count())]
start = data_parallel_rank * tensor_parallel_size
end = start + tensor_parallel_size
selected = all_devices[start:end]
# ROCm: do NOT shrink the visibility mask to ``selected``. Restricting
# CUDA_VISIBLE_DEVICES (e.g. to "6,7") makes vLLM renumber those GPUs locally
# as cuda:0,1, so the device ids the rollout uses/reports diverge from the
# trainer's global numbering, and the cross-process RCCL weight-sync fails on
# ROCm with "invalid device ordinal" (the trainer can't resolve the rollout's
# GPUs). Instead keep EVERY physical GPU visible and only reorder the mask so
# the selected devices come first: vLLM still lands on the intended physical
# GPUs (cuda:0..tp-1 -> selected), while RCCL can resolve all peers (incl. the
# trainer's GPUs) by PCI bus id, so train<->rollout communication works.
if env_var == 'CUDA_VISIBLE_DEVICES' and is_torch_rocm():
total = get_physical_device_count()
sel_ints = []
for x in selected:
try:
sel_ints.append(int(x))
except ValueError:
sel_ints = []
break
if sel_ints and all(0 <= i < total for i in sel_ints):
rest = [i for i in range(total) if i not in sel_ints]
reordered = ','.join(str(i) for i in (sel_ints + rest))
os.environ['CUDA_VISIBLE_DEVICES'] = reordered
os.environ['HIP_VISIBLE_DEVICES'] = reordered
return
os.environ[env_var] = ','.join(selected)
def llm_worker(args: RolloutArguments, data_parallel_rank: int, master_port: int, connection: Connection) -> None:
try:
args._import_external_plugins()
_set_visible_devices_for_dp_rank(data_parallel_rank, args.vllm_tensor_parallel_size)
os.environ['VLLM_DP_MASTER_PORT'] = str(master_port)
os.environ['SWIFT_ROLLOUT_DP_RANK'] = str(data_parallel_rank)
os.environ['SWIFT_ROLLOUT_TP_RANK'] = str(args.vllm_tensor_parallel_size)
worker_seed = get_seed()
engine = SwiftRolloutDeploy.get_infer_engine(args, template=args.get_template(), seed=worker_seed)
rollout_engine = get_rollout_engine_type(args, engine)
except Exception:
connection.send({'status': 'error', 'error': traceback.format_exc()})
return
connection.send({'status': 'ready'})
while True:
# Wait for commands from the parent process
try:
command = connection.recv()
except KeyboardInterrupt:
engine.engine.collective_rpc(method='close_communicator')
break
# Handle commands
if command['type'] in ['call', 'fire_and_forget']:
method_name = command['method']
args, kwargs = command.get('args', ()), command.get('kwargs', {})
method = getattr(rollout_engine, method_name, None) or getattr(rollout_engine.engine, method_name, None)
try:
result = method(*args, **kwargs)
except Exception:
logger.error(f'Method execution failed: {method_name}\n{traceback.format_exc()}')
result = None
if command['type'] == 'call':
connection.send(result)
elif command['type'] == 'shutdown':
break
async def async_llm_worker(args: RolloutArguments, data_parallel_rank: int, master_port: int,
connection: Connection) -> None:
try:
args._import_external_plugins()
os.environ['SWIFT_ROLLOUT_DP_RANK'] = str(data_parallel_rank)
os.environ['SWIFT_ROLLOUT_TP_RANK'] = str(args.vllm_tensor_parallel_size)
worker_seed = get_seed()
engine = SwiftRolloutDeploy.get_infer_engine(args, template=args.get_template(), seed=worker_seed)
rollout_engine = get_rollout_engine_type(args, engine)
except Exception:
connection.send({'status': 'error', 'error': traceback.format_exc()})
return
connection.send({'status': 'ready'})
loop = asyncio.get_running_loop()
while True:
try:
command = await loop.run_in_executor(None, connection.recv)
except KeyboardInterrupt:
await engine.engine.collective_rpc(method='close_communicator')
break
# Handle commands
if command['type'] in ['call', 'fire_and_forget']:
method_name = command['method']
args, kwargs = command.get('args', ()), command.get('kwargs', {})
method = getattr(rollout_engine, method_name, None) or getattr(rollout_engine.engine, method_name, None)
try:
result = await method(*args, **kwargs)
except Exception:
logger.error(f'Method execution failed: {method_name}\n{traceback.format_exc()}')
result = None
if command['type'] == 'call':
connection.send(result)
elif command['type'] == 'shutdown':
break
def llm_worker_entry(*args, **kwargs):
asyncio.run(async_llm_worker(*args, **kwargs))
class SwiftRolloutDeploy(SwiftPipeline):
args_class = RolloutArguments
args: args_class
def _register_rl_rollout_app(self):
self.app.get('/health/')(self.health)
self.app.get('/get_world_size/')(self.get_world_size)
self.app.get('/get_model_state_keys/')(self.get_model_state_keys)
self.app.post('/init_communicator/')(self.init_communicator)
self.app.post('/update_named_param/')(self.update_named_param)
self.app.post('/update_adapter_flattened_param/')(self.update_adapter_flattened_param)
self.app.post('/update_adapter_param/')(self.update_adapter_param)
self.app.post('/update_flattened_params/')(self.update_flattened_params)
self.app.post('/process_weights_after_loading/')(self.process_weights_after_loading)
self.app.post('/reset_prefix_cache/')(self.reset_prefix_cache)
self.app.post('/reset_encoder_cache/')(self.reset_encoder_cache)
self.app.post('/reset_mm_cache/')(self.reset_mm_cache)
self.app.post('/close_communicator/')(self.close_communicator)
self.app.post('/infer/', response_model=None)(self.infer)
self.app.post('/get_engine_type/')(self.get_engine_type)
def __init__(self, args: Optional[Union[List[str], RolloutArguments]] = None):
super().__init__(args)
self.use_gym_env = self.args.use_gym_env
self.use_async_engine = self.args.vllm_use_async_engine
self.num_connections = 1 if self.use_async_engine else self.args.vllm_data_parallel_size
safe_set_start_method()
self.app = FastAPI(lifespan=self.lifespan)
self._register_rl_rollout_app()
self.master_port = get_open_port()
self.connections = []
self.processes = []
self._start_data_parallel_workers()
def _start_data_parallel_workers(self):
for data_parallel_rank in range(self.num_connections):
parent_conn, child_conn = Pipe()
worker_func = llm_worker_entry if self.use_async_engine else llm_worker
process = Process(target=worker_func, args=(self.args, data_parallel_rank, self.master_port, child_conn))
process.start()
self.connections.append(parent_conn)
self.processes.append(process)
@asynccontextmanager
async def lifespan(self, app: FastAPI):
pending_connections = set(range(self.num_connections))
while pending_connections:
for idx in list(pending_connections):
connection = self.connections[idx]
if not connection.poll(timeout=0.1):
if not self.processes[idx].is_alive():
raise RuntimeError(f'Worker process {idx} exited unexpectedly during initialization. '
'Check worker logs for details.')
continue
msg = connection.recv()
if isinstance(msg, dict) and msg.get('status') == 'error':
error_msg = msg.get('error', 'Unknown error')
raise RuntimeError(f'Worker process {idx} failed during initialization:\n{error_msg}')
if isinstance(msg, dict) and msg.get('status') == 'ready':
pending_connections.discard(idx)
yield
# Wait for processes to terminate
for process in self.processes:
process.join(timeout=10)
if process.is_alive():
logger.warning(f'Process {process} is still alive after 10 seconds, attempting to terminate...')
process.terminate()
process.join()
@staticmethod
def get_infer_engine(args: RolloutArguments, template=None, **kwargs):
kwargs.update({
'model_id_or_path': args.model,
'model_type': args.model_type,
'revision': args.model_revision,
'torch_dtype': args.torch_dtype,
'template': template,
'use_async_engine': args.vllm_use_async_engine,
'max_lora_rank': args.vllm_max_lora_rank,
})
infer_backend = kwargs.pop('infer_backend', None) or args.infer_backend
if infer_backend != 'vllm':
infer_backend = 'vllm'
logger.info('Currently, rollout only supports the vLLM backend. Set vLLM backend')
kwargs.update(args.get_vllm_engine_kwargs())
kwargs.update({'enable_lora': args.vllm_enable_lora}) # override
# Important: Use processed_logprobs so temperature scaling affects the logprobs
# This is required for correct importance sampling in rollout correction
kwargs['logprobs_mode'] = 'processed_logprobs' if check_vllm_version_ge('0.10.2') else None
# used for RL external rollout backend
engine_kwargs = kwargs.get('engine_kwargs', {})
# for RL rollout model weight sync
engine_kwargs.update({'worker_extension_cls': 'swift.pipelines.infer.rollout.WeightSyncWorkerExtension'})
load_format = engine_kwargs.pop('load_format', 'auto')
kwargs['load_format'] = load_format
if args.vllm_use_async_engine and args.vllm_data_parallel_size > 1:
engine_kwargs['data_parallel_size'] = args.vllm_data_parallel_size
kwargs['engine_kwargs'] = engine_kwargs
return GRPOVllmEngine(**kwargs)
async def health(self):
"""
Health check endpoint to verify that the server is running.
"""
return {'status': 'ok'}
async def get_world_size(self):
"""
Retrieves the world size from the LLM engine.
Returns:
`dict`:
A dictionary containing the world size.
Example response:
```json
{"world_size": 8}
```
"""
return {'world_size': self.args.vllm_tensor_parallel_size * self.args.vllm_data_parallel_size}
async def get_model_state_keys(self):
"""Get runtime vLLM model parameter names from one worker group."""
if not self.connections:
return {'keys': []}
kwargs = {'method': 'get_state_keys'}
self.connections[0].send({'type': 'call', 'method': 'collective_rpc', 'kwargs': kwargs})
result = self.connections[0].recv()
keys = []
if isinstance(result, list):
if result and all(isinstance(x, str) for x in result):
keys = result
else:
for item in result:
if isinstance(item, list) and item and all(isinstance(x, str) for x in item):
keys = item
break
return {'keys': keys}
async def init_communicator(self, request: InitCommunicatorRequest):
"""
Initializes the communicator for synchronizing model weights between a client and multiple server
workers.
Args:
request (`InitCommunicatorRequest`):
- `host` (`str`): Hostname or IP address of the master node.
- `port` (`int`): Port number to be used for communication.
- `world_size` (`int`): Total number of participating processes in the group.
"""
world_size = self.args.vllm_tensor_parallel_size * self.args.vllm_data_parallel_size + 1
# The function init_communicator is called this way: init_communicator(host, port, world_size)
# So with collective_rpc we need to call it this way:
# llm.collective_rpc(method="init_communicator", args=(host, port, world_size))
kwargs = {'method': 'init_communicator', 'args': (request.host, request.port, world_size)}
for connection in self.connections:
connection.send({'type': 'fire_and_forget', 'method': 'collective_rpc', 'kwargs': kwargs})
return {'message': 'Request received, initializing communicator'}
async def update_named_param(self, request: UpdateWeightsRequest):
"""
Updates the model weights with the provided tensor.
Once this endpoint is called, the client process should broadcast the updated weights to all server workers.
Args:
request (`UpdateWeightsRequest`):
- `name` (`str`): Name of the weight tensor being updated.
- `dtype` (`str`): Data type of the weight tensor (e.g., `"torch.float32"`).
- `shape` (list of `int`): Shape of the weight
"""
# The function update_named_param is called this way: update_named_param("name", torch.float32, (10, 10))
# So with collective_rpc we need to call it this way:
# llm.collective_rpc("update_named_param", args=("name", torch.float32, (10, 10)))
kwargs = {'method': 'update_named_param', 'args': (request.name, request.dtype, tuple(request.shape))}
for connection in self.connections:
connection.send({'type': 'fire_and_forget', 'method': 'collective_rpc', 'kwargs': kwargs})
return {'message': 'Request received, updating named parameter'}
async def update_adapter_flattened_param(self, request: UpdateFlattenedAdapterRequest):
peft_config = asdict(request.peft_config)
metadatas = [
metadata.model_dump() if hasattr(metadata, 'model_dump') else metadata.dict()
for metadata in request.metadatas
]
kwargs = {'method': 'update_adapter_flattened_param', 'args': (peft_config, metadatas)}
for connection in self.connections:
connection.send({'type': 'fire_and_forget', 'method': 'collective_rpc', 'kwargs': kwargs})
return {'message': 'Request received, updating adapter parameter'}
async def update_adapter_param(self, request: UpdateAdapterRequest):
"""
Updates the LoRA adapter weights without flattening.
Each tensor is broadcast individually.
Args:
request (UpdateAdapterRequest):
- peft_config (LoraConfig): PEFT configuration for the adapter.
- lora_tensors_metadata (List[FlattenedTensorMetadata]): Metadata for each tensor.
"""
peft_config = asdict(request.peft_config)
lora_tensors_metadata = [
metadata.model_dump() if hasattr(metadata, 'model_dump') else metadata.dict()
for metadata in request.lora_tensors_metadata
]
kwargs = {'method': 'update_adapter_param', 'args': (peft_config, lora_tensors_metadata)}
for connection in self.connections:
connection.send({'type': 'fire_and_forget', 'method': 'collective_rpc', 'kwargs': kwargs})
return {'message': 'Request received, updating adapter parameter (non-flattened)'}
async def update_flattened_params(self, request: UpdateFlattenedParamsRequest):
"""
Updates the model weights with flattened tensor data.
Args:
request (UpdateFlattenedParamsRequest):
- metadatas (List[FlattenedTensorMetadata]): Metadata for the flattened tensors.
"""
metadatas = [
metadata.model_dump() if hasattr(metadata, 'model_dump') else metadata.dict()
for metadata in request.metadatas
]
kwargs = {'method': 'update_flattened_params', 'args': (metadatas, )}
for connection in self.connections:
connection.send({'type': 'fire_and_forget', 'method': 'collective_rpc', 'kwargs': kwargs})
return {'message': 'Request received, updating flattened parameters'}
async def process_weights_after_loading(self):
"""
Triggers process_weights_after_loading on all workers.
"""
kwargs = {'method': 'process_weights_after_loading', 'args': ()}
for connection in self.connections:
connection.send({'type': 'call', 'method': 'collective_rpc', 'kwargs': kwargs})
# Wait for all workers to complete before returning
loop = asyncio.get_running_loop()
await asyncio.gather(*(loop.run_in_executor(None, connection.recv) for connection in self.connections))
return {'message': 'Weights processed after loading'}
async def reset_prefix_cache(self):
"""
Resets the prefix cache for the model.
"""
for connection in self.connections:
connection.send({'type': 'call', 'method': 'reset_prefix_cache'})
# Wait for and collect all results
all_outputs = [connection.recv() for connection in self.connections]
success = all(output for output in all_outputs)
return {'message': 'Request received, resetting prefix cache status: ' + str(success)}
async def reset_encoder_cache(self):
"""Resets the encoder cache (vision encoder embeddings) for the model."""
for connection in self.connections:
connection.send({'type': 'call', 'method': 'reset_encoder_cache'})
all_outputs = [connection.recv() for connection in self.connections]
success = all(output for output in all_outputs)
return {'message': 'Request received, resetting encoder cache status: ' + str(success)}
async def reset_mm_cache(self):
"""Resets the multimodal processor cache for the model."""
for connection in self.connections:
connection.send({'type': 'call', 'method': 'reset_mm_cache'})
all_outputs = [connection.recv() for connection in self.connections]
success = all(output for output in all_outputs)
return {'message': 'Request received, resetting mm cache status: ' + str(success)}
async def get_engine_type(self):
"""
Return a dictionary describing the runtime engine configuration.
The returned object contains three keys:
- engine_type (str): Either 'AsyncLLMEngine' or 'LLMEngine', indicating
whether the asynchronous or synchronous engine is in use.
- use_gym_env (bool, optional): Present and True **only when**
``use_async_engine`` and ``use_gym_env`` are both True.
- enable_multi_turn (bool): True if multi-turn scheduling is enabled
via ``args.multi_turn_scheduler``, otherwise False.
Returns
-------
dict
A concise specification of the current engine setup.
"""
enable_multi_turn = False
if self.args.multi_turn_scheduler:
enable_multi_turn = True
use_gym_env = False
if self.use_async_engine and self.use_gym_env:
use_gym_env = True
engine_type = 'AsyncLLMEngine' if self.use_async_engine else 'LLMEngine'
enable_lora = self.args.vllm_enable_lora
return {
'engine_type': engine_type,
'enable_multi_turn': enable_multi_turn,
'use_gym_env': use_gym_env,
'enable_lora': enable_lora,
}
async def close_communicator(self):
"""
Closes the weight update group and cleans up associated resources.
"""
kwargs = {'method': 'close_communicator'}
for connection in self.connections:
connection.send({'type': 'fire_and_forget', 'method': 'collective_rpc', 'kwargs': kwargs})
return {'message': 'Request received, closing communicator'}
async def infer(
self,
infer_requests: List[Union[Dict, RolloutInferRequest]],
request_config: Optional[RequestConfig] = None,
*,
use_tqdm: Optional[bool] = None,
):
chunked_infer_requests = chunk_list(infer_requests, self.num_connections)
# Send the prompts to each worker
for i, (connection, requests) in enumerate(zip(self.connections, chunked_infer_requests)):
# When the number of prompts is less than data_parallel_size, some workers will receive empty prompts.
# However, vLLM requires that we always send at least one prompt. So we send a placeholder prompt to comply
# with vLLM's requirement, and we later ignore the result.
if not requests:
requests = [RolloutInferRequest(messages=[{'role': 'user', 'content': '<placeholder>'}])]
kwargs = {'infer_requests': requests, 'request_config': request_config, 'use_tqdm': use_tqdm}
method = 'infer' if not self.use_async_engine else 'async_infer'
connection.send({'type': 'call', 'method': method, 'kwargs': kwargs})
all_outputs = [connection.recv() for connection in self.connections]
# Handle empty prompts (see above)
all_outputs = [output for output, requests in zip(all_outputs, chunked_infer_requests) if requests]
all_outputs = list(chain.from_iterable(all_outputs)) # from list of list to single list
return all_outputs
def run(self):
args = self.args
uvicorn.run(self.app, host=args.host, port=args.port, log_level=args.log_level)
def rollout_main(args: Optional[Union[List[str], RolloutArguments]] = None) -> None:
SwiftRolloutDeploy(args).main()
def is_accessible(port: int):
infer_client = InferClient(port=port)
try:
infer_client.get_model_list()
except ClientConnectorError:
return False
return True
@contextmanager
def run_rollout(args: RolloutArguments, return_url: bool = False):
if isinstance(args, RolloutArguments) and args.__class__.__name__ == 'RolloutArguments':
deploy_args = args
else:
args_dict = asdict(args)
parameters = inspect.signature(RolloutArguments).parameters
for k in list(args_dict.keys()):
if k not in parameters or args_dict[k] is None:
args_dict.pop(k)
deploy_args = RolloutArguments(**args_dict)
mp = multiprocessing.get_context('spawn')
process = mp.Process(target=rollout_main, args=(deploy_args, ))
process.start()
try:
while not is_accessible(deploy_args.port):
time.sleep(1)
yield f'http://127.0.0.1:{deploy_args.port}/v1' if return_url else deploy_args.port
finally:
process.terminate()
logger.info('The deployment process has been terminated.')