Skip to content

[feat] Support value head and critic loss - #2188

Open
YanhuiDua wants to merge 10 commits into
stack/rl-data-packingfrom
stack/rl-value-head
Open

YanhuiDua wants to merge 10 commits into
stack/rl-data-packingfrom
stack/rl-value-head

Conversation

@YanhuiDua

@YanhuiDua YanhuiDua commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stack 关系

本 PR 属于 Stack #2189,依赖顺序从底到顶为:

层 PR 职责
AgentLoop 数据准备 #2102 统一 RolloutState 的全序列字段和 labels 监督约定
训练数据打包 #2185 controller 生成索引计划,worker 转换并物化训练 pack
Value head #2188 value-head 模型、HF 权重映射和 critic loss;对应原 #2099
PPO #2186 在共享样本和 pack 计划上接入 critic worker、GAE 与 actor 训练

本层基于 packing #2185,PPO #2186 基于本层。 这是原 fork PR #2099 在主仓库中的 stack 版本,包含其最新三个提交。原 PR 保留用于查阅 review 讨论。

功能

  • 新增 critic ValueHead(Linear(hidden_size, 1)),通过 TransformerConfig.head_type="value_head" 选择;VL/compose 模型使用 text_config.head_type。
  • 运行时模块名保持 lm_head;HF 保存与加载使用顶层 value_head.weight,不添加 language_model. 前缀。
  • 新增 CriticLossConfig:不提供 old_values 时使用 MSE,提供时使用 clipped value loss。
  • value head 不绑定 embeddings,并拒绝 MTP。
  • FSDPConfig.fp32_lm_head 更名为 fp32_head,覆盖 LM head 与 value head;RolloutConfig.fp32_lm_head 保持不变。
  • 支持使用 actor checkpoint 初始化 critic;缺失的 value-head 权重按加载逻辑随机初始化。

与 PPO 层的分工

本层提供 value-head 模型、HF 权重映射、critic loss 和对应配置适配。上层 #2186 提供 attached CriticWorker、共享样本缓存、value 收集、controller GAE 计算和 actor/critic 训练调度。

value-head 核心提交从原 #2186 的历史中拆出,因此上层不再重复包含该功能 diff。原 #2099 后续的测试注释修正和加载完成后的分布式 barrier 也包含在本层。

测试与验证

  • tests/engine/test_critic_sft.py 覆盖 MTP 约束、HF key 映射、MSE/clipped loss、actor→critic 加载、保存与重新加载。
  • 保存/加载测试在所有 rank 完成加载后同步,避免临时目录提前删除。
  • 通过源码 diff 确认:拆层后的整个栈与拆层前相比,仅新增原 [WIP] support value head #2099 后续的测试修改,运行时代码保持一致。
  • 本次未运行单测或训练;本 PR 保持 Draft。

Comment thread xtuner/v1/model/base.py Resolved
Comment thread xtuner/v1/model/base.py Resolved
@PengchengShi00

PengchengShi00 commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

当前 critic head 加载与初始化逻辑

1. Critic 模型支持的加载来源

Critic 模型可以从两类 actor 权重初始化:

  1. 官方 HuggingFace 权重

    • 这类权重通常没有 metadata.head_type 信息;
    • backbone 正常加载;随机初始化 value head。
  2. XTuner 保存的 actor 权重

    • 这类权重的 index metadata 中会有:"head_type": "lm_head"
    • 允许把 lm_head actor 权重加载成 value_head critic 模型;
    • backbone 正常加载;
    • 如果 checkpoint 中没有 value head 权重,则只随机初始化 value head。
    • 通常应使用 from_hf(..., strict=False),strict=True 仍可能因为缺少 value_head.weight 而失败。
    • 反向是不允许的,因为 critic checkpoint 中没有 vocabulary head,无法初始化 actor。

2. Value head 的随机初始化方式

只有当 _load_params() 返回的 loaded_keys 中没有:lm_head.weight,也就是 runtime 上的 critic head 确实没有加载成功时,才会随机初始化。

初始化分布为: Normal(mean=0, std=1 / (hidden_size + 1))

如果 value head 已经通过默认 key 或自定义 key 成功加载,则不会重新初始化,也不会覆盖已加载权重。

3. 自定义 critic head 权重名

如果 critic head 默认保存名是:value_head.weight,而 checkpoint 中实际名字是: critic.weight

那么应配置为: hf_key_mapping = { "value_head.weight": "critic.weight"}

完整转换链路是:

  runtime 参数名:lm_head.weight
          ↓
  默认 HF key:value_head.weight
          ↓ 应用 hf_key_mapping
  实际 checkpoint key:critic.weight
          ↓ LoadSpec 加载
  写回 runtime 参数:lm_head.weight

如果 hf_key_mapping 中没有配置 critic head 的映射,系统会使用模型默认的 HF key,例如: value_head.weight。如果 checkpoint 中使用了自定义名字但没有配置 mapping,系统不会猜测该权重是不是 critic head,最终会视为缺失并随机初始化。

4. Runtime 名称与保存名称

Critic head 在模型运行时的模块名和参数名仍然是:lm_head和lm_head.weight,这样做是为了复用原有模型 head 的挂载位置和 forward 路径。

但在保存成 HF 格式时,会把它重命名为:value_head.weight,实际名称可能根据模型 adapter 带前缀,例如:language_model.value_head.weight。

同时保存的 index metadata 中会写入:{"head_type": "value_head"}

@PengchengShi00 PengchengShi00 changed the title [WIP] Support value head and critic loss [feat] Support value head and critic loss Oct 20, 2026
@PengchengShi00
PengchengShi00 marked this pull request as ready for review October 20, 2026 12:37
@YanhuiDua

Copy link
Copy Markdown
Collaborator Author

@claude review

@github-actions

Copy link
Copy Markdown
Contributor

Claude:

Summary

本 PR 新增 critic ValueHead(head_type="value_head" 选择)、HF 侧 value_head.weight 命名与 index metadata、actor→critic 加载与缺失 value head 随机初始化,以及 CriticLossConfig(MSE / clipped value loss),并把 module/lm_head/ 重命名为 module/head/。核心加载与权重映射链路经过 review 讨论后已修正(load_spec_mapping 已应用 hf_key_mapping,actor checkpoint 可初始化 critic),运行时与落盘命名保持一致;主要遗留问题集中在分层耦合、被忽略的配置项、head 策略分散,以及核心 loss 数学在 CI 中无真实执行路径。

另外提示:PR 描述称 FSDPConfig.fp32_lm_head 更名为 fp32_head,但代码实际保留 fp32_lm_head 并新增 fp32_value_head(config/fsdp.py#L34-L35),描述与实现不一致。

Main Flowchart after this PR

flowchart TD
    A["TransformerConfig.head_type='value_head'"] --> B["config.build_head()"]
    B --> C["ValueHead = nn.Linear(hidden, 1)<br/>runtime 模块名仍为 lm_head"]
    C -.->|"module 层 import rl 层"| P1["value_head.py:9<br/>import xtuner.v1.rl.loss.critic_loss<br/>⚠ 分层倒置 (W1)"]
    A --> D["from_hf()"]
    D --> E["_validate_head_checkpoint()"]
    E --> F["_load_params()"]
    F --> G["_maybe_init_unloaded_critic_head()"]
    G --> P2["from_hf 内联 loaded/unloaded/missing_keys 修补<br/>⚠ 抽象层级混杂 (W5)"]
    P2 --> H["返回 keys 三元组"]
    C --> I["ValueHead.forward(hidden, loss_ctx)"]
    I --> J["CriticLossContext.forward"]
    J --> P3["仅 eager 路径;mode='chunk' 被静默忽略<br/>⚠ (W2)"]
    J --> K["loss_fn: MSE / clipped value loss"]
    K --> P4["CI 中无真实执行路径(测试被 env + 8 卡 gate)<br/>⚠ (W3)"]
    L["dense.py / moe.py"] --> P5["tie 抑制 + fp32 head 选择重复 3+2 处<br/>⚠ head 策略分散 (W4)"]

    style P1 fill:#ffd6d6
    style P2 fill:#ffd6d6
    style P3 fill:#ffd6d6
    style P4 fill:#ffd6d6
    style P5 fill:#ffd6d6
    style C fill:#d6e8ff
    style G fill:#d6e8ff
    style J fill:#d6e8ff
Loading

核心原理实现与单测

  • value head 构建与命名:build_head() 统一生成 LMHead / ValueHead,运行时沿用 lm_head 挂载点,落盘经 _to_hf_key_list → _remap_critic_hf_keys 改名为 value_head.weight;save 路径通过 load_spec_mapping 取 HF key,因此 save/load 命名自洽,tie 模型也不会把 value head 映射成 embed_tokens。
  • actor→critic 加载:_validate_head_checkpoint 允许 lm_head checkpoint 初始化 value_head(反向拒绝),_maybe_init_unloaded_critic_head 基于全局 checkpoint key 判断而非 rank 本地 loaded_keys,避免只有部分 rank 进入 DTensor 集合通信;compose 模型经 language_model.from_hf 复用该逻辑,vision/projector 因无 head_type 提前返回。
  • critic loss:build 区分 old_values 是否存在选择 MSE / clipped,build_batches 的全局分母与 CE 的 dp/sp/grad-acc 校准设计一致,rank_grad_tokens == 0 时返回零梯度 loss。
  • 单测覆盖:TestCriticCustomHFKeyMapping 以真实 from_hf 覆盖了自定义 hf_key_mapping 不被覆写的回归点(对应此前 review 讨论的修复),是本 PR 唯一无需 GPU/权重即可运行的用例;其余 HF key、MTP 约束、MSE/clipped loss、保存重载均依赖分布式环境,其中 loss 数学部分还额外依赖 QWEN3_5_MOE_PATH(见 W3)。

抽象与信息隐藏评估

  • Warning xtuner/v1/module/head/value_head.py:9:module 层顶层 import xtuner.v1.rl.loss.critic_loss 使核心模型层反向依赖整个 RL loss 包(经 rl.utils 连带 ray),而 critic_loss 本身只依赖 xtuner.v1.loss.*,应下移到 xtuner/v1/loss/ 或改为 TYPE_CHECKING import。
  • Warning xtuner/v1/rl/loss/critic_loss.py:18-31:CriticLossConfig 继承了 mode / chunk_size,但 CriticLossContext.forward 从不分支,mode="chunk" 被静默忽略,Interface 暴露了无效果的旋钮。
  • Warning xtuner/v1/model/dense/dense.py:68 / moe.py:1407-1414 / base.py:666-673:“value head 不 tie、用独立 fp32 开关、落盘换名”这组 head 策略分散在 dense 三处、moe 一处与 base 的临时改写 config.tie_word_embeddings,缺少 Locality,建议收敛到 build_head() 所在的 config/head 接缝。

公开 Interface 的线性业务流程评估

  • Warning xtuner/v1/model/base.py:618-629:公开接口 from_hf 把 loaded_keys / unloaded_keys / missing_keys 的低层修补与魔法名 "lm_head.weight" 内联在主流程中,建议收敛为一个“初始化缺失 critic head 并校正 key 集合”的单步私有方法。

单测建议

  • Warning tests/engine/test_critic_sft.py:258:MSE / clipped loss、build_batches 全局分母、ValueHead.forward(loss_ctx=None) 等核心行为全部被 QWEN3_5_MOE_PATH 与 8 卡 DDP gate 住,常规 CI 无真实执行路径,建议补一个走 CriticLossConfig.build → build_batches → ValueHead.__call__ 的 CPU 用例(未 init_process_group 时 forward 的 all_reduce 会跳过)。

其他 Issues

本次未发现 Warning 及以上的正确性、安全或性能问题;RL worker 侧对 critic 返回的普通 dict extra_info 已有 isinstance 分支兜底,SFT Trainer 的 loss_cfg 仅接受 CELossConfig,相关路径不可达。

Verdict

COMMENT

Comment thread xtuner/v1/module/head/value_head.py Outdated
from torch.distributed.tensor import DTensor
from typing_extensions import overload

from xtuner.v1.rl.loss.critic_loss import CriticLossContext

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude: [设计] 这个顶层 import 让核心 module 层反向依赖 RL 层。链路为:xtuner/v1/module/__init__.py → .head → value_head → 执行 xtuner/v1/rl/loss/__init__.py → grpo_loss → ..utils → ray_accelerator_worker → import ray;而 xtuner/v1/model/__init__.py 又 import 了 xtuner.v1.module.router.greedy,于是任何模型 import 都会连带拉起整个 RL loss 包与 ray。

目前 xtuner/v1/loss/__init__.py → rl_loss → xtuner.v1.rl.utils.misc 已经使 ray 成为事实上的硬依赖,所以这不是新的安装期 break;真实摩擦在分层与环依赖脆弱性:xtuner/v1/rl/loss/* 一旦出现任何顶层 xtuner.v1.model|module import(现在没有),立刻变成硬 import cycle。对比 LMHead 只依赖同层的 xtuner.v1.loss.ce_loss。

critic_loss.py 实际只依赖 xtuner.v1.loss.* 与 xtuner.v1.utils.*,没有任何 RL 专属依赖。建议二选一:

  1. 把 critic_loss.py 移到 xtuner/v1/loss/critic_loss.py(与 LMHeadLossContext 同层),在 xtuner.v1.rl.loss 继续 re-export 保持兼容;
  2. 至少改为 TYPE_CHECKING import(该类型仅用于注解与 overload):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from xtuner.v1.rl.loss.critic_loss import CriticLossContext

方案 1 更能提升 Locality:head 与其 loss ctx 留在同一层,调用者无需跨层学习接口。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

这个要修改,不应该让sft的运行环境依赖ray

Comment thread xtuner/v1/loss/critic_loss.py Resolved
Comment thread tests/engine/test_critic_sft.py Resolved
Comment thread xtuner/v1/model/base.py Outdated
Comment on lines +618 to +629
self._validate_head_checkpoint(Path(hf_path))
hf_loader = HFCheckpointLoader(hf_path)
loaded_keys, unloaded_keys, missing_keys = self._load_params(hf_loader, strict=strict)
# XTuner runtime critic 模型内部仍然叫:lm_head.weight
if self._maybe_init_unloaded_critic_head(hf_loader):
# 如果随机初始化 ValueHead,对 loaded_keys、unloaded_keys、missing_keys 进行更新
# missing_keys 是 HF 名,loaded_keys / unloaded_keys 是运行时名
loaded_keys.add("lm_head.weight")
unloaded_keys.discard("lm_head.weight")
head_load_spec = self.load_spec_mapping.get("lm_head.weight")
if head_load_spec is not None:
missing_keys -= set(head_load_spec.global_hf_keys)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude: [设计] 公开接口 from_hf 的主流程里混进了低层 key 集合修补:loaded_keys.add("lm_head.weight")、unloaded_keys.discard(...)、查 load_spec_mapping、missing_keys -= global_hf_keys,还带着魔法运行时名 "lm_head.weight"。按线性业务流程,高层只应表达“校验 head → 加载参数 → 补齐缺失 critic head”,三类 key 的一致性规则属于实现细节,且目前“决定是否初始化”和“修正 key 集合”这两半规则被拆在两处,新增 head 类型时要同时改调用点和私有方法。

建议收敛为单步,让该 Module 同时拥有决策、初始化与 key 对账:

self._validate_head_checkpoint(Path(hf_path))
hf_loader = HFCheckpointLoader(hf_path)
loaded_keys, unloaded_keys, missing_keys = self._load_params(hf_loader, strict=strict)
return self._init_missing_critic_head(hf_loader, loaded_keys, unloaded_keys, missing_keys)

收益:from_hf 保持同一抽象层级;critic head 的命名与 key 对账规则集中在一处,便于单测直接针对该方法验证。

Comment thread xtuner/v1/model/dense/dense.py Outdated
Comment on lines +224 to +231
if self.config.head_type == "value_head":
head_fp32 = self.fsdp_config.fp32_value_head
else:
lm_head_mp_policy = mp_policy
head_fp32 = self.fsdp_config.fp32_lm_head
if head_fp32:
head_mp_policy = MixedPrecisionPolicy(param_dtype=torch.float32, reduce_dtype=torch.float32)
else:
head_mp_policy = mp_policy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude: [设计] [复杂] head 的策略判断散落在多个调用点,缺少 Locality:

  • dense.py:68、dense.py:272、dense.py:308 三处重复 config.tie_word_embeddings and config.head_type != "value_head";
  • fp32 选择逻辑在 dense.py 本段与 moe.py:1407-1414 逐字重复(head_type == "value_head" ? fp32_value_head : fp32_lm_head);
  • 落盘换名在 base.py:_to_hf_key_list 中靠临时改写共享状态 self.config.tie_word_embeddings = False 加正则 _remap_critic_hf_keys 实现。

即“value head 不 tie / 用独立 fp32 开关 / 落盘叫 value_head”这三条本属 head 自身的规则,被要求由每个模型族的 __init__ 与 fully_shard 分别记住;再加一个 head 类型或新模型族时需要同步修改 5+ 处。

建议把策略收敛到 build_head() 所在的 config/head 接缝,例如在 TransformerConfig 上提供:

@property
def tie_head(self) -> bool:
    return self.tie_word_embeddings and self.head_type != "value_head"

def head_fp32(self, fsdp_cfg: FSDPConfig) -> bool:
    return fsdp_cfg.fp32_value_head if self.head_type == "value_head" else fsdp_cfg.fp32_lm_head

dense/moe 只消费 config.tie_head 与 config.head_fp32(fsdp_cfg);HF 名则由 head 侧声明(而非 BaseModel 临时改 config 再正则替换),避免对共享 config 的瞬时写入。收益:规则与缺陷集中一处,Interface 更小,单测可直接针对 config 行为验证。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

建议这样修改

@YanhuiDua

Copy link
Copy Markdown
Collaborator Author

除了已提出的claude的意见外,我没有其他意见了。另外,base.py 的改动看起来是为支持 critic model 的补丁,但我暂时也没想到更好的实现,base.py 也请 @jayhenry review下

@YanhuiDua
YanhuiDua requested a review from jayhenry October 21, 2026 08:16

@jayhenry jayhenry left a comment

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.

基于 head 1a06068 提交 10 条 inline review:F1–F6、D1、D2、A1、A3,均附建议伪代码。F1/F2 与 build(device="cpu") 参数错误已在同一 head 的 CPU 最小复现中确认;设计候选与待补测试尚未实施。F4/P0 是指定的 SP>1 验证门槛,不表示已证实 SP 数学错误。A3 将最终 load-spec key 集合、head 例外及复制后的严格检查统一留在 _load_params,使 from_hf 保持线性流程。

ProduceBatchResult impact: not affected
RoutedExperts impact: not affected
Ray concurrency impact: not affected

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 全局分母)。

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.

合理,已按照建议修改

model_cfg: TransformerConfig | BaseComposeConfig
optim_cfg: OptimConfig
loss_cfg: BaseRLLossConfig
loss_cfg: BaseRLLossConfig | CriticLossConfig

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.

[F3 / P2] 这里接受 critic config,但当前 worker 的 fit Implementation 仍只支持 actor 数据流。

_pack_one_batch:988 调用 loss_cfg.build(data=loss_inputs, device="cpu"),CriticLossConfig.build 不接受 device(已 CPU 复现 TypeError)。即便补上参数,当前 pack 只准备 advantages/logprobs 等 actor 字段,没有必需的 returns,critic build 仍返回 None。ref/metrics 的两个 isinstance 不能让整个训练 workflow 可替换。

既然本层明确将 critic worker 接入交给 #2186,建议本层先收回 union 与两个守卫,让配置 Interface 与已交付能力一致:

class WorkerConfig(BaseModel):
    loss_cfg: BaseRLLossConfig

# TrainingWorker 恢复既有 actor 契约。
if worker_cfg.loss_cfg.use_kl_loss:
    ...  # 构建 ref model。
extra_info_dict = loss_cfg.finalize_metrics(extra_info_dict, DEVICE)

# #2186 在接通 returns/old_values、CPU 物化、SP 和 fit 后扩展 critic workflow。

这是提前放宽配置的问题,不代表当前 PR 已接通的 PPO 流程发生回归。若保留 union,应通过完整 worker 数据准备入口证明 critic 可训练;仅让 build(device="cpu") 不报错不够。

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.

critic 完整 RL 流程在下一个 PR(#2186)接入。device 参数的原因:xtuner/v1/rl/loss 下的 LossConfig.build 均接收 device(base_loss.py:105,worker 在 _pack_one_batch 固定传 device="cpu",即 worker.py:988),而 xtuner/v1/loss 下的均不接收。critic 的 SFT 与 RL 共用同一个 CriticLossConfig,因此对齐 RL 侧协议、在 build 中增加 device 参数(默认 DEVICE)是合理的,已在 xtuner/v1/loss/critic_loss.py 中添加。

engine.model.train()
loss_ctx = loss_cfg.build(
data={"shifted_labels": shifted_labels, "returns": returns},
sp_mesh=None,

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.

[F4 / P0,验证门槛] 请补齐 SP>1 的真实分布式回归测试。

现有 critic 数值用例均使用 sp_mesh=None,且标准模型批量入口还被 F1 阻断。静态看 critic 的本地 mask→WORLD 分母→autograd WORLD SUM 与 CE token 路径一致;此处 P0 表示要求补齐验证,不表示已证实 SP 数学错误。

至少覆盖 MSE/clipped、SP=2、非整除长度 padding、分片跨 packed sample、某 rank 全 ignore、多 microbatch 共用分母,并与 SP=1 比较 loss、head 与 backbone 的全局梯度:

@pytest.mark.parametrize("loss_kind", ["mse", "clipped"])
@pytest.mark.parametrize("case", [
    "odd_length", "cross_sample", "empty_rank", "microbatches",
])
def test_critic_sp_matches_sp1(loss_kind, case):
    weights, batch = make_tiny_training_case(loss_kind, case)
    ref = run_public_training_case(weights, batch, sp_size=1)
    actual = run_public_training_case(weights, batch, sp_size=2)
    assert_close(actual.loss, ref.loss)
    assert_close(actual.head_grad, ref.head_grad)
    assert_close(actual.backbone_grads, ref.backbone_grads)

run_public_training_case 应创建真实进程组/mesh/tiny model,并走 build_loss_ctx_batch → train_step → clip_grad_norm(do_clip=False),在既有梯度归约完成后重建全局梯度,不 mock loss、attention 或 SP。两次运行使用相同全局数据及初始权重。

还需固定字段契约:build(..., sp_mesh=...) 输入完整 returns/old_values,只切一次;若冻结前向已产出本地 old_values,不能再作为完整序列重复切分。

Comment thread xtuner/v1/model/base.py
"""
cleaned = self._clean_param_name(name)
if getattr(self.config, "head_type", "lm_head") == "value_head" and cleaned.startswith("lm_head"):
return self._remap_critic_hf_keys(self.to_hf_key_list(name))

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.

[D1,Interface] 公开 to_hf_key_list 与新增私有 _to_hf_key_list 对同一参数给出不同语义。

Qwen3.5 critic 的公开调用 to_hf_key_list("lm_head.weight") 仍得到 lm_head.weight,私有 wrapper 才得到实际 I/O 使用的 value_head.weight(均在应用 hf_key_mapping 前)。调用者必须知道应绕过旧公开方法。

运行时 _init_load_spec:1250、严格预检 :1970 已切换,但 xtuner/_testing/testcase.py 的 load_params_from_hf:143、xtuner_ckpt_key:159、materialize_submodule:227 仍调用旧公开入口,critic head 会绕过重命名。

建议保留单一公开完整映射 Interface,将模型族规则下沉为私有 hook:

def to_hf_key_list(self, name):
    keys = self._model_hf_keys(name)  # 模型族 override 迁到这个 hook。
    cleaned = self._clean_param_name(name)
    if getattr(self.config, "head_type", "lm_head") == "value_head" and cleaned.startswith("lm_head."):
        keys = self._remap_critic_hf_keys(keys)
    return keys

# I/O、测试工具和外部消费者继续调用公开 to_hf_key_list。

若暂时保留 wrapper,则需要完整映射的消费者应切换过去;override、wrapper 内部委派与 InternVL 包装不能机械替换,否则可能递归或改变扩展语义。请增加公开方法与真实保存 key 一致、测试工具能加载 critic head 的回归用例。

Comment thread xtuner/v1/model/base.py
else:
return self.to_hf_key_list(name)

def _head_checkpoint_meta(self) -> dict[str, Any]:

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.

[D2,设计候选] 在保持当前 runtime lm_head 的前提下,尝试根据实际 checkpoint keys 判断角色,移除 head metadata 协议。

当前 HF 产物已经包含 value_head,所以这项精简不要求运行时改名。可用模型族 head 命名和显式来源别名解析角色,替代保存、compose 转发、读取 index metadata.head_type 的一套逻辑。

present = set(loader.weight_map)  # 全局 keys,各 rank 一致。
actor_keys, critic_keys = self._checkpoint_head_keys()
has_actor = bool(present & actor_keys)
has_critic = bool(present & critic_keys)
source_role = (
    "mixed" if has_actor and has_critic else
    "critic" if has_critic else "actor" if has_actor else "unknown"
)
if target_is_actor and source_role in ("critic", "mixed"):
    raise RuntimeError("Cannot load a checkpoint containing a value head into an actor")
...  # 继续现有参数校验/加载与缺失 critic head 初始化。
# 同步/异步/compose 停止写 head_type,其他 metadata 与 weight_map 保留。
# 删除 _head_checkpoint_meta 及对应 override/metadata 方向校验。

_checkpoint_head_keys 要拥有模型族前缀与明确别名的解析规则;不能简单搜索子串。自定义 critic.weight 必须有来源映射,未知别名不能靠 shape 猜角色;未找到独立 head 时保留 unknown,因为 tied actor 可能没有独立 lm_head 权重。

这是在明确可识别 key 契约后尝试移除 metadata 的候选,不能承诺未知来源仍能被无条件识别。请通过真实无 head metadata 的 checkpoint 覆盖 critic→actor 拒绝(含 strict=False)、actor→critic 初始化、critic round-trip、自定义别名与 tied actor。

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。

Comment thread xtuner/v1/model/base.py
def _load_params(self, checkpoint_loader: HFCheckpointLoader, strict=True) -> tuple:
matched_hf_keys: set[str] = set(checkpoint_loader.weight_map)
expected_hf_keys: set[str] = set(chain(*map(self.to_hf_key_list, self.state_dict())))
expected_hf_keys: set[str] = set(chain(*map(self._to_hf_key_list, self.state_dict())))

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.

[A3,架构] 建议把最终 key 集合与 head 例外的严格校验留在 _load_params,并复用 load spec。

这个位置本来就负责严格预检,适合直接收敛规则。当前 expected_hf_keys 用 _to_hf_key_list 重新推导,而实际复制和缺头初始化用已应用 hf_key_mapping 的 LoadSpec;应使用同一事实来源。from_hf 保持“校验角色→加载→初始化缺失 head”的线性流程。

关键是同时调整复制后的 missing/unloaded 严格检查:只放行确定会被初始化的 value head,保留 backbone 检查,且不提前把未初始化参数记为 loaded。

def _load_params(self, checkpoint_loader, strict=True):
    present = set(checkpoint_loader.weight_map)
    expected = {
        key for spec in self.load_spec_mapping.values()
        for key in spec.global_hf_keys
    }
    allowed_missing, allowed_extra = self._head_load_exceptions(checkpoint_loader)
    if strict:
        missing = expected - present - allowed_missing
        unexpected = present - expected - allowed_extra
        if missing or unexpected:
            raise RuntimeError(f"Missing: {missing}; unexpected: {unexpected}")

    ...  # 保留当前递归复制、shape 校验,产生 loaded_keys/unloaded_keys/missing_keys。
    if strict:
        bad_missing = missing_keys - allowed_missing
        bad_unloaded = {
            name for name in unloaded_keys
            if not set(self.load_spec_mapping[name].global_hf_keys) <= allowed_missing
        }
        if bad_missing or bad_unloaded:
            raise RuntimeError(f"Missing: {bad_missing}; unloaded: {bad_unloaded}")
    return loaded_keys, unloaded_keys, missing_keys  # 保留原报告,尚未把缺头标为 loaded。

# from_hf 继续:
loaded, unloaded, missing = self._load_params(loader, strict=strict)
return self._init_missing_critic_head(loader, loaded, unloaded, missing)

_head_load_exceptions 只对合法 actor→critic 的缺头情况提供例外:missing 限于会初始化的完整 value-head key 集合,extra 限于明确解析的 actor head;同类 checkpoint 返回空集合,critic→actor 的角色拒绝不应被 strict=False 绕过。模型族前缀/自定义映射要明确,不能忽略所有包含 lm_head 的 key,也不能豁免 backbone。所有 rank 根据全局 key map 一致决定初始化。

请通过公开 from_hf 测试自定义映射 strict 加载、actor→critic strict 缺头补齐、额外 backbone 缺失仍报错,以及成功加载的 value head 不被覆盖。该项是在既有行为上扩展有针对性的 strict 契约,不是已确认的新运行时故障。

Comment thread xtuner/v1/model/base.py
for i in range(self.num_hidden_layers)
]

def build_head(self) -> nn.Linear:

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.

改为 config.head_config.build() 和当前仓库使用惯例更一致

[F5 / P1,设计整改] 建议用 config.head_config.build(...) 表达 head 构建。

attention/router 已采用配置对象拥有 build 的模式。这里把 head_type 分支放进整个 TransformerConfig,后续构造规则仍会堆在模型配置中。建议将选择与构建收敛到 head 配置,提升 Locality,并让 head_config.type 替代独立 head_type,避免双重配置来源。

最小伪代码可用一个配置类,不必引入配置继承层级:

class HeadConfig(BaseModel):
    type: Literal["lm_head", "value_head"] = "lm_head"

    def build(self, hidden_size: int, vocab_size: int):
        if self.type == "value_head":
            return ValueHead(hidden_size, bias=False)
        return LMHead(hidden_size, vocab_size, bias=False)

class TransformerConfig(XTunerBaseModelConfig):
    head_config: HeadConfig = Field(default_factory=HeadConfig)

self.lm_head = config.head_config.build(config.hidden_size, config.vocab_size)

共享尺寸由父配置传入,符合 MHAConfig.build(hidden_size=...) 惯例,无需重复保存尺寸来追求无参 build。请覆盖默认 actor、显式 critic、配置序列化以及 head 类型/形状;已有 head_type 配置如需兼容,在解析入口转换一次。这里是设计整改优先级。

Comment thread xtuner/v1/model/base.py
raise ValueError(f"Invalid head_type: {self.head_type}")

@property
def tie_head(self) -> bool:

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.

没必要改名?

[F6 / P1,设计整改] 建议保留 tie_word_embeddings 为实际执行状态的唯一来源。

这里已比重复条件分支更集中,但 raw tie_word_embeddings=True 与 effective tie_head=False 同时存在,配置与执行多了一层语义。可以在统一 critic 配置构造/转换入口归一化为 False,构建、FSDP 重绑和 HF mapper 继续消费原字段,并删除 tie_head 派生属性。

# 统一 actor -> critic 配置转换入口;采用 F5 后改为设置 head_config。
critic_cfg = actor_cfg.model_copy(deep=True)
critic_cfg.head_type = "value_head"
critic_cfg.tie_word_embeddings = False

# 模型构建、FSDP 重绑、HF mapper 继续使用原字段。
if config.tie_word_embeddings:
    self.lm_head.weight = self.embed_tokens.weight

相同归一化规则也需覆盖直接构造和 HF 配置转换。当前测试创建配置后直接赋值 head_type,这些路径应统一;只加构造期 validator 覆盖不到后续赋值,也不要再在 key 查询时临时翻转字段。

请从 tie=True 的 actor 转换并验证 critic 导出配置为 False、head/embedding 不共享参数、保存重载一致,同时保持 actor 的原 tie 行为。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants