Prhub

#3172 [feat] snapshot eval during --debug-train-only by reusing the eval dispatcher

原始 PR 作者 Zhichenzzz 合并时间 2026-09-15 12:17 文件变更 24 提交数 8 评论 1 代码增减 +401 / -46

执行摘要

纯 SFT train-only 复用 EvalDispatcher 支持 checkpoint 快照评估

PR body 明确说明动机:"Let a pure-SFT --debug-train-only run evaluate its checkpoints with the existing snapshot-eval machinery (EvalDispatcher/EvalFleet), instead of adding a train-only-specific eval path"(让纯 SFT 的 --debug-train-only 运行用现有 snapshot-eval 机制评估 checkpoint,而不是新增 train-only 专用 eval 路径)。此前 train-only 模式会关闭所有推理路径,纯 SFT 无法评估 checkpoint;该 PR 选择复用而非平行实现,并取代 #2882。

值得精读。三个设计决策值得关注:一是“复用现有 EvalDispatcher 而非新增 train-only 专用路径”,避免平行实现漂移;二是把引擎启动决策派生为 args 上的单一字段,删除散落在 sglang_config/specs/controller 里的多处短路,降低不一致风险;三是用“显式校验 + 强制 3 个 eval 点的 e2e”代替静默跳过,任何短路都会让 CI 失败。后续 3 个修复 commit(resume id、final force、router sizing)展示了边界条件的排查思路,可作为同类接入的参考。

讨论亮点

唯一实质性 review 评论来自 yueming-yuan(P2 级别),在 resume eval 语义上提出了正确性问题:

Use the completed rollout's version for resume eval — train.py:

  1. On resume, start_rollout_id is the loaded checkpoint's rollout plus one. Dispatching that next ID has two consequences: checkpoint-reuse mode requests a nonexistent checkpoint, producing ckpt_missing; staging mode exports under the same step_N directory that the first resumed training iteration may subsequently overwrite. With --eval-interval 1, I reproduced both pending evaluations reading the post-training weights, losing the intended pre-training measurement. Use the loaded checkpoint's completed rollout ID.

该问题在 commit 17f47bb 中修复:训练前基线评估改为 dispatch(args.start_rollout_id - 1),并由 tests/fast/test_train.py::test_resumed_run_evaluates_the_completed_rollout_not_the_next_one 锁定行为。yueming-yuan 最终 APPROVED,评价 "LGTHuman, with one codex comment"。

实现拆解

实现按以下 5 步展开:

  1. 引擎启动决策集中化(miles/utils/arguments.py + miles/backends/sglang_utils/sglang_config.py + miles/ray/specs/inference.py):miles_validate_args 在 --debug-train-only 下先归零 rollout_num_gpus(放在 colocate 规范化之后,避免 colocate 把 actor GPU 数写回),再派生新字段 starts_inference_engines = not debug_train_only or eval_num_gpus > 0。_compute_raw_sglang_config 与 _compute_rollout_offset 改读该字段:纯 train-only 且无 eval fleet 时返回空模型配置、offset 为 0;有 eval fleet 时只构建 eval 模型,且 eval bundle 排在 actor bundles 之后。specs_router 与 specs_inference_engine 删掉各自的 debug_train_only 短路,引擎启停完全由配置文件决定。

  2. train.py 接入 EvalDispatcher(train.py):新增 eval_dispatcher = EvalDispatcher(args, actor_model, rollout_executor);eval-only 特例和训练前基线评估都改走 eval_dispatcher.dispatch,训练循环内的周期 eval 改为 dispatch(rollout_id, force=rollout_id == num_rollout - 1),并给 should_run_periodic_action 补上 args.num_rollout 参数(否则最后一轮 cadence 不命中,force 永不触发);循环后调用 eval_dispatcher.drain() 排空异步 eval。

  3. 边界修复:resume 场景从 dispatch(start_rollout_id) 改为 dispatch(start_rollout_id - 1),评估已完成 rollout,避免 checkpoint-reuse 模式请求不存在的 checkpoint(ckpt_missing)以及 staging 模式把快照导出到第一个恢复迭代即将覆盖的 step_N 目录;EvalDispatcher._export 在 --offload-train 下对 actor 做 onload/offload,对齐 save_training_model;compute_miles_router_config 新增 num_engines 参数,max_connections 从 sglang_server_concurrency * num_engines 计算,避免 train-only 下 rollout_num_gpus=0 导致连接池为 0、每个 eval 引擎健康检查超时、所有点退化为 skipped_unhealthy;init_http_client 在 snapshot eval 开启时始终创建客户端,且无 rollout GPU 时按 eval fleet(eval_num_gpus // eval_num_gpus_per_engine)计算并发。

  4. 参数校验收紧(miles/utils/arguments.py):snapshot eval 校验从“禁止 debug_train_only/debug_rollout_only”放宽为显式排除 --debug-rollout-only 与 --load-debug-rollout-data(replay 路径不加载 rollout 函数,无物可 eval),并在 --debug-train-only 下强制 eval_function_path != rollout_function_path(SFT 训练函数只消费 message 列表,不能评估 HF snapshot)。

  5. 测试配套:新增 e2e tests/e2e/short/test_qwen3_0.6B_sft_snapshot_eval.py(stage-c-2-gpu-h200,labels short/eval/megatron,声明 hopper/blackwell 硬件),metric checker 用 --ci-metric-checker-expect-num 3 强制恰好 3 个 eval 点;fast 侧新增 TestSnapshotEvalValidation、TestInitHttpClientConcurrency、TestEvalBeforeTrain、TestFinalEval,并更新 test_sglang_config.py 的 TestRolloutOffset、test_rollout_server.py、test_checkpoint_eval.py、test_rollout_executor.py、test_placement_group_shared_ppo.py、test_config.py。

文件 模块 状态 重要度
train.py 训练驱动 modified 6.61
miles/utils/arguments.py 参数校验 modified 6.1
miles/backends/sglang_utils/sglang_config.py 引擎配置 modified 5.54
miles/ray/specs/inference.py 引擎规格 modified 5.51
miles/ray/rollout/eval_dispatch.py 评估调度 modified 5.44
miles/router/config.py 请求路由 modified 5.67
miles/utils/http_utils.py 网络客户端 modified 4.95
tests/e2e/short/test_qwen3_0.6B_sft_snapshot_eval.py 端到端测试 added 7.08
tests/fast/test_train.py 驱动测试 modified 6.21
tests/fast/utils/test_arguments.py 参数测试 modified 6.25

关键符号

train miles_validate_args _compute_raw_sglang_config _compute_rollout_offset specs_router specs_inference_engine compute_miles_router_config init_http_client EvalDispatcher.dispatch EvalDispatcher._export EvalDispatcher.drain test_fresh_run_evaluates_the_initial_checkpoint_at_step_zero test_resumed_run_evaluates_the_completed_rollout_not_the_next_one test_the_last_rollout_is_always_evaluated_even_off_cadence

关键源码片段

train.py core-logic

同步训练主入口的 eval 驱动改造:eval-only、训练前基线、周期 eval 全部从 rollout_executor.eval.remote 切换为 EvalDispatcher.dispatch,新增 drain 收尾,并修正 eval cadence 需 num_rollout 参数才能让最终 force 生效的边界问题。

# train.py:train() 中 eval 路径统一接入 EvalDispatcher
# (以下整理 eval 相关主干,训练循环与 offload 细节用 ... 省略)
​
    # EvalDispatcher 持有 actor 与 rollout executor,负责 snapshot 导出、
    # 异步派发与快照 GC。train_async.py 已用它,这里让 train.py 复用同一机制
    eval_dispatcher = EvalDispatcher(args, actor_model, rollout_executor)
​
    # eval-only 特例:不训练,只对 --hf-checkpoint 基线评估一次
    if args.num_rollout == 0 and args.eval_interval is not None:
        await inference_controller.prepare_eval()
        await eval_dispatcher.dispatch(0, hf_dir=args.hf_checkpoint)
​
    # 训练前基线评估:移到训练循环外。fresh run 评估 step 0(--hf-checkpoint);
    # resume run 评估已完成的 checkpoint(start_rollout_id - 1),而不是下一个
    # rollout——下一个 rollout 的 checkpoint 尚不存在(ckpt_missing),
    # 且 staging 模式下会把快照导出到第一个恢复迭代即将覆盖的 step_N 目录
    if (
        args.num_rollout > args.start_rollout_id
        and args.eval_interval is not None
        and not args.skip_eval_before_train
    ):
        await inference_controller.prepare_eval()
        if args.start_rollout_id == 0:
            await eval_dispatcher.dispatch(0, hf_dir=args.hf_checkpoint)
        else:
            await eval_dispatcher.dispatch(args.start_rollout_id - 1)
​
    for rollout_id in range(args.start_rollout_id, args.num_rollout):
        ...
        # 周期 eval:cadence 判断必须带上 num_rollout,否则最后一轮
        # 永远无法命中检查点,dispatch(force=True) 也就不可达
        if should_run_periodic_action(
            rollout_id, args.eval_interval, num_rollout_per_epoch, args.num_rollout
        ):
            await inference_controller.prepare_eval()
            await eval_dispatcher.dispatch(rollout_id, force=rollout_id == args.num_rollout - 1)
        ...
​
    # 训练结束前排空所有异步 eval 任务,避免快照评估未完成就拆散资源
    await eval_dispatcher.drain()
miles/utils/arguments.py core-logic

引擎启动决策的新判据源与 snapshot eval 校验重构:--debug-train-only 下强制 rollout_num_gpus=0 并派生 starts_inference_engines;快照 eval 从完全禁止 debug 模式改为显式拒绝 debug_rollout_only/load-debug-rollout-data,并要求 train-only 显式指定 eval function。

# miles/utils/arguments.py:miles_validate_args 中引擎启动决策与 snapshot-eval 校验
​
    # colocate 规范化会把 actor 的 GPU 数写回 rollout_num_gpus,
    # 因此 train-only 的归零必须放在它之后
    if args.colocate:
        if args.rollout_num_gpus != args.actor_num_gpus_per_node * args.actor_num_nodes:
            args.rollout_num_gpus = args.actor_num_gpus_per_node * args.actor_num_nodes
​
    # 引擎启动决策:train-only 不开 rollout 引擎;若配置了 eval fleet
    # (--eval-num-gpus > 0)则仍会启动评估引擎。该派生字段取代散落在
    # sglang_config / specs / controller 里的 debug_train_only 短路,
    # 成为各处读取的单一事实来源
    if args.debug_train_only:
        args.rollout_num_gpus = 0
    args.starts_inference_engines = not args.debug_train_only or args.eval_num_gpus > 0
​
    ...
​
    # snapshot eval 参数校验:从“禁止 debug 模式”放宽为显式排除无法共存的
    # 调试组合,并强制 train-only 自己提供可评估 snapshot 的 eval 函数
    if args.eval_uses_snapshots:
        ...
        assert not args.debug_rollout_only, "Snapshot eval is not supported with debug_rollout_only."
        assert args.load_debug_rollout_data is None, (
            "Snapshot eval is not supported with --load-debug-rollout-data: "
            "no rollout functions are loaded."
        )
        # SFT 训练函数只消费 message 列表,不能评估 HF snapshot,
        # 所以 train-only 下必须显式指定 fleet-aware 的 --eval-function-path
        if args.debug_train_only:
            assert args.eval_function_path != args.rollout_function_path, (
                "Snapshot eval during --debug-train-only requires an explicit "
                "--eval-function-path; the training rollout function cannot evaluate snapshots."
            )
miles/backends/sglang_utils/sglang_config.py core-logic

引擎配置生成的核心:以 starts_inference_engines 取代 debug_train_only 短路,train-only 无 eval fleet 时不建任何模型、有 eval fleet 时只建 eval 模型;rollout offset 仅在无推理引擎时为 0,否则 eval fleet 排在 actor bundles 之后。

# miles/backends/sglang_utils/sglang_config.py:引擎配置按派生字段生成
​
def _compute_raw_sglang_config(args) -> _RawSglangConfig:
    # 没有任何引擎需要启动:纯 train-only 且未配置 eval fleet,
    # 直接返回空配置,全程不落 sglang 进程
    if not args.starts_inference_engines:
        return _RawSglangConfig(models=[])
​
    eval_num_gpus = args.eval_num_gpus
    rollout_num_gpus = args.rollout_num_gpus or 0
​
    # 显式 YAML 配置时,GPU 总数必须等于 rollout 加 eval 的 GPU 数;
    # 归零的 rollout_num_gpus 用 0 参与求和,保持校验依然成立
    if getattr(args, "sglang_config", None) is not None:
        config = _RawSglangConfig.from_yaml(args.sglang_config)
        expected = rollout_num_gpus + eval_num_gpus
        actual = config.total_num_gpus
        assert actual == expected, "YAML 配置的 GPU 总数必须等于 rollout 加 eval 的 GPU 数"
        ...
​
    # train-only + eval fleet:不建任何 rollout 模型,只保留 eval 模型配置;
    # prefill 服务器分支只在有 rollout 引擎时才有意义
    if rollout_num_gpus == 0:
        config = _RawSglangConfig(models=[])
    elif args.prefill_num_servers is not None:
        config = _RawSglangConfig.from_prefill_num_servers(args)
    else:
        config = _RawSglangConfig(...)
    ...
    return config
​
​
def _compute_rollout_offset(args) -> int:
    """Offset(PG bundle 槽位)表示 rollout GPU 从哪个位置开始占位。"""
    # 无任何推理引擎时不需要为 rollout 预留位置;debug_rollout_only 与
    # colocate 同样返回 0。train-only + eval fleet 时此处不为 0,
    # eval bundle 会排在 actor bundle 之后而不是抢占前段
    if args.debug_rollout_only or args.colocate or not args.starts_inference_engines:
        return 0
    if getattr(args, "critic_train_only", False):
        return args.critic_num_nodes * args.critic_num_gpus_per_node
    ...

评论区精华

Resume 时 snapshot eval 应使用已完成 rollout 的版本(P2) 正确性

yueming-yuan 指出:resume 时 start_rollout_id 是已加载 checkpoint 的 rollout 加一,dispatch 下一个 ID 会产生两个后果——checkpoint-reuse 模式请求不存在的 checkpoint(ckpt_missing);staging 模式导出到第一个恢复训练迭代随后会覆盖的 step_N 目录。在 --eval-interval 1 下可复现两个待处理 eval 都读到训练后权重,丢失预期的预训练测量。

结论:commit 17f47bb 修复为评估已完成 rollout(start_rollout_id - 1),并用 test_resumed_run_evaluates_the_completed_rollout_not_the_next_one 锁定行为:eval:2 必须先于 prepare_rollout:3,且 prepare_rollout:3 之前不得出现 eval:3。 · 已解决

风险与影响

  1. 核心训练入口变更(train.py):所有走同步 train.py 的训练(不限于 debug-train-only)的 eval 路径都从 rollout_executor.eval.remote 切换为 EvalDispatcher.dispatch,异步语义(force、drain、快照导出时序)与旧路径不同,普通 RL 训练也有回归暴露面;fast 测试覆盖了 cadence 与 resume,但多引擎 GPU 场景仍需观察。
  2. resume eval 语义变化:resume 时从此前准备评估 start_rollout_id 改为 start_rollout_id - 1,依赖旧行为的用户会看到评估点编号变化;在 staging 模式下旧行为会覆盖即将重写的快照目录,因此这是必要修复,但属于行为变更。
  3. router 连接数计算变化(miles/router/config.py + miles/ray/specs/inference.py):max_connections 从 sglang_server_concurrency * rollout_num_gpus // rollout_num_gpus_per_engine 改为 sglang_server_concurrency * num_engines(model_cfg.num_server_cells),影响所有使用 --use-miles-router 的运行,不只 train-only;需要验证与 rollout 数量不一致的配置下连接池是否符合预期。
  4. 新派生字段的一致性依赖:starts_inference_engines 依赖 miles_validate_args 先于 specs/config 执行;任何绕过该校验的入口或测试 fixture 都会拿到未派生字段(commit b90cb4a 就修了一个漏掉的 fixture),后续新增入口需注意。
  5. 参数校验收紧:--debug-train-only 现在强制 rollout_num_gpus=0(含 colocate 场景),快照 eval 拒绝 --load-debug-rollout-data,这些组合此前可能“静默可用”,现在会直接断言失败。

功能上,纯 SFT(--debug-train-only)用户现在可配置 --eval-num-gpus 获得与 async 训练一致的 snapshot export、异步调度与快照 GC;使用上,train-only 下必须显式提供 fleet-aware 的 --eval-function-path,且 eval prompt 需要预渲染列(--eval-input-key),--apply-chat-template 不能用于 SFT 训练数据的 eval 提示。系统层面,train.py 与 train_async.py 的 eval 路径收敛到同一个 EvalDispatcher,未来维护单一路径;train.py 的 eval-before-train 语义(含 resume)被修正并有 fast 测试锁定。影响范围包括所有走 train.py 的同步训练(eval 路径)以及所有使用 miles router 的运行(连接数计算变化)。

核心训练入口变更 resume eval 语义变化 router 连接数计算变化 新派生字段一致性依赖 eval 路径行为差异

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论