diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md
index 77709bc436e66..8a255c4c897c4 100644
--- a/docs/ContribOperators.md
+++ b/docs/ContribOperators.md
@@ -4935,7 +4935,7 @@ This version of the operator has been available since version 1 of the 'com.micr
activation_type : string
Activation function to use. Choose from relu, gelu, silu, swiglu and identity. Default is relu
block_size : int
-Size of each quantization block along the K (input feature) dimension. Must be power of two and ≥ 16 (e.g., 16, 32, 64, 128). If provided, both hidden_size and inter_size must be divisible by the block size. Otherwise, there is no blocking and a whole column shares one scaling factor.
+Size of each quantization block along the K (input feature) dimension. Must be power of two and ≥ 16 (e.g., 16, 32, 64, 128). Both hidden_size and inter_size must be divisible by the block size. The FP4 modes always use blocking: MXFP4 ('fp4'/'wfp4afp8') is normalized to block_size 32 and NVFP4 ('nvfp4') to block_size 16, even when block_size is omitted. For integer quantization ('int'), omitting block_size means there is no blocking and a whole column shares one scaling factor.
expert_weight_bits : int
Number of bits used in quantized weights. Supported values are 2, 4, and 8. Default is 4 bits
k : int
diff --git a/docs/contrib_ops/cuda/moe_qmoe.md b/docs/contrib_ops/cuda/moe_qmoe.md
index dd451cbe44c03..44cd7928c4b26 100644
--- a/docs/contrib_ops/cuda/moe_qmoe.md
+++ b/docs/contrib_ops/cuda/moe_qmoe.md
@@ -21,6 +21,11 @@ and have been significantly modified for ONNX Runtime — see
7. [Cross-Architecture Packing Compatibility](#7-cross-architecture-packing-compatibility)
8. [SwiGLU Fusion](#8-swiglu-fusion)
9. [FP4 (MXFP4) Details](#9-fp4-mxfp4-details)
+ - [9.9 Runtime environment variables](#99-runtime-environment-variables)
+ - [9.10 Interleaved GEMV layout + dtype-conditional accumulation](#910-interleaved-gemv-layout--dtype-conditional-accumulation)
+9b. [NVFP4 (W4A16, block-16) Details](#9b-nvfp4-w4a16-block-16-details)
+ - [9b.2 Fused GEMV decode (group-16)](#9b2-fused-gemv-decode-group-16)
+ - [9b.3 Fast E2M1 → half/bf16 decode](#9b3-fast-e2m1--halfbf16-decode)
10. [FP8 (W8A16) Details](#10-fp8-w8a16-details)
11. [WFP4AFP8 Details](#11-wfp4afp8-details)
12. [Future / Deferred Modes](#12-future--deferred-modes)
@@ -70,8 +75,8 @@ input tokens → router (top-k softmax) → permute by expert
| `activation_beta` | float | `0.0` | SwiGLU beta. Default `0.0` (Standard SwiGLU); GPT-OSS uses `1.0`. |
| `swiglu_limit` | float | unset (`+inf`) | SwiGLU clamp limit. Unset means no clamp (Standard SwiGLU); GPT-OSS uses `7.0`. |
| `expert_weight_bits` (QMoE only) | int | 4 | 4 (INT4/MXFP4) or 8 (INT8/FP8). |
-| `block_size` (QMoE only) | int | -1 | Group size for INT4/INT8 group-wise quantization. -1 = per-output-channel. |
-| `quant_type` (QMoE only) | string | `"int"` | `"int"`, `"fp4"`, `"fp8"`, `"wfp4afp8"`. See [§3](#3-quantization-modes). |
+| `block_size` (QMoE only) | int | -1 | Group size for INT4/INT8 group-wise quantization. -1 = per-output-channel. FP4/WFP4AFP8 normalize an omitted value to 32 and require 32 when present; NVFP4 normalizes an omitted value to 16 and requires 16 when present. |
+| `quant_type` (QMoE only) | string | `"int"` | `"int"`, `"fp4"`, `"nvfp4"`, `"fp8"`, `"wfp4afp8"`. See [§3](#3-quantization-modes). |
| `weights_prepacked` (QMoE only) | int | -1 | Tri-state, only meaningful when `quant_type="int"`. The prepacked layouts selected by `-1` and `1` are **EP-determined**. `-1` (default): the INT4/INT8 `fc1`/`fc2` initializers are already prepacked in the EP's default layout (e.g. from `pack_weights_for_cuda_mixed_gemm` for the CUDA EP). `1`: already prepacked in an alternate EP-selected layout. `0`: the initializers are raw `[E, N, K/pack]` tensors (as produced by `quantize_matmul_{4,8}bits`) and the kernel runs the CUTLASS layout transform in `PrePack()`. **Note:** the CUDA EP INT4/INT8 MoE GEMM always runs the Ampere (SM80) kernel — even on SM90 — so it consumes the SM80 `fpA_intB` layout on all architectures; `-1` and `1` are therefore equivalent for the CUDA EP today, and `1` is reserved for a possible future Hopper-specific layout. See [§5.1](#51-weights-input-2--5--8). |
### 2.2 Type Constraints
@@ -79,8 +84,8 @@ input tokens → router (top-k softmax) → permute by expert
| Constraint | Allowed Types | Used By |
|------------|---------------|---------|
| `T` | `float`, `float16`, `bfloat16` | input, output, biases, router |
-| `T1` | `uint8`, `float8e4m3fn` | quantized weights and zero points: INT4/INT8/FP4 weights use `uint8`; FP8 weights use `float8e4m3fn` |
-| `T2` | `float`, `float16`, `bfloat16`, `uint8` | INT4/INT8 weight scales use floating-point tensors; MXFP block scales use `uint8` storage |
+| `T1` | `uint8`, `float8e4m3fn` | quantized weights and zero points: INT4/INT8/FP4/NVFP4 weights use `uint8`; FP8 weights use `float8e4m3fn` |
+| `T2` | `float`, `float16`, `bfloat16`, `uint8`, `float8e4m3fn` | INT4/INT8 weight scales use floating-point tensors; MXFP block scales use `uint8` (`float8e8m0`) storage; **NVFP4 block scales use `float8e4m3fn`** |
| `T4` | `float` | per-expert global scales, FP8 activation scales |
### 2.3 Inputs
@@ -93,10 +98,10 @@ to the selected `quant_type` are simply omitted (most are `Optional`).
| 0 | `input` | T | `(num_tokens, hidden_size)` | all |
| 1 | `router_probs` | T | `(num_tokens, num_experts)` | all |
| 2 | `fc1_experts_weights` | T1 | `(E, fusion×inter, hidden/pack)` | all |
-| 3 | `fc1_scales` | T2 (Opt) | varies — see [§2.4](#24-input-369-interpretation-by-quant_type) | int, fp4, wfp4afp8 |
+| 3 | `fc1_scales` | T2 (Opt) | varies — see [§2.4](#24-input-369-interpretation-by-quant_type) | int, fp4, nvfp4, wfp4afp8 |
| 4 | `fc1_experts_bias` | T (Opt) | `(E, fusion×inter)` | optional |
| 5 | `fc2_experts_weights` | T1 | `(E, hidden, inter/pack)` | all |
-| 6 | `fc2_scales` | T2 (Opt) | varies | int, fp4, wfp4afp8 |
+| 6 | `fc2_scales` | T2 (Opt) | varies | int, fp4, nvfp4, wfp4afp8 |
| 7 | `fc2_experts_bias` | T (Opt) | `(E, hidden)` | optional |
| 8 | `fc3_experts_weights` | T1 (Opt) | `(E, inter, hidden/pack)` | optional (SwiGLU split-weight) |
| 9 | `fc3_scales` | T2 (Opt) | varies | optional |
@@ -105,8 +110,8 @@ to the selected `quant_type` are simply omitted (most are `Optional`).
| 12 | `fc2_zero_points` | T1 (Opt) | matches `fc2_scales` | int only |
| 13 | `fc3_zero_points` | T1 (Opt) | matches `fc3_scales` | optional, int only |
| 14 | `router_weights` | T (Opt) | `(num_tokens, num_experts)` | optional (DeepSeek noaux_tc) |
-| 15 | `fc1_global_scale` | T4 (Opt) | `(num_experts,)` | fp4, fp8, wfp4afp8 |
-| 16 | `fc2_global_scale` | T4 (Opt) | `(num_experts,)` | fp4, fp8, wfp4afp8 |
+| 15 | `fc1_global_scale` | T4 (Opt) | `(num_experts,)` | fp4, nvfp4, fp8, wfp4afp8 |
+| 16 | `fc2_global_scale` | T4 (Opt) | `(num_experts,)` | fp4, nvfp4, fp8, wfp4afp8 |
| 17 | `fc1_act_scale` | T4 (Opt) | `(1,)` or `(num_experts,)` | wfp4afp8 (Variant A) |
| 18 | `fc2_act_scale` | T4 (Opt) | `(1,)` or `(num_experts,)` | wfp4afp8 (Variant A) |
| 19 | `fc1_act_block_scale` | T2 (Opt, float8e8m0) | `(E, M_pad, K/32)` | wfp4afp8 (Variant B) |
@@ -127,11 +132,12 @@ is used for both (backward compatible).
| `"int"` (group-wise) | float / fp16 / bf16 | `(E, N, K/block_size)` | `w_float = w_int × scale (+ zero)` |
| `"int"` (per-channel) | float / fp16 / bf16 | `(E, N)` | per-output-channel scale |
| `"fp4"` | uint8 (`float_ue8m0_t`) | `(E, N, K/32)` | MXFP4 block scale, group=32 |
+| `"nvfp4"` | uint8 (`float8e4m3fn` bytes) | `(E, N, K/16)` | NVFP4 block scale, group=16 (needs `fc*_global_scale`) |
| `"fp8"` | — | — | not used; only the per-expert global scale (input 15/16/17) is needed |
| `"wfp4afp8"` | uint8 (`float_ue8m0_t`) | `(E, N, K/32)` | MXFP4 block scale, group=32 |
Inputs 11/12/13 (`fc*_zero_points`) are valid only for `"int"`. FP8 e4m3 and
-FP4 e2m1 are symmetric formats with no zero-point.
+FP4 e2m1 (both MXFP4 and NVFP4) are symmetric formats with no zero-point.
---
@@ -143,19 +149,21 @@ FP4 e2m1 are symmetric formats with no zero-point.
| `"int"` (8-bit) | W8A16 | FP16/BF16 | INT8 group-wise | SM75+ | — | always |
| `"fp8"` | W8A16-fp8 | BF16/FP16 | FP8 e4m3 (no packing) | **SM90+** native | dequant→A16 on SM<90 | `ENABLE_FP8` (CUDA ≥ 11.8) |
| `"fp4"` | W4A16-MXFP4 | BF16/FP16 | MXFP4 e2m1, group=32 | **SM120+** native | dequant→A16 on SM<120 | `ENABLE_FP4` + `USE_FP4_QMOE` (CUDA ≥ 12.8) |
+| `"nvfp4"` | W4A16-NVFP4 | BF16/FP16 | NVFP4 e2m1, group=16, `float8e4m3fn` block scale + per-expert FP32 global scale | dequant→A16 (all SM) + **fused GEMV decode** | `ENABLE_FP4` + `USE_FP4_QMOE` (CUDA ≥ 12.8) |
| `"wfp4afp8"` | W4A8-MXFP4×FP8 | FP8 e4m3 (quantized in-runner) | MXFP4 e2m1, group=32 | **SM100+** native | dequant→A16 on SM<100 | `ENABLE_FP4` + `USE_FP4_QMOE` + `ENABLE_FP8` |
Selection logic (see [moe_quantization.cc](onnxruntime/contrib_ops/cuda/moe/moe_quantization.cc)):
```cpp
if (quant_type_ == "fp4") use_fp4_dequant_fallback_ = (sm_ < 120);
+if (quant_type_ == "nvfp4") use_fp4_dequant_fallback_ = true; // no native block-scaled path yet; fused GEMV decode covers small-decode shapes
if (quant_type_ == "wfp4afp8") use_wfp4afp8_dequant_fallback_ = (sm_ < 100);
if (quant_type_ == "fp8") use_fp8_dequant_fallback_ = (sm_ < 90);
```
`expert_weight_bits` validation:
- `int` → 4 or 8
-- `fp4`, `wfp4afp8` → must be 4
+- `fp4`, `nvfp4`, `wfp4afp8` → must be 4
- `fp8` → must be 8
When the build is configured without the corresponding flags, `quant_type`
@@ -183,7 +191,7 @@ under [onnxruntime/contrib_ops/cuda/llm/moe_gemm/](onnxruntime/contrib_ops/cuda/
| Path | CUTLASS class | Used for | SM range |
|------|---------------|----------|----------|
-| **MoE GEMV fast path** | `fpA_intB_gemv`-based custom kernel | INT4/INT8 per-column W*A16 and symmetric INT4/INT8 block-wise W*A16 with FP16 or BF16 activations and true decode row counts | SM80+ |
+| **MoE GEMV fast path** | `fpA_intB_gemv`-based custom kernel | INT4/INT8 per-column W*A16 and symmetric INT4/INT8 block-wise W*A16, and **MXFP4 (group-32) / NVFP4 (group-16) W4A16** decode, with FP16 or BF16 activations and true decode row counts | SM80+ |
| **Ampere GemmGrouped** | `cutlass::gemm::kernel::GemmGrouped` | INT4/INT8 W*A16, FP8 W8A16 dequant fallback, FP32 | SM75–SM89, plus all mixed-input on SM90/SM120 |
| **TMA Warp-Specialized (mixed-input)** | `CollectiveBuilderMixedInput` | Same-type FP16×FP16 / BF16×BF16, native MXFP4 W4A16 | SM90 (same-type), SM120 (FP4 W4A16) |
| **Block-Scaled Tensor Op** | `OpClassBlockScaledTensorOp` | Native FP8×MXFP4 (`wfp4afp8`) | SM100+ (Blackwell) |
@@ -220,6 +228,7 @@ switch is cached on first use.
| FP16/BF16 (no quant, MoE op) | Ampere GemmGrouped | TMA WS (same-type) | TMA WS / valid Blackwell spec | TMA WS / Ampere fallback |
| FP8 W8A16 native | dequant fallback | TMA WS | TMA WS | SM89 FP8 kernel redirect |
| FP4 W4A16 native | dequant fallback | dequant fallback | dequant fallback | TMA WS mixed-input FP4 |
+| NVFP4 W4A16 (group-16) | dequant fallback + fused GEMV decode | dequant fallback + fused GEMV decode | dequant fallback + fused GEMV decode | dequant fallback + fused GEMV decode |
| WFP4AFP8 native | dequant fallback | dequant fallback | Block-scaled tensor op | Block-scaled tensor op |
| FP32 | Ampere GemmGrouped (forced) | same | same | same |
@@ -752,6 +761,142 @@ switch (hopper_inputs.fusion) {
| `moe_gemm_template_dispatch_tma_ws_mixed_dtype.h` | `FUSION` param throughout; `PackedScalesNum`-based K tile; direct N tile mapping; workspace calc with `Ntile=128` |
| `moe_gemm_template_dispatch.h` | FUSION routing in `dispatchToArch`; removed restrictive wfp4a16 config filter |
+### 9.9 Runtime environment variables
+
+The FP4 path is gated by several process-start environment variables (read once in the
+QMoE constructor). Defaults give the validated production behavior; the rest are opt-in or
+debug switches.
+
+| Variable | Default | Effect |
+|----------|---------|--------|
+| `ORT_ENABLE_FP4_GEMV` | on | Fused MXFP4 GEMV decode kernel. Set to `0` to force the dequant-to-dense fallback (debugging/bisecting). Active in the SM<120 fallback regime, and as the decode arm when native CUTLASS prefill is enabled. |
+| `ORT_FP4_GEMV_AUTOTUNE` | `0` | Opt-in per-shape autotune of the GEMV CtaN/Threads tiling. Enabling it synchronizes the first uncached inference for each shape. |
+| `ORT_FP4_GEMV_AUTOTUNE_LOG` | `0` | Set to `1` to log the chosen GEMV configs per shape. |
+| `ORT_FP4_GEMV_INTERLEAVED` | `0` | **Experimental, opt-in.** Routes the MXFP4 decode GEMV through the `ColumnMajorInterleaved` weight layout (`kInterleave=4`, `kStepK=32`) with dtype-conditional accumulation. fp16 gets faster decode; bf16 stays accuracy-safe. Default off keeps the shipping `ColumnMajor` path byte-for-byte unchanged. See [§9.10](#910-interleaved-gemv-layout--dtype-conditional-accumulation). |
+| `ORT_FP4_GEMV_INTERLEAVED_HALFACC` | `0` | **Override.** When `ORT_FP4_GEMV_INTERLEAVED=1`, forces 16-bit accumulation for *both* fp16 and bf16, overriding the dtype-conditional policy; regresses bf16 accuracy, so it is off by default. |
+| `ORT_FP4_SM80_GEMM` | `1` | Routes SM80/Ampere FP4 prefill through the fused-dequant grouped GEMM. Set to `0` to force dense fallback for debugging or comparison. Decode still routes through fused MXFP4 GEMV when supported. |
+| `ORT_ENABLE_FP4_CUTLASS_GEMM` | `0` | Opt-in native SM90 WFP4A16 CUTLASS GEMM (fast prefill). Requires FP16, SM90, and aligned shapes (`hidden`/`inter` divisible by 256). Must be combined with `ORT_ENABLE_FP4_CUTLASS_UNSAFE=1`. |
+| `ORT_ENABLE_FP4_CUTLASS_UNSAFE` | `0` | Confirms use of the experimental native SM90 path. Without it, a request to enable native GEMM logs a warning and falls back to dequant/GEMV. |
+| `ORT_FP4_PREFILL_MIN_TOKENS` | `64` | When native CUTLASS is enabled, the per-node decode threshold. Tokens with `M >= threshold` (prefill) route to native CUTLASS; `M < threshold` (decode) route to the fused GEMV. Both weight/scale layouts are pre-packed so one node serves both regimes. |
+
+When native CUTLASS is enabled, weights and scales are dual-prepacked (native layout plus
+raw e8m0 block scales for GEMV), so `ORT_FP4_PREFILL_MIN_TOKENS` selects per call between the
+prefill and decode kernels with no extra conversion.
+
+### 9.10 Interleaved GEMV layout + dtype-conditional accumulation
+
+**Opt-in (`ORT_FP4_GEMV_INTERLEAVED=1`, default off).** The shipping MXFP4 decode GEMV uses a
+non-interleaved `ColumnMajor` weight layout (`kInterleave=1`, `kStepK=8`). This experimental path
+instead mirrors the INT4 GEMV's `ColumnMajorInterleaved` layout (`kInterleave=4`, `kStepK=32`, 4×
+fewer K-loop trips) and pairs it with a **dtype-conditional accumulator** to balance bf16 accuracy
+against occupancy:
+
+```cpp
+// onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemv_fp4.cu
+template
+using Fp4GemvAccT = std::conditional_t::value, half, float>;
+```
+
+| Activation | Accumulator | Rationale |
+|-----------|-------------|-----------|
+| **fp16** | fp16 (`half`) | fp16's 10-bit mantissa tolerates 16-bit accumulation over the longer `kStepK=32` chains; the cheaper accumulator keeps register use low so the interleaved layout's K-trip savings translate into a real speedup. |
+| **bf16** | fp32 (`float`) | bf16's 7-bit mantissa loses too much precision under 16-bit accumulation (fails tolerance), so it must accumulate in fp32 — at the cost of the extra registers that erase the speedup. |
+
+The interleaved weights are produced by the `gemv_interleaved` branch of
+[`PrePackRepackFP4Weights`](onnxruntime/contrib_ops/cuda/moe/moe_quantization.cc), which routes the
+raw e2m1 codes per-expert through the CUTLASS fpA_intB SM80 `W4_A16` preprocessor with
+`apply_bias_interleave=false` (the integer-only `+8`/pair-interleave step would corrupt the
+floating-point e2m1 codes; the layout-only steps apply unchanged). Block scales are unchanged
+(`kStepK=32` equals the MXFP4 block size). The shape gate requires `n % (CtaN*4)==0` and
+`k % 64==0`; `CtaN`/`Threads` are pinned (`4`/`128`) so the kernel always matches the prepacked
+weights.
+
+The interleaved layout speeds up fp16 decode; bf16 gets no speedup (the fp32 register cost cancels
+it) but stays accurate. The path is opt-in so fp16 deployments can take the win without affecting
+bf16 or the shipping default.
+
+---
+
+## 9b. NVFP4 (W4A16, block-16) Details
+
+`quant_type="nvfp4"` is a second FP4 weight-only mode, distinct from MXFP4 (`"fp4"`):
+
+| | MXFP4 (`"fp4"`) | **NVFP4 (`"nvfp4"`)** |
+|---|---|---|
+| Weight codes | E2M1, 2 codes/byte (`uint8`) | E2M1, 2 codes/byte (`uint8`) — same |
+| Block size | 32 | **16** |
+| Block scale | `float8e8m0` (E8M0, pow-of-two), stored `uint8` | **`float8e4m3fn`** (E4M3) |
+| Global scale | 1.0 (implicit) | **per-expert FP32** `fc*_global_scale` (`weight_scale_2`) |
+| Reconstruct | `w = e2m1(code) · e8m0(block_scale)` | `w = e2m1(code) · e4m3(block_scale[n, k/16]) · global_scale[e]` |
+
+NVFP4 is the format emitted by NVIDIA Model-Optimizer (e.g. `nvidia/Qwen3.6-35B-A3B-NVFP4`).
+
+### 9b.1 Dispatch
+
+There is no native block-scaled CUTLASS path for NVFP4 today (that is Blackwell-only and not yet
+wired), so NVFP4 **always** uses the dequant-to-A16 fallback ([§4.3](#43-dequant-to-a16-fallback))
+for prefill / GEMV-unsupported shapes, and the **fused GEMV decode fast path**
+([§4](#4-architecture-dispatch--kernel-paths)) for small-decode shapes. `enable_fp4_gemv_` is on by
+default for NVFP4 (opt-out `ORT_ENABLE_FP4_GEMV=0`); `enable_fp4_sm80_gemm_` stays off (the SM80
+grouped-GEMM FP4 prefill path is MXFP4-only).
+
+### 9b.2 Fused GEMV decode (group-16)
+
+The MXFP4 decode GEMV ([§9.10](#910-interleaved-gemv-layout--dtype-conditional-accumulation)) is
+block-size generic: the kernel indexes scales as `scales[e][k/group_size][n]`, and `GroupSize` is a
+compile-time template (`static_assert((CtaK/kInterleave) % GroupSize == 0)`). NVFP4 reuses it with
+`group_size = 16`:
+
+- `is_moe_gemv_fp4_supported` accepts `group_size ∈ {16, 32}`; the dispatch instantiates the
+ `GroupSize=16` cases in `dispatch_moe_gemv_group_size` /
+ `dispatch_moe_gemv_interleaved_swiglu_group_size` ([moe_gemv_device.cuh](onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemv_device.cuh)).
+- NVFP4 uses **only** the non-interleaved `ColumnMajor` layout; the opt-in interleaved path
+ ([§9.10](#910-interleaved-gemv-layout--dtype-conditional-accumulation)) is MXFP4-only because its
+ `kStepK=32` tile is tied to the block-32 scale layout.
+- `QMoECombineNvfp4ScalesForGemv` ([qmoe_kernels.cu](onnxruntime/contrib_ops/cuda/moe/qmoe_kernels.cu))
+ decodes the `float8e4m3fn` block scales, folds in the per-expert FP32 global scale, and rewrites
+ `[E, n, k/16] → [E, k/16, n]` in the activation dtype (`TypeA`) that the GEMV expects.
+- The decode gate ([moe_quantization.cc](onnxruntime/contrib_ops/cuda/moe/moe_quantization.cc)) fires
+ when `expanded = num_tokens·top_k ∈ (0, 8]`, SwiGLU is fused, and both FC1
+ (`n=2·inter`, `k=hidden`) and FC2 (`n=hidden`, `k=inter`) satisfy `n,k ≥ 512` and group-16 block
+ alignment. For Qwen3.6-35B-A3B (`hidden=2048`, `inter=512`, `E=256`, `top_k=8`) both GEMMs qualify.
+
+### 9b.3 Fast E2M1 → half/bf16 decode
+
+Profiling the NVFP4 decode GEMV on H200 (SM90) at the Qwen shapes showed the FC1/FC2 kernels are
+**ALU-pipeline bound** (ncu: ALU ≈ 79%, DRAM ≈ 7%), i.e. the per-element FP4 dequantization — not
+memory or tiling — dominates. The original `Fp4I2FConverter::decode` used a `float` lookup table,
+a sign branch, and a per-element `float → half/bf16` conversion. It is replaced by a **branchless
+`prmt.b32` byte-select** that builds the 16-bit float bit pattern directly (E2M1 has only eight
+magnitudes, so the half/bf16 encodings are packed into two 32-bit constants and selected by the
+low three code bits, with the sign bit shifted into place):
+
+```cpp
+// onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemv/details.h — Fp4I2FConverter::decode (bit-identical to the LUT)
+uint32_t sel = code & 0x7u, sign = uint32_t(code & 0x8u) << 12; // sign -> bit 15
+// half: low byte always 0 -> one prmt; bf16: two prmt (high + low byte).
+```
+
+This is numerically bit-identical to the previous LUT. Measured decode speedup (H200, `tokens=1`,
+`top_k=8`, `hidden=2048`, `inter=512`, `E=256`, bf16, autotuned tiling):
+
+| kernel | before | after | Δ |
+|--------|-------:|------:|---|
+| FC1 GEMV (SwiGLU-fused, `n=1024 k=2048`) | 20.6 µs | **13.25 µs** | **−36%** |
+| FC2 GEMV (`n=2048 k=512`) | 10.7 µs | **7.55 µs** | **−29%** |
+
+ncu confirms the mechanism: FC1 compute (SM) throughput dropped 68.8% → 55.9% and DRAM rose
+7.3% → 10.2% (a more balanced kernel). Full-node latency (incl. expand / sort / finalize /
+softmax-topk overhead) fell 66.8 µs → 56.1 µs, and end-to-end Qwen3.6 decode reaches ~131 tok/s on
+H200.
+
+### 9b.4 Testing & benchmarking
+
+- Parity: [test_qmoe_nvfp4_cuda.py](onnxruntime/test/python/transformers/test_qmoe_nvfp4_cuda.py)
+ (`-k gemv` compares `ORT_ENABLE_FP4_GEMV=1` vs `=0` vs a torch reference).
+- Kernel microbench: `bench_qmoe_nvfp4_gemv.py` builds the QMoE node at the Qwen decode shape and
+ times GEMV vs the dequant fallback (with `ORT_FP4_GEMV_AUTOTUNE_LOG=1` for the chosen tiling).
+
---
## 10. FP8 (W8A16) Details
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/detail/collective/mixed_input_utils.hpp b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/detail/collective/mixed_input_utils.hpp
index b713c97747fb2..2d79718e0e986 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/detail/collective/mixed_input_utils.hpp
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/detail/collective/mixed_input_utils.hpp
@@ -379,7 +379,7 @@ struct MixedGroupedGemmInputUtils {
constexpr uint32_t immLut = (0xf0 & 0xcc) ^ 0xaa;
asm volatile(
"{\n"
- " lop3 .b32 %0, %2, %4, %5, %6;\n"
+ " lop3.b32 %0, %2, %4, %5, %6;\n"
" xor .b32 %1, %3, %5; \n"
"}\n"
: "=r"(scale_pos_[0]), "=r"(scale_pos_[1])
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/mixed_gemm_B_layout.h b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/mixed_gemm_B_layout.h
index c0656ac784830..51cffac23f48d 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/mixed_gemm_B_layout.h
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/mixed_gemm_B_layout.h
@@ -107,6 +107,23 @@ struct LayoutDetailsB
+struct LayoutDetailsB= 75>::type> {
+ static constexpr int ThreadblockK = 128 * 8 / cutlass::sizeof_bits::value;
+
+ private:
+ static constexpr int ElementsPerCacheLine = 128 * 8 / sizeof_bits::value;
+ static constexpr int ColumnsInterleaved = ElementsPerCacheLine / ThreadblockK;
+
+ public:
+ using Layout = layout::ColumnMajorTileInterleave;
+ static constexpr int ElementsPerAccess = 128 / cutlass::sizeof_bits::value;
+ using Operator = cutlass::arch::OpMultiplyAddDequantizeInterleavedBToA;
+};
+
} // namespace kernel
} // namespace gemm
} // namespace cutlass
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/moe_cutlass_kernel.h b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/moe_cutlass_kernel.h
index ecfebd7a258d9..7ab652ab66863 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/moe_cutlass_kernel.h
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/kernel/moe_cutlass_kernel.h
@@ -180,7 +180,8 @@ struct MoeFCGemm {
int64_t const* total_tokens_including_expert, int64_t gemm_n, int64_t gemm_k,
GemmCoord* host_problem_sizes = nullptr)
: problem_count(problem_count), threadblock_count(threadblock_count), group_size(group_size), output_op(output_op), ptr_A(const_cast(ptr_A)), ptr_B(const_cast(ptr_B)), weight_scales(const_cast(weight_scales)), weight_zeros(const_cast(weight_zeros)), ptr_C(const_cast(ptr_C)), C_is_broadcast{C_is_broadcast}, ptr_D(ptr_D), total_tokens_including_expert(total_tokens_including_expert), gemm_n(gemm_n), gemm_k(gemm_k), host_problem_sizes(nullptr) {
- if (platform::is_same::value || platform::is_same::value) {
+ if (platform::is_same::value || platform::is_same::value ||
+ platform::is_same::value) {
assert(weight_scales);
}
this->gather_A_indices = nullptr;
@@ -283,7 +284,8 @@ struct MoeFCGemm {
}
static Status can_implement(Arguments const& args) {
- if constexpr (platform::is_same::value || platform::is_same::value) {
+ if constexpr (platform::is_same::value || platform::is_same::value ||
+ platform::is_same::value) {
if (args.weight_scales == nullptr) {
CUTLASS_TRACE_HOST("MoeFCGemm::can_implement() - weight scales are required for uint8_t and uint4b_t");
return Status::kInvalid;
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_multistage.h b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_multistage.h
index 8d73329ed7713..86f38300d4b27 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_multistage.h
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_multistage.h
@@ -118,8 +118,9 @@ struct DqMma::value,
"Mma multistage must dequantize after ldsm");
- static_assert(platform::is_same::value || platform::is_same::value,
- "Element B must be uint8 or uint4");
+ static_assert(platform::is_same::value || platform::is_same::value ||
+ platform::is_same::value,
+ "Element B must be uint8, uint4 or float_e2m1");
static cutlass::arch::CacheOperation::Kind const CacheOpA = ((sizeof_bits::value * kAlignmentA) == 128)
? cutlass::arch::CacheOperation::Global
@@ -218,8 +219,9 @@ struct DqMma::value,
"Mma multistage must dequantize after ldsm");
- static_assert(platform::is_same::value || platform::is_same::value,
- "Element B must be uint8 or uint4");
+ static_assert(platform::is_same::value || platform::is_same::value ||
+ platform::is_same::value,
+ "Element B must be uint8, uint4 or float_e2m1");
static cutlass::arch::CacheOperation::Kind const CacheOpA = ((sizeof_bits::value * kAlignmentA) == 128)
? cutlass::arch::CacheOperation::Global
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_pipelined.h b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_pipelined.h
index ae0cee20d3575..14b59fd783331 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_pipelined.h
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_dq_mma_pipelined.h
@@ -116,8 +116,9 @@ struct DqMma::value || platform::is_same::value,
"Element A must be fp16 or bf16");
- static_assert(platform::is_same::value || platform::is_same::value,
- "Element B must be uint8 or uint4");
+ static_assert(platform::is_same::value || platform::is_same::value ||
+ platform::is_same::value,
+ "Element B must be uint8, uint4 or float_e2m1");
using OperatorInfo = arch::DetagOperator;
using Operator = typename OperatorInfo::Operator;
@@ -201,8 +202,9 @@ struct DqMma::value || platform::is_same::value,
"Element A must be fp16 or bf16");
- static_assert(platform::is_same::value || platform::is_same::value,
- "Element B must be uint8 or uint4");
+ static_assert(platform::is_same::value || platform::is_same::value ||
+ platform::is_same::value,
+ "Element B must be uint8, uint4 or float_e2m1");
using OperatorInfo = arch::DetagOperator;
using Operator = typename OperatorInfo::Operator;
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma.h b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma.h
index dfe99c271f547..8f95212619437 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma.h
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma.h
@@ -221,6 +221,91 @@ struct DefaultMma
+struct DefaultMma {
+ private:
+ static constexpr int kAlignmentScale = 128 / sizeof_bits::value;
+
+ using Mma = DqMma;
+
+ public:
+ using MmaCore = typename Mma::MmaCore;
+ using IteratorA = typename Mma::IteratorA;
+ using IteratorB = typename Mma::IteratorB;
+ using ThreadblockMma = typename Mma::ThreadblockMma;
+};
+
+////////////////////////////////////////////////////////////////////////////////
+/// Specialization for row-major output (OperatorClass TensorOp), fp16 activation & MXFP4 (e2m1) weight,
+/// mma multistage (stage>=3)
+template <
+ /// Layout type for A matrix operand
+ typename LayoutA,
+ /// Access granularity of A matrix in units of elements
+ int kAlignmentA,
+ /// Layout type for B matrix operand
+ typename LayoutB,
+ /// Access granularity of B matrix in units of elements
+ int kAlignmentB,
+ /// Element type for internal accumulation
+ typename ElementAccumulator,
+ /// Tag indicating architecture to tune for
+ typename ArchTag,
+ /// Threadblock-level tile size (concept: GemmShape)
+ typename ThreadblockShape,
+ /// Warp-level tile size (concept: GemmShape)
+ typename WarpShape,
+ /// Instruction-level tile size (concept: GemmShape)
+ typename InstructionShape,
+ /// Operation performed by GEMM
+ typename Operator,
+ ///
+ int kStages,
+ /// Shared memory clear option
+ SharedMemoryClearOption SharedMemoryClear>
+struct DefaultMma {
+ private:
+ static constexpr int kAlignmentScale = 128 / sizeof_bits::value;
+
+ using Mma = DqMma;
+
+ public:
+ using MmaCore = typename Mma::MmaCore;
+ using IteratorA = typename Mma::IteratorA;
+ using IteratorB = typename Mma::IteratorB;
+ using ThreadblockMma = typename Mma::ThreadblockMma;
+};
+
#ifdef ENABLE_FP8
////////////////////////////////////////////////////////////////////////////////
/// Specialization for row-major output (OperatorClass TensorOp), fp8 activation & int4 weight, mma multistage
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma_bf16.h b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma_bf16.h
index cb5ce0f72b362..ba40045dfdb9b 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma_bf16.h
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/threadblock/default_mma_bf16.h
@@ -331,6 +331,52 @@ struct DefaultMma
+struct DefaultMma {
+ private:
+ static constexpr int kAlignmentScale = 128 / sizeof_bits::value;
+
+ using Mma = DqMma;
+
+ public:
+ using MmaCore = typename Mma::MmaCore;
+ using IteratorA = typename Mma::IteratorA;
+ using IteratorB = typename Mma::IteratorB;
+ using ThreadblockMma = typename Mma::ThreadblockMma;
+};
+
+////////////////////////////////////////////////////////////////////////////////
+/// Specialization for row-major output (OperatorClass TensorOp), bf16 activation & MXFP4 (e2m1) weight,
+/// mma multistage (stage>=3)
+template <
+ typename LayoutA, int kAlignmentA, typename LayoutB, int kAlignmentB, typename ElementAccumulator,
+ typename ArchTag, typename ThreadblockShape, typename WarpShape, typename InstructionShape, typename Operator,
+ int kStages, SharedMemoryClearOption SharedMemoryClear>
+struct DefaultMma {
+ private:
+ static constexpr int kAlignmentScale = 128 / sizeof_bits::value;
+
+ using Mma = DqMma;
+
+ public:
+ using MmaCore = typename Mma::MmaCore;
+ using IteratorA = typename Mma::IteratorA;
+ using IteratorB = typename Mma::IteratorB;
+ using ThreadblockMma = typename Mma::ThreadblockMma;
+};
+
} // namespace threadblock
} // namespace gemm
} // namespace cutlass
diff --git a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/interleaved_numeric_conversion.h b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/interleaved_numeric_conversion.h
index 86c45a865954e..788b7dc701ced 100644
--- a/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/interleaved_numeric_conversion.h
+++ b/onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/interleaved_numeric_conversion.h
@@ -394,6 +394,187 @@ struct FastInterleavedAndBiasedNumericArrayConverter {
/////////////////////////////////////////////////////////////////////////////////////////////////
+// FP4 (e2m1) -> half/bf16 fast converters.
+//
+// These mirror the uint4b_t specializations above so the SM80 fused-dequant grouped GEMM
+// (DqMmaMultistage) can target FP4 weights, exactly as it does INT4 weights today. Two
+// differences from the integer path:
+// 1. No zero-point bias. INT4 packs with a +8 bias that the converter subtracts. e2m1 codes
+// are raw 4-bit floats, so the matching weight pre-pack interleaves WITHOUT adding a bias.
+// 2. Conversion is a bit-field expand, not an integer-to-float magic-add. e2m1 = [s e1 e0 m0]
+// with exponent bias 1; the 8 magnitudes are {0, .5, 1, 1.5, 2, 3, 4, 6}.
+//
+// The de-interleave order matches add_bias_and_interleave_int4s_inplace_kernel's permutation
+// [e0,e2,e4,e6,e1,e3,e5,e7] (sans bias), so the same weight-prepack interleave is reused.
+template <>
+struct FastInterleavedAndBiasedNumericArrayConverter {
+ using result_type = Array;
+ using source_type = Array;
+
+ // Convert a single 4-bit e2m1 code (0..15) to the raw 16-bit IEEE half bit pattern.
+ CUTLASS_HOST_DEVICE
+ static uint32_t e2m1_to_half_bits(uint32_t v) {
+ uint32_t sign = (v & 0x8u) << 12; // e2m1 sign bit -> half bit 15
+ uint32_t e = (v >> 1) & 0x3u; // 2-bit exponent (bias 1)
+ uint32_t m = v & 0x1u; // 1-bit mantissa
+ // Normal (e != 0): half exponent = e - 1 + 15 = e + 14; mantissa bit -> half mantissa MSB.
+ // e == 0: subnormal 0.5 (m == 1) -> 0x3800, or zero (m == 0) -> 0x0000.
+ uint32_t mag = (e != 0u) ? (((14u + e) << 10) | (m ? 0x200u : 0u))
+ : (m ? 0x3800u : 0u);
+ return sign | mag;
+ }
+
+ CUTLASS_DEVICE
+ static result_type convert(source_type const& source) {
+ result_type result;
+ uint16_t* r = reinterpret_cast(&result);
+ uint32_t const packed = reinterpret_cast(source);
+
+ uint32_t d[8];
+ CUTLASS_PRAGMA_UNROLL
+ for (int k = 0; k < 8; ++k) {
+ d[k] = (packed >> (4 * k)) & 0xFu;
+ }
+
+ // Invert the [e0,e2,e4,e6,e1,e3,e5,e7] interleave so result holds logical order e0..e7.
+ r[0] = static_cast(e2m1_to_half_bits(d[0]));
+ r[1] = static_cast(e2m1_to_half_bits(d[4]));
+ r[2] = static_cast(e2m1_to_half_bits(d[1]));
+ r[3] = static_cast(e2m1_to_half_bits(d[5]));
+ r[4] = static_cast(e2m1_to_half_bits(d[2]));
+ r[5] = static_cast(e2m1_to_half_bits(d[6]));
+ r[6] = static_cast(e2m1_to_half_bits(d[3]));
+ r[7] = static_cast(e2m1_to_half_bits(d[7]));
+ return result;
+ }
+
+ CUTLASS_DEVICE
+ result_type operator()(source_type const& s) {
+ return convert(s);
+ }
+};
+
+template
+struct FastInterleavedAndBiasedNumericArrayConverter {
+ static constexpr int VEC_WIDTH = 8;
+ static_assert(!(N % VEC_WIDTH), "N must be multiple of 8.");
+
+ using result_type = Array;
+ using source_type = Array;
+
+ CUTLASS_DEVICE
+ static result_type convert(source_type const& source) {
+ using scalar_result_type = typename result_type::Element;
+ using scalar_source_type = typename source_type::Element;
+ FastInterleavedAndBiasedNumericArrayConverter
+ convert_vector_;
+
+ result_type result;
+ using vec_result = Array;
+ using vec_source = Array;
+
+ vec_result* result_ptr = reinterpret_cast(&result);
+ vec_source const* source_ptr = reinterpret_cast(&source);
+
+ CUTLASS_PRAGMA_UNROLL
+ for (int i = 0; i < N / VEC_WIDTH; ++i) {
+ result_ptr[i] = convert_vector_(source_ptr[i]);
+ }
+
+ return result;
+ }
+
+ CUTLASS_DEVICE
+ result_type operator()(source_type const& s) {
+ return convert(s);
+ }
+};
+
+template <>
+struct FastInterleavedAndBiasedNumericArrayConverter {
+ using result_type = Array;
+ using source_type = Array;
+
+ // Convert a single 4-bit e2m1 code (0..15) to the raw 16-bit bfloat16 bit pattern.
+ CUTLASS_HOST_DEVICE
+ static uint32_t e2m1_to_bf16_bits(uint32_t v) {
+ uint32_t sign = (v & 0x8u) << 12; // e2m1 sign bit -> bf16 bit 15
+ uint32_t e = (v >> 1) & 0x3u; // 2-bit exponent (bias 1)
+ uint32_t m = v & 0x1u; // 1-bit mantissa
+ // Normal (e != 0): bf16 exponent = e - 1 + 127 = e + 126; mantissa bit -> bf16 mantissa MSB.
+ // e == 0: subnormal 0.5 (m == 1) -> 0x3F00, or zero (m == 0) -> 0x0000.
+ uint32_t mag = (e != 0u) ? (((126u + e) << 7) | (m ? 0x40u : 0u))
+ : (m ? 0x3F00u : 0u);
+ return sign | mag;
+ }
+
+ CUTLASS_DEVICE
+ static result_type convert(source_type const& source) {
+ result_type result;
+ uint16_t* r = reinterpret_cast(&result);
+ uint32_t const packed = reinterpret_cast(source);
+
+ uint32_t d[8];
+ CUTLASS_PRAGMA_UNROLL
+ for (int k = 0; k < 8; ++k) {
+ d[k] = (packed >> (4 * k)) & 0xFu;
+ }
+
+ r[0] = static_cast(e2m1_to_bf16_bits(d[0]));
+ r[1] = static_cast(e2m1_to_bf16_bits(d[4]));
+ r[2] = static_cast(e2m1_to_bf16_bits(d[1]));
+ r[3] = static_cast(e2m1_to_bf16_bits(d[5]));
+ r[4] = static_cast(e2m1_to_bf16_bits(d[2]));
+ r[5] = static_cast(e2m1_to_bf16_bits(d[6]));
+ r[6] = static_cast(e2m1_to_bf16_bits(d[3]));
+ r[7] = static_cast(e2m1_to_bf16_bits(d[7]));
+ return result;
+ }
+
+ CUTLASS_DEVICE
+ result_type operator()(source_type const& s) {
+ return convert(s);
+ }
+};
+
+template
+struct FastInterleavedAndBiasedNumericArrayConverter {
+ static constexpr int VEC_WIDTH = 8;
+ static_assert(!(N % VEC_WIDTH), "N must be multiple of 8.");
+
+ using result_type = Array;
+ using source_type = Array;
+
+ CUTLASS_DEVICE
+ static result_type convert(source_type const& source) {
+ using scalar_result_type = typename result_type::Element;
+ using scalar_source_type = typename source_type::Element;
+ FastInterleavedAndBiasedNumericArrayConverter
+ convert_vector_;
+
+ result_type result;
+ using vec_result = Array;
+ using vec_source = Array;
+
+ vec_result* result_ptr = reinterpret_cast(&result);
+ vec_source const* source_ptr = reinterpret_cast(&source);
+
+ CUTLASS_PRAGMA_UNROLL
+ for (int i = 0; i < N / VEC_WIDTH; ++i) {
+ result_ptr[i] = convert_vector_(source_ptr[i]);
+ }
+
+ return result;
+ }
+
+ CUTLASS_DEVICE
+ result_type operator()(source_type const& s) {
+ return convert(s);
+ }
+};
+
+/////////////////////////////////////////////////////////////////////////////////////////////////
+
} // namespace cutlass
/////////////////////////////////////////////////////////////////////////////////////////////////
diff --git a/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors.h b/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors.h
index c3b734816cf84..b1af64b1e16a4 100644
--- a/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors.h
+++ b/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors.h
@@ -33,6 +33,17 @@ enum class QuantType {
int get_arch_for_mixed_gemm_weight_preprocess(int arch);
+// ``apply_bias_interleave`` controls the final integer-only "+bias + pair-interleave" step
+// (step 4). It MUST be left true for genuine signed-integer weights (INT4/INT8). For MXFP4
+// (e2m1) codes the nibbles are floating-point, so the integer +8 bias would corrupt them;
+// FP4 callers pass false to skip step 4 while keeping the layout-only steps 1-3 (row-permute,
+// subbyte-transpose, column-interleave), which apply to e2m1 unchanged.
+//
+// ``interleave_without_bias`` (only consulted when ``apply_bias_interleave`` is false) applies
+// step 4's [e0,e2,e4,e6,e1,e3,e5,e7] nibble pair-interleave WITHOUT the +8 bias. This is the
+// layout the SM80 MoE grouped GEMM's e2m1 dequant converter expects (it inverts that
+// permutation). The fused MXFP4 GEMV decode kernel uses a different layout and leaves this
+// false.
void preprocess_weights_for_mixed_gemm_cuda(cudaStream_t stream,
int arch,
int8_t* preprocessed_quantized_weight,
@@ -40,7 +51,9 @@ void preprocess_weights_for_mixed_gemm_cuda(cudaStream_t stream,
int32_t* d_permutation_map,
std::vector const& shape,
QuantType quant_type,
- bool synchronize = true);
+ bool synchronize = true,
+ bool apply_bias_interleave = true,
+ bool interleave_without_bias = false);
} // namespace weight_only
} // namespace kernels
diff --git a/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors_impl.cu b/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors_impl.cu
index 7e83bdda72eab..e5823c88d3d7f 100644
--- a/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors_impl.cu
+++ b/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_preprocessors_impl.cu
@@ -482,6 +482,49 @@ __global__ void add_bias_and_interleave_int4s_inplace_kernel(uint32_t* tensor, s
}
}
+// Interleave-only variant for MXFP4 (e2m1) weights: applies the SAME
+// [e0,e2,e4,e6,e1,e3,e5,e7] nibble pair-interleave as
+// add_bias_and_interleave_int4s_inplace_kernel, but writes the raw 4-bit code unchanged (no
+// signed +8 bias). The e2m1 dequant converter
+// (FastInterleavedAndBiasedNumericArrayConverter) inverts exactly this
+// permutation, so the grouped GEMM requires the interleave while the floating-point nibbles must
+// stay untouched.
+__global__ void interleave_int4s_inplace_kernel(uint32_t* tensor, size_t num_elts) {
+ int idx = blockIdx.x * blockDim.x + threadIdx.x;
+ size_t register_idx = static_cast(idx);
+ if (register_idx < num_elts / 8) {
+ uint32_t current_register = tensor[register_idx];
+ uint32_t transformed_register = 0;
+ for (int i = 0; i < 8; ++i) {
+ uint8_t raw_nibble = (current_register >> (i * 4)) & 0x0F;
+ int dest_idx = ((i % 2) == 0) ? (i / 2) : ((i - 1) / 2 + 4);
+ transformed_register |= (static_cast(raw_nibble & 0x0F) << (dest_idx * 4));
+ }
+ tensor[register_idx] = transformed_register;
+ }
+}
+
+void interleave_without_bias_quantized_tensor_inplace_cuda(
+ int8_t* tensor,
+ size_t num_elts,
+ QuantType quant_type,
+ cudaStream_t stream) {
+ ORT_ENFORCE(quant_type == QuantType::W4_A16 || quant_type == QuantType::W4_AFP8,
+ "Interleave-without-bias is only supported for 4-bit (e2m1) weights.");
+ if (num_elts == 0) {
+ return;
+ }
+ ORT_ENFORCE(num_elts >= 8 && num_elts % 8 == 0,
+ "Interleave-without-bias requires the number of 4-bit elements to be a non-zero multiple of 8, got ",
+ num_elts, ".");
+ const int threads_per_block = 256;
+ const int num_registers = SafeInt(num_elts) / 8;
+ const int num_blocks = (num_registers + threads_per_block - 1) / threads_per_block;
+ interleave_int4s_inplace_kernel<<>>(
+ reinterpret_cast(tensor),
+ num_elts);
+}
+
/**
* @brief Launches the CUDA kernel for in-place bias addition and interleaving.
*
@@ -541,7 +584,9 @@ void preprocess_weights_for_mixed_gemm_cuda(cudaStream_t stream,
int32_t* d_permutation_map,
std::vector const& shape,
QuantType quant_type,
- bool synchronize) {
+ bool synchronize,
+ bool apply_bias_interleave,
+ bool interleave_without_bias) {
LayoutDetails details = getLayoutDetailsForTransform(quant_type, arch);
ORT_ENFORCE(shape.size() == 2 || shape.size() == 3, "Shape must be 2-D or 3-D");
@@ -578,11 +623,24 @@ void preprocess_weights_for_mixed_gemm_cuda(cudaStream_t stream,
std::swap(src_buf, dst_buf);
}
- add_bias_and_interleave_quantized_tensor_inplace_cuda(
- src_buf,
- num_elts,
- quant_type,
- stream);
+ // Step 4 (integer-only): add the +bias and pair-interleave. Skipped for MXFP4 (e2m1)
+ // float codes, which would be corrupted by the integer bias; the preceding layout-only
+ // steps already produced the ColumnMajorInterleaved nibble order the GEMV consumes.
+ if (apply_bias_interleave) {
+ add_bias_and_interleave_quantized_tensor_inplace_cuda(
+ src_buf,
+ num_elts,
+ quant_type,
+ stream);
+ } else if (interleave_without_bias) {
+ // MXFP4 grouped-GEMM layout: apply step 4's [e0,e2,e4,e6,e1,e3,e5,e7] nibble interleave
+ // (which the e2m1 dequant converter inverts) WITHOUT the integer +8 bias.
+ interleave_without_bias_quantized_tensor_inplace_cuda(
+ src_buf,
+ num_elts,
+ quant_type,
+ stream);
+ }
if (preprocessed_quantized_weight != src_buf) {
const size_t num_bytes = num_elts * static_cast(get_weight_quant_bits(quant_type)) / static_cast(8);
diff --git a/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemv/details.h b/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemv/details.h
index 4fa64ef329c57..2504f12b57776 100644
--- a/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemv/details.h
+++ b/onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemv/details.h
@@ -94,6 +94,15 @@ struct Int4DetailsW {
static constexpr int kElemBits = 4;
};
+struct Fp4DetailsW {
+ static constexpr int kElemBits = 4;
+};
+
+template
+struct IsFp4Weight : std::false_type {};
+template <>
+struct IsFp4Weight : std::true_type {};
+
template
struct ColumnMajor {
using DetailsA = TypeDetailsA;
@@ -223,12 +232,66 @@ struct I2FConverter {
}
};
+template
+struct Fp4I2FConverter {
+ static_assert(std::is_same_v || std::is_same_v);
+
+ // Branchless E2M1 (FP4) -> half/bf16 decode. E2M1 has only eight magnitudes
+ // {0, 0.5, 1, 1.5, 2, 3, 4, 6}, so the 16-bit float bit pattern is built directly with a
+ // single prmt.b32 byte-select from packed magnitude constants (two for bf16, whose low byte
+ // is not always zero) plus a shifted sign bit. This replaces the per-element float LUT
+ // lookup + sign branch + float->AType conversion, which profiling showed to be the dominant
+ // ALU-pipeline cost of the small-decode FP4 GEMV (ncu: ALU ~79% of a compute-bound kernel).
+ // The magnitude bytes below are the exact half/bf16 encodings of the eight FP4 values, so the
+ // result is bit-identical to the previous LUT path.
+ __device__ __forceinline__ static AType decode(uint8_t code) {
+#if defined(__CUDA_ARCH__)
+ uint32_t const sel = code & 0x7u;
+ uint32_t const sign = static_cast(code & 0x8u) << 12; // FP4 sign bit -> bit 15
+ if constexpr (std::is_same_v) {
+ // half high byte per magnitude (low byte is always 0):
+ // codes 0..3 -> {0x00, 0x38, 0x3C, 0x3E}, codes 4..7 -> {0x40, 0x42, 0x44, 0x46}.
+ uint32_t hb;
+ asm("prmt.b32 %0, %1, %2, %3;" : "=r"(hb) : "r"(0x3E3C3800u), "r"(0x46444240u), "r"(sel));
+ return __ushort_as_half(static_cast(((hb & 0xFFu) << 8) | sign));
+ } else {
+ // bf16 high byte {0x00,0x3F,0x3F,0x3F, 0x40,0x40,0x40,0x40} and
+ // low byte {0x00,0x00,0x80,0xC0, 0x00,0x40,0x80,0xC0}.
+ uint32_t hb, lb;
+ asm("prmt.b32 %0, %1, %2, %3;" : "=r"(hb) : "r"(0x3F3F3F00u), "r"(0x40404040u), "r"(sel));
+ asm("prmt.b32 %0, %1, %2, %3;" : "=r"(lb) : "r"(0xC0800000u), "r"(0xC0804000u), "r"(sel));
+ return __ushort_as_bfloat16(static_cast(((hb & 0xFFu) << 8) | (lb & 0xFFu) | sign));
+ }
+#else
+ constexpr float kValues[8] = {0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 3.0f, 4.0f, 6.0f};
+ float v = kValues[code & 0x7];
+ return static_cast((code & 0x8) ? -v : v);
+#endif
+ }
+
+ template
+ __device__ __forceinline__ static void convert(void* src, void* dst) {
+ static_assert(N % 2 == 0);
+ uint8_t const* s = reinterpret_cast(src);
+ AType* d = reinterpret_cast(dst);
+#pragma unroll
+ for (int i = 0; i < N; i += 2) {
+ uint8_t byte = s[i >> 1];
+ d[i] = decode(static_cast(byte & 0x0F));
+ d[i + 1] = decode(static_cast((byte >> 4) & 0x0F));
+ }
+ }
+};
+
template
struct ConverterWrapper {
using TypeDetailsA = typename Details::TypeDetailsA;
using TypeDetailsW = typename Details::TypeDetailsW;
static constexpr bool kUseInterleavedConverter = Details::kUseInterleavedConverter;
- using Converter = I2FConverter;
+ using Converter = std::conditional_t<
+ IsFp4Weight::value,
+ Fp4I2FConverter,
+ I2FConverter>;
};
template
diff --git a/onnxruntime/contrib_ops/cuda/llm/moe_gemm/launchers/moe_gemm_tma_ws_mixed_input_launcher.inl b/onnxruntime/contrib_ops/cuda/llm/moe_gemm/launchers/moe_gemm_tma_ws_mixed_input_launcher.inl
index ba23174d3c203..70badcbb41087 100644
--- a/onnxruntime/contrib_ops/cuda/llm/moe_gemm/launchers/moe_gemm_tma_ws_mixed_input_launcher.inl
+++ b/onnxruntime/contrib_ops/cuda/llm/moe_gemm/launchers/moe_gemm_tma_ws_mixed_input_launcher.inl
@@ -14,10 +14,10 @@
* limitations under the License.
*/
-#ifdef __GNUC__ // Check if the compiler is GCC or Clang
+#ifdef __GNUC__ // Check if the compiler is GCC or Clang
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wstrict-aliasing"
-#endif // __GNUC__
+#endif // __GNUC__
#include "cutlass/epilogue/collective/default_epilogue.hpp"
#include "cutlass/epilogue/thread/linear_combination.h"
@@ -50,9 +50,9 @@
#include "contrib_ops/cuda/llm/cutlass_extensions/gemm/collective/collective_builder_mixed_input.hpp"
#include "contrib_ops/cuda/llm/cutlass_extensions/gemm_configs.h"
-#ifdef __GNUC__ // Check if the compiler is GCC or Clang
+#ifdef __GNUC__ // Check if the compiler is GCC or Clang
#pragma GCC diagnostic pop
-#endif // __GNUC__
+#endif // __GNUC__
#include "core/common/common.h"
#include "contrib_ops/cuda/llm/common/cuda_runtime_utils.h"
@@ -61,12 +61,9 @@
#include "contrib_ops/cuda/llm/cutlass_type_conversion.h"
#include "contrib_ops/cuda/llm/moe_gemm/launchers/moe_gemm_tma_ws_mixed_input_launcher.h"
-namespace onnxruntime::llm
-{
-namespace kernels
-{
-namespace cutlass_kernels
-{
+namespace onnxruntime::llm {
+namespace kernels {
+namespace cutlass_kernels {
namespace tk = onnxruntime::llm::common;
namespace tkc = onnxruntime::llm::cutlass_extensions;
@@ -75,238 +72,225 @@ using EpilogueFusion = TmaWarpSpecializedGroupedGemmInput::EpilogueFusion;
using namespace cute;
template
+ typename CTAShape, typename ClusterShape, typename MainloopScheduleType, typename EpilogueScheduleType,
+ cutlass::WeightOnlyQuantOp QuantOp>
void sm90_generic_mixed_moe_gemm_kernelLauncher(GroupedGemmInput inputs,
- TmaWarpSpecializedGroupedGemmInput hopper_inputs, int sm_count_, size_t* workspace_size)
-{
- ORT_LLM_LOG_ENTRY();
-
- /////////////////////////////////////////////////////////////////////////////////////////////////
- /// GEMM kernel configurations
- /////////////////////////////////////////////////////////////////////////////////////////////////
- static_assert(FUSION == EpilogueFusion::NONE || FUSION == EpilogueFusion::FINALIZE,
- "Unimplemented fusion provided to TMA WS Mixed MoE gemm launcher");
- constexpr static bool IsFinalizeFusion = FUSION == EpilogueFusion::FINALIZE;
-
- /////////////////////////////////////////////////////////////////////////////////////////////////
- /// GEMM kernel configurations
- /////////////////////////////////////////////////////////////////////////////////////////////////
-
- // A matrix configuration
- using ElementA = typename CudaToCutlassTypeAdapter::type;
- using LayoutA = cutlass::layout::RowMajor; // Layout type for A matrix operand
- constexpr int AlignmentA
- = 128 / cutlass::sizeof_bits::value; // Alignment of A matrix in units of elements (up to 16 bytes)
-
- // B matrix configuration
- using ElementB_ = typename CudaToCutlassTypeAdapter::type;
- using ElementB = std::conditional_t, cutlass::int4b_t, ElementB_>;
- using LayoutB = cutlass::layout::ColumnMajor; // Layout type for B matrix operand
- constexpr int AlignmentB
- = 128 / cutlass::sizeof_bits::value; // Memory access granularity/alignment of B matrix in units of
- // elements (up to 16 bytes)
-
- // This example manually swaps and transposes, so keep transpose of input layouts
- using LayoutA_Transpose = typename cutlass::layout::LayoutTranspose::type;
- using LayoutB_Transpose = typename cutlass::layout::LayoutTranspose::type;
-
- // Need to pass a pointer type to make the 3rd dimension of Stride be _0
- using StrideA = cute::remove_pointer_t>;
- using StrideB = cute::remove_pointer_t>;
-
- // Scale configuration
- constexpr bool use_wfp4a16 = std::is_same_v;
- constexpr int group_size = use_wfp4a16 ? cutlass::gemm::collective::detail::mxfp4_group_size
- : cutlass::gemm::collective::detail::int4_group_size;
- constexpr int PackedScalesNum = get<2>(CTAShape{}) / group_size;
- using ElementScale = std::conditional_t;
- using ElementScalePacked = cutlass::Array;
- using LayoutScale = cutlass::layout::RowMajor;
-
- // C/D matrix configuration
- using ElementC = typename CudaToCutlassTypeAdapter::type;
- using LayoutC = cutlass::layout::RowMajor; // Layout type for C and D matrix operands
- constexpr int AlignmentC
- = 128 / cutlass::sizeof_bits::value; // Memory access granularity/alignment of C matrix in units of
- // elements (up to 16 bytes)
-
- // D matrix configuration
- using ElementD = ElementC;
- using LayoutD = LayoutC;
- constexpr int AlignmentD = 128 / cutlass::sizeof_bits::value;
-
- using ElementFinalOutput = ElementC;
- using ElementBias = ElementFinalOutput;
- using ElementRouterScales = float;
-
- // Core kernel configurations
- using ElementAccumulator = float; // Element type for internal accumulation
- using ArchTag = cutlass::arch::Sm90; // Tag indicating the minimum SM that supports the intended feature
- using OperatorClass = cutlass::arch::OpClassTensorOp; // Operator class tag
- using TileShape = CTAShape; // Threadblock-level tile size
- using StageCountType = cutlass::gemm::collective::StageCountAuto; // Stage count maximized based on the tile size
- using KernelSchedule
- = std::conditional_t,
- cutlass::gemm::KernelPtrArrayTmaWarpSpecializedPingpong,
- cutlass::gemm::KernelPtrArrayTmaWarpSpecializedCooperative>;
- using EpilogueSchedule
- = std::conditional_t,
- cutlass::epilogue::PtrArrayTmaWarpSpecializedPingpong,
- cutlass::epilogue::PtrArrayTmaWarpSpecializedCooperative>; // Epilogue to launch
-
- using StrideC = TmaWarpSpecializedGroupedGemmInput::StrideC;
-
- // Default epilogue (NONE fusion)
- using CollectiveEpilogueDefault = typename cutlass::epilogue::collective::CollectiveBuilder::type*,
- AlignmentC, ElementD, typename cutlass::layout::LayoutTranspose::type*, AlignmentD,
- EpilogueSchedule>::CollectiveOp;
-
- // Fused finalize epilogue (FINALIZE fusion)
- using CollectiveEpilogueFinalize =
- typename cutlass::epilogue::collective::EpilogueMoeFusedFinalizeBuilder<
- ArchTag, TileShape,
- ElementC, StrideC*,
- ElementFinalOutput,
- TmaWarpSpecializedGroupedGemmInput::FusedFinalizeEpilogue::StrideFinalOutput,
- ElementAccumulator,
- ElementAccumulator,
- ElementBias, TmaWarpSpecializedGroupedGemmInput::FusedFinalizeEpilogue::StrideBias,
- ElementRouterScales,
- TmaWarpSpecializedGroupedGemmInput::FusedFinalizeEpilogue::StrideRouterScales
- >::CollectiveOp;
-
- using CollectiveEpilogue = std::conditional_t;
-
- // =========================================================== MIXED INPUT WITH SCALES
- // =========================================================================== The Scale information must get paired
- // with the operand that will be scaled. In this example, B is scaled so we make a tuple of B's information and the
- // scale information.
- using CollectiveMainloop = typename cutlass::gemm::collective::CollectiveBuilderMixedInput, LayoutB_Transpose*, AlignmentB, ElementA, LayoutA_Transpose*,
- AlignmentA, ElementAccumulator, TileShape, ClusterShape,
- cutlass::gemm::collective::StageCountAutoCarveout(
- sizeof(typename CollectiveEpilogue::SharedStorage))>,
- KernelSchedule>::CollectiveOp;
-
- using GemmKernel = cutlass::gemm::kernel::GemmUniversal>,
- CollectiveMainloop, CollectiveEpilogue>;
-
- using GemmGrouped = cutlass::gemm::device::GemmUniversalAdapter;
- using StrideD = typename GemmKernel::InternalStrideD;
- using StrideS = typename CollectiveMainloop::StrideScale;
-
- GemmGrouped gemm;
- using Args = typename GemmGrouped::Arguments;
-
- cutlass::KernelHardwareInfo hw_info;
- hw_info.device_id = 0;
- hw_info.sm_count = sm_count_;
-
- using EpilogueArguments = typename CollectiveEpilogue::Arguments;
- using EpilogueScalars = decltype(EpilogueArguments{}.thread);
-
- auto make_epilogue_scalars = [&]() -> EpilogueScalars {
- if constexpr (IsFinalizeFusion) {
- return EpilogueScalars{ElementAccumulator(1.f), ElementAccumulator(0.f)};
- } else {
- EpilogueScalars scalars;
- scalars.alpha = use_wfp4a16 ? 1 : 0;
- scalars.beta = 0;
- scalars.alpha_ptr = nullptr;
- scalars.beta_ptr = nullptr;
- scalars.alpha_ptr_array = use_wfp4a16 ? nullptr : inputs.alpha_scales;
- scalars.beta_ptr_array = nullptr;
- // One alpha and beta per each group
- scalars.dAlpha = {cute::_0{}, cute::_0{}, use_wfp4a16 ? 0 : 1};
- scalars.dBeta = {cute::_0{}, cute::_0{}, use_wfp4a16 ? 0 : 1};
- return scalars;
- }
- };
-
- auto make_epilogue_args = [&]() -> EpilogueArguments {
- auto scalars = make_epilogue_scalars();
- if constexpr (IsFinalizeFusion) {
- auto epi_params = hopper_inputs.fused_finalize_epilogue;
- return EpilogueArguments{
- scalars,
- nullptr, hopper_inputs.stride_c, // C params
- reinterpret_cast(epi_params.ptr_final_output),
- epi_params.stride_final_output, // D (output) params
- reinterpret_cast(epi_params.ptr_bias),
- epi_params.stride_bias, // Bias params
- epi_params.ptr_router_scales, epi_params.stride_router_scales, // Router scales
- epi_params.ptr_expert_first_token_offset, // Offset of this expert's token in the router scales
- epi_params.ptr_source_token_index, // Index of the source token to sum into
- epi_params.num_rows_in_final_output // Number of tokens in the output buffer
- };
- } else {
- return EpilogueArguments{scalars,
- reinterpret_cast(hopper_inputs.ptr_c), hopper_inputs.stride_c,
- reinterpret_cast(hopper_inputs.default_epilogue.ptr_d),
- hopper_inputs.default_epilogue.stride_d};
- }
- };
-
- if (workspace_size != nullptr)
- {
- const Args args{cutlass::gemm::GemmUniversalMode::kGrouped,
- {inputs.num_experts, hopper_inputs.int4_groupwise_params.shape.problem_shapes, nullptr},
- {reinterpret_cast(hopper_inputs.ptr_b), hopper_inputs.stride_b,
- reinterpret_cast(hopper_inputs.ptr_a), hopper_inputs.stride_a,
- reinterpret_cast(hopper_inputs.int4_groupwise_params.ptr_s_a),
- hopper_inputs.int4_groupwise_params.stride_s_a, group_size},
- make_epilogue_args(),
- hw_info};
- *workspace_size = gemm.get_workspace_size(args);
- return;
+ TmaWarpSpecializedGroupedGemmInput hopper_inputs, int sm_count_, size_t* workspace_size) {
+ ORT_LLM_LOG_ENTRY();
+
+ /////////////////////////////////////////////////////////////////////////////////////////////////
+ /// GEMM kernel configurations
+ /////////////////////////////////////////////////////////////////////////////////////////////////
+ static_assert(FUSION == EpilogueFusion::NONE || FUSION == EpilogueFusion::FINALIZE,
+ "Unimplemented fusion provided to TMA WS Mixed MoE gemm launcher");
+ constexpr static bool IsFinalizeFusion = FUSION == EpilogueFusion::FINALIZE;
+
+ /////////////////////////////////////////////////////////////////////////////////////////////////
+ /// GEMM kernel configurations
+ /////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // A matrix configuration
+ using ElementA = typename CudaToCutlassTypeAdapter::type;
+ using LayoutA = cutlass::layout::RowMajor; // Layout type for A matrix operand
+ constexpr int AlignmentA = 128 / cutlass::sizeof_bits::value; // Alignment of A matrix in units of elements (up to 16 bytes)
+
+ // B matrix configuration
+ using ElementB_ = typename CudaToCutlassTypeAdapter::type;
+ using ElementB = std::conditional_t, cutlass::int4b_t, ElementB_>;
+ using LayoutB = cutlass::layout::ColumnMajor; // Layout type for B matrix operand
+ constexpr int AlignmentB = 128 / cutlass::sizeof_bits::value; // Memory access granularity/alignment of B matrix in units of
+ // elements (up to 16 bytes)
+
+ // This example manually swaps and transposes, so keep transpose of input layouts
+ using LayoutA_Transpose = typename cutlass::layout::LayoutTranspose::type;
+ using LayoutB_Transpose = typename cutlass::layout::LayoutTranspose::type;
+
+ // Need to pass a pointer type to make the 3rd dimension of Stride be _0
+ using StrideA = cute::remove_pointer_t>;
+ using StrideB = cute::remove_pointer_t>;
+
+ // Scale configuration
+ constexpr bool use_wfp4a16 = std::is_same_v;
+ constexpr int group_size = use_wfp4a16 ? cutlass::gemm::collective::detail::mxfp4_group_size
+ : cutlass::gemm::collective::detail::int4_group_size;
+ constexpr int PackedScalesNum = get<2>(CTAShape{}) / group_size;
+ using ElementScale = std::conditional_t;
+ using ElementScalePacked = cutlass::Array;
+ using LayoutScale = cutlass::layout::RowMajor;
+
+ // C/D matrix configuration
+ using ElementC = typename CudaToCutlassTypeAdapter::type;
+ using LayoutC = cutlass::layout::RowMajor; // Layout type for C and D matrix operands
+ constexpr int AlignmentC = 128 / cutlass::sizeof_bits::value; // Memory access granularity/alignment of C matrix in units of
+ // elements (up to 16 bytes)
+
+ // D matrix configuration
+ using ElementD = ElementC;
+ using LayoutD = LayoutC;
+ constexpr int AlignmentD = 128 / cutlass::sizeof_bits::value;
+
+ using ElementFinalOutput = ElementC;
+ using ElementBias = ElementFinalOutput;
+ using ElementRouterScales = float;
+
+ // Core kernel configurations
+ using ElementAccumulator = float; // Element type for internal accumulation
+ using ArchTag = cutlass::arch::Sm90; // Tag indicating the minimum SM that supports the intended feature
+ using OperatorClass = cutlass::arch::OpClassTensorOp; // Operator class tag
+ using TileShape = CTAShape; // Threadblock-level tile size
+ using StageCountType = cutlass::gemm::collective::StageCountAuto; // Stage count maximized based on the tile size
+ using KernelSchedule = std::conditional_t,
+ cutlass::gemm::KernelPtrArrayTmaWarpSpecializedPingpong,
+ cutlass::gemm::KernelPtrArrayTmaWarpSpecializedCooperative>;
+ using EpilogueSchedule = std::conditional_t,
+ cutlass::epilogue::PtrArrayTmaWarpSpecializedPingpong,
+ cutlass::epilogue::PtrArrayTmaWarpSpecializedCooperative>; // Epilogue to launch
+
+ using StrideC = TmaWarpSpecializedGroupedGemmInput::StrideC;
+
+ // Default epilogue (NONE fusion)
+ using CollectiveEpilogueDefault = typename cutlass::epilogue::collective::CollectiveBuilder::type*,
+ AlignmentC, ElementD, typename cutlass::layout::LayoutTranspose::type*, AlignmentD,
+ EpilogueSchedule>::CollectiveOp;
+
+ // Fused finalize epilogue (FINALIZE fusion)
+ using CollectiveEpilogueFinalize =
+ typename cutlass::epilogue::collective::EpilogueMoeFusedFinalizeBuilder<
+ ArchTag, TileShape,
+ ElementC, StrideC*,
+ ElementFinalOutput,
+ TmaWarpSpecializedGroupedGemmInput::FusedFinalizeEpilogue::StrideFinalOutput,
+ ElementAccumulator,
+ ElementAccumulator,
+ ElementBias, TmaWarpSpecializedGroupedGemmInput::FusedFinalizeEpilogue::StrideBias,
+ ElementRouterScales,
+ TmaWarpSpecializedGroupedGemmInput::FusedFinalizeEpilogue::StrideRouterScales>::CollectiveOp;
+
+ using CollectiveEpilogue = std::conditional_t;
+
+ // =========================================================== MIXED INPUT WITH SCALES
+ // =========================================================================== The Scale information must get paired
+ // with the operand that will be scaled. In this example, B is scaled so we make a tuple of B's information and the
+ // scale information.
+ using CollectiveMainloop = typename cutlass::gemm::collective::CollectiveBuilderMixedInput, LayoutB_Transpose*, AlignmentB, ElementA, LayoutA_Transpose*,
+ AlignmentA, ElementAccumulator, TileShape, ClusterShape,
+ cutlass::gemm::collective::StageCountAutoCarveout(
+ sizeof(typename CollectiveEpilogue::SharedStorage))>,
+ KernelSchedule>::CollectiveOp;
+
+ using GemmKernel = cutlass::gemm::kernel::GemmUniversal>,
+ CollectiveMainloop, CollectiveEpilogue>;
+
+ using GemmGrouped = cutlass::gemm::device::GemmUniversalAdapter;
+ using StrideD = typename GemmKernel::InternalStrideD;
+ using StrideS = typename CollectiveMainloop::StrideScale;
+
+ GemmGrouped gemm;
+ using Args = typename GemmGrouped::Arguments;
+
+ cutlass::KernelHardwareInfo hw_info;
+ hw_info.device_id = 0;
+ hw_info.sm_count = sm_count_;
+
+ using EpilogueArguments = typename CollectiveEpilogue::Arguments;
+ using EpilogueScalars = decltype(EpilogueArguments{}.thread);
+
+ auto make_epilogue_scalars = [&]() -> EpilogueScalars {
+ if constexpr (IsFinalizeFusion) {
+ return EpilogueScalars{ElementAccumulator(1.f), ElementAccumulator(0.f)};
+ } else {
+ auto const* alpha_ptr_array = use_wfp4a16 ? hopper_inputs.alpha_scale_ptr_array : inputs.alpha_scales;
+ EpilogueScalars scalars;
+ scalars.alpha = alpha_ptr_array ? 0 : 1;
+ scalars.beta = 0;
+ scalars.alpha_ptr = nullptr;
+ scalars.beta_ptr = nullptr;
+ scalars.alpha_ptr_array = alpha_ptr_array;
+ scalars.beta_ptr_array = nullptr;
+ // One alpha and beta per each group
+ scalars.dAlpha = {cute::_0{}, cute::_0{}, alpha_ptr_array ? 1 : 0};
+ scalars.dBeta = {cute::_0{}, cute::_0{}, 0};
+ return scalars;
}
-
- auto arguments = Args{cutlass::gemm::GemmUniversalMode::kGrouped,
- {inputs.num_experts, hopper_inputs.int4_groupwise_params.shape.problem_shapes, nullptr},
- {reinterpret_cast(hopper_inputs.ptr_b), hopper_inputs.stride_b,
- reinterpret_cast(hopper_inputs.ptr_a), hopper_inputs.stride_a,
- reinterpret_cast(hopper_inputs.int4_groupwise_params.ptr_s_a),
- hopper_inputs.int4_groupwise_params.stride_s_a, group_size},
- make_epilogue_args(),
- hw_info};
-
- size_t const required_workspace = gemm.get_workspace_size(arguments);
- ORT_ENFORCE(required_workspace <= hopper_inputs.gemm_workspace_size,
- "[Mixed dtype WS grouped GEMM] given workspace size insufficient, ", hopper_inputs.gemm_workspace_size,
- " < ", required_workspace);
-
- auto can_implement = gemm.can_implement(arguments);
- if (can_implement != cutlass::Status::kSuccess)
- {
- std::string err_msg = "mixed dtype WS grouped cutlass kernel will fail for params. Error: "
- + std::string(cutlassGetStatusString(can_implement));
- std::cout << err_msg << std::endl;
- ORT_THROW("[Mixed dtype WS grouped GEMM] " + err_msg);
- }
-
- auto init_status = gemm.initialize(arguments, hopper_inputs.gemm_workspace, inputs.stream);
- if (init_status != cutlass::Status::kSuccess)
- {
- std::string err_msg = "Failed to initialize cutlass mixed dtype WS grouped gemm. Error: "
- + std::string(cutlassGetStatusString(init_status));
- ORT_THROW("[Mixed dtype WS grouped GEMM] " + err_msg);
- }
-
- auto run_status = gemm.run(inputs.stream);
- if (run_status != cutlass::Status::kSuccess)
- {
- std::string err_msg = "Failed to run cutlass mixed dtype WS grouped gemm. Error: "
- + std::string(cutlassGetStatusString(run_status));
- ORT_THROW("[Mixed dtype WS grouped GEMM] " + err_msg);
+ };
+
+ auto make_epilogue_args = [&]() -> EpilogueArguments {
+ auto scalars = make_epilogue_scalars();
+ if constexpr (IsFinalizeFusion) {
+ auto epi_params = hopper_inputs.fused_finalize_epilogue;
+ return EpilogueArguments{
+ scalars,
+ nullptr, hopper_inputs.stride_c, // C params
+ reinterpret_cast(epi_params.ptr_final_output),
+ epi_params.stride_final_output, // D (output) params
+ reinterpret_cast(epi_params.ptr_bias),
+ epi_params.stride_bias, // Bias params
+ epi_params.ptr_router_scales, epi_params.stride_router_scales, // Router scales
+ epi_params.ptr_expert_first_token_offset, // Offset of this expert's token in the router scales
+ epi_params.ptr_source_token_index, // Index of the source token to sum into
+ epi_params.num_rows_in_final_output // Number of tokens in the output buffer
+ };
+ } else {
+ return EpilogueArguments{scalars,
+ reinterpret_cast(hopper_inputs.ptr_c), hopper_inputs.stride_c,
+ reinterpret_cast(hopper_inputs.default_epilogue.ptr_d),
+ hopper_inputs.default_epilogue.stride_d};
}
+ };
+
+ if (workspace_size != nullptr) {
+ const Args args{cutlass::gemm::GemmUniversalMode::kGrouped,
+ {inputs.num_experts, hopper_inputs.int4_groupwise_params.shape.problem_shapes, nullptr},
+ {reinterpret_cast(hopper_inputs.ptr_b), hopper_inputs.stride_b,
+ reinterpret_cast(hopper_inputs.ptr_a), hopper_inputs.stride_a,
+ reinterpret_cast(hopper_inputs.int4_groupwise_params.ptr_s_a),
+ hopper_inputs.int4_groupwise_params.stride_s_a, group_size},
+ make_epilogue_args(),
+ hw_info};
+ *workspace_size = gemm.get_workspace_size(args);
return;
+ }
+
+ auto arguments = Args{cutlass::gemm::GemmUniversalMode::kGrouped,
+ {inputs.num_experts, hopper_inputs.int4_groupwise_params.shape.problem_shapes, nullptr},
+ {reinterpret_cast(hopper_inputs.ptr_b), hopper_inputs.stride_b,
+ reinterpret_cast(hopper_inputs.ptr_a), hopper_inputs.stride_a,
+ reinterpret_cast(hopper_inputs.int4_groupwise_params.ptr_s_a),
+ hopper_inputs.int4_groupwise_params.stride_s_a, group_size},
+ make_epilogue_args(),
+ hw_info};
+
+ size_t const required_workspace = gemm.get_workspace_size(arguments);
+ ORT_ENFORCE(required_workspace <= hopper_inputs.gemm_workspace_size,
+ "[Mixed dtype WS grouped GEMM] given workspace size insufficient, ", hopper_inputs.gemm_workspace_size,
+ " < ", required_workspace);
+
+ auto can_implement = gemm.can_implement(arguments);
+ if (can_implement != cutlass::Status::kSuccess) {
+ std::string err_msg = "mixed dtype WS grouped cutlass kernel will fail for params. Error: " + std::string(cutlassGetStatusString(can_implement));
+ std::cout << err_msg << std::endl;
+ ORT_THROW("[Mixed dtype WS grouped GEMM] " + err_msg);
+ }
+
+ auto init_status = gemm.initialize(arguments, hopper_inputs.gemm_workspace, inputs.stream);
+ if (init_status != cutlass::Status::kSuccess) {
+ std::string err_msg = "Failed to initialize cutlass mixed dtype WS grouped gemm. Error: " + std::string(cutlassGetStatusString(init_status));
+ ORT_THROW("[Mixed dtype WS grouped GEMM] " + err_msg);
+ }
+
+ auto run_status = gemm.run(inputs.stream);
+ if (run_status != cutlass::Status::kSuccess) {
+ std::string err_msg = "Failed to run cutlass mixed dtype WS grouped gemm. Error: " + std::string(cutlassGetStatusString(run_status));
+ ORT_THROW("[Mixed dtype WS grouped GEMM] " + err_msg);
+ }
+ return;
}
-} // namespace cutlass_kernels
-} // namespace kernels
-} // namespace onnxruntime::llm
+} // namespace cutlass_kernels
+} // namespace kernels
+} // namespace onnxruntime::llm
diff --git a/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_kernels.h b/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_kernels.h
index 2a17f00191f8f..b6577dfcfff6a 100644
--- a/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_kernels.h
+++ b/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_kernels.h
@@ -201,8 +201,10 @@ struct TmaWarpSpecializedGroupedGemmInput {
FpXBlockScalingType fpX_block_scaling_type = FpXBlockScalingType::NONE;
struct INT4GroupwiseParams {
- constexpr static int group_size = 128; // Unused, hard-coded to 128
+ constexpr static int int4_group_size = 128;
+ constexpr static int wfp4a16_group_size = 32;
bool enabled = false;
+ bool use_wfp4a16 = false;
using SFA = __nv_bfloat16;
using SFB = __nv_bfloat16; // Unused
using ProblemShapeInt = cutlass::gemm::GroupProblemShape>;
@@ -299,9 +301,16 @@ class MoeGemmRunner {
TmaWarpSpecializedGroupedGemmInput hopper_inputs);
std::vector getConfigs() const;
- static std::vector getConfigs(int sm);
- static std::vector getTmaWarpSpecializedConfigs(int sm);
- static std::vector getAmpereConfigs(int sm);
+ static std::vector getConfigs(int sm, bool use_sm80_fp4 = false);
+ static std::vector getTmaWarpSpecializedConfigs(int sm, bool use_sm80_fp4 = false);
+ static std::vector getAmpereConfigs(int sm, bool use_sm80_fp4 = false);
+
+ // Route wfp4a16 (MXFP4 weights + FP16/BF16 activations) through the SM80 fused-dequant grouped
+ // GEMM instead of the SM90 TMA WS path. The decision is made once in the QMoE op constructor
+ // (which reads ORT_FP4_SM80_GEMM / ORT_ENABLE_FP4_CUTLASS_GEMM at the right time) and pushed in
+ // here, so config selection at inference time does NOT depend on the live environment.
+ void setUseSm80Fp4(bool v) { use_sm80_fp4_ = v; }
+ [[nodiscard]] bool useSm80Fp4() const { return use_sm80_fp4_; }
[[nodiscard]] bool isTmaWarpSpecialized(cutlass_extensions::CutlassGemmConfig gemm_config) const;
[[nodiscard]] bool supportsTmaWarpSpecialized() const;
@@ -325,6 +334,9 @@ class MoeGemmRunner {
private:
int sm_{};
int multi_processor_count_{};
+ // wfp4a16 only: when true, config selection offers the SM80 fused-dequant Ampere grouped GEMM
+ // and suppresses the SM90 TMA WS configs. Set by the QMoE op after construction.
+ bool use_sm80_fp4_ = false;
mutable int num_experts_ = 0;
mutable size_t gemm_workspace_size_ = 0;
size_t calcMaxWorkspaceSize(int num_experts) const;
diff --git a/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_template_dispatch.h b/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_template_dispatch.h
index 7c63fb948c045..15fd5042fdc38 100644
--- a/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_template_dispatch.h
+++ b/onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_template_dispatch.h
@@ -127,7 +127,11 @@ struct genericMoeGemmKernelLauncher {
"Specialized for half, float");
#endif
- static_assert(cutlass::platform::is_same::value || cutlass::platform::is_same::value || cutlass::platform::is_same::value);
+ static_assert(cutlass::platform::is_same::value || cutlass::platform::is_same::value || cutlass::platform::is_same::value
+#if defined(ENABLE_FP4)
+ || cutlass::platform::is_same::value
+#endif
+ );
static_assert(arch::kMinComputeCapability < 90, "Sm90+ architecture should use specialized kernels");
@@ -371,7 +375,7 @@ static void dispatch(GroupedGemmInput
MoeGemmRunner::getConfigs() const {
ORT_LLM_LOG_ENTRY();
- return getConfigs(sm_);
+ return getConfigs(sm_, use_sm80_fp4_);
+}
+
+// Whether wfp4a16 should use the SM80 fused-dequant grouped GEMM (vs the SM90 TMA WS path).
+// The ``use_sm80_fp4`` flag is the decision captured by the QMoE op constructor and pushed into
+// the runner via setUseSm80Fp4(); we only add the hard architectural guard that the SM80 path is
+// for Ampere through pre-Blackwell. No environment is read here, so inference-time config selection
+// is independent of the live environment.
+inline bool moeUseSm80Fp4(int sm, bool use_sm80_fp4) {
+ return use_sm80_fp4 && sm >= 80 && sm < 120;
}
template
std::vector MoeGemmRunner::getConfigs(
- int sm) {
+ int sm, bool use_sm80_fp4) {
ORT_LLM_LOG_ENTRY();
- std::vector candidate_configs = getTmaWarpSpecializedConfigs(sm);
- std::vector ampere_configs = getAmpereConfigs(sm);
+ std::vector candidate_configs = getTmaWarpSpecializedConfigs(sm, use_sm80_fp4);
+ std::vector ampere_configs = getAmpereConfigs(sm, use_sm80_fp4);
std::copy(ampere_configs.begin(), ampere_configs.end(), std::back_inserter(candidate_configs));
return candidate_configs;
}
template
std::vector
-MoeGemmRunner::getAmpereConfigs(int sm) {
+MoeGemmRunner::getAmpereConfigs(int sm, bool use_sm80_fp4) {
ORT_LLM_LOG_ENTRY();
using onnxruntime::llm::cutlass_extensions::CutlassGemmConfig;
static constexpr auto weight_only_flag = std::is_same::value ? CutlassGemmConfig::NONE : CutlassGemmConfig::WEIGHT_ONLY;
@@ -630,6 +643,13 @@ MoeGemmRunner::getAmpereConfigs(int sm
if (!kernels::cutlass_kernels::isValidAmpereMOESpecialisation() || (use_w4afp8 && sm != 89)) {
return {};
}
+ // wfp4a16 is Ampere-valid (for the SM80 fused-dequant path) but only offer SM80 configs when the
+ // SM80 FP4 path is enabled (default-on for sm < 120); otherwise the default SM90 TMA WS path is used.
+ if constexpr (use_wfp4a16) {
+ if (!moeUseSm80Fp4(sm, use_sm80_fp4)) {
+ return {};
+ }
+ }
std::vector ampere_configs = kernels::cutlass_kernels::get_candidate_configs(sm, max_split_k, config_type_param);
return ampere_configs;
@@ -637,7 +657,7 @@ MoeGemmRunner::getAmpereConfigs(int sm
template
std::vector
-MoeGemmRunner::getTmaWarpSpecializedConfigs(int sm) {
+MoeGemmRunner::getTmaWarpSpecializedConfigs(int sm, bool use_sm80_fp4) {
ORT_LLM_LOG_ENTRY();
using onnxruntime::llm::cutlass_extensions::CutlassGemmConfig;
static constexpr auto weight_only_flag = std::is_same::value ? CutlassGemmConfig::NONE : CutlassGemmConfig::WEIGHT_ONLY;
@@ -652,6 +672,14 @@ MoeGemmRunner