Prhub

#1900 Read the slurm allocation when the train config is built

原始 PR 作者 fzyzcjy 合并时间 2026-08-09 18:34 文件变更 4 提交数 7 评论 1 代码增减 +37 / -3

执行摘要

修复 SLURM 节点数在 import 时固化,改为构造时读取

该 PR 是 tracking issue #1837「Refactoring and enhancements」系列的一部分,目标是为启动脚本建立可复现的快照测试。commit 消息指出:ExecuteTrainConfig.num_nodes 原来以类级默认值读取环境变量,导致值在 command_utils 被导入时就固定下来,测试无法用 monkeypatch 还原,进程在导入后设置该变量也不生效。改为 default_factory 可在每次构造时读取。同时 dataclass_cli 复制参数默认值时,对 factory 字段会复制 dataclasses 的哨兵 _HAS_DEFAULT_FACTORY,click 会对该哨兵做类型转换导致失败,因此需要 _resolve_default 立即调用 factory 获得真实默认值。

建议精读。该 PR 虽然改动量小,但揭示了 dataclass 默认值在 CLI 框架(click/typer)中的经典陷阱,并给出了 default_factory 延迟求值与 _resolve_default 桥接的干净解法。对于任何需要将 dataclass 配置暴露为命令行参数的代码,都有直接参考价值。同时它展示了如何在重构链中用小步提交修复底层语义问题,为后续测试基建铺路。

讨论亮点

PR 由 yueming-yuan 直接批准(APPROVED),未留下任何 review 评论。关联 issue #1837 上的评论仅为 Gemini Code Assist 停用提示,与本改动无关。因此没有实质的设计争议或未解决问题。

实现拆解

实现分为四步:

  1. 修改 command_utils.pyExecuteTrainConfig:将 num_nodes: int = int(os.environ.get("SLURM_JOB_NUM_NODES", "1")) 改为 num_nodes: int = field(default_factory=lambda: int(os.environ.get("SLURM_JOB_NUM_NODES", "1"))),使环境变量在每次实例构造时读取,修复导入时固化的问题。同时将 from dataclasses import dataclass 扩展为 from dataclasses import dataclass, field

  2. typer_utils.py 新增 _resolve_default 助手:该函数检查 field.default_factory 是否为 dataclasses.MISSING,若非则立即调用 factory 返回真实默认值,否则返回 param.default

  3. _wrap 中应用 _resolve_default:构造新参数时,通过 param.replace(annotation=new_annotation, default=_resolve_default(field, param)) 将真实默认值写入 click/typer 签名,避免哨兵对象进入命令行解析。
  4. 补充测试test_command_utils.py 新增 TestExecuteTrainConfig.test_num_nodes_reads_the_slurm_allocation_when_the_config_is_built,验证构造实例时读取环境变量;test_typer_utils.py 新增 test_a_field_with_a_default_factory_is_usable_from_the_command_line,验证带 default_factory 的字段可通过命令行参数覆盖默认值。无额外配置或部署改动。
文件 模块 状态 重要度
miles/utils/typer_utils.py CLI 工具 modified 6.11
miles/utils/external_utils/command_utils.py 命令工具 modified 5.23
tests/fast/utils/test_command_utils.py 测试 modified 4.73
tests/fast/utils/test_typer_utils.py 测试 modified 5.3

关键符号

_resolve_default _wrap test_a_field_with_a_default_factory_is_usable_from_the_command_line test_num_nodes_reads_the_slurm_allocation_when_the_config_is_built

关键源码片段

miles/utils/typer_utils.py core-logic

核心逻辑变更:新增 `_resolve_default` 处理 dataclass `default_factory` 字段,并应用于 `_wrap` 的参数签名构建,是保证 CLI 正确解析的关键。

# miles/utils/typer_utils.pydef _resolve_default(field: dataclasses.Field, param: inspect.Parameter) -> object:
    # 当 dataclass 字段声明了 default_factory 时 , dataclasses 的哨兵 _HAS_DEFAULT_FACTORY 才是默认值
    # click 会对这个哨兵做类型转换 , 导致类似 int 字段解析失败 , 所以这里要提前调用 factory
    if field.default_factory is not dataclasses.MISSING:
        return field.default_factory()
    return param.default
​
​
def _wrap(func: _F, *, env_var_prefix: str) -> _F:
    ...
    new_parameters: list[inspect.Parameter] = []
    for param in old_parameters:
        field: dataclasses.Field = fields_by_name[param.name]
​
        typer_kwargs: dict[str, object] = {}
        if env_var_prefix:
            typer_kwargs["envvar"] = f"{env_var_prefix}{param.name.upper()}"
        if "help" in field.metadata:
            typer_kwargs["help"] = field.metadata["help"]
​
        resolved_type: type = resolved_hints.get(param.name, param.annotation)
        new_annotation = Annotated[resolved_type, typer.Option(**typer_kwargs)]
​
        # 关键 : 用 _resolve_default 得到真实默认值 , 而不是 dataclasses 的哨兵
        new_parameters.append(
            param.replace(annotation=new_annotation, default=_resolve_default(field, param))
        )
    ...
    wrapped.__signature__ = init_sig.replace(parameters=new_parameters)
    ...
    return wrapped
miles/utils/external_utils/command_utils.py dependency-wiring

直接修复点:`ExecuteTrainConfig.num_nodes` 从类级默认值改为 `default_factory`,使环境变量在构造时读取,这是本 PR 的主要行为变更。

# miles/utils/external_utils/command_utils.py
from dataclasses import dataclass, field# 该配置类被各种启动脚本继承 , num_nodes 需要跟随 SLURM 分配动态变化
@dataclass
class ExecuteTrainConfig:
    cuda_core_dump: bool = False
    # 原来这里直接写 int(os.environ.get(...)) 会在 import 时固化 , 现在改为 default_factory
    # 每次 ExecuteTrainConfig() 构造时重新读取环境变量 , monkeypatch 和运行时修改都能生效
    num_nodes: int = field(default_factory=lambda: int(os.environ.get("SLURM_JOB_NUM_NODES", "1")))
    extra_env_vars: str = ""
    output_dir: str = "/root/shared_data"

评论区精华

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

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

风险与影响

技术风险较小但需注意两点:

  1. 行为变更ExecuteTrainConfig.num_nodes 从导入时读取改为构造时读取。若现有调用方依赖“导入后环境变量不再影响”的旧行为,可能产生差异;但该改动的预期方向即修复这一语义问题,属于有意的行为修正。
  2. default_factory 重复调用:在 dataclass_cli 包装的 CLI 启动时,每次构建签名都会调用一次 _resolve_default,若 factory 有副作用或开销,可能被多次执行。当前 factory 仅为环境变量读取,风险极低。另外,_wrap 中构造的签名默认值从哨兵变为真实值,任何依赖 __signature__ 内省的工具(如自动文档生成)可能会看到不同的默认表示。

影响范围集中在两块:

  • miles/utils/typer_utils.py:所有使用 dataclass_cli 装饰器的启动脚本都会受影响,尤其是有 default_factory 字段的 dataclass 配置,现在能够被 click 正确解析。
  • miles/utils/external_utils/command_utils.pyExecuteTrainConfig 及其子类的行为更符合直觉,且为后续快照测试(如 PR #1901)提供了可重复的 num_nodes 取值,保证生成命令的确定性。

对普通用户而言此改动透明,主要受益者是维护启动脚本和快照测试的工程师。

环境变量读取时机变更 核心工具函数变更 测试覆盖补充

关联 Issue

#1837 Tracking issue for refactoring and enhancements

完整报告

参与讨论