Prhub

#34081 config: business code no longer reads the published ServerArgs

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

执行摘要

配置读取全迁 runtime_context,ratchet 基线归零

PR body 明确目标:『The last field reads outside the resolution pipeline are gone』,即业务代码不再读取已发布的 ServerArgs。此前残留的两类读取(派生成员、被 live 拓扑遮蔽的配置尺寸)都需要『命名归宿』,而 runtime_context 是 slot 的持有者,因此成为这些访问器的唯一家园。ratchet 的豁免从名字列表改为模块所有者,防止新读取隐藏在熟悉的成员名后面。

值得精读。重点学习:runtime_context 访问器层的设计(bag 派生与 slot 透传的取舍)、ratchet 的 AST 扫描与词法作用域处理(模块别名、嵌套函数、动态 getattr)、遮蔽守卫对全部绑定形式的枚举,以及缓存生命周期在 publish/reset 中的管理。这些模式对大型 Python 代码库的配置演进有直接借鉴价值。

讨论亮点

Codex 提出两处 P1 并获修复:cutedsl_moe_max_num_tokens 访问器返回 bound method 会引发 TypeError(作者确认后新增 TestNamedAccessorsCallWhatTheyWrap 静态检查推广到所有访问器);自适应 draft-token bound 在 decode 热路径反复读取 JSON(作者改为 lru_cache 按 cfg_path 键控,2000 次调用 0.7 ms)。多轮 P2 聚焦两个 AST 守卫的作用域语义:ratchet 的模块别名从文件级 union 改为词法作用域判定,遮蔽守卫补全 for/with/walrus/except/解构等所有绑定形式,且嵌套 def 名称计入外层绑定。遗留未解决疑虑:sarvam_moe 改为进程级全局访问器后多 Engine 场景可能读到其他 Engine 的配置;preserve_config/override restore 路径未恢复 adaptive bound 缓存。

实现拆解

  1. 新增命名访问器层python/sglang/srt/runtime_context.py 增加 6 个派生成员访问器与 4 个 configured_*_size() 访问器。凡输入全部是已发布 bag 叶子的(spec、schedule、exec.kernel)直接从 bag 派生,跟随 post-publish override;真正依赖 ServerArgs 计算或进程 model config 的(mamba_cache_chunk_size、uses_mla_backend、process_model_config)保持只读透传,由 runtime_context 独占读取 slot。configured_*_size 通过 _configured_parallel 读取 parallel bag,绕过 ParallelContext 的 live 属性遮蔽。
  2. 迁移业务调用点:18 处调用点改为命名访问器。典型案例:sarvam_moe.py_set_current_attention_backend 改用 attention_backends() 且 radix cache 判断改读 get_memory()schedule_batch.pyhybrid_linear_attn_backend.py 的 mamba 状态缓存点改 mamba_cache_chunk_size()kimi_k3.py/kimi_k25.py 的 IPC refcount 改 configured_tp_size()(与 tokenizer 进程的回收器对齐);flashinfer_cutedsl.pycutedsl_moe_max_num_tokens()cp/base.py 的 DSA 谓词改 get_parallel().enable_prefill_cp
  3. 收紧配置读取守卫test_global_config_read_ratchet.py 将豁免改为模块所有者集合 _SLOT_OWNERS(runtime_context、server_args、arg_groups),direct/alias 基线设为 0;扫描扩展识别 getattr 动态读取、模块级别名(含实例属性形式)、别名拷贝到 fixpoint,并修正词法作用域遮蔽——每个读按 enclosing function chain 判定,嵌套函数绑定不再污染外层。configured_*_size 的 7 个调用点与原因登记进 _CONFIGURED_SIZE_CALL_SITES,被测试断言精确匹配。
  4. 新增遮蔽守卫test_context_accessor_shadowing.py 用 AST 扫描整个包,检查函数内局部绑定是否遮蔽已导入的访问器(会触发 UnboundLocalError);覆盖全部绑定形式:赋值、for、with、walrus、except、解构、嵌套 def/class 名,并按词法作用域跟踪函数级 import 的可见性。
  5. 配套测试与清理test_runtime_context.py 新增跨层一致性测试(bag 访问器与 ServerArgs 成员在所有输入组合上相等)、自适应 bound 生命周期测试(republish/reset 后缓存失效重算)、访问器包装语义静态测试(方法必须调用、属性不能调用)。同时删除 9 处死绑定与失效测试 mock,server_args.pyget_attention_backendsenable_mamba_extra_buffer 等委托 arg_groups.overrides*_of helper,消除重复实现。
文件 模块 状态 重要度
python/sglang/srt/runtime_context.py 上下文 modified 8.66
test/registered/unit/test_global_config_read_ratchet.py 配置守卫 modified 7.81
test/registered/unit/test_context_accessor_shadowing.py 遮蔽守卫 added 7.96
test/registered/unit/test_runtime_context.py 上下文测试 modified 7.43
python/sglang/srt/server_args.py 启动参数 modified 6.43
python/sglang/srt/models/sarvam_moe.py 模型层 modified 6.38
python/sglang/srt/managers/schedule_batch.py 调度器 modified 6.22

关键符号

mamba_cache_chunk_size max_speculative_num_draft_tokens _adaptive_draft_token_bound uses_mla_backend attention_backends process_model_config cutedsl_moe_max_num_tokens _configured_parallel configured_tp_size configured_pp_size configured_moe_dp_size configured_attn_cp_size RuntimeContext.set_server_args RuntimeContext.reset_context _shadowing_assignments _collect TestAdaptiveDraftBoundLifecycle.test_republishing_recomputes_the_bound SarvamMoE._set_current_attention_backend ScheduleBatch._mamba_radix_cache_v2_req_prepare_for_extend

关键源码片段

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

核心变更文件:新增派生成员访问器与 configured_*_size 访问器,并在 set_server_args/reset_context 中管理自适应 draft bound 缓存生命周期,是唯一允许读取 ServerArgs slot 的模块。

# --- Derived config accessors ------------------------------------------------
# 以下值由多个配置字段加 HF config 推导,无法成为 namespace 叶子;
# runtime_context 是 slot 的持有者,因此这里是它们唯一的『命名归宿』。
# 每个访问器保持 ServerArgs 成员的精确语义(包括总是基于进程即目标
# 模型的 model config 推导)。def mamba_cache_chunk_size() -> int:
    """mamba 状态缓存点粒度:max(模型 mamba chunk size, page_size)。"""
    # 该成员是属性且只在发布后读取,直接透传 slot。
    return get_server_args().mamba_cache_chunk_size
​
​
def max_speculative_num_draft_tokens() -> int | None:
    """投机解码可用的最大 draft-token 数。    三个输入都是 spec bag 的叶子,所以从袋读取并跟随 post-publish
    override;ServerArgs 上的同名成员是解析管线用的发布前等价物。
    """
    spec = get_spec()
    if spec.speculative_num_draft_tokens is None:
        return None
    if not spec.speculative_adaptive:
        return spec.speculative_num_draft_tokens
    # 自适应分支要解析 JSON 配置,且此函数按 decode batch 调用
    # (spec_prepare_for_decode),所以按输入(cfg_path)做 lru_cache,
    # 而不是缓存一次,这样 post-publish override 仍会重算。
    return _adaptive_draft_token_bound(spec.speculative_adaptive_config)
​
​
@functools.lru_cache(maxsize=8)
def _adaptive_draft_token_bound(cfg_path: str | None) -> int:
    # 自适应 spec 目前要求 topk=1,所以每个运行时状态需要 steps + 1
    # 个 draft-token 槽位,与 ServerArgs 成员镜像一致。
    from sglang.srt.speculative.adaptive_spec_params import (
        resolve_candidate_steps_from_config,
    )
    candidate_steps = resolve_candidate_steps_from_config(cfg_path=cfg_path)
    return max(candidate_steps) + 1
​
​
def uses_mla_backend() -> bool:
    """本进程模型是否走 MLA 注意力路径。"""
    return get_server_args().use_mla_backend()
​
​
def attention_backends() -> tuple:
    """配置的 (prefill, decode) 后端对,split 字段回退到 attention_backend。    三个输入都是 exec.kernel 叶子,所以从袋读取并跟随 post-publish
    override;ServerArgs.get_attention_backends 是解析管线的发布前等价物。
    """
    from sglang.srt.arg_groups.overrides import attention_backends_of
    # 三个叶子在同一 bag 中,直接复用解析管线的 helper —— 回退规则只有一份。
    return attention_backends_of(get_exec().kernel)
​
​
def _configured_parallel(name: str):
    """读 parallel bag 本身而非 ParallelContext —— live property 会遮蔽
    tp/pp/moe_dp/attn_cp 四个名字。parallel 不在 per-role 命名空间表内,
    所以不能走 config_bag() 的角色检查。
    """
    config = _CONTEXT.parallel._config
    if config is None:
        raise ValueError('config namespace parallel not published')
    return getattr(config, name)
​
​
def configured_tp_size() -> int:
    return _configured_parallel('tp_size')
test/registered/unit/test_context_accessor_shadowing.py test-coverage

新增 AST 守卫,防止机械迁移产生的函数级局部变量遮蔽访问器导致 UnboundLocalError 回潮,是本次重构的关键防回归设施。

def _bound_names(target):
    """一个绑定目标引入的所有名字,解构展开。    a, (b, c) = ... 与 for x, y in ... 会穿过 Tuple/List/Starred 节点,
    所以只接受裸 ast.Name 的检查会漏掉它们。
    """
    if isinstance(target, ast.Name):
        yield target.id
    elif isinstance(target, ast.Starred):
        yield from _bound_names(target.value)
    elif isinstance(target, (ast.Tuple, ast.List)):
        for element in target.elts:
            yield from _bound_names(element)
​
​
def _own_scope_statements(node) -> tuple:
    """该函数自身作用域的语句,以及每个嵌套 def/class 的 (name, lineno)。    嵌套定义的 *名字* 是当前作用域的绑定(先调用后定义会抛
    UnboundLocalError),但其 *函数体* 属于嵌套作用域,下降访问会错误归属。
    """
    own_scope = []
    nested_def_bindings = []
    pending = list(node.body)
    while pending:
        stmt = pending.pop()
        if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            nested_def_bindings.append((stmt.name, stmt.lineno))
            continue
        if isinstance(stmt, ast.Lambda):
            continue
        own_scope.append(stmt)
        pending.extend(ast.iter_child_nodes(stmt))
    return own_scope, nested_def_bindings
​
​
def _shadowing_assignments(tree: ast.AST, module_accessors: set[str]):
    """报告『函数内局部绑定遮蔽了该作用域可见的访问器』。    Python 依据函数内 *任意* 绑定判定名字为局部,因此 for 变量、
    with ... as、walrus、推导式目标、except ... as 都与赋值一样
    会遮蔽访问器。可见性遵循词法作用域:模块级 import 可达所有函数,
    函数内 import 只达自身与嵌套函数,不会误报无关兄弟函数。
    """
    stack = [(fn, module_accessors) for fn in _child_functions(tree.body)]
    while stack:
        node, inherited = stack.pop()
        own_scope, nested_def_bindings = _own_scope_statements(node)
        local_imports = {
            alias.asname or alias.name
            for stmt in own_scope
            if isinstance(stmt, ast.ImportFrom) and stmt.module == _CONTEXT_MODULE
            for alias in stmt.names
        }
        visible = inherited | local_imports
        # 内嵌 def/class 的名字绑定在本作用域,等同赋值。
        for name, lineno in nested_def_bindings:
            if name in visible:
                yield node.name, name, lineno
        for inner in own_scope:
            targets = []
            # 收集所有会绑定局部的语句形式。
            if isinstance(inner, ast.Assign):
                targets = inner.targets
            elif isinstance(inner, (ast.AnnAssign, ast.AugAssign)):
                targets = [inner.target]
            elif isinstance(inner, (ast.For, ast.AsyncFor, ast.comprehension)):
                targets = [inner.target]
            elif isinstance(inner, ast.NamedExpr):
                targets = [inner.target]
            elif isinstance(inner, (ast.With, ast.AsyncWith)):
                targets = [i.optional_vars for i in inner.items if i.optional_vars]
            elif isinstance(inner, ast.ExceptHandler) and inner.name:
                targets = [ast.Name(id=inner.name, ctx=ast.Store())]
            for target in targets:
                # 解构绑定需要递归展开 Tuple/List/Starred。
                for name in _bound_names(target):
                    if name in visible:
                        yield node.name, name, getattr(inner, 'lineno', node.lineno)
        # 把『本作用域可见集合』传给子函数,继续深度优先扫描。
        for nested in _child_functions(node.body):
            stack.append((nested, visible))

评论区精华

cutedsl_moe_max_num_tokens 访问器返回 bound method 导致 TypeError 正确性

Codex P1:`cutedsl_moe_max_num_tokens` 在 server_args.py 中以 `def` 定义,访问器直接 `return get_server_args().cutedsl_moe_max_num_tokens` 会返回 bound method,`get_parallel().dp_size * cutedsl_moe_max_num_tokens()` 在 wrapper 初始化时抛 TypeError。

结论:作者确认并让访问器调用成员;同时新增 `TestNamedAccessorsCallWhatTheyWrap` 静态检查所有 `return get_server_args().X` 形态,断言方法必须调用、属性 / cached_property 不能调用。 · 已解决

自适应 draft-token bound 在 decode 热路径反复读 JSON 性能

Codex P1:adaptive spec + lazy mamba extra buffer 时每次 decode 准备都会调用 `resolve_candidate_steps_from_config()` 同步打开解析 JSON;原 `ServerArgs.max_speculative_num_draft_tokens` 是 `cached_property`,这次改动引入了热路径文件 I/O。

结论:作者用 `functools.lru_cache(maxsize=8)` 按 `cfg_path` 键控 memoize;测量 2000 次调用 0.7 ms。 · 已解决

自适应 bound 缓存跨 publish/reset 生命周期残留 正确性

Codex P2:进程级 `lru_cache` 在 `reset_context()` 或重新 publish 且复用同一 adaptive-config 路径时返回旧 bound,文件内容已变则低估 draft-token 缓冲。

结论:在 `RuntimeContext.set_server_args` 与 `reset_context` 中 `_adaptive_draft_token_bound.cache_clear()`;由 `TestAdaptiveDraftBoundLifecycle` 覆盖 republish 与 reset 两个场景。 · 已解决

ratchet 模块级别名按文件级 union 会漏检全局读取 正确性

Codex P2:模块绑定 `sa = get_server_args()` 后,任一函数有同名局部 `sa`,文件级 union 会把该名字加入 shadowed 集合,吞掉全模块所有 `sa.field` 读取,比不检查更糟。

结论:改为按词法作用域判定:parent map 给每个读定位 enclosing function chain,只有作用域链上的绑定才算遮蔽;反向验证模块级读、全局读、局部遮蔽三种场景。 · 已解决

ratchet 嵌套函数绑定被 ast.walk 错误归属到外层 正确性

Codex P2:外层函数读模块别名 `sa.field`,内嵌函数有自己局部 `sa` 时,`ast.walk` 下降收集导致外层读被错误抑制。

结论:收集绑定时不下降嵌套 FunctionDef/AsyncFunctionDef/Lambda/ClassDef 体;每个作用域只归属自己的 store。 · 已解决

accessor shadowing 守卫漏掉 for/with/walrus/except/ 解构等绑定形式 正确性

Codex P2:原守卫只检查 Assign/AnnAssign/AugAssign 且只接受裸 ast.Name;但 for 变量、with ... as、walrus、推导式目标、except ... as 都会在整函数内把名字判定为局部,同样触发 UnboundLocalError。

结论:扩展收集全部绑定语句形式,并用递归 target 展开处理 Tuple/List/Starred 解构;函数内 re-import 同一 callable 显式豁免并写进 docstring。 · 已解决

嵌套函数名也算外层绑定(def get_exec 遮蔽) 正确性

Codex P2:函数内 `def get_exec(): ...` 会作为外层作用域绑定,先调用后定义抛 UnboundLocalError;守卫此前丢弃 FunctionDef 节点未记名。

结论:`_own_scope_statements` 把嵌套 def/class 名字计入外层绑定,同时不下降其函数体。 · 已解决

sarvam_moe 多 Engine 场景配置隔离疑虑 设计

Codex P1:`sarvam_moe` 改为读进程级全局 `attention_backends()` 与 `get_memory()` 后,多 Engine 进程内其他 Engine 发布配置会改变该实例的选择,与 `get_attn_forward_method(self._server_args, ...)` 使用的实例配置可能不一致。

结论:无后续回复,属未解决疑虑;建议按 Engine 归属实例配置或由 runner 传递已解析后端。 · 待处理

preserve_config/override restore 生命周期未恢复 adaptive bound 缓存 正确性

Codex P2:`preserve_config()` 发布临时上下文会清空进程级缓存,但 restore 路径直接恢复 bags 而不重建缓存;原配置文件被改写或删除时恢复后的上下文会重新打开它或使用变动后的 bound。

结论:无作者回复,未解决;建议 restore 时也恢复 memo 或按 publication 记录缓存。 · 待处理

kimi IPC refcount 与 recycler 的 configured_tp_size 不一致 正确性

审查指出 kimi_k25 的 IPC consumer count 注释声称匹配 `try_to_recycle`,但代码用 live `parallel.tp_size`,而回收器在 tokenizer 进程读 `configured_tp_size()`;live==configured 仅在普通 serving 成立,单向 override 会 desync refcount。

结论:kimi_k25 与 kimi_k3 均改为 `configured_tp_size()`,两个调用点登记进 `_CONFIGURED_SIZE_CALL_SITES` 并注明原因;测试改为 publish 配置而非 fake getter。 · 已解决

风险与影响

多 Engine 配置隔离风险:sarvam_moe.py 的注意力后端与 radix cache 判断改读进程级最新发布配置,若多个 in-process Engine 使用不同 attention backend,可能出现后端选择与 get_attn_forward_method 不一致的执行路径(Codex P1,未关闭)。缓存生命周期风险:_adaptive_draft_token_bound 是进程级 lru_cachepreserve_config()_ServerArgsOverride.restore() 绕过 set_server_args 直接恢复 bags 时缓存不会重建,文件被改写或删除时可能失败或用旧值(无回复)。AST 扫描局限:ratchet 看不到运行时名字 getattr(sa, name)、容器/跨对象间接引用、跨作用域拷贝;遮蔽守卫对函数内重新 import 同一 callable 显式豁免,若未来语义分化可能误报。configured vs live 语义:configured_*_size 读 bag 而非 record,bag 未发布时抛 ValueError,调用时机(Indexer 构造、tokenizer 进程)必须保持正确顺序。回归面:35 文件、18 调用点迁移触及 mamba radix cache、speculative decoding、MLA backend、Kimi 多模态 IPC、CuteDSL MoE A2A 等核心路径,单测共 583 项通过,但部分路径依赖 e2e 或动态触发。

对用户与运维:ServerArgs 解析后成为只读记录,运行时改配置必须走 override 与配置袋,行为契约变化。对开发者:新增配置读取必须通过 runtime_context 访问器或 bags,ratchet 强制执行;configured_*_size 语义需要登记原因。对系统:消除业务代码对发布记录的字段读取,配置读取统一到单一真源,为多实例隔离与可复现解析奠基。对团队:需要理解模块所有者豁免和词法作用域扫描规则,相关测试成为 CI 守门员。

核心配置路径重构 跨模块 35 文件 AST 静态扫描防回归 缓存生命周期复杂 多引擎配置隔离疑虑

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论