Prhub

#37086 [Config] Round 5.1: the published-side readers ask the bags, and a platform fact gets one address

原始 PR 作者 ch-wan 合并时间 2026-08-30 17:18 文件变更 148 提交数 10 评论 5 代码增减 +1585 / -1114

执行摘要

读者端配置改问 bags,平台事实统一单地址

PR body 指出:Round 4 已让 arg_groups 做到「解析不再写 ServerArgs 字段」,但读者端仍是半转换状态——运行时代码直接读原始 server_args 字段、两个生产名字仅为了测试可 patch 而存在、平台事实(如『这台机器是 SM100』)在每个导入模块各有一份拷贝,一次只能对单个读者声明。后者已造成 round-4 CI 失败:选择 attention 后端的模块与校验选择的模块持有同一事实的不同拷贝,并阻塞 #37087 的 per-model 拆分。本 PR 的目标是完成读者端,让解析结果只从 config bags 读取、平台事实只有一个地址。

值得精读。该 PR 展示了大型配置重构中「机械化迁移 + 语义守卫」的组合拳:每个提交独立可审(commit 3 是风险点,commit 7/10 是它的两个逃生口);PlatformContext 的单一地址设计、test_pre_publish_readers.py 从启动源码 AST 派生保护集、test_platform_address_not_frozen.py 的模块级冻结扫描都是可复用的测试模式。建议重点阅读 runtime_context.py 的 PlatformContext/override_platform、commit 3 的转换判据(has the reader's process published yet),以及 PR body 给出的三连问(何时运行、持有谁的 record、关闭写回是否破坏了这一处)。同时注意两条未解决的 Codex 意见(describe_kv_events_publisher 的 load_endpoint、冻结扫描覆盖盲区),合并后值得跟进修复。

讨论亮点

4 条 review 评论全部来自 chatgpt-codex-connector[bot](Codex 自动审查,均为 P2 级别),两条已在最终提交修复、两条仍未解决:

  1. _speculative_draft_quantization_explicitly_set 会被当成缓存种子(runtime_context.py):Codex 指出按下划线前缀归类会把真实 dataclass 字段误当私有缓存,导致 resolution_result 与 bag 继续回答旧值。最终提交已改为按「是否为 dataclass 字段」切分,真实字段走 declare_late_resolution,其余才 seed 私有缓存。已解决。
  2. describe_kv_events_publisher 直读原始 load_publish_endpoint:当该字段经由声明(override_server_args / 外部 late resolver)提供时,scheduler 已从 bag 启用 load publisher,而描述符仍读原始值,会漏报 load_endpoint_port_base,使路由发现不了活动 socket;Codex 建议改用 resolved.load_publish_endpoint。未见修复证据,未解决。
  3. deepseek_v4_backend 模块级冻结 _is_sm120:转换期间写出的 _is_sm120 = get_platform().is_sm120 在 import 时固化答案,override 无法到达且结果依赖 import 顺序——PR body 称之为「看起来转换了、实际没转换」。最终提交已清除这 4 处(含 fp8_utils 的 3 处),并由 test_platform_address_not_frozen.py 守卫。已解决。
  4. 冻结扫描只覆盖直接 Assign:Codex 指出 AST 扫描只检查 tree.body 的直接 Assign 且 RHS 必须是纯 get_platform().fact,漏掉 layers/communicator.py:95-96 的布尔赋值、quantization/fp4_utils.py:26 的 try 内条件赋值、fused_moe_triton/triton_kernels_moe.py:25 的模块级分支。守卫无法兑现「无模块级冻结」的声明。未解决。

实现拆解

按 10 个独立提交拆解如下:

  1. 移除最后两个测试补丁缝attention_backends_of(3 个 patch 点)与 supports_mamba_cache_extra_buffer(2 个)不再作为 arg_groups/overrides.py 中可 patch 的接口,改由调用方直接给出输入字段。attention_backends_of 的语义就是 prefill_attention_backend or attention_backend(decode 同理),cuda-graph 测试夹具直接设置 attention_backend 字段即可。
  2. runtime_context 导入提升:48 个文件里的 86 处函数内 from ... import 提升为模块级;用「在新解释器里先导入受影响模块」的方式冒烟验证,发现 16 个断点,以不动点迭代 + 失败回滚收尾到 86→4。剩下的 4 个是真实循环依赖:eplb/expert_distribution ×2、musa/flashattention_backendmoe_runner/flashinfer_trtllm
  3. 读者问 bags + 关闭写回_apply_fields——最后一个写回 ServerArgs 的通道——被删除,ServerArgs.__setattr__ 守卫中的 _internal_write 豁免随之移除(见 server_args.py 的改动)。转换判据不是「字段能否映射」,而是「读者的进程是否已 publish」:launcher 自身的 172 处 pre-publish 读取(9 个文件)留在 record 上。
  4. 平台事实单一地址runtime_context.py 新增 PlatformContext,通过 _PLATFORM_PROBES / _PLATFORM_VALUES 两张表把事实名映射到 utils/common.py 里的唯一实现 probe(这些 probe 仍是唯一真实实现,这里只是它们的地址,不是第二份拷贝)。get_platform() 返回全局单例,override_platform(**facts) 提供带作用域的上下文管理器与装饰器,可嵌套、可恢复、未知事实双路径拒绝。
  5. sm 家族与数值事实跟进:47 个文件里 176 处 is_sm90/is_sm100/is_sm120/is_blackwell 等读取走地址;device_smdevice_capability 作为非 yes/no 事实加入 _PLATFORM_VALUES
  6. KV-events 描述符搬家ServerArgs.describe_kv_events_publisher(117 行)从 record 移到发布侧 runtime_context.describe_kv_events_publisher/server_info 与 gRPC bridge 两个调用方都运行在已发布进程中,适合读 bags。
  7. configure_logger 缺陷修复:commit 3 引入的缺陷——configure_logger 在 publish 前运行却读 get_observability() bag,导致每次真实启动崩溃(单测因先 publish 而全绿);修复为读它手上被传入的 record。multimodal_gen 传入的 ServerArgs 从不发布 bags,由 test_pre_publish_readers.py 按值钉住。
  8. arg_groups 内设备探测走地址:83 处读取改走 get_platform(),这使 #37087 成为可能——一个模型家族搬进自己的模块时,探测读取随之迁移,模块级 patch 也必须跟着搬。
  9. 清理无人读取的参数start_sidecarupdate_deep_gemm_configcreate_msprobe_debugger 删掉不再使用的 server_args 形参及所有调用点实参;deep_gemm_wrapper 的转发包装也被发现只是在传递 record。
  10. per-worker 放置回归构造参数:两个被转成 bag 的读者(决定 worker 落哪张 GPU、_fast_image_processor_device)按既定规则改回通过构造参数(MMEncoder(gpu_id=...))读取,避免配置层承载 per-worker 事实。

测试与验证配套:新增 test_platform_context.pytest_pre_publish_readers.pytest_platform_address_not_frozen.py 三个测试文件;62-shape 解析一致性探测在分支 tip 与每个单独提交上逐字段一致;注册单测逐文件对比 base 失败集为 0 回归;真实启动 Qwen3-0.6B 完成 /health/generate 往返。

文件 模块 状态 重要度
python/sglang/srt/runtime_context.py 上下文 modified 8.84
python/sglang/srt/arg_groups/overrides.py 覆盖声明 modified 7.92
test/registered/unit/test_pre_publish_readers.py 预发布守卫 added 7.55
python/sglang/srt/server_args.py 服务参数 modified 7.48
test/registered/unit/test_platform_context.py 平台事实 added 7.13
test/registered/unit/test_platform_address_not_frozen.py 冻结守卫 added 7.09
python/sglang/srt/mem_cache/sparsity/factory.py 稀疏缓存 modified 6.64
python/sglang/srt/distributed/bootstrap.py 分布式启动 modified 6.61
python/sglang/srt/layers/quantization/fp8_utils.py 量化工具 modified 6.56

关键符号

get_platform override_platform PlatformContext _resolved_or_field describe_kv_events_publisher parse_hisparse_config _resolve_dist_init_method _apply_fields configure_logger _pre_publish_callees

关键源码片段

python/sglang/srt/runtime_context.py dependency-wiring

本 PR 的架构核心:新增 PlatformContext / get_platform / override_platform 单一地址机制、_resolved_or_field 辅助函数;override_server_args 安装逻辑改为按真实 dataclass 字段切分(修复 Codex P2);KV-events 描述符落位于此。

# 平台事实的统一地址:_PLATFORM_PROBES 把「事实名」映射到 utils.common 里的唯一实现 probe。
# 所有读者都通过 get_platform() 问同一个对象,而不是各自 import 一份拷贝——
# 后者正是 round-4 CI 失败(选择后端与校验后端各持一份 is_sm100_supported)的根因。
_PLATFORM_PROBES: Dict[str, str] = {
    "is_cuda": "is_cuda",
    "is_sm90": "is_sm90_supported",
    "is_sm100": "is_sm100_supported",
    "is_sm120": "is_sm120_supported",
    "is_blackwell": "is_blackwell_supported",
    "has_amx": "cpu_has_amx_support",
    "has_flashinfer": "is_flashinfer_available",
    # ...
}# 非 yes/no 的数值型事实也走同一地址,便于 override 统一生效。
_PLATFORM_VALUES: Dict[str, str] = {
    "device_sm": "get_device_sm",
    "device_capability": "get_device_capability",
}
​
​
class PlatformContext:
    """机器自身事实的单一地址;override 一次即可让所有读者看到同一答案。"""
​
    __slots__ = ("_overrides",)
​
    def __init__(self) -> None:
        object.__setattr__(self, "_overrides", {})
​
    def __getattr__(self, name: str) -> Any:
        # 读取时先查 override 表,否则回落到底层 probe(已 lru_cache,约 26 ns)。
        probe = _PLATFORM_PROBES.get(name) or _PLATFORM_VALUES.get(name)
        if probe is None:
            known = sorted(set(_PLATFORM_PROBES) | set(_PLATFORM_VALUES))
            raise AttributeError(
                f"unknown platform fact {name!r}; known: {', '.join(known)}"
            )
        overrides = object.__getattribute__(self, "_overrides")
        if name in overrides:
            return overrides[name]
        from sglang.srt.utils import common as _common
​
        return getattr(_common, probe)()
​
    def __setattr__(self, name: str, value: Any) -> None:
        # 直接赋值只会移动一个读者的答案,正是要消灭的缺陷:必须走 override_platform。
        raise AttributeError(
            "platform facts are not assigned; use "
            "`sglang.srt.runtime_context.override_platform(...)` so every reader agrees"
        )
​
    def _install(self, **facts: Any) -> Dict[str, Any]:
        # 未知事实在两条路径(getattr 与 override)上都被拒绝。
        unknown = set(facts) - set(_PLATFORM_PROBES) - set(_PLATFORM_VALUES)
        if unknown:
            raise ValueError(f"unknown platform fact(s): {sorted(unknown)}")
        overrides = object.__getattribute__(self, "_overrides")
        previous = {k: overrides[k] for k in facts if k in overrides}
        missing = [k for k in facts if k not in overrides]
        overrides.update(facts)
        return {"previous": previous, "missing": missing}
​
    def _restore(self, saved: Dict[str, Any]) -> None:
        # 恢复时只回写之前存在的键、删掉本次新增的键,保证可嵌套。
        overrides = object.__getattribute__(self, "_overrides")
        overrides.update(saved["previous"])
        for k in saved["missing"]:
            overrides.pop(k, None)
python/sglang/srt/arg_groups/overrides.py core-logic

删除 _apply_fields 写回通道(-112 行);Kimi-K3、DeepSeek、MiniMax、GPT-OSS 等模型覆盖里的 sm 家族与设备探测统一改走 get_platform(),是平台地址在解析管线中的最大应用点。

# Kimi-K3 的注意力后端决策:此前直接调用 is_sm100_supported() 与 get_device_sm(),
# 每个导入模块各持一份拷贝,导致 round-4 CI 中「选择后端」与「校验后端」读到不同事实。
# 现在统一问 get_platform(),一次 override_platform 即可覆盖所有读者。
if not (get_platform().is_sm100 and get_platform().device_sm in (100, 103)):
    return {}
backends_unset = is_attention_backend_not_set(cfg)
if cfg.speculative_algorithm != "DSPARK":
    if not backends_unset:
        return {}
    logger.info(
        "Use trtllm_mla as the default prefill and decode attention "
        "backend for Kimi-K3 on SM100/SM103."
    )
    return {
        "decode_attention_backend": "trtllm_mla",
        "prefill_attention_backend": "trtllm_mla",
    }
# DSPARK 分支:verify 跑在 decode 后端上,避免 flashinfer 每步 D2H 同步,
# 显式后端旋钮仍优先,但 mode 旋钮依然需要声明。
q_len = cfg.speculative_num_draft_tokens or (
    cfg.speculative_dspark_block_size + 1
    if cfg.speculative_dspark_block_size is not None
    else 8 # checkpoint 自动推断发生在 overrides 之后,K3 draft 用 block 7
)
test/registered/unit/test_pre_publish_readers.py test-coverage

新增的守护测试:从 _launch_subprocesses 源码 AST 派生「publish 之前被调用的函数集」,逐个在未发布上下文里驱动,防止读者被转成 bag 后只在真实启动时暴雷(configure_logger 就是这么 shipped 的)。

def _pre_publish_callees():
    """读取 _launch_subprocesses 源码,推导它在 publish 之前调用的所有函数。    保护集从启动路径派生而非手工罗列:一旦有人在 publish 之前新增调用,
    它自动进入保护集;任何被转换为读 config bag 的 pre-publish 读者都会让测试变红。
    """
    source = (
        pathlib.Path(next(iter(sglang.__path__))) / "srt" / "entrypoints" / "engine.py"
    ).read_text(encoding="utf-8-sig")
    tree = ast.parse(source)
    launcher = next(
        node
        for node in ast.walk(tree)
        if isinstance(node, ast.FunctionDef) and node.name == "_launch_subprocesses"
    )
    # publish 调用所在行之前的所有普通函数调用,都是保护对象。
    publish_line = min(
        node.lineno
        for node in ast.walk(launcher)
        if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "publish"
    )
    return {
        node.func.id
        for node in ast.walk(launcher)
        if isinstance(node, ast.Call)
        and isinstance(node.func, ast.Name)
        and node.lineno < publish_line
    }
​
​
def test_none_of_them_asks_a_bag(self):
    """每个 pre-publish 读者都在未发布上下文中被调用。    一旦某个读者改成读 config bag,就会抛 "config namespace ... not published",
    该异常即失败信号;其他异常属于调用者自身,不属于本守卫的管辖范围。
    """
    server_args = ServerArgs(model_path="dummy", log_level="warning")
    for name, call in sorted(_EXERCISED.items()):
        with self.subTest(callee=name):
            reset_context()
            try:
                call(server_args)
            except Exception as exc: # noqa: BLE001 -- 见 docstring
                self.assertNotIn(
                    "not published",
                    str(exc),
                    f"{name} runs before publish and asked a config bag",
                )

评论区精华

下划线前缀的真实配置字段被当作缓存种子 正确性

Codex P2:override_server_args 把所有下划线前缀名字当私有缓存直接写属性,会悄悄破坏 _speculative_draft_quantization_explicitly_set——它是真实 dataclass 字段且发布在 spec 命名空间。直接写属性后,之前的 resolution 声明仍保持权威,resolution_result、get_spec()、draft ModelConfig 都继续答 False。

结论:已修复:head 版本改为按『是否为 dataclass 字段』切分,真实字段走 declare_late_resolution,仅非字段名 seed 私有缓存。 · 已解决

describe_kv_events_publisher 直读原始 load_publish_endpoint 正确性

Codex P2:当 load_publish_endpoint 通过声明(override_server_args / 外部 late resolver)提供时,本提交不再把公开 override 写回 ServerArgs 字段,scheduler 从 resolved bag 启用 load publisher,而描述符仍读原始值,/server_info 会漏掉 load_endpoint_port_base,导致路由发现不了活动 socket。

结论:建议改用 resolved.load_publish_endpoint 与其他描述符输入一致;PR 内未见修复证据,属未决疑虑。 · unresolved

deepseek_v4_backend 模块级冻结 _is_sm120 正确性

Codex P2:_is_sm120 = get_platform().is_sm120 在 import 时固化真实 probe 结果,override_platform(is_sm120=...) 无法到达 SM120 相关后端路径,行为依赖模块导入顺序,违背单一地址契约。

结论:已解决:PR 新增 test_platform_address_not_frozen.py 禁止任何模块级 x = get_platform().y,最终提交清除 4 处冻结(含 fp8_utils 的 3 处)。 · 已解决

冻结扫描只覆盖直接 Assign 节点 测试

Codex P2:_frozen_platform_reads 只检查 tree.body 的直接 Assign 且 RHS 是纯 get_platform().fact,当前仍漏掉 layers/communicator.py:95-96 的布尔赋值、quantization/fp4_utils.py:26 的 try 内条件赋值、fused_moe_triton/triton_kernels_moe.py:25 的模块级分支——这些值或副作用仍依赖 import 顺序,守卫无法兑现其声明的『无模块级冻结』不变量。

结论:建议递归遍历模块级表达式、检测嵌入在布尔 / 条件 / 分支里的平台读取;PR 内未修复。 · unresolved

风险与影响

主要风险集中在 commit 3 的读者转换与平台事实的单一地址化:

  • pre-publish 读取风险:被转成 bag 的读者若运行在 publish 之前会直接崩溃。configure_logger 已真实发生过(每次启动即挂,单测全绿),test_pre_publish_readers.py 用 AST 从 _launch_subprocesses 源码派生保护集来兜底,但该守卫只覆盖启动路径,multimodal_gen 等独立入口仍可能漏网。
  • import 顺序敏感:模块级 x = get_platform().y 会把事实冻结在 import 时刻。守卫测试只覆盖直接 Assign,Codex 已指出 communicator.pyfp4_utils.pytriton_kernels_moe.py 的嵌入读取仍在扫描盲区,行为仍可能依赖 import 顺序。
  • describe_kv_events_publisher 读原始字段load_publish_endpoint 若由 late resolution 声明提供,描述符会漏掉 load_endpoint_port_base,导致 model gateway 等路由无法发现活动 socket——这是 wire contract 层面的兼容性风险,且 Codex 意见未在 PR 内解决。
  • 超宽改动面:148 个文件、+1585/-1114。虽有 62-shape 解析一致性探测与 0 回归验证,但该验证覆盖的是「解析结果不变」,无法覆盖所有运行时读取路径;且 PR 依赖「每个注册单测逐文件运行」的失败集对比,对测试本身的覆盖盲区敏感。
  • 有利面:不触碰任何 kernel 与 model-forward 代码,get_platform() 底层 probe 已 lru_cache,单次读取约 26.2 ns,不在每请求路径上。

影响面为 SGLang 全部配置读取路径:27 个文件 81 处运行时读取改问 bags,47 个文件 176 处 sm 家族探测走统一地址,arg_groups 内 83 处设备探测迁移。对最终用户无可见行为变化(62-shape 解析结果逐字段一致),但测试与插件作者受影响:不能再通过 patch("sglang.srt.arg_groups.serving_hook.is_cuda") 这类模块级 patch 伪造平台,必须改用 override_platform(is_cuda=True) 装饰器或上下文管理器。对团队而言,这是一次配置读取纪律的收敛——record 只承载 operator 输入与 launcher 的 pre-publish 读取,业务代码统一问 bags,平台事实只有一个可 override 的地址。它是 #37087 per-model 拆分的前置,合并后该 PR 的 base 会被自动重定向到 main。

跨模块重构 148 文件 核心配置路径 pre-publish 读取风险 import 顺序敏感 review 意见未全部解决

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论