Files
modelscope--ms-swift/swift/ray/megatron/gkd_trainer.py
T
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

381 lines
19 KiB
Python

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