Prhub

#6675 [rollout, trainer] fix: handle empty token sequences in _pad_token_ids and metric timing

原始 PR 作者 GJWu-zyx 合并时间 2026-06-14 14:56 文件变更 4 提交数 1 评论 11 代码增减 +93 / -1

执行摘要

修复空 token 序列导致的两个崩溃

partial_rollout=False 配置下,vLLM 请求可能被中断导致没有生成任何 token,现有代码无法处理空序列。PR body 提到 'tokenizer.pad() with empty input returns a dict with list values instead of tensors' 和 'empty response batch yields zero token counts, causing ZeroDivisionError'。这两个都是有效的边界情况,需要优雅处理。

值得合入。同时建议阅读 review 讨论,了解如何处理 tokenizer pad 和 metrics 中类似的边界情况,这些模式在类似项目中具有参考价值。

讨论亮点

讨论集中在两个设计决策上:

  • pad_token_id 选择: 最初使用 torch.zeros 填充,但 reviewer 指出可能使用了错误的 token ID(如 Llama-3 的 pad_token_id 不是 0)。作者随后改为使用 tokenizer.pad_token_id,并增加默认值为 0。
  • 零 token 指标: 最初使用 max(num_tokens, 1) 避免除零,但 reviewer 指出这会导致在无 token 时报告整个 timing 作为 per-token 值,具有误导性。作者改为在 token 数为 0 时返回 0.0。
    此外,reviewer 要求为两个修复添加回归测试,作者均已添加并确认。

实现拆解

  1. 修复 _pad_token_ids (agent_loop.py): 在函数开头检查 tokens 是否为空,若为空则使用 tokenizer 的 pad_token_id(若不存在则 fallback 到 0)构造全填充的 tensor,跳过 tokenizer.pad() 调用,避免其返回 list 导致下游 .dim() 崩溃。
  2. 修复 compute_timing_metrics (metric_utils.py): 在推导 timing_per_token_ms 时,当 num_tokens_of_section[name] 为 0 时返回 0.0 而不是除以零或返回误导性指标。
  3. 添加回归测试:
    • test_agent_loop_extra_fields_schema_on_cpu.py 中新增 _FakeTokenizerCustomPad 类(pad_token_id=42)和 test_agent_loop_pad_token_ids_empty_with_non_zero_pad_id 测试,验证空序列使用正确的 pad_id 且 attention_mask 全零。
    • test_metric_utils_on_cpu.py 中新增 test_compute_timing_metrics_zero_tokens 测试,验证零 token 时 per-token 指标返回 0.0。
      这些改动相互独立,分别修复两个模块的边界情况。
文件 模块 状态 重要度
verl/experimental/agent_loop/agent_loop.py Agent 循环 modified 6.41
verl/trainer/ppo/metric_utils.py 训练器 modified 5.75
tests/experimental/agent_loop/test_agent_loop_extra_fields_schema_on_cpu.py Agent 测试 modified 6.7
tests/trainer/ppo/test_metric_utils_on_cpu.py 指标测试 modified 5.38

关键符号

_pad_token_ids compute_timing_metrics

关键源码片段

verl/experimental/agent_loop/agent_loop.py core-logic

核心修复文件:在 _pad_token_ids 中增加空序列处理,避免 tokenizer.pad 返回 list 导致崩溃

def _pad_token_ids(
    self,
    tokens: list[int],
    *,
    max_length: int,
    padding_side: str,
    return_attention_mask: bool,
) -> dict[str, torch.Tensor]:
    """Right/left pad a flat list of token ids to a ``(1, max_length)`` tensor."""
    # tokenizer.pad() with empty input returns dict with list values
    # instead of tensors, which breaks downstream .dim() calls.
    if not tokens:
        # 使用 tokenizer 的 pad_token_id,若为 None 则 fallback 到 0
        pad_id = self.tokenizer.pad_token_id if self.tokenizer.pad_token_id is not None else 0
        result = {"input_ids": torch.full((1, max_length), pad_id, dtype=torch.long)}
        if return_attention_mask:
            result["attention_mask"] = torch.zeros((1, max_length), dtype=torch.long)
        return result
    self.tokenizer.padding_side = padding_side
    padded = self.tokenizer.pad(
        {"input_ids": tokens},
        padding="max_length",
        max_length=max_length,
        return_tensors="pt",
        return_attention_mask=return_attention_mask,
    )
    if padded["input_ids"].dim() == 1:
        padded["input_ids"] = padded["input_ids"].unsqueeze(0)
        if return_attention_mask:
            padded["attention_mask"] = padded["attention_mask"].unsqueeze(0)
    return padded
verl/trainer/ppo/metric_utils.py core-logic

修复 compute_timing_metrics 除零异常,当 response token 数为 0 时返回 0.0

def compute_timing_metrics(batch: DataProto, timing_raw: dict[str, float]) -> dict[str, Any]:
    # ... 前面的代码计算 num_prompt_tokens, num_response_tokens, num_overall_tokens
    # ... 以及 num_tokens_of_section 字典
    return {
        **{f"timing_s/{name}": value for name, value in timing_raw.items()},
        **{
            f"timing_per_token_ms/{name}": (
                timing_raw[name] * 1000 / num_tokens_of_section[name] if num_tokens_of_section[name] > 0 else 0.0
            )
            for name in set(num_tokens_of_section.keys()) & set(timing_raw.keys())
        },
    }

评论区精华

使用正确的 pad_token_id 而非硬编码 0 正确性

gemini-code-assist[bot] 指出使用 torch.zeros 填充可能使用了错误的 token ID(如 Llama-3 的 pad_token_id 可能不是 0)。Luosuu 要求修复。GJWu-zyx 确认已采纳并修复。

结论:改用 self.tokenizer.pad_token_id,若为 None 则 fallback 到 0 · 已解决

零 token 时返回 0.0 而非 misleading 值 正确性

gemini-code-assist[bot] 指出使用 max(...,1) 会报告整个 timing 作为 per-token 时间,误导。Luosuu 要求修复。GJWu-zyx 确认已修复。

结论:当 token 数为 0 时返回 0.0 · 已解决

添加回归测试覆盖边界情况 测试

Luosuu 要求为两个修复添加回归测试,特别是使用非零 pad_token_id 的 tokenizer。GJWu-zyx 确认添加了两个测试。

结论:添加了两个回归测试,覆盖空序列 pad 和零 token 指标 · 已解决

风险与影响

风险较低。所有新增代码仅在空 token 序列的边界条件下执行,不影响正常路径。在 _pad_token_ids 中 fallback 到 0 的行为与之前代码默认一致,且 pad_token_id 获取进行了 None 检查,避免 AttributeError。在 compute_timing_metrics 中,当 token 数为 0 时返回 0.0,不会影响其他计算结果。主要风险来自 tokenizer 的 pad_token_id 可能为 None 但代码已处理,以及潜在的其他依赖空序列返回 tensor 格式的代码,但测试覆盖了这些情况。

影响范围限于使用 partial_rollout=False 且遇到 vLLM 请求中断的场景。对此类用户,该 PR 修复了训练过程中断的 bug,使训练能够正常完成。对其他用户无影响。团队内合并后可以减少相关 issue 报告。

边缘路径变更 需测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论