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

594 lines
30 KiB
Python

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