[MLAS] Add an AVX-512 fused kernel for LinearAttention - #31674
Open
mirounga wants to merge 6 commits into
Open
Conversation
Infer output hidden dim as max(q_num_heads, kv_num_heads) * d_v to match Compute(); add standard-GQA regression tests.
The WebGPU kernel allocated its output with kv_num_heads * d_v, but the op
produces one output per Q head for standard GQA, so the output hidden dim is
max(q_num_heads, kv_num_heads) * d_v — as the schema inference and the CPU and
CUDA kernels already had it. Any model with q_num_heads > kv_num_heads failed
with a shape mismatch against the inferred shape, e.g. expected {2,10,512} but
got {2,10,128} for q=8/kv=2/d_v=64.
The shader shared a single row stride between V and the output, which is only
correct while q == kv. Split it: packed_dv (num_heads * d_v) still indexes the
value reads, and the new packed_dv_out (max(q_num_heads, num_heads) * d_v)
indexes the output writes. Without this the widened allocation would leave the
extra query heads overwriting each other's rows.
Inverse GQA (q < kv) is unaffected: there max(q,kv) == kv, so packed_dv_out ==
packed_dv and every index is unchanged.
This is the WebGPU counterpart to the shape-inference fix in 24f9b88; the
six *_StandardGQA_* tests added there were the first coverage of q > kv and
were failing on WebGPU. ContribOpLinearAttentionTest now passes 37/37 on the
WebGPU EP (was 31/37), verified on Radeon 890M / Mesa RADV via Dawn.
The CPU LinearAttention contrib op carried its entire math kernel in the
EP kernel file: a 245-line anonymous-namespace ProcessHead driven by a
ThreadPool::TryParallelFor in Compute. Every other fused-attention kernel
in the repo (MlasFlashAttention, MlasFlashAttentionGQA,
MlasFlashAttentionQuantizedKV) lives in MLAS, where the ISA dispatch
infrastructure, the per-thread scratch convention and the unit-test and
benchmark harnesses already exist.
Keeping the math in contrib_ops has concrete costs: there is nowhere to
put a vectorized kernel (contrib_ops/cpu contains no intrinsics by
design), the kernel cannot be tested or benchmarked in isolation, and the
d_k*d_v >= 4096 MLAS threshold is invisible to anyone reading MLAS.
Add MlasLinearAttention, following the MlasFlashAttention pattern: an
args struct in mlas.h, a threaded routine dispatched through
MlasExecuteThreaded, and a caller-allocated buffer indexed by
thread_id * buffer_size_per_thread. Work is partitioned over
(batch, kv head) pairs - each owns a disjoint state matrix, and the
sequential token dependency lives entirely within a pair.
The recurrence is unchanged: same four steps, same d_k*d_v >= 4096
threshold selecting SGEMM over scalar loops, same alpha=1.0 plus separate
post-scale in the readout. MlasGemm becomes MlasSgemmOperation because
the outer parallel loop already owns the threads; with a null thread pool
MlasGemmBatch already resolved to a single full-range MlasSgemmOperation,
so the arithmetic is identical.
Two interface cleanups the move made possible:
- The heads_per_group == 0 in-band sentinel for inverse GQA is gone.
Both GQA directions reduce to "read HeadsPerGroup consecutive query
heads from h_q0, write HeadsPerGroup consecutive output heads from
h_out0", collapsing the duplicated 38-line readout branch into one
loop. The kernel receives resolved pointers and never sees the head
mapping.
- MlasLinearAttentionOutputHiddenSize is exported and used for both the
EP's output shape and MLAS's output stride, so the two cannot
disagree. That mismatch is what commits 24f9b88 and a6acfff
fixed.
Also add a MLAS_LINEAR_ATTENTION_DISPATCH table with the portable kernel
registered as the baseline for every target, so the indirect-call path is
exercised everywhere from the start rather than being dead code. The
dispatch granularity is one whole (batch, kv head) sequence: the state
matrix is d_k*d_v floats (8-64 KB, L1/L2 resident), so a vectorized
kernel must be free to fuse decay, retrieval, update and readout into a
single streaming pass over it. Finer-grained primitives would force one
traversal of that working set per primitive. No per-ISA translation unit
is added here, so the per-ISA cmake blocks are untouched.
Behavioural notes for review:
- Threading scheduling differs. TryParallelFor's cost model could
collapse to serial for tiny problems; the static partition has no
cost model, but the partition count is clamped to batch * kv_heads,
which also lets the common B=1, H_kv=1 decode case take the inline
path in MlasExecuteThreaded. Numerics are unaffected: heads are
independent, so results are thread-count invariant.
- Scratch moves from a per-shard std::vector<float>(d_v) to one
GetTempSpaceAllocator block of thread_count * buffer_size_per_thread.
- Guard the past_state memcpy with ps_data != state_data. The
recurrence runs in place on the present_state output tensor, so if
ORT ever aliases input 3 with output 1 the unguarded copy is UB.
- On arm64 with KleidiAI, MlasGemmBatch could route to KAI while
MlasSgemmOperation never does. KAI is very unlikely to accept these
M=1 / K=1 shapes, and bypassing it inside a parallel region is
arguably correct, but this is the one place the port could differ.
Not verified on arm64.
Add test/mlas/unittest/test_linear_attention.cpp, which checks both the
output and the final state against an independent double-precision oracle
written from the recurrence definition. Coverage spans all four update
rules, both decay and beta layouts, (d_k, d_v) on both sides of the 4096
threshold including 64x64 exactly, key-head sharing, standard and inverse
GQA, and thread counts 1/2/3/8 to exercise both the clamp and the
remainder split. Add test/mlas/bench/bench_linear_attention.cpp with an
in-TU scalar baseline as a fixed comparison point for future kernels.
Neither exists for the flash-attention family today.
Verified in a -march=native RelWithDebInfo build: all 39
ContribOpLinearAttentionTest cases pass, and pass identically under
taskset at 1, 2, 4 and 8 cores.
The portable linear-attention kernel walks the state matrix S once per step
of the recurrence - decay, retrieval, rank-1 update, readout - which is
roughly 4 reads and 2 writes of S per token. S is d_k*d_v floats (8-64 KB,
L1/L2 resident but far larger than the register file), so that traffic, not
the arithmetic, is what the kernel is bound by.
Add an AVX-512 kernel behind MLAS_LINEAR_ATTENTION_DISPATCH that fuses all
four steps into two passes, one read and one write, using the identity
o_g[j] = scale * ( sum_i q'_g[i]*S_old[i,j] + (q_g . k) * upd[j] )
with q'_g[i] = dec[i]*q_g[i]. The readout is therefore expressible from
S_old plus a rank-1 correction and never needs the written-back S_new, so
pass 1 can accumulate `retrieved` and every query head's partial readout
from a single read of S_old while pass 2 writes S_new in place. S is
processed one 32-column panel at a time, so pass 2 re-reads the panel from
L1 and the intermediates stay in ZMM registers.
Two kernels: a single-readout-head variant (MHA, and inverse GQA where one
query head is shared) and a GQA variant templated on the group size, where
one read of each S_old panel feeds `retrieved` plus all NOUT heads'
accumulators at 1 + NOUT FMAs per load. Templating on NOUT keeps the
per-head accumulators at compile-time indices so they stay in registers; a
runtime loop spills them. AVX-512 embedded broadcast makes the pre-weight
vectors memory operands, so they cost no registers.
Everything is intrinsics rather than autovectorized: left to the compiler,
the pass-1 accumulators spilled and q.k became a serial scalar horizontal
reduction.
Both kernels are additionally templated on whether the rule carries decay.
This is not a micro-optimization - without it the kernel *lost* to the
portable path on the `linear` rule at d_k = d_v = 128 (0.77-0.97x). That
rule is the one the portable path handles in the fewest passes, so it is
already close to memory-bound on a tuned SGEMM, and the kernel was paying
for a decay it did not have: filling dec[i] = 1, pre-multiplying both
weight vectors by 1, and doing a redundant multiply per element in pass 2.
The specialization drops all three - the kernels read q and k directly and
copy nothing, and pass 2 collapses from a multiply plus an FMA to a single
FMA - which brings `linear` back to parity.
The kernel is used when d_k % 16 == 0, d_k <= 256, d_v % 32 == 0 and the
group size is 1, 2, 4 or 8; anything else falls back to the portable
kernel, which has no shape restrictions. The panel loops are deliberately
unmasked and the pre-weight buffers fixed-size, which is what sets those
bounds. A group size of 16 is excluded: it would need a 16-column
single-lane variant to fit the register file, and that was benchmarked
slower than a batched GEMM.
Registration is in the AVX512F block of platform.cpp, inside
MLAS_TARGET_AMD64, matching the two cmake lists the translation unit is
added to (mlas_platform_srcs_avx512 with /arch:AVX512 for MSVC,
mlas_platform_srcs_avx512f with -mavx512f for GCC/Clang). Only AVX512F is
required - no BW/DQ/VL/VNNI - so this is not gated on Avx512Supported_.
Non-x86 targets never reference the symbol.
Measured A/B in one binary with only the registration line toggled, on a
-march=native RelWithDebInfo build, over 44 shape/rule combinations:
rule median range
linear 1.01x 0.92 - 9.24x
gated 1.22x 1.06 - 7.77x
delta 1.32x 1.02 - 10.21x
gated_delta 1.46x 1.09 - 7.92x
Overall median 1.32x. The largest wins are below the portable kernel's
d_k*d_v >= 4096 SGEMM threshold, where it would otherwise run scalar
loops. Three combinations remain marginally slower (0.92x, 0.98x, 0.98x,
all `linear` at d_k = d_v = 128); the 0.92x case is a single (batch, kv
head) pair, so it runs unthreaded against MLAS SGEMM. Routing is left
simple rather than special-casing it.
The kernel reassociates the floating-point sums relative to the portable
path, so results agree to tolerance rather than bit-exactly.
Extend the MLAS unit test to cover the eligibility envelope: head configs
now span group sizes 1, 2, 4, 8 and 16 (the last must fall back), and the
shape list adds {272, 32}, which satisfies d_k % 16 == 0 but exceeds the
d_k <= 256 bound. Extend the benchmark to all four update rules. Both
kernels were confirmed to actually execute rather than silently falling
back, by perturbing each call site independently and checking the unit
test fails.
All 39 ContribOpLinearAttentionTest cases pass, at 1, 4 and 8 cores.
# Conflicts: # onnxruntime/test/contrib_ops/linear_attention_op_test.cc
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds an AVX-512F optimized FP32 LinearAttention recurrence kernel to MLAS (with dispatch selection) and migrates the CPU contrib LinearAttention operator to call the new MLAS entrypoint, alongside expanded unit tests/benchmarks and updated schema/docs for inverse-GQA output packing.
Changes:
- Implement MLAS LinearAttention core + dispatch contract, plus an AVX-512F fused two-pass kernel.
- Route the CPU contrib
LinearAttentionoperator throughMlasLinearAttention(including scratch allocation and head-mapping validation). - Extend MLAS unit tests/benchmarks and update schema/test comments for
max(H_q, H_kv) * d_voutput packing.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| onnxruntime/test/mlas/unittest/test_linear_attention.cpp | Adds MLAS unit test comparing MlasLinearAttention against a scalar oracle across shapes/rules/head configs. |
| onnxruntime/test/mlas/bench/bench_linear_attention.cpp | Adds benchmark for scalar baseline vs MLAS LinearAttention across rules/shapes. |
| onnxruntime/test/contrib_ops/linear_attention_op_test.cc | Updates output-shape comment and expands contrib-op coverage to all four update rules in a config. |
| onnxruntime/core/mlas/lib/platform.cpp | Initializes default LinearAttention dispatch and overrides it for AVX-512F targets. |
| onnxruntime/core/mlas/lib/mlasi.h | Declares LinearAttention dispatch externs and adds dispatch pointer to platform struct. |
| onnxruntime/core/mlas/lib/linear_attention.h | Introduces work-item struct and dispatch interface for LinearAttention kernels. |
| onnxruntime/core/mlas/lib/linear_attention.cpp | Implements portable LinearAttention kernel + threaded driver and default dispatch object. |
| onnxruntime/core/mlas/lib/linear_attention_kernel_avx512.cpp | Implements AVX-512 fused recurrence kernel and AVX-512 dispatch object. |
| onnxruntime/core/mlas/inc/mlas.h | Adds public MLAS LinearAttention API surface and argument struct/enums. |
| onnxruntime/core/graph/contrib_ops/bert_defs.cc | Updates LinearAttention schema docs and output-0 shape inference to max(H_q, H_kv) * d_v. |
| onnxruntime/contrib_ops/cpu/bert/linear_attention.h | Stores resolved MLAS rule enum in the kernel instance. |
| onnxruntime/contrib_ops/cpu/bert/linear_attention.cc | Refactors CPU kernel to validate inputs and call MlasLinearAttention with allocated scratch/state/output. |
| cmake/onnxruntime_mlas.cmake | Adds new MLAS LinearAttention sources (including AVX-512 TU) to the build. |
Suppressed comments (1)
onnxruntime/core/mlas/lib/linear_attention_kernel_avx512.cpp:215
- wkv_buf is allocated at MaxK regardless of HAS_DECAY, but it’s only used in the HAS_DECAY && needs_retrieval path. Making it conditional avoids unnecessary stack usage for the no-decay specialization and for the decay specialization when retrieval is disabled.
float wkv_buf[MaxK];
float wqv_buf[HAS_DECAY ? NOUT * MaxK : 1];
Review and CI feedback on microsoft#31674. No change to kernel behaviour. Formatting. Run clang-format over the two new files under onnxruntime/test/mlas/. They were the only ones flagged because .lintrunner.toml excludes onnxruntime/core/mlas/** from CLANGFORMAT but not onnxruntime/test/mlas/**. This is also what was failing the check reported as "Python format": that job runs `lintrunner --all-files`, which includes the CLANGFORMAT linter, so unformatted C++ fails it despite the name. `lintrunner --all-files` is now clean. Add the missing <algorithm> include to the benchmark, which uses std::copy_n and std::fill and was relying on transitive includes. Size the AVX-512 pre-weight buffers to 1 when HAS_DECAY is false. Without decay both kernels read the query rows and the key directly and never write these buffers, so the no-decay instantiation was reserving ~2 KB of dead stack per call in the single-head kernel and another d_k floats in the GQA one. The GQA kernel already did this for wqv_buf; wkv_buf and both of the single-head buffers now follow. Assert the argument preconditions rather than throwing, and document them on MlasLinearAttentionArgs. A reviewer asked for MLAS_THROW_EX validation of the head counts and the decay/beta pointers. Declining that, because it would make LinearAttention the only attention entry point in MLAS that validates: MlasFlashAttention, MlasFlashAttentionGQA and MlasFlashAttentionQuantizedKV are all bare dispatch shims, and mlas.h documents "num_heads % kv_num_heads == 0" for GQA in a comment that nothing checks. MlasGemmBatch and MlasQNBitGemmBatch are the same. By convention the EP validates and reports a Status, and contrib_ops/cpu/bert/linear_attention.cc already does so more thoroughly than either MHA or GQA. There is also a concrete cost: under MLAS_NO_EXCEPTION (minimal and mobile builds) MLAS_THROW_EX becomes abort(), turning a caller bug into a process kill instead of a failed Status. assert() matches what qnbitgemm.cpp and sqnbitgemm_kernel_avx2.cpp do for internal invariants and still catches a mis-filled args struct in a debug build, before it becomes a division by zero or a null dereference inside a kernel. Docs. Reword the Output(0) schema description and regenerate the corresponding line in docs/ContribOperators.md, which had gone out of sync when the description was last changed - the cause of the Windows GPU Kernel Documentation Validation failure. The rewording is not cosmetic: gen_contrib_doc.py interpolates the description straight into an HTML <dd> element with no escaping, so the previous wording's "inverse GQA (H_q < H_kv)" would have emitted a bare < inside markup and rendered as a stray tag. The text now avoids angle brackets entirely and reads "inverse GQA, where H_kv exceeds H_q". The regenerated <dd> matches the concatenated schema string exactly. Verified on a -march=native RelWithDebInfo build: `lintrunner --all-files` clean, build clean with no new warnings, MLAS unit test passes, and all 39 ContribOpLinearAttentionTest cases pass at 1, 4 and 8 cores. Both AVX-512 kernels were confirmed to still execute rather than fall back, by perturbing the single-head and group-of-four dispatch sites independently and checking the unit test fails for each.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[MLAS] Add an AVX-512 fused kernel for LinearAttention
The portable linear-attention kernel walks the state matrix S once per step
of the recurrence - decay, retrieval, rank-1 update, readout - which is
roughly 4 reads and 2 writes of S per token. S is d_k*d_v floats (8-64 KB,
L1/L2 resident but far larger than the register file), so that traffic, not
the arithmetic, is what the kernel is bound by.
Add an AVX-512 kernel behind MLAS_LINEAR_ATTENTION_DISPATCH that fuses all
four steps into two passes, one read and one write, using the identity
with q'_g[i] = dec[i]*q_g[i]. The readout is therefore expressible from
S_old plus a rank-1 correction and never needs the written-back S_new, so
pass 1 can accumulate
retrievedand every query head's partial readoutfrom a single read of S_old while pass 2 writes S_new in place. S is
processed one 32-column panel at a time, so pass 2 re-reads the panel from
L1 and the intermediates stay in ZMM registers.
Two kernels: a single-readout-head variant (MHA, and inverse GQA where one
query head is shared) and a GQA variant templated on the group size, where
one read of each S_old panel feeds
retrievedplus all NOUT heads'accumulators at 1 + NOUT FMAs per load. Templating on NOUT keeps the
per-head accumulators at compile-time indices so they stay in registers; a
runtime loop spills them. AVX-512 embedded broadcast makes the pre-weight
vectors memory operands, so they cost no registers.
Everything is intrinsics rather than autovectorized: left to the compiler,
the pass-1 accumulators spilled and q.k became a serial scalar horizontal
reduction.
Both kernels are additionally templated on whether the rule carries decay.
This is not a micro-optimization - without it the kernel lost to the
portable path on the
linearrule at d_k = d_v = 128 (0.77-0.97x). Thatrule is the one the portable path handles in the fewest passes, so it is
already close to memory-bound on a tuned SGEMM, and the kernel was paying
for a decay it did not have: filling dec[i] = 1, pre-multiplying both
weight vectors by 1, and doing a redundant multiply per element in pass 2.
The specialization drops all three - the kernels read q and k directly and
copy nothing, and pass 2 collapses from a multiply plus an FMA to a single
FMA - which brings
linearback to parity.The kernel is used when d_k % 16 == 0, d_k <= 256, d_v % 32 == 0 and the
group size is 1, 2, 4 or 8; anything else falls back to the portable
kernel, which has no shape restrictions. The panel loops are deliberately
unmasked and the pre-weight buffers fixed-size, which is what sets those
bounds. A group size of 16 is excluded: it would need a 16-column
single-lane variant to fit the register file, and that was benchmarked
slower than a batched GEMM.
Registration is in the AVX512F block of platform.cpp, inside
MLAS_TARGET_AMD64, matching the two cmake lists the translation unit is
added to (mlas_platform_srcs_avx512 with /arch:AVX512 for MSVC,
mlas_platform_srcs_avx512f with -mavx512f for GCC/Clang). Only AVX512F is
required - no BW/DQ/VL/VNNI - so this is not gated on Avx512Supported_.
Non-x86 targets never reference the symbol.
Measured A/B in one binary with only the registration line toggled, on a
-march=native RelWithDebInfo build, over 44 shape/rule combinations:
Overall median 1.32x. The largest wins are below the portable kernel's
d_k*d_v >= 4096 SGEMM threshold, where it would otherwise run scalar
loops. Three combinations remain marginally slower (0.92x, 0.98x, 0.98x,
all
linearat d_k = d_v = 128); the 0.92x case is a single (batch, kvhead) pair, so it runs unthreaded against MLAS SGEMM. Routing is left
simple rather than special-casing it.
The kernel reassociates the floating-point sums relative to the portable
path, so results agree to tolerance rather than bit-exactly.
Extend the MLAS unit test to cover the eligibility envelope: head configs
now span group sizes 1, 2, 4, 8 and 16 (the last must fall back), and the
shape list adds {272, 32}, which satisfies d_k % 16 == 0 but exceeds the
d_k <= 256 bound. Extend the benchmark to all four update rules. Both
kernels were confirmed to actually execute rather than silently falling
back, by perturbing each call site independently and checking the unit
test fails.
All 39 ContribOpLinearAttentionTest cases pass, at 1, 4 and 8 cores.