Prhub

#1298 [OPD] Per-position teacher scoring (sparse top-k) + kaixih's robustness fixes

原始 PR 作者 maocheng23 合并时间 2026-08-03 15:27 文件变更 7 提交数 3 评论 5 代码增减 +604 / -18

执行摘要

OPD top-k 打分稀疏化,响应降 22.9×

OPD top-k teacher 评分存在二次方数据体积问题:PR body 明确指出当前实现把"全局 union 广播到每个位置",导致响应为 O(response_len · |union|) JSON,"dominated by client-side parse + map-building (the rollout-tail CPU bottleneck seen on B200)"。目标是借助 sgl-project/sglang#27421 的 per-position 字段把响应降到 O(response_len · k)。同时吸收 kaixih 的 opd-qwen3-b200-fixes 中正交的稳定性修复:打分请求不能无限挂起、日志不能整 dump 嵌套 logprob dict;第三个 commit 还引入多 teacher 路由,使同一训练批次可按任务路由到专精 teacher。

值得精读,尤其 _per_position_ids 的布局对齐设计(prompt 空槽 + 绝对位置索引复用 _trim_input_field)、parse_teacher_urls 的 fail-fast 风格、以及 reward 日志摘要模式。建议跟进 review 遗留问题:修复 _post_json 的 None 超时语义、确认 --sglang-router-request-timeout-secs 注册情况;若计划上线 --opd-topk-per-position,先确认 teacher/student 均为 patched 版本,并在小规模 rollout 上比对 flat 与 per-position 的 reward 一致性。

讨论亮点

review 的核心交锋集中在 _post_json 的超时语义。gemini-code-assist[bot] 以高优先级指出:timeout_secs 为 None 时 aiohttp.ClientTimeout(total=None)禁用 aiohttp 默认的 5 分钟超时,请求可能无限挂起,并给出"仅在非 None 时传 timeout"的 suggestion;合入者 Shi-Dong 回复 'Please address this.' 要求处理,但 head 代码中该建议未被采纳,属已批准却遗留的高风险点。第二个高优先级意见是 --sglang-router-request-timeout-secs 未在 miles/utils/arguments.py 注册(commit 消息称其为 existing 参数,head 快照中未见注册代码),若属实该超时保护实际不可用。另有 medium 级 prompt_len 防御式计算建议,以及 Shi-Dong 的命名 nit(测试导入的 _compute_topk_reverse_kl_per_position_ids 等建议去掉前导下划线),两者均未处理。

实现拆解

实现按 5 步推进:

  1. 打通稀疏打分协议_score_payload 扩展 token_ids_positions 参数,优先写入 token_ids_logprob_positions,与旧的扁平 token_ids_logprob 互斥;新增 _per_position_ids 把按 response 位置组织的 top-k 条目转成按绝对输入位置索引的 id 列表(prompt 段用空列表占位),保证与 logprob_start_len=0 及既有 _trim_input_fieldvalues[1:][-response_length:] 取位方式一致。这是 per-position 正确性的核心:response 位置 r 的 id 列表必须落在绝对索引 prompt_len + r

  2. 重写 reward_func 打分流程:先经 _teacher_url_for_sample 解析本样本的 teacher 端点(未配置路由时回退 --rm-url);再依 --opd-topk-per-position 在 per-position 与 flat 双路径间选择;teacher 与 student 两侧(STUDENT_ON_TEACHER_STRATEGIES)的打分都支持新布局;所有 _post_json 调用统一带上 per-request timeout_secs

  3. 多 teacher 路由parse_teacher_urls 只按第一个 = 切分 NAME=URL,保留 URL 内 query 参数(如 ?tag=a=b),对格式错误、重复名字直接抛错;_teacher_url_for_samplesample.metadata[--opd-teacher-key] 路由,缺失或未知名字回退保留名 default,无 default 则报错,奉行"静默蒸馏错 teacher 比直接失败更糟"的原则。

  4. 参数与启动校验miles/utils/arguments.py 注册 --opd-topk-per-position--opd-teacher-urls--opd-teacher-keymiles_validate_args 增加组合校验(--opd-teacher-urls 仅限 --opd-type=sglang;未开 --use-opd 时禁止设置),并在启动期 fail-fast 解析路由表(局部 import 避免 miles.utils 反向依赖 rollout)。

  5. 日志与配套miles/rollout/sglang_rollout.py 新增 _len_or_value_sample_text_preview_reward_log_summary,first/finish rollout 日志改为参数化格式字符串 + 形状摘要;tests/fast/rollout/test_on_policy_distillation.py 新增 12 个用例覆盖 padding 布局、双路径 payload、URL 解析与路由回退(注册为 stage-a-cpu CI);新增 run-qwen3-8B-opd-multi-teacher.sh 示例(math teacher Qwen3-32B + code teacher Qwen3-Coder-30B-A3B)及 docs/advanced/on-policy-distillation.md、示例 README 更新。

文件 模块 状态 重要度
miles/rollout/on_policy_distillation.py OPD 打分 modified 8.62
miles/rollout/sglang_rollout.py rollout 主控 modified 7.31
miles/utils/arguments.py 参数解析 modified 6.58
tests/fast/rollout/test_on_policy_distillation.py OPD 测试 modified 6.95
examples/on_policy_distillation/run-qwen3-8B-opd-multi-teacher.sh 示例脚本 added 5.22
docs/advanced/on-policy-distillation.md OPD 文档 modified 3.35
examples/on_policy_distillation/README.md 示例文档 modified 1.5

关键符号

parse_teacher_urls _teacher_url_for_sample _score_payload _per_position_ids _post_json reward_func _len_or_value _sample_text_preview _reward_log_summary

关键源码片段

miles/rollout/sglang_rollout.py observability

rollout 主循环日志改造:用 `_reward_log_summary` 等摘要替代整 dump 嵌套 logprob,避免 OPD 打分 reward 撑爆日志。

def _reward_log_summary(reward: Any) -> Any:
    """把 OPD 打分返回的嵌套 logprob dict 摘要成形状/长度,替代整 dump。    非 dict 奖励走 _len_or_value;dict 则逐层收敛:外层键 -> 子 dict 的
    keys 列表 + meta_info 关键字段的长度摘要,控制日志体积。
    """
    if not isinstance(reward, dict):
        return _len_or_value(reward)
​
    summary: dict[str, Any] = {}
    for key, value in reward.items():
        if not isinstance(value, dict):
            summary[key] = _len_or_value(value)
            continue
​
        entry: dict[str, Any] = {"keys": list(value.keys())}
        meta_info = value.get("meta_info")
        if isinstance(meta_info, dict):
            # 只保留高频字段的形状信息。
            entry["meta_info"] = {
                meta_key: _len_or_value(meta_info[meta_key])
                for meta_key in (
                    "id",
                    "finish_reason",
                    "prompt_tokens",
                    "weight_version",
                    "input_token_logprobs",
                    "input_token_ids_logprobs",
                    "input_top_logprobs",
                )
                if meta_key in meta_info
            }
        summary[key] = entry
    return summary
​
​
def _len_or_value(value: Any) -> Any:
    # 容器型字段用 {type, len} 摘要,标量原样返回。
    if isinstance(value, (dict, list, tuple, str)):
        return {"type": type(value).__name__, "len": len(value)}
    return value

评论区精华

_post_json 传 None 超时会禁用 aiohttp 默认超时 正确性

gemini-code-assist[bot] 高优先级评论:`timeout_secs` 为 None 时 `aiohttp.ClientTimeout(total=None)` 会禁用默认 5 分钟超时,请求可无限挂起;建议仅在非 None 时传 timeout。Shi-Dong 在该线程回复 'Please address this.' 要求处理。

结论:head 代码未采纳建议,`_post_json` 仍保留原写法;PR 已合并,该风险留存。 · unresolved

--sglang-router-request-timeout-secs 参数是否注册存疑 正确性

gemini-code-assist[bot] 高优先级评论:`reward_func` 读取 `sglang_router_request_timeout_secs`,但该参数未在 `miles/utils/arguments.py` 注册,命令行传参会报 unrecognized arguments;commit 消息却称其为 existing 参数。

结论:head 快照的 arguments.py 中未见注册代码,需人工确认;若属实,超时保护实际不可用。 · unresolved

prompt_len 计算的防御式处理 正确性

gemini-code-assist[bot] medium 级建议:`prompt_len = len(sample.tokens) - sample.response_length` 可能因 tokens 为空或 response_length 超界产生 TypeError/ 负值,建议 `max(0, len(sample.tokens or []) - (sample.response_length or 0))`。

结论:未采纳;现有调用上下文由 Sample 类型保证字段非空,风险较低。 · unresolved

测试导入的私有函数命名 style

Shi-Dong nit:测试从 `miles.rollout.on_policy_distillation` 导入 `_compute_topk_reverse_kl`、`_per_position_ids` 等带前导下划线函数,若这些函数并非模块内私有,建议去掉前导下划线。

结论:未处理,维持 `_` 前缀;属于风格建议,不影响功能。 · unresolved

风险与影响

  1. 超时语义回退(高)_post_jsontimeout_secs=None 时构造 ClientTimeout(total=None),会禁用 aiohttp 默认超时,使"防挂起"目标可能落空甚至引入无限等待;Gemini 已指出、Shi-Dong 要求处理但合并时 head 代码未改。
  2. 外部 sglang patch 依赖--opd-topk-per-position 依赖 sgl-project/sglang#27421 的 patch;若 teacher 与 student 服务器 patch 版本不一致,未知字段可能被旧版忽略或报错。默认关闭缓解了未部署场景,但开启后没有运行时检测。
  3. 布局对齐耦合_per_position_ids 依赖 sglang 侧按绝对位置、chunked prefill 下对齐(LogitsMetadata.extend_logprob_start_pos_cpu)等实现细节;miles 侧单测只验证 padding 布局,端到端对齐由外部 PR 保证,若 sglang patch 有偏差,训练信号会静默错位(错误 token 的 logprob),且不易察觉。
  4. 参数注册存疑--sglang-router-request-timeout-secs 在 head 快照的 arguments.py 中未见注册代码,若属实则命令行传参会报 unrecognized arguments,超时保护实际不生效。
  5. 日志格式变更:first/finish rollout 日志从完整 reward dump 变为摘要,依赖日志全文的运维/排查脚本需适配;多 teacher 路由启用后 --rm-url 被忽略,用户需注意配置语义。

训练效率:开启新开关后,B200 上 OPD 打分客户端的解析/建 map CPU 开销显著降低(响应实测 22.9×);功能面:支持任务级多 teacher 路由,同一训练任务可混合 math/code 等专精 teacher,OPD 配方更灵活;稳定性:打分请求不再可能无限阻塞 rollout(前提是超时参数真正可用并修复 None 语义);团队:需要部署 patch 后的 sglang(sglang-miles 分支)才能开启 per-position,文档与示例已同步,成本可控。整体影响集中在 OPD 训练路径,默认关闭保证对存量任务零侵入。

None 超时禁用默认限制 依赖外部 sglang patch 待核实参数注册 布局对齐耦合外部实现

关联 Issue

#27421 [logprob] Per-position token_ids_logprob for sparse OPD top-k scoring

完整报告

参与讨论