chore: import upstream snapshot with attribution
Lint test / lint (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:58 +08:00
commit a203934033
1368 changed files with 175001 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
+47
View File
@@ -0,0 +1,47 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .driver_utils import (RayConfig, build_dataset_from_dict, compute_iter_params, estimate_dp_size,
extract_iteration, merge_group_dict, parse_args_from_dict, parse_ray_yaml)
from .grpo_trainer import GRPOTrainer
from .loss import GRPOLoss, Loss
from .megatron_worker import MegatronWorker
from .pipeline import MegatronRayPipeline, register_ray_trainer
from .resource_pool import ResourcePool, ResourcePoolManager
from .rollout import RolloutAdapter, RolloutMode, RolloutReplica, VllmEngineConfig, VllmServer
from .worker_group import CollectMode, DispatchMode, WorkerGroup, dispatch_collect
def __getattr__(name):
_imports = {
'RayConfig': '.driver_utils',
'build_dataset_from_dict': '.driver_utils',
'compute_iter_params': '.driver_utils',
'estimate_dp_size': '.driver_utils',
'extract_iteration': '.driver_utils',
'merge_group_dict': '.driver_utils',
'parse_args_from_dict': '.driver_utils',
'parse_ray_yaml': '.driver_utils',
'GRPOTrainer': '.grpo_trainer',
'MegatronRayPipeline': '.pipeline',
'register_ray_trainer': '.pipeline',
'Loss': '.loss',
'GRPOLoss': '.loss',
'ResourcePool': '.resource_pool',
'ResourcePoolManager': '.resource_pool',
'RolloutMode': '.rollout',
'RolloutReplica': '.rollout',
'VllmEngineConfig': '.rollout',
'VllmServer': '.rollout',
'RolloutAdapter': '.rollout',
'MegatronWorker': '.megatron_worker',
'CollectMode': '.worker_group',
'DispatchMode': '.worker_group',
'WorkerGroup': '.worker_group',
'dispatch_collect': '.worker_group',
}
if name in _imports:
import importlib
return getattr(importlib.import_module(_imports[name], __name__), name)
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
+296
View File
@@ -0,0 +1,296 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Base class for Ray-based Megatron trainers (driver-side)."""
from __future__ import annotations
import os
import ray
import torch
from contextlib import contextmanager
from typing import Any, Dict, List, Tuple
from swift.rl_core.data import GRPOBatch, OnPolicySample
from swift.rl_core.resample import resample_encode_failed_inputs
from swift.rlhf_trainers.utils import create_cyclic_iterator
from swift.template.base import Template
from swift.utils import JsonlWriter, get_logger
from .driver_utils import compute_iter_params
from .worker_group import DPDispatchedDict
logger = get_logger()
class BaseRayTrainer:
"""Shared driver-side logic for Ray Megatron trainers.
Subclasses implement ``_prepare_state`` and ``_train_loop``.
"""
def __init__(
self,
worker_groups: Dict[str, Any],
rollout_replicas: List[Any],
weight_sync_mode: str = 'nccl',
sleep_level: int = 1,
teacher_replicas: List[Any] = None,
):
self.worker_groups = worker_groups
self.rollout_replicas = rollout_replicas
self._weight_sync_mode = weight_sync_mode
self._sleep_level = sleep_level
self.teacher_replicas = teacher_replicas or []
def set_data_info(self, data_info: Dict[str, Any]) -> None:
self._data_info = data_info
@property
def train_group(self):
return self.worker_groups['train']
@property
def is_colocated_rollout(self) -> bool:
from .rollout.replica import RolloutMode
if not self.rollout_replicas:
return False
return self.rollout_replicas[0].mode == RolloutMode.HYBRID
@property
def ckpt_manager(self):
if not hasattr(self, '_ckpt_manager'):
from .checkpoint_engine import CheckpointEngineManager
tg = self.train_group
self._ckpt_manager = CheckpointEngineManager(
train_actors=tg.workers,
rollout_replicas=self.rollout_replicas,
weight_sync_mode=self._weight_sync_mode,
is_colocated=self.is_colocated_rollout,
sleep_level=self._sleep_level,
train_group=tg,
)
return self._ckpt_manager
def _distribute_to_replicas(self, batch, params):
n = len(self.rollout_replicas)
chunk_size = (len(batch) + n - 1) // n
refs = []
for i, replica in enumerate(self.rollout_replicas):
shard = batch[i * chunk_size:(i + 1) * chunk_size]
if not shard:
continue
refs.append(replica.generate(shard, params))
parts = ray.get(refs)
result = []
for p in parts:
result.extend(p)
return result
@contextmanager
def _generation_context(self, tg, ckpt):
offload_model = getattr(self.args, 'offload_model', False)
offload_optimizer = getattr(self.args, 'offload_optimizer', False)
enable_offload = offload_model or offload_optimizer or self.is_colocated_rollout
if enable_offload:
tg.offload_to_cpu()
if self.is_colocated_rollout:
ckpt.wake_up_rollout(tags=['kv_cache'])
try:
yield
finally:
tg.finalize_generation()
if self.is_colocated_rollout:
ckpt.sleep_rollout()
if enable_offload:
tg.reload_to_gpu()
def _build_dataloader(self):
info = self._data_info
dataset = info['train_dataset']
num_gen = int(info.get('num_generations', 1) or 1)
spg = self._steps_per_generation
prompts_per_generation = max(info['global_batch_size'] * spg // max(num_gen, 1), 1)
self._dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=prompts_per_generation,
shuffle=True,
collate_fn=info['data_collator'],
drop_last=True,
)
self._data_iter = create_cyclic_iterator(self._dataloader)
logger.info('%s driver dataloader: dataset=%d, prompts_per_generation=%d, num_gen=%d, spg=%d',
type(self).__name__, len(dataset), prompts_per_generation, num_gen, spg)
def _build_resample_iterator(self) -> None:
"""Independent cyclic prompt iterator (different shuffle order) used to replace
encode-failed prompts (truncation_strategy='delete') and to refill DAPO
dynamic_sample std=0 groups (driver-side)."""
info = self._data_info
dataset = info['train_dataset']
num_gen = int(info.get('num_generations', 1) or 1)
spg = self._steps_per_generation
prompts_per_generation = max(info['global_batch_size'] * spg // max(num_gen, 1), 1)
loader = torch.utils.data.DataLoader(
dataset,
batch_size=prompts_per_generation,
shuffle=True,
collate_fn=info['data_collator'],
drop_last=True,
)
self._resample_iter = create_cyclic_iterator(loader)
def _resample_failed_prompts(self, prompts: List[dict], strip_response: bool = True) -> List[dict]:
"""Replace prompts whose encode fails with fresh ones from the resample iterator.
Shares the backend-agnostic loop with HF / Megatron (see ``rl_core.resample``)."""
return resample_encode_failed_inputs(
self.template,
self._resample_iter,
prompts,
max_resample_rounds=getattr(self, '_max_resample_rounds', 10),
strip_response=strip_response,
)
def _collate_for_workers(self, tg, samples: List[OnPolicySample],
**collate_kwargs) -> Tuple['DPDispatchedDict', List['GRPOBatch']]:
"""Driver-side collate: ``List[OnPolicySample]`` -> ``({dp_rank: [model_inputs]}, flat_grpo_batches)``.
The driver owns the whole global batch, so it does the (pure-CPU)
``template.data_collator`` itself — mirroring the non-Ray Megatron path
where each rank encodes then collates its own micro-batches. The worker
receives the collated micro-batches directly (dispatch='dp') and only runs
the rank-local ``prepare_batch`` (PP/CP slice) + forward.
Layout: split the global batch into ``dp_size`` contiguous shards, each into
``micro_batch_size`` micro-batches, collate each via the shared
``collate_to_grpo_micro_batch``. The second return value is the per-micro-batch
``GRPOBatch`` list IN SAMPLE ORDER (dp_rank major, then micro-batch): the same
objects referenced inside the dispatch dict, so the caller fills old/ref logps +
advantages on them and they reach ``train_step`` via the dispatch dict.
"""
from swift.rlhf_trainers.utils import collate_to_grpo_micro_batch
dp_size = tg.dp_size
mbs = int(self.args.micro_batch_size)
n = len(samples)
if n % dp_size != 0:
raise ValueError(f'_collate_for_workers: batch size {n} not divisible by dp_size {dp_size}.')
shard_size = n // dp_size
dispatch = DPDispatchedDict()
flat_grpo_batches: List['GRPOBatch'] = []
for dp_rank in range(dp_size):
shard = samples[dp_rank * shard_size:(dp_rank + 1) * shard_size]
micro_batches = []
for i in range(0, len(shard), mbs):
chunk = shard[i:i + mbs]
model_inputs, grpo_batch = collate_to_grpo_micro_batch(
chunk,
self.template,
device=self.device,
padding_to=self._padding_to,
router_replay_mode=getattr(self.args, 'router_replay_mode', 'disabled'),
**collate_kwargs,
)
model_inputs['grpo_batch'] = grpo_batch
micro_batches.append(model_inputs)
flat_grpo_batches.append(grpo_batch)
dispatch[dp_rank] = micro_batches
return dispatch, flat_grpo_batches
def _prepare_state(self) -> None:
"""Shared ``_prepare_state`` prefix for all Ray trainers.
Resolves the fields every driver needs (args / template / device /
global_batch_size / temperature / beta / steps_per_generation /
padding_to) from ``_data_info``. Subclasses call ``super()._prepare_state()``
first, then set algorithm-specific state (advantage / dynamic_sample for
GRPO; lmbda / teacher for GKD).
"""
assert hasattr(self, '_data_info'), 'call set_data_info() before train()'
info = self._data_info
args = info['_driver_args']
self.args = args
self.template: Template = info['template']
self.device = torch.device('cpu')
self.global_batch_size = int(args.global_batch_size)
self.temperature = args.temperature
self.beta = args.beta
# steps_per_generation>1: one generation feeds spg training steps.
gen_bs = getattr(args, 'generation_batch_size', None)
spg = getattr(args, 'steps_per_generation', None)
if gen_bs is not None:
self._steps_per_generation = max(int(gen_bs) // self.global_batch_size, 1)
elif spg is not None:
self._steps_per_generation = int(spg)
else:
self._steps_per_generation = 1
self._padding_to = info.get('_padding_to')
def _train_loop(self, tg, train_iters, iteration) -> int:
raise NotImplementedError
def train(self) -> Any:
self._prepare_state()
tg = self.train_group
self._build_dataloader()
if getattr(self, '_needs_resample_iterator', False):
self._build_resample_iterator()
args_override = compute_iter_params(self._data_info, tg.dp_size)
meta = tg.setup(args_override)
train_iters = meta['train_iters']
iteration = meta['iteration']
try:
iteration = self._train_loop(tg, train_iters, iteration)
finally:
results = tg.finalize()
return results
def _maybe_log_completions(self, rollout_with_outputs: List[OnPolicySample], rewards=None, gen_step=None) -> None:
"""Driver-side ``log_completions``: dump prompt/completion (+reward) to
``output_dir/completions.jsonl``. No-op unless ``args.log_completions`` is set.
Completions live on the driver (rollout side), so this is the right place to log them
(worker on_log handles scalar metrics)."""
args = self.args
if not getattr(args, 'log_completions', False) or not rollout_with_outputs:
return
if getattr(self, '_completions_writer', None) is None:
self._completions_writer = JsonlWriter(os.path.join(args.output_dir, 'completions.jsonl'))
table = []
for i, item in enumerate(rollout_with_outputs):
msgs = item.messages
has_resp = bool(msgs) and msgs[-1].get('role') == 'assistant'
completion = self._decode_log_content(msgs[-1].get('content')) if has_resp else ''
prompt_msgs = msgs[:-1] if has_resp else msgs
row = {'gen_step': gen_step, 'prompt': self._format_log_prompt(prompt_msgs), 'completion': completion}
if rewards is not None and i < len(rewards):
row['reward'] = float(rewards[i])
table.append(row)
self._completions_writer.append(table)
def _format_log_prompt(self, prompt_msgs) -> str:
"""Render the prompt as the model actually sees it (chat template applied),
matching the non-Ray ``_apply_chat_template_to_messages_list`` so completions.jsonl
is consistent across backends. Falls back to a plain role/content join if encode fails
(e.g. multimodal placeholders the driver template can't re-encode standalone)."""
from swift.template import TemplateInputs
try:
template_inputs = TemplateInputs.from_dict({'messages': [dict(m) for m in prompt_msgs]})
res = self.template.encode(template_inputs)
return self.template.safe_decode(res['input_ids'])
except Exception:
return ''.join(f"{m.get('role')}: {m.get('content')}\n" for m in prompt_msgs)
def _decode_log_content(self, content) -> str:
"""Decode an assistant message content for logging (mirrors non-Ray)."""
if isinstance(content, str):
return content
if isinstance(content, list):
return self.template.safe_decode(content)
if isinstance(content, dict) and 'input_ids' in content:
return self.template.safe_decode(content['input_ids'])
return str(content) if content is not None else ''
@@ -0,0 +1,16 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from .base import CheckpointEngine, MasterMetadata, TensorMeta
from .hccl import HCCLCheckpointEngine
from .manager import CheckpointEngineManager
from .mixin import CheckpointEngineMixin
from .nccl import NCCLCheckpointEngine
__all__ = [
'CheckpointEngine',
'MasterMetadata',
'TensorMeta',
'CheckpointEngineMixin',
'CheckpointEngineManager',
'NCCLCheckpointEngine',
'HCCLCheckpointEngine',
]
@@ -0,0 +1,89 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from __future__ import annotations
import socket
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple, TypedDict
if TYPE_CHECKING:
import torch
class TensorMeta(TypedDict):
"""Metadata for a tensor in the weight bucket."""
name: str
shape: 'torch.Size'
dtype: 'torch.dtype'
offset: int
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('', 0))
return s.getsockname()[1]
def _is_valid_ipv6_address(addr: str) -> bool:
try:
socket.inet_pton(socket.AF_INET6, addr)
return True
except (OSError, socket.error):
return False
@dataclass
class MasterMetadata:
"""Metadata from the master (trainer rank 0) for topology building."""
zmq_ip: str
zmq_port: int
nccl_store_host: str = ''
nccl_store_port: int = 0
class CheckpointEngine(ABC):
rank: Optional[int] = None
@abstractmethod
def prepare(self) -> Dict[str, Any]:
"""Prepare the checkpoint engine before weight synchronization.
Allocate weight transfer buffers, setup communication channels,
and return metadata needed for topology building.
"""
raise NotImplementedError
@classmethod
@abstractmethod
def build_topology(
cls,
trainer_world_size: int,
rollout_world_size: int,
metadata: List[Dict],
) -> Tuple[Dict[str, List[Any]], Dict[str, List[Any]]]:
"""Build communication topology between trainer and rollout workers.
Returns (trainer_kwargs, rollout_kwargs) for init_process_group().
"""
raise NotImplementedError
@abstractmethod
def init_process_group(self, **kwargs):
"""Initialize the process group for weight synchronization."""
raise NotImplementedError
@abstractmethod
def finalize(self):
"""Finalize: free buffers, optionally destroy the process group."""
raise NotImplementedError
@abstractmethod
async def send_weights(self, weights: Generator[Tuple[str, 'torch.Tensor'], None, None]):
"""Send model weights to rollout workers."""
raise NotImplementedError
@abstractmethod
async def receive_weights(self) -> AsyncGenerator[Tuple[str, 'torch.Tensor'], None]:
"""Receive model weights from trainer."""
raise NotImplementedError
@@ -0,0 +1,466 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
# Adapted from twinkle/src/twinkle/checkpoint_engine/hccl_checkpoint_engine.py
"""HCCL-based checkpoint engine for Ascend NPU.
Uses HCCL for weight payload transfer and ZMQ REQ/REP for bucket
metadata handshakes (reliable, with timeout).
"""
from __future__ import annotations
import os
import time
import torch
import zmq
from dataclasses import dataclass
from typing import Any, AsyncGenerator, Generator, List, Optional, Tuple
from swift.utils import get_current_device, synchronize
from swift.utils.logger import get_logger
from .base import CheckpointEngine, TensorMeta, _find_free_port, _is_valid_ipv6_address
logger = get_logger()
def _configure_zmq_socket(socket: zmq.Socket, timeout_ms: int, linger: int = 0) -> None:
"""Apply timeout/linger options to a ZMQ socket."""
socket.setsockopt(zmq.RCVTIMEO, timeout_ms)
socket.setsockopt(zmq.SNDTIMEO, timeout_ms)
socket.setsockopt(zmq.LINGER, linger)
@dataclass
class HCCLMasterMetadata:
"""Metadata from the master for HCCL process group initialization."""
zmq_ip: str
zmq_port: int
dist_ip: str
dist_port: int
def _stateless_init_hccl(
master_address: str,
master_port: int,
rank: int,
world_size: int,
device: int,
):
"""Create a stateless HCCL communicator via vLLM's StatelessProcessGroup."""
import socket as _socket
from datetime import timedelta
from torch.distributed import TCPStore
from vllm.distributed.utils import StatelessProcessGroup
from vllm_ascend.distributed.device_communicators.pyhccl import PyHcclCommunicator
launch_server = (rank == 0)
listen_socket = None
listen_fd = None
if launch_server:
if _is_valid_ipv6_address(master_address):
listen_socket = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
else:
listen_socket = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
listen_socket.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
listen_socket.bind((master_address, master_port))
listen_socket.listen()
listen_fd = listen_socket.fileno()
store = TCPStore(
host_name=master_address,
port=master_port,
world_size=world_size,
is_master=launch_server,
timeout=timedelta(seconds=300),
use_libuv=False,
master_listen_fd=listen_fd,
)
pg = StatelessProcessGroup(
rank=rank,
world_size=world_size,
store=store,
socket=listen_socket,
data_expiration_seconds=3600,
)
return PyHcclCommunicator(pg, device=device)
class HCCLCheckpointEngine(CheckpointEngine):
"""HCCL checkpoint engine for Ascend NPU weight synchronization."""
def __init__(
self,
bucket_size: int = 3072 << 20,
group_name: str = 'swift_ckpt',
rebuild_group: bool = True,
**kwargs,
) -> None:
self.bucket_size = bucket_size
self.group_name = group_name
self.rebuild_group = rebuild_group
self.pyhccl = None
self.meta_timeout_s = int(os.environ.get('SWIFT_CKPT_HCCL_META_TIMEOUT_S', '300'))
self.meta_timeout_ms = self.meta_timeout_s * 1000
self.device = get_current_device()
self.is_master = False
self.rank: Optional[int] = None
self.world_size: Optional[int] = None
self.send_buf: Optional[torch.Tensor] = None
self.recv_buf: Optional[torch.Tensor] = None
self.socket: Optional[zmq.Socket] = None
self._zmq_ctx: Optional[zmq.Context] = None
self._prepared = False
self._group_initialized = False
self.ip: Optional[str] = None
self.zmq_port: Optional[int] = None
self.dist_port: Optional[int] = None
def _new_socket(self, socket_type: int) -> zmq.Socket:
assert self._zmq_ctx is not None
socket = self._zmq_ctx.socket(socket_type)
_configure_zmq_socket(socket, timeout_ms=self.meta_timeout_ms)
return socket
def _recv_pyobj(self, where: str) -> Any:
assert self.socket is not None
try:
return self.socket.recv_pyobj()
except zmq.error.Again as e:
raise RuntimeError(f'HCCL metadata timeout ({self.meta_timeout_s}s) waiting at {where}.') from e
def _send_pyobj(self, payload: Any, where: str) -> None:
assert self.socket is not None
try:
self.socket.send_pyobj(payload)
except zmq.error.Again as e:
raise RuntimeError(f'HCCL metadata timeout ({self.meta_timeout_s}s) sending at {where}.') from e
def _start_zmq_server(self):
import ray
self.ip = ray.util.get_node_ip_address().strip('[]')
self.zmq_port = _find_free_port()
self.dist_port = _find_free_port()
self._zmq_ctx = zmq.Context()
self.socket = self._new_socket(zmq.REP)
if _is_valid_ipv6_address(self.ip):
address = f'tcp://[{self.ip}]:{self.zmq_port}'
self.socket.setsockopt(zmq.IPV6, 1)
else:
address = f'tcp://{self.ip}:{self.zmq_port}'
self.socket.bind(address)
def _connect_zmq_client(self, metadata: HCCLMasterMetadata):
self._zmq_ctx = zmq.Context()
self.socket = self._new_socket(zmq.REQ)
if _is_valid_ipv6_address(metadata.zmq_ip):
address = f'tcp://[{metadata.zmq_ip}]:{metadata.zmq_port}'
self.socket.setsockopt(zmq.IPV6, 1)
else:
address = f'tcp://{metadata.zmq_ip}:{metadata.zmq_port}'
self.socket.connect(address)
# ── Core lifecycle ───────────────────────────────────────────────────
def prepare(self) -> Optional[HCCLMasterMetadata]:
if self._prepared:
if self.is_master:
return HCCLMasterMetadata(
zmq_ip=self.ip, zmq_port=self.zmq_port, dist_ip=self.ip, dist_port=self.dist_port)
return None
self.send_buf = torch.zeros(self.bucket_size, dtype=torch.uint8, device='npu')
self.recv_buf = torch.zeros(self.bucket_size, dtype=torch.uint8, device='npu')
if self.is_master:
self._start_zmq_server()
self._prepared = True
return HCCLMasterMetadata(zmq_ip=self.ip, zmq_port=self.zmq_port, dist_ip=self.ip, dist_port=self.dist_port)
self._prepared = True
return None
def finalize(self):
if self.rebuild_group:
if self.socket is not None:
try:
self.socket.close()
except Exception as e:
logger.warning('Error closing ZMQ socket: %s', e)
self.socket = None
if self._zmq_ctx is not None:
try:
self._zmq_ctx.term()
except Exception as e:
logger.warning('Error terminating ZMQ context: %s', e)
self._zmq_ctx = None
if self.rank is not None and self.rank >= 0 and self.pyhccl is not None:
try:
self.pyhccl.destroyComm(self.pyhccl.comm)
except Exception:
pass
self.pyhccl = None
self.rank = None
self.world_size = None
self.send_buf = None
self.recv_buf = None
self._prepared = False
self._group_initialized = False
@classmethod
def build_topology(
cls,
trainer_world_size: int,
rollout_world_size: int,
metadata: List[Any],
) -> Tuple[dict, dict]:
master_metadata = None
for m in metadata:
if m is not None:
master_metadata = m
break
trainer_kwargs = {
'rank': [0] + [-1] * (trainer_world_size - 1),
'world_size': [rollout_world_size + 1] * trainer_world_size,
'master_metadata': [master_metadata] * trainer_world_size,
}
rollout_kwargs = {
'rank': list(range(1, rollout_world_size + 1)),
'world_size': [rollout_world_size + 1] * rollout_world_size,
'master_metadata': [master_metadata] * rollout_world_size,
}
return trainer_kwargs, rollout_kwargs
def init_process_group(self, rank: int, world_size: int, master_metadata: HCCLMasterMetadata):
if rank < 0:
self.rank = rank
self.world_size = world_size
self._group_initialized = True
return
if self._group_initialized and not self.rebuild_group:
return
if self.rebuild_group or self.pyhccl is None:
self.pyhccl = _stateless_init_hccl(
master_address=master_metadata.dist_ip,
master_port=master_metadata.dist_port,
rank=rank,
world_size=world_size,
device=self.device,
)
self.rank = rank
self.world_size = world_size
else:
assert self.rank == rank
assert self.world_size == world_size
if self.rank > 0 and self.socket is None:
self._connect_zmq_client(master_metadata)
signal = torch.tensor([1], dtype=torch.int8, device=get_current_device())
self.pyhccl.all_reduce(signal)
self._group_initialized = True
logger.info('HCCL init_process_group: rank=%d, world_size=%d', self.rank, self.world_size)
# ── Metadata exchange ────────────────────────────────────────────────
def _serve_bucket_requests(self, bucket_id: int, metadata: dict) -> None:
"""Master serves bucket metadata to all receivers via REQ/REP."""
assert self.rank == 0 and self.world_size is not None
if self.world_size <= 1:
return
pending = set(range(1, self.world_size))
while pending:
req = self._recv_pyobj(f'NEXT request for bucket={bucket_id}')
if not isinstance(req, dict) or req.get('type') != 'NEXT':
self._send_pyobj({'ok': False, 'error': f'unexpected: {req}'}, 'NEXT reply')
continue
req_rank = int(req.get('rank', -1))
req_bucket_id = int(req.get('bucket_id', -1))
if req_rank not in pending or req_bucket_id != bucket_id:
self._send_pyobj({'ok': False, 'error': 'rank/bucket mismatch'}, 'NEXT reply')
continue
self._send_pyobj({'ok': True, 'metadata': metadata}, 'NEXT reply')
pending.remove(req_rank)
def _request_bucket(self, bucket_id: int) -> dict:
"""Receiver requests bucket metadata from master via REQ/REP."""
assert self.rank is not None and self.rank > 0
self._send_pyobj({'type': 'NEXT', 'rank': self.rank, 'bucket_id': bucket_id}, f'NEXT send bucket={bucket_id}')
resp = self._recv_pyobj(f'NEXT recv bucket={bucket_id}')
if not isinstance(resp, dict) or not resp.get('ok', False):
raise RuntimeError(f'Metadata request failed for bucket {bucket_id}: {resp}')
return resp['metadata']
# ── Send / Receive ───────────────────────────────────────────────────
@torch.no_grad()
async def send_weights(
self,
weights: Generator[Tuple[str, torch.Tensor], None, None],
):
assert self.rank is not None and self.rank <= 0
if self.rank < 0:
for _ in weights:
pass
return
send_buf = self.send_buf
start_time = time.time()
bucket_meta: List[dict] = []
offset = 0
bucket_id = 0
total_params = 0
total_bytes = 0
def _flush(is_last: bool):
nonlocal bucket_meta, offset, bucket_id, total_bytes
if not bucket_meta and not is_last:
return
metadata = {
'bucket_id': bucket_id,
'is_last': is_last,
'bucket_meta': bucket_meta,
'payload_size': offset,
}
self._serve_bucket_requests(bucket_id, metadata)
self.pyhccl.broadcast(send_buf, src=0)
synchronize()
total_bytes += offset
bucket_id += 1
bucket_meta = []
offset = 0
for name, weight in weights:
total_params += 1
if weight.device.type == 'cpu':
weight = weight.to(get_current_device())
if not weight.is_contiguous():
weight = weight.contiguous()
weight_u8 = weight.view(-1).view(torch.uint8)
nbytes = weight_u8.numel()
if nbytes == 0:
if offset >= self.bucket_size:
_flush(is_last=False)
bucket_meta.append({
'name': name,
'shape': weight.shape,
'dtype': weight.dtype,
'offset': offset,
'nbytes': 0,
'chunk_offset': 0,
'total_nbytes': 0,
})
continue
chunk_offset = 0
while chunk_offset < nbytes:
if offset >= self.bucket_size:
_flush(is_last=False)
chunk_nbytes = min(self.bucket_size - offset, nbytes - chunk_offset)
send_buf[offset:offset + chunk_nbytes].copy_(weight_u8[chunk_offset:chunk_offset + chunk_nbytes])
bucket_meta.append({
'name': name,
'shape': weight.shape,
'dtype': weight.dtype,
'offset': offset,
'nbytes': chunk_nbytes,
'chunk_offset': chunk_offset,
'total_nbytes': nbytes,
})
offset += chunk_nbytes
chunk_offset += chunk_nbytes
_flush(is_last=True)
elapsed = time.time() - start_time
bandwidth = total_bytes / elapsed / (1024**3) if elapsed > 0 else 0.0
logger.debug('HCCL send_weights done: rank=%d, params=%d, time=%.2fs, bw=%.2f GB/s', self.rank, total_params,
elapsed, bandwidth)
@torch.no_grad()
async def receive_weights(self) -> AsyncGenerator[Tuple[str, torch.Tensor], None]:
assert self.rank is not None and self.rank > 0
recv_buf = self.recv_buf
bucket_id = 0
total_params = 0
total_bytes = 0
start_time = time.time()
partial_tensors: dict = {}
while True:
metadata = self._request_bucket(bucket_id)
self.pyhccl.broadcast(recv_buf, src=0)
synchronize()
bucket_meta = metadata['bucket_meta']
entries = bucket_meta.values() if isinstance(bucket_meta, dict) else bucket_meta
total_bytes += int(metadata.get('payload_size', self.bucket_size))
for meta in entries:
name = meta['name']
dtype = meta['dtype']
shape = meta['shape']
if not isinstance(shape, torch.Size):
shape = torch.Size(shape)
offset = int(meta['offset'])
nbytes = int(meta.get('nbytes', dtype.itemsize * shape.numel()))
chunk_offset = int(meta.get('chunk_offset', 0))
total_nbytes = int(meta.get('total_nbytes', dtype.itemsize * shape.numel()))
if nbytes == total_nbytes and chunk_offset == 0:
tensor = recv_buf[offset:offset + nbytes].view(dtype=dtype).view(shape)
yield name, tensor
total_params += 1
continue
state = partial_tensors.get(name)
if state is None:
state = {
'buffer': torch.empty(total_nbytes, dtype=torch.uint8, device=recv_buf.device),
'dtype': dtype,
'shape': shape,
'total': total_nbytes,
'received': 0,
}
partial_tensors[name] = state
if nbytes > 0:
state['buffer'][chunk_offset:chunk_offset + nbytes].copy_(recv_buf[offset:offset + nbytes])
state['received'] += nbytes
if state['received'] == state['total']:
full_size = dtype.itemsize * shape.numel()
tensor = state['buffer'][:full_size].view(dtype=dtype).view(shape)
yield name, tensor
total_params += 1
del partial_tensors[name]
if metadata['is_last']:
if partial_tensors:
pending = ', '.join(sorted(partial_tensors.keys())[:8])
raise RuntimeError(f'Incomplete chunked weights: {len(partial_tensors)} pending: {pending}')
break
bucket_id += 1
elapsed = time.time() - start_time
bandwidth = total_bytes / elapsed / (1024**3) if elapsed > 0 else 0.0
logger.debug('HCCL receive_weights done: rank=%d, params=%d, time=%.2fs, bw=%.2f GB/s', self.rank, total_params,
elapsed, bandwidth)
@@ -0,0 +1,171 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from __future__ import annotations
import ray
from typing import Any, List, Optional
from swift.utils.logger import get_logger
from .nccl import NCCLCheckpointEngine
logger = get_logger()
class CheckpointEngineManager:
def __init__(
self,
train_actors: List[Any],
rollout_replicas: List[Any],
*,
weight_sync_mode: str = 'nccl',
is_colocated: bool = False,
sleep_level: int = 1,
train_group: Any,
):
self.train_actors = train_actors
self._rollout_replicas = rollout_replicas
self.rollout_actors = [r.primary for r in rollout_replicas]
self._weight_sync_mode = weight_sync_mode
self.is_colocated = is_colocated
self._train_group = train_group
if is_colocated and sleep_level >= 2:
logger.warning(
'sleep_level=%d capped to 1 in colocate mode '
'(out-of-process vLLM cannot safely discard all GPU memory).', sleep_level)
sleep_level = 1
self.sleep_level = sleep_level
self.base_sync_done: bool = False
self._model_keys: Optional[List[str]] = None
self._sleeping_tags: set = set()
def sync_weights(self, merge_and_sync: bool = True) -> None:
"""Synchronize weights from training model to rollout replicas."""
if self.is_colocated:
self.sleep_rollout()
self.wake_up_rollout(tags=['weights'])
if self._weight_sync_mode == 'naive':
self._sync_weights_naive(merge_and_sync)
else:
self._sync_weights_nccl(merge_and_sync)
def sleep_rollout(self) -> None:
if self._sleeping_tags:
return
for replica in self._rollout_replicas:
replica.sleep(level=self.sleep_level)
self._sleeping_tags = {'weights', 'kv_cache'}
logger.debug('CheckpointEngineManager: rollout replicas sleeping (level=%d)', self.sleep_level)
def wake_up_rollout(self, tags: Optional[List[str]] = None) -> None:
if not self._sleeping_tags:
return
for replica in self._rollout_replicas:
replica.wake_up(tags=tags)
if tags is None:
self._sleeping_tags.clear()
else:
self._sleeping_tags -= set(tags)
logger.debug('CheckpointEngineManager: rollout wake_up tags=%s, still_sleeping=%s', tags, self._sleeping_tags)
def _sync_weights_naive(self, merge_and_sync: bool) -> None:
tg = self._train_group
adapter_only = self.base_sync_done and not merge_and_sync
need_merge = not adapter_only and merge_and_sync
if need_merge:
tg.merge_lora()
if self.is_colocated:
tg.offload_to_cpu()
try:
tg.update_weights(adapter_only=adapter_only)
finally:
if self.is_colocated:
tg.reload_to_gpu()
if need_merge:
tg.unmerge_lora()
if not self.base_sync_done:
self.base_sync_done = True
logger.debug('CheckpointEngineManager[naive]: initial weight sync done')
def _sync_weights_nccl(self, merge_and_sync: bool) -> None:
"""NCCL broadcast weight sync path.
Lifecycle:
1. prepare_checkpoint_engine on all actors
2. build_topology
3. init_process_group on all actors (concurrent — required for TCPStore)
4. send_weights (train) + receive_weights (rollout) concurrently
5. finalize_checkpoint_engine on all actors
"""
n_train = len(self.train_actors)
n_rollout = len(self.rollout_actors)
# 1. Prepare — train side: rank 0 is master, others are not
is_master_flags = [True] + [False] * (n_train - 1)
prepare_refs = [
actor.prepare_checkpoint_engine.remote(flag) for actor, flag in zip(self.train_actors, is_master_flags)
]
prepare_results = ray.get(prepare_refs)
model_metadata = prepare_results[0]
# 1b. Prepare — rollout side: all non-master
rollout_prepare_refs = [actor.prepare_checkpoint_engine.remote(False) for actor in self.rollout_actors]
ray.get(rollout_prepare_refs)
# 2. Build topology
model_kwargs, rollout_kwargs = NCCLCheckpointEngine.build_topology(n_train, n_rollout, [model_metadata])
# 3. Init process groups (MUST be concurrent — TCPStore server
# blocks until all clients connect)
train_init_refs = [
actor.init_checkpoint_process_group.remote(
rank=model_kwargs['rank'][i],
world_size=model_kwargs['world_size'][i],
master_metadata=model_kwargs['master_metadata'][i],
) for i, actor in enumerate(self.train_actors)
]
rollout_init_refs = [
actor.init_checkpoint_process_group.remote(
rank=rollout_kwargs['rank'][i],
world_size=rollout_kwargs['world_size'][i],
master_metadata=rollout_kwargs['master_metadata'][i],
) for i, actor in enumerate(self.rollout_actors)
]
ray.get(train_init_refs + rollout_init_refs)
# 4. Send/receive weights (concurrent)
adapter_only = self.base_sync_done and not merge_and_sync
need_merge = not adapter_only and merge_and_sync
peft_config = None
if adapter_only:
peft_config = ray.get(self.train_actors[0].get_peft_config_dict.remote())
if need_merge:
merge_refs = [actor.merge_lora.remote() for actor in self.train_actors]
ray.get(merge_refs)
train_send_refs = [
actor.send_checkpoint_weights.remote(adapter_only=adapter_only) for actor in self.train_actors
]
rollout_recv_refs = [
actor.receive_checkpoint_weights.remote(
base_sync_done=self.base_sync_done,
peft_config=peft_config,
) for actor in self.rollout_actors
]
ray.get(train_send_refs + rollout_recv_refs)
if need_merge:
unmerge_refs = [actor.unmerge_lora.remote() for actor in self.train_actors]
ray.get(unmerge_refs)
# 5. Finalize
train_fin_refs = [actor.finalize_checkpoint_engine.remote() for actor in self.train_actors]
rollout_fin_refs = [actor.finalize_checkpoint_engine.remote() for actor in self.rollout_actors]
ray.get(train_fin_refs + rollout_fin_refs)
if not self.base_sync_done:
self.base_sync_done = True
logger.info('CheckpointEngineManager[nccl]: initial weight sync to %d replica(s) '
'(lora_only=%s)', n_rollout, not merge_and_sync)
@@ -0,0 +1,57 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from __future__ import annotations
from typing import Optional
from .base import CheckpointEngine, MasterMetadata
class CheckpointEngineMixin:
"""Mixin providing checkpoint engine lifecycle methods for Ray actors.
Add this to Ray actors (MegatronWorker, VllmServer) to give them
checkpoint engine capabilities. The Manager calls these methods
via ``ray.remote()`` to coordinate NCCL weight sync.
Applied to ``MegatronWorker`` (send side) and ``VllmServer``
(receive side).
"""
_checkpoint_engine: Optional[CheckpointEngine] = None
_bucket_size: int = 3072 << 20
def _get_or_create_checkpoint_engine(self) -> CheckpointEngine:
"""Get or create the checkpoint engine instance (lazy singleton)."""
if self._checkpoint_engine is None:
from transformers.utils import is_torch_npu_available
if is_torch_npu_available():
from .hccl import HCCLCheckpointEngine
self._checkpoint_engine = HCCLCheckpointEngine(self._bucket_size, rebuild_group=False)
else:
from .nccl import NCCLCheckpointEngine
self._checkpoint_engine = NCCLCheckpointEngine(self._bucket_size)
return self._checkpoint_engine
def prepare_checkpoint_engine(self, is_master: bool) -> Optional[MasterMetadata]:
"""Prepare checkpoint engine for weight sync."""
engine = self._get_or_create_checkpoint_engine()
engine.is_master = is_master
return engine.prepare()
def init_checkpoint_process_group(
self,
rank: int,
world_size: int,
master_metadata: MasterMetadata,
) -> None:
engine = self._get_or_create_checkpoint_engine()
engine.init_process_group(
rank=rank,
world_size=world_size,
master_metadata=master_metadata,
)
def finalize_checkpoint_engine(self) -> None:
"""Finalize checkpoint engine: release buffers, optionally destroy group."""
if self._checkpoint_engine is not None:
self._checkpoint_engine.finalize()
@@ -0,0 +1,378 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from __future__ import annotations
import asyncio
import ray
import time
import torch
import torch.distributed as dist
from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple
from swift.utils import get_current_device, synchronize
from swift.utils.logger import get_logger
from .base import CheckpointEngine, MasterMetadata, TensorMeta, _find_free_port, _is_valid_ipv6_address
logger = get_logger()
def _pg_broadcast(pg, tensor, src: int = 0):
"""Broadcast tensor via raw ProcessGroupNCCL (unregistered PG)."""
opts = dist.BroadcastOptions()
opts.rootRank = src
work = pg.broadcast([tensor], opts)
work.wait()
class BroadcastOperation:
"""Async NCCL broadcast in a thread pool executor."""
def __init__(self, rank, pg, bucket, metadata, zmq_socket, topic):
self.rank = rank
self.pg = pg
self.bucket = bucket
self.metadata = metadata
self.socket = zmq_socket
self.topic = topic
loop = asyncio.get_running_loop()
self._task = loop.run_in_executor(None, self._run)
def _run(self):
import zmq
if self.rank == 0:
self.socket.send_string(self.topic, flags=zmq.SNDMORE)
self.socket.send_pyobj(self.metadata)
else:
self.socket.recv_string()
self.metadata = self.socket.recv_pyobj()
_pg_broadcast(self.pg, self.bucket, src=0)
async def wait_for_complete(self) -> dict:
await self._task
return self.metadata
class NCCLCheckpointEngine(CheckpointEngine):
def __init__(
self,
bucket_size: int = 3072 << 20,
group_name: str = 'swift_ckpt',
rebuild_group: bool = False,
**kwargs,
) -> None:
self.bucket_size = bucket_size
self.group_name = group_name
self.rebuild_group = rebuild_group
self.is_master = False
self.topic = 'bucket_metadata'
self.rank = None
self.world_size = None
self.send_buf = None
self.recv_buf = None
self.socket = None
self._pg = None
self._store = None
self._prepared = False
self._group_initialized = False
def _start_zmq_server(self):
import zmq
self.ip = ray.util.get_node_ip_address().strip('[]')
self.listen_port = _find_free_port()
context = zmq.Context()
self.socket = context.socket(zmq.PUB)
if _is_valid_ipv6_address(self.ip):
address = f'tcp://[{self.ip}]:{self.listen_port}'
self.socket.setsockopt(zmq.IPV6, 1)
else:
address = f'tcp://{self.ip}:{self.listen_port}'
self.socket.bind(address)
def _connect_zmq_client(self, metadata: MasterMetadata):
import zmq
context = zmq.Context()
self.socket = context.socket(zmq.SUB)
if _is_valid_ipv6_address(metadata.zmq_ip):
address = f'tcp://[{metadata.zmq_ip}]:{metadata.zmq_port}'
self.socket.setsockopt(zmq.IPV6, 1)
else:
address = f'tcp://{metadata.zmq_ip}:{metadata.zmq_port}'
self.socket.connect(address)
self.socket.setsockopt_string(zmq.SUBSCRIBE, self.topic)
def prepare(self) -> Optional[MasterMetadata]:
"""Allocate buffers and start ZMQ server (master only). Idempotent."""
if self._prepared:
if self.is_master:
return MasterMetadata(
zmq_ip=self.ip,
zmq_port=self.listen_port,
nccl_store_host=self._nccl_store_host,
nccl_store_port=self._nccl_store_port,
)
return None
device = get_current_device()
self.send_buf = torch.zeros(self.bucket_size, dtype=torch.uint8, device=device)
self.recv_buf = torch.zeros(self.bucket_size, dtype=torch.uint8, device=device)
if self.is_master:
self._start_zmq_server()
self._nccl_store_host = self.ip
self._nccl_store_port = _find_free_port()
self._prepared = True
return MasterMetadata(
zmq_ip=self.ip,
zmq_port=self.listen_port,
nccl_store_host=self._nccl_store_host,
nccl_store_port=self._nccl_store_port,
)
else:
self._prepared = True
return None
def finalize(self):
"""Clean up resources. Full teardown only when rebuild_group=True."""
if self.rebuild_group:
if self.socket is not None:
try:
self.socket.close()
except Exception as e:
logger.warning('Error closing ZMQ socket: %s', e)
self.socket = None
if self._pg is not None:
self._pg = None
self._store = None
self.rank = None
self.world_size = None
self.send_buf = None
self.recv_buf = None
self._prepared = False
self._group_initialized = False
@classmethod
def build_topology(
cls,
trainer_world_size: int,
rollout_world_size: int,
metadata: List[Dict],
) -> Tuple[Dict[str, List[Any]], Dict[str, List[Any]]]:
"""Build NCCL broadcast topology: trainer rank0 as source, rollout as receivers."""
master_metadata = metadata[0]
trainer_kwargs = {
'rank': [0] + [-1] * (trainer_world_size - 1),
'world_size': [rollout_world_size + 1] * trainer_world_size,
'master_metadata': [master_metadata] * trainer_world_size,
}
rollout_kwargs = {
'rank': list(range(1, rollout_world_size + 1)),
'world_size': [rollout_world_size + 1] * rollout_world_size,
'master_metadata': [master_metadata] * rollout_world_size,
}
return trainer_kwargs, rollout_kwargs
def init_process_group(self, rank: int, world_size: int, master_metadata: MasterMetadata):
"""Initialize a dedicated NCCL process group for weight synchronization.
Creates a ``ProcessGroupNCCL`` directly (without registering it in
the default ``_World``), using a ``TCPStore`` hosted by the master
for rendezvous.
Idempotent when ``rebuild_group=False``.
"""
import os
if rank < 0:
self.rank = rank
self.world_size = world_size
self._group_initialized = True
return
if self._group_initialized and not self.rebuild_group:
return
if self._pg is None:
self.rank = rank
self.world_size = world_size
os.environ['NCCL_CUMEM_ENABLE'] = '0'
is_store_master = (rank == 0)
self._store = dist.TCPStore(
host_name=master_metadata.nccl_store_host,
port=master_metadata.nccl_store_port,
world_size=world_size,
is_master=is_store_master,
wait_for_workers=True,
)
self._pg = dist.ProcessGroupNCCL(
self._store,
rank,
world_size,
)
else:
assert self.rank == rank, f'rank {rank} != self.rank {self.rank}'
assert self.world_size == world_size
if self.rank > 0 and self.socket is None:
self._connect_zmq_client(master_metadata)
barrier_tensor = torch.zeros(1, dtype=torch.int32, device=get_current_device())
_pg_broadcast(self._pg, barrier_tensor, src=0)
synchronize()
# ZMQ PUB/SUB "slow joiner" mitigation: after NCCL barrier confirms
# all participants are connected, give SUB sockets time to fully
# establish the subscription before PUB sends metadata.
if self.rank == 0 and self.socket is not None:
time.sleep(0.1)
self._group_initialized = True
# ── Send / Receive ───────────────────────────────────────────────────
@torch.no_grad()
async def send_weights(
self,
weights: Generator[Tuple[str, 'torch.Tensor'], None, None],
):
"""Send model weights to rollout workers via NCCL broadcast.
Uses double buffering: fill send_buf while the previous bucket
is being broadcast, then swap buffers.
"""
assert self.rank is not None and self.rank <= 0
if self.rank < 0:
for name, weight in weights:
pass
return
send_buf, recv_buf = self.send_buf, self.recv_buf
broadcast_op = None
start_time = time.time()
bucket_meta: Dict[str, TensorMeta] = {}
offset = 0
for name, weight in weights:
if offset + weight.nbytes > self.bucket_size:
synchronize()
if broadcast_op is not None:
await broadcast_op.wait_for_complete()
broadcast_op = BroadcastOperation(
rank=self.rank,
pg=self._pg,
bucket=send_buf,
metadata={
'bucket_meta': bucket_meta,
'is_last': False
},
zmq_socket=self.socket,
topic=self.topic,
)
send_buf, recv_buf = recv_buf, send_buf
bucket_meta = {}
offset = 0
assert offset + weight.nbytes <= self.bucket_size, (
f'Weight {name}({weight.shape}, {weight.dtype}) is too large '
f'for bucket ({self.bucket_size / 1e6:.1f} MB). Increase bucket_size.')
bucket_meta[name] = {
'name': name,
'shape': weight.shape,
'dtype': weight.dtype,
'offset': offset,
}
send_buf[offset:offset + weight.nbytes].copy_(weight.view(-1).view(torch.uint8), non_blocking=True)
offset += weight.nbytes
synchronize()
if broadcast_op is not None:
await broadcast_op.wait_for_complete()
broadcast_op = BroadcastOperation(
rank=self.rank,
pg=self._pg,
bucket=send_buf,
metadata={
'bucket_meta': bucket_meta,
'is_last': True
},
zmq_socket=self.socket,
topic=self.topic,
)
await broadcast_op.wait_for_complete()
logger.debug('Rank %d send weights done, time cost: %.2fs', self.rank, time.time() - start_time)
@torch.no_grad()
async def receive_weights(self) -> AsyncGenerator[Tuple[str, 'torch.Tensor'], None]:
"""Receive model weights from trainer via NCCL broadcast.
Uses double buffering: receive into recv_buf while processing
send_buf, then swap.
Yields:
Tuples of (name, tensor). The tensor is a *view* into the
receive buffer — callers that need to keep it should clone it.
"""
assert self.rank is not None and self.rank > 0
send_buf, recv_buf = self.send_buf, self.recv_buf
total_bytes, total_params = 0, 0
start_time = time.time()
broadcast_op = BroadcastOperation(
rank=self.rank,
pg=self._pg,
bucket=recv_buf,
metadata=None,
zmq_socket=self.socket,
topic=self.topic,
)
metadata = await broadcast_op.wait_for_complete()
total_bytes += self.bucket_size
total_params += len(metadata['bucket_meta'])
send_buf, recv_buf = recv_buf, send_buf
while not metadata['is_last']:
broadcast_op = BroadcastOperation(
rank=self.rank,
pg=self._pg,
bucket=recv_buf,
metadata=None,
zmq_socket=self.socket,
topic=self.topic,
)
for name, meta in metadata['bucket_meta'].items():
dtype, shape = meta['dtype'], meta['shape']
size = dtype.itemsize * shape.numel()
tensor = send_buf[meta['offset']:meta['offset'] + size].view(dtype=dtype).view(shape)
yield name, tensor
metadata = await broadcast_op.wait_for_complete()
total_bytes += self.bucket_size
total_params += len(metadata['bucket_meta'])
synchronize()
send_buf, recv_buf = recv_buf, send_buf
for name, meta in metadata['bucket_meta'].items():
dtype, shape = meta['dtype'], meta['shape']
size = dtype.itemsize * shape.numel()
tensor = send_buf[meta['offset']:meta['offset'] + size].view(dtype=dtype).view(shape)
yield name, tensor
elapsed = time.time() - start_time
bandwidth = total_bytes / elapsed / (1024 * 1024 * 1024) if elapsed > 0 else 0
logger.debug('receive_weights done: rank=%d, params=%d, time=%.2fs, bandwidth=%.2f GB/s', self.rank,
total_params, elapsed, bandwidth)
+289
View File
@@ -0,0 +1,289 @@
"""Driver-side helpers shared by the Ray Megatron pipeline.
This module owns the "plain Python" helpers that do not belong to any
particular trainer class:
* YAML → dict config parsing and merging
* structured config parsing from YAML group dicts
* driver-side dataset building (via dict, no argv round-trip)
* train/eval iteration bookkeeping
* extracting the canonical iteration from worker results
"""
import json
import os
import torch
from dataclasses import dataclass, field
from typing import Any, Dict, List
from swift.arguments import BaseArguments
from swift.utils import get_logger, parse_args, seed_everything, to_abspath
logger = get_logger()
_RAY_ONLY_KEYS = frozenset({'gpus', 'colocate_groups', 'nnodes'})
_PARALLEL_DEFAULTS: Dict[str, Any] = {
'tensor_model_parallel_size': 1,
'pipeline_model_parallel_size': 1,
'context_parallel_size': 1,
}
def parse_args_from_dict(class_type, cfg: Dict[str, Any]):
"""Construct a dataclass from a config dict via HfArgumentParser."""
argv = _dict_to_argv(cfg)
args, remaining_args = parse_args(class_type, argv)
if remaining_args:
logger.warning('parse_args_from_dict: unrecognised args: %s', remaining_args)
return args
def _dict_to_argv(cfg: Dict[str, Any]) -> List[str]:
argv: List[str] = []
for k, v in cfg.items():
if k in _RAY_ONLY_KEYS or v is None:
continue
flag = f'--{k}'
if isinstance(v, bool):
argv += [flag, str(v).lower()]
elif isinstance(v, (list, tuple)):
argv.append(flag)
argv += [str(item) for item in v]
elif isinstance(v, dict):
argv += [flag, json.dumps(v)]
else:
argv += [flag, str(v)]
return argv
@dataclass
class RayConfig:
rlhf_type: str = 'grpo'
colocate_groups: List[List[str]] = field(default_factory=list)
train_gpus: int = 0
rollout_gpus: int = 0
teacher_gpus: int = 0
sleep_level: int = 1
nnodes: int = 1
@property
def group_gpus(self) -> Dict[str, int]:
return {
'train': self.train_gpus,
'rollout': self.rollout_gpus,
'teacher': self.teacher_gpus,
}
def gpus_as_process_on_nodes(self, total_gpus: int) -> List[int]:
"""Split ``total_gpus`` evenly across ``nnodes`` for ResourcePool."""
if self.nnodes <= 1:
return [total_gpus]
per_node, remainder = divmod(total_gpus, self.nnodes)
if remainder != 0:
raise ValueError(f'total_gpus={total_gpus} is not evenly divisible by nnodes={self.nnodes}')
return [per_node] * self.nnodes
def parse_ray_yaml(config_path: str) -> 'tuple[RayConfig, Dict[str, Dict[str, Any]], Dict[str, Any]]':
"""Parse a Ray YAML config into (ray_config, group_dicts, shared_dict)."""
import yaml
with open(config_path) as f:
raw = yaml.safe_load(f)
rlhf_type = raw.get('rlhf_type')
colocate_groups = raw.pop('colocate_groups', [])
sleep_level = int(raw.pop('sleep_level', 1))
nnodes = int(raw.pop('nnodes', 1))
group_configs: Dict[str, dict] = {}
for g in KNOWN_GROUPS:
group_configs[g] = raw.pop(g, {}) or {}
gpu_counts = {g: int(cfg.pop('gpus', 0)) for g, cfg in group_configs.items()}
shared_config = dict(raw)
for key, default in _PARALLEL_DEFAULTS.items():
shared_config.setdefault(key, default)
ray_config = RayConfig(
rlhf_type=rlhf_type,
colocate_groups=colocate_groups,
train_gpus=gpu_counts.get('train', 0),
rollout_gpus=gpu_counts.get('rollout', 0),
teacher_gpus=gpu_counts.get('teacher', 0),
sleep_level=sleep_level,
nnodes=nnodes,
)
_validate_colocate_groups(colocate_groups, gpu_counts)
return ray_config, group_configs, shared_config
KNOWN_GROUPS = frozenset(('train', 'rollout', 'teacher'))
def _validate_colocate_groups(
colocate_groups: List[List[str]],
gpu_counts: Dict[str, int],
) -> None:
"""Validate colocate_groups: ≥2 roles, known, non-overlapping, each with gpus > 0."""
if not colocate_groups:
return
seen: set = set()
for idx, group in enumerate(colocate_groups):
if not isinstance(group, list) or len(group) < 2:
raise ValueError(f'colocate_groups[{idx}] must be a list of ≥2 roles, '
f'got {group!r}')
group_gpu_counts = set()
for role in group:
if role not in KNOWN_GROUPS:
raise ValueError(f'colocate_groups[{idx}] contains unknown role {role!r}; '
f'valid roles: {sorted(KNOWN_GROUPS)}')
if role in seen:
raise ValueError(f'Role {role!r} appears in multiple colocate groups')
seen.add(role)
n = gpu_counts.get(role, 0)
if n <= 0:
raise ValueError(f'Role {role!r} in colocate_groups[{idx}] has 0 GPUs; '
f'colocated roles must each have gpus > 0')
group_gpu_counts.add(n)
if len(group_gpu_counts) > 1:
raise ValueError(f'colocate_groups[{idx}] roles have different GPU counts '
f'{dict(zip(group, [gpu_counts[r] for r in group]))}; '
f'colocated roles must share the same GPU set')
def merge_group_dict(shared: Dict[str, Any], group: Dict[str, Any]) -> Dict[str, Any]:
"""Merge shared + group config, stripping Ray-only keys and None values."""
merged = {**shared, **group}
for k in _RAY_ONLY_KEYS:
merged.pop(k, None)
return {k: v for k, v in merged.items() if v is not None}
def estimate_dp_size(cfg: Dict[str, Any], gpus: int) -> int:
"""Estimate DP size from a merged group config dict."""
tp = cfg.get('tensor_model_parallel_size', 1)
pp = cfg.get('pipeline_model_parallel_size', 1)
cp = cfg.get('context_parallel_size', 1)
assert gpus % (tp * pp * cp) == 0
return gpus // (tp * pp * cp)
def build_dataset_from_dict(cfg: Dict[str, Any]):
"""Build dataset on the driver without instantiating a Megatron pipeline.
"""
from swift.megatron.arguments import MegatronRLHFArguments
from swift.rlhf_trainers.utils import identity_data_collator
cfg = dict(cfg)
cfg['skip_megatron_init'] = True
args = parse_args_from_dict(MegatronRLHFArguments, cfg)
if hasattr(args, 'seed'):
seed_everything(args.seed)
rlhf_type = args.rlhf_type
if rlhf_type in ('grpo', 'gkd'):
args.remove_unused_columns = False
if args.output_dir is None:
args.output_dir = f'megatron_output/{args.model_suffix}'
args.output_dir = to_abspath(args.output_dir)
os.makedirs(args.output_dir, exist_ok=True)
with torch.device('meta'):
_, processor = args.get_model_processor(load_model=False, download_model=args.mcore_model is None)
template = _prepare_template(args, processor)
train_dataset, val_dataset = _prepare_dataset(args)
data_collator = identity_data_collator if rlhf_type in ('grpo', 'gkd') else template.data_collator
# TODO: integrate val_dataset / eval_iters into the training loop
return {
'train_dataset': train_dataset,
'val_dataset': val_dataset,
'data_collator': data_collator,
'micro_batch_size': args.micro_batch_size,
'global_batch_size': args.global_batch_size,
'padding_free': args.padding_free,
'num_train_epochs': args.num_train_epochs,
'train_iters': args.train_iters,
'save_strategy': args.save_strategy,
'eval_iters': args.eval_iters,
'num_generations': args.num_generations,
'template': template,
'_driver_args': args,
}
def _prepare_template(args, processor):
"""Create template from args and processor — no pipeline object needed."""
template = args.get_template(processor)
mode_mapping = {'grpo': 'train', 'gkd': 'train', 'kto': 'kto'}
template.set_mode(mode_mapping.get(args.rlhf_type, 'rlhf'))
template.use_megatron = True
return template
def _prepare_dataset(args: BaseArguments):
"""Load and optionally encode dataset — no pipeline object needed."""
# Ray pipeline has no validation/eval loop yet
if args.split_dataset_ratio and args.split_dataset_ratio > 0:
logger.warning(
'Ray pipeline has no validation loop yet; overriding split_dataset_ratio '
'%s -> 0.0 (no validation split).', args.split_dataset_ratio)
args.split_dataset_ratio = 0.0
if args.val_dataset:
logger.warning('Ray pipeline has no validation loop yet; ignoring val_dataset=%s.', args.val_dataset)
args.val_dataset = []
assert args.rlhf_type in ('grpo', 'gkd')
return args.load_dataset()
def compute_iter_params(data_info: Dict[str, Any], dp_size: int) -> Dict[str, Any]:
"""Compute train_iters / eval_iters / save_steps on the driver."""
mbs = data_info['micro_batch_size']
gbs = data_info['global_batch_size']
step_batch_size = mbs * dp_size
num_gen = data_info.get('num_generations', 1)
train_ds = data_info.get('train_dataset')
val_ds = data_info.get('val_dataset')
train_len = len(train_ds) if train_ds is not None and hasattr(train_ds, '__len__') else 0
val_len = len(val_ds) if val_ds is not None and hasattr(val_ds, '__len__') else 0
result: Dict[str, Any] = {}
if data_info.get('save_strategy') == 'epoch' and train_len > 0:
ds_sample = train_len // step_batch_size * step_batch_size * num_gen
result['save_steps'] = ds_sample // gbs
result['eval_steps'] = result['save_steps']
train_iters = data_info.get('train_iters')
if data_info.get('num_train_epochs') is not None and train_len > 0:
ds_sample = train_len // step_batch_size * step_batch_size * num_gen
train_iters = ds_sample * data_info['num_train_epochs'] // gbs
result['train_iters'] = train_iters
eval_iters = data_info.get('eval_iters', -1)
if eval_iters is not None and eval_iters < 0:
if val_len == 0:
eval_iters = 0
else:
ds_sample = val_len // step_batch_size * step_batch_size * num_gen
eval_iters = max(ds_sample // gbs, 1)
if val_len > 0 and val_len < step_batch_size:
eval_iters = 0
result['eval_iters'] = eval_iters or 0
return result
def extract_iteration(step_results) -> int:
"""Read the canonical iteration off ``WorkerGroup.execute`` results."""
if not step_results:
return 0
for r in step_results:
if isinstance(r, dict) and 'iteration' in r:
return int(r['iteration'])
return 0
+380
View File
@@ -0,0 +1,380 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Driver-side GKD trainer for Ray-based Megatron training."""
from __future__ import annotations
import copy
import random
import ray
import torch
from contextlib import contextmanager
from typing import List
from swift.infer_engine.protocol import RequestConfig, RolloutOutput
from swift.rl_core.data import GKDSample
from swift.rlhf_trainers.gkd_loss import DataSource, TeacherOutput
from swift.rlhf_trainers.utils import parse_prompt_logprobs
from swift.rollout import MultiTurnScheduler, invoke_async_hook, multi_turns, run_multi_turn
from swift.utils import get_logger, remove_response
from .base_trainer import BaseRayTrainer
from .driver_utils import extract_iteration
from .worker_group import DPDispatchedDict
logger = get_logger()
class GKDTrainer(BaseRayTrainer):
def _prepare_state(self) -> None:
super()._prepare_state()
args = self.args
self.sft_alpha = args.sft_alpha
self.gkd_logits_topk = args.gkd_logits_topk
# GKD on-policy schedule: each step is on-policy (student generates) with
# probability ``lmbda``; otherwise off-policy (distill on dataset responses).
self.lmbda = args.lmbda
self._data_source_rng = random.Random(getattr(args, 'seed', 42))
# GKD generates exactly one completion per prompt (on-policy student generation),
# so num_generations is always 1 here regardless of the (GRPO-oriented) default.
self._data_info['num_generations'] = 1
self._teacher_model_dir = getattr(args, 'teacher_model_dir', None) or args.teacher_model
self._teacher_model_server = args.teacher_model_server
self._teacher_use_disable_adapter = args._teacher_use_disable_adapter
if self._teacher_use_disable_adapter:
self._teacher_model_dir = None
if self._teacher_model_server and not self.teacher_replicas:
raise NotImplementedError('teacher_model_server is not yet supported in the Ray pipeline. '
'Use teacher_model (colocated) or teacher replicas (teacher.gpus > 0) instead.')
vp_size = getattr(args, 'virtual_pipeline_model_parallel_size', None)
assert vp_size is None or vp_size == 1, \
'Ray GKD does not support VPP (virtual_pipeline_model_parallel_size > 1).'
# truncation_strategy='delete': resample prompts whose encode fails (over max_length).
self.truncation_strategy = args.truncation_strategy
self.max_completion_length = args.max_completion_length
self._max_resample_rounds = getattr(args, 'max_resample_times', 3)
self._needs_resample_iterator = self.truncation_strategy == 'delete'
self._prepare_multi_turn()
def _prepare_multi_turn(self) -> None:
args = self.args
self._multi_turn_scheduler: MultiTurnScheduler | None = None
self._max_turns: int | None = getattr(args, 'max_turns', None)
self._enable_server_multi_turn = False
scheduler_cfg = getattr(args, 'multi_turn_scheduler', None)
if not scheduler_cfg:
return
if isinstance(scheduler_cfg, str):
if scheduler_cfg not in multi_turns:
raise ValueError(f'Unknown multi_turn_scheduler: {scheduler_cfg!r}; '
f'available: {list(multi_turns)}')
scheduler_kwargs = {'max_turns': self._max_turns}
tokenizer = getattr(getattr(self, 'template', None), 'tokenizer', None)
if tokenizer is not None:
scheduler_kwargs['tokenizer'] = tokenizer
gym_env = getattr(args, 'gym_env', None)
if gym_env is not None:
scheduler_kwargs['gym_env'] = gym_env
self._multi_turn_scheduler = multi_turns[scheduler_cfg](**scheduler_kwargs)
else:
assert isinstance(scheduler_cfg, MultiTurnScheduler)
self._multi_turn_scheduler = scheduler_cfg
def _train_loop(self, tg, train_iters, iteration):
ckpt = self.ckpt_manager
spg = self._steps_per_generation
# Initialize colocated teacher if configured (skip for self-distillation via disable_adapter)
if self._teacher_model_dir and not self._teacher_model_server and not self._teacher_use_disable_adapter:
tg.execute('init_teacher_model', self._teacher_model_dir)
logger.info('Colocated teacher model initialized from %s', self._teacher_model_dir)
while iteration < train_iters:
# One generation (a single data_source) feeds ``spg`` training steps.
prompt_batch = next(self._data_iter)
if self.truncation_strategy == 'delete':
prompt_batch = self._resample_failed_prompts(prompt_batch, strip_response=False)
data_source = self._determine_data_source()
if data_source == DataSource.STUDENT:
# On-policy: sync the latest weights to the rollout engine and generate.
ckpt.sync_weights(merge_and_sync=True)
with self._generation_context(tg, ckpt):
rollout_batch = self._expand_for_generation(prompt_batch)
completions = self._generate(rollout_batch)
gkd_samples = self._postprocess_rollout(rollout_batch, completions)
source_items = gkd_samples
else:
# Off-policy (lmbda<1): distill on the dataset's ground-truth responses,
# no generation and no weight sync to the rollout engine.
gkd_samples = [GKDSample.from_row(item) for item in prompt_batch]
source_items = gkd_samples
self._maybe_log_completions(gkd_samples if data_source == DataSource.STUDENT else None, gen_step=iteration)
# Split one generation into ``spg`` chunks; each chunk is one training step
# (same data_source). spg=1 degenerates to a single chunk == the whole batch.
# n == global_batch_size * spg: the driver dataloader uses drop_last=True + a
# cyclic iterator (see _setup_dataloader) and GKD uses num_generations=1, so n is
# always an exact multiple of spg and the spg chunks tile source_items with no
# remainder. (max(., 1) only guards the impossible spg > n case.)
n = len(source_items)
chunk_size = max(n // spg, 1)
for step_idx in range(spg):
if iteration >= train_iters:
break
chunk = source_items[step_idx * chunk_size:(step_idx + 1) * chunk_size]
if not chunk:
break
samples = self._encode_rollout_batch(chunk)
use_colocated_teacher = self._teacher_use_disable_adapter or (self._teacher_model_dir
and not self._teacher_model_server)
if self.teacher_replicas:
if data_source != DataSource.STUDENT:
raise NotImplementedError('Teacher replicas currently require on-policy generation (lmbda=1). '
'Use a colocated teacher_model for lmbda<1 (off-policy) training.')
self._fetch_teacher_from_replicas(chunk, samples)
# Driver collates the student (and, for the colocated path, the teacher view)
# micro-batches; the worker only runs prepare_batch (PP/CP slice) + forward.
dispatch = self._collate_for_workers_gkd(tg, samples, data_source, with_teacher=use_colocated_teacher)
if use_colocated_teacher:
# Teacher forwards on the worker (CP slicing keeps each rank's shard
# aligned) and caches per-micro-batch; train_step attaches the cache.
tg.compute_teacher_logits(dispatch)
results = tg.train_step(dispatch)
iteration = extract_iteration(results)
return iteration
def _determine_data_source(self):
"""Pick the data source for this step (GKD on/off-policy schedule).
With probability ``lmbda`` the step is on-policy (the student generates the
response); otherwise it is off-policy and we distill on the dataset's
ground-truth response.
"""
if self._data_source_rng.random() < self.lmbda:
return DataSource.STUDENT
return DataSource.DATASET
def _expand_for_generation(self, prompt_batch):
"""Convert prompt dicts to GKDSample and strip response for generation."""
samples = [GKDSample.from_row(item) for item in prompt_batch]
for s in samples:
remove_response(s.messages)
return samples
def _generate(self, batch: List[GKDSample]) -> List[RolloutOutput]:
args = self.args
request_config = RequestConfig(
n=1,
max_tokens=args.max_completion_length,
temperature=args.temperature,
top_p=args.top_p,
top_k=args.top_k,
stop=args.stop_words or None,
return_details=True,
)
# Convert samples to RolloutInferRequest at the engine boundary
# (same pattern as GRPO Ray trainer).
requests = [s.to_infer_request() for s in batch]
if self._multi_turn_scheduler is not None and not self._enable_server_multi_turn:
# Mode A: driver-side trainer loop. run_multi_turn mutates `messages`
# in place on RolloutInferRequest objects.
invoke_async_hook(self._multi_turn_scheduler.on_trajectory_start(requests))
first_turn = [
RolloutOutput(response=resp) for resp in self._distribute_to_replicas(requests, request_config)
]
return run_multi_turn(
requests=requests,
first_turn_outputs=first_turn,
scheduler=self._multi_turn_scheduler,
rollout_fn=lambda reqs, cfg:
[RolloutOutput(response=resp) for resp in self._distribute_to_replicas(reqs, cfg)],
request_config=request_config,
max_turns=self._max_turns,
)
completions = self._distribute_to_replicas(requests, request_config)
return [RolloutOutput(response=resp) for resp in completions]
def _postprocess_rollout(self, samples, outputs):
"""Merge rollout outputs back onto GKDSample (deepcopy to match HF path)."""
results = []
for sample, output in zip(samples, outputs):
if output is not None:
sample = copy.deepcopy(sample)
sample.apply_rollout_output(rollout_output=output)
results.append(sample)
return results
@contextmanager
def _extended_max_length(self):
"""Temporarily extend template.max_length by max_completion_length so the
prompt+response (token-in-token-out) encodes without truncation.
"""
template = self.template
original = template.max_length
template.max_length = original + self.args.max_completion_length
try:
yield
finally:
template.max_length = original
def _encode_rollout_batch(self, samples: List[GKDSample]):
"""Encode samples into per-sample worker payloads.
Uses the shared ``encode_gkd_samples`` helper (same as HF / Megatron GKD)
so OPSD logic is fully encapsulated. Returns per-sample payloads with
``encoded`` (student) and optionally ``teacher_encoded`` (OPSD teacher view).
"""
from swift.rlhf_trainers.gkd_helpers import encode_gkd_samples
template = self.template
with self._extended_max_length():
student_list, teacher_list, has_opsd = encode_gkd_samples(samples, template)
result = []
for i in range(len(samples)):
payload = {'encoded': student_list[i]}
if has_opsd:
payload['teacher_encoded'] = teacher_list[i]
result.append(payload)
return result
def _collate_for_workers_gkd(self, tg, samples: List[dict], data_source, *, with_teacher: bool):
"""Driver-side GKD collate: ``List[payload-dict]`` -> ``{dp_rank: [model_inputs]}``.
Mirrors the non-Ray GKD ``_encode_samples`` (data_collator on the rank, teacher
forward later via prepare_batch). Each micro-batch ``model_inputs`` carries:
- student forward tensors (``template.data_collator`` of ``encoded``),
- ``data_source`` (SFT gating in loss_func),
- ``teacher_model_inputs`` (colocated path): the collated teacher VIEW for the
worker teacher forward — OPSD uses ``teacher_encoded``, else ``encoded``,
- ``teacher_output`` (teacher-replicas path): the per-sample TeacherOutputs
(already on each sample) collated into one batched TeacherOutput.
"""
from swift.megatron.utils import get_padding_to
from .megatron_worker import MegatronWorker
template = self.template
padding_to = self._padding_to if self._padding_to is not None else get_padding_to(self.args)
dp_size = tg.dp_size
mbs = int(self.args.micro_batch_size)
n = len(samples)
if n % dp_size != 0:
raise ValueError(f'_collate_for_workers_gkd: batch size {n} not divisible by dp_size {dp_size}.')
shard_size = n // dp_size
dispatch = DPDispatchedDict()
for dp_rank in range(dp_size):
shard = samples[dp_rank * shard_size:(dp_rank + 1) * shard_size]
micro_batches = []
for i in range(0, len(shard), mbs):
chunk = shard[i:i + mbs]
model_inputs = template.data_collator([s['encoded'] for s in chunk], padding_to=padding_to)
model_inputs['data_source'] = data_source
if with_teacher:
has_opsd = chunk[0].get('teacher_encoded') is not None
key = 'teacher_encoded' if has_opsd else 'encoded'
model_inputs['teacher_model_inputs'] = template.data_collator(
batch=[s[key] for s in chunk], padding_to=padding_to)
elif chunk[0].get('teacher_output') is not None:
# Teacher-replicas path: per-sample TeacherOutputs collated on the driver
# (pure tensor ops). The teacher seq length differs from the student under
# OPSD, so align by mask (is_opsd) rather than padding to the student length.
if getattr(self.args, 'context_parallel_size', 1) > 1:
raise ValueError('Standalone teacher replicas (teacher.gpus > 0) do not support '
'context_parallel_size > 1: per-sample teacher token-logprobs are built '
'from raw sequence lengths and cannot be CP-sharded to align with the '
'student. Use a colocated teacher_model for CP>1.')
has_opsd = any(s.get('teacher_encoded') is not None for s in chunk)
model_inputs['teacher_output'] = MegatronWorker._collate_teacher_outputs(
[s['teacher_output'] for s in chunk],
self.device,
padding_free=template.padding_free,
target_seq_len=model_inputs['labels'].shape[-1],
is_opsd=has_opsd)
micro_batches.append(model_inputs)
dispatch[dp_rank] = micro_batches
return dispatch
def _fetch_teacher_from_replicas(self, gkd_samples: List[GKDSample], samples):
"""Fetch teacher logprobs from Ray teacher replicas.
Uses to_infer_request() + teacher_messages replacement (OPSD) to build
unified RolloutInferRequest objects, matching HF GKD's _build_teacher_requests.
"""
topk = self.gkd_logits_topk
assert topk is not None, 'gkd_logits_topk must be set when using teacher replicas'
requests = []
teacher_encodeds = [] # teacher-side encoded (OPSD) or None (non-OPSD)
for s, sample in zip(gkd_samples, samples):
req = s.to_infer_request()
teacher_encoded = sample.get('teacher_encoded')
if s.teacher_messages:
req.messages = s.teacher_messages
teacher_encodeds.append(teacher_encoded)
else:
teacher_encodeds.append(None)
requests.append(req)
request_config = RequestConfig(prompt_logprobs=topk, max_tokens=1, temperature=0.0)
replicas = self.teacher_replicas
n = len(replicas)
chunk_size = (len(requests) + n - 1) // n
refs = []
for i, replica in enumerate(replicas):
shard = requests[i * chunk_size:(i + 1) * chunk_size]
if not shard:
continue
refs.append(replica.generate(shard, request_config))
parts = ray.get(refs)
responses = []
for p in parts:
responses.extend(p)
for sample, response, t_encoded in zip(samples, responses, teacher_encodeds):
parsed = parse_prompt_logprobs(response, topk=topk)
encoded = t_encoded if t_encoded is not None else sample['encoded']
teacher_labels = t_encoded.get('labels') if t_encoded is not None else None
sample['teacher_output'] = self._build_per_sample_teacher_output(parsed, encoded, topk, teacher_labels)
@staticmethod
def _build_per_sample_teacher_output(parsed, encoded, topk, labels=None):
"""Build a per-sample TeacherOutput from parsed prompt logprobs.
For OPSD, ``encoded`` is the teacher-side encoding and ``labels`` are
its labels; together they let ``extract_active`` mask-align the shared response.
For non-OPSD, teacher and student share the same encoding, so we fall
back to ``encoded['labels']`` when ``labels`` is not provided.
"""
if labels is None:
labels = encoded.get('labels')
lps, ixs = parsed
input_ids = encoded['input_ids']
seq_len = len(input_ids) if isinstance(input_ids, list) else input_ids.shape[-1]
parsed_len = len(lps)
topk_logprobs = torch.full((seq_len, topk), float('-inf'), dtype=torch.float32)
topk_indices = torch.zeros(seq_len, topk, dtype=torch.long)
length = min(parsed_len, seq_len)
if length > 0:
topk_logprobs[:length] = torch.tensor(lps[:length], dtype=torch.float32)
topk_indices[:length] = torch.tensor(ixs[:length], dtype=torch.long)
kwargs = dict(topk_logprobs=topk_logprobs.unsqueeze(0), topk_indices=topk_indices.unsqueeze(0))
if labels is not None:
t_labels = labels
if not isinstance(t_labels, torch.Tensor):
t_labels = torch.tensor(t_labels, dtype=torch.long)
kwargs['labels'] = t_labels.unsqueeze(0) if t_labels.dim() == 1 else t_labels
return TeacherOutput(**kwargs)
+593
View File
@@ -0,0 +1,593 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from __future__ import annotations
import concurrent.futures
import copy
import os
import torch
import uuid
from typing import Any, Dict, List, Optional, Tuple
from swift.dataset import RowPreprocessor
from swift.infer_engine import RequestConfig
from swift.infer_engine.protocol import RolloutOutput
from swift.rl_core.advantage import (compute_advantages, compute_reward_metrics, compute_teacher_kl_per_token,
expand_advantage_to_per_token)
from swift.rl_core.data import GRPOBatch, GRPOSample
from swift.rl_core.grpo_algorithm import compute_std_for_dynamic_sampling, score_completions
from swift.rlhf_trainers.gkd_helpers import (TeacherServerConfig, assemble_teacher_completion_logprobs,
build_opsd_samples, build_teacher_requests, encode_teacher_view,
fetch_teacher_parsed_by_routing, parse_teacher_model_server,
remap_teacher_logps_to_student_frame,
resolve_dynamic_opd_self_distillation)
from swift.rlhf_trainers.utils import encode_sample, make_reward_weights, resolve_reward_funcs
from swift.rollout import MultiTurnScheduler, invoke_async_hook, multi_turns, run_multi_turn
from swift.utils import get_logger, remove_response
from .base_trainer import BaseRayTrainer
from .driver_utils import extract_iteration
logger = get_logger()
class GRPOTrainer(BaseRayTrainer):
"""Driver-side GRPO trainer."""
def _prepare_state(self) -> None:
super()._prepare_state()
args = self.args
self.num_generations = args.num_generations
self.advantage_estimator = args.advantage_estimator
self.scale_rewards = args.scale_rewards
self.kl_in_reward = args.kl_in_reward
self._teacher_model_dir = getattr(args, 'teacher_model_dir', None) or args.teacher_model
self._teacher_model_server = getattr(args, 'teacher_model_server', None)
self._teacher_use_disable_adapter = getattr(args, '_teacher_use_disable_adapter', False)
if self._teacher_use_disable_adapter:
self._teacher_model_dir = None
teacher_explicit = bool(self._teacher_model_dir or self._teacher_model_server
or self._teacher_use_disable_adapter)
self._has_teacher_explicit = teacher_explicit
self._is_dynamic_self_distillation = resolve_dynamic_opd_self_distillation(
has_teacher_explicit=teacher_explicit,
is_self_distillation=not teacher_explicit,
)
self._has_teacher = teacher_explicit or self._is_dynamic_self_distillation
self.teacher_kl_coef = getattr(args, 'teacher_kl_coef', 1.0)
# Parse teacher_model_server: supports single URL and multi-teacher JSON.
self.use_teacher_api = self._teacher_model_server is not None
self.teacher_configs: List[TeacherServerConfig] = []
self.teacher_clients = []
if self.use_teacher_api:
self.teacher_configs = parse_teacher_model_server(self._teacher_model_server)
from swift.rlhf_trainers.vllm_client import VLLMInferClient
self.teacher_clients = [VLLMInferClient(base_urls=[cfg.url]) for cfg in self.teacher_configs]
self._prepare_rewards()
self._prepare_multi_turn()
# Ray supports router replay only in R3 (rollout records routed_experts, the driver
# collates them into the train micro-batch). R2 records during the policy logps
# forward, which — with driver-side collation — would not flow back into the train
# batch; reject it explicitly (mirrors pipeline.py's R3-only rollout wiring).
router_mode = getattr(args, 'router_replay_mode', 'disabled')
if router_mode not in ('disabled', 'R3'):
raise ValueError(f"Ray Megatron GRPO supports router_replay_mode in {{'disabled', 'R3'}}, "
f'got {router_mode!r}. Use R3 (rollout-recorded routing) for the Ray pipeline.')
# DAPO dynamic_sample + truncation_strategy='delete' resampling (driver-side).
self.dynamic_sample = getattr(args, 'dynamic_sample', False)
self.max_resample_times = getattr(args, 'max_resample_times', 3)
self.truncation_strategy = args.truncation_strategy
self._max_resample_rounds = getattr(args, 'max_resample_times', 10)
self._needs_resample_iterator = self.dynamic_sample or self.truncation_strategy == 'delete'
def _prepare_multi_turn(self) -> None:
"""Configure driver-side multi-turn scheduler (Mode A only).
Mode B (server-side scheduler) is intentionally not enabled here because
:class:`VllmServer.launch_server` does not yet wrap the engine via
``get_rollout_engine_type`` — the server-side scheduler plumbing is a
separate cross-process change. When that lands, set
``self._enable_server_multi_turn`` from a new
``RolloutReplica.get_engine_type()`` passthrough.
"""
args = self.args
self._multi_turn_scheduler: Optional[MultiTurnScheduler] = None
self._max_turns: Optional[int] = getattr(args, 'max_turns', None)
self._enable_server_multi_turn = False
scheduler_cfg = getattr(args, 'multi_turn_scheduler', None)
if not scheduler_cfg:
return
if isinstance(scheduler_cfg, str):
if scheduler_cfg not in multi_turns:
raise ValueError(f'Unknown multi_turn_scheduler: {scheduler_cfg!r}; '
f'available: {list(multi_turns)}')
scheduler_kwargs = {'max_turns': self._max_turns}
gym_env = getattr(args, 'gym_env', None)
if gym_env is not None:
scheduler_kwargs['gym_env'] = gym_env
self._multi_turn_scheduler = multi_turns[scheduler_cfg](**scheduler_kwargs)
else:
assert isinstance(scheduler_cfg, MultiTurnScheduler)
self._multi_turn_scheduler = scheduler_cfg
def _prepare_rewards(self):
args = self.args
reward_funcs_cfg = (args.reward_funcs or []).copy()
if not isinstance(reward_funcs_cfg, list):
reward_funcs_cfg = [reward_funcs_cfg]
self.reward_funcs, self.reward_func_names = resolve_reward_funcs(reward_funcs_cfg, args=args)
# use_gym_env: gym total_reward is appended as an extra reward column so it can
# blend with reward_funcs via reward_weights. When reward_funcs is empty, it becomes
# the single reward source.
self.use_gym_env = bool(getattr(args, 'use_gym_env', False))
if self.use_gym_env:
self.reward_func_names.append('gym_reward')
self.reward_weights = make_reward_weights(args.reward_weights, len(self.reward_func_names), self.device)
self.reward_model_plugins = [None] * len(self.reward_funcs)
if not self.reward_funcs and not self.use_gym_env and not getattr(self, '_has_teacher', False):
raise ValueError('GRPOTrainer: no reward functions configured '
'(or pass use_gym_env: true / a teacher for OPD-RL)')
def _get_request_config(self):
"""Build a RequestConfig for rollout generation."""
from swift.infer_engine.protocol import RequestConfig
args = self.args
return RequestConfig(
n=1,
max_tokens=args.max_completion_length,
temperature=args.temperature,
top_p=args.top_p,
top_k=args.top_k,
repetition_penalty=args.repetition_penalty,
stop=args.stop_words or None,
return_details=True,
logprobs=True,
)
def _train_loop(self, tg, train_iters, iteration):
ckpt = self.ckpt_manager
merge_and_sync = not self.args.vllm_enable_lora
spg = self._steps_per_generation
# OPD-RL: load the colocated teacher once (disable_adapter self-distillation needs none).
if self._teacher_model_dir and not self._teacher_use_disable_adapter:
tg.execute('init_teacher_model', self._teacher_model_dir)
logger.info('OPD-RL colocated teacher model initialized from %s', self._teacher_model_dir)
while iteration < train_iters:
ckpt.sync_weights(merge_and_sync=merge_and_sync)
with self._generation_context(tg, ckpt):
prompt_batch = next(self._data_iter)
if self.truncation_strategy == 'delete':
prompt_batch = self._resample_failed_prompts(prompt_batch)
rollout_batch = self.expand_for_generation(prompt_batch)
completions = self._generate(rollout_batch)
rollout_with_outputs = self._postprocess_rollout(rollout_batch, completions)
rewards_per_func = self.score_completions(rollout_with_outputs)
# DAPO dynamic sampling: drop zero-variance (std==0) prompt groups and
# resample fresh prompts (the rollout engine is still awake in this context).
if self.dynamic_sample:
rollout_with_outputs, rewards_per_func = self._dynamic_sampling(rollout_with_outputs,
rewards_per_func)
self._maybe_log_completions(
rollout_with_outputs, rewards=rewards_per_func.sum(dim=1).tolist(), gen_step=iteration)
n_samples = len(rollout_with_outputs)
chunk_size = n_samples // spg
all_chunks = [] # per spg step: (dispatch, flat_grpo_batches)
for step_idx in range(spg):
chunk_start = step_idx * chunk_size
chunk_end = chunk_start + chunk_size
chunk_rollout = rollout_with_outputs[chunk_start:chunk_end]
chunk_samples = self.encode_rollout_batch(chunk_rollout)
dispatch, grpo_batches = self._collate_for_workers(tg, chunk_samples)
logps_rows = tg.compute_logps(dispatch)
self._scatter_logps(grpo_batches, logps_rows, 'old_per_token_logps')
if self.beta != 0.0:
ref_rows = tg.compute_ref_logps(dispatch)
self._scatter_logps(grpo_batches, ref_rows, 'ref_per_token_logps')
# OPD-RL: teacher logp on the sampled tokens (same frame as old/ref logps).
# TODO(perf): When use_teacher_api, start API requests after old_logps scatter
# and overlap with ref_logps computation (beta != 0) to hide API latency.
if self._has_teacher:
if self.use_teacher_api:
self._compute_teacher_api_logps(chunk_samples, grpo_batches)
else:
self._compute_teacher_logps(tg, chunk_samples, dispatch, grpo_batches)
all_chunks.append((dispatch, grpo_batches))
for step_idx in range(spg):
if iteration >= train_iters:
break
dispatch, grpo_batches = all_chunks[step_idx]
chunk_start = step_idx * chunk_size
chunk_end = chunk_start + chunk_size
chunk_rewards_pf = rewards_per_func[chunk_start:chunk_end]
kl_values = self._compute_kl_from_batches(grpo_batches) if self.beta != 0.0 else None
chunk_advantages, rewards = self.compute_advantages(chunk_rewards_pf, kl_values=kl_values)
self._scatter_advantages(grpo_batches, chunk_advantages)
results = tg.train_step(
dispatch, extra_metrics=self._build_grpo_log_metrics(rewards, chunk_advantages, chunk_rewards_pf))
iteration = extract_iteration(results)
return iteration
def _compute_teacher_logps(self, tg, chunk_samples: List[GRPOSample], dispatch,
grpo_batches: List[GRPOBatch]) -> None:
"""OPD-RL: fill each micro-batch's ``teacher_per_token_logps`` (student frame).
Non-OPSD: the teacher forwards the SAME student-collated dispatch, so the rows
already align to the student ``completion_mask`` frame and are scattered directly.
OPSD: the teacher forwards its own (teacher_prompt + same response) encoding via a
separate dispatch, then the teacher-frame logps are remapped onto the student frame.
"""
has_opsd_batch = build_opsd_samples(chunk_samples)
if not has_opsd_batch:
if not self._has_teacher_explicit:
return
teacher_rows = tg.compute_teacher_logps(dispatch)
self._scatter_logps(grpo_batches, teacher_rows, 'teacher_per_token_logps')
return
# OPSD: encode the teacher view (teacher_prompt + shared response) and dispatch separately.
for s in chunk_samples:
s.encoded = encode_teacher_view(s, self.template)
teacher_dispatch, teacher_grpo_batches = self._collate_for_workers(tg, chunk_samples)
# Restore the student encoding so the dispatched student micro-batches stay the student frame.
self.encode_rollout_batch(chunk_samples)
teacher_rows = tg.compute_teacher_logps(teacher_dispatch)
self._scatter_logps(teacher_grpo_batches, teacher_rows, 'teacher_per_token_logps')
for student_gb, teacher_gb in zip(grpo_batches, teacher_grpo_batches):
student_gb.teacher_per_token_logps = remap_teacher_logps_to_student_frame(
teacher_gb.teacher_per_token_logps.to(student_gb.completion_mask.device),
teacher_gb.completion_mask.to(student_gb.completion_mask.device), student_gb.completion_mask)
def _compute_teacher_api_logps(self, chunk_samples: List[GRPOSample], grpo_batches: List[GRPOBatch]) -> None:
"""Driver-side: fetch teacher logps from API servers, scatter into GRPOBatch.
Each sample routes to exactly one teacher by tag (single teacher = all samples). Runs on
the driver, so there is no distributed gather: the per-teacher requests go straight to
``client.infer``. Multiple teachers infer concurrently (distinct HTTP servers).
"""
from swift.rlhf_trainers.utils import parse_prompt_logprobs
build_opsd_samples(chunk_samples)
request_config = RequestConfig(prompt_logprobs=0, max_tokens=1, temperature=0.0)
def infer(reqs, client):
if not reqs: # no sample routed to this teacher: skip the empty HTTP call
return []
responses = client.infer(reqs, request_config=request_config, use_tqdm=False)
return [parse_prompt_logprobs(r, topk=0) for r in responses]
requests = build_teacher_requests(chunk_samples, self.template)
all_rti = [s.response_token_ids for s in chunk_samples]
parsed = fetch_teacher_parsed_by_routing(
chunk_samples,
requests,
self.teacher_configs,
self.teacher_clients,
gather_fn=lambda reqs: reqs, # driver-side: no distributed gather
infer_fn=infer,
scatter_fn=lambda reqs, parsed_global: parsed_global, # already local
is_main_process=True,
tag_key=self.args.teacher_tag_key)
offset = 0
for gb in grpo_batches:
device = gb.completion_mask.device
n = gb.completion_mask.shape[0]
teacher_out = assemble_teacher_completion_logprobs(
parsed[offset:offset + n], gb.completion_mask, device, response_token_ids=all_rti[offset:offset + n])
gb.teacher_per_token_logps = teacher_out.topk_logprobs[..., 0]
offset += n
@staticmethod
def _scatter_logps(grpo_batches: List[GRPOBatch], rows: List[Dict[str, torch.Tensor]], key: str) -> None:
"""Stack the flat per-sample logps rows (dp_flat, sample order) back onto each
micro-batch's GRPOBatch as ``[B, T]`` — the same carrier non-Ray Megatron uses.
``completion_mask`` is NOT touched here: it was built by the driver collate and the
worker only forwards logps, so the existing ``gb.completion_mask`` is already correct.
"""
# The worker keys ``old_per_token_logps`` rows as ``per_token_logps``; ref / teacher
# rows carry their destination key verbatim.
src_key = 'per_token_logps' if key == 'old_per_token_logps' else key
pos = 0
for gb in grpo_batches:
b = gb.completion_mask.shape[0]
chunk = rows[pos:pos + b]
pos += b
setattr(gb, key, torch.stack([r[src_key] for r in chunk], dim=0))
assert pos == len(rows), f'_scatter_logps: consumed {pos} rows but got {len(rows)}'
@staticmethod
def _align_width(x: torch.Tensor, width: int) -> torch.Tensor:
"""Truncate or right-pad the last (token) dim of ``x`` to ``width``.
A small width drift is expected (padding alignment across micro-batches), but a large
gap means the teacher logps are mis-shaped (e.g. a different tokenizer) and silently
slicing them would corrupt the per-token KL -- guard against that.
"""
cur = x.shape[-1]
if cur == width:
return x
assert abs(cur - width) <= 8, (f'teacher logp width {cur} differs from mask width {width} by more than the '
'padding slack; teacher/student token alignment is likely broken.')
if cur > width:
return x[..., :width]
return torch.nn.functional.pad(x, (0, width - cur))
def _scatter_advantages(self, grpo_batches: List[GRPOBatch], advantages: torch.Tensor) -> None:
"""Write the advantage onto each micro-batch's GRPOBatch, expanding the per-sequence base
advantage to per-token ``[B, T]`` so the OPD-RL signed teacher log-ratio is added per token
(``adv_t = base + coef * (teacher_logp - student_logp)``). ``advantages`` is ``[N]`` in sample order."""
pos = 0
kl_sum, tok_sum = 0.0, 0.0
for gb in grpo_batches:
b = gb.completion_mask.shape[0]
base = advantages[pos:pos + b].to(gb.completion_mask.device)
pos += b
teacher_lp = policy_lp = None
if self._has_teacher and gb.teacher_per_token_logps is not None and gb.old_per_token_logps is not None:
# teacher / old logps share the completion_mask frame (worker forwards them on the
# driver-collated batch); align widths defensively to the mask before computing k3.
T = gb.completion_mask.shape[-1]
teacher_lp = self._align_width(gb.teacher_per_token_logps, T).to(gb.completion_mask.device)
policy_lp = self._align_width(gb.old_per_token_logps, T).to(gb.completion_mask.device)
k3 = compute_teacher_kl_per_token(teacher_lp, policy_lp, gb.completion_mask.to(teacher_lp.dtype))
kl_sum += k3.sum().item()
tok_sum += gb.completion_mask.sum().item()
gb.advantages = expand_advantage_to_per_token(
base,
gb.completion_mask,
teacher_per_token_logps=teacher_lp,
policy_per_token_logps=policy_lp,
teacher_kl_coef=self.teacher_kl_coef if teacher_lp is not None else 0.0,
)
assert pos == advantages.shape[0], f'_scatter_advantages: wrote {pos} but got {advantages.shape[0]}'
# Per-token teacher KL averaged over response tokens (monitoring only; the signal is applied
# per-token above). Surfaced via _build_grpo_log_metrics -> worker on_log.
self._last_teacher_kl = (kl_sum / tok_sum) if tok_sum > 0 else None
def _build_grpo_log_metrics(self, rewards, advantages, rewards_per_func) -> Dict[str, float]:
"""Driver-computed GRPO metrics (reward / reward_std / adv_nonzero / per-func),
injected into the worker megatron on_log so all logging is unified there."""
reward_metrics = compute_reward_metrics(
rewards=rewards,
rewards_per_func=rewards_per_func,
reward_func_names=self.reward_func_names,
num_generations=self.num_generations,
scale_rewards=self.scale_rewards,
)
metrics = {
'reward': reward_metrics.reward_mean,
'reward_std': reward_metrics.reward_std,
'frac_reward_zero_std': reward_metrics.frac_reward_zero_std,
'adv_nonzero': (advantages.abs() > 1e-8).float().mean().item(),
}
if getattr(self, '_last_teacher_kl', None) is not None:
metrics['teacher_kl'] = self._last_teacher_kl
# Flatten per-function metrics into scalar values the worker can inject.
for name in self.reward_func_names:
metrics[name] = reward_metrics.per_func_mean[name]
metrics[f'rewards/{name}/std'] = reward_metrics.per_func_std[name]
return metrics
def _generate(self, samples: List[GRPOSample]) -> List[RolloutOutput]:
"""Run a prompt batch through rollout replicas.
Returns ``List[RolloutOutput]`` (one per request). For Mode A
(driver-side multi-turn) the per-turn ``response_token_ids`` and
``response_loss_mask`` are accumulated inside each ``RolloutOutput``.
"""
request_config = self._get_request_config()
# Convert samples to RolloutInferRequest at the engine boundary.
requests = [s.to_infer_request() for s in samples]
if self._multi_turn_scheduler is not None and not self._enable_server_multi_turn:
# Mode A: driver-side trainer loop. run_multi_turn mutates `messages`
# in place on RolloutInferRequest objects.
invoke_async_hook(self._multi_turn_scheduler.on_trajectory_start(requests))
first_turn = [
RolloutOutput(response=resp) for resp in self._distribute_to_replicas(requests, request_config)
]
return run_multi_turn(
requests=requests,
first_turn_outputs=first_turn,
scheduler=self._multi_turn_scheduler,
rollout_fn=lambda reqs, cfg:
[RolloutOutput(response=resp) for resp in self._distribute_to_replicas(reqs, cfg)],
request_config=request_config,
max_turns=self._max_turns,
)
# Mode B (server-side multi-turn, currently disabled) + single-turn share this path.
completions = self._distribute_to_replicas(requests, request_config)
assert len(completions) == len(requests)
return [RolloutOutput(response=resp) for resp in completions]
def _postprocess_rollout(self, samples: List[GRPOSample], outputs: List[RolloutOutput]) -> List[GRPOSample]:
if not outputs:
return list(samples)
if len(outputs) != len(samples):
raise RuntimeError(f'GRPOTrainer: rollout produced {len(outputs)} completions '
f'for {len(samples)} samples; shapes mismatch.')
results = []
for sample, output in zip(samples, outputs):
if output is None:
results.append(sample)
continue
sample = copy.deepcopy(sample)
sample.apply_rollout_output(rollout_output=output)
results.append(sample)
return results
def expand_for_generation(
self,
prompt_batch: List[Dict[str, Any]],
) -> List[GRPOSample]:
num_gen = self.num_generations
samples: List[GRPOSample] = []
for item in prompt_batch:
base = GRPOSample.from_row(item)
if base.messages:
remove_response(base.messages)
base.request_id = uuid.uuid4().hex
samples.append(base)
for _ in range(num_gen - 1):
dup = copy.deepcopy(base)
dup.request_id = uuid.uuid4().hex
samples.append(dup)
return samples
def score_completions(
self,
samples: List[GRPOSample],
) -> torch.Tensor:
"""Score completions using the backend-agnostic shared helper.
The driver-side Ray trainer already sees the global prompt/completion
batch, so no distributed gather is performed here.
"""
return score_completions(
samples,
reward_funcs=self.reward_funcs,
reward_model_plugins=self.reward_model_plugins,
use_gym_env=self.use_gym_env,
device=self.device,
)
def compute_advantages(
self,
rewards_per_func: torch.Tensor,
kl_values: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Return the per-sequence base advantage and rewards, both shaped [N] (N = B * num_gen).
The driver already holds every completion of each group, so no gather is needed before
calling the pure advantage function. The OPD-RL teacher signal is applied per-token later in
``_scatter_advantages`` (see ``expand_advantage_to_per_token``).
"""
return compute_advantages(
rewards_per_func=rewards_per_func,
reward_weights=self.reward_weights,
num_generations=self.num_generations,
advantage_estimator=self.advantage_estimator,
scale_rewards=self.scale_rewards,
kl_in_reward=self.kl_in_reward,
beta=self.beta,
kl_values=kl_values,
)
def _dynamic_sampling(
self,
samples: List[GRPOSample],
rewards_per_func: torch.Tensor,
) -> Tuple[List[GRPOSample], torch.Tensor]:
num_gen = self.num_generations
target = len(samples)
valid_samples: List[GRPOSample] = []
valid_rewards: List[torch.Tensor] = []
cur_samples, cur_rewards = samples, rewards_per_func
for resample_count in range(self.max_resample_times + 1):
grouped_std = compute_std_for_dynamic_sampling(
cur_rewards,
self.reward_weights,
num_gen,
)
keep_mask = grouped_std > 0
for i in range(len(cur_samples)):
if keep_mask[i]:
valid_samples.append(cur_samples[i])
valid_rewards.append(cur_rewards[i])
logger.info('dynamic_sample round %d: kept %d/%d (std>0), accumulated %d/%d', resample_count,
int(keep_mask.sum().item()), len(cur_samples), len(valid_samples), target)
if len(valid_samples) >= target or resample_count >= self.max_resample_times:
break
prompt_batch = next(self._resample_iter)
if self.truncation_strategy == 'delete':
prompt_batch = self._resample_failed_prompts(prompt_batch)
cur_samples = self.expand_for_generation(prompt_batch)
comp = self._generate(cur_samples)
cur_samples = self._postprocess_rollout(cur_samples, comp)
cur_rewards = self.score_completions(cur_samples)
if len(valid_samples) >= target:
return valid_samples[:target], torch.stack(valid_rewards[:target])
logger.warning('dynamic_sample: only %d/%d std>0 samples after %d retries; using original batch.',
len(valid_samples), target, self.max_resample_times)
return samples, rewards_per_func
def _batch_encode_parallel(self, infer_requests: List[Dict[str, Any]], strict: bool):
max_workers = max(min(32, os.cpu_count() or 1, len(infer_requests)), 1)
encoded: List[Dict[str, Any]] = []
errors: List[Tuple[int, Exception]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex:
futures = [ex.submit(self.template.encode, req, return_length=True) for req in infer_requests]
concurrent.futures.wait(futures)
for i, fut in enumerate(futures):
try:
encoded.append(fut.result())
except Exception as e: # pragma: no cover
if strict:
raise
errors.append((i, e))
return encoded, errors
def encode_rollout_batch(
self,
samples: List[GRPOSample],
) -> List[GRPOSample]:
"""Encode each sample in place and return the samples.
This is the driver → worker boundary: the same ``GRPOSample`` objects
cross the RPC (``tg.compute_logps`` / ``tg.train_step``) — the worker
feeds them straight to ``collate_to_grpo_micro_batch`` (the shared collate
used by HF / Megatron). Uses the shared ``encode_sample`` helper so bug
fixes to loss_mask / non_thinking_prefix propagate across all backends.
"""
for sample in samples:
encoded = encode_sample(sample, self.template)
encoded.pop('_extra_kwargs', None)
sample.encoded = encoded
return samples
def _compute_kl_from_batches(self, grpo_batches: List[GRPOBatch]) -> Optional[torch.Tensor]:
"""Per-sample KL = sum_t (old_lp - ref_lp) * completion_mask, in sample order.
Reads the [B, T] logps/mask off each micro-batch GRPOBatch (the unified logps
carrier), so the driver-side DAPO ``kl_in_reward`` penalty matches non-Ray.
"""
if not (self.kl_in_reward and self.beta != 0.0):
return None
kl_values = []
for gb in grpo_batches:
old_lp, ref_lp, mask = gb.old_per_token_logps, gb.ref_per_token_logps, gb.completion_mask
if old_lp is None or ref_lp is None or mask is None:
return None
old_lp = old_lp.to(self.device)
ref_lp = ref_lp.to(self.device)
mask = mask.to(self.device)
width = min(old_lp.shape[-1], ref_lp.shape[-1], mask.shape[-1])
per_token_kl = (old_lp[..., :width] - ref_lp[..., :width]) * mask[..., :width].to(old_lp.dtype)
kl_values.append(per_token_kl.sum(dim=-1)) # [B] per-sample
if not kl_values:
return None
return torch.cat(kl_values, dim=0)
+2
View File
@@ -0,0 +1,2 @@
from .base import Loss
from .grpo import GRPOLoss
+55
View File
@@ -0,0 +1,55 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Abstract base for worker-side loss computation.
A ``Loss`` subclass defines ``forward_step`` and ``loss_func`` -- the two
methods that Megatron's pipeline-parallel scheduler calls during
training. Users who want to customise loss computation only need to
subclass ``Loss`` and override these two methods; no understanding of
the internal Megatron trainer is required.
Example::
class MyLoss(Loss):
def __init__(self, args):
self.label_smoothing = args.label_smoothing
def forward_step(self, data_iterator, model):
batch = next(data_iterator)
output = model(batch['input_ids'], ...)
return output, partial(self.loss_func, labels=batch['labels'])
def loss_func(self, output_tensor, *, labels):
loss = F.cross_entropy(output_tensor, labels)
return loss, {'loss': loss.item()}
Then register it::
register_ray_trainer('my_algo', trainer='...MyDriver', loss='...MyLoss')
"""
from abc import ABC, abstractmethod
class Loss(ABC):
"""Abstract base for worker-side loss / forward computation.
Mirrors the two methods that Megatron's PP scheduler calls:
``forward_step(data_iterator, model) -> (output, loss_fn)``
and ``loss_func(output_tensor, **ctx) -> (loss, metrics)``.
Subclasses may wrap an existing trainer via composition for code
reuse (see ``GRPOLoss``) or implement these from scratch.
"""
@abstractmethod
def forward_step(self, data_iterator, model):
"""Run a single forward micro-batch through *model*.
Returns ``(output_tensor, partial(self.loss_func, ...))``.
"""
@abstractmethod
def loss_func(self, output_tensor, **kwargs):
"""Compute scalar loss + metrics from ``output_tensor``.
Returns ``(loss, metric_dict)``.
"""
+141
View File
@@ -0,0 +1,141 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""GKD loss for Ray-based Megatron training."""
from __future__ import annotations
import torch
from functools import partial
from megatron.core import mpu
from typing import Any, Dict, List, Optional
from swift.megatron.trainers.gkd_utils import cp_reduce, tp_gather_topk, vocab_parallel_topk
from swift.megatron.trainers.utils import prepare_batch
from swift.megatron.trainers.vocab_parallel_utils import vocab_parallel_kl_div, vocab_parallel_log_softmax
from swift.megatron.utils import forward_step_helper
from swift.rlhf_trainers.gkd_loss import DataSource, TeacherOutput, gkd_loss
from swift.utils import get_current_device, to_device
from .base import Loss
class GKDLoss(Loss):
"""GKD loss: JSD between student and teacher + optional SFT loss."""
def __init__(self, args):
self.args = args
self.beta = getattr(args, 'beta', 0.5)
self.temperature = getattr(args, 'temperature', 1.0)
self.sft_alpha = getattr(args, 'sft_alpha', 0.0)
def forward_step(self, data_iterator, model):
data = next(data_iterator)
teacher_output = data.pop('teacher_output', TeacherOutput())
data_source = data.pop('data_source', None)
data.pop('grpo_batch', None) # RL signals packed in GRPOBatch (not used by GKD loss)
data = prepare_batch(self.args, data)
data.pop('loss_scale', None)
data.pop('routed_experts', None) # MoE routing replay data (not a model forward kwarg)
labels = data.pop('labels', None)
# data is now clean model forward kwargs (template.encode guarantees this)
student_output = model(**data)
return student_output, partial(
self.loss_func,
labels=labels,
teacher_output=teacher_output,
data_source=data_source,
model=model,
)
# ------------------------------------------------------------------
# Teacher logit computation (called from MegatronWorker thin wrapper)
# ------------------------------------------------------------------
def compute_teacher_logits(
self,
teacher_model: torch.nn.Module,
teacher_micro_batches: List[Dict[str, Any]],
args,
) -> List[TeacherOutput]:
gkd_logits_topk = getattr(args, 'gkd_logits_topk', None)
device = get_current_device()
outputs: List[TeacherOutput] = []
with torch.no_grad():
for teacher_model_inputs in teacher_micro_batches:
collated = to_device(dict(teacher_model_inputs), device)
teacher_data = prepare_batch(args, collated)
teacher_data.pop('loss_scale', None)
# Labels are the teacher's (OPSD) or == student's (non-OPSD); prepare_batch
# shifts them so extract_active mask-aligns the shared response.
labels = teacher_data.pop('labels', None)
teacher_logits = forward_step_helper(teacher_model, teacher_data)
if teacher_logits is None:
# PP non-last stage: no logits; placeholder keeps micro-batch alignment.
outputs.append(TeacherOutput())
continue
teacher_logits = teacher_logits.detach()
if gkd_logits_topk is not None:
topk_logits, topk_indices = vocab_parallel_topk(teacher_logits, k=gkd_logits_topk)
outputs.append(TeacherOutput(topk_logprobs=topk_logits, topk_indices=topk_indices, labels=labels))
else:
outputs.append(TeacherOutput(full_logits=teacher_logits, labels=labels))
del collated
return outputs
# ------------------------------------------------------------------
# Loss computation
# ------------------------------------------------------------------
def loss_func(self, output_tensor, *, labels, teacher_output, data_source=None, model=None):
args = self.args
student_logits = output_tensor
jsd_total, jsd_num_valid = gkd_loss(
student_logits,
teacher_output,
labels,
self.beta,
self.temperature,
gather_fn=tp_gather_topk,
log_softmax_fn=vocab_parallel_log_softmax,
kl_div_fn=vocab_parallel_kl_div)
jsd_loss_val = cp_reduce(jsd_total, jsd_num_valid, cp_size=args.context_parallel_size)
loss = jsd_loss_val
sft_loss = None
# SFT loss only applies to ground-truth (dataset) responses; skip it on
# student-generated (on-policy) responses, matching the non-ray GKD trainer.
if self.sft_alpha > 0 and data_source != DataSource.STUDENT:
# Vocab-parallel-aware SFT loss: route through ``model.compute_language_model_loss``
# (mirrors the non-ray GKD trainer). Naive ``torch.nn.functional.cross_entropy``
# would index out of bounds on the TP-sharded local vocab when TP>1.
assert model is not None, 'sft_alpha>0 requires the model handle from forward_step'
unwrapped = model
while hasattr(unwrapped, 'module'):
unwrapped = unwrapped.module
if hasattr(unwrapped, 'language_model'):
unwrapped = unwrapped.language_model
logits_sbv = student_logits.transpose(0, 1).contiguous()
per_token_loss = unwrapped.compute_language_model_loss(labels, logits_sbv)
loss_mask = labels != -100
sft_loss_sum = (per_token_loss * loss_mask).sum()
sft_loss_count = loss_mask.sum().float()
if args.context_parallel_size > 1:
sft_stats = torch.stack([sft_loss_sum, sft_loss_count])
torch.distributed.all_reduce(
sft_stats, op=torch.distributed.ReduceOp.SUM, group=mpu.get_context_parallel_group())
sft_loss_sum, sft_loss_count = sft_stats[0], sft_stats[1]
sft_loss = sft_loss_sum / sft_loss_count if sft_loss_count > 0 else sft_loss_sum * 0
loss = loss + self.sft_alpha * sft_loss
metric = {'loss': loss.detach().clone()}
if sft_loss is not None:
metric['jsd_loss'] = jsd_loss_val.detach().clone()
metric['sft_loss'] = sft_loss.detach().clone()
dp_group = mpu.get_data_parallel_group()
reporting = torch.stack(list(metric.values()), dim=0)
torch.distributed.all_reduce(reporting, torch.distributed.ReduceOp.AVG, group=dp_group)
metric = {k: reporting[i] for i, k in enumerate(metric.keys())}
loss = loss / mpu.get_context_parallel_world_size()
return loss, metric
+75
View File
@@ -0,0 +1,75 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""GRPO loss for Ray-based Megatron training.
``GRPOLoss`` reuses ``MegatronGRPOTrainer.forward_step`` and
``loss_func`` via a dummy trainer instance (created with ``__new__``
to skip heavy ``__init__`` side-effects like vLLM / reward setup).
This is an internal implementation detail for code reuse --
users writing custom losses do NOT need to understand or replicate
this pattern; they simply subclass ``Loss`` and implement
``forward_step`` / ``loss_func``.
"""
from __future__ import annotations
from typing import Any, Dict
from .base import Loss
class GRPOLoss(Loss):
"""GRPO loss registered in the pipeline registry.
Builds a minimal ``MegatronGRPOTrainer`` stub that only holds
algorithm parameters (beta, epsilon, …) and reuses its
``forward_step`` / ``loss_func`` without duplicating code.
To define a custom loss, subclass ``Loss``, override
``forward_step`` / ``loss_func``, and pass the dotted path to
``register_ray_trainer(..., loss='your.module.YourLoss')``.
"""
def __init__(self, args):
self._dummy = self._create_dummy_trainer(args)
@staticmethod
def _create_dummy_trainer(args):
"""Create a minimal MegatronGRPOTrainer for loss computation only.
Skips the heavy __init__ side-effects (vLLM, reward, model init)
by using __new__ and manually initialising only the fields that
``forward_step`` / ``loss_func`` actually read.
"""
import torch
from swift.megatron.trainers.grpo_trainer import MegatronGRPOTrainer
from swift.utils import is_last_rank
cls = MegatronGRPOTrainer
dummy = cls.__new__(cls)
dummy.args = args
dummy._setup_teacher()
dummy._init_grpo_params()
dummy._prepare_metrics()
dummy.log_rollout_offpolicy_metrics = args.log_rollout_offpolicy_metrics
dummy.disable_rollout_importance_sampling = False
dummy.enable_routing_replay = args.router_replay_mode != 'disabled'
dummy.micro_batch_size = args.micro_batch_size
dummy.temperature = args.temperature
dummy.is_main_process = is_last_rank()
dummy.process_index = torch.distributed.get_rank() if torch.distributed.is_initialized() else 0
dummy.world_size = torch.distributed.get_world_size() if torch.distributed.is_initialized() else 1
dummy._step = 0
dummy.max_completion_length = args.max_completion_length
class _AlwaysTraining:
training = True
dummy.unwrapped_models = [_AlwaysTraining()]
return dummy
def forward_step(self, data_iterator, model):
from swift.megatron.trainers.grpo_trainer import MegatronGRPOTrainer
cls = MegatronGRPOTrainer
return cls.forward_step(self._dummy, data_iterator, model)
def loss_func(self, output_tensor, *, data: Dict[str, Any]):
return self._dummy.loss_func(output_tensor, data=data)
+640
View File
@@ -0,0 +1,640 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from __future__ import annotations
import os
import torch
from contextlib import nullcontext
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from swift.rl_core.data import GRPOBatch
from swift.utils import gc_collect, get_current_device, get_logger
from .checkpoint_engine import CheckpointEngineMixin
from .worker_group import dispatch_collect
if TYPE_CHECKING:
from swift.rlhf_trainers.gkd_loss import TeacherOutput
logger = get_logger()
# --- worker-side data-flow types ------------------------------------------
# Since collation moved to the driver, the worker consumes already-collated
# micro-batches and returns per-sample logps:
#
# ModelInputs: a collated micro-batch (driver-side ``collate_to_grpo_micro_batch``)
# fed to ``model(**model_inputs)``. Carries the model-forward tensors plus
# ``grpo_batch`` (GRPOBatch) and optional ``teacher_output`` / ``teacher_model_inputs``
# / ``data_source``.
# LogpsRow: one per-sample logps result returned worker→driver (collected via
# ``collect='dp_flat'``), e.g. ``{'per_token_logps': ..., 'completion_mask': ...}``.
ModelInputs = Dict[str, Any]
LogpsRow = Dict[str, torch.Tensor]
def _import_class(dotted_path: str):
"""Import a class from a dotted module path like ``'a.b.ClassName'``."""
import importlib
mod_path, cls_name = dotted_path.rsplit('.', 1)
return getattr(importlib.import_module(mod_path), cls_name)
def _make_lifecycle_trainer(args, template):
from swift.megatron.trainers.rlhf_mixin import MegatronRLHFTrainer
class _LifecycleTrainer(MegatronRLHFTrainer):
def forward_step(self, data_iterator, model):
return None
return _LifecycleTrainer(args, template)
class MegatronWorker(CheckpointEngineMixin):
def __init__(self):
self._megatron = None
self._loss_fn = None
self._args = None
self._pipeline = None
self.rollout = None
self._checkpoint_engine = None
self._bucket_size: int = 3072 << 20
self.actor = None # TrainableModelWorker
self.ref = None # MegatronModelWorker (explicit ref for full fine-tune)
self.teacher = None # MegatronModelWorker (colocated teacher)
def init_actor(
self,
cfg: Dict[str, Any],
loss_cls_path: Optional[str] = None,
rollout_config: Optional[Dict[str, Any]] = None,
) -> None:
"""Initialise the training (actor) model, optimizer, and optionally the rollout adapter.
This only sets up the actor model for training. Ref and teacher models
are initialized separately (ref in _init_trainable, teacher via
init_teacher_model).
Args:
cfg: Merged config dict (shared + group overrides).
rollout_config: When provided, creates an internal RolloutAdapter.
"""
from swift.megatron.arguments import MegatronRLHFArguments
from swift.megatron.pipelines.train.rlhf import MegatronRLHF
from .driver_utils import parse_args_from_dict
args = parse_args_from_dict(MegatronRLHFArguments, cfg)
self._pipeline = MegatronRLHF(args)
self._loss_cls_path = loss_cls_path
self._args = self._pipeline.args
self._init_trainable()
if rollout_config:
self._init_rollout_adapter(rollout_config)
def _init_trainable(self):
from .model_worker import TrainableModelWorker
pipeline = self._pipeline
args = pipeline.args
self._megatron = _make_lifecycle_trainer(args, pipeline.template)
if self._loss_cls_path:
loss_cls = _import_class(self._loss_cls_path)
self._loss_fn = loss_cls(args)
self._megatron.forward_step = self._loss_fn.forward_step
self.actor = TrainableModelWorker(args, self._megatron)
if args.tuner_type == 'full' and self._megatron.ref_models:
from .model_worker import MegatronModelWorker
self.ref = MegatronModelWorker(args, self._megatron.ref_models)
@dispatch_collect(dispatch='broadcast', collect='first')
def init_teacher_model(self, model_dir: str):
"""Load a colocated teacher model (same parallelism as student)."""
from .model_worker import MegatronModelWorker
# Prefer the worker's own resolved teacher_model_dir; bridge.load_weights needs a
# real local path to locate safetensors (a raw model id yields an empty state dict).
model_dir = getattr(self._args, 'teacher_model_dir', None) or model_dir
self.teacher = MegatronModelWorker.from_pretrained(self._args, model_dir)
logger.info('Colocated teacher model loaded from %s', model_dir)
if getattr(self._args, 'offload_teacher_model', False):
self.teacher.offload_to_cpu()
@dispatch_collect(dispatch='dp', collect='first')
def compute_teacher_logits(self, micro_batches: List[ModelInputs]) -> None:
"""Forward the teacher on each driver-collated micro-batch's ``teacher_model_inputs``
and cache one batched ``TeacherOutput`` per micro-batch (worker-local).
Cached (never returned to the driver): for context parallel each rank forwards its
own sequence shard, so the cache is already the correct local slice. ``train_step``
attaches it to the matching micro-batch (same dispatch dict ⇒ same order).
"""
teacher_inputs = [mi['teacher_model_inputs'] for mi in micro_batches]
if getattr(self._args, '_teacher_use_disable_adapter', False):
# Self-distillation (LoRA): teacher = student base model with the LoRA adapter
# disabled — no separate teacher loaded.
from contextlib import ExitStack
megatron = self._megatron
with ExitStack() as stack:
for m in megatron.peft_models:
stack.enter_context(m.disable_adapter())
self._cached_teacher_logits = self._loss_fn.compute_teacher_logits(megatron.unwrapped_models[0],
teacher_inputs, self._args)
else:
assert self.teacher is not None, 'Teacher model not initialized. Call init_teacher_model first.'
with self.teacher.loaded_context():
self._cached_teacher_logits = self._loss_fn.compute_teacher_logits(self.teacher.models[0],
teacher_inputs, self._args)
gc_collect()
def get_parallel_info(self) -> Dict[str, Any]:
from megatron.core import mpu
info = {
'dp_rank':
mpu.get_data_parallel_rank(),
'dp_size':
mpu.get_data_parallel_world_size(),
'is_collector':
(mpu.get_tensor_model_parallel_rank() == 0
and mpu.get_pipeline_model_parallel_rank() == mpu.get_pipeline_model_parallel_world_size() - 1
and mpu.get_context_parallel_rank() == 0),
}
return info
def get_padding_to(self) -> Optional[int]:
"""Delegates to ``swift.megatron.utils.get_padding_to`` (handles SP, CP, fp8)."""
from swift.megatron.utils import get_padding_to
return get_padding_to(self._args)
@dispatch_collect(dispatch='broadcast', collect='first')
def setup(self, args_override: Dict[str, Any]) -> Dict[str, Any]:
"""Apply pre-computed args from driver, then set up model training.
The driver is responsible for computing train_iters, eval_iters,
save_steps, etc. The worker just applies the overrides.
"""
megatron = self._megatron
for k, v in args_override.items():
if v is not None:
setattr(megatron.args, k, v)
megatron.setup_model_training()
return {
'train_iters': megatron.args.train_iters,
'iteration': megatron.state.iteration,
}
@dispatch_collect(dispatch='dp', collect='all')
def train_step(self,
micro_batches: List[ModelInputs],
extra_metrics: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
from swift.utils import to_device
megatron = self._megatron
args = megatron.args
assert isinstance(micro_batches, list), \
f'train_step expects List[ModelInputs], got {type(micro_batches).__name__}'
self._inject_extra_metrics(extra_metrics)
# GKD colocated teacher: attach the per-micro-batch TeacherOutput cached by
# compute_teacher_logits (worker-local, already CP-correct), aligned by order.
cached_teacher = getattr(self, '_cached_teacher_logits', None)
if cached_teacher is not None:
for mi, t_out in zip(micro_batches, cached_teacher):
if t_out is not None:
mi['teacher_output'] = t_out
self._cached_teacher_logits = None
# Driver-side collate produces CPU tensors; move each micro-batch to the GPU.
device = get_current_device()
moved: List[ModelInputs] = []
for mi in micro_batches:
mi.pop('teacher_model_inputs', None) # consumed by compute_teacher_logits
grpo_batch = mi.pop('grpo_batch', None)
teacher_output = mi.pop('teacher_output', None) # GPU (cache) or CPU (replicas)
mi = to_device(mi, device)
if grpo_batch is not None:
mi['grpo_batch'] = grpo_batch.to_device(device)
if teacher_output is not None:
mi['teacher_output'] = teacher_output.to_device(device)
moved.append(mi)
micro_batches = moved
data_iterator = iter(micro_batches)
assert len(micro_batches) == args.num_microbatches, (
f'Worker got {len(micro_batches)} micro-batches but args.num_microbatches='
f'{args.num_microbatches}; check per_device_generation_batch_size / micro_batch_size config.')
router_replay_mode = getattr(args, 'router_replay_mode', 'disabled')
need_routing_replay = router_replay_mode != 'disabled'
RouterReplay = None
if need_routing_replay:
try:
from megatron.core.transformer.moe.router_replay import RouterReplay, RouterReplayAction
RouterReplay.set_global_router_replay_action(RouterReplayAction.REPLAY_FORWARD)
except ImportError:
need_routing_replay = False
try:
megatron.run_train_step(data_iterator, None)
finally:
if need_routing_replay and RouterReplay is not None:
RouterReplay.clear_global_indices()
RouterReplay.clear_global_router_replay_action()
del data_iterator
gc_collect()
return self._extract_step_metrics(megatron)
def _inject_extra_metrics(self, extra_metrics) -> None:
"""Inject driver-computed metrics (reward, MathAccuracy, data_source, ...) into
the megatron trainer's ``_train_metrics`` so they flow through the standard
``on_log`` path (console PrintCallback + tensorboard + swanlab), unifying ALL
logging in the worker's megatron callbacks (the driver no longer prints metrics).
Values are stored as ``[sum, count]`` pairs to match ``_aggregated_metrics`` /
``_log_callback`` (which divides sum/count), so a per-step scalar logs as itself.
"""
if not extra_metrics:
return
megatron = self._megatron
tm = getattr(megatron, '_train_metrics', None)
if tm is None:
tm = megatron._train_metrics = {}
device = get_current_device()
for k, v in extra_metrics.items():
if v is None:
continue
add = torch.tensor([float(v), 1.0], dtype=torch.float32, device=device)
tm[k] = tm[k] + add if k in tm else add
@staticmethod
def _extract_step_metrics(megatron) -> Dict[str, Any]:
"""Extract training metrics from the last logged step.
After ``run_train_step``, Megatron's ``on_log`` stores metrics
in ``_last_logged_metrics``. We extract numeric values and
normalize key names (e.g. ``learning_rate`` → ``lr``) so the
driver receives a clean dict for aggregation and logging.
"""
result: Dict[str, Any] = {'iteration': megatron.state.iteration}
logged = getattr(megatron, '_last_logged_metrics', None) or {}
for k, v in logged.items():
if isinstance(v, (int, float)):
result[k] = v
else:
try:
result[k] = float(v)
except (TypeError, ValueError):
continue
if 'learning_rate' in result:
result['lr'] = result.pop('learning_rate')
return result
@dispatch_collect(dispatch='dp', collect='dp_flat')
def compute_logps(self, micro_batches: List[ModelInputs]) -> List[LogpsRow]:
"""Compute per-token logps under the current policy model.
Receives this dp_rank's collated micro-batches (driver-side collate, CPU)
and runs the rank-local forward; returns one row per sample.
"""
model = self._megatron.unwrapped_models[0]
return self._compute_logps_micro_batches(micro_batches, model, 'per_token_logps')
@dispatch_collect(dispatch='dp', collect='dp_flat')
def compute_ref_logps(self, micro_batches: List[ModelInputs]) -> List[LogpsRow]:
"""Compute per-token logps under the frozen reference model."""
if self.ref is not None:
return self._compute_logps_micro_batches(micro_batches, self.ref.models[0], 'ref_per_token_logps')
with self.actor.null_ref_context() as ref_models:
return self._compute_logps_micro_batches(micro_batches, ref_models[0], 'ref_per_token_logps')
@dispatch_collect(dispatch='dp', collect='dp_flat')
def compute_teacher_logps(self, micro_batches: List[ModelInputs]) -> List[LogpsRow]:
"""OPD-RL: per-token teacher logp on the sampled tokens (token-in-token-out).
Same forward/frame as ``compute_logps`` so the teacher logp aligns with the policy's;
same-model LoRA self-distillation disables the student's adapter, otherwise the colocated
teacher (loaded via ``init_teacher_model``) is used.
"""
if getattr(self._args, '_teacher_use_disable_adapter', False):
from contextlib import ExitStack
with ExitStack() as stack:
for m in self._megatron.peft_models:
stack.enter_context(m.disable_adapter())
return self._compute_logps_micro_batches(micro_batches, self._megatron.unwrapped_models[0],
'teacher_per_token_logps')
# Dynamic self-distillation (teacher is None): teacher = student (same weights
# including LoRA). No offload/load needed.
model = self.teacher.models[0] if self.teacher else self._megatron.unwrapped_models[0]
with (self.teacher.loaded_context() if self.teacher else nullcontext()):
return self._compute_logps_micro_batches(micro_batches, model, 'teacher_per_token_logps')
def _compute_logps_micro_batches(
self,
micro_batches: List[ModelInputs],
model,
output_key: str,
) -> List[LogpsRow]:
from swift.utils import to_device
device = get_current_device()
rows: List[LogpsRow] = []
for model_inputs in micro_batches:
grpo_batch = model_inputs.pop('grpo_batch').to_device(device)
model_inputs = to_device(model_inputs, device)
model_inputs['grpo_batch'] = grpo_batch # _compute_logps pops it again
out = self._compute_logps(model_inputs, model, output_key)
if output_key not in out:
continue
rows.extend(
self._split_logps_rows(
out[output_key],
output_key,
routed_experts=out.get('routed_experts'),
seq_lengths=grpo_batch.seq_lengths))
return rows
@staticmethod
def _split_logps_rows(
logps: Optional[torch.Tensor],
key: str,
*,
routed_experts: Optional[torch.Tensor] = None,
seq_lengths: Optional[torch.Tensor] = None,
) -> List[LogpsRow]:
if logps is None:
return []
routed_rows: List[Optional[torch.Tensor]] = [None] * int(logps.shape[0])
if routed_experts is not None:
routed = routed_experts.detach().cpu() if isinstance(routed_experts, torch.Tensor) \
else torch.as_tensor(routed_experts)
if routed.dim() > 0 and routed.shape[0] == logps.shape[0]:
routed_rows = [routed[i] for i in range(logps.shape[0])]
elif routed.dim() > 1 and routed.shape[0] == 1 and seq_lengths is not None:
seq_cpu = seq_lengths.detach().cpu().tolist()
start = 0
for i in range(logps.shape[0]):
seq_len = int(seq_cpu[i])
end = start + seq_len
routed_rows[i] = routed[0, start:end]
start = end
rows: List[LogpsRow] = []
for i in range(logps.shape[0]):
item: LogpsRow = {key: logps[i].detach().cpu()}
if routed_rows[i] is not None:
item['routed_experts'] = routed_rows[i].detach().cpu()
rows.append(item)
return rows
def _compute_logps(self, model_inputs: ModelInputs, model, output_key: str) -> Dict[str, torch.Tensor]:
"""Compute per-token logps for a single collated micro-batch."""
from swift.rlhf_trainers.utils import pad_logps_back_to_batch
megatron = self._megatron
args = self._args
temperature = getattr(args, 'temperature', 1.0)
# grpo_batch carries the per-batch masks/seq_lengths; pop it so what
# remains is the pure ``model(**model_inputs)`` forward kwargs.
grpo_batch: GRPOBatch = model_inputs.pop('grpo_batch')
seq_lengths = grpo_batch.seq_lengths
batch_size = grpo_batch.completion_mask.shape[0]
max_seq_len = grpo_batch.completion_mask.shape[1]
enable_routing_replay = bool(getattr(megatron, 'enable_routing_replay', False))
router_mode = getattr(args, 'router_replay_mode', 'disabled')
RouterReplay = None
RouterReplayAction = None
if enable_routing_replay:
try:
from megatron.core.transformer.moe.router_replay import RouterReplay, RouterReplayAction
except ImportError:
enable_routing_replay = False
if enable_routing_replay and RouterReplay is not None:
if router_mode == 'R2':
RouterReplay.set_global_router_replay_action(RouterReplayAction.RECORD)
elif router_mode == 'R3':
RouterReplay.set_global_router_replay_action(RouterReplayAction.REPLAY_FORWARD)
routing_topk_idx = None
try:
logps_packed, routing_topk_idx = megatron.compute_per_token_logps(
model, iter([model_inputs]), temperature=temperature)
finally:
if enable_routing_replay and RouterReplay is not None:
RouterReplay.clear_global_indices()
RouterReplay.clear_global_router_replay_action()
out: Dict[str, torch.Tensor] = {}
if logps_packed is not None:
if args.padding_free:
logps, _ = pad_logps_back_to_batch(
logps_rmpad=logps_packed,
logits_to_keep=max_seq_len,
batch_size=batch_size,
seq_lengths=seq_lengths)
else:
logps = logps_packed
out[output_key] = logps.detach().cpu()
if routing_topk_idx is not None:
out['routed_experts'] = routing_topk_idx.detach().cpu()
return out
@dispatch_collect(dispatch='broadcast', collect='first')
def finalize(self) -> Dict[str, Any]:
from swift.utils import is_last_rank
megatron = self._megatron
megatron.finalize_training()
self._pipeline._handle_trainer_state(megatron, is_last_rank())
state = megatron.state
return {
'last_model_checkpoint': state.last_model_checkpoint,
'best_model_checkpoint': state.best_model_checkpoint,
'best_metric': state.best_metric,
}
def _init_rollout_adapter(self, rollout_config: Dict[str, Any]) -> None:
"""Create the internal RolloutAdapter.
The adapter lazily resolves the VllmServer handle via named actor,
so it can be created before the server is fully started.
"""
from .rollout.adapter import RolloutAdapter
tp = rollout_config['rollout_tp_size']
dp = rollout_config['rollout_dp_size']
world_per_replica = tp * dp
rank = int(os.environ.get('RANK', '0'))
replica_rank = rank // world_per_replica
rollout_rank = rank % world_per_replica
bucket_mb = rollout_config.get('bucket_size_mb', 2048)
self.rollout = RolloutAdapter(
replica_rank=replica_rank,
rollout_rank=rollout_rank,
bucket_size_mb=bucket_mb,
)
logger.info('MegatronWorker[rank=%s]: rollout adapter created (replica=%d, rollout_rank=%d)', rank,
replica_rank, rollout_rank)
@dispatch_collect(dispatch='broadcast', collect='first')
def merge_lora(self):
"""Merge LoRA adapters into base weights (must be called before offload)."""
megatron = self._megatron
if megatron.args.tuner_type in ('lora', 'lora_llm'):
megatron.merge_lora_adapters()
@dispatch_collect(dispatch='broadcast', collect='first')
def unmerge_lora(self):
"""Unmerge LoRA adapters to restore training state (call after reload)."""
megatron = self._megatron
if megatron.args.tuner_type in ('lora', 'lora_llm'):
megatron.unmerge_lora_adapters()
@dispatch_collect(dispatch='broadcast', collect='first')
def update_weights(self, adapter_only: bool = False):
"""Push training weights to rollout via IPC (streaming).
All TP ranks must call export_weights (contains TP collectives).
Only the primary rank sends; others drain the iterator.
For full-weight sync with LoRA, the caller must ensure merge_lora()
was called beforehand and unmerge_lora() is called after reload.
Args:
adapter_only: When True, export only LoRA adapter weights
(peft_format=True) and pass peft_config to vLLM for
TensorLoRARequest loading. When False, export full
merged weights.
"""
megatron = self._megatron
target_device = 'cpu' if megatron.args.offload_bridge else None
if adapter_only:
weight_iter = megatron.bridge.export_weights(
megatron.unwrapped_models, target_device=target_device, peft_format=True)
peft_config = self.get_peft_config_dict()
lora_names = None
else:
weight_iter = megatron.bridge.export_weights(megatron.unwrapped_models, target_device=target_device)
peft_config = None
lora_names = self._resolve_lora_param_names()
if self.rollout is None:
# No rollout adapter attached just drain the
# iterator so all TP ranks finish the collective export.
for _ in weight_iter:
pass
return
self.rollout.update_weights(
weight_iter,
vllm_lora_param_names=lora_names,
peft_config=peft_config,
base_sync_done=adapter_only,
)
self.rollout.reset_prefix_cache()
def _resolve_lora_param_names(self) -> Optional[set]:
"""Get vLLM param names for LoRA mapping, if applicable."""
megatron = self._megatron
if not (megatron.args.tuner_type == 'lora' and megatron.args.vllm_enable_lora):
return None
raw_names = self.rollout.get_model_param_names()
if not raw_names:
return None
from swift.rlhf_trainers.utils import expand_vllm_param_name_aliases
expanded = expand_vllm_param_name_aliases(set(raw_names))
stripped = set()
for n in expanded:
stripped.add(n)
if n.startswith('model.'):
stripped.add(n[len('model.'):])
return stripped
@dispatch_collect(dispatch='broadcast', collect='first')
def finalize_generation(self):
if self.rollout is not None:
self.rollout.reset_prefix_cache()
@dispatch_collect(dispatch='broadcast', collect='first')
def offload_to_cpu(self):
self.actor.offload_to_cpu()
if self.ref is not None:
self.ref.offload_to_cpu()
if self.teacher is not None:
self.teacher.offload_to_cpu()
@dispatch_collect(dispatch='broadcast', collect='first')
def reload_to_gpu(self):
self.actor.reload_to_gpu()
if self.ref is not None:
self.ref.reload_to_gpu(load_grad=False)
# When offload_teacher_model is set, the teacher is managed by compute_teacher_logits
# (loaded only for the teacher forward), so keep it on CPU here.
if self.teacher is not None and not getattr(self._args, 'offload_teacher_model', False):
self.teacher.reload_to_gpu(load_grad=False)
@staticmethod
def _align_seq_len(t, target_len, pad_val=0):
"""Pad or truncate a tensor along dim=1 to target_len. Works for 2D [B,S] and 3D [B,S,*]."""
cur = t.shape[1]
if cur == target_len:
return t
if cur < target_len:
pad = (0, target_len - cur) if t.dim() == 2 else (0, 0, 0, target_len - cur)
return torch.nn.functional.pad(t, pad, value=pad_val)
return t[:, :target_len]
@staticmethod
def _collate_teacher_outputs(
teacher_outputs: List['TeacherOutput'],
device: torch.device,
padding_free: bool = False,
target_seq_len: Optional[int] = None,
is_opsd: bool = False,
) -> 'TeacherOutput':
"""Collate per-sample TeacherOutputs into a batched one (driver-side).
For non-OPSD: each tensor is aligned to target_seq_len (pad or truncate).
For OPSD: teacher keeps its own length (target_seq_len ignored).
"""
from swift.rlhf_trainers.gkd_loss import TeacherOutput
effective_target = None if is_opsd else target_seq_len
pad_vals = {'topk_logprobs': float('-inf'), 'labels': -100}
fields = ('full_logits', 'topk_logprobs', 'topk_indices', 'labels')
kwargs = {}
for field in fields:
tensors = [getattr(t, field) for t in teacher_outputs]
tensors = [t for t in tensors if t is not None]
if not tensors:
continue
pad_val = pad_vals.get(field, 0)
if effective_target is not None:
tensors = [MegatronWorker._align_seq_len(t, effective_target, pad_val) for t in tensors]
if padding_free:
non_empty = [t for t in tensors if t.shape[0] > 0]
kwargs[field] = torch.cat(non_empty, dim=1).to(device)
else:
kwargs[field] = torch.cat(tensors, dim=0).to(device)
return TeacherOutput(**kwargs)
def send_checkpoint_weights(self, adapter_only: bool = False) -> None:
"""Export and send model weights via NCCL checkpoint engine."""
import asyncio
megatron = self._megatron
engine = self._get_or_create_checkpoint_engine()
target_device = 'cpu' if megatron.args.offload_bridge else None
weight_iter = megatron.bridge.export_weights(
megatron.unwrapped_models, target_device=target_device, peft_format=adapter_only)
asyncio.run(engine.send_weights(weight_iter))
def get_peft_config_dict(self) -> dict:
"""Return the PEFT config for LoRA-only sync."""
from dataclasses import asdict
peft_config = self._megatron.unwrapped_models[0].peft_config['default']
return asdict(peft_config)
def shutdown(self):
self.rollout = None
self._megatron = None
self._loss_fn = None
self._checkpoint_engine = None
self.actor = None
self.ref = None
self.teacher = None
+127
View File
@@ -0,0 +1,127 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Single-model worker abstraction for Ray-based Megatron training.
MegatronModelWorker wraps one Megatron model (inference-only).
TrainableModelWorker extends it with training capabilities via _LifecycleTrainer.
"""
from __future__ import annotations
import torch
from contextlib import contextmanager
from typing import Any, Dict, Optional, Sequence
from swift.utils import gc_collect, get_current_device, get_logger, to_device
logger = get_logger()
class MegatronModelWorker:
"""Wraps a single Megatron model with inference / offload interfaces.
Two creation paths:
- ``__init__(args, models)``: wraps models already created in the current
process (e.g. ref models created by MegatronRLHFTrainer.prepare_model).
- ``from_pretrained(args, model_dir)``: independently loads a new model
from disk (e.g. colocated teacher with different weights).
"""
def __init__(self, args, models, bridge=None):
self.args = args
self.models = models
self.bridge = bridge
@classmethod
def from_pretrained(cls, args, model_dir):
"""Load an inference-only model (ref / teacher) from disk."""
from transformers import AutoConfig
from swift.megatron.model import get_mcore_model
hf_config = AutoConfig.from_pretrained(model_dir, trust_remote_code=True)
models = get_mcore_model(args, hf_config)
for m in models:
if not args.use_cpu_initialization:
m.cuda(torch.cuda.current_device())
m.requires_grad_(False)
m.eval()
models[0].config.bridge.load_weights(models, model_dir)
return cls(args, models, bridge=models[0].config.bridge)
def compute_per_token_logps(self, data_iterator, temperature=1.0, enable_routing_replay=False):
from swift.megatron.trainers.utils import compute_per_token_logps_fn
return compute_per_token_logps_fn(
self.models[0],
self.args,
data_iterator,
temperature=temperature,
enable_routing_replay=enable_routing_replay)
def offload_to_cpu(self):
from swift.megatron.trainers.utils import offload_megatron_model_to_cpu
offload_megatron_model_to_cpu(self.models)
gc_collect()
def reload_to_gpu(self, load_grad=False):
from swift.megatron.trainers.utils import load_megatron_model_to_gpu
load_megatron_model_to_gpu(self.models, load_grad=load_grad)
@contextmanager
def loaded_context(self, load_grad=False):
"""Temporarily load model to GPU, offload on exit.
No-op if offloading is not configured. Use this to bracket
inference on an offloaded model (e.g. teacher forward).
"""
if not getattr(self.args, 'offload_teacher_model', False):
yield
return
self.reload_to_gpu(load_grad=load_grad)
try:
yield
finally:
self.offload_to_cpu()
class TrainableModelWorker(MegatronModelWorker):
"""Trainable model wrapping a _LifecycleTrainer with optimizer / training step.
Note: the lifecycle_trainer dependency on MegatronRLHFTrainer is a
transitional design. Future refactoring should extract the needed
capabilities (optimizer, model wrapping, ref model context) into
standalone components so the ray module no longer depends on the
non-ray trainer hierarchy.
"""
def __init__(self, args, lifecycle_trainer):
self._trainer = lifecycle_trainer
super().__init__(args, lifecycle_trainer.wrapped_models, lifecycle_trainer.bridge)
@property
def trainer(self):
return self._trainer
@property
def unwrapped_models(self):
return self._trainer.unwrapped_models
def set_forward_step(self, fn):
self._trainer.forward_step = fn
def run_train_step(self, data_iterator):
self._trainer.run_train_step(data_iterator, None)
def null_ref_context(self):
return self._trainer.null_ref_context()
def offload_to_cpu(self):
from swift.megatron.trainers.utils import offload_megatron_model_to_cpu, offload_megatron_optimizer
offload_megatron_model_to_cpu(self._trainer.wrapped_models)
if getattr(self._trainer, 'optimizer', None) and self.args.offload_optimizer:
offload_megatron_optimizer(self._trainer.optimizer)
gc_collect()
def reload_to_gpu(self, load_grad=True):
from swift.megatron.trainers.utils import load_megatron_model_to_gpu, load_megatron_optimizer
load_megatron_model_to_gpu(self._trainer.wrapped_models)
if getattr(self._trainer, 'optimizer', None) and self.args.offload_optimizer:
load_megatron_optimizer(self._trainer.optimizer)
+409
View File
@@ -0,0 +1,409 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
import importlib
import os
import ray
from contextlib import contextmanager
from typing import Any, Dict, List, Optional
from swift.utils import get_logger
from .base_trainer import BaseRayTrainer
from .driver_utils import (build_dataset_from_dict, compute_iter_params, estimate_dp_size, merge_group_dict,
parse_ray_yaml)
logger = get_logger()
_TRAINER_REGISTRY: Dict[str, Dict[str, Any]] = {
'grpo': {
'trainer': 'swift.ray.megatron.grpo_trainer.GRPOTrainer',
'loss': 'swift.ray.megatron.loss.grpo.GRPOLoss',
},
'gkd': {
'trainer': 'swift.ray.megatron.gkd_trainer.GKDTrainer',
'loss': 'swift.ray.megatron.loss.gkd.GKDLoss',
},
}
def register_ray_trainer(
rlhf_type: str,
trainer: str,
loss: Optional[str] = None,
):
"""Register a custom algorithm for the Ray pipeline.
Args:
rlhf_type: Algorithm identifier (e.g. ``'grpo'``).
trainer: Dotted path to the driver-side trainer class.
The class must accept ``(worker_groups, rollout_replicas)``
and expose ``set_data_info()`` / ``train()`` methods.
Example: ``'swift.ray.megatron.grpo_trainer.GRPOTrainer'``
loss: Dotted path to a ``Loss`` subclass that defines
``forward_step`` + ``loss_func``.
Pass ``None`` to use the internal trainer's forward_step.
"""
_TRAINER_REGISTRY[rlhf_type] = {'trainer': trainer, 'loss': loss}
class MegatronRayPipeline:
def __init__(self, config_path: str):
self.ray_config, group_configs, shared_config = parse_ray_yaml(config_path)
shared_config['use_ray'] = True
self.rlhf_type = self.ray_config.rlhf_type
if self.rlhf_type not in _TRAINER_REGISTRY:
raise ValueError('Unknown rlhf_type %r. Available: %s' % (self.rlhf_type, list(_TRAINER_REGISTRY)))
self.group_cfgs: Dict[str, Dict[str, Any]] = {
g: merge_group_dict(shared_config, gd)
for g, gd in group_configs.items()
}
self.shared_cfg = {k: v for k, v in shared_config.items() if v is not None}
self._entry = _TRAINER_REGISTRY[self.rlhf_type]
self.resource_pool_manager = None
self.worker_groups: Dict[str, Any] = {}
self.rollout_replicas: List[Any] = []
self.teacher_replicas: List[Any] = []
def init(self) -> None:
# Initialize Ray, create resource pools, spawn workers and replicas.
self._data_info = self._build_dataset()
self._compute_train_iters()
ray.init(ignore_reinit_error=True)
self._create_pools()
self._init_worker_groups()
with self._colocate_offload_ctx():
self._init_rollout_replicas()
self._init_teacher_replicas()
self._driver_trainer: BaseRayTrainer = self._create_trainer()
self._driver_trainer.set_data_info(self._data_info)
def train(self) -> Any:
"""Run the training loop. Requires ``init()`` to have been called."""
if not hasattr(self, '_driver_trainer'):
raise RuntimeError('MegatronRayPipeline.train(): call init() first')
return self._driver_trainer.train()
def run(self) -> Any:
"""Convenience: ``init()`` + ``train()`` + ``shutdown()``."""
self.init()
try:
return self.train()
finally:
self._shutdown()
def _build_dataset(self) -> Dict[str, Any]:
# Merge train group config (tuner_type, lora_rank, etc.) into shared cfg so that
# _check_teacher can detect LoRA self-distillation (teacher_model == model + lora).
cfg = {**self.shared_cfg, **self.group_cfgs.get('train', {})}
data_info = build_dataset_from_dict(cfg)
return data_info
def _compute_train_iters(self):
train_cfg = self.group_cfgs.get('train')
gpus = self.group_gpus.get('train', 0)
assert train_cfg is not None and gpus > 0
dp_size = estimate_dp_size(train_cfg, gpus)
iter_params = compute_iter_params(self._data_info, dp_size)
train_iters = iter_params.get('train_iters')
assert train_iters is not None and train_iters > 0
self.group_cfgs['train']['train_iters'] = train_iters
def _create_pools(self):
from .resource_pool import ResourcePool, ResourcePoolManager
colocated_sets = {frozenset(g) for g in self.colocate_groups}
pool_mapping: Dict[str, ResourcePool] = {}
assigned: set = set()
for colocated in colocated_sets:
# Colocated roles share one GPU set, so they must request the same number
# of gpus. Validate explicitly (and avoid relying on frozenset order).
gpus_by_role = {g: self.group_gpus.get(g, 0) for g in colocated}
distinct = set(gpus_by_role.values())
if distinct == {0}:
continue
if len(distinct) > 1:
raise ValueError(f'Colocated roles must request the same number of gpus, but got '
f'{gpus_by_role}. Set an equal `gpus` for all roles in {sorted(colocated)}.')
gpus = distinct.pop()
pon = self.ray_config.gpus_as_process_on_nodes(gpus)
shared = ResourcePool(pon, max_colocate_count=len(colocated))
for g in colocated:
pool_mapping[g] = shared
assigned.add(g)
for name, gpus in self.group_gpus.items():
if name in assigned or gpus <= 0:
continue
pon = self.ray_config.gpus_as_process_on_nodes(gpus)
pool_mapping[name] = ResourcePool(pon)
self.resource_pool_manager = ResourcePoolManager(pool_mapping)
self.resource_pool_manager.create_all()
def _is_rollout_hybrid(self) -> bool:
"""True if rollout shares its pool with train (HYBRID mode)."""
return any('rollout' in cg and 'train' in cg for cg in self.colocate_groups)
def _init_worker_groups(self):
self._validate_grpo_train_batch_params()
self._spawn_train_group('train')
train_wg = self.worker_groups['train']
padding_vals = train_wg.broadcast('get_padding_to')
self._data_info['_padding_to'] = next((v for v in padding_vals if v is not None), None)
def _validate_grpo_train_batch_params(self) -> None:
"""Early validation of GRPO batch params before spawning workers.
Resolves generation_batch_size / steps_per_generation, then validates
DP alignment — all via static helpers in RLHFMegatronArgumentsMixin.
"""
if self.rlhf_type != 'grpo':
return
train_cfg = self.group_cfgs.get('train')
train_gpus = self.group_gpus.get('train', 0)
if not train_cfg or train_gpus <= 0:
return
cfg = dict(train_cfg)
global_batch_size = cfg.get('global_batch_size')
if global_batch_size <= 0:
return
micro_batch_size = cfg.get('micro_batch_size', 1)
num_generations = cfg.get('num_generations', 8)
from swift.megatron.arguments.megatron_args import RLHFMegatronArgumentsMixin
generation_batch_size, _ = RLHFMegatronArgumentsMixin.resolve_generation_batch_size(
cfg.get('generation_batch_size'), cfg.get('steps_per_generation'), global_batch_size, num_generations)
dp_size = estimate_dp_size(cfg, train_gpus)
RLHFMegatronArgumentsMixin.validate_batch_dp_alignment(generation_batch_size, num_generations, dp_size,
micro_batch_size, train_gpus)
def _spawn_train_group(self, role: str) -> None:
from .megatron_worker import MegatronWorker
from .worker_group import WorkerGroup
pool = self.resource_pool_manager.get_pool(role)
cfg = dict(self.group_cfgs.get(role, {}))
cfg.setdefault('rlhf_type', self.ray_config.rlhf_type)
worker_cls = ray.remote(num_gpus=0)(MegatronWorker)
wg = WorkerGroup.from_pool(role, pool, worker_cls=worker_cls)
loss_cls = self._entry.get('loss')
rollout_config = self._build_rollout_config_for_workers() if self._is_rollout_hybrid() else None
wg.broadcast('init_actor', cfg, loss_cls_path=loss_cls, rollout_config=rollout_config)
wg.build_dispatch_info(worker_cls=MegatronWorker)
self.worker_groups[role] = wg
logger.info('MegatronWorker group [%s] on %d GPUs', role, pool.world_size)
@contextmanager
def _colocate_offload_ctx(self):
"""Offload train workers during vLLM init (colocate only)."""
need = self._is_rollout_hybrid() and bool(self.shared_cfg.get('offload_model', True))
colocated_wgs = [
wg for role, wg in self.worker_groups.items() if need and any(role in g for g in self.colocate_groups)
]
for wg in colocated_wgs:
wg.broadcast('offload_to_cpu')
try:
yield
finally:
for wg in colocated_wgs:
wg.broadcast('reload_to_gpu')
def _init_rollout_replicas(self) -> None:
rollout_gpus = self.group_gpus.get('rollout', 0)
if rollout_gpus <= 0:
self.rollout_replicas = []
return
from .rollout.replica import RolloutReplica
rollout_cfg = self._with_router_replay_rollout_config(self.group_cfgs.get('rollout', {}))
is_hybrid = self._is_rollout_hybrid()
pool = self.resource_pool_manager.get_pool('train' if is_hybrid else 'rollout')
template_kwargs = self._get_template_kwargs_for_rollout()
self.rollout_replicas = RolloutReplica.create_replicas(
rollout_cfg=rollout_cfg,
rollout_gpus=rollout_gpus,
pool=pool,
is_hybrid=is_hybrid,
sleep_level=self.ray_config.sleep_level,
template_kwargs=template_kwargs,
)
def _init_teacher_replicas(self) -> None:
teacher_gpus = self.group_gpus.get('teacher', 0)
if teacher_gpus <= 0:
self.teacher_replicas = []
return
from .rollout.replica import RolloutReplica
teacher_cfg = self.group_cfgs.get('teacher', {})
pool = self.resource_pool_manager.get_pool('teacher')
template_kwargs = self._get_template_kwargs_for_rollout()
args = self._data_info.get('_driver_args')
if args is not None:
base_len = template_kwargs.get('max_length') or getattr(args, 'max_length')
template_kwargs = dict(template_kwargs)
template_kwargs['max_length'] = base_len + args.max_completion_length
self.teacher_replicas = RolloutReplica.create_replicas(
rollout_cfg=teacher_cfg,
rollout_gpus=teacher_gpus,
pool=pool,
is_hybrid=False,
sleep_level=0,
template_kwargs=template_kwargs,
actor_name_prefix='swift_teacher_server',
)
def _with_router_replay_rollout_config(self, rollout_cfg: Dict[str, Any]) -> Dict[str, Any]:
cfg = dict(rollout_cfg or {})
args = self._data_info.get('_driver_args')
router_mode = getattr(args, 'router_replay_mode', 'disabled') if args is not None else 'disabled'
if router_mode != 'R3':
return cfg
from swift.rlhf_trainers.utils import check_vllm_version_ge
if not check_vllm_version_ge('0.14.0'):
raise ValueError('router_replay_mode=R3 requires vLLM>=0.14.0 to return routed_experts.')
engine_kwargs = dict(cfg.get('vllm_engine_kwargs') or {})
engine_kwargs.setdefault('enable_return_routed_experts', True)
# https://github.com/vllm-project/vllm/pull/39917
import vllm
from packaging import version
vllm_version = vllm.__version__
if vllm_version is not None and version.parse('0.21.0rc1') <= version.parse(vllm_version) <= version.parse(
'0.21.0'):
engine_kwargs.setdefault('async_scheduling', False)
cfg['vllm_engine_kwargs'] = engine_kwargs
return cfg
def _get_template_kwargs_for_rollout(self) -> Dict[str, Any]:
"""Extract template config for vLLM alignment (padding_free=False, sp=1)."""
args = self._data_info.get('_driver_args')
if args is None:
return {}
kwargs = args.get_template_kwargs()
kwargs['padding_free'] = False
kwargs['sequence_parallel_size'] = 1
return kwargs
def _build_rollout_config_for_workers(self) -> Optional[Dict[str, Any]]:
"""Build rollout config dict for MegatronWorker._init_rollout_adapter.
Returns None if no rollout GPUs are configured.
"""
rollout_gpus = self.group_gpus.get('rollout', 0)
if rollout_gpus <= 0:
return None
rollout_cfg = self.group_cfgs.get('rollout', {})
bucket_mb = int(os.environ.get('SWIFT_RAY_WEIGHT_BUCKET_MB', '2048'))
return {
'rollout_tp_size': rollout_cfg.get('vllm_tensor_parallel_size', 1),
'rollout_dp_size': rollout_cfg.get('vllm_data_parallel_size', 1),
'bucket_size_mb': bucket_mb,
}
def _create_trainer(self):
cls_path = self._entry['trainer']
mod_path, cls_name = cls_path.rsplit('.', 1)
mod = importlib.import_module(mod_path)
trainer_cls = getattr(mod, cls_name)
weight_sync_mode = self._get_weight_sync_mode()
sleep_level = self._resolve_sleep_level()
return trainer_cls(
self.worker_groups,
self.rollout_replicas,
weight_sync_mode=weight_sync_mode,
sleep_level=sleep_level,
teacher_replicas=self.teacher_replicas)
def _resolve_sleep_level(self) -> int:
"""Colocate: honor user config; separate: force 0."""
user_level = self.ray_config.sleep_level
if self._is_rollout_hybrid():
return user_level
if user_level != 0:
logger.warning('sleep_level=%d ignored in separate mode (vLLM stays resident). '
'Overriding to 0.', user_level)
return 0
def _get_weight_sync_mode(self) -> str:
"""Colocate: naive (IPC); separate: nccl (broadcast)."""
if self._is_rollout_hybrid():
return 'naive'
return 'nccl'
@property
def group_gpus(self) -> Dict[str, int]:
return self.ray_config.group_gpus
@property
def colocate_groups(self) -> List[List[str]]:
return self.ray_config.colocate_groups
def _shutdown(self):
"""Best-effort teardown — each step swallows exceptions so a
failure in one stage does not skip the remaining cleanup."""
for replica in self.rollout_replicas:
try:
replica.shutdown()
except Exception as e: # noqa: BLE001
logger.warning('RolloutReplica shutdown failed: %s', e)
self.rollout_replicas = []
for replica in self.teacher_replicas:
try:
replica.shutdown()
except Exception as e: # noqa: BLE001
logger.warning('TeacherReplica shutdown failed: %s', e)
self.teacher_replicas = []
seen: set = set()
for wg in self.worker_groups.values():
if id(wg) not in seen:
seen.add(id(wg))
try:
wg.shutdown()
except Exception as e: # noqa: BLE001
logger.warning('WorkerGroup shutdown failed: %s', e)
self.worker_groups.clear()
if self.resource_pool_manager is not None:
try:
self.resource_pool_manager.destroy_all()
except Exception as e: # noqa: BLE001
logger.warning('destroy_all placement groups failed: %s', e)
ray.shutdown()
def main():
import sys
argv = sys.argv[1:]
config_path = None
for i, arg in enumerate(argv):
if arg == '--config' and i + 1 < len(argv):
config_path = argv[i + 1]
break
if config_path is None:
raise ValueError('Usage: python -m swift.ray.megatron.pipeline --config <yaml>')
return MegatronRayPipeline(config_path).run()
if __name__ == '__main__':
main()
+168
View File
@@ -0,0 +1,168 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from dataclasses import dataclass, field
from typing import Any, Dict, List, Tuple
from swift.utils import get_logger
logger = get_logger()
def sort_pgs_by_node_ip(pgs: List[Any]) -> List[Any]:
"""Sort placement groups by node IP for deterministic rank assignment.
Sorts PGs by node IP for deterministic ordering.
"""
import ray
node_ip = {node['NodeID']: node['NodeManagerAddress'] for node in ray.nodes()}
pg_ip = {}
for pg in pgs:
specs = ray._private.state.state.placement_group_table(pg.id)
node_id = specs['bundles_to_node_id'][0]
pg_ip[pg.id] = node_ip[node_id]
return sorted(pgs, key=lambda pg: pg_ip[pg.id])
@dataclass
class ResourcePool:
"""A pool of GPU resources backed by multiple Ray placement groups.
Args:
process_on_nodes: GPUs per node. ``[8]`` = 8 GPUs on 1 node,
``[4, 4]`` = 8 GPUs across 2 nodes.
max_colocate_count: How many WorkerGroups share these GPUs.
"""
process_on_nodes: List[int]
max_colocate_count: int = 1
pgs: List[Any] = field(default_factory=list, repr=False, init=False)
node_ips: List[str] = field(default_factory=list, repr=False, init=False)
bundle_infos: List[Tuple[str, str]] = field(default_factory=list, repr=False, init=False)
@property
def world_size(self) -> int:
return sum(self.process_on_nodes)
@property
def num_nodes(self) -> int:
return len(self.process_on_nodes)
@property
def visible_devices(self) -> List[int]:
"""Physical GPU ordinals: flat list across all nodes."""
return [int(info[1]) if info[1] else i for i, info in enumerate(self.bundle_infos)]
def create(self, device_name: str = 'GPU'):
"""Create one PG per node with STRICT_PACK strategy."""
import ray
from ray.util.placement_group import placement_group
if device_name == 'npu':
device_name = 'NPU'
elif device_name == 'cuda':
device_name = 'GPU'
bundle_template = {
device_name: 1,
'CPU': max(self.max_colocate_count, 1),
}
pgs = []
for n_gpus in self.process_on_nodes:
bundles = [bundle_template.copy() for _ in range(n_gpus)]
pg = placement_group(bundles, strategy='STRICT_PACK')
pgs.append(pg)
ray.get([pg.ready() for pg in pgs])
self.pgs = sort_pgs_by_node_ip(pgs)
self._discover_bundle_infos()
def _discover_bundle_infos(self):
"""Probe each bundle's accelerator_id via lightweight actors.
Swift-specific: needed because Swift uses num_gpus=0 + explicit
CUDA_VISIBLE_DEVICES (torchrun style).
"""
import os
import ray
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
from transformers.utils import is_torch_npu_available
@ray.remote(num_gpus=0.01, num_cpus=0.01)
def _probe_bundle():
ctx = ray.get_runtime_context()
acc_ids = ctx.get_accelerator_ids()
gpu_id = ''
for key in ('GPU', 'NPU'):
ids = acc_ids.get(key, [])
if ids:
gpu_id = ids[0]
break
return ctx.get_node_id(), gpu_id
all_infos: List[Tuple[str, str]] = []
node_id_to_ip = {node['NodeID']: node['NodeManagerAddress'] for node in ray.nodes()}
for pg_idx, pg in enumerate(self.pgs):
refs = [
_probe_bundle.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg, placement_group_bundle_index=i), ).remote()
for i in range(self.process_on_nodes[pg_idx])
]
results = ray.get(refs)
for r in results:
all_infos.append(r)
vis_key = 'ASCEND_RT_VISIBLE_DEVICES' if is_torch_npu_available() else 'CUDA_VISIBLE_DEVICES'
parent_cvd = os.environ.get(vis_key, '')
if parent_cvd:
phys_ids = [x.strip() for x in parent_cvd.split(',')]
all_infos = [(nid, phys_ids[int(gid)] if gid.isdigit() and int(gid) < len(phys_ids) else gid)
for nid, gid in all_infos]
self.bundle_infos = all_infos
seen: set = set()
node_ips = []
for nid, _ in all_infos:
if nid not in seen:
seen.add(nid)
node_ips.append(node_id_to_ip.get(nid, ''))
self.node_ips = node_ips
logger.info('ResourcePool: %d PG(s), %d bundles, node_ips=%s', len(self.pgs), len(all_infos), self.node_ips)
def destroy(self):
if self.pgs:
import ray
for pg in self.pgs:
try:
ray.util.remove_placement_group(pg)
except Exception: # noqa: BLE001
pass
self.pgs = []
class ResourcePoolManager:
"""Manages multiple ResourcePools, deduplicating shared pools (colocate)."""
def __init__(self, pool_mapping: Dict[str, 'ResourcePool']):
self._pools = pool_mapping
def get_pool(self, group_name: str) -> 'ResourcePool':
return self._pools[group_name]
def create_all(self):
seen: set = set()
for pool in self._pools.values():
if id(pool) not in seen:
seen.add(id(pool))
pool.create()
def destroy_all(self):
seen: set = set()
for pool in self._pools.values():
if id(pool) not in seen:
seen.add(id(pool))
pool.destroy()
+25
View File
@@ -0,0 +1,25 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .adapter import RolloutAdapter
from .ray_vllm_engine import RayVllmEngine
from .replica import RolloutMode, RolloutReplica, VllmEngineConfig
from .vllm_server import VllmServer
from .weight_transfer import BucketedWeightSender
def __getattr__(name):
_imports = {
'RolloutAdapter': '.adapter',
'RayVllmEngine': '.ray_vllm_engine',
'RolloutMode': '.replica',
'RolloutReplica': '.replica',
'VllmEngineConfig': '.replica',
'VllmServer': '.vllm_server',
'BucketedWeightSender': '.weight_transfer',
}
if name in _imports:
import importlib
return getattr(importlib.import_module(_imports[name], __name__), name)
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
+123
View File
@@ -0,0 +1,123 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from __future__ import annotations
import asyncio
import logging
import ray
import time
import torch
from typing import Any, Generator, List, Optional, Tuple
logger = logging.getLogger(__name__)
def _is_ipc_supported() -> bool:
"""Check if CUDA IPC is supported (GPU=True, NPU=fallback to SHM)."""
from transformers.utils import is_torch_npu_available
return not is_torch_npu_available()
class RolloutAdapter:
def __init__(
self,
*,
replica_rank: int = 0,
rollout_rank: int = 0,
bucket_size_mb: int = 2048,
):
self.replica_rank = replica_rank
self.rollout_rank = rollout_rank
self.bucket_size_mb = bucket_size_mb
self.is_primary = (rollout_rank == 0)
self.use_shm = not _is_ipc_supported()
self.zmq_handle = (f'ipc:///tmp/swift-rollout-zmq-replica-{replica_rank}-rank-{rollout_rank}.sock')
self._server_handle = None
# Persistent CUDA IPC buffer reused across all update_weights() syncs so the IPC
# handle stays stable (the vLLM worker's mapping cache hits) and no IPC mapping
# leaks per step.
self._ipc_buffer: Optional[torch.Tensor] = None
@property
def server_handle(self) -> Any:
if self._server_handle is None:
self._server_handle = ray.get_actor(f'swift_rollout_server_{self.replica_rank}_0')
return self._server_handle
def update_weights(
self,
weight_iter: Generator[Tuple[str, torch.Tensor], None, None],
*,
vllm_lora_param_names: Optional[set] = None,
peft_config: Optional[dict] = None,
base_sync_done: bool = False,
) -> None:
"""Push training weights to vLLM via ZMQ IPC.
Only the primary rank (rollout_rank == 0) sends weights;
other ranks are no-op.
Args:
weight_iter: Iterator of (name, tensor) from bridge.export_weights.
vllm_lora_param_names: When set, remap dense param names to
LoRA-wrapped names (*.base_layer.weight) for full-weight sync
with vllm_enable_lora.
peft_config: When provided with base_sync_done=True, vLLM loads
weights as a LoRA adapter via TensorLoRARequest.
base_sync_done: Indicates this is an adapter-only sync after
the initial full weight sync has completed.
"""
if vllm_lora_param_names:
from swift.rlhf_trainers.utils import add_base_layer_suffix_by_param_names
weight_iter = add_base_layer_suffix_by_param_names(weight_iter, vllm_lora_param_names)
if not self.is_primary:
for _ in weight_iter:
pass
return
from ..rollout.weight_transfer import BucketedWeightSender
start_time = time.time()
# Lazily (re)allocate the persistent IPC buffer; reused across syncs so the
# handle signature stays stable and the worker-side IPC cache hits.
external_buffer = None
if not self.use_shm:
from swift.utils import get_current_device
bucket_size = self.bucket_size_mb << 20
if self._ipc_buffer is None or self._ipc_buffer.numel() < bucket_size:
self._ipc_buffer = torch.empty(bucket_size, dtype=torch.uint8, device=get_current_device())
external_buffer = self._ipc_buffer
async def _do_ipc_sync():
sender = BucketedWeightSender(
zmq_handle=self.zmq_handle,
bucket_size_mb=self.bucket_size_mb,
use_shm=self.use_shm,
external_buffer=external_buffer,
)
try:
async with sender:
rpc_ref = self.server_handle.update_weights_ipc.remote(self.zmq_handle, self.use_shm, 600,
peft_config, base_sync_done)
await sender.handshake()
await sender.send_weights(weight_iter)
await asyncio.get_running_loop().run_in_executor(None, ray.get, rpc_ref)
finally:
sender.cleanup()
asyncio.run(_do_ipc_sync())
logger.debug('RolloutAdapter: update_weights done (replica=%d, adapter_only=%s, %.2fs)', self.replica_rank,
base_sync_done,
time.time() - start_time)
def reset_prefix_cache(self) -> None:
if not self.is_primary:
return
ray.get(self.server_handle.reset_prefix_cache.remote())
def get_model_param_names(self) -> List[str]:
if not self.is_primary:
return []
return ray.get(self.server_handle.get_model_param_names.remote()) or []
@@ -0,0 +1,228 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""RayVllmEngine — vLLM engine wrapper for Ray rollout actors.
Reuses ``VllmEngine``'s template / processor initialisation but creates
the ``AsyncLLM`` engine inside a **persistent event-loop thread** so that
vLLM's ZMQ-based ``AsyncMPClient`` (which caches event-loop-bound tasks
and sockets) stays consistent across all async operations.
``VllmServer`` holds a ``RayVllmEngine`` instance and delegates all
engine operations to it.
"""
import asyncio
import os
import threading
import torch
from typing import Any, Dict, List, Optional
from swift.rlhf_trainers.utils import set_expandable_segments
from swift.utils import gc_collect
from swift.utils.logger import get_logger
logger = get_logger()
class RayVllmEngine:
def __init__(
self,
model_id: str,
*,
tensor_parallel_size: int = 1,
gpu_memory_utilization: float = 0.9,
max_model_len: Optional[int] = None,
max_num_seqs: int = 256,
enable_sleep_mode: bool = False,
enable_lora: bool = False,
max_lora_rank: int = 8,
enable_prefix_caching: bool = False,
enforce_eager: bool = False,
trust_remote_code: bool = True,
dtype: str = 'auto',
load_format: str = 'auto',
template_kwargs: Optional[Dict[str, Any]] = None,
**engine_kwargs,
):
os.environ.setdefault('VLLM_USE_V1', '1')
os.environ.setdefault('VLLM_WORKER_MULTIPROC_METHOD', 'spawn')
os.environ.setdefault('VLLM_ENGINE_ITERATION_TIMEOUT_S', '86400')
self.model_id = model_id
self.tp_size = tensor_parallel_size
self.enable_sleep_mode = enable_sleep_mode
self.enable_lora = enable_lora
distributed_executor_backend = 'mp' if tensor_parallel_size > 1 else None
extra_engine_kwargs = dict(engine_kwargs)
extra_engine_kwargs['worker_extension_cls'] = ('swift.pipelines.infer.rollout.WeightSyncWorkerExtension')
from swift.model import get_processor
from swift.template import get_template
processor = get_processor(model_id_or_path=model_id, download_model=True)
template = get_template(processor, **(template_kwargs or {}))
self.template = template
self.tokenizer = template.tokenizer
# --- Start persistent event loop thread ---
self._loop = asyncio.new_event_loop()
self._loop_thread = threading.Thread(
target=self._run_event_loop,
daemon=True,
name='RayVllmEngine-EventLoop',
)
self._loop_dead = threading.Event()
self._loop_exception: Optional[BaseException] = None
self._loop_thread.start()
# --- Create VllmEngine (with engine) inside the event loop ---
self._vllm_engine = self._run_in_loop(
self._create_engine_async(
model_id,
template=template,
tensor_parallel_size=tensor_parallel_size,
gpu_memory_utilization=gpu_memory_utilization,
max_model_len=max_model_len,
max_num_seqs=max_num_seqs,
enable_sleep_mode=enable_sleep_mode,
enable_lora=enable_lora,
max_lora_rank=max_lora_rank,
enable_prefix_caching=enable_prefix_caching,
enforce_eager=enforce_eager,
load_format=load_format,
distributed_executor_backend=distributed_executor_backend,
logprobs_mode='processed_logprobs',
engine_kwargs=extra_engine_kwargs,
))
self.engine = self._vllm_engine.engine
logger.info(
'RayVllmEngine: ready (model=%s, tp=%d, sleep=%s, load_format=%s)',
model_id,
tensor_parallel_size,
enable_sleep_mode,
load_format,
)
@staticmethod
async def _create_engine_async(model_id: str, *, template, **kwargs):
from swift.infer_engine import VllmEngine
engine = VllmEngine(
model_id,
use_async_engine=True,
template=template,
**kwargs,
)
return engine
def _run_event_loop(self):
asyncio.set_event_loop(self._loop)
try:
self._loop.run_forever()
except Exception as exc:
logger.exception('RayVllmEngine event loop died unexpectedly')
self._loop_exception = exc
self._loop_dead.set()
def _run_in_loop(self, coro):
if self._loop_dead.is_set():
cause = self._loop_exception
raise RuntimeError('RayVllmEngine event loop is no longer running. '
f'Original error: {type(cause).__name__}: {cause}'
if cause else 'RayVllmEngine event loop is no longer running') from cause
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result()
# ------------------------------------------------------------------
# Sleep / Wake-up
# ------------------------------------------------------------------
def sleep(self, level: int = 2):
if not self.enable_sleep_mode:
return
self._run_in_loop(self.engine.sleep(level=level))
gc_collect()
set_expandable_segments(True)
logger.debug('RayVllmEngine: sleeping at level %d', level)
def wake_up(self, tags: Optional[List[str]] = None):
if not self.enable_sleep_mode:
return
if tags is None or 'kv_cache' in tags:
gc_collect()
set_expandable_segments(False)
self._run_in_loop(self.engine.wake_up(tags=tags))
logger.debug('RayVllmEngine: woke up with tags %s', tags)
def generate_batch(
self,
infer_requests: List[Any],
request_config: Optional[Any] = None,
) -> List[Any]:
from swift.infer_engine.protocol import RequestConfig
if request_config is None:
request_config = RequestConfig()
async def _gen():
tasks = [self._vllm_engine.infer_async(req, request_config) for req in infer_requests]
return await asyncio.gather(*tasks)
return list(self._run_in_loop(_gen()))
def get_model_param_names(self) -> List[str]:
"""Return parameter names from vLLM model via collective_rpc."""
async def _get():
result = await self.engine.collective_rpc('get_state_keys')
if result and isinstance(result[0], list):
return result[0]
return []
return self._run_in_loop(_get())
def reset_prefix_cache(self):
self._run_in_loop(self.engine.reset_prefix_cache())
def update_weights_ipc(
self,
zmq_handle: str,
use_shm: bool = False,
timeout_s: int = 600,
peft_config: Optional[dict] = None,
base_sync_done: bool = False,
):
"""Trigger the vLLM worker extension's ``update_weights_from_ipc``."""
async def _rpc():
rpc_kwargs = {
'use_shm': use_shm,
'zmq_handle': zmq_handle,
'timeout_s': timeout_s,
}
if peft_config is not None and base_sync_done:
rpc_kwargs['peft_config'] = peft_config
rpc_kwargs['base_sync_done'] = base_sync_done
return await asyncio.wait_for(
self.engine.collective_rpc('update_weights_from_ipc', kwargs=rpc_kwargs),
timeout=timeout_s,
)
self._run_in_loop(_rpc())
def shutdown(self):
if self.engine is not None:
try:
self.engine.shutdown()
except Exception as e: # noqa: BLE001
logger.warning('RayVllmEngine shutdown error: %s', e)
self.engine = None
if self._loop is not None:
try:
self._loop.call_soon_threadsafe(self._loop.stop)
if self._loop_thread is not None:
self._loop_thread.join(timeout=5)
except Exception:
pass
gc_collect()
+284
View File
@@ -0,0 +1,284 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
import ray
from collections import defaultdict
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from swift.rlhf_trainers.args_mixin import VllmArguments
from swift.utils.logger import get_logger
if TYPE_CHECKING:
from ..resource_pool import ResourcePool
logger = get_logger()
class RolloutMode(str, Enum):
HYBRID = 'hybrid'
STANDALONE = 'standalone'
@dataclass
class VllmEngineConfig(VllmArguments):
model: str = ''
sleep_level: int = 0
vllm_enable_lora: bool = False
trust_remote_code: bool = True
dtype: str = 'auto'
load_format: str = 'auto'
# override
vllm_enable_prefix_caching: bool = True
def __post_init__(self):
VllmArguments.__post_init__(self)
@classmethod
def from_rollout_cfg(cls, rollout_cfg: Dict[str, Any], *, sleep_level: int = 0) -> 'VllmEngineConfig':
"""Build from merged rollout config dict."""
cfg = rollout_cfg or {}
known_fields = {f.name for f in cls.__dataclass_fields__.values()}
kwargs: Dict[str, Any] = {}
for key, val in cfg.items():
if key in known_fields and val is not None:
kwargs[key] = val
if sleep_level > 0:
kwargs['sleep_level'] = sleep_level
if cfg.get('tuner_type', 'full') == 'lora':
kwargs.setdefault('vllm_enable_lora', True)
return cls(**kwargs)
def to_launch_kwargs(self, rollout_mode: str, template_kwargs: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
kw: Dict[str, Any] = {
'model_id': self.model,
'dtype': self.dtype,
'rollout_mode': rollout_mode,
'tensor_parallel_size': self.vllm_tensor_parallel_size,
'gpu_memory_utilization': self.vllm_gpu_memory_utilization,
'max_num_seqs': self.vllm_max_num_seqs,
'enforce_eager': self.vllm_enforce_eager,
'trust_remote_code': self.trust_remote_code,
'load_format': self.load_format,
'enable_sleep_mode': (self.sleep_level > 0),
'enable_lora': self.vllm_enable_lora,
'max_lora_rank': self.vllm_max_lora_rank,
'data_parallel_size': self.vllm_data_parallel_size,
'enable_prefix_caching': self.vllm_enable_prefix_caching,
}
if self.vllm_max_model_len is not None:
kw['max_model_len'] = self.vllm_max_model_len
if template_kwargs:
kw['template_kwargs'] = template_kwargs
extra = self.vllm_engine_kwargs
if isinstance(extra, dict):
kw.update(extra)
return kw
class RolloutReplica:
"""One vLLM rollout replica on Ray (single or multi-node)."""
def __init__(
self,
config: VllmEngineConfig,
mode: RolloutMode = RolloutMode.HYBRID,
replica_rank: int = 0,
template_kwargs: Optional[Dict[str, Any]] = None,
actor_name_prefix: str = 'swift_rollout_server',
) -> None:
self.config = config
self.mode = mode
self.replica_rank = replica_rank
self.template_kwargs = template_kwargs
self.actor_name_prefix = actor_name_prefix
self._servers: List[Any] = []
@classmethod
def create_replicas(
cls,
rollout_cfg: Dict[str, Any],
rollout_gpus: int,
pool: 'ResourcePool',
is_hybrid: bool,
sleep_level: int = 0,
template_kwargs: Optional[Dict[str, Any]] = None,
actor_name_prefix: str = 'swift_rollout_server',
) -> List['RolloutReplica']:
"""Factory: create all rollout replicas from pipeline config.
Uses two-phase initialization for parallelism:
Phase 1 — spawn all Ray actors (fast, non-blocking)
Phase 2 — launch_server on all replicas concurrently
"""
config = VllmEngineConfig.from_rollout_cfg(rollout_cfg, sleep_level=sleep_level)
world_size_per_replica = config.vllm_tensor_parallel_size * config.vllm_data_parallel_size
if world_size_per_replica > rollout_gpus:
raise ValueError(f'tp*dp ({world_size_per_replica}) exceeds rollout GPUs ({rollout_gpus})')
if rollout_gpus % world_size_per_replica != 0:
raise ValueError(f'rollout GPUs ({rollout_gpus}) must be divisible by '
f'tp*dp ({world_size_per_replica})')
n_replicas = rollout_gpus // world_size_per_replica
mode = RolloutMode.HYBRID if is_hybrid else RolloutMode.STANDALONE
replicas: List['RolloutReplica'] = []
bundle_infos = pool.bundle_infos
for i in range(n_replicas):
offset = i * world_size_per_replica
replica_infos = bundle_infos[offset:offset + world_size_per_replica]
nodes = {info[0] for info in replica_infos}
gpus_per_node = (world_size_per_replica if len(nodes) == 1 else world_size_per_replica // len(nodes))
replica = cls(
config, mode=mode, replica_rank=i, template_kwargs=template_kwargs, actor_name_prefix=actor_name_prefix)
replica._spawn_actors(replica_infos, gpus_per_node)
replicas.append(replica)
cls._parallel_launch_all(replicas)
logger.info('Rollout: %d replica(s) in %s mode (tp=%d, dp=%d, total_gpus=%d)', n_replicas, mode.value.upper(),
config.vllm_tensor_parallel_size, config.vllm_data_parallel_size, rollout_gpus)
return replicas
@classmethod
def _parallel_launch_all(cls, replicas: List['RolloutReplica']) -> None:
"""Phase 2: launch vLLM engines on all replicas in parallel."""
all_refs = []
for replica in replicas:
refs = replica._launch_engines_async()
all_refs.extend(refs)
if all_refs:
ray.get(all_refs)
for replica in replicas:
logger.info('RolloutReplica[replica=%d, mode=%s]: launched %d server(s) (tp=%d, model=%s)',
replica.replica_rank, replica.mode.value, len(replica._servers),
replica.config.vllm_tensor_parallel_size, replica.config.model)
def _spawn_actors(
self,
worker_infos: List[Tuple[str, str]],
gpus_per_node: int,
) -> None:
"""Phase 1: create Ray actors without starting engines.
``num_gpus=0`` + ``NOSET_CVD`` + explicit visible-device env so
the actor sees exactly the GPUs from pool bundles; NodeAffinity
pins each actor to the correct node.
"""
from ray.runtime_env import RuntimeEnv
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
from transformers.utils import is_torch_npu_available
from .vllm_server import VllmServer
visible_key = 'ASCEND_RT_VISIBLE_DEVICES' if is_torch_npu_available() else 'CUDA_VISIBLE_DEVICES'
node_groups = self._group_by_node(worker_infos)
self._nnodes = len(node_groups)
actor_cls = ray.remote(num_gpus=0, num_cpus=1)(VllmServer)
for node_rank, (node_id, gpu_ids) in enumerate(node_groups):
cvd = ','.join(gpu_ids)
env_vars: Dict[str, str] = {
'VLLM_USE_V1': '1',
'RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES': '1',
'RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES': '1',
'RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES': '1',
'RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES': '1',
'NCCL_CUMEM_ENABLE': '0',
visible_key: cvd,
}
handle = actor_cls.options(
scheduling_strategy=NodeAffinitySchedulingStrategy(node_id=node_id, soft=False),
runtime_env=RuntimeEnv(env_vars=env_vars),
name=f'{self.actor_name_prefix}_{self.replica_rank}_{node_rank}',
max_concurrency=10,
).remote(
node_rank=node_rank,
nnodes=self._nnodes,
gpus_per_node=gpus_per_node,
cuda_visible_devices=cvd,
)
self._servers.append(handle)
def _launch_engines_async(self) -> List[ray.ObjectRef]:
"""Phase 2: issue launch_server calls, return ObjectRefs (non-blocking)."""
launch_kw = self.config.to_launch_kwargs(self.mode.value, template_kwargs=self.template_kwargs)
nnodes = getattr(self, '_nnodes', 1)
if nnodes > 1:
master_address, master_port, dp_rpc_port = ray.get(self._servers[0].get_master_address.remote())
refs = [
server.launch_server.remote(
master_address=master_address, master_port=master_port, dp_rpc_port=dp_rpc_port, **launch_kw)
for server in self._servers
]
else:
refs = [self._servers[0].launch_server.remote(**launch_kw)]
return refs
@staticmethod
def _group_by_node(worker_infos: List[Tuple[str, str]]) -> List[Tuple[str, List[str]]]:
"""Group worker infos by node, preserving order.
Returns list of ``(node_id, [accelerator_id, ...])`` in node
encounter order.
"""
ordered_nodes: List[str] = []
node_gpus: Dict[str, List[str]] = defaultdict(list)
for node_id, acc_id in worker_infos:
if node_id not in node_gpus:
ordered_nodes.append(node_id)
node_gpus[node_id].append(acc_id)
return [(nid, node_gpus[nid]) for nid in ordered_nodes]
@property
def primary(self) -> Any:
"""The node_rank=0 ``VllmServer`` actor handle.
Callers that need ``sleep`` / ``wake_up`` / ``reset_prefix_cache``
/ ``update_weights_ipc`` / ``update_weights_direct`` talk to
this handle directly.
"""
if not self._servers:
raise RuntimeError('RolloutReplica: not launched yet')
return self._servers[0]
@property
def servers(self) -> List[Any]:
"""All server actor handles (one per node)."""
return list(self._servers)
def sleep(self, level: int = 1) -> None:
"""Put all servers in this replica to sleep."""
refs = [server.sleep.remote(level) for server in self._servers]
ray.get(refs)
def wake_up(self, tags=None) -> None:
"""Wake all servers in this replica."""
refs = [server.wake_up.remote(tags=tags) for server in self._servers]
ray.get(refs)
def generate(
self,
infer_requests: List[Any],
request_config: Any = None,
) -> ray.ObjectRef:
"""Submit generation to the primary server, returns an ObjectRef."""
return self.primary.generate.remote(infer_requests, request_config)
def shutdown(self) -> None:
for server in self._servers:
try:
ray.get(server.shutdown.remote(), timeout=30)
except Exception as e: # noqa: BLE001
logger.warning('RolloutReplica shutdown error: %s', e)
try:
ray.kill(server, no_restart=True)
except Exception: # noqa: BLE001
pass
self._servers = []
+251
View File
@@ -0,0 +1,251 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""VllmServer — Ray Actor hosting a vLLM engine via :class:`RayVllmEngine`.
Thin Ray-actor shell that delegates to :class:`RayVllmEngine` for all
engine operations. ``RolloutReplica`` wraps this class with
``ray.remote`` when spawning real actors.
Multi-node topology::
Node 0 (node_rank=0)
├── MegatronWorker actors — training processes
└── VllmServer actor (primary) — runs full server + API
Node 1 (node_rank=1)
├── MegatronWorker actors
└── VllmServer actor (headless) — participates in TP, no API
"""
import asyncio
import os
import socket
import torch
from transformers.utils import is_torch_npu_available
from typing import Any, Dict, List, Optional, Tuple
from swift.utils import gc_collect, get_logger
from ..checkpoint_engine import CheckpointEngineMixin
logger = get_logger()
def _parse_bool_env(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return val.lower() in ('1', 'true')
def _get_free_port(address: str = '') -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((address, 0))
port = sock.getsockname()[1]
sock.close()
return port
class VllmServer(CheckpointEngineMixin):
def __init__(
self,
node_rank: int = 0,
nnodes: int = 1,
gpus_per_node: int = 8,
cuda_visible_devices: str = '',
) -> None:
self._engine = None
self._node_rank = node_rank
self._nnodes = nnodes
self._gpus_per_node = gpus_per_node
if cuda_visible_devices:
key = 'ASCEND_RT_VISIBLE_DEVICES' if is_torch_npu_available() else 'CUDA_VISIBLE_DEVICES'
os.environ[key] = cuda_visible_devices
self._server_address = None
self._master_address: Optional[str] = None
self._master_port: Optional[int] = None
self._dp_rpc_port: Optional[int] = None
if node_rank == 0:
import ray as _ray
self._server_address = _ray.util.get_node_ip_address()
self._master_address = self._server_address
self._master_port = _get_free_port(self._server_address)
self._dp_rpc_port = _get_free_port(self._server_address)
def get_master_address(self) -> Tuple[str, int, int]:
"""Return ``(master_address, master_port, dp_rpc_port)`` from node_rank=0."""
return self._master_address, self._master_port, self._dp_rpc_port
def launch_server(
self,
*,
model_id: str,
rollout_mode: str = 'hybrid',
tensor_parallel_size: int = 1,
gpu_memory_utilization: float = 0.9,
max_model_len: Optional[int] = None,
max_num_seqs: int = 256,
enable_sleep_mode: bool = False,
enable_lora: bool = False,
max_lora_rank: int = 8,
enable_prefix_caching: bool = False,
enforce_eager: bool = False,
trust_remote_code: bool = True,
dtype: str = 'auto',
load_format: str = 'auto',
master_address: Optional[str] = None,
master_port: Optional[int] = None,
dp_rpc_port: Optional[int] = None,
data_parallel_size: int = 1,
template_kwargs: Optional[Dict[str, Any]] = None,
**engine_kwargs,
) -> Dict[str, Any]:
if self._node_rank != 0:
self._master_address = master_address
self._master_port = master_port
self._dp_rpc_port = dp_rpc_port
import ray as _ray
self._server_address = _ray.util.get_node_ip_address()
extra_engine_kwargs = dict(engine_kwargs)
if self._nnodes > 1:
extra_engine_kwargs['nnodes'] = self._nnodes
extra_engine_kwargs['node_rank'] = self._node_rank
extra_engine_kwargs['master_addr'] = self._master_address
extra_engine_kwargs['master_port'] = self._master_port
if data_parallel_size > 1:
assert self._gpus_per_node % tensor_parallel_size == 0, (
'gpus_per_node should be divisible by vllm_tensor_parallel_size')
dp_size_local = self._gpus_per_node // tensor_parallel_size
extra_engine_kwargs['data_parallel_size'] = data_parallel_size
extra_engine_kwargs['data_parallel_size_local'] = dp_size_local
extra_engine_kwargs['data_parallel_start_rank'] = self._node_rank * dp_size_local
extra_engine_kwargs['data_parallel_address'] = self._master_address
extra_engine_kwargs['data_parallel_rpc_port'] = self._dp_rpc_port
from .ray_vllm_engine import RayVllmEngine
self._engine = RayVllmEngine(
model_id,
tensor_parallel_size=tensor_parallel_size,
gpu_memory_utilization=gpu_memory_utilization,
max_model_len=max_model_len,
max_num_seqs=max_num_seqs,
enable_sleep_mode=enable_sleep_mode,
enable_lora=enable_lora,
max_lora_rank=max_lora_rank,
enable_prefix_caching=enable_prefix_caching,
enforce_eager=enforce_eager,
trust_remote_code=trust_remote_code,
dtype=dtype,
load_format=load_format,
template_kwargs=template_kwargs,
**extra_engine_kwargs,
)
logger.info(
'VllmServer[mode=%s, node_rank=%d/%d]: engine initialized (model=%s, tp=%d)',
rollout_mode,
self._node_rank,
self._nnodes,
model_id,
tensor_parallel_size,
)
return {
'model_id': model_id,
'tp_size': tensor_parallel_size,
'node_rank': self._node_rank,
}
def generate(
self,
infer_requests: List[Any],
request_config: Any = None,
) -> List[Any]:
return self._engine.generate_batch(infer_requests, request_config)
def sleep(self, level: int = 2) -> None:
self._engine.sleep(level=level)
def wake_up(self, tags: Optional[List[str]] = None) -> None:
self._engine.wake_up(tags=tags)
def get_model_param_names(self) -> List[str]:
return self._engine.get_model_param_names()
def reset_prefix_cache(self) -> None:
self._engine.reset_prefix_cache()
def update_weights_ipc(
self,
zmq_handle: str,
use_shm: bool = False,
timeout_s: int = 600,
peft_config: Optional[dict] = None,
base_sync_done: bool = False,
) -> None:
self._engine.update_weights_ipc(
zmq_handle, use_shm=use_shm, timeout_s=timeout_s, peft_config=peft_config, base_sync_done=base_sync_done)
def receive_checkpoint_weights(
self,
base_sync_done: bool = False,
peft_config: Optional[dict] = None,
) -> None:
"""Receive weights via NCCL broadcast and stream into vLLM."""
engine = self._get_or_create_checkpoint_engine()
# CUDA defaults to IPC (SHM disabled) to avoid frequent SharedMemory
# warnings and long-run instability. NPU keeps SHM as default.
use_shm = _parse_bool_env('SWIFT_RAY_NCCL_RECV_USE_SHM', default=is_torch_npu_available())
async def _receive_and_load():
from .weight_transfer import BucketedWeightSender
zmq_handle = f'ipc:///tmp/swift-nccl-recv-{os.getpid()}.sock'
bucket_mb = int(os.environ.get('SWIFT_RAY_WEIGHT_BUCKET_MB', '2048'))
sender = BucketedWeightSender(
zmq_handle=zmq_handle,
bucket_size_mb=bucket_mb,
use_shm=use_shm,
)
async def _weight_stream():
async for name, tensor in engine.receive_weights():
if use_shm and tensor.device.type != 'cpu':
tensor = tensor.cpu()
yield name, tensor
try:
async with sender:
rpc_kwargs = {
'use_shm': use_shm,
'zmq_handle': zmq_handle,
'timeout_s': 600,
}
if peft_config is not None and base_sync_done:
rpc_kwargs['peft_config'] = peft_config
rpc_kwargs['base_sync_done'] = base_sync_done
rpc_task = asyncio.ensure_future(
self._engine.engine.collective_rpc(
'update_weights_from_ipc',
kwargs=rpc_kwargs,
))
# Allow collective_rpc to schedule and bind its ZMQ socket
await asyncio.sleep(0)
await sender.handshake()
await sender.send_weights_async(_weight_stream())
await rpc_task
finally:
sender.cleanup()
self._engine._run_in_loop(_receive_and_load())
def shutdown(self) -> None:
self._checkpoint_engine = None
if self._engine is not None:
self._engine.shutdown()
self._engine = None
gc_collect()
@@ -0,0 +1,186 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Weight transfer utilities for training → rollout weight synchronization.
BucketedWeightSender is used by the **training** side (MegatronWorker
via RolloutAdapter) to ship weights to vLLM's WeightSyncWorkerExtension
through ZMQ IPC. It was originally in vllm_server.py but belongs here
because it is a training-side concern, not a rollout engine concern.
"""
from __future__ import annotations
import asyncio
import os
import torch
import uuid
from typing import Any, Dict, Optional
from swift.utils import get_current_device, synchronize
from swift.utils.logger import get_logger
logger = get_logger()
class BucketedWeightSender:
"""Streams model weights to vLLM worker via ZMQ IPC with bucketed transfer."""
def __init__(
self,
zmq_handle: str,
bucket_size_mb: int = 512,
use_shm: bool = False,
timeout_s: int = 600,
external_buffer: Optional[torch.Tensor] = None,
):
self.zmq_handle = zmq_handle
self.bucket_size = int(bucket_size_mb) << 20
self.use_shm = use_shm
self.timeout_ms = int(timeout_s * 1000)
self.socket = None
self.buffer = None
self.shm = None
self._pending_handshake = None
# When provided, reuse this caller-owned persistent GPU buffer instead of
# allocating a fresh one per sync. Reusing the same storage keeps the CUDA IPC
# handle signature stable across sync rounds, so the vLLM worker's IPC-mapping
# cache hits and no new mapping is leaked each step.
self._external_buffer = external_buffer
self._owns_buffer = True
async def __aenter__(self):
self._init_socket_and_buffer()
return self
async def __aexit__(self, exc_type, exc, tb):
self.cleanup()
def _init_socket_and_buffer(self):
"""Bind the REQ socket and allocate the bucket buffer."""
import zmq
ctx = zmq.Context.instance()
self.socket = ctx.socket(zmq.REQ)
self.socket.setsockopt(zmq.RCVTIMEO, self.timeout_ms)
self.socket.setsockopt(zmq.SNDTIMEO, self.timeout_ms)
self.socket.setsockopt(zmq.LINGER, 0)
self.socket.bind(self.zmq_handle)
from torch.multiprocessing.reductions import reduce_tensor
if not self.use_shm:
if self._external_buffer is not None and self._external_buffer.numel() >= self.bucket_size:
# Reuse the persistent buffer -> stable IPC handle -> worker cache hit.
self.buffer = self._external_buffer
self._owns_buffer = False
else:
self.buffer = torch.empty(self.bucket_size, dtype=torch.uint8, device=get_current_device())
self._owns_buffer = True
self._pending_handshake = reduce_tensor(self.buffer)
else:
from multiprocessing import shared_memory
shm_name = f'swift_weights_{uuid.uuid4().hex}'
self.shm = shared_memory.SharedMemory(name=shm_name, create=True, size=self.bucket_size)
self.buffer = torch.frombuffer(self.shm.buf, dtype=torch.uint8)
self._pending_handshake = {'name': shm_name, 'size': self.bucket_size}
async def handshake(self):
if self._pending_handshake is None:
raise RuntimeError('BucketedWeightSender.handshake() called before enter or twice: '
'the handshake payload is consumed on first call, a second handshake '
'within the same ``async with`` block would ship stale metadata.')
loop = asyncio.get_running_loop()
payload = self._pending_handshake
self._pending_handshake = None
def _send_recv():
self.socket.send_pyobj(payload)
return self.socket.recv()
await loop.run_in_executor(None, _send_recv)
async def _stream_weights_inner(self, items_iter, is_async: bool = False):
"""Shared bucketing logic for sync and async weight iterators."""
loop = asyncio.get_running_loop()
offset = 0
bucket_meta: Dict[str, Dict[str, Any]] = {}
n_weights = 0
def _zmq_send_recv(payload):
self.socket.send_pyobj(payload)
return self.socket.recv()
async def _flush(is_last: bool):
nonlocal offset, bucket_meta
if not bucket_meta and not is_last:
return
if self.buffer.device.type != 'cpu':
synchronize()
await loop.run_in_executor(None, _zmq_send_recv, {'bucket_meta': bucket_meta, 'is_last': is_last})
offset = 0
bucket_meta = {}
async def _process(name, weight):
nonlocal offset, n_weights
if self.use_shm and weight.device.type != 'cpu':
weight = weight.cpu()
if not weight.is_contiguous():
weight = weight.contiguous()
nbytes = int(weight.nbytes)
if nbytes > self.bucket_size:
raise RuntimeError(f'Weight {name} ({tuple(weight.shape)}, {weight.dtype}) '
f'is {nbytes} bytes, exceeding bucket size ({self.bucket_size}).')
if offset + nbytes > self.bucket_size:
await _flush(False)
bucket_meta[name] = {
'name': name,
'shape': weight.shape,
'dtype': weight.dtype,
'offset': offset,
}
self.buffer[offset:offset + nbytes].copy_(weight.view(-1).view(torch.uint8), non_blocking=True)
offset += nbytes
n_weights += 1
if is_async:
async for name, weight in items_iter:
await _process(name, weight)
else:
for name, weight in items_iter:
await _process(name, weight)
await _flush(True)
logger.debug('BucketedWeightSender: sent %d weights', n_weights)
async def send_weights(self, weights):
"""Stream weights into buckets. Accepts ``dict`` or iterator."""
items = weights.items() if isinstance(weights, dict) else weights
await self._stream_weights_inner(items, is_async=False)
async def send_weights_async(self, async_weights):
"""Stream weights from an async generator into buckets."""
await self._stream_weights_inner(async_weights, is_async=True)
def cleanup(self):
if self.socket is not None:
self.socket.close()
self.socket = None
if self.zmq_handle.startswith('ipc://'):
ipc_path = self.zmq_handle[len('ipc://'):]
try:
if os.path.exists(ipc_path):
os.remove(ipc_path)
except OSError:
pass
if self._owns_buffer:
del self.buffer
self.buffer = None
if self.shm is not None:
try:
self.shm.close()
self.shm.unlink()
except (FileNotFoundError, BufferError):
pass
self.shm = None
self._pending_handshake = None
from swift.utils import gc_collect, ipc_collect
gc_collect()
ipc_collect()
+441
View File
@@ -0,0 +1,441 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
import ray
import socket
import torch
from enum import Enum
from ray.runtime_env import RuntimeEnv
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
from transformers.utils import is_torch_npu_available
from typing import TYPE_CHECKING, Any, Dict, List, Union
from swift.utils.logger import get_logger
if TYPE_CHECKING:
from .resource_pool import ResourcePool
logger = get_logger()
class DispatchMode(str, Enum):
BROADCAST = 'broadcast'
DP = 'dp'
DP_SPLIT = 'dp_split'
class CollectMode(str, Enum):
ALL = 'all'
DP = 'dp'
DP_FLAT = 'dp_flat'
FIRST = 'first'
_DC_ATTR = '_dispatch_collect_meta'
class DPDispatchedDict(dict):
"""Marker dict for dp-dispatched data, keyed by dp_rank."""
def _is_dp_dispatched(value) -> bool:
return isinstance(value, DPDispatchedDict)
def dispatch_collect(
dispatch: Union[DispatchMode, str] = DispatchMode.BROADCAST,
collect: Union[CollectMode, str] = CollectMode.ALL,
):
"""Decorator declaring default dispatch/collect for a worker method."""
dispatch = DispatchMode(dispatch) if isinstance(dispatch, str) else dispatch
collect = CollectMode(collect) if isinstance(collect, str) else collect
def decorator(fn):
setattr(fn, _DC_ATTR, {'dispatch': dispatch, 'collect': collect})
return fn
return decorator
def _slice_dp(value: Any, dp_size: int) -> Any:
"""Split *value* into ``DPDispatchedDict``{dp_rank: chunk}.
Handles tensors, lists, and dicts (recursed into); scalars and
un-sliceable values are broadcast as-is. Raises on empty or
sub-dp_size inputs rather than silently collapsing to broadcast —
callers must pad upstream.
"""
if isinstance(value, torch.Tensor):
n = value.shape[0]
if n == 0:
raise ValueError(f'_slice_dp got empty tensor (shape={tuple(value.shape)})')
if n < dp_size:
raise ValueError(f'_slice_dp: tensor first dim {n} < dp_size {dp_size}. '
f'Pad the batch upstream or use dispatch="broadcast".')
if n % dp_size != 0:
raise ValueError(f'_slice_dp: tensor first dim {n} not divisible by dp_size {dp_size}. '
f'Pad the batch upstream or use dispatch="broadcast".')
parts = value.chunk(dp_size)
return DPDispatchedDict({i: p for i, p in enumerate(parts)})
if isinstance(value, list):
n = len(value)
if n == 0:
raise ValueError('_slice_dp got empty list')
if n < dp_size:
raise ValueError(f'_slice_dp: list length {n} < dp_size {dp_size}. '
f'Pad the batch upstream or use dispatch="broadcast".')
if n % dp_size != 0:
raise ValueError(f'_slice_dp: list length {n} not divisible by dp_size {dp_size}. '
f'Pad the batch upstream or use dispatch="broadcast".')
chunk_size = n // dp_size
result = DPDispatchedDict()
for i in range(dp_size):
result[i] = value[i * chunk_size:(i + 1) * chunk_size]
return result
if isinstance(value, tuple):
n = len(value)
if n == 0:
raise ValueError('_slice_dp got empty tuple')
if n < dp_size:
raise ValueError(f'_slice_dp: tuple length {n} < dp_size {dp_size}. '
f'Pad the batch upstream or use dispatch="broadcast".')
if n % dp_size != 0:
raise ValueError(f'_slice_dp: tuple length {n} not divisible by dp_size {dp_size}. '
f'Pad the batch upstream or use dispatch="broadcast".')
chunk_size = n // dp_size
result = DPDispatchedDict()
for i in range(dp_size):
result[i] = value[i * chunk_size:(i + 1) * chunk_size]
return result
if isinstance(value, dict):
if _is_dp_dispatched(value):
return value
splits = {k: _slice_dp(v, dp_size) for k, v in value.items()}
any_split = any(_is_dp_dispatched(v) for v in splits.values())
if not any_split:
return value
result = DPDispatchedDict()
for k, v in splits.items():
if _is_dp_dispatched(v):
for dp_r, chunk in v.items():
result.setdefault(dp_r, {})[k] = chunk
else:
for dp_r in range(dp_size):
result.setdefault(dp_r, {})[k] = v
return result
return value
_DISPATCH_FNS: Dict[str, Any] = {}
_COLLECT_FNS: Dict[str, Any] = {}
def _register_builtin_modes():
_DISPATCH_FNS[DispatchMode.BROADCAST] = WorkerGroup._dispatch_broadcast
_DISPATCH_FNS[DispatchMode.DP] = WorkerGroup._dispatch_dp
_DISPATCH_FNS[DispatchMode.DP_SPLIT] = WorkerGroup._dispatch_dp_split
_COLLECT_FNS[CollectMode.ALL] = WorkerGroup._collect_all
_COLLECT_FNS[CollectMode.DP] = WorkerGroup._collect_dp
_COLLECT_FNS[CollectMode.DP_FLAT] = WorkerGroup._collect_dp_flat
_COLLECT_FNS[CollectMode.FIRST] = WorkerGroup._collect_first
class WorkerGroup:
"""A group of Ray actors with dispatch / collect helpers.
After workers are up, call :meth:`build_dispatch_info` to query
``get_parallel_info`` on every actor, cache the dp layout, and
bind decorated methods on the group instance. Subsequent calls
like ``wg.train_step(batch)`` dispatch + collect automatically
using the metadata attached by :func:`dispatch_collect`.
"""
def __init__(self, name: str, worker_handles: List[Any]):
self.name = name
self._workers = list(worker_handles)
self._dp_rank_map: List[int] = []
self._collect_mask: List[bool] = []
self._dp_size: int = 0
@property
def world_size(self) -> int:
return len(self._workers)
@property
def workers(self) -> List[Any]:
return list(self._workers)
@property
def dp_size(self) -> int:
if self._dp_size == 0:
raise RuntimeError(f'WorkerGroup[{self.name}]: build_dispatch_info() has '
'not been called yet (dp_size is unknown).')
return self._dp_size
def __len__(self) -> int:
return len(self._workers)
def build_dispatch_info(self, worker_cls, rpc: str = 'get_parallel_info'):
"""Query workers' parallel info and bind decorated methods."""
infos = ray.get([getattr(w, rpc).remote() for w in self._workers])
if len(infos) != self.world_size:
raise RuntimeError(f'WorkerGroup[{self.name}]: expected {self.world_size} info entries, got {len(infos)}')
self._dp_rank_map = [int(i['dp_rank']) for i in infos]
self._collect_mask = [bool(i['is_collector']) for i in infos]
sizes = {int(i['dp_size']) for i in infos}
if len(sizes) != 1:
raise ValueError(f'WorkerGroup[{self.name}]: inconsistent dp_size across workers: {sizes}')
self._dp_size = sizes.pop()
self._bind_decorated_methods(worker_cls)
logger.info('WorkerGroup[%s]: dp_size=%d, world_size=%d, collectors=%d', self.name, self._dp_size,
self.world_size, sum(self._collect_mask))
def _bind_decorated_methods(self, worker_cls) -> List[str]:
"""Bind methods carrying ``_dispatch_collect_meta`` onto the group.
We only bind methods that explicitly opt in via
:func:`dispatch_collect`; other helpers on the worker class
stay private to the actor so callers can't accidentally talk
to them through the group. Any name already defined on the
group (methods like ``execute`` / ``broadcast``, properties
like ``dp_size``) is skipped with a warning — the caller can
still reach it via ``wg.execute(name, ...)``.
"""
bound = []
for attr_name in dir(worker_cls):
if attr_name.startswith('_'):
continue
method = getattr(worker_cls, attr_name, None)
if method is None or not callable(method):
continue
meta = getattr(method, _DC_ATTR, None)
if meta is None:
continue
if hasattr(type(self), attr_name) or attr_name in self.__dict__:
logger.warning(
'WorkerGroup[%s]: worker method %r collides with an existing '
'attribute on WorkerGroup; call wg.execute(%r, ...) instead.', self.name, attr_name, attr_name)
continue
setattr(self, attr_name, self._make_bound(attr_name, meta['dispatch'], meta['collect']))
bound.append(attr_name)
return bound
def _make_bound(self, method_name: str, default_dispatch: str, default_collect: str):
wg = self
def _bound(*args, dispatch=None, collect=None, **kwargs):
return wg.execute(
method_name,
*args,
dispatch=dispatch if dispatch is not None else default_dispatch,
collect=collect if collect is not None else default_collect,
**kwargs,
)
_bound.__name__ = method_name
_bound.__qualname__ = f'{wg.name}.{method_name}'
_bound.__doc__ = (f'Bound remote method {method_name} '
f'(dispatch={default_dispatch}, collect={default_collect})')
return _bound
def execute(
self,
method_name: str,
*args,
dispatch: Union[DispatchMode, str] = DispatchMode.BROADCAST,
collect: Union[CollectMode, str] = CollectMode.ALL,
**kwargs,
) -> Any:
"""Call a remote method with configurable dispatch/collect."""
dispatch = DispatchMode(dispatch) if isinstance(dispatch, str) else dispatch
collect = CollectMode(collect) if isinstance(collect, str) else collect
per_worker = self._dispatch(dispatch, args, kwargs)
futures = [getattr(w, method_name).remote(*a, **kw) for w, (a, kw) in zip(self._workers, per_worker)]
return self._collect(collect, ray.get(futures))
def broadcast(self, method_name: str, *args, **kwargs) -> List[Any]:
"""Same args to every worker, block, return list."""
return self.execute(method_name, *args, dispatch=DispatchMode.BROADCAST, collect=CollectMode.ALL, **kwargs)
def _dispatch(self, mode: Union[DispatchMode, str], args: tuple, kwargs: dict) -> List[tuple]:
mode = DispatchMode(mode) if isinstance(mode, str) else mode
fn = _DISPATCH_FNS.get(mode)
if fn is None:
raise ValueError(f'Unknown dispatch mode: {mode!r}; registered: {list(_DISPATCH_FNS)}')
return fn(self, args, kwargs)
def _dispatch_broadcast(self, args: tuple, kwargs: dict) -> List[tuple]:
return [(args, kwargs)] * self.world_size
def _dispatch_dp(self, args: tuple, kwargs: dict) -> List[tuple]:
result = []
for dp_r in self._dp_rank_map:
worker_args = tuple(a[dp_r] if _is_dp_dispatched(a) else a for a in args)
worker_kwargs = {k: v[dp_r] if _is_dp_dispatched(v) else v for k, v in kwargs.items()}
result.append((worker_args, worker_kwargs))
return result
def _dispatch_dp_split(self, args: tuple, kwargs: dict) -> List[tuple]:
dp = self.dp_size
split_args = tuple(_slice_dp(a, dp) for a in args)
split_kwargs = {k: _slice_dp(v, dp) for k, v in kwargs.items()}
return self._dispatch_dp(split_args, split_kwargs)
def _collect(self, mode: Union[CollectMode, str], results: List[Any]) -> Any:
mode = CollectMode(mode) if isinstance(mode, str) else mode
fn = _COLLECT_FNS.get(mode)
if fn is None:
raise ValueError(f'Unknown collect mode: {mode!r}; registered: {list(_COLLECT_FNS)}')
return fn(self, results)
def _collect_all(self, results: List[Any]) -> List[Any]:
return results
def _collect_dp(self, results: List[Any]) -> Dict[int, Any]:
collected: Dict[int, Any] = {}
for r, dp_r, is_coll in zip(results, self._dp_rank_map, self._collect_mask):
if is_coll and r is not None:
collected[dp_r] = r
return collected
def _collect_dp_flat(self, results: List[Any]) -> List[Any]:
"""Collect from DP collectors and flatten into a single ordered list.
Equivalent to ``_collect_dp`` followed by sorting by rank and
concatenating lists, which is the most common access pattern on
the driver side.
"""
per_rank = self._collect_dp(results)
flat: List[Any] = []
for rk in sorted(per_rank.keys()):
part = per_rank[rk]
if isinstance(part, list):
flat.extend(part)
elif part is not None:
flat.append(part)
return flat
def _collect_first(self, results: List[Any]) -> Any:
for r, is_coll in zip(results, self._collect_mask):
if is_coll and r is not None:
return r
return None
def shutdown(self, timeout: float = 30.0) -> None:
"""Best-effort shutdown of every worker and kill the Ray actors."""
if not self._workers:
return
pending = []
for w in self._workers:
fn = getattr(w, 'shutdown', None)
if fn is None:
continue
try:
pending.append(fn.remote())
except Exception as e: # noqa: BLE001
logger.warning('WorkerGroup[%s] shutdown dispatch failed: %s', self.name, e)
if pending:
try:
ray.get(pending, timeout=timeout)
except Exception as e: # noqa: BLE001
logger.warning('WorkerGroup[%s] shutdown timed out / raised: %s', self.name, e)
for w in self._workers:
try:
ray.kill(w, no_restart=True)
except Exception as e: # noqa: BLE001
logger.warning('WorkerGroup[%s] ray.kill failed: %s', self.name, e)
self._workers = []
@staticmethod
def _get_device_env_config() -> Dict[str, str]:
"""Return platform-specific environment variable names.
Supports CUDA (GPU) and Ascend (NPU).
"""
if is_torch_npu_available():
return {
'visible_devices_key': 'ASCEND_RT_VISIBLE_DEVICES',
'device_max_connections_key': 'HCCL_DEVICE_MAX_CONNECTIONS',
}
return {
'visible_devices_key': 'CUDA_VISIBLE_DEVICES',
'device_max_connections_key': 'CUDA_DEVICE_MAX_CONNECTIONS',
}
@classmethod
def from_pool(
cls,
name: str,
resource_pool: 'ResourcePool',
worker_cls: str,
) -> 'WorkerGroup':
"""Spawn actors on a :class:`ResourcePool`.
Iterates PGs, discovers master on PG[0], creates workers with
env vars for distributed init.
Swift uses ``num_gpus=0`` + ``NOSET_CVD`` + explicit CVD
(torchrun style for Megatron TP/PP visibility).
"""
master_addr, master_port = cls._discover_master(resource_pool)
dev_cfg = cls._get_device_env_config()
vis = resource_pool.visible_devices
world_size = resource_pool.world_size
workers = []
rank = 0
for pg_idx, pg in enumerate(resource_pool.pgs):
local_ws = resource_pool.process_on_nodes[pg_idx]
node_gpu_ids = [str(vis[rank + j]) for j in range(local_ws)]
node_cvd = ','.join(node_gpu_ids)
for local_rank in range(local_ws):
env_vars = {
'RANK': str(rank),
'LOCAL_RANK': str(local_rank),
'WORLD_SIZE': str(world_size),
'LOCAL_WORLD_SIZE': str(local_ws),
'MASTER_ADDR': master_addr,
'MASTER_PORT': str(master_port),
dev_cfg['device_max_connections_key']: '1',
dev_cfg['visible_devices_key']: node_cvd,
'RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES': '1',
'RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES': '1',
'RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES': '1',
'NCCL_CUMEM_ENABLE': '0',
'RAY_SWIFT_GROUP': f'default,{name}',
}
w = worker_cls.options(
num_gpus=0,
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg, placement_group_bundle_index=local_rank),
runtime_env=RuntimeEnv(env_vars=env_vars),
).remote()
workers.append(w)
rank += 1
return cls(name, workers)
@staticmethod
def _discover_master(resource_pool: 'ResourcePool'):
"""Find master IP + free port on PG[0] bundle[0].
Discovers a free port on the first bundle of the first PG.
"""
@ray.remote(num_gpus=0, num_cpus=0.01)
def _probe():
addr = ray.util.get_node_ip_address()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('', 0))
port = sock.getsockname()[1]
sock.close()
return addr, port
pg = resource_pool.pgs[0]
return ray.get(
_probe.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg, placement_group_bundle_index=0), ).remote())
_register_builtin_modes()