Prhub

#37087 [Config] Round 5.2: the per-model declarations get their own modules

原始 PR 作者 ch-wan 合并时间 2026-08-30 17:24 文件变更 44 提交数 7 评论 2 代码增减 +2898 / -1730

执行摘要

按模型家族拆分配置覆盖声明,守卫字段重叠

PR body 引用 2026-07-03 placement ruling:config-time model overrides 必须留在 arg_groups/(模型模块不在 __post_init__ 导入、前端进程不得依赖 GPU kernel 导入),但 family 增长后允许机械拆分。27 个 handler 和 992 行挤在单个 3043 行的 overrides.py 里,新增模型家族意味着要编辑所有家族共用的文件,且各家族逻辑被埋在注册表、视图和后处理 pass 之间。拆分此前被 #37086 阻塞——它给 platform facts 一个唯一地址后,family 模块才能连同 probe reads 一起移出。

值得精读。重点关注三处设计:一是 model_override_base.py 的双视图设计——ResolvedView(构造时快照 overlay,适合 post-process pass)与 ResolvingConfig(每次读取遍历 stash,适合声明后的实时读取),以及 collect_model_override_declarations 的 last-writer-wins 语义;二是"用测试禁止重叠代替 pin import 顺序"的决策,这是对 import 副作用注册体系的正确解耦;三是 test_model_source_paths.py 的 mutation 验证方法,展示了如何证明测试真的有效而不是假设有效。后续维护者新增模型家族时应遵守 model_overrides/ 目录约定并保持单字段单一归属。

讨论亮点

本 PR 没有人工 review 评论(Codex Review 完成且无 findings),核心讨论集中在 PR body 的作者自述中:

"isort alphabetised that list mid-split and flipped which module won for InternS2Mobius。The guard is not a pinned order。An overlap is a defect on its own: nobody owns the value, and it gets settled by whatever order the imports happen to be in."

这是本 PR 最重要的设计交锋:一个纯机械拆分引发的 import 顺序敏感问题,最终用"禁止重叠声明"而非"固定 import 顺序"来根治,使 isort 可以自由排序列表。

"That is how a change to get_model_config()'s cache semantics went green through PR CI and broke two days later in the nightly: the axis it broke was not being looked at."

作者以此说明 test_model_source_paths.py 的动机——测试空白会让回归在 PR CI 阶段漏网,并强调新测试用 mutation 验证过有效性,而不是假设有效。

实现拆解

  1. 基础设施下沉到 model_override_base.py:注册表(MODEL_OVERRIDES_MODEL_OVERRIDE_FNS_PREDICATE_OVERRIDE_FNS)、注册装饰器(register_model_overrideregister_model_override_predicate_register_for)、只读视图(ResolvedView 快照视图、ResolvingConfig 实时视图)、访问器(attention_backends_ofmodel_config_ofget_default_attn_backend 等)全部移入新文件。依赖方向是关键:family 模块只 import base,不反向依赖 overrides.py,因此没有循环 import。overrides.py 通过 from sglang.srt.arg_groups import model_override_base 读取注册对象并 re-export 同名符号,保持既有调用方兼容。
  2. 26 个家族模块纯移动:每个模块一个 _<family>_overrides(server_args, hf_config) -> dict,用 @_register_for(...) 注册一个或多个架构,model_overrides/__init__.py 的 import 列表即注册动作。验证方式是 live registry 快照前后一致(55 个架构、61 次注册、顺序一致)以及 62-shape resolution probe 在 7 个 commit 上逐一对比 IDENTICAL,而不是逐文件人工审查。
  3. 非机械决策:禁止字段重叠:isort 在拆分中自动排序 import 列表曾翻转 InternS2Mobius 的赢家声明。作者放弃固定 import 顺序,改为由 test_model_override_split.py 断言任何架构的两个注册者不得声明同一字段、常量表不与会话竞争、磁盘上的 family 模块必须全部出现在 import 列表中。字段名提取复用 chain-read ratchet 的工具函数,避免两套扫描逻辑不一致。
  4. 重复规则收编modelexpress_config 的 JSON 解析此前在 arg_groups(transfer-engine gate)和 record(_parsed_modelexpress_config 属性)各有一份,统一为 modelexpress_config_ofpre_capture_activation_reserve_mb 改写为 pre_capture_activation_reserve_mb_of(arg_groups helper + runtime_context 的 published-bag 双胞胎,tier 一致性测试改为两 tier 直接对比);ssl_verify 并入 serving_hook 的 SSL 校验;kv_event_block_size_support_mamba_cache_extra_buffer 等 record 成员删除并委托到 arg_groups。ServerArgs 净删约 70 行。
  5. 测试与验证配套test_model_source_paths.py 覆盖三条此前无 PR-CI 的模型路径解析轴(GGUF Hub 引用、ModelScope repo id、remote-connector URL),全部通过 mock/stub 免网络,并用三个 mutation 验证测试有效性(删 GGUF 声明失败 3 例、删 ModelScope 下载目录查找失败 1 例、删 model_weights 写入失败 1 例);处理器测试从裸 MagicMock 改为真实 ServerArgs,避免所有属性为真值导致 worker 计数分支误判。
文件 模块 状态 重要度
python/sglang/srt/arg_groups/model_override_base.py 覆盖注册表 added 9.17
python/sglang/srt/arg_groups/overrides.py 覆盖注册表 modified 8.65
python/sglang/srt/arg_groups/model_overrides/kimi_k3.py 模型配置 added 8.78
python/sglang/srt/server_args.py 服务参数 modified 8.0
test/registered/unit/test_model_override_split.py 拆分守卫 added 7.43
test/registered/unit/server_args/test_model_source_paths.py 模型路径 added 8.02
python/sglang/srt/arg_groups/model_overrides/__init__.py 模型配置 modified 6.0
python/sglang/srt/arg_groups/model_overrides/minimax_m3.py 模型配置 added 7.72
python/sglang/srt/arg_groups/model_overrides/deepseek_v2.py 模型配置 added 7.71

关键符号

register_model_override register_model_override_predicate _register_for _invoke_provider resolving_view resolved_view model_config_of collect_model_override_declarations modelexpress_config_of pre_capture_activation_reserve_mb_of _kimi_k3_overrides _kimi_k3_moe_runner_overrides _minimax_m3_overrides _deepseek_family_overrides

关键源码片段

python/sglang/srt/arg_groups/model_override_base.py data-contract

PR 的核心:注册表、只读视图(ResolvedView/ResolvingConfig)与访问器全部下沉到这里,定义 family 模块的依赖方向,是拆分的地基。

"""per-model 覆盖声明所依赖的基础设施。"""import logging
from typing import Any, Callable, Dict, List, Optional, Tuplelogger = logging.getLogger(__name__)
​
​
# 常量级覆盖:arch -> {field: value},在 derived provider 之前应用。
MODEL_OVERRIDES: Dict[str, Dict[str, Any]] = {
    "MistralLarge3ForCausalLM": {"dtype": "bfloat16"},
    "PixtralForConditionalGeneration": {"dtype": "bfloat16"},
}# 派生覆盖 provider 注册表,按注册顺序保存。
_MODEL_OVERRIDE_FNS: Dict[str, List[Callable[..., dict]]] = {}# 谓词键控 provider(旧分支按架构子串匹配),同样按注册顺序。
_PREDICATE_OVERRIDE_FNS: List[Tuple[Callable[[str], bool], Callable[..., dict]]] = []
​
​
def register_model_override(architecture: str):
    """注册一个按架构精确匹配的派生覆盖 provider。    被装饰的可调用对象接收 (server_args, hf_config),不得修改两者,
    返回 {field: resolved_value} 字典;什么都不适用时返回空字典。
    """
​
    def decorator(fn: Callable[..., dict]) -> Callable[..., dict]:
        _MODEL_OVERRIDE_FNS.setdefault(architecture, []).append(fn)
        return fn
​
    return decorator
​
​
class ResolvedView:
    """post-process pass 拿到的只读快照视图。    构造时把当前 declaration stash 叠加为 overlay;pass 从自己的槽位
    读取时看到的是"已累积声明覆盖在原始字段上"的状态。写入被拒绝,
    pass 只能返回新声明。
    """
​
    __slots__ = ("_server_args", "_overlay")
​
    def __init__(self, server_args: Any, overlay: Optional[Dict[str, Any]] = None):
        object.__setattr__(self, "_server_args", server_args)
        object.__setattr__(self, "_overlay", overlay or {})
​
    def __getattr__(self, name: str) -> Any:
        overlay = object.__getattribute__(self, "_overlay")
        if name in overlay:
            return overlay[name]
        # overlay 没有的字段回退到 record 的原始输入
        return getattr(object.__getattribute__(self, "_server_args"), name)
​
    def __setattr__(self, name: str, value: Any) -> None:
        raise AttributeError("ResolvedView is read-only; passes return declarations")
​
​
class ResolvingConfig:
    """实时读取视图:每次读取都走一遍 stash,最后写入者优先。    ResolvedView 在构造时快照 overlay,适合 post-process pass;
    而 resolver 在"声明之后"再读取时,需要的是当前最新答案。
    """
​
    __slots__ = ("_server_args",)
​
    def __init__(self, server_args: Any):
        object.__setattr__(self, "_server_args", server_args)
​
    def __getattr__(self, name: str) -> Any:
        server_args = object.__getattribute__(self, "_server_args")
        for _source, declared in reversed(
            getattr(server_args, "_resolved_overrides", None) or ()
        ):
            if name in declared:
                return declared[name]
        return getattr(server_args, name)
​
    def __setattr__(self, name: str, value: Any) -> None:
        raise AttributeError("ResolvingConfig is read-only")
python/sglang/srt/arg_groups/model_overrides/kimi_k3.py data-contract

26 个家族模块中最复杂的一个,集中 DCP/DSPARK/SM100 注意力与 MoE 后端默认,最能代表 per-family 声明的真实复杂度。

@_register_for("KimiK3ForConditionalGeneration")
def _kimi_k3_overrides(server_args: Any, hf_config: Any) -> dict:
    cfg = resolving_view(server_args)
​
    # DCP(decode 上下文并行)分支:注意力后端、KV dtype、通信后端
    # 都有强约束,且与 FlashInfer 版本能力绑定。
    if cfg.dcp_size > 1:
        overrides = {}
        if cfg.enable_symm_mem:
            logger.warning(
                "Kimi-K3 DCP disables --enable-symm-mem due to decode CUDA "
                "graph correctness issues."
            )
            overrides["enable_symm_mem"] = False
​
        if cfg.speculative_algorithm == "DSPARK":
            # DSPARK 的 target-verify 必须跑在 decode 后端
            #(cutedsl_mla 实现了 DCP signature),否则 trtllm_mla 的
            # 基础 _run_decode_kernel 会因多余的 kwargs 抛 TypeError。
            overrides["speculative_attention_mode"] = "decode"
​
        prefill_backend, decode_backend = attention_backends_of(cfg)
        if decode_backend == "cutedsl_mla" or decode_backend is None:
            _require_kimi_k3_cutedsl_dcp_support()
            overrides.update(
                prefill_attention_backend="trtllm_mla",
                decode_attention_backend="cutedsl_mla",
            )
        elif decode_backend == "tokenspeed_mla":
            overrides.update(
                prefill_attention_backend="tokenspeed_mla",
                decode_attention_backend="tokenspeed_mla",
                kv_cache_dtype="fp8_e4m3",
            )
        else:
            raise AssertionError(
                f"Decode attention backend for Kimi-K3 DCP must be "
                f"'cutedsl_mla' or 'tokenspeed_mla', got {decode_backend!r}."
            )
​
        if cfg.dcp_replicate_q_proj is None:
            overrides["dcp_replicate_q_proj"] = True
        # 非 fabric 设备回落 a2a,MNNVL fabric 走 fi_a2a
        overrides["dcp_comm_backend"] = (
            "fi_a2a" if is_mnnvl_fabric_device() else "a2a"
        )
        return overrides
​
    # 非 DCP:只有 SM100/SM103 才干预默认后端
    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 {}
        return {
            "decode_attention_backend": "trtllm_mla",
            "prefill_attention_backend": "trtllm_mla",
        }
​
    # DSPARK 分支:q_len 决定 verify kernel 是否能跑在 decode 后端;
    # checkpoint 自动推断发生在 overrides 之后,draft 默认 block 7。
    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
    )
    overrides = {}
    if backends_unset:
        backend = "trtllm_mla"
        overrides["decode_attention_backend"] = backend
        overrides["prefill_attention_backend"] = "trtllm_mla"
    else:
        # 显式 backend 优先,但 speculative_attention_mode 是独立旋钮,
        # 仍需要声明——否则 verify 留在 prefill 后端,host-side plan
        # 默认 flashinfer 会带来每步 D2H 同步。
        _, backend = attention_backends_of(cfg)
    if _dspark_verify_on_decode_backend(backend, q_len, cfg.kv_cache_dtype):
        overrides["speculative_attention_mode"] = "decode"
    return overrides
test/registered/unit/test_model_override_split.py test-coverage

把 " 禁止字段重叠 " 这个非机械决策固化为测试,保证 import 顺序不再承载行为,是拆分质量的契约标尺。

class TestModelOverrideSplit(CustomTestCase):
    def test_no_field_is_declared_by_two_family_modules(self):
        # 找被两个以上家族模块认领的架构(合法:例如 Qwen3NextForCausalLM
        # 的注意力形状来自 qwen3_5、MoE runner 来自 qwen3_moe)
        contested = {
            arch: fns for arch, fns in _MODEL_OVERRIDE_FNS.items() if len(fns) > 1
        }
        self.assertTrue(contested, "the scan found no architecture with two claimants")
        for arch, fns in sorted(contested.items()):
            with self.subTest(architecture=arch):
                seen: dict[str, str] = {}
                for fn in fns:
                    for field in _returned_field_names(fn):
                        earlier = seen.get(field)
                        self.assertIsNone(
                            earlier,
                            f"{arch}: {fn.__module__}.{fn.__name__} and {earlier} "
                            f"both declare {field!r}, so which one wins depends "
                            f"on the order of the imports in "
                            f"arg_groups/model_overrides/__init__.py",
                        )
                        seen[field] = f"{fn.__module__}.{fn.__name__}"
​
    def test_the_import_list_names_every_family_module(self):
        # importing 即注册;某模块从 import 列表漏掉 = 该家族静默失效,
        # 而直接 import 该 provider 的测试不会发现。
        package = pathlib.Path(model_overrides.__file__).parent
        on_disk = {
            path.stem for path in package.glob("*.py") if path.stem != "__init__"
        }
        imported = {
            alias.name
            for node in ast.walk(ast.parse((package / "__init__.py").read_text()))
            if isinstance(node, ast.ImportFrom)
            and node.module == "sglang.srt.arg_groups.model_overrides"
            for alias in node.names
        }
        self.assertEqual(on_disk, imported)

评论区精华

家族模块字段重叠 vs import 顺序语义 设计

isort 在拆分中自动排序 import 列表,曾翻转 `InternS2Mobius` 的赢家声明;作者认为 overlap 本身就是缺陷:没有所有权归属,胜负由 import 顺序决定。

结论:放弃 pinned order,改为禁止同一架构的两个模块声明同一字段;`test_model_override_split.py` 在重叠发生当天失败,import 列表可自由排序。 · 已解决

model-source 测试空白导致的回归漏网 测试

`get_model_config()` 缓存语义变更在 PR CI 全绿、两天后在 nightly 破坏;GGUF/ModelScope/remote-connector 三条路径此前无注册测试。

结论:新增 `test_model_source_paths.py`,并用三个 mutation 验证测试有效性(删 GGUF 声明失败 3 例、删 ModelScope 下载目录查找失败 1 例、删 `model_weights` 写入失败 1 例)。 · 已解决

stacked 依赖 #37086 与 base 重检 other

PR stacked on #37086;合并后 repo force-deletes merged heads 并 auto-retarget 子 PR 到 main,base 需要重新检查。

结论:作者要求先合并 #37086,并提示合并后需重新检查 base 与重跑 CI。 · 已解决

风险与影响

  1. 注册顺序与 import 副作用的耦合:虽然守卫测试消除了同架构字段重叠,但 _PREDICATE_OVERRIDE_FNS 仍按注册顺序应用、常量表先于 callables;未来新增 predicate 或常量注册仍可能重新引入顺序敏感,目前只有 test_model_override_split.py 覆盖精确架构注册,predicate 顺序无对应守卫。
  2. ServerArgs 公共契约变更pre_capture_activation_reserve_mbssl_verifymodelexpress_urlmodelexpress_transportkv_event_block_size_parsed_modelexpress_config_support_mamba_cache_extra_buffer 等 record 成员被删除;out-of-tree 插件或外部调用方若直接调用这些方法会立即 break,PR 未提供 deprecated shim 或迁移清单。
  3. import 副作用注册的静默失效model_overrides/__init__.py 的 import 列表一旦漏掉某家族模块,对应模型的配置声明会静默不生效;test_the_import_list_names_every_family_module 用 glob 对比磁盘与 import 列表可兜底,但测试本身位于 CPU unit 套件,若有人改测试套件选择器可能失去保护。
  4. 覆盖验证范围:62-shape probe 覆盖配置解析行为,但 26 个家族模块分散后,跨家族公共逻辑变更需要同时改多个文件;遗漏风险由 split 测试兜底,但不存在端到端模型启动覆盖(PR 声明无 kernel/model-forward 变更,nightly 风险较低)。

对用户无运行行为影响:62-shape resolution probe 在分支 tip 和每个 commit 上 IDENTICAL,live registry 快照 55 架构/61 注册顺序一致,所有注册单元测试 0 回归。对系统的影响集中在配置导入图与 ServerArgs 接口面:overrides.py 精简接近一半,record 成员减少 7 个,配置解析的关注点从单文件分散到按家族组织的模块。对团队而言,新增模型家族时不再需要编辑所有家族共用的 3043 行文件,文件冲突面和认知负载显著下降;同时"不得重叠声明"成为新增配置字段时必须遵守的契约,测试会在合并前拦截违规。

配置解析核心路径 ServerArgs 契约变更 import 副作用注册 跨模块重构

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论