Repository navigation
Speed up CPU GroupQueryAttention decode with a grouped flash kernel - #33920
Pierluigi Urru (stwykd) wants to merge 7 commits into
Conversation
Portable CPU attention kernel for fp32 and fp16 KV caches, built on MLAS SGEMM, MLAS fp16 conversion and the thread pool. All query heads that share a KV head are stacked as the rows of one GEMM against each cache tile, so a tile is read, converted to fp32 and packed once per KV head instead of once per query head, and reused by every row block of a prefill task. The softmax runs online in fp32 with causal and local window tile skipping, softcap, additive bias and head sinks. When there are fewer tasks than threads (decode), the cache is split into chunks whose partial softmax states are merged with a log-sum-exp reduction. Planning is done in 64-bit. The arguments are strided and take separate K and V caches with their own widths, so other CPU attention ops can reuse the kernel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The non-quantized CPU path processed one query head per task, so every KV head was read (and, for fp16 caches, copied to fp32) once per query head, and the flash path that avoided the score matrix was fp32 only. Route fp32 and fp16 caches through the grouped kernel instead. It covers causal and bidirectional attention, local windows, softcap, attention bias, smooth softmax and head sinks, packed QKV, ragged and right-padded batches, shared and read-only caches and the windowed cache. qk_output, quantized caches and ORT_GQA_DISABLE_FLASH_ATTENTION keep the existing paths, and so do fp16 caches where MLAS has fp16 GEMMs (ARM64 with fp16 arithmetic): there the per-head path computes in fp16, which is less accurate but still faster than the kernel's fp32 tiles. The present-cache append of the fp32 flash path moves into a helper that both paths use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Check the kernel against an fp64 reference for fp32 and fp16 caches, group sizes 1, 3 and 8, split decode, padded prefill, local windows, softcap, bias, head sinks and bidirectional attention, with unreadable rows poisoned. Check the op against the per-head path it replaces, through ORT_GQA_DISABLE_FLASH_ATTENTION and through the qk_output fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The grouped path now handles every step the fp32 flash path did, and is faster in decode, batched decode and prefill at every context length measured, so ApplyAttentionFlash is unreachable. MlasFlashAttentionGQA stays in MLAS with its own tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
fp16 caches now take the grouped flash path as well, except where MLAS has fp16 GEMMs, so check them against the reference with the path enabled and disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the fp32-only flash path section with the grouped path: what it covers, when it runs, how it splits work and the cache, and its numbers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- One-row GEMM blocks (decode with num_heads == kv_num_heads) skip packing the value tile, and on x64 the key tile too, folding the scale into the query so MLAS uses its single-row kernels. Packing each tile for a single row made that decode up to about 30% slower than before at short contexts. - Off x64, where MLAS has no single-row kernel for the transposed Q*K' product, ungrouped fp32 decode goes back to the MLAS fp32 flash path and its GEMV decode kernel, so that dispatch is kept rather than removed. - The split merge now skips only splits whose softmax sum is zero, so a NaN score gives NaN at any thread count, as it did before. - Fix the description of the cache split in the CPU notes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
the failing checks aren't from this change, which only touches the CPU GQA kernel: the T5 beam search tests in test_generation.py fail on main too (tiktoken missing and a protobuf version mismatch in the CI image), the kernel docs check diff is in Gather and Relu rows this PR doesn't touch, and the CUDA plugin EP job failed one FpA-IntB tuning cache test by a tiny margin (it passes on main and on #33914, so it looks flaky, could someone rerun it?). test_gqa_cpu.py and the GQA C++ tests pass in every job that runs them |
|
Tianlei Wu (@tianleiwu) you added the CPU flash attention path, would you mind taking a look? this applies the same tiling to grouped heads and fp16 caches, reading each cache tile once per KV head instead of once per query head |
There was a problem hiding this comment.
🟡 Changes recommended
Deferring the sink can produce infinite results when the correct output is finite, and incorrect ARM64EC guards bypass the intended GEMV fallback.
2 open issues
What changed in this PR
This PR accelerates CPU GroupQueryAttention, especially decode, by sharing each KV-cache tile across query heads that use it.
Changes:
- Adds a portable FP32/FP16 grouped attention kernel with tiled online softmax and split-cache processing.
- Integrates the kernel with GQA dispatch and cache handling while preserving existing fallback paths.
- Extends reference and parity tests and documents dispatch behavior and benchmarks.
| File | Description |
|---|---|
onnxruntime/test/python/transformers/test_gqa_cpu.py |
Extends decode parity tests to FP16. |
onnxruntime/test/contrib_ops/group_query_attention_op_test.cc |
Adds FP64-reference kernel tests and per-head-path comparisons. |
onnxruntime/contrib_ops/cpu/bert/grouped_flash_attention.h |
Defines the reusable kernel interface and cache strides. |
onnxruntime/contrib_ops/cpu/bert/grouped_flash_attention.cc |
Implements grouped tiled attention and split-state reduction. |
onnxruntime/contrib_ops/cpu/bert/group_query_attention.cc |
Dispatches eligible GQA calls to the grouped kernel. |
onnxruntime/contrib_ops/cpu/bert/gqa_attention_base.h |
Adds eligibility checks, argument setup, and shared cache appending. |
docs/contrib_ops/cpu/gqa.md |
Documents the algorithm, activation conditions, tests, and performance. |
Review effort: Max
| const size_t head_size = static_cast<size_t>(args_.head_size); | ||
| const size_t v_head_size = static_cast<size_t>(args_.v_head_size); | ||
|
|
||
| std::fill_n(m, rows, kLowest); |
| // attention, local windows, softcap, attention bias and smooth softmax/head sink; other steps take another path. | ||
| template <typename T> | ||
| bool CanApplyAttentionGrouped(bool has_output_qk, bool has_present, bool read_only_cache, int sequence_length) const { | ||
| #if !defined(_M_AMD64) && !defined(__x86_64__) |


Description
CPU GroupQueryAttention with fp32 or fp16 KV caches is far slower than its cache size allows, mostly in decode. On
main:gqa_attention_base.h:1475,:1729), so each KV head is read once per query head, and with fp16 caches each task copies its KV head's whole K and V to fp32 (:1589,:1812).group_query_attention.cc:811). Its decode is one GEMV task per query head, splits the cache only whenbatch_size * num_heads < threads(gqa_attention_base.h:1291), and its GEMVs (flashattn_gqa.cpp:51-101) are built with baseline flags (onnxruntime_mlas.cmake:73), so the x64 object has no AVX or FMA.This PR adds
grouped_flash_attention.{h,cc}, a portable kernel on MLAS SGEMM, fp16 conversion and the thread pool. Per KV head it stacks the (token, head) rows of the group as the GEMM M dimension against each cache tile, so a tile is read, converted and packed once. Blocks of a single row (decode withnum_heads == kv_num_heads) skip packing and use MLAS's single-row kernels instead. The softmax runs online in fp32 with causal and window tile skipping. With fewer tasks than threads (decode) the cache is split and merged with a log-sum-exp reduction. Planning arithmetic is inptrdiff_t.GQA uses it for fp32 and fp16 caches, covering causal and bidirectional attention, local window, softcap, attention bias, smooth softmax and head sink, rotary, packed QKV, ragged and
query_seqlensbatches, shared, separate and read-only caches, and the sliding window cache. Quantized caches,qk_outputandORT_GQA_DISABLE_FLASH_ATTENTION=1keep the existing paths, as do fp16 caches where MLAS has fp16 GEMMs (ARM64 with fp16 arithmetic), where the per-head path is still faster. The fp32 flash dispatch stays behind the new path: off x64, ungrouped fp32 decode still uses its GEMV kernel, which is faster there because MLAS has no single-row kernel for the transposedQ*K'product.docs/contrib_ops/cpu/gqa.mddescribes the dispatch.The kernel takes separate K and V caches with their own widths and strides, so other CPU attention ops can share it, for example latent attention (#33914) with V as a column window of K.
Tests
New tests check the kernel against fp64 (fp32/fp16, group sizes 1, 3, 8, split decode, padded prefill, window, softcap, bias, sinks, bidirectional, NaN-poisoned unread rows) and the op against the per-head path; the Python decode parity test now covers fp16. GQA C++ and Python tests pass on x64; on ARM64 the C++ tests pass and Python fails only where
maindoes (below). Over 1000 random configurations, outputs matchmainwithin 2.6e-6 (fp32) and one fp16 ulp.Performance
One layer, 4 threads, Xeon w5-2565X, cache flushed between decode calls, median of 3 runs, ms:
Ungrouped decode (
num_heads == kv_num_heads, as Phi-3-mini exports) doesn't regress: 8/8 heads fp32 at 2K and 8K is 0.83x and 0.81x the time ofmain, 32/32 heads fp16 at 2K 0.91x, on a busy host. On Neoverse N1 it stays on the GEMV path and matchesmain.fp16 decode at 32K reads the cache at 22 GB/s, a third of 4-core bandwidth. On Neoverse N1, fp32 decode is 1.4 to 2.4x faster and prefill 1.1x.
Follow-ups
MlasFlashAttentionGQA, with an ARM64 single-rowQ*K'kernel so ungrouped decode can use the grouped path there too.test_gqa_interactive_one_batchfails onmainon Neoverse N1).