Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 176 additions & 178 deletions docs/ContribOperators.md

Large diffs are not rendered by default.

77 changes: 71 additions & 6 deletions docs/contrib_ops/cuda/moe_qmoe.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
| `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). |
| `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 the EP's SM90 (Hopper) 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()` for the runtime arch. **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). |
Comment thread
tianleiwu marked this conversation as resolved.
Outdated

### 2.2 Type Constraints

Expand Down Expand Up @@ -228,10 +229,53 @@

### 5.1 Weights (input 2 / 5 / 8)

Not transformed at runtime. INT4/INT8 weights must already be packed offline by
`pack_weights_for_cuda_mixed_gemm` (see [§6](#6-weight-formats)). MXFP4 weights
must be packed by `pack_fp4_weights_for_cuda_moe_gemm`. FP8 weights are stored
as raw e4m3 bytes (no packing).
**INT4/INT8** weight layout is controlled by the `weights_prepacked` attribute
([§2.1](#21-attributes)). The prepacked layouts selected by `-1` and `1` are
determined by the execution provider:

- **`weights_prepacked=-1` (default)** — the `fc1`/`fc2` weights are already in
the EP's default prepacked layout (e.g. packed offline by
`pack_weights_for_cuda_mixed_gemm` for the CUDA EP). They are copied to GPU
and consumed as-is.
- **`weights_prepacked=1`** — the `fc1`/`fc2` weights are already in the EP's
**SM90** (Hopper) prepacked layout (reserved; see the note below).
- **`weights_prepacked=0`** — the `fc1`/`fc2` weights are raw, schema-conformant
`[E, N, K/pack]` tensors as produced by `quantize_matmul_{4,8}bits`. `PrePack`
runs the CUTLASS layout transform itself via `PrePackIntExpertWeights`,
removing the offline pre-pack dependency. This makes integer QMoE symmetric
with `MatMulNBits::PrePack_B`.

> **Single layout on the CUDA EP.** The CUDA EP INT4/INT8 MoE GEMM always
> dispatches to the Ampere (**SM80**) grouped-GEMM kernel — even on SM90 —
> because mixed int-weight + fp16/bf16 activation is not a valid Hopper TMA
> warp-specialized specialisation (`isValidHopperMOESpecialisation` is `false`).
> This matches **TensorRT-LLM**, which likewise routes `W4A16`/`W8A16` MoE to the
> SM80 kernel on Hopper; its Hopper TMA-WS mixed-dtype MoE kernel is reserved for
> `W4A8` (FP8 activation) and `WFP4A16` (FP4 weight). Consequently the CUDA EP
> consumes the **SM80 `fpA_intB` layout on every GPU**, `PrePack` always packs
> for SM80, and `weights_prepacked=-1` and `=1` are equivalent today. `1` is
> accepted and reserved for a possible future Hopper-specific layout (e.g.
> `W4A8`). There is therefore no architecture-match constraint: SM80-format
> weights run correctly on SM90 via the SM80 kernel.

`PrePackIntExpertWeights` loops over the `E` experts and, per expert, applies the
same transpose + row-permutation / column-interleave / bias / pair-interleave
transform as `pack_weights_for_cuda_mixed_gemm` (see [§6.1](#61-int4-group-wise-quant_typeint-expert_weight_bits4)),
always targeting the SM80 layout. SM75+ is required. The source
`[E, N, K/pack]` initializers are released after their shapes are cached
(`fc1_weights_shape_` / `fc2_weights_shape_`), so peak weight memory stays ~1×.
The prepacked GPU buffers (`packed_fc1_weights_` / `packed_fc2_weights_`) are then
preferred by `ComputeInternal`. If prepacking is disabled at the session level
(`session.disable_prepacking`), the buffers stay null and the raw initializer
pointers are read at compute time instead.

> **Note**: `weights_prepacked=0` is the only path that triggers an in-`PrePack`
> layout transform for INT weights. FP4 / FP8 / WFP4AFP8 weight handling is
> unaffected.

MXFP4 weights must be packed by `pack_fp4_weights_for_cuda_moe_gemm`. FP8 weights
are stored as raw e4m3 bytes (no packing).


### 5.2 INT4/INT8 scales + zero-point → bias

Expand Down Expand Up @@ -287,7 +331,12 @@
INT4 packing layout within a byte: `[high_nibble | low_nibble] = [elt_1 | elt_0]`.
Each INT4 element is in `[-8, 7]` (signed) before bias, `[0, 15]` after the +8 bias.

#### Preprocessing pipeline (offline, `pack_weights_for_cuda_mixed_gemm`)
#### Preprocessing pipeline (offline `pack_weights_for_cuda_mixed_gemm`, or in-`PrePack` via `PrePackIntExpertWeights`)

This is the layout transform applied either offline by
`pack_weights_for_cuda_mixed_gemm`, or per-expert inside `PrePack` when
`weights_prepacked=0` (see [§5.1](#51-weights-input-2--5--8)).


1. **Input layout**: `[N, K]` per expert (Out × In), 2 elements per byte for INT4.
2. **Transpose & signed conversion**:
Expand Down Expand Up @@ -405,6 +454,17 @@
— does not use `pack_weights_for_cuda_mixed_gemm`.
- **FP8**: no packing.

> **QMoE uses Group A on every GPU.** The table above describes the layouts the
> `pack_weights_for_cuda_mixed_gemm` *preprocessor* can emit. The QMoE INT4/INT8
> MoE GEMM, however, always dispatches to the Ampere (SM80) grouped-GEMM kernel —
> even on SM90 — because mixed int-weight + fp16/bf16 activation is not a valid
> Hopper TMA warp-specialized specialisation (the same is true in TensorRT-LLM).
> It therefore consumes the **Group A (SM80) layout on all architectures,
> including Hopper**. For QMoE, always pack INT4/INT8 weights for SM80 (`arch=80`),
> and `PrePackIntExpertWeights` (`weights_prepacked=0`) does exactly that
> regardless of the runtime device SM. Group B (SM90) layout is currently unused
> by QMoE.

---

## 8. SwiGLU Fusion
Expand Down Expand Up @@ -830,7 +890,7 @@
|-----------|----------|
| [test_moe_cuda.py](onnxruntime/test/python/transformers/test_moe_cuda.py) | Standard MoE on CUDA: FP16/BF16, SiLU/GeLU/SwiGLU, routing, GEMM parity. SwiGLU coverage includes both GPT-OSS (`TestSwigluMoE`: interleaved, alpha=1.702/beta=1.0/limit=7.0) and Standard/Llama-Gemma (`TestStandardSwigluMoE`: concatenated `swiglu_fusion=2`, alpha=1.0/beta=0.0/no limit → `SiLU(Gate)×Value`). |
| [test_moe_cpu.py](onnxruntime/test/python/transformers/test_moe_cpu.py) | Standard MoE on CPU (smoke). |
| [test_qmoe_cuda.py](onnxruntime/test/python/transformers/test_qmoe_cuda.py) | INT4/INT8 QMoE — primary regression signal for the production QMoE path. Exercises `pack_weights_for_cuda_mixed_gemm` and dequant-then-matmul reference. |
| [test_qmoe_cuda.py](onnxruntime/test/python/transformers/test_qmoe_cuda.py) | INT4/INT8 QMoE — primary regression signal for the production QMoE path. Exercises `pack_weights_for_cuda_mixed_gemm` and dequant-then-matmul reference. `TestQMoEIntPrePackSmoke` covers the raw-weight `weights_prepacked=0` in-`PrePack` layout transform (smoke test: asserts finite output, not bit-parity). |
| [test_qmoe_cpu.py](onnxruntime/test/python/transformers/test_qmoe_cpu.py) | INT4/INT8 QMoE on CPU (smoke). |
| [test_qmoe_fp4_cuda.py](onnxruntime/test/python/transformers/test_qmoe_fp4_cuda.py) | MXFP4 QMoE: quantization utilities, packing, FP16/BF16, SiLU/SwiGLU, top-k and expert-count variants. End-to-end runs on SM120; on SM<120 the dequant fallback is exercised. |
| [test_qmoe_fp8_cuda.py](onnxruntime/test/python/transformers/test_qmoe_fp8_cuda.py) | FP8 W8A16 QMoE on SM90+ native path and SM<90 dequant fallback. |
Expand Down Expand Up @@ -954,6 +1014,11 @@
cannot. See [§14.1](#141-msvc-and-tma-grouped-moe-gemm).
- **WFP4AFP8 native** requires SM100+ hardware; only the dequant fallback path
is validated end-to-end so far.
- **In-`PrePack` INT weight layout transform** (`weights_prepacked=0`) is
currently covered only by a smoke test (`TestQMoEIntPrePackSmoke`), not a
bit-parity check: the existing offline pre-pack harness hardcodes
`force_arch=80` and produces incorrect output on SM≥90, so a parity
comparison against it is omitted until that harness honours the runtime SM.

Check warning on line 1021 in docs/contrib_ops/cuda/moe_qmoe.md

View workflow job for this annotation

GitHub Actions / Optional Lint

[misspell] reported by reviewdog 🐶 "honours" is a misspelling of "honors" Raw Output: ./docs/contrib_ops/cuda/moe_qmoe.md:1021:54: "honours" is a misspelling of "honors"
Comment thread
tianleiwu marked this conversation as resolved.
Outdated
- **Hopper W4A8** (INT4 weight + FP8 activation) is not supported — TRT-LLM gates
its fast path to SM89 only.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ enum class QuantType {
W4_AFP8
};

int get_arch_for_mixed_gemm_weight_preprocess(int arch);

void preprocess_weights_for_mixed_gemm_cuda(cudaStream_t stream,
int arch,
int8_t* preprocessed_quantized_weight,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,19 @@ void add_bias_and_interleave_quantized_tensor_inplace_cuda(
}
}

int get_arch_for_mixed_gemm_weight_preprocess(int arch) {
ORT_ENFORCE(arch >= 75, "Unsupported CUDA architecture: ", arch);
if (arch < 80) {
return 75;
}
#ifndef EXCLUDE_SM_90
if (arch >= 90 && arch < 100) {
return 90;
}
#endif
return 80;
}

void preprocess_weights_for_mixed_gemm_cuda(cudaStream_t stream,
int arch,
int8_t* preprocessed_quantized_weight,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,11 @@ LayoutDetails getLayoutDetailsForArch(QuantType quant_type) {
}

LayoutDetails getLayoutDetailsForTransform(QuantType quant_type, int arch) {
ORT_ENFORCE(arch >= 75, "Unsupported CUDA architecture: ", arch);
if (arch < 80) {
arch = get_arch_for_mixed_gemm_weight_preprocess(arch);
if (arch == 75) {
return getLayoutDetailsForArch<cutlass::arch::Sm75>(quant_type);
#ifndef EXCLUDE_SM_90
} else if (arch >= 90 && arch < 100) {
} else if (arch == 90) {
return getLayoutDetailsForArch<cutlass::arch::Sm90>(quant_type);
#endif
} else {
Expand Down
53 changes: 27 additions & 26 deletions onnxruntime/contrib_ops/cuda/moe/moe_quantization.cc
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,26 @@ QMoE::QMoE(const OpKernelInfo& op_kernel_info) : CudaKernel(op_kernel_info), MoE
this->quant_type_ = op_kernel_info.GetAttrOrDefault<std::string>("quant_type", "int");
ORT_ENFORCE(quant_type_ == "int" || quant_type_ == "fp4" || quant_type_ == "fp8" || quant_type_ == "wfp4afp8",
"quant_type must be 'int', 'fp4', 'fp8', or 'wfp4afp8', but got '", quant_type_, "'");
// ``weights_prepacked`` is an optional tri-state attribute that defaults to
// -1 (auto) in the schema, so each EP picks its own backward-compatible
// default rather than the schema imposing one:
// -1 (auto, also the schema default): the EP decides. The CUDA EP's
// backward-compatible default is "prepacked" because all pre-existing
// tooling ships CUTLASS-prepacked weights.
// 1: initializers are already prepacked; the compute path reads them as-is.
// 0: initializers are raw [E, N, K/pack]; the PrePack hook lays them out.
// ``weights_prepacked`` is an optional tri-state attribute (default -1) that
// declares the layout of the int4/int8 fc1/fc2 weight initializers. The
// concrete prepacked layouts selected by -1 and 1 are determined by the
// execution provider. The CUDA EP maps the tri-state as:
// -1 (default): already prepacked in the EP's default int weight layout.
// 1: already prepacked in the EP's SM90 (Hopper) int weight layout.
// 0: raw [E, N, K/pack] initializers; the PrePack hook lays them out.
Comment thread
tianleiwu marked this conversation as resolved.
//
// Important: the CUDA QMoE int4/int8 MoE GEMM always dispatches to the
// Ampere (SM80) grouped-GEMM kernel -- even on SM90 -- because mixed
// int-weight + fp16/bf16 activation is not a valid Hopper TMA warp-specialized
// specialisation (see isValidHopperMOESpecialisation). The kernel therefore
// consumes the SM80/Ampere CUTLASS fpA_intB layout on every GPU. As a result
// the EP default (-1) is the SM80 layout regardless of the runtime device SM,
// and SM80-format weights are valid on SM90 (they run via the SM80 kernel).
// PrePack (weights_prepacked=0) packs for the SM80 layout accordingly.
const int64_t weights_prepacked_mode =
op_kernel_info.GetAttrOrDefault<int64_t>("weights_prepacked", static_cast<int64_t>(-1));
ORT_ENFORCE(weights_prepacked_mode == -1 || weights_prepacked_mode == 0 || weights_prepacked_mode == 1,
"weights_prepacked must be -1 (auto), 0, or 1, but got ", weights_prepacked_mode);
"weights_prepacked must be -1 (default), 0, or 1, but got ", weights_prepacked_mode);
weights_prepacked_ = (weights_prepacked_mode != 0);
#if !defined(ENABLE_FP4) || !defined(USE_FP4_QMOE)
ORT_ENFORCE(quant_type_ != "fp4", "QMoE quant_type='fp4' requires USE_FP4_QMOE with CUDA 12.8 or newer.");
Expand Down Expand Up @@ -850,7 +858,7 @@ Status QMoE::ComputeInternal(OpKernelContext* context) const {
// PrePack converted the raw int4/int8 weights to the CUTLASS fpA_intB
// layout that the runner consumes and freed the source initializer
// (``is_packed = true``). Gate on ``int_weights_consumed_by_prepack``
// (which already requires ``packed_fc1_weights_ != nullptr``) rather than
// (which already requires both packed weight buffers) rather than
// just ``is_int && !weights_prepacked_``: when prepacking is disabled at
// the session level (``session.disable_prepacking``) PrePack never runs,
// the prepack buffers stay null, and the raw initializer pointers read
Expand Down Expand Up @@ -1158,22 +1166,15 @@ void QMoE::PrePackIntExpertWeights(const Tensor& tensor, cudaStream_t stream, Al
const int64_t k_packed = shape[2];
const int64_t k = k_packed * pack_factor;

// Weight packing is architecture-aware (see
// docs/contrib_ops/cuda/moe_qmoe.md §7 "Cross-Architecture Packing
// Compatibility"). SM90 (Hopper) uses its own Permuted-Linear layout that
// skips column interleaving, so it is its own compatibility group. Every
// other supported arch — SM75/80/86/89 and SM100/120 (Blackwell) — shares
// the SM80 fpA_intB layout, so they all pack as SM80. SM70 and older lack
// INT8 LDSM and are unsupported. The compute-side runner selects the same
// layout from this clamped arch, so the two cannot drift.
//
// SM75 is passed through unchanged (rather than clamped to 80) even though it
// shares SM80's layout: the compute-side dispatch (getLayoutDetailsForTransform)
// still has a distinct SM75 branch, so mirroring it here avoids confusing a
// reader into thinking prepack and dispatch disagree.
ORT_ENFORCE(sm_ >= 75,
"QMoE int4/int8 weight prepack requires SM75 or newer, got sm=", sm_);
const int packing_sm = (sm_ == 90 || sm_ == 75) ? sm_ : 80;
// The CUDA QMoE int4/int8 MoE GEMM always dispatches to the Ampere (SM80)
// grouped-GEMM kernel -- even on SM90 -- because mixed int-weight + fp16/bf16
// is not a valid Hopper TMA warp-specialized specialisation. The kernel thus
// consumes the SM80 CUTLASS fpA_intB layout on every GPU, so the weights must
// always be preprocessed for SM80 regardless of the runtime device SM.
// (Using get_arch_for_mixed_gemm_weight_preprocess(sm_) here would emit the
// SM90 layout on Hopper, which the SM80 kernel cannot consume -> wrong output.)
const int packing_sm =
onnxruntime::llm::kernels::weight_only::get_arch_for_mixed_gemm_weight_preprocess(80);
Comment thread
justinchuby marked this conversation as resolved.

// Per-expert sizes.
const size_t per_expert_bytes = static_cast<size_t>(n) * static_cast<size_t>(k) / pack_factor;
Expand Down
27 changes: 17 additions & 10 deletions onnxruntime/contrib_ops/cuda/moe/moe_quantization.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,23 @@ class QMoE final : public CudaKernel, public MoEBase {
IAllocatorUniquePtr<void>& packed_buf, bool& is_packed);
int64_t expert_weight_bits_;
bool is_fp16_;
// When true (the schema default), the int4/int8 fc1/fc2 weight
// initializers are already in the CUTLASS fpA_intB layout — produced
// offline e.g. via ``pack_weights_for_cuda_mixed_gemm`` — and the
// compute path reads them as-is. When false, the raw schema-conformant
// ``[E, N, K/pack]`` layout (as produced by
// ``quantize_matmul_{4,8}bits``) is rewritten inside the PrePack hook
// via ``PrePackIntExpertWeights``, removing the offline prepack
// dependency. Only meaningful when ``quant_type_ == "int"``. Derived from
// the optional tri-state ``weights_prepacked`` attribute: -1/auto (or
// absent) maps to true on the CUDA EP, 1 maps to true, 0 maps to false.
// When true, the int4/int8 fc1/fc2 weight initializers are already in a
// CUTLASS fpA_intB layout — produced offline e.g. via
// ``pack_weights_for_cuda_mixed_gemm`` — and the compute path reads them
// as-is. When false, the raw schema-conformant ``[E, N, K/pack]`` layout
// (as produced by ``quantize_matmul_{4,8}bits``) is rewritten inside the
// PrePack hook via ``PrePackIntExpertWeights``, removing the offline
// prepack dependency. Only meaningful when ``quant_type_ == "int"``.
// Derived from the optional tri-state ``weights_prepacked`` attribute:
// -1 (default) and 1 both map to true; 0 maps to false. The concrete
// prepacked layouts selected by -1 and 1 are determined by the execution
// provider. For the CUDA EP the int4/int8 MoE GEMM always dispatches to the
// Ampere (SM80) grouped-GEMM kernel -- even on SM90 -- because mixed
// int-weight + fp16/bf16 activation is not a valid Hopper TMA warp-specialized
// specialisation (matches TensorRT-LLM, which also routes W4A16/W8A16 MoE to
// the SM80 kernel on Hopper). The kernel therefore consumes the SM80 fpA_intB
// layout on every GPU, so -1 and 1 are currently equivalent for the CUDA EP;
// 1 is reserved for a possible future Hopper-specific layout (e.g. W4A8).
bool weights_prepacked_ = true;
// Cached source weight shapes captured at PrePack time. When the
// PrePack hook consumed and released the original int4/int8 weight
Expand Down
16 changes: 4 additions & 12 deletions onnxruntime/core/graph/contrib_ops/contrib_defs.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1520,18 +1520,10 @@ ONNX_MS_OPERATOR_SET_SCHEMA(
AttributeProto::STRING,
std::string("int"))
.Attr("weights_prepacked",
"Only meaningful when quant_type='int'. Tri-state control over whether the "
"int4/int8 fc1/fc2 weight initializers are already laid out in the CUTLASS "
"fpA_intB format expected by the runner. -1 (auto): let the execution provider "
"choose its own backward-compatible default; the CUDA EP treats auto as "
"prepacked. 1: the initializers are already prepacked (e.g. produced offline by "
"pack_weights_for_cuda_mixed_gemm) and are consumed as-is. 0: the initializers "
"are raw, un-prepacked [E, N, K/pack] tensors as produced by "
"quantize_matmul_{4,8}bits; the kernel runs the CUTLASS layout transform itself "
"in PrePack(), matching the behaviour of MatMulNBits and removing the offline "
"pre-pack requirement from exporters. Defaults to -1 (auto) so each execution "
"provider can pick its own backward-compatible default rather than the schema "
"imposing one.",
"Only meaningful when quant_type='int'. Tri-state control over the layout of the "
"int4/int8 fc1/fc2 weight initializers. The concrete prepacked layouts selected by "
"-1 and 1 are determined by the execution provider. 0: the initializers are raw, "
"un-prepacked [E, N, K/pack] tensors as produced by quantize_matmul_{4,8}bits. Defaults to -1.",
AttributeProto::INT,
static_cast<int64_t>(-1))
.Input(0,
Expand Down
Loading
Loading