[AMD] GLM-5.2 NextN: cast draft fused MoE to per-channel FP8 (#39155)

Co-authored-by: fanxingran <xingran.fan@amd.com>
This commit is contained in:
Zhang, Jiejing
2026-09-15 18:56:48 -07:00
committed by GitHub
co-authored by fanxingran
parent 7eedd57ab0
commit f920be4b09
4 changed files with 326 additions and 51 deletions
+3
View File
@@ -983,6 +983,9 @@ class Envs:
SGLANG_MOE_NVFP4_DISPATCH = EnvBool(False) SGLANG_MOE_NVFP4_DISPATCH = EnvBool(False)
SGLANG_NVFP4_CKPT_FP8_GEMM_IN_ATTN = EnvBool(False) SGLANG_NVFP4_CKPT_FP8_GEMM_IN_ATTN = EnvBool(False)
SGLANG_NVFP4_CKPT_FP8_NEXTN_MOE = EnvBool(False) SGLANG_NVFP4_CKPT_FP8_NEXTN_MOE = EnvBool(False)
# GLM NextN (MTP): cast the draft layer's bf16 fused MoE to per-channel FP8
# on load. Unrelated to the NVFP4 block-FP8 NextN path above.
SGLANG_GLM_NEXTN_MOE_PTPC = EnvBool(False)
SGLANG_QUANT_ALLOW_DOWNCASTING = EnvBool(False) SGLANG_QUANT_ALLOW_DOWNCASTING = EnvBool(False)
SGLANG_FP8_IGNORED_LAYERS = EnvStr("") SGLANG_FP8_IGNORED_LAYERS = EnvStr("")
SGLANG_FP4_IGNORED_LAYERS = EnvStr("") SGLANG_FP4_IGNORED_LAYERS = EnvStr("")
@@ -13,7 +13,7 @@ from sglang.srt.layers.moe.moe_runner.triton import TritonMoeQuantInfo
from sglang.srt.layers.quantization.fp8_utils import normalize_e4m3fn_to_e4m3fnuz from sglang.srt.layers.quantization.fp8_utils import normalize_e4m3fn_to_e4m3fnuz
from sglang.srt.layers.quantization.quark.schemes import QuarkMoEScheme from sglang.srt.layers.quantization.quark.schemes import QuarkMoEScheme
from sglang.srt.layers.quantization.utils import all_close_1d, per_tensor_dequantize from sglang.srt.layers.quantization.utils import all_close_1d, per_tensor_dequantize
from sglang.srt.utils import get_bool_env_var, is_hip, set_weight_attrs from sglang.srt.utils import get_bool_env_var, is_hip, print_info_once, set_weight_attrs
if TYPE_CHECKING: if TYPE_CHECKING:
from sglang.srt.layers.moe.token_dispatcher import ( from sglang.srt.layers.moe.token_dispatcher import (
@@ -31,8 +31,6 @@ _use_aiter = get_bool_env_var("SGLANG_USE_AITER") and _is_hip
if _use_aiter: if _use_aiter:
from aiter.ops.shuffle import shuffle_weight from aiter.ops.shuffle import shuffle_weight
from sglang.kernels.ops.moe.rocm_moe_utils import rocm_fused_experts_tkw1
class QuarkW8A8FP8MoE(QuarkMoEScheme): class QuarkW8A8FP8MoE(QuarkMoEScheme):
def __init__(self, weight_config: dict[str, Any], input_config: dict[str, Any]): def __init__(self, weight_config: dict[str, Any], input_config: dict[str, Any]):
@@ -238,74 +236,84 @@ class QuarkW8A8FP8MoE(QuarkMoEScheme):
f"Unsupported weight quantization strategy: {self.weight_qscheme}." f"Unsupported weight quantization strategy: {self.weight_qscheme}."
) )
if ( # Triton reads the canonical layout; only AITER wants the shuffled one,
_use_aiter # which aiter.fused_moe selects on via the is_shuffled tag.
and self.is_weight_per_channel if _use_aiter and self.runner.runner_backend.is_aiter():
and self.moe_runner_config.apply_router_weight_on_input
):
with torch.no_grad(): with torch.no_grad():
# Pre-shuffle weights
layer.w13_weight = torch.nn.Parameter( layer.w13_weight = torch.nn.Parameter(
shuffle_weight(layer.w13_weight.data, (16, 16)), shuffle_weight(layer.w13_weight.data, (16, 16)),
requires_grad=False, requires_grad=False,
) )
layer.w13_weight.is_shuffled = True
torch.cuda.empty_cache() torch.cuda.empty_cache()
layer.w2_weight = torch.nn.Parameter( layer.w2_weight = torch.nn.Parameter(
shuffle_weight(layer.w2_weight.data, (16, 16)), shuffle_weight(layer.w2_weight.data, (16, 16)),
requires_grad=False, requires_grad=False,
) )
layer.w2_weight.is_shuffled = True
torch.cuda.empty_cache() torch.cuda.empty_cache()
def create_moe_runner( def create_moe_runner(
self, layer: torch.nn.Module, moe_runner_config: MoeRunnerConfig self, layer: torch.nn.Module, moe_runner_config: MoeRunnerConfig
): ):
from sglang.srt.layers.moe.utils import (
get_moe_a2a_backend,
get_moe_runner_backend,
)
self.moe_runner_config = moe_runner_config self.moe_runner_config = moe_runner_config
self.runner = MoeRunner(MoeRunnerBackend.TRITON, moe_runner_config) moe_runner_backend = get_moe_runner_backend()
a2a_supports_aiter = get_moe_a2a_backend().supports_aiter()
# AITER's per_Token fused MoE needs per-channel weight scales; the
# per-tensor scheme has no equivalent there.
use_aiter_runner = (
_use_aiter
and self.is_weight_per_channel
and a2a_supports_aiter
and (moe_runner_backend.is_auto() or moe_runner_backend.is_aiter())
)
self.runner = MoeRunner(
MoeRunnerBackend.AITER if use_aiter_runner else MoeRunnerBackend.TRITON,
moe_runner_config,
)
print_info_once(
f"QuarkW8A8FP8MoE runner={self.runner.runner_backend.value} "
f"(use_aiter={_use_aiter} per_channel={self.is_weight_per_channel} "
f"a2a_supports_aiter={a2a_supports_aiter} "
f"requested={moe_runner_backend.value})"
)
def apply_weights( def apply_weights(
self, self,
layer: torch.nn.Module, layer: torch.nn.Module,
dispatch_output: StandardDispatchOutput, dispatch_output: StandardDispatchOutput,
) -> CombineInput: ) -> CombineInput:
if self.runner.runner_backend.is_aiter():
from sglang.srt.layers.moe.token_dispatcher import StandardCombineInput from sglang.srt.layers.moe.moe_runner.aiter import (
AiterMoeQuantInfo,
x = dispatch_output.hidden_states AiterQuantType,
topk_output = dispatch_output.topk_output
moe_runner_config = self.moe_runner_config
if (
_use_aiter
and self.is_weight_per_channel
and moe_runner_config.apply_router_weight_on_input
):
topk_weights, topk_ids, _ = topk_output
output = rocm_fused_experts_tkw1(
hidden_states=x,
w1=layer.w13_weight,
w2=layer.w2_weight,
topk_weights=topk_weights,
topk_ids=topk_ids,
activation=moe_runner_config.activation,
apply_router_weight_on_input=moe_runner_config.apply_router_weight_on_input,
use_fp8_w8a8=True,
per_channel_quant=self.is_weight_per_channel,
w1_scale=layer.w13_weight_scale,
w2_scale=layer.w2_weight_scale,
a1_scale=layer.w13_input_scale,
a2_scale=layer.w2_input_scale,
) )
return StandardCombineInput(hidden_states=output)
else: quant_info = AiterMoeQuantInfo(
quant_info = TritonMoeQuantInfo(
w13_weight=layer.w13_weight, w13_weight=layer.w13_weight,
w2_weight=layer.w2_weight, w2_weight=layer.w2_weight,
use_fp8_w8a8=True, quant_type=AiterQuantType.PER_TOKEN,
per_channel_quant=self.is_weight_per_channel,
w13_scale=layer.w13_weight_scale, w13_scale=layer.w13_weight_scale,
w2_scale=layer.w2_weight_scale, w2_scale=layer.w2_weight_scale,
a13_scale=layer.w13_input_scale, a13_scale=layer.w13_input_scale,
a2_scale=layer.w2_input_scale, a2_scale=layer.w2_input_scale,
expert_mask=layer.dispatcher.expert_mask_gpu,
) )
return self.runner.run(dispatch_output, quant_info) return self.runner.run(dispatch_output, quant_info)
quant_info = TritonMoeQuantInfo(
w13_weight=layer.w13_weight,
w2_weight=layer.w2_weight,
use_fp8_w8a8=True,
per_channel_quant=self.is_weight_per_channel,
w13_scale=layer.w13_weight_scale,
w2_scale=layer.w2_weight_scale,
a13_scale=layer.w13_input_scale,
a2_scale=layer.w2_input_scale,
)
return self.runner.run(dispatch_output, quant_info)
+112 -8
View File
@@ -14,6 +14,7 @@
"""Inference-only GLM-4.5, GLM-4.6 and GLM-4.7 model compatible with HuggingFace weights""" """Inference-only GLM-4.5, GLM-4.6 and GLM-4.7 model compatible with HuggingFace weights"""
import copy
import logging import logging
import re import re
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
@@ -95,6 +96,7 @@ from sglang.srt.utils import (
is_hip, is_hip,
is_non_idle_and_non_empty, is_non_idle_and_non_empty,
is_npu, is_npu,
log_info_on_rank0,
make_layers, make_layers,
) )
from sglang.srt.utils.hf_transformers_utils import get_rope_config from sglang.srt.utils.hf_transformers_utils import get_rope_config
@@ -110,6 +112,42 @@ _device_sm = get_device_sm()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_GLM_NEXTN_EXPERT_PROJ_RE = re.compile(
r"mlp\.experts\.\d+\.(gate_proj|up_proj|down_proj)\.weight$"
)
def enable_glm_nextn_moe_ptpc(
quant_config: Optional[QuantizationConfig],
) -> bool:
return (
envs.SGLANG_GLM_NEXTN_MOE_PTPC.get()
and quant_config is not None
and quant_config.get_name() == "quark"
)
def glm_nextn_mtp_fused_experts_excluded(
quant_config: Optional[QuantizationConfig],
num_hidden_layers: int,
) -> bool:
exclude_layers = getattr(quant_config, "exclude_layers", None) or []
layer_prefix = f"model.layers.{num_hidden_layers}."
return any(
name.startswith(layer_prefix) and ".mlp.experts." in name
for name in exclude_layers
)
def should_apply_glm_nextn_moe_ptpc(
quant_config: Optional[QuantizationConfig],
num_hidden_layers: int,
) -> bool:
if not enable_glm_nextn_moe_ptpc(quant_config):
return False
return glm_nextn_mtp_fused_experts_excluded(quant_config, num_hidden_layers)
if _is_npu: if _is_npu:
from sgl_kernel_npu.norm.split_qkv_rmsnorm_rope import split_qkv_rmsnorm_rope from sgl_kernel_npu.norm.split_qkv_rmsnorm_rope import split_qkv_rmsnorm_rope
@@ -1464,10 +1502,58 @@ class GlmMoeDsaForCausalLMNextN(DeepseekV3ForCausalLMNextN):
return name.replace(layer_prefix, "model", 1) return name.replace(layer_prefix, "model", 1)
return name.replace(layer_prefix, "model.decoder", 1) return name.replace(layer_prefix, "model.decoder", 1)
def _maybe_quant_glm_nextn_moe_to_ptpc(self, weights):
"""Cast this GLM-5.2 draft layer's routed experts to per-channel FP8."""
layer_id = self.config.num_hidden_layers
if not should_apply_glm_nextn_moe_ptpc(self.quant_config, layer_id):
return weights
layer_prefix = f"model.layers.{layer_id}"
fp8_max = torch.finfo(torch.float8_e4m3fn).max
log_info_on_rank0(
logger,
"GLM NextN MoE PTPC: casting draft expert weights under "
f"{layer_prefix}.mlp to fp8_e4m3 per-channel",
)
def _cast() -> Iterable[Tuple[str, torch.Tensor]]:
for name, tensor in weights:
if not (
name.startswith(layer_prefix + ".")
and _GLM_NEXTN_EXPERT_PROJ_RE.search(name)
):
yield name, tensor
continue
if tensor.ndim != 2:
raise ValueError(
f"{name}: PTPC cast expects a 2D expert weight, "
f"got {tuple(tensor.shape)}"
)
weight = tensor.to(torch.float32)
scale = weight.abs().amax(dim=-1, keepdim=True).clamp(min=1e-12)
scale /= fp8_max
yield (
name,
(weight / scale).clamp(-fp8_max, fp8_max).to(torch.float8_e4m3fn),
)
yield name[: -len("weight")] + "weight_scale", scale.squeeze(-1)
return _cast()
def load_weights(self, weights: Iterable[Tuple[str, torch.Tensor]]):
weights = self._maybe_quant_glm_nextn_moe_to_ptpc(weights)
return super().load_weights(weights)
def _resolve_nextn_quant_config(self, config, quant_config): def _resolve_nextn_quant_config(self, config, quant_config):
if quant_config is None or quant_config.get_name() != "quark": if quant_config is None or quant_config.get_name() != "quark":
return quant_config return quant_config
# The caller reuses this QuarkConfig for the target model and lm_head,
# so the draft-only rewrites below need a private copy of the wrapper
# and of the dict its schemes are read from.
quant_config = copy.copy(quant_config)
quant_config.quant_config = copy.deepcopy(quant_config.quant_config)
layer_prefix = f"model.layers.{config.num_hidden_layers}" layer_prefix = f"model.layers.{config.num_hidden_layers}"
# Quark's per-module scheme selection (e.g. MTP self_attn in PTPC-FP8 # Quark's per-module scheme selection (e.g. MTP self_attn in PTPC-FP8
@@ -1500,16 +1586,34 @@ class GlmMoeDsaForCausalLMNextN(DeepseekV3ForCausalLMNextN):
names.add(self._map_mtp_ckpt_name(name, layer_prefix)) names.add(self._map_mtp_ckpt_name(name, layer_prefix))
# Fused routed experts are queried by the coarse module prefix # Fused routed experts are queried by the coarse module prefix
# "model.decoder.mlp.experts". Expanded per-expert leaf excludes do not # "model.decoder.mlp.experts", which expanded per-expert leaf excludes
# match that prefix, so add the coarse prefix when any routed expert in # do not match. So that module needs its own entry: bf16 as in the
# the MTP layer is excluded. This keeps only that fused MoE module bf16 # checkpoint, or the scheme matching the on-load PTPC-FP8 cast.
# while allowing the remaining draft modules to use their quant config. # Same gate as the weight-loader cast (Quark-excluded = bf16 in ckpt).
if any(".mlp.experts." in name for name in mtp_excluded): if should_apply_glm_nextn_moe_ptpc(quant_config, config.num_hidden_layers):
mtp_layer_quant_config = quant_config.quant_config.setdefault(
"layer_quant_config", {}
)
mtp_layer_quant_config["model.decoder.mlp.experts"] = {
"weight": {
"dtype": "fp8_e4m3",
"is_dynamic": False,
"qscheme": "per_channel",
},
# Dynamic per_channel is QuarkW8A8FP8MoE's per-token input.
"input_tensors": {
"dtype": "fp8_e4m3",
"is_dynamic": True,
"qscheme": "per_channel",
},
}
logger.info(
"SGLANG_GLM_NEXTN_MOE_PTPC=1: MTP fused MoE "
"(model.decoder.mlp.experts) runs as PTPC-FP8"
)
elif any(".mlp.experts." in name for name in mtp_excluded):
names.add("model.decoder.mlp.experts") names.add("model.decoder.mlp.experts")
import copy
quant_config = copy.copy(quant_config)
quant_config.exclude_layers = list(names) quant_config.exclude_layers = list(names)
return quant_config return quant_config
@@ -0,0 +1,160 @@
"""CI for SGLANG_GLM_NEXTN_MOE_PTPC=1 (GLM-5.2 NextN per-channel FP8 draft MoE).
The feature is off by default. Without a case that turns the flag on, CI never
touches the Quark scheme rewrite and cannot claim the path works. These tests
exercise that ON wiring on CPU without loading a 70B MXFP4 checkpoint.
A full serve+generate job still needs the MXFP4 weights in the runner cache;
register that separately as nightly if the checkpoint is present.
"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import torch
from sglang.srt.models.glm4_moe import (
GlmMoeDsaForCausalLMNextN,
enable_glm_nextn_moe_ptpc,
should_apply_glm_nextn_moe_ptpc,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
LAYER = 78
PREFIX = f"model.layers.{LAYER}"
EXPERT_LEAF = f"{PREFIX}.mlp.experts.0.w1"
ATTN_LEAF = f"{PREFIX}.self_attn.q_proj"
EXPERT_WEIGHT = f"{PREFIX}.mlp.experts.0.gate_proj.weight"
_PTPC_ENV = "sglang.srt.models.glm4_moe.envs.SGLANG_GLM_NEXTN_MOE_PTPC.get"
def _quark_cfg(*, exclude=None, layer_quant=None):
return SimpleNamespace(
get_name=lambda: "quark",
quant_config={"layer_quant_config": dict(layer_quant or {}), "exclude": []},
exclude_layers=list(
exclude if exclude is not None else [EXPERT_LEAF, ATTN_LEAF]
),
)
class TestEnableGlmNextnMoePtpc(CustomTestCase):
def test_off_by_default(self):
self.assertFalse(enable_glm_nextn_moe_ptpc(_quark_cfg()))
def test_on_requires_quark(self):
with patch(
_PTPC_ENV,
return_value=True,
):
self.assertTrue(enable_glm_nextn_moe_ptpc(_quark_cfg()))
self.assertFalse(
enable_glm_nextn_moe_ptpc(SimpleNamespace(get_name=lambda: "fp8"))
)
self.assertFalse(enable_glm_nextn_moe_ptpc(None))
def test_apply_requires_excluded_mtp_experts(self):
with patch(
_PTPC_ENV,
return_value=True,
):
self.assertTrue(should_apply_glm_nextn_moe_ptpc(_quark_cfg(), LAYER))
self.assertFalse(
should_apply_glm_nextn_moe_ptpc(_quark_cfg(exclude=[ATTN_LEAF]), LAYER)
)
class TestResolveNextnQuantConfigPtpcOn(CustomTestCase):
def _resolve(self, cfg, flag: bool):
model = GlmMoeDsaForCausalLMNextN.__new__(GlmMoeDsaForCausalLMNextN)
hf = SimpleNamespace(num_hidden_layers=LAYER)
with patch(
_PTPC_ENV,
return_value=flag,
):
return model._resolve_nextn_quant_config(hf, cfg)
def test_flag_off_excludes_fused_experts(self):
src = _quark_cfg()
out = self._resolve(src, flag=False)
self.assertIn("model.decoder.mlp.experts", out.exclude_layers)
self.assertNotIn(
"model.decoder.mlp.experts",
out.quant_config.get("layer_quant_config", {}),
)
def test_flag_on_assigns_ptpc_scheme_instead_of_bf16_exclude(self):
src = _quark_cfg()
out = self._resolve(src, flag=True)
self.assertNotIn("model.decoder.mlp.experts", out.exclude_layers)
scheme = out.quant_config["layer_quant_config"]["model.decoder.mlp.experts"]
self.assertEqual(scheme["weight"]["dtype"], "fp8_e4m3")
self.assertEqual(scheme["weight"]["qscheme"], "per_channel")
self.assertFalse(scheme["weight"]["is_dynamic"])
self.assertEqual(scheme["input_tensors"]["dtype"], "fp8_e4m3")
self.assertEqual(scheme["input_tensors"]["qscheme"], "per_channel")
self.assertTrue(scheme["input_tensors"]["is_dynamic"])
def test_flag_on_does_not_mutate_caller_config(self):
src = _quark_cfg()
orig_exclude = list(src.exclude_layers)
orig_layer = dict(src.quant_config.get("layer_quant_config") or {})
self._resolve(src, flag=True)
self.assertEqual(src.exclude_layers, orig_exclude)
self.assertEqual(src.quant_config.get("layer_quant_config") or {}, orig_layer)
def test_flag_on_skips_ptpc_when_experts_not_excluded(self):
src = _quark_cfg(exclude=[ATTN_LEAF])
out = self._resolve(src, flag=True)
self.assertNotIn("model.decoder.mlp.experts", out.exclude_layers)
self.assertNotIn(
"model.decoder.mlp.experts",
out.quant_config.get("layer_quant_config", {}),
)
def test_flag_on_skips_ptpc_when_mtp_not_in_exclude(self):
src = _quark_cfg(exclude=[])
out = self._resolve(src, flag=True)
self.assertNotIn(
"model.decoder.mlp.experts",
out.quant_config.get("layer_quant_config", {}),
)
class TestMaybeQuantGlmNextnMoeToPtpc(CustomTestCase):
def _cast(self, cfg, flag: bool):
loader = GlmMoeDsaForCausalLMNextN.__new__(GlmMoeDsaForCausalLMNextN)
loader.quant_config = cfg
loader.config = SimpleNamespace(num_hidden_layers=LAYER)
weights = [(EXPERT_WEIGHT, torch.ones(4, 8, dtype=torch.bfloat16))]
with patch(
_PTPC_ENV,
return_value=flag,
):
return list(loader._maybe_quant_glm_nextn_moe_to_ptpc(weights))
def test_flag_on_casts_excluded_bf16_experts(self):
out = self._cast(_quark_cfg(), flag=True)
names = [name for name, _ in out]
self.assertIn(EXPERT_WEIGHT, names)
self.assertIn(EXPERT_WEIGHT[: -len("weight")] + "weight_scale", names)
weight = dict(out)[EXPERT_WEIGHT]
self.assertEqual(weight.dtype, torch.float8_e4m3fn)
def test_flag_on_does_not_cast_when_experts_not_excluded(self):
src = _quark_cfg(exclude=[ATTN_LEAF])
out = self._cast(src, flag=True)
self.assertEqual(len(out), 1)
name, tensor = out[0]
self.assertEqual(name, EXPERT_WEIGHT)
self.assertEqual(tensor.dtype, torch.bfloat16)
if __name__ == "__main__":
unittest.main()