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

297 lines
12 KiB
Python

# 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 ''