Skip to content

Speed up CPU GroupQueryAttention decode with a grouped flash kernel - #33920

Draft
Pierluigi Urru (stwykd) wants to merge 7 commits into
microsoft:mainfrom
stwykd:gqa-cpu-decode
Draft

Pierluigi Urru (stwykd) wants to merge 7 commits into
microsoft:mainfrom
stwykd:gqa-cpu-decode

Conversation

@stwykd

Copy link
Copy Markdown

Description

CPU GroupQueryAttention with fp32 or fp16 KV caches is far slower than its cache size allows, mostly in decode. On main:

  • The non-quantized path runs one task per query head (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).
  • The flash path is fp32 only (group_query_attention.cc:811). Its decode is one GEMV task per query head, splits the cache only when batch_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 with num_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 in ptrdiff_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_seqlens batches, shared, separate and read-only caches, and the sliding window cache. Quantized caches, qk_output and ORT_GQA_DISABLE_FLASH_ATTENTION=1 keep 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 transposed Q*K' product. docs/contrib_ops/cpu/gqa.md describes 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 main does (below). Over 1000 random configurations, outputs match main within 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:

2K 8K 32K
decode fp16, 24 q / 8 kv heads (Llama 3.2 3B) 0.87 → 0.49 5.43 → 1.72 32.4 → 5.86
decode fp16, 32/8 (Llama 3 8B) 1.08 → 0.49 6.88 → 1.76 48.0 → 6.00
decode fp16, 28/4 (Qwen2.5 7B) 1.10 → 0.30 6.52 → 1.05 42.6 → 3.59
decode fp32, 24/8 0.99 → 0.59 5.72 → 2.17 21.3 → 7.75
prefill 128, fp16, 24/8 6.28 → 5.77 25.1 → 20.8 113.6 → 81.4
prefill 128, fp32, 24/8 6.46 → 5.88 24.0 → 22.1 93.0 → 83.1

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 of main, 32/32 heads fp16 at 2K 0.91x, on a busy host. On Neoverse N1 it stays on the GEMV path and matches main.

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

  • Share the kernel with latent attention, or move it into MLAS beside MlasFlashAttentionGQA, with an ARM64 single-row Q*K' kernel so ungrouped decode can use the grouped path there too.
  • Small-group decode is bound by MLAS packing each tile; a hand-written AVX2 kernel is 1.3x faster at 32K, so an MLAS multi-row fp16 GEMV would help.
  • An fp16-GEMM variant for ARM64, whose per-head fp16 path is less accurate (test_gqa_interactive_one_batch fails on main on Neoverse N1).
  • Quantized caches through the same tiling.

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

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@stwykd

Copy link
Copy Markdown
Author

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

@stwykd

Copy link
Copy Markdown
Author

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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__)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants