diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index e39641d06dc1b..3c1afcc34ed24 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -4991,7 +4991,7 @@ This version of the operator has been available since version 1 of the 'com.micr
bias (optional) : T
Bias tensor with shape (hidden_size + hidden_size + v_hidden_size) from input projection
token_offset : M
-
Offset of each token before packing, with shape (batch_size, sequence_length). Values must be in [0, batch_size * sequence_length).
+
Offset of each token before packing, with shape (batch_size, sequence_length).
cumulative_sequence_length : M
A tensor with shape (batch_size + 1). It specifies the cumulative sequence length.
attention_bias (optional) : T
@@ -5046,8 +5046,8 @@ This version of the operator has been available since version 1 of the 'com.micr sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. QSA positions are always the request-local logical cache positions derived - from past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. + incomplete block. QSA positions are the request-local logical cache positions derived from + past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -5104,17 +5104,19 @@ This version of the operator has been available since version 1 of the 'com.micr
Scale applied to the per-head ReLU scores. Default is 1/sqrt(head_size).
state_capacity : int (required)
Fixed capacity (number of entries) of past_key_state / present_key_state. Must be > 0.
+
state_update_capacity : int
+
Only for policy_mode 'qsa': maximum number of leading token transitions captured per request. Must be in [0, 8]. Default is 0.
token_budget : int
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
-#### Inputs +#### Inputs (16 - 18)
query : T
-
Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, logical reshape, and rotary embedding.
-
key : T
-
Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first head_size channels are the Ca series and the last head_size channels the Cb series.
+
Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', key may be omitted and query then contains row-wise concatenated query and key projections with shape (total_tokens, (num_heads + 1) * head_size).
+
key (optional) : T
+
Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first head_size channels are the Ca series and the last head_size channels the Cb series. May be omitted for policy_mode 'qsa' when query contains the packed query/key projection.
query_norm_weight : T
Effective RMSNorm multiplier of the queries, with shape (head_size).
key_norm_weight : T
@@ -5143,9 +5145,13 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.
past_state_lengths : M
Generic per-request state length with shape (batch_size, 2). Column 0 is the key_state entry count (policy_mode 'qsa': complete-block count; 'csa': compressed-entry count); column 1 is the pending-buffer length (policy_mode 'qsa': incomplete-block length in [0, compress_ratio); 'csa': buffer length in [0, 2 * compress_ratio)).
+
state_update_capture_count (optional) : M
+
Only for policy_mode 'qsa': number of leading token transitions to capture for each request, with shape (batch_size). Values are clamped to the request length and state_update_capacity. Required when state_update_capacity is positive.
+
state_update_active (optional) : M
+
Only for policy_mode 'qsa': optional capture gate with shape (1). A zero value disables capture.
-#### Outputs +#### Outputs (6 - 7)
selected_indices : M
@@ -5160,6 +5166,8 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'csa': updated gate buffer with the same fixed shape as past_gate_buffer.
present_state_lengths : M
Updated generic per-request state length, with the same fixed shape as past_state_lengths.
+
state_update (optional) : T
+
Only for policy_mode 'qsa': compact transition payloads with shape (batch_size, state_update_capacity, head_size). A token that completes a compression block stores the prepared block representative; any other captured token stores its raw key. Inactive and unused slots are zero.
#### Type Constraints @@ -6943,7 +6951,7 @@ This version of the operator has been available since version 1 of the 'com.micr
input : T
Input tensor with shape (total_tokens, hidden_size)
token_offset : M
-
Offset of non-padding tokens and paddings. Its shape is (batch_size, sequence_length), and values must be in [0, batch_size * sequence_length).
+
Offset of non-padding tokens and paddings. Its shape is (batch_size, sequence_length)
#### Outputs @@ -7616,11 +7624,11 @@ This version of the operator has been available since version 1 of the 'com.micr
key_norm_weight : T
Effective RMSNorm multiplier of the compressed keys, with shape (head_size).
cos_cache : T
-
Cosine rotary table indexed by absolute key position, with shape (batch_size, max_rotary_sequence_length, rotary_width).
+
Cosine rotary table indexed by absolute key position, shared across the batch with shape (max_rotary_sequence_length, rotary_width) or request-specific with shape (batch_size, max_rotary_sequence_length, rotary_width).
sin_cache : T
Sine rotary table with the same shape as cos_cache.
mask (optional) : TB
-
Only for policy_mode 'qsa': INT64 padding mask with shape (batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. total_sequence_length is past_sequence_length + sequence_length.
+
Only for policy_mode 'qsa': INT64 padding mask with shape (batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. total_sequence_length is past_sequence_length + sequence_length. When omitted, every position through past_sequence_length plus the current query index is visible.
past_key : T
Cached indexer keys. For policy_mode 'qsa', these are raw keys; for 'csa', they are compressed keys. Shape is (batch_size, past_sequence_length, head_size), or (batch_size, max_cache_length, head_size) when a valid past_sequence_length is provided.
gate (optional) : T
diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index 10a1b5b96072b..96ac677d0966d 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1131,7 +1131,7 @@ The **OpSet Version** column uses the following notation: |NhwcConv|*in* X:**T**
*in* W:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedAttention|*in* input:**T**
*in* weights:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedMultiHeadAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| -|PackedSparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* query_norm_weight:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* cumulative_sequence_lengths:**M**
*in* past_sequence_lengths:**M**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_key_state:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*in* past_state_lengths:**M**
*out* selected_indices:**M**
*out* selected_counts:**M**
*out* present_key_state:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**
*out* present_state_lengths:**M**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| +|PackedSparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* query_norm_weight:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* cumulative_sequence_lengths:**M**
*in* past_sequence_lengths:**M**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_key_state:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*in* past_state_lengths:**M**
*in* state_update_capture_count:**M**
*in* state_update_active:**M**
*out* selected_indices:**M**
*out* selected_counts:**M**
*out* present_key_state:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**
*out* present_state_lengths:**M**
*out* state_update:**T**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| |PagedAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* key_cache:**T_CACHE**
*in* value_cache:**T_CACHE**
*in* cumulative_sequence_length:**S**
*in* past_seqlens:**S**
*in* block_table:**S**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* slot_mapping:**S**
*in* head_sink:**T**
*in* q_norm_weight:**T**
*in* k_norm_weight:**T**
*in* k_scale:**T_KV_SCALE**
*in* v_scale:**T_KV_SCALE**
*in* attention_metadata:**S**
*out* output:**T**
*out* key_cache_out:**T_CACHE**
*out* value_cache_out:**T_CACHE**|1+|**S** = tensor(int32)
**T** = tensor(bfloat16), tensor(float16)
**T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8), tensor(uint8)
**T_KV_SCALE** = tensor(float)| |QAttention|*in* input:**T1**
*in* weight:**T2**
*in* bias:**T3**
*in* input_scale:**T3**
*in* weight_scale:**T3**
*in* mask_index:**T4**
*in* input_zero_point:**T1**
*in* weight_zero_point:**T2**
*in* past:**T3**
*out* output:**T3**
*out* present:**T3**|1+|**T1** = tensor(int8)
**T2** = tensor(int8)
**T3** = tensor(float), tensor(float16)
**T4** = tensor(int32)| |QMoE|*in* input:**T**
*in* router_probs:**T**
*in* fc1_experts_weights:**T1**
*in* fc1_scales:**T2**
*in* fc1_experts_bias:**T**
*in* fc2_experts_weights:**T1**
*in* fc2_scales:**T2**
*in* fc2_experts_bias:**T**
*in* fc3_experts_weights:**T1**
*in* fc3_scales:**T2**
*in* fc3_experts_bias:**T**
*in* fc1_zero_points:**T1**
*in* fc2_zero_points:**T1**
*in* fc3_zero_points:**T1**
*in* router_weights:**T**
*in* fc1_global_scale:**T4**
*in* fc2_global_scale:**T4**
*in* fc1_act_scale:**T4**
*in* fc2_act_scale:**T4**
*in* fc1_act_block_scale:**T2**
*in* fc2_act_block_scale:**T2**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(float16)
**T1** = tensor(float8e4m3fn), tensor(uint8)
**T2** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(float8e8m0)
**T4** = tensor(float)| diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index acc9bd448a143..a1bfe8634d170 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -84,7 +84,7 @@ for a runtime: | 1 | `key` | both | `T` | `(B, S, D)` for `qsa`, `(B, S, 2D)` for `csa` | | 2 | `query_norm_weight` | both | `T` | `(D)` | | 3 | `key_norm_weight` | both | `T` | `(D)` | -| 4 | `cos_cache` | both | `T` | `(B, max_rotary_sequence_length, R)` | +| 4 | `cos_cache` | both | `T` | `(max_rotary_sequence_length, R)` when shared across the batch, or `(B, max_rotary_sequence_length, R)` | | 5 | `sin_cache` | both | `T` | same as `cos_cache` | | 6 | `mask` | `qsa` | `TB` | INT64 padding mask `(B, T)` | | 7 | `past_key` | both | `T` | `(B, P, D)`; raw keys for `qsa`, compressed keys for `csa` | @@ -261,11 +261,11 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l | Stage | Kernel | Parallelism | |---|---|---| -| 1 | `CopyQsaPastKeyKernel` / `AppendQsaKeyKernel` | element | +| 1 | `CopyQsaPastKeyKernel` / `AppendQsaKeyKernel` | element; the copy is skipped for aliased fixed-capacity caches | | 2 | `RotateQueryKernel` | one block per `(b, s, h)` | -| 3 | `CompactVisibleKernel` | one block per `(b, s)`; Hillis–Steele scan in shared memory | -| 4 | `QsaBlockScoreKernel` | one block per `(b, s, block)`; mean-pool → RMSNorm → rotary → score | -| 5 | `QsaSelectKernel` | one block per `(b, s)`; iterated block arg-max | +| 3 | `InitializePrefixVisibleCountKernel` or mask analysis/compaction | omitted masks initialize prefix counts directly; irregular masks use one block per `(b, s)` | +| 4 | `QsaBlockScoreQwenKernel` / `QsaBlockScoreKernel` | one block per `(b, s, block)`; the Qwen `D=128, r=4, N=4` path assigns one warp per query head | +| 5 | `QsaPartialTopKKernel` / `QsaSelectKernel` | one block per `(b, s)`; score-threshold radix selection and bounded winner sorting | ### `csa` @@ -283,18 +283,19 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l | Policy | Buffer | Elements | |---|---|---| | `qsa` | float | `B*S*N*D` (rotated query) + `B*S*max_block_count` (block scores) | -| `qsa` | int32 | `B*S*T` (visible indices) + `B*S` (visible counts) | +| `qsa` | int32 | optional `B*S*T` (arbitrary-mask visible indices) + `B*S` (visible counts) + up to `B*S*block_topk` (TopK indices) | | `csa` | float | `B*S*N*D` (rotated query) + `B*S*present_compressed_length` (scores) + `B*W*D` (new compressed keys before persistent-cache rounding) | Every size is derived from shapes and attributes only. -### Selection without a visited bitmap +### Selection -`QsaSelectKernel` and `CsaSelectKernel` repeatedly scan for the next element in the total order -"score descending, index ascending", using the previously emitted `(score, index)` pair as the -cursor. That avoids an `O(entries)` bitmap in shared memory, keeps the selection deterministic and -makes ties resolve to the smaller index. The cost is `O(topk * entries)` per query row, which is -the main performance follow-up below. +For QSA `block_topk` values from 33 through 512, `QsaPartialTopKKernel` finds the score threshold +with four byte-wise radix-histogram passes, gathers exactly the winning candidates, resolves +threshold ties by ascending index, and sorts only that bounded set. Selection storage and output +traffic are therefore `O(block_topk)`, rather than `O(entries)`, while preserving deterministic +"score descending, index ascending" ordering. Smaller QSA rows use the in-kernel block TopK; +larger configured TopK values retain the repeated-scan fallback. ## 8. Numerics and Determinism @@ -322,7 +323,8 @@ The CUDA kernel additionally validates concrete runtime dimensions and cross-inp - `index_topk` absent or `<= 0` for `csa`; - an output count other than 2 (`qsa`) or 3 (`csa`); - a `past_proj_buffer` length outside `[0, 2 * compress_ratio)`; -- rank mismatches and incompatible concrete dimensions between `query`, `key`, the caches and the buffers. +- rank or dimension mismatches between `query`, `key`, the caches and the buffers. Rotary caches + may be shared rank-2 tables or request-specific rank-3 tables. Schema and statically knowable errors fail at `Graph::Resolve()`; checks that require concrete runtime dimensions fail before any CUDA kernel is launched. @@ -341,7 +343,12 @@ contains: - **Numeric tests** (`float`, `float16`, `bfloat16`) that run the CUDA kernel against a float reference implementation of both policies written directly from the reference semantics. Inputs are round-tripped through the tested element type before the reference runs, so the reference - sees exactly the values the kernel reads. These tests skip when no CUDA EP is available. + sees exactly the values the kernel reads. Shared rank-2 rotary caches are covered for both CUDA + and WebGPU. These tests skip when the requested EP is unavailable. +- **Long-context parity tests** at 8K, 32K, 64K, 128K and 256K for + `compress_ratio=4, token_budget=2048`. +- **Fixed-address CUDA graph capture and replay** in + `onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc`. Run them with: @@ -349,21 +356,28 @@ Run them with: ./build/Linux/Release/onnxruntime_provider_test --gtest_filter='SparseAttentionIndexer*' ``` +Run the CUDA decode sweep against a built Python package with: + +```bash +python onnxruntime/python/tools/microbench/sparse_attention_indexer.py +``` + ## 11. Known Limitations and Performance Follow-ups The implementation is correctness-first. The following are known and deliberate: -1. **Selection is `O(topk * entries)` per query row.** Each emitted index costs a full block-wide - scan. A radix-select or a per-row bitonic top-k would reduce this to roughly one pass, and is the - single biggest win for large `index_topk` / `token_budget`. -2. **Scoring is not tensor-core accelerated.** `QsaBlockScoreKernel` and `CsaScoreKernel` compute - `q · k` with a shared-memory block reduction, one dot product per block. A tiled GEMM (or a - fused `ReLU`+reduce epilogue) would be far better once shapes grow. -3. **The rotated query is materialized in `float32`.** That costs `B*S*N*D` floats of workspace. - Fusing the rotation into the scoring kernels removes the traffic at the cost of recomputing the - rotation per block. -4. **`CompactVisibleKernel` is `O(T)` per query row** and re-reads each batch's `(B, T)` padding - mask for every `s`. Sharing work across query rows would reduce that traffic for long contexts. -5. **`compress_ratio` and `head_size` are not specialized.** Templating the hot kernels on a small - set of common values would remove the dynamic loop bounds. +1. **Exact query-specific scoring remains `O(visible_tokens / compress_ratio)`.** Every decode query + can change, so an exact implementation must inspect every eligible block; persistent candidates + from an earlier query cannot preserve QSA semantics. The bounded radix path removes full-context + sorting and candidate buffers, but not this scoring lower bound. +2. **Generic scoring is not tensor-core accelerated.** The common Qwen `D=128, r=4, N=4` path uses + vectorized loads and one warp per query head. Other QSA shapes and `CsaScoreKernel` retain the + generic shared-memory reductions. +3. **Rotated queries use an FP32 workspace.** Query normalization and rotation are computed once + per `(b, s, h)` before block scoring instead of being recomputed for every candidate block. +4. **Irregular-mask analysis remains `O(T)` per query row.** Omitting the mask declares + prefix-causal visibility and skips analysis and compaction. Padding masks with holes still + require exact compaction because block membership is defined by visible rank, not position. +5. **Only the common Qwen dimensions are specialized.** Other `compress_ratio`, `head_size`, and + head-count combinations retain dynamic loop bounds in the generic scoring kernel. 6. **No CPU kernel.** The operator is CUDA-only today. diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md index b6d4c0b3fefed..bd6877cf32189 100644 --- a/docs/contrib_ops/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -10,7 +10,7 @@ Source: (policy enum, selected-capacity formula and CSA window-plan arithmetic, shared unmodified with `SparseAttentionIndexer`), [packed_sparse_attention_indexer_common.h](../../onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h) -(fixed 16-input / 6-output slot map), +(fixed 18-input / 7-output slot map), [packed_sparse_attention_indexer.cc](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc) / [packed_sparse_attention_indexer_impl.cu](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu) (CUDA), @@ -51,16 +51,17 @@ Attributes: | `epsilon` | default `1e-6` | RMSNorm epsilon | | `scale` | default `1/sqrt(head_size)` | per-head score scale | | `head_weight_scale` | `csa` only, default `1/sqrt(num_heads)` | head-weight score scale | +| `state_update_capacity` | `qsa` only, default `0`, range `[0, 8]` | maximum captured transitions per request | -Inputs are **fixed at 16 indices** for both policies (unlike the dense op, which uses a different +Inputs are **fixed at 18 indices** for both policies (unlike the dense op, which uses a different input/output count per policy). A slot not owned by the active policy is a *positional* optional: its `NodeProto` input name is empty rather than the slot being removed from the list, so every later slot keeps its fixed index. | # | Name | Shape | Type | Policy | |---|---|---|---|---| -| 0 | `query` | `(total_tokens, num_heads*head_size)` | T | both | -| 1 | `key` | `(total_tokens, head_size)` qsa / `(total_tokens, 2*head_size)` csa | T | both | +| 0 | `query` | `(total_tokens, num_heads*head_size)`; when qsa omits `key`, `(total_tokens, (num_heads+1)*head_size)` containing row-wise `[query \| key]` | T | both | +| 1 | `key` | `(total_tokens, head_size)` qsa / `(total_tokens, 2*head_size)` csa; optional for qsa when packed into `query` | T | both | | 2 | `query_norm_weight` | `(head_size)` | T | both | | 3 | `key_norm_weight` | `(head_size)` | T | both | | 4 | `cos_cache` | `(max_position, rotary_width)` or `(batch_size, max_position, rotary_width)` | T | both | @@ -75,9 +76,11 @@ later slot keeps its fixed index. | 13 | `past_kv_buffer` | `(batch_size, 2*compress_ratio-1, width)` | T | both (generic) | | 14 | `past_gate_buffer` | same shape as `past_kv_buffer` | T | csa only | | 15 | `past_state_lengths` | `(batch_size, 2)` | int32, device-resident | both (generic) | +| 16 | `state_update_capture_count` | `(batch_size)` | int32, device-resident | qsa capture only | +| 17 | `state_update_active` | `(1)` | int32, device-resident | optional qsa capture gate | -Outputs are **fixed at 6 indices** for both policies (`present_gate_buffer` is declared with an -empty output name for `qsa`, the same positional-optional convention as above): +Outputs have six fixed indices plus optional slot 6. `present_gate_buffer` is declared with an +empty output name for `qsa`; `state_update` is declared only when QSA capture is enabled: | # | Name | Shape | Type | Policy | |---|---|---|---|---| @@ -87,6 +90,7 @@ empty output name for `qsa`, the same positional-optional convention as above): | 3 | `present_kv_buffer` | same shape as `past_kv_buffer` | T | both | | 4 | `present_gate_buffer` | same shape as `past_gate_buffer` | T | csa only | | 5 | `present_state_lengths` | same shape as `past_state_lengths` | int32 | both | +| 6 | `state_update` | `(batch_size, state_update_capacity, head_size)` | T | qsa capture only | `capacity` is `token_budget + compress_ratio - 1` for `qsa` and `index_topk` for `csa`, exactly the same formula (`SelectedCapacity`) used by `SparseAttentionIndexer`. @@ -118,16 +122,26 @@ Both policies read and write the *same four* state slots — there is no separat `CsaWindowPlan`/`TryComputeCsaWindowPlan`). State never grows. `present_*` always has exactly the same shape as `past_*`; only the *contents* -change. Input/output aliasing is supported. CUDA avoids unsafe buffer aliases; WebGPU omits an -aliased `past_*` read-only binding and reads the prior contents through the matching read-write -`present_*` binding. Each request is handled by one invocation, and buffer compaction reads entries -at or above the destination index before overwriting them. +change. CUDA supports aliasing `past_key_state` with `present_key_state` and `past_state_lengths` +with `present_state_lengths`; it skips the baseline key-state copy when those buffers alias. +WebGPU supports aliasing every corresponding `past_*` / `present_*` pair and reads prior contents +through the matching read-write `present_*` binding. Each request is handled by one invocation, +and buffer compaction reads entries at or above the destination index before overwriting them. **State overflow.** If a call would close more blocks/windows than `state_capacity - old_entry_count` allows, that request's step is rejected as a deterministic no-op: its state and state lengths remain unchanged, and its selection outputs stay empty. This never reads or writes outside a tensor's fixed extent and never silently truncates state. +**Transactional QSA capture.** When `state_update_capacity > 0`, +`state_update_capture_count[b]` requests capture of that request's leading transitions, clamped to +the request length and the configured capacity. `state_update_active == 0` disables capture for the +call. Disabled, unused, and out-of-range slots are zero. A captured token that completes a +compression block stores the prepared block representative; other tokens store their raw key. +After restoring a pre-call state snapshot, a runtime can replay the accepted prefix in order: +append raw entries to the pending buffer, and replace each block-completion transition with its +prepared representative in `key_state`. + ## 4. Packed metadata and device-side safety `cumulative_sequence_lengths` and `past_sequence_lengths` are **device-resident** tensors, read @@ -251,19 +265,22 @@ mechanics with no packed equivalent): `onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc` covers: - shape inference for `qsa` and `csa` (fixed `selected_indices`/`selected_counts` shapes, fixed - state output shapes, the strict per-slot policy validation, and the always-6-outputs contract); + state output shapes, strict per-slot policy validation, and optional capture output); - multi-request packed batches with unequal token counts, a zero-token request row, and prefill followed by decode with independent per-request state (CUDA/WebGPU, skipped without the respective execution provider); - `qsa` state-capacity overflow safety; +- active and disabled transactional QSA capture on CUDA and WebGPU; - FP32/FP16 (and CUDA-only BF16) numeric coverage against an in-file reference that mirrors this document's contract. ## 10. Known limitations and follow-ups - The reference CUDA/WebGPU kernels prioritize correctness over throughput (see the top-of-file - comments in the `.cu`/`.cc` implementations); they are not yet tuned for large `state_capacity` - or long packed batches. + comments in the `.cu`/`.cc` implementations). CUDA QSA fuses query rotation into scoring, uses + the shared-memory TopK path for up to 32 selected blocks, bounded partial TopK for 33–512 blocks, + and a hierarchical TopK path for common long-context Qwen shapes. Values above 512 that do not + match the hierarchical specialization retain the correctness-first repeated-scan fallback. - OgaEngine / Model Builder integration (declaring `past_key_state` etc. as Engine-managed, per-request fixed-size state, analogous to a paged auxiliary cache) is out of scope for this operator definition and is expected in a follow-up to `microsoft/onnxruntime-genai`. diff --git a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h index c457179e40a8c..a5b2be971c8aa 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h @@ -51,7 +51,9 @@ enum InputIndex : int { kPastKvBuffer = 13, // generic: [batch_size, 2 * compress_ratio - 1, width] kPastGateBuffer = 14, // csa only: same shape as past_kv_buffer kPastStateLengths = 15, // generic: [batch_size, 2], int32 - kInputCount = 16, + kStateUpdateCaptureCount = 16, // qsa only, optional: [batch_size], int32 + kStateUpdateActive = 17, // qsa only, optional: [1], int32 + kInputCount = 18, }; // Fixed output slots. present_gate_buffer is declared (with an empty name) but not produced for @@ -63,12 +65,14 @@ enum OutputIndex : int { kPresentKvBuffer = 3, // same shape as past_kv_buffer kPresentGateBuffer = 4, // csa only: same shape as past_gate_buffer kPresentStateLengths = 5, // [batch_size, 2], int32 - kOutputCount = 6, + kStateUpdate = 6, // qsa only, optional: [batch_size, state_update_capacity, head_size] + kOutputCount = 7, }; // Every PackedSparseAttentionIndexer node declares all 6 fixed outputs; present_gate_buffer is an -// empty-name optional output for policy_mode="qsa". -constexpr int kFixedOutputCount = kOutputCount; +// empty-name optional output for policy_mode="qsa". State update capture is an optional appended +// seventh output so existing nodes retain their output layout. +constexpr int kFixedOutputCount = kStateUpdate; // Column layout of past_state_lengths / present_state_lengths. enum StateLengthColumn : int { diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 0e9e3e2d8325c..4e2bed76767d6 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -19,22 +19,22 @@ namespace cuda { using namespace onnxruntime::cuda; namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; -#define REGISTER_KERNEL_TYPED(T) \ - ONNX_OPERATOR_TYPED_KERNEL_EX( \ - PackedSparseAttentionIndexer, \ - kMSDomain, \ - 1, \ - T, \ - kCudaExecutionProvider, \ - (*KernelDefBuilder::Create()) \ - .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ - .MayInplace(psai::kPastKeyState, psai::kPresentKeyState) \ - .MayInplace(psai::kPastKvBuffer, psai::kPresentKvBuffer) \ - .MayInplace(psai::kPastGateBuffer, psai::kPresentGateBuffer) \ - .MayInplace(psai::kPastStateLengths, \ - psai::kPresentStateLengths), \ +#define REGISTER_KERNEL_TYPED(T) \ + ONNX_OPERATOR_TYPED_KERNEL_EX( \ + PackedSparseAttentionIndexer, \ + kMSDomain, \ + 1, \ + T, \ + kCudaExecutionProvider, \ + (*KernelDefBuilder::Create()) \ + .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ + .InputMemoryType(OrtMemTypeCPUInput, psai::kStateUpdateActive) \ + .MayInplace(psai::kPastKeyState, psai::kPresentKeyState) \ + .MayInplace(psai::kPastKvBuffer, psai::kPresentKvBuffer) \ + .MayInplace(psai::kPastGateBuffer, psai::kPresentGateBuffer) \ + .MayInplace(psai::kPastStateLengths, psai::kPresentStateLengths), \ PackedSparseAttentionIndexer); REGISTER_KERNEL_TYPED(float) @@ -110,6 +110,10 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo "PackedSparseAttentionIndexer: state_capacity is required"); ORT_ENFORCE(state_capacity_ > 0 && state_capacity_ <= std::numeric_limits::max(), "PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", state_capacity_); + state_update_capacity_ = info.GetAttrOrDefault("state_update_capacity", 0); + ORT_ENFORCE(state_update_capacity_ >= 0 && state_update_capacity_ <= 8, + "PackedSparseAttentionIndexer: state_update_capacity must be in [0, 8], got ", + state_update_capacity_); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); @@ -129,6 +133,8 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo token_budget_, " compress_ratio=", compress_ratio_); index_topk_ = 0; } else { + ORT_ENFORCE(state_update_capacity_ == 0, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); ORT_ENFORCE(has_index_topk, "PackedSparseAttentionIndexer: index_topk is required when policy_mode is 'csa'"); ORT_ENFORCE(!has_token_budget, "PackedSparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); @@ -155,14 +161,22 @@ Status PackedSparseAttentionIndexer::ComputeInternal(OpKernelContext* context const bool position_ids_provided = psai::kPositionIds < context->InputCount() && context->Input(psai::kPositionIds) != nullptr; ORT_RETURN_IF(position_ids_provided == is_qsa, - "PackedSparseAttentionIndexer: input ", psai::kPositionIds, - is_qsa ? " (position_ids) must be omitted for policy_mode 'qsa'" - : " (position_ids) is required for policy_mode 'csa'"); + "PackedSparseAttentionIndexer: position_ids is required for " + "policy_mode 'csa' and must be omitted for policy_mode 'qsa'"); const bool gate_buffer_provided = psai::kPastGateBuffer < context->InputCount() && context->Input(psai::kPastGateBuffer) != nullptr; ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: input ", psai::kPastGateBuffer, gate_buffer_provided ? " must be omitted for policy_mode 'qsa'" : " is required for policy_mode 'csa'"); + if (!is_qsa) { + for (int index : {psai::kStateUpdateCaptureCount, psai::kStateUpdateActive}) { + ORT_RETURN_IF(index < context->InputCount() && context->Input(index) != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } + Tensor* state_update = context->Output(psai::kStateUpdate, TensorShape({0})); + ORT_RETURN_IF(state_update != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } return is_qsa ? ComputeQsa(context) : ComputeCsa(context); } @@ -179,9 +193,20 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const Tensor* sin_cache = context->Input(psai::kSinCache); const Tensor* cumulative_sequence_lengths = context->Input(psai::kCumulativeSequenceLengths); const Tensor* past_sequence_lengths = context->Input(psai::kPastSequenceLengths); + const Tensor* position_ids = context->Input(psai::kPositionIds); const Tensor* past_key_state = context->Input(psai::kPastKeyState); const Tensor* past_kv_buffer = context->Input(psai::kPastKvBuffer); const Tensor* past_state_lengths = context->Input(psai::kPastStateLengths); + const Tensor* state_update_capture_count = + context->InputCount() > psai::kStateUpdateCaptureCount + ? context->Input(psai::kStateUpdateCaptureCount) + : nullptr; + const Tensor* state_update_active = + context->InputCount() > psai::kStateUpdateActive ? context->Input(psai::kStateUpdateActive) : nullptr; + + ORT_RETURN_IF(state_update_capacity_ > 0 && state_update_capture_count == nullptr, + "PackedSparseAttentionIndexer: state_update_capture_count is required when " + "state_update_capacity is positive"); ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); @@ -192,13 +217,16 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const int64_t total_tokens = query_shape[0]; ORT_RETURN_IF(query_norm_weight == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); const auto& query_norm_shape = query_norm_weight->Shape(); - ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1, + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0, "PackedSparseAttentionIndexer: query_norm_weight must have shape (head_size), got ", query_norm_shape.ToString()); const int64_t head_size = query_norm_shape[0]; - ORT_RETURN_IF(head_size <= 0 || query_shape[1] % head_size != 0, - "PackedSparseAttentionIndexer: query width must be divisible by head_size"); - const int64_t num_heads = query_shape[1] / head_size; + const bool has_separate_key = key != nullptr; + const int64_t query_width = query_shape[1] - (has_separate_key ? 0 : head_size); + ORT_RETURN_IF(query_width <= 0 || query_width % head_size != 0, + "PackedSparseAttentionIndexer: query width must contain a positive multiple of head_size", + has_separate_key ? "" : " followed by one packed key head"); + const int64_t num_heads = query_width / head_size; ORT_RETURN_IF_ERROR(CheckIntDimension("total_tokens", total_tokens)); ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); @@ -216,7 +244,12 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); - ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + if (position_ids != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); + } + if (has_separate_key) { + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + } ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); @@ -243,6 +276,12 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", {batch_size, psai::kStateLengthColumns})); + if (state_update_capture_count != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_capture_count, "state_update_capture_count", {batch_size})); + } + if (state_update_active != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_active, "state_update_active", {1})); + } const int64_t capacity = psai::SelectedCapacity(psai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_); @@ -257,19 +296,30 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con context->Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, head_size})); Tensor* present_state_lengths = context->Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + Tensor* state_update = context->OutputCount() > psai::kStateUpdate + ? context->Output( + psai::kStateUpdate, TensorShape({batch_size, state_update_capacity_, head_size})) + : nullptr; ORT_RETURN_IF(selected_indices == nullptr || selected_counts == nullptr || present_key_state == nullptr || present_kv_buffer == nullptr || present_state_lengths == nullptr, "PackedSparseAttentionIndexer: policy_mode 'qsa' requires selected_indices, selected_counts, " "present_key_state, present_kv_buffer and present_state_lengths outputs"); + if (state_update != nullptr && state_update->SizeInBytes() > 0) { + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(state_update->MutableDataRaw(), 0, state_update->SizeInBytes(), + Stream(context))); + } PackedSparseAttentionIndexerParams params; params.batch_size = static_cast(batch_size); params.total_tokens = static_cast(total_tokens); params.num_heads = static_cast(num_heads); params.head_size = static_cast(head_size); + params.query_row_stride = static_cast(query_shape[1]); + params.key_row_stride = static_cast(has_separate_key ? head_size : query_shape[1]); params.rotary_width = static_cast(rotary.rotary_width); params.max_rotary_length = static_cast(rotary.max_rotary_length); params.cos_cache_batched = rotary.batched; + params.has_position_ids = position_ids != nullptr; params.compress_ratio = static_cast(compress_ratio_); params.state_capacity = static_cast(state_capacity); params.buffer_capacity = static_cast(buffer_capacity); @@ -277,29 +327,38 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con params.epsilon = epsilon_; params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.block_topk = static_cast(token_budget_ / compress_ratio_); + params.state_update_capacity = static_cast(state_update_capacity_); auto float_workspace = GetScratchBuffer(GetQsaPackedWorkspaceFloatCount(params), GetComputeStream(context)); auto overflow_flags = GetScratchBuffer(static_cast(std::max(batch_size, 1)), GetComputeStream(context)); + const CudaT* query_data = reinterpret_cast(query->Data()); + const CudaT* key_data = has_separate_key + ? reinterpret_cast(key->Data()) + : query_data + query_width; return LaunchQsaPackedSparseAttentionIndexer( Stream(context), params, - reinterpret_cast(query->Data()), - reinterpret_cast(key->Data()), + query_data, + key_data, reinterpret_cast(query_norm_weight->Data()), reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), cumulative_sequence_lengths->Data(), past_sequence_lengths->Data(), + position_ids != nullptr ? position_ids->Data() : nullptr, reinterpret_cast(past_key_state->Data()), reinterpret_cast(past_kv_buffer->Data()), past_state_lengths->Data(), + state_update_capture_count != nullptr ? state_update_capture_count->Data() : nullptr, + state_update_active == nullptr || state_update_active->Data()[0] != 0, selected_indices->MutableData(), selected_counts->MutableData(), reinterpret_cast(present_key_state->MutableData()), reinterpret_cast(present_kv_buffer->MutableData()), present_state_lengths->MutableData(), + state_update != nullptr ? reinterpret_cast(state_update->MutableData()) : nullptr, float_workspace.get(), overflow_flags.get()); } @@ -416,9 +475,11 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con params.total_tokens = static_cast(total_tokens); params.num_heads = static_cast(num_heads); params.head_size = static_cast(head_size); + params.query_row_stride = static_cast(query_shape[1]); params.rotary_width = static_cast(rotary.rotary_width); params.max_rotary_length = static_cast(rotary.max_rotary_length); params.cos_cache_batched = rotary.batched; + params.has_position_ids = true; params.compress_ratio = static_cast(compress_ratio_); params.state_capacity = static_cast(state_capacity); params.buffer_capacity = static_cast(buffer_capacity); diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h index c8f3ec19f2082..112be6e3f46af 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h @@ -24,6 +24,7 @@ class PackedSparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel packed_sparse_attention_indexer::Policy policy_; int64_t compress_ratio_; int64_t state_capacity_; + int64_t state_update_capacity_; int64_t token_budget_; int64_t index_topk_; float epsilon_; diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 9f882bf732e04..a5ed4610319e9 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -30,23 +30,37 @@ #include #include #include +#include #include #include "contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h" #include "contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh" #include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" +#include "core/providers/cuda/cu_inc/topk_warp_sort.cuh" namespace onnxruntime { namespace contrib { namespace cuda { namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; +namespace topk = onnxruntime::cuda::topk; namespace { // The block reductions below halve the active thread count, so this must stay a power of two. constexpr int kThreads = 128; +constexpr int kWarpSize = 32; +constexpr int kBoundedTopKMax = 512; +constexpr int kBoundedTopKItemsPerThread = kBoundedTopKMax / kThreads; +constexpr int kDistributedTopKMaxRows = 64; +constexpr int kDistributedTopKMinBlocks = 2048; +constexpr int kHierarchicalTileBlocks = 8; +constexpr int kHierarchicalScoreThreads = 1024; +constexpr int kHierarchicalMergeThreads = 256; +constexpr int kQwenHeadSize = 128; +constexpr int kQwenCompressRatio = 4; +constexpr int kQwenNumHeads = 4; template Status ConfigureDynamicSharedMemory(Kernel kernel, size_t bytes, const char* kernel_name) { @@ -60,6 +74,36 @@ Status ConfigureDynamicSharedMemory(Kernel kernel, size_t bytes, const char* ker return CUDA_CALL(cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, max_bytes)); } +bool UseHierarchicalQsaTopK(const PackedSparseAttentionIndexerParams& params) { + return params.total_tokens > 0 && params.total_tokens <= kDistributedTopKMaxRows && + params.state_capacity >= kDistributedTopKMinBlocks && + params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads && params.block_topk > kSaiFastTopKMax && + params.block_topk <= kBoundedTopKMax; +} + +bool UseRaggedQsaPrefill(const PackedSparseAttentionIndexerParams& params) { + return params.total_tokens > kDistributedTopKMaxRows && + params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads; +} + +int GetHierarchicalTileCount(const PackedSparseAttentionIndexerParams& params) { + return (params.state_capacity + kHierarchicalTileBlocks - 1) / kHierarchicalTileBlocks; +} + +template +struct alignas(sizeof(T) * 4) QsaVector4 { + T values[4]; +}; + +template +__device__ __forceinline__ float4 LoadQsaVector4(const T* source) { + const QsaVector4 packed = *reinterpret_cast*>(source); + return make_float4(to_float(packed.values[0]), to_float(packed.values[1]), + to_float(packed.values[2]), to_float(packed.values[3])); +} + // Largest b such that cumulative_sequence_lengths[b] <= token, assuming the array is nondecreasing. // If the data itself is malformed this may attribute a token to the wrong request, but the result // is always an index in [0, batch_size), so it can never cause an out-of-bounds access. @@ -98,9 +142,10 @@ template __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const T* past_kv_buffer, - const int32_t* past_state_lengths, + const int32_t* past_state_lengths, const int32_t* state_update_capture_count, + bool state_update_active, T* present_key_state, T* present_kv_buffer, - int32_t* present_state_lengths, int32_t* overflow_flags, + int32_t* present_state_lengths, T* state_update, int32_t* overflow_flags, PackedSparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* pooled = shared; @@ -134,6 +179,12 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con const bool rejected = invalid_metadata || full_new_block_count > capacity_left; const int new_block_count = rejected ? 0 : full_new_block_count; const int new_buf_len = rejected ? old_buf_len : (pending % params.compress_ratio); + const bool capture_active = !rejected && state_update != nullptr && state_update_capture_count != nullptr && + state_update_active; + const int capture_count = capture_active + ? min(max(state_update_capture_count[b], 0), + min(req_len, params.state_update_capacity)) + : 0; // Barrier: every thread has now read past_state_lengths (identically) before any thread below // writes present_state_lengths, which keeps this correct even if the two tensors alias. @@ -145,6 +196,18 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con overflow_flags[b] = rejected ? 1 : 0; } + for (int t = static_cast(threadIdx.x); t < capture_count; t += static_cast(blockDim.x)) { + if ((old_buf_len + t + 1) % params.compress_ratio != 0) { + const int64_t capture_base = + (static_cast(b) * params.state_update_capacity + t) * params.head_size; + const int64_t key_base = (static_cast(req_start) + t) * params.key_row_stride; + for (int d = 0; d < params.head_size; ++d) { + state_update[capture_base + d] = key[key_base + d]; + } + } + } + __syncthreads(); + for (int k = 0; k < new_block_count; ++k) { for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { float sum = 0.0f; @@ -155,7 +218,7 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con params.head_size + d]) : to_float(key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * - params.head_size + + params.key_row_stride + d]); } pooled[d] = sum / static_cast(params.compress_ratio); @@ -186,8 +249,14 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con __syncthreads(); const int64_t out_base = (static_cast(b) * params.state_capacity + entry) * params.head_size; + const int completion_token = (k + 1) * params.compress_ratio - old_buf_len - 1; + const int64_t capture_base = + (static_cast(b) * params.state_update_capacity + completion_token) * params.head_size; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { present_key_state[out_base + d] = from_float(rotated[d]); + if (completion_token < capture_count) { + state_update[capture_base + d] = from_float(rotated[d]); + } } __syncthreads(); } @@ -207,7 +276,9 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con params.head_size + d]) : to_float( - key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * params.head_size + d]); + key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * + params.key_row_stride + + d]); present_kv_buffer[out_base + d] = from_float(value); } } @@ -232,10 +303,11 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { const int token = static_cast(row / params.num_heads); const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); - const int64_t base = row * params.head_size; + const int64_t input_base = static_cast(token) * params.query_row_stride + + static_cast(row % params.num_heads) * params.head_size; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - shared[d] = to_float(query[base + d]); + shared[d] = to_float(query[input_base + d]); } __syncthreads(); @@ -251,9 +323,9 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig __syncthreads(); const int64_t abs_position = - kUseLeadingRope - ? static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]) - : position_ids[token]; + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); const int position = SaiClampPosition(abs_position, params.max_rotary_length); const int64_t cache_offset = (static_cast(params.cos_cache_batched ? batch : 0) * params.max_rotary_length + position) * @@ -262,10 +334,9 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig const T* sin_row = sin_cache + cache_offset; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - query_rotated[base + d] = kUseLeadingRope - ? SaiLeadingRope(shared, params.rotary_width, cos_row, sin_row, d) - : SaiTrailingRope(shared, params.head_size, params.rotary_width, cos_row, - sin_row, d); + query_rotated[row * params.head_size + d] = + kUseLeadingRope ? SaiLeadingRope(shared, params.rotary_width, cos_row, sin_row, d) + : SaiTrailingRope(shared, params.head_size, params.rotary_width, cos_row, sin_row, d); } __syncthreads(); } @@ -277,7 +348,7 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig template __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* query_rotated, const int32_t* cumulative_sequence_lengths, - const int32_t* past_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, const int32_t* present_state_lengths, float* block_scores, PackedSparseAttentionIndexerParams params) { extern __shared__ float reduction[]; @@ -296,7 +367,9 @@ __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* que } const int64_t abs_position = - static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); const int64_t visible_block_count = causal_count < key_len_after ? causal_count : key_len_after; if (static_cast(block_index) >= visible_block_count) { @@ -324,18 +397,248 @@ __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* que } } +// One CTA per packed query row. Eight state blocks are scored concurrently, with one warp per +// (state block, query head). Unlike the generic rectangular launch, each CTA stops at its row's +// live causal block count and never launches or writes work for unused fixed-capacity slots. +template +__global__ void QsaRaggedPrefillScoreKernel(const T* present_key_state, const float* query_rotated, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, const int32_t* overflow_flags, + float* block_scores, PackedSparseAttentionIndexerParams params) { + __shared__ float head_scores[kHierarchicalTileBlocks][kQwenNumHeads]; + + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int block_slot = warp / kQwenNumHeads; + const int head = warp % kQwenNumHeads; + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, kQwenCompressRatio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = overflow_flags[batch] == 0 ? static_cast(visible_64 < 0 ? 0 : visible_64) : 0; + + for (int tile_start = 0; tile_start < block_count; tile_start += kHierarchicalTileBlocks) { + const int block_index = tile_start + block_slot; + float score = 0.0f; + if (block_index < block_count) { + const int d = lane * 4; + const int64_t query_base = + (static_cast(token) * kQwenNumHeads + head) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.state_capacity + block_index) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key_state + key_base + d); + score = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + score = topk::WarpReduceSum(score); + } + if (lane == 0) { + head_scores[block_slot][head] = fmaxf(score, 0.0f); + } + __syncthreads(); + if (threadIdx.x < kHierarchicalTileBlocks && tile_start + threadIdx.x < block_count) { + float total_score = 0.0f; +#pragma unroll + for (int query_head = 0; query_head < kQwenNumHeads; ++query_head) { + total_score += head_scores[threadIdx.x][query_head]; + } + const float scaled_score = total_score * params.scale; + block_scores[static_cast(token) * params.state_capacity + tile_start + threadIdx.x] = + scaled_score == 0.0f ? 0.0f : scaled_score; + } + __syncthreads(); + } + } +} + +template +__global__ void QsaScoreTileTopKKernel(const T* present_key_state, const float* query_rotated, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, const int32_t* overflow_flags, + uint64_t* tile_keys, int tile_count, + PackedSparseAttentionIndexerParams params) { + __shared__ uint64_t keys[kHierarchicalTileBlocks]; + __shared__ float head_scores[kHierarchicalTileBlocks][kQwenNumHeads]; + + const int64_t total_tiles = static_cast(params.total_tokens) * tile_count; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int block_slot = warp / kQwenNumHeads; + const int head = warp % kQwenNumHeads; + for (int64_t work = blockIdx.x; work < total_tiles; work += gridDim.x) { + const int tile = static_cast(work % tile_count); + const int token = static_cast(work / tile_count); + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, kQwenCompressRatio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = overflow_flags[batch] == 0 ? static_cast(visible_64 < 0 ? 0 : visible_64) : 0; + + const int block_index = tile * kHierarchicalTileBlocks + block_slot; + float score = 0.0f; + if (block_index < block_count) { + const int d = lane * 4; + const int64_t query_base = + (static_cast(token) * kQwenNumHeads + head) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.state_capacity + block_index) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key_state + key_base + d); + score = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + score = topk::WarpReduceSum(score); + } + if (lane == 0) { + head_scores[block_slot][head] = fmaxf(score, 0.0f); + } + __syncthreads(); + if (threadIdx.x < kHierarchicalTileBlocks) { + const int tile_block = tile * kHierarchicalTileBlocks + static_cast(threadIdx.x); + uint64_t key = topk::kPaddingSortKey; + if (tile_block < block_count) { + float total_score = 0.0f; +#pragma unroll + for (int query_head = 0; query_head < kQwenNumHeads; ++query_head) { + total_score += head_scores[threadIdx.x][query_head]; + } + const float scaled_score = total_score * params.scale; + key = topk::PackStableSortKey(scaled_score == 0.0f ? 0.0f : scaled_score, tile_block); + } + keys[threadIdx.x] = key; + } + __syncthreads(); + if (threadIdx.x == 0) { + for (int index = 1; index < kHierarchicalTileBlocks; ++index) { + const uint64_t key = keys[index]; + int insertion = index; + while (insertion > 0 && key > keys[insertion - 1]) { + keys[insertion] = keys[insertion - 1]; + --insertion; + } + keys[insertion] = key; + } + } + __syncthreads(); + const int64_t output_base = work * kHierarchicalTileBlocks; + if (threadIdx.x < kHierarchicalTileBlocks) { + tile_keys[output_base + threadIdx.x] = keys[threadIdx.x]; + } + __syncthreads(); + } +} + +__device__ __forceinline__ uint64_t QsaMergeRank(const uint64_t* left, int left_count, + const uint64_t* right, int right_count, int rank) { + int low = max(0, rank - right_count); + int high = min(rank, left_count); + while (low <= high) { + const int left_rank = (low + high) / 2; + const int right_rank = rank - left_rank; + if (left_rank > 0 && right_rank < right_count && left[left_rank - 1] < right[right_rank]) { + high = left_rank - 1; + } else if (right_rank > 0 && left_rank < left_count && right[right_rank - 1] < left[left_rank]) { + low = left_rank + 1; + } else { + const uint64_t left_key = left_rank < left_count ? left[left_rank] : topk::kPaddingSortKey; + const uint64_t right_key = right_rank < right_count ? right[right_rank] : topk::kPaddingSortKey; + return max(left_key, right_key); + } + } + return topk::kPaddingSortKey; +} + +__global__ void QsaMergeTileTopKKernel(const uint64_t* input_keys, uint64_t* output_keys, + int key_stride, int list_width, int rows) { + const int pairs_per_row = (key_stride + 2 * list_width - 1) / (2 * list_width); + const int64_t total_pairs = static_cast(rows) * pairs_per_row; + for (int64_t work = blockIdx.x; work < total_pairs; work += gridDim.x) { + const int pair = static_cast(work % pairs_per_row); + const int64_t row = work / pairs_per_row; + const int left_start = pair * 2 * list_width; + const int right_start = left_start + list_width; + const int left_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - left_start)); + const int right_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - right_start)); + const int output_count = min(left_count + right_count, kBoundedTopKMax); + const int64_t row_base = row * key_stride; + for (int rank = threadIdx.x; rank < output_count; rank += blockDim.x) { + output_keys[row_base + left_start + rank] = + QsaMergeRank(input_keys + row_base + left_start, left_count, + input_keys + row_base + right_start, right_count, rank); + } + } +} + +__global__ void QsaEmitHierarchicalTopKKernel(const uint64_t* tile_keys, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, + const int64_t* position_ids, + const int32_t* present_state_lengths, + const int32_t* overflow_flags, int key_stride, + int32_t* selected_indices, int32_t* selected_counts, + PackedSparseAttentionIndexerParams params) { + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + int32_t* output = selected_indices + static_cast(token) * params.capacity; + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = overflow_flags[batch] == 0 ? static_cast(visible_64 < 0 ? 0 : visible_64) : 0; + const int selected = min(params.block_topk, block_count); + for (int rank = threadIdx.x; rank < selected; rank += blockDim.x) { + const int selected_block = topk::UnpackStableSortIndex(tile_keys[static_cast(token) * key_stride + rank]); + for (int t = 0; t < params.compress_ratio; ++t) { + output[rank * params.compress_ratio + t] = selected_block * params.compress_ratio + t; + } + } + const int64_t block_start = static_cast(block_count) * params.compress_ratio; + const int64_t natural_tail = abs_position >= block_start ? abs_position - block_start + 1 : 0; + const int remaining_capacity = params.capacity - selected * params.compress_ratio; + const int64_t tail_count_64 = natural_tail < remaining_capacity ? natural_tail : remaining_capacity; + const int tail_count = overflow_flags[batch] == 0 ? static_cast(tail_count_64 < 0 ? 0 : tail_count_64) : 0; + for (int t = threadIdx.x; t < tail_count; t += blockDim.x) { + output[selected * params.compress_ratio + t] = static_cast(block_start + t); + } + for (int position = selected * params.compress_ratio + tail_count + threadIdx.x; + position < params.capacity; position += blockDim.x) { + output[position] = -1; + } + if (threadIdx.x == 0) { + selected_counts[token] = selected * params.compress_ratio + tail_count; + } + } +} + // One block per query token. Emits the token indices of the highest scoring blocks followed by the // causally visible tokens of the trailing incomplete block, and the exact active count. A request // whose update step was rejected for exceeding state_capacity this call (overflow_flags[batch] set) // always gets the safe empty result: indices stay -1 (already reset below) and count is 0. -__global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumulative_sequence_lengths, - const int32_t* past_sequence_lengths, +__global__ void QsaSelectKernel(const float* block_scores, const int32_t* topk_indices, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, const int32_t* present_state_lengths, const int32_t* overflow_flags, int32_t* selected_indices, int32_t* selected_counts, PackedSparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* shared_value = shared; - int* shared_index = reinterpret_cast(shared + blockDim.x); + const bool use_fast_topk = params.block_topk <= kSaiFastTopKMax; + const int shared_entries = use_fast_topk ? static_cast(blockDim.x) * params.block_topk + : static_cast(blockDim.x); + int* shared_index = reinterpret_cast(shared + shared_entries); for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { int32_t* out_row = selected_indices + static_cast(token) * params.capacity; @@ -355,7 +658,9 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; const int64_t abs_position = - static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); const int visible_block_count = static_cast(visible_64 < 0 ? 0 : visible_64); @@ -363,28 +668,52 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula const float* scores_row = block_scores + static_cast(token) * params.state_capacity; - float previous_score = 0.0f; - int previous_index = -1; - int emitted_blocks = 0; - for (int rank = 0; rank < selected; ++rank) { - float best_value = 0.0f; - int best_index = -1; - SaiScanForNext(scores_row, visible_block_count, previous_score, previous_index, &best_value, &best_index); - shared_value[threadIdx.x] = best_value; - shared_index[threadIdx.x] = best_index; - __syncthreads(); - SaiBlockArgMax(shared_value, shared_index); - previous_index = shared_index[0]; - previous_score = shared_value[0]; - __syncthreads(); - if (previous_index < 0) { - break; + int emitted_blocks = selected; + if (topk_indices != nullptr) { + const int32_t* topk_row = topk_indices + static_cast(token) * params.state_capacity; + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = topk_row[rank]; + for (int t = static_cast(threadIdx.x); t < params.compress_ratio; + t += static_cast(blockDim.x)) { + out_row[rank * params.compress_ratio + t] = selected_block * params.compress_ratio + t; + } } - for (int t = static_cast(threadIdx.x); t < params.compress_ratio; t += static_cast(blockDim.x)) { - out_row[rank * params.compress_ratio + t] = previous_index * params.compress_ratio + t; + __syncthreads(); + } else if (selected > 0 && use_fast_topk) { + SaiBlockTopK(scores_row, visible_block_count, selected, shared_value, shared_index); + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = shared_index[rank]; + for (int t = static_cast(threadIdx.x); t < params.compress_ratio; + t += static_cast(blockDim.x)) { + out_row[rank * params.compress_ratio + t] = selected_block * params.compress_ratio + t; + } } - emitted_blocks = rank + 1; __syncthreads(); + } else { + float previous_score = 0.0f; + int previous_index = -1; + emitted_blocks = 0; + for (int rank = 0; rank < selected; ++rank) { + float best_value = 0.0f; + int best_index = -1; + SaiScanForNext(scores_row, visible_block_count, previous_score, previous_index, &best_value, &best_index); + shared_value[threadIdx.x] = best_value; + shared_index[threadIdx.x] = best_index; + __syncthreads(); + SaiBlockArgMax(shared_value, shared_index); + previous_index = shared_index[0]; + previous_score = shared_value[0]; + __syncthreads(); + if (previous_index < 0) { + break; + } + for (int t = static_cast(threadIdx.x); t < params.compress_ratio; + t += static_cast(blockDim.x)) { + out_row[rank * params.compress_ratio + t] = previous_index * params.compress_ratio + t; + } + emitted_blocks = rank + 1; + __syncthreads(); + } } // The trailing incomplete block is always causally visible in full up to this query's own @@ -404,6 +733,148 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula } } +// One block per packed query token. Radix-selects a bounded candidate set, then sorts only that +// set. The output aliases the corresponding score row after all scores for that row have been read. +__global__ void QsaPartialTopKKernel(float* block_scores, const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, const int32_t* overflow_flags, + PackedSparseAttentionIndexerParams params) { + using Sort = cub::BlockRadixSort; + __shared__ union { + uint32_t histogram[256]; + typename Sort::TempStorage sort; + } temp; + __shared__ uint64_t selected_keys[kBoundedTopKMax]; + __shared__ int32_t equal_warp_offsets[kThreads / kWarpSize + 1]; + __shared__ uint32_t prefix; + __shared__ int remaining; + __shared__ int gathered; + __shared__ int equal_seen; + + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + if (overflow_flags[batch] != 0) { + continue; + } + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = static_cast(visible_64 < 0 ? 0 : visible_64); + const int selected = min(params.block_topk, block_count); + if (selected == 0) { + continue; + } + float* scores = block_scores + static_cast(token) * params.state_capacity; + + if (threadIdx.x == 0) { + prefix = 0; + remaining = selected; + } + __syncthreads(); + + for (int shift = 24; shift >= 0; shift -= 8) { + for (int bucket = threadIdx.x; bucket < 256; bucket += blockDim.x) { + temp.histogram[bucket] = 0; + } + __syncthreads(); + const uint32_t current_prefix = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint32_t score_key = static_cast(topk::PackStableSortKey(scores[index], 0) >> 32); + if (shift == 24 || (score_key >> (shift + 8)) == (current_prefix >> (shift + 8))) { + atomicAdd(&temp.histogram[(score_key >> shift) & 0xffu], 1u); + } + } + __syncthreads(); + if (threadIdx.x == 0) { + int rank = remaining; + for (int bucket = 255; bucket >= 0; --bucket) { + const int count = static_cast(temp.histogram[bucket]); + if (rank > count) { + rank -= count; + } else { + prefix |= static_cast(bucket) << shift; + remaining = rank; + break; + } + } + } + __syncthreads(); + } + + if (threadIdx.x == 0) { + gathered = 0; + } + __syncthreads(); + const uint32_t threshold = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint64_t key = topk::PackStableSortKey(scores[index], index); + if (static_cast(key >> 32) > threshold) { + const int slot = atomicAdd(&gathered, 1); + selected_keys[slot] = key; + } + } + __syncthreads(); + + if (threadIdx.x == 0) { + equal_seen = 0; + } + __syncthreads(); + for (int base = 0; base < block_count && equal_seen < remaining; base += blockDim.x) { + const int index = base + static_cast(threadIdx.x); + const bool equal = index < block_count && + static_cast(topk::PackStableSortKey(scores[index], 0) >> 32) == threshold; + const unsigned int warp_mask = __ballot_sync(0xffffffffu, equal); + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); + if (lane == 0) { + equal_warp_offsets[warp] = __popc(warp_mask); + } + __syncthreads(); + if (threadIdx.x == 0) { + int tile_total = 0; + for (int i = 0; i < kThreads / kWarpSize; ++i) { + const int warp_count = equal_warp_offsets[i]; + equal_warp_offsets[i] = tile_total; + tile_total += warp_count; + } + equal_warp_offsets[kThreads / kWarpSize] = tile_total; + } + __syncthreads(); + const int equal_rank = equal_seen + equal_warp_offsets[warp] + lane_rank; + if (equal && equal_rank < remaining) { + selected_keys[gathered + equal_rank] = topk::PackStableSortKey(scores[index], index); + } + __syncthreads(); + if (threadIdx.x == 0) { + equal_seen += equal_warp_offsets[kThreads / kWarpSize]; + } + __syncthreads(); + } + + uint64_t keys[kBoundedTopKItemsPerThread]; +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; + } + Sort(temp.sort).SortDescending(keys); + int32_t* topk_row = reinterpret_cast(scores); +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + if (rank < selected) { + topk_row[rank] = topk::UnpackStableSortIndex(keys[item]); + } + } + __syncthreads(); + } +} + // --------------------------------------------------------------------------------------------- // policy_mode = "csa" // --------------------------------------------------------------------------------------------- @@ -738,7 +1209,12 @@ __global__ void CsaSelectKernel(const float* scores, const int32_t* cumulative_s size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { const size_t rows = static_cast(params.total_tokens); - return rows * params.num_heads * params.head_size + rows * static_cast(std::max(params.state_capacity, 1)); + const size_t query_count = rows * params.num_heads * params.head_size; + if (UseHierarchicalQsaTopK(params)) { + const size_t key_count = rows * GetHierarchicalTileCount(params) * kHierarchicalTileBlocks; + return query_count + 4 * key_count; + } + return query_count + rows * static_cast(std::max(params.state_capacity, 1)); } size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { @@ -751,10 +1227,11 @@ Status LaunchQsaPackedSparseAttentionIndexer( cudaStream_t stream, const PackedSparseAttentionIndexerParams& params, const T* query, const T* key, const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, - const int32_t* past_sequence_lengths, const T* past_key_state, - const T* past_kv_buffer, const int32_t* past_state_lengths, int32_t* selected_indices, + const int32_t* past_sequence_lengths, const int64_t* position_ids, const T* past_key_state, + const T* past_kv_buffer, const int32_t* past_state_lengths, const int32_t* state_update_capture_count, + bool state_update_active, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, - float* float_workspace, int32_t* overflow_flags) { + T* state_update, float* float_workspace, int32_t* overflow_flags) { if (params.batch_size > 0) { const int64_t key_state_elems = static_cast(params.batch_size) * params.state_capacity * params.head_size; @@ -785,15 +1262,19 @@ Status LaunchQsaPackedSparseAttentionIndexer( const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); QsaUpdateStateKernel<<>>( key, key_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, past_kv_buffer, - past_state_lengths, present_key_state, present_kv_buffer, present_state_lengths, overflow_flags, params); + past_state_lengths, state_update_capture_count, state_update_active, present_key_state, present_kv_buffer, + present_state_lengths, state_update, overflow_flags, params); if (params.total_tokens == 0) { return CUDA_CALL(cudaGetLastError()); } float* query_rotated = float_workspace; - float* block_scores = - float_workspace + static_cast(params.total_tokens) * params.num_heads * params.head_size; + float* selection_workspace = + query_rotated + static_cast(params.total_tokens) * params.num_heads * params.head_size; + const bool use_hierarchical_topk = UseHierarchicalQsaTopK(params); + const bool use_ragged_prefill = UseRaggedQsaPrefill(params); + float* block_scores = use_hierarchical_topk ? nullptr : selection_workspace; const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); @@ -802,20 +1283,59 @@ Status LaunchQsaPackedSparseAttentionIndexer( PackedRotateQueryKernel, rotate_shared_bytes, "PackedRotateQueryKernel")); PackedRotateQueryKernel<<>>( query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, - nullptr, query_rotated, params); + position_ids, query_rotated, params); - if (params.state_capacity > 0) { + const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); + if (use_hierarchical_topk) { + const int tile_count = GetHierarchicalTileCount(params); + const int key_stride = tile_count * kHierarchicalTileBlocks; + const size_t key_count = static_cast(params.total_tokens) * key_stride; + uint64_t* merge_input = reinterpret_cast(selection_workspace); + uint64_t* merge_output = merge_input + key_count; + const int64_t tile_work = static_cast(params.total_tokens) * tile_count; + QsaScoreTileTopKKernel<<(std::min(tile_work, kSaiMaxGridDimX)), + kHierarchicalScoreThreads, 0, stream>>>( + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, overflow_flags, merge_input, tile_count, params); + for (int list_width = kHierarchicalTileBlocks; list_width < key_stride; list_width *= 2) { + const int pairs_per_row = (key_stride + 2 * list_width - 1) / (2 * list_width); + const int64_t merge_work = static_cast(params.total_tokens) * pairs_per_row; + QsaMergeTileTopKKernel<<(std::min(merge_work, kSaiMaxGridDimX)), + kHierarchicalMergeThreads, 0, stream>>>( + merge_input, merge_output, key_stride, list_width, params.total_tokens); + std::swap(merge_input, merge_output); + } + QsaEmitHierarchicalTopKKernel<<>>( + merge_input, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, + overflow_flags, key_stride, selected_indices, selected_counts, params); + } else if (use_ragged_prefill) { + QsaRaggedPrefillScoreKernel<<>>( + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, overflow_flags, block_scores, params); + } else if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); QsaBlockScoreKernel<<>>( - present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, block_scores, params); } - const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); - QsaSelectKernel<<>>( - block_scores, cumulative_sequence_lengths, past_sequence_lengths, present_state_lengths, - overflow_flags, selected_indices, selected_counts, params); + const bool use_bounded_topk = + !use_hierarchical_topk && params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; + int32_t* topk_indices = use_bounded_topk ? reinterpret_cast(block_scores) : nullptr; + if (use_bounded_topk) { + QsaPartialTopKKernel<<>>( + block_scores, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, + overflow_flags, params); + } + const int topk_shared_entries = + !use_bounded_topk && params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; + if (!use_hierarchical_topk) { + QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( + block_scores, topk_indices, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, overflow_flags, selected_indices, selected_counts, params); + } return CUDA_CALL(cudaGetLastError()); } @@ -905,9 +1425,8 @@ Status LaunchCsaPackedSparseAttentionIndexer( #define INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(T) \ template Status LaunchQsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ - const T*, const T*, const int32_t*, const int32_t*, const T*, const T*, const int32_t*, \ - int32_t*, \ - int32_t*, T*, T*, int32_t*, float*, int32_t*); \ + const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, const T*, const int32_t*, \ + const int32_t*, bool, int32_t*, int32_t*, T*, T*, int32_t*, T*, float*, int32_t*); \ template Status LaunchCsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ const T*, const T*, const T*, const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, \ diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index 08f67953af4d9..aa8b346d044c6 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -21,9 +21,12 @@ struct PackedSparseAttentionIndexerParams { int total_tokens = 0; int num_heads = 0; int head_size = 0; + int query_row_stride = 0; + int key_row_stride = 0; int rotary_width = 0; // cos_cache.shape[-1] int max_rotary_length = 0; // cos_cache.shape[-2] bool cos_cache_batched = false; // cos_cache rank: 3 = [batch, pos, rot], 2 = [pos, rot] + bool has_position_ids = false; int compress_ratio = 0; int state_capacity = 0; // past_key_state.shape[1] int buffer_capacity = 0; // past_kv_buffer.shape[1] == 2 * compress_ratio - 1 @@ -33,6 +36,7 @@ struct PackedSparseAttentionIndexerParams { // policy_mode = "qsa" int block_topk = 0; // token_budget / compress_ratio + int state_update_capacity = 0; // policy_mode = "csa" int index_topk = 0; @@ -61,14 +65,18 @@ Status LaunchQsaPackedSparseAttentionIndexer( const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, + const int64_t* position_ids, const T* past_key_state, const T* past_kv_buffer, const int32_t* past_state_lengths, + const int32_t* state_update_capture_count, + bool state_update_active, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, + T* state_update, float* float_workspace, int32_t* overflow_flags); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 0e99c050b7767..9620206ec730a 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -57,6 +57,32 @@ Status CheckIntDimension(const char* name, int64_t value, bool allow_zero = true return Status::OK(); } +struct RotaryCacheShape { + bool batched; + int64_t max_rotary_length; + int64_t rotary_width; +}; + +Status CheckRotaryCache(const Tensor* cos_cache, const Tensor* sin_cache, int64_t batch_size, + RotaryCacheShape& out) { + ORT_RETURN_IF(cos_cache == nullptr, "SparseAttentionIndexer: cos_cache is required"); + const auto& cos_shape = cos_cache->Shape(); + out.batched = cos_shape.NumDimensions() == 3; + ORT_RETURN_IF_NOT( + (out.batched && cos_shape[0] == batch_size && cos_shape[1] > 0) || + (cos_shape.NumDimensions() == 2 && cos_shape[0] > 0), + "SparseAttentionIndexer: cos_cache must have shape (max_rotary_sequence_length, rotary_width) or " + "(batch_size, max_rotary_sequence_length, rotary_width), got ", + cos_shape.ToString()); + out.max_rotary_length = out.batched ? cos_shape[1] : cos_shape[0]; + out.rotary_width = out.batched ? cos_shape[2] : cos_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", out.max_rotary_length, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", out.rotary_width, false)); + ORT_RETURN_IF_NOT(sin_cache != nullptr && sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as cos_cache"); + return Status::OK(); +} + Status ReadOptionalLength(OpKernelContext* context, int index, const char* name, int64_t& value) { const Tensor* tensor = index < context->InputCount() ? context->Input(index) : nullptr; if (tensor == nullptr) { @@ -118,6 +144,9 @@ Status SparseAttentionIndexer::ComputeInternal(OpKernelContext* context) cons "SparseAttentionIndexer: key is required for policy_mode 'csa'"); for (int index : {sai::kMask, sai::kGate, sai::kPositionBias, sai::kHeadWeights, sai::kPositionIds, sai::kPastProjBuffer}) { + if (is_qsa && index == sai::kMask) { + continue; + } const bool policy_owns_slot = is_qsa ? index == sai::kMask : index != sai::kMask; const bool provided = index < context->InputCount() && context->Input(index) != nullptr; ORT_RETURN_IF(provided != policy_owns_slot, "SparseAttentionIndexer: input ", index, @@ -166,18 +195,10 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); - const auto& cos_shape = cos_cache->Shape(); - ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, - "SparseAttentionIndexer: cos_cache must have shape " - "(batch_size, max_rotary_sequence_length, rotary_width), got ", - cos_shape.ToString()); - const int64_t max_rotary_length = cos_shape[1]; - const int64_t rotary_width = cos_shape[2]; - ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", max_rotary_length, false)); - ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", rotary_width, false)); - ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape, - "SparseAttentionIndexer: sin_cache must have the same shape as " - "cos_cache"); + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; ORT_RETURN_IF_NOT(rotary_width > 0 && rotary_width % 2 == 0 && rotary_width <= head_size, "SparseAttentionIndexer: policy_mode 'qsa' requires an even rotary_width in (0, head_size], got ", rotary_width); @@ -207,12 +228,14 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); - const auto& mask_shape = mask->Shape(); - ORT_RETURN_IF_NOT( - mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_sequence_length, - "SparseAttentionIndexer: mask must be INT64 with shape (batch_size, total_sequence_length), with " - "total_sequence_length=", - total_sequence_length, ", got ", mask_shape.ToString()); + if (mask != nullptr) { + const auto& mask_shape = mask->Shape(); + ORT_RETURN_IF_NOT( + mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_sequence_length, + "SparseAttentionIndexer: mask must be INT64 with shape (batch_size, total_sequence_length), with " + "total_sequence_length=", + total_sequence_length, ", got ", mask_shape.ToString()); + } SparseAttentionIndexerParams params; params.batch_size = static_cast(batch_size); @@ -223,6 +246,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { params.key_row_stride = packed_qk ? static_cast(query_shape[2]) : static_cast(head_size); params.rotary_width = static_cast(rotary_width); params.max_rotary_length = static_cast(max_rotary_length); + params.rotary_cache_batch_stride = rotary_cache_shape.batched ? params.max_rotary_length : 0; params.compress_ratio = static_cast(compress_ratio_); params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_)); @@ -238,6 +262,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { : total_sequence_length); params.max_block_count = static_cast(total_sequence_length / compress_ratio_); params.block_topk = static_cast(token_budget_ / compress_ratio_); + params.use_block_representatives = false; Tensor* selected_indices = context->Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, params.capacity})); @@ -247,7 +272,8 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); auto float_workspace = GetScratchBuffer(GetQsaWorkspaceFloatCount(params), GetComputeStream(context)); - auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), GetComputeStream(context)); + auto int_workspace = + GetScratchBuffer(GetQsaWorkspaceIntCount(params, mask != nullptr), GetComputeStream(context)); const CudaT* query_data = reinterpret_cast(query->Data()); const CudaT* key_data = packed_qk ? query_data + num_heads * head_size @@ -260,7 +286,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), - mask->Data(), + mask == nullptr ? nullptr : mask->Data(), reinterpret_cast(past_key->Data()), selected_indices->MutableData(), reinterpret_cast(present_key->MutableData()), @@ -309,18 +335,10 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "SparseAttentionIndexer: 2 * head_size must be no greater than INT_MAX"); const int64_t width = 2 * head_size; - const auto& cos_shape = cos_cache->Shape(); - ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, - "SparseAttentionIndexer: cos_cache must have shape " - "(batch_size, max_rotary_sequence_length, rotary_width), got ", - cos_shape.ToString()); - const int64_t max_rotary_length = cos_shape[1]; - const int64_t rotary_width = cos_shape[2]; - ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", max_rotary_length, false)); - ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", rotary_width, false)); - ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape, - "SparseAttentionIndexer: sin_cache must have the same shape as " - "cos_cache"); + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; ORT_RETURN_IF_NOT(rotary_width > 0 && 2 * rotary_width <= head_size, "SparseAttentionIndexer: policy_mode 'csa' requires 0 < 2 * rotary_width <= head_size, got " "rotary_width=", @@ -380,6 +398,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.key_row_stride = static_cast(key->Shape()[2]); params.rotary_width = static_cast(rotary_width); params.max_rotary_length = static_cast(max_rotary_length); + params.rotary_cache_batch_stride = rotary_cache_shape.batched ? params.max_rotary_length : 0; params.compress_ratio = static_cast(compress_ratio_); params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_)); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh index 12321d1e499fc..eb63636ab998d 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh @@ -26,6 +26,7 @@ namespace cuda { namespace { constexpr int64_t kSaiMaxGridDimX = 2147483647; +constexpr int kSaiFastTopKMax = 32; __device__ __forceinline__ float SaiNegativeInfinity() { return -CUDART_INF_F; } @@ -95,6 +96,77 @@ __device__ __forceinline__ void SaiScanForNext(const float* scores, int count, f } } +__device__ __forceinline__ bool SaiTopKBetter(float lhs_value, int lhs_index, float rhs_value, int rhs_index) { + return lhs_index >= 0 && + (rhs_index < 0 || lhs_value > rhs_value || (lhs_value == rhs_value && lhs_index < rhs_index)); +} + +// Finds up to kSaiFastTopKMax entries with one global read of each score. Each thread first keeps +// a sorted local list, then adjacent threads merge their lists in shared memory. The final list is +// ordered by score descending and index ascending, matching SaiScanForNext/SaiBlockArgMax. +__device__ __forceinline__ void SaiBlockTopK(const float* scores, int count, int top_k, + float* shared_value, int* shared_index) { + float local_value[kSaiFastTopKMax]; + int local_index[kSaiFastTopKMax]; + for (int rank = 0; rank < top_k; ++rank) { + local_value[rank] = 0.0f; + local_index[rank] = -1; + } + + for (int candidate = static_cast(threadIdx.x); candidate < count; + candidate += static_cast(blockDim.x)) { + const float value = scores[candidate]; + if (!SaiTopKBetter(value, candidate, local_value[top_k - 1], local_index[top_k - 1])) { + continue; + } + int rank = top_k - 1; + while (rank > 0 && SaiTopKBetter(value, candidate, local_value[rank - 1], local_index[rank - 1])) { + local_value[rank] = local_value[rank - 1]; + local_index[rank] = local_index[rank - 1]; + --rank; + } + local_value[rank] = value; + local_index[rank] = candidate; + } + + const int thread_offset = static_cast(threadIdx.x) * top_k; + for (int rank = 0; rank < top_k; ++rank) { + shared_value[thread_offset + rank] = local_value[rank]; + shared_index[thread_offset + rank] = local_index[rank]; + } + __syncthreads(); + + for (int stride = 1; stride < static_cast(blockDim.x); stride <<= 1) { + if ((static_cast(threadIdx.x) % (2 * stride)) == 0) { + const int right_offset = (static_cast(threadIdx.x) + stride) * top_k; + for (int rank = 0; rank < top_k; ++rank) { + local_value[rank] = shared_value[thread_offset + rank]; + local_index[rank] = shared_index[thread_offset + rank]; + } + + int left_rank = 0; + int right_rank = 0; + for (int rank = 0; rank < top_k; ++rank) { + const bool take_right = + right_rank < top_k && + (left_rank == top_k || + SaiTopKBetter(shared_value[right_offset + right_rank], shared_index[right_offset + right_rank], + local_value[left_rank], local_index[left_rank])); + if (take_right) { + shared_value[thread_offset + rank] = shared_value[right_offset + right_rank]; + shared_index[thread_offset + rank] = shared_index[right_offset + right_rank]; + ++right_rank; + } else { + shared_value[thread_offset + rank] = local_value[left_rank]; + shared_index[thread_offset + rank] = local_index[left_rank]; + ++left_rank; + } + } + } + __syncthreads(); + } +} + // --------------------------------------------------------------------------------------------- // Rotary embeddings // --------------------------------------------------------------------------------------------- diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu index 213296f767826..ef3538d0e8207 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -11,20 +11,47 @@ #include #include #include +#include #include #include "contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh" #include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" +#include "core/providers/cuda/cu_inc/topk_warp_sort.cuh" namespace onnxruntime { namespace contrib { namespace cuda { +namespace topk = onnxruntime::cuda::topk; + namespace { // The block reductions below halve the active thread count, so this must stay a power of two. constexpr int kThreads = 128; +constexpr int kWarpSize = 32; constexpr int64_t kMaxGridDimX = kSaiMaxGridDimX; +constexpr int kBoundedTopKMax = 512; +constexpr int kBoundedTopKItemsPerThread = kBoundedTopKMax / kThreads; +constexpr int kDistributedTopKMaxRows = 64; +constexpr int kDistributedTopKMinBlocks = 2048; +constexpr int kHierarchicalTileBlocks = 8; +constexpr int kHierarchicalScoreThreads = 1024; +constexpr int kHierarchicalMergeThreads = 256; +constexpr int kQwenHeadSize = 128; +constexpr int kQwenCompressRatio = 4; +constexpr int kQwenNumHeads = 4; + +template +struct alignas(sizeof(T) * 4) QsaVector4 { + T values[4]; +}; + +template +__device__ __forceinline__ float4 LoadQsaVector4(const T* source) { + const QsaVector4 packed = *reinterpret_cast*>(source); + return make_float4(to_float(packed.values[0]), to_float(packed.values[1]), + to_float(packed.values[2]), to_float(packed.values[3])); +} // Thin, same-signature aliases over the device math shared with the packed indexer implementation // (sparse_attention_indexer_device_math.cuh), so the kernels below are unchanged. @@ -45,6 +72,23 @@ __device__ __forceinline__ float NegativeInfinity() { return SaiNegativeInfinity int GridForElements(int64_t count) { return SaiGridForElements(count, kThreads); } +bool UseDistributedQsaTopK(const SparseAttentionIndexerParams& params, bool has_mask) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + return !has_mask && rows > 0 && rows <= kDistributedTopKMaxRows && + params.max_block_count >= kDistributedTopKMinBlocks && + params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads && + params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; +} + +bool UseHierarchicalQsaTopK(const SparseAttentionIndexerParams& params, bool has_mask) { + return params.use_block_representatives && UseDistributedQsaTopK(params, has_mask); +} + +int GetHierarchicalTileCount(const SparseAttentionIndexerParams& params) { + return (params.max_block_count + kHierarchicalTileBlocks - 1) / kHierarchicalTileBlocks; +} + __device__ __forceinline__ float BlockSum(float value, float* shared) { return SaiBlockSum(value, shared); } __device__ __forceinline__ void BlockArgMax(float* shared_value, int* shared_index) { @@ -118,7 +162,7 @@ __global__ void RotateQueryKernel(const T* query, const T* query_norm_weight, : position_ids[static_cast(batch) * params.sequence_length + token]; const int position = ClampPosition(raw_position, params.max_rotary_length); const int64_t cache_offset = - (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; const T* cos_row = cos_cache + cache_offset; const T* sin_row = sin_cache + cache_offset; @@ -165,42 +209,181 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention } } -// One block per query row; compacts the visible key positions of that row into visible_indices. -__global__ void CompactVisibleKernel(const int64_t* mask, int32_t* visible_indices, int32_t* visible_count, +template +__global__ void AppendQsaKeyTailKernel(const T* key, T* present_key, SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.head_size; + for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; + index += static_cast(gridDim.x) * blockDim.x) { + const int d = static_cast(index % params.head_size); + const int64_t token_row = index / params.head_size; + const int token = static_cast(token_row % params.sequence_length); + const int batch = static_cast(token_row / params.sequence_length); + const int position = params.past_sequence_length + token; + const int completed_tokens = params.total_sequence_length / kQwenCompressRatio * kQwenCompressRatio; + if (position >= completed_tokens) { + const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; + const int cache_position = representative_capacity + position % kQwenCompressRatio; + present_key[(static_cast(batch) * params.key_cache_capacity + cache_position) * params.head_size + d] = + key[token_row * params.key_row_stride + d]; + } + } +} + +// Shared-buffer Qwen caches are opaque state. Completed block representatives occupy a contiguous +// prefix, followed by four scratch slots that carry the current incomplete raw block. +template +__global__ void QsaBuildBlockRepresentativesKernel(const T* past_key, const T* key, T* present_key, + const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, + SparseAttentionIndexerParams params) { + __shared__ float pooled[kQwenHeadSize]; + __shared__ float reduction[kThreads]; + const int first_block = params.past_sequence_length / kQwenCompressRatio; + const int completed_blocks = params.total_sequence_length / kQwenCompressRatio; + const int blocks_per_batch = completed_blocks - first_block; + const int total_blocks = params.batch_size * blocks_per_batch; + for (int work = blockIdx.x; work < total_blocks; work += gridDim.x) { + const int batch = work / blocks_per_batch; + const int block = first_block + work % blocks_per_batch; + const int first_position = block * kQwenCompressRatio; + const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + float sum = 0.0f; +#pragma unroll + for (int token = 0; token < kQwenCompressRatio; ++token) { + const int position = first_position + token; + if (position < params.past_sequence_length) { + const int cache_position = representative_capacity + position % kQwenCompressRatio; + sum += to_float(past_key[(static_cast(batch) * params.past_key_capacity + cache_position) * + kQwenHeadSize + + d]); + } else { + const int key_token = position - params.past_sequence_length; + sum += to_float(key[(static_cast(batch) * params.sequence_length + key_token) * + params.key_row_stride + + d]); + } + } + pooled[d] = sum * (1.0f / static_cast(kQwenCompressRatio)); + } + __syncthreads(); + float sum_squares = 0.0f; + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = BlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(kQwenHeadSize) + params.epsilon); + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + pooled[d] *= inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + const int position = ClampPosition(first_position, params.max_rotary_length); + const int64_t rotary_offset = + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + const int64_t output_base = + (static_cast(batch) * params.key_cache_capacity + block) * kQwenHeadSize; + present_key[output_base + d] = from_float(LeadingRope( + pooled, params.rotary_width, cos_cache + rotary_offset, sin_cache + rotary_offset, d)); + } + __syncthreads(); + } +} + +// Encodes a prefix-visible row as -count-1. QSA can then index that row directly without +// materializing its visible positions. Non-prefix rows keep a nonnegative count and are compacted +// by CompactNonPrefixVisibleKernel. +__device__ __forceinline__ bool IsVisible(const int64_t* mask, int64_t row, int position, + const SparseAttentionIndexerParams& params) { + const int batch = static_cast(row / params.sequence_length); + const int query = static_cast(row % params.sequence_length); + return mask[static_cast(batch) * params.total_sequence_length + position] != 0 && + position <= params.past_sequence_length + query; +} + +__global__ void AnalyzeVisibleKernel(const int64_t* mask, int32_t* visible_count, SparseAttentionIndexerParams params) { - extern __shared__ int32_t shared_scan[]; + __shared__ int32_t counts[kThreads]; + __shared__ int32_t maxima[kThreads]; const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { - const int batch = static_cast(row / params.sequence_length); + int32_t count = 0; + int32_t maximum = -1; + for (int position = threadIdx.x; position < params.total_sequence_length; position += blockDim.x) { + if (IsVisible(mask, row, position, params)) { + ++count; + maximum = position; + } + } + counts[threadIdx.x] = count; + maxima[threadIdx.x] = maximum; + __syncthreads(); + for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) { + if (threadIdx.x < stride) { + counts[threadIdx.x] += counts[threadIdx.x + stride]; + maxima[threadIdx.x] = max(maxima[threadIdx.x], maxima[threadIdx.x + stride]); + } + __syncthreads(); + } + if (threadIdx.x == 0) { + const int32_t visible = counts[0]; + visible_count[row] = maxima[0] == visible - 1 ? -visible - 1 : visible; + } + __syncthreads(); + } +} + +__global__ void InitializePrefixVisibleCountKernel(int32_t* visible_count, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; row < rows; + row += static_cast(gridDim.x) * blockDim.x) { const int query = static_cast(row % params.sequence_length); - const int64_t* mask_row = mask + static_cast(batch) * params.total_sequence_length; + visible_count[row] = -(params.past_sequence_length + query + 1) - 1; + } +} + +// One block per non-prefix query row; compacts visible key positions into visible_indices. +__global__ void CompactNonPrefixVisibleKernel(const int64_t* mask, int32_t* visible_indices, + const int32_t* visible_count, + SparseAttentionIndexerParams params) { + __shared__ int32_t warp_offsets[kThreads / kWarpSize + 1]; + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + if (visible_count[row] < 0) { + continue; + } int32_t* out_row = visible_indices + row * params.total_sequence_length; int32_t offset = 0; - for (int64_t base = 0; base < params.total_sequence_length; base += blockDim.x) { - const int64_t position = base + threadIdx.x; - const int32_t flag = - (position < params.total_sequence_length && position <= params.past_sequence_length + query && - mask_row[position] != 0) - ? 1 - : 0; - shared_scan[threadIdx.x] = flag; - __syncthreads(); - for (int stride = 1; stride < blockDim.x; stride <<= 1) { - const int32_t addend = (threadIdx.x >= static_cast(stride)) ? shared_scan[threadIdx.x - stride] : 0; - __syncthreads(); - shared_scan[threadIdx.x] += addend; - __syncthreads(); + for (int base = 0; base < params.total_sequence_length; base += blockDim.x) { + const int position = base + static_cast(threadIdx.x); + const bool visible = position < params.total_sequence_length && + IsVisible(mask, row, position, params); + const unsigned int warp_mask = __ballot_sync(0xffffffffu, visible); + const int lane = threadIdx.x % kWarpSize; + const int warp = threadIdx.x / kWarpSize; + const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); + if (lane == 0) { + warp_offsets[warp] = __popc(warp_mask); } - if (flag != 0) { - out_row[offset + shared_scan[threadIdx.x] - 1] = static_cast(position); + __syncthreads(); + if (threadIdx.x == 0) { + int32_t tile_total = 0; + for (int i = 0; i < kThreads / kWarpSize; ++i) { + const int32_t warp_count = warp_offsets[i]; + warp_offsets[i] = tile_total; + tile_total += warp_count; + } + warp_offsets[kThreads / kWarpSize] = tile_total; } - const int32_t tile_total = shared_scan[blockDim.x - 1]; __syncthreads(); + const int32_t warp_offset = warp_offsets[warp]; + const int32_t tile_total = warp_offsets[kThreads / kWarpSize]; + if (visible) { + out_row[offset + warp_offset + lane_rank] = position; + } offset += tile_total; } - if (threadIdx.x == 0) { - visible_count[row] = offset; - } __syncthreads(); } } @@ -223,7 +406,10 @@ __global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present const int block_index = static_cast(work % params.max_block_count); const int64_t row = work / params.max_block_count; const int batch = static_cast(row / params.sequence_length); - const int block_count = visible_count[row] / params.compress_ratio; + const int encoded_visible = visible_count[row]; + const bool prefix_visible = encoded_visible < 0; + const int visible = prefix_visible ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; if (block_index >= block_count) { if (threadIdx.x == 0) { @@ -232,14 +418,16 @@ __global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present continue; } - const int32_t* index_row = visible_indices + row * params.total_sequence_length; - const int32_t* group = index_row + static_cast(block_index) * params.compress_ratio; + const int32_t* index_row = prefix_visible ? nullptr : visible_indices + row * params.total_sequence_length; + const int32_t* group = prefix_visible ? nullptr + : index_row + static_cast(block_index) * params.compress_ratio; const int64_t key_base = static_cast(batch) * params.key_cache_capacity * params.head_size; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { float sum = 0.0f; for (int t = 0; t < params.compress_ratio; ++t) { - sum += to_float(present_key[key_base + static_cast(group[t]) * params.head_size + d]); + const int position = prefix_visible ? block_index * params.compress_ratio + t : group[t]; + sum += to_float(present_key[key_base + static_cast(position) * params.head_size + d]); } pooled[d] = sum / static_cast(params.compress_ratio); } @@ -256,9 +444,10 @@ __global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present } __syncthreads(); - const int position = ClampPosition(group[0], params.max_rotary_length); + const int first_position = prefix_visible ? block_index * params.compress_ratio : group[0]; + const int position = ClampPosition(first_position, params.max_rotary_length); const int64_t cache_offset = - (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { rotated[d] = LeadingRope(pooled, params.rotary_width, cos_cache + cache_offset, sin_cache + cache_offset, d); @@ -276,7 +465,465 @@ __global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present } if (threadIdx.x == 0) { - block_scores[row * params.max_block_count + block_index] = score * params.scale; + const float scaled_score = score * params.scale; + block_scores[row * params.max_block_count + block_index] = scaled_score == 0.0f ? 0.0f : scaled_score; + } + __syncthreads(); + } +} + +// Qwen's common QSA layout has four query heads of width 128 and pools four keys per block. +// One warp prepares the vectorized pooled key, then each query head is scored by one warp. +template +__global__ void QsaBlockScoreQwenKernel(const float* query_rotated, const T* present_key, + const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, + const int32_t* visible_indices, const int32_t* visible_count, + float* block_scores, uint32_t* high_histogram, + SparseAttentionIndexerParams params) { + __shared__ float pooled[kQwenHeadSize]; + __shared__ float rotated[kQwenHeadSize]; + __shared__ float head_scores[kQwenNumHeads]; + __shared__ float inverse_rms; + + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.max_block_count; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int block_index = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + const int batch = static_cast(row / params.sequence_length); + const int encoded_visible = visible_count[row]; + const bool prefix_visible = encoded_visible < 0; + const int visible = prefix_visible ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / kQwenCompressRatio; + + if (block_index >= block_count) { + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = NegativeInfinity(); + } + continue; + } + + const int32_t* group = prefix_visible + ? nullptr + : visible_indices + row * params.total_sequence_length + + static_cast(block_index) * kQwenCompressRatio; + const int64_t key_base = static_cast(batch) * params.key_cache_capacity * kQwenHeadSize; + + if (warp == 0) { + const int d = lane * 4; + float4 sum = make_float4(0.0f, 0.0f, 0.0f, 0.0f); +#pragma unroll + for (int token = 0; token < kQwenCompressRatio; ++token) { + const int position = prefix_visible ? block_index * kQwenCompressRatio + token : group[token]; + const float4 value = LoadQsaVector4( + present_key + key_base + static_cast(position) * kQwenHeadSize + d); + sum.x += value.x; + sum.y += value.y; + sum.z += value.z; + sum.w += value.w; + } + constexpr float kInverseCompressRatio = 1.0f / static_cast(kQwenCompressRatio); + pooled[d] = sum.x * kInverseCompressRatio; + pooled[d + 1] = sum.y * kInverseCompressRatio; + pooled[d + 2] = sum.z * kInverseCompressRatio; + pooled[d + 3] = sum.w * kInverseCompressRatio; + + float sum_squares = pooled[d] * pooled[d] + pooled[d + 1] * pooled[d + 1] + + pooled[d + 2] * pooled[d + 2] + pooled[d + 3] * pooled[d + 3]; + sum_squares = topk::WarpReduceSum(sum_squares); + if (lane == 0) { + inverse_rms = rsqrtf(sum_squares / static_cast(kQwenHeadSize) + params.epsilon); + } + } + __syncthreads(); + + if (warp == 0) { + const int d = lane * 4; + const float4 weight = LoadQsaVector4(key_norm_weight + d); + pooled[d] *= inverse_rms * weight.x; + pooled[d + 1] *= inverse_rms * weight.y; + pooled[d + 2] *= inverse_rms * weight.z; + pooled[d + 3] *= inverse_rms * weight.w; + } + __syncthreads(); + + if (warp == 0) { + const int d = lane * 4; + const int first_position = prefix_visible ? block_index * kQwenCompressRatio : group[0]; + const int position = ClampPosition(first_position, params.max_rotary_length); + const int64_t cache_offset = + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; +#pragma unroll + for (int element = 0; element < 4; ++element) { + rotated[d + element] = LeadingRope(pooled, params.rotary_width, cos_cache + cache_offset, + sin_cache + cache_offset, d + element); + } + } + __syncthreads(); + + const int d = lane * 4; + const int64_t query_base = (row * kQwenNumHeads + warp) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(rotated + d); + float dot = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + dot = topk::WarpReduceSum(dot); + if (lane == 0) { + head_scores[warp] = fmaxf(dot, 0.0f); + } + __syncthreads(); + + if (threadIdx.x == 0) { + float score = 0.0f; +#pragma unroll + for (int head = 0; head < kQwenNumHeads; ++head) { + score += head_scores[head]; + } + const float scaled_score = score * params.scale; + const float stored_score = scaled_score == 0.0f ? 0.0f : scaled_score; + block_scores[row * params.max_block_count + block_index] = stored_score; + if (high_histogram != nullptr) { + const uint32_t score_key = static_cast(topk::PackStableSortKey(stored_score, 0) >> 32); + atomicAdd(high_histogram + row * 256 + (score_key >> 24), 1u); + } + } + __syncthreads(); + } +} + +template +__global__ void QsaBlockScoreQwenCachedKernel(const float* query_rotated, const T* present_key, + const int32_t* visible_count, float* block_scores, + uint32_t* high_histogram, + SparseAttentionIndexerParams params) { + __shared__ float head_scores[kQwenNumHeads]; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.max_block_count; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int block_index = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + const int batch = static_cast(row / params.sequence_length); + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / kQwenCompressRatio; + if (block_index >= block_count) { + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = NegativeInfinity(); + } + continue; + } + + const int d = lane * 4; + const int64_t query_base = (row * kQwenNumHeads + warp) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.key_cache_capacity + block_index) * + kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key + key_base + d); + float dot = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + dot = topk::WarpReduceSum(dot); + if (lane == 0) { + head_scores[warp] = fmaxf(dot, 0.0f); + } + __syncthreads(); + if (threadIdx.x == 0) { + float score = 0.0f; +#pragma unroll + for (int head = 0; head < kQwenNumHeads; ++head) { + score += head_scores[head]; + } + const float scaled_score = score * params.scale; + const float stored_score = scaled_score == 0.0f ? 0.0f : scaled_score; + block_scores[row * params.max_block_count + block_index] = stored_score; + if (high_histogram != nullptr) { + const uint32_t score_key = static_cast(topk::PackStableSortKey(stored_score, 0) >> 32); + atomicAdd(high_histogram + row * 256 + (score_key >> 24), 1u); + } + } + __syncthreads(); + } +} + +template +__global__ void QsaScoreTileTopKKernel(const float* query_rotated, const T* present_key, + const int32_t* visible_count, uint64_t* tile_keys, + int tile_count, SparseAttentionIndexerParams params) { + __shared__ uint64_t keys[kHierarchicalTileBlocks]; + __shared__ float head_scores[kHierarchicalTileBlocks][kQwenNumHeads]; + + const int64_t total_tiles = + static_cast(params.batch_size) * params.sequence_length * tile_count; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int block_slot = warp / kQwenNumHeads; + const int head = warp % kQwenNumHeads; + for (int64_t work = blockIdx.x; work < total_tiles; work += gridDim.x) { + const int tile = static_cast(work % tile_count); + const int64_t row = work / tile_count; + const int batch = static_cast(row / params.sequence_length); + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / kQwenCompressRatio; + + const int block_index = tile * kHierarchicalTileBlocks + block_slot; + float score = 0.0f; + if (block_index < block_count) { + const int d = lane * 4; + const int64_t query_base = (row * kQwenNumHeads + head) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.key_cache_capacity + block_index) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key + key_base + d); + score = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + score = topk::WarpReduceSum(score); + } + if (lane == 0) { + head_scores[block_slot][head] = fmaxf(score, 0.0f); + } + __syncthreads(); + if (threadIdx.x < kHierarchicalTileBlocks) { + uint64_t key = topk::kPaddingSortKey; + if (block_index - block_slot + threadIdx.x < block_count) { + float total_score = 0.0f; +#pragma unroll + for (int query_head = 0; query_head < kQwenNumHeads; ++query_head) { + total_score += head_scores[threadIdx.x][query_head]; + } + const float scaled_score = total_score * params.scale; + key = topk::PackStableSortKey(scaled_score == 0.0f ? 0.0f : scaled_score, + block_index - block_slot + threadIdx.x); + } + keys[threadIdx.x] = key; + } + __syncthreads(); + if (threadIdx.x == 0) { + for (int index = 1; index < kHierarchicalTileBlocks; ++index) { + const uint64_t key = keys[index]; + int insertion = index; + while (insertion > 0 && key > keys[insertion - 1]) { + keys[insertion] = keys[insertion - 1]; + --insertion; + } + keys[insertion] = key; + } + } + __syncthreads(); + const int64_t output_base = work * kHierarchicalTileBlocks; + if (threadIdx.x < kHierarchicalTileBlocks) { + tile_keys[output_base + threadIdx.x] = keys[threadIdx.x]; + } + __syncthreads(); + } +} + +__device__ __forceinline__ uint64_t QsaMergeRank(const uint64_t* left, int left_count, + const uint64_t* right, int right_count, int rank) { + int low = max(0, rank - right_count); + int high = min(rank, left_count); + while (low <= high) { + const int left_rank = (low + high) / 2; + const int right_rank = rank - left_rank; + if (left_rank > 0 && right_rank < right_count && left[left_rank - 1] < right[right_rank]) { + high = left_rank - 1; + } else if (right_rank > 0 && left_rank < left_count && right[right_rank - 1] < left[left_rank]) { + low = left_rank + 1; + } else { + const uint64_t left_key = left_rank < left_count ? left[left_rank] : topk::kPaddingSortKey; + const uint64_t right_key = right_rank < right_count ? right[right_rank] : topk::kPaddingSortKey; + return max(left_key, right_key); + } + } + return topk::kPaddingSortKey; +} + +__global__ void QsaMergeTileTopKKernel(const uint64_t* input_keys, uint64_t* output_keys, + int key_stride, int list_width, + SparseAttentionIndexerParams params) { + const int pairs_per_row = (key_stride + 2 * list_width - 1) / (2 * list_width); + const int64_t total_pairs = + static_cast(params.batch_size) * params.sequence_length * pairs_per_row; + for (int64_t work = blockIdx.x; work < total_pairs; work += gridDim.x) { + const int pair = static_cast(work % pairs_per_row); + const int64_t row = work / pairs_per_row; + const int left_start = pair * 2 * list_width; + const int right_start = left_start + list_width; + const int left_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - left_start)); + const int right_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - right_start)); + const int output_count = min(left_count + right_count, kBoundedTopKMax); + const int64_t row_base = row * key_stride; + for (int rank = threadIdx.x; rank < output_count; rank += blockDim.x) { + output_keys[row_base + left_start + rank] = + QsaMergeRank(input_keys + row_base + left_start, left_count, + input_keys + row_base + right_start, right_count, rank); + } + } +} + +__global__ void QsaEmitHierarchicalTopKKernel(const uint64_t* tile_keys, + const int32_t* visible_count, + int key_stride, + int32_t* selected_indices, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + int32_t* output = selected_indices + row * params.capacity; + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + const int selected = min(params.block_topk, block_count); + for (int rank = threadIdx.x; rank < selected; rank += blockDim.x) { + const int selected_block = topk::UnpackStableSortIndex( + tile_keys[row * key_stride + rank]); + for (int token = 0; token < params.compress_ratio; ++token) { + output[rank * params.compress_ratio + token] = selected_block * params.compress_ratio + token; + } + } + const int tail_start = block_count * params.compress_ratio; + for (int token = threadIdx.x; token < visible - tail_start; token += blockDim.x) { + output[selected * params.compress_ratio + token] = tail_start + token; + } + for (int position = selected * params.compress_ratio + visible - tail_start + threadIdx.x; + position < params.capacity; position += blockDim.x) { + output[position] = -1; + } + } +} + +__global__ void QsaChooseRadixBucketKernel(const uint32_t* histogram, uint64_t* prefixes, + int32_t* remaining, int shift, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + if (threadIdx.x == 0) { + int rank = shift == 56 ? min(params.block_topk, params.max_block_count) : remaining[row]; + uint64_t prefix = shift == 56 ? 0u : prefixes[row]; + for (int bucket = 255; bucket >= 0; --bucket) { + const int count = static_cast(histogram[row * 256 + bucket]); + if (rank > count) { + rank -= count; + } else { + prefix |= static_cast(bucket) << shift; + prefixes[row] = prefix; + remaining[row] = rank; + break; + } + } + } + } +} + +__global__ void QsaCompactHighBucketKernel(const float* block_scores, const int32_t* visible_count, + const uint64_t* prefixes, int32_t* candidate_counts, + int32_t* candidate_indices, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + const int64_t total = rows * params.max_block_count; + for (int64_t work = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; work < total; + work += static_cast(gridDim.x) * blockDim.x) { + const int index = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + if (index < block_count) { + const float score = block_scores[row * params.max_block_count + index]; + const uint64_t key = topk::PackStableSortKey(score, index); + if ((key >> 56) >= (prefixes[row] >> 56)) { + const int slot = atomicAdd(candidate_counts + row, 1); + candidate_indices[row * params.max_block_count + slot] = index; + } + } + } +} + +__global__ void QsaCandidateHistogramKernel(const float* block_scores, const int32_t* candidate_counts, + const int32_t* candidate_indices, const uint64_t* prefixes, + uint32_t* histogram, int shift, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + const int64_t total = rows * params.max_block_count; + for (int64_t work = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; work < total; + work += static_cast(gridDim.x) * blockDim.x) { + const int slot = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + if (slot < candidate_counts[row]) { + const int index = candidate_indices[row * params.max_block_count + slot]; + const float score = block_scores[row * params.max_block_count + index]; + const uint64_t key = topk::PackStableSortKey(score, index); + if ((key >> (shift + 8)) == (prefixes[row] >> (shift + 8))) { + atomicAdd(histogram + row * 256 + ((key >> shift) & 0xffu), 1u); + } + } + } +} + +// Gathers the bounded winner set from the compacted high radix bucket, sorts it, and writes token +// indices directly. Stable score ties prefer lower block indices, matching the reference path. +__global__ void QsaDistributedSelectKernel(const float* block_scores, const int32_t* visible_count, + const int32_t* candidate_counts, + const int32_t* candidate_indices, + const uint64_t* prefixes, + int32_t* selected_indices, + SparseAttentionIndexerParams params) { + using Sort = cub::BlockRadixSort; + __shared__ typename Sort::TempStorage sort_temp; + __shared__ uint64_t selected_keys[kBoundedTopKMax]; + __shared__ int gathered; + + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + int32_t* output = selected_indices + row * params.capacity; + for (int position = threadIdx.x; position < params.capacity; position += blockDim.x) { + output[position] = -1; + } + if (threadIdx.x == 0) { + gathered = 0; + } + __syncthreads(); + + const uint64_t threshold = prefixes[row]; + const int candidate_count = candidate_counts[row]; + for (int slot = threadIdx.x; slot < candidate_count; slot += blockDim.x) { + const int index = candidate_indices[row * params.max_block_count + slot]; + const uint64_t key = topk::PackStableSortKey( + block_scores[row * params.max_block_count + index], index); + if (key >= threshold) { + const int output_slot = atomicAdd(&gathered, 1); + selected_keys[output_slot] = key; + } + } + __syncthreads(); + + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + const int selected = min(params.block_topk, block_count); + uint64_t keys[kBoundedTopKItemsPerThread]; +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; + } + Sort(sort_temp).SortDescending(keys); +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + if (rank < selected) { + const int selected_block = topk::UnpackStableSortIndex(keys[item]); + for (int token = 0; token < params.compress_ratio; ++token) { + output[rank * params.compress_ratio + token] = + selected_block * params.compress_ratio + token; + } + } + } + const int tail_start = block_count * params.compress_ratio; + for (int token = threadIdx.x; token < visible - tail_start; token += blockDim.x) { + output[selected * params.compress_ratio + token] = tail_start + token; } __syncthreads(); } @@ -285,11 +932,15 @@ __global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present // One block per query row. Emits the token indices of the highest scoring blocks followed by the // visible tokens of the trailing incomplete block. __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visible_indices, - const int32_t* visible_count, int32_t* selected_indices, + const int32_t* visible_count, const int32_t* topk_indices, + int32_t* selected_indices, SparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* shared_value = shared; - int* shared_index = reinterpret_cast(shared + blockDim.x); + const bool use_fast_topk = params.block_topk <= kSaiFastTopKMax; + const int shared_entries = use_fast_topk ? static_cast(blockDim.x) * params.block_topk + : static_cast(blockDim.x); + int* shared_index = reinterpret_cast(shared + shared_entries); const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { @@ -299,39 +950,200 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl } __syncthreads(); - const int visible = visible_count[row]; + const int encoded_visible = visible_count[row]; + const bool prefix_visible = encoded_visible < 0; + const int visible = prefix_visible ? -encoded_visible - 1 : encoded_visible; const int block_count = visible / params.compress_ratio; const int selected = min(params.block_topk, block_count); const float* scores_row = block_scores + row * params.max_block_count; const int32_t* index_row = visible_indices + row * params.total_sequence_length; - float previous_score = 0.0f; - int previous_index = -1; - int emitted = 0; - for (int rank = 0; rank < selected; ++rank) { - float best_value = 0.0f; - int best_index = -1; - ScanForNext(scores_row, block_count, previous_score, previous_index, &best_value, &best_index); - shared_value[threadIdx.x] = best_value; - shared_index[threadIdx.x] = best_index; - __syncthreads(); - BlockArgMax(shared_value, shared_index); - previous_index = shared_index[0]; - previous_score = shared_value[0]; - __syncthreads(); - if (previous_index < 0) { - break; + int emitted = selected; + if (topk_indices != nullptr) { + const int32_t* topk_row = topk_indices + row * params.block_topk; + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = static_cast(topk_row[rank]); + for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { + out_row[rank * params.compress_ratio + t] = + prefix_visible ? selected_block * params.compress_ratio + t + : index_row[selected_block * params.compress_ratio + t]; + } } - for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { - out_row[rank * params.compress_ratio + t] = index_row[previous_index * params.compress_ratio + t]; + __syncthreads(); + } else if (selected > 0 && use_fast_topk) { + SaiBlockTopK(scores_row, block_count, selected, shared_value, shared_index); + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = shared_index[rank]; + for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { + out_row[rank * params.compress_ratio + t] = + prefix_visible ? selected_block * params.compress_ratio + t + : index_row[selected_block * params.compress_ratio + t]; + } } - emitted = rank + 1; __syncthreads(); + } else { + float previous_score = 0.0f; + int previous_index = -1; + emitted = 0; + for (int rank = 0; rank < selected; ++rank) { + float best_value = 0.0f; + int best_index = -1; + ScanForNext(scores_row, block_count, previous_score, previous_index, &best_value, &best_index); + shared_value[threadIdx.x] = best_value; + shared_index[threadIdx.x] = best_index; + __syncthreads(); + BlockArgMax(shared_value, shared_index); + previous_index = shared_index[0]; + previous_score = shared_value[0]; + __syncthreads(); + if (previous_index < 0) { + break; + } + for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { + out_row[rank * params.compress_ratio + t] = + prefix_visible ? previous_index * params.compress_ratio + t + : index_row[previous_index * params.compress_ratio + t]; + } + emitted = rank + 1; + __syncthreads(); + } } const int tail_start = block_count * params.compress_ratio; for (int t = threadIdx.x; t < visible - tail_start; t += blockDim.x) { - out_row[emitted * params.compress_ratio + t] = index_row[tail_start + t]; + out_row[emitted * params.compress_ratio + t] = + prefix_visible ? tail_start + t : index_row[tail_start + t]; + } + __syncthreads(); + } +} + +// One block per query row. Four radix passes find the TopK score threshold, then the winners are +// gathered with stable ties and only that bounded set is sorted by score and index. +__global__ void QsaPartialTopKKernel(const float* block_scores, const int32_t* visible_count, + int32_t* topk_indices, SparseAttentionIndexerParams params) { + using Sort = cub::BlockRadixSort; + __shared__ union { + uint32_t histogram[256]; + typename Sort::TempStorage sort; + } temp; + __shared__ uint64_t selected_keys[kBoundedTopKMax]; + __shared__ int32_t equal_warp_offsets[kThreads / kWarpSize + 1]; + __shared__ uint32_t prefix; + __shared__ int remaining; + __shared__ int gathered; + __shared__ int equal_seen; + + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + const int selected = min(params.block_topk, block_count); + if (selected == 0) { + continue; + } + const float* scores = block_scores + row * params.max_block_count; + + if (threadIdx.x == 0) { + prefix = 0; + remaining = selected; + } + __syncthreads(); + + for (int shift = 24; shift >= 0; shift -= 8) { + for (int bucket = threadIdx.x; bucket < 256; bucket += blockDim.x) { + temp.histogram[bucket] = 0; + } + __syncthreads(); + const uint32_t current_prefix = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint32_t score_key = static_cast(topk::PackStableSortKey(scores[index], 0) >> 32); + if (shift == 24 || (score_key >> (shift + 8)) == (current_prefix >> (shift + 8))) { + atomicAdd(&temp.histogram[(score_key >> shift) & 0xffu], 1u); + } + } + __syncthreads(); + if (threadIdx.x == 0) { + int rank = remaining; + for (int bucket = 255; bucket >= 0; --bucket) { + const int count = static_cast(temp.histogram[bucket]); + if (rank > count) { + rank -= count; + } else { + prefix |= static_cast(bucket) << shift; + remaining = rank; + break; + } + } + } + __syncthreads(); + } + + if (threadIdx.x == 0) { + gathered = 0; + } + __syncthreads(); + const uint32_t threshold = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint64_t key = topk::PackStableSortKey(scores[index], index); + if (static_cast(key >> 32) > threshold) { + const int slot = atomicAdd(&gathered, 1); + selected_keys[slot] = key; + } + } + __syncthreads(); + + if (threadIdx.x == 0) { + equal_seen = 0; + } + __syncthreads(); + for (int base = 0; base < block_count && equal_seen < remaining; base += blockDim.x) { + const int index = base + static_cast(threadIdx.x); + const bool equal = index < block_count && + static_cast(topk::PackStableSortKey(scores[index], 0) >> 32) == threshold; + const unsigned int warp_mask = __ballot_sync(0xffffffffu, equal); + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); + if (lane == 0) { + equal_warp_offsets[warp] = __popc(warp_mask); + } + __syncthreads(); + if (threadIdx.x == 0) { + int tile_total = 0; + for (int i = 0; i < kThreads / kWarpSize; ++i) { + const int warp_count = equal_warp_offsets[i]; + equal_warp_offsets[i] = tile_total; + tile_total += warp_count; + } + equal_warp_offsets[kThreads / kWarpSize] = tile_total; + } + __syncthreads(); + const int equal_rank = equal_seen + equal_warp_offsets[warp] + lane_rank; + if (equal && equal_rank < remaining) { + selected_keys[gathered + equal_rank] = topk::PackStableSortKey(scores[index], index); + } + __syncthreads(); + if (threadIdx.x == 0) { + equal_seen += equal_warp_offsets[kThreads / kWarpSize]; + } + __syncthreads(); + } + + uint64_t keys[kBoundedTopKItemsPerThread]; +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; + } + Sort(temp.sort).SortDescending(keys); +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + if (rank < selected) { + topk_indices[row * params.block_topk + rank] = topk::UnpackStableSortIndex(keys[item]); + } } __syncthreads(); } @@ -433,7 +1245,7 @@ __global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_ const int64_t entry = static_cast(params.past_compressed_length) + window; const int position = ClampPosition(entry * params.compress_ratio, params.max_rotary_length); const int64_t cache_offset = - (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; const int64_t out_base = (static_cast(batch) * params.compressed_cache_capacity + entry) * params.head_size; const int64_t workspace_base = @@ -576,12 +1388,23 @@ __global__ void CsaSelectKernel(const float* scores, const int64_t* position_ids size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { const size_t rows = static_cast(params.batch_size) * params.sequence_length; - return rows * params.num_heads * params.head_size + rows * std::max(params.max_block_count, 1); + const size_t query_count = rows * params.num_heads * params.head_size; + return UseHierarchicalQsaTopK(params, false) + ? query_count + : query_count + rows * std::max(params.max_block_count, 1); } -size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params) { +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params, bool has_mask) { const size_t rows = static_cast(params.batch_size) * params.sequence_length; - return rows * std::max(params.total_sequence_length, 1) + rows; + if (UseHierarchicalQsaTopK(params, has_mask)) { + const size_t key_count = rows * GetHierarchicalTileCount(params) * kHierarchicalTileBlocks; + return rows + 1 + 4 * key_count; + } + if (UseDistributedQsaTopK(params, has_mask)) { + return rows + rows * std::max(params.max_block_count, 1) + rows * 256 + rows * 4 + 1; + } + return (has_mask ? rows * std::max(params.total_sequence_length, 1) : 0) + rows + + rows * std::min(params.block_topk, kBoundedTopKMax); } size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { @@ -608,10 +1431,26 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio CopyQsaPastKeyKernel<<>>( past_key, present_key, params); } - const int64_t new_key_elements = - static_cast(params.batch_size) * params.sequence_length * params.head_size; - if (new_key_elements > 0) { - AppendQsaKeyKernel<<>>(key, present_key, params); + if (params.use_block_representatives) { + const int first_block = params.past_sequence_length / kQwenCompressRatio; + const int completed_blocks = params.total_sequence_length / kQwenCompressRatio; + const int64_t new_blocks = static_cast(params.batch_size) * (completed_blocks - first_block); + if (new_blocks > 0) { + QsaBuildBlockRepresentativesKernel<<>>( + past_key, key, present_key, key_norm_weight, cos_cache, sin_cache, params); + } + const int64_t new_key_elements = + static_cast(params.batch_size) * params.sequence_length * params.head_size; + if (new_key_elements > 0) { + AppendQsaKeyTailKernel<<>>( + key, present_key, params); + } + } else { + const int64_t new_key_elements = + static_cast(params.batch_size) * params.sequence_length * params.head_size; + if (new_key_elements > 0) { + AppendQsaKeyKernel<<>>(key, present_key, params); + } } if (rows == 0) { @@ -620,8 +1459,32 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio float* query_rotated = float_workspace; float* block_scores = query_rotated + rows * params.num_heads * params.head_size; - int32_t* visible_indices = int_workspace; - int32_t* visible_count = int_workspace + rows * params.total_sequence_length; + int32_t* visible_indices = mask == nullptr ? nullptr : int_workspace; + int32_t* visible_count = mask == nullptr ? int_workspace : int_workspace + rows * params.total_sequence_length; + int32_t* topk_indices = visible_count + rows; + const bool use_hierarchical_topk = UseHierarchicalQsaTopK(params, mask != nullptr); + const bool use_distributed_topk = UseDistributedQsaTopK(params, mask != nullptr); + uint64_t* hierarchical_keys = use_hierarchical_topk + ? reinterpret_cast((reinterpret_cast(topk_indices) + + alignof(uint64_t) - 1) & + ~(alignof(uint64_t) - 1)) + : nullptr; + const size_t hierarchical_key_count = use_hierarchical_topk + ? static_cast(rows) * GetHierarchicalTileCount(params) * + kHierarchicalTileBlocks + : 0; + uint64_t* hierarchical_scratch = use_hierarchical_topk ? hierarchical_keys + hierarchical_key_count : nullptr; + int32_t* candidate_indices = use_distributed_topk && !use_hierarchical_topk ? topk_indices : nullptr; + uint32_t* radix_histogram = use_distributed_topk && !use_hierarchical_topk + ? reinterpret_cast(candidate_indices + rows * params.max_block_count) + : nullptr; + uint64_t* radix_prefixes = use_distributed_topk && !use_hierarchical_topk + ? reinterpret_cast((reinterpret_cast(radix_histogram + rows * 256) + + alignof(uint64_t) - 1) & + ~(alignof(uint64_t) - 1)) + : nullptr; + int32_t* radix_remaining = radix_prefixes ? reinterpret_cast(radix_prefixes + rows) : nullptr; + int32_t* candidate_counts = radix_remaining ? radix_remaining + rows : nullptr; const size_t value_bytes = static_cast(params.head_size) * sizeof(float); const size_t rotate_shared_bytes = value_bytes + kThreads * sizeof(float); @@ -630,8 +1493,13 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio ConfigureDynamicSharedMemory(RotateQueryKernel, rotate_shared_bytes, "RotateQueryKernel")); const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); - CompactVisibleKernel<<>>( - mask, visible_indices, visible_count, params); + if (mask == nullptr) { + InitializePrefixVisibleCountKernel<<>>(visible_count, params); + } else { + AnalyzeVisibleKernel<<>>(mask, visible_count, params); + CompactNonPrefixVisibleKernel<<>>( + mask, visible_indices, visible_count, params); + } const int64_t query_rows = rows * params.num_heads; const int rotate_blocks = static_cast(std::min(query_rows, kMaxGridDimX)); @@ -639,17 +1507,84 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio query, query_norm_weight, cos_cache, sin_cache, nullptr, query_rotated, params); if (params.max_block_count > 0) { - ORT_RETURN_IF_ERROR( - ConfigureDynamicSharedMemory(QsaBlockScoreKernel, score_shared_bytes, "QsaBlockScoreKernel")); - const int64_t block_work = rows * params.max_block_count; - const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); - QsaBlockScoreKernel<<>>( - query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, - block_scores, params); + if (use_distributed_topk && !use_hierarchical_topk) { + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(radix_histogram, 0, rows * 256 * sizeof(uint32_t), stream)); + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(candidate_counts, 0, rows * sizeof(int32_t), stream)); + } + if (use_hierarchical_topk) { + const int tile_count = GetHierarchicalTileCount(params); + const int64_t tile_work = rows * tile_count; + const int tile_blocks = static_cast(std::min(tile_work, kMaxGridDimX)); + QsaScoreTileTopKKernel<<>>( + query_rotated, present_key, visible_count, hierarchical_keys, tile_count, params); + } else { + const int64_t block_work = rows * params.max_block_count; + const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); + if (params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads) { + if (params.use_block_representatives) { + QsaBlockScoreQwenCachedKernel<<>>( + query_rotated, present_key, visible_count, block_scores, radix_histogram, params); + } else { + QsaBlockScoreQwenKernel<<>>( + query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, + block_scores, radix_histogram, params); + } + } else { + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(QsaBlockScoreKernel, score_shared_bytes, "QsaBlockScoreKernel")); + QsaBlockScoreKernel<<>>( + query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, + block_scores, params); + } + } } - QsaSelectKernel<<>>( - block_scores, visible_indices, visible_count, selected_indices, params); + const bool use_bounded_topk = + params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; + if (use_hierarchical_topk) { + const int block_count = GetHierarchicalTileCount(params) * kHierarchicalTileBlocks; + uint64_t* merge_input = hierarchical_keys; + uint64_t* merge_output = hierarchical_scratch; + for (int list_width = kHierarchicalTileBlocks; list_width < block_count; list_width *= 2) { + const int pairs_per_row = (block_count + 2 * list_width - 1) / (2 * list_width); + const int64_t merge_work = rows * pairs_per_row; + QsaMergeTileTopKKernel<<(std::min(merge_work, kMaxGridDimX)), + kHierarchicalMergeThreads, 0, stream>>>( + merge_input, merge_output, block_count, list_width, params); + std::swap(merge_input, merge_output); + } + QsaEmitHierarchicalTopKKernel<<>>( + merge_input, visible_count, block_count, selected_indices, params); + } else if (use_distributed_topk) { + QsaChooseRadixBucketKernel<<>>( + radix_histogram, radix_prefixes, radix_remaining, 56, params); + QsaCompactHighBucketKernel<<>>( + block_scores, visible_count, radix_prefixes, candidate_counts, candidate_indices, params); + for (int shift = 48; shift >= 0; shift -= 8) { + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(radix_histogram, 0, rows * 256 * sizeof(uint32_t), stream)); + QsaCandidateHistogramKernel<<>>( + block_scores, candidate_counts, candidate_indices, radix_prefixes, radix_histogram, shift, params); + QsaChooseRadixBucketKernel<<>>( + radix_histogram, radix_prefixes, radix_remaining, shift, params); + } + QsaDistributedSelectKernel<<>>( + block_scores, visible_count, candidate_counts, candidate_indices, radix_prefixes, + selected_indices, params); + } else if (use_bounded_topk) { + QsaPartialTopKKernel<<>>( + block_scores, visible_count, topk_indices, params); + } + + const int topk_shared_entries = !use_bounded_topk && params.block_topk <= kSaiFastTopKMax + ? kThreads * params.block_topk + : kThreads; + if (!use_distributed_topk) { + QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( + block_scores, visible_indices, visible_count, use_bounded_topk ? topk_indices : nullptr, + selected_indices, params); + } return CUDA_CALL(cudaGetLastError()); } diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index f245303ccec7d..f4f5ab7b5dfbb 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -20,8 +20,9 @@ struct SparseAttentionIndexerParams { int head_size = 0; int query_row_stride = 0; int key_row_stride = 0; - int rotary_width = 0; // cos_cache.shape[2] - int max_rotary_length = 0; // cos_cache.shape[1] + int rotary_width = 0; + int max_rotary_length = 0; + int rotary_cache_batch_stride = 0; int compress_ratio = 0; int capacity = 0; // selected_indices.shape[2] float epsilon = 1e-6f; @@ -34,6 +35,7 @@ struct SparseAttentionIndexerParams { int key_cache_capacity = 0; int max_block_count = 0; // total_sequence_length / compress_ratio int block_topk = 0; // token_budget / compress_ratio + bool use_block_representatives = false; // policy_mode = "csa" int past_compressed_length = 0; @@ -51,7 +53,7 @@ struct SparseAttentionIndexerParams { // Scratch requirements, in elements. size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); -size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params); +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params, bool has_mask); size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); template diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index cca50c4a3aadd..e147d806b2666 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -159,12 +159,25 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& norm = shader.AddInput("key_norm_weight", ShaderUsage::UseUniform); const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& sequence_metadata = shader.AddInput("sequence_metadata", ShaderUsage::UseUniform); + const ShaderVariableHelper* capture_count = nullptr; + const ShaderVariableHelper* state_update_active = nullptr; + if (capture_state_update_) { + capture_count = &shader.AddInput("state_update_capture_count", ShaderUsage::UseUniform); + if (has_state_update_active_) { + state_update_active = &shader.AddInput("state_update_active", ShaderUsage::UseUniform); + } + } const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_kv_buffer = shader.AddOutput("present_kv_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); + const ShaderVariableHelper* state_update = nullptr; + if (capture_state_update_) { + state_update = &shader.AddOutput( + "state_update", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + } const ShaderVariableHelper& kv_buffer_history = present_kv_buffer; const ShaderVariableHelper& state_lengths_history = present_state_lengths; @@ -174,7 +187,8 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * uniforms.head_size + d;\n" << " return f32(" << kv_buffer_history.GetByOffset("idx") << ");\n" << " }\n" - << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * uniforms.head_size + d;\n" + << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * uniforms.key_row_stride + " + "uniforms.key_offset + d;\n" << " return f32(" << key.GetByOffset("idx2") << ");\n" << "}\n" << "fn pooled(b: u32, k: i32, old_buf_len: i32, req_start: i32, d: u32) -> f32 {\n" @@ -253,15 +267,54 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe << " let new_block_count = full_new_block_count;\n" << " let new_buf_len = pending % i32(uniforms.compress_ratio);\n" << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len + new_block_count") << "\n" - << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "new_buf_len") << "\n" + << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "new_buf_len") << "\n"; + if (capture_state_update_) { + shader.MainFunctionBody() + << " let capture_is_active = " + << (has_state_update_active_ ? state_update_active->GetByOffset("0") + " != 0" : "true") << ";\n" + << " let capture_tokens = select(0, min(max(" << capture_count->GetByOffset("b") + << ", 0), min(req_len, i32(uniforms.state_update_capacity))), capture_is_active);\n"; + } else { + shader.MainFunctionBody() << " let capture_tokens = 0;\n"; + } + shader.MainFunctionBody() << " for (var k = 0; k < new_block_count; k++) {\n" << " let entry = u32(old_key_len + k);\n" + << " let completion_token = (k + 1) * i32(uniforms.compress_ratio) - old_buf_len - 1;\n" << " for (var d = 0u; d < uniforms.head_size; d++) {\n" << " let value = rotated(b, k, old_buf_len, req_start, old_key_len, d);\n" << " " << present_key_state.SetByOffset("(b * uniforms.state_capacity + entry) * uniforms.head_size + d", "present_key_state_element_t(value)") - << "\n" + << "\n"; + if (capture_state_update_) { + shader.MainFunctionBody() + << " if (completion_token < capture_tokens) {\n" + << " " + << state_update->SetByOffset( + "(b * uniforms.state_update_capacity + u32(completion_token)) * uniforms.head_size + d", + "state_update_element_t(value)") + << "\n" + << " }\n"; + } + shader.MainFunctionBody() + << " }\n" + << " }\n" + << " for (var t = 0; t < capture_tokens; t++) {\n" + << " if ((old_buf_len + t + 1) % i32(uniforms.compress_ratio) != 0) {\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n"; + if (capture_state_update_) { + shader.MainFunctionBody() + << " let value = " + << key.GetByOffset("u32(req_start + t) * uniforms.key_row_stride + uniforms.key_offset + d") << ";\n" + << " " + << state_update->SetByOffset( + "(b * uniforms.state_update_capacity + u32(t)) * uniforms.head_size + d", + "state_update_element_t(value)") + << "\n"; + } + shader.MainFunctionBody() + << " }\n" << " }\n" << " }\n" << " for (var t = 0; t < new_buf_len; t++) {\n" @@ -277,6 +330,60 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe return Status::OK(); } +Status PackedSparseAttentionIndexerQsaCaptureProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& key = shader.AddInput("key", ShaderUsage::UseUniform); + const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); + const auto& capture_count = shader.AddInput("state_update_capture_count", ShaderUsage::UseUniform); + const auto& present_key_state = + shader.AddInput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); + const ShaderVariableHelper* state_update_active = nullptr; + if (has_state_update_active_) { + state_update_active = &shader.AddInput("state_update_active", ShaderUsage::UseUniform); + } + const auto& state_update = + shader.AddOutput("state_update", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + + shader.MainFunctionBody() + << " let b = workgroup_idx;\n" + << " if (b >= uniforms.batch_size || local_idx != 0u || " << overflow_flags.GetByOffset("b") + << " != 0) { return; }\n"; + if (has_state_update_active_) { + shader.MainFunctionBody() + << " if (" << state_update_active->GetByOffset("0") << " == 0) { return; }\n"; + } + shader.MainFunctionBody() + << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" + << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" + << " let req_len = max(req_end - req_start, 0);\n" + << " let past_len = max(" << past_seqlens.GetByOffset("b") << ", 0);\n" + << " let old_key_len = past_len / i32(uniforms.compress_ratio);\n" + << " let old_buf_len = past_len % i32(uniforms.compress_ratio);\n" + << " let capture_tokens = min(max(" << capture_count.GetByOffset("b") + << ", 0), min(req_len, i32(uniforms.state_update_capacity)));\n" + << " for (var t = 0; t < capture_tokens; t++) {\n" + << " let completes_block = (old_buf_len + t + 1) % i32(uniforms.compress_ratio) == 0;\n" + << " let completed_entry = old_key_len + (old_buf_len + t + 1) / i32(uniforms.compress_ratio) - 1;\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " var value = f32(" + << key.GetByOffset("u32(req_start + t) * uniforms.key_row_stride + uniforms.key_offset + d") << ");\n" + << " if (completes_block && completed_entry >= 0 && completed_entry < i32(uniforms.state_capacity)) {\n" + << " value = f32(" + << present_key_state.GetByOffset( + "(b * uniforms.state_capacity + u32(completed_entry)) * uniforms.head_size + d") + << ");\n" + << " }\n" + << " " + << state_update.SetByOffset( + "(b * uniforms.state_update_capacity + u32(t)) * uniforms.head_size + d", + "state_update_element_t(value)") + << "\n" + << " }\n" + << " }\n"; + return Status::OK(); +} + Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& query_metadata = shader.AddInput("query_metadata", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); @@ -311,7 +418,7 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << " + (i32(token) - req_start);\n" << "}\n" << "fn normalized_query_value(token: u32, head: u32, d: u32) -> f32 {\n" - << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" + << " let base = token * uniforms.query_row_stride + head * uniforms.head_size;\n" << " var square_sum = 0.0;\n" << " for (var k = 0u; k < uniforms.head_size; k++) {\n" << " let value = f32(" << query_metadata.GetByOffset("base + k") << ");\n" @@ -319,11 +426,11 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << " }\n" << " return f32(" << query_metadata.GetByOffset("base + d") << ") * inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" - << query_metadata.GetByOffset("uniforms.total_tokens * uniforms.num_heads * uniforms.head_size + d") + << query_metadata.GetByOffset("uniforms.query_norm_offset + d") << ");\n" << "}\n" << "fn query_value(token: u32, head: u32, d: u32, position: i32, b: u32) -> f32 {\n" - << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" + << " let base = token * uniforms.query_row_stride + head * uniforms.head_size;\n" << " var value = normalized_query_value(token, head, d);\n" << " if (d >= uniforms.rotary_width) { return value; }\n" << " let half = uniforms.rotary_width / 2u;\n" @@ -760,6 +867,9 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& i "no greater than INT_MAX"); ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity_).IsOK() && state_capacity_ > 0, "PackedSparseAttentionIndexer: state_capacity must be a positive integer"); + state_update_capacity_ = info.GetAttrOrDefault("state_update_capacity", 0); + ORT_ENFORCE(state_update_capacity_ >= 0 && state_update_capacity_ <= 8, + "PackedSparseAttentionIndexer: state_update_capacity must be in [0, 8]"); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); @@ -772,6 +882,8 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& i "PackedSparseAttentionIndexer: csa attributes must be omitted for qsa"); index_topk_ = 0; } else { + ORT_ENFORCE(state_update_capacity_ == 0, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); ORT_ENFORCE(has_index_topk && index_topk_ > 0, "PackedSparseAttentionIndexer: index_topk must be > 0 for csa"); ORT_ENFORCE(!has_token_budget, "PackedSparseAttentionIndexer: token_budget must be omitted for csa"); token_budget_ = 0; @@ -798,6 +910,16 @@ Status PackedSparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::Comput ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: past_gate_buffer ", gate_buffer_provided ? "must be omitted for policy_mode 'qsa'" : "is required for policy_mode 'csa'"); + if (!is_qsa) { + for (int index : {psai::kStateUpdateCaptureCount, psai::kStateUpdateActive}) { + ORT_RETURN_IF(index < context.InputCount() && context.Input(index) != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } + Tensor* state_update = + context.OutputCount() > psai::kStateUpdate ? context.Output(psai::kStateUpdate, TensorShape({0})) : nullptr; + ORT_RETURN_IF(state_update != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } return is_qsa ? ComputeQsa(context) : ComputeCsa(context); } @@ -813,6 +935,14 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont const Tensor* past_key_state = context.Input(psai::kPastKeyState); const Tensor* past_kv_buffer = context.Input(psai::kPastKvBuffer); const Tensor* past_state_lengths = context.Input(psai::kPastStateLengths); + const Tensor* state_update_capture_count = + context.InputCount() > psai::kStateUpdateCaptureCount ? context.Input(psai::kStateUpdateCaptureCount) : nullptr; + const Tensor* state_update_active = + context.InputCount() > psai::kStateUpdateActive ? context.Input(psai::kStateUpdateActive) : nullptr; + + ORT_RETURN_IF(state_update_capacity_ > 0 && state_update_capture_count == nullptr, + "PackedSparseAttentionIndexer: state_update_capture_count is required when " + "state_update_capacity is positive"); ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); @@ -821,10 +951,13 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF(query_norm == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); const auto& query_norm_shape = query_norm->Shape(); ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0 && - query_shape[1] % query_norm_shape[0] == 0, + (query_shape[1] - (key != nullptr ? 0 : query_norm_shape[0])) > 0 && + (query_shape[1] - (key != nullptr ? 0 : query_norm_shape[0])) % query_norm_shape[0] == 0, "PackedSparseAttentionIndexer: invalid flattened query dimensions"); const int64_t head_size = query_norm_shape[0]; - const int64_t num_heads = query_shape[1] / head_size; + const bool has_separate_key = key != nullptr; + const int64_t query_width = query_shape[1] - (has_separate_key ? 0 : head_size); + const int64_t num_heads = query_width / head_size; ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "PackedSparseAttentionIndexer: invalid query dimensions"); ORT_RETURN_IF(cu_seqlens == nullptr, "PackedSparseAttentionIndexer: cumulative_sequence_lengths is required"); @@ -836,7 +969,9 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); - ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + if (has_separate_key) { + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + } ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); @@ -858,6 +993,12 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", {batch_size, psai::kStateLengthColumns})); + if (state_update_capture_count != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_capture_count, "state_update_capture_count", {batch_size})); + } + if (state_update_active != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_active, "state_update_active", {1})); + } Tensor rotary_cache = context.CreateGPUTensor(cos_cache->DataType(), TensorShape({2 * cos_cache->Shape().Size()})); @@ -875,6 +1016,13 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont context.Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, head_size})); Tensor* present_state_lengths = context.Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + Tensor* state_update = context.OutputCount() > psai::kStateUpdate + ? context.Output(psai::kStateUpdate, + TensorShape({batch_size, state_update_capacity_, head_size})) + : nullptr; + if (state_update != nullptr) { + context.FillZero(*state_update); + } Tensor overflow_flags = context.CreateGPUTensor(DataTypeImpl::GetType(), TensorShape({batch_size})); if (present_key_state->DataRaw() != past_key_state->DataRaw()) { @@ -926,32 +1074,63 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(PackTwoTensors(context, *query, *query_norm, query_metadata)); if (batch_size > 0) { + const Tensor* key_source = has_separate_key ? key : query; ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( context, "QSA update", - {key, norm, &rotary_cache, &sequence_metadata, present_key_state, present_kv_buffer, present_state_lengths, - &overflow_flags})); - PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; - update.CacheHint(rotary.batched) + {key_source, norm, &rotary_cache, &sequence_metadata, present_key_state, present_kv_buffer, + present_state_lengths, &overflow_flags})); + PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched, false, false}; + update.CacheHint(rotary.batched, false, false) .SetWorkgroupSize(kWorkgroupSize) - .AddInputs({{key, ProgramTensorMetadataDependency::Type}, + .AddInputs({{key_source, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, {&sequence_metadata, ProgramTensorMetadataDependency::Type}}); update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) - .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}) + .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}); + update .SetDispatchGroupSize(ToUint32(batch_size)) .AddUniformVariables({{ToUint32(batch_size)}, {ToUint32(total_tokens)}, + {ToUint32(has_separate_key ? head_size : query_shape[1])}, + {ToUint32(has_separate_key ? 0 : query_width)}, {ToUint32(compress_ratio_)}, {ToUint32(state_capacity)}, {ToUint32(buffer_capacity)}, {ToUint32(head_size)}, {ToUint32(rotary.rotary_width)}, {ToUint32(rotary.max_rotary_length)}, + {ToUint32(state_update_capacity_)}, {epsilon_}}); ORT_RETURN_IF_ERROR(context.RunProgram(update)); + + if (state_update != nullptr && state_update_capacity_ > 0) { + PackedSparseAttentionIndexerQsaCaptureProgram capture{state_update_active != nullptr}; + capture.CacheHint(state_update_active != nullptr) + .SetWorkgroupSize(kWorkgroupSize) + .AddInputs({{key_source, ProgramTensorMetadataDependency::Type}, + {cu_seqlens, ProgramTensorMetadataDependency::Type}, + {past_seqlens, ProgramTensorMetadataDependency::Type}, + {state_update_capture_count, ProgramTensorMetadataDependency::Type}, + {present_key_state, ProgramTensorMetadataDependency::Type}, + {&overflow_flags, ProgramTensorMetadataDependency::Type}}); + if (state_update_active != nullptr) { + capture.AddInput({state_update_active, ProgramTensorMetadataDependency::Type}); + } + capture.AddOutput({state_update, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32(batch_size)) + .AddUniformVariables({{ToUint32(batch_size)}, + {ToUint32(total_tokens)}, + {ToUint32(has_separate_key ? head_size : query_shape[1])}, + {ToUint32(has_separate_key ? 0 : query_width)}, + {ToUint32(compress_ratio_)}, + {ToUint32(state_capacity)}, + {ToUint32(state_update_capacity_)}, + {ToUint32(head_size)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(capture)); + } } ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( @@ -974,6 +1153,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {ToUint32(batch_size)}, {ToUint32(num_heads)}, {ToUint32(head_size)}, + {ToUint32(query_shape[1])}, + {ToUint32(query_shape.Size())}, {ToUint32(rotary.rotary_width)}, {ToUint32(rotary.max_rotary_length)}, {ToUint32(compress_ratio_)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index bb75938a81ff2..69f590b73a2bd 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -32,23 +32,31 @@ class PackedSparseAttentionIndexerCopyProgram final class PackedSparseAttentionIndexerQsaUpdateProgram final : public Program { public: - explicit PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched) + PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched, bool capture_state_update, + bool has_state_update_active) : Program{"PackedSparseAttentionIndexerQsaUpdate"}, - cos_cache_batched_{cos_cache_batched} {} + cos_cache_batched_{cos_cache_batched}, + capture_state_update_{capture_state_update}, + has_state_update_active_{has_state_update_active} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, {"total_tokens", ProgramUniformVariableDataType::Uint32}, + {"key_row_stride", ProgramUniformVariableDataType::Uint32}, + {"key_offset", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"state_capacity", ProgramUniformVariableDataType::Uint32}, {"buffer_capacity", ProgramUniformVariableDataType::Uint32}, {"head_size", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"state_update_capacity", ProgramUniformVariableDataType::Uint32}, {"epsilon", ProgramUniformVariableDataType::Float32}); private: bool cos_cache_batched_; + bool capture_state_update_; + bool has_state_update_active_; }; // One invocation per query token: rotates the query, scores it against every causally visible @@ -66,6 +74,8 @@ class PackedSparseAttentionIndexerQsaSelectProgram final {"batch_size", ProgramUniformVariableDataType::Uint32}, {"num_heads", ProgramUniformVariableDataType::Uint32}, {"head_size", ProgramUniformVariableDataType::Uint32}, + {"query_row_stride", ProgramUniformVariableDataType::Uint32}, + {"query_norm_offset", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, @@ -103,6 +113,27 @@ class PackedSparseAttentionIndexerCsaUpdateProgram final bool cos_cache_batched_; }; +class PackedSparseAttentionIndexerQsaCaptureProgram final + : public Program { + public: + explicit PackedSparseAttentionIndexerQsaCaptureProgram(bool has_state_update_active) + : Program{"PackedSparseAttentionIndexerQsaCapture"}, + has_state_update_active_{has_state_update_active} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"total_tokens", ProgramUniformVariableDataType::Uint32}, + {"key_row_stride", ProgramUniformVariableDataType::Uint32}, + {"key_offset", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"state_capacity", ProgramUniformVariableDataType::Uint32}, + {"state_update_capacity", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}); + + private: + bool has_state_update_active_; +}; + // One invocation per query token: rotates the query, scores it against every causally visible // compressed key_state entry, and selects the index_topk highest scoring entries. class PackedSparseAttentionIndexerCsaSelectProgram final @@ -143,6 +174,7 @@ class PackedSparseAttentionIndexer final : public WebGpuKernel { packed_sparse_attention_indexer::Policy policy_; int64_t compress_ratio_; int64_t state_capacity_; + int64_t state_update_capacity_; int64_t token_budget_; int64_t index_topk_; float epsilon_; diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 39ee7857def52..493dddc586972 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -49,6 +49,30 @@ Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list< return Status::OK(); } +struct RotaryCacheShape { + bool batched; + int64_t max_rotary_length; + int64_t rotary_width; +}; + +Status CheckRotaryCache(const Tensor* cos_cache, const Tensor* sin_cache, int64_t batch_size, + RotaryCacheShape& out) { + ORT_RETURN_IF(cos_cache == nullptr, "SparseAttentionIndexer: cos_cache is required"); + const auto& cos_shape = cos_cache->Shape(); + out.batched = cos_shape.NumDimensions() == 3; + ORT_RETURN_IF_NOT( + (out.batched && cos_shape[0] == batch_size && cos_shape[1] > 0) || + (cos_shape.NumDimensions() == 2 && cos_shape[0] > 0), + "SparseAttentionIndexer: cos_cache must have shape (max_rotary_sequence_length, rotary_width) or " + "(batch_size, max_rotary_sequence_length, rotary_width), got ", + cos_shape.ToString()); + out.max_rotary_length = out.batched ? cos_shape[1] : cos_shape[0]; + out.rotary_width = out.batched ? cos_shape[2] : cos_shape[1]; + ORT_RETURN_IF_NOT(sin_cache != nullptr && sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as cos_cache"); + return Status::OK(); +} + Status CheckUint32(std::initializer_list> values) { for (const auto& [value, name] : values) { ORT_RETURN_IF(value < 0 || static_cast(value) > std::numeric_limits::max(), @@ -129,17 +153,24 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& const auto& key_norm = shader.AddInput("key_norm_weight", ShaderUsage::UseUniform); const auto& cos_cache = shader.AddInput("cos_cache", ShaderUsage::UseUniform); const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); - const auto& mask = shader.AddInput("mask", ShaderUsage::UseUniform); const auto& selected = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); shader.AdditionalImplementation() << "fn visible(row: u32, token: u32) -> bool {\n" - << " let batch = row / uniforms.sequence_length;\n" - << " let query = row % uniforms.sequence_length;\n" - << " let offset = batch * uniforms.total_sequence_length + token;\n" - << " let mask_value = " << mask.GetByOffset("offset", true) << ";\n" - << " return token <= uniforms.past_sequence_length + query && " - "(mask_value.x != 0u || mask_value.y != 0u);\n" + << " let query = row % uniforms.sequence_length;\n"; + if (has_mask_) { + const auto& mask = shader.AddInput("mask", ShaderUsage::UseUniform); + shader.AdditionalImplementation() + << " let batch = row / uniforms.sequence_length;\n" + << " let offset = batch * uniforms.total_sequence_length + token;\n" + << " let mask_value = " << mask.GetByOffset("offset", true) << ";\n" + << " return token <= uniforms.past_sequence_length + query && " + "(mask_value.x != 0u || mask_value.y != 0u);\n"; + } else { + shader.AdditionalImplementation() + << " return token <= uniforms.past_sequence_length + query;\n"; + } + shader.AdditionalImplementation() << "}\n" << "fn visible_at(row: u32, ordinal: u32) -> u32 {\n" << " var seen = 0u;\n" @@ -176,7 +207,7 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let batch = row / uniforms.sequence_length;\n" << " let token = row % uniforms.sequence_length;\n" << " let position = clamp_position(uniforms.past_sequence_length + token);\n" - << " let cache = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width + d;\n" + << " let cache = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width + d;\n" << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" << sin_cache.GetByOffset("cache") << ");\n" << " }\n" @@ -211,7 +242,7 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let paired = sign * normalized_value(row, block, pair_d);\n" << " let batch = row / uniforms.sequence_length;\n" << " let position = clamp_position(visible_at(row, block * uniforms.compress_ratio));\n" - << " let cache = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width + d;\n" + << " let cache = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width + d;\n" << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" << sin_cache.GetByOffset("cache") << ");\n" << " }\n" @@ -398,7 +429,7 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper << " let max_position = uniforms.max_rotary_length - 1u;\n" << " let position = select(entry * uniforms.compress_ratio, max_position, " "entry > max_position / uniforms.compress_ratio);\n" - << " let cache_base = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width;\n" + << " let cache_base = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width;\n" << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" << " for (var d = 0u; d < uniforms.head_size; d++) {\n" << " var value = pooled_value(batch, window, d) * inverse_rms * f32(" << norm.GetByOffset("d") << ");\n" @@ -529,7 +560,7 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let paired = sign * normalized_query_value(row, head, pair_d);\n" << " let batch = row / uniforms.sequence_length;\n" << " let position = clamped_position(row, uniforms.max_rotary_length - 1u);\n" - << " let cache = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width + offset / 2u;\n" + << " let cache = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width + offset / 2u;\n" << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" << sin_cache.GetByOffset("cache") << ");\n" << " }\n" @@ -617,14 +648,16 @@ Status SparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::ComputeConte const bool is_qsa = policy_ == sai::Policy::kQsa; ORT_RETURN_IF(!is_qsa && context.Input(sai::kKey) == nullptr, "SparseAttentionIndexer: key is required for policy_mode 'csa'"); - for (int index : {sai::kMask, sai::kGate, sai::kPositionBias, sai::kHeadWeights, + for (int index : {sai::kGate, sai::kPositionBias, sai::kHeadWeights, sai::kPositionIds, sai::kPastProjBuffer}) { - const bool policy_owns_slot = is_qsa ? index == sai::kMask : index != sai::kMask; + const bool policy_owns_slot = !is_qsa; const bool provided = index < context.InputCount() && context.Input(index) != nullptr; ORT_RETURN_IF(provided != policy_owns_slot, "SparseAttentionIndexer: input ", index, provided ? " must be omitted for policy_mode '" : " is required for policy_mode '", is_qsa ? sai::kPolicyModeQsa : sai::kPolicyModeCsa, "'"); } + ORT_RETURN_IF(!is_qsa && context.Input(sai::kMask) != nullptr, + "SparseAttentionIndexer: mask must be omitted for policy_mode 'csa'"); return is_qsa ? ComputeQsa(context) : ComputeCsa(context); } @@ -677,18 +710,18 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c } ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm, "key_norm_weight", {head_size})); - const auto& cos_shape = cos_cache->Shape(); - ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, - "SparseAttentionIndexer: invalid cos_cache shape"); - const int64_t max_rotary_length = cos_shape[1]; - const int64_t rotary_width = cos_shape[2]; - ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape && rotary_width > 0 && rotary_width % 2 == 0 && - rotary_width <= head_size, + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; + ORT_RETURN_IF_NOT(rotary_width > 0 && rotary_width % 2 == 0 && rotary_width <= head_size, "SparseAttentionIndexer: invalid qsa rotary cache shape"); - const auto& mask_shape = mask->Shape(); - ORT_RETURN_IF_NOT( - mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_length, - "SparseAttentionIndexer: qsa mask must be INT64 with shape (batch_size, total_sequence_length)"); + if (mask != nullptr) { + const auto& mask_shape = mask->Shape(); + ORT_RETURN_IF_NOT( + mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_length, + "SparseAttentionIndexer: qsa mask must be INT64 with shape (batch_size, total_sequence_length)"); + } const int64_t capacity = sai::SelectedCapacity(policy_, token_budget_, index_topk_, compress_ratio_); ORT_RETURN_IF_ERROR(CheckUint32({{max_rotary_length, "rotary cache length"}, @@ -701,8 +734,11 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c ORT_RETURN_IF_ERROR(CheckUint32Product("present key element count", {batch_size, total_length, head_size})); ORT_RETURN_IF_ERROR(CheckUint32Product("selected index element count", {batch_size, sequence_length, capacity})); ORT_RETURN_IF_ERROR(CheckUint32Product("rotary cache element count", - {batch_size, max_rotary_length, rotary_width})); - ORT_RETURN_IF_ERROR(CheckUint32Product("mask element count", {batch_size, total_length})); + {rotary_cache_shape.batched ? batch_size : 1, + max_rotary_length, rotary_width})); + if (mask != nullptr) { + ORT_RETURN_IF_ERROR(CheckUint32Product("mask element count", {batch_size, total_length})); + } Tensor* selected = context.Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, capacity})); Tensor* present = context.Output(sai::kPresentKey, TensorShape({batch_size, total_length, head_size})); @@ -735,16 +771,19 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c if (rows == 0) { return Status::OK(); } - SparseAttentionIndexerQsaSelectProgram select; - select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity) + SparseAttentionIndexerQsaSelectProgram select{mask != nullptr}; + select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity, + mask != nullptr) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, {present, ProgramTensorMetadataDependency::Type}, {query_norm, ProgramTensorMetadataDependency::Type}, {key_norm, ProgramTensorMetadataDependency::Type}, {cos_cache, ProgramTensorMetadataDependency::Type}, - {sin_cache, ProgramTensorMetadataDependency::Type}}) - .AddInput({mask, ProgramTensorMetadataDependency::Type, {mask->Shape().Size()}, 1}) - .AddOutput({selected, ProgramTensorMetadataDependency::Type}) + {sin_cache, ProgramTensorMetadataDependency::Type}}); + if (mask != nullptr) { + select.AddInput({mask, ProgramTensorMetadataDependency::Type, {mask->Shape().Size()}, 1}); + } + select.AddOutput({selected, ProgramTensorMetadataDependency::Type}) .SetWorkgroupSize(kSerialWorkgroupSize) .SetDispatchGroupSize(ToUint32(rows)) .AddUniformVariables({{ToUint32(rows)}, @@ -754,6 +793,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(query_shape[2])}, {ToUint32(rotary_width)}, {ToUint32(max_rotary_length)}, + {rotary_cache_shape.batched ? ToUint32(max_rotary_length) : 0u}, {ToUint32(compress_ratio_)}, {ToUint32(capacity)}, {ToUint32(past_length)}, @@ -804,12 +844,11 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c ORT_RETURN_IF_ERROR(CheckShape(head_weights, "head_weights", {batch_size, sequence_length, num_heads})); ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {batch_size, sequence_length})); - const auto& cos_shape = cos_cache->Shape(); - ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, - "SparseAttentionIndexer: invalid cos_cache shape"); - const int64_t max_rotary_length = cos_shape[1]; - const int64_t rotary_width = cos_shape[2]; - ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape && rotary_width > 0 && 2 * rotary_width <= head_size, + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; + ORT_RETURN_IF_NOT(rotary_width > 0 && 2 * rotary_width <= head_size, "SparseAttentionIndexer: invalid csa rotary cache shape"); const auto& past_compressed_shape = past_compressed->Shape(); ORT_RETURN_IF_NOT(past_compressed_shape.NumDimensions() == 3 && @@ -863,7 +902,8 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c CheckUint32Product("projection buffer element count", {2, batch_size, plan.present_buffer_length, width})); ORT_RETURN_IF_ERROR(CheckUint32Product("selected index element count", {batch_size, sequence_length, capacity})); ORT_RETURN_IF_ERROR(CheckUint32Product("rotary cache element count", - {batch_size, max_rotary_length, rotary_width})); + {rotary_cache_shape.batched ? batch_size : 1, + max_rotary_length, rotary_width})); Tensor* selected = context.Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, capacity})); Tensor* present_compressed = @@ -914,6 +954,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(head_size)}, {ToUint32(rotary_width)}, {ToUint32(max_rotary_length)}, + {rotary_cache_shape.batched ? ToUint32(max_rotary_length) : 0u}, {ToUint32(compress_ratio_)}, {ToUint32(past_compressed_length)}, {ToUint32(present_compressed_length)}, @@ -980,6 +1021,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(head_size)}, {ToUint32(rotary_width)}, {ToUint32(max_rotary_length)}, + {rotary_cache_shape.batched ? ToUint32(max_rotary_length) : 0u}, {ToUint32(compress_ratio_)}, {ToUint32(capacity)}, {ToUint32(present_compressed_length)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index f8751b0283ea0..81bc0095cc99e 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -47,7 +47,8 @@ class SparseAttentionIndexerQsaConcatProgram final class SparseAttentionIndexerQsaSelectProgram final : public Program { public: - SparseAttentionIndexerQsaSelectProgram() : Program{"SparseAttentionIndexerQsaSelect"} {} + explicit SparseAttentionIndexerQsaSelectProgram(bool has_mask) + : Program{"SparseAttentionIndexerQsaSelect"}, has_mask_{has_mask} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"rows", ProgramUniformVariableDataType::Uint32}, @@ -57,6 +58,7 @@ class SparseAttentionIndexerQsaSelectProgram final {"query_row_stride", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"rotary_cache_batch_stride", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"capacity", ProgramUniformVariableDataType::Uint32}, {"past_sequence_length", ProgramUniformVariableDataType::Uint32}, @@ -64,6 +66,9 @@ class SparseAttentionIndexerQsaSelectProgram final {"block_topk", ProgramUniformVariableDataType::Uint32}, {"epsilon", ProgramUniformVariableDataType::Float32}, {"scale", ProgramUniformVariableDataType::Float32}); + + private: + bool has_mask_; }; class SparseAttentionIndexerCsaCopyCompressedProgram final @@ -90,6 +95,7 @@ class SparseAttentionIndexerCsaCompressProgram final {"head_size", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"rotary_cache_batch_stride", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"past_compressed_length", ProgramUniformVariableDataType::Uint32}, {"present_compressed_length", ProgramUniformVariableDataType::Uint32}, @@ -135,6 +141,7 @@ class SparseAttentionIndexerCsaSelectProgram final {"head_size", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"rotary_cache_batch_stride", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"capacity", ProgramUniformVariableDataType::Uint32}, {"present_compressed_length", ProgramUniformVariableDataType::Uint32}, diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index e1c3f3f2100cb..c86dcf948bdae 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -1023,8 +1023,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OpSchema::Optional) .Input(4, "token_offset", - "Offset of each token before packing, with shape (batch_size, sequence_length). " - "Values must be in [0, batch_size * sequence_length).", + "Offset of each token before packing, with shape (batch_size, sequence_length).", "M") .Input(5, "cumulative_sequence_length", @@ -2239,6 +2238,19 @@ const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerShape(ONNX_NAMESPA return &shape; } +const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerRotaryCacheShape( + ONNX_NAMESPACE::InferenceContext& ctx, int index) { + if (!SparseAttentionIndexerHasInput(ctx, index) || !hasInputShape(ctx, index)) { + return nullptr; + } + const auto& shape = getInputShape(ctx, index); + if (shape.dim_size() != 2 && shape.dim_size() != 3) { + fail_shape_inference("SparseAttentionIndexer: input ", index, " must have rank 2 or 3, got rank ", + shape.dim_size()); + } + return &shape; +} + } // namespace void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx) { @@ -2279,8 +2291,8 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex } // Strict policy input validation: every slot of the inactive policy must be omitted, and every - // slot of the active policy must be provided. - constexpr int kQsaOnlyInputs[] = {sai::kMask}; + // required slot of the active policy must be provided. The QSA mask is optional because an + // omitted mask requests prefix-causal visibility. constexpr int kCsaOnlyInputs[] = {sai::kGate, sai::kPositionBias, sai::kHeadWeights, sai::kPositionIds, sai::kPastProjBuffer}; for (int index = sai::kQuery; index <= sai::kSinCache; ++index) { @@ -2294,12 +2306,9 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex if (!SparseAttentionIndexerHasInput(ctx, sai::kPastKey)) { fail_shape_inference("SparseAttentionIndexer: past_key is required for every policy_mode"); } - for (int index : kQsaOnlyInputs) { - if (SparseAttentionIndexerHasInput(ctx, index) != is_qsa) { - fail_shape_inference("SparseAttentionIndexer: input ", index, - is_qsa ? " is required when policy_mode is 'qsa'" - : " must be omitted when policy_mode is 'csa'"); - } + if (!is_qsa && SparseAttentionIndexerHasInput(ctx, sai::kMask)) { + fail_shape_inference("SparseAttentionIndexer: input ", sai::kMask, + " must be omitted when policy_mode is 'csa'"); } for (int index : kCsaOnlyInputs) { if (SparseAttentionIndexerHasInput(ctx, index) == is_qsa) { @@ -2318,8 +2327,8 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex const bool has_key = SparseAttentionIndexerHasInput(ctx, sai::kKey); (void)SparseAttentionIndexerShape(ctx, sai::kKey, 3); (void)SparseAttentionIndexerShape(ctx, sai::kKeyNormWeight, 1); - (void)SparseAttentionIndexerShape(ctx, sai::kCosCache, 3); - (void)SparseAttentionIndexerShape(ctx, sai::kSinCache, 3); + (void)SparseAttentionIndexerRotaryCacheShape(ctx, sai::kCosCache); + (void)SparseAttentionIndexerRotaryCacheShape(ctx, sai::kSinCache); const auto* query_shape = SparseAttentionIndexerShape(ctx, sai::kQuery, 3); if (query_shape == nullptr) { @@ -2565,7 +2574,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "T") .Input(4, "cos_cache", - "Cosine rotary table indexed by absolute key position, with shape " + "Cosine rotary table indexed by absolute key position, shared across the batch with shape " + "(max_rotary_sequence_length, rotary_width) or request-specific with shape " "(batch_size, max_rotary_sequence_length, rotary_width).", "T") .Input(5, @@ -2576,7 +2586,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "mask", "Only for policy_mode 'qsa': INT64 padding mask with shape " "(batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. " - "total_sequence_length is past_sequence_length + sequence_length.", + "total_sequence_length is past_sequence_length + sequence_length. When omitted, every position " + "through past_sequence_length plus the current query index is visible.", "TB", OpSchema::Optional) .Input(7, @@ -2697,6 +2708,12 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference fail_shape_inference("PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", state_capacity); } + const int64_t state_update_capacity = + getAttribute(ctx, "state_update_capacity", static_cast(0)); + if (state_update_capacity < 0 || state_update_capacity > 8) { + fail_shape_inference("PackedSparseAttentionIndexer: state_update_capacity must be in [0, 8], got ", + state_update_capacity); + } const int64_t token_budget = getAttribute(ctx, "token_budget", static_cast(0)); const int64_t index_topk = getAttribute(ctx, "index_topk", static_cast(0)); @@ -2725,13 +2742,17 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference // Strict policy input validation: every fixed slot required by every policy must be provided; // csa-only slots must be provided iff policy_mode is 'csa'. - for (int index : {psai::kQuery, psai::kKey, psai::kKeyNormWeight, psai::kCosCache, psai::kSinCache, + for (int index : {psai::kQuery, psai::kQueryNormWeight, psai::kKeyNormWeight, psai::kCosCache, psai::kSinCache, psai::kCumulativeSequenceLengths, psai::kPastSequenceLengths, psai::kPastKeyState, psai::kPastKvBuffer, psai::kPastStateLengths}) { if (!PackedSparseAttentionIndexerHasInput(ctx, index)) { fail_shape_inference("PackedSparseAttentionIndexer: input ", index, " is required for every policy_mode"); } } + const bool has_key = PackedSparseAttentionIndexerHasInput(ctx, psai::kKey); + if (!is_qsa && !has_key) { + fail_shape_inference("PackedSparseAttentionIndexer: key is required for policy_mode 'csa'"); + } for (int index : {psai::kGate, psai::kPositionBias, psai::kHeadWeights, psai::kPastGateBuffer}) { if (PackedSparseAttentionIndexerHasInput(ctx, index) == is_qsa) { fail_shape_inference("PackedSparseAttentionIndexer: input ", index, @@ -2744,10 +2765,24 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference is_qsa ? " (position_ids) must be omitted when policy_mode is 'qsa'" : " (position_ids) is required when policy_mode is 'csa'"); } + const bool has_capture_count = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateCaptureCount); + const bool has_state_update_active = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateActive); + const bool has_state_update_output = + ctx.getNumOutputs() > static_cast(psai::kStateUpdate) && ctx.getOutputType(psai::kStateUpdate) != nullptr; + if (!is_qsa && (state_update_capacity > 0 || has_capture_count || + has_state_update_active || has_state_update_output)) { + fail_shape_inference("PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } + if (state_update_capacity > 0 && !has_capture_count) { + fail_shape_inference( + "PackedSparseAttentionIndexer: state_update_capture_count is required when " + "state_update_capacity is positive"); + } - if (ctx.getNumOutputs() != static_cast(psai::kFixedOutputCount)) { - fail_shape_inference("PackedSparseAttentionIndexer: exactly ", psai::kFixedOutputCount, - " declared outputs are required, got ", ctx.getNumOutputs()); + if (ctx.getNumOutputs() < static_cast(psai::kFixedOutputCount) || + ctx.getNumOutputs() > static_cast(psai::kOutputCount)) { + fail_shape_inference("PackedSparseAttentionIndexer: expected ", psai::kFixedOutputCount, " or ", + psai::kOutputCount, " declared outputs, got ", ctx.getNumOutputs()); } if (ctx.hasOutput(psai::kPresentGateBuffer) == is_qsa) { fail_shape_inference("PackedSparseAttentionIndexer: output ", psai::kPresentGateBuffer, @@ -2764,6 +2799,9 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference // 'qsa'; only propagate its type/shape when it is actually produced. propagateElemTypeFromInputToOutput(ctx, psai::kPastGateBuffer, psai::kPresentGateBuffer); } + if (has_state_update_output) { + propagateElemTypeFromInputToOutput(ctx, psai::kPastKeyState, psai::kStateUpdate); + } const auto* query_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQuery, 2); const auto* key_shape = PackedSparseAttentionIndexerShape(ctx, psai::kKey, 2); @@ -2804,6 +2842,10 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { position_ids_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPositionIds, 1); } + const auto* capture_count_shape = + PackedSparseAttentionIndexerShape(ctx, psai::kStateUpdateCaptureCount, 1); + const auto* state_update_active_shape = + PackedSparseAttentionIndexerShape(ctx, psai::kStateUpdateActive, 1); auto require_equal_dims = [](const ONNX_NAMESPACE::TensorShapeProto* lhs, int lhs_index, const ONNX_NAMESPACE::TensorShapeProto* rhs, int rhs_index, @@ -2832,6 +2874,9 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference "past_key_state and past_kv_buffer batch dimensions must match"); require_equal_dims(state_lengths_shape, 0, key_state_shape, 0, "past_state_lengths dimension 0 must equal the state batch dimension"); + require_equal_dims(capture_count_shape, 0, key_state_shape, 0, + "state_update_capture_count dimension 0 must equal the state batch dimension"); + require_dim_value(state_update_active_shape, 0, 1, "state_update_active dimension 0 must equal 1"); require_equal_dims(key_state_shape, 2, query_norm_shape, 0, "past_key_state dimension 2 must equal head_size"); require_dim_value(key_state_shape, 1, state_capacity, "past_key_state dimension 1 must equal state_capacity"); @@ -2926,8 +2971,12 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (query_shape != nullptr) { const auto& total_tokens_dim = query_shape->dim(0); const auto& query_width_dim = query_shape->dim(1); - if (query_width_dim.has_dim_value() && query_width_dim.dim_value() <= 0) { - fail_shape_inference("PackedSparseAttentionIndexer: query width must be > 0, got ", + const int64_t packed_key_heads = is_qsa && !has_key ? 1 : 0; + if (query_width_dim.has_dim_value() && query_norm_shape != nullptr && + query_norm_shape->dim(0).has_dim_value() && + query_width_dim.dim_value() <= packed_key_heads * query_norm_shape->dim(0).dim_value()) { + fail_shape_inference("PackedSparseAttentionIndexer: query width must contain at least one query head", + has_key ? "" : " followed by one packed key head", ", got ", query_width_dim.dim_value()); } if (query_width_dim.has_dim_value() && query_norm_shape != nullptr && @@ -2960,6 +3009,13 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (state_lengths_shape != nullptr) { updateOutputShape(ctx, psai::kPresentStateLengths, *state_lengths_shape); } + if (has_state_update_output && key_state_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto state_update_shape; + *state_update_shape.add_dim() = key_state_shape->dim(0); + state_update_shape.add_dim()->set_dim_value(state_update_capacity); + *state_update_shape.add_dim() = key_state_shape->dim(2); + updateOutputShape(ctx, psai::kStateUpdate, state_update_shape); + } } constexpr const char* PackedSparseAttentionIndexer_ver1_doc = R"DOC( @@ -2992,8 +3048,8 @@ request's own packed token range and fixed-capacity state slice: sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. QSA positions are always the request-local logical cache positions derived - from past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. + incomplete block. QSA positions are the request-local logical cache positions derived from + past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -3044,6 +3100,11 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Attr("state_capacity", "Fixed capacity (number of entries) of past_key_state / present_key_state. Must be > 0.", AttributeProto::INT) + .Attr("state_update_capacity", + "Only for policy_mode 'qsa': maximum number of leading token transitions captured per request. " + "Must be in [0, 8]. Default is 0.", + AttributeProto::INT, + static_cast(0)) .Attr("token_budget", "Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. " "Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.", @@ -3070,14 +3131,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(0, "query", "Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, " - "logical reshape, and rotary embedding.", + "logical reshape, and rotary embedding. For policy_mode 'qsa', key may be omitted and query then " + "contains row-wise concatenated query and key projections with shape " + "(total_tokens, (num_heads + 1) * head_size).", "T") .Input(1, "key", "Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for " "policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first " - "head_size channels are the Ca series and the last head_size channels the Cb series.", - "T") + "head_size channels are the Ca series and the last head_size channels the Cb series. May be " + "omitted for policy_mode 'qsa' when query contains the packed query/key projection.", + "T", + OpSchema::Optional) .Input(2, "query_norm_weight", "Effective RMSNorm multiplier of the queries, with shape (head_size).", @@ -3154,6 +3219,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "pending-buffer length (policy_mode 'qsa': incomplete-block length in [0, compress_ratio); 'csa': " "buffer length in [0, 2 * compress_ratio)).", "M") + .Input(16, + "state_update_capture_count", + "Only for policy_mode 'qsa': number of leading token transitions to capture for each request, " + "with shape (batch_size). Values are clamped to the request length and state_update_capacity. " + "Required when state_update_capacity is positive.", + "M", + OpSchema::Optional) + .Input(17, + "state_update_active", + "Only for policy_mode 'qsa': optional capture gate with shape (1). A zero value disables capture.", + "M", + OpSchema::Optional) .Output(0, "selected_indices", "Selected entries with shape (total_tokens, capacity). capacity is " @@ -3183,6 +3260,14 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "present_state_lengths", "Updated generic per-request state length, with the same fixed shape as past_state_lengths.", "M") + .Output(6, + "state_update", + "Only for policy_mode 'qsa': compact transition payloads with shape " + "(batch_size, state_update_capacity, head_size). A token that completes a compression block " + "stores the prepared block representative; any other captured token stores its raw key. " + "Inactive and unused slots are zero.", + "T", + OpSchema::Optional) .TypeConstraint("T", {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, "Constrain floating point tensors to float, float16 and bfloat16.") @@ -3779,8 +3864,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "T") .Input(1, "token_offset", - "Offset of non-padding tokens and paddings. Its shape is (batch_size, sequence_length), " - "and values must be in [0, batch_size * sequence_length).", + "Offset of non-padding tokens and paddings. Its shape is (batch_size, sequence_length)", "M") .Output(0, "output", diff --git a/onnxruntime/python/tools/microbench/sparse_attention_indexer.py b/onnxruntime/python/tools/microbench/sparse_attention_indexer.py new file mode 100644 index 0000000000000..58a1e8bb566ba --- /dev/null +++ b/onnxruntime/python/tools/microbench/sparse_attention_indexer.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import argparse +import time + +import numpy as np +from onnx import TensorProto, helper + +import onnxruntime as ort + + +def create_model(context_length: int, head_size: int, num_heads: int, use_mask: bool) -> bytes: + token_budget = 2048 + compress_ratio = 4 + capacity = token_budget + compress_ratio - 1 + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT16, [1, 1, num_heads * head_size]), + helper.make_tensor_value_info("key", TensorProto.FLOAT16, [1, 1, head_size]), + helper.make_tensor_value_info("weight", TensorProto.FLOAT16, [head_size]), + helper.make_tensor_value_info("cosine", TensorProto.FLOAT16, [1, context_length, head_size]), + helper.make_tensor_value_info("sine", TensorProto.FLOAT16, [1, context_length, head_size]), + ] + if use_mask: + inputs.append(helper.make_tensor_value_info("mask", TensorProto.INT64, [1, context_length])) + inputs.extend( + [ + helper.make_tensor_value_info("key_cache", TensorProto.FLOAT16, [1, context_length, head_size]), + helper.make_tensor_value_info("past_sequence_length", TensorProto.INT32, [1]), + ] + ) + outputs = [ + helper.make_tensor_value_info("selected", TensorProto.INT32, [1, 1, capacity]), + helper.make_tensor_value_info("present_key", TensorProto.FLOAT16, [1, context_length, head_size]), + ] + node = helper.make_node( + "SparseAttentionIndexer", + [ + "query", + "key", + "weight", + "cosine", + "sine", + "mask" if use_mask else "", + "key_cache", + "", + "", + "", + "", + "past_sequence_length", + ], + ["selected", "present_key"], + domain="com.microsoft", + policy_mode="qsa", + compress_ratio=compress_ratio, + token_budget=token_budget, + ) + graph = helper.make_graph([node], "sparse_attention_indexer_benchmark", inputs, outputs) + model = helper.make_model( + graph, + opset_imports=[helper.make_opsetid("", 17), helper.make_opsetid("com.microsoft", 1)], + ir_version=10, + ) + return model.SerializeToString() + + +def benchmark(context_length: int, warmup: int, iterations: int, use_mask: bool) -> float: + head_size = 128 + num_heads = 4 + session = ort.InferenceSession( + create_model(context_length, head_size, num_heads, use_mask), + providers=["CUDAExecutionProvider"], + ) + inputs = { + "query": np.zeros((1, 1, num_heads * head_size), dtype=np.float16), + "key": np.zeros((1, 1, head_size), dtype=np.float16), + "weight": np.ones((head_size,), dtype=np.float16), + "cosine": np.ones((1, context_length, head_size), dtype=np.float16), + "sine": np.zeros((1, context_length, head_size), dtype=np.float16), + "key_cache": np.zeros((1, context_length, head_size), dtype=np.float16), + "past_sequence_length": np.array([context_length - 1], dtype=np.int32), + } + if use_mask: + inputs["mask"] = np.ones((1, context_length), dtype=np.int64) + io_binding = session.io_binding() + device_inputs = {} + for name, value in inputs.items(): + device_inputs[name] = ort.OrtValue.ortvalue_from_numpy(value, "cuda", 0) + io_binding.bind_ortvalue_input(name, device_inputs[name]) + selected = ort.OrtValue.ortvalue_from_shape_and_type([1, 1, 2051], np.int32, "cuda", 0) + io_binding.bind_ortvalue_output("selected", selected) + io_binding.bind_ortvalue_output("present_key", device_inputs["key_cache"]) + + for _ in range(warmup): + session.run_with_iobinding(io_binding) + io_binding.synchronize_outputs() + + start = time.perf_counter() + for _ in range(iterations): + session.run_with_iobinding(io_binding) + io_binding.synchronize_outputs() + return (time.perf_counter() - start) * 1000.0 / iterations + + +def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark CUDA QSA SparseAttentionIndexer decode") + parser.add_argument("--contexts", nargs="+", type=int, default=[8192, 32768, 65536, 131072, 262144]) + parser.add_argument("--warmup", type=int, default=10) + parser.add_argument("--iterations", type=int, default=100) + parser.add_argument("--use-mask", action="store_true") + args = parser.parse_args() + + if "CUDAExecutionProvider" not in ort.get_available_providers(): + raise RuntimeError("CUDAExecutionProvider is unavailable") + for context_length in args.contexts: + latency = benchmark(context_length, args.warmup, args.iterations, args.use_mask) + print(f"context={context_length:>6} latency_ms={latency:.3f}") + + +if __name__ == "__main__": + main() diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index ee17a0a905cc0..afcc8ff4ec3c5 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -2840,6 +2840,18 @@ def copy_state_output(output_index, input_index, dtype): if policy_mode != "qsa": copy_state_output(4, 14, output_dtype) # present_gate_buffer <- past_gate_buffer copy_state_output(5, 15, onnx.TensorProto.INT32) # present_state_lengths <- past_state_lengths + if policy_mode == "qsa" and len(node.output) > 6 and node.output[6]: + past_key_shape = self._get_sympy_shape(node, 12) + if past_key_shape is not None: + state_update_capacity = get_attribute(node, "state_update_capacity", 0) + vi = self.known_vi_[node.output[6]] + vi.CopyFrom( + helper.make_tensor_value_info( + node.output[6], + output_dtype, + get_shape_from_sympy_shape([past_key_shape[0], state_update_capacity, past_key_shape[2]]), + ) + ) def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc new file mode 100644 index 0000000000000..4fa3727adcb53 --- /dev/null +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include + +#include +#include +#include +#include + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" +#include "core/platform/env.h" +#include "gtest/gtest.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { +namespace { + +TEST(SparseAttentionIndexerCudaKernelTest, QsaWorkspaceIncludesPreparedQuery) { + SparseAttentionIndexerParams params; + params.batch_size = 2; + params.sequence_length = 3; + params.num_heads = 4; + params.head_size = 128; + params.max_block_count = 2048; + + constexpr size_t kRows = 6; + constexpr size_t kPreparedQueryElements = kRows * 4 * 128; + constexpr size_t kBlockScoreElements = kRows * 2048; + EXPECT_EQ(GetQsaWorkspaceFloatCount(params), kPreparedQueryElements + kBlockScoreElements); +} + +TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) { + constexpr int kContextLength = 8192; + constexpr int kHeadSize = 128; + constexpr int kNumHeads = 4; + constexpr int kTokenBudget = 2048; + constexpr int kCompressRatio = 4; + constexpr int kThreads = 128; + constexpr int kWarmupIterations = 10; + constexpr int kTimedIterations = 50; + + SparseAttentionIndexerParams params; + params.batch_size = 1; + params.sequence_length = 1; + params.num_heads = kNumHeads; + params.head_size = kHeadSize; + params.rotary_width = kHeadSize; + params.max_rotary_length = kContextLength; + params.compress_ratio = kCompressRatio; + params.capacity = kTokenBudget + kCompressRatio - 1; + params.scale = 0.5f; + params.past_sequence_length = kContextLength - 1; + params.total_sequence_length = kContextLength; + params.past_key_capacity = kContextLength; + params.key_cache_capacity = kContextLength; + params.max_block_count = kContextLength / kCompressRatio; + params.block_topk = kTokenBudget / kCompressRatio; + + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamCreate(&stream)); + + float* query = nullptr; + float* key = nullptr; + float* weight = nullptr; + float* cosine = nullptr; + float* sine = nullptr; + int64_t* mask = nullptr; + float* key_cache = nullptr; + int32_t* selected = nullptr; + float* float_workspace = nullptr; + int32_t* int_workspace = nullptr; + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&query), kNumHeads * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&key), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&weight), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&cosine), kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&sine), kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&mask), kContextLength * sizeof(int64_t))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&key_cache), kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&selected), params.capacity * sizeof(int32_t))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&float_workspace), + GetQsaWorkspaceFloatCount(params) * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&int_workspace), + GetQsaWorkspaceIntCount(params, true) * sizeof(int32_t))); + + std::vector ones(kContextLength * kHeadSize, 1.0f); + std::vector zeros(kContextLength * kHeadSize, 0.0f); + std::vector visible(kContextLength, 1); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(query, ones.data(), kNumHeads * kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key, ones.data(), kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(weight, ones.data(), kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(cosine, ones.data(), ones.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(sine, zeros.data(), zeros.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(mask, visible.data(), kContextLength * sizeof(int64_t), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key_cache, ones.data(), ones.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + const auto launch = [&]() { + return LaunchQsaSparseAttentionIndexer( + stream, params, query, key, weight, weight, cosine, sine, mask, key_cache, selected, key_cache, + float_workspace, int_workspace); + }; + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + cudaGraph_t graph = nullptr; + cudaGraphExec_t graph_exec = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal)); + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamEndCapture(stream, &graph)); + + size_t node_count = 0; + ASSERT_EQ(cudaSuccess, cudaGraphGetNodes(graph, nullptr, &node_count)); + std::vector nodes(node_count); + ASSERT_EQ(cudaSuccess, cudaGraphGetNodes(graph, nodes.data(), &node_count)); + + int query_prepare_nodes = 0; + int block_score_nodes = 0; + for (cudaGraphNode_t node : nodes) { + cudaGraphNodeType node_type; + ASSERT_EQ(cudaSuccess, cudaGraphNodeGetType(node, &node_type)); + if (node_type != cudaGraphNodeTypeKernel) { + continue; + } + + cudaKernelNodeParams kernel_params{}; + ASSERT_EQ(cudaSuccess, cudaGraphKernelNodeGetParams(node, &kernel_params)); + if (kernel_params.blockDim.x != kThreads) { + continue; + } + + const size_t query_prepare_shared_bytes = (kHeadSize + kThreads) * sizeof(float); + if (kernel_params.gridDim.x == kNumHeads && kernel_params.sharedMemBytes == query_prepare_shared_bytes) { + ++query_prepare_nodes; + } + if (kernel_params.gridDim.x == static_cast(params.max_block_count) && + kernel_params.sharedMemBytes == 0) { + ++block_score_nodes; + } + } + EXPECT_EQ(query_prepare_nodes, 1) << "QSA query RMSNorm and RoPE must run once per query head"; + EXPECT_EQ(block_score_nodes, 1) << "QSA scoring must consume prepared queries without extra shared state"; + + ASSERT_EQ(cudaSuccess, cudaGraphInstantiate(&graph_exec, graph, nullptr, nullptr, 0)); + for (int i = 0; i < kWarmupIterations; ++i) { + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + } + + cudaEvent_t start = nullptr; + cudaEvent_t stop = nullptr; + ASSERT_EQ(cudaSuccess, cudaEventCreate(&start)); + ASSERT_EQ(cudaSuccess, cudaEventCreate(&stop)); + ASSERT_EQ(cudaSuccess, cudaEventRecord(start, stream)); + for (int i = 0; i < kTimedIterations; ++i) { + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + } + ASSERT_EQ(cudaSuccess, cudaEventRecord(stop, stream)); + ASSERT_EQ(cudaSuccess, cudaEventSynchronize(stop)); + float elapsed_ms = 0.0f; + ASSERT_EQ(cudaSuccess, cudaEventElapsedTime(&elapsed_ms, start, stop)); + const float average_us = elapsed_ms * 1000.0f / kTimedIterations; + RecordProperty("qsa_8k_decode_us", std::to_string(average_us)); + + std::vector actual(params.capacity); + ASSERT_EQ(cudaSuccess, cudaMemcpy(actual.data(), selected, actual.size() * sizeof(int32_t), + cudaMemcpyDeviceToHost)); + for (int i = 0; i < kTokenBudget; ++i) { + EXPECT_EQ(actual[i], i); + } + for (int i = kTokenBudget; i < params.capacity; ++i) { + EXPECT_EQ(actual[i], -1); + } + + ASSERT_EQ(cudaSuccess, cudaEventDestroy(stop)); + ASSERT_EQ(cudaSuccess, cudaEventDestroy(start)); + ASSERT_EQ(cudaSuccess, cudaGraphExecDestroy(graph_exec)); + ASSERT_EQ(cudaSuccess, cudaGraphDestroy(graph)); + ASSERT_EQ(cudaSuccess, cudaFree(int_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(float_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(selected)); + ASSERT_EQ(cudaSuccess, cudaFree(key_cache)); + ASSERT_EQ(cudaSuccess, cudaFree(mask)); + ASSERT_EQ(cudaSuccess, cudaFree(sine)); + ASSERT_EQ(cudaSuccess, cudaFree(cosine)); + ASSERT_EQ(cudaSuccess, cudaFree(weight)); + ASSERT_EQ(cudaSuccess, cudaFree(key)); + ASSERT_EQ(cudaSuccess, cudaFree(query)); + ASSERT_EQ(cudaSuccess, cudaStreamDestroy(stream)); +} + +TEST(SparseAttentionIndexerCudaKernelTest, DISABLED_QsaHierarchicalLongContextBenchmark) { + constexpr int kHeadSize = 128; + constexpr int kNumHeads = 4; + constexpr int kTokenBudget = 2048; + constexpr int kCompressRatio = 4; + constexpr int kWarmupIterations = 10; + constexpr int kTimedIterations = 100; + const std::string context_environment = Env::Default().GetEnvironmentVar("ORT_QSA_BENCH_CONTEXT"); + const int context_length = context_environment.empty() ? 65536 : std::stoi(context_environment); + + SparseAttentionIndexerParams params; + params.batch_size = 1; + params.sequence_length = 1; + params.num_heads = kNumHeads; + params.head_size = kHeadSize; + params.rotary_width = kHeadSize; + params.max_rotary_length = context_length; + params.compress_ratio = kCompressRatio; + params.capacity = kTokenBudget + kCompressRatio - 1; + params.scale = 0.5f; + params.past_sequence_length = context_length - 1; + params.total_sequence_length = context_length; + params.past_key_capacity = context_length; + params.key_cache_capacity = context_length; + params.max_block_count = context_length / kCompressRatio; + params.block_topk = kTokenBudget / kCompressRatio; + params.use_block_representatives = true; + + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamCreate(&stream)); + float* query = nullptr; + float* key = nullptr; + float* weight = nullptr; + float* cosine = nullptr; + float* sine = nullptr; + float* key_cache = nullptr; + int32_t* selected = nullptr; + float* float_workspace = nullptr; + int32_t* int_workspace = nullptr; + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&query), kNumHeads * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&key), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&weight), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&cosine), context_length * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&sine), context_length * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&key_cache), context_length * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&selected), params.capacity * sizeof(int32_t))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&float_workspace), + GetQsaWorkspaceFloatCount(params) * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&int_workspace), + GetQsaWorkspaceIntCount(params, false) * sizeof(int32_t))); + + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(query, 0, kNumHeads * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(key, 0, kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(weight, 0, kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(cosine, 0, context_length * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(sine, 0, context_length * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(key_cache, 0, context_length * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + const auto launch = [&]() { + return LaunchQsaSparseAttentionIndexer( + stream, params, query, key, weight, weight, cosine, sine, nullptr, key_cache, selected, key_cache, + float_workspace, int_workspace); + }; + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + for (int iteration = 0; iteration < kWarmupIterations; ++iteration) { + ASSERT_TRUE(launch().IsOK()); + } + + cudaEvent_t start = nullptr; + cudaEvent_t stop = nullptr; + ASSERT_EQ(cudaSuccess, cudaEventCreate(&start)); + ASSERT_EQ(cudaSuccess, cudaEventCreate(&stop)); + ASSERT_EQ(cudaSuccess, cudaEventRecord(start, stream)); + for (int iteration = 0; iteration < kTimedIterations; ++iteration) { + ASSERT_TRUE(launch().IsOK()); + } + ASSERT_EQ(cudaSuccess, cudaEventRecord(stop, stream)); + ASSERT_EQ(cudaSuccess, cudaEventSynchronize(stop)); + float elapsed_ms = 0.0f; + ASSERT_EQ(cudaSuccess, cudaEventElapsedTime(&elapsed_ms, start, stop)); + const float average_ms = elapsed_ms / kTimedIterations; + std::cout << "QSA_HIERARCHICAL context=" << context_length << " average_ms=" << average_ms + << " blocks_scanned=" << params.max_block_count + << " tile_count=" << (params.max_block_count + 7) / 8 + << " selected_entries=" << params.block_topk << std::endl; + RecordProperty("qsa_hierarchical_ms", std::to_string(average_ms)); + + ASSERT_EQ(cudaSuccess, cudaEventDestroy(stop)); + ASSERT_EQ(cudaSuccess, cudaEventDestroy(start)); + ASSERT_EQ(cudaSuccess, cudaFree(int_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(float_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(selected)); + ASSERT_EQ(cudaSuccess, cudaFree(key_cache)); + ASSERT_EQ(cudaSuccess, cudaFree(sine)); + ASSERT_EQ(cudaSuccess, cudaFree(cosine)); + ASSERT_EQ(cudaSuccess, cudaFree(weight)); + ASSERT_EQ(cudaSuccess, cudaFree(key)); + ASSERT_EQ(cudaSuccess, cudaFree(query)); + ASSERT_EQ(cudaSuccess, cudaStreamDestroy(stream)); +} + +} // namespace +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index c5fc1e1996493..6c8b21529e4ca 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -100,9 +100,13 @@ struct GraphOptions { int64_t key_total_tokens = -1; int64_t kv_buffer_capacity = -1; int64_t gate_buffer_width = -1; + bool packed_qk = false; bool add_index_topk = false; bool add_csa_inputs = false; bool add_position_ids = false; + bool add_capture_count = false; + bool add_state_update_active = false; + int64_t state_update_capacity = 0; bool add_qsa_gate_output = false; bool omit_csa_gate_output = false; int output_count = psai::kFixedOutputCount; @@ -111,8 +115,8 @@ struct GraphOptions { int64_t BufferCapacity(int64_t compress_ratio) { return 2 * compress_ratio - 1; } -// Builds a fixed 16-input node; csa-only slots are left empty for policy_mode "qsa", as the schema -// requires. position_ids (slot 11) is forbidden for "qsa" and forced on for "csa". +// Builds a fixed-input node; csa-only slots are left empty for policy_mode "qsa", as the schema +// requires. position_ids (slot 11) is omitted for "qsa" and forced on for "csa". void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { const bool is_csa = options.policy_mode == psai::kPolicyModeCsa; const int64_t width = is_csa ? 2 * options.head_size : options.head_size; @@ -121,9 +125,12 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { std::vector inputs{ builder.MakeInput( - std::vector{options.total_tokens, options.num_heads * options.head_size}), - builder.MakeInput( - std::vector{options.key_total_tokens >= 0 ? options.key_total_tokens : options.total_tokens, width}), + std::vector{options.total_tokens, + (options.num_heads + (options.packed_qk ? 1 : 0)) * options.head_size}), + options.packed_qk + ? &empty + : builder.MakeInput(std::vector{ + options.key_total_tokens >= 0 ? options.key_total_tokens : options.total_tokens, width}), builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{64, options.rotary_width}), @@ -160,6 +167,14 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { inputs.push_back(&empty); } inputs.push_back(builder.MakeInput(std::vector{options.batch_size, 2})); + if (options.add_capture_count || options.add_state_update_active) { + inputs.push_back(options.add_capture_count + ? builder.MakeInput(std::vector{options.batch_size}) + : &empty); + } + if (options.add_state_update_active) { + inputs.push_back(builder.MakeInput(std::vector{1})); + } std::vector outputs; for (int i = 0; i < options.output_count; ++i) { @@ -172,6 +187,9 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { node.AddAttribute("policy_mode", options.policy_mode); node.AddAttribute("compress_ratio", options.compress_ratio); node.AddAttribute("state_capacity", options.state_capacity); + if (options.state_update_capacity != 0) { + node.AddAttribute("state_update_capacity", options.state_update_capacity); + } if (is_csa || options.add_index_topk) { node.AddAttribute("index_topk", static_cast(3)); } else { @@ -201,6 +219,34 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndSt ONNX_NAMESPACE::TensorProto_DataType_INT32, {options.batch_size, 2}); } +TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaAcceptsPackedQueryKey) { + GraphOptions options; + options.packed_qk = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + + const Node& node = *model->MainGraph().Nodes().begin(); + EXPECT_FALSE(node.InputDefs()[psai::kKey]->Exists()); + ExpectShape(model->MainGraph(), node.OutputDefs()[psai::kPresentKeyState]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.state_capacity, options.head_size}); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaInfersStateUpdate) { + GraphOptions options; + options.state_update_capacity = 4; + options.add_capture_count = true; + options.add_state_update_active = true; + options.output_count = psai::kOutputCount; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + ExpectShape(graph, node.OutputDefs()[psai::kStateUpdate]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.state_update_capacity, options.head_size}); +} + TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndState) { GraphOptions options; options.policy_mode = psai::kPolicyModeCsa; @@ -297,7 +343,7 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { GraphOptions options; options.num_heads = 0; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "query width must be > 0"); + "query width must contain at least one query head"); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsGenericBufferCapacityOverflow) { @@ -326,7 +372,7 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithPositionIds) GraphOptions options; options.add_position_ids = true; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "position_ids) must be omitted when policy_mode is 'qsa'"); + "must be omitted when policy_mode is 'qsa'"); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingPositionIds) { @@ -379,7 +425,25 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsWrongOutputCount) { GraphOptions options; options.output_count = 4; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "output size 4 not in range [min=6, max=6]"); + "output size 4 not in range [min=6, max=7]"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsStateUpdateCapacityWithoutCaptureCount) { + GraphOptions options; + options.state_update_capacity = 4; + options.output_count = psai::kOutputCount; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "state_update_capture_count is required"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaStateUpdateCapture) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + options.state_update_capacity = 4; + options.add_capture_count = true; + options.output_count = psai::kOutputCount; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "only valid for policy_mode 'qsa'"); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaPresentGateBuffer) { @@ -551,6 +615,9 @@ struct QsaPackedProblem { int max_position = 64; float epsilon = 1.0e-6f; std::optional scale; + int state_update_capacity = 0; + std::vector state_update_capture_count; + std::optional state_update_active; std::vector query; std::vector key; @@ -558,6 +625,7 @@ struct QsaPackedProblem { std::vector key_norm_weight; std::vector cos_cache; // shared: [max_position, rotary_width] std::vector sin_cache; + std::vector position_ids; std::vector past_key_state; // [batch, state_capacity, head_size] std::vector past_kv_buffer; // [batch, buffer_capacity, head_size] @@ -572,6 +640,7 @@ struct QsaPackedResult { std::vector present_key_state; std::vector present_kv_buffer; std::vector present_state_lengths; + std::vector state_update; }; void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { @@ -585,6 +654,8 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { out.present_key_state = p.past_key_state; out.present_kv_buffer = p.past_kv_buffer; out.present_state_lengths = p.past_state_lengths; + out.state_update.assign( + static_cast(p.batch_size) * p.state_update_capacity * p.head_size, 0.0f); std::vector key_len_after(static_cast(p.batch_size)); std::vector overflowed(static_cast(p.batch_size), false); @@ -651,6 +722,30 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { out.present_state_lengths[static_cast(b) * 2 + 0] = old_key_len + new_block_count; out.present_state_lengths[static_cast(b) * 2 + 1] = new_buf_len; key_len_after[static_cast(b)] = old_key_len + new_block_count; + + const bool capture_active = p.state_update_capacity > 0 && !p.state_update_capture_count.empty() && + (!p.state_update_active.has_value() || *p.state_update_active != 0); + const int capture_count = capture_active + ? std::clamp(p.state_update_capture_count[static_cast(b)], 0, + std::min(req_len, p.state_update_capacity)) + : 0; + for (int t = 0; t < capture_count; ++t) { + const bool completes_block = (old_buf_len + t + 1) % p.compress_ratio == 0; + for (int d = 0; d < head_size; ++d) { + const size_t capture_offset = + (static_cast(b) * p.state_update_capacity + t) * head_size + d; + if (completes_block) { + const int block = (old_buf_len + t + 1) / p.compress_ratio - 1; + out.state_update[capture_offset] = + out.present_key_state[(static_cast(b) * p.state_capacity + old_key_len + block) * + head_size + + d]; + } else { + out.state_update[capture_offset] = + p.key[(static_cast(req_start) + t) * head_size + d]; + } + } + } } out.selected_indices.assign(static_cast(total_tokens) * capacity, -1); @@ -662,7 +757,9 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; for (int token = req_start; token < req_end; ++token) { - const int position = p.past_sequence_lengths[static_cast(b)] + (token - req_start); + const int position = p.position_ids.empty() + ? p.past_sequence_lengths[static_cast(b)] + (token - req_start) + : static_cast(p.position_ids[static_cast(token)]); const int causal_count = CausalThreshold(position, p.compress_ratio); const int visible_block_count = std::min(key_len_after[static_cast(b)], causal_count); const int selected = std::min(block_topk, visible_block_count); @@ -744,7 +841,7 @@ QsaPackedProblem MakeQsaPackedProblem(QsaPackedProblem problem = {}) { template void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedProblem(), ProviderKind provider_kind = ProviderKind::Cuda, QsaPackedResult* actual = nullptr, - const ConfigOptions* webgpu_options = nullptr) { + bool packed_qk = false, const ConfigOptions* webgpu_options = nullptr) { auto provider = CreateProvider(provider_kind, webgpu_options); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") @@ -773,27 +870,54 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); test.AddAttribute("state_capacity", static_cast(problem.state_capacity)); test.AddAttribute("token_budget", static_cast(problem.token_budget)); + if (problem.state_update_capacity > 0) { + test.AddAttribute("state_update_capacity", static_cast(problem.state_update_capacity)); + } if (problem.scale.has_value()) { test.AddAttribute("scale", *problem.scale); } - test.AddInput("query", {total_tokens, problem.num_heads * head_size}, ToElementType(problem.query)); - test.AddInput("key", {total_tokens, head_size}, ToElementType(problem.key)); + if (packed_qk) { + const int64_t query_width = problem.num_heads * head_size; + const int64_t packed_width = query_width + head_size; + std::vector query_key(static_cast(total_tokens * packed_width)); + for (int64_t token = 0; token < total_tokens; ++token) { + std::copy_n(problem.query.begin() + token * query_width, query_width, + query_key.begin() + token * packed_width); + std::copy_n(problem.key.begin() + token * head_size, head_size, + query_key.begin() + token * packed_width + query_width); + } + test.AddInput("query_key", {total_tokens, packed_width}, ToElementType(query_key)); + test.AddOptionalInputEdge(); + } else { + test.AddInput("query", {total_tokens, problem.num_heads * head_size}, ToElementType(problem.query)); + test.AddInput("key", {total_tokens, head_size}, ToElementType(problem.key)); + } test.AddInput("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); test.AddInput("cos_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.cos_cache)); test.AddInput("sin_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.sin_cache)); test.AddInput("cumulative_sequence_lengths", {batch_size + 1}, problem.cumulative_sequence_lengths); test.AddInput("past_sequence_lengths", {batch_size}, problem.past_sequence_lengths); - test.AddOptionalInputEdge(); // gate - test.AddOptionalInputEdge(); // position_bias - test.AddOptionalInputEdge(); // head_weights - test.AddOptionalInputEdge(); // position_ids + test.AddOptionalInputEdge(); // gate + test.AddOptionalInputEdge(); // position_bias + test.AddOptionalInputEdge(); // head_weights + if (problem.position_ids.empty()) { + test.AddOptionalInputEdge(); + } else { + test.AddInput("position_ids", {total_tokens}, problem.position_ids); + } test.AddInput("past_key_state", {batch_size, problem.state_capacity, head_size}, ToElementType(problem.past_key_state)); test.AddInput("past_kv_buffer", {batch_size, buffer_capacity, head_size}, ToElementType(problem.past_kv_buffer)); test.AddOptionalInputEdge(); // past_gate_buffer test.AddInput("past_state_lengths", {batch_size, 2}, problem.past_state_lengths); + if (problem.state_update_capacity > 0) { + test.AddInput("state_update_capture_count", {batch_size}, problem.state_update_capture_count); + if (problem.state_update_active.has_value()) { + test.AddInput("state_update_active", {1}, {*problem.state_update_active}); + } + } test.AddOutput("selected_indices", {total_tokens, problem.Capacity()}, expected.selected_indices); test.AddOutput("selected_counts", {total_tokens}, expected.selected_counts); @@ -803,6 +927,10 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP ToElementType(expected.present_kv_buffer), false, 0.0f, tolerance); test.AddOptionalOutputEdge(); // present_gate_buffer test.AddOutput("present_state_lengths", {batch_size, 2}, expected.present_state_lengths); + if (problem.state_update_capacity > 0) { + test.AddOutput("state_update", {batch_size, problem.state_update_capacity, head_size}, + ToElementType(expected.state_update), false, 0.0f, tolerance); + } RunOnProvider(test, std::move(provider)); if (actual != nullptr) { const auto& fetches = test.GetFetches(); @@ -821,6 +949,10 @@ TEST(PackedSparseAttentionIndexerTest, QsaFloat16) { RunQsaPackedTest TEST(PackedSparseAttentionIndexerTest, QsaBFloat16) { RunQsaPackedTest(2.0e-2f); } +TEST(PackedSparseAttentionIndexerTest, QsaPackedQueryKey) { + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::Cuda, nullptr, true); +} + TEST(PackedSparseAttentionIndexerTest, QsaPrefillThenDecodeIndependentState) { if (DefaultCudaExecutionProvider() == nullptr) { GTEST_SKIP() << "CUDA execution provider is not available"; @@ -865,6 +997,95 @@ TEST(PackedSparseAttentionIndexerTest, QsaStateCapacityOverflowIsSafe) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); } +TEST(PackedSparseAttentionIndexerTest, QsaStateUpdateCapturesRawAndBlockTransitions) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 3}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::Cuda); +} + +TEST(PackedSparseAttentionIndexerTest, QsaStateUpdateActiveZeroClearsAllSlots) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 5}; + problem.state_update_active = 0; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::Cuda); +} + +TEST(PackedSparseAttentionIndexerTest, QsaStateUpdateActiveOneCapturesRawAndBlockTransitions) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 5}; + problem.state_update_active = 1; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::Cuda); +} + +TEST(PackedSparseAttentionIndexerTest, QsaBoundedTopKMaximum) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {1040}; + problem.compress_ratio = 2; + problem.token_budget = 1024; + problem.state_capacity = 520; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); +} + +TEST(PackedSparseAttentionIndexerTest, QsaHierarchicalTopK) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {8204}; + problem.head_size = 128; + problem.num_heads = 4; + problem.rotary_width = 64; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem.state_capacity = 2051; + problem = MakeQsaPackedProblem(std::move(problem)); + std::fill(problem.query.begin(), problem.query.end(), 1.0f); + std::fill(problem.query_norm_weight.begin(), problem.query_norm_weight.end(), 1.0f); + std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); + std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); + for (int block = 0; block < problem.state_capacity; ++block) { + const float value = static_cast(block + 1) / problem.state_capacity; + std::fill_n(problem.past_key_state.begin() + static_cast(block) * problem.head_size, + problem.head_size, value); + } + RunQsaPackedTest(1.0e-5f, std::move(problem)); +} + +TEST(PackedSparseAttentionIndexerTest, QsaRaggedPrefill) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 65}; + problem.past_sequence_lengths = {1024}; + problem.head_size = 128; + problem.num_heads = 4; + problem.rotary_width = 64; + problem.compress_ratio = 4; + problem.token_budget = 128; + problem.state_capacity = 512; + problem.max_position = 2048; + problem = MakeQsaPackedProblem(std::move(problem)); + std::fill(problem.query_norm_weight.begin(), problem.query_norm_weight.end(), 1.0f); + std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); + std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); + RunQsaPackedTest(1.0e-4f, std::move(problem)); +} + TEST(PackedSparseAttentionIndexerTest, QsaMalformedStateLengthsAreClamped) { QsaPackedProblem problem; problem.batch_size = 1; @@ -884,10 +1105,37 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaPackedTest(2.0e-3f, MakeQsaPackedProblem(), ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaPackedQueryKey) { + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu, nullptr, true); +} + +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateUpdateCapturesRawAndBlockTransitions) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 3}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); +} + +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateUpdateActiveZeroClearsAllSlots) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 5}; + problem.state_update_active = 0; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, QsaPortableStorageBindingLimit) { ConfigOptions config_options; ASSERT_STATUS_OK(config_options.AddConfigEntry(webgpu::options::kMaxStorageBuffersPerShaderStage, "8")); - RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu, nullptr, &config_options); + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu, nullptr, false, &config_options); } TEST(PackedSparseAttentionIndexerWebGpuTest, QsaAllEmptyBatchPreservesState) { diff --git a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc index c98aaf244b548..74656e6e0b582 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -96,7 +96,9 @@ struct QsaGraphOptions { bool add_index_topk = false; bool add_csa_inputs = false; bool share_cache = false; + bool shared_rotary_cache = false; bool packed_qk = false; + bool add_mask = true; std::string policy_mode = sai::kPolicyModeQsa; }; @@ -118,9 +120,15 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { std::vector{options.batch_size, options.sequence_length, options.head_size}), builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{options.head_size}), - builder.MakeInput(std::vector{options.batch_size, total, options.rotary_width}), - builder.MakeInput(std::vector{options.batch_size, total, options.rotary_width}), - builder.MakeInput(std::vector{options.batch_size, total}), + builder.MakeInput(options.shared_rotary_cache + ? std::vector{total, options.rotary_width} + : std::vector{options.batch_size, total, options.rotary_width}), + builder.MakeInput(options.shared_rotary_cache + ? std::vector{total, options.rotary_width} + : std::vector{options.batch_size, total, options.rotary_width}), + options.add_mask + ? builder.MakeInput(std::vector{options.batch_size, total}) + : &empty, builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; if (options.add_csa_inputs) { @@ -160,6 +168,7 @@ struct CsaGraphOptions { int64_t output_count = sai::kCsaOutputCount; bool add_token_budget = false; bool share_cache = false; + bool shared_rotary_cache = false; }; void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { @@ -171,8 +180,12 @@ void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { builder.MakeInput(std::vector{options.batch_size, options.sequence_length, width}), builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{options.head_size}), - builder.MakeInput(std::vector{options.batch_size, 64, options.rotary_width}), - builder.MakeInput(std::vector{options.batch_size, 64, options.rotary_width}), + builder.MakeInput(options.shared_rotary_cache + ? std::vector{64, options.rotary_width} + : std::vector{options.batch_size, 64, options.rotary_width}), + builder.MakeInput(options.shared_rotary_cache + ? std::vector{64, options.rotary_width} + : std::vector{options.batch_size, 64, options.rotary_width}), &empty, builder.MakeInput( std::vector{options.batch_size, @@ -307,6 +320,7 @@ struct QsaProblem { int token_budget = 4; float epsilon = 1.0e-6f; std::optional scale; + bool shared_rotary_cache = false; std::vector query; std::vector key; @@ -353,10 +367,11 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std selected.assign(static_cast(problem.batch_size) * problem.sequence_length * capacity, -1); for (int b = 0; b < problem.batch_size; ++b) { - const float* cos_base = problem.cos_cache.data() + - static_cast(b) * problem.MaxRotaryLength() * problem.rotary_width; - const float* sin_base = problem.sin_cache.data() + - static_cast(b) * problem.MaxRotaryLength() * problem.rotary_width; + const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); + const float* cos_base = + problem.cos_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; + const float* sin_base = + problem.sin_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; for (int s = 0; s < problem.sequence_length; ++s) { const size_t row = static_cast(b) * problem.sequence_length + s; @@ -440,6 +455,7 @@ struct CsaProblem { float epsilon = 1.0e-6f; std::optional scale; std::optional head_weight_scale; + bool shared_rotary_cache = false; std::vector query; std::vector key; @@ -506,10 +522,11 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, } } + const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); const float* cos_base = - problem.cos_cache.data() + static_cast(b) * problem.max_rotary_length * problem.rotary_width; + problem.cos_cache.data() + cache_batch * problem.max_rotary_length * problem.rotary_width; const float* sin_base = - problem.sin_cache.data() + static_cast(b) * problem.max_rotary_length * problem.rotary_width; + problem.sin_cache.data() + cache_batch * problem.max_rotary_length * problem.rotary_width; for (int window = 0; window < plan.new_window_count; ++window) { const bool has_previous = window >= 1 || plan.overlap_length >= problem.compress_ratio; @@ -660,12 +677,12 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { 1.10f, 0.29f); problem.query_norm_weight = MakeWave(static_cast(problem.head_size), 1.20f, 0.23f); problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.70f, 0.17f); - problem.cos_cache = MakeWave(static_cast(problem.batch_size) * total * problem.rotary_width, 0.20f, 0.13f); - problem.sin_cache = MakeWave(static_cast(problem.batch_size) * total * problem.rotary_width, 0.90f, 0.19f); + const size_t cache_batches = problem.shared_rotary_cache ? 1 : static_cast(problem.batch_size); + problem.cos_cache = MakeWave(cache_batches * total * problem.rotary_width, 0.20f, 0.13f); + problem.sin_cache = MakeWave(cache_batches * total * problem.rotary_width, 0.90f, 0.19f); problem.past_key = MakeWave( static_cast(problem.batch_size) * problem.PastKeyCapacity() * problem.head_size, 0.05f, 0.23f); - // Row 0 sees four tokens (two complete blocks, no tail); row 1 sees five (two blocks plus a tail). problem.mask.assign(static_cast(problem.batch_size) * total, 1); for (int b = 0; b < problem.batch_size; ++b) { for (int t = 0; t <= b && t < problem.past_sequence_length; ++t) { @@ -677,7 +694,8 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { template void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), - ProviderKind provider_kind = ProviderKind::Cuda, bool packed_qk = false) { + ProviderKind provider_kind = ProviderKind::Cuda, bool packed_qk = false, + bool omit_mask = false) { auto provider = CreateProvider(provider_kind); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") @@ -729,9 +747,16 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), } test.AddInput("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); - test.AddInput("cos_cache", {batch_size, total, problem.rotary_width}, ToElementType(problem.cos_cache)); - test.AddInput("sin_cache", {batch_size, total, problem.rotary_width}, ToElementType(problem.sin_cache)); - test.AddInput("mask", {batch_size, total}, problem.mask); + const std::vector rotary_cache_shape = problem.shared_rotary_cache + ? std::vector{total, problem.rotary_width} + : std::vector{batch_size, total, problem.rotary_width}; + test.AddInput("cos_cache", rotary_cache_shape, ToElementType(problem.cos_cache)); + test.AddInput("sin_cache", rotary_cache_shape, ToElementType(problem.sin_cache)); + if (omit_mask) { + test.AddOptionalInputEdge(); + } else { + test.AddInput("mask", {batch_size, total}, problem.mask); + } test.AddInput("past_key", {batch_size, problem.PastKeyCapacity(), head_size}, ToElementType(problem.past_key)); for (int slot = sai::kGate; slot < sai::kPastSequenceLength; ++slot) { @@ -754,12 +779,11 @@ CsaProblem MakeCsaProblem(CsaProblem problem = {}) { problem.key = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * width, 0.60f, 0.21f); problem.query_norm_weight = MakeWave(static_cast(problem.head_size), 1.10f, 0.19f); problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.45f, 0.31f); + const size_t cache_batches = problem.shared_rotary_cache ? 1 : static_cast(problem.batch_size); problem.cos_cache = - MakeWave(static_cast(problem.batch_size) * problem.max_rotary_length * problem.rotary_width, 0.15f, - 0.27f); + MakeWave(cache_batches * problem.max_rotary_length * problem.rotary_width, 0.15f, 0.27f); problem.sin_cache = - MakeWave(static_cast(problem.batch_size) * problem.max_rotary_length * problem.rotary_width, 1.05f, - 0.33f); + MakeWave(cache_batches * problem.max_rotary_length * problem.rotary_width, 1.05f, 0.33f); problem.gate = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * width, 0.80f, 0.24f); problem.position_bias = MakeWave(static_cast(problem.compress_ratio) * width, 0.33f, 0.11f); problem.head_weights = MakeWave( @@ -834,10 +858,12 @@ void RunCsaTest(const CsaProblem& base, float tolerance, test.AddInput("key", {batch_size, sequence_length, width}, ToElementType(problem.key)); test.AddInput("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); - test.AddInput("cos_cache", {batch_size, problem.max_rotary_length, problem.rotary_width}, - ToElementType(problem.cos_cache)); - test.AddInput("sin_cache", {batch_size, problem.max_rotary_length, problem.rotary_width}, - ToElementType(problem.sin_cache)); + const std::vector rotary_cache_shape = + problem.shared_rotary_cache + ? std::vector{problem.max_rotary_length, problem.rotary_width} + : std::vector{batch_size, problem.max_rotary_length, problem.rotary_width}; + test.AddInput("cos_cache", rotary_cache_shape, ToElementType(problem.cos_cache)); + test.AddInput("sin_cache", rotary_cache_shape, ToElementType(problem.sin_cache)); test.AddOptionalInputEdge(); test.AddInput("past_key", {batch_size, problem.PastCompressedCapacity(), head_size}, ToElementType(problem.past_compressed_key)); @@ -935,6 +961,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsPackedQk) { {options.batch_size, options.sequence_length, options.token_budget + options.compress_ratio - 1}); } +TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsOmittedMask) { + QsaGraphOptions options; + options.add_mask = false; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); +} + TEST(SparseAttentionIndexerShapeInferenceTest, QsaSharedCacheKeepsCapacity) { QsaGraphOptions options; options.share_cache = true; @@ -948,6 +981,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaSharedCacheKeepsCapacity) { {options.batch_size, options.key_cache_capacity, options.head_size}); } +TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsSharedRotaryCache) { + QsaGraphOptions options; + options.shared_rotary_cache = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); +} + TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { CsaGraphOptions options; std::unique_ptr model; @@ -986,6 +1026,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, CsaSharedCacheKeepsCapacity) { {options.batch_size, options.compressed_cache_capacity, options.head_size}); } +TEST(SparseAttentionIndexerShapeInferenceTest, CsaAcceptsSharedRotaryCache) { + CsaGraphOptions options; + options.shared_rotary_cache = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, model)); +} + #ifndef ORT_NO_EXCEPTIONS namespace { @@ -1131,6 +1178,12 @@ TEST(SparseAttentionIndexerTest, QsaPackedQkFloat) { RunQsaTest(1.0e-5f, MakeQsaProblem(), ProviderKind::Cuda, true); } +TEST(SparseAttentionIndexerTest, QsaMasklessPrefixCausal) { + QsaProblem problem = MakeQsaProblem(); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + TEST(SparseAttentionIndexerTest, QsaFloat16) { RunQsaTest(2.0e-3f); } TEST(SparseAttentionIndexerTest, QsaBFloat16) { RunQsaTest(2.0e-2f); } @@ -1144,6 +1197,155 @@ TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaNonPrefixMask) { + QsaProblem problem = MakeQsaProblem(); + const int total = problem.TotalSequenceLength(); + problem.mask[1] = 0; + problem.mask[total - 1] = 1; + RunQsaTest(1.0e-5f, std::move(problem)); +} + +TEST(SparseAttentionIndexerTest, QsaMultiTileNonPrefixMask) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 299; + problem.token_budget = 64; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 0); + std::fill_n(problem.mask.begin(), 127, 1); + problem.mask[128] = 1; + problem.mask[200] = 1; + RunQsaTest(1.0e-5f, std::move(problem)); +} + +TEST(SparseAttentionIndexerTest, QsaSinglePassTopKLimit) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 300; + problem.token_budget = 64; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + +TEST(SparseAttentionIndexerTest, QsaRepeatedScanTopKFallback) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 300; + problem.token_budget = 66; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + +TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKParity) { + for (const int context_length : {8192, 32768, 65536, 131072, 262144}) { + SCOPED_TRACE("context_length=" + std::to_string(context_length)); + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = context_length - 1; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem = MakeQsaProblem(std::move(problem)); + + std::fill(problem.query.begin(), problem.query.end(), 0.0f); + for (int head = 0; head < problem.num_heads; ++head) { + problem.query[static_cast(head) * problem.head_size] = 1.0f; + } + std::fill(problem.key_norm_weight.begin(), problem.key_norm_weight.end(), 1.0f); + std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); + std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); + std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); + std::fill(problem.key.begin(), problem.key.end(), 0.0f); + + const int block_count = context_length / problem.compress_ratio; + for (int position = 0; position < context_length; ++position) { + const float rank = static_cast(position / problem.compress_ratio + 1) / + static_cast(block_count); + std::vector& cache = position < problem.past_sequence_length ? problem.past_key : problem.key; + const int cache_position = position < problem.past_sequence_length ? position : 0; + const size_t offset = static_cast(cache_position) * problem.head_size; + cache[offset] = rank; + cache[offset + 1] = 1.0f; + } + + RunQsaTest(1.0e-5f, std::move(problem)); + } +} + +TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKTies) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 8191; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem.scale = 0.0f; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + +template +void RunQsaQwenSpecializedScoreAndPartialTopK(float tolerance) { + QsaProblem problem; + problem.batch_size = 2; + problem.sequence_length = 2; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 4094; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(tolerance, std::move(problem), ProviderKind::Cuda, false, true); +} + +TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKFloat) { + RunQsaQwenSpecializedScoreAndPartialTopK(1.0e-5f); +} + +TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKFloat16) { + RunQsaQwenSpecializedScoreAndPartialTopK(2.0e-3f); +} + +TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKBFloat16) { + RunQsaQwenSpecializedScoreAndPartialTopK(2.0e-2f); +} + +TEST(SparseAttentionIndexerTest, QsaQwenFixedCapacityRawCacheDistributedTopK) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 8191; + problem.key_cache_capacity = 8192; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + +TEST(SparseAttentionIndexerTest, QsaQwenDistributedPartialTopKTies) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 65535; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + std::fill(problem.query.begin(), problem.query.end(), 0.0f); + std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); + std::fill(problem.key.begin(), problem.key.end(), 0.0f); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; @@ -1156,6 +1358,12 @@ TEST(SparseAttentionIndexerTest, QsaSharedCacheCapacity) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaSharedRotaryCache) { + QsaProblem problem; + problem.shared_rotary_cache = true; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } @@ -1225,6 +1433,12 @@ TEST(SparseAttentionIndexerTest, CsaSharedCacheCapacity) { RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f); } +TEST(SparseAttentionIndexerTest, CsaSharedRotaryCache) { + CsaProblem problem; + problem.shared_rotary_cache = true; + RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f); +} + TEST(SparseAttentionIndexerTest, CsaExplicitZeroScales) { CsaProblem problem = MakeCsaProblem(); problem.scale = 0.0f; @@ -1256,6 +1470,12 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaPackedQkFloat) { RunQsaTest(1.0e-5f, MakeQsaProblem(), ProviderKind::WebGpu, true); } +TEST(SparseAttentionIndexerWebGpuTest, QsaMasklessPrefixCausal) { + QsaProblem problem = MakeQsaProblem(); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::WebGpu, false, true); +} + TEST(SparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaTest(4.0e-3f, MakeQsaProblem(), ProviderKind::WebGpu); } @@ -1266,6 +1486,12 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaExplicitZeroScale) { RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, QsaSharedRotaryCache) { + QsaProblem problem; + problem.shared_rotary_cache = true; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem)), ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, QsaInt64HighWordMask) { QsaProblem problem = MakeQsaProblem(); for (int64_t& mask_value : problem.mask) { @@ -1284,6 +1510,12 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 6.0e-3f, ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, CsaSharedRotaryCache) { + CsaProblem problem; + problem.shared_rotary_cache = true; + RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f, ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaFloat16ScoresNewKeysBeforeCacheRounding) { CsaProblem problem; problem.batch_size = 1; diff --git a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py index 425a97c92c196..fceb590dbe242 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -483,8 +483,8 @@ def test_packed_sparse_attention_indexer_qsa(self): node = helper.make_node( "PackedSparseAttentionIndexer", [ - "query", - "key", + "query_key", + "", "query_norm_weight", "key_norm_weight", "cos_cache", @@ -499,6 +499,8 @@ def test_packed_sparse_attention_indexer_qsa(self): "past_kv_buffer", "", "past_state_lengths", + "state_update_capture_count", + "state_update_active", ], [ "selected_indices", @@ -507,16 +509,17 @@ def test_packed_sparse_attention_indexer_qsa(self): "present_kv_buffer", "", "present_state_lengths", + "state_update", ], domain="com.microsoft", policy_mode="qsa", compress_ratio=4, state_capacity=5, token_budget=8, + state_update_capacity=4, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["total_tokens", 16]), - helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["total_tokens", 8]), + helper.make_tensor_value_info("query_key", TensorProto.FLOAT16, ["total_tokens", 24]), helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, [64, 8]), @@ -526,6 +529,8 @@ def test_packed_sparse_attention_indexer_qsa(self): helper.make_tensor_value_info("past_key_state", TensorProto.FLOAT16, [2, 5, 8]), helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT16, [2, 7, 8]), helper.make_tensor_value_info("past_state_lengths", TensorProto.INT32, [2, 2]), + helper.make_tensor_value_info("state_update_capture_count", TensorProto.INT32, [2]), + helper.make_tensor_value_info("state_update_active", TensorProto.INT32, [1]), ] inferred = self._infer_packed_sparse_attention_indexer(node, inputs) @@ -539,6 +544,8 @@ def test_packed_sparse_attention_indexer_qsa(self): self.assertEqual(self._tensor_shape(outputs["present_kv_buffer"]), [2, 7, 8]) self.assertEqual(self._tensor_shape(outputs["present_state_lengths"]), [2, 2]) self.assertEqual(outputs["present_state_lengths"].type.tensor_type.elem_type, TensorProto.INT32) + self.assertEqual(self._tensor_shape(outputs["state_update"]), [2, 4, 8]) + self.assertEqual(outputs["state_update"].type.tensor_type.elem_type, TensorProto.FLOAT16) def test_packed_sparse_attention_indexer_csa(self): node = helper.make_node(