# 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 ') return MegatronRayPipeline(config_path).run() if __name__ == '__main__': main()