Repository navigation
[feat] Support value head and critic loss #2188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
27eb554
41044fb
5e9818f
28f3e49
7a7f639
0877871
ce9c228
da2e7a5
27089af
1a06068
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,208 @@ | ||||||||||||||||||
| # Copyright (c) OpenMMLab. All rights reserved. | ||||||||||||||||||
| from typing import Any, cast | ||||||||||||||||||
|
|
||||||||||||||||||
| import torch | ||||||||||||||||||
| import torch.distributed as dist | ||||||||||||||||||
| import torch.nn.functional as F | ||||||||||||||||||
| from pydantic import model_validator | ||||||||||||||||||
| from torch.distributed.device_mesh import DeviceMesh | ||||||||||||||||||
| from torch.distributed.nn.functional import all_reduce | ||||||||||||||||||
|
|
||||||||||||||||||
| from xtuner.v1.loss.base_loss_ctx import BaseLossConfig, BaseLossContext, BaseLossKwargs | ||||||||||||||||||
| from xtuner.v1.loss.utils import sp_split | ||||||||||||||||||
| from xtuner.v1.utils.device import get_device | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| DEVICE = get_device() | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class CriticLossConfig(BaseLossConfig): | ||||||||||||||||||
| """Configuration for PPO critic clipped value-loss. | ||||||||||||||||||
|
|
||||||||||||||||||
| Unlike GRPO / PPO policy loss, this config does not inherit | ||||||||||||||||||
| BaseRLLossConfig. It is used by ValueHead, not the language-model | ||||||||||||||||||
| vocabulary head. | ||||||||||||||||||
|
|
||||||||||||||||||
| Only mode="eager" is supported: the value head output is 1-dim and | ||||||||||||||||||
| needs no chunking. Setting mode="chunk" raises a validation error. | ||||||||||||||||||
|
|
||||||||||||||||||
| Args: | ||||||||||||||||||
| cliprange_value (float): Clip range applied to the difference between | ||||||||||||||||||
| the current value and old_values. Defaults to 0.2. | ||||||||||||||||||
| ignore_idx (int): Token index excluded from the loss. Defaults to -100. | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| cliprange_value: float = 0.2 | ||||||||||||||||||
|
|
||||||||||||||||||
| @model_validator(mode="after") | ||||||||||||||||||
| def _reject_chunk_mode(self): | ||||||||||||||||||
| if self.mode != "eager": | ||||||||||||||||||
| raise ValueError( | ||||||||||||||||||
| "CriticLossConfig only supports mode='eager'; the value head output is 1-dim and needs no chunking" | ||||||||||||||||||
| ) | ||||||||||||||||||
| return self | ||||||||||||||||||
|
|
||||||||||||||||||
| @property | ||||||||||||||||||
| def loss_ctx_cls(self) -> type["CriticLossContext"]: | ||||||||||||||||||
| return CriticLossContext | ||||||||||||||||||
|
|
||||||||||||||||||
| @property | ||||||||||||||||||
| def _loss_kwargs_cls(self) -> type["CriticLossKwargs"]: | ||||||||||||||||||
| return CriticLossKwargs | ||||||||||||||||||
|
|
||||||||||||||||||
| def build( | ||||||||||||||||||
| self, | ||||||||||||||||||
| data: dict, | ||||||||||||||||||
| sp_mesh: DeviceMesh | None = None, | ||||||||||||||||||
| ) -> "CriticLossContext | None": | ||||||||||||||||||
| """Build critic loss context from data dict. | ||||||||||||||||||
|
|
||||||||||||||||||
| Args: | ||||||||||||||||||
| data (dict): Data dictionary containing critic-specific fields: | ||||||||||||||||||
| - shifted_labels (torch.Tensor): The shifted labels | ||||||||||||||||||
| - returns (torch.Tensor): Per-token regression targets | ||||||||||||||||||
| - old_values (torch.Tensor | None): Frozen values for clipping | ||||||||||||||||||
| (optional, can be omitted for SFT MSE) | ||||||||||||||||||
| sp_mesh (DeviceMesh | None): Sequence parallel device mesh | ||||||||||||||||||
|
|
||||||||||||||||||
| Returns: | ||||||||||||||||||
| CriticLossContext | None: The built loss context, or None if required | ||||||||||||||||||
| fields are missing. | ||||||||||||||||||
| """ | ||||||||||||||||||
| if "shifted_labels" not in data or "returns" not in data: | ||||||||||||||||||
| return None | ||||||||||||||||||
|
|
||||||||||||||||||
| shifted_labels = data["shifted_labels"] | ||||||||||||||||||
| returns = data["returns"] | ||||||||||||||||||
| old_values = data.get("old_values", None) | ||||||||||||||||||
|
|
||||||||||||||||||
| loss_kwargs = CriticLossKwargs( | ||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [A1,架构] 建议深化现有 loss 准备 Module,集中形状、设备和 SP 规则,并明确 batch 校准阶段。 目前 BaseModel 在 build 时迁移/切分,worker 在 CPU packing、设备迁移、SP 和校准的不同位置组织 context,测试又手工拼装。Interface 的隐含顺序分散在多个调用方;F1 的参数不匹配与 F2 的形状处理是具体摩擦。 建议现有 Config/Context 统一准备协议,由 critic 构建时将完整监督字段规范为 [B,S],设备迁移与 SP 切分在 loss Module 内完成一次: class CriticLossConfig(BaseLossConfig):
def build(self, data, sp_mesh=None, device=None):
labels = data["shifted_labels"]
old = data.get("old_values")
kwargs = CriticLossKwargs(
shifted_labels=labels,
returns=data["returns"].reshape_as(labels),
old_values=None if old is None else old.reshape_as(labels),
).to(DEVICE if device is None else device)
if sp_mesh is not None and sp_mesh.size() > 1:
kwargs.sp_split(sp_mesh)
return CriticLossContext(self, kwargs)
# CPU packing 保留原始监督字段;执行阶段使用相同 build 协议。
contexts = [loss_cfg.build(data, sp_mesh=sp_mesh, device=device) for data in batch]
... # actor 需要时,由 worker 采集并填入已是本地分片的冻结预测。
contexts = loss_cfg.loss_ctx_cls.build_batches(
contexts, cu_seq_lens_list=cu_seq_lens_list, sp_mesh=sp_mesh
)伪代码省略了当前缺字段返回 None 的分支,应保留。critic loss_fn 之后直接消费 [B,S] 的监督字段,不再 squeeze;actor 冻结预测调度仍由上层拥有,不能重复切分已是本地的 old_logprobs/old_values。 这会把 Locality 收回 loss Module,准备路径同时服务模型、worker 和测试;复用现有类即可。回归测试应从模型公开批量入口验证最终 loss/gradient,避免再增加只逐项转发步骤的浅 wrapper。 |
||||||||||||||||||
| shifted_labels=shifted_labels, | ||||||||||||||||||
| returns=returns, | ||||||||||||||||||
| old_values=old_values, | ||||||||||||||||||
| ).to(DEVICE) | ||||||||||||||||||
| if sp_mesh is not None and sp_mesh.size() > 1: | ||||||||||||||||||
| loss_kwargs = loss_kwargs.sp_split(sp_mesh) | ||||||||||||||||||
|
|
||||||||||||||||||
| return CriticLossContext(self, loss_kwargs) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class CriticLossKwargs(BaseLossKwargs): | ||||||||||||||||||
| """Keyword arguments for critic loss computation. | ||||||||||||||||||
|
|
||||||||||||||||||
| Args: | ||||||||||||||||||
| shifted_labels (torch.Tensor): The shifted labels for the input sequences. | ||||||||||||||||||
| returns (torch.Tensor): Per-token regression targets (GAE return or SFT target). | ||||||||||||||||||
| old_values (torch.Tensor | None): Frozen values used for clipping. If None, | ||||||||||||||||||
| the loss is unclipped MSE. | ||||||||||||||||||
| loss_weight (torch.Tensor | None): Weights for each token in the loss. | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| shifted_labels: torch.Tensor | ||||||||||||||||||
| returns: torch.Tensor | ||||||||||||||||||
| old_values: torch.Tensor | None = None | ||||||||||||||||||
| loss_weight: torch.Tensor | None = None | ||||||||||||||||||
|
|
||||||||||||||||||
| def sp_split(self, sp_mesh: DeviceMesh) -> "CriticLossKwargs": | ||||||||||||||||||
| self.shifted_labels = sp_split(self.shifted_labels, sp_mesh=sp_mesh, split_dim=1, padding_value=-100) | ||||||||||||||||||
| self.returns = sp_split(self.returns, sp_mesh=sp_mesh, split_dim=1, padding_value=0.0) | ||||||||||||||||||
| if self.old_values is not None: | ||||||||||||||||||
| self.old_values = sp_split(self.old_values, sp_mesh=sp_mesh, split_dim=1, padding_value=0.0) | ||||||||||||||||||
| return self | ||||||||||||||||||
|
|
||||||||||||||||||
| def to(self, device: torch.device | str) -> "CriticLossKwargs": | ||||||||||||||||||
| self.shifted_labels = self.shifted_labels.to(device) | ||||||||||||||||||
| self.returns = self.returns.to(device) | ||||||||||||||||||
| if self.old_values is not None: | ||||||||||||||||||
| self.old_values = self.old_values.to(device) | ||||||||||||||||||
| return self | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class CriticLossContext(BaseLossContext): | ||||||||||||||||||
| """Critic loss context for PPO value-head training. | ||||||||||||||||||
|
|
||||||||||||||||||
| Args: | ||||||||||||||||||
| loss_cfg (CriticLossConfig): Configuration for critic loss computation. | ||||||||||||||||||
| loss_kwargs (CriticLossKwargs): Keyword arguments required for loss calculation. | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| loss_cfg: CriticLossConfig | ||||||||||||||||||
| loss_kwargs: CriticLossKwargs | ||||||||||||||||||
|
|
||||||||||||||||||
| def __init__(self, loss_cfg: CriticLossConfig, loss_kwargs: CriticLossKwargs): | ||||||||||||||||||
| super().__init__(loss_cfg, loss_kwargs) | ||||||||||||||||||
|
|
||||||||||||||||||
| @staticmethod | ||||||||||||||||||
| def build_batches(loss_ctx_list: list["CriticLossContext"]) -> list["CriticLossContext"]: # type: ignore[override] | ||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [F1 / P1] 批量构建 Interface 与现有调用方不兼容。
建议新增 context 对齐既有批量协议,保留 token 归一化实现,让模型继续通过多态调用: @staticmethod
def build_batches(
loss_ctx_list,
cu_seq_lens_list=None,
sp_mesh=None,
):
# critic 按有效 token 全局校准,不使用样本边界;接受统一调用协议。
... # 保留当前 mask、WORLD 分母与归一化。
return loss_ctx_list请补一个从
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 根因
主 loss 需要
Fix 之后
@staticmethod
def build_batches( # type: ignore[override]
loss_ctx_list: list["CriticLossContext"],
cu_seq_lens_list: Sequence[torch.IntTensor] | None = None,
sp_mesh: DeviceMesh | None = None,
) -> list["CriticLossContext"]:
# Accepted for the `BaseModel.build_loss_ctx_batch` call protocol; unused because
# critic loss calibrates per token via the ignore mask below.
del cu_seq_lens_list, sp_mesh
...RL worker 侧的裸调 |
||||||||||||||||||
| assert len(loss_ctx_list) > 0, "loss_ctx_list can not be empty" | ||||||||||||||||||
|
|
||||||||||||||||||
| loss_cfg = loss_ctx_list[0].loss_cfg | ||||||||||||||||||
| loss_weight_list: list[torch.Tensor] = [] | ||||||||||||||||||
| for loss_ctx in loss_ctx_list: | ||||||||||||||||||
| shifted_labels = loss_ctx.loss_kwargs.shifted_labels | ||||||||||||||||||
| loss_weight = torch.ones_like(shifted_labels, dtype=torch.float32) | ||||||||||||||||||
| loss_weight[shifted_labels == loss_cfg.ignore_idx] = 0.0 | ||||||||||||||||||
| loss_ctx.loss_kwargs.loss_weight = loss_weight | ||||||||||||||||||
| loss_weight_list.append(loss_weight) | ||||||||||||||||||
|
|
||||||||||||||||||
| rank_denominator = sum(loss_weight.sum() for loss_weight in loss_weight_list) | ||||||||||||||||||
| rank_denominator = cast(torch.Tensor, rank_denominator) | ||||||||||||||||||
| global_denominator = rank_denominator | ||||||||||||||||||
| if dist.is_initialized(): | ||||||||||||||||||
| dist.all_reduce(global_denominator, op=dist.ReduceOp.SUM) | ||||||||||||||||||
|
|
||||||||||||||||||
| if global_denominator == 0: | ||||||||||||||||||
| global_denominator.add_(1) | ||||||||||||||||||
|
|
||||||||||||||||||
| for loss_ctx in loss_ctx_list: | ||||||||||||||||||
| loss_ctx._batch_size = len(loss_ctx_list) | ||||||||||||||||||
| assert loss_ctx.loss_kwargs.loss_weight is not None | ||||||||||||||||||
| loss_ctx.loss_kwargs.loss_weight = loss_ctx.loss_kwargs.loss_weight / global_denominator | ||||||||||||||||||
| return loss_ctx_list | ||||||||||||||||||
|
|
||||||||||||||||||
| def loss_fn( | ||||||||||||||||||
| self, | ||||||||||||||||||
| hidden_states: torch.Tensor, | ||||||||||||||||||
| head_weight: torch.Tensor, | ||||||||||||||||||
| head_bias: torch.Tensor | None, | ||||||||||||||||||
| loss_kwargs: CriticLossKwargs, | ||||||||||||||||||
| ) -> tuple[torch.Tensor, tuple[torch.Tensor | None, dict[str, Any]]]: | ||||||||||||||||||
| values = F.linear(hidden_states, head_weight, head_bias).float() | ||||||||||||||||||
| values_squeezed = values.squeeze(-1) | ||||||||||||||||||
|
|
||||||||||||||||||
| shifted_labels = loss_kwargs.shifted_labels | ||||||||||||||||||
| returns = loss_kwargs.returns | ||||||||||||||||||
| old_values = loss_kwargs.old_values | ||||||||||||||||||
| loss_weight = loss_kwargs.loss_weight | ||||||||||||||||||
| assert loss_weight is not None, "loss_weight can not be None" | ||||||||||||||||||
|
|
||||||||||||||||||
| returns = returns.squeeze(-1) | ||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [F2 / P2]
当前 head 的 CPU 公开路径复现: 建议只去掉确定存在的 head 输出维,监督字段与逐 token 权重统一到预测形状: values = F.linear(hidden_states, head_weight, head_bias).float()
v = values.squeeze(-1) # [B,S]
returns = loss_kwargs.returns.reshape_as(v)
weight = loss_kwargs.loss_weight.reshape_as(v)
old = loss_kwargs.old_values
old = None if old is None else old.reshape_as(v)
# 以下保留现有 MSE/clipped 公式,用 v、returns、weight、old 计算。请参数化
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 合理,已按照建议修改 |
||||||||||||||||||
| loss_weight = loss_weight.squeeze(-1) | ||||||||||||||||||
| if old_values is not None: | ||||||||||||||||||
| old_values = old_values.squeeze(-1) | ||||||||||||||||||
|
|
||||||||||||||||||
| rank_grad_tokens = (shifted_labels != self.loss_cfg.ignore_idx).sum() | ||||||||||||||||||
| if rank_grad_tokens == 0: | ||||||||||||||||||
| loss = values.sum() * 0 | ||||||||||||||||||
| elif old_values is None: | ||||||||||||||||||
| per_token = 0.5 * (values_squeezed - returns) ** 2 | ||||||||||||||||||
| loss = (per_token * loss_weight).sum() | ||||||||||||||||||
| else: | ||||||||||||||||||
| cliprange = self.loss_cfg.cliprange_value | ||||||||||||||||||
| v_clip = old_values + (values_squeezed - old_values).clamp(-cliprange, cliprange) | ||||||||||||||||||
| per_token = 0.5 * torch.maximum((values_squeezed - returns) ** 2, (v_clip - returns) ** 2) | ||||||||||||||||||
| loss = (per_token * loss_weight).sum() | ||||||||||||||||||
|
|
||||||||||||||||||
| return loss, (values, {}) | ||||||||||||||||||
|
|
||||||||||||||||||
| def forward( | ||||||||||||||||||
| self, | ||||||||||||||||||
| hidden_states: torch.Tensor, | ||||||||||||||||||
| head_weight: torch.Tensor, | ||||||||||||||||||
| head_bias: torch.Tensor | None = None, | ||||||||||||||||||
| ) -> tuple[torch.Tensor, tuple[torch.Tensor | None, dict[str, Any]]]: | ||||||||||||||||||
| assert self.loss_kwargs is not None, "loss_kwargs must be set before calling forward" | ||||||||||||||||||
| loss, (values, extra_info) = self.loss_fn(hidden_states, head_weight, head_bias, self.loss_kwargs) | ||||||||||||||||||
| if dist.is_initialized(): | ||||||||||||||||||
| loss = all_reduce(loss, op=dist.ReduceOp.SUM, group=dist.group.WORLD) | ||||||||||||||||||
| return loss, (values, extra_info) | ||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.