Repository navigation
Fix CPU Attention causal mask for q_len=1 with past_key and kv_len > 1 - #34226
Manoj Kumar Thapa (iammanoj807) wants to merge 1 commit into
Conversation
With is_causal=1 and a past_key cache, the CPU kernel skipped the causal mask whenever q_sequence_length == 1. That is only a no-op when kv_sequence_length == 1. With two or more new KV tokens the single query also attended the keys after position past_sequence_length, while the ONNX spec (and this kernel for q_len > 1) only allows keys t <= past_sequence_length. Keep the shortcut for the single-token decode step and apply the causal mask otherwise. Add a CPU test for opsets 23 and 24, with and without a boolean attn_mask.
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues require changes to the CPU Attention causal-masking fix.
No open issues 🎉
What changed in this PR
Fixes CPU ONNX Attention causal masking when a single query uses a past cache and receives multiple new key/value tokens. This completes the past-cache case left unchanged by #29050.
Changes:
- Restricts the causal-mask shortcut to single-token query and key/value decode steps.
- Adds CPU regression coverage for opsets 23 and 24, with and without a boolean attention mask.
No actionable findings.
| File | Description |
|---|---|
onnxruntime/test/providers/cpu/llm/attention_op_test.cc |
Adds regression checks for causal attention outputs and present-cache contents. |
onnxruntime/core/providers/cpu/llm/attention.cc |
Applies causal masking when a single query appends multiple key/value tokens to a past cache. |
Review effort: Balanced
|
@microsoft-github-policy-service agree |
|
I also checked the CUDA EP, which this PR does not change, on a Tesla T4 (sm75) with onnxruntime-gpu 1.32.0. Inputs were
So the same model gives different results for head_size 4 and head_size 8. This also happens for MEA sets One possible fix is to send |
Description
With
is_causal=1and apast_keycache, the CPUAttentionkernel (opsets 23 and 24) skips the causal mask wheneverq_sequence_length == 1:That is a no-op only for a one-token decode step (
kv_sequence_length == 1), where the query may see every key. When the step appends two or more new KV tokens, the query should see only keyst <= past_sequence_length, but it also sees the newer keys after it.The spec defines the causal mask with a past cache as
j <= i + past_sequence_length. Opset 23 says "queryiattends keyjiffj <= i + past_sequence_length". Opset 24 says "offset = past_sequence_lengthwhenpast_keyis provided" (onnx/onnx#8068). Forq_len > 1this kernel already follows that rule: its overlay loop maskst >= past_sequence_length + s + 1. ORT's Python test reference (construct_causal_maskintest_onnx_attention/common.py) uses the same rule.This PR keeps the shortcut only when
kv_sequence_length == 1too, so a normal decode step still skips the mask buffer. Every other case goes through the existing overlay. Thenonpad_kv_seqlenbranch is unchanged, becausenonpad_kv_seqlencannot be combined withpast_key.Repro (opset 23; opset 24 gives the same):
The query is at position 2, so it should attend keys 0, 1 and 2 equally. ORT 1.32.0 also attends key 3. With this change ORT returns
[1/3, 1/3, 1/3, 0].Adds
AttentionTest.Attention4DCausalSingleQueryWithPastMultiTokenKV_CPU, which covers opsets 23 and 24, with and without a booleanattn_mask. It fails on main (0.25 instead of 1/3, and 1/3 instead of 0.5 with the mask) and passes with this change.Motivation and Context
This is the past-cache half of #29020. #29050 fixed
q_len=1, kv_len>1without a past cache and left this case as it was. The Copilot review on #29050 pointed at this line: "skipping causal here would incorrectly allow the single query to attend to keys beyond its logical position".The trigger is narrow. A normal decode step (
q_len == kv_len == 1) is not affected. It needs a step that appends more KV tokens than it has queries.Testing (macOS arm64, Release build, CPU EP):
AttentionTest.*: 78 passed and 12 skipped (CUDA only). On main, 77 pass and the new test fails.*Attention*gtests: the same pass/fail set as main, plus the new test. Some contrib-op tests fail on both because my partial build did not copy their testdata models.ORT_ENABLE_ALL. They cover:q_len1–3,kv_len1–4, past 0/1/3,is_causal0/1;qk_matmul_output_mode, and an explicitscale.is_causal=1, past > 0, q_len == 1, kv_len >= 2, every output (Y,present_key,present_value,qk_matmul_output) is bit-identical to main.onnx/backend/test/case/node/attention.py(from the onnx submodule; 87 of the 93 run on the CPU EP) give identical results on main and this branch.lintrunner: no issues.CUDA EP: not changed by this PR. On a Tesla T4 with onnxruntime-gpu 1.32.0, the unfused CUDA path follows the spec. The memory-efficient (MEA) path uses the offset
total_sequence_length - q_sequence_lengthwhen a past cache is present, so it is wrong wheneverkv_len != q_len(see the comment below). Flash needs sm80 and was not tested. The new test runs on CPU only.