Skip to content

[Feat] Add off-policy masking for partial rollouts - #2003

Open
YanhuiDua wants to merge 4 commits into
InternLM:mainfrom
YanhuiDua:support-offpolicy-mask
Open

[Feat] Add off-policy masking for partial rollouts#2003
YanhuiDua wants to merge 4 commits into
InternLM:mainfrom
YanhuiDua:support-offpolicy-mask

Conversation

@YanhuiDua

@YanhuiDua YanhuiDua commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

背景

在 partial rollout 场景下,同一条 response 中的 token 可能由不同版本的 policy 生成。

现有 sequence staleness 使用 response 中最早的模型版本表示整条样本的 staleness,无法区分:

  • 已经过期的旧 policy token;
  • 仍然可以参与训练的新 policy token。

本 PR 引入 token staleness,使系统能够:

  • 按 token 排除过期的 response token;
  • 当一条样本不再包含任何有效训练 token 时,将其标记为过期;
  • 只重置实际过期样本的 response;
  • 保留同一 rollout group 中其他未过期样本的生成结果。

主要改动

  1. Token 级 staleness mask:增加 max_token_staleness 配置,计算方式与 seq staleness 相同,在 take batch 阶段统一更新 response mask
  2. Token-expired 生命周期: ReplayBuffer 通过统一的生命周期逻辑处理 sequence 和 token staleness,执行时机为 replay_buffer.putrefresh_staleness,与更新 sequence staleness 相同
  3. 增强 tail_batch_trigger_size 语义
  • tail_batch_trigger_size = -1: 关闭过期 group 的 rerollout
  • tail_batch_trigger_size = 0: 立即优先 rerollout 过期 group,但保持普通异步生产和 oversampling 策略
  • tail_batch_trigger_size > 0: 等待 EXPIRED pool 累积到指定 group 数量后进入 tail-batch 模式

说明:这个PR不改动agentic RL的过期语义

token staleness 处理关键阶段

如何采样

  flowchart LR
      A[刷新 staleness] --> B[统计 EXPIRED groups]
      B --> C{tail_batch_trigger_size}
      C -- -1 --> D[采样 ABORTED 或新数据]
      C -- 0 且存在 EXPIRED --> E[优先采样 EXPIRED group]
      C -- 大于0且达到阈值 --> F[进入 tail batch]
      C -- 大于0但未达到阈值 --> D
      E --> G[保持正常异步生产和 oversampling]
      F --> H[关闭本轮 oversampling]
      D --> I[执行 rollout]
      G --> I
      H --> I
Loading

如何判断一个样本是否过期

 flowchart LR
      A[刷新 seq staleness] --> B{超过 seq threshold}
      B -- 是 --> C[state 标记为 EXPIRED]
      B -- 否 --> D{普通 rollout 且配置 token threshold}
      D -- 否 --> E[state 保持有效]
      D -- 是 --> F[计算 effective response mask]
      F --> G{是否存在有效 token}
      G -- 否 --> C
      G -- 是 --> E
      C --> H[StorageItem 标记为 EXPIRED]
      H --> I{是否允许 rerollout}
      I -- 是 --> J[只清空实际过期 state 的 response]
      I -- 否 --> K[丢弃整个 group]
Loading

配置示例

AsyncProduceStrategyConfig(
    max_staleness=2,
    max_token_staleness=0,
    tail_batch_trigger_size=0,
)

该配置表示:

  • sequence staleness 允许额外滞后两个权重同步周期;
  • token staleness 只接受当前权重同步周期内生成的 token;
  • 当样本的有效 token 全部过期后,立即允许 rerollout;
  • immediate rerollout 不会使生产策略切换到 tail-batch 模式。

@jayhenry

Copy link
Copy Markdown
Collaborator

@claude review

1 similar comment
@YanhuiDua

Copy link
Copy Markdown
Collaborator Author

@claude review

Comment thread xtuner/v1/rl/rollout/worker.py Outdated
Comment thread xtuner/v1/data_proto/rl_data.py Outdated
Comment thread xtuner/v1/rl/agent_loop_manager/agent_loop_manager.py Outdated
Comment thread xtuner/v1/rl/replay_buffer.py
Comment thread xtuner/v1/rl/rollout/utils.py Outdated
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude: ## Summary

Adds off-policy masking for partial rollouts: response tokens from prior partial-rollout rounds are masked out of the loss via response_mask, with per-token model-step tracking to refresh masks at consumption time in the replay buffer.

Issues

Critical

  • xtuner/v1/rl/rollout/worker.py: Missing else branch in the early-exit path when enable_partial_rollout is False. Execution falls through to the generation loop and makes unnecessary backend requests.

Warning

  • xtuner/v1/data_proto/rl_data.py:329: cast on a potentially-None response_mask won't prevent a runtime crash in zip(..., strict=True). Samples without a response_mask (e.g., pre-existing on-policy samples in the replay buffer) will crash when masking is refreshed.
  • xtuner/v1/rl/agent_loop_manager/agent_loop_manager.py:143-145: Configuration validation uses assert which is stripped under -O. Should raise ValueError. Same issue in disagg_agent_loop_manager.py.
  • xtuner/v1/rl/replay_buffer.py:507: task_mask_offpolicy is now a required parameter — this is a breaking interface change for external callers.
  • xtuner/v1/rl/rollout/utils.py:296-302: Off-policy masking path assumes routed_experts from the backend always covers the complete sequence. If the backend returns only new-token routing (as in some LMDeploy configs), this replacement would be incorrect.

Nit

  • Style: PR uses from __future__ import annotations in produce_utils.py (commit 3) which changes forward-ref quoting style — fine, but the commit message doesn't mention this cleanup.

ProduceBatchResult impact

Not directly affected — no fields on ProduceBatchResult are added or changed. However, the response_mask field on RolloutState (which feeds into rollout_states inside the result) is now mutated by refresh_response_mask at consumption time — downstream consumers should expect masked-out tokens.

RoutedExperts impact

The off-policy masking path replaces routed_experts instead of concatenating. Old history refs are freed via free_object_refs — ownership transfer is correct for this path. The non-masking path retains the existing concatenation semantics unchanged.

Ray concurrency impact

Not affected — no changes to Ray actor methods, decorators, or concurrency groups.

Verdict

REQUEST_CHANGES — the missing else branch in worker.py is a regression for all users with enable_partial_rollout=False (the default).

@YanhuiDua

Copy link
Copy Markdown
Collaborator Author

@claude review

Comment thread xtuner/v1/rl/agent_loop_manager/disagg_agent_loop_manager.py Outdated
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude: ## Summary

PATCH 4/4 addresses all critical and most warning-level issues from the previous review (missing else branch, cast on None, assertValueError, breaking interface). Good work on the fix iteration.

One remaining issue:

Warning

  • disagg_agent_loop_manager.py:84-87: The assertValueError conversion from PATCH 4 was applied to agent_loop_manager.py but missed in disagg_agent_loop_manager.py. Same rationale: assert is stripped under -O.

ProduceBatchResult impact

Not affected — no ProduceBatchResult fields are added or changed. The response_mask on RolloutState (inside rollout_states) is now mutated by refresh_response_mask at consumption time.

RoutedExperts impact

Off-policy path replaces (rather than concatenates) routed_experts, freeing the old ref via free_object_refs. Ownership is correct for this path.

Ray concurrency impact

Not affected.

Verdict

COMMENT — the remaining assert is a minor fix (one-line change in disagg_agent_loop_manager.py). The critical issues from the first review are resolved.

@YanhuiDua
YanhuiDua force-pushed the support-offpolicy-mask branch from 39de861 to 0561379 Compare August 11, 2026 11:14
Comment thread xtuner/v1/rl/rollout/utils.py Outdated
Comment thread xtuner/v1/rl/replay_buffer.py Outdated
@jayhenry

jayhenry commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

对 partial rollout 样本做 off-policy mask 时, off-policy 的定义应该也要跟当前"过期"的定义一致?
或者新增一个字段 max_trainable_response_staleness: int | None = None 替换 off policy partial rollout

  • None:所有未过期 token 都可训练,对应原来的 False。
  • 0:只训练当前权重同步周期生成的 token。
  • N:允许额外 N 个同步周期的 token 参与 loss。
  • max_staleness:仍只负责整组过期、reset 和 rerollout,保持修改前语义。

@jayhenry

Copy link
Copy Markdown
Collaborator

@claude review

Comment thread xtuner/v1/data_proto/rl_data.py
Comment thread xtuner/v1/rl/agent_loop_manager/produce_utils.py Outdated
Comment thread xtuner/v1/rl/agent_loop_manager/produce_utils.py Outdated
Comment thread xtuner/v1/data_proto/utils.py Outdated
Comment thread xtuner/v1/rl/agent_loop_manager/__init__.py
Comment thread xtuner/v1/train/rl_trainer.py
Comment thread xtuner/v1/rl/agent_loop_manager/producer.py
Comment thread tests/rl/test_staleness_policy.py Outdated
Comment thread tests/rl/test_staleness_policy.py Outdated
Comment thread tests/rl/test_multi_task_agent_loop_manager.py
@YanhuiDua
YanhuiDua force-pushed the support-offpolicy-mask branch from 04f0f12 to 1f40fff Compare August 13, 2026 06:41
@YanhuiDua

Copy link
Copy Markdown
Collaborator Author

@claude review

Comment thread xtuner/v1/train/rl_trainer.py
Comment thread xtuner/v1/train/rl_trainer.py Outdated
Comment thread xtuner/v1/data_proto/rl_data.py
Comment thread xtuner/v1/rl/agent_loop_manager/produce_utils.py Outdated
Comment thread xtuner/v1/rl/agent_loop_manager/producer.py
Comment thread xtuner/v1/rl/rollout/utils.py
Comment thread tests/rl/test_multi_task_agent_loop_manager.py Outdated
Comment thread tests/rl/test_staleness_policy.py
Comment thread tests/rl/test_prepare_train_data.py Outdated
Comment thread tests/rl/test_multi_task_agent_loop_manager.py
@jayhenry
jayhenry force-pushed the support-offpolicy-mask branch from 1f40fff to 8d65892 Compare August 13, 2026 09:29
@jayhenry

Copy link
Copy Markdown
Collaborator

@claude review

@YanhuiDua
YanhuiDua force-pushed the support-offpolicy-mask branch from 8d65892 to 43c9e5e Compare August 13, 2026 11:40
@YanhuiDua

Copy link
Copy Markdown
Collaborator Author

@claude review

@YanhuiDua
YanhuiDua force-pushed the support-offpolicy-mask branch from 8694013 to b26bc3f Compare August 13, 2026 13:21
@jayhenry

Copy link
Copy Markdown
Collaborator

@claude review

@github-actions

Copy link
Copy Markdown
Contributor

Claude:

Summary

本 PR 引入 token 级 staleness:新增 max_token_staleness 配置,在 ReplayBuffer 侧用统一的 _apply_staleness_lifecycle 处理 sequence/token 两级过期(只重置真正过期 state 的 response,保留同组新鲜成员),在 take_train_batch 侧按 token 新鲜度重算 response_mask,并把 tail_batch_trigger_size 扩展为 -1 / 0 / N 三态语义(默认值由 0 改为 -1)。

ProduceBatchResult impact: 三处 trainer-visible 变化 —(1) produced_tokens 语义由「整条 response 长度」改为「本轮新增 token 数」;(2) token-expiry 会把原本 COMPLETED 的 group 翻成 EXPIRED,影响 leftover_completed / leftover_expired / produced_samples;(3) tail_batch_trigger_size=0 使 EXPIRED group 变为可重试(此前直接丢弃)。

RoutedExperts impact: token-expiry 只对过期 state 调 reset_rollout_response(内部 free_object_refs),同组保留的 fresh state 会在 EXPIRED pool 中继续持有 routed_experts ObjectRef 直到 rerollout 完成或整组丢弃 —持有窗口变长,但 owner 唯一、非 retryable 分支走 discard_rollout_state,未发现泄漏。

Ray concurrency impact: not affected(无 ray.method、concurrency group 或 actor 构造改动)。

Main Flowchart after this PR

flowchart TD
    A[put / refresh_staleness] --> B[_apply_staleness_lifecycle]
    B --> C{seq_staleness >= stale_threshold}
    C -- 是 --> F[标记该 state EXPIRED]
    C -- 否 --> D{agentic group 或 token_stale_threshold 为空}
    D -- 是 --> E[保持原状态]
    D -- 否 --> G[calculate_effective_response_mask]
    G --> H{存在有效 token}
    H -- 是 --> E
    H -- 否 --> F
    F --> I{expired_groups_retryable}
    I -- 是 --> J[只重置过期 state 的 response<br/>整组进 EXPIRED pool]
    I -- 否 --> K[整组丢弃<br/>含仍新鲜成员]
    E --> L[take_batch]
    J --> M[从 EXPIRED pool rerollout]
    M --> A
    L --> N[take_train_batch 重算 response_mask]
    N --> O[_prepare_train_data / 训练]
    style K fill:#ffcccc,stroke:#cc0000
    style N fill:#ffe0b2,stroke:#e65100
    style G fill:#e3f2fd,stroke:#1565c0
    style J fill:#e3f2fd,stroke:#1565c0
Loading

核心原理实现与单测

核心实现为三段:calculate_effective_response_mask(semantic mask ∩ token staleness mask)、ReplayBuffer._apply_staleness_lifecycle(put/refresh 两个时机统一做 seq+token 过期判定与清理)、take_train_batch(消费期写回 effective mask)。

真实代码路径已被覆盖的部分:

  • mask 纯函数行为(阈值放宽、与 semantic mask 求交、rerollout 后无 semantic mask):tests/rl/test_staleness_policy.py::TestTokenStalenessMask
  • 生命周期经公开 put / refresh_staleness / get + 真实 Naive/Pandas storage:tests/rl/test_replay_buffer.py 新增 5 例,含 token/seq 过期保留新鲜成员、agentic group 跳过、非 retryable 整组丢弃。
  • 消费期 mask 经公开 AgentLoopManager.produce_batchtests/rl/test_multi_task_agent_loop_manager.py 2 例(普通与 agentic 分支)。
  • tail_batch_trigger_size=0 的采样语义与 disagg put-time consumer step:tests/rl/test_producer.py 2 例。

覆盖缺口见「单测建议」。

抽象与信息隐藏评估

  • Warning xtuner/v1/rl/agent_loop_manager/producer.py#L210disagg_producer.py#L265 同):max_token_staleness 的 docstring 声明「不会使 group 过期或重跑,只收缩 response_mask」,与实现的 token-expired 生命周期相反,配合新默认 tail_batch_trigger_size=-1 会静默丢弃整组。
  • Warning xtuner/v1/rl/agent_loop_manager/produce_utils.py#L621:同一条 token-staleness 规则(含 agentic 判定)在 produce_utilsreplay_buffer 两个 Module 各实现一遍,且 guard 不一致,规则的 Locality 被打散。

单测建议

  • Warning xtuner/v1/rl/replay_buffer.py#L490:PR 的头号行为「EXPIRED group 保留 fresh 成员后重跑」只有 buffer 字段级断言,缺少覆盖重跑路径的用例(该行为依赖 worker.generatestatus == COMPLETED 早退才不会覆写保留的 response)。
  • Warning xtuner/v1/rl/agent_loop_manager/produce_utils.py#L173produced_tokens 改为只统计本轮新增 token,这一 ProduceBatchResult 字段的语义变化没有任何断言覆盖。

其他 Issues

  • Warning xtuner/v1/rl/agent_loop_manager/producer.py#L233disagg_producer.py#L278 同):tail_batch_trigger_size 旧默认值 0 的语义被翻转(原「丢弃过期组」→ 现「立即 rerollout」),显式写 0 的存量配置行为静默改变且无提示。

Verdict

REQUEST_CHANGES

Comment on lines +206 to +212
max_token_staleness (int | None): Maximum extra weight-sync periods a
response token may lag behind before it is masked out of the loss.
``None`` disables token-level masking, ``0`` accepts only tokens
produced within the current sync period, and ``N`` allows ``N``
extra periods. Unlike ``max_staleness``, this does not expire or
re-roll a group; it only shrinks ``response_mask``. Defaults to
None.

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: [设计] docstring 与实现相反:这里声明 max_token_staleness “does not expire or re-roll a group; it only shrinks response_mask”,但 ReplayBuffer._apply_staleness_lifecycle 在 effective mask 全 0 时会把该 state 标为 EXPIRED、整组翻入 EXPIRED pool(replay_buffer.py#L471-L500)。

更关键的是它和新默认值的组合:tail_batch_trigger_size 默认 -1expired_groups_retryable=Falseputif status == Status.EXPIRED and not expired_groups_retryable: return,整组(含仍新鲜的成员,discard_rollout_state 释放全部字段)被直接丢弃。也就是说,用户只按 docstring 打开 max_token_staleness=0 而不动 tail_batch_trigger_size,得到的不是“收缩 mask”,而是静默丢数据。

ProduceBatchResult impact: 原本会计入 COMPLETED 的 group 变成 EXPIRED 或被丢弃,leftover_completed/leftover_expired/produced_samples 都会随之改变。

建议两件事一起做:

  1. 改写 docstring,明确 token staleness 也会触发 group 级 expire/rerollout(disagg_producer.py:265 同样文案);
  2. build() 里对 max_token_staleness is not None and tail_batch_trigger_size < 0 给出 warning 或直接校验失败,避免这个组合静默生效。

Comment on lines +616 to +628
for task in task_runners:
if task.token_stale_threshold is None:
continue
for group in batch_by_task.get(task.task_name, []):
# NOTE: input_ids/labels 表示 agentic 训练分支,当前暂不支持 agentic token-staleness masking。
if any(item.input_ids is not None or item.labels is not None for item in group):
continue
for rollout_state in group:
rollout_state.response_mask = calculate_effective_response_mask(
rollout_state,
current_train_step=current_train_step,
token_stale_threshold=task.token_stale_threshold,
)

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: [设计] 同一条 token-staleness 规则被拆到两个 Module 各写一遍,且两处 guard 不一致。

replay_buffer.py 侧(L457-L479)先算 is_agentic_group = any(item.input_ids is not None or item.labels is not None ...),再对每个 state 加了 if not item.response_ids or (item.response_mask is not None and not any(item.response_mask)): continue 才调用 calculate_effective_response_mask;这里的消费期只重复了 agentic 判定,缺少 response_ids 与全 0 semantic mask 的 guard。

结果是两个 Seam 对「哪些 state 适用 token mask」的判定可以不一致:被 buffer 明确跳过评估的 state,在 take_train_batch 里仍会被送进 calculate_effective_response_mask(该函数用 cast 假设 response_ids/response_model_steps 非 None,已有单独意见)。同时,未来任何一处修规则都必须记得改另一处。

建议把这条规则收敛成 rl_data.py 里紧邻 calculate_effective_response_mask 的一个 Deep 函数,例如:

def apply_effective_response_mask(group, *, current_train_step, token_stale_threshold) -> list[bool]:
    """返回每个 state 是否已无可训练 token;内部统一处理 agentic 跳过与空 response guard。"""

_apply_staleness_lifecycle 用它的返回值决定 expire,take_train_batch 用它写回 mask。这样规则、guard 和 agentic 例外集中在一处(Locality),两个调用者只需知道一个小 Interface,也可以直接对这个公开函数补单测。

max_staleness: int = Field(default=0, ge=0)
tail_batch_trigger_size: int = 0
max_token_staleness: int | None = Field(default=None, ge=0)
tail_batch_trigger_size: int = Field(default=-1, ge=-1)

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: [兼容性] 默认值从 0 改为 -1 的同时,0 本身的语义也被翻转了:expired_groups_retryabletail_batch_trigger_size > 0 改成 >= 0,所以显式写 0 的存量配置(如 TAIL_BATCH_TRIGGER_SIZE)从「丢弃过期组」变成「立即 rerollout」。ProduceBatchResult impact: leftover_expiredproduced_samples 随之改变。建议启动时对 0 打一条语义说明日志。


async def put_generated_group(self, group: list[RolloutState]) -> bool:
produced_tokens = sum(len(item.response_ids) for item in group if item.response_ids is not None)
produced_tokens = sum(len(item.response_ids or []) - len(item.response_model_steps or []) for item in group)

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: [测试] produced_tokens 语义改为「仅本轮新增 token」(原为整条 response 长度),这是 ProduceBatchResult 的 trainer-visible 字段,但没有任何断言覆盖。建议补一例:同一 group 连续两轮 partial rollout,断言第二轮只计入新增 token。

Comment on lines +487 to +494
return storage_status

# 4. cleanup sample or cleanup response for expired sample
if expired_groups_retryable:
for item, expired in zip(group, expired_mask):
if expired:
item.status = Status.EXPIRED
reset_rollout_response(item)

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: [测试] 本 PR 的头号行为「只重置过期 state、保留同组新鲜成员」目前只有 buffer 字段级断言,缺少重跑路径覆盖:保留成员不被覆写依赖 worker.generatestatus == COMPLETED 早退,建议补一例覆盖 EXPIRED group 重跑后新鲜 response 仍在、整组回到 COMPLETED。

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.

2 participants