Skip to content

Commit d92c50d

Browse files
Merge branch 'microsoft:main' into fp16-split/02-mlas-half-api-squashed
2 parents e920588 + e0b2b97 commit d92c50d

63 files changed

Lines changed: 3940 additions & 285 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/contrib_ops/cuda/moe_qmoe.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,27 @@ per-column INT4, block-wise INT4/INT8, and interleaved-SwiGLU GEMV kernels.
989989
| Kernel instantiation | `moe_gemv.cu` adds `__nv_bfloat16` details/instantiations (group sizes 0/32/64/128, INT4/INT8, bias on/off) under `ENABLE_BF16`. | The custom FC1/FC2 GEMV kernels run for BF16; no grouped-GEMM fallback when the FP16 gate would route. |
990990
| Profiling | GPT-OSS-20B, Qwen3.6-35B-A3B, and Gemma model shapes profiled with `block_size=64` for both dtypes. | BF16 matches FP16 routing and latency within noise (about 1.3x–1.5x faster than grouped GEMM); SwiGLU BF16 parity tests pass. |
991991

992+
#### Split-K2 SwiGLU GEMV default path
993+
994+
The fp16 INT4 interleaved-SwiGLU GEMV path uses a two-pass Split-K2 FC1 kernel by
995+
default for supported decode shapes. The first pass computes two K-split FP32
996+
partials into QMoE workspace, and the second pass reduces those partials, adds
997+
optional bias, and applies the interleaved SwiGLU epilogue. FC2 stays on the
998+
regular `moe_gemv_kernel` path.
999+
1000+
Set `ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1` before process start to force the
1001+
previous single-kernel FC1 SwiGLU GEMV path for debugging, A/B benchmarking, or
1002+
bisecting numerical differences. On GPT-OSS-20B, Split-K2 reduced FC1 kernel
1003+
work from about 21.42 us to 19.98 us and improved repeated CUDA-graph decode
1004+
throughput by about 0.9% to 1.6% with valid focused-helper output. A 1000-sample
1005+
MMLU smoke matched the opt-out fallback within noise. A future autotuner can
1006+
replace this hand-selected default with per-shape route selection.
1007+
1008+
```bash
1009+
onnxruntime/test/python/transformers/profile_qmoe_gemv.py \
1010+
--case gpt_oss_20b_m1_top4_fp16_2880x2880_e32 \
1011+
--disable-splitk2-swiglu --warmup 5 --repeat 100 --nvtx
1012+
```
9921013
#### Accumulation policy
9931014

9941015
The QMoE GEMV fast path accumulates fp16 activations in fp16 by default. Set

docs/contrib_ops/cuda/qmoe_gemv_experiments.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,169 @@ Every case reported `has_invalid_output=false`.
979979
- Per-column INT8 W8A16 decode shapes route to GEMV for both FP16 and BF16 and
980980
beat the grouped-GEMM fallback at every profiled shape.
981981

982+
## 2026-06-19: Split-K2 Two-Pass SwiGLU GEMV Experiment
983+
984+
### Change Under Test
985+
986+
- Code commit: `f1d6718be719c1237be392c0389874b6a8926a3c`
987+
(`Experiment QMoE split-K SwiGLU GEMV`).
988+
- Added default Split-K2 route with opt-out env knob:
989+
`ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1`.
990+
- Scope: FP16 INT4/interleaved-SwiGLU FC1 GEMV path for decode-shaped QMoE.
991+
- Implementation:
992+
- First pass launches `moe_gemv_splitk_partials_kernel` with `SplitK=2` and
993+
writes FP32 partials into QMoE workspace.
994+
- Second pass launches `moe_gemv_splitk_reduce_swiglu_kernel` to reduce the
995+
partials, add optional bias, and apply SwiGLU.
996+
- FC2 remains on the existing `moe_gemv_kernel`.
997+
- Scratch is allocated only for the supported Split-K2 route. Setting
998+
`ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1` restores the previous single-kernel
999+
FC1 SwiGLU GEMV path.
1000+
1001+
### Repro Notes
1002+
1003+
- Build: `cmake --build build/cu130/Release --target onnxruntime_providers_cuda --parallel $(nproc)`.
1004+
- Important provider sync: Python tests importing from
1005+
`build/cu130/Release/onnxruntime` load
1006+
`build/cu130/Release/onnxruntime/capi/libonnxruntime_providers_cuda.so`, not
1007+
only the top-level `build/cu130/Release/libonnxruntime_providers_cuda.so` or
1008+
the venv copy. Sync all relevant copies before measuring:
1009+
1010+
```bash
1011+
cp build/cu130/Release/libonnxruntime_providers_cuda.so \
1012+
build/cu130/Release/onnxruntime/capi/libonnxruntime_providers_cuda.so
1013+
cp build/cu130/Release/libonnxruntime_providers_cuda.so \
1014+
.venv_cu130/lib/python3.14/site-packages/onnxruntime/capi/libonnxruntime_providers_cuda.so
1015+
```
1016+
1017+
- Focused QMoE helper:
1018+
1019+
```bash
1020+
cd ~
1021+
CUDA_VISIBLE_DEVICES=1 \
1022+
LD_LIBRARY_PATH=~/onnxruntime/build/cu130/Release:~/cuda13.0/lib64:~/cudnn9.19_cuda13/lib:~/cudnn9.19_cuda13/lib64:${LD_LIBRARY_PATH:-} \
1023+
PYTHONPATH=~/onnxruntime/build/cu130/Release:~/onnxruntime/onnxruntime/test/python/transformers \
1024+
~/onnxruntime/.venv_cu130/bin/python \
1025+
~/onnxruntime/onnxruntime/test/python/transformers/profile_qmoe_gemv.py \
1026+
--case gpt_oss_20b_m1_top4_fp16_2880x2880_e32 --warmup 3 --repeat 20
1027+
```
1028+
1029+
### Focused QMoE Smoke
1030+
1031+
Both modes reported `has_invalid_output=false`.
1032+
1033+
| Mode | Env | Latency ms |
1034+
|------|-----|------------|
1035+
| Baseline | `ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1` | 0.072344 |
1036+
| Split-K2 | none | 0.073816 |
1037+
1038+
The short helper was slightly slower with split-K2, so Nsight was required to
1039+
confirm route selection and isolate kernel time.
1040+
1041+
### Nsight Systems Kernel Results
1042+
1043+
Artifacts:
1044+
1045+
- Baseline: `/tmp/qmoe_gptoss_baseline_final.{nsys-rep,sqlite}`
1046+
- Split-K2: `/tmp/qmoe_gptoss_splitk_final.{nsys-rep,sqlite}`
1047+
1048+
Command shape:
1049+
1050+
```bash
1051+
~/cuda13.0/bin/nsys profile -t cuda,nvtx --force-overwrite true \
1052+
-o /tmp/qmoe_gptoss_splitk_final --export=sqlite \
1053+
~/onnxruntime/.venv_cu130/bin/python \
1054+
~/onnxruntime/onnxruntime/test/python/transformers/profile_qmoe_gemv.py \
1055+
--case gpt_oss_20b_m1_top4_fp16_2880x2880_e32 --warmup 3 --repeat 30 --nvtx
1056+
```
1057+
1058+
Parsed with `parse_nsys.py --nvtx-range benchmark --pattern '%'`.
1059+
1060+
| Mode | Kernel | Calls | Avg us |
1061+
|------|--------|-------|--------|
1062+
| Baseline | `moe_gemv_interleaved_swiglu_kernel` | 30 | 21.42 |
1063+
| Baseline | `moe_gemv_kernel` | 30 | 12.13 |
1064+
| Split-K2 | `moe_gemv_splitk_partials_kernel` | 30 | 17.59 |
1065+
| Split-K2 | `moe_gemv_splitk_reduce_swiglu_kernel` | 30 | 2.39 |
1066+
| Split-K2 | `moe_gemv_kernel` | 30 | 12.22 |
1067+
1068+
Split-K2 reduced FC1 kernel work from about `21.42 us` to `17.59 + 2.39 =
1069+
19.98 us`, a net FC1 reduction of about `1.44 us` per QMoE invocation. End-to-end
1070+
under Nsight was effectively tied:
1071+
1072+
| Mode | Helper latency ms |
1073+
|------|-------------------|
1074+
| Baseline | 0.079855 |
1075+
| Split-K2 | 0.079728 |
1076+
1077+
### Model-Level Decode Benchmark With CUDA Graph
1078+
1079+
The user requested model-level measurement assuming CUDA graph. Both runs used
1080+
the GPT-OSS-20B INT4 QMoE model package, CUDA graph enabled, XQA enabled, and
1081+
deterministic MoE tactic selection:
1082+
1083+
```bash
1084+
MODEL=models/gpt-oss-20b/variants/cuda_int4_int4_qmoe_rtn_matmul_only \
1085+
GPU=0 PROMPT_LEN=512 GEN_LEN=128 REPS=10 WARMUP=3 CUDA_GRAPH=1 XQA=1 SYNC_LIB=1 \
1086+
ORT_FORCE_DETERMINISTIC_MOE=1 \
1087+
bash scripts/bench_gpt_oss_ort_decode.sh
1088+
```
1089+
1090+
Baseline additionally set `ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1`.
1091+
1092+
| Run | Mode | Decode latency ms/token | Decode throughput tok/s |
1093+
|-----|------|-------------------------|-------------------------|
1094+
| R1, `REPS=5`, `WARMUP=2` | Baseline | 2.869450 | 348.498901 |
1095+
| R1, `REPS=5`, `WARMUP=2` | Split-K2 | 2.823800 | 354.132707 |
1096+
| R2, `REPS=10`, `WARMUP=3` | Baseline | 2.865840 | 348.937861 |
1097+
| R2, `REPS=10`, `WARMUP=3` | Split-K2 | 2.839335 | 352.195107 |
1098+
1099+
The longer CUDA-graph pair showed about `+0.9%` decode throughput. The shorter
1100+
pair showed about `+1.6%`. Since the focused helper reported valid output and
1101+
the model-level gain repeated in the same direction, even this modest gain is
1102+
worth enabling for GPT-OSS-20B decode while keeping an opt-out for A/B checks.
1103+
1104+
After flipping Split-K2 to the default and adding
1105+
`ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1` as the opt-out, three more paired
1106+
CUDA-graph model runs were collected with `REPS=10`, `WARMUP=3`, prompt length
1107+
512, and generation length 128:
1108+
1109+
| Run | Mode | Decode latency ms/token | Decode throughput tok/s |
1110+
|-----|------|-------------------------|-------------------------|
1111+
| R3 | Default Split-K2 | 3.017252 | 331.427448 |
1112+
| R3 | Split-K2 disabled | 3.055736 | 327.253380 |
1113+
| R4 | Default Split-K2 | 3.006739 | 332.586260 |
1114+
| R4 | Split-K2 disabled | 3.047570 | 328.130314 |
1115+
| R5 | Default Split-K2 | 3.009466 | 332.284898 |
1116+
| R5 | Split-K2 disabled | 3.047015 | 328.190090 |
1117+
| Average | Default Split-K2 | 3.011152 | 332.099536 |
1118+
| Average | Split-K2 disabled | 3.050107 | 327.857928 |
1119+
1120+
The default Split-K2 route was faster in all three pairs, averaging `+1.29%`
1121+
decode throughput and `-1.28%` decode latency versus the opt-out fallback.
1122+
1123+
### Accuracy Smoke
1124+
1125+
A 1000-sample `match_mmlu` smoke was run with the local parallel eval harness on
1126+
all eight H200 GPUs, using the same GPT-OSS-20B INT4 QMoE model package and the
1127+
current ORT build package. The default Split-K2 run scored `0.8380` pooled
1128+
accuracy; the opt-out fallback with `ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1`
1129+
scored `0.8350`. The small positive difference is within smoke-test noise, and
1130+
there is no accuracy regression signal from enabling Split-K2 by default.
1131+
1132+
### Decision
1133+
1134+
- Enable Split-K2 by default for its supported fp16 INT4 interleaved-SwiGLU GEMV
1135+
scope.
1136+
- Keep `ORT_DISABLE_MOE_GEMV_SPLITK2_SWIGLU=1` as the fallback and A/B knob.
1137+
- The 1000-sample MMLU smoke matched the opt-out fallback within noise, so the
1138+
default flip has an accuracy sanity check in addition to focused-helper valid
1139+
output.
1140+
- Future work:
1141+
- Add per-shape autotune so route selection is data-driven instead of a fixed
1142+
default.
1143+
- Try a launch-fused reduction strategy or cooperative approach to keep the
1144+
FC1 parallelism benefit without the extra reduce launch.
9821145
## 2026-06-19 FP16 Accumulation Default: SM90, GPT-OSS Decode Shape
9831146

9841147
### Setup

include/onnxruntime/core/providers/cuda/cuda_provider_options.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ struct OrtCUDAProviderOptionsV2 {
3333
int tunable_op_enable = 0; // flag specifying if TunableOp is enabled.
3434
int tunable_op_tuning_enable = 0; // flag specifying if TunableOp is enabled for tuning, this relies on TunableOp is enabled.
3535
int tunable_op_max_tuning_duration_ms = 0; // Max tuning duration time limit for TunableOp.
36-
int enable_skip_layer_norm_strict_mode = 0; // flag specifying if SkipLayerNorm is in strict mode. If true, use LayerNormalization kernel.
37-
// The strict mode has better accuracy but lower performance.
36+
int enable_skip_layer_norm_strict_mode = 0; // [Deprecated] Accepted for ABI/back-compat but not stored in EP info. SkipLayerNorm always accumulates in fp32.
37+
// Setting it has no effect on computation or output.
3838
int prefer_nhwc = 0; // make the CUDA EP NHWC preferred
3939
int use_ep_level_unified_stream = 0; // flag specifying if ep level stream is used or not
4040
int use_tf32 = 1; // use TF32

onnxruntime/contrib_ops/cpu/bert/group_query_attention_helper.h

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Status Check_QKV(const T* packed_qkv, const T* value, const int num_heads, const
9797

9898
template <typename T>
9999
Status CheckPast(const T* past_key, const T* past_value, int batch_size, int kv_num_heads, int head_size, int kv_cache_bit_width,
100-
int& past_sequence_length) {
100+
int& past_sequence_length, int kv_cache_extra_bits = 0) {
101101
const auto& past_key_dims = past_key->Shape().GetDims();
102102
const auto& past_value_dims = past_value->Shape().GetDims();
103103

@@ -140,17 +140,25 @@ Status CheckPast(const T* past_key, const T* past_value, int batch_size, int kv_
140140
// We assume all sequence in past kv are right-padded to max or past sequence length
141141
past_sequence_length = static_cast<int>(past_key_dims[2]);
142142

143-
// For 4-bit quantized KV cache, actual dimension is head_size / 2 because 2 nibbles are packed into one byte.
144-
// Note that we have checked that head_size is a multiple of 8 in Check_QKV.
145-
int packed_head_size = (kv_cache_bit_width == 4) ? (head_size / 2) : head_size;
143+
// Compute expected KV cache head dimension from quantization parameters.
144+
// kv_cache_bit_width: bits per element (4 or 8). 0 means no quantization.
145+
// kv_cache_extra_bits: additional metadata bits per head
146+
// (e.g., 32bits for TurboQuant storing scale).
147+
int packed_head_size;
148+
if (kv_cache_bit_width == 0) {
149+
packed_head_size = head_size;
150+
} else {
151+
int bits_per_element = static_cast<int>(past_key->DataType()->Size()) * 8;
152+
packed_head_size = (head_size * kv_cache_bit_width + kv_cache_extra_bits) / bits_per_element;
153+
}
146154
if (past_key_dims[3] != packed_head_size) {
147155
return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT,
148-
"Input 'past_key' dimension 3 should be same as head_size, got ",
156+
"Input 'past_key' dimension 3 should match the packed KV head dimension, got ",
149157
past_key_dims[3], " expected ", packed_head_size);
150158
}
151159
if (past_value_dims[3] != packed_head_size) {
152160
return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT,
153-
"Input 'past_value' dimension 3 should be same as head_size, got ",
161+
"Input 'past_value' dimension 3 should match the packed KV head dimension, got ",
154162
past_value_dims[3], " expected ", packed_head_size);
155163
}
156164
return Status::OK();
@@ -206,7 +214,12 @@ Status CheckInputs(const T* query,
206214
const T* total_seqlen,
207215
float scale,
208216
float softcap,
209-
int kv_cache_bit_width) {
217+
int kv_cache_bit_width,
218+
int max_threads_per_block = 0,
219+
int kv_cache_extra_bits = 0) {
220+
if (max_threads_per_block > 0 && num_heads > max_threads_per_block) {
221+
return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT, "num_heads should be no larger than ", max_threads_per_block);
222+
}
210223
// Note: Here S* is seqlen_past_kv_cache, S+ is seqlen_present_kv_cache
211224
// past_key : (B, N_k, S*, H) or (B, N_k, S+, H) or nullptr
212225
// past_value : (B, N_k, S*, H) or (B, N_k, S+, H) or nullptr
@@ -246,10 +259,15 @@ Status CheckInputs(const T* query,
246259
kv_sequence_length = sequence_length;
247260
}
248261

262+
if (kv_cache_extra_bits != 0 && kv_cache_bit_width == 0) {
263+
return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT,
264+
"kv_cache_extra_bits requires kv_cache_bit_width to be non-zero.");
265+
}
266+
249267
// Check past-present KV
250268
int32_t past_sequence_length = 0;
251269
if (past_key != nullptr && past_value != nullptr) {
252-
ORT_RETURN_IF_ERROR(CheckPast(past_key, past_value, batch_size, kv_num_heads, head_size, kv_cache_bit_width, past_sequence_length));
270+
ORT_RETURN_IF_ERROR(CheckPast(past_key, past_value, batch_size, kv_num_heads, head_size, kv_cache_bit_width, past_sequence_length, kv_cache_extra_bits));
253271
// When past KV exists, Q and K/V must have the same sequence length,
254272
// UNLESS kv_sequence_length is 0 (shared KV: new K/V are empty, past buffer
255273
// already contains the full shared KV cache — no append needed).
@@ -377,30 +395,6 @@ Status CheckInputs(const T* query,
377395
return Status::OK();
378396
}
379397

380-
template <typename T = Tensor>
381-
Status CheckInputs(const T* query,
382-
const T* key,
383-
const T* value,
384-
const T* past_key,
385-
const T* past_value,
386-
const T* cos_cache,
387-
const T* sin_cache,
388-
void* parameters,
389-
int num_heads,
390-
int kv_num_heads,
391-
const T* seqlens_k,
392-
const T* total_seqlen,
393-
float scale,
394-
float softcap,
395-
int kv_cache_bit_width,
396-
int max_threads_per_block) {
397-
if (max_threads_per_block > 0 && num_heads > max_threads_per_block) {
398-
return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT, "num_heads should be no larger than ", max_threads_per_block);
399-
}
400-
401-
return CheckInputs(query, key, value, past_key, past_value, cos_cache, sin_cache, parameters, num_heads, kv_num_heads, seqlens_k, total_seqlen, scale, softcap, kv_cache_bit_width);
402-
}
403-
404398
template <typename T = Tensor>
405399
Status CheckCustomAttentionInputs(const T* position_ids,
406400
const T* attention_bias,

0 commit comments

Comments
 (0)