Prhub

#36275 fix(moe): guard FP8 delegate activation params

原始 PR 作者 ActiveSky 合并时间 2026-08-26 20:12 文件变更 2 提交数 5 评论 2 代码增减 +126 / -1

执行摘要

修复 FP8 委托方法读取缺失配置导致的启动崩溃

issue #36264 报告:当 Fp8MoEMethodMxfp4FlashinferTrtllmMoEMethod 作为 delegate 处理 fp8-fallback 层(如混合 NVFP4 checkpoint 中未量化到 NVFP4 的 draft/MTP head)时,create_moe_runner() 不会把 moe_runner_config 转发给委托方;而 #33962 新增的 _prepare_flashinfer_trtllm_activation_params() 无条件读取 self.moe_runner_config,导致 AttributeError: 'Fp8MoEMethod' object has no attribute 'moe_runner_config'。该 bug 自 #25820 起休眠,直到 #33962 新加属性访问后才触发。

值得精读。该 bug 是两个 PR 交叉产生的典型“潜伏回归”:委托模式(#25820)与无条件属性访问(#33962)单独看都没有问题,组合后才崩溃。修复方式(用实例所有权标志叠加全局配置判断)是一个简洁且可复用的防御式设计;新增测试对“持有者/借用者/后端类型”三轴组合覆盖完整,可作为量化方法单元测试的范例。

讨论亮点

PR 的代码 review 评论为空,但 issue 评论区有一轮 linter 修复沟通:

nvpohanh:@ActiveSky could you fix the linter error?
ActiveSky:已为受影响的文件补充 missing trailing newlines 并推送,请复审。

更重要的设计讨论沉淀在 issue #36264 的根因分析中:Fp8MoEMethodMxfp4FlashinferTrtllmMoEMethod 借用时从不获得 moe_runner_config,而 Runner 后端是全局配置,因此不能只靠后端类型判断是否物化参数。本 PR 用“实例所有权”这一显式语义解决该问题,并在测试模块 docstring 中固化该约定,避免后续再次误用。

实现拆解

本 PR 用“实例是否持有 MoeRunner”作为唯一判据,修正后处理阶段对全局 Runner 后端配置的误用。具体步骤如下:

  1. 初始化所有权标志:在 python/sglang/srt/layers/quantization/fp8.pyFp8MoEMethod.__init__ 中新增 self._owns_moe_runner = False。这是修复的根基,因为 MxFP4 包装方法只借用该实例做权重加载,从不调用 create_moe_runner(),所以默认必须按“不持有”处理。

  2. 在 runner 构造入口维护标志create_moe_runner() 开头先重置为 False,只有 flashinfer_trtllmflashinfer_trtllm_routedhpc_ops 等真正构造出 MoeRunner 的分支才置为 True。这样既覆盖持有者,也防止上一次调用遗留的“持有”状态。

  3. 给参数物化加守卫process_weights_after_loading() 中原本无条件调用的 _prepare_flashinfer_trtllm_activation_params(layer) 改为 and self._owns_moe_runner 才执行。因为 Runner 后端是全局开关,仅靠 get_moe_runner_backend() 无法区分“持有者”和“借用者”,必须叠加实例所有权判断。

  4. 补充 CPU 回归测试:新增 test/registered/unit/layers/quantization/test_fp8_moe_runner_ownership.py,通过 register_cpu_ci(est_time=2, suite="base-a-test-cpu") 注册进 CPU CI。测试覆盖三种场景:借用者跳过物化(test_borrowed_delegate_skips_trtllm_activation_params)、持有者在 TRT-LLM 后端物化参数(test_owning_method_prepares_trtllm_activation_params)、持有者在非 TRT-LLM 后端不物化(test_owning_method_skips_params_on_non_trtllm_backend)。

  5. 配套验证pre-commit runpy_compile 本地通过;因本地缺 pybase64triton,pytest 无法收集,依赖 CI 执行。无文档和性能基准改动。

文件 模块 状态 重要度
python/sglang/srt/layers/quantization/fp8.py 量化层 modified 6.0
test/registered/unit/layers/quantization/test_fp8_moe_runner_ownership.py 单元测试 added 7.22

关键符号

Fp8MoEMethod.__init__ Fp8MoEMethod.create_moe_runner Fp8MoEMethod.process_weights_after_loading Fp8MoEMethod._prepare_flashinfer_trtllm_activation_params TestFp8MoERunnerOwnership.test_borrowed_delegate_skips_trtllm_activation_params TestFp8MoERunnerOwnership.test_owning_method_prepares_trtllm_activation_params

关键源码片段

python/sglang/srt/layers/quantization/fp8.py core-logic

核心修复文件:引入 `_owns_moe_runner` 所有权标志,并在 `process_weights_after_loading` 中为 TRT-LLM 激活参数物化加守卫。

# Fp8MoEMethod 的初始化段:新增 _owns_moe_runner 所有权标志。
def __init__(self, quant_config: Fp8Config):
    self.quant_config = quant_config
    self.use_mxfp8 = getattr(self.quant_config, 'use_mxfp8', False)
    self.block_quant = (
        self.use_mxfp8 or self.quant_config.weight_block_size is not None
    )
    self.weight_block_size = self.quant_config.weight_block_size
    # 关键修复:默认标记为「不持有 MoeRunner」。
    # MxFP4 wrapper 方法只借用本实例做权重加载,
    # 从不调用 create_moe_runner(),因此 moe_runner_config 未设置。
    self._owns_moe_runner = False
    # ... 其余初始化逻辑,如 cutlass 后端断言等# 后处理入口:仅在持有者上物化 TRT-LLM 激活参数。
def process_weights_after_loading(self, layer: Module) -> None:
    # ... 权重对齐等前置逻辑,如 align_fp8_moe_weights_for_flashinfer_trtllm
    # runner backend 是全局开关,对借用者同样为真;
    # 但借用者没有 moe_runner_config,且对应 kernel 不消费这些参数,
    # 因此只有持有者才需要物化 SwiGLU 激活参数。
    if (
        get_moe_runner_backend().is_flashinfer_trtllm()
        or get_moe_runner_backend().is_flashinfer_trtllm_routed()
    ) and self._owns_moe_runner:
        self._prepare_flashinfer_trtllm_activation_params(layer)
    # ... 后续 HPC ops / dispatcher 处理# runner 构造入口:每次重置,真正构造出 runner 才标记为持有者。
def create_moe_runner(self, layer, moe_runner_config: MoeRunnerConfig):
    self._owns_moe_runner = False
    self.moe_runner_config = moe_runner_config
    moe_runner_backend = get_moe_runner_backend()
    # ... 根据 backend 分支构造 runner
    if (
        moe_runner_backend.is_flashinfer_trtllm()
        or moe_runner_backend.is_flashinfer_trtllm_routed()
        or moe_runner_backend.is_hpc_ops()
    ):
        self.runner = MoeRunner(moe_runner_backend, moe_runner_config)
        self._owns_moe_runner = True
    else:
        # TODO(cwan): 其余 backend 暂不构造 runner,保持非持有状态。
        pass
test/registered/unit/layers/quantization/test_fp8_moe_runner_ownership.py test-coverage

新增 CPU 回归测试,覆盖借用者跳过、持有者物化、非 TRT-LLM 后端跳过三种所有权场景,防止 issue #36264 复现。

class TestFp8MoERunnerOwnership(CustomTestCase):
    # 验证 Fp8MoEMethod 只有在“持有”MoeRunner 时才物化 TRT-LLM 激活参数。
​
    def setUp(self):
        moe = get_flags().moe
        self._saved_runner_backend = moe.runner_backend
        moe.runner_backend = MoeRunnerBackend.FLASHINFER_TRTLLM
        # _use_hip_int4 会把路径带进 ROCm int4 分支,绕过守卫,这里钉死为 False。
        hip_int4 = patch('sglang.srt.layers.quantization.fp8._use_hip_int4', False)
        hip_int4.start()
        self.addCleanup(hip_int4.stop)
​
    def tearDown(self):
        get_flags().moe.runner_backend = self._saved_runner_backend
​
    @staticmethod
    def _make_block_fp8_method() -> Fp8MoEMethod:
        # 不调用 create_moe_runner,_owns_moe_runner 保持构造器默认 False,
        # 恰好等价于 MxFP4 wrapper 委托方的真实状态。
        return Fp8MoEMethod(
            Fp8Config(is_checkpoint_fp8_serialized=True, weight_block_size=[128, 128])
        )
​
    def test_borrowed_delegate_skips_trtllm_activation_params(self):
        # 回归测试:委托方在 TRT-LLM 后端下不得读取 moe_runner_config。
        method = self._make_block_fp8_method()
        layer = SimpleNamespace(
            num_local_experts=2,
            w13_weight=torch.empty(2, 4),
        )
​
        with patch.object(method, 'process_weights_after_loading_block_quant') as work:
            method.process_weights_after_loading(layer)
        work.assert_called_once_with(layer)
​
        # 关键断言:layer 上不出现 _flashinfer_trtllm_* 属性。
        for name in ('gemm1_alpha', 'gemm1_beta', 'gemm1_clamp_limit'):
            self.assertFalse(hasattr(layer, f'_flashinfer_trtllm_{name}'))

评论区精华

委托方法不应读取 moe_runner_config 设计

issue #36264 指出 Fp8MoEMethod 被 Mxfp4FlashinferTrtllmMoEMethod 借用时从不获得 moe_runner_config,而 #33962 引入的激活参数物化无条件读取该属性导致 AttributeError。PR 通过所有权标志把“是否物化”与“是否持有 runner”绑定,避免仅靠全局 backend 判断。

结论:采用 _owns_moe_runner 标志作为守卫,测试覆盖三种所有权场景。 · 已解决

Linter 错误修复 style

nvpohanh 在 issue 评论中要求 ActiveSky 修复 linter 错误;ActiveSky 回应已为受影响文件补充 missing trailing newlines 并推送。

结论:linter 错误已修复,无其他 review 评论。 · 已解决

风险与影响

  1. 启动路径行为变化process_weights_after_loading 是模型加载必经路径,守卫改变后借用者不再物化 _flashinfer_trtllm_* 参数。目前借用者的 kernel 不消费这些参数,但若未来有代码在借用者的 layer 上执行 TRT-LLM 分支的 apply(),可能读到缺失属性。
  2. 标志维护耦合_owns_moe_runner 的语义依赖 create_moe_runner 的分支结构。hpc_ops 分支目前也置 True,而 else 分支(TODO(cwan))保持 False;若后续重构 Runner 构造路径,标志与真实持有关系可能失配。
  3. 测试覆盖局限:CPU 单测用 SimpleNamespace 桩层 + mock,未覆盖真实 GPU 上 FlashInfer TRT-LLM kernel 的集成路径,也未覆盖 Mxfp4FlashinferTrtllmMoEMethod 完整加载流程。
  4. 回归面:源码改动仅 9 行,集中在 fp8.py,不触碰推理 kernel 路径,整体风险较低。

用户影响:修复了混合 NVFP4 checkpoint(主 MoE 专家用 NVFP4、draft/MTP head 回退到 FP8)配合 --moe-runner-backend flashinfer_trtllm(_routed) 且开启投机解码(DSPARK/EAGLE)时的启动崩溃,此类用户可以正常加载模型。
系统影响:不影响推理性能,仅在权重后处理阶段增加一个布尔判断;新增 CPU 测试用例可防止同类回归。
团队影响:确立了“量化方法是否持有 Runner”的显式语义,为后续 delegate 模式的扩展(如其他混合量化包装)提供了可复用的防御式设计模式。

启动路径变更 全局后端配置与实例所有权耦合 CPU 测试未覆盖 GPU kernel 集成

关联 Issue

#36264 [Bug] AttributeError: 'Fp8MoEMethod' object has no attribute 'moe_runner_config' when using flashinfer_trtllm_routed MoE backend with speculative decoding (DSPARK/EAGLE) on hybrid NVFP4 checkpoints

完整报告

参与讨论