Prhub

#36725 config: every handler declares its cuda-graph decisions

原始 PR 作者 ch-wan 合并时间 2026-08-28 03:55 文件变更 5 提交数 1 评论 0 代码增减 +402 / -58

执行摘要

cuda-graph 决策改由各 handler 显式声明

PR body 明确指出问题所在:_parse_cuda_graph_config 声明了一个 CudaGraphConfig,但 19 个后续 handler 通过 resolving_view 拿到该对象并原地编辑(58 处赋值)。“错误在于 provenance:overrides log 把决策记在 _parse_cuda_graph_config 头上,而 validate_declarations 永远看不到后来的修改。” 换言之,值是对的,但归属错了,导致配置来源不可追踪、声明校验失效。这属于配置解析基础设施的健壮性修正。

值得精读,尤其是 with_phase 的设计(不可变更新原语)和 TestDeclaredValuesAreNotEditedLater 的 guard 测试写法(包装全部 stash 写入通道 + 多形态矩阵)。由于该 PR 无独立 review 讨论,建议与系列后续 PR #36620-#36622 一起 review,以理解声明模型的完整演进。阅读时重点关注 _handle_gpu_memory_settings 的 deepcopy 例外和 with_phase 的共享引用警告。

讨论亮点

PR 无外部 review 评论,以下提炼自作者 PR body 中的设计说明:

  • 作者指出 provenance 问题是核心:“什么错的是 provenance:overrides log 把决策记在 _parse_cuda_graph_config 头上,而 validate_declarations 永远看不到后来的修改。”
  • 初始计数严重低估:“那是个窄 grep,只匹配到局部别名拼写,漏掉了直接拼写……它还匹配 == 比较,直到我加了负 lookahead——把其中一个转成声明会是一个真正的 bug。”
  • Guard 矩阵扩展的价值:“disaggregation_mode="prefill"enable_deterministic_inference=True 各到达一个前三种 shape 从未跑过的 handler,且都还在漂移。DP-attention shape 又抓到两个——通过局部别名访问 config 的 handler,第一遍转换漏掉了。”
  • torch.compile 的处理:“object.__getattribute__ 会 graph-break。这是被测量而不是假设——新读取能追踪到 torch.compile(fullgraph=True),并被回归测试固定。”

实现拆解

  1. 引入不可变更新原语 with_phase:在 python/sglang/srt/model_executor/cuda_graph_config.py 新增 with_phase(config, phase, **changes),用 dataclasses.replace 重建两个 PhaseConfig 并构造新的 CudaGraphConfig,确保结果与输入不共享任何 PhaseConfig。这是所有后续声明改写的基础,避免一个 handler 的修改污染另一个 handler 的声明。
  2. 将 18 个 handler 的 36 处原地赋值改为声明:在 python/sglang/srt/server_args.py_handle_* 方法中,把 cfg.cuda_graph_config.decode.backend = ... 这类写入改写为 self._declare("_handler名", cuda_graph_config=with_phase(...))。典型如 _handle_model_capability_adjustments 中 HRM-Text、EmbeddingGemma 和 prefill sizing 分支,每个决策以 handler 自身名义进入 stash,修复来源归属。
  3. _handle_gpu_memory_settings 特殊处理:该 handler 承担 58 个赋值中的 22 个,且彼此依赖并读取自身早期赋值,因此保留对 cfg.cuda_graph_configdeepcopy,在全部计算结束后统一声明一次。
  4. 加固 guard 测试:在 test/registered/unit/server_args/test_resolution_declarations.py 新增 TestDeclaredValuesAreNotEditedLater,包装 declare_resolutiondeclare_late_resolutiondeclare_direct_writesrun_post_process_pass 四个 stash 写入通道,在每条记录落地时深拷贝,结束时对比;覆盖 8 种启动形态(plain、cuda-graph 旋钮、chunked prefill、显式 JSON、disaggregation、deterministic、EAGLE、DP attention)。
  5. 更新既有测试与计数核查:test/registered/unit/server_args/test_server_args.pytest_decode_graph_stays_enabled_in_both_comm_modes 改用 resolution_result 读取声明后的配置;用 AST pass 重新统计赋值面(19 方法 58 赋值),修正窄 grep 漏项。
文件 模块 状态 重要度
python/sglang/srt/server_args.py 参数解析 modified 7.48
test/registered/unit/server_args/test_resolution_declarations.py 声明守卫 modified 7.36
python/sglang/srt/model_executor/cuda_graph_config.py 图配置 modified 7.12
python/sglang/srt/hardware_backend/npu/utils.py NPU 后端 modified 6.86
test/registered/unit/server_args/test_server_args.py 参数测试 modified 4.1

关键符号

with_phase _handle_model_capability_adjustments _handle_gpu_memory_settings set_default_server_args _resolve_recording_each_entry test_no_entry_changes_after_it_is_recorded _parse_cuda_graph_config

关键源码片段

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

主变更文件,19 个 handler 中大部分 cuda-graph 原地赋值改为 `self._declare` + `with_phase` 声明,修复 provenance 归属并影响所有启动流程。

# 在 _handle_model_capability_adjustments 内,HRM-Text (prefix_lm) 检测分支。
# 变更前直接改 cfg.cuda_graph_config.decode.backend / prefill.backend,
# 变更后改为以本 handler 名义声明新的配置,确保 provenance 归属正确。
if is_hrm_text and getattr(hf_config, "prefix_lm", True):
    run_post_process_pass(self, _hrm_text_attention_force)
    self._declare(
        "_handle_model_capability_adjustments",
        chunked_prefill_size=-1,
    )
    self._declare(
        "_handle_model_capability_adjustments",
        disable_radix_cache=True,
    )
    self._declare(
        "_handle_model_capability_adjustments",
        disable_cuda_graph=True,
    )
    # cuda_graph_config 已从 legacy boolean 解析,仅翻转 boolean 不会停止
    # graph capture,因此必须直接声明 backend 为 DISABLED。
    self._declare(
        "_handle_model_capability_adjustments",
        cuda_graph_config=with_phase(
            cfg.cuda_graph_config, Phase.DECODE, backend=Backend.DISABLED
        ),
    )
    self._declare(
        "_handle_model_capability_adjustments",
        cuda_graph_config=with_phase(
            cfg.cuda_graph_config, Phase.PREFILL, backend=Backend.DISABLED
        ),
    )
test/registered/unit/server_args/test_resolution_declarations.py test-coverage

新增 guard 测试 `TestDeclaredValuesAreNotEditedLater`,包装四个 stash 写入通道并在每条记录落地时深拷贝,捕获“声明后被修改”的漂移;覆盖 8 种启动形态,是验证本次重构正确性的核心测试。

class TestDeclaredValuesAreNotEditedLater(CustomTestCase):
    """声明记录的是值,不是句柄。    stash 保留 handler 传入的对象;如果 handler 声明一个可变对象后又原地
    修改它,就会重写已经进入日志的条目。projection 仍然回答最终状态,
    所以其他机制不会察觉——丢失的是“哪个 handler 决定了什么”,
    validate_declarations 也看不到后续修改。
    """
​
    def _resolve_recording_each_entry(self, **supplied):
        """解析,并在每条 stash 条目落地的那一刻深拷贝一份。"""
        from sglang.srt.arg_groups import overrides
​
        recorded = []
​
        def watch(name):
            original = getattr(overrides, name)
​
            def wrapper(server_args, *args, **kwargs):
                result = original(server_args, *args, **kwargs)
                stash = getattr(server_args, "_resolved_overrides", None) or []
                while len(recorded) < len(stash):
                    index = len(recorded)
                    recorded.append((index, copy.deepcopy(stash[index])))
                return result
​
            return original, wrapper
​
        # 所有会追加到 stash 的通道。
        patched = {}
        for name in (
            "declare_resolution",
            "declare_late_resolution",
            "declare_direct_writes",
            "run_post_process_pass",
        ):
            original, wrapper = watch(name)
            patched[name] = original
            setattr(overrides, name, wrapper)
        try:
            path = tempfile.mkdtemp(prefix="declared_values_")
            self.addCleanup(shutil.rmtree, path, ignore_errors=True)
            with open(os.path.join(path, "config.json"), "w") as handle:
                json.dump(_MINI_CONFIG, handle)
            server_args = ServerArgs(
                model_path=path, device="cuda", random_seed=42, **supplied
            )
            server_args.resolve_once()
        finally:
            for name, original in patched.items():
                setattr(overrides, name, original)
        return server_args, recorded
​
    def test_no_entry_changes_after_it_is_recorded(self):
        # 每个 handler family 取一种启动形态。
        for label, supplied in (
            ("plain", {}),
            ("cuda_graph_knobs", {"cuda_graph_max_bs_decode": 16}),
            ("chunked_prefill", {"chunked_prefill_size": 1024}),
            ("explicit_json", {"cuda_graph_config": {"decode": {"max_bs": 12}}}),
            ("disaggregation", {"disaggregation_mode": "prefill"}),
            ("deterministic", {"enable_deterministic_inference": True}),
            ("speculative", {"speculative_algorithm": "EAGLE"}),
            ("dp_attention", {"tp_size": 2, "dp_size": 2, "enable_dp_attention": True}),
        ):
            with self.subTest(shape=label):
                server_args, recorded = self._resolve_recording_each_entry(**supplied)
                stash = server_args._resolved_overrides
                self.assertGreater(len(recorded), 0, "没有记录,说明没在监视")
                drifted = [...] # 对比 recorded 与最终 stash
                self.assertEqual(drifted, [], "声明被后续修改")
python/sglang/srt/model_executor/cuda_graph_config.py data-contract

新增 `with_phase` 不可变更新函数,是本次重构的核心原语,决定了所有 handler 如何声明新的 cuda_graph_config。

def with_phase(config: "CudaGraphConfig", phase: str, **changes) -> "CudaGraphConfig":
    """返回只修改一个 phase 的 config 副本。    Resolution 语义是“声明值”,而不是“编辑句柄”:handler 决定某个图配置时,
    交给 stash 的必须是一个新对象,而不是修改先前 handler 已声明的对象。
    因此这里重建两个 phase,保证返回的 config 与输入不共享任何 PhaseConfig。
    """
    if phase not in Phase.ALL:
        raise KeyError(phase)
    # 不是深拷贝:dataclasses.replace 复制字段引用,所以 list 型字段 bs
    # 仍然共享。调用方必须 rebind bs,绝不能原地 mutate 它。
    return CudaGraphConfig(
        **{
            # 只有目标 phase 应用 changes,另一个 phase 原样 replace。
            name: replace(getattr(config, name), **(changes if name == phase else {}))
            for name in Phase.ALL
        }
    )

评论区精华

声明来源归属与 validate_declarations 盲区 设计

作者指出 `_parse_cuda_graph_config` 声明的对象被 19 个 handler 原地编辑 58 次,导致 overrides log 将决策错误记在该 handler 名下,且 `validate_declarations` 完全看不到后续修改。

结论:引入 `with_phase`,让每个 handler 以自己名义声明新的 cuda_graph_config,修复来源归属。 · 已解决

初始计数低估与窄 grep 风险 正确性

初始 census 说 20 sites in one method,但窄 grep 只匹配局部别名拼写,漏掉直接拼写;grep 还会误匹配 `==` 比较,转化会引入真实 bug。

结论:改用 AST pass 统计,得到 19 方法 58 赋值,并加负 lookahead 排除比较。 · 已解决

guard 测试矩阵扩展暴露漂移 handler 测试

前三种启动形态下 guard 通过,但 disaggregation 和 deterministic 各暴露一个漂移 handler;DP-attention 又抓到两个通过局部别名访问 config 的 handler。

结论:扩展到 8 种启动形态,覆盖所有 handler 家族。 · 已解决

torch.compile graph-break 风险 性能

gate helpers 在 compiled forwards 内读取 parallel leaves,`object.__getattribute__` 会 graph-break;作者实测新读取能在 `torch.compile(fullgraph=True)` 下追踪。

结论:接受改动,以回归测试固定该行为。 · 已解决

风险与影响

核心风险是启动路径变更:server_args.py 是每个服务进程必经的配置解析路径,19 个 handler 的声明方式重构可能影响特定模型/硬件组合(如 HRM-Text、NPU、EmbeddingGemma)的最终 cuda-graph 配置。虽然 24 种启动形态 × 478 共享字段的分辨率对比为 0 差异,但覆盖仅限 CPU 侧,未覆盖真实多进程组或多卡环境。
其次,with_phase 并非深拷贝:dataclasses.replace 复制字段引用,bs 这类 list 字段仍共享;若未来有代码原地改动 list,将引入别名 bug(代码注释已明确警告)。
此外,本 PR 无 GPU 精度测试,所有验证基于 CPU 侧 resolution dump 与 guard;且该 PR 强依赖同一系列的后续 PR(#36620-#36622),若后续回退可能破坏一致性。NPU 路径(npu/utils.py)的声明方式变化虽值相同,但缺少 NPU 实测。

对用户:无可观察行为变化,但配置来源日志更准确,便于排查“谁改了 cuda-graph 配置”。对系统:修复 overrides 来源归属,validate_declarations 能看见后续决策;声明不可变的模式为后续平行读取重构铺路。对团队:本 PR 确立了“声明不可变、来源可追踪”的配置编写范式,是五 PR 系列的关键一步,后续 #36620-#36622 均建立在此模型上。

核心配置路径变更 无 GPU 精度验证 系列前置依赖 不可变更新共享引用风险

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论