Prhub

#34094 config: pin that resolution is reproducible from the raw input

原始 PR 作者 ch-wan 合并时间 2026-08-10 05:44 文件变更 1 提交数 1 评论 43 代码增减 +375 / -0

执行摘要

新增解析可重现性测试,钉死 ServerArgs 纯函数契约

PR body 说明:配置分层的最终状态让 ServerArgs 保持用户原始输入,每个发布进程自己派生解析值——bags 不跨进程边界,子进程只能从交给它的记录重新投影。这只有在同一原始输入解析两次得到相同答案时才成立。测试从三方面固定:

1) 相等原始输入两次解析逐字段一致(嵌套 dataclass 结构化比较);
2) 显式 backend 的解析不改变下一个解析的默认(声明注册表进程全局);
3) 解析兄弟配置不触碰第一个记录。失败意味着某解析步骤读了它同时写入的状态,这恰是会让 launcher 与其 schedulers 静默分歧的机制。

值得精读。虽然只有一个测试文件,但它演示了如何为"纯函数化"契约设计回归测试,并完整展示了审查者与作者的往返式修复过程。重点看 _comparable 的快照语义、_process_state/_restore_process_state 的进程状态恢复、_STICKY_ACROSS_RESOLUTIONS 对已知粘性行为的显式命名,以及 _callTestMethod 禁用重试的理由。

讨论亮点

最有价值的交锋集中在"测试如何避免把回归变成通过":

  • 关于 CI 重试(codex P2):作者承认 "addCleanup runs after the last attempt, so the retry would start from exactly the state the failed attempt leaked",因此该文件 _callTestMethod 绕过 CustomTestCase 重试,恢复也覆盖 EnvField._set_to_none
  • 关于 CPU 套件假绿(ch-wan):"On base-a-test-cpu, Llama-mini auto-default is already triton, so explicit vs default is a full no-op",改用 torch_native 并断言显式解析确实不等于默认解析(assertNotEqual)。
  • 关于发现的真实粘性字段:修复 subtest 隔离后作者发现 "resolving a multimodal config first sets the env to 1, and the next resolution of a text-only model comes out with mm_feature_transport=cuda_ipc... it is existing behaviour",选择用 _STICKY_ACROSS_RESOLUTIONS 显式命名而非隐藏,让未来的新粘性字段直接失败。
  • 尾部 P1 提醒(codex):"This unconditional cuda_ipc assertion makes test_a_resolution_does_not_leak_into_the_next fail on both non-NVIDIA registrations"——材料中未见到作者对最终门控实现的确认回复。

实现拆解

  1. 变更入口与 CI 注册:唯一新增文件 test/registered/unit/server_args/test_resolution_is_reproducible.py。文件顶部通过 register_cpu_ci(base-a-test-cpu)、register_cuda_ci(base-b、1-gpu-small)、register_amd_ci(stage-b-test-1-gpu-small-amd)注册到三套 CI。原因是 is_cuda()is_hip()、设备能力等分支只能由真实硬件到达,mock 谓词会固定 mock 而非解析本身,故选择多平台真实注册。
  2. 形状矩阵设计:_SHAPES 覆盖 plain(Llama mini JSON)、speculative(EAGLE 相关参数)、multimodal(Qwen2-VL mini + vision_config)、plain_cpu_device(device="cpu",专门到达 _handle_cpu_backends);仅当 torch.cuda.is_available() 时追加 deepseek_dsa 形状——DeepSeek-V3 mini 配置带 index_topk 进入 DSA 路径,该路径在解析时探测 torch.cuda.get_device_capability(),无驱动主机上会抛错,因此必须硬件门控。所有形状均为内联 JSON,不加载权重。
  3. 进程状态快照与恢复:_process_state 沿 type(envs) 的 MRO 收集所有 EnvField,返回 os.environ 拷贝加每个字段的 _set_to_none 标志;_restore_process_state 恢复两者。setUp 保存 pristine 状态并 addCleanup 恢复;每个 subtest 循环体开头再显式恢复 pristine,避免前一个形状的解析(如 multimodal 写入 SGLANG_USE_CUDA_IPC_TRANSPORT)污染后续断言。_callTestMethod 直接调用 unittest.TestCase 版本,禁用 CustomTestCase 的 CI 重试——否则重试会从第一次泄漏的状态启动,把回归变成通过。
  4. 比较与排除规则:_comparable 逐 dataclass 字段取值;嵌套 dataclass(如 cuda_graph_config)走 asdict 结构化比较,其余可变值(list/dict)一律 copy.deepcopy,防止快照持有活对象导致共享可变字段被原地修改时断言假通过;排除 _NOT_COMPARABLE(random_seed,文档性保留)与 _STICKY_ACROSS_RESOLUTIONS(mm_feature_transport,main 上已有的粘性字段,显式命名以便新粘性字段暴露)。
  5. 四个测试用例:同输入双解析逐字段一致(矩阵驱动);显式 backend(torch_native,CPU/CUDA 上均非默认)解析前取 control、解析后重新默认解析必须与 control 完全一致,并追加 multimodal、torch_compile、deepseek_dsa 为中间形状的二级探针;兄弟解析隔离(快照先于第二次解析,且断言两个结果相等);声明 provenance 可复现(第二次解析前深拷贝 _resolved_overrides,解析后还断言 first._resolved_overrides == 快照)。
文件 模块 状态 重要度
test/registered/unit/server_args/test_resolution_is_reproducible.py 配置解析 added 7.48

关键符号

_process_state _restore_process_state _callTestMethod _resolved _comparable test_two_resolutions_of_the_same_input_agree test_a_resolution_does_not_leak_into_the_next test_resolving_a_sibling_leaves_the_first_alone test_the_declaration_provenance_is_reproducible

关键源码片段

test/registered/unit/server_args/test_resolution_is_reproducible.py test-coverage

本次唯一变更文件,从三个角度钉死 ServerArgs 解析在相同原始输入下可重现:逐字段一致、显式后端不改变后续默认、兄弟解析互不影响。测试自身包含防假绿设计(禁用 CI 重试、深拷贝快照、subtest 前恢复 pristine 状态、探针必须非默认且断言发散),并注册到 CPU/CUDA/AMD 三套 CI,是配置袋重构栈第 12 步的验证层。

"""Resolution is a pure function of the raw input plus this node's environment.配置分层重构把 ServerArgs 保持为用户原始输入,每个进程自己派生解析值;
bag 不跨进程边界,子进程只能从交给它的记录重新投影。
所以「相同原始输入两次解析结果一致」是派生契约的地基。
"""# 已知的跨解析粘性字段:multimodal 解析会写 SGLANG_USE_CUDA_IPC_TRANSPORT,
# 下一次解析(即使纯文本)会读取 is_set() 并继承——这是 main 的现有行为,
# 测试显式命名它,新的粘性字段将无法隐藏在排除名单里。
_STICKY_ACROSS_RESOLUTIONS = frozenset({"mm_feature_transport"})
​
​
def _process_state(self):
    """一次解析可能留下的进程状态:os.environ 以及 EnvField 的 _set_to_none。    _set_to_none 是描述符级标志,os.environ 不携带;沿 MRO 遍历,
    否则漏掉定义在基类上的字段。
    """
    fields = {}
    for klass in reversed(type(envs).__mro__):
        for name, field in vars(klass).items():
            if isinstance(field, EnvField):
                fields[name] = field
    return (dict(os.environ), {n: f._set_to_none for n, f in fields.items()})
​
​
def _restore_process_state(self, state):
    # 恢复到快照状态,避免上一次解析的泄漏影响后续子测试
    saved_environ, saved_none_flags = state
    os.environ.clear()
    os.environ.update(saved_environ)
    for name, was_none in saved_none_flags.items():
        getattr(type(envs), name)._set_to_none = was_none
​
​
def _comparable(self, server_args: ServerArgs) -> dict:
    """逐字段快照:嵌套 dataclass 走 asdict,其余可变值一律 deepcopy。    如果存的是活对象,兄弟解析原地改 list/dict 时快照跟着变,
    隔离断言就会在真正回归上通过。
    """
    out = {}
    for field in dataclasses.fields(server_args):
        if field.name in _NOT_COMPARABLE:
            continue
        value = getattr(server_args, field.name)
        out[field.name] = (
            dataclasses.asdict(value)
            if dataclasses.is_dataclass(value)
            else copy.deepcopy(value)
        )
    return out
​
​
def test_two_resolutions_of_the_same_input_agree(self):
    for label, config, kwargs in _SHAPES:
        with self.subTest(shape=label):
            # 每个形状从测试方法起始状态开始,而不是上一个形状留下的状态
            self._restore_process_state(self._pristine_state)
            model_path = self._config_dir(config)
            first = self._resolved(model_path, **kwargs)
            second = self._resolved(model_path, **kwargs)
            self.assertEqual(self._comparable(first), self._comparable(second))

评论区精华

CI 重试会把状态泄漏变成通过 测试

codex P2:CustomTestCase._callTestMethod 在 CI 中重试一次失败用例,但 addCleanup 只在最后一次尝试后运行;第一次尝试泄漏 os.environ 或全局注册表后,重试从污染状态开始,两个 control 一致,回归被变成通过。

结论:作者在 _callTestMethod 中直接调用 unittest.TestCase._callTestMethod 绕过重试,并让恢复逻辑同时覆盖 EnvField._set_to_none 与 os.environ。 · 已解决

CPU 套件上探针是默认值导致假绿 测试

ch-wan:base-a-test-cpu 上 Llama-mini 自动默认就是 triton,explicit 与 default 完全等价,顺序依赖未真正被测到。

结论:探测后端改为 torch_native(CPU/CUDA 均非默认),并先断言显式解析 != 默认解析(assertNotEqual)再继续比较。 · 已解决

_comparable 快照持有活对象 测试

codex P2:当 ServerArgs 字段是普通 list/dict 等可变对象时,快照存的是原对象;兄弟解析原地修改共享字段会同步移动快照,隔离断言照样通过。

结论:非 dataclass 值一律 copy.deepcopy,dataclass 走 asdict,快照与活对象脱钩。 · 已解决

subtest 之间进程状态不隔离 测试

codex P2:setUp 只注册方法级 cleanup,plain 形状先跑并设置 SGLANG_USE_CUDA_IPC_TRANSPORT,后续 speculative/DeepSeek 形状继承该值;只在首次解析时出现的回归被隐藏。

结论:每个 shape 与 intermediate 循环体开头调用 _restore_process_state,恢复到测试方法起始的 pristine 状态。 · 已解决

mm_feature_transport 的真实跨解析粘性 正确性

修复 subtest 隔离后作者发现:multimodal 解析先写 env=1,下一次纯文本解析得到 mm_feature_transport=cuda_ipc;在栈的 base commit 上可复现,属于现有行为而非栈引入。

结论:不隐藏该行为,显式命名 _STICKY_ACROSS_RESOLUTIONS={"mm_feature_transport"} 排除出字段比较,并写明机制;未来的新粘性字段将直接失败而非混入。 · 已解决

硬件门控与三套 CI 注册 测试

codex P2:仅 CPU 注册无法到达 is_cuda()/is_hip()/ 设备能力分支;mock 谓词会固定 mock 而非解析本身。作者确认只传 device="cuda" 不会让 is_cuda() 为真。

结论:注册 register_cpu_ci、register_cuda_ci、register_amd_ci 三套,DeepSeek-DSA 形状在 GPU 注册上执行,CPU 无设备时可跳过 DSA 形状避免崩溃。 · 已解决

DeepSeek mini 缺 index_topk 不进入 DSA 测试

ch-wan:DeepseekV3ForCausalLM mini 配置没有 index_topk,永远不触发 DSA 路径,与 " 处理器 fan-out 最多 " 的注释矛盾,最重的写后 is_set() 粘性路径未被测。

结论:mini JSON 加入 index_topk/index_head_dim/index_n_heads,形状更名为 deepseek_dsa。 · 已解决

尾部 P1/P2:CUDA-only 断言门控、legacy override 清除、provenance 覆盖全形状 正确性

codex P1:无条件断言 cuda_ipc 会在 CPU/AMD 失败;P2:进程若以 SGLANG_USE_CUDA_IPC_TRANSPORT=0 启动则 auto-selection 不触发,仍断言 cuda_ipc 会失败;P2:provenance 检查只覆盖默认 Llama 形状,模型专属声明对象损坏无人观测。

结论:材料中未见作者对这些尾部评论的明确回复;合并版本 head 摘录中已能见到 plain_cpu_device 形状与 _STICKY_ACROSS_RESOLUTIONS 机制,但具体门控与 env 清除是否最终落地需以合并后代码为准。 · unresolved

风险与影响

本 PR 无生产代码变更,风险集中在测试本身与 CI 稳定性:

  • 尾部数个 P1/P2 线程(CUDA-only 传输断言门控、进程以 SGLANG_USE_CUDA_IPC_TRANSPORT=0 启动时 auto-selection 不触发、provenance 检查仅覆盖默认 Llama 形状)在材料中未见作者明确回复,合并版本是否全部落实存在不确定性;若未落实,CPU/AMD 套件或特定环境变量下的 CUDA runner 可能出现预期外失败。
  • 测试依赖对进程状态的完整快照与恢复;若未来 EnvField 增加不在类属性上声明的状态,_process_state 会漏掉,恢复不完整可能使后续用例互相污染。
  • 测试钉死的是 fork 场景(子进程继承父进程环境与模块级缓存);spawn 子进程冷模块缓存(runtime_context 中的 functools memos 等)下的分歧不在覆盖范围,docstring 已声明该边界。
  • CI 时长增加:每个注册约 10s 预估时间,三平台合计约 30s。

对用户无直接运行时影响(纯测试变更)。对系统:该测试保护配置袋重构栈的"子进程自行派生解析值"契约,任何解析过程中读写进程状态的回归都会被拦截,避免 launcher 与 scheduler 静默分歧;同时把 mm_feature_transport 的跨解析粘性行为记录为已知契约,为未来消除该行为提供断言基点。对团队:43 条 review 评论的往返打磨沉淀出一套"状态污染类测试防假绿"的方法论(禁用重试、深拷贝快照、subtest 隔离、探针必须非默认且断言确实发散),值得其他测试复用。

纯测试变更 尾部 P1/P2 线程未确认关闭 spawn 进程冷缓存场景超覆盖范围 依赖进程级状态恢复的测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论