Prhub

#34376 [Fix] Make the linear-attn kernel choice per-runner, and pin draft/target loader-hook parity

原始 PR 作者 ch-wan 合并时间 2026-08-15 15:36 文件变更 10 提交数 1 评论 8 代码增减 +288 / -130

执行摘要

linear-attn 内核选择改为 per-runner 隔离,修复 draft/target 配置污染

PR body 将本 PR 定位为 #33312 的延续:#33312 修复的是一种『per-runner 决策中两个参与者答案不一致』的问题——DSV4 DSpark draft entry 类没有暴露其 target 家族携带的 shared-experts-fusion gate,loader 为 target 和 draft 装了不同决策,draft 权重按错误布局装载,accept length 从 5.60 掉到 2.05。本 PR 修复同类问题:linear-attn 内核后端是进程级共享的,attn_backend_wrapper 每 runner 重建一次模块级字典,导致 draft 无法持有与 target 不同的选择,且 config-override 路径不写回 ServerArgs 记录,第二次重建会把第一次的选择静默覆盖为基础后端。此外旧的 test_fusion_gate_coverage.py 只能抓住『已消费决策』的类,抓不住『应当消费却没有消费』的类,需要新的不变量测试。

值得精读。重点看三个设计决策:per-runner stamp 替代进程级表;fail-closed(去掉静默 triton 回退)的守卫方式;draft/target hook parity 不变量测试如何用『存在性一致』而非『实现一致』来表达『必须回答 loader 的问题』。此外 Codex 的 P1 泄漏机制讨论很有价值,它解释了为什么把自动默认值写进进程级配置叶子是泄漏源——建议结合 test_the_per_runner_default_stays_out_of_the_process_config 一起阅读。

讨论亮点

核心讨论围绕 4 个问题展开,全部解决:

  • Codex 的 P1:自动默认值泄漏。Codex bot 指出旧代码通过 get_context().override(...) 把 SM100 GDN 的 linear_attn_prefill_backend="flashinfer" 写进进程级配置,后续非 GDN draft 解析时会读到该叶子,即使 prefill_default=None 也会被错打上 FlashInfer stamp,per-runner 隔离并未真正生效。作者承认并修复:删除该 override 路径,让配置叶子只承载启动时显式请求的值,自动默认值只存在于 runner 自己的 stamp 中,并用测试双向钉住隔离。
  • msgspec 约定。评审建议 LinearAttnBackends 使用 msgspec.Struct(frozen)而非 dataclasses.dataclass,符合项目对新容器的规则。作者已转换,并指出 frozen 同时强化了『stamp 一次后不可变更』的意图。
  • 测试同义反复。原 test_an_unstamped_runner_raises 只断言空类缺属性,任何空类都成立;作者改为构造最小 runner double 并调用真实 GDNAttnBackend(runner),断言 AttributeError 信息包含 linear_attn_backends,让失败确实来自生产代码的 guard。
  • 死代码清理。移除 _get_backend 后遗留的 import logginglogger 绑定被删除。

实现拆解

  1. 解析入口替换linear/utils.py 删除进程级 _BACKENDS 字典、initialize_linear_attn_config()_get_backend() 与三个模块级 getter,新增 frozen LinearAttnBackendsmsgspec.Struct)与纯函数 resolve_linear_attn_backends()。新函数从 get_exec().mamba 读取已发布的配置叶子,prefill 优先级为「显式 flag > 调用方传入的 auto-default > 基础 backend」,verify 未设置时沿用 decode 的 flashinfer 分支或回退 triton,语义与旧路径等价。
  2. 打 stamp 的位置attention_registry.pyattn_backend_wrapper 在构造 linear-attn 后端对象之前执行 runner.linear_attn_backends = resolve_linear_attn_backends(prefill_default=...);同时删除原先通过 get_context().override("gdn_backend.sm100_flashinfer_default", ...) 把自动默认值写进进程级配置的路径——这正是泄漏机制:第二个 runner 会把 target 的自动默认值误读成操作员显式 flag。
  3. 消费者改造:GDN(gdn_backend.py)、KDA(kda_backend.py)、Ascend GDN(ascend_gdn_backend.py)三个后端构造函数的读取源从模块级 getter 改为 model_runner.linear_attn_backendsdecode / prefill / verify 字段。后端构造发生在 stamp 之后,因此未打 stamp 的构造路径会因属性缺失直接抛 AttributeError,fail-closed,不再存在『工作但错误』的静默 triton 回退。
  4. 测试与夹具配套test_linear_attn_config.py 重写为 TestLinearAttnBackends,钉住优先级、双 runner 各自持有不同选择、unstamped runner 在真实 GDNAttnBackend 构造时抛错、以及自动默认值不进入进程配置;新增 test_draft_entry_hook_parity.py 对注册表中每个名字带 NextN / MTP / DSpark / DFlash / Standalone 后缀的 draft entry class 断言 shared_experts_fusion_disable_reason 存在性与 target 一致;三个 attention fixture(gdn_attention.pykda_attention.pylightning_attention.py)改为模拟 attn_backend_wrapper 先打 stamp 再构造后端。另清理了 utils.py 中不再使用的 logging 导入与 logger 绑定。
  5. 影响面:单 runner 启动行为不变;目标场景(GDN target + 非 GDN draft 共存于同一进程)从『draft 权重按错误布局装载』修复为各自持有独立选择。
文件 模块 状态 重要度
python/sglang/srt/layers/attention/linear/utils.py 注意力配置 modified 8.28
python/sglang/srt/layers/attention/attention_registry.py 注意力注册 modified 5.76
test/registered/unit/models/test_draft_entry_hook_parity.py 钩子对等 added 7.42
test/registered/unit/layers/attention/test_linear_attn_config.py 配置单测 modified 7.13
python/sglang/srt/layers/attention/linear/gdn_backend.py GDN 后端 modified 4.91
python/sglang/srt/layers/attention/linear/kda_backend.py KDA 后端 modified 4.94
python/sglang/srt/hardware_backend/npu/attention/ascend_gdn_backend.py NPU 后端 modified 5.08
python/sglang/test/kits/attention_unittest/attention_methods/gdn_attention.py 测试夹具 modified 3.88
python/sglang/test/kits/attention_unittest/attention_methods/kda_attention.py 测试夹具 modified 3.88
python/sglang/test/kits/attention_unittest/attention_methods/lightning_attention.py 测试夹具 modified 3.88

关键符号

resolve_linear_attn_backends LinearAttnBackends GDNAttnBackend.__init__ KDAAttnBackend.__init__ AscendGDNAttnBackend.__init__ attn_backend_wrapper _target_of TestDraftEntryHookParity.test_a_draft_resolves_a_gate_exactly_when_its_target_does

关键源码片段

python/sglang/srt/layers/attention/linear/utils.py core-logic

核心文件:进程级 `_BACKENDS` 表替换为 per-runner `LinearAttnBackends` stamp,新增 `resolve_linear_attn_backends()`,是本次修复的语义载体;同时清理了静默 triton 回退与死代码。

# linear/utils.py —— 每个 runner 的 linear-attn 内核选择
#
# 旧实现用进程级 `_BACKENDS` 字典,每次 runner 构建都重建一次;
# 第二次重建会通过 config-override 路径把第一次的选择覆盖掉
# (override 不写回 ServerArgs 记录,导致后续 runner 解析回基础 backend)。
# 新实现把选择封装成 per-runner 的不可变 stamp,由 wrapper 在构建后端前打好。class LinearAttnBackends(msgspec.Struct, frozen=True):
    """单个 runner 的 linear-attn 内核选择,按阶段分开存放。    target 与 draft 共存在同一进程,可以持有不同选择:只有模型是 GDN 的
    runner 才会得到 SM100 FlashInfer prefill 默认值,显式 flag 也只作用于
    启动时传入它的那个 runner。
    """
​
    decode: LinearAttnKernelBackend
    prefill: LinearAttnKernelBackend
    verify: LinearAttnKernelBackend
​
​
def resolve_linear_attn_backends(
    prefill_default: Optional[str] = None,
) -> LinearAttnBackends:
    """从已发布的配置叶子解析出本 runner 的内核选择。    `prefill_default` 是调用方自己的自动默认值(SM100 GDN 场景);
    显式配置的 `--linear-attn-prefill-backend` 优先级更高。
    解析完成后返回 frozen 容器,只打日志,不写入任何进程级配置。
    """
    mamba = get_exec().mamba # 已发布的配置叶子,只含启动时显式请求的值
    base = mamba.linear_attn_backend
    decode = LinearAttnKernelBackend(mamba.linear_attn_decode_backend or base)
    prefill = LinearAttnKernelBackend(
        mamba.linear_attn_prefill_backend or prefill_default or base
    )
​
    # verify 未设置时沿用历史行为:decode 是 flashinfer 则用其
    # recurrent kernel,否则回退 triton。
    verify = mamba.linear_attn_verify_backend
    if verify is None:
        verify = decode.value if decode.is_flashinfer() else "triton"
​
    backends = LinearAttnBackends(
        decode=decode, prefill=prefill, verify=LinearAttnKernelBackend(verify)
    )
    rank0_log(
        f"Linear attention kernel backend: decode={backends.decode.value}, "
        f"prefill={backends.prefill.value}, verify={backends.verify.value}"
    )
    return backends
python/sglang/srt/layers/attention/attention_registry.py dependency-wiring

stamp 的落点:`attn_backend_wrapper` 在构建后端前设置 `runner.linear_attn_backends`,并删除把自动默认值写入进程级配置的 `get_context().override(...)` 路径,这是修复泄漏机制的关键。

# attention_registry.py —— attn_backend_wrapper 中的 stamp 逻辑
# 在构建任何 linear-attn 后端对象之前,先把本 runner 的内核选择打上去;
# 后端构造函数读取的就是这个 stamp。删除旧的
# `get_context().override("gdn_backend.sm100_flashinfer_default", ...)`
# 记录路径,避免 target 的自动默认值泄漏成进程级“显式 flag”。check_environments()
prefill_default = None
# 只有模型为 GDN 且非 NPU 的 runner 才带 SM100 FlashInfer 自动默认值
if hybrid_gdn_config(runner.model_config) is not None and not is_npu():
    prefill_default = flashinfer_gdn_prefill_default(runner)# 先 stamp,再构造后端;未走此路径的后端会因属性缺失立即抛出
# AttributeError(fail-closed),而不是静默回退到“工作但错误”的内核。
runner.linear_attn_backends = resolve_linear_attn_backends(
    prefill_default=prefill_default
)hybrid_backend_cls = HybridLinearAttnBackend
if hybrid_gdn_config(runner.model_config) is not None:
    # 后续分支用 runner.prefill_attention_backend_str /
    # decode_attention_backend_str 校验 Blackwell / NPU 上允许的后端组合
    ...
test/registered/unit/models/test_draft_entry_hook_parity.py test-coverage

新增不变量测试:对注册表中每个名字带 `NextN` / `MTP` / `DSpark` / `DFlash` / `Standalone` 后缀的 draft entry class 断言 `shared_experts_fusion_disable_reason` 存在性与 target 一致,能拦住 #33312 那类『draft 未暴露 target 携带的门控』缺陷。

# test_draft_entry_hook_parity.py —— draft 与 target 的 loader-hook 存在性对等
# 背景:loader 会向 entry class 询问 shared-experts-fusion 决策
# (install_shared_experts_fusion_decision);若 draft 的 entry class 没有
# 暴露 target 家族携带的自动关闭条件,draft 就会解析出与 target 不同的
# 决策,权重按错误的布局装载。DSV4 DSpark 曾因此缺陷引入回归
# (accept length 从 5.60 掉到 2.05,由 PR #33312 修复)。# draft 后缀:`...NextN` / `...MTP` / `...DSpark` / `...DFlash` / `...Standalone`
# 对应的模型通常是 target 的某个阶段镜像;`...Eagle` 是独立 checkpoint,不在此列。
DRAFT_SUFFIX = re.compile(r"(NextN|MTP|DSpark|DFlash|Standalone)$")# 刻意不检查 weight-name maps:draft checkpoint 有自己的名字,天然不同。
PARITY_HOOKS = ("shared_experts_fusion_disable_reason",)
​
​
def _target_of(arch: str, archs: dict):
    """返回 draft entry class 所服务的 target 架构名,若它按名字指向 target。"""
    match = DRAFT_SUFFIX.search(arch)
    if not match:
        return None
    base = arch[: match.start()]
    for candidate in (base, f"{base}ForCausalLM"):
        if candidate in archs and candidate != arch:
            return candidate
    return None
​
​
class TestDraftEntryHookParity(CustomTestCase):
    def test_a_draft_resolves_a_gate_exactly_when_its_target_does(self):
        # 遍历注册表,对每一对 draft/target 断言 hook 存在性一致;
        # 只比较“有没有”,不要求实现相同(Qwen3.5 MTP 带参数委托是合法的)。
        checked, offenders = 0, []
        for arch, cls in archs.items():
            target = _target_of(arch, archs)
            if target is None:
                continue
            for hook in PARITY_HOOKS:
                checked += 1
                if hasattr(cls, hook) == hasattr(archs[target], hook):
                    continue
                offenders.append(f"{arch} vs {target}: {hook} 存在性不一致")
        self.assertGreater(checked, 10, "draft/target 配对未找到任何条目")
        self.assertEqual([], offenders, "draft 与 target 必须对 loader hook 给出相同答案")

评论区精华

自动默认值泄漏进进程级配置导致 draft 仍读到 FlashInfer 正确性

Codex bot 提出 P1:当 SM100 GDN target 先于非 GDN draft 初始化时,旧代码通过 `get_context().override("gdn_backend.sm100_flashinfer_default", ...)` 把 `linear_attn_prefill_backend="flashinfer"` 写进进程级配置;draft 解析时即使 `prefill_default=None` 也会读到这个被覆盖的叶子,从而被错误打上 FlashInfer stamp,per-runner 隔离并未真正生效。

结论:作者确认这是泄漏机制并修复:删除 `get_context().override(...)` 记录路径,让配置叶子只承载启动时显式请求的值,自动默认值只存在于 runner 自己的 stamp 中;`test_linear_attn_config.py` 新增双向钉住该隔离。 · 已解决

新容器必须用 msgspec.Struct 而非 dataclass 设计

评审建议 `LinearAttnBackends` 使用 `msgspec.Struct`(frozen),符合项目对新容器的规则(严格类型、多语言迁移路径),并引用 `VerifyMask`、`UnifiedMLAHooks` 等先例。

结论:作者已转换,并指出 frozen 也能强化『stamp 一次后不可变更』的意图。 · 已解决

unstamped runner 测试是空类上的同义反复 测试

评审指出原测试只断言空 `_Runner` 缺属性,任何空类都成立,无法钉住『构造真实后端时 fail-fast』;若将来恢复模块级 fallback 或默认 stamp,测试不会变红。

结论:作者改为构造最小 runner double(CPU 设备 + conv pool),调用真实 `GDNAttnBackend(runner)` 并断言 `AttributeError` 信息包含 `linear_attn_backends`,让失败确实来自生产代码的 guard。 · 已解决

删除回退后遗留死代码 logging/logger style

评审指出移除 `_get_backend` 的 warning 回退后,`import logging` 与 `logger` 绑定成为死代码,会触发 unused-import 检查。

结论:作者已删除,并说明该条此前位于 review body 的『outside the diff』区,现已纳入检查通道。 · 已解决

风险与影响

  1. 动态 stamp 属性runner.linear_attn_backends 是动态添加的属性,未在 ModelRunner 类定义中显式声明。任何绕过 attn_backend_wrapper 的后端构造路径(如未来的新 fixture 或硬件后端)都会立即抛 AttributeError;这是设计意图(fail-closed),但对扩展者是行为变更。
  2. 配置语义变化get_context().override(...) 记录路径被删除后,resolved_server_args_dict() 不再包含自动默认的 linear_attn_prefill_backend 字段,若有其他模块依赖该字段观察 SM100 默认值会读到 None。测试已钉住『自动默认值不进入进程配置』,但对外部观测者属于语义变化。
  3. verify 后端等价性:verify 默认逻辑(decode.value 为 flashinfer 时跟随 decode,否则 triton)与旧实现等价,但 KDA 的 EAGLE tree-verify 限制(topk > 1 时抛 ValueError)依赖 verify backend 判定,任何解析偏差都会在 KDAAttnBackend.__init__ 暴露。
  4. CI 状态:PR 页记录的 Base / Extra 两次 CI 运行均为失败状态,作者声明 CPU 单元测试无新增失败,但合并前仍需确认失败原因与本 PR 无关。

对用户与系统:修复 GDN target + 非 GDN draft 组合(如 DSV4 DSpark 与 GDN target 同进程)下 draft 内核选择被静默覆盖的问题,避免权重布局错误导致接受率从 5.60 掉到 2.05 这类退化;单 runner KDA / GDN / NPU Ascend 用户行为不变。对配置语义:进程级配置叶子只承载启动时显式请求的值,自动默认值改由 per-runner stamp 承载,与 full-attn 后端的 prefill_attention_backend_str / decode_attention_backend_str 模式对齐。对团队:确立了 per-runner stamp 的配置承载方式,以及『draft 与 target 必须对 loader hook 给出相同答案』的不变量测试,后者能让 #33312 一类缺陷在未来提交中被自动拦截。

进程级配置覆盖路径已删除 per-runner stamp 为动态属性未在类中声明 涉及 GDN/KDA/Ascend 多后端协同 CI 状态显示失败需确认 配置语义变化影响外部观测者

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论