fix(modelopt): dispatch NVFP4 MoE on the cached backend, not the live global (#38932)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Po-Han Huang (NVIDIA) <53919306+nvpohanh@users.noreply.github.com>
This commit is contained in:
Divy
2026-09-20 19:28:54 -04:00
committed by GitHub
co-authored by Claude Opus 5 Po-Han Huang
parent 2fa6b94e34
commit 42875bcd2a
2 changed files with 194 additions and 4 deletions
@@ -3045,6 +3045,7 @@ class ModelOptNvFp4FusedMoEMethod(FusedMoEMethodBase):
# tuple). Defer per-attribute access to the branches that actually
# consume them.
activation = self.moe_runner_config.activation
# Use the cached backend: the global differs under speculative decoding.
moe_runner_backend = getattr(
self, "_moe_runner_backend", get_moe_runner_backend()
)
@@ -3077,8 +3078,11 @@ class ModelOptNvFp4FusedMoEMethod(FusedMoEMethodBase):
quant_info = self.get_marlin_quant_info(layer)
return self.runner.run(dispatch_output, quant_info)
# FlashInfer TRTLLM FP4 path
if self.enable_flashinfer_trtllm_moe and hasattr(layer, "g1_scale_c"):
# FlashInfer TRTLLM FP4 path (routed shares the weight prep and the runner)
if (
moe_runner_backend.is_flashinfer_trtllm()
or moe_runner_backend.is_flashinfer_trtllm_routed()
):
from sglang.srt.layers.moe.moe_runner.flashinfer_trtllm import (
FlashInferTrtllmFp4MoeQuantInfo,
)
@@ -3114,7 +3118,7 @@ class ModelOptNvFp4FusedMoEMethod(FusedMoEMethodBase):
return self.runner.run(dispatch_output, quant_info)
if self.enable_flashinfer_cutedsl_moe:
if moe_runner_backend.is_flashinfer_cutedsl():
from sglang.srt.layers.moe.moe_runner.flashinfer_cutedsl import (
CuteDslFp4MoeQuantInfo,
ensure_cutedsl_wrapper,
@@ -3170,7 +3174,7 @@ class ModelOptNvFp4FusedMoEMethod(FusedMoEMethodBase):
)
return self.runner.run(dispatch_output, quant_info)
if self.enable_flashinfer_cutlass_moe:
if moe_runner_backend.is_flashinfer_cutlass():
from sglang.srt.layers.moe.moe_runner.flashinfer_cutlass import (
FlashInferCutlassMoeQuantInfo,
)
@@ -0,0 +1,186 @@
"""CPU unit tests for NVFP4 fused-MoE backend dispatch in ModelOptNvFp4FusedMoEMethod.apply.
apply() picks the kernel path from the backend the method cached in
create_moe_runner, not from the process-wide MoE runner backend, which
speculative decoding changes after the weights were prepared. These tests pin
that for the FlashInfer TRT-LLM path, which serves the regular and the routed
TRT-LLM backend from one weight prep.
The platform check and the runner are stubbed and the layer is a bag of small
tensors, so the tests stay on CPU; the kernels are covered on-device.
"""
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=6, suite="base-a-test-cpu")
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest import mock
import torch
# Import modelopt_quant before flashinfer_trtllm (see
# test_modelopt_nvfp4_moe_scales.py for the circular-import reason).
# isort: off
from sglang.srt.layers.quantization import modelopt_quant
from sglang.srt.layers.quantization.modelopt_quant import ModelOptNvFp4FusedMoEMethod
from sglang.srt.layers.moe.moe_runner.flashinfer_trtllm import (
FlashInferTrtllmFp4MoeQuantInfo,
)
# isort: on
from sglang.srt.layers.moe.moe_runner.base import MoeRunnerConfig
from sglang.srt.layers.moe.utils import MoeRunnerBackend
from sglang.test.test_utils import CustomTestCase
NUM_EXPERTS = 8
HIDDEN = 64
INTERMEDIATE = 32
class _Runner:
"""Records the quant_info apply() hands to the runner."""
def __init__(self):
self.calls = []
def run(self, dispatch_output, quant_info):
self.calls.append((dispatch_output, quant_info))
return "combine-input"
def _trtllm_prepared_layer() -> SimpleNamespace:
"""A layer as align_fp4_moe_weights_for_flashinfer_trtllm leaves it: packed FP4
weights, FP8 block scales and the TRT-LLM output scalars, incl. g1_scale_c."""
def p(t: torch.Tensor) -> torch.nn.Parameter:
return torch.nn.Parameter(t, requires_grad=False)
return SimpleNamespace(
w13_weight=p(
torch.zeros(NUM_EXPERTS, 2 * INTERMEDIATE, HIDDEN // 2, dtype=torch.uint8)
),
w2_weight=p(
torch.zeros(NUM_EXPERTS, HIDDEN, INTERMEDIATE // 2, dtype=torch.uint8)
),
w13_weight_scale=p(
torch.zeros(
NUM_EXPERTS, 2 * INTERMEDIATE, HIDDEN // 16, dtype=torch.float8_e4m3fn
)
),
w2_weight_scale=p(
torch.zeros(
NUM_EXPERTS, HIDDEN, INTERMEDIATE // 16, dtype=torch.float8_e4m3fn
)
),
g1_scale_c=p(torch.full((NUM_EXPERTS,), 0.25, dtype=torch.float32)),
g1_alphas=p(torch.full((NUM_EXPERTS,), 0.5, dtype=torch.float32)),
g2_alphas=p(torch.full((NUM_EXPERTS,), 0.75, dtype=torch.float32)),
w13_input_scale_quant=torch.tensor(2.0, dtype=torch.float32),
num_experts=NUM_EXPERTS,
num_local_experts=NUM_EXPERTS,
moe_ep_rank=0,
intermediate_size_per_partition=INTERMEDIATE,
)
@contextmanager
def _live_backend(backend: MoeRunnerBackend):
"""The process-wide MoE runner backend, as the method sees it."""
with mock.patch.object(
modelopt_quant, "get_moe_runner_backend", return_value=backend
):
yield
def _method_set_up_for(backend: MoeRunnerBackend) -> ModelOptNvFp4FusedMoEMethod:
"""A method constructed and given its runner while ``backend`` was the live one."""
with (
_live_backend(backend),
mock.patch.object(
modelopt_quant,
"get_platform",
return_value=SimpleNamespace(is_blackwell=True),
),
mock.patch.object(modelopt_quant, "is_cuda", return_value=False),
):
method = ModelOptNvFp4FusedMoEMethod(
SimpleNamespace(use_per_token_activation=False)
)
method.create_moe_runner(
SimpleNamespace(),
MoeRunnerConfig(
num_experts=NUM_EXPERTS,
num_local_experts=NUM_EXPERTS,
hidden_size=HIDDEN,
intermediate_size_per_partition=INTERMEDIATE,
activation="silu",
is_gated=True,
),
)
method.runner = _Runner()
return method
class TestNvFp4MoeDispatch(CustomTestCase):
def test_routed_trtllm_takes_the_trtllm_path(self):
for backend in (
MoeRunnerBackend.FLASHINFER_TRTLLM_ROUTED,
MoeRunnerBackend.FLASHINFER_TRTLLM,
):
with self.subTest(backend=backend.value):
method = _method_set_up_for(backend)
layer = _trtllm_prepared_layer()
dispatch_output = object()
with _live_backend(backend):
out = method.apply(layer, dispatch_output)
self.assertEqual(out, "combine-input")
((seen_dispatch, quant_info),) = method.runner.calls
self.assertIs(seen_dispatch, dispatch_output)
self.assertIsInstance(quant_info, FlashInferTrtllmFp4MoeQuantInfo)
self.assertEqual(
quant_info.g1_scale_c.data_ptr(), layer.g1_scale_c.data_ptr()
)
self.assertEqual(
quant_info.g1_alphas.data_ptr(), layer.g1_alphas.data_ptr()
)
self.assertEqual(quant_info.local_num_experts, NUM_EXPERTS)
self.assertEqual(
quant_info.intermediate_size_per_partition, INTERMEDIATE
)
def test_dispatch_follows_the_backend_the_runner_was_created_for(self):
method = _method_set_up_for(MoeRunnerBackend.FLASHINFER_TRTLLM_ROUTED)
for live in (
MoeRunnerBackend.AUTO,
MoeRunnerBackend.FLASHINFER_CUTLASS,
MoeRunnerBackend.FLASHINFER_CUTEDSL,
):
with self.subTest(live=live.value):
method.runner = _Runner()
with _live_backend(live):
method.apply(_trtllm_prepared_layer(), object())
((_, quant_info),) = method.runner.calls
self.assertIsInstance(quant_info, FlashInferTrtllmFp4MoeQuantInfo)
def test_missing_trtllm_prep_names_the_missing_field(self):
method = _method_set_up_for(MoeRunnerBackend.FLASHINFER_TRTLLM_ROUTED)
layer = _trtllm_prepared_layer()
del layer.g1_scale_c
with _live_backend(MoeRunnerBackend.FLASHINFER_TRTLLM_ROUTED):
with self.assertRaises(AttributeError) as ctx:
method.apply(layer, object())
self.assertIn("g1_scale_c", str(ctx.exception))
self.assertEqual(method.runner.calls, [])
if __name__ == "__main__":
unittest.main()