Video-ORA-9B / code /verl /trainer /ray_trainer.py
lyhisme's picture
Add OraRL training and evaluation code
53c10a4 verified
Raw
History Blame Contribute Delete
99 kB
# Copyright 2024 Bytedance Ltd. and/or its affiliates
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
PPO Trainer with Ray-based single controller.
This trainer supports model-agonistic model initialization with huggingface.
"""
import importlib
import json
import math
import os
import time
import uuid
from collections import defaultdict, deque
from copy import deepcopy
from dataclasses import dataclass, field
from enum import IntEnum, auto
from typing import Any, Optional, Type
import numpy as np
import ray
import torch
from ray.experimental.tqdm_ray import tqdm
from torchdata.stateful_dataloader import StatefulDataLoader
from transformers import PreTrainedTokenizer, ProcessorMixin
from ..protocol import DataProto, pad_dataproto_to_divisor, unpad_dataproto
from ..single_controller.base import Worker
from ..single_controller.ray import RayClassWithInitArgs, RayResourcePool, RayWorkerGroup
from ..single_controller.ray.base import create_colocated_worker_cls
from ..utils import torch_functional as VF
from ..utils.checkpoint import CHECKPOINT_TRACKER, find_latest_ckpt, remove_obsolete_ckpt, thin_out_old_ckpts
from ..utils.logger import Tracker
from ..utils.multimodal_contract import validate_multi_modal_data_contract
from ..utils.py_functional import convert_dict_to_str, timer, unflatten_dict
from ..utils.seqlen_balancing import get_seqlen_balanced_partitions, log_seqlen_unbalance
from ..workers.fsdp_workers import FSDPWorker
from ..workers.reward import AutoRewardManager
from .config import PPOConfig
from .core_algos import (
AdvantageEstimator,
FixedKLController,
KLController,
compute_advantage_return,
compute_kl,
get_kl_controller,
)
from .orarl_post_selection import (
PostSelectionReference,
balance_post_selection_group,
)
from .orarl_selection import select_orarl_rollouts
from .metrics import (
compute_data_metrics,
compute_length_metrics,
compute_throughout_metrics,
compute_timing_metrics,
reduce_metrics,
)
from .orarl_config import (
is_orarl,
validate_algorithm,
)
def _disable_tqdm() -> bool:
return os.getenv("VERL_DISABLE_TQDM", "0") == "1"
def _print_step_summary_enabled() -> bool:
return os.getenv("VERL_PRINT_STEP_SUMMARY", "1") == "1"
def _skip_old_log_probs_enabled() -> bool:
return os.getenv("VERL_SKIP_OLD_LOGPROBS", "0") == "1"
class _NoOpProgress:
def update(self, *args, **kwargs) -> None:
pass
def _fmt_metric(value: Any, precision: int = 4) -> str:
if value is None:
return "n/a"
try:
return f"{float(value):.{precision}f}"
except (TypeError, ValueError):
return str(value)
def _fmt_duration(seconds: float) -> str:
seconds = max(0, int(seconds))
hours, remainder = divmod(seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if hours:
return f"{hours}h{minutes:02d}m{seconds:02d}s"
return f"{minutes}m{seconds:02d}s"
class Role(IntEnum):
"""
To create more roles dynamically, you can subclass Role and add new members
"""
Actor = auto()
Rollout = auto()
ActorRollout = auto()
Critic = auto()
RefPolicy = auto()
RewardModel = auto()
ActorRolloutRef = auto()
@dataclass
class ResourcePoolManager:
"""
Define a resource pool specification. Resource pool will be initialized first.
"""
resource_pool_spec: dict[str, list[int]]
mapping: dict[Role, str]
resource_pool_dict: dict[str, RayResourcePool] = field(default_factory=dict)
def create_resource_pool(self):
"""Create ray resource pools for distributed training."""
for resource_pool_name, process_on_nodes in self.resource_pool_spec.items():
# max_colocate_count means the number of WorkerGroups (i.e. processes) in each RayResourcePool
# For FSDP backend, we recommend using max_colocate_count=1 that merge all WorkerGroups into one.
# For Megatron backend, we recommend using max_colocate_count>1 that can utilize different WorkerGroup for different models
resource_pool = RayResourcePool(
process_on_nodes=process_on_nodes, use_gpu=True, max_colocate_count=1, name_prefix=resource_pool_name
)
self.resource_pool_dict[resource_pool_name] = resource_pool
self._check_resource_available()
def get_resource_pool(self, role: Role) -> RayResourcePool:
"""Get the resource pool of the worker."""
return self.resource_pool_dict[self.mapping[role]]
def get_num_gpus(self) -> int:
"""Get the number of gpus in this cluster."""
return sum([n_gpus for process_on_nodes in self.resource_pool_spec.values() for n_gpus in process_on_nodes])
def _check_resource_available(self):
"""Check if the resource pool can be satisfied in this ray cluster."""
gpus_available = ray.available_resources().get("GPU", 0)
gpus_required = self.get_num_gpus()
if gpus_available < gpus_required:
raise ValueError(f"Total available GPUs {gpus_available} is less than total desired GPUs {gpus_required}.")
def _load_dotted_callable(spec: str):
"""Resolve a 'pkg.mod:attr' string into the callable it points to."""
if ":" not in spec:
raise ValueError(
f"Expected a 'module.path:function' style spec, got {spec!r}. "
"Example: 'orarl.rewards:build_oracle_response_from_ground_truth'."
)
module_path, attr = spec.split(":", 1)
module = importlib.import_module(module_path)
try:
fn = getattr(module, attr)
except AttributeError as err:
raise AttributeError(f"Module {module_path!r} has no attribute {attr!r}.") from err
if not callable(fn):
raise TypeError(f"{spec!r} resolved to a non-callable object: {type(fn).__name__}")
return fn
def apply_kl_penalty(data: DataProto, kl_ctrl: KLController, kl_penalty="kl"):
"""Apply KL penalty to the token-level rewards."""
token_level_scores = data.batch["token_level_scores"]
batch_size = data.batch.batch_size[0]
response_mask = data.batch["response_mask"]
# compute kl between ref_policy and current policy
kld = compute_kl(data.batch["old_log_probs"], data.batch["ref_log_probs"], kl_penalty=kl_penalty)
kld = kld * response_mask # (batch_size, response_length)
data.batch["token_level_rewards"] = token_level_scores - kl_ctrl.kl_coef * kld
current_kl = torch.mean(VF.masked_mean(kld, mask=response_mask, dim=-1)).item()
metrics = {"actor/kl_penalty": current_kl, "actor/kl_coef": kl_ctrl.kl_coef}
# According to https://github.com/huggingface/trl/blob/v0.11.0/trl/trainer/ppo_trainer.py#L880
kl_ctrl.update(current_kl=current_kl, n_steps=batch_size)
return data, metrics
def compute_advantage(
data: DataProto,
adv_estimator: AdvantageEstimator,
gamma: float = 1.0,
lam: float = 1.0,
scale_rewards: bool = True,
directional_gain: bool = False,
directional_gain_gamma: float = 0.25,
directional_gain_positive_only: bool = False,
directional_gain_recenter: bool = False,
):
"""Compute advantage estimates for policy optimization.
``scale_rewards=False`` selects raw-centered GRPO advantages ``r-mean``
without per-group standard-deviation whitening.
When ``is_oracle_row`` exists in ``data.non_tensor_batch`` the GRPO-family
estimator keeps the oracle out of the baseline: the per-group mean is built
over on-policy rollouts only, so a perfect-reward oracle cannot suppress a
genuine on-policy winner. The standard deviation is also on-policy only, so
the oracle cannot inflate it and shrink on-policy gradients. Has no effect
for non-GRPO estimators or for runs without oracle rows.
"""
adv_inputs = {
"token_level_rewards": data.batch["token_level_rewards"],
"response_mask": data.batch["response_mask"],
"index": data.non_tensor_batch["uid"],
"gamma": gamma,
"lam": lam,
"scale_rewards": bool(scale_rewards),
}
if "values" in data.batch:
adv_inputs["values"] = data.batch["values"]
if "reward_baselines" in data.batch:
adv_inputs["reward_baselines"] = data.batch["reward_baselines"]
telemetry: dict[str, Any] = {}
is_oracle_row = data.non_tensor_batch.get("is_oracle_row", None)
if is_oracle_row is not None:
adv_inputs["is_oracle_row"] = is_oracle_row
adv_inputs["oracle_excluded_baseline"] = True
if directional_gain:
adv_inputs["directional_gain"] = True
adv_inputs["directional_gain_gamma"] = float(directional_gain_gamma)
adv_inputs["directional_gain_positive_only"] = bool(
directional_gain_positive_only
)
adv_inputs["directional_gain_recenter"] = bool(directional_gain_recenter)
adv_inputs["_telemetry_out"] = telemetry
advantages, returns = compute_advantage_return(adv_estimator, **adv_inputs)
data.batch["advantages"] = advantages
data.batch["returns"] = returns
if telemetry:
# Stash so the training loop can merge into per-step metrics.
data.meta_info = dict(data.meta_info)
data.meta_info["_advantage_telemetry"] = telemetry
return data
@torch.no_grad()
def compute_oracle_advantage_diagnostics(
data: DataProto,
eps: float = 1e-6,
) -> dict[str, float]:
"""Per-step diagnostics for the oracle-excluded advantage baseline.
Returns ``orarl/*`` metrics quantifying how much the oracle row would
contaminate a vanilla GRPO baseline and how much the oracle-excluded
statistics recover. No-op without oracle rows (returns {}).
Metrics emitted (per-batch averages over groups unless noted):
Per-group statistics (reward space):
- orarl/mu_op_mean mean of μ_op (on-policy mean) across groups
- orarl/mu_g_mean mean of μ_g (full-group mean)
- orarl/sigma_op_mean mean of σ_op
- orarl/sigma_g_mean mean of σ_g
- orarl/sigma_ratio_mean mean of σ_g / σ_op (>1 ↔ oracle far outside
the on-policy reward distribution)
- orarl/oracle_mean_contamination
|μ_g - μ_op| / σ_op — how far the oracle would
shift the baseline; high values flag the groups
where excluding it matters most
Recovery counts, computed against the current ``data.batch['advantages']``:
- orarl/sign_flip_count on-policy rows whose advantage sign differs
from what vanilla GRPO (μ_g, σ_g) would give —
the rows the oracle-excluded baseline rescued
- orarl/sign_flip_rate sign_flip_count / total_op_rows
- orarl/groups_with_no_op_pos_baseline
groups where vanilla GRPO would leave every
on-policy advantage ≤ 0, so a sign-balanced
selector would have no positive policy row
Magnitudes (post-transform advantages):
- orarl/oracle_adv_mag_mean / oracle_adv_mag_max |A_oracle|
- orarl/op_adv_pos_mean mean |A_op| over on-policy positives
- orarl/op_adv_neg_mean mean |A_op| over on-policy negatives
Use these to confirm (a) |A_oracle| stays moderate (e.g. < 5) so PPO
clipping does not eat the oracle signal, and (b) sign_flip_rate is
non-trivial, which means the oracle-excluded baseline is doing real work.
"""
is_oracle_row_np = data.non_tensor_batch.get("is_oracle_row", None)
if is_oracle_row_np is None:
return {}
response_mask = data.batch["response_mask"]
token_level_rewards = data.batch["token_level_rewards"]
advantages = data.batch["advantages"]
scores = (token_level_rewards * response_mask).sum(dim=-1) # (bsz,)
mask_sum = response_mask.sum(dim=-1).clamp(min=1)
adv_signed = (advantages * response_mask).sum(dim=-1) / mask_sum # (bsz,)
bsz = scores.shape[0]
is_oracle = torch.tensor(
[bool(is_oracle_row_np[i]) for i in range(bsz)],
dtype=torch.bool, device=scores.device,
)
uids = data.non_tensor_batch["uid"]
uid_to_idx: dict[Any, list[int]] = defaultdict(list)
for i, uid in enumerate(uids):
uid_to_idx[uid].append(i)
mu_op_acc, mu_g_acc = [], []
sigma_op_acc, sigma_g_acc = [], []
# Robust ratio aggregation: collect ratios ONLY from groups with non-trivial
# sigma_op (above SIGMA_FLOOR), so a degenerate group with σ_op≈0 doesn't
# blow up the per-batch average. Also report the unweighted mean(σ_g) /
# mean(σ_op) as a stable companion.
SIGMA_FLOOR = 1e-3
contam_acc, sigma_ratio_acc = [], []
degenerate_op_groups = 0
sign_flip = 0
total_op = 0
no_op_pos_groups = 0
n_groups = 0
oracle_mags, op_pos_mags, op_neg_mags = [], [], []
for indices in uid_to_idx.values():
if len(indices) < 2:
continue
idx_t = torch.tensor(indices, dtype=torch.long, device=scores.device)
g_scores = scores.index_select(0, idx_t)
g_adv = adv_signed.index_select(0, idx_t)
g_oracle_mask = is_oracle.index_select(0, idx_t)
op_mask = ~g_oracle_mask
if op_mask.sum() < 1:
continue
op_scores = g_scores[op_mask]
oracle_scores = g_scores[g_oracle_mask]
mu_op = op_scores.mean()
mu_g = g_scores.mean()
sigma_op = (op_scores.std(unbiased=False)
if op_scores.numel() > 1 else torch.zeros_like(mu_op))
sigma_g = g_scores.std(unbiased=False)
mu_op_acc.append(mu_op.item())
mu_g_acc.append(mu_g.item())
sigma_op_v = float(sigma_op.item())
sigma_g_v = float(sigma_g.item())
sigma_op_acc.append(sigma_op_v)
sigma_g_acc.append(sigma_g_v)
if sigma_op_v < SIGMA_FLOOR:
# σ_op too tiny → ratio/contamination divergent; skip from
# per-group ratio averages but still count it.
degenerate_op_groups += 1
else:
contam_acc.append(abs(float(mu_g.item()) - float(mu_op.item())) / sigma_op_v)
sigma_ratio_acc.append(sigma_g_v / sigma_op_v)
# Hypothetical baseline advantages (always full-group μ, σ) for the
# on-policy rows — used to count sign-flips vs the actual variant.
baseline_op_adv = (op_scores - mu_g) / (sigma_g + eps)
actual_op_adv = g_adv[op_mask]
sign_flips_mask = (
((actual_op_adv > 0) & (baseline_op_adv < 0))
| ((actual_op_adv < 0) & (baseline_op_adv > 0))
)
sign_flip += int(sign_flips_mask.sum().item())
total_op += int(op_mask.sum().item())
if (baseline_op_adv <= 0).all():
no_op_pos_groups += 1
op_abs = actual_op_adv.abs()
op_pos_mags.extend(op_abs[actual_op_adv > 0].tolist())
op_neg_mags.extend(op_abs[actual_op_adv < 0].tolist())
if g_oracle_mask.any():
oracle_mags.extend(g_adv[g_oracle_mask].abs().tolist())
n_groups += 1
if n_groups == 0:
return {}
mean_sigma_op = sum(sigma_op_acc) / n_groups if sigma_op_acc else 0.0
mean_sigma_g = sum(sigma_g_acc) / n_groups if sigma_g_acc else 0.0
mean_mu_op = sum(mu_op_acc) / n_groups if mu_op_acc else 0.0
mean_mu_g = sum(mu_g_acc) / n_groups if mu_g_acc else 0.0
out: dict[str, float] = {
"orarl/mu_op_mean": mean_mu_op,
"orarl/mu_g_mean": mean_mu_g,
"orarl/sigma_op_mean": mean_sigma_op,
"orarl/sigma_g_mean": mean_sigma_g,
# Two ratio summaries — different aggregation:
# - *_per_group: average of per-group ratios (more sensitive but
# requires non-degenerate σ_op; we already filter < SIGMA_FLOOR).
# - *_of_means: ratio of per-batch averages (always finite, more
# stable for swanlab plots; use this if per_group is noisy).
"orarl/sigma_ratio_per_group": (
sum(sigma_ratio_acc) / max(len(sigma_ratio_acc), 1)
if sigma_ratio_acc else 0.0
),
"orarl/sigma_ratio_of_means": (
mean_sigma_g / mean_sigma_op if mean_sigma_op > 0 else 0.0
),
"orarl/oracle_mean_contamination_per_group": (
sum(contam_acc) / max(len(contam_acc), 1) if contam_acc else 0.0
),
"orarl/oracle_mean_contamination_of_means": (
abs(mean_mu_g - mean_mu_op) / mean_sigma_op if mean_sigma_op > 0 else 0.0
),
"orarl/degenerate_op_groups": float(degenerate_op_groups),
"orarl/sign_flip_count": float(sign_flip),
"orarl/sign_flip_rate": sign_flip / max(total_op, 1),
"orarl/groups_with_no_op_pos_baseline": float(no_op_pos_groups),
"orarl/n_groups": float(n_groups),
}
if oracle_mags:
out["orarl/oracle_adv_mag_mean"] = sum(oracle_mags) / len(oracle_mags)
out["orarl/oracle_adv_mag_max"] = max(oracle_mags)
if op_pos_mags:
out["orarl/op_adv_pos_mean"] = sum(op_pos_mags) / len(op_pos_mags)
out["orarl/op_adv_pos_count"] = float(len(op_pos_mags))
if op_neg_mags:
out["orarl/op_adv_neg_mean"] = sum(op_neg_mags) / len(op_neg_mags)
out["orarl/op_adv_neg_count"] = float(len(op_neg_mags))
return out
@torch.no_grad()
def apply_detached_oracle_advantage(
data: DataProto,
scale: float = 1.0,
beta: float = 1.0,
directional_gain_gamma: float = 0.0,
directional_gain_max: float = 4.0,
match_best_ratio: float = 0.0,
match_best_min: float = 0.5,
match_best_max: float = 2.0,
eps: float = 1e-6,
) -> dict[str, float]:
"""Overwrite each oracle row's advantage with a detached positive anchor.
A_oracle_raw = scale * w_g * gain_g
w_g = clip((r_oracle - mean_op) / (r_oracle + eps), 0, 1) ** beta
gain_g = clip((σ_g / σ_op) ** directional_gain_gamma, ≤ directional_gain_max)
= 1 (γ=0)
Optional best-on-policy matching (``match_best_ratio > 0``) keeps the
detached oracle row from dominating the contrastive policy rows:
cap_g = clip(match_best_ratio * max(A_op positive), match_best_min, match_best_max)
A_oracle = min(A_oracle_raw, cap_g)
The cap comes from the already-normalized, post-gain on-policy advantages in
the same group. When a group has no positive on-policy advantage,
``match_best_min`` acts as a small bootstrap anchor rather than suppressing
the oracle to zero.
``r_oracle`` is the oracle row's reward and ``mean_op`` the group's on-policy
mean reward (both summed ``token_level_scores``). Setting rather than scaling
the advantage means the oracle never reaches the policy rows through the
whitening denominator: combined with the on-policy-only σ, a high-reward
oracle can no longer inflate σ and shrink on-policy gradients.
The anchor stays positive with a magnitude that tracks how far on-policy is
from the oracle along two axes:
* reward gap ``w_g`` — →0 on solved groups, →1 on hard groups;
* variance gap ``gain_g`` — the same oracle-gap factor ``(σ_g/σ_op)^γ``
that scales the on-policy toward-oracle push. A positive
``directional_gain_gamma`` puts the anchor on that same pre-cap scale.
σ_g is the full-group std, σ_op the on-policy std; gain_g falls back to
1 on a degenerate σ_op group.
Mutates ``data.batch['advantages']`` in place, oracle rows only. No-op
without ``is_oracle_row``. Emits per-step and per-task telemetry.
"""
SIGMA_OP_FALLBACK_THRESHOLD: float = 1e-3
is_oracle_row_np = data.non_tensor_batch.get("is_oracle_row", None)
if is_oracle_row_np is None:
return {}
# Prefer the pure reward (pre-KL) for gating; fall back to the KL-adjusted one.
if "token_level_scores" in data.batch:
reward_tok = data.batch["token_level_scores"]
else:
reward_tok = data.batch["token_level_rewards"]
advantages = data.batch["advantages"]
response_mask = data.batch["response_mask"]
scores = (reward_tok * response_mask).sum(dim=-1) # (bsz,)
bsz = scores.shape[0]
device = scores.device
is_oracle = torch.tensor(
[bool(is_oracle_row_np[i]) for i in range(bsz)], dtype=torch.bool, device=device
)
uids = data.non_tensor_batch["uid"]
problem_types = data.non_tensor_batch.get("problem_type", None)
uid_to_idx: dict[Any, list[int]] = defaultdict(list)
for i, uid in enumerate(uids):
uid_to_idx[uid].append(i)
w_acc: list[float] = []
a_oracle_acc: list[float] = []
gain_acc: list[float] = []
raw_a_oracle_acc: list[float] = []
best_op_adv_acc: list[float] = []
cap_acc: list[float] = []
cap_applied = 0
per_task_w: dict[str, list[float]] = defaultdict(list)
n_oracle_groups = 0
use_gain = directional_gain_gamma > 0.0
use_best_match = match_best_ratio > 0.0
for indices in uid_to_idx.values():
idx_t = torch.tensor(indices, dtype=torch.long, device=device)
g_oracle = is_oracle.index_select(0, idx_t)
if not bool(g_oracle.any()):
continue
op_mask = ~g_oracle
if int(op_mask.sum().item()) < 1:
continue
g_scores = scores.index_select(0, idx_t)
r_oracle = float(g_scores[g_oracle].max().item())
op_scores = g_scores[op_mask]
mean_op = float(op_scores.mean().item())
w = (r_oracle - mean_op) / (r_oracle + eps)
w = max(0.0, min(1.0, w))
if beta != 1.0:
w = float(w ** beta)
# Directional gain (σ_g/σ_op)^γ — the SAME factor that scales the
# on-policy toward-oracle push, so the anchor rides on the same scale.
gain = 1.0
if use_gain and int(op_mask.sum().item()) >= 2:
sigma_op = float(op_scores.std().item())
sigma_g = float(g_scores.std().item()) if g_scores.numel() >= 2 else 0.0
if sigma_op >= SIGMA_OP_FALLBACK_THRESHOLD:
gain = max(
1.0,
min(
(sigma_g / (sigma_op + eps)) ** directional_gain_gamma,
directional_gain_max,
),
)
raw_a_oracle = float(scale) * w * gain
a_oracle = raw_a_oracle
if use_best_match:
group_advantages = advantages.index_select(0, idx_t)
group_response_mask = response_mask.index_select(0, idx_t)
op_advantages = group_advantages[op_mask]
op_response_mask = group_response_mask[op_mask].to(group_advantages.dtype)
op_lengths = op_response_mask.sum(dim=-1).clamp_min(1.0)
op_sequence_advantages = (op_advantages * op_response_mask).sum(dim=-1) / op_lengths
positive_advantages = op_sequence_advantages[op_sequence_advantages > 0]
best_op_adv = (
float(positive_advantages.max().item())
if positive_advantages.numel() > 0
else 0.0
)
target_cap = (
float(match_best_ratio) * best_op_adv
if best_op_adv > 0.0
else float(match_best_min)
)
target_cap = max(float(match_best_min), min(float(match_best_max), target_cap))
a_oracle = min(raw_a_oracle, target_cap)
best_op_adv_acc.append(best_op_adv)
cap_acc.append(target_cap)
cap_applied += int(a_oracle < raw_a_oracle)
for j in indices:
if bool(is_oracle[j]):
advantages[j] = a_oracle * response_mask[j].to(advantages.dtype)
w_acc.append(w)
a_oracle_acc.append(a_oracle)
raw_a_oracle_acc.append(raw_a_oracle)
gain_acc.append(gain)
n_oracle_groups += 1
if problem_types is not None:
per_task_w[str(problem_types[indices[0]])].append(w)
if n_oracle_groups == 0:
return {}
data.batch["advantages"] = advantages
out: dict[str, float] = {
"orarl/detached_w_mean": float(sum(w_acc) / len(w_acc)),
"orarl/detached_a_oracle_mean": float(sum(a_oracle_acc) / len(a_oracle_acc)),
"orarl/detached_a_oracle_raw_mean": float(sum(raw_a_oracle_acc) / len(raw_a_oracle_acc)),
"orarl/detached_gain_mean": float(sum(gain_acc) / len(gain_acc)),
"orarl/detached_groups": float(n_oracle_groups),
}
if use_best_match:
out.update({
"orarl/detached_best_op_adv_mean": float(
sum(best_op_adv_acc) / len(best_op_adv_acc)
),
"orarl/detached_match_cap_mean": float(sum(cap_acc) / len(cap_acc)),
"orarl/detached_match_cap_applied_frac": float(cap_applied) / float(n_oracle_groups),
})
for task, ws in per_task_w.items():
if ws:
out[f"orarl/detached_w_mean_by_task/{task}"] = float(sum(ws) / len(ws))
return out
# Per-row scalar metric keys to mirror from reward_metrics into
# `batch.non_tensor_batch` so downstream consumers can read them at the rollout
# level. ``iou_raw`` is the un-shaped IoU, the only reward component strictly
# aligned with the evaluation metric.
_ROW_LEVEL_REWARD_KEYS_TO_PROPAGATE: tuple[str, ...] = ("iou_raw",)
def _propagate_per_row_reward_metrics(
batch: DataProto,
reward_metrics: dict[str, list[float]],
keys: tuple[str, ...] = _ROW_LEVEL_REWARD_KEYS_TO_PROPAGATE,
) -> None:
"""Write selected per-row reward components into ``batch.non_tensor_batch``.
``reward_metrics`` is a dict-of-lists in row order (length == bsz). For
keys present in ``reward_metrics`` we materialize an ndarray on the
batch's non_tensor_batch so subsequent batch slicing and selection
naturally keep the per-row alignment. Idempotent (skips keys already
present).
"""
bsz = len(batch)
for key in keys:
if key in batch.non_tensor_batch:
continue
values = reward_metrics.get(key)
if values is None:
continue
if len(values) != bsz:
continue
arr = np.asarray(values, dtype=np.float32)
# Replace NaN/Inf with 0 so downstream torch ops never explode.
np.nan_to_num(arr, copy=False, nan=0.0, posinf=0.0, neginf=0.0)
batch.non_tensor_batch[key] = arr
def _sequence_advantage_scores(
advantages: torch.Tensor,
response_mask: torch.Tensor,
) -> torch.Tensor:
"""Collapse token advantages without making selection response-length biased.
GRPO broadcasts one scalar advantage over every valid response token. A
masked sum therefore multiplies that scalar by the response length and can
rank a weaker, longer completion above a stronger, shorter completion.
Masked mean recovers the original scalar and remains well-defined for
estimators whose token advantages are not uniform.
"""
if advantages.shape != response_mask.shape:
raise ValueError(
"advantages and response_mask must have identical shapes, got "
f"{tuple(advantages.shape)} and {tuple(response_mask.shape)}."
)
return VF.masked_mean(advantages, response_mask, dim=-1)
@torch.no_grad()
def build_orarl_post_selection_references(
data: DataProto,
) -> dict[Any, PostSelectionReference]:
"""Snapshot all policy rows before OraRL selection drops any of them."""
required_batch_keys = ("advantages", "response_mask")
missing = [key for key in required_batch_keys if key not in data.batch]
if missing or "uid" not in data.non_tensor_batch:
raise ValueError(
"post-selection balance requires advantages, response_mask, and uid; "
f"missing={missing + ([] if 'uid' in data.non_tensor_batch else ['uid'])}."
)
if "token_level_scores" in data.batch:
reward_tokens = data.batch["token_level_scores"]
elif "token_level_rewards" in data.batch:
reward_tokens = data.batch["token_level_rewards"]
else:
raise ValueError(
"post-selection balance requires token_level_scores or token_level_rewards."
)
advantages = data.batch["advantages"]
response_mask = data.batch["response_mask"]
sequence_advantages = _sequence_advantage_scores(
advantages,
response_mask,
).detach().float()
sequence_rewards = (
reward_tokens * response_mask.to(reward_tokens.dtype)
).sum(dim=-1).detach().float()
uids = np.asarray(data.non_tensor_batch["uid"], dtype=object)
is_oracle = np.asarray(
data.non_tensor_batch.get(
"is_oracle_row",
np.zeros(sequence_advantages.shape[0], dtype=bool),
),
dtype=bool,
)
if len(uids) != sequence_advantages.shape[0] or len(is_oracle) != len(uids):
raise ValueError("uid and oracle flags must align with the batch rows.")
uid_to_rows: dict[Any, list[int]] = defaultdict(list)
for row, uid in enumerate(uids):
uid_to_rows[uid].append(row)
references: dict[Any, PostSelectionReference] = {}
device = sequence_advantages.device
for uid, rows in uid_to_rows.items():
op_rows = [row for row in rows if not bool(is_oracle[row])]
if not op_rows:
continue
op_idx = torch.tensor(op_rows, dtype=torch.long, device=device)
op_advantages = sequence_advantages.index_select(0, op_idx)
op_rewards = sequence_rewards.index_select(0, op_idx)
on_policy_rms = float(torch.sqrt(torch.mean(op_advantages.square())).item())
sigma_op = (
float(op_rewards.std().item())
if op_rewards.numel() >= 2
else 0.0
)
references[uid] = PostSelectionReference(
on_policy_rms=on_policy_rms,
sigma_op=sigma_op,
on_policy_rows=len(op_rows),
)
return references
@torch.no_grad()
def apply_orarl_post_selection_advantage_balance(
data: DataProto,
references: dict[Any, PostSelectionReference],
*,
recenter: bool,
rms_match: bool,
rms_min_scale: float,
) -> dict[str, float]:
"""Correct each OraRL-selected group before the actor backward pass."""
if not recenter and not rms_match:
return {}
is_oracle_np = data.non_tensor_batch.get("is_oracle_row")
if is_oracle_np is None:
return {
"orarl/post_selection_enabled": 1.0,
"orarl/post_selection_recenter": float(recenter),
"orarl/post_selection_rms_match": float(rms_match),
"orarl/post_selection_groups": 0.0,
"orarl/post_selection_groups_skipped": 0.0,
}
advantages = data.batch["advantages"]
response_mask = data.batch["response_mask"]
sequence_advantages = _sequence_advantage_scores(
advantages,
response_mask,
).detach().float()
uids = np.asarray(data.non_tensor_batch["uid"], dtype=object)
is_oracle = np.asarray(is_oracle_np, dtype=bool)
if len(uids) != sequence_advantages.shape[0] or len(is_oracle) != len(uids):
raise ValueError("uid and oracle flags must align with selected batch rows.")
uid_to_rows: dict[Any, list[int]] = defaultdict(list)
for row, uid in enumerate(uids):
uid_to_rows[uid].append(row)
metric_values: dict[str, list[float]] = defaultdict(list)
processed_groups = 0
skipped_groups = 0
device = sequence_advantages.device
for uid, rows in uid_to_rows.items():
reference = references.get(uid)
group_oracle = np.asarray(
[bool(is_oracle[row]) for row in rows],
dtype=bool,
)
oracle_rows = int(group_oracle.sum())
if oracle_rows > 1:
raise ValueError(
f"post-selection group {uid!r} contains {oracle_rows} oracle "
"rows; expected at most one."
)
no_actual_selection = (
reference is not None
and reference.on_policy_rows > 0
and len(rows) >= reference.on_policy_rows + 1
)
if reference is None or oracle_rows == 0 or no_actual_selection:
skipped_groups += 1
continue
idx_t = torch.tensor(rows, dtype=torch.long, device=device)
active_advantages = sequence_advantages.index_select(0, idx_t)
active_is_oracle = torch.tensor(
group_oracle,
dtype=torch.bool,
device=device,
)
balanced, group_metrics = balance_post_selection_group(
active_advantages,
active_is_oracle,
reference=reference,
recenter=recenter,
rms_match=rms_match,
rms_min_scale=rms_min_scale,
)
balanced_tokens = (
balanced.to(dtype=advantages.dtype).unsqueeze(-1)
* response_mask.index_select(0, idx_t).to(advantages.dtype)
)
advantages.index_copy_(
0,
idx_t.to(device=advantages.device),
balanced_tokens.to(device=advantages.device),
)
for key, value in group_metrics.items():
metric_values[key].append(value)
processed_groups += 1
data.batch["advantages"] = advantages
metrics: dict[str, float] = {
"orarl/post_selection_enabled": 1.0,
"orarl/post_selection_recenter": float(recenter),
"orarl/post_selection_rms_match": float(rms_match),
"orarl/post_selection_groups": float(processed_groups),
"orarl/post_selection_groups_skipped": float(skipped_groups),
}
for key, values in metric_values.items():
if values:
metrics[f"orarl/post_selection_{key}"] = float(
sum(values) / len(values)
)
return metrics
def _rollout_token_diversity_metrics(
responses: torch.Tensor,
response_mask: torch.Tensor,
n_rollouts: int,
) -> dict[str, float]:
"""Measure whether an ``n``-sample rollout actually explores distinct text."""
if responses.shape != response_mask.shape:
raise ValueError("responses and response_mask must have identical shapes.")
if n_rollouts <= 1 or responses.shape[0] % n_rollouts != 0:
return {}
group_unique_fractions: list[float] = []
all_identical = 0
pairwise_agreements: list[float] = []
length_stds: list[float] = []
for start in range(0, responses.shape[0], n_rollouts):
group_ids = responses[start : start + n_rollouts]
group_mask = response_mask[start : start + n_rollouts].bool()
sequences = [
tuple(group_ids[i][group_mask[i]].detach().cpu().tolist())
for i in range(n_rollouts)
]
unique_count = len(set(sequences))
group_unique_fractions.append(unique_count / n_rollouts)
all_identical += int(unique_count == 1)
lengths = group_mask.sum(dim=-1).to(dtype=torch.float32)
length_stds.append(float(lengths.std(unbiased=False).item()))
for i in range(n_rollouts):
for j in range(i + 1, n_rollouts):
common = group_mask[i] & group_mask[j]
if common.any():
agreement = (group_ids[i][common] == group_ids[j][common]).float().mean()
pairwise_agreements.append(float(agreement.item()))
num_groups = len(group_unique_fractions)
return {
"rollout/exact_unique_fraction": float(sum(group_unique_fractions) / num_groups),
"rollout/all_identical_group_fraction": float(all_identical / num_groups),
"rollout/pairwise_token_agreement_mean": float(
sum(pairwise_agreements) / max(1, len(pairwise_agreements))
),
"rollout/response_length_std_mean": float(sum(length_stds) / num_groups),
}
def _on_policy_reward_diversity_metrics(data: DataProto) -> dict[str, float]:
"""Report useful reward spread per prompt, excluding injected oracle rows."""
if "token_level_scores" not in data.batch or "uid" not in data.non_tensor_batch:
return {}
scores = data.batch["token_level_scores"].sum(dim=-1).detach().cpu().float().tolist()
uids = np.asarray(data.non_tensor_batch["uid"], dtype=object)
is_oracle = np.asarray(
data.non_tensor_batch.get("is_oracle_row", np.zeros(len(scores), dtype=bool)),
dtype=bool,
)
grouped: dict[Any, list[float]] = defaultdict(list)
for uid, score, oracle_row in zip(uids, scores, is_oracle):
if not oracle_row:
grouped[uid].append(float(score))
stds: list[float] = []
unique_fractions: list[float] = []
for group_scores in grouped.values():
if len(group_scores) <= 1:
continue
score_tensor = torch.tensor(group_scores, dtype=torch.float32)
stds.append(float(score_tensor.std(unbiased=False).item()))
unique_fractions.append(
len({round(score, 7) for score in group_scores}) / len(group_scores)
)
if not stds:
return {}
return {
"rollout/reward_group_std_mean": float(sum(stds) / len(stds)),
"rollout/zero_reward_std_group_fraction": float(
sum(std <= 1e-8 for std in stds) / len(stds)
),
"rollout/reward_unique_fraction": float(
sum(unique_fractions) / len(unique_fractions)
),
}
class RayPPOTrainer:
"""
Note that this trainer runs on the driver process on a single CPU/GPU node.
"""
def __init__(
self,
config: PPOConfig,
tokenizer: PreTrainedTokenizer,
processor: Optional[ProcessorMixin],
train_dataloader: StatefulDataLoader,
val_dataloader: StatefulDataLoader,
role_worker_mapping: dict[Role, Type[Worker]],
resource_pool_manager: ResourcePoolManager,
ray_worker_group_cls: Type[RayWorkerGroup] = RayWorkerGroup,
reward_fn: Optional[AutoRewardManager] = None,
val_reward_fn: Optional[AutoRewardManager] = None,
):
self.tokenizer = tokenizer
self.processor = processor
self.train_dataloader = train_dataloader
self.val_dataloader = val_dataloader
self.config = config
self.reward_fn = reward_fn
self.val_reward_fn = val_reward_fn
self._is_orarl = is_orarl(config)
validate_algorithm(config)
self.val_reward_score = 0.0
self.best_val_reward_score = -1.0
self.best_global_step = None
# Best-train ckpt tracking (separate from rolling save_limit window).
# See TrainerConfig.keep_best_train_ckpt for rationale. The buffer is a
# FIFO of recent metric values; we save when the smoothed value strictly
# improves and we have already passed best_train_min_step.
self._best_train_window: deque[float] = deque(
maxlen=max(1, int(config.trainer.best_train_smooth_window))
)
self._best_train_score: float = float("-inf")
self._best_train_step: int = 0
self.hybrid_engine = config.worker.hybrid_engine
self.role_worker_mapping = role_worker_mapping
self.resource_pool_manager = resource_pool_manager
self.use_reward_model = Role.RewardModel in role_worker_mapping
self.ray_worker_group_cls = ray_worker_group_cls
# define KL control
if config.algorithm.disable_kl:
self.use_reference_policy = False
self.kl_ctrl = FixedKLController(init_kl_coef=0.0)
print("KL is disabled, no KL metrics will be logged. Please set `kl_coef=0` to log KL metrics.")
else:
self.use_reference_policy = True
self.kl_ctrl = get_kl_controller(config.algorithm)
if config.algorithm.adv_estimator == AdvantageEstimator.GAE:
self.use_critic = True
else:
self.use_critic = False
if config.algorithm.adv_estimator not in list(AdvantageEstimator):
raise NotImplementedError(f"Unknown advantage estimator: {config.algorithm.adv_estimator}.")
# generate_sequences uses DP_COMPUTE_PROTO, which splits the prompt
# batch across every actor-rollout worker before rollout.n fan-out.
# Validate the dataloader's actual batch size here instead of failing
# after all Ray workers and rollout engines have been initialized.
rollout_world_size = config.trainer.n_gpus_per_node * config.trainer.nnodes
rollout_dispatch_batch_size = (
config.data.mini_rollout_batch_size
if config.data.mini_rollout_batch_size is not None
else config.data.rollout_batch_size
)
if rollout_dispatch_batch_size % rollout_world_size != 0:
batch_size_key = (
"data.mini_rollout_batch_size"
if config.data.mini_rollout_batch_size is not None
else "data.rollout_batch_size"
)
raise ValueError(
f"{batch_size_key}={rollout_dispatch_batch_size} must be divisible by "
f"the actor-rollout world size {rollout_world_size} "
f"(trainer.nnodes={config.trainer.nnodes} * "
f"trainer.n_gpus_per_node={config.trainer.n_gpus_per_node}). "
"The prompt batch is sharded before worker.rollout.n is applied."
)
if config.data.rollout_batch_size % config.worker.actor.global_batch_size != 0:
raise ValueError("Rollout batch size must be divisible by actor global batch size.")
if (
config.data.rollout_batch_size * config.worker.rollout.n
) % config.worker.actor.micro_batch_size_per_device_for_experience != 0:
raise ValueError(
"Rollout batch size * rollout.n must be divisible by actor micro batch size for experience."
)
if self.use_critic:
if config.data.rollout_batch_size % config.worker.critic.global_batch_size != 0:
raise ValueError("Rollout batch size must be divisible by critic global batch size.")
if (
config.data.rollout_batch_size * config.worker.rollout.n
) % config.worker.critic.micro_batch_size_per_device_for_experience != 0:
raise ValueError(
"Rollout batch size * rollout.n must be divisible by critic micro batch size for experience."
)
if (
config.algorithm.adv_estimator in (AdvantageEstimator.GRPO, AdvantageEstimator.RLOO)
and config.worker.rollout.n == 1
):
raise ValueError("GRPO and RLOO algorithm need `config.worker.rollout.n > 1`.")
_scale_rewards = bool(getattr(config.algorithm, "scale_rewards", True))
_adv_est = getattr(
config.algorithm.adv_estimator,
"value",
config.algorithm.adv_estimator,
)
if _adv_est == "grpo":
if _scale_rewards:
print("[GRPO] Scaled advantages ON: A=(r-mean)/(std+eps).")
else:
print("[GRPO] Raw-centered advantages ON: A=r-mean (no std whitening).")
self._oracle_builder = None
if self._is_orarl:
if getattr(config.worker.rollout, "calculate_log_probs", False):
raise ValueError(
"OraRL oracle rows require worker.rollout.calculate_log_probs=false "
"so old log probabilities are recomputed for their actual tokens."
)
self._oracle_builder = _load_dotted_callable(
config.algorithm.oracle_builder
)
print(
"[OraRL] enabled | append annotation-as-rollout | "
f"directional_gain_gamma={config.algorithm.directional_gain_gamma} | "
"detached oracle | strict sign-balanced selection | "
"post-selection moment correction"
)
if config.trainer.max_steps is not None:
self.training_steps = config.trainer.max_steps
elif config.data.mini_rollout_batch_size is not None:
num_examples = len(train_dataloader) * config.data.mini_rollout_batch_size
self.training_steps = num_examples // config.data.rollout_batch_size * config.trainer.total_epochs
else:
self.training_steps = len(train_dataloader) * config.trainer.total_epochs
rollout_rows = config.data.rollout_batch_size * config.worker.rollout.n
actor_global_batch_size = config.worker.actor.global_batch_size
self.skip_old_log_probs = (
_skip_old_log_probs_enabled()
and config.worker.actor.ppo_epochs == 1
and actor_global_batch_size in {config.data.rollout_batch_size, rollout_rows}
)
if _skip_old_log_probs_enabled() and not self.skip_old_log_probs:
print(
"[trainer] VERL_SKIP_OLD_LOGPROBS=1 requested but disabled for this run: "
"requires actor.ppo_epochs=1 and one actor global mini-batch per rollout step."
)
elif self.skip_old_log_probs:
print(
"[trainer] VERL_SKIP_OLD_LOGPROBS=1: skipping FSDP old_log_probs recompute; "
"actor update will use its first forward log_probs.detach() as old_log_probs. "
"Reference-policy KL, if enabled, is still computed separately."
)
config.worker.actor.optim.training_steps = self.training_steps
config.worker.critic.optim.training_steps = self.training_steps
print(f"Total training steps: {self.training_steps}")
def init_workers(self) -> None:
"""Init resource pool and worker group"""
self.resource_pool_manager.create_resource_pool()
self.resource_pool_to_cls = {pool: {} for pool in self.resource_pool_manager.resource_pool_dict.values()}
# create actor, rollout and ref
if self.hybrid_engine:
resource_pool = self.resource_pool_manager.get_resource_pool(Role.ActorRolloutRef)
actor_rollout_ref_cls = RayClassWithInitArgs(
cls=self.role_worker_mapping[Role.ActorRolloutRef], config=self.config.worker, role="actor_rollout_ref"
)
self.resource_pool_to_cls[resource_pool]["actor_rollout_ref"] = actor_rollout_ref_cls
else:
raise NotImplementedError
# create critic
if self.use_critic:
resource_pool = self.resource_pool_manager.get_resource_pool(Role.Critic)
critic_cls = RayClassWithInitArgs(
cls=self.role_worker_mapping[Role.Critic], config=self.config.worker, role="critic"
)
self.resource_pool_to_cls[resource_pool]["critic"] = critic_cls
# create a reward model if reward_fn is None
if self.use_reward_model:
# we create a RM here
resource_pool = self.resource_pool_manager.get_resource_pool(Role.RewardModel)
rm_cls = RayClassWithInitArgs(
cls=self.role_worker_mapping[Role.RewardModel], config=self.config.worker, role="reward"
)
self.resource_pool_to_cls[resource_pool]["rm"] = rm_cls
# initialize WorkerGroup
# NOTE: if you want to use a different resource pool for each role, which can support different parallel size,
# you should not use `create_colocated_worker_cls`. Instead, directly pass different resource pool to different worker groups.
# See https://github.com/volcengine/verl/blob/master/examples/ray/tutorial.ipynb for more information.
all_wg: dict[str, FSDPWorker] = {}
self.wg_dicts = []
for resource_pool, class_dict in self.resource_pool_to_cls.items():
worker_dict_cls = create_colocated_worker_cls(class_dict=class_dict)
wg_dict = self.ray_worker_group_cls(resource_pool=resource_pool, ray_cls_with_init=worker_dict_cls)
spawn_wg = wg_dict.spawn(prefix_set=class_dict.keys())
all_wg.update(spawn_wg)
# keep the referece of WorkerDict to support ray >= 2.31. Ref: https://github.com/ray-project/ray/pull/45699
self.wg_dicts.append(wg_dict)
if self.use_critic:
self.critic_wg = all_wg["critic"]
self.critic_wg.init_model()
if self.use_reward_model:
self.rm_wg = all_wg["rm"]
self.rm_wg.init_model()
# we should create rollout at the end so that vllm can have a better estimation of kv cache memory
self.actor_rollout_ref_wg = all_wg["actor_rollout_ref"]
self.actor_rollout_ref_wg.init_model()
def _save_checkpoint(self) -> None:
# path: {save_checkpoint_path}/global_step_{global_step}/{actor,critic}
# Only advance best_global_step on steps that actually ran validation.
# The condition must match the one guarding _validate() in the main loop.
# Otherwise val_reward_score stays at its initial 0.0 while
# best_val_reward_score starts at -1.0, so 0.0 > -1.0 always holds and
# the first save is pinned as "best" forever. remove_obsolete_ckpt would
# then protect the oldest checkpoint and evict recent ones instead — with
# save_limit=2 the disk keeps the oldest plus the newest rather than the
# two most recent.
validation_ran_this_step = (
self.val_reward_fn is not None
and self.config.trainer.val_freq > 0
and self.global_step % self.config.trainer.val_freq == 0
)
if validation_ran_this_step and self.val_reward_score > self.best_val_reward_score:
self.best_val_reward_score = self.val_reward_score
self.best_global_step = self.global_step
remove_obsolete_ckpt(
self.config.trainer.save_checkpoint_path,
self.global_step,
self.best_global_step,
self.config.trainer.save_limit,
)
folder_path = os.path.join(self.config.trainer.save_checkpoint_path, f"global_step_{self.global_step}")
actor_path = os.path.join(folder_path, "actor")
self.actor_rollout_ref_wg.save_checkpoint(actor_path, save_model_only=self.config.trainer.save_model_only)
if self.use_critic:
critic_path = os.path.join(folder_path, "critic")
self.critic_wg.save_checkpoint(critic_path, save_model_only=self.config.trainer.save_model_only)
dataloader_path = os.path.join(folder_path, "dataloader.pt")
dataloader_state_dict = self.train_dataloader.state_dict()
torch.save(dataloader_state_dict, dataloader_path)
checkpointer_tracker_info = {
"best_global_step": self.best_global_step,
"best_val_reward_score": round(self.best_val_reward_score, 4),
"last_global_step": self.global_step,
"last_actor_path": os.path.abspath(actor_path),
}
checkpointer_tracker_path = os.path.join(self.config.trainer.save_checkpoint_path, CHECKPOINT_TRACKER)
with open(checkpointer_tracker_path, "w") as f:
json.dump(checkpointer_tracker_info, f, ensure_ascii=False, indent=2)
# Keep optimizer/extra_state/dataloader only for the newest checkpoint and
# thin older ones down to weights (huggingface/ + model_*.pt).
if (
self.config.trainer.keep_optim_only_latest
and not self.config.trainer.save_model_only
):
thin_out_old_ckpts(
self.config.trainer.save_checkpoint_path,
keep_full_step=self.global_step,
)
def _maybe_save_best_train_checkpoint(self, metrics: dict) -> None:
"""Save model-only ckpt when smoothed train metric hits a new best.
Independent of save_freq / save_limit:
- Lives under {save_checkpoint_path}/best_train/global_step_{N}/actor/
- Always model-only (no optim/dataloader): for downstream eval only.
- Old best is removed atomically when a new best is found, so the
best_train/ subdir holds at most ONE checkpoint.
Decision rule:
smoothed = mean of last `best_train_smooth_window` values of
metrics[best_train_metric_key]
if step >= best_train_min_step AND smoothed > self._best_train_score:
save and update tracker
"""
cfg = self.config.trainer
if not getattr(cfg, "keep_best_train_ckpt", False):
return
metric_val = metrics.get(cfg.best_train_metric_key)
if metric_val is None:
return
try:
metric_val = float(metric_val)
except (TypeError, ValueError):
return
self._best_train_window.append(metric_val)
if self.global_step < int(cfg.best_train_min_step):
return
if len(self._best_train_window) < self._best_train_window.maxlen:
# Wait until the smoothing buffer is full, otherwise early steps
# have an artificially small window and bias toward early peaks.
return
smoothed = sum(self._best_train_window) / len(self._best_train_window)
if smoothed <= self._best_train_score:
return
# New best: save model-only into a side directory.
prev_best_step = self._best_train_step
prev_best_score = self._best_train_score
self._best_train_score = smoothed
self._best_train_step = self.global_step
best_root = os.path.join(cfg.save_checkpoint_path, "best_train")
os.makedirs(best_root, exist_ok=True)
new_dir = os.path.join(best_root, f"global_step_{self.global_step}")
new_actor = os.path.join(new_dir, "actor")
# Save current step (model only). Force save_model_only=True regardless
# of the global flag so this side ckpt stays small.
self.actor_rollout_ref_wg.save_checkpoint(new_actor, save_model_only=True)
if self.use_critic:
new_critic = os.path.join(new_dir, "critic")
self.critic_wg.save_checkpoint(new_critic, save_model_only=True)
# Tracker file lets downstream eval scripts auto-discover the best step.
tracker = {
"best_train_step": self._best_train_step,
"best_train_metric_key": cfg.best_train_metric_key,
"best_train_smoothed_value": round(self._best_train_score, 6),
"best_train_smooth_window": self._best_train_window.maxlen,
"best_train_actor_path": os.path.abspath(new_actor),
"previous_best_step": prev_best_step,
"previous_best_smoothed_value": (
round(prev_best_score, 6) if prev_best_score != float("-inf") else None
),
}
with open(os.path.join(best_root, "best_train_tracker.json"), "w") as f:
json.dump(tracker, f, ensure_ascii=False, indent=2)
# Evict the previous best ckpt directory (we only keep one in best_train/).
if prev_best_step and prev_best_step != self.global_step:
old_dir = os.path.join(best_root, f"global_step_{prev_best_step}")
if os.path.isdir(old_dir):
import shutil
try:
shutil.rmtree(old_dir)
except OSError as exc:
print(f"[best-train ckpt] WARN: failed to remove {old_dir}: {exc}")
prev_score_str = (
f"{prev_best_score:.4f}" if prev_best_score != float("-inf") else "n/a"
)
print(
f"[best-train ckpt] step={self.global_step} "
f"smoothed_{cfg.best_train_metric_key}={self._best_train_score:.4f} "
f"(prev best step={prev_best_step or 0}, value={prev_score_str}) "
f"→ {new_actor}",
flush=True,
)
def _load_checkpoint(self) -> None:
if self.config.trainer.load_checkpoint_path is not None:
load_checkpoint_path = self.config.trainer.load_checkpoint_path
elif self.config.trainer.find_last_checkpoint:
load_checkpoint_path, tracker_info = find_latest_ckpt(self.config.trainer.save_checkpoint_path)
if tracker_info is not None:
self.best_val_reward_score = tracker_info.get("best_val_reward_score", 0.0)
self.best_global_step = tracker_info.get("best_global_step", 0)
else:
load_checkpoint_path = None
if load_checkpoint_path is None:
return
if "global_step_" not in load_checkpoint_path.strip(os.path.sep).split(os.path.sep)[-1]:
raise ValueError("`load_checkpoint_path` should end with `global_step_*`.")
print(f"Load from checkpoint: {load_checkpoint_path}.")
self.global_step = int(load_checkpoint_path.strip(os.path.sep).split("global_step_")[-1])
actor_path = os.path.join(load_checkpoint_path, "actor")
self.actor_rollout_ref_wg.load_checkpoint(actor_path)
if self.use_critic:
critic_path = os.path.join(load_checkpoint_path, "critic")
self.critic_wg.load_checkpoint(critic_path)
dataloader_path = os.path.join(load_checkpoint_path, "dataloader.pt")
if os.path.exists(dataloader_path):
dataloader_state_dict = torch.load(dataloader_path, weights_only=False)
self.train_dataloader.load_state_dict(dataloader_state_dict)
else:
print(f"No dataloader state found at {dataloader_path}, will start from scratch.")
def _assert_multimodal_contract(self, data: DataProto, stage: str) -> None:
if "multi_modal_data" not in data.non_tensor_batch:
return
problem_ids = data.non_tensor_batch.get("problem_id", None)
uids = data.non_tensor_batch.get("uid", None)
for idx, multi_modal_data in enumerate(data.non_tensor_batch["multi_modal_data"]):
try:
validate_multi_modal_data_contract(multi_modal_data)
except Exception as exc:
problem_id = None if problem_ids is None else problem_ids[idx]
uid = None if uids is None else uids[idx]
raise ValueError(
f"{stage}: invalid multi_modal_data at index={idx}, uid={uid}, problem_id={problem_id}: {exc}"
) from exc
def _maybe_log_val_generations(
self,
inputs: list[str],
outputs: list[str],
labels: list[str],
scores: list[float],
problem_ids: list[Any],
) -> None:
"""Log a table of validation samples"""
if self.config.trainer.val_generations_to_log <= 0:
return
# Create tuples of (input, output, label, score, problem_id) and sort by input text
samples = list(zip(inputs, outputs, labels, scores, problem_ids))
samples.sort(key=lambda x: x[0]) # Sort by input text
# Use fixed random seed for deterministic shuffling
rng = np.random.RandomState(42)
rng.shuffle(samples)
samples = samples[: self.config.trainer.val_generations_to_log]
self.logger.log_generation(samples, self.global_step)
def _validate(self) -> dict[str, Any]:
reward_tensor_lst = []
# Lists to collect samples for the table
sample_inputs, sample_outputs, sample_labels, sample_scores, sample_problem_ids = [], [], [], [], []
reward_metrics_lst = defaultdict(list)
length_metrics_lst = defaultdict(list)
print("Start validation...")
self.actor_rollout_ref_wg.prepare_rollout_engine()
for batch_dict in self.val_dataloader:
test_batch = DataProto.from_single_dict(batch_dict)
test_gen_batch = test_batch.pop(
batch_keys=["input_ids", "attention_mask", "position_ids"],
non_tensor_batch_keys=["raw_prompt_ids", "multi_modal_data"],
)
repeat_times = self.config.worker.rollout.val_override_config.get("n", 1)
test_gen_batch.meta_info = self.config.worker.rollout.val_override_config
test_gen_batch.meta_info["image_min_pixels"] = self.config.data.image_min_pixels
test_gen_batch.meta_info["image_max_pixels"] = self.config.data.image_max_pixels
test_gen_batch.meta_info["video_min_pixels"] = self.config.data.val_video_min_pixels
test_gen_batch.meta_info["video_max_pixels"] = self.config.data.val_video_max_pixels
test_gen_batch.meta_info["video_total_pixels"] = self.config.data.val_video_total_pixels
test_gen_batch.meta_info["video_fps"] = self.config.data.val_video_fps
test_gen_batch.meta_info["video_max_frames"] = self.config.data.val_video_max_frames
self._assert_multimodal_contract(test_gen_batch, stage="validate")
test_gen_batch, pad_size = pad_dataproto_to_divisor(test_gen_batch, self.actor_rollout_ref_wg.world_size)
test_output_gen_batch = self.actor_rollout_ref_wg.generate_sequences(test_gen_batch)
test_output_gen_batch = unpad_dataproto(test_output_gen_batch, pad_size=pad_size * repeat_times)
# repeat to align with repeated responses in rollout
test_batch = test_batch.repeat(repeat_times=repeat_times, interleave=True)
test_batch = test_batch.union(test_output_gen_batch)
# evaluate using reward_function
# Only pass fields needed by reward, excluding large multi_modal_data to reduce serialization
val_reward_batch = test_batch.select(
batch_keys=["responses", "response_mask"],
non_tensor_batch_keys=[k for k in test_batch.non_tensor_batch if k != "multi_modal_data"],
)
reward_tensor, reward_metrics = ray.get(self.val_reward_fn.compute_reward.remote(val_reward_batch))
# store generations
input_ids = test_batch.batch["prompts"]
input_texts = [self.tokenizer.decode(ids, skip_special_tokens=True) for ids in input_ids]
output_ids = test_batch.batch["responses"]
output_texts = [self.tokenizer.decode(ids, skip_special_tokens=True) for ids in output_ids]
scores = reward_tensor.sum(-1).cpu().tolist()
sample_inputs.extend(input_texts)
sample_outputs.extend(output_texts)
sample_labels.extend(test_batch.non_tensor_batch["ground_truth"].tolist())
sample_scores.extend(scores)
if "problem_id" in test_batch.non_tensor_batch:
sample_problem_ids.extend(test_batch.non_tensor_batch["problem_id"].tolist())
else:
sample_problem_ids.extend([None] * len(scores))
reward_tensor_lst.append(reward_tensor)
for key, value in reward_metrics.items():
reward_metrics_lst[key].extend(value)
for key, value in compute_length_metrics(test_batch).items():
length_metrics_lst[key].append(value)
self.actor_rollout_ref_wg.release_rollout_engine()
self._maybe_log_val_generations(
sample_inputs, sample_outputs, sample_labels, sample_scores, sample_problem_ids
)
if self.config.trainer.val_generations_to_log > 0 and sample_inputs:
print("Sample problem_id:", sample_problem_ids[0])
print("Sample prompt (with template):", sample_inputs[0])
print("Sample response:", sample_outputs[0])
print("Sample ground_truth:", sample_labels[0])
print("Sample reward:", sample_scores[0])
self.val_reward_score = torch.cat(reward_tensor_lst, dim=0).sum(-1).mean().item()
val_reward_metrics = {f"val/{key}_reward": value for key, value in reduce_metrics(reward_metrics_lst).items()}
val_length_metrics = {f"val_{key}": value for key, value in reduce_metrics(length_metrics_lst).items()}
print("Finish validation.")
return {"val/reward_score": self.val_reward_score, **val_reward_metrics, **val_length_metrics}
def _balance_batch(self, batch: DataProto, metrics: dict[str, Any], logging_prefix: str = "global_seqlen") -> None:
"""Reorder the data on single controller such that each dp rank gets similar total tokens"""
attention_mask = batch.batch["attention_mask"]
batch_size = attention_mask.shape[0]
global_seqlen_lst = batch.batch["attention_mask"].view(batch_size, -1).sum(-1).tolist() # (train_batch_size,)
world_size = self.actor_rollout_ref_wg.world_size
global_partition_lst = get_seqlen_balanced_partitions(
global_seqlen_lst, k_partitions=world_size, equal_size=True
)
# reorder based on index. The data will be automatically equally partitioned by dispatch function
global_idx = torch.tensor([j for partition in global_partition_lst for j in partition])
batch.reorder(global_idx)
global_balance_stats = log_seqlen_unbalance(
seqlen_list=global_seqlen_lst, partitions=global_partition_lst, prefix=logging_prefix
)
metrics.update(global_balance_stats)
def _inject_oracle_rollout_in_gen_output(
self,
gen_batch_output: DataProto,
ground_truths: Optional[np.ndarray],
extras: Optional[np.ndarray],
n: int,
replace_index: Optional[int] = None,
) -> int:
"""Build and mark annotation-derived oracle rollout rows."""
if not self.config.algorithm.oracle_injection or self._oracle_builder is None:
return 0
if ground_truths is None or len(ground_truths) == 0:
return 0
# `replace_index` override lets callers pin the target slot regardless of
# the config default (used by the append path, which calls this with n=1
# and index 0 so every representative row becomes its group's oracle).
if replace_index is None:
replace_index = self.config.algorithm.oracle_replace_index
if replace_index == -1:
replace_index = n - 1
responses = gen_batch_output.batch["responses"].clone()
response_length = responses.size(1)
device = responses.device
pad_token_id = self.tokenizer.pad_token_id
eos_token_id = gen_batch_output.meta_info.get("eos_token_id", self.tokenizer.eos_token_id)
# `get_response_mask` supports list[int]; single-eos tail append needs a scalar.
eos_single = eos_token_id[0] if isinstance(eos_token_id, (list, tuple)) else eos_token_id
is_oracle_row = np.zeros(responses.size(0), dtype=bool)
replaced = 0
for i, oracle_raw in enumerate(ground_truths):
if oracle_raw is None:
continue
extra = extras[i] if extras is not None and i < len(extras) else {}
if extra is None:
extra = {}
try:
oracle_text = self._oracle_builder(str(oracle_raw), extra)
except Exception as err:
print(f"[OraRL] oracle_builder failed at idx={i} annotation={oracle_raw!r}: {err}")
continue
if not isinstance(oracle_text, str) or len(oracle_text.strip()) == 0:
continue
oracle_token_ids = self.tokenizer.encode(oracle_text, add_special_tokens=False)
# Ensure the oracle response terminates with EOS so response_mask covers it.
if eos_single is not None and (len(oracle_token_ids) == 0 or oracle_token_ids[-1] != eos_single):
oracle_token_ids = oracle_token_ids + [eos_single]
if len(oracle_token_ids) > response_length:
# Keep the tail so EOS survives; pad head is unusual, just truncate front.
oracle_token_ids = oracle_token_ids[-response_length:]
oracle_tokens = VF.pad_2d_list_to_length(
[oracle_token_ids],
pad_token_id,
max_length=response_length,
).to(device)
replace_idx = i * n + replace_index
responses[replace_idx] = oracle_tokens.squeeze(0)
is_oracle_row[replace_idx] = True
replaced += 1
if replaced == 0:
return 0
prompts = gen_batch_output.batch["prompts"]
prompt_length = prompts.size(-1)
attention_mask = gen_batch_output.batch["attention_mask"]
position_ids = gen_batch_output.batch["position_ids"]
prompt_attention_mask = attention_mask[..., :prompt_length]
prompt_position_ids = position_ids[..., :prompt_length]
response_mask = VF.get_response_mask(responses, eos_token_id=eos_token_id, dtype=prompt_attention_mask.dtype)
sequence_ids = torch.cat([prompts, responses], dim=-1)
batch_size = responses.size(0)
delta_position_id = torch.arange(1, response_length + 1, device=position_ids.device)
delta_position_id = delta_position_id.view(1, -1).expand(batch_size, -1)
if prompt_position_ids.ndim == 3: # qwen2vl mrope: (batch_size, 4, seq_length)
delta_position_id = delta_position_id.view(batch_size, 1, -1).expand(
batch_size, prompt_position_ids.size(1), -1
)
response_position_ids = prompt_position_ids[..., -1:] + delta_position_id
full_position_ids = torch.cat([prompt_position_ids, response_position_ids], dim=-1)
full_attention_mask = torch.cat((prompt_attention_mask, response_mask), dim=-1)
gen_batch_output.batch["responses"] = responses
gen_batch_output.batch["input_ids"] = sequence_ids
gen_batch_output.batch["response_mask"] = response_mask
gen_batch_output.batch["position_ids"] = full_position_ids
gen_batch_output.batch["attention_mask"] = full_attention_mask
gen_batch_output.non_tensor_batch["is_oracle_row"] = is_oracle_row
# When the rollout collected per-sequence mean logprobs, the values at
# oracle-row positions still describe the ORIGINAL on-policy sample
# (different tokens), so they are meaningless for ranking. Override them
# with +inf so the oracle row always survives the probability pre-filter;
# `selection_keep_oracle` force-keeps it in the reward-based stage.
seq_lp = gen_batch_output.non_tensor_batch.get("seq_logprob_for_filter", None)
if seq_lp is not None:
seq_lp = np.asarray(seq_lp, dtype=np.float32, copy=True)
seq_lp[is_oracle_row] = np.float32(np.inf)
gen_batch_output.non_tensor_batch["seq_logprob_for_filter"] = seq_lp
return replaced
def _build_oracle_append_rows(
self,
gen_batch_output: DataProto,
ground_truths: np.ndarray,
extras: Optional[np.ndarray],
n: int,
) -> Optional[DataProto]:
"""Build one extra oracle rollout per prompt for append mode.
Uses the first on-policy rollout of every group as a template (it already
carries the correct prompt tokens + multi_modal_data), then overwrites its
response with the annotation-derived one by reusing
``_inject_oracle_rollout_in_gen_output`` with ``n=1`` and
``replace_index=0``, so every representative row becomes its group's
oracle.
The returned DataProto has ``len(ground_truths)`` rows (one per prompt),
all flagged ``is_oracle_row=True``, with the SAME batch/non-tensor keys as
``gen_batch_output`` — so the caller can union it with the per-prompt
metadata and ``DataProto.concat`` it onto the on-policy block, growing each
group from ``n`` to ``n+1``. ``index_select`` copies the tensors, so
overwriting the template never mutates the on-policy rows.
Returns ``None`` if no oracle row could be built (empty batch or the
builder returned nothing for every prompt), so the caller falls back to a
plain GRPO group for this step.
"""
num_prompts = len(ground_truths)
if num_prompts == 0:
return None
rep_idx = np.array([i * n for i in range(num_prompts)], dtype=np.int64)
oracle_rows = gen_batch_output.index_select(rep_idx)
replaced = self._inject_oracle_rollout_in_gen_output(
gen_batch_output=oracle_rows,
ground_truths=ground_truths,
extras=extras,
n=1,
replace_index=0,
)
if self._is_orarl and replaced != num_prompts:
raise RuntimeError(
"OraRL requires one valid oracle rollout per prompt, but the "
f"configured builder produced {replaced} of {num_prompts}."
)
if replaced == 0:
return None
return oracle_rows
def _make_batch_data(self, metrics: dict[str, Any]) -> DataProto:
print("Start generating batch...")
try:
batch_dict = next(self.data_iterator)
except StopIteration:
self.data_iterator = iter(self.train_dataloader)
batch_dict = next(self.data_iterator)
meta_info = {
"image_min_pixels": self.config.data.image_min_pixels,
"image_max_pixels": self.config.data.image_max_pixels,
"video_min_pixels": self.config.data.video_min_pixels,
"video_max_pixels": self.config.data.video_max_pixels,
"video_total_pixels": self.config.data.video_total_pixels,
"video_fps": self.config.data.video_fps,
"video_max_frames": self.config.data.video_max_frames,
}
new_batch: DataProto = DataProto.from_single_dict(batch_dict, meta_info=meta_info)
new_batch.non_tensor_batch["uid"] = np.array(
[str(uuid.uuid4()) for _ in range(len(new_batch.batch))], dtype=object
)
gen_batch = new_batch.pop(
batch_keys=["input_ids", "attention_mask", "position_ids"],
non_tensor_batch_keys=["raw_prompt_ids", "multi_modal_data"],
meta_info_keys=[
"image_min_pixels",
"image_max_pixels",
"video_min_pixels",
"video_max_pixels",
"video_total_pixels",
"video_fps",
"video_max_frames",
],
)
self._assert_multimodal_contract(gen_batch, stage="train")
rollout_n = int(self.config.worker.rollout.n)
rollout_backend = str(self.config.worker.rollout.name).lower()
if rollout_backend in {"hf", "transformers"}:
if rollout_n != self.actor_rollout_ref_wg.world_size:
raise ValueError(
"Official HF rollout requires rollout.n == actor world size so "
"each rank contributes one completion per prompt; got "
f"n={rollout_n}, world_size={self.actor_rollout_ref_wg.world_size}."
)
# ONE_TO_ALL sends all unique prompts to every rank. Each rank runs
# one HF generation per prompt; the custom collector transposes the
# results into contiguous n-sized prompt groups.
gen_batch_output = self.actor_rollout_ref_wg.generate_sequences_hf_official(
gen_batch
)
# The HF workers deliberately omit this large object from their
# outputs. Restore the driver's original once, then repeat by n
# below; this avoids sending 64 duplicate 448-frame tensors back
# through Ray.
multi_modal_data = gen_batch.non_tensor_batch.get("multi_modal_data")
if multi_modal_data is not None:
new_batch.non_tensor_batch["multi_modal_data"] = multi_modal_data
else:
gen_batch_output = self.actor_rollout_ref_wg.generate_sequences(gen_batch)
expected_rollout_rows = len(new_batch) * rollout_n
if len(gen_batch_output) != expected_rollout_rows:
raise ValueError(
"Rollout output row count does not match prompt-group semantics: "
f"got {len(gen_batch_output)}, expected {len(new_batch)} * "
f"{rollout_n} = {expected_rollout_rows}."
)
metrics.update(
_rollout_token_diversity_metrics(
gen_batch_output.batch["responses"],
gen_batch_output.batch["response_mask"],
rollout_n,
)
)
if self.config.algorithm.adv_estimator == "remax":
gen_baseline_batch = deepcopy(gen_batch)
gen_baseline_batch.meta_info["temperature"] = 0
gen_baseline_batch.meta_info["n"] = 1
gen_baseline_output = self.actor_rollout_ref_wg.generate_sequences(gen_baseline_batch)
new_batch = new_batch.union(gen_baseline_output)
remax_reward_batch = new_batch.select(
batch_keys=["responses", "response_mask"],
non_tensor_batch_keys=[k for k in new_batch.non_tensor_batch if k != "multi_modal_data"],
)
reward_baseline_tensor, _ = ray.get(self.reward_fn.compute_reward.remote(remax_reward_batch))
reward_baseline_tensor = reward_baseline_tensor.sum(dim=-1)
new_batch.pop(batch_keys=list(gen_baseline_output.batch.keys()))
new_batch.batch["reward_baselines"] = reward_baseline_tensor
del gen_baseline_batch, gen_baseline_output
# Add an annotation-derived oracle response to each prompt's group.
# * replace mode: overwrite one on-policy rollout in place; group stays n.
# * append mode: keep all n on-policy rollouts and add the oracle as an
# extra row (group becomes n+1). We build the oracle rows here but defer
# the concat until the on-policy block has been assembled below.
n = rollout_n
oracle_mode = getattr(self.config.algorithm, "oracle_injection_mode", "replace")
append_oracle = bool(self.config.algorithm.oracle_injection and oracle_mode == "append")
oracle_append_rows: Optional[DataProto] = None
if self.config.algorithm.oracle_injection:
oracle_values = new_batch.non_tensor_batch.get("ground_truth", None)
if oracle_values is None:
raise KeyError(
"algorithm.oracle_injection=True but `ground_truth` is missing from the "
"batch non_tensor_batch. Check your dataset's answer_key."
)
# Oracle builders need the task family to avoid inferring it from
# ambiguous labels such as a single option letter. Keep lightweight
# row metadata, but never duplicate media tensors into this side
# channel.
oracle_extra_keys = [
key
for key in new_batch.non_tensor_batch
if key not in {"ground_truth", "multi_modal_data", "raw_prompt_ids"}
]
oracle_extras = np.asarray(
[
{
key: new_batch.non_tensor_batch[key][row]
for key in oracle_extra_keys
}
for row in range(len(new_batch))
],
dtype=object,
)
if append_oracle:
oracle_append_rows = self._build_oracle_append_rows(
gen_batch_output,
oracle_values,
oracle_extras,
n,
)
metrics["orarl/oracle/groups_injected"] = int(
0 if oracle_append_rows is None else len(oracle_append_rows)
)
# Flag the on-policy block so `is_oracle_row` exists on every row
# before the concat below (concat requires matching keys).
gen_batch_output.non_tensor_batch["is_oracle_row"] = np.zeros(
len(gen_batch_output), dtype=bool
)
else:
oracle_injected = self._inject_oracle_rollout_in_gen_output(
gen_batch_output=gen_batch_output,
ground_truths=oracle_values,
extras=oracle_extras,
n=n,
)
metrics["orarl/oracle/groups_injected"] = int(oracle_injected)
# On-policy block: repeat the per-prompt metadata n times and attach the
# generated rollouts. `repeat` returns a fresh DataProto, so `new_batch`
# (the un-repeated per-prompt metadata) stays intact for the oracle rows.
op_batch = new_batch.repeat(repeat_times=n, interleave=True)
op_batch = op_batch.union(gen_batch_output)
if oracle_append_rows is not None:
# Attach per-prompt metadata (uid/ground_truth/problem_type/...) to the
# oracle rows so each carries its group's uid, then concat onto the
# block. Everything downstream (advantage, selection, diagnostics)
# groups by uid, so the oracle row need not be physically adjacent.
oracle_full = new_batch.union(oracle_append_rows)
batch_out = DataProto.concat([op_batch, oracle_full])
group_size = n + 1
else:
batch_out = op_batch
group_size = n
return batch_out[: self.config.data.rollout_batch_size * group_size]
def fit(self):
"""
The training loop of PPO.
The driver process only need to call the compute functions of the worker group through RPC to construct the PPO dataflow.
The light-weight advantage computation is done on the driver process.
"""
self.logger = Tracker(loggers=self.config.trainer.logger, config=self.config.to_dict())
self.global_step = 0
main_tqdm = _NoOpProgress() if _disable_tqdm() else tqdm(
range(self.training_steps),
desc="Running step",
position=0,
)
val_metrics: Optional[dict[str, Any]] = None
# load checkpoint before doing anything
self._load_checkpoint()
main_tqdm.update(self.global_step)
# perform validation before training
# currently, we only support validation using the reward_function.
if self.val_reward_fn is not None and self.config.trainer.val_before_train:
val_metrics = self._validate()
self.logger.log(data=val_metrics, step=self.global_step)
if self.config.trainer.val_only:
return
self.data_iterator = iter(self.train_dataloader)
train_start_time = time.time()
while self.global_step < self.training_steps:
self.global_step += 1
metrics, timing_raw = {}, {}
with timer("step", timing_raw):
# make a batch of data
with timer("gen", timing_raw):
self.actor_rollout_ref_wg.prepare_rollout_engine()
batch = self._make_batch_data(metrics=metrics)
self.actor_rollout_ref_wg.release_rollout_engine()
# balance the number of valid tokens on each dp rank.
# NOTE: this breaks the order of data inside the batch.
# Please take care when you implement group based adv computation such as GRPO and rloo
self._balance_batch(batch, metrics=metrics)
# compute global valid tokens
batch.meta_info["global_token_num"] = torch.sum(batch.batch["attention_mask"], dim=-1).tolist()
# compute reward asynchronously so it can overlap with old-log-prob compute.
if "token_level_scores" not in batch.batch:
with timer("reward", timing_raw):
reward_batch = batch.select(
batch_keys=["responses", "response_mask"],
non_tensor_batch_keys=[k for k in batch.non_tensor_batch if k != "multi_modal_data"],
)
reward_ref = self.reward_fn.compute_reward.remote(reward_batch)
# recompute old_log_probs
with timer("old", timing_raw):
# Actor update always needs the rollout temperature to compute response log-probs.
batch.meta_info["temperature"] = self.config.worker.rollout.temperature
if "rollout_log_probs" in batch.batch:
# Bypass mode: reuse the per-token logprobs vLLM already
# returned as old_log_probs. This skips one FSDP recompute
# and keeps the first PPO mini-batch ratio away from a
# constant 1, so clipping acts as a real trust region.
# vLLM applies log-softmax to logits/temperature, matching
# actor.compute_log_prob, so the values are interchangeable.
rollout_lp = batch.batch.pop("rollout_log_probs")
batch.batch["old_log_probs"] = rollout_lp.to(torch.float32)
elif self.skip_old_log_probs:
batch.meta_info["skip_old_log_probs"] = True
metrics["actor/old_log_probs_skipped"] = 1.0
else:
old_log_probs = self.actor_rollout_ref_wg.compute_log_probs(batch)
batch = batch.union(old_log_probs)
# compute ref_log_probs
if self.use_reference_policy:
with timer("ref", timing_raw):
ref_log_probs = self.actor_rollout_ref_wg.compute_ref_log_probs(batch)
batch = batch.union(ref_log_probs)
# compute values
if self.use_critic:
with timer("values", timing_raw):
values = self.critic_wg.compute_values(batch)
batch = batch.union(values)
with timer("adv", timing_raw):
if "token_level_scores" not in batch.batch:
# get token level scores asynchronously
reward_tensor, reward_metrics = ray.get(reward_ref)
batch.batch["token_level_scores"] = reward_tensor
_propagate_per_row_reward_metrics(batch, reward_metrics)
metrics.update(_on_policy_reward_diversity_metrics(batch))
reward_metrics = {f"reward/{k}": v for k, v in reduce_metrics(reward_metrics).items()}
metrics.update(reward_metrics)
# apply kl penalty if available
if not self.config.algorithm.use_kl_loss and self.use_reference_policy:
# apply kl penalty to reward
batch, kl_metrics = apply_kl_penalty(batch, self.kl_ctrl, self.config.algorithm.kl_penalty)
metrics.update(kl_metrics)
else:
batch.batch["token_level_rewards"] = batch.batch["token_level_scores"]
# compute advantages, executed on the driver process
batch = compute_advantage(
batch,
adv_estimator=self.config.algorithm.adv_estimator,
gamma=self.config.algorithm.gamma,
lam=self.config.algorithm.lam,
scale_rewards=bool(self.config.algorithm.scale_rewards),
directional_gain=bool(
self.config.algorithm.directional_gain
),
directional_gain_gamma=float(
self.config.algorithm.directional_gain_gamma
),
directional_gain_positive_only=bool(
self.config.algorithm.directional_gain_positive_only
),
directional_gain_recenter=bool(
self.config.algorithm.directional_gain_recenter
),
)
# Report the raw per-group oracle-gap statistics before any
# anchoring rewrites the oracle advantage. No-op without
# oracle rows.
oracle_diag = compute_oracle_advantage_diagnostics(batch)
if oracle_diag:
metrics.update(oracle_diag)
adv_telemetry = batch.meta_info.pop("_advantage_telemetry", None)
if adv_telemetry:
for k, v in adv_telemetry.items():
metrics[f"orarl/{k}"] = float(v)
# OraRL stage 4: overwrite the oracle row with a detached
# positive anchor so it never enters the group mean/std.
if self.config.algorithm.detached_oracle_advantage:
# The released recipe keeps the directional gain
# policy-only, so the anchor is governed solely by its
# adaptive cap.
_det_gain_gamma = (
float(self.config.algorithm.directional_gain_gamma)
if (
self.config.algorithm.directional_gain
and self.config.algorithm.detached_oracle_use_directional_gain
)
else 0.0
)
det_metrics = apply_detached_oracle_advantage(
batch,
scale=float(
self.config.algorithm.detached_oracle_advantage_scale
),
beta=float(self.config.algorithm.oracle_reward_gate_beta),
directional_gain_gamma=_det_gain_gamma,
match_best_ratio=float(
self.config.algorithm.detached_oracle_match_best_ratio
),
match_best_min=float(
self.config.algorithm.detached_oracle_match_best_min
),
match_best_max=float(
self.config.algorithm.detached_oracle_match_best_max
),
)
if det_metrics:
metrics.update(det_metrics)
# OraRL stage 5: retain the oracle plus a strict sign-balanced
# subset of policy rows before the actor backward pass.
if self._is_orarl and not self.use_critic:
full_score = batch.batch["token_level_scores"].sum(-1)
metrics["orarl/selection/full_batch_score_mean"] = float(
full_score.mean().item()
)
metrics["orarl/selection/full_batch_score_max"] = float(
full_score.max().item()
)
metrics["orarl/selection/full_batch_score_min"] = float(
full_score.min().item()
)
_post_recenter = bool(
self.config.algorithm.post_selection_recenter
)
_post_rms_match = bool(
self.config.algorithm.post_selection_rms_match
)
post_selection_references = (
build_orarl_post_selection_references(batch)
if _post_recenter or _post_rms_match
else {}
)
with timer("orarl_selection", timing_raw):
batch, selection_metrics = select_orarl_rollouts(
batch,
n_rollouts=self.config.worker.rollout.n,
prune_ratio=self.config.algorithm.selection_prune_ratio,
world_size=self.actor_rollout_ref_wg.world_size,
positive_quota=self.config.algorithm.selection_positive_quota,
negative_quota=self.config.algorithm.selection_negative_quota,
)
metrics.update(selection_metrics)
if _post_recenter or _post_rms_match:
with timer("post_selection_balance", timing_raw):
post_balance_metrics = (
apply_orarl_post_selection_advantage_balance(
batch,
post_selection_references,
recenter=_post_recenter,
rms_match=_post_rms_match,
rms_min_scale=float(
self.config.algorithm.post_selection_rms_min_scale
),
)
)
metrics.update(post_balance_metrics)
# Re-balance per-rank seqlen after pruning: the original
# balance partitioned all G rows uniformly, but dropping the
# low-|adv| rows skews the per-rank totals. Cheap, and keeps
# the actor update from being bottlenecked on a single rank.
if (
selection_metrics.get(
"orarl/selection/dropped_rows",
0.0,
)
> 0
):
self._balance_batch(
batch,
metrics=metrics,
logging_prefix="orarl_selection_seqlen",
)
# update critic
if self.use_critic:
with timer("update_critic", timing_raw):
critic_output = self.critic_wg.update_critic(batch)
critic_metrics = reduce_metrics(critic_output.non_tensor_batch)
metrics.update(critic_metrics)
# update actor
if self.config.trainer.critic_warmup <= self.global_step:
metrics["actor/backward_rows"] = float(len(batch))
metrics["actor/backward_rows_per_prompt"] = float(
len(batch) / max(1, int(self.config.data.rollout_batch_size))
)
if self._is_orarl:
metrics["actor/oracle_injection_enabled"] = 1.0
metrics["actor/orarl_selection_enabled"] = 1.0
with timer("update_actor", timing_raw):
actor_output = self.actor_rollout_ref_wg.update_actor(batch)
actor_metrics = reduce_metrics(actor_output.non_tensor_batch)
metrics.update(actor_metrics)
# validate
if (
self.val_reward_fn is not None
and self.config.trainer.val_freq > 0
and self.global_step % self.config.trainer.val_freq == 0
):
with timer("validation", timing_raw):
val_metrics = self._validate()
metrics.update(val_metrics)
if self.config.trainer.save_freq > 0 and self.global_step % self.config.trainer.save_freq == 0:
with timer("save_checkpoint", timing_raw):
self._save_checkpoint()
# collect metrics
num_gpus = self.resource_pool_manager.get_num_gpus()
metrics.update(compute_data_metrics(batch=batch, use_critic=self.use_critic))
metrics.update(compute_timing_metrics(batch=batch, timing_raw=timing_raw))
metrics.update(compute_throughout_metrics(batch=batch, timing_raw=timing_raw, num_gpus=num_gpus))
# Best-train ckpt: track every step (decoupled from save_freq) and
# save model-only into a side dir when smoothed reward improves.
with timer("save_best_train_checkpoint", timing_raw):
self._maybe_save_best_train_checkpoint(metrics)
if _print_step_summary_enabled():
step_time = metrics.get("timing_s/step")
elapsed = time.time() - train_start_time
avg_step_time = elapsed / max(1, self.global_step)
remaining_steps = max(0, self.training_steps - self.global_step)
eta = avg_step_time * remaining_steps
print(
"[TRAIN STEP] "
f"{self.global_step}/{self.training_steps} "
f"step_s={_fmt_metric(step_time, precision=1)} "
f"elapsed={_fmt_duration(elapsed)} "
f"eta={_fmt_duration(eta)}",
flush=True,
)
self.logger.log(data=metrics, step=self.global_step)
main_tqdm.update()
# perform validation after training (skip entirely when val_freq <= 0)
if self.val_reward_fn is not None and self.config.trainer.val_freq > 0:
if (
val_metrics is None
or self.global_step % self.config.trainer.val_freq != 0
):
val_metrics = self._validate()
self.logger.log(data=val_metrics, step=self.global_step)
print(f"Final validation metrics:\n{convert_dict_to_str(unflatten_dict(val_metrics))}")
if self.config.trainer.save_freq <= 0 or self.global_step % self.config.trainer.save_freq != 0:
self._save_checkpoint()