Prhub

#33170 config: route parallel config-leaf reads through get_parallel()

原始 PR 作者 ch-wan 合并时间 2026-08-01 23:58 文件变更 81 提交数 1 评论 19 代码增减 +344 / -405

执行摘要

并行配置读取全量迁至 get_parallel(),弹性 EP 写路径改走 override

PR body 明确指出:"The parallel namespace was the last reader family left on get_server_args(): 106 config-leaf reads ... still bypassed the published config bags"。在已发布的配置 bag 模型下,直读 server_args 会绕过被 override 过的已解析配置,造成读写两侧分离的 desync——这正是 #33168 暴露的 chunked-prefix gate 问题类型(写入落在无人读取的实例上)。此外,原先的写路径 server_args.override 只改 pristine 实例,而读取方已在迁移到 bag,弹性 EP 扩容后的新值无法被读取方看到。

值得精读。三个设计决策值得学习:(1) 访问器慢路径保持 dynamo 可追踪——graph-break 是编译路径上的隐性炸弹,用 _fields 字典判断和槽位直读替代 getattribute;(2) live-shadowed 拓扑尺寸原则——活属性优先于 bag,避免"同一名字两个值"的歧义;(3) ratchet 机制让跨 80+ 文件的迁移可以被机械验证。对要扩展 SGLang 配置体系、或编写 torch.compile 兼容代码的工程师都有参考价值。

讨论亮点

本 PR 由作者 ch-wan 自审,r2 有 4 项 open items,r3 全部关闭("Clean — no open findings")。核心交锋:

"gather-mode detection still calls require_mlp_tp_gather(mr.server_args)... Those helpers still branch on server_args.dp_size... After bag-only scale, re-entrant _dummy_run / buffer alloc can recompute gather mode from stale dp_size."

base_runner.py 的评论指出弹性扩容后重新进入的 dummy buffer 分配可能基于陈旧 dp_size 推导 gather 模式,require_mlp_tp_gather 内部对 EP scale 的 short-circuit 会掩盖此问题但很脆弱。作者修复为三个 helper 全部改读 bag。

"the function is half-migrated and will miss any future bag-only override of the siblings"

expert_location.py 的评论强调半迁移表面的隐患:ep_size 已切 bag 但同块 sibling 叶子(elastic_ep_initial_size、ep_join_rank_offset)还在 server_args 上;后续跟进评论又指出 ep_num_redundant_experts、ep_dispatch_algorithm 残留。均被修复,_init_raw 删除 server_args 参数,server_args.tp_size 作为刻意保留的 live-shadowed 读取。

conn.py 的评论指出防御性 getattr(self.server_args, "enable_dsa_cache_layer_split", False) 与迁移方向及项目 no-defensive-getattr 规范不一致,改为 get_parallel().enable_dsa_cache_layer_split。ratchet 数字的 nit(描述写 39→35,实际 38→34)也已修正描述。

实现拆解

1. 访问器核心改造(python/sglang/srt/runtime_context.py)

重构 ParallelContext.getattr:原实现用 object.getattribute(" _config ") 防御式取槽位,这在 torch.compile 编译路径下会 graph-break;而 enable_moe_dense_fully_dp() 等 gate helper 运行在编译后的模型 forward 里。新实现直接访问 slots 槽位 self._config,对下划线属性直接抛 AttributeError(同时打断 pickle/copy 协议在 init 前探测槽位导致的递归),并用 name in config._fields(普通 dict 条目)替代 _ConfigBag.contains(不可追踪)。配套 fullgraph 回归测试 test_parallel_config_leaves_trace_under_torch_compile 钉住该模式。

2. 106 处读取翻转(跨 81 文件)

按家族分布:

  • PP 调度循环(managers/scheduler_pp_mixin.py):pp_async_batch_depth、enable_dsa_prefill_context_parallel 等 9 处切换;
  • 弹性 EP 读取(model_executor/model_runner.py):ep_join_rank_offset、elastic_ep_initial_size、enable_dp_attention、dwdp_size、dcp_replicate_q_proj 等;
  • gather 模式判定(utils/common.py):require_mlp_tp_gather / require_attn_tp_gather / require_mlp_sync 的 dp_size、enable_dp_attention、moe_dense_tp_size、enable_dp_lm_head、disable_attn_tp_gather、elastic_ep_backend 全部改读 bag;
  • 专家放置(eplb/expert_location.py):ep_join_mode、moe_a2a_backend、ep_num_redundant_experts、ep_dispatch_algorithm 等从 get_exec().moe 读取;
  • PD 连接(disaggregation/common/conn.py):dp_size、load_balance_method、enable_dsa_cache_layer_split、nnodes,含 bootstrap_room % dp_size 的 prefill DP 路由校验;
  • 投机解码(speculative/dspark_components/dspark_worker_v2.py):enable_dp_attention、speculative_eagle_topk、mamba_track_interval;
  • 一批 MoE 模型文件(deepseek_v4、glm4_moe、qwen3_moe、sdar、bailing_moe 等,各 +2/-8):enable_dp_lm_head 从 get_server_args() 改为 get_parallel();
  • 模型加载(model_loader/loader.py):weight-info 字典的 moe_dense_tp_size、enable_dp_lm_head 改读 parallel 访问器。
    刻意保留 5 个 live-shadowed 拓扑尺寸(tp/pp/dcp/attn_cp/moe_dp_size)继续读 server_args:live @property 在访问器上优先,条件初始化 group 在无条件调用点会 fail-loud。

3. 弹性 EP 写路径重路由(model_runner.py)

_initialize_elastic_ep_joiner、_expand_eplb_metadata_for_scale、_finalize_scale_up 中的 4 处 server_args.override("elastic_ep.scale_join"/"elastic_ep.scale", ...) 改为 get_context().override(...),使扩容后的新值直接落进已发布 bag,与读取侧同源;配套把对应实例读取(ep_join_rank_offset、elastic_ep_initial_size、expert_location gpus-per-node 路径)一并切到 bag。ServerArgs.override 调用点 ratchet 从 38 降至 34。

4. 专家放置辅助函数去参数化 + 测试联动(python/sglang/srt/eplb/expert_location.py、test/registered/unit/eplb/test_compute_logical_to_rank_dispatch_physical_map.py)

compute_logical_to_rank_dispatch_physical_map / _compute_logical_to_all_physical_map / _prefer_same_node_experts 删除 server_args 参数,全部从 bag 读取;_init_raw 同步删除未用的 server_args 参数。单测从 types.SimpleNamespace stub 改为 get_context().override_server_args(...) 作用域发布(新增 _published 辅助函数),被测函数读到的就是测试发布的那份配置,避免 stub 与真实读取路径脱节。

5. 验证与配套

全量注册 CPU 套件在相同机器/环境双跑(本分支 vs main,16 并行分区)零不对称失败;GPU smoke:piecewise-prefill 编译(58 token tiers,无 recompile storm)与 tp2/dp2 DP-attention + enable_dp_lm_head 服务均通过。labels 含 amd 与 deepseek,牵动 AMD 与 DeepSeek 相关 CI 测试面。未包含多节点弹性 EP 的端到端测试。

文件 模块 状态 重要度
python/sglang/srt/runtime_context.py 运行时上下文 modified 6.72
python/sglang/srt/eplb/expert_location.py 专家路由 modified 7.28
python/sglang/srt/model_executor/model_runner.py 模型执行 modified 6.79
python/sglang/srt/utils/common.py 共享工具 modified 6.45
python/sglang/srt/managers/scheduler_pp_mixin.py PP 调度 modified 6.62
python/sglang/srt/disaggregation/common/conn.py PD 连接 modified 6.32
test/registered/unit/eplb/test_compute_logical_to_rank_dispatch_physical_map.py 单元测试 modified 6.58
python/sglang/srt/speculative/dspark_components/dspark_worker_v2.py 投机解码 modified 6.19
python/sglang/srt/model_loader/loader.py 模型加载 modified 6.15
python/sglang/srt/models/deepseek_v4.py 模型定义 modified 5.92

关键符号

ParallelContext.__getattr__ _prefer_same_node_experts _compute_logical_to_all_physical_map compute_logical_to_rank_dispatch_physical_map ExpertLocationMetadata._init_common ExpertLocationMetadata._init_raw require_mlp_tp_gather require_attn_tp_gather require_mlp_sync _initialize_elastic_ep_joiner _expand_eplb_metadata_for_scale _finalize_scale_up init_pp_loop_state register_to_bootstrap

关键源码片段

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

ParallelContext.__getattr__ 重构为本 PR 的基石:从 object.__getattribute__ 防御式取槽改为槽位直读 + _fields 字典判断,使配置叶子读取在 torch.compile 下保持可追踪,并打断 pickle/copy 协议探测槽位的递归。

class ParallelContext:
    """并行拓扑命名空间。    实时拓扑(size / rank / group)通过 ``@property`` 读透(规范 getter),而
    并行**配置**叶子(``nccl_port``、``pp_max_micro_batch_size``、
    ``enable_dp_attention`` ...)通过 ``__getattr__`` 从已发布的 parallel
    配置 bag 供给。当配置叶子与实时 property 重名(如 ``tp_size``)时,
    property(实时事实)优先;dist 就绪后两者保持同值。
    """
​
    __slots__ = ("_overrides", "_config")
​
    def __init__(self):
        self._overrides = {}
        self._config = None # parallel 配置 bag,publish 时接线
​
    def __getattr__(self, name):
        # 只有在既非实时 @property 也非 slot 时才会走到这里:从已发布的 bag
        # 提供 parallel 配置叶子。函数体必须保持 dynamo 可追踪 ——
        # get_parallel().moe_dense_tp_size 这类读取可能跑在编译后的模型
        # forward 里,而 object.__getattribute__ 会 graph-break。
        if name.startswith("_"):
            # 配置叶子没有下划线前缀;这里直接抛错还能打断 pickle/copy
            # 协议在 __init__ 之前探测槽位导致的递归。
            raise AttributeError(name)
        config = self._config
        # _fields 是 bag 上的普通 __dict__ 条目;对 dict 做 in 判断,
        # 避开 _ConfigBag.__contains__(不可追踪)。
        if config is not None and name in config._fields:
            return getattr(config, name)
        detail = (
            "not a published parallel config leaf"
            if config is not None
            else "config not published"
        )
        raise AttributeError(f"ParallelContext has no {name!r} ({detail})")
python/sglang/srt/eplb/expert_location.py core-logic

专家放置辅助函数族(_prefer_same_node_experts、_compute_logical_to_all_physical_map、compute_logical_to_rank_dispatch_physical_map、_init_common/_init_raw)彻底去除 server_args 参数,全部配置读改走 get_exec().moe / get_parallel(),是本 PR 中配置契约变化最深的模块。

def _prefer_same_node_experts() -> bool:
    """同节点专家偏好判定;原签名携带 server_args,现从已发布配置读取。"""
    from sglang.srt.elastic_ep.elastic_ep import elastic_expanded_world_enabled
    from sglang.srt.runtime_context import get_exec
​
    return (
        get_exec().moe.ep_join_mode != "scale" and not elastic_expanded_world_enabled()
    )
​
​
@staticmethod
def _init_common(server_args: ServerArgs, model_config: ModelConfig):
    # 局部导入避免模块级循环依赖;读取统一走 bag 访问器。
    from sglang.srt.runtime_context import get_exec, get_parallel
​
    model_config_for_expert_location = (
        ModelConfigForExpertLocation.from_model_config(model_config)
    )
    if model_config_for_expert_location is None:
        return None
​
    base_num_physical_experts = (
        model_config_for_expert_location.num_logical_experts
        + get_exec().moe.ep_num_redundant_experts
    )
    # 弹性 EP 扩容会通过 get_context().override 改写已发布 bag 里的 ep_size,
    # 因此这里必须读 bag(唯一已解析来源),而不是 pristine 的 server_args。
    ep_size = get_parallel().ep_size
    num_physical_experts = base_num_physical_experts
    initial_ep_size = get_parallel().elastic_ep_initial_size
    if initial_ep_size is not None:
        if get_exec().moe.ep_join_mode == "scale":
            # tp_size 是刻意保留的 live-shadowed 拓扑尺寸(活属性优先)
            ep_size = max(
                ep_size,
                get_parallel().ep_join_rank_offset + server_args.tp_size,
            )
        num_physical_experts, num_local_physical_experts = (
            _compute_elastic_expert_layout(
                base_num_physical_experts,
                initial_ep_size,
                ep_size,
            )
        )
    else:
        assert num_physical_experts % ep_size == 0
        num_local_physical_experts = num_physical_experts // ep_size
    # ... 后续沿用原布局计算逻辑构建 common 字典
python/sglang/srt/utils/common.py dependency-wiring

gather 模式判定三件套(require_mlp_tp_gather / require_attn_tp_gather / require_mlp_sync)改读 bag,消除弹性扩容后从陈旧 dp_size 推导 gather 模式的风险——这正是 review 中 base_runner 评论暴露的问题。

def require_mlp_tp_gather(server_args: ServerArgs):
    """MLP 输入是否经 all-gather 而非 all-reduce 获得。    仅当每个 MLP TP 组内含多个 attention DP 组时成立。elastic-EP 扩容会把
    dp_size 改写进已发布 bag,因此所有 DP 相关叶子必须读 bag,避免按陈旧
    实例值错误推导 gather 模式。
    """
    from sglang.srt.layers.moe.utils import get_moe_a2a_backend
    from sglang.srt.runtime_context import get_exec, get_parallel
​
    if get_parallel().enable_dp_attention:
        assert get_parallel().dp_size > 1, "dp_size must be greater than 1"
        if get_exec().moe.elastic_ep_backend is not None:
            from sglang.srt.elastic_ep.elastic_ep import (
                elastic_expanded_world_enabled,
            )
​
            if elastic_expanded_world_enabled():
                return True
        if get_parallel().moe_dense_tp_size is None:
            # TODO(ch-wan): 部分 MoE 模型没有 dense 层
            return True
        elif not get_parallel().enable_dp_lm_head:
            return True
        elif get_moe_a2a_backend().is_none():
            return True
        elif get_moe_a2a_backend().is_flashinfer():
            # FlashInfer MoE A2A 需要 rank 无关、DP 同步的每 rank token 数:
            # decode cuda-graph bucket 必须在各 EP rank 完全一致,否则重放
            # 不同尺寸的图会造成非法内存访问(issue #30242)。
            return True
        else:
            return (
                get_parallel().moe_dense_tp_size
                > server_args.tp_size // get_parallel().dp_size
            )
    else:
        return False

评论区精华

base_runner 的 gather 模式 helper 仍读 pristine server_args,弹性扩容后可能从陈旧 dp_size 重推导 gather mode 正确性

ch-wan 指出在同文件已 co-flip dp_size 的情况下,require_mlp_tp_gather(mr.server_args) 等 helper 仍基于实例的 dp_size / enable_dp_attention / moe_dense_tp_size 分支,bag-only scale 后重进入 _dummy_run / buffer alloc 会用陈旧值(如 scale-to-full-DP 后 require_attn_tp_gather 的 dp_size < tp_size 判断);elastic 短路只掩盖 EP scale 场景。

结论:三个 helper 全部改读 bag 叶子(enable_dp_attention、dp_size、moe_dense_tp_size、enable_dp_lm_head、disable_attn_tp_gather;elastic_ep_backend 走 get_exec().moe),tp_size 按 live-shadowed 规则保留参数。 · 已解决

expert_location 半迁移表面:ep_size 已切 bag 但 sibling 叶子仍在 server_args 设计

评论指出 _init_common 中 elastic_ep_initial_size、ep_join_rank_offset、nnodes 仍来自 pristine server_args,"the function is half-migrated and will miss any future bag-only override of the siblings";跟进评论又指出 ep_num_redundant_experts 与 ep_dispatch_algorithm 仍是 exec.moe 叶子的参数读取。

结论:全部改读 get_parallel() / get_exec().moe;_init_raw 删除未用 server_args 参数;server_args.tp_size 保留为刻意 live-shadowed 读取。 · 已解决

conn.py bootstrap 注册保留防御性 getattr,与迁移方向不一致 style

load_balance_method 已切 get_parallel(),但下一行 enable_dsa_cache_layer_split 仍是 getattr(self.server_args, ...),与项目 no-defensive-getattr 规范及 mooncake 侧 bag 读取不一致。

结论:改为 get_parallel().enable_dsa_cache_layer_split,去除防御性 getattr。 · 已解决

r2 disposition 四项 open items(PD 路由、system_dp_size、dsa_cache_layer_split、expert_location 残留) 正确性

ch-wan 在 issue 评论列出四项仍 open 的问题:conn.py prefill DP 路由仍读 kv_mgr.server_args.dp_size(bootstrap_room % dp_size 可与 bag 不一致)、CommonKVManager.__init__ 的 system_dp_size 快照来自实例、同文件 enable_dsa_cache_layer_split 残留读取、expert_location 的 ep_num_redundant_experts / ep_dispatch_algorithm。

结论:四项全部在 head b38d5a7f98a4 修复;r3 复核 clean,无遗留 finding。 · 已解决

ServerArgs.override ratchet 数字与 PR 描述 off-by-one documentation

评论指出实际 baseline 为 38 → 34(叠加 #33168 的 39 → 38),PR 描述写成 39 → 35,属于 provenance 噪音。

结论:PR 描述已修正为 38 → 34。 · 已解决

风险与影响

  1. 大面机械翻转(81 文件):任何漏网读取会在运行时 fail-loud(AttributeError),这比静默读错值安全,但若类似 #33168 的写路径残留,会再次出现"写入无人读取的实例"类 bug——本 PR 正是为此专门重路由了 4 处弹性 EP 写入。
  2. dynamo 图编译约束:getattr 必须保持可追踪,后续任何引入 object.getattribute 或 _ConfigBag.contains 的改动都会 graph-break,唯一的护栏是 test_parallel_config_leaves_trace_under_torch_compile fullgraph 测试。
  3. 弹性 EP 场景验证深度有限:review 发现并修复的 base_runner 重进入路径(_dummy_run / buffer alloc)仍依赖 elastic_expanded_world_enabled 的 short-circuit 掩盖;本 PR 未做多节点弹性扩容端到端测试。
  4. 行为语义变化:get_parallel() 对未发布字段抛 AttributeError 而非返回旧值,属于有意的 fail-fast,但对外部扩展代码是破坏性变化。
  5. 运行期开销:event_loop_pp 等热循环中 pp_async_batch_depth 由实例属性访问变为 getattr + _fields 检查 + getattr,单次开销极小但处于每 micro-batch 每 step 的路径上,需留意后续性能回归。

对用户:无任何 CLI/API 变化;行为修复体现在弹性 EP 扩容后配置一致性与 chunked-prefix 类 gate 的读写同源。对系统:完成 parallel 命名空间的访问器迁移,消除 106 处双源读取;override 调用点收敛至 34 处,配置只有 bag 一个已解析来源。对团队:确立了全仓配置读写规范——新代码必须通过 get_parallel()/get_exec()/get_spec() 等访问器读取配置,不得直读 server_args(除了刻意保留的 live-shadowed 拓扑尺寸);同时 ratchet 机制让后续迁移可以机械验证、增量推进。

跨 81 文件大范围读取路径变更 dynamo 图编译约束(__getattr__ 需保持可追踪) 弹性 EP 扩容缺少多节点端到端测试 热循环新增 __getattr__ 间接层 fail-loud 行为变更(未发布字段抛 AttributeError)

关联 Issue

#33168 Fix the chunked-prefix-cache gate writing config the backends never read

完整报告

参与讨论