Prhub

#6818 [reward] feat: colocated reward model for v1 sync/colocate_async trainer

原始 PR 作者 Begunner 合并时间 2026-06-23 16:59 文件变更 5 提交数 4 评论 1 代码增减 +406 / -5

执行摘要

实现 V1 PPO 训练器共置奖励模型评分

之前 V1 PPO 训练器的 _compute_reward_colocate 仅抛出 NotImplementedError,无法实际使用共置奖励模型。该 PR 填补了这一空白,使得奖励模型可以与 rollout 共享 GPU,避免独立部署带来的额外资源开销。PR 描述明确要求“Implement colocated reward model scoring for V1 PPO trainer”并添加测试。

值得精读,特别是 _compute_reward_colocate 的实现和注意力掩码的构建方式,展示了如何在 PPO 训练器中集成共置奖励模型。对应的单元测试设计也值得参考。团队成员应了解 separate_async 模式的限制。

讨论亮点

该 PR 的主要设计决策体现在 trainer_separate_async.py 中的断言:共置奖励模型在 separate_async 模式下不被支持,因为独立 rollout 从不暂停,无法释放 GPU 内存供奖励模型复用。yyDing1 审批通过(LGTM),没有其他争议。

实现拆解

按照以下步骤实现:

  1. 实现核心方法 _compute_reward_colocateverl/trainer/ppo/v1/trainer_base.py):从 TransferQueue 中读取 promptsresponsesraw_prompt,计算每行长度并构造右填充注意力掩码,组装 DataProto 输入,调用 RewardLoopManager.compute_rm_score 获取奖励分数,然后将结果写回 TransferQueue。同时新增辅助方法 _lengths_to_mask 用于根据有效长度生成布尔掩码。

  2. 阻止 separate_async 模式使用共置模型verl/trainer/ppo/v1/trainer_separate_async.py):在 PPOTrainerSeparateAsync.__init__ 中添加断言,如果启用了奖励模型且 enable_resource_pool=False(即共置模式),则抛出 AssertionError,引导用户使用独立资源池模式。

  3. 新增 CPU 单元测试tests/trainer/ppo/v1/test_compute_reward_colocate_on_cpu.py):在不依赖 GPU 运行时的条件下,独立测试 _lengths_to_mask 的正确性,以及注意力掩码与 RewardManagerBase.assemble_rm_scores 的契约(即掩码尾部求和等于有效响应长度)。

  4. 新增端到端回归测试脚本tests/special_e2e/run_v1_colocate_async_disrm.sh):在 2+ GPU 节点上运行 V1 colocate_async 训练器,使用 Skywork-Reward 模型作为共置判别式奖励模型,验证完整训练流程。

  5. CI 配置更新.github/workflows/reward_model_vllm.yml):将新的 E2E 测试脚本加入触发器路径,并添加运行步骤。

文件 模块 状态 重要度
verl/trainer/ppo/v1/trainer_base.py 训练器 modified 7.73
tests/trainer/ppo/v1/test_compute_reward_colocate_on_cpu.py 单元测试 added 7.73
tests/special_e2e/run_v1_colocate_async_disrm.sh E2E 测试 added 6.11
verl/trainer/ppo/v1/trainer_separate_async.py 训练器 modified 5.44
.github/workflows/reward_model_vllm.yml CI modified 3.4

关键符号

_compute_reward_colocate _lengths_to_mask

关键源码片段

verl/trainer/ppo/v1/trainer_base.py core-logic

核心逻辑变更,实现共置奖励模型评分方法和长度转掩码辅助函数

# Copyright 2024 Bytedance Ltd. and/or its affiliates
# (license header omitted for brevity)def _compute_reward_colocate(self, batch: KVBatchMeta, metrics: dict | None = None) -> KVBatchMeta:
    """Compute the reward score with a colocated reward model."""
    assert self.reward_loop_manager is not None, "RewardLoopManager is None"
​
    # 1. 从 TransferQueue 读取 prompts、responses 和 raw_prompt
    fields = ["prompts", "responses", "raw_prompt"]
    data = tq.kv_batch_get(keys=batch.keys, partition_id=batch.partition_id, select_fields=fields)
​
    prompt_lengths = data["prompts"].offsets().diff()
    response_lengths = data["responses"].offsets().diff()
    prompts = data["prompts"].to_padded_tensor(padding=self.tokenizer.pad_token_id)
    responses = data["responses"].to_padded_tensor(padding=self.tokenizer.pad_token_id)
​
    # 2. 重建注意力掩码: [prompt_mask | response_mask]
    prompt_mask = self._lengths_to_mask(prompt_lengths, prompts.size(1))
    response_mask = self._lengths_to_mask(response_lengths, responses.size(1))
    attention_mask = torch.cat([prompt_mask, response_mask], dim=1)
​
    # raw_prompt 是非张量字段,来自 TransferQueue 的类型多样,list() 统一为列表
    raw_prompts = list(data["raw_prompt"])
    raw_prompt_arr = np.empty(len(raw_prompts), dtype=object)
    raw_prompt_arr[:] = raw_prompts
​
    rm_input = DataProto(
        batch=TensorDict(
            {"prompts": prompts, "responses": responses, "attention_mask": attention_mask},
            batch_size=len(batch),
        ),
        non_tensor_batch={"raw_prompt": raw_prompt_arr},
    )
​
    # 3. 调用 RewardLoopManager 计算奖励分数(内部管理唤醒 / 睡眠模型)
    rm_output = self.reward_loop_manager.compute_rm_score(rm_input)
​
    # 4. 将 rm_scores 写回 TransferQueue(省略奖励额外信息的写回)
    padded_rm_scores = rm_output.batch["rm_scores"]
    rm_scores = torch.nested.as_nested_tensor(
        [padded_rm_scores[i, : response_lengths[i]] for i in range(len(batch))],
        layout=torch.jagged,
    )
    # 实际写回逻辑包括 kv_batch_put 等,此处省略
    return batch
tests/trainer/ppo/v1/test_compute_reward_colocate_on_cpu.py test-coverage

CPU 单元测试,验证掩码构造和奖励分数组装契约,不依赖 GPU 运行时

# Copyright 2024 Bytedance Ltd. and/or its affiliates
# (license header omitted for brevity)def _lengths_to_mask(lengths: torch.Tensor, width: int) -> torch.Tensor:
    """Standalone copy of ``PPOTrainer._lengths_to_mask`` 用于单元测试。    后续保持与 trainer_base.py 同步。直接导入 trainer 会引入大量运行时依赖(ray、transfer_queue、vllm)。
    """
    positions = torch.arange(width, device=lengths.device).unsqueeze(0)
    return (positions < lengths.unsqueeze(1)).to(torch.int64)
​
​
def _assemble_rm_scores(prompts, attention_mask, responses, scores):
    """Replica of ``RewardManagerBase.assemble_rm_scores`` 用于断言契约。"""
    prompt_length = prompts.size(1)
    valid_response_length = attention_mask[:, prompt_length:].sum(dim=1)
    rm_scores = torch.zeros_like(responses, dtype=torch.float32)
    rm_scores[torch.arange(rm_scores.size(0)), valid_response_length - 1] = rm_scores.new_tensor(scores)
    return rm_scores
​
​
class TestLengthsToMask:
    def test_basic_right_padding(self):
        lengths = torch.tensor([1, 3, 2])
        mask = _lengths_to_mask(lengths, width=4)
        expected = torch.tensor([[1, 0, 0, 0], [1, 1, 1, 0], [1, 1, 0, 0]], dtype=torch.int64)
        assert torch.equal(mask, expected)
​
    def test_full_and_empty_rows(self):
        lengths = torch.tensor([0, 4])
        mask = _lengths_to_mask(lengths, width=4)
        expected = torch.tensor([[0, 0, 0, 0], [1, 1, 1, 1]], dtype=torch.int64)
        assert torch.equal(mask, expected)
​
    def test_row_count_matches_lengths(self):
        lengths = torch.tensor([2, 2, 2, 2, 2])
        mask = _lengths_to_mask(lengths, width=3)
        assert mask.shape == (5, 3)
​
​
class TestAttentionMaskContract:
    def test_valid_response_length_recovered(self):
        prompt_lengths = torch.tensor([2, 1, 3])
        response_lengths = torch.tensor([3, 2, 1])
        prompt_width, response_width = 4, 4
        prompt_mask = _lengths_to_mask(prompt_lengths, prompt_width)
        response_mask = _lengths_to_mask(response_lengths, response_width)
        attention_mask = torch.cat([prompt_mask, response_mask], dim=1)
        recovered = attention_mask[:, prompt_width:].sum(dim=1)
        assert torch.equal(recovered, response_lengths)
​
    def test_score_lands_on_last_valid_response_token(self):
        # 验证分数被放置在最后一个有效响应令牌位置,且其他地方为零
        prompt_lengths = torch.tensor([2, 1])
        response_lengths = torch.tensor([3, 2])
        prompt_width, response_width = 4, 5
        prompts = torch.zeros((2, prompt_width), dtype=torch.int64)
        responses = torch.zeros((2, response_width), dtype=torch.int64)
        prompt_mask = _lengths_to_mask(prompt_lengths, prompt_width)
        response_mask = _lengths_to_mask(response_lengths, response_width)
        attention_mask = torch.cat([prompt_mask, response_mask], dim=1)
        scores = [0.5, -1.0]
        rm_scores = _assemble_rm_scores(prompts, attention_mask, responses, scores)
        assert rm_scores[0, 2].item() == 0.5
        assert rm_scores[1, 1].item() == -1.0
        assert rm_scores.sum().item() == (0.5 - 1.0)
        assert torch.equal(rm_scores.sum(dim=1), torch.tensor(scores))

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  • GPU 内存竞争:共置模式下奖励模型与训练/rollout 共享 GPU,若配置不当可能导致 OOM。E2E 测试中通过降低 gpu_memory_utilization 和启用 free_cache_engine 缓解,但用户需自行调参。
  • TransferQueue 依赖:核心路径依赖 TransferQueue 的 kv_batch_getkv_batch_put,若队列状态异常可能导致训练卡死。
  • separate_async 模式限制:通过断言阻止了共置模型的误用,但可能对期望在 separate_async 下使用共置模型的用户造成困惑,需文档说明。

该 PR 使 V1 PPO 训练器(sync / colocate_async)能够使用共置奖励模型,降低了对额外 GPU 资源的需求,提高了硬件利用率。影响范围限于 V1 PPO 训练器的奖励计算管道,不影响 V0 或其他后端。单独的 separate_async 模式被显式排除,需用户明确使用独立资源池。

共置模型 GPU 内存竞争 separate_async 模式显式禁止 依赖 TransferQueue

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论