Skip to content
418 changes: 418 additions & 0 deletions tests/engine/test_critic_sft.py

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion xtuner/v1/config/fsdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ class FSDPConfig(BaseModel):
# TODO: (caoweihan) Convert `torch.dtype` to `Annotated` for compatibility with cyclopts
param_dtype: Annotated[torch.dtype, Parameter(help="Data type for model parameters")] = torch.bfloat16
reduce_dtype: Annotated[torch.dtype, Parameter(help="Data type for reduction operations")] = torch.bfloat16
fp32_lm_head: Annotated[bool, Parameter(help="Use float32 for language model head")] = False
fp32_lm_head: Annotated[bool, Parameter(help="Use float32 for the language model head")] = False
fp32_value_head: Annotated[bool, Parameter(help="Use float32 for the critic value head")] = False
# TODO: deprecate `torch_compile` in favor of `compile_cfg` in XTunerBaseModelConfig
torch_compile: Annotated[bool, Parameter(help="Enable model compilation for faster inference")] = True
mesh_prefix: Annotated[str, Parameter(help="Prefix for device mesh configuration in distributed training")] = (
Expand Down
4 changes: 4 additions & 0 deletions xtuner/v1/loss/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .base_loss_ctx import BaseLossConfig, BaseLossContext, BaseLossKwargs
from .ce_loss import CELossConfig, CELossContext, LMHeadLossContext
from .chunk_loss import ChunkLoss
from .critic_loss import CriticLossConfig, CriticLossContext, CriticLossKwargs
from .moe_loss import (
BalancingLossConfig,
BalancingLossContext,
Expand All @@ -26,6 +27,9 @@
"CELossContext",
"CELossConfig",
"ChunkLoss",
"CriticLossConfig",
"CriticLossContext",
"CriticLossKwargs",
"BaseLossConfig",
"BaseLossContext",
"BaseLossKwargs",
Expand Down
208 changes: 208 additions & 0 deletions xtuner/v1/loss/critic_loss.py
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
Comment thread
jayhenry marked this conversation as resolved.
Resolved

@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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F1 / P1] 批量构建 Interface 与现有调用方不兼容。

BaseModel.build_loss_ctx_batch(model/base.py:1348)固定调用 build_batches(contexts, cu_seq_lens_list=..., sp_mesh=...),这里却只接受列表。配置 lm_loss_cfg=CriticLossConfig() 后调用模型公开批量入口,会抛 TypeError: unexpected keyword argument 'cu_seq_lens_list'。已在当前 head 的真实模块上用 CPU 最小复现确认;现有两个 loss 测试手工调用 build_batches([ctx]),绕过了该入口。

建议新增 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

请补一个从 model.build_loss_ctx_batch([data]) 进入、再 forward/backward 的公开路径回归测试。影响限定为使用 critic 配置的批量入口,普通 CE 训练不受此参数错误影响。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

根因

build_batches 按调用方分三类协议:

类别 代表 调用方 签名
主 loss CELossContext(ce_loss.py:125)、CriticLossContext BaseModel.build_loss_ctx_batch(base.py:1348),关键字传全三参 (loss_ctx_list, cu_seq_lens_list, sp_mesh)
辅助 loss BalancingLossContext(moe_loss.py:89)、ZLossContext _add_auxiliary_loss(base.py:1394)裸调 (loss_ctx_list)
RL loss GRPOLossContext(grpo_loss.py:99)、OrealLossContext RL worker 裸调(worker.py:1240) (loss_ctx_list),继承 CE 却收窄签名(# type: ignore[override])

主 loss 需要 cu_seq_lens_list(sample/square reduction 按样本边界算有效 token)和 sp_mesh(SP>1 时 sp_gather/sp_split);辅助/RL loss 只需 _batch_size 或全局 token 分母,不需要序列结构信息。入口对所有挂 lm_loss_cfg 的 loss 无差别传满协议。

CriticLossContext 照搬了 RL loss 的单参数写法,但 RL context 收窄签名安全的前提是"永远走裸调协议";critic 挂在 lm_loss_cfg 上、会被 SFT 公开入口以完整协议调用,于是第一步组 batch 就抛 TypeError: unexpected keyword argument 'cu_seq_lens_list'。critic 同时走 SFT 与 RL 两条链,必须保持与主 loss 一致的完整签名。

Fix 之后

CriticLossContext.build_batches 对齐模型批量协议,新增 cu_seq_lens_list / sp_mesh 两个默认 None 的参数并显式丢弃——critic 按 token 全局校准,不使用样本边界;逐 token 的 ignore 掩码已覆盖 SP padding(sp_split 时 padding 填 -100),因此这两个参数对 critic 无语义,仅为兼容统一调用协议:

@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 侧的裸调 build_batches(step_loss_ctxs) 不受影响(取默认 None),两条调用链共用同一套校准逻辑(逐 token 掩码权重 + dist.all_reduce 全局分母)。

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F2 / P2] B>1,S=1 时这里会把不同样本的 target 交叉广播。

values.squeeze(-1) 是 [B,S],但二维 returns/weight 为 [B,1] 时再次 squeeze 会变为 [B];差值随后扩成 [B,B]。MSE/clipped 两分支都会使用这些形状;SP 后本地长度为 1 时也需覆盖。

当前 head 的 CPU 公开路径复现:ValueHead(1) 权重为 1,hidden 为 [[[1.0]], [[3.0]]],returns 为 [[0.0], [2.0]],两个 token 权重均为 1/2。实际 loss=3.0、head weight gradient=6.0;正确值分别为 0.5 和 2.0。

建议只去掉确定存在的 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 计算。

请参数化 (B,S)=(1,32),(2,1),(2,3),比较 MSE/clipped 的数值与梯度。测试 oracle 也有同样的按末维 squeeze 模式,单 token 用例应使用逐 token 手算期望。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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)
Loading
Loading