diff --git a/docs_new/docs/advanced_features/quantization.mdx b/docs_new/docs/advanced_features/quantization.mdx
index f913a0d3e..fd2f6df24 100644
--- a/docs_new/docs/advanced_features/quantization.mdx
+++ b/docs_new/docs/advanced_features/quantization.mdx
@@ -46,8 +46,8 @@ The following table summarizes quantization method support across NVIDIA and AMD
mxfp4 |
Yes |
Yes |
- WIP |
- Requires CDNA3/CDNA4 with MXFP support; uses Aiter |
+ Yes (A5) |
+ On GPU: requires CDNA3/CDNA4 with MXFP support (uses Aiter). On Ascend NPU (A5): W4A4 MXFP4 for Qwen3 dense LLM (MXFP4 weights + activations) — online uses dual-level MXFP4, offline W4A4_MXFP4 checkpoints (single-level) are auto-detected via modelslim |
mxfp8 |
diff --git a/docs_new/docs/hardware-platforms/ascend-npus/ascend_npu_quantization.mdx b/docs_new/docs/hardware-platforms/ascend-npus/ascend_npu_quantization.mdx
index f4ea12d64..cd8ab03fe 100644
--- a/docs_new/docs/hardware-platforms/ascend-npus/ascend_npu_quantization.mdx
+++ b/docs_new/docs/hardware-platforms/ascend-npus/ascend_npu_quantization.mdx
@@ -68,6 +68,14 @@ SGLang supports **mix-bits** quantization (independently defines and loads each
√ |
x |
+
+ | MXFP4 W4A4 |
+ Linear |
+ x |
+ x |
+ WIP |
+ x |
+
| W4A4 dynamic |
MoE |
@@ -377,6 +385,29 @@ python3 -m sglang.launch_server \
> - The packed-FP4 dtype passed to the NPU ops (`dst_type` / `x2_dtype` / `input_dtype`) must be resolved from `torch_npu.float4_e2m1fn_x2` (an int enum), not the `torch.float4_e2m1fn_x2` dtype object, which recent op-plugin builds reject.
> - Online and offline share the same kernel path and layout; they differ only in the weight source (RTN at load vs msmodelslim calibration).
+**MXFP4 W4A4 for LLM dense models (e.g. Qwen3 / Qwen3.5):**
+
+LLM dense W4A4 (MXFP4 4-bit weights + 4-bit activations) Linear support was added in [PR #23795](https://github.com/sgl-project/sglang/pull/23795). Requires Ascend A5 series (Ascend 950) or newer — the dual-level online path uses the `DualLevelQuantBatchMatmul` op, which A2/A3 lack. On the Ascend NPU backend `--quantization mxfp4` selects this W4A4 path (on GPU the same flag selects the upstream OCP MXFP4 MoE config instead).
+
+- Online W4A4 quantization (BF16/FP16 weights → dual-level MXFP4 at load time):
+
+```bash Command
+python3 -m sglang.launch_server \
+ --model-path Qwen/Qwen3-8B \
+ --quantization mxfp4 \
+ --device npu --attention-backend ascend \
+ --host 0.0.0.0 --port 30000 \
+ --mem-fraction-static 0.8 --tp-size 1
+```
+
+- Offline W4A4 quantization (msmodelslim pre-quantized weights, `W4A4_MXFP4` scheme; no `--quantization` flag needed — auto-detected from `quant_model_description.json`).
+
+> **Implementation Notes:**
+> - **Online** (`NPUDualLevelMXFP4LinearMethod`) uses **dual-level** MXFP4: both weights and activations are quantized with a fine FP8 (E4M3) L0 block scale plus a coarser L1 scale via `npu_dynamic_dual_level_mx_quant`, and the matmul runs via `npu_dual_level_quant_matmul` (weight in FRACTAL_NZ). Dual-level captures per-block dynamic range far better than a single UE8M0 (power-of-2) scale, which is what made an earlier single-level RTN online path degenerate (greedy decoding could loop without emitting EOS).
+> - **Offline** (`ModelSlimMXFP4Scheme` → `NPUSingleLevelMXFP4OfflineLinearMethod`) is **single-level**: msmodelslim's `W4A4_MXFP4` checkpoint ships single-level UE8M0 block scales (block_size = 32), so the matmul runs via `npu_quant_matmul(..., x1_dtype=x2_dtype=torch_npu.float4_e2m1fn_x2, group_sizes=[1, 1, 32])`. The online and offline paths therefore use different matmul kernels — they no longer share the matmul path.
+> - As with W4A8, the packed-FP4 dtype passed to the NPU ops (`dst_type` / `x2_dtype`) must be resolved from `torch_npu.float4_e2m1fn_x2` (an int enum), not the `torch.float4_e2m1fn_x2` dtype object, which recent op-plugin builds reject.
+> - Validated end-to-end on Ascend A5 hardware.
+
## Diffusion Model Quantization on Ascend NPU
SGLang-Diffusion supports MXFP8 online and offline quantization for diffusion models (such as Wan2.2) on Ascend NPUs. MXFP8 requires A5; the ModelSlim W8A8/W4A4 schemes work on A2/A3.
diff --git a/python/sglang/srt/hardware_backend/npu/quantization/linear_method_npu.py b/python/sglang/srt/hardware_backend/npu/quantization/linear_method_npu.py
index ffd25114d..166220a97 100644
--- a/python/sglang/srt/hardware_backend/npu/quantization/linear_method_npu.py
+++ b/python/sglang/srt/hardware_backend/npu/quantization/linear_method_npu.py
@@ -625,3 +625,278 @@ class NPUMXFP4W4A8OfflineLinearMethod(_NPULinearMethodBase):
# Restore original shape (replace last dim with output features).
output_shape = list(input_shape[:-1]) + [output.shape[-1]]
return output.reshape(output_shape)
+
+
+class NPUSingleLevelMXFP4LinearMethod(_NPULinearMethodBase):
+ """Ascend NPU W4A4 online quantization: single-level MXFP4.
+
+ True W4(weight) A4(activation): both weights and activations are quantised to
+ single-level MXFP4 (``float4_e2m1fn_x2``), unlike the W4A8 path which keeps FP8
+ activations. All NPU ops go through ``torch.ops.npu.*`` (no top-level
+ ``torch_npu``) and the fp4 dtype comes from ``_get_float4_e2m1fn_x2_dtype()``.
+
+ Weight quantization (process_weights_after_loading):
+ BF16/FP16 weight → npu_dynamic_mx_quant(dst=float4_e2m1fn_x2)
+ → (packed FP4 [out, in//2], UE8M0 block scale) → transpose [in//2, out]
+
+ Inference (apply):
+ BF16/FP16 activation → npu_dynamic_mx_quant(dst=float4_e2m1fn_x2) (A4)
+ → npu_quant_matmul(x1_dtype = x2_dtype = float4_e2m1fn_x2,
+ group_sizes=[1, 1, MXFP4_BLOCK_SIZE])
+
+ Triggered by ``--quantization mxfp4`` on Ascend NPU. Hardware: Ascend 950 (A5)
+ with a recent torch_npu exposing ``float4_e2m1fn_x2``.
+ """
+
+ def create_weights(
+ self,
+ layer: torch.nn.Module,
+ input_size_per_partition: int,
+ output_partition_sizes,
+ input_size: int,
+ output_size: int,
+ params_dtype: torch.dtype,
+ **extra_weight_attrs,
+ ):
+ """Register an unquantized (``params_dtype``) weight placeholder.
+
+ The checkpoint still holds full-precision BF16/FP16 weights: the loader
+ fills this buffer, then ``process_weights_after_loading`` quantizes it to
+ MXFP4 in place.
+ """
+ from sglang.srt.layers.parameter import ModelWeightParameter
+
+ output_size_per_partition = sum(output_partition_sizes)
+ weight_loader = extra_weight_attrs.get("weight_loader")
+
+ layer.logical_widths = output_partition_sizes
+ layer.input_size_per_partition = input_size_per_partition
+ layer.output_size_per_partition = output_size_per_partition
+ layer.orig_dtype = params_dtype
+
+ # Load weights in original dtype; quantise to MXFP4 in
+ # process_weights_after_loading.
+ weight = ModelWeightParameter(
+ data=torch.empty(
+ output_size_per_partition,
+ input_size_per_partition,
+ dtype=params_dtype,
+ ),
+ input_dim=1,
+ output_dim=0,
+ weight_loader=weight_loader,
+ )
+ layer.register_parameter("weight", weight)
+
+ def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
+ # Online single-level MXFP4 weight quant. All NPU ops go through
+ # torch.ops.npu.* (no torch_npu); the fp4 dtype comes from the shared
+ # _get_float4_e2m1fn_x2_dtype() helper (the torch_npu int enum).
+ fp4_dtype = _get_float4_e2m1fn_x2_dtype()
+
+ weight_fp = layer.weight.data
+ if weight_fp.dtype not in (torch.float16, torch.bfloat16):
+ weight_fp = weight_fp.to(torch.bfloat16)
+ # Move to NPU if needed (cpu offload may have put it on CPU).
+ if not weight_fp.is_npu:
+ weight_fp = weight_fp.to(f"npu:{torch.npu.current_device()}")
+
+ # BF16 -> packed FP4 (float4_e2m1fn_x2, [out, in//2]) + UE8M0 block scale.
+ qw, w_scale = torch.ops.npu.npu_dynamic_mx_quant(
+ weight_fp, dst_type=fp4_dtype, round_mode="round"
+ )
+ # Pre-transpose the weight to [in//2, out] for npu_quant_matmul; use
+ # .data= to preserve the non-contiguous transpose view (npu_quant_matmul
+ # reads strides directly — .contiguous() would reorder data and break
+ # block-scale alignment).
+ layer.weight = Parameter(qw, requires_grad=False)
+ layer.weight.data = layer.weight.data.transpose(0, 1)
+
+ # weight_scale -> [in//64, out, 2] (3D), matching the offline W4A4 path,
+ # the W4A8 path and vllm-ascend's W4A4_MXFP4 layout. npu_dynamic_mx_quant
+ # already returns the scale as [out, in//64, 2] (3D) on current builds;
+ # older builds may return [out, in//32] (2D) — reshape those first so the
+ # transpose always yields the 3D layout npu_quant_matmul requires.
+ if w_scale.dim() == 2:
+ n, k = w_scale.shape
+ w_scale = w_scale.reshape(n, k // 2, 2)
+ layer.weight_scale = Parameter(w_scale.transpose(-3, -2), requires_grad=False)
+
+ def apply(
+ self,
+ layer: torch.nn.Module,
+ x: torch.Tensor,
+ bias: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ e8m0_dtype = _get_float8_e8m0fnu_dtype()
+ fp4_dtype = _get_float4_e2m1fn_x2_dtype()
+
+ original_dtype = x.dtype
+ if original_dtype not in (torch.float16, torch.bfloat16):
+ x = x.to(torch.bfloat16)
+ original_dtype = torch.bfloat16
+
+ # Flatten to 2D [tokens, hidden] for npu_dynamic_mx_quant.
+ input_shape = x.shape
+ x_2d = x.reshape(-1, x.shape[-1])
+
+ # Dynamic single-level MXFP4 activation quantisation (A4 — FP4).
+ qx, input_scale = torch.ops.npu.npu_dynamic_mx_quant(
+ x_2d, dst_type=fp4_dtype, round_mode="round"
+ )
+
+ # Single-level MXFP4 matmul (weight & scale already transposed at load
+ # time): x1_dtype = x2_dtype = fp4, group_sizes=[1, 1, block].
+ output = torch.ops.npu.npu_quant_matmul(
+ qx,
+ layer.weight,
+ layer.weight_scale,
+ scale_dtype=e8m0_dtype,
+ pertoken_scale=input_scale,
+ pertoken_scale_dtype=e8m0_dtype,
+ bias=bias.to(torch.float32) if bias is not None else None,
+ output_dtype=original_dtype,
+ x1_dtype=fp4_dtype,
+ x2_dtype=fp4_dtype,
+ group_sizes=[1, 1, MXFP4_BLOCK_SIZE],
+ )
+
+ # Restore original shape (replace last dim with output features).
+ output_shape = list(input_shape[:-1]) + [output.shape[-1]]
+ return output.reshape(output_shape)
+
+
+class NPUSingleLevelMXFP4OfflineLinearMethod(NPUSingleLevelMXFP4LinearMethod):
+ """Ascend NPU offline W4A4 (ModelSlim ``W4A4_MXFP4``): fp8-container FP4 weights.
+
+ Kernel for the offline ``ModelSlimMXFP4Scheme`` (delegated as ``self.kernel``).
+ The msmodelslim ``W4A4_MXFP4`` checkpoint stores weights as **fp4-in-fp8
+ container** (``float8_e4m3fn`` [out, in], one FP4 value per byte) plus UE8M0
+ block scales (``uint8`` [out, in//32]). The weight is re-packed to
+ ``float4_e2m1fn_x2`` (two FP4 per byte) and the scale reshaped to 3D; it then
+ shares the online :class:`NPUSingleLevelMXFP4LinearMethod` matmul (``apply``)
+ exactly — only the weight source differs (msmodelslim checkpoint vs online RTN).
+ Mirrors vllm-ascend's single-level W4A4 MXFP4 layout.
+ """
+
+ def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
+ # Re-pack fp8-container FP4 to float4_e2m1fn_x2 and pre-transpose to match
+ # the online path's layout. All NPU ops go through torch.ops.npu.* (no
+ # torch_npu); the fp4 dtype must be the torch_npu enum (helper).
+ fp4_dtype = _get_float4_e2m1fn_x2_dtype()
+
+ weight = layer.weight.data
+ if not weight.is_npu:
+ weight = weight.to(f"npu:{torch.npu.current_device()}")
+ # fp8 container -> float4_e2m1fn_x2 (2 FP4 per byte): [out, in] -> [out, in//2].
+ weight_fp4 = torch.ops.npu.npu_dtype_cast(weight, fp4_dtype)
+ # Transpose to [in//2, out]; no .contiguous() (preserve the strided view so
+ # the block-scale mapping stays intact).
+ layer.weight = Parameter(weight_fp4.transpose(0, 1), requires_grad=False)
+
+ weight_scale = layer.weight_scale.data
+ if not weight_scale.is_npu:
+ weight_scale = weight_scale.to(f"npu:{torch.npu.current_device()}")
+ # npu_quant_matmul with float4_e2m1fn_x2 requires x2Scale to be 3D:
+ # [out, in/32] -> [out, in/64, 2] -> transpose to [in/64, out, 2].
+ n_dim, k_dim = weight_scale.shape
+ layer.weight_scale = Parameter(
+ weight_scale.reshape(n_dim, k_dim // 2, 2).transpose(0, 1),
+ requires_grad=False,
+ )
+
+
+class NPUDualLevelMXFP4LinearMethod(NPUSingleLevelMXFP4LinearMethod):
+ """Ascend NPU W4A4 online quantization: dual-level MXFP4 (higher accuracy).
+
+ This is the sole online ``--quantization mxfp4`` linear path. Instead of a single
+ UE8M0 (power-of-2) block scale, dual-level MX quant produces a finer L0 (FP8 E4M3)
+ block scale plus a coarser L1 scale, so per-block dynamic range is captured far
+ more accurately — this fixed the online-RTN degradation that made single-level
+ decoding loop (never emitting EOS) under greedy sampling. (The single-level
+ :class:`NPUSingleLevelMXFP4LinearMethod` is retained only as the offline path's
+ base — msmodelslim checkpoints ship single-level UE8M0 scales.)
+
+ All NPU ops go through ``torch.ops.npu.*`` (no top-level ``torch_npu``). Only
+ ``create_weights`` (the BF16/FP16 placeholder) is shared with the single-level
+ base; weight post-processing and the matmul are fully dual-level.
+
+ Weight quantization (process_weights_after_loading):
+ BF16/FP16 weight → npu_dynamic_dual_level_mx_quant
+ → (packed FP4 weight, L0 scale, L1 scale); weight cast to FRACTAL_NZ,
+ L0 scale transposed to [in//l0_block, out].
+
+ Inference (apply):
+ BF16/FP16 activation → npu_dynamic_dual_level_mx_quant (A4, dual-level)
+ → npu_dual_level_quant_matmul(act, weight, act_l0, w_l0, act_l1, w_l1)
+
+ Reference: Diffusion ``NPUMXFP4DiffusionLinearMethod`` / MindIE-SD
+ ``W4A4MXFP4DualQuantLinear``. Hardware: Ascend 950 (A5) only — the
+ ``DualLevelQuantBatchMatmul`` op is unavailable on A2/A3.
+ """
+
+ def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
+ weight_fp = layer.weight.data
+ if weight_fp.dtype not in (torch.float16, torch.bfloat16):
+ weight_fp = weight_fp.to(torch.bfloat16)
+ # Move to NPU if needed (cpu offload may have put it on CPU).
+ if not weight_fp.is_npu:
+ weight_fp = weight_fp.to(f"npu:{torch.npu.current_device()}")
+
+ # Dual-level MXFP4 weight quant: packed FP4 weight + L0 (fine, FP8 E4M3)
+ # and L1 (coarse) block scales.
+ qw, w_l0_scale, w_l1_scale = torch.ops.npu.npu_dynamic_dual_level_mx_quant(
+ weight_fp, smooth_scale=None
+ )
+
+ # npu_dual_level_quant_matmul requires the weight (x2) in FRACTAL_NZ.
+ # View the packed FP4 as int8 first (npu_format_cast takes int dtypes).
+ qw_nz = npu_format_cast(
+ qw.view(torch.int8),
+ NPUACLFormat.ACL_FORMAT_FRACTAL_NZ,
+ customize_dtype=torch.int8,
+ )
+
+ # L0 scale -> [in//l0_block, out] (op returns [out, in//l0_block, 1]).
+ w_l0_scale = w_l0_scale.squeeze(-1).transpose(0, 1).contiguous()
+
+ layer.weight = Parameter(qw_nz, requires_grad=False)
+ layer.weight_l0_scale = Parameter(w_l0_scale, requires_grad=False)
+ layer.weight_l1_scale = Parameter(w_l1_scale, requires_grad=False)
+
+ def apply(
+ self,
+ layer: torch.nn.Module,
+ x: torch.Tensor,
+ bias: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ original_dtype = x.dtype
+ if original_dtype not in (torch.float16, torch.bfloat16):
+ x = x.to(torch.bfloat16)
+ original_dtype = torch.bfloat16
+
+ # Flatten to 2D [tokens, hidden] for the quant operators.
+ input_shape = x.shape
+ x_2d = x.reshape(-1, x.shape[-1])
+
+ # Dynamic dual-level MXFP4 activation quant (A4): packed FP4 + L0/L1 scales.
+ qx, act_l0_scale, act_l1_scale = torch.ops.npu.npu_dynamic_dual_level_mx_quant(
+ x_2d, smooth_scale=None
+ )
+
+ # Dual-level matmul. Arg order (act, weight, act_l0, w_l0, act_l1, w_l1);
+ # the weight is NOT transposed here (unlike the single-level path).
+ output = torch.ops.npu.npu_dual_level_quant_matmul(
+ qx,
+ layer.weight,
+ act_l0_scale,
+ layer.weight_l0_scale,
+ act_l1_scale,
+ layer.weight_l1_scale,
+ bias=bias.to(torch.float32) if bias is not None else None,
+ output_dtype=original_dtype,
+ )
+
+ # Restore original shape (replace last dim with output features).
+ output_shape = list(input_shape[:-1]) + [output.shape[-1]]
+ return output.reshape(output_shape)
diff --git a/python/sglang/srt/layers/quantization/__init__.py b/python/sglang/srt/layers/quantization/__init__.py
index 5d66ff35f..26a90ca34 100644
--- a/python/sglang/srt/layers/quantization/__init__.py
+++ b/python/sglang/srt/layers/quantization/__init__.py
@@ -46,6 +46,7 @@ from sglang.srt.layers.quantization.modelslim.modelslim import ModelSlimConfig
from sglang.srt.layers.quantization.moe_wna16 import MoeWNA16Config
from sglang.srt.layers.quantization.mxfp4 import Mxfp4Config
from sglang.srt.layers.quantization.npu_mxfp4 import Mxfp4W4A8Config
+from sglang.srt.layers.quantization.npu_mxfp4_w4a4 import Mxfp4W4A4Config
from sglang.srt.layers.quantization.nvfp4_online import NvFp4OnlineConfig
from sglang.srt.layers.quantization.petit import PetitNvFp4Config
from sglang.srt.layers.quantization.qoq import QoQConfig
@@ -117,6 +118,10 @@ if is_npu():
BASE_QUANTIZATION_METHODS.update(
{
"gptq": GPTQAscendConfig,
+ # On NPU, `mxfp4` means single-level W4A4 MXFP4 for dense LLM (the
+ # upstream `Mxfp4Config` OCP-MoE path is only registered on
+ # cpu/cuda/hip above, so there is no collision here).
+ "mxfp4": Mxfp4W4A4Config,
}
)
diff --git a/python/sglang/srt/layers/quantization/modelslim/modelslim.py b/python/sglang/srt/layers/quantization/modelslim/modelslim.py
index 7af3f9171..e51d58c31 100644
--- a/python/sglang/srt/layers/quantization/modelslim/modelslim.py
+++ b/python/sglang/srt/layers/quantization/modelslim/modelslim.py
@@ -16,6 +16,7 @@ from sglang.srt.layers.quantization.base_config import (
QuantizationConfig,
)
from sglang.srt.layers.quantization.modelslim.schemes import (
+ ModelSlimMXFP4Scheme,
ModelSlimMXFP4W4A8Scheme,
ModelSlimMXFP8Scheme,
ModelSlimW4A4Int4,
@@ -208,6 +209,7 @@ class ModelSlimConfig(QuantizationConfig):
("W8A8_DYNAMIC", ModelSlimW8A8Int8),
("W8A8_MXFP8", ModelSlimMXFP8Scheme),
("W4A8_MXFP", ModelSlimMXFP4W4A8Scheme),
+ ("W4A4_MXFP4", ModelSlimMXFP4Scheme),
]
quant_schemes = [self.quant_description.get(prefix + ".weight", "")]
diff --git a/python/sglang/srt/layers/quantization/modelslim/schemes/__init__.py b/python/sglang/srt/layers/quantization/modelslim/schemes/__init__.py
index 1b0233967..5d78a2d43 100644
--- a/python/sglang/srt/layers/quantization/modelslim/schemes/__init__.py
+++ b/python/sglang/srt/layers/quantization/modelslim/schemes/__init__.py
@@ -7,6 +7,7 @@
from .modelslim_scheme import ModelSlimLinearScheme, ModelSlimMoEScheme
from .modelslim_mxfp8 import ModelSlimMXFP8Scheme
from .modelslim_mxfp4_w4a8 import ModelSlimMXFP4W4A8Scheme
+from .modelslim_mxfp4 import ModelSlimMXFP4Scheme
# isort: on
from .modelslim_w4a4_int4 import ModelSlimW4A4Int4
@@ -20,6 +21,7 @@ __all__ = [
"ModelSlimMoEScheme",
"ModelSlimMXFP8Scheme",
"ModelSlimMXFP4W4A8Scheme",
+ "ModelSlimMXFP4Scheme",
"ModelSlimW8A8Int8",
"ModelSlimW4A4Int4",
"ModelSlimW4A4Int4MoE",
diff --git a/python/sglang/srt/layers/quantization/modelslim/schemes/modelslim_mxfp4.py b/python/sglang/srt/layers/quantization/modelslim/schemes/modelslim_mxfp4.py
new file mode 100644
index 000000000..1dcb4d5e5
--- /dev/null
+++ b/python/sglang/srt/layers/quantization/modelslim/schemes/modelslim_mxfp4.py
@@ -0,0 +1,96 @@
+"""ModelSlim W4A4_MXFP4 scheme for pre-quantized weight inference on Ascend NPU (SRT).
+
+The msmodelslim ``W4A4_MXFP4`` checkpoint stores weights in an **fp8 container**:
+
+ weight: float8_e4m3fn shape [out, in] (one FP4 value per byte)
+ weight_scale: uint8 (UE8M0) shape [out, in//32] (block scales, group_size=32)
+
+This is a true W4(weight) A4(activation) scheme: both weights and activations are
+single-level MXFP4. Weight post-processing and the matmul are delegated to
+``NPUSingleLevelMXFP4OfflineLinearMethod`` (``self.kernel``): the fp8-container FP4
+is re-packed to ``float4_e2m1fn_x2`` (two FP4 per byte) + transposed and the scale
+reshaped to 3D, then ``npu_quant_matmul`` runs with ``x1_dtype = x2_dtype =
+float4_e2m1fn_x2`` and ``group_sizes=[1, 1, 32]`` — sharing the online
+``NPUSingleLevelMXFP4LinearMethod`` matmul exactly (only the weight source differs).
+
+This differs from ``W4A8_MXFP`` (packed-uint8 FP4 weights + FP8 activations) and
+from ``W8A8_MXFP8`` (float8_e4m3fn weights of shape [out, in]).
+"""
+
+from typing import Dict, List, Optional
+
+import torch
+
+from sglang.srt.hardware_backend.npu.quantization.linear_method_npu import (
+ NPUSingleLevelMXFP4OfflineLinearMethod,
+)
+from sglang.srt.layers.parameter import GroupQuantScaleParameter, ModelWeightParameter
+from sglang.srt.layers.quantization.modelslim.schemes import ModelSlimLinearScheme
+
+# Fixed by the msmodelslim W4A4_MXFP4 export format (group_size=32).
+MXFP4_BLOCK_SIZE = 32
+
+
+class ModelSlimMXFP4Scheme(ModelSlimLinearScheme):
+ """W4A4_MXFP4 offline scheme — fp8-container FP4 weights, MXFP4 activations."""
+
+ def __init__(
+ self,
+ quant_config: Optional[Dict[str, any]] = None,
+ prefix: Optional[str] = None,
+ ):
+ # quant_config / prefix accepted to match ModelSlimConfig.get_linear_scheme's
+ # dispatch signature; W4A4_MXFP4 needs no per-layer config beyond create_weights.
+ del quant_config, prefix
+ self.kernel = NPUSingleLevelMXFP4OfflineLinearMethod()
+
+ def create_weights(
+ self,
+ layer: torch.nn.Module,
+ input_size_per_partition: int,
+ output_partition_sizes: List[int],
+ input_size: int,
+ output_size: int,
+ params_dtype: torch.dtype,
+ **extra_weight_attrs,
+ ):
+ weight_loader = extra_weight_attrs.get("weight_loader")
+ output_size_per_partition = sum(output_partition_sizes)
+
+ # msmodelslim exports weight as float8_e4m3fn, shape [out, in] — one FP4
+ # value per byte (fp8 container). The kernel re-packs it to
+ # float4_e2m1fn_x2 (2 FP4 per byte) in process_weights_after_loading.
+ weight = ModelWeightParameter(
+ data=torch.empty(
+ (output_size_per_partition, input_size_per_partition),
+ dtype=torch.float8_e4m3fn,
+ ),
+ input_dim=1,
+ output_dim=0,
+ weight_loader=weight_loader,
+ )
+ layer.register_parameter("weight", weight)
+
+ # UE8M0 block scales: uint8, shape [out, in//32].
+ scale_dim = input_size_per_partition // MXFP4_BLOCK_SIZE
+ weight_scale = GroupQuantScaleParameter(
+ data=torch.empty(
+ (output_size_per_partition, scale_dim),
+ dtype=torch.uint8,
+ ),
+ input_dim=1,
+ output_dim=0,
+ weight_loader=weight_loader,
+ )
+ layer.register_parameter("weight_scale", weight_scale)
+
+ def process_weights_after_loading(self, layer: torch.nn.Module):
+ self.kernel.process_weights_after_loading(layer)
+
+ def apply_weights(
+ self,
+ layer: torch.nn.Module,
+ x: torch.Tensor,
+ bias: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ return self.kernel.apply(layer, x, bias)
diff --git a/python/sglang/srt/layers/quantization/npu_mxfp4_w4a4.py b/python/sglang/srt/layers/quantization/npu_mxfp4_w4a4.py
new file mode 100644
index 000000000..ec8f4160b
--- /dev/null
+++ b/python/sglang/srt/layers/quantization/npu_mxfp4_w4a4.py
@@ -0,0 +1,140 @@
+"""MXFP4 W4A4 online quantization config (dual-level MXFP4 weights + activations).
+
+Triggered by ``--quantization mxfp4`` on the Ascend NPU backend. On CUDA / AMD /
+CPU the ``mxfp4`` key resolves to the upstream :class:`Mxfp4Config` (OCP MXFP4
+MoE) instead; the per-device split is done at registration time in
+``sglang.srt.layers.quantization.__init__`` (this config is only registered
+inside the ``is_npu()`` block, mirroring ``GPTQAscendConfig``).
+
+Online mode: FP16/BF16 weights are quantised to **dual-level** MXFP4 in
+``process_weights_after_loading`` (a finer FP8 E4M3 L0 block scale plus a coarser
+L1 scale); activations are dynamically quantised the same way and the matmul runs
+via ``npu_dual_level_quant_matmul`` (see :class:`NPUDualLevelMXFP4LinearMethod`).
+Dual-level is the sole online path — it captures per-block dynamic range far more
+accurately than a single-level UE8M0 scale, avoiding the RTN degradation that made
+single-level online decoding loop under greedy sampling. Requires Ascend 950 (A5).
+
+Offline (msmodelslim ``W4A4_MXFP4``) checkpoints are single-level (the checkpoint
+stores UE8M0 scales) and are handled separately by the ``modelslim`` config
+(``ModelSlimMXFP4Scheme`` → ``NPUSingleLevelMXFP4OfflineLinearMethod``), not this
+class.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Dict, List, Optional
+
+import torch
+
+from sglang.srt.layers.quantization.base_config import (
+ QuantizationConfig,
+ QuantizeMethodBase,
+)
+from sglang.srt.layers.quantization.unquant import (
+ UnquantizedFusedMoEMethod,
+ UnquantizedLinearMethod,
+)
+from sglang.srt.layers.quantization.utils import is_layer_skipped
+from sglang.srt.utils import is_npu
+
+logger = logging.getLogger(__name__)
+
+
+class Mxfp4W4A4Config(QuantizationConfig):
+ """Single-level MXFP4 W4A4 online quantization config for Ascend NPU.
+
+ True W4(weight) A4(activation): both weights and activations are quantised
+ to single-level MXFP4 (``float4_e2m1fn_x2``). The device-specific linear
+ method is selected in ``get_quant_method``; only Ascend NPU is wired up
+ today (on other devices ``mxfp4`` maps to the upstream ``Mxfp4Config``).
+ """
+
+ def __init__(
+ self,
+ ignored_layers: Optional[List[str]] = None,
+ packed_modules_mapping: Optional[Dict[str, str]] = None,
+ ):
+ super().__init__()
+ self.ignored_layers = ignored_layers or []
+ self.packed_modules_mapping = packed_modules_mapping or {}
+
+ @classmethod
+ def get_name(cls) -> str:
+ return "mxfp4"
+
+ @classmethod
+ def get_supported_act_dtypes(cls) -> List[torch.dtype]:
+ return [torch.bfloat16, torch.half]
+
+ @classmethod
+ def get_min_capability(cls) -> int:
+ return 0 # NPU bypasses CUDA capability checks
+
+ @classmethod
+ def get_config_filenames(cls) -> List[str]:
+ return []
+
+ @classmethod
+ def from_config(cls, config: Dict) -> Mxfp4W4A4Config:
+ ignored_layers = cls.get_from_keys_or(
+ config, ["ignored_layers", "modules_to_not_convert"], None
+ )
+ if ignored_layers:
+ normalized: List[str] = []
+ for layer in ignored_layers:
+ base = layer.removeprefix("model.")
+ normalized.append(base)
+ normalized.append(f"model.{base}")
+ ignored_layers = normalized
+ packed_modules_mapping = (
+ cls.get_from_keys_or(config, ["packed_modules_mapping"], {}) or {}
+ )
+ return cls(
+ ignored_layers=ignored_layers,
+ packed_modules_mapping=packed_modules_mapping,
+ )
+
+ def get_quant_method(
+ self, layer: torch.nn.Module, prefix: str
+ ) -> Optional[QuantizeMethodBase]:
+ from sglang.srt.layers.linear import LinearBase
+ from sglang.srt.layers.moe.fused_moe_triton import FusedMoE
+
+ if isinstance(layer, LinearBase):
+ if is_layer_skipped(
+ prefix,
+ self.ignored_layers,
+ fused_mapping=self.packed_modules_mapping,
+ ):
+ return UnquantizedLinearMethod()
+ if is_npu():
+ from sglang.srt.hardware_backend.npu.quantization.linear_method_npu import (
+ NPUDualLevelMXFP4LinearMethod,
+ )
+
+ # Online W4A4 always uses dual-level MXFP4 (finer FP8 L0 scales):
+ # single-level RTN was too lossy and degenerated under greedy
+ # decoding. Requires Ascend 950 (A5). The single-level kernel is
+ # retained only for the offline msmodelslim path.
+ return NPUDualLevelMXFP4LinearMethod(self)
+ raise NotImplementedError(
+ "mxfp4 W4A4 (single-level MXFP4 weights + activations) is currently "
+ "only implemented for the Ascend NPU backend; no CUDA/other-device "
+ "kernel exists in this config. Add a device branch here when one lands."
+ )
+ elif isinstance(layer, FusedMoE):
+ # MoE single-level MXFP4 W4A4 not yet implemented; fall back to unquantised
+ logger.warning(
+ "MXFP4 W4A4 quantization is not yet supported for FusedMoE layers "
+ "(prefix=%s). Falling back to unquantized MoE — MoE weights will "
+ "run in full precision (BF16/FP16).",
+ prefix,
+ )
+ return UnquantizedFusedMoEMethod(
+ layer.use_triton_kernels, layer.use_flashinfer_trtllm_moe
+ )
+ return None
+
+ def get_scaled_act_names(self) -> List[str]:
+ return []