docs/contrib_ops/cpu/gqa.md
This document describes the current CPU implementation of the com.microsoft.GroupQueryAttention operator in ONNX Runtime, with emphasis on quantized KV-cache execution.
The CPU GroupQueryAttention kernel is implemented in:
onnxruntime/contrib_ops/cpu/bert/group_query_attention.cconnxruntime/contrib_ops/cpu/bert/gqa_attention_base.honnxruntime/contrib_ops/cpu/bert/group_query_attention_helper.hQuantized KV-cache GEMM helpers are implemented in MLAS:
onnxruntime/core/mlas/inc/mlas_qkv_quant.honnxruntime/core/mlas/lib/qkv_quant.cpponnxruntime/core/mlas/lib/qkv_quant_kernel_avx2.cpponnxruntime/core/mlas/lib/qkv_quant_kernel_avx512vnni.cpponnxruntime/core/mlas/lib/qkv_quant_kernel_neon.cpponnxruntime/core/mlas/lib/flashattn_qkv.cpp (flash attention tiled kernel)The operator schema itself is defined in:
onnxruntime/core/graph/contrib_ops/contrib_defs.ccRelevant tests and benchmarks are in:
onnxruntime/test/contrib_ops/group_query_attention_op_test.cconnxruntime/test/python/transformers/test_gqa_cpu_quantized.pyonnxruntime/test/python/transformers/test_gqa.pyonnxruntime/test/mlas/unittest/test_qkv_quant.cpponnxruntime/test/mlas/bench/bench_qkv_quant.cppThis document focuses on runtime behavior on the CPU Execution Provider, not on the full operator schema.
At a high level, the CPU kernel executes GroupQueryAttention in these stages:
The non-quantized and quantized paths share the surrounding validation, masking, softmax, and output flow. Their main difference is how the K/V cache is stored and read during QK and SV GEMMs.
The quantized path has two execution strategies:
[S, T] attention score matrix, applies masking and softmax, then computes the SV product. Simple but memory-intensive for long sequences.The flash path is selected by default when conditions are met (see below). Set ORT_GQA_DISABLE_FLASH_ATTENTION=1 to force the naive path.
When k_quant_type and v_quant_type are NONE, kv_cache_bit_width must be 0. The past and present K/V tensors use the same floating-point element type as the kernel specialization.
When quantization is enabled, k_quant_type and v_quant_type must match and may be:
PER_TENSORPER_CHANNELThe CPU quantized path supports:
kv_cache_bit_width = 8 for signed INT8 cache valueskv_cache_bit_width = 4 for signed INT4 cache values packed into uint8The scale inputs are always float tensors:
PER_TENSOR: k_scale and v_scale each contain exactly one elementPER_CHANNEL: each scale tensor contains kv_num_heads * head_size elementsFor per-channel scales, each KV head uses a contiguous head_size scale slice. For per-tensor scales, all heads share the single scale value.
The past and present KV cache tensors use BNSH layout:
batch_sizekv_num_headscache_sequence_lengthhead_sizeFor INT4, two signed 4-bit values are stored in each byte. The packed head dimension is ceil(head_size / 2) bytes. For INT8, the packed head dimension is head_size bytes.
During quantized execution, new key/value vectors are quantized on write into the present cache. Existing past-cache data and newly written present-cache data are then consumed by MLAS quantized GEMM helpers.
The naive (full materialization) path executes attention as three separate stages:
The QK stage computes:
attention_scores = scale * query * K_cache^T
For quantized K cache, the CPU path calls MlasQKGemm with:
ABThe default MLAS contract is exact with respect to the FP32 query operand: only the K cache is dequantized on the fly. The query row is not quantized by default.
After QK GEMM, the CPU path applies the same attention-score processing used by the non-quantized path, including supported combinations of:
The quantized cache mode does not change these score-processing semantics.
The SV stage computes:
output = attention_probs * V_cache
For quantized V cache, the CPU path calls MlasSVGemm with:
ABAs with QK GEMM, the default MLAS contract preserves the FP32 left-hand operand and dequantizes only the cached V values on the fly.
The flash attention path (MlasFlashAttentionQuantizedKV) processes K/V in blocks with online softmax, fusing QK, masking, softmax, and SV into a single tiled loop. This avoids the O(S×T) memory allocation for the full attention matrix.
For each (batch, head, q_block) tile:
MlasQKGemm on a block slice of quantized K cache (Bc rows at a time)
1b. Attention bias — Add the corresponding tile of the bias tensor (if present) to QK scoresm and sum l, rescale accumulated output with exp(m_old − m_new)MlasSVGemm(..., Beta=1.0) dequantizes V on the fly and accumulates softmax(QK_block) × V_block into the output in a single pass (no intermediate FP32 buffer)1/l after all KV blocks are processedThe flash path is selected when ALL of the following hold:
ORT_GQA_DISABLE_FLASH_ATTENTION environment variable is not set (or set to 0)total_sequence_length > 1Attention bias is fully supported in the flash path (applied per-tile after QK GEMM). The bias tensor shape [B|1, N|1, S, T] supports broadcast along both batch and head dimensions.
When any condition is not met, the kernel falls back to the naive full-materialization path.
Block sizes are chosen based on L2 cache size:
kv_block_size (Bc): Sized so that a full KV block's scores + dequantized V fit within L2. Typical values: 128–256.q_block_size (Br): Sized for the query tile. Typical value: 64.The flash kernel parallelizes across (batch, head, q_block) tiles using the ORT intra-op thread pool. Each thread gets a private working buffer containing space for:
l[Br] and m[Br] — running softmax statisticsscores[Br × Bc] — QK scores for current KV blocktemp_output[Br × H] — accumulated outputThe V dequantization temp buffer was eliminated by fusing dequantization into MlasSVGemm with Beta=1.0 (accumulate mode). This reduces per-thread buffer size by Bc × H × 4 bytes (e.g., 64 KB for Bc=128, H=128).
For decode steps (sequence_length == 1), the standard (batch, head, q_block) partitioning yields only batch × num_heads tasks, which can underutilize thread pools on machines with many cores (e.g., 96 threads with batch=1, num_heads=32 produces only 32 tasks).
When batch × num_heads < thread_count and kv_chunk_count > 1, the kernel switches to a flash decoding strategy that also partitions along the KV sequence dimension:
batch × num_heads × kv_chunk_count tasks): Each thread computes partial attention for one KV chunk, producing per-chunk (m, l, S_exp × V) stored in a partials buffer.batch × num_heads tasks): Merge partials using log-sum-exp rescaling: output = Σ_c(exp(m_c − m_global) × partial_c) / Σ_c(exp(m_c − m_global) × l_c).The partials buffer is allocated alongside the per-thread scratch in a single allocation:
scores[Bc] (one float per KV block element)batch × num_heads × kv_chunks × (2 + H) floats (m, l, and partial output per chunk)MLAS selects the best available quantized KV-cache GEMM implementation through the platform dispatch table.
Current CPU paths include:
qkv_quant.cppThe AVX512 implementation also contains an optional approximate VNNI QK path for INT8 per-tensor K cache. It is disabled by default because it quantizes the FP32 query row before the dot product, which changes the MlasQKGemm numeric contract and can make results differ from scalar, AVX2, and NEON paths.
To opt in explicitly, set:
ORT_MLAS_QKGEMM_S8_APPROX_VNNI=1
This opt-in currently applies only to AVX512 INT8 per-tensor MlasQKGemm. It does not affect INT8 per-channel, INT4, or MlasSVGemm paths.
CPU GroupQueryAttention coverage is split across operator-level and MLAS-level tests:
onnxruntime/test/contrib_ops/group_query_attention_op_test.cc
onnxruntime/test/python/transformers/test_gqa_cpu_quantized.py
onnxruntime/test/python/transformers/test_gqa.py
onnxruntime/test/mlas/unittest/test_qkv_quant.cpp
MlasKVQuantize, MlasKVDequantize, MlasQKGemm, and MlasSVGemm contract tests.The MLAS benchmark for quantized KV-cache GEMM and flash attention is:
onnxruntime/test/mlas/bench/bench_qkv_quant.cppBuild and test commands depend on the local build directory. For an existing CPU Release build rooted at build/cpu_test/Release, the focused tests can be run with:
cd build/cpu_test/Release
# MLAS quantized KV-cache primitive tests.
./onnxruntime_mlas_test
# CPU operator tests for quantized GroupQueryAttention.
./onnxruntime_test_all --gtest_filter="*GroupQueryAttentionQuantized*"
The Python integration test can be run from the repository root after activating the build/test environment:
python onnxruntime/test/python/transformers/test_gqa_cpu_quantized.py
Rebuild the benchmark target after changing MLAS code:
cmake --build build/cpu_test/Release --target onnxruntime_mlas_benchmark -j $(nproc)
Run the representative INT8 per-tensor QKGemm decode benchmark for scalar, AVX2, and the default platform dispatch:
cd build/cpu_test/Release
unset ORT_MLAS_QKGEMM_S8_APPROX_VNNI
./onnxruntime_mlas_benchmark \
--benchmark_filter='BM_QKGemm(/M:1/N_seqlen:512/K_head:128/QuantType:0|_Scalar/M:1/N:512/K:128/QuantType:0|_Avx2/M:1/N:512/K:128/QuantType:0)' \
--benchmark_min_time=0.5s \
--benchmark_repetitions=3 \
--benchmark_report_aggregates_only=true
Run the opt-in approximate AVX512 VNNI path with:
cd build/cpu_test/Release
ORT_MLAS_QKGEMM_S8_APPROX_VNNI=1 ./onnxruntime_mlas_benchmark \
--benchmark_filter='BM_QKGemm/M:1/N_seqlen:512/K_head:128/QuantType:0' \
--benchmark_min_time=0.5s \
--benchmark_repetitions=3 \
--benchmark_report_aggregates_only=true
Run flash vs naive full-attention benchmark:
cd build/cpu_test/Release
./onnxruntime_mlas_benchmark \
--benchmark_filter='BM_GQA_(Naive|Flash)' \
--benchmark_min_time=0.5s \
--benchmark_repetitions=3 \
--benchmark_report_aggregates_only=true
To force the naive path at the operator level (for A/B testing during inference):
ORT_GQA_DISABLE_FLASH_ATTENTION=1 ./your_inference_app
The following results were measured on an Intel Xeon Platinum 8480C, 96 CPUs, using the CPU Release benchmark binary. Shape: M=1, N=512, K=128, INT8 per-tensor QKGemm.
| Implementation | Latency (ns, mean) | vs Scalar |
|---|---|---|
| Scalar fallback | 31,027 | 1.0x |
| AVX2 FP32 fused dequant-dot | 4,234 | 7.3x |
| AVX512 FP32 fused dequant-dot, default | 3,736 | 8.3x |
AVX512 VNNI approximate, ORT_MLAS_QKGEMM_S8_APPROX_VNNI=1 | 2,020 | 15.4x |
For comparison, the earlier PR description reported the approximate AVX512 VNNI path at 1,938 ns for this shape, with scalar at 30,179 ns and AVX2 at 4,219 ns. The default AVX512 path is now the exact FP32 fused-dequant implementation, so it is slower than approximate VNNI but preserves the MlasQKGemm FP32-query contract.
Measured on Intel Xeon Platinum 8480C, 96 CPUs. INT8 quantized KV cache, threads=8.
Two benchmark levels are reported:
benchmark_gqa_cpu_flash.py): Measures the full GQA operator via InferenceSession, including KV cache concatenation, quantization of new K/V, and Python/C++ boundary overhead.bench_qkv_quant.cpp): Measures only the attention kernel (QK+softmax+SV), isolating the algorithmic gain from operator overhead.# Operator-level Python benchmark:
cd /tmp
PYTHONPATH=build/cpu/Release python \
onnxruntime/test/python/transformers/benchmark_gqa_cpu_flash.py --warmup 5 --repeats 20
# MLAS kernel-level C++ benchmark:
cd build/cpu/Release
./onnxruntime_mlas_benchmark \
--benchmark_filter='BM_GQA_(Naive|Flash)' \
--benchmark_min_time=0.5s \
--benchmark_repetitions=3 \
--benchmark_report_aggregates_only=true
Shape: B=1, num_heads=16, kv_num_heads=8, head_size=128, INT8 per-tensor.
| Seq Length | Naive (ms) | Flash (ms) | Speedup | Source |
|---|---|---|---|---|
| 512 | 7.7 | 8.9 | 0.9x | operator |
| 1024 | 36.8 | 30.2 | 1.2x | operator |
| 2048 | 157.9 | 110.2 | 1.4x | operator |
| 4096 | 790.6 | 427.1 | 1.9x | operator |
| 512 | 9.9 | 8.1 | 1.2x | MLAS kernel |
| 1024 | 44.4 | 27.0 | 1.6x | MLAS kernel |
| 2048 | 190.9 | 116.9 | 1.6x | MLAS kernel |
| 4096 | 1257.8 | 461.6 | 2.7x | MLAS kernel |
The operator-level naive path is faster than the MLAS-level naive at small S because the naive path's QK GEMM batches all heads in one call, amortizing thread dispatch. At larger S, the flash kernel's O(S×Bc) tiling wins decisively.
MLAS kernel-level per-channel results:
| Seq Length | Naive (ms) | Flash (ms) | Speedup | Source |
|---|---|---|---|---|
| 512 | 10.7 | 10.8 | 1.0x | MLAS kernel |
| 1024 | 49.5 | 41.7 | 1.2x | MLAS kernel |
| 2048 | 212.1 | 164.1 | 1.3x | MLAS kernel |
| 4096 | 1223.9 | 607.8 | 2.0x | MLAS kernel |
Shape: B=1, num_heads=16, kv_num_heads=8, head_size=128, INT8 per-tensor. Flash decoding is NOT active for this config (batch×heads=16 > threads=8).
| Total Seqlen | Naive | Flash | Speedup | Source |
|---|---|---|---|---|
| 513 | 0.133 ms | 0.149 ms | 0.9x | operator |
| 1025 | 0.258 ms | 0.224 ms | 1.2x | operator |
| 2049 | 0.453 ms | 0.394 ms | 1.2x | operator |
| 4097 | 0.681 ms | 0.679 ms | 1.0x | operator |
| 512 | 32 us | 22 us | 1.4x | MLAS kernel |
| 1024 | 71 us | 47 us | 1.5x | MLAS kernel |
| 2048 | 120 us | 87 us | 1.4x | MLAS kernel |
| 4096 | 210 us | 174 us | 1.2x | MLAS kernel |
At the MLAS kernel level, the flash path is consistently 1.2–1.5x faster for decode due to fused single-pass KV access (better cache locality). At the operator level, the gain is partially masked by KV cache concatenation overhead (~100us), which dominates at short sequences but becomes less significant at longer ones.
MLAS kernel-level per-channel decode results:
| Total Seqlen | Naive (us) | Flash (us) | Speedup | Source |
|---|---|---|---|---|
| 512 | 53 | 31 | 1.7x | MLAS kernel |
| 1024 | 86 | 52 | 1.7x | MLAS kernel |
| 2048 | 172 | 97 | 1.8x | MLAS kernel |
| 4096 | 299 | 191 | 1.6x | MLAS kernel |
Shape: B=1, num_heads=4, kv_num_heads=4 (MHA), head_size=128, threads=8. Flash decoding IS active (batch×heads=4 < threads=8, KV partitioned across idle threads).
| Total Seqlen | Naive (us) | Flash (us) | Speedup | Quant |
|---|---|---|---|---|
| 512 | 31 | 25 | 1.2x | per-tensor |
| 1024 | 41 | 25 | 1.6x | per-tensor |
| 2048 | 67 | 34 | 2.0x | per-tensor |
| 4096 | 197 | 54 | 3.7x | per-tensor |
| 512 | 25 | 28 | 0.9x | per-channel |
| 1024 | 72 | 27 | 2.7x | per-channel |
| 2048 | 144 | 37 | 3.9x | per-channel |
| 4096 | 304 | 60 | 5.1x | per-channel |
(Source: MLAS kernel-level benchmark)
| Seq Length | Naive Peak (MB) | Flash Peak (MB) | Memory Reduction |
|---|---|---|---|
| 2048 | +294 | +44 | 6.7x |
| 4096 | +1107 | +82 | 13.5x |
| 4096 (N=32) | +2131 | +87 | 24.5x |
Summary: The flash path's primary benefit for prefill is memory reduction — avoiding the full O(N×S×T) attention matrix. For S=4096 with 16 heads, the naive path allocates ~1 GB for attention scores while the flash path uses ~80 MB regardless of sequence length. The prefill latency speedup (1.2–2.7x at kernel level, 1.2–1.9x at operator level) comes from improved cache locality. For decode, the tiled kernel provides 1.2–1.8x kernel-level speedup from fused single-pass KV access; at operator level the gain is visible for T≥1024 but masked by KV concat overhead at shorter sequences. When flash decoding is active (batch×heads < threads), KV partitioning across idle threads yields an additional 2–5x speedup for long sequences.
The current CPU GroupQueryAttention implementation has a few important limitations:
float only.kv_cache_bit_width must be 0 when quantization is disabled, and 4 or 8 when quantization is enabled.uint8 bytes and requires consumers to use the packed head dimension.Further optimization opportunities include:
M=1, N=512/2048, and K=64/128.CPU features that are limited or not implemented relative to the broader operator schema include:
GroupQueryAttentionBase<T>::GroupQueryAttentionBase(...)
num_heads, kv_num_heads, quantization type, and kv_cache_bit_widthGroupQueryAttention<T>::Compute(...)
GroupQueryAttentionBase<T>::ApplyAttentionQuantized(...)
MlasQKGemm and MlasSVGemmGroupQueryAttentionBase<T>::ApplyAttentionQuantizedFlash(...)
MlasFlashAttentionQuantizedKV with L2-cache-aware block sizesMlasQKGemm(...)
MlasSVGemm(...)
C = Beta*C + A*dequant(B) where A is FP32 attention probabilities and B is quantized V cacheBeta=0 (overwrite) for naive path; Beta=1.0 (accumulate) for flash pathMlasFlashAttentionQuantizedKV(...)