Prhub

#33011 config: preserve resolved config across nested publishes + mutation ratchets

原始 PR 作者 ch-wan 合并时间 2026-08-01 06:05 文件变更 5 提交数 1 评论 5 代码增减 +244 / -5

执行摘要

配置生命周期快照恢复,双基线护栏锁定迁移

RFC #30696(RuntimeContext as the configuration API)指出 ServerArgs 同时承担“用户输入”与“运行时决议”双重身份、__post_init__ 职责过载、多进程配置安装缺乏类型身份等问题,目标之一是“One write path for resolution: publish(plus namespace .override for audited post-publish mutation)”。本 PR 解决该迁移中的具体落地难点:draft worker 等嵌套构建会 publish 私有 ServerArgs 副本,旧代码靠 finally 里的 set_server_args(saved) 恢复外层配置,但该恢复会从原始 ServerArgs 重新投影 bags,静默丢弃目标进程加载期通过 override() 写入的解析值(如 KV cache dtype,作者在评审回复中确认这是 main 上的活跃问题)。PR body 还明确引入两条 ratchet:ServerArgs.override 调用点精确下限、迁移延迟测试列表精确下限,防止迁移过程中写入面回涨或测试被继续模块级跳过。

值得精读。这是 RFC #30696 配置命名空间迁移的奠基 PR,核心看点有三:一是 preserve_config() 按值快照恢复的设计,解决“恢复即重建、重建即丢失”的经典问题;二是双向 ratchet(只减不增 + 减少必须下调基线)作为 CI 演进护栏的范式,值得其他大型迁移借鉴;三是 Codex 评审提出的两个真实缺陷与作者修复的往返过程,展示了上下文管理器设计中“必须有生产调用方”和“必须恢复对象内部状态而非引用”两条原则。

讨论亮点

评审主要由 Codex bot 提出两个 P1/P2 级问题,作者逐一修复:

  • P1(正确性):新增的 preserve_config() 当时没有任何生产调用方,build_draft_tp_worker 仍在 finally 中执行 set_server_args(saved_server_args),当目标初始化已通过 get_context().override() 记录解析值(如 KV cache dtype)时,republish 会从原始 ServerArgs 重建 bags 并清空 override log,DFLASH/DSPARK 初始化仍可能丢弃目标配置。作者将 build_draft_tp_worker 改为 with preserve_config(): 包裹构建,并确认旧路径是“main 上的活跃问题”;栈中下一 PR(#33012)会在该作用域内再做 draft-scoped publish。
  • P2(正确性):初始实现直接保存 bags 与 overrides_log 的引用,作用域内不 republish 直接 override() 会就地改动对象,finally 恢复的是已被改动的同一对象,解析值与 provenance 会泄漏出作用域。作者新增 _snapshot_bag_values / _restore_bag_values 按叶子快照,并补测试 test_preserve_config_restores_in_scope_override_without_republish

两个问题指向同一类陷阱:“恢复”如果只是重新赋值对象引用,而不是恢复对象内部状态,就不算恢复

实现拆解

1. 变更入口与核心 API:preserve_config()

python/sglang/srt/runtime_context.py 新增 @contextmanager 装饰的 RuntimeContext.preserve_config(),进入时快照 6 项状态:_server_args 槽位引用、_config_bags 引用、bag 树逐叶子值、_overrides_log 拷贝、parallel._configflags.capture.enable_torch_compilefinally 中原样恢复。设计核心是“原样恢复”而非“重新投影”——重新投影会丢失 post-publish override。

2. 按值快照而非引用快照

新增模块级函数 _snapshot_bag_values()_restore_bag_values(),递归遍历 _ConfigBag_fields_subs,对每个叶子做 dict() 拷贝并按路径回写;_overrides_loglist() 新拷贝。原因:get_context().override() 会就地修改 bags 并向同一个 log 列表 append,引用快照在 finally 中恢复的仍是已被改动的同一对象——这是 Codex P2 评审指出的真实缺陷,修复后新增了对应回归测试。

3. 生产接线:build_draft_tp_worker

python/sglang/srt/speculative/draft_worker_common.py 删除 get_server_args 导入与 try/finally 手动保存恢复,改为 with get_context().preserve_config(): 包裹 TpModelWorker 构建,在修复 main 上丢配置 bug 的同时,让新 API 有了第一个生产调用方(回应 Codex P1 评审)。

4. 双向 ratchet 护栏

新增两个测试文件,均注册 CPU CI(register_cpu_ci(est_time=5, suite="base-a-test-cpu")):

  • test/registered/unit/test_server_args_writer_ratchet.py:用正则计数 server_args.override( / \bargs.override( / \bsa.override( 调用点,基线 49,排除 srt/server_args.pysrt/arg_groupsmultimodal_gen
  • test/registered/unit/test_migration_deferral_ratchet.py:扫描 test/ 树下带 config-namespace migration marker 的模块跳过文件数,基线 15。

两者都是双向失败:数量增多直接 fail(禁止新增写入面或新延迟),数量减少也 fail(要求下调基线,把迁移进展锁死在 CI 里)。

5. 回归测试配套

test/registered/unit/test_runtime_context_override.py 新增 3 个用例:test_preserve_config_keeps_post_publish_overrides(嵌套 publish 后外层 override 原样保留,并逐条校验 overrides_log())、test_preserve_config_restores_in_scope_override_without_republish(无 republish 的 in-scope override 按值回滚)、test_preserve_config_restores_on_exception(异常路径同样恢复)。

文件 模块 状态 重要度
python/sglang/srt/runtime_context.py 配置上下文 modified 8.31
python/sglang/srt/speculative/draft_worker_common.py 草稿构建 modified 5.97
test/registered/unit/test_server_args_writer_ratchet.py 写入护栏 added 6.92
test/registered/unit/test_migration_deferral_ratchet.py 迁移护栏 added 6.98
test/registered/unit/test_runtime_context_override.py 覆盖测试 modified 6.49

关键符号

preserve_config _snapshot_bag_values _restore_bag_values build_draft_tp_worker test_preserve_config_keeps_post_publish_overrides test_preserve_config_restores_in_scope_override_without_republish test_preserve_config_restores_on_exception test_server_args_override_call_sites_match_the_baseline test_deferred_test_files_match_the_baseline

关键源码片段

python/sglang/srt/runtime_context.py core-logic

本 PR 核心:新增 `preserve_config()` 上下文管理器与 `_snapshot_bag_values` / `_restore_bag_values` 按值快照恢复逻辑,这是配置命名空间迁移的基础设施,直接决定嵌套 publish 能否保留外层 post-publish override。

# —— 配置 bag 树的按值快照 / 恢复 ——
# bags 会被 get_context().override() 就地修改,快照必须逐叶子拷贝,
# 否则 finally 里恢复的仍是同一个已被改动的对象(引用别名问题)。def _snapshot_bag_values(bags: dict | None) -> dict | None:
    """Per-leaf value snapshot of a config-bag tree."""
    if bags is None:
        return None
    snap: dict = {}
​
    def walk(prefix: str, bag) -> None:
        # 用 dict() 拷贝该叶子的 _fields,以完整路径作为 key
        snap[prefix] = dict(object.__getattribute__(bag, "_fields"))
        for name, sub in object.__getattribute__(bag, "_subs").items():
            walk(f"{prefix}.{name}", sub)
​
    for name, bag in bags.items():
        walk(name, bag)
    return snap
​
​
def _restore_bag_values(bags: dict, snap: dict) -> None:
    # 只回写快照里存在的 key,作用域内新增的叶子不会残留
    def walk(prefix: str, bag) -> None:
        for key, value in snap[prefix].items():
            bag._set(key, value)
        for name, sub in object.__getattribute__(bag, "_subs").items():
            walk(f"{prefix}.{name}", sub)
​
    for name, bag in bags.items():
        walk(name, bag)
​
​
# —— preserve_config:嵌套构建的保护壳 ——
# 槽位与 bags 可直接保存引用,但 bags 内部值、overrides_log 可变,
# 必须按值快照;parallel._config 与 capture 标志也一并纳入保护。@contextmanager
def preserve_config(self):
    """Snapshot the full config lifecycle and reinstate it verbatim on exit."""
    prev_server_args = self._server_args
    prev_bags = self._config_bags
    prev_bag_values = _snapshot_bag_values(prev_bags)
    prev_overrides_log = list(self._overrides_log) # 拷贝,防止 append 泄漏
    prev_parallel_config = self.parallel._config
    prev_capture = self.flags.capture.enable_torch_compile
    try:
        yield
    finally:
        self._server_args = prev_server_args
        self._config_bags = prev_bags
        if prev_bags is not None:
            # 恢复叶子值而非仅重新赋值对象,保证 in-scope override 回滚
            _restore_bag_values(prev_bags, prev_bag_values)
        self._overrides_log = prev_overrides_log
        self.parallel._config = prev_parallel_config
        self.flags.capture.enable_torch_compile = prev_capture
python/sglang/srt/speculative/draft_worker_common.py dependency-wiring

preserve_config 的第一个生产调用方:`build_draft_tp_worker` 由手动 save/republish 改为 `with preserve_config()` 包裹,修复 main 上嵌套构建丢 post-publish override 的活跃 bug,影响 DFLASH/DSPARK 等投机解码路径。

def build_draft_tp_worker(
    *,
    server_args: ServerArgs,
    gpu_id: int,
    ps: ParallelState,
    nccl_port: int,
    target_model_config: ModelConfig,
    algo_label: str,
    attention_backend_override: Optional[str] = None,
) -> DraftWorkerBundle:
    draft_server_args = deepcopy(server_args)
    # draft 副本的解析值调整必须走 audited mutation point:
    # 解析后的 ServerArgs 拒绝裸赋值,override() 保留写入来源
    draft_server_args.override(
        "draft_worker.build",
        skip_tokenizer_init=True,
        speculative_draft_attention_backend=draft_backend,
        prefill_attention_backend=None,
        decode_attention_backend=None,
        attention_backend=draft_backend,
        context_length=target_model_config.context_len,
    )
​
    # 用 preserve_config() 替代旧的 finally: set_server_args(saved):
    # 旧实现从原始 ServerArgs 重新投影 bags,会丢弃目标进程加载期
    # 通过 override 写入的解析值(如 ModelRunner 记录的 KV cache dtype)
    with get_context().preserve_config():
        draft_worker = TpModelWorker(
            server_args=draft_server_args,
            gpu_id=gpu_id,
            ps=ps,
            nccl_port=nccl_port,
            is_draft_worker=True,
        )
​
    draft_model_runner = draft_worker.model_runner
    draft_worker.draft_runner = draft_model_runner
    return DraftWorkerBundle(
        draft_worker=draft_worker,
        draft_model_runner=draft_model_runner,
        draft_model=draft_model_runner.model,
        resolved_attention_backend=draft_backend,
    )
test/registered/unit/test_server_args_writer_ratchet.py test-coverage

新增的写入面 ratchet:用正则精确钉住 `ServerArgs.override` 调用点基线 49,双向失败机制阻止迁移过程中写入面回涨,是本次迁移的 CI 护栏核心。

# —— 写入面 ratchet:ServerArgs.override 调用点只减不增 ——
# ServerArgs.override 只改实例本身,resolved-config bags 永远看不到写入,
# 命名空间读取方(get_exec() / get_memory())会与写入方失步;迁移终态里
# post-publish 的进程级修改必须走 get_context().override(source, **fields)_WRITER_PATTERNS = [
    re.compile(r"server_args\.override\("),
    re.compile(r"\bargs\.override\("),
    re.compile(r"\bsa\.override\("),
]
# 解析管线自身的 declare 面按设计会转发到 override,multimodal_gen 的
# ServerArgs 是另一个类,不在本契约内,均排除
_EXCLUDED = ("srt/server_args.py", "srt/arg_groups", "multimodal_gen")
_BASELINE = 49
​
​
class TestServerArgsWriterRatchet(CustomTestCase):
    def test_server_args_override_call_sites_match_the_baseline(self):
        count = 0
        for path in sorted(_SGLANG_ROOT.rglob("*.py")):
            rel = path.relative_to(_SGLANG_ROOT).as_posix()
            if rel.startswith(_EXCLUDED):
                continue
            source = path.read_text()
            count += sum(len(p.findall(source)) for p in _WRITER_PATTERNS)
        # 双向失败:增多提示改走 get_context().override(),
        # 减少要求下调基线,把迁移进展锁死在 CI 里
        if count > _BASELINE:
            self.fail("ServerArgs.override call-sites grew...")
        if count < _BASELINE:
            self.fail("ServerArgs.override call-sites shrank...")

评论区精华

preserve_config 必须有真实生产调用方(draft 构建路径) 正确性

Codex 提出 P1 级问题:新增的 preserve_config 没有任何生产调用方,build_draft_tp_worker 仍在 finally 中执行 get_context().set_server_args(saved_server_args),当目标初始化已通过 get_context().override() 记录解析值(如 ModelRunner 记录的 KV cache dtype)时,republish 会从原始 ServerArgs 重建 bags 并清空 override log,导致 DFLASH/DSPARK 初始化仍可能丢弃目标配置。

结论:作者在合入前修复:build_draft_tp_worker 改用 with get_context().preserve_config(): 包裹 TpModelWorker 构建,替代手动 save/republish;作者确认旧路径在 main 上就是活跃问题,栈的下一 PR(#33012)会进一步在该作用域内做 draft-scoped publish。 · 已解决

快照必须按值拷贝,防止 in-scope override 泄漏 正确性

Codex 提出 P2 级问题:初始实现保存的是 bags 与 overrides_log 的引用,作用域内若直接 get_context().override()(不重新 publish),会就地修改 bags 并向同一个 list append,finally 里只是把已改动的同一对象重新赋值,解析值与 provenance 会泄漏到作用域外。

结论:作者按值快照重写:新增 _snapshot_bag_values / _restore_bag_values 逐叶子拷贝 _fields,overrides_log 用 list() 拷贝,并新增回归测试 test_preserve_config_restores_in_scope_override_without_republish 覆盖该场景。 · 已解决

风险与影响

  • 核心配置路径变更RuntimeContext 是每个进程唯一的配置持有者,preserve_config() 的快照清单(slot、bags、provenance、parallel 配置、capture 标志)若漏掉未来新增的状态字段,嵌套构建后会静默丢失或残留状态;后续 PR(如 #33012 的 role publish)必须同步维护这份清单。
  • 文本匹配护栏的固有误差:writer ratchet 用正则 server_args.override(args.override(sa.override( 计数,docstring、注释与测试工具中的匹配也会计入(作者已注明“the count is textual”);新代码若用新的变量别名可能漏报,若恰好叫 args 又可能误报,护栏是演进约束而非精确清单。
  • 投机解码路径回归build_draft_tp_worker 的改动影响 DFLASH/DSPARK 等 draft 场景的初始化,preserve_config 与后续 draft-scoped publish 的叠加顺序需要验证;本 PR 有单元级回归测试,但未见端到端 draft 模型验证。
  • 栈依赖:本 PR 单独合入安全,但设计价值依赖 #33012/#33013 落地;若栈后续停滞,ratchet 会持续约束 ServerArgs.override 写入面,短期可能给并行开发的其他 PR 带来 CI 摩擦。
  • 运行时行为:修复嵌套 publish 场景中外层配置 post-publish override 被丢弃的问题,主要惠及带 draft worker(DFLASH/DSPARK)的投机解码部署,解析后的 KV cache dtype 等配置不再静默回退。
  • 开发流程:两个 ratchet 测试常驻 CPU CI 套件,对所有后续 PR 施加“只减不增”的硬约束:新增 ServerArgs.override 调用点或新增模块级迁移延迟都会让 CI 失败,指引开发者改走 get_context().override() 或恢复被跳过的测试。
  • 架构演进:作为 3-PR 栈的基石,为 #33012(role 化 publish)与 #33013(628 处读取点迁移到命名空间访问器)提供“嵌套构建不破坏外层配置”的语义保证,是配置命名空间迁移能否安全推进的关键前提。
核心配置路径变更 文本匹配基线测试可能误报 依赖后续栈 PR 落地 投机解码路径变更

关联 Issue

#30696 [RFC] RuntimeContext as the configuration API

完整报告

参与讨论