Repository navigation
[CPU] Add windowed kv cache for GQA - #31142
kunal-vaishnavi merged 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds CPU Execution Provider support for sliding_window_cache=1 on com.microsoft::GroupQueryAttention, enabling cache-relative (windowed) KV caches with a drifting-layout compaction strategy to reduce per-token decode latency while preserving numerical parity with a full-length cache.
Changes:
- Implement windowed KV cache planning/compaction/staging in the CPU GQA kernel
Compute()to reuse existing attention paths with cache-relativeseqlens_k. - Add CPU parity coverage by reusing the existing CUDA windowed-cache test suite on
CPUExecutionProvider(fp32 cache) and add stress scenarios for frequent compactions/staging. - Update the GQA documentation to reflect the corrected capacity requirement (
C >= W) and describe CPU behavior/limitations.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| onnxruntime/contrib_ops/cpu/bert/group_query_attention.cc | Implements windowed KV cache planning and memory movement (compaction + staging) for CPU GQA. |
| onnxruntime/contrib_ops/cpu/bert/group_query_attention.h | Adds a private sliding_window_cache_ attribute flag on the CPU kernel. |
| onnxruntime/test/python/transformers/test_gqa.py | Adds CPU windowed-cache parity tests by subclassing the existing suite; extends scenarios for small-slack compaction and staged chunked prefill. |
| docs/contrib_ops/cuda/gqa.md | Updates windowed cache capacity guidance and documents CPU support + limitations. |
Comments suppressed due to low confidence (1)
onnxruntime/contrib_ops/cpu/bert/group_query_attention.cc:94
PlanWindowedKvCachecomputespast_sequence_length + sequence_lengthandseqlens_k[b] + 1 - sequence_lengthusingint, which can overflow whenseqlens_k[b]is nearINT32_MAX. Even if such inputs are invalid, overflowing before validation is undefined behavior. Do the arithmetic inint64_t/SafeIntbefore passing values toWindowedCacheEnd.
for (int b = 0; b < batch_size; ++b) {
const int past_sequence_length = seqlens_k[b] + 1 - sequence_length; // P
const int end_before = WindowedCacheEnd(past_sequence_length, capacity, gap);
const int end_after = WindowedCacheEnd(past_sequence_length + sequence_length, capacity, gap);
const int kept = end_after - sequence_length; // resident rows that survive this step
| int WindowedCacheEnd(int past_sequence_length, int capacity, int gap) { | ||
| if (past_sequence_length <= capacity) { | ||
| return past_sequence_length; // still filling: nothing has been reclaimed yet | ||
| } | ||
| const int overflow = past_sequence_length - capacity; | ||
| const int reclaimed = gap * ((overflow + gap - 1) / gap); | ||
| return past_sequence_length - reclaimed; | ||
| } |
There was a problem hiding this comment.
This looks like a valid issue.
There was a problem hiding this comment.
Good catch — fixed in d3c1a4d on the parent branch (#29904).
WindowedCacheEnd now takes int64_t parameters and does the intermediate math in 64-bit, and PlanWindowedKvCache computes past_sequence_length as std::max<int64_t>(int64_t{seqlens_k[b]} + 1 - sequence_length, 0). seqlens_k is caller-supplied, so seqlens_k[b] + 1 and overflow + gap - 1 could signed-overflow near INT32_MAX — UB, even though later validation would reject such inputs. The result is bounded by capacity, so narrowing back to int is safe. The clamp at zero also prevents a bogus (too small) seqlens_k from producing a negative row count downstream.
The equivalent CUDA helper in group_query_attention_impl.cu got the same treatment in the same commit.
Apply the same amortised-compaction approach from the CPU windowed KV-cache
kernel to the CUDA GetSequenceLengths device kernel.
For single-token decode steps (sequence_length == 1) the append-point
now follows the drifting formula:
gap = C - W + 1
end(P) = P while P <= C
= P - gap * ceil((P - C) / gap) afterwards (so W <= end <= C)
which is still a pure function of P, so the op stays stateless. With gap > 1
evict_counts[b] is 0 on all but one step in every gap, meaning CompactKvCacheRows
hits the d <= 0 early-exit on those steps and performs no memory traffic. The
early-exit path already existed; this change makes it fire frequently instead of
never (the prior code always produced evict_counts = 1 once the cache was full).
Multi-token steps keep the original Lp = min(P, C) formula because the staging
buffer size must be predictable and the write-back offset semantics are different.
On A100 fp16 the capacity sweep shows the decode latency is flat within noise
(±0.008 ms out of ~0.254 ms total) across all capacities from C=W to C=W+1920.
This is because the dominant cost is CUDA graph node scheduling overhead
(~24 launches x ~2 µs/launch = ~48 µs/token), not compaction data movement.
The drifting layout eliminates the data movement on gap-1 out of every gap steps,
but the launch overhead is unchanged. In other words the compaction kernel cost
was negligible on CUDA even before this change.
The correct practical conclusion is that C == W is already optimal for CUDA
(minimum memory footprint, no performance cost), unlike CPU where the optimal
is C = W + 16. This commit still makes the code correct and self-consistent
and removes the stale comment that claimed amortization was impossible.
26 parity tests pass (13 CUDA + 13 CPU).
|
|
||
| // Fills `steps` and the cache-relative seqlens_k, and decides whether the step needs a staging | ||
| // cache. `seqlens_k[b]` is the absolute total length minus one, i.e. T - 1 = P + S - 1. | ||
| void PlanWindowedKvCache(const int32_t* seqlens_k, |
There was a problem hiding this comment.
From Copilot:
In PlanWindowedKvCache, we’re doing seqlens_k[b] + 1 - sequence_length and past_sequence_length + sequence_length in int. Could we switch this arithmetic to int64_t or SafeInt before converting back? That would avoid signed-overflow UB on invalid/extreme inputs before validation kicks in.
There was a problem hiding this comment.
Fixed in d3c1a4d on the parent branch (#29904): past_sequence_length is now computed as std::max<int64_t>(int64_t{seqlens_k[b]} + 1 - sequence_length, 0), so neither the + 1 nor the subtraction can overflow, and a bogus seqlens_k cannot yield a negative row count. WindowedCacheEnd and the matching CUDA __device__ helper were widened to int64_t in the same commit.
| // The QK output is laid out over the absolute total_sequence_length, while a windowed cache | ||
| // computes scores over cache-relative positions only. Reject the combination rather than | ||
| // emitting a buffer whose columns do not mean what the shape says. | ||
| if (parameters.is_windowed_kv_cache && output_qk != nullptr) { |
There was a problem hiding this comment.
Is output_qk implemented inside GQA? From what I recall, we added it to MHA because GQA's optimized kernels (e.g. flash attention, memory efficient attention) would never accumulate the QK matrix.
There was a problem hiding this comment.
Yes, qk_output is implemented in GQA. The schema registers the qk_output attribute and runs shape inference for output index 3 (GroupQueryAttentionTypeAndShapeInference(ctx, 3, qk_output_index) in core/graph/contrib_ops/bert_defs.cc), and the CPU kernel writes that output through the unfused attention path in gqa_attention_base.h.
The reason it feels restricted is that the fast paths opt out of it: both the flash-attention and memory-efficient-attention branches are only selected when output_qk == nullptr. So requesting QK output forces the unfused kernel. That is also why the windowed cache rejects the combination for now — the unfused path would need separate handling for the drifting cache layout.
f0f834e
into
tlwu/20260727/sliding_window_cache
|
I added some small notes before I noticed this was merging into another PR. I merged the CPU PR into the CUDA PR so that all changes can be made in one branch. |
Add CPU support for
sliding_window_cache=1oncom.microsoft::GroupQueryAttention, matching theexisting CUDA/XQA contract: the cache buffer holds only the most recent tokens, indexed in
cache-relative coordinates; results are numerically identical to a full-length cache.
The second commit introduces a drifting-layout optimisation that amortises the per-step compaction
cost, reducing per-token decode latency by 1.35–1.81× depending on buffer size.
Summary of Changes
CPU Kernel (
group_query_attention.cc / .h)contrib_ops/cpu/bert/group_query_attention.ccWindowedCacheEnd,PlanWindowedKvCache,MoveKvCacheRows) plus the windowed block insideCompute().contrib_ops/cpu/bert/group_query_attention.hsliding_window_cache_field.Key design decisions:
Single intercept point. The CPU kernel has four attention paths (naive, quantized, quantized-flash, MLAS-flash). Instead of modifying each, the entire windowed-cache logic lives in
Compute()and passes a compacted buffer as bothpast_key/valueandpresent_key/value. The CPU kernel inferspast_present_share_bufferfrom pointer equality, soConcatStateChunkGQAonly appends new tokens.Drifting layout (commit 2). Resident entries live at
[0, end)with the append point tracked via:This is still a pure function of the absolute past length
T, so the op is stateless. A wholegap-sized block is reclaimed at once, so compaction runs once everygapdecode steps instead of every step. Safety: entries in[0, end − W)are older than the window and are already zeroed by the local-window mask — the same property that makes anyC > Wcache correct.Skip compaction on drifting steps. When
seed_offset == 0(the common decode step) theMoveKvCacheRowscalls are bypassed entirely; a no-op thread-pool dispatch is not free (~65 µs/token).Staging for hazardous steps. A multi-token step that would compact below the history its own first query reads runs against a temporary staging cache of
max_b(end_before) + Srows; the surviving tail is written back afterwards.Thread-pool parallel
MoveKvCacheRows. Compaction is parallelised over(batch, kv_head)viaThreadPool::TryParallelForwith per-slicememmove. AtC = W = 128(0.50 MiB moved per token) the serial version cost ~0.32 ms/token, making windowed decode 3× slower than the full cache; parallelised over 8 threads it drops to noise.Tests (
test_gqa.py)TestGQAWindowedKvCacheCpuCPUExecutionProviderwithfloat32cache.test_small_slack_many_compactionsC = W + 8, 400 decode steps → ~44 compaction events; exercises the drifting layout exhaustively.test_chunked_prefill_small_slackC = W + 8; staging from a drifted append point (the gap is < chunk size).Documentation (
docs/contrib_ops/cuda/gqa.md)C ≥ Wis the minimum; modest surplus is recommended for CPU performance.Performance Results
All measurements on AMD EPYC 7V12, 96 cores, 8 intra-op threads, fp32, 64 Q-heads / 8 KV-heads × 64 (
head_size),local_window_size = 128.Before this PR — full-cache is unchanged
Windowed decode latency (decode p10 ms) before the drifting layout was always-compact-on-every-step. The baseline (no sliding window) did not regress:
Windowed vs full cache — decode throughput (decode p10 ms)
C = W + 256(slack 256, the old default):Windowed decode is flat in context length (attention scans only
Weffective keys). The CPU full-cache path does not exploitlocal_window_sizeand scans the entire cache.KV memory: full cache at 8192 tokens = 33.2 MiB; windowed (
C = 384) = 1.50 MiB (fixed).Drifting layout — capacity sweep (prefill 2048 + 800 decode steps, decode p50 ms, 3 runs)
A/B baseline pins
gap = 1in the planner, which is equivalent to always-compact-on-every-step.The new decode-latency optimum is
C = W + 16(decode p50 0.065 ms, 1.35× better thanC = Wbefore this change). The curve is now a U-shape with a flat floor over slack 4–128; without drift it was monotonically worse with capacity because every decode step shiftedC − 1rows.The analytic optimum matches: minimising
attention_slope × slack/2 + shift_cost/gapovergapgivesslack* = sqrt(2 × shift_cost / attention_slope) ≈ 17.Testing
Expected: 26 windowed tests pass, 4 CPU regression tests pass.
Limitations (CPU only)
The following are rejected with a clear error at session creation or compute time when combined with
sliding_window_cache=1on CPU:attention_biasinputqk_outputattributekv_sequence_length == 0)These limitations do not apply to the CUDA path.
Checklist
docs/contrib_ops/cuda/gqa.md)