Back to Onnxruntime

CPU GroupQueryAttention performance experiments

docs/contrib_ops/cpu/gqa_fp16_experiments.md

1.29.010.5 KB
Original Source

CPU GroupQueryAttention performance experiments

FP16 inputs with INT8 KV cache

This experiment compares FP16 and FP32 CPU GroupQueryAttention decode latency while both use a per-tensor INT8 KV cache. It uses the flash-attention path selected by default by the operator.

Environment

  • Machine: AMD EPYC 7763 (AVX2), 16 physical cores / 32 logical CPUs.
  • Runtime: CPU-only Release wheel built from PR head d144409dec9f1bb47ddea77867308af194debfcd.
  • CPU affinity: logical CPUs 0-7.
  • Operator configuration: batch size 1, query heads 16, KV heads 8, head size 128, decode sequence length 1, and per-tensor INT8 KV cache.
  • Measurement: two runs, each with 50 warmup iterations and 500 measured iterations. The table reports the mean of the two runs.

Build and install

From the repository root, activate the Python virtual environment and build the CPU wheel:

bash
source .venv/bin/activate
./build.sh --build_dir build/cpu-fp16 --config Release --update --build --parallel 16 \
  --enable_pybind --build_shared_lib --build_wheel --skip_tests \
  --compile_no_warning_as_error \
  --cmake_extra_defines Python_EXECUTABLE="$VIRTUAL_ENV/bin/python"
cd build/cpu-fp16/Release
"$VIRTUAL_ENV/bin/python" "$PWD/../../../setup.py" bdist_wheel
"$VIRTUAL_ENV/bin/python" -m pip install --force-reinstall --no-deps \
  dist/onnxruntime-1.29.0-cp314-cp314-linux_x86_64.whl

The --compile_no_warning_as_error flag was needed on this host because GCC 15 reports a standard-library maybe-uninitialized false positive under the default warning policy.

Benchmark commands

Run outside the source checkout so Python imports the installed wheel rather than the source package:

bash
cd /tmp
taskset -c 0-7 "$VIRTUAL_ENV/bin/python" \
  ~/onnxruntime/onnxruntime/test/python/transformers/benchmark_gqa_cpu_flash.py \
  --decode_only --warmup 50 --repeats 500

Run the command twice. The benchmark covers decode total sequence lengths 513, 1025, 2049, and 4097, and prints separate FP32 and FP16 results using the same INT8 cache configuration.

Results

Flash-attention latency in milliseconds:

Decode total lengthFP32 run 1FP32 run 2FP32 meanFP16 run 1FP16 run 2FP16 meanFP16 vs. FP32
5130.3070.3250.3160.2700.2780.27413% faster
10250.3970.3930.3950.4180.3550.3862% faster
20490.7850.7810.7830.8650.8570.86110% slower
40970.8691.1371.0030.7831.0000.89211% faster

FP16 is faster at three of the four tested decode lengths, including the longest context. The single 2049-token result is within the observed run-to-run timing variation, so these results do not show a sustained FP16 performance regression relative to FP32.

FP16 quantized KV GEMM optimization

This experiment isolates the MlasQKGemmFp16 and MlasSVGemmFp16 calls used by quantized GQA. The original FP16 kernels repeatedly converted FP16 query values inside the QK dot products, and the SV implementation used a cache-unfriendly loop order that was substantially slower than the equivalent FP32 kernel for INT8 caches.

Changes

  • QK converts an FP16 query tile to FP32 once when conversion is profitable. On AVX2, single-row INT8 decode keeps direct FP16 vector loads, while INT4 decode and multi-row prefill reuse the converted tile. On AVX-512 VNNI, INT8 uses direct FP16 loads for any row count and only INT4 reuses the converted tile. NEON converts once and reuses its existing FP32 kernel.
  • SV reuses the cache-friendly FP32 accumulation kernel with one FP32 scratch row, then writes the result to FP16 with vectorized conversion.
  • Equivalent algorithmic changes are applied to AVX2, AVX-512, and NEON dispatches.

The KleidiAI matmul_clamp_f16_f16_f16p kernel was considered but not used. It requires an FP16 packed RHS and produces FP16 output, whereas these APIs consume an INT4/INT8 KV cache and QK produces FP32 scores. Dequantizing and packing the full cache on every attention call would discard the fused quantized-cache advantage.

Microbenchmark setup

  • Binary: build/cpu-fp16/Release/onnxruntime_mlas_benchmark.
  • Machine: the same AMD EPYC 7763 AVX2 host.
  • Shape: decode M=1, head size 128, total sequence lengths 512 and 2048.
  • Quantization: S8/S4, per-tensor and per-channel.
  • Measurement: 10 repetitions with a 0.5-second minimum per repetition. Tables report median real time. Coefficients of variation were 0.07-1.04%.

Example command:

bash
build/cpu-fp16/Release/onnxruntime_mlas_benchmark \
  --benchmark_filter='BM_(QKGemm|QKGemmFp16|SVGemm|SVGemmFp16)/M:1/.*128.*(512|2048).*' \
  --benchmark_min_time=0.5s --benchmark_repetitions=10 \
  --benchmark_report_aggregates_only=true --benchmark_time_unit=us

Accumulated FP16 improvements

The before columns are measurements of the original FP16 kernels from the same build and machine. After vs. FP32 compares the optimized FP16 API with the FP32 API in the final build.

optotal lengthquantizationFP16 before (us)FP16 after (us)FP16 improvementafter vs. FP32
QK512S8 per-tensor5.7255.3875.9%6.1% faster
QK512S8 per-channel6.6996.3824.7%4.0% faster
QK512S4 per-tensor14.60213.3178.8%1.0% faster
QK512S4 per-channel14.14113.4834.7%0.4% faster
QK2048S8 per-tensor23.00421.5166.5%6.1% faster
QK2048S8 per-channel26.81525.5124.9%4.1% faster
QK2048S4 per-tensor58.47653.2229.0%0.9% faster
QK2048S4 per-channel56.57853.9274.7%0.3% faster
SV512S8 per-tensor10.1565.00750.7%8.4% slower
SV512S8 per-channel10.1505.58844.9%2.4% faster
SV512S4 per-tensor10.84310.5952.3%0.9% slower
SV512S4 per-channel10.5889.8526.9%1.3% slower
SV2048S8 per-tensor41.59820.09151.7%9.1% slower
SV2048S8 per-channel41.48422.34046.1%2.3% faster
SV2048S4 per-tensor43.02842.3991.5%1.0% slower
SV2048S4 per-channel42.12039.3616.6%1.3% slower

QK FP16 improves by 4.7-9.0% and is faster than FP32 in every measured decode case. The largest gain is SV with INT8, where FP16 improves by 44.9-51.7%; optimized SV finishes within 9.1% of FP32 across all measured modes.

Correctness and build validation:

  • onnxruntime_mlas_test --gtest_filter='KVQuant.*': passed.
  • AVX2 and AVX-512 builds: passed.
  • lintrunner: passed.
  • NEON source diagnostics: clean, but an Arm64 compile was unavailable because this machine does not have an AArch64 cross compiler or sysroot.

Intra-op thread scaling after the GEMM optimization

This experiment measures whether the FP16 gain remains visible with 4 and 8 intra-op threads in the end-to-end GQA operator. It uses the same per-tensor INT8 cache, model shape, decode lengths, warmup, and iteration count as the first experiment.

Setup and commands

  • Optimized wheel built from commit 2b68b5c9ed8f4beba1546ad584f6c672b852d48c plus the uncommitted quantized KV GEMM changes described above.
  • Four-thread runs are pinned to logical CPUs 0-3; eight-thread runs are pinned to 0-7.
  • Two runs per thread count, each with 50 warmup and 500 measured iterations.
  • The benchmark script accepts --intra_op_num_threads so FP32 and FP16 sessions use the same requested thread count.
bash
cd /tmp
taskset -c 0-3 "$VIRTUAL_ENV/bin/python" \
  ~/onnxruntime/onnxruntime/test/python/transformers/benchmark_gqa_cpu_flash.py \
  --decode_only --warmup 50 --repeats 500 --intra_op_num_threads 4

taskset -c 0-7 "$VIRTUAL_ENV/bin/python" \
  ~/onnxruntime/onnxruntime/test/python/transformers/benchmark_gqa_cpu_flash.py \
  --decode_only --warmup 50 --repeats 500 --intra_op_num_threads 8

Flash-attention results

Latency in milliseconds. FP16 vs. FP32 compares means at the same thread count.

threadstotal lengthFP32 run 1FP32 run 2FP32 meanFP16 run 1FP16 run 2FP16 meanFP16 vs. FP32
45130.2440.2060.2250.2540.2530.25413% slower
410250.3780.4490.4140.4000.4510.4263% slower
420490.6650.8120.7390.7900.6380.7143% faster
440971.3291.0531.1911.1471.6571.40218% slower
85130.1880.1940.1910.2390.2370.23825% slower
810250.3930.3600.3770.2880.2890.28923% faster
820490.7430.6090.6760.4340.5690.50226% faster
840971.0620.7740.9180.9760.8740.9251% slower

Scaling from 4 to 8 threads

total lengthFP32 speedupFP32 latency reductionFP16 speedupFP16 latency reduction
5131.18x15%1.07x6%
10251.10x9%1.47x32%
20491.09x8%1.42x30%
40971.30x23%1.52x34%

At 8 threads, optimized FP16 is 23-26% faster than FP32 at total lengths 1025 and 2049. FP16 also scales more strongly than FP32 from 4 to 8 threads at lengths 1025-4097. The 513-token case is too small to amortize FP16 path overhead, and FP16 remains slower there.

The two-run variation is material for some long-context points: coefficients of variation reach 10-18% for the noisiest Flash measurements. In particular, the 4097-token FP16/FP32 comparison should be treated as approximate. The stable 8-thread 1025-token FP16 result has 0.2% CV; the 2049-token result has 13.5% CV but remains faster in both aggregate comparison and the isolated MLAS measurements.

End-to-end accumulated comparison

The following compares the first experiment's original eight-thread FP16 means with the optimized eight-thread FP16 means. Unlike the isolated MLAS table, these runs were taken at different times and have significant system-level variation, so they show end-to-end trend rather than a controlled kernel-only speedup.

total lengthoriginal FP16 meanoptimized FP16 meanchange
5130.274 ms0.238 ms13% faster
10250.386 ms0.289 ms25% faster
20490.861 ms0.502 ms42% faster
40970.892 ms0.925 ms4% slower

The controlled MLAS microbenchmarks are the primary evidence for accumulated kernel gains. The end-to-end results agree at 513-2049 tokens; the 4097-token result is dominated by the run-to-run variation already visible in both experiments.