From 3e309f4fd3255d79b600da42358fa6ef4999089e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:32:20 +0000 Subject: [PATCH 01/16] Implement SparseAttentionIndexer CUDA operator Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/OperatorKernels.md | 1 + .../cuda/sparse_attention_indexer.md | 364 +++++++ .../sparse/sparse_attention_indexer_common.h | 114 +++ .../contrib_ops/cuda/cuda_contrib_kernels.cc | 6 + .../cuda/sparse/sparse_attention_indexer.cc | 343 +++++++ .../cuda/sparse/sparse_attention_indexer.h | 35 + .../sparse/sparse_attention_indexer_impl.cu | 725 ++++++++++++++ .../sparse/sparse_attention_indexer_impl.h | 91 ++ .../core/graph/contrib_ops/bert_defs.cc | 395 ++++++++ onnxruntime/core/graph/contrib_ops/ms_opset.h | 2 + .../python/tools/symbolic_shape_infer.py | 74 ++ .../sparse_attention_indexer_op_test.cc | 927 ++++++++++++++++++ 12 files changed, 3077 insertions(+) create mode 100644 docs/contrib_ops/cuda/sparse_attention_indexer.md create mode 100644 onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h create mode 100644 onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index de7882c5076f7..56368f439b128 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1143,6 +1143,7 @@ The **OpSet Version** column uses the following notation: |SkipLayerNormalization|*in* input:**T**
*in* skip:**T**
*in* gamma:**T**
*in* beta:**T**
*in* bias:**T**
*out* output:**T**
*out* mean:**U**
*out* inv_std_var:**U**
*out* input_skip_bias_sum:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| |SkipSimplifiedLayerNormalization|*in* input:**T**
*in* skip:**T**
*in* gamma:**T**
*in* bias:**T**
*out* output:**T**
*out* mean:**U**
*out* inv_std_var:**U**
*out* input_skip_bias_sum:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| |SparseAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* past_key:**T**
*in* past_value:**T**
*in* block_row_indices:**M**
*in* block_col_indices:**M**
*in* total_sequence_length:**M**
*in* key_total_sequence_lengths:**M**
*in* cos_cache:**T**
*in* sin_cache:**T**
*out* output:**T**
*out* present_key:**T**
*out* present_value:**T**|1+|**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float16)| +|SparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* mask:**TB**
*in* past_key:**T**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_compressed_key:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*out* selected_indices:**M**
*out* present_key:**T**
*out* present_compressed_key:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)
**TB** = tensor(bool)| |TransposeMatMul|*in* A:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(double), tensor(float), tensor(float16)| |Trilu|*in* X:**T**
*in* k:**tensor(int64)**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(bool), tensor(double), tensor(float), tensor(float16), tensor(int16), tensor(int32), tensor(int64), tensor(int8), tensor(uint16), tensor(uint32), tensor(uint64), tensor(uint8)| |UnfoldTensor|*in* input:**T**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(bool), tensor(double), tensor(float), tensor(float16), tensor(int16), tensor(int32), tensor(int64), tensor(int8), tensor(string), tensor(uint16), tensor(uint32), tensor(uint64), tensor(uint8)| diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md new file mode 100644 index 0000000000000..c3c2b422957a6 --- /dev/null +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -0,0 +1,364 @@ +# SparseAttentionIndexer — Operator Documentation + +This document describes the `com.microsoft::SparseAttentionIndexer` contrib operator: the two +indexer policies it implements, the schema and state contract, the CUDA kernel pipeline, and the +known limitations and performance follow-ups. + +The operator answers a single question for every query token: *which* keys is the following +attention operator allowed to read. It does not compute attention itself. Two policies are +supported, selected by the `policy_mode` attribute: + +| `policy_mode` | Reference implementation | Selection granularity | +|---|---|---| +| `qsa` | `Qwen4ExpTextQSAIndexer` | token indices, grouped in blocks of `compress_ratio` | +| `csa` | `DeepseekV4Indexer` / `DeepseekV4IndexerScorer` | compressed-entry indices | + +Source: +[bert_defs.cc](../../../onnxruntime/core/graph/contrib_ops/bert_defs.cc) (schema), +[sparse_attention_indexer_common.h](../../../onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h) +(slot map, capacity and window plan shared by the schema, the kernel and the tests), +[sparse_attention_indexer.cc](../../../onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc), +[sparse_attention_indexer_impl.cu](../../../onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu). + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Operator Schema](#2-operator-schema) +3. [State Contract](#3-state-contract) +4. [Policy `qsa`](#4-policy-qsa) +5. [Policy `csa`](#5-policy-csa) +6. [Rotary Embeddings](#6-rotary-embeddings) +7. [CUDA Kernel Pipeline](#7-cuda-kernel-pipeline) +8. [Numerics and Determinism](#8-numerics-and-determinism) +9. [Validation Rules](#9-validation-rules) +10. [Testing](#10-testing) +11. [Known Limitations and Performance Follow-ups](#11-known-limitations-and-performance-follow-ups) + +--- + +## 1. Overview + +Sparse-attention decoders run a small, cheap "indexer" attention next to the real attention. The +indexer has its own low-rank query/key projections, its own RMSNorm and its own rotary embedding, +and its only product is a set of indices. The real attention then reads just those keys. + +`SparseAttentionIndexer` implements that stage as one operator with three properties that matter +for a runtime: + +- **Fixed output capacity.** `selected_indices` always has shape + `(batch_size, sequence_length, capacity)` where `capacity` is a pure function of the attributes + (`token_budget + compress_ratio - 1` for `qsa`, `index_topk` for `csa`). Entries that are not + used are `-1`. No output size depends on tensor *data*, so the kernel never copies a count back + to the host and never allocates from a device-computed size. +- **Explicit, graph-visible state.** Everything that survives between calls — the concatenated + indexer key cache, the compressed-key cache and the partial-window buffers — is an input/output + pair. The operator caches nothing internally. +- **A single versioned schema.** Both policies share one schema; the policy-specific inputs and + outputs are optional slots at fixed indices and are validated strictly (see + [§9](#9-validation-rules)). + +## 2. Operator Schema + +### Attributes + +| Attribute | Type | Required | Description | +|---|---|---|---| +| `policy_mode` | string | yes | Exactly `qsa` or `csa`. Any other value is rejected. | +| `compress_ratio` | int | yes | Tokens folded into one block/entry. Must be `> 0`. | +| `token_budget` | int | `qsa` only | Maximum number of tokens taken from complete blocks. Must be `> 0` and divisible by `compress_ratio`. Must be absent for `csa`. | +| `index_topk` | int | `csa` only | Number of compressed entries selected per query. Must be `> 0`. Must be absent for `qsa`. | +| `epsilon` | float | no | RMSNorm epsilon of the compressed key. Default `1e-6`. | +| `scale` | float | no | Scale of the per-head ReLU score. Default `1/sqrt(head_size)`. | +| `head_weight_scale` | float | `csa` only | Scale applied to `head_weights`. Default `1/sqrt(num_heads)`. Must be absent for `qsa`. | + +### Inputs + +`B` = batch size, `S` = sequence length, `N` = `num_heads`, `D` = `head_size`, +`r` = `compress_ratio`, `P` = past sequence length, `T = P + S`, `R` = rotary width. + +| # | Name | Policy | Type | Shape | +|---|---|---|---|---| +| 0 | `query` | both | `T` | `(B, S, N, D)` | +| 1 | `key` | both | `T` | `(B, S, D)` for `qsa`, `(B, S, 2D)` for `csa` | +| 2 | `key_norm_weight` | both | `T` | `(D)` | +| 3 | `cos_cache` | both | `T` | `(B, max_rotary_sequence_length, R)` | +| 4 | `sin_cache` | both | `T` | same as `cos_cache` | +| 5 | `mask` | `qsa` | `TB` | `(B, 1, S, T)` or `(B, S, T)` | +| 6 | `past_key` | `qsa` | `T` | `(B, P, D)` | +| 7 | `gate` | `csa` | `T` | `(B, S, 2D)` | +| 8 | `position_bias` | `csa` | `T` | `(r, 2D)` | +| 9 | `head_weights` | `csa` | `T` | `(B, S, N)` | +| 10 | `position_ids` | `csa` | `I` | `(B, S)` | +| 11 | `past_compressed_key` | `csa` | `T` | `(B, Pc, D)` | +| 12 | `past_kv_buffer` | `csa` | `T` | `(B, Lb, 2D)`, `Lb` in `[0, 2r)` | +| 13 | `past_gate_buffer` | `csa` | `T` | same as `past_kv_buffer` | + +### Outputs + +| # | Name | Policy | Type | Shape | +|---|---|---|---|---| +| 0 | `selected_indices` | both | `M` | `(B, S, capacity)` | +| 1 | `present_key` | `qsa` | `T` | `(B, T, D)` | +| 2 | `present_compressed_key` | `csa` | `T` | `(B, Pc + W, D)` | +| 3 | `present_kv_buffer` | `csa` | `T` | `(B, Lb', 2D)` | +| 4 | `present_gate_buffer` | `csa` | `T` | same as `present_kv_buffer` | + +`W` is the number of complete windows closed by this call and `Lb'` the new buffer length; both +follow from `Lb`, `S` and `r` alone (see [§5](#5-policy-csa)). + +### Type constraints + +| Name | Allowed types | +|---|---| +| `T` | `tensor(float)`, `tensor(float16)`, `tensor(bfloat16)` | +| `TB` | `tensor(bool)` | +| `I` | `tensor(int64)` | +| `M` | `tensor(int32)` | + +Only the CUDA execution provider registers a kernel. There is no CPU kernel; the header under +`contrib_ops/cpu/sparse/` only holds the CUDA-free constants that the schema, the kernel and the +tests must agree on. + +### Output slot discipline + +A `qsa` node declares exactly 2 outputs (`selected_indices`, `present_key`). A `csa` node declares +exactly 5, leaving slot 1 as a missing optional so that the three `csa` state outputs keep their +fixed indices. Shape inference verifies the declared output count *before* touching any output, so +`getOutputType` is never called past the declared range. + +## 3. State Contract + +`SparseAttentionIndexer` is a pure function of its inputs. Every value it needs on the next call is +returned as an output, so the caller (or the ORT session binding) owns the buffers: + +| Policy | State pair | +|---|---| +| `qsa` | `past_key` → `present_key` | +| `csa` | `past_compressed_key` → `present_compressed_key` | +| `csa` | `past_kv_buffer` → `present_kv_buffer` | +| `csa` | `past_gate_buffer` → `present_gate_buffer` | + +`present_key` and `present_compressed_key` grow by a number of positions that is known from the +input shapes and the attributes, so the graph can pre-allocate them. The two `csa` buffers stay +bounded by `2r - 1` positions. + +## 4. Policy `qsa` + +For every `(b, s)`: + +1. **Visible set.** `visible = [t for t in range(T) if mask[b, s, t]]`, in ascending `t`. +2. **Complete blocks.** `nblocks = len(visible) // r`. Block `j` covers + `visible[j*r : (j+1)*r]`. +3. **Pooled key.** `k_j = mean` of the `r` raw `present_key` rows of block `j`, then + `RMSNorm(k_j) * key_norm_weight`, then rotary at absolute position `visible[j*r]`. +4. **Score.** `score_j = scale * sum_h ReLU(q_h · k_j)` where `q_h` is the rotated query head + at absolute position `P + s`. +5. **Selection.** `topk = min(token_budget // r, nblocks)` blocks, ordered by decreasing score. + Their tokens are emitted in that block order, `r` token indices per block. +6. **Tail.** The `len(visible) % r` tokens of the trailing incomplete block, + `visible[nblocks*r:]`, are appended unconditionally. +7. Remaining entries up to `capacity = token_budget + r - 1` are `-1`. + +The capacity is exactly `token_budget` selected tokens plus at most `r - 1` tail tokens. + +## 5. Policy `csa` + +### Window bookkeeping + +Let `ext = concat(past_kv_buffer, key)` along the sequence axis (and likewise for the gates). +The buffer is split as + +``` +overlap = (Lb >= r) ? r : 0 # previous complete window, the "Ca" source +leftover = Lb - overlap # tokens of the still-open window, always < r +pending = leftover + S +W = pending / r # complete windows closed by this call +``` + +Window `w` (`0 <= w < W`) covers `ext[overlap + w*r : overlap + (w+1)*r]`. Its `Ca` partner is the +preceding `r` tokens, which exist iff `w >= 1 || overlap == r`. Because `leftover < r` always holds, +a buffer length `>= r` unambiguously means "the previous complete window is present" — no extra +state tensor is needed to disambiguate. + +The new buffer starts at `overlap + (W-1)*r` when `W > 0` (the last complete window followed by the +new leftover) and at `0` otherwise, giving + +``` +Lb' = (W > 0) ? r + pending % r : Lb + S +``` + +`Lb'` is again in `[0, 2r)`. + +### Compression + +For window `w`, a `2r`-slot pooling window is built from `[B, 2r, D]` values and `[B, 2r, D]` gates: + +- slots `[r, 2r)` take the **`Cb`** half (channels `[D, 2D)`) of the window's own `r` tokens; +- slots `[0, r)` take the **`Ca`** half (channels `[0, D)`) of the preceding `r` tokens, or are + masked out (value `0`, gate `-inf`) when that window does not exist. + +`position_bias[slot % r]` is added to the raw gate (the buffers store the **raw** projection, so +the bias is re-applied at use time and never accumulates). A softmax over the `2r` slots is taken +**per channel `d`**, the values are weighted and summed, the result is RMS-normalized with +`key_norm_weight`, and finally rotated at absolute position `(Pc + w) * r`. + +### Scoring and selection + +``` +scores[b, s, e] = head_weight_scale * sum_h head_weights[b, s, h] * scale * ReLU(q_h · k_e) +threshold[b, s] = (position_ids[b, s] + 1) / r # integer division +scores[b, s, e] = -inf for e >= threshold[b, s] +``` + +The top `min(index_topk, Pc + W)` entries are emitted in decreasing score order; any emitted entry +whose index is `>= threshold` (which only happens when fewer than `index_topk` entries are +visible) is written as `-1`, and the remaining capacity is `-1`. + +## 6. Rotary Embeddings + +Both policies reuse the model's precomputed `cos_cache` / `sin_cache`, indexed by absolute +position. This keeps the exact rotary variant of each reference model without re-deriving +frequencies inside the kernel: + +- **`qsa` (leading, split-half).** `R = cos_cache.shape[2]` channels are rotated, and the rotation + is the split-half form + `out[i] = x[i]*cos[i] - x[i + R/2]*sin[i]`, `out[i + R/2] = x[i + R/2]*cos[i + R/2] + x[i]*sin[i + R/2]`, + applied to channels `[0, R)`. Channels `[R, D)` pass through. Feeding an MRoPE-expanded + `cos_cache`/`sin_cache` therefore reproduces the model's MRoPE exactly, because the operator + never recomputes the position→angle mapping. +- **`csa` (trailing, interleaved).** `2R` channels are rotated and they are the **last** `2R` + channels of the head. `cos`/`sin` are half-width, so entry `j >> 1` covers the channel pair + `(j, j+1)`, and the rotation is the interleaved form + `out[2i] = x[2i]*cos[i] - x[2i+1]*sin[i]`, `out[2i+1] = x[2i+1]*cos[i] + x[2i]*sin[i]`. + +Positions are clamped into `[0, max_rotary_sequence_length - 1]` inside the kernel, so an +out-of-range `position_ids` value cannot read out of bounds. + +### `key_norm_weight` and zero-centered gamma + +`key_norm_weight` is the **effective** multiplier: the kernel computes +`normalized * key_norm_weight`. Qwen's `Qwen4ExpTextRMSNorm` multiplies by `1 + gamma`. Exporters +targeting that model must fold the addition into the initializer (`key_norm_weight = 1 + gamma`). +DeepSeek's `DeepseekV4RMSNorm` uses a plain `weight *`, so its tensor is passed through unchanged. + +## 7. CUDA Kernel Pipeline + +All kernels use a fixed block of 128 threads (a power of two, required by the shared-memory block +reductions). Grid sizes are clamped to 65535 blocks and the elementwise kernels use grid-stride +loops, so no launch configuration depends on tensor data. + +### `qsa` + +| Stage | Kernel | Parallelism | +|---|---|---| +| 1 | `ConcatPastKeyKernel` | element | +| 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 | + +### `csa` + +| Stage | Kernel | Parallelism | +|---|---|---| +| 1 | `CsaCopyPastCompressedKernel` | element | +| 2 | `CsaCompressKernel` | one block per `(b, window)`; per-channel softmax over `2r` slots | +| 3 | `CsaCopyBufferKernel` | element | +| 4 | `RotateQueryKernel` | one block per `(b, s, h)` | +| 5 | `CsaScoreKernel` | one block per `(b, s, entry)` | +| 6 | `CsaSelectKernel` | one block per `(b, s)`; iterated block arg-max | + +### Workspaces + +| 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) | +| `csa` | float | `B*S*N*D` (rotated query) + `B*S*present_compressed_length` (scores) | + +Every size is derived from shapes and attributes only. + +### Selection without a visited bitmap + +`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. + +## 8. Numerics and Determinism + +- Pooling, softmax, RMS normalization, rotary and scoring are all done in `float32`; only the final + store is rounded to the tensor element type. This is a deliberate deviation from the reference + implementations, which for `qsa` run in the model dtype — the operator is strictly more accurate, + never less. +- The `2r`-slot softmax is a two-pass (max-subtracted) formulation, so a fully masked slot column + cannot produce `NaN`. +- Selection order is a total order, so the output is bitwise reproducible for a given input. +- `float16` and `bfloat16` are supported for all `T` tensors. `bfloat16` uses the conversion + helpers in `cu_inc/cuda_type_helper.cuh`, which are emulated on pre-`sm_80` devices, so no + architecture guard is required. + +## 9. Validation Rules + +Shape inference and the kernel both reject: + +- a `policy_mode` other than `qsa` / `csa`; +- `compress_ratio <= 0`; +- a `qsa` node that provides any `csa`-only input (slots 7–13) or attribute, and vice versa; +- a missing required input for the active policy; +- `token_budget` absent, `<= 0`, or not divisible by `compress_ratio` for `qsa`; +- `index_topk` absent or `<= 0` for `csa`; +- an output count other than 2 (`qsa`) or 5 (`csa`); +- `past_kv_buffer` / `past_gate_buffer` lengths outside `[0, 2 * compress_ratio)` or differing from + each other; +- rank or dimension mismatches between `query`, `key`, the caches and the buffers. + +Because the checks live in shape inference, most misuse fails at `Graph::Resolve()` with a clear +message rather than at kernel launch. + +## 10. Testing + +[`onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc`](../../../onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc) +contains: + +- **Shape-inference tests** that build a real `Model`, run `Graph::Resolve()` and assert the + inferred element type and shape of every output for both policies, including the buffer-only + `csa` case (`W == 0`). +- **Negative tests** that assert the exact validation message for each rule in + [§9](#9-validation-rules), including a `csa` node that declares too few outputs — the case that + would otherwise write past the declared output range. +- **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. + +Run them with: + +```bash +./build/Linux/Release/onnxruntime_provider_test --gtest_filter='SparseAttentionIndexer*' +``` + +## 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 the mask for every `s`. For long + contexts a batched exclusive scan over the whole `(B, S, T)` mask would be cheaper. +5. **`present_key` / `present_compressed_key` are copied every call.** An in-place cache with a + `past_sequence_length` input (as `GroupQueryAttention` does) would avoid the copy, at the cost of + a less explicit state contract. +6. **`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. +7. **No CPU kernel.** The operator is CUDA-only today. diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h new file mode 100644 index 0000000000000..d9eb3d1671cca --- /dev/null +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include +#include + +namespace onnxruntime { +namespace contrib { +namespace sparse_attention_indexer { + +// Values accepted by the policy_mode attribute of com.microsoft.SparseAttentionIndexer. +constexpr const char* kPolicyModeQsa = "qsa"; +constexpr const char* kPolicyModeCsa = "csa"; + +enum class Policy { + kQsa, + kCsa, +}; + +inline bool TryParsePolicy(const std::string& policy_mode, Policy& policy) { + if (policy_mode == kPolicyModeQsa) { + policy = Policy::kQsa; + return true; + } + if (policy_mode == kPolicyModeCsa) { + policy = Policy::kCsa; + return true; + } + return false; +} + +// Input slots. Slots 5-6 belong to policy_mode="qsa" and slots 7-13 to policy_mode="csa"; +// a slot that does not belong to the active policy must be omitted from the node. +enum InputIndex : int { + kQuery = 0, + kKey = 1, + kKeyNormWeight = 2, + kCosCache = 3, + kSinCache = 4, + kMask = 5, + kPastKey = 6, + kGate = 7, + kPositionBias = 8, + kHeadWeights = 9, + kPositionIds = 10, + kPastCompressedKey = 11, + kPastKvBuffer = 12, + kPastGateBuffer = 13, + kInputCount = 14, +}; + +// Output slots. Slot 1 belongs to policy_mode="qsa" and slots 2-4 to policy_mode="csa". +enum OutputIndex : int { + kSelectedIndices = 0, + kPresentKey = 1, + kPresentCompressedKey = 2, + kPresentKvBuffer = 3, + kPresentGateBuffer = 4, + kOutputCount = 5, +}; + +// A "qsa" node declares selected_indices + present_key; a "csa" node declares every slot so that +// the three csa state outputs keep their fixed indices (slot 1 is left as a missing optional). +constexpr int kQsaOutputCount = 2; +constexpr int kCsaOutputCount = 5; + +// Number of selected entries emitted per query. The capacity only depends on attributes, so it is +// a compile-time constant of the graph rather than a function of the data. +inline int64_t SelectedCapacity(Policy policy, int64_t token_budget, int64_t index_topk, + int64_t compress_ratio) { + return policy == Policy::kQsa ? token_budget + compress_ratio - 1 : index_topk; +} + +// How the "csa" token buffer is split and how many new compressed entries this call emits. +// +// past_kv_buffer / past_gate_buffer carry `overlap_length` tokens of the previous complete window +// (the Ca operand of the next window) followed by `leftover_length` tokens of an incomplete window. +// Since `leftover_length` is always < compress_ratio, a buffer length >= compress_ratio uniquely +// means "the previous complete window is present", so no extra state tensor is needed. +struct CsaWindowPlan { + int64_t overlap_length = 0; // compress_ratio, or 0 before the first complete window + int64_t leftover_length = 0; // tokens of the current incomplete window + int64_t new_window_count = 0; // complete windows closed by this call + int64_t present_buffer_length = 0; // length of present_kv_buffer / present_gate_buffer + int64_t present_buffer_start = 0; // offset of that buffer inside [past buffer | new tokens] +}; + +inline bool TryComputeCsaWindowPlan(int64_t past_buffer_length, int64_t sequence_length, + int64_t compress_ratio, CsaWindowPlan& plan) { + if (compress_ratio <= 0 || sequence_length < 0 || past_buffer_length < 0 || + past_buffer_length >= 2 * compress_ratio) { + return false; + } + + plan.overlap_length = past_buffer_length >= compress_ratio ? compress_ratio : 0; + plan.leftover_length = past_buffer_length - plan.overlap_length; + + const int64_t pending = plan.leftover_length + sequence_length; + plan.new_window_count = pending / compress_ratio; + if (plan.new_window_count > 0) { + plan.present_buffer_length = compress_ratio + pending % compress_ratio; + plan.present_buffer_start = plan.overlap_length + (plan.new_window_count - 1) * compress_ratio; + } else { + plan.present_buffer_length = past_buffer_length + sequence_length; + plan.present_buffer_start = 0; + } + return true; +} + +} // namespace sparse_attention_indexer +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc b/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc index 95d81e53a3d49..03529ea5eb417 100644 --- a/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc +++ b/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc @@ -249,6 +249,9 @@ class CUDA_MS_OP_TYPED_CLASS_NAME(1, MLFloat16_MLFloat16, DecoderMaskedMultiHead class CUDA_MS_OP_CLASS_NAME(1, GemmFloat8); class CUDA_MS_OP_TYPED_CLASS_NAME(1, MLFloat16, SparseAttention); class CUDA_MS_OP_TYPED_CLASS_NAME(1, BFloat16, SparseAttention); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, float, SparseAttentionIndexer); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, MLFloat16, SparseAttentionIndexer); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, BFloat16, SparseAttentionIndexer); class CUDA_MS_OP_THREE_TYPED_CLASS_NAME(1, uint8_t, float, int32_t, GatherBlockQuantized); class CUDA_MS_OP_THREE_TYPED_CLASS_NAME(1, uint8_t, MLFloat16, int32_t, GatherBlockQuantized); @@ -551,6 +554,9 @@ Status RegisterCudaContribKernels(KernelRegistry& kernel_registry) { BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, + BuildKernelCreateInfo, + BuildKernelCreateInfo, + BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc new file mode 100644 index 0000000000000..5a3d84eacb7a6 --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer.h" + +#include +#include + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" +#include "core/providers/cuda/cuda_common.h" +#include "core/providers/cuda/cuda_type_conversion.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +using namespace onnxruntime::cuda; +namespace sai = onnxruntime::contrib::sparse_attention_indexer; + +#define REGISTER_KERNEL_TYPED(T) \ + ONNX_OPERATOR_TYPED_KERNEL_EX( \ + SparseAttentionIndexer, \ + kMSDomain, \ + 1, \ + T, \ + kCudaExecutionProvider, \ + (*KernelDefBuilder::Create()) \ + .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("TB", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("M", DataTypeImpl::GetTensorType()), \ + SparseAttentionIndexer); + +REGISTER_KERNEL_TYPED(float) +REGISTER_KERNEL_TYPED(MLFloat16) +REGISTER_KERNEL_TYPED(BFloat16) + +#undef REGISTER_KERNEL_TYPED + +namespace { + +Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list expected) { + ORT_RETURN_IF(tensor == nullptr, "SparseAttentionIndexer: ", name, " is required"); + const TensorShape expected_shape(expected); + ORT_RETURN_IF_NOT(tensor->Shape() == expected_shape, "SparseAttentionIndexer: ", name, " must have shape ", + expected_shape.ToString(), ", got ", tensor->Shape().ToString()); + return Status::OK(); +} + +} // namespace + +template +SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : CudaKernel(info) { + std::string policy_mode; + ORT_ENFORCE(info.GetAttr("policy_mode", &policy_mode).IsOK(), + "SparseAttentionIndexer: policy_mode is required"); + ORT_ENFORCE(sai::TryParsePolicy(policy_mode, policy_), "SparseAttentionIndexer: policy_mode must be '", + sai::kPolicyModeQsa, "' or '", sai::kPolicyModeCsa, "', got '", policy_mode, "'"); + + ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), + "SparseAttentionIndexer: compress_ratio is required"); + ORT_ENFORCE(compress_ratio_ > 0, "SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio_); + + const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); + const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); + float head_weight_scale = 0.0f; + const bool has_head_weight_scale = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); + + if (policy_ == sai::Policy::kQsa) { + ORT_ENFORCE(has_token_budget, "SparseAttentionIndexer: token_budget is required when policy_mode is 'qsa'"); + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale, + "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); + ORT_ENFORCE(token_budget_ > 0 && token_budget_ % compress_ratio_ == 0, + "SparseAttentionIndexer: token_budget must be > 0 and divisible by compress_ratio, got token_budget=", + token_budget_, " compress_ratio=", compress_ratio_); + index_topk_ = 0; + } else { + ORT_ENFORCE(has_index_topk, "SparseAttentionIndexer: index_topk is required when policy_mode is 'csa'"); + ORT_ENFORCE(!has_token_budget, "SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); + ORT_ENFORCE(index_topk_ > 0, "SparseAttentionIndexer: index_topk must be > 0, got ", index_topk_); + token_budget_ = 0; + } + + epsilon_ = info.GetAttrOrDefault("epsilon", 1.0e-6f); + ORT_ENFORCE(epsilon_ >= 0.0f, "SparseAttentionIndexer: epsilon must be >= 0, got ", epsilon_); + scale_ = info.GetAttrOrDefault("scale", 0.0f); + head_weight_scale_ = has_head_weight_scale ? head_weight_scale : 0.0f; +} + +template +Status SparseAttentionIndexer::ComputeInternal(OpKernelContext* context) const { + const bool is_qsa = policy_ == sai::Policy::kQsa; + for (int index = sai::kMask; index < sai::kInputCount; ++index) { + const bool policy_owns_slot = is_qsa ? (index <= sai::kPastKey) : (index >= sai::kGate); + 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, "'"); + } + + return is_qsa ? ComputeQsa(context) : ComputeCsa(context); +} + +template +Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { + using CudaT = typename OrtToCudaType::type; + + const Tensor* query = context->Input(sai::kQuery); + const Tensor* key = context->Input(sai::kKey); + const Tensor* key_norm_weight = context->Input(sai::kKeyNormWeight); + const Tensor* cos_cache = context->Input(sai::kCosCache); + const Tensor* sin_cache = context->Input(sai::kSinCache); + const Tensor* mask = context->Input(sai::kMask); + const Tensor* past_key = context->Input(sai::kPastKey); + + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, + "SparseAttentionIndexer: query must have shape (batch_size, sequence_length, num_heads, head_size)" + ", got ", + query_shape.ToString()); + const int64_t batch_size = query_shape[0]; + const int64_t sequence_length = query_shape[1]; + const int64_t num_heads = query_shape[2]; + const int64_t head_size = query_shape[3]; + + 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_NOT(sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as " + "cos_cache"); + 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); + + const auto& past_shape = past_key->Shape(); + ORT_RETURN_IF_NOT(past_shape.NumDimensions() == 3 && past_shape[0] == batch_size && past_shape[2] == head_size, + "SparseAttentionIndexer: past_key must have shape (batch_size, past_sequence_length, head_size)" + ", got ", + past_shape.ToString()); + const int64_t past_sequence_length = past_shape[1]; + const int64_t total_sequence_length = past_sequence_length + sequence_length; + + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); + + const auto& mask_shape = mask->Shape(); + const bool mask_is_4d = mask_shape.NumDimensions() == 4; + ORT_RETURN_IF_NOT( + (mask_is_4d && mask_shape[0] == batch_size && mask_shape[1] == 1 && mask_shape[2] == sequence_length && + mask_shape[3] == total_sequence_length) || + (mask_shape.NumDimensions() == 3 && mask_shape[0] == batch_size && mask_shape[1] == sequence_length && + mask_shape[2] == total_sequence_length), + "SparseAttentionIndexer: mask must have shape (batch_size, 1, sequence_length, total_sequence_length) or " + "(batch_size, sequence_length, total_sequence_length) with total_sequence_length=", + total_sequence_length, ", got ", mask_shape.ToString()); + + SparseAttentionIndexerParams params; + params.batch_size = static_cast(batch_size); + params.sequence_length = static_cast(sequence_length); + params.num_heads = static_cast(num_heads); + params.head_size = static_cast(head_size); + params.rotary_width = static_cast(rotary_width); + params.max_rotary_length = static_cast(max_rotary_length); + params.compress_ratio = static_cast(compress_ratio_); + params.capacity = static_cast( + sai::SelectedCapacity(sai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_)); + params.epsilon = epsilon_; + params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.past_sequence_length = static_cast(past_sequence_length); + params.total_sequence_length = static_cast(total_sequence_length); + params.max_block_count = static_cast(total_sequence_length / compress_ratio_); + params.block_topk = static_cast(token_budget_ / compress_ratio_); + + Tensor* selected_indices = context->Output(sai::kSelectedIndices, + TensorShape({batch_size, sequence_length, params.capacity})); + Tensor* present_key = context->Output(sai::kPresentKey, TensorShape({batch_size, total_sequence_length, head_size})); + ORT_RETURN_IF(selected_indices == nullptr || present_key == nullptr, + "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); + + auto float_workspace = GetScratchBuffer(GetQsaWorkspaceFloatCount(params), context->GetComputeStream()); + auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), context->GetComputeStream()); + + return LaunchQsaSparseAttentionIndexer( + Stream(context), params, + reinterpret_cast(query->Data()), + reinterpret_cast(key->Data()), + reinterpret_cast(key_norm_weight->Data()), + reinterpret_cast(cos_cache->Data()), + reinterpret_cast(sin_cache->Data()), + mask->Data(), + reinterpret_cast(past_key->Data()), + selected_indices->MutableData(), + reinterpret_cast(present_key->MutableData()), + float_workspace.get(), + int_workspace.get()); +} + +template +Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { + using CudaT = typename OrtToCudaType::type; + + const Tensor* query = context->Input(sai::kQuery); + const Tensor* key = context->Input(sai::kKey); + const Tensor* key_norm_weight = context->Input(sai::kKeyNormWeight); + const Tensor* cos_cache = context->Input(sai::kCosCache); + const Tensor* sin_cache = context->Input(sai::kSinCache); + const Tensor* gate = context->Input(sai::kGate); + const Tensor* position_bias = context->Input(sai::kPositionBias); + const Tensor* head_weights = context->Input(sai::kHeadWeights); + const Tensor* position_ids = context->Input(sai::kPositionIds); + const Tensor* past_compressed_key = context->Input(sai::kPastCompressedKey); + const Tensor* past_kv_buffer = context->Input(sai::kPastKvBuffer); + const Tensor* past_gate_buffer = context->Input(sai::kPastGateBuffer); + + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, + "SparseAttentionIndexer: query must have shape (batch_size, sequence_length, num_heads, head_size)" + ", got ", + query_shape.ToString()); + const int64_t batch_size = query_shape[0]; + const int64_t sequence_length = query_shape[1]; + const int64_t num_heads = query_shape[2]; + const int64_t head_size = query_shape[3]; + 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_NOT(sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as " + "cos_cache"); + 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=", + rotary_width, " head_size=", head_size); + + const auto& past_compressed_shape = past_compressed_key->Shape(); + ORT_RETURN_IF_NOT(past_compressed_shape.NumDimensions() == 3 && past_compressed_shape[0] == batch_size && + past_compressed_shape[2] == head_size, + "SparseAttentionIndexer: past_compressed_key must have shape " + "(batch_size, past_compressed_length, head_size), got ", + past_compressed_shape.ToString()); + const int64_t past_compressed_length = past_compressed_shape[1]; + + const auto& past_buffer_shape = past_kv_buffer->Shape(); + ORT_RETURN_IF_NOT(past_buffer_shape.NumDimensions() == 3 && past_buffer_shape[0] == batch_size && + past_buffer_shape[2] == width, + "SparseAttentionIndexer: past_kv_buffer must have shape " + "(batch_size, buffer_length, 2 * head_size), got ", + past_buffer_shape.ToString()); + const int64_t past_buffer_length = past_buffer_shape[1]; + + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); + ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckShape(position_bias, "position_bias", {compress_ratio_, width})); + 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})); + ORT_RETURN_IF_ERROR(CheckShape(past_gate_buffer, "past_gate_buffer", + {batch_size, past_buffer_length, width})); + + sai::CsaWindowPlan plan; + ORT_RETURN_IF_NOT(sai::TryComputeCsaWindowPlan(past_buffer_length, sequence_length, compress_ratio_, plan), + "SparseAttentionIndexer: past_kv_buffer sequence length must be in [0, 2 * compress_ratio), got ", + past_buffer_length); + const int64_t present_compressed_length = past_compressed_length + plan.new_window_count; + + SparseAttentionIndexerParams params; + params.batch_size = static_cast(batch_size); + params.sequence_length = static_cast(sequence_length); + params.num_heads = static_cast(num_heads); + params.head_size = static_cast(head_size); + params.rotary_width = static_cast(rotary_width); + params.max_rotary_length = static_cast(max_rotary_length); + params.compress_ratio = static_cast(compress_ratio_); + params.capacity = static_cast( + sai::SelectedCapacity(sai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_)); + params.epsilon = epsilon_; + params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.past_compressed_length = static_cast(past_compressed_length); + params.present_compressed_length = static_cast(present_compressed_length); + params.past_buffer_length = static_cast(past_buffer_length); + params.overlap_length = static_cast(plan.overlap_length); + params.new_window_count = static_cast(plan.new_window_count); + params.present_buffer_length = static_cast(plan.present_buffer_length); + params.present_buffer_start = static_cast(plan.present_buffer_start); + params.index_topk = static_cast(index_topk_); + params.head_weight_scale = + head_weight_scale_ != 0.0f ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads)); + + Tensor* selected_indices = context->Output(sai::kSelectedIndices, + TensorShape({batch_size, sequence_length, params.capacity})); + Tensor* present_compressed_key = context->Output( + sai::kPresentCompressedKey, TensorShape({batch_size, present_compressed_length, head_size})); + Tensor* present_kv_buffer = + context->Output(sai::kPresentKvBuffer, TensorShape({batch_size, plan.present_buffer_length, width})); + Tensor* present_gate_buffer = + context->Output(sai::kPresentGateBuffer, TensorShape({batch_size, plan.present_buffer_length, width})); + ORT_RETURN_IF(selected_indices == nullptr || present_compressed_key == nullptr || present_kv_buffer == nullptr || + present_gate_buffer == nullptr, + "SparseAttentionIndexer: policy_mode 'csa' requires selected_indices, present_compressed_key, " + "present_kv_buffer and present_gate_buffer outputs"); + + auto float_workspace = GetScratchBuffer(GetCsaWorkspaceFloatCount(params), context->GetComputeStream()); + + const CudaT* empty = nullptr; + return LaunchCsaSparseAttentionIndexer( + Stream(context), params, + reinterpret_cast(query->Data()), + reinterpret_cast(key->Data()), + reinterpret_cast(key_norm_weight->Data()), + reinterpret_cast(cos_cache->Data()), + reinterpret_cast(sin_cache->Data()), + reinterpret_cast(gate->Data()), + reinterpret_cast(position_bias->Data()), + reinterpret_cast(head_weights->Data()), + position_ids->Data(), + past_compressed_length > 0 ? reinterpret_cast(past_compressed_key->Data()) : empty, + past_buffer_length > 0 ? reinterpret_cast(past_kv_buffer->Data()) : empty, + past_buffer_length > 0 ? reinterpret_cast(past_gate_buffer->Data()) : empty, + selected_indices->MutableData(), + present_compressed_length > 0 ? reinterpret_cast(present_compressed_key->MutableData()) : nullptr, + plan.present_buffer_length > 0 ? reinterpret_cast(present_kv_buffer->MutableData()) : nullptr, + plan.present_buffer_length > 0 ? reinterpret_cast(present_gate_buffer->MutableData()) : nullptr, + float_workspace.get()); +} + +template class SparseAttentionIndexer; +template class SparseAttentionIndexer; +template class SparseAttentionIndexer; + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h new file mode 100644 index 0000000000000..0abbb6064a3a4 --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" +#include "core/common/common.h" +#include "core/providers/cuda/cuda_kernel.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +template +class SparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel { + public: + explicit SparseAttentionIndexer(const OpKernelInfo& info); + Status ComputeInternal(OpKernelContext* context) const override; + + private: + Status ComputeQsa(OpKernelContext* context) const; + Status ComputeCsa(OpKernelContext* context) const; + + sparse_attention_indexer::Policy policy_; + int64_t compress_ratio_; + int64_t token_budget_; + int64_t index_topk_; + float epsilon_; + float scale_; // 0 means "derive from head_size" + float head_weight_scale_; // 0 means "derive from num_heads" +}; + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu new file mode 100644 index 0000000000000..ea05afcd7886e --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -0,0 +1,725 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Correctness-first implementation of com.microsoft.SparseAttentionIndexer. Every stage is a +// straightforward kernel that mirrors the reference semantics; see +// docs/contrib_ops/cuda/sparse_attention_indexer.md for the performance follow-ups. + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" + +#include +#include +#include +#include + +#include + +#include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +namespace { + +// The block reductions below halve the active thread count, so this must stay a power of two. +constexpr int kThreads = 128; +constexpr int64_t kMaxGridDimX = 2147483647; + +__device__ __forceinline__ float NegativeInfinity() { return -CUDART_INF_F; } + +int GridForElements(int64_t count) { + const int64_t blocks = (count + kThreads - 1) / kThreads; + return static_cast(std::clamp(blocks, 1, 65535)); +} + +// --------------------------------------------------------------------------------------------- +// Shared device helpers +// --------------------------------------------------------------------------------------------- + +__device__ __forceinline__ float BlockSum(float value, float* shared) { + shared[threadIdx.x] = value; + __syncthreads(); + for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) { + if (threadIdx.x < stride) { + shared[threadIdx.x] += shared[threadIdx.x + stride]; + } + __syncthreads(); + } + const float total = shared[0]; + __syncthreads(); + return total; +} + +// Reduces (value, index) pairs to the largest value, breaking ties towards the smaller index. +// A negative index marks an empty slot. shared_value/shared_index must already be filled and synced. +__device__ __forceinline__ void BlockArgMax(float* shared_value, int* shared_index) { + for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) { + if (threadIdx.x < stride) { + const int other_index = shared_index[threadIdx.x + stride]; + if (other_index >= 0) { + const int this_index = shared_index[threadIdx.x]; + const float other_value = shared_value[threadIdx.x + stride]; + const float this_value = shared_value[threadIdx.x]; + if (this_index < 0 || other_value > this_value || + (other_value == this_value && other_index < this_index)) { + shared_value[threadIdx.x] = other_value; + shared_index[threadIdx.x] = other_index; + } + } + } + __syncthreads(); + } +} + +// Per-thread scan for the best entry that comes strictly after (previous_score, previous_index) in +// the total order "score descending, then index ascending". Entries already emitted are therefore +// skipped without needing a visited bitmap. +__device__ __forceinline__ void ScanForNext(const float* scores, int count, float previous_score, + int previous_index, float* best_value, int* best_index) { + *best_index = -1; + *best_value = 0.0f; + for (int candidate = static_cast(threadIdx.x); candidate < count; + candidate += static_cast(blockDim.x)) { + const float value = scores[candidate]; + if (previous_index >= 0 && + !(value < previous_score || (value == previous_score && candidate > previous_index))) { + continue; + } + if (*best_index < 0 || value > *best_value || + (value == *best_value && candidate < *best_index)) { + *best_value = value; + *best_index = candidate; + } + } +} + +// Split-half rotary over the leading `rotary_width` channels (the convention used by the qsa +// reference). Channels beyond `rotary_width` pass through unchanged. +template +__device__ __forceinline__ float LeadingRope(const float* value, int rotary_width, const T* cos_row, + const T* sin_row, int d) { + if (d >= rotary_width) { + return value[d]; + } + const int half = rotary_width / 2; + const float paired = (d < half) ? -value[d + half] : value[d - half]; + return value[d] * to_float(cos_row[d]) + paired * to_float(sin_row[d]); +} + +// Interleaved rotary over the trailing 2 * rotary_width channels (the convention used by the csa +// reference). Each cos/sin entry covers one channel pair, matching repeat_interleave(2). +template +__device__ __forceinline__ float TrailingRope(const float* value, int head_size, int rotary_width, + const T* cos_row, const T* sin_row, int d) { + const int base = head_size - 2 * rotary_width; + if (d < base) { + return value[d]; + } + const int offset = d - base; + const float paired = ((offset & 1) == 0) ? -value[d + 1] : value[d - 1]; + return value[d] * to_float(cos_row[offset >> 1]) + paired * to_float(sin_row[offset >> 1]); +} + +// Highest compressed entry a query at `position` may attend to, matching (position + 1) // ratio. +__device__ __forceinline__ int64_t CausalThreshold(int64_t position, int compress_ratio) { + return position < 0 ? 0 : (position + 1) / compress_ratio; +} + +__device__ __forceinline__ int ClampPosition(int64_t position, int max_rotary_length) { + if (position < 0) { + return 0; + } + const int64_t limit = max_rotary_length - 1; + return static_cast(position < limit ? position : limit); +} + +// --------------------------------------------------------------------------------------------- +// Shared kernels +// --------------------------------------------------------------------------------------------- + +// One block per (batch, token, head). kUseLeadingRope selects the qsa convention; otherwise the +// csa convention with positions taken from position_ids. +template +__global__ void RotateQueryKernel(const T* query, const T* cos_cache, const T* sin_cache, + const int64_t* position_ids, float* query_rotated, + SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + const int64_t rows = static_cast(params.batch_size) * params.sequence_length * params.num_heads; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const int token = static_cast((row / params.num_heads) % params.sequence_length); + const int batch = static_cast(row / (static_cast(params.num_heads) * params.sequence_length)); + const int64_t base = row * params.head_size; + + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + shared[d] = to_float(query[base + d]); + } + __syncthreads(); + + const int64_t raw_position = kUseLeadingRope + ? static_cast(params.past_sequence_length) + token + : 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; + const T* cos_row = cos_cache + cache_offset; + const T* sin_row = sin_cache + cache_offset; + + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + query_rotated[base + d] = + kUseLeadingRope ? LeadingRope(shared, params.rotary_width, cos_row, sin_row, d) + : TrailingRope(shared, params.head_size, params.rotary_width, cos_row, sin_row, d); + } + __syncthreads(); + } +} + +// --------------------------------------------------------------------------------------------- +// policy_mode = "qsa" +// --------------------------------------------------------------------------------------------- + +template +__global__ void ConcatPastKeyKernel(const T* past_key, const T* key, T* present_key, + SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.total_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.total_sequence_length); + const int batch = static_cast(token_row / params.total_sequence_length); + present_key[index] = + token < params.past_sequence_length + ? past_key[(static_cast(batch) * params.past_sequence_length + token) * params.head_size + d] + : key[(static_cast(batch) * params.sequence_length + (token - params.past_sequence_length)) * + params.head_size + + d]; + } +} + +// One block per query row; compacts the visible key positions of that row into visible_indices. +__global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, int32_t* visible_count, + SparseAttentionIndexerParams params) { + extern __shared__ int32_t shared_scan[]; + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const bool* mask_row = mask + row * params.total_sequence_length; + int32_t* out_row = visible_indices + row * params.total_sequence_length; + int32_t offset = 0; + for (int base = 0; base < params.total_sequence_length; base += blockDim.x) { + const int position = base + static_cast(threadIdx.x); + const int32_t flag = (position < params.total_sequence_length && mask_row[position]) ? 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(); + } + if (flag != 0) { + out_row[offset + shared_scan[threadIdx.x] - 1] = position; + } + const int32_t tile_total = shared_scan[blockDim.x - 1]; + __syncthreads(); + offset += tile_total; + } + if (threadIdx.x == 0) { + visible_count[row] = offset; + } + __syncthreads(); + } +} + +// One block per (query row, block index). Pools compress_ratio visible keys, normalizes, rotates +// and scores the result against every query head. +template +__global__ void QsaBlockScoreKernel(const T* present_key, const T* key_norm_weight, const T* cos_cache, + const T* sin_cache, const float* query_rotated, + const int32_t* visible_indices, const int32_t* visible_count, + float* block_scores, SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* pooled = shared; + float* rotated = shared + params.head_size; + float* reduction = shared + 2 * params.head_size; + + 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 block_count = visible_count[row] / params.compress_ratio; + + if (block_index >= block_count) { + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = NegativeInfinity(); + } + 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 int64_t key_base = static_cast(batch) * params.total_sequence_length * 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]); + } + pooled[d] = sum / static_cast(params.compress_ratio); + } + __syncthreads(); + + float sum_squares = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = BlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + pooled[d] = pooled[d] * inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + + const int position = ClampPosition(group[0], params.max_rotary_length); + const int64_t cache_offset = + (static_cast(batch) * params.max_rotary_length + 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); + } + __syncthreads(); + + float score = 0.0f; + for (int head = 0; head < params.num_heads; ++head) { + const float* query_head = query_rotated + (row * params.num_heads + head) * params.head_size; + float partial = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + partial += query_head[d] * rotated[d]; + } + score += fmaxf(BlockSum(partial, reduction), 0.0f); + } + + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = score * params.scale; + } + __syncthreads(); + } +} + +// 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, + SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* shared_value = shared; + int* shared_index = reinterpret_cast(shared + blockDim.x); + + 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* out_row = selected_indices + row * params.capacity; + for (int position = threadIdx.x; position < params.capacity; position += blockDim.x) { + out_row[position] = -1; + } + __syncthreads(); + + const int visible = visible_count[row]; + 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; + } + 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]; + } + 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]; + } + __syncthreads(); + } +} + +// --------------------------------------------------------------------------------------------- +// policy_mode = "csa" +// --------------------------------------------------------------------------------------------- + +// Reads channel `channel` of token `position` of the virtual sequence [past buffer | new tokens]. +template +__device__ __forceinline__ float ExtendedValue(const T* past_buffer, const T* current, int batch, + int position, int channel, + const SparseAttentionIndexerParams& params) { + const int width = 2 * params.head_size; + if (position < params.past_buffer_length) { + return to_float( + past_buffer[(static_cast(batch) * params.past_buffer_length + position) * width + channel]); + } + return to_float( + current[(static_cast(batch) * params.sequence_length + (position - params.past_buffer_length)) * width + + channel]); +} + +// One block per (batch, new window). Softmax-pools the 2 * compress_ratio window slots, normalizes +// and rotates the result into present_compressed_key. +template +__global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_buffer, + const T* past_gate_buffer, const T* position_bias, + const T* key_norm_weight, const T* cos_cache, const T* sin_cache, + T* present_compressed_key, SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* pooled = shared; + float* reduction = shared + params.head_size; + + const int width = 2 * params.head_size; + const int64_t total = static_cast(params.batch_size) * params.new_window_count; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int window = static_cast(work % params.new_window_count); + const int batch = static_cast(work / params.new_window_count); + const bool has_previous = window >= 1 || params.overlap_length >= params.compress_ratio; + const int previous_base = params.overlap_length + (window - 1) * params.compress_ratio; + const int current_base = params.overlap_length + window * params.compress_ratio; + + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + float max_gate = NegativeInfinity(); + if (has_previous) { + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float value = + ExtendedValue(past_gate_buffer, gate, batch, previous_base + slot, d, params) + + to_float(position_bias[static_cast(slot) * width + d]); + max_gate = fmaxf(max_gate, value); + } + } + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float value = + ExtendedValue(past_gate_buffer, gate, batch, current_base + slot, params.head_size + d, params) + + to_float(position_bias[static_cast(slot) * width + params.head_size + d]); + max_gate = fmaxf(max_gate, value); + } + + float denominator = 0.0f; + float accumulator = 0.0f; + if (has_previous) { + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float logit = + ExtendedValue(past_gate_buffer, gate, batch, previous_base + slot, d, params) + + to_float(position_bias[static_cast(slot) * width + d]); + const float weight = __expf(logit - max_gate); + denominator += weight; + accumulator += weight * ExtendedValue(past_kv_buffer, key, batch, previous_base + slot, d, params); + } + } + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float logit = + ExtendedValue(past_gate_buffer, gate, batch, current_base + slot, params.head_size + d, params) + + to_float(position_bias[static_cast(slot) * width + params.head_size + d]); + const float weight = __expf(logit - max_gate); + denominator += weight; + accumulator += + weight * ExtendedValue(past_kv_buffer, key, batch, current_base + slot, params.head_size + d, params); + } + pooled[d] = denominator > 0.0f ? accumulator / denominator : 0.0f; + } + __syncthreads(); + + float sum_squares = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = BlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + pooled[d] = pooled[d] * inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + + 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; + const int64_t out_base = + (static_cast(batch) * params.present_compressed_length + entry) * params.head_size; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + present_compressed_key[out_base + d] = from_float(TrailingRope( + pooled, params.head_size, params.rotary_width, cos_cache + cache_offset, sin_cache + cache_offset, d)); + } + __syncthreads(); + } +} + +template +__global__ void CsaCopyPastCompressedKernel(const T* past_compressed_key, T* present_compressed_key, + SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.past_compressed_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 int64_t entry_row = index / params.head_size; + const int entry = static_cast(entry_row % params.past_compressed_length); + const int batch = static_cast(entry_row / params.past_compressed_length); + const int d = static_cast(index % params.head_size); + present_compressed_key[(static_cast(batch) * params.present_compressed_length + entry) * params.head_size + + d] = past_compressed_key[index]; + } +} + +template +__global__ void CsaCopyBufferKernel(const T* key, const T* gate, const T* past_kv_buffer, + const T* past_gate_buffer, T* present_kv_buffer, T* present_gate_buffer, + SparseAttentionIndexerParams params) { + const int width = 2 * params.head_size; + const int64_t total = static_cast(params.batch_size) * params.present_buffer_length * width; + for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; + index += static_cast(gridDim.x) * blockDim.x) { + const int channel = static_cast(index % width); + const int64_t token_row = index / width; + const int token = static_cast(token_row % params.present_buffer_length); + const int batch = static_cast(token_row / params.present_buffer_length); + const int source = params.present_buffer_start + token; + present_kv_buffer[index] = from_float(ExtendedValue(past_kv_buffer, key, batch, source, channel, params)); + present_gate_buffer[index] = + from_float(ExtendedValue(past_gate_buffer, gate, batch, source, channel, params)); + } +} + +// One thread per (query row, compressed entry). Also applies the causal mask so that the selection +// kernel only has to read scores. +template +__global__ void CsaScoreKernel(const float* query_rotated, const T* present_compressed_key, + const T* head_weights, const int64_t* position_ids, float* scores, + SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.sequence_length * + params.present_compressed_length; + for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; + index += static_cast(gridDim.x) * blockDim.x) { + const int entry = static_cast(index % params.present_compressed_length); + const int64_t row = index / params.present_compressed_length; + const int batch = static_cast(row / params.sequence_length); + + const int64_t threshold = CausalThreshold(position_ids[row], params.compress_ratio); + if (static_cast(entry) >= threshold) { + scores[index] = NegativeInfinity(); + continue; + } + + const int64_t key_base = + (static_cast(batch) * params.present_compressed_length + entry) * params.head_size; + float total_score = 0.0f; + for (int head = 0; head < params.num_heads; ++head) { + const float* query_head = query_rotated + (row * params.num_heads + head) * params.head_size; + float dot = 0.0f; + for (int d = 0; d < params.head_size; ++d) { + dot += query_head[d] * to_float(present_compressed_key[key_base + d]); + } + total_score += fmaxf(dot, 0.0f) * to_float(head_weights[row * params.num_heads + head]); + } + scores[index] = total_score * params.scale * params.head_weight_scale; + } +} + +__global__ void CsaSelectKernel(const float* scores, const int64_t* position_ids, int32_t* selected_indices, + SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* shared_value = shared; + int* shared_index = reinterpret_cast(shared + blockDim.x); + + 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* out_row = selected_indices + row * params.capacity; + for (int position = threadIdx.x; position < params.capacity; position += blockDim.x) { + out_row[position] = -1; + } + __syncthreads(); + + const int count = params.present_compressed_length; + const int selected = min(params.index_topk, count); + const int64_t threshold = CausalThreshold(position_ids[row], params.compress_ratio); + const float* scores_row = scores + row * count; + + float previous_score = 0.0f; + int previous_index = -1; + for (int rank = 0; rank < selected; ++rank) { + float best_value = 0.0f; + int best_index = -1; + ScanForNext(scores_row, 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; + } + if (threadIdx.x == 0 && static_cast(previous_index) < threshold) { + out_row[rank] = previous_index; + } + __syncthreads(); + } + } +} + +} // namespace + +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); +} + +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params) { + const size_t rows = static_cast(params.batch_size) * params.sequence_length; + return rows * std::max(params.total_sequence_length, 1) + rows; +} + +size_t GetCsaWorkspaceFloatCount(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.present_compressed_length, 1); +} + +template +Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentionIndexerParams& params, + const T* query, const T* key, const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, const bool* mask, + const T* past_key, int32_t* selected_indices, T* present_key, + float* float_workspace, int32_t* int_workspace) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + + // The present state is produced even when there is no query row to score, so that a zero-length step still + // forwards the incoming cache unchanged. + const int64_t present_key_elements = + static_cast(params.batch_size) * params.total_sequence_length * params.head_size; + if (present_key_elements > 0) { + ConcatPastKeyKernel<<>>(past_key, key, present_key, + params); + } + + if (rows == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + float* query_rotated = float_workspace; + float* block_scores = float_workspace + rows * params.num_heads * params.head_size; + int32_t* visible_indices = int_workspace; + int32_t* visible_count = int_workspace + rows * params.total_sequence_length; + + const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + + const int rotate_blocks = static_cast(std::min(rows * params.num_heads, kMaxGridDimX)); + RotateQueryKernel<<>>( + query, cos_cache, sin_cache, nullptr, query_rotated, params); + + const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); + CompactVisibleKernel<<>>( + mask, visible_indices, visible_count, params); + + if (params.max_block_count > 0) { + const int64_t block_work = rows * params.max_block_count; + const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); + QsaBlockScoreKernel<<>>( + present_key, key_norm_weight, cos_cache, sin_cache, query_rotated, visible_indices, visible_count, + block_scores, params); + } + + QsaSelectKernel<<>>( + block_scores, visible_indices, visible_count, selected_indices, params); + + return CUDA_CALL(cudaGetLastError()); +} + +template +Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentionIndexerParams& params, + const T* query, const T* key, const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, const T* gate, + const T* position_bias, const T* head_weights, + const int64_t* position_ids, const T* past_compressed_key, + const T* past_kv_buffer, const T* past_gate_buffer, + int32_t* selected_indices, T* present_compressed_key, + T* present_kv_buffer, T* present_gate_buffer, float* float_workspace) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + + // The present state is produced even when there is no query row to score, so that a zero-length step still + // forwards the incoming cache unchanged. + const int64_t past_compressed_elements = + static_cast(params.batch_size) * params.past_compressed_length * params.head_size; + if (past_compressed_elements > 0) { + CsaCopyPastCompressedKernel<<>>( + past_compressed_key, present_compressed_key, params); + } + + if (params.new_window_count > 0) { + const int compress_blocks = static_cast( + std::min(static_cast(params.batch_size) * params.new_window_count, kMaxGridDimX)); + CsaCompressKernel<<>>( + key, gate, past_kv_buffer, past_gate_buffer, position_bias, key_norm_weight, cos_cache, sin_cache, + present_compressed_key, params); + } + + const int64_t present_buffer_elements = + static_cast(params.batch_size) * params.present_buffer_length * 2 * params.head_size; + if (present_buffer_elements > 0) { + CsaCopyBufferKernel<<>>( + key, gate, past_kv_buffer, past_gate_buffer, present_kv_buffer, present_gate_buffer, params); + } + + if (rows == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + float* query_rotated = float_workspace; + float* scores = float_workspace + rows * params.num_heads * params.head_size; + + const int rotate_blocks = static_cast(std::min(rows * params.num_heads, kMaxGridDimX)); + RotateQueryKernel<<>>( + query, cos_cache, sin_cache, position_ids, query_rotated, params); + + if (params.present_compressed_length > 0) { + CsaScoreKernel<<>>( + query_rotated, present_compressed_key, head_weights, position_ids, scores, params); + } + + const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); + CsaSelectKernel<<>>( + scores, position_ids, selected_indices, params); + + return CUDA_CALL(cudaGetLastError()); +} + +#define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ + template Status LaunchQsaSparseAttentionIndexer(cudaStream_t, const SparseAttentionIndexerParams&, \ + const T*, const T*, const T*, const T*, const T*, \ + const bool*, const T*, int32_t*, T*, float*, int32_t*); \ + template Status LaunchCsaSparseAttentionIndexer( \ + cudaStream_t, const SparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, const T*, \ + const T*, const T*, const T*, const int64_t*, const T*, const T*, const T*, int32_t*, T*, T*, T*, float*); + +INSTANTIATE_SPARSE_ATTENTION_INDEXER(float) +INSTANTIATE_SPARSE_ATTENTION_INDEXER(half) +INSTANTIATE_SPARSE_ATTENTION_INDEXER(__nv_bfloat16) + +#undef INSTANTIATE_SPARSE_ATTENTION_INDEXER + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h new file mode 100644 index 0000000000000..e577208437d9a --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include + +#include "core/providers/cuda/cuda_common.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +// Everything the device code needs to know about a SparseAttentionIndexer call. All of it is +// derived from attributes and input shapes, so no device data is ever read on the host. +struct SparseAttentionIndexerParams { + int batch_size = 0; + int sequence_length = 0; + int num_heads = 0; + int head_size = 0; + int rotary_width = 0; // cos_cache.shape[2] + int max_rotary_length = 0; // cos_cache.shape[1] + int compress_ratio = 0; + int capacity = 0; // selected_indices.shape[2] + float epsilon = 1e-6f; + float scale = 0.0f; + + // policy_mode = "qsa" + int past_sequence_length = 0; + int total_sequence_length = 0; + int max_block_count = 0; // total_sequence_length / compress_ratio + int block_topk = 0; // token_budget / compress_ratio + + // policy_mode = "csa" + int past_compressed_length = 0; + int present_compressed_length = 0; + int past_buffer_length = 0; + int overlap_length = 0; + int new_window_count = 0; + int present_buffer_length = 0; + int present_buffer_start = 0; + int index_topk = 0; + float head_weight_scale = 0.0f; +}; + +// Scratch requirements, in elements. +size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params); +size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); + +template +Status LaunchQsaSparseAttentionIndexer( + cudaStream_t stream, + const SparseAttentionIndexerParams& params, + const T* query, + const T* key, + const T* key_norm_weight, + const T* cos_cache, + const T* sin_cache, + const bool* mask, + const T* past_key, + int32_t* selected_indices, + T* present_key, + float* float_workspace, + int32_t* int_workspace); + +template +Status LaunchCsaSparseAttentionIndexer( + cudaStream_t stream, + const SparseAttentionIndexerParams& params, + const T* query, + const T* key, + const T* key_norm_weight, + const T* cos_cache, + const T* sin_cache, + const T* gate, + const T* position_bias, + const T* head_weights, + const int64_t* position_ids, + const T* past_compressed_key, + const T* past_kv_buffer, + const T* past_gate_buffer, + int32_t* selected_indices, + T* present_compressed_key, + T* present_kv_buffer, + T* present_gate_buffer, + float* float_workspace); + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index f57ec84b0ad24..dbaebf5b191fe 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -11,6 +11,7 @@ #include "core/graph/contrib_ops/onnx_function_util.h" #include "core/graph/contrib_ops/shape_inference_functions.h" #include "contrib_ops/cpu/bert/attention_common.h" +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" // Suppress a warning: global initializer calls a non-constexpr function 'symbol' which is from // ONNX_OPERATOR_SET_SCHEMA_EX macro and only happens in debug build #if defined(_WIN32) && !defined(NDEBUG) @@ -1894,6 +1895,400 @@ ONNX_MS_OPERATOR_SET_SCHEMA( SparseAttentionTypeAndShapeInference(ctx, 3); })); +namespace sai = ::onnxruntime::contrib::sparse_attention_indexer; + +namespace { + +bool SparseAttentionIndexerHasInput(ONNX_NAMESPACE::InferenceContext& ctx, int index) { + return static_cast(index) < ctx.getNumInputs() && ctx.getInputType(index) != nullptr; +} + +// Copies a dimension (value or symbolic parameter) from an input shape into an output shape. +void SparseAttentionIndexerAppendDim(ONNX_NAMESPACE::TensorShapeProto& shape, + const ONNX_NAMESPACE::TensorShapeProto_Dimension& dim) { + *shape.add_dim() = dim; +} + +const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerShape(ONNX_NAMESPACE::InferenceContext& ctx, int index, + int expected_rank) { + if (!SparseAttentionIndexerHasInput(ctx, index) || !hasInputShape(ctx, index)) { + return nullptr; + } + const auto& shape = getInputShape(ctx, index); + if (shape.dim_size() != expected_rank) { + fail_shape_inference("SparseAttentionIndexer: input ", index, " must have rank ", expected_rank, + ", got rank ", shape.dim_size()); + } + return &shape; +} + +} // namespace + +void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx) { + const std::string policy_mode = getAttribute(ctx, "policy_mode", std::string()); + sai::Policy policy = sai::Policy::kQsa; + if (!sai::TryParsePolicy(policy_mode, policy)) { + fail_shape_inference("SparseAttentionIndexer: policy_mode must be 'qsa' or 'csa', got '", policy_mode, "'"); + } + const bool is_qsa = policy == sai::Policy::kQsa; + + const int64_t compress_ratio = getAttribute(ctx, "compress_ratio", static_cast(0)); + if (compress_ratio <= 0) { + fail_shape_inference("SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio); + } + + const int64_t token_budget = getAttribute(ctx, "token_budget", static_cast(0)); + const int64_t index_topk = getAttribute(ctx, "index_topk", static_cast(0)); + if (is_qsa) { + if (ctx.getAttribute("index_topk") != nullptr || ctx.getAttribute("head_weight_scale") != nullptr) { + fail_shape_inference( + "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); + } + if (token_budget <= 0 || token_budget % compress_ratio != 0) { + fail_shape_inference( + "SparseAttentionIndexer: policy_mode 'qsa' requires token_budget > 0 and divisible by " + "compress_ratio, got token_budget=", + token_budget, " compress_ratio=", compress_ratio); + } + } else { + if (ctx.getAttribute("token_budget") != nullptr) { + fail_shape_inference("SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); + } + if (index_topk <= 0) { + fail_shape_inference("SparseAttentionIndexer: policy_mode 'csa' requires index_topk > 0, got ", index_topk); + } + } + + // 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, sai::kPastKey}; + constexpr int kCsaOnlyInputs[] = {sai::kGate, sai::kPositionBias, sai::kHeadWeights, + sai::kPositionIds, sai::kPastCompressedKey, + sai::kPastKvBuffer, sai::kPastGateBuffer}; + for (int index = sai::kQuery; index <= sai::kSinCache; ++index) { + if (!SparseAttentionIndexerHasInput(ctx, index)) { + fail_shape_inference("SparseAttentionIndexer: input ", index, " 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'"); + } + } + for (int index : kCsaOnlyInputs) { + if (SparseAttentionIndexerHasInput(ctx, index) == is_qsa) { + fail_shape_inference("SparseAttentionIndexer: input ", index, + is_qsa ? " must be omitted when policy_mode is 'qsa'" + : " is required when policy_mode is 'csa'"); + } + } + + const size_t expected_outputs = is_qsa ? sai::kQsaOutputCount : sai::kCsaOutputCount; + if (ctx.getNumOutputs() != expected_outputs) { + fail_shape_inference("SparseAttentionIndexer: policy_mode '", policy_mode, "' requires exactly ", + expected_outputs, " declared outputs, got ", ctx.getNumOutputs()); + } + + updateOutputElemType(ctx, sai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); + + (void)SparseAttentionIndexerShape(ctx, sai::kKey, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kKeyNormWeight, 1); + (void)SparseAttentionIndexerShape(ctx, sai::kCosCache, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kSinCache, 3); + + const auto* query_shape = SparseAttentionIndexerShape(ctx, sai::kQuery, 4); + if (query_shape == nullptr) { + return; + } + const auto& batch_dim = query_shape->dim(0); + const auto& sequence_dim = query_shape->dim(1); + const auto& head_size_dim = query_shape->dim(3); + + const int64_t capacity = sai::SelectedCapacity(policy, token_budget, index_topk, compress_ratio); + ONNX_NAMESPACE::TensorShapeProto selected_shape; + SparseAttentionIndexerAppendDim(selected_shape, batch_dim); + SparseAttentionIndexerAppendDim(selected_shape, sequence_dim); + selected_shape.add_dim()->set_dim_value(capacity); + updateOutputShape(ctx, sai::kSelectedIndices, selected_shape); + + if (is_qsa) { + // ctx.getNumOutputs() == 2 was enforced above, so index 1 is in range. + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentKey); + const auto* past_key_shape = SparseAttentionIndexerShape(ctx, sai::kPastKey, 3); + if (past_key_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto present_shape; + SparseAttentionIndexerAppendDim(present_shape, batch_dim); + auto* total_dim = present_shape.add_dim(); + if (past_key_shape->dim(1).has_dim_value() && sequence_dim.has_dim_value()) { + total_dim->set_dim_value(past_key_shape->dim(1).dim_value() + sequence_dim.dim_value()); + } + SparseAttentionIndexerAppendDim(present_shape, head_size_dim); + updateOutputShape(ctx, sai::kPresentKey, present_shape); + } + return; + } + + // ctx.getNumOutputs() == 5 was enforced above, so indices 2, 3 and 4 are all in range. + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentCompressedKey); + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentKvBuffer); + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentGateBuffer); + + const auto* past_compressed_shape = SparseAttentionIndexerShape(ctx, sai::kPastCompressedKey, 3); + const auto* past_buffer_shape = SparseAttentionIndexerShape(ctx, sai::kPastKvBuffer, 3); + const auto* past_gate_shape = SparseAttentionIndexerShape(ctx, sai::kPastGateBuffer, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kGate, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kPositionBias, 2); + (void)SparseAttentionIndexerShape(ctx, sai::kHeadWeights, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kPositionIds, 2); + + if (past_buffer_shape != nullptr && past_gate_shape != nullptr) { + for (int axis = 0; axis < 3; ++axis) { + const auto& kv_dim = past_buffer_shape->dim(axis); + const auto& gate_dim = past_gate_shape->dim(axis); + if (kv_dim.has_dim_value() && gate_dim.has_dim_value() && kv_dim.dim_value() != gate_dim.dim_value()) { + fail_shape_inference( + "SparseAttentionIndexer: past_gate_buffer must have the same shape as past_kv_buffer, " + "but dimension ", + axis, " is ", gate_dim.dim_value(), " instead of ", kv_dim.dim_value()); + } + } + } + + sai::CsaWindowPlan plan; + const bool plan_known = past_buffer_shape != nullptr && past_buffer_shape->dim(1).has_dim_value() && + sequence_dim.has_dim_value() && + sai::TryComputeCsaWindowPlan(past_buffer_shape->dim(1).dim_value(), + sequence_dim.dim_value(), compress_ratio, plan); + if (past_buffer_shape != nullptr && past_buffer_shape->dim(1).has_dim_value() && + sequence_dim.has_dim_value() && !plan_known) { + fail_shape_inference( + "SparseAttentionIndexer: past_kv_buffer sequence length must be in [0, 2 * compress_ratio), got ", + past_buffer_shape->dim(1).dim_value()); + } + + if (past_compressed_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto present_shape; + SparseAttentionIndexerAppendDim(present_shape, batch_dim); + auto* entry_dim = present_shape.add_dim(); + if (plan_known && past_compressed_shape->dim(1).has_dim_value()) { + entry_dim->set_dim_value(past_compressed_shape->dim(1).dim_value() + plan.new_window_count); + } + SparseAttentionIndexerAppendDim(present_shape, head_size_dim); + updateOutputShape(ctx, sai::kPresentCompressedKey, present_shape); + } + + if (past_buffer_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto buffer_shape; + SparseAttentionIndexerAppendDim(buffer_shape, batch_dim); + auto* buffer_dim = buffer_shape.add_dim(); + if (plan_known) { + buffer_dim->set_dim_value(plan.present_buffer_length); + } + SparseAttentionIndexerAppendDim(buffer_shape, past_buffer_shape->dim(2)); + updateOutputShape(ctx, sai::kPresentKvBuffer, buffer_shape); + updateOutputShape(ctx, sai::kPresentGateBuffer, buffer_shape); + } +} + +constexpr const char* SparseAttentionIndexer_ver1_doc = R"DOC( +Selects, for every query token, the sparse-attention candidates that the following attention +operator is allowed to read. It covers the two indexer flavours used by recent sparse-attention +decoders, chosen with the policy_mode attribute: + + policy_mode = "qsa" ("query sparse attention" token indexer) + Groups the tokens that are visible to a query into complete blocks of compress_ratio tokens, + mean-pools the indexer keys of every block, normalizes and rotates the pooled key, scores it + against the query heads with sum_h ReLU(q_h . k), keeps the token_budget / compress_ratio + highest scoring blocks and emits the token indices of those blocks followed by the visible + tokens of the trailing incomplete block. + + policy_mode = "csa" ("compressed sparse attention" block indexer) + Compresses every compress_ratio consecutive tokens into one entry with a softmax-gated pooling + over a window of 2 * compress_ratio slots (the previous window contributes its "Ca" half and + the current window its "Cb" half), normalizes and rotates the entry, appends it to the + compressed-key state, scores the queries against every compressed entry with + sum_h w_h * ReLU(q_h . k), masks the entries a query may not attend to and emits the index_topk + highest scoring entry indices. + +Common contract: + * selected_indices is int32 with a fixed capacity that only depends on attributes: + token_budget + compress_ratio - 1 for "qsa" and index_topk for "csa". Unused entries are -1, + so no output size depends on the data and no device-to-host synchronization is required. + * All state is explicit in the graph. Nothing is cached inside the operator. + * Rotary embeddings reuse the precomputed cos_cache / sin_cache tables, which are indexed by + absolute key position. "qsa" applies the half-rotation of the model's (M)RoPE to the leading + rotary_dim = cos_cache.shape[2] channels. "csa" applies its trailing rotary to the last + 2 * cos_cache.shape[2] channels, with each cos/sin entry covering two consecutive channels. + * key_norm_weight is the effective RMSNorm multiplier. Models that store a zero-centered gamma + (the normalized value is multiplied by 1 + gamma) must fold the addition into this initializer. + * Accumulation, pooling, softmax, normalization and scoring are performed in float32 and the + result is rounded once to the tensor element type. + * Ties in the top-k selection are broken by the smaller entry index, and the emitted entries are + ordered by decreasing score, so the result is deterministic. + +State layout for policy_mode = "csa": past_kv_buffer / past_gate_buffer hold the tokens that have +not been folded into a compressed entry yet. When their length is >= compress_ratio, the first +compress_ratio tokens are the previous complete window (the "Ca" operand of the next window) and +the remainder is the current incomplete window; when it is < compress_ratio there is no previous +complete window and the whole buffer is the incomplete window. The length is therefore always in +[0, 2 * compress_ratio), and the number of compressed entries emitted by a call is known from the +input shapes alone. position_bias is re-applied to the buffered gates, so the buffers hold the raw +gate projection. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + SparseAttentionIndexer, 1, + OpSchema() + .SetDoc(SparseAttentionIndexer_ver1_doc) + .Attr("policy_mode", + "Indexer policy. Must be exactly 'qsa' (token indexer) or 'csa' (compressed block indexer).", + AttributeProto::STRING) + .Attr("compress_ratio", + "Number of consecutive tokens folded into one compressed block. Must be > 0.", + AttributeProto::INT) + .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'.", + AttributeProto::INT, + OPTIONAL_VALUE) + .Attr("index_topk", + "Only for policy_mode 'csa': number of compressed entries selected per query. Must be > 0. " + "Must be omitted when policy_mode is 'qsa'.", + AttributeProto::INT, + OPTIONAL_VALUE) + .Attr("epsilon", + "Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.", + AttributeProto::FLOAT, + 1.0e-6f) + .Attr("scale", + "Scale applied to the per-head ReLU scores. Default is 1/sqrt(head_size).", + AttributeProto::FLOAT, + OPTIONAL_VALUE) + .Attr("head_weight_scale", + "Only for policy_mode 'csa': scale applied to head_weights. Default is 1/sqrt(num_heads). " + "Must be omitted when policy_mode is 'qsa'.", + AttributeProto::FLOAT, + OPTIONAL_VALUE) + .Input(0, + "query", + "Indexer queries with shape (batch_size, sequence_length, num_heads, head_size), already " + "normalized but not yet rotated.", + "T") + .Input(1, + "key", + "Indexer key projection of the new tokens. Shape is (batch_size, sequence_length, head_size) " + "for policy_mode 'qsa' and (batch_size, sequence_length, 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") + .Input(2, + "key_norm_weight", + "Effective RMSNorm multiplier of the compressed keys, with shape (head_size).", + "T") + .Input(3, + "cos_cache", + "Cosine rotary table indexed by absolute key position, with shape " + "(batch_size, max_rotary_sequence_length, rotary_width).", + "T") + .Input(4, + "sin_cache", + "Sine rotary table with the same shape as cos_cache.", + "T") + .Input(5, + "mask", + "Only for policy_mode 'qsa': tokens visible to each query, with shape " + "(batch_size, 1, sequence_length, total_sequence_length) or " + "(batch_size, sequence_length, total_sequence_length). " + "total_sequence_length is past_sequence_length + sequence_length.", + "TB", + OpSchema::Optional) + .Input(6, + "past_key", + "Only for policy_mode 'qsa': cached indexer keys with shape " + "(batch_size, past_sequence_length, head_size).", + "T", + OpSchema::Optional) + .Input(7, + "gate", + "Only for policy_mode 'csa': gate projection of the new tokens with shape " + "(batch_size, sequence_length, 2 * head_size).", + "T", + OpSchema::Optional) + .Input(8, + "position_bias", + "Only for policy_mode 'csa': per-slot gate bias with shape (compress_ratio, 2 * head_size).", + "T", + OpSchema::Optional) + .Input(9, + "head_weights", + "Only for policy_mode 'csa': per-head score weights with shape " + "(batch_size, sequence_length, num_heads).", + "T", + OpSchema::Optional) + .Input(10, + "position_ids", + "Only for policy_mode 'csa': absolute position of every query with shape " + "(batch_size, sequence_length).", + "I", + OpSchema::Optional) + .Input(11, + "past_compressed_key", + "Only for policy_mode 'csa': compressed keys emitted by previous calls, with shape " + "(batch_size, past_compressed_length, head_size).", + "T", + OpSchema::Optional) + .Input(12, + "past_kv_buffer", + "Only for policy_mode 'csa': buffered key projections with shape " + "(batch_size, buffer_length, 2 * head_size), where buffer_length is in [0, 2 * compress_ratio).", + "T", + OpSchema::Optional) + .Input(13, + "past_gate_buffer", + "Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.", + "T", + OpSchema::Optional) + .Output(0, + "selected_indices", + "Selected entries with shape (batch_size, sequence_length, capacity). capacity is " + "token_budget + compress_ratio - 1 for policy_mode 'qsa', where the values are token indices " + "into the key cache, and index_topk for policy_mode 'csa', where the values are compressed " + "entry indices. Unused entries are -1.", + "M") + .Output(1, + "present_key", + "Only for policy_mode 'qsa': past_key concatenated with key, with shape " + "(batch_size, total_sequence_length, head_size).", + "T", + OpSchema::Optional) + .Output(2, + "present_compressed_key", + "Only for policy_mode 'csa': past_compressed_key concatenated with the entries emitted by " + "this call, with shape (batch_size, present_compressed_length, head_size).", + "T", + OpSchema::Optional) + .Output(3, + "present_kv_buffer", + "Only for policy_mode 'csa': updated key buffer with shape " + "(batch_size, present_buffer_length, 2 * head_size).", + "T", + OpSchema::Optional) + .Output(4, + "present_gate_buffer", + "Only for policy_mode 'csa': updated gate buffer with the same shape as present_kv_buffer.", + "T", + OpSchema::Optional) + .TypeConstraint("T", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain floating point tensors to float, float16 and bfloat16.") + .TypeConstraint("TB", {"tensor(bool)"}, "Constrain the visibility mask to boolean tensors.") + .TypeConstraint("I", {"tensor(int64)"}, "Constrain position ids to 64-bit integer tensors.") + .TypeConstraint("M", {"tensor(int32)"}, "Constrain selected indices to 32-bit integer tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + SparseAttentionIndexerTypeAndShapeInference(ctx); + })); + constexpr const char* Longformer_Attention_doc = R"DOC( Longformer Self Attention with a local context and a global context. Tokens attend locally: Each token attends to its W previous tokens and W succeeding tokens with W being the window length. A selected few tokens diff --git a/onnxruntime/core/graph/contrib_ops/ms_opset.h b/onnxruntime/core/graph/contrib_ops/ms_opset.h index 50e421b90125e..c607b7f81cb7a 100644 --- a/onnxruntime/core/graph/contrib_ops/ms_opset.h +++ b/onnxruntime/core/graph/contrib_ops/ms_opset.h @@ -116,6 +116,7 @@ class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SkipGroupNorm); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SkipLayerNormalization); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SkipSimplifiedLayerNormalization); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SparseAttention); +class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SparseAttentionIndexer); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SparseToDenseMatMul); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, Tokenizer); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, TorchEmbedding); @@ -242,6 +243,7 @@ class OpSet_Microsoft_ver1 { fn(GetOpSchema()); fn(GetOpSchema()); fn(GetOpSchema()); + fn(GetOpSchema()); fn(GetOpSchema()); fn(GetOpSchema()); fn(GetOpSchema()); diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index 1f40c0e729f40..ed2a384f30401 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -237,6 +237,7 @@ def __init__(self, int_max, auto_merge, guess_output_rank, verbose, prefix=""): "SkipLayerNormalization": self._infer_SkipLayerNormalization, "SkipSimplifiedLayerNormalization": self._infer_SkipLayerNormalization, "SparseAttention": self._infer_SparseAttention, + "SparseAttentionIndexer": self._infer_SparseAttentionIndexer, "UnfoldTensor": self._infer_UnfoldTensor, } self.aten_op_dispatcher_ = { @@ -496,6 +497,7 @@ def _onnx_infer_single_node(self, node): "SkipLayerNormalization", "SkipSimplifiedLayerNormalization", "SparseAttention", + "SparseAttentionIndexer", "SkipGroupNorm", "QLinearAdd", "QLinearMul", @@ -2626,6 +2628,75 @@ def _infer_GroupQueryAttention(self, node): # noqa: N802 def _infer_SparseAttention(self, node): # noqa: N802 self._infer_GroupQueryAttention(node) + def _infer_SparseAttentionIndexer(self, node): # noqa: N802 + policy_mode = get_attribute(node, "policy_mode", b"") + if isinstance(policy_mode, bytes): + policy_mode = policy_mode.decode("utf-8") + compress_ratio = get_attribute(node, "compress_ratio", 0) + query_shape = self._get_sympy_shape(node, 0) + output_dtype = self.known_vi_[node.input[0]].type.tensor_type.elem_type + + if policy_mode == "qsa": + capacity = get_attribute(node, "token_budget", 0) + compress_ratio - 1 + else: + capacity = get_attribute(node, "index_topk", 0) + + vi = self.known_vi_[node.output[0]] + vi.CopyFrom( + helper.make_tensor_value_info( + node.output[0], + onnx.TensorProto.INT32, + get_shape_from_sympy_shape([*query_shape[:2], capacity]), + ) + ) + + def set_output(index, sympy_shape): + if index >= len(node.output) or not node.output[index]: + return + out_vi = self.known_vi_[node.output[index]] + out_vi.CopyFrom( + helper.make_tensor_value_info(node.output[index], output_dtype, get_shape_from_sympy_shape(sympy_shape)) + ) + + def past_shape(index): + if index >= len(node.input) or not node.input[index]: + return None + return self._get_sympy_shape(node, index) + + if policy_mode == "qsa": + past_key_shape = past_shape(6) + past_length = past_key_shape[1] if past_key_shape else 0 + set_output(1, [query_shape[0], past_length + query_shape[1], query_shape[3]]) + return + + past_compressed_shape = past_shape(11) + past_buffer_shape = past_shape(12) + if past_compressed_shape is None or past_buffer_shape is None: + return + + buffer_length = past_buffer_shape[1] + sequence_length = query_shape[1] + + # The number of compressed entries emitted by this call is known as soon as the buffer and + # query lengths are; otherwise fall back to fresh symbolic dimensions. + if compress_ratio > 0 and is_literal(buffer_length) and is_literal(sequence_length): + buffer_length = int(buffer_length) + sequence_length = int(sequence_length) + overlap_length = compress_ratio if buffer_length >= compress_ratio else 0 + pending = buffer_length - overlap_length + sequence_length + new_window_count = pending // compress_ratio + present_buffer_length = ( + compress_ratio + pending % compress_ratio if new_window_count > 0 else buffer_length + sequence_length + ) + present_compressed_length = past_compressed_shape[1] + new_window_count + else: + present_compressed_length = self._new_symbolic_dim_from_output(node, 2, 1) + present_buffer_length = self._new_symbolic_dim_from_output(node, 3, 1) + + set_output(2, [query_shape[0], present_compressed_length, query_shape[3]]) + set_output(3, [query_shape[0], present_buffer_length, past_buffer_shape[2]]) + set_output(4, [query_shape[0], present_buffer_length, past_buffer_shape[2]]) + def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) if len(node.output) > 1: @@ -2875,6 +2946,9 @@ def get_prereq(node): # Skip symbolic shape inference for RotaryEmbedding functions that have extraneous outputs # generated by `export_modules_as_functions` continue + if not node.output[i_o]: + # A missing optional output is declared with an empty name and has no value info. + continue vi = self.known_vi_[node.output[i_o]] out_type = vi.type diff --git a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc new file mode 100644 index 0000000000000..ef8aadf3428ca --- /dev/null +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -0,0 +1,927 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Coverage for com.microsoft.SparseAttentionIndexer. +// +// The "ShapeInference" suite drives Graph::Resolve() directly, so it runs in every build: it pins +// the fixed selected_indices capacity, the policy-specific state outputs and the strict policy +// validation. Cases that are expected to fail shape inference call fail_shape_inference, which +// aborts in ORT_NO_EXCEPTIONS builds, so they are compiled out there. +// +// The numeric suite needs the CUDA execution provider (the operator has no CPU kernel) and is +// skipped when it is unavailable. Expectations come from a float reference in this file that +// mirrors the operator contract; the inputs are rounded to the tested element type first so the +// reference sees exactly what the kernel reads. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" + +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" +#include "core/graph/constants.h" +#include "core/graph/model.h" +#include "test/common/tensor_op_test_utils.h" +#include "test/providers/provider_test_utils.h" +#include "test/test_environment.h" +#include "test/unittest_util/graph_transform_test_builder.h" +#include "test/util/include/asserts.h" +#include "test/util/include/default_providers.h" + +namespace onnxruntime { +namespace test { + +namespace sai = ::onnxruntime::contrib::sparse_attention_indexer; + +namespace { + +constexpr int kOnnxOpsetVersion = 17; + +// --------------------------------------------------------------------------------------------- +// Shape inference helpers +// --------------------------------------------------------------------------------------------- + +Status BuildAndResolve(const std::function& add_node, + std::unique_ptr& model) { + std::unordered_map domain_to_version; + domain_to_version[kOnnxDomain] = kOnnxOpsetVersion; + domain_to_version[kMSDomain] = 1; + + model = std::unique_ptr(new Model("sparse_attention_indexer", /*is_onnx_domain_only=*/false, ModelMetaData(), + PathString(), IOnnxRuntimeOpSchemaRegistryList(), domain_to_version, {}, + DefaultLoggingManager().DefaultLogger())); + + ModelTestBuilder builder(model->MainGraph()); + add_node(builder); + builder.SetGraphOutputs(); + return model->MainGraph().Resolve(); +} + +void ExpectShape(const Graph& graph, const std::string& name, ONNX_NAMESPACE::TensorProto_DataType elem_type, + const std::vector& expected) { + const NodeArg* arg = graph.GetNodeArg(name); + ASSERT_NE(arg, nullptr); + const ONNX_NAMESPACE::TypeProto* type = arg->TypeAsProto(); + ASSERT_NE(type, nullptr); + ASSERT_TRUE(type->has_tensor_type()); + EXPECT_EQ(type->tensor_type().elem_type(), static_cast(elem_type)); + const ONNX_NAMESPACE::TensorShapeProto& shape = type->tensor_type().shape(); + ASSERT_EQ(shape.dim_size(), static_cast(expected.size())); + for (int i = 0; i < shape.dim_size(); ++i) { + ASSERT_TRUE(shape.dim(i).has_dim_value()) << "dimension " << i << " of " << name << " is not static"; + EXPECT_EQ(shape.dim(i).dim_value(), expected[static_cast(i)]) << "dimension " << i << " of " << name; + } +} + +struct QsaGraphOptions { + int64_t batch_size = 2; + int64_t sequence_length = 3; + int64_t num_heads = 2; + int64_t head_size = 8; + int64_t past_sequence_length = 4; + int64_t rotary_width = 8; + int64_t compress_ratio = 2; + int64_t token_budget = 4; + bool add_index_topk = false; + bool add_csa_inputs = false; + std::string policy_mode = sai::kPolicyModeQsa; +}; + +// Builds a "qsa" node whose csa-only input slots are left empty, as the schema requires. +void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { + const int64_t total = options.past_sequence_length + options.sequence_length; + NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); + std::vector inputs{ + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, options.num_heads, + options.head_size}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, 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, 1, options.sequence_length, total}), + builder.MakeInput(std::vector{options.batch_size, options.past_sequence_length, + options.head_size}), + }; + if (options.add_csa_inputs) { + inputs.push_back(builder.MakeInput( + std::vector{options.batch_size, options.sequence_length, 2 * options.head_size})); + } else { + inputs.push_back(&empty); + } + for (int slot = sai::kPositionBias; slot < sai::kInputCount; ++slot) { + inputs.push_back(&empty); + } + + std::vector outputs{builder.MakeOutput(), builder.MakeOutput()}; + Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); + node.AddAttribute("policy_mode", options.policy_mode); + node.AddAttribute("compress_ratio", options.compress_ratio); + node.AddAttribute("token_budget", options.token_budget); + if (options.add_index_topk) { + node.AddAttribute("index_topk", static_cast(4)); + } +} + +struct CsaGraphOptions { + int64_t batch_size = 2; + int64_t sequence_length = 5; + int64_t num_heads = 2; + int64_t head_size = 8; + int64_t rotary_width = 4; + int64_t compress_ratio = 4; + int64_t index_topk = 3; + int64_t past_compressed_length = 6; + int64_t past_buffer_length = 5; + int64_t output_count = sai::kCsaOutputCount; + bool add_token_budget = false; +}; + +void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { + const int64_t width = 2 * options.head_size; + NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); + std::vector inputs{ + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, options.num_heads, + options.head_size}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, width}), + 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}), + &empty, + &empty, + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, width}), + builder.MakeInput(std::vector{options.compress_ratio, width}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, options.num_heads}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length}), + builder.MakeInput(std::vector{options.batch_size, options.past_compressed_length, + options.head_size}), + builder.MakeInput(std::vector{options.batch_size, options.past_buffer_length, width}), + builder.MakeInput(std::vector{options.batch_size, options.past_buffer_length, width}), + }; + + std::vector outputs{builder.MakeOutput()}; + for (int64_t slot = 1; slot < options.output_count; ++slot) { + outputs.push_back(slot == sai::kPresentKey ? &empty : builder.MakeOutput()); + } + + Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); + node.AddAttribute("policy_mode", std::string(sai::kPolicyModeCsa)); + node.AddAttribute("compress_ratio", options.compress_ratio); + node.AddAttribute("index_topk", options.index_topk); + if (options.add_token_budget) { + node.AddAttribute("token_budget", static_cast(8)); + } +} + +// --------------------------------------------------------------------------------------------- +// Numeric reference +// --------------------------------------------------------------------------------------------- + +// Deterministic values in [-1, 1]; distinct phases keep the per-block scores well separated so the +// selection order does not depend on rounding. +std::vector MakeWave(size_t count, float phase, float step) { + std::vector values(count); + for (size_t i = 0; i < count; ++i) { + values[i] = std::sin(phase + step * static_cast(i)); + } + return values; +} + +template +std::vector ToElementType(const std::vector& data) { + if constexpr (std::is_same_v) { + return ToFloat16(data); + } else if constexpr (std::is_same_v) { + return ToBFloat16(data); + } else { + return data; + } +} + +// Rounds through the tested element type so the reference consumes exactly the kernel's inputs. +template +std::vector RoundTrip(const std::vector& data) { + if constexpr (std::is_same_v) { + return data; + } else { + std::vector converted = ToElementType(data); + std::vector result(data.size()); + for (size_t i = 0; i < data.size(); ++i) { + result[i] = converted[i].ToFloat(); + } + return result; + } +} + +// Split-half rotary over the leading rotary_width channels. +std::vector LeadingRope(const std::vector& value, int rotary_width, const float* cos_row, + const float* sin_row) { + const int head_size = static_cast(value.size()); + const int half = rotary_width / 2; + std::vector result(value); + for (int d = 0; d < rotary_width && d < head_size; ++d) { + const float paired = (d < half) ? -value[static_cast(d + half)] : value[static_cast(d - half)]; + result[static_cast(d)] = value[static_cast(d)] * cos_row[d] + paired * sin_row[d]; + } + return result; +} + +// Interleaved rotary over the trailing 2 * rotary_width channels. +std::vector TrailingRope(const std::vector& value, int rotary_width, const float* cos_row, + const float* sin_row) { + const int head_size = static_cast(value.size()); + const int base = head_size - 2 * rotary_width; + std::vector result(value); + for (int d = base; d < head_size; ++d) { + const int offset = d - base; + const float paired = ((offset & 1) == 0) ? -value[static_cast(d + 1)] : value[static_cast(d - 1)]; + result[static_cast(d)] = + value[static_cast(d)] * cos_row[offset >> 1] + paired * sin_row[offset >> 1]; + } + return result; +} + +std::vector RmsNormalize(const std::vector& value, const std::vector& weight, float epsilon) { + float sum_squares = 0.0f; + for (float element : value) { + sum_squares += element * element; + } + const float inverse_rms = 1.0f / std::sqrt(sum_squares / static_cast(value.size()) + epsilon); + std::vector result(value.size()); + for (size_t d = 0; d < value.size(); ++d) { + result[d] = value[d] * inverse_rms * weight[d]; + } + return result; +} + +// Order used by both selection kernels: score descending, then entry index ascending. +std::vector RankByScore(const std::vector& scores, int count) { + std::vector order(static_cast(count)); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), [&scores](int left, int right) { + if (scores[static_cast(left)] != scores[static_cast(right)]) { + return scores[static_cast(left)] > scores[static_cast(right)]; + } + return left < right; + }); + return order; +} + +struct QsaProblem { + int batch_size = 1; + int sequence_length = 2; + int num_heads = 2; + int head_size = 4; + int past_sequence_length = 3; + int rotary_width = 4; + int compress_ratio = 2; + int token_budget = 4; + float epsilon = 1.0e-6f; + + std::vector query; + std::vector key; + std::vector key_norm_weight; + std::vector cos_cache; + std::vector sin_cache; + std::vector mask; + std::vector past_key; + + int TotalSequenceLength() const { return past_sequence_length + sequence_length; } + int MaxRotaryLength() const { return TotalSequenceLength(); } + int Capacity() const { return token_budget + compress_ratio - 1; } +}; + +void QsaReference(const QsaProblem& problem, std::vector& selected, std::vector& present_key) { + const int total = problem.TotalSequenceLength(); + const int head_size = problem.head_size; + const int capacity = problem.Capacity(); + const int block_topk = problem.token_budget / problem.compress_ratio; + const float scale = 1.0f / std::sqrt(static_cast(head_size)); + + present_key.assign(static_cast(problem.batch_size) * total * head_size, 0.0f); + for (int b = 0; b < problem.batch_size; ++b) { + for (int t = 0; t < total; ++t) { + for (int d = 0; d < head_size; ++d) { + present_key[(static_cast(b) * total + t) * head_size + d] = + t < problem.past_sequence_length + ? problem.past_key[(static_cast(b) * problem.past_sequence_length + t) * head_size + d] + : problem.key[(static_cast(b) * problem.sequence_length + t - problem.past_sequence_length) * + head_size + + d]; + } + } + } + + 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; + for (int s = 0; s < problem.sequence_length; ++s) { + const size_t row = static_cast(b) * problem.sequence_length + s; + + std::vector> rotated_query(static_cast(problem.num_heads)); + const int query_position = problem.past_sequence_length + s; + for (int h = 0; h < problem.num_heads; ++h) { + const size_t base = (row * problem.num_heads + h) * head_size; + std::vector head(problem.query.begin() + base, problem.query.begin() + base + head_size); + rotated_query[static_cast(h)] = + LeadingRope(head, problem.rotary_width, cos_base + query_position * problem.rotary_width, + sin_base + query_position * problem.rotary_width); + } + + std::vector visible; + for (int t = 0; t < total; ++t) { + if (problem.mask[row * total + t] != 0) { + visible.push_back(t); + } + } + const int block_count = static_cast(visible.size()) / problem.compress_ratio; + + std::vector scores(static_cast(block_count), 0.0f); + for (int block = 0; block < block_count; ++block) { + std::vector pooled(static_cast(head_size), 0.0f); + for (int t = 0; t < problem.compress_ratio; ++t) { + const int token = visible[static_cast(block * problem.compress_ratio + t)]; + for (int d = 0; d < head_size; ++d) { + pooled[static_cast(d)] += + present_key[(static_cast(b) * total + token) * head_size + d]; + } + } + for (float& element : pooled) { + element /= static_cast(problem.compress_ratio); + } + pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); + const int key_position = visible[static_cast(block * problem.compress_ratio)]; + pooled = LeadingRope(pooled, problem.rotary_width, cos_base + key_position * problem.rotary_width, + sin_base + key_position * problem.rotary_width); + + float score = 0.0f; + for (int h = 0; h < problem.num_heads; ++h) { + float dot = 0.0f; + for (int d = 0; d < head_size; ++d) { + dot += rotated_query[static_cast(h)][static_cast(d)] * pooled[static_cast(d)]; + } + score += std::max(dot, 0.0f); + } + scores[static_cast(block)] = score * scale; + } + + const std::vector order = RankByScore(scores, block_count); + const int emitted = std::min(block_topk, block_count); + int32_t* out_row = selected.data() + row * capacity; + for (int rank = 0; rank < emitted; ++rank) { + for (int t = 0; t < problem.compress_ratio; ++t) { + out_row[rank * problem.compress_ratio + t] = + visible[static_cast(order[static_cast(rank)] * problem.compress_ratio + t)]; + } + } + const int tail_start = block_count * problem.compress_ratio; + for (size_t t = static_cast(tail_start); t < visible.size(); ++t) { + out_row[emitted * problem.compress_ratio + static_cast(t) - tail_start] = visible[t]; + } + } + } +} + +struct CsaProblem { + int batch_size = 1; + int sequence_length = 3; + int num_heads = 2; + int head_size = 4; + int rotary_width = 2; + int compress_ratio = 2; + int index_topk = 2; + int past_compressed_length = 1; + int past_buffer_length = 3; + int max_rotary_length = 5; + float epsilon = 1.0e-6f; + + std::vector query; + std::vector key; + std::vector key_norm_weight; + std::vector cos_cache; + std::vector sin_cache; + std::vector gate; + std::vector position_bias; + std::vector head_weights; + std::vector position_ids; + std::vector past_compressed_key; + std::vector past_kv_buffer; + std::vector past_gate_buffer; + + int Width() const { return 2 * head_size; } +}; + +// Value of channel `channel` of token `position` of the virtual sequence [past buffer | new tokens]. +float ExtendedValue(const CsaProblem& problem, const std::vector& past, const std::vector& current, + int batch, int position, int channel) { + const int width = problem.Width(); + if (position < problem.past_buffer_length) { + return past[(static_cast(batch) * problem.past_buffer_length + position) * width + channel]; + } + return current[(static_cast(batch) * problem.sequence_length + position - problem.past_buffer_length) * + width + + channel]; +} + +void CsaReference(const CsaProblem& problem, std::vector& selected, + std::vector& present_compressed_key, std::vector& present_kv_buffer, + std::vector& present_gate_buffer) { + sai::CsaWindowPlan plan; + ASSERT_TRUE(sai::TryComputeCsaWindowPlan(problem.past_buffer_length, problem.sequence_length, + problem.compress_ratio, plan)); + + const int head_size = problem.head_size; + const int width = problem.Width(); + const int present_compressed_length = + problem.past_compressed_length + static_cast(plan.new_window_count); + const int present_buffer_length = static_cast(plan.present_buffer_length); + const float scale = 1.0f / std::sqrt(static_cast(head_size)); + const float head_weight_scale = 1.0f / std::sqrt(static_cast(problem.num_heads)); + + present_compressed_key.assign( + static_cast(problem.batch_size) * present_compressed_length * head_size, 0.0f); + present_kv_buffer.assign(static_cast(problem.batch_size) * present_buffer_length * width, 0.0f); + present_gate_buffer.assign(present_kv_buffer.size(), 0.0f); + selected.assign(static_cast(problem.batch_size) * problem.sequence_length * problem.index_topk, -1); + + for (int b = 0; b < problem.batch_size; ++b) { + for (int entry = 0; entry < problem.past_compressed_length; ++entry) { + for (int d = 0; d < head_size; ++d) { + present_compressed_key[(static_cast(b) * present_compressed_length + entry) * head_size + d] = + problem.past_compressed_key[(static_cast(b) * problem.past_compressed_length + entry) * head_size + + d]; + } + } + + const float* cos_base = + problem.cos_cache.data() + static_cast(b) * 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; + + for (int window = 0; window < plan.new_window_count; ++window) { + const bool has_previous = window >= 1 || plan.overlap_length >= problem.compress_ratio; + const int previous_base = static_cast(plan.overlap_length) + (window - 1) * problem.compress_ratio; + const int current_base = static_cast(plan.overlap_length) + window * problem.compress_ratio; + + std::vector pooled(static_cast(head_size), 0.0f); + for (int d = 0; d < head_size; ++d) { + std::vector logits; + std::vector values; + if (has_previous) { + for (int slot = 0; slot < problem.compress_ratio; ++slot) { + logits.push_back( + ExtendedValue(problem, problem.past_gate_buffer, problem.gate, b, previous_base + slot, d) + + problem.position_bias[static_cast(slot) * width + d]); + values.push_back( + ExtendedValue(problem, problem.past_kv_buffer, problem.key, b, previous_base + slot, d)); + } + } + for (int slot = 0; slot < problem.compress_ratio; ++slot) { + logits.push_back( + ExtendedValue(problem, problem.past_gate_buffer, problem.gate, b, current_base + slot, head_size + d) + + problem.position_bias[static_cast(slot) * width + head_size + d]); + values.push_back( + ExtendedValue(problem, problem.past_kv_buffer, problem.key, b, current_base + slot, head_size + d)); + } + + const float max_logit = *std::max_element(logits.begin(), logits.end()); + float denominator = 0.0f; + float accumulator = 0.0f; + for (size_t slot = 0; slot < logits.size(); ++slot) { + const float weight = std::exp(logits[slot] - max_logit); + denominator += weight; + accumulator += weight * values[slot]; + } + pooled[static_cast(d)] = accumulator / denominator; + } + + pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); + const int entry = problem.past_compressed_length + window; + const int position = std::min(entry * problem.compress_ratio, problem.max_rotary_length - 1); + pooled = TrailingRope(pooled, problem.rotary_width, cos_base + position * problem.rotary_width, + sin_base + position * problem.rotary_width); + for (int d = 0; d < head_size; ++d) { + present_compressed_key[(static_cast(b) * present_compressed_length + entry) * head_size + d] = + pooled[static_cast(d)]; + } + } + + for (int token = 0; token < present_buffer_length; ++token) { + const int source = static_cast(plan.present_buffer_start) + token; + for (int channel = 0; channel < width; ++channel) { + const size_t index = (static_cast(b) * present_buffer_length + token) * width + channel; + present_kv_buffer[index] = ExtendedValue(problem, problem.past_kv_buffer, problem.key, b, source, channel); + present_gate_buffer[index] = ExtendedValue(problem, problem.past_gate_buffer, problem.gate, b, source, channel); + } + } + + for (int s = 0; s < problem.sequence_length; ++s) { + const size_t row = static_cast(b) * problem.sequence_length + s; + const int64_t position = problem.position_ids[row]; + const int query_position = static_cast( + std::min(std::max(position, 0), problem.max_rotary_length - 1)); + + std::vector> rotated_query(static_cast(problem.num_heads)); + for (int h = 0; h < problem.num_heads; ++h) { + const size_t base = (row * problem.num_heads + h) * head_size; + std::vector head(problem.query.begin() + base, problem.query.begin() + base + head_size); + rotated_query[static_cast(h)] = + TrailingRope(head, problem.rotary_width, cos_base + query_position * problem.rotary_width, + sin_base + query_position * problem.rotary_width); + } + + const int64_t threshold = position < 0 ? 0 : (position + 1) / problem.compress_ratio; + std::vector scores(static_cast(present_compressed_length), 0.0f); + for (int entry = 0; entry < present_compressed_length; ++entry) { + if (static_cast(entry) >= threshold) { + scores[static_cast(entry)] = -std::numeric_limits::infinity(); + continue; + } + float total_score = 0.0f; + for (int h = 0; h < problem.num_heads; ++h) { + float dot = 0.0f; + for (int d = 0; d < head_size; ++d) { + dot += rotated_query[static_cast(h)][static_cast(d)] * + present_compressed_key[(static_cast(b) * present_compressed_length + entry) * head_size + d]; + } + total_score += std::max(dot, 0.0f) * problem.head_weights[row * problem.num_heads + h]; + } + scores[static_cast(entry)] = total_score * scale * head_weight_scale; + } + + const std::vector order = RankByScore(scores, present_compressed_length); + const int emitted = std::min(problem.index_topk, present_compressed_length); + int32_t* out_row = selected.data() + row * problem.index_topk; + for (int rank = 0; rank < emitted; ++rank) { + const int entry = order[static_cast(rank)]; + out_row[rank] = static_cast(entry) < threshold ? entry : -1; + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// Numeric runners +// --------------------------------------------------------------------------------------------- + +bool HasCudaProvider() { return DefaultCudaExecutionProvider() != nullptr; } + +void RunOnCuda(OpTester& test) { + std::vector> providers; + providers.push_back(DefaultCudaExecutionProvider()); + test.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); +} + +QsaProblem MakeQsaProblem() { + QsaProblem problem; + const int total = problem.TotalSequenceLength(); + problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * + problem.head_size, + 0.35f, 0.41f); + problem.key = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.head_size, + 1.10f, 0.29f); + 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); + problem.past_key = MakeWave( + static_cast(problem.batch_size) * problem.past_sequence_length * 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) * problem.sequence_length * total, 0); + for (int b = 0; b < problem.batch_size; ++b) { + for (int s = 0; s < problem.sequence_length; ++s) { + const size_t row = static_cast(b) * problem.sequence_length + s; + const int visible = problem.past_sequence_length + s + 1; + for (int t = 0; t < visible; ++t) { + problem.mask[row * total + t] = 1; + } + } + } + return problem; +} + +template +void RunQsaTest(float tolerance) { + if (!HasCudaProvider()) { + GTEST_SKIP() << "CUDA execution provider is not available"; + } + + QsaProblem problem = MakeQsaProblem(); + problem.query = RoundTrip(problem.query); + problem.key = RoundTrip(problem.key); + problem.key_norm_weight = RoundTrip(problem.key_norm_weight); + problem.cos_cache = RoundTrip(problem.cos_cache); + problem.sin_cache = RoundTrip(problem.sin_cache); + problem.past_key = RoundTrip(problem.past_key); + + std::vector selected; + std::vector present_key; + QsaReference(problem, selected, present_key); + + const int64_t batch_size = problem.batch_size; + const int64_t sequence_length = problem.sequence_length; + const int64_t total = problem.TotalSequenceLength(); + const int64_t head_size = problem.head_size; + + std::unique_ptr mask(new bool[problem.mask.size()]); + for (size_t i = 0; i < problem.mask.size(); ++i) { + mask[i] = problem.mask[i] != 0; + } + + OpTester test("SparseAttentionIndexer", 1, onnxruntime::kMSDomain); + test.AddAttribute("policy_mode", std::string(sai::kPolicyModeQsa)); + test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); + test.AddAttribute("token_budget", static_cast(problem.token_budget)); + test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, + ToElementType(problem.query)); + test.AddInput("key", {batch_size, sequence_length, head_size}, ToElementType(problem.key)); + 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, 1, sequence_length, total}, mask.get(), problem.mask.size()); + test.AddInput("past_key", {batch_size, problem.past_sequence_length, head_size}, + ToElementType(problem.past_key)); + test.AddOutput("selected_indices", {batch_size, sequence_length, problem.Capacity()}, selected); + test.AddOutput("present_key", {batch_size, total, head_size}, ToElementType(present_key), false, 0.0f, + tolerance); + RunOnCuda(test); +} + +CsaProblem MakeCsaProblem() { + CsaProblem problem; + const int width = problem.Width(); + problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * + problem.head_size, + 0.25f, 0.37f); + problem.key = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * width, 0.60f, 0.21f); + problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.45f, 0.31f); + problem.cos_cache = + MakeWave(static_cast(problem.batch_size) * 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); + 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( + static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads, 1.30f, 0.47f); + problem.past_compressed_key = MakeWave( + static_cast(problem.batch_size) * problem.past_compressed_length * problem.head_size, 0.50f, 0.39f); + problem.past_kv_buffer = + MakeWave(static_cast(problem.batch_size) * problem.past_buffer_length * width, 0.95f, 0.18f); + problem.past_gate_buffer = + MakeWave(static_cast(problem.batch_size) * problem.past_buffer_length * width, 1.45f, 0.22f); + + problem.position_ids.assign(static_cast(problem.batch_size) * problem.sequence_length, 0); + for (int b = 0; b < problem.batch_size; ++b) { + for (int s = 0; s < problem.sequence_length; ++s) { + problem.position_ids[static_cast(b) * problem.sequence_length + s] = 2 + s; + } + } + return problem; +} + +template +void RunCsaTest(const CsaProblem& base, float tolerance) { + if (!HasCudaProvider()) { + GTEST_SKIP() << "CUDA execution provider is not available"; + } + + CsaProblem problem = base; + problem.query = RoundTrip(problem.query); + problem.key = RoundTrip(problem.key); + problem.key_norm_weight = RoundTrip(problem.key_norm_weight); + problem.cos_cache = RoundTrip(problem.cos_cache); + problem.sin_cache = RoundTrip(problem.sin_cache); + problem.gate = RoundTrip(problem.gate); + problem.position_bias = RoundTrip(problem.position_bias); + problem.head_weights = RoundTrip(problem.head_weights); + problem.past_compressed_key = RoundTrip(problem.past_compressed_key); + problem.past_kv_buffer = RoundTrip(problem.past_kv_buffer); + problem.past_gate_buffer = RoundTrip(problem.past_gate_buffer); + + std::vector selected; + std::vector present_compressed_key; + std::vector present_kv_buffer; + std::vector present_gate_buffer; + CsaReference(problem, selected, present_compressed_key, present_kv_buffer, present_gate_buffer); + ASSERT_FALSE(::testing::Test::HasFatalFailure()); + + sai::CsaWindowPlan plan; + ASSERT_TRUE(sai::TryComputeCsaWindowPlan(problem.past_buffer_length, problem.sequence_length, + problem.compress_ratio, plan)); + const int64_t batch_size = problem.batch_size; + const int64_t sequence_length = problem.sequence_length; + const int64_t head_size = problem.head_size; + const int64_t width = problem.Width(); + const int64_t present_compressed_length = problem.past_compressed_length + plan.new_window_count; + + OpTester test("SparseAttentionIndexer", 1, onnxruntime::kMSDomain); + test.AddAttribute("policy_mode", std::string(sai::kPolicyModeCsa)); + test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); + test.AddAttribute("index_topk", static_cast(problem.index_topk)); + test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, + ToElementType(problem.query)); + test.AddInput("key", {batch_size, sequence_length, width}, ToElementType(problem.key)); + 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)); + test.AddOptionalInputEdge(); + test.AddOptionalInputEdge(); + test.AddInput("gate", {batch_size, sequence_length, width}, ToElementType(problem.gate)); + test.AddInput("position_bias", {problem.compress_ratio, width}, ToElementType(problem.position_bias)); + test.AddInput("head_weights", {batch_size, sequence_length, problem.num_heads}, + ToElementType(problem.head_weights)); + test.AddInput("position_ids", {batch_size, sequence_length}, problem.position_ids); + test.AddInput("past_compressed_key", {batch_size, problem.past_compressed_length, head_size}, + ToElementType(problem.past_compressed_key)); + test.AddInput("past_kv_buffer", {batch_size, problem.past_buffer_length, width}, + ToElementType(problem.past_kv_buffer)); + test.AddInput("past_gate_buffer", {batch_size, problem.past_buffer_length, width}, + ToElementType(problem.past_gate_buffer)); + + test.AddOutput("selected_indices", {batch_size, sequence_length, problem.index_topk}, selected); + test.AddOptionalOutputEdge(); + test.AddOutput("present_compressed_key", {batch_size, present_compressed_length, head_size}, + ToElementType(present_compressed_key), false, 0.0f, tolerance); + test.AddOutput("present_kv_buffer", {batch_size, plan.present_buffer_length, width}, + ToElementType(present_kv_buffer), false, 0.0f, tolerance); + test.AddOutput("present_gate_buffer", {batch_size, plan.present_buffer_length, width}, + ToElementType(present_gate_buffer), false, 0.0f, tolerance); + RunOnCuda(test); +} + +// A call whose tokens do not close a window: the buffer only grows and the compressed state is +// unchanged, so the queries score against the entries produced by earlier calls. +CsaProblem MakeCsaBufferOnlyProblem() { + CsaProblem problem; + problem.sequence_length = 1; + problem.num_heads = 1; + problem.compress_ratio = 4; + problem.past_compressed_length = 2; + problem.past_buffer_length = 2; + problem.max_rotary_length = 9; + const int width = problem.Width(); + + problem.query = MakeWave(static_cast(problem.num_heads) * problem.head_size, 0.31f, 0.43f); + problem.key = MakeWave(static_cast(problem.sequence_length) * width, 0.66f, 0.25f); + problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.41f, 0.35f); + problem.cos_cache = MakeWave(static_cast(problem.max_rotary_length) * problem.rotary_width, 0.12f, 0.29f); + problem.sin_cache = MakeWave(static_cast(problem.max_rotary_length) * problem.rotary_width, 1.02f, 0.36f); + problem.gate = MakeWave(static_cast(problem.sequence_length) * width, 0.84f, 0.26f); + problem.position_bias = MakeWave(static_cast(problem.compress_ratio) * width, 0.37f, 0.13f); + problem.head_weights = MakeWave(static_cast(problem.num_heads), 1.20f, 0.51f); + problem.past_compressed_key = + MakeWave(static_cast(problem.past_compressed_length) * problem.head_size, 0.52f, 0.41f); + problem.past_kv_buffer = MakeWave(static_cast(problem.past_buffer_length) * width, 0.97f, 0.20f); + problem.past_gate_buffer = MakeWave(static_cast(problem.past_buffer_length) * width, 1.48f, 0.24f); + problem.position_ids = {8}; + return problem; +} + +} // namespace + +// --------------------------------------------------------------------------------------------- +// Shape inference +// --------------------------------------------------------------------------------------------- + +TEST(SparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndPresentKey) { + QsaGraphOptions options; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + const int64_t capacity = options.token_budget + options.compress_ratio - 1; + ExpectShape(graph, node.OutputDefs()[sai::kSelectedIndices]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.batch_size, options.sequence_length, capacity}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentKey]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.past_sequence_length + options.sequence_length, options.head_size}); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { + CsaGraphOptions options; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, model)); + + // buffer_length 5 with compress_ratio 4 means one complete window is buffered and one token is + // pending, so the five new tokens close exactly one window and leave two pending. + sai::CsaWindowPlan plan; + ASSERT_TRUE(sai::TryComputeCsaWindowPlan(options.past_buffer_length, options.sequence_length, + options.compress_ratio, plan)); + ASSERT_EQ(plan.new_window_count, 1); + ASSERT_EQ(plan.present_buffer_length, 6); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + ExpectShape(graph, node.OutputDefs()[sai::kSelectedIndices]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.batch_size, options.sequence_length, options.index_topk}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentCompressedKey]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.past_compressed_length + plan.new_window_count, options.head_size}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentKvBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, plan.present_buffer_length, 2 * options.head_size}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentGateBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, plan.present_buffer_length, 2 * options.head_size}); +} + +#ifndef ORT_NO_EXCEPTIONS + +namespace { + +void ExpectResolveFailure(const std::function& add_node, + const std::string& expected_message) { + std::unique_ptr model; + const Status status = BuildAndResolve(add_node, model); + ASSERT_FALSE(status.IsOK()) << "expected shape inference to reject the node"; + EXPECT_NE(status.ErrorMessage().find(expected_message), std::string::npos) + << "actual message: " << status.ErrorMessage(); +} + +} // namespace + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsUnknownPolicyMode) { + QsaGraphOptions options; + options.policy_mode = "qsa_v2"; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "policy_mode must be 'qsa' or 'csa'"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaAttribute) { + QsaGraphOptions options; + options.add_index_topk = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "index_topk and head_weight_scale must not be set"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { + QsaGraphOptions options; + options.token_budget = 5; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "requires token_budget > 0 and divisible by compress_ratio"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { + QsaGraphOptions options; + options.add_csa_inputs = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "must be omitted when policy_mode is 'qsa'"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithQsaAttribute) { + CsaGraphOptions options; + options.add_token_budget = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "token_budget must not be set when policy_mode is 'csa'"); +} + +// A "csa" node must declare every state output. Rejecting the node before any output is written +// keeps inference from touching an output index the node does not have. +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithMissingStateOutputs) { + CsaGraphOptions options; + options.output_count = 3; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "requires exactly 5 declared outputs"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedCsaBuffer) { + CsaGraphOptions options; + options.past_buffer_length = 2 * options.compress_ratio; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "past_kv_buffer sequence length must be in [0, 2 * compress_ratio)"); +} + +#endif // ORT_NO_EXCEPTIONS + +// --------------------------------------------------------------------------------------------- +// Numeric behaviour (CUDA only) +// --------------------------------------------------------------------------------------------- + +TEST(SparseAttentionIndexerTest, QsaFloat) { RunQsaTest(1.0e-5f); } + +TEST(SparseAttentionIndexerTest, QsaFloat16) { RunQsaTest(2.0e-3f); } + +TEST(SparseAttentionIndexerTest, QsaBFloat16) { RunQsaTest(2.0e-2f); } + +TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } + +TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } + +TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProblem(), 3.0e-2f); } + +TEST(SparseAttentionIndexerTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f); } + +} // namespace test +} // namespace onnxruntime From d0a8e7a570602fd6425ec0a438aaa3fc4a91395d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:12:36 +0000 Subject: [PATCH 02/16] Harden SparseAttentionIndexer validation and tests Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse_attention_indexer.md | 6 ++--- .../cuda/sparse/sparse_attention_indexer.cc | 2 ++ .../core/graph/contrib_ops/bert_defs.cc | 4 +++ .../sparse_attention_indexer_op_test.cc | 27 ++++++++++++++----- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index c3c2b422957a6..4a51d8859a349 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -246,8 +246,8 @@ DeepSeek's `DeepseekV4RMSNorm` uses a plain `weight *`, so its tensor is passed ## 7. CUDA Kernel Pipeline All kernels use a fixed block of 128 threads (a power of two, required by the shared-memory block -reductions). Grid sizes are clamped to 65535 blocks and the elementwise kernels use grid-stride -loops, so no launch configuration depends on tensor data. +reductions). Elementwise kernels clamp the grid to 65535 blocks and use grid-stride loops; kernels +that assign one block to each work item clamp the grid to the CUDA `gridDim.x` limit. ### `qsa` @@ -267,7 +267,7 @@ loops, so no launch configuration depends on tensor data. | 2 | `CsaCompressKernel` | one block per `(b, window)`; per-channel softmax over `2r` slots | | 3 | `CsaCopyBufferKernel` | element | | 4 | `RotateQueryKernel` | one block per `(b, s, h)` | -| 5 | `CsaScoreKernel` | one block per `(b, s, entry)` | +| 5 | `CsaScoreKernel` | one thread per `(b, s, entry)` | | 6 | `CsaSelectKernel` | one block per `(b, s)`; iterated block arg-max | ### Workspaces diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 5a3d84eacb7a6..c7955310afd57 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -122,6 +122,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { const int64_t sequence_length = query_shape[1]; const int64_t num_heads = query_shape[2]; const int64_t head_size = query_shape[3]; + ORT_RETURN_IF_NOT(num_heads > 0, "SparseAttentionIndexer: num_heads must be > 0, got ", num_heads); const auto& cos_shape = cos_cache->Shape(); ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, @@ -226,6 +227,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { const int64_t sequence_length = query_shape[1]; const int64_t num_heads = query_shape[2]; const int64_t head_size = query_shape[3]; + ORT_RETURN_IF_NOT(num_heads > 0, "SparseAttentionIndexer: num_heads must be > 0, got ", num_heads); const int64_t width = 2 * head_size; const auto& cos_shape = cos_cache->Shape(); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index dbaebf5b191fe..07dbaf29bfdac 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2004,7 +2004,11 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex } const auto& batch_dim = query_shape->dim(0); const auto& sequence_dim = query_shape->dim(1); + const auto& num_heads_dim = query_shape->dim(2); const auto& head_size_dim = query_shape->dim(3); + if (num_heads_dim.has_dim_value() && num_heads_dim.dim_value() <= 0) { + fail_shape_inference("SparseAttentionIndexer: num_heads must be > 0, got ", num_heads_dim.dim_value()); + } const int64_t capacity = sai::SelectedCapacity(policy, token_budget, index_topk, compress_ratio); ONNX_NAMESPACE::TensorShapeProto selected_shape; 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 ef8aadf3428ca..e18f7594cb392 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -275,7 +275,7 @@ std::vector RankByScore(const std::vector& scores, int count) { } struct QsaProblem { - int batch_size = 1; + int batch_size = 2; int sequence_length = 2; int num_heads = 2; int head_size = 4; @@ -393,7 +393,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std } struct CsaProblem { - int batch_size = 1; + int batch_size = 2; int sequence_length = 3; int num_heads = 2; int head_size = 4; @@ -582,8 +582,7 @@ void RunOnCuda(OpTester& test) { test.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); } -QsaProblem MakeQsaProblem() { - QsaProblem problem; +QsaProblem MakeQsaProblem(QsaProblem problem = {}) { const int total = problem.TotalSequenceLength(); problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * problem.head_size, @@ -611,12 +610,11 @@ QsaProblem MakeQsaProblem() { } template -void RunQsaTest(float tolerance) { +void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { if (!HasCudaProvider()) { GTEST_SKIP() << "CUDA execution provider is not available"; } - QsaProblem problem = MakeQsaProblem(); problem.query = RoundTrip(problem.query); problem.key = RoundTrip(problem.key); problem.key_norm_weight = RoundTrip(problem.key_norm_weight); @@ -767,6 +765,7 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { // unchanged, so the queries score against the entries produced by earlier calls. CsaProblem MakeCsaBufferOnlyProblem() { CsaProblem problem; + problem.batch_size = 1; problem.sequence_length = 1; problem.num_heads = 1; problem.compress_ratio = 4; @@ -866,6 +865,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaAttribute) { "index_topk and head_weight_scale must not be set"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { + QsaGraphOptions options; + options.num_heads = 0; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "num_heads must be > 0"); +} + TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { QsaGraphOptions options; options.token_budget = 5; @@ -915,6 +921,15 @@ TEST(SparseAttentionIndexerTest, QsaFloat16) { RunQsaTest(2.0e-3f); } TEST(SparseAttentionIndexerTest, QsaBFloat16) { RunQsaTest(2.0e-2f); } +TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { + QsaProblem problem; + problem.batch_size = 1; + problem.head_size = 192; + problem.past_sequence_length = 200; + problem.rotary_width = 4; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } From 9acfe2389d1d77d8f644842726025e0806f3fd76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:51:50 +0000 Subject: [PATCH 03/16] Implement SparseAttentionIndexer for WebGPU Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse_attention_indexer.md | 7 +- .../webgpu/sparse_attention_indexer.md | 43 + .../webgpu/bert/sparse_attention_indexer.cc | 812 ++++++++++++++++++ .../webgpu/bert/sparse_attention_indexer.h | 159 ++++ .../webgpu/webgpu_contrib_kernels.cc | 2 + .../sparse_attention_indexer_op_test.cc | 80 +- 6 files changed, 1089 insertions(+), 14 deletions(-) create mode 100644 docs/contrib_ops/webgpu/sparse_attention_indexer.md create mode 100644 onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc create mode 100644 onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index 4a51d8859a349..c7bae91e48617 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -117,9 +117,10 @@ follow from `Lb`, `S` and `r` alone (see [§5](#5-policy-csa)). | `I` | `tensor(int64)` | | `M` | `tensor(int32)` | -Only the CUDA execution provider registers a kernel. There is no CPU kernel; the header under -`contrib_ops/cpu/sparse/` only holds the CUDA-free constants that the schema, the kernel and the -tests must agree on. +CUDA registers all three `T` types. WebGPU registers `float` and `float16`; see the +[WebGPU implementation notes](../webgpu/sparse_attention_indexer.md). There is no CPU kernel; the +header under `contrib_ops/cpu/sparse/` only holds the provider-neutral constants that the schema, +kernels and tests must agree on. ### Output slot discipline diff --git a/docs/contrib_ops/webgpu/sparse_attention_indexer.md b/docs/contrib_ops/webgpu/sparse_attention_indexer.md new file mode 100644 index 0000000000000..7d012bc66591a --- /dev/null +++ b/docs/contrib_ops/webgpu/sparse_attention_indexer.md @@ -0,0 +1,43 @@ +# SparseAttentionIndexer on WebGPU + +The WebGPU execution provider implements version 1 of +`com.microsoft.SparseAttentionIndexer` for the `qsa` and `csa` policies. It uses +the provider-neutral schema and state ABI described in the +[operator documentation](../cuda/sparse_attention_indexer.md). + +## Supported subset + +- batched inputs; +- `qsa` and `csa` policy modes; +- `float32` and `float16`; +- explicit graph-visible key, compressed-key, and incomplete-window state; +- arbitrary boolean QSA visibility masks; +- deterministic score-descending, index-ascending TopK ties. + +BF16 and packed/variable-length inputs are not registered by the WebGPU kernel. +Unknown policies and policy-incompatible inputs or attributes are rejected. + +## Execution + +State concatenation, visible-token grouping, QSA pooling, CSA overlap +compression, RMS normalization, rotary embedding, scoring, selection, and +output padding execute in WGSL. The implementation does not map GPU buffers, +read selected values back to the host, or retain state in the kernel object. +All reductions and softmax calculations accumulate in FP32, including for +FP16 inputs. + +The initial implementation prioritizes correctness and uses one independently +writable workgroup per query or completed CSA window. Candidate scoring during +selection is recomputed rather than materialized, avoiding candidate-count +limits and GPU-to-CPU synchronization at the cost of additional computation. + +## Follow-up work + +- packed/variable-length input; +- specialized large-candidate TopK; +- subgroup-optimized reductions; +- fused projection, pooling, and scoring; +- reduced recomputation and temporary-buffer use; +- selector/executor fusion; +- WebGPU `DynamicSparseAttention` and `SparsePagedAttention`; +- additional element types. diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc new file mode 100644 index 0000000000000..7e95aae61c274 --- /dev/null +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -0,0 +1,812 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include "contrib_ops/webgpu/bert/sparse_attention_indexer.h" + +#include +#include +#include + +#include "contrib_ops/webgpu/webgpu_contrib_kernels.h" +#include "core/providers/webgpu/shader_helper.h" +#include "core/providers/webgpu/webgpu_supported_types.h" + +namespace onnxruntime { +namespace contrib { +namespace webgpu { + +namespace sai = onnxruntime::contrib::sparse_attention_indexer; + +ONNX_OPERATOR_KERNEL_EX( + SparseAttentionIndexer, + kMSDomain, + 1, + kWebGpuExecutionProvider, + (*KernelDefBuilder::Create()) + .TypeConstraint("T", WebGpuSupportedFloatTypes()) + .TypeConstraint("TB", DataTypeImpl::GetTensorType()) + .TypeConstraint("I", DataTypeImpl::GetTensorType()) + .TypeConstraint("M", DataTypeImpl::GetTensorType()), + SparseAttentionIndexer); + +namespace { + +constexpr uint32_t kWorkgroupSize = 64; + +Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list expected) { + ORT_RETURN_IF(tensor == nullptr, "SparseAttentionIndexer: ", name, " is required"); + const TensorShape expected_shape(expected); + ORT_RETURN_IF_NOT(tensor->Shape() == expected_shape, "SparseAttentionIndexer: ", name, " must have shape ", + expected_shape.ToString(), ", got ", tensor->Shape().ToString()); + return Status::OK(); +} + +uint32_t ToUint32(int64_t value) { + return onnxruntime::narrow(value); +} + +} // namespace + +Status SparseAttentionIndexerFillProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& output = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); + shader.MainFunctionBody() + << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.total") + << " " << output.SetByOffset("global_idx", "-1") << "\n"; + return Status::OK(); +} + +Status SparseAttentionIndexerQsaConcatProgram::GenerateShaderCode(ShaderHelper& shader) const { + const ShaderVariableHelper* past = nullptr; + const ShaderVariableHelper* current = nullptr; + if (has_past_) { + past = &shader.AddInput("past_key", ShaderUsage::UseUniform); + } + if (has_current_) { + current = &shader.AddInput("key", ShaderUsage::UseUniform); + } + const auto& present = shader.AddOutput("present_key", + ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + + shader.MainFunctionBody() + << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.total") + << " let d = global_idx % uniforms.head_size;\n" + << " let token_row = global_idx / uniforms.head_size;\n" + << " let token = token_row % uniforms.total_sequence_length;\n" + << " let batch = token_row / uniforms.total_sequence_length;\n"; + if (has_past_) { + shader.MainFunctionBody() + << " if (token < uniforms.past_sequence_length) {\n" + << " " << present.SetByOffset("global_idx", "present_key_element_t(" + past->GetByOffset("(batch * uniforms.past_sequence_length + token) * uniforms.head_size + d") + ")") + << "\n" + << " return;\n" + << " }\n"; + } + if (has_current_) { + shader.MainFunctionBody() + << " let current_token = token - uniforms.past_sequence_length;\n" + << " " << present.SetByOffset("global_idx", "present_key_element_t(" + current->GetByOffset("(batch * uniforms.sequence_length + current_token) * uniforms.head_size + d") + ")") + << "\n"; + } + return Status::OK(); +} + +Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); + const auto& present_key = shader.AddInput("present_key", ShaderUsage::UseUniform); + const auto& 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 offset = row * uniforms.total_sequence_length + token;\n" + << " return " << mask.GetByOffset("offset / 4u") << "[offset % 4u];\n" + << "}\n" + << "fn visible_at(row: u32, ordinal: u32) -> u32 {\n" + << " var seen = 0u;\n" + << " for (var token = 0u; token < uniforms.total_sequence_length; token++) {\n" + << " if (visible(row, token)) {\n" + << " if (seen == ordinal) { return token; }\n" + << " seen++;\n" + << " }\n" + << " }\n" + << " return uniforms.total_sequence_length;\n" + << "}\n" + << "fn clamp_position(position: u32) -> u32 {\n" + << " return min(position, uniforms.max_rotary_length - 1u);\n" + << "}\n" + << "fn query_value(row: u32, head: u32, d: u32) -> f32 {\n" + << " let base = (row * uniforms.num_heads + head) * uniforms.head_size;\n" + << " var value = f32(" << query.GetByOffset("base + d") << ");\n" + << " if (d < uniforms.rotary_width) {\n" + << " let half = uniforms.rotary_width / 2u;\n" + << " let pair_d = select(d - half, d + half, d < half);\n" + << " let sign = select(1.0, -1.0, d < half);\n" + << " let paired = sign * f32(" << query.GetByOffset("base + pair_d") << ");\n" + << " 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" + << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" + << sin_cache.GetByOffset("cache") << ");\n" + << " }\n" + << " return value;\n" + << "}\n" + << "fn pooled_value(row: u32, block: u32, d: u32) -> f32 {\n" + << " let batch = row / uniforms.sequence_length;\n" + << " let key_base = batch * uniforms.total_sequence_length * uniforms.head_size;\n" + << " var sum = 0.0;\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let token = visible_at(row, block * uniforms.compress_ratio + slot);\n" + << " sum += f32(" << present_key.GetByOffset("key_base + token * uniforms.head_size + d") << ");\n" + << " }\n" + << " return sum / f32(uniforms.compress_ratio);\n" + << "}\n" + << "fn normalized_value(row: u32, block: u32, d: u32) -> f32 {\n" + << " var square_sum = 0.0;\n" + << " for (var k = 0u; k < uniforms.head_size; k++) {\n" + << " let value = pooled_value(row, block, k);\n" + << " square_sum += value * value;\n" + << " }\n" + << " return pooled_value(row, block, d) * inverseSqrt(square_sum / f32(uniforms.head_size) + " + "uniforms.epsilon) * f32(" + << norm.GetByOffset("d") << ");\n" + << "}\n" + << "fn key_value(row: u32, block: u32, d: u32) -> f32 {\n" + << " var value = normalized_value(row, block, d);\n" + << " if (d < uniforms.rotary_width) {\n" + << " let half = uniforms.rotary_width / 2u;\n" + << " let pair_d = select(d - half, d + half, d < half);\n" + << " let sign = select(1.0, -1.0, d < half);\n" + << " 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" + << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" + << sin_cache.GetByOffset("cache") << ");\n" + << " }\n" + << " return value;\n" + << "}\n" + << "fn block_score(row: u32, block: u32) -> f32 {\n" + << " var score = 0.0;\n" + << " for (var head = 0u; head < uniforms.num_heads; head++) {\n" + << " var dot = 0.0;\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " dot += query_value(row, head, d) * key_value(row, block, d);\n" + << " }\n" + << " score += max(dot, 0.0);\n" + << " }\n" + << " return score * uniforms.scale;\n" + << "}\n"; + + shader.MainFunctionBody() + << " let row = workgroup_idx;\n" + << " if (row >= uniforms.rows || local_idx != 0u) { return; }\n" + << " let output_base = row * uniforms.capacity;\n" + << " for (var i = 0u; i < uniforms.capacity; i++) {\n" + << " " << selected.SetByOffset("output_base + i", "-1") << "\n" + << " }\n" + << " var visible_count = 0u;\n" + << " for (var token = 0u; token < uniforms.total_sequence_length; token++) {\n" + << " if (visible(row, token)) { visible_count++; }\n" + << " }\n" + << " let block_count = visible_count / uniforms.compress_ratio;\n" + << " let selected_blocks = min(uniforms.block_topk, block_count);\n" + << " var previous_score = 0.0;\n" + << " var previous_index = -1i;\n" + << " for (var rank = 0u; rank < selected_blocks; rank++) {\n" + << " var best_score = 0.0;\n" + << " var best_index = -1i;\n" + << " for (var candidate = 0u; candidate < block_count; candidate++) {\n" + << " let score = block_score(row, candidate);\n" + << " if (previous_index >= 0 && !(score < previous_score || " + "(score == previous_score && i32(candidate) > previous_index))) { continue; }\n" + << " if (best_index < 0 || score > best_score || (score == best_score && i32(candidate) < best_index)) {\n" + << " best_score = score;\n" + << " best_index = i32(candidate);\n" + << " }\n" + << " }\n" + << " if (best_index < 0) { break; }\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let token = visible_at(row, u32(best_index) * uniforms.compress_ratio + slot);\n" + << " " << selected.SetByOffset("output_base + rank * uniforms.compress_ratio + slot", "i32(token)") << "\n" + << " }\n" + << " previous_score = best_score;\n" + << " previous_index = best_index;\n" + << " }\n" + << " let tail_start = block_count * uniforms.compress_ratio;\n" + << " for (var tail = tail_start; tail < visible_count; tail++) {\n" + << " let output = selected_blocks * uniforms.compress_ratio + tail - tail_start;\n" + << " " << selected.SetByOffset("output_base + output", "i32(visible_at(row, tail))") << "\n" + << " }\n"; + return Status::OK(); +} + +Status SparseAttentionIndexerCsaCopyCompressedProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& past = shader.AddInput("past_compressed_key", ShaderUsage::UseUniform); + const auto& present = shader.AddOutput("present_compressed_key", + ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + shader.MainFunctionBody() + << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.total") + << " let d = global_idx % uniforms.head_size;\n" + << " let entry_row = global_idx / uniforms.head_size;\n" + << " let entry = entry_row % uniforms.past_length;\n" + << " let batch = entry_row / uniforms.past_length;\n" + << " let output = (batch * uniforms.present_length + entry) * uniforms.head_size + d;\n" + << " " << present.SetByOffset("output", "present_compressed_key_element_t(" + past.GetByOffset("global_idx") + ")") + << "\n"; + return Status::OK(); +} + +Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& key = shader.AddInput("key", ShaderUsage::UseUniform); + const auto& gate = shader.AddInput("gate", ShaderUsage::UseUniform); + const ShaderVariableHelper* past_kv = nullptr; + const ShaderVariableHelper* past_gate = nullptr; + if (has_past_buffer_) { + past_kv = &shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); + past_gate = &shader.AddInput("past_gate_buffer", ShaderUsage::UseUniform); + } + const auto& bias = shader.AddInput("position_bias", ShaderUsage::UseUniform); + const auto& 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& present = shader.AddOutput("present_compressed_key", + ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + + shader.AdditionalImplementation() + << "fn kv_value(batch: u32, position: u32, channel: u32) -> f32 {\n"; + if (has_past_buffer_) { + shader.AdditionalImplementation() + << " if (position < uniforms.past_buffer_length) {\n" + << " return f32(" << past_kv->GetByOffset( + "(batch * uniforms.past_buffer_length + position) * " + "(2u * uniforms.head_size) + channel") + << ");\n" + << " }\n"; + } + shader.AdditionalImplementation() + << " let token = position - uniforms.past_buffer_length;\n" + << " return f32(" << key.GetByOffset( + "(batch * uniforms.sequence_length + token) * " + "(2u * uniforms.head_size) + channel") + << ");\n" + << "}\n" + << "fn gate_value(batch: u32, position: u32, channel: u32) -> f32 {\n"; + if (has_past_buffer_) { + shader.AdditionalImplementation() + << " if (position < uniforms.past_buffer_length) {\n" + << " return f32(" << past_gate->GetByOffset( + "(batch * uniforms.past_buffer_length + position) * " + "(2u * uniforms.head_size) + channel") + << ");\n" + << " }\n"; + } + shader.AdditionalImplementation() + << " let token = position - uniforms.past_buffer_length;\n" + << " return f32(" << gate.GetByOffset( + "(batch * uniforms.sequence_length + token) * " + "(2u * uniforms.head_size) + channel") + << ");\n" + << "}\n" + << "fn pooled_value(batch: u32, window: u32, d: u32) -> f32 {\n" + << " let width = 2u * uniforms.head_size;\n" + << " let current_base = uniforms.overlap_length + window * uniforms.compress_ratio;\n" + << " let has_previous = window >= 1u || uniforms.overlap_length >= uniforms.compress_ratio;\n" + << " var max_gate = -3.4028234663852886e+38;\n" + << " if (has_previous) {\n" + << " let previous_base = uniforms.overlap_length + (window - 1u) * uniforms.compress_ratio;\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " max_gate = max(max_gate, gate_value(batch, previous_base + slot, d) + f32(" + << bias.GetByOffset("slot * width + d") << "));\n" + << " }\n" + << " }\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " max_gate = max(max_gate, gate_value(batch, current_base + slot, uniforms.head_size + d) + f32(" + << bias.GetByOffset("slot * width + uniforms.head_size + d") << "));\n" + << " }\n" + << " var denominator = 0.0;\n" + << " var accumulator = 0.0;\n" + << " if (has_previous) {\n" + << " let previous_base = uniforms.overlap_length + (window - 1u) * uniforms.compress_ratio;\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let weight = exp(gate_value(batch, previous_base + slot, d) + f32(" + << bias.GetByOffset("slot * width + d") << ") - max_gate);\n" + << " denominator += weight;\n" + << " accumulator += weight * kv_value(batch, previous_base + slot, d);\n" + << " }\n" + << " }\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let channel = uniforms.head_size + d;\n" + << " let weight = exp(gate_value(batch, current_base + slot, channel) + f32(" + << bias.GetByOffset("slot * width + uniforms.head_size + d") << ") - max_gate);\n" + << " denominator += weight;\n" + << " accumulator += weight * kv_value(batch, current_base + slot, channel);\n" + << " }\n" + << " return select(0.0, accumulator / denominator, denominator > 0.0);\n" + << "}\n"; + + shader.MainFunctionBody() + << " let work = workgroup_idx;\n" + << " if (work >= uniforms.work_items || local_idx != 0u) { return; }\n" + << " let window = work % uniforms.new_window_count;\n" + << " let batch = work / uniforms.new_window_count;\n" + << " var square_sum = 0.0;\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " let value = pooled_value(batch, window, d);\n" + << " square_sum += value * value;\n" + << " }\n" + << " let inverse_rms = inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon);\n" + << " let entry = uniforms.past_compressed_length + window;\n" + << " let position = min(entry * uniforms.compress_ratio, uniforms.max_rotary_length - 1u);\n" + << " let cache_base = (batch * uniforms.max_rotary_length + 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" + << " if (d >= rotary_base) {\n" + << " let offset = d - rotary_base;\n" + << " let pair_d = select(d - 1u, d + 1u, (offset & 1u) == 0u);\n" + << " let sign = select(1.0, -1.0, (offset & 1u) == 0u);\n" + << " let paired = sign * pooled_value(batch, window, pair_d) * inverse_rms * f32(" + << norm.GetByOffset("pair_d") << ");\n" + << " value = value * f32(" << cos_cache.GetByOffset("cache_base + offset / 2u") + << ") + paired * f32(" << sin_cache.GetByOffset("cache_base + offset / 2u") << ");\n" + << " }\n" + << " let output = (batch * uniforms.present_compressed_length + entry) * uniforms.head_size + d;\n" + << " " << present.SetByOffset("output", "present_compressed_key_element_t(value)") << "\n" + << " }\n"; + return Status::OK(); +} + +Status SparseAttentionIndexerCsaCopyBufferProgram::GenerateShaderCode(ShaderHelper& shader) const { + const ShaderVariableHelper* key = nullptr; + const ShaderVariableHelper* gate = nullptr; + if (has_current_) { + key = &shader.AddInput("key", ShaderUsage::UseUniform); + gate = &shader.AddInput("gate", ShaderUsage::UseUniform); + } + const ShaderVariableHelper* past_kv = nullptr; + const ShaderVariableHelper* past_gate = nullptr; + if (has_past_buffer_) { + past_kv = &shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); + past_gate = &shader.AddInput("past_gate_buffer", ShaderUsage::UseUniform); + } + const auto& present_kv = shader.AddOutput("present_kv_buffer", + ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& present_gate = shader.AddOutput("present_gate_buffer", + ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + + shader.MainFunctionBody() + << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.total") + << " let width = 2u * uniforms.head_size;\n" + << " let channel = global_idx % width;\n" + << " let token_row = global_idx / width;\n" + << " let token = token_row % uniforms.present_buffer_length;\n" + << " let batch = token_row / uniforms.present_buffer_length;\n" + << " let source = uniforms.present_buffer_start + token;\n"; + if (has_past_buffer_) { + shader.MainFunctionBody() + << " if (source < uniforms.past_buffer_length) {\n" + << " let input = (batch * uniforms.past_buffer_length + source) * width + channel;\n" + << " " << present_kv.SetByOffset("global_idx", "present_kv_buffer_element_t(" + past_kv->GetByOffset("input") + ")") + << "\n" + << " " << present_gate.SetByOffset("global_idx", "present_gate_buffer_element_t(" + past_gate->GetByOffset("input") + ")") + << "\n" + << " return;\n" + << " }\n"; + } + if (has_current_) { + shader.MainFunctionBody() + << " let current_token = source - uniforms.past_buffer_length;\n" + << " let input = (batch * uniforms.sequence_length + current_token) * width + channel;\n" + << " " + << present_kv.SetByOffset("global_idx", + "present_kv_buffer_element_t(" + key->GetByOffset("input") + ")") + << "\n" + << " " + << present_gate.SetByOffset("global_idx", + "present_gate_buffer_element_t(" + gate->GetByOffset("input") + ")") + << "\n"; + } + return Status::OK(); +} + +Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); + const auto& compressed_key = shader.AddInput("present_compressed_key", ShaderUsage::UseUniform); + const auto& head_weights = shader.AddInput("head_weights", ShaderUsage::UseUniform); + const auto& position_ids = shader.AddInput("position_ids", ShaderUsage::UseUniform); + const auto& cos_cache = shader.AddInput("cos_cache", ShaderUsage::UseUniform); + const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); + const auto& selected = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); + + shader.AdditionalImplementation() + << "fn query_value(row: u32, head: u32, d: u32) -> f32 {\n" + << " let base = (row * uniforms.num_heads + head) * uniforms.head_size;\n" + << " var value = f32(" << query.GetByOffset("base + d") << ");\n" + << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" + << " if (d >= rotary_base) {\n" + << " let offset = d - rotary_base;\n" + << " let pair_d = select(d - 1u, d + 1u, (offset & 1u) == 0u);\n" + << " let sign = select(1.0, -1.0, (offset & 1u) == 0u);\n" + << " let paired = sign * f32(" << query.GetByOffset("base + pair_d") << ");\n" + << " let batch = row / uniforms.sequence_length;\n" + << " let raw_position = max(" << position_ids.GetByOffset("row") << ", 0);\n" + << " let position = min(u32(raw_position), uniforms.max_rotary_length - 1u);\n" + << " let cache = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width + offset / 2u;\n" + << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" + << sin_cache.GetByOffset("cache") << ");\n" + << " }\n" + << " return value;\n" + << "}\n" + << "fn entry_score(row: u32, entry: u32) -> f32 {\n" + << " let batch = row / uniforms.sequence_length;\n" + << " let key_base = (batch * uniforms.present_compressed_length + entry) * uniforms.head_size;\n" + << " var score = 0.0;\n" + << " for (var head = 0u; head < uniforms.num_heads; head++) {\n" + << " var dot = 0.0;\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " dot += query_value(row, head, d) * f32(" << compressed_key.GetByOffset("key_base + d") << ");\n" + << " }\n" + << " score += max(dot, 0.0) * f32(" + << head_weights.GetByOffset("row * uniforms.num_heads + head") << ");\n" + << " }\n" + << " return score * uniforms.scale * uniforms.head_weight_scale;\n" + << "}\n"; + + shader.MainFunctionBody() + << " let row = workgroup_idx;\n" + << " if (row >= uniforms.rows || local_idx != 0u) { return; }\n" + << " let output_base = row * uniforms.capacity;\n" + << " for (var i = 0u; i < uniforms.capacity; i++) {\n" + << " " << selected.SetByOffset("output_base + i", "-1") << "\n" + << " }\n" + << " let position = " << position_ids.GetByOffset("row") << ";\n" + << " let threshold = select(0u, u32(position + 1) / uniforms.compress_ratio, position >= 0);\n" + << " let count = uniforms.present_compressed_length;\n" + << " let ranks = min(uniforms.capacity, count);\n" + << " var previous_score = 0.0;\n" + << " var previous_index = -1i;\n" + << " for (var rank = 0u; rank < ranks; rank++) {\n" + << " var best_score = 0.0;\n" + << " var best_index = -1i;\n" + << " for (var candidate = 0u; candidate < count; candidate++) {\n" + << " let score = select(-3.4028234663852886e+38, entry_score(row, candidate), candidate < threshold);\n" + << " if (previous_index >= 0 && !(score < previous_score || " + "(score == previous_score && i32(candidate) > previous_index))) { continue; }\n" + << " if (best_index < 0 || score > best_score || (score == best_score && i32(candidate) < best_index)) {\n" + << " best_score = score;\n" + << " best_index = i32(candidate);\n" + << " }\n" + << " }\n" + << " if (best_index < 0) { break; }\n" + << " if (u32(best_index) < threshold) {\n" + << " " << selected.SetByOffset("output_base + rank", "best_index") << "\n" + << " }\n" + << " previous_score = best_score;\n" + << " previous_index = best_index;\n" + << " }\n"; + return Status::OK(); +} + +SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : WebGpuKernel(info) { + std::string policy_mode; + ORT_ENFORCE(info.GetAttr("policy_mode", &policy_mode).IsOK(), + "SparseAttentionIndexer: policy_mode is required"); + ORT_ENFORCE(sai::TryParsePolicy(policy_mode, policy_), "SparseAttentionIndexer: policy_mode must be '", + sai::kPolicyModeQsa, "' or '", sai::kPolicyModeCsa, "', got '", policy_mode, "'"); + ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), + "SparseAttentionIndexer: compress_ratio is required"); + ORT_ENFORCE(compress_ratio_ > 0, "SparseAttentionIndexer: compress_ratio must be > 0"); + + const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); + const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); + float head_weight_scale = 0.0f; + const bool has_head_weight_scale = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); + if (policy_ == sai::Policy::kQsa) { + ORT_ENFORCE(has_token_budget && token_budget_ > 0 && token_budget_ % compress_ratio_ == 0, + "SparseAttentionIndexer: token_budget must be > 0 and divisible by compress_ratio for qsa"); + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale, + "SparseAttentionIndexer: csa attributes must be omitted for qsa"); + index_topk_ = 0; + } else { + ORT_ENFORCE(has_index_topk && index_topk_ > 0, + "SparseAttentionIndexer: index_topk must be > 0 for csa"); + ORT_ENFORCE(!has_token_budget, "SparseAttentionIndexer: token_budget must be omitted for csa"); + token_budget_ = 0; + } + epsilon_ = info.GetAttrOrDefault("epsilon", 1.0e-6f); + ORT_ENFORCE(epsilon_ >= 0.0f, "SparseAttentionIndexer: epsilon must be >= 0"); + scale_ = info.GetAttrOrDefault("scale", 0.0f); + head_weight_scale_ = has_head_weight_scale ? head_weight_scale : 0.0f; +} + +Status SparseAttentionIndexer::ComputeInternal(ComputeContext& context) const { + const bool is_qsa = policy_ == sai::Policy::kQsa; + for (int index = sai::kMask; index < sai::kInputCount; ++index) { + const bool policy_owns_slot = is_qsa ? (index <= sai::kPastKey) : (index >= sai::kGate); + 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, "'"); + } + return is_qsa ? ComputeQsa(context) : ComputeCsa(context); +} + +Status SparseAttentionIndexer::ComputeQsa(ComputeContext& context) const { + const Tensor* query = context.Input(sai::kQuery); + const Tensor* key = context.Input(sai::kKey); + const Tensor* norm = context.Input(sai::kKeyNormWeight); + const Tensor* cos_cache = context.Input(sai::kCosCache); + const Tensor* sin_cache = context.Input(sai::kSinCache); + const Tensor* mask = context.Input(sai::kMask); + const Tensor* past_key = context.Input(sai::kPastKey); + + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, "SparseAttentionIndexer: query must have rank 4"); + const int64_t batch_size = query_shape[0]; + const int64_t sequence_length = query_shape[1]; + const int64_t num_heads = query_shape[2]; + const int64_t head_size = query_shape[3]; + ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "SparseAttentionIndexer: invalid query dimensions"); + const auto& past_shape = past_key->Shape(); + ORT_RETURN_IF_NOT(past_shape.NumDimensions() == 3 && past_shape[0] == batch_size && past_shape[2] == head_size, + "SparseAttentionIndexer: invalid past_key shape"); + const int64_t past_length = past_shape[1]; + const int64_t total_length = past_length + sequence_length; + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(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, + "SparseAttentionIndexer: invalid qsa rotary cache shape"); + const auto& mask_shape = mask->Shape(); + ORT_RETURN_IF_NOT( + (mask_shape.NumDimensions() == 4 && mask_shape[0] == batch_size && mask_shape[1] == 1 && + mask_shape[2] == sequence_length && mask_shape[3] == total_length) || + (mask_shape.NumDimensions() == 3 && mask_shape[0] == batch_size && + mask_shape[1] == sequence_length && mask_shape[2] == total_length), + "SparseAttentionIndexer: invalid qsa mask shape"); + + const int64_t capacity = sai::SelectedCapacity(policy_, token_budget_, index_topk_, compress_ratio_); + 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})); + const int64_t present_elements = present->Shape().Size(); + if (present_elements > 0) { + const bool has_past = past_length > 0; + const bool has_current = sequence_length > 0; + SparseAttentionIndexerQsaConcatProgram concat{has_past, has_current}; + concat.CacheHint(has_past, has_current) + .SetWorkgroupSize(kWorkgroupSize); + if (has_past) { + concat.AddInput({past_key, ProgramTensorMetadataDependency::Type}); + } + if (has_current) { + concat.AddInput({key, ProgramTensorMetadataDependency::Type}); + } + concat.AddOutput({present, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize((ToUint32(present_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .AddUniformVariables({{ToUint32(present_elements)}, + {ToUint32(sequence_length)}, + {ToUint32(past_length)}, + {ToUint32(total_length)}, + {ToUint32(head_size)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(concat)); + } + const int64_t rows = batch_size * sequence_length; + if (rows == 0) { + return Status::OK(); + } + SparseAttentionIndexerQsaSelectProgram select; + select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity) + .AddInputs({{query, ProgramTensorMetadataDependency::Type}, + {present, ProgramTensorMetadataDependency::Type}, + {norm, ProgramTensorMetadataDependency::Type}, + {cos_cache, ProgramTensorMetadataDependency::Type}, + {sin_cache, ProgramTensorMetadataDependency::Type}}) + .AddInput({mask, ProgramTensorMetadataDependency::Type, {(mask->Shape().Size() + 3) / 4}, 4}) + .AddOutput({selected, ProgramTensorMetadataDependency::Type}) + .SetWorkgroupSize(kWorkgroupSize) + .SetDispatchGroupSize(ToUint32(rows)) + .AddUniformVariables({{ToUint32(rows)}, + {ToUint32(sequence_length)}, + {ToUint32(num_heads)}, + {ToUint32(head_size)}, + {ToUint32(rotary_width)}, + {ToUint32(max_rotary_length)}, + {ToUint32(compress_ratio_)}, + {ToUint32(capacity)}, + {ToUint32(past_length)}, + {ToUint32(total_length)}, + {ToUint32(token_budget_ / compress_ratio_)}, + {epsilon_}, + {scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}}); + return context.RunProgram(select); +} + +Status SparseAttentionIndexer::ComputeCsa(ComputeContext& context) const { + const Tensor* query = context.Input(sai::kQuery); + const Tensor* key = context.Input(sai::kKey); + const Tensor* norm = context.Input(sai::kKeyNormWeight); + const Tensor* cos_cache = context.Input(sai::kCosCache); + const Tensor* sin_cache = context.Input(sai::kSinCache); + const Tensor* gate = context.Input(sai::kGate); + const Tensor* bias = context.Input(sai::kPositionBias); + const Tensor* head_weights = context.Input(sai::kHeadWeights); + const Tensor* position_ids = context.Input(sai::kPositionIds); + const Tensor* past_compressed = context.Input(sai::kPastCompressedKey); + const Tensor* past_kv = context.Input(sai::kPastKvBuffer); + const Tensor* past_gate = context.Input(sai::kPastGateBuffer); + + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, "SparseAttentionIndexer: query must have rank 4"); + const int64_t batch_size = query_shape[0]; + const int64_t sequence_length = query_shape[1]; + const int64_t num_heads = query_shape[2]; + const int64_t head_size = query_shape[3]; + ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "SparseAttentionIndexer: invalid query dimensions"); + const int64_t width = 2 * head_size; + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); + ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckShape(bias, "position_bias", {compress_ratio_, width})); + 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, + "SparseAttentionIndexer: invalid csa rotary cache shape"); + const auto& past_compressed_shape = past_compressed->Shape(); + ORT_RETURN_IF_NOT(past_compressed_shape.NumDimensions() == 3 && + past_compressed_shape[0] == batch_size && past_compressed_shape[2] == head_size, + "SparseAttentionIndexer: invalid past_compressed_key shape"); + const int64_t past_compressed_length = past_compressed_shape[1]; + const auto& past_buffer_shape = past_kv->Shape(); + ORT_RETURN_IF_NOT(past_buffer_shape.NumDimensions() == 3 && past_buffer_shape[0] == batch_size && + past_buffer_shape[2] == width, + "SparseAttentionIndexer: invalid past_kv_buffer shape"); + const int64_t past_buffer_length = past_buffer_shape[1]; + ORT_RETURN_IF_ERROR(CheckShape(past_gate, "past_gate_buffer", {batch_size, past_buffer_length, width})); + + sai::CsaWindowPlan plan; + ORT_RETURN_IF_NOT(sai::TryComputeCsaWindowPlan(past_buffer_length, sequence_length, compress_ratio_, plan), + "SparseAttentionIndexer: invalid csa buffer length"); + const int64_t present_compressed_length = past_compressed_length + plan.new_window_count; + const int64_t capacity = sai::SelectedCapacity(policy_, token_budget_, index_topk_, compress_ratio_); + Tensor* selected = + context.Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, capacity})); + Tensor* present_compressed = context.Output( + sai::kPresentCompressedKey, TensorShape({batch_size, present_compressed_length, head_size})); + Tensor* present_kv = + context.Output(sai::kPresentKvBuffer, TensorShape({batch_size, plan.present_buffer_length, width})); + Tensor* present_gate = + context.Output(sai::kPresentGateBuffer, TensorShape({batch_size, plan.present_buffer_length, width})); + + const int64_t past_compressed_elements = batch_size * past_compressed_length * head_size; + if (past_compressed_elements > 0) { + SparseAttentionIndexerCsaCopyCompressedProgram copy; + copy.CacheHint(query->GetElementType()) + .AddInput({past_compressed, ProgramTensorMetadataDependency::Type}) + .AddOutput({present_compressed, ProgramTensorMetadataDependency::Type}) + .SetWorkgroupSize(kWorkgroupSize) + .SetDispatchGroupSize((ToUint32(past_compressed_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .AddUniformVariables({{ToUint32(past_compressed_elements)}, + {ToUint32(head_size)}, + {ToUint32(past_compressed_length)}, + {ToUint32(present_compressed_length)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(copy)); + } + + if (plan.new_window_count > 0) { + const bool has_past_buffer = past_buffer_length > 0; + SparseAttentionIndexerCsaCompressProgram compress{has_past_buffer}; + compress.CacheHint(query->GetElementType(), has_past_buffer, head_size, rotary_width, compress_ratio_) + .AddInputs({{key, ProgramTensorMetadataDependency::Type}, + {gate, ProgramTensorMetadataDependency::Type}}); + if (has_past_buffer) { + compress.AddInputs({{past_kv, ProgramTensorMetadataDependency::Type}, + {past_gate, ProgramTensorMetadataDependency::Type}}); + } + compress.AddInputs({{bias, ProgramTensorMetadataDependency::Type}, + {norm, ProgramTensorMetadataDependency::Type}, + {cos_cache, ProgramTensorMetadataDependency::Type}, + {sin_cache, ProgramTensorMetadataDependency::Type}}) + .AddOutput({present_compressed, ProgramTensorMetadataDependency::Type}) + .SetWorkgroupSize(kWorkgroupSize) + .SetDispatchGroupSize(ToUint32(batch_size * plan.new_window_count)) + .AddUniformVariables({{ToUint32(batch_size * plan.new_window_count)}, + {ToUint32(sequence_length)}, + {ToUint32(head_size)}, + {ToUint32(rotary_width)}, + {ToUint32(max_rotary_length)}, + {ToUint32(compress_ratio_)}, + {ToUint32(past_compressed_length)}, + {ToUint32(present_compressed_length)}, + {ToUint32(past_buffer_length)}, + {ToUint32(plan.overlap_length)}, + {ToUint32(plan.new_window_count)}, + {epsilon_}}); + ORT_RETURN_IF_ERROR(context.RunProgram(compress)); + } + + const int64_t present_buffer_elements = batch_size * plan.present_buffer_length * width; + if (present_buffer_elements > 0) { + const bool has_past_buffer = past_buffer_length > 0; + const bool has_current = sequence_length > 0; + SparseAttentionIndexerCsaCopyBufferProgram copy_buffer{has_past_buffer, has_current}; + copy_buffer.CacheHint(query->GetElementType(), has_past_buffer, has_current); + if (has_current) { + copy_buffer.AddInputs({{key, ProgramTensorMetadataDependency::Type}, + {gate, ProgramTensorMetadataDependency::Type}}); + } + if (has_past_buffer) { + copy_buffer.AddInputs({{past_kv, ProgramTensorMetadataDependency::Type}, + {past_gate, ProgramTensorMetadataDependency::Type}}); + } + copy_buffer.AddOutputs({{present_kv, ProgramTensorMetadataDependency::Type}, + {present_gate, ProgramTensorMetadataDependency::Type}}) + .SetWorkgroupSize(kWorkgroupSize) + .SetDispatchGroupSize((ToUint32(present_buffer_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .AddUniformVariables({{ToUint32(present_buffer_elements)}, + {ToUint32(sequence_length)}, + {ToUint32(head_size)}, + {ToUint32(past_buffer_length)}, + {ToUint32(plan.present_buffer_length)}, + {ToUint32(plan.present_buffer_start)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(copy_buffer)); + } + + const int64_t rows = batch_size * sequence_length; + if (rows == 0) { + return Status::OK(); + } + if (present_compressed_length == 0) { + const int64_t output_elements = selected->Shape().Size(); + SparseAttentionIndexerFillProgram fill; + fill.AddOutput({selected, ProgramTensorMetadataDependency::None}) + .SetWorkgroupSize(kWorkgroupSize) + .SetDispatchGroupSize((ToUint32(output_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .AddUniformVariable({ToUint32(output_elements)}); + return context.RunProgram(fill); + } + SparseAttentionIndexerCsaSelectProgram select; + select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity) + .AddInputs({{query, ProgramTensorMetadataDependency::Type}, + {present_compressed, ProgramTensorMetadataDependency::Type}, + {head_weights, ProgramTensorMetadataDependency::Type}, + {position_ids, ProgramTensorMetadataDependency::Type}, + {cos_cache, ProgramTensorMetadataDependency::Type}, + {sin_cache, ProgramTensorMetadataDependency::Type}}) + .AddOutput({selected, ProgramTensorMetadataDependency::Type}) + .SetWorkgroupSize(kWorkgroupSize) + .SetDispatchGroupSize(ToUint32(rows)) + .AddUniformVariables({{ToUint32(rows)}, + {ToUint32(sequence_length)}, + {ToUint32(num_heads)}, + {ToUint32(head_size)}, + {ToUint32(rotary_width)}, + {ToUint32(max_rotary_length)}, + {ToUint32(compress_ratio_)}, + {ToUint32(capacity)}, + {ToUint32(present_compressed_length)}, + {scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}, + {head_weight_scale_ != 0.0f + ? head_weight_scale_ + : 1.0f / std::sqrt(static_cast(num_heads))}}); + return context.RunProgram(select); +} + +} // namespace webgpu +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h new file mode 100644 index 0000000000000..b39a903f67809 --- /dev/null +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" +#include "core/providers/webgpu/program.h" +#include "core/providers/webgpu/webgpu_kernel.h" + +namespace onnxruntime { +namespace contrib { +namespace webgpu { + +using namespace onnxruntime::webgpu; + +class SparseAttentionIndexerFillProgram final : public Program { + public: + SparseAttentionIndexerFillProgram() : Program{"SparseAttentionIndexerFill"} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"total", ProgramUniformVariableDataType::Uint32}); +}; + +class SparseAttentionIndexerQsaConcatProgram final + : public Program { + public: + SparseAttentionIndexerQsaConcatProgram(bool has_past, bool has_current) + : Program{"SparseAttentionIndexerQsaConcat"}, has_past_{has_past}, has_current_{has_current} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"total", ProgramUniformVariableDataType::Uint32}, + {"sequence_length", ProgramUniformVariableDataType::Uint32}, + {"past_sequence_length", ProgramUniformVariableDataType::Uint32}, + {"total_sequence_length", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}); + + private: + bool has_past_; + bool has_current_; +}; + +class SparseAttentionIndexerQsaSelectProgram final + : public Program { + public: + SparseAttentionIndexerQsaSelectProgram() : Program{"SparseAttentionIndexerQsaSelect"} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"rows", ProgramUniformVariableDataType::Uint32}, + {"sequence_length", ProgramUniformVariableDataType::Uint32}, + {"num_heads", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"rotary_width", ProgramUniformVariableDataType::Uint32}, + {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"capacity", ProgramUniformVariableDataType::Uint32}, + {"past_sequence_length", ProgramUniformVariableDataType::Uint32}, + {"total_sequence_length", ProgramUniformVariableDataType::Uint32}, + {"block_topk", ProgramUniformVariableDataType::Uint32}, + {"epsilon", ProgramUniformVariableDataType::Float32}, + {"scale", ProgramUniformVariableDataType::Float32}); +}; + +class SparseAttentionIndexerCsaCopyCompressedProgram final + : public Program { + public: + SparseAttentionIndexerCsaCopyCompressedProgram() : Program{"SparseAttentionIndexerCsaCopyCompressed"} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"total", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"past_length", ProgramUniformVariableDataType::Uint32}, + {"present_length", ProgramUniformVariableDataType::Uint32}); +}; + +class SparseAttentionIndexerCsaCompressProgram final + : public Program { + public: + SparseAttentionIndexerCsaCompressProgram(bool has_past_buffer) + : Program{"SparseAttentionIndexerCsaCompress"}, has_past_buffer_{has_past_buffer} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"work_items", ProgramUniformVariableDataType::Uint32}, + {"sequence_length", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"rotary_width", ProgramUniformVariableDataType::Uint32}, + {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"past_compressed_length", ProgramUniformVariableDataType::Uint32}, + {"present_compressed_length", ProgramUniformVariableDataType::Uint32}, + {"past_buffer_length", ProgramUniformVariableDataType::Uint32}, + {"overlap_length", ProgramUniformVariableDataType::Uint32}, + {"new_window_count", ProgramUniformVariableDataType::Uint32}, + {"epsilon", ProgramUniformVariableDataType::Float32}); + + private: + bool has_past_buffer_; +}; + +class SparseAttentionIndexerCsaCopyBufferProgram final + : public Program { + public: + SparseAttentionIndexerCsaCopyBufferProgram(bool has_past_buffer, bool has_current) + : Program{"SparseAttentionIndexerCsaCopyBuffer"}, + has_past_buffer_{has_past_buffer}, + has_current_{has_current} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"total", ProgramUniformVariableDataType::Uint32}, + {"sequence_length", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"past_buffer_length", ProgramUniformVariableDataType::Uint32}, + {"present_buffer_length", ProgramUniformVariableDataType::Uint32}, + {"present_buffer_start", ProgramUniformVariableDataType::Uint32}); + + private: + bool has_past_buffer_; + bool has_current_; +}; + +class SparseAttentionIndexerCsaSelectProgram final + : public Program { + public: + SparseAttentionIndexerCsaSelectProgram() : Program{"SparseAttentionIndexerCsaSelect"} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"rows", ProgramUniformVariableDataType::Uint32}, + {"sequence_length", ProgramUniformVariableDataType::Uint32}, + {"num_heads", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"rotary_width", ProgramUniformVariableDataType::Uint32}, + {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"capacity", ProgramUniformVariableDataType::Uint32}, + {"present_compressed_length", ProgramUniformVariableDataType::Uint32}, + {"scale", ProgramUniformVariableDataType::Float32}, + {"head_weight_scale", ProgramUniformVariableDataType::Float32}); +}; + +class SparseAttentionIndexer final : public WebGpuKernel { + public: + explicit SparseAttentionIndexer(const OpKernelInfo& info); + Status ComputeInternal(ComputeContext& context) const override; + + private: + Status ComputeQsa(ComputeContext& context) const; + Status ComputeCsa(ComputeContext& context) const; + + sparse_attention_indexer::Policy policy_; + int64_t compress_ratio_; + int64_t token_budget_; + int64_t index_topk_; + float epsilon_; + float scale_; + float head_weight_scale_; +}; + +} // namespace webgpu +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc b/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc index d1d9c589727ec..50be1fd0eef31 100644 --- a/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc +++ b/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc @@ -10,6 +10,7 @@ #include "contrib_ops/webgpu/bert/linear_attention.h" #include "contrib_ops/webgpu/bert/linear_attention_gates.h" #include "contrib_ops/webgpu/bert/paged_attention.h" +#include "contrib_ops/webgpu/bert/sparse_attention_indexer.h" #include "core/framework/op_kernel.h" @@ -49,6 +50,7 @@ static const BuildKernelCreateInfoFn build_kernel_create_info_function_table[] = BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, + BuildKernelCreateInfo, BuildKernelCreateInfo, // LayerNormalization used to be a contrib op that (incorrectly) used kOnnxDomain so we need to version it BuildKernelCreateInfo, 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 e18f7594cb392..fa0e61225ca95 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -574,11 +574,25 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, // Numeric runners // --------------------------------------------------------------------------------------------- -bool HasCudaProvider() { return DefaultCudaExecutionProvider() != nullptr; } +enum class ProviderKind { + Cuda, + WebGpu, +}; + +std::unique_ptr CreateProvider(ProviderKind provider_kind) { + if (provider_kind == ProviderKind::Cuda) { + return DefaultCudaExecutionProvider(); + } +#ifdef USE_WEBGPU + return DefaultWebGpuExecutionProvider(); +#else + return nullptr; +#endif +} -void RunOnCuda(OpTester& test) { +void RunOnProvider(OpTester& test, std::unique_ptr provider) { std::vector> providers; - providers.push_back(DefaultCudaExecutionProvider()); + providers.push_back(std::move(provider)); test.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); } @@ -610,9 +624,12 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { } template -void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { - if (!HasCudaProvider()) { - GTEST_SKIP() << "CUDA execution provider is not available"; +void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), + ProviderKind provider_kind = ProviderKind::Cuda) { + auto provider = CreateProvider(provider_kind); + if (provider == nullptr) { + GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") + << " execution provider is not available"; } problem.query = RoundTrip(problem.query); @@ -652,7 +669,7 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { test.AddOutput("selected_indices", {batch_size, sequence_length, problem.Capacity()}, selected); test.AddOutput("present_key", {batch_size, total, head_size}, ToElementType(present_key), false, 0.0f, tolerance); - RunOnCuda(test); + RunOnProvider(test, std::move(provider)); } CsaProblem MakeCsaProblem() { @@ -690,9 +707,12 @@ CsaProblem MakeCsaProblem() { } template -void RunCsaTest(const CsaProblem& base, float tolerance) { - if (!HasCudaProvider()) { - GTEST_SKIP() << "CUDA execution provider is not available"; +void RunCsaTest(const CsaProblem& base, float tolerance, + ProviderKind provider_kind = ProviderKind::Cuda) { + auto provider = CreateProvider(provider_kind); + if (provider == nullptr) { + GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") + << " execution provider is not available"; } CsaProblem problem = base; @@ -758,7 +778,7 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { ToElementType(present_kv_buffer), false, 0.0f, tolerance); test.AddOutput("present_gate_buffer", {batch_size, plan.present_buffer_length, width}, ToElementType(present_gate_buffer), false, 0.0f, tolerance); - RunOnCuda(test); + RunOnProvider(test, std::move(provider)); } // A call whose tokens do not close a window: the buffer only grows and the compressed state is @@ -790,6 +810,14 @@ CsaProblem MakeCsaBufferOnlyProblem() { return problem; } +CsaProblem MakeCsaNoCompressedEntryProblem() { + CsaProblem problem = MakeCsaBufferOnlyProblem(); + problem.past_compressed_length = 0; + problem.past_compressed_key.clear(); + problem.position_ids = {0}; + return problem; +} + } // namespace // --------------------------------------------------------------------------------------------- @@ -938,5 +966,35 @@ TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProb TEST(SparseAttentionIndexerTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f); } +TEST(SparseAttentionIndexerTest, CsaNoCompressedEntry) { + RunCsaTest(MakeCsaNoCompressedEntryProblem(), 1.0e-5f); +} + +#ifdef USE_WEBGPU +TEST(SparseAttentionIndexerWebGpuTest, QsaFloat) { + RunQsaTest(1.0e-5f, MakeQsaProblem(), ProviderKind::WebGpu); +} + +TEST(SparseAttentionIndexerWebGpuTest, QsaFloat16) { + RunQsaTest(4.0e-3f, MakeQsaProblem(), ProviderKind::WebGpu); +} + +TEST(SparseAttentionIndexerWebGpuTest, CsaFloat) { + RunCsaTest(MakeCsaProblem(), 1.0e-5f, ProviderKind::WebGpu); +} + +TEST(SparseAttentionIndexerWebGpuTest, CsaFloat16) { + RunCsaTest(MakeCsaProblem(), 6.0e-3f, ProviderKind::WebGpu); +} + +TEST(SparseAttentionIndexerWebGpuTest, CsaBufferOnlyStep) { + RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f, ProviderKind::WebGpu); +} + +TEST(SparseAttentionIndexerWebGpuTest, CsaNoCompressedEntry) { + RunCsaTest(MakeCsaNoCompressedEntryProblem(), 1.0e-5f, ProviderKind::WebGpu); +} +#endif + } // namespace test } // namespace onnxruntime From 7adf13988c4a5b2c90fc94d9adbc591e1281e997 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:16:08 +0000 Subject: [PATCH 04/16] Address SparseAttentionIndexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse/sparse_attention_indexer.cc | 27 ++-- .../cuda/sparse/sparse_attention_indexer.h | 6 +- .../sparse/sparse_attention_indexer_impl.cu | 4 +- .../core/graph/contrib_ops/bert_defs.cc | 17 +- .../sparse_attention_indexer_op_test.cc | 61 +++++++- ...untime_test_python_symbolic_shape_infer.py | 145 ++++++++++++++++++ 6 files changed, 232 insertions(+), 28 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index c7955310afd57..8ac96c38d69b1 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -4,6 +4,7 @@ #include "contrib_ops/cuda/sparse/sparse_attention_indexer.h" #include +#include #include #include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" @@ -59,32 +60,36 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : Cu ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), "SparseAttentionIndexer: compress_ratio is required"); - ORT_ENFORCE(compress_ratio_ > 0, "SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio_); + ORT_ENFORCE(compress_ratio_ > 0 && compress_ratio_ <= std::numeric_limits::max(), + "SparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", compress_ratio_); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); float head_weight_scale = 0.0f; - const bool has_head_weight_scale = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); + has_head_weight_scale_ = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); if (policy_ == sai::Policy::kQsa) { ORT_ENFORCE(has_token_budget, "SparseAttentionIndexer: token_budget is required when policy_mode is 'qsa'"); - ORT_ENFORCE(!has_index_topk && !has_head_weight_scale, + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale_, "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); - ORT_ENFORCE(token_budget_ > 0 && token_budget_ % compress_ratio_ == 0, - "SparseAttentionIndexer: token_budget must be > 0 and divisible by compress_ratio, got token_budget=", + ORT_ENFORCE(token_budget_ > 0 && token_budget_ % compress_ratio_ == 0 && + token_budget_ <= std::numeric_limits::max() - compress_ratio_ + 1, + "SparseAttentionIndexer: token_budget must be > 0, divisible by compress_ratio, and produce a " + "selected capacity no greater than INT_MAX, got token_budget=", token_budget_, " compress_ratio=", compress_ratio_); index_topk_ = 0; } else { ORT_ENFORCE(has_index_topk, "SparseAttentionIndexer: index_topk is required when policy_mode is 'csa'"); ORT_ENFORCE(!has_token_budget, "SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); - ORT_ENFORCE(index_topk_ > 0, "SparseAttentionIndexer: index_topk must be > 0, got ", index_topk_); + ORT_ENFORCE(index_topk_ > 0 && index_topk_ <= std::numeric_limits::max(), + "SparseAttentionIndexer: index_topk must be in (0, INT_MAX], got ", index_topk_); token_budget_ = 0; } epsilon_ = info.GetAttrOrDefault("epsilon", 1.0e-6f); ORT_ENFORCE(epsilon_ >= 0.0f, "SparseAttentionIndexer: epsilon must be >= 0, got ", epsilon_); - scale_ = info.GetAttrOrDefault("scale", 0.0f); - head_weight_scale_ = has_head_weight_scale ? head_weight_scale : 0.0f; + has_scale_ = info.GetAttr("scale", &scale_).IsOK(); + head_weight_scale_ = head_weight_scale; } template @@ -171,7 +176,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_)); params.epsilon = epsilon_; - params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.past_sequence_length = static_cast(past_sequence_length); params.total_sequence_length = static_cast(total_sequence_length); params.max_block_count = static_cast(total_sequence_length / compress_ratio_); @@ -287,7 +292,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_)); params.epsilon = epsilon_; - params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.past_compressed_length = static_cast(past_compressed_length); params.present_compressed_length = static_cast(present_compressed_length); params.past_buffer_length = static_cast(past_buffer_length); @@ -297,7 +302,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.present_buffer_start = static_cast(plan.present_buffer_start); params.index_topk = static_cast(index_topk_); params.head_weight_scale = - head_weight_scale_ != 0.0f ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads)); + has_head_weight_scale_ ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads)); Tensor* selected_indices = context->Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, params.capacity})); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h index 0abbb6064a3a4..2976340232762 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h @@ -26,8 +26,10 @@ class SparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel { int64_t token_budget_; int64_t index_topk_; float epsilon_; - float scale_; // 0 means "derive from head_size" - float head_weight_scale_; // 0 means "derive from num_heads" + float scale_; + float head_weight_scale_; + bool has_scale_; + bool has_head_weight_scale_; }; } // namespace cuda 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 ea05afcd7886e..ffc453754231b 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -123,7 +123,7 @@ __device__ __forceinline__ float TrailingRope(const float* value, int head_size, // Highest compressed entry a query at `position` may attend to, matching (position + 1) // ratio. __device__ __forceinline__ int64_t CausalThreshold(int64_t position, int compress_ratio) { - return position < 0 ? 0 : (position + 1) / compress_ratio; + return position < 0 ? 0 : position / compress_ratio + (position % compress_ratio == compress_ratio - 1); } __device__ __forceinline__ int ClampPosition(int64_t position, int max_rotary_length) { @@ -668,7 +668,7 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio past_compressed_key, present_compressed_key, params); } - if (params.new_window_count > 0) { + if (params.batch_size > 0 && params.new_window_count > 0) { const int compress_blocks = static_cast( std::min(static_cast(params.batch_size) * params.new_window_count, kMaxGridDimX)); CsaCompressKernel<<>>( diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 07dbaf29bfdac..d3abc4e955610 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include +#include #include #include @@ -1933,8 +1934,8 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex const bool is_qsa = policy == sai::Policy::kQsa; const int64_t compress_ratio = getAttribute(ctx, "compress_ratio", static_cast(0)); - if (compress_ratio <= 0) { - fail_shape_inference("SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio); + if (compress_ratio <= 0 || compress_ratio > std::numeric_limits::max()) { + fail_shape_inference("SparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", compress_ratio); } const int64_t token_budget = getAttribute(ctx, "token_budget", static_cast(0)); @@ -1944,18 +1945,20 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex fail_shape_inference( "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); } - if (token_budget <= 0 || token_budget % compress_ratio != 0) { + if (token_budget <= 0 || token_budget % compress_ratio != 0 || + token_budget > std::numeric_limits::max() - compress_ratio + 1) { fail_shape_inference( - "SparseAttentionIndexer: policy_mode 'qsa' requires token_budget > 0 and divisible by " - "compress_ratio, got token_budget=", + "SparseAttentionIndexer: policy_mode 'qsa' requires token_budget > 0, divisible by " + "compress_ratio, and a selected capacity no greater than INT_MAX, got token_budget=", token_budget, " compress_ratio=", compress_ratio); } } else { if (ctx.getAttribute("token_budget") != nullptr) { fail_shape_inference("SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); } - if (index_topk <= 0) { - fail_shape_inference("SparseAttentionIndexer: policy_mode 'csa' requires index_topk > 0, got ", index_topk); + if (index_topk <= 0 || index_topk > std::numeric_limits::max()) { + fail_shape_inference("SparseAttentionIndexer: policy_mode 'csa' requires index_topk in (0, INT_MAX], got ", + index_topk); } } 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 e18f7594cb392..6c1668be5e72b 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -284,6 +285,7 @@ struct QsaProblem { int compress_ratio = 2; int token_budget = 4; float epsilon = 1.0e-6f; + std::optional scale; std::vector query; std::vector key; @@ -303,7 +305,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std const int head_size = problem.head_size; const int capacity = problem.Capacity(); const int block_topk = problem.token_budget / problem.compress_ratio; - const float scale = 1.0f / std::sqrt(static_cast(head_size)); + const float scale = problem.scale.value_or(1.0f / std::sqrt(static_cast(head_size))); present_key.assign(static_cast(problem.batch_size) * total * head_size, 0.0f); for (int b = 0; b < problem.batch_size; ++b) { @@ -404,6 +406,8 @@ struct CsaProblem { int past_buffer_length = 3; int max_rotary_length = 5; float epsilon = 1.0e-6f; + std::optional scale; + std::optional head_weight_scale; std::vector query; std::vector key; @@ -445,8 +449,9 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, const int present_compressed_length = problem.past_compressed_length + static_cast(plan.new_window_count); const int present_buffer_length = static_cast(plan.present_buffer_length); - const float scale = 1.0f / std::sqrt(static_cast(head_size)); - const float head_weight_scale = 1.0f / std::sqrt(static_cast(problem.num_heads)); + const float scale = problem.scale.value_or(1.0f / std::sqrt(static_cast(head_size))); + const float head_weight_scale = + problem.head_weight_scale.value_or(1.0f / std::sqrt(static_cast(problem.num_heads))); present_compressed_key.assign( static_cast(problem.batch_size) * present_compressed_length * head_size, 0.0f); @@ -540,7 +545,10 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, sin_base + query_position * problem.rotary_width); } - const int64_t threshold = position < 0 ? 0 : (position + 1) / problem.compress_ratio; + const int64_t threshold = + position < 0 ? 0 + : position / problem.compress_ratio + + (position % problem.compress_ratio == problem.compress_ratio - 1); std::vector scores(static_cast(present_compressed_length), 0.0f); for (int entry = 0; entry < present_compressed_length; ++entry) { if (static_cast(entry) >= threshold) { @@ -640,6 +648,9 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { test.AddAttribute("policy_mode", std::string(sai::kPolicyModeQsa)); test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); test.AddAttribute("token_budget", static_cast(problem.token_budget)); + if (problem.scale.has_value()) { + test.AddAttribute("scale", *problem.scale); + } test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, ToElementType(problem.query)); test.AddInput("key", {batch_size, sequence_length, head_size}, ToElementType(problem.key)); @@ -655,8 +666,7 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { RunOnCuda(test); } -CsaProblem MakeCsaProblem() { - CsaProblem problem; +CsaProblem MakeCsaProblem(CsaProblem problem = {}) { const int width = problem.Width(); problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * problem.head_size, @@ -728,6 +738,12 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { test.AddAttribute("policy_mode", std::string(sai::kPolicyModeCsa)); test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); test.AddAttribute("index_topk", static_cast(problem.index_topk)); + if (problem.scale.has_value()) { + test.AddAttribute("scale", *problem.scale); + } + if (problem.head_weight_scale.has_value()) { + test.AddAttribute("head_weight_scale", *problem.head_weight_scale); + } test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, ToElementType(problem.query)); test.AddInput("key", {batch_size, sequence_length, width}, ToElementType(problem.key)); @@ -879,6 +895,14 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisible "requires token_budget > 0 and divisible by compress_ratio"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedQsaCapacity) { + QsaGraphOptions options; + options.compress_ratio = 2; + options.token_budget = std::numeric_limits::max() - 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "selected capacity no greater than INT_MAX"); +} + TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { QsaGraphOptions options; options.add_csa_inputs = true; @@ -930,6 +954,12 @@ TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { + QsaProblem problem = MakeQsaProblem(); + problem.scale = 0.0f; + RunQsaTest(1.0e-5f, std::move(problem)); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } @@ -938,5 +968,24 @@ TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProb TEST(SparseAttentionIndexerTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f); } +TEST(SparseAttentionIndexerTest, CsaExplicitZeroScales) { + CsaProblem problem = MakeCsaProblem(); + problem.scale = 0.0f; + problem.head_weight_scale = 0.0f; + RunCsaTest(problem, 1.0e-5f); +} + +TEST(SparseAttentionIndexerTest, CsaInt64MaxPosition) { + CsaProblem problem = MakeCsaProblem(); + problem.position_ids[0] = std::numeric_limits::max(); + RunCsaTest(problem, 1.0e-5f); +} + +TEST(SparseAttentionIndexerTest, CsaEmptyBatch) { + CsaProblem problem; + problem.batch_size = 0; + RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f); +} + } // namespace test } // namespace onnxruntime 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 0fdad07556db9..b3ac87e07284b 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -124,6 +124,151 @@ def _check_shapes(self, graph, inferred_graph, vis): # type: (GraphProto, Graph assert vi == inferred_vi, f"\n{vi}\n{inferred_vi}\n" raise AssertionError() + def _infer_sparse_attention_indexer(self, node, inputs): + outputs = [ + helper.make_tensor_value_info(name, TensorProto.UNDEFINED, None) + for name in node.output + if name + ] + graph = helper.make_graph([node], "SparseAttentionIndexer_Test", inputs, outputs) + model = helper.make_model( + graph, + opset_imports=[helper.make_opsetid("", 17), helper.make_opsetid("com.microsoft", 1)], + ) + return SymbolicShapeInference.infer_shapes(model, auto_merge=True) + + @staticmethod + def _tensor_shape(value_info): + return [ + dimension.dim_param if dimension.dim_param else dimension.dim_value + for dimension in value_info.type.tensor_type.shape.dim + ] + + def test_sparse_attention_indexer_qsa(self): + node = helper.make_node( + "SparseAttentionIndexer", + ["query", "key", "key_norm_weight", "cos_cache", "sin_cache", "mask", "past_key"], + ["selected_indices", "present_key"], + domain="com.microsoft", + policy_mode="qsa", + compress_ratio=4, + token_budget=8, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["batch", "sequence", 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["batch", "sequence", 8]), + helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), + helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, ["batch", "total", 8]), + helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT16, ["batch", "total", 8]), + helper.make_tensor_value_info("mask", TensorProto.BOOL, ["batch", 1, "sequence", "total"]), + helper.make_tensor_value_info("past_key", TensorProto.FLOAT16, ["batch", "past", 8]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(self._tensor_shape(outputs["selected_indices"]), ["batch", "sequence", 11]) + self.assertEqual(outputs["selected_indices"].type.tensor_type.elem_type, TensorProto.INT32) + self.assertEqual(self._tensor_shape(outputs["present_key"]), ["batch", "past + sequence", 8]) + self.assertEqual(outputs["present_key"].type.tensor_type.elem_type, TensorProto.FLOAT16) + + def test_sparse_attention_indexer_csa_static_with_empty_output(self): + node = helper.make_node( + "SparseAttentionIndexer", + [ + "query", + "key", + "key_norm_weight", + "cos_cache", + "sin_cache", + "", + "", + "gate", + "position_bias", + "head_weights", + "position_ids", + "past_compressed_key", + "past_kv_buffer", + "past_gate_buffer", + ], + ["selected_indices", "", "present_compressed_key", "present_kv_buffer", "present_gate_buffer"], + domain="com.microsoft", + policy_mode="csa", + compress_ratio=4, + index_topk=3, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT, [2, 5, 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT, [8]), + helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT, [2, 64, 4]), + helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT, [2, 64, 4]), + helper.make_tensor_value_info("gate", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("position_bias", TensorProto.FLOAT, [4, 16]), + helper.make_tensor_value_info("head_weights", TensorProto.FLOAT, [2, 5, 2]), + helper.make_tensor_value_info("position_ids", TensorProto.INT64, [2, 5]), + helper.make_tensor_value_info("past_compressed_key", TensorProto.FLOAT, [2, 6, 8]), + helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("past_gate_buffer", TensorProto.FLOAT, [2, 5, 16]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(list(inferred.graph.node[0].output), list(node.output)) + self.assertEqual(self._tensor_shape(outputs["selected_indices"]), [2, 5, 3]) + self.assertEqual(self._tensor_shape(outputs["present_compressed_key"]), [2, 7, 8]) + self.assertEqual(self._tensor_shape(outputs["present_kv_buffer"]), [2, 6, 16]) + self.assertEqual(self._tensor_shape(outputs["present_gate_buffer"]), [2, 6, 16]) + + def test_sparse_attention_indexer_csa_symbolic_fallback(self): + node = helper.make_node( + "SparseAttentionIndexer", + [ + "query", + "key", + "key_norm_weight", + "cos_cache", + "sin_cache", + "", + "", + "gate", + "position_bias", + "head_weights", + "position_ids", + "past_compressed_key", + "past_kv_buffer", + "past_gate_buffer", + ], + ["selected_indices", "", "present_compressed_key", "present_kv_buffer", "present_gate_buffer"], + domain="com.microsoft", + policy_mode="csa", + compress_ratio=4, + index_topk=3, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT, ["batch", "sequence", 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT, ["batch", "sequence", 16]), + helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT, [8]), + helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT, ["batch", 64, 4]), + helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT, ["batch", 64, 4]), + helper.make_tensor_value_info("gate", TensorProto.FLOAT, ["batch", "sequence", 16]), + helper.make_tensor_value_info("position_bias", TensorProto.FLOAT, [4, 16]), + helper.make_tensor_value_info("head_weights", TensorProto.FLOAT, ["batch", "sequence", 2]), + helper.make_tensor_value_info("position_ids", TensorProto.INT64, ["batch", "sequence"]), + helper.make_tensor_value_info("past_compressed_key", TensorProto.FLOAT, ["batch", "compressed", 8]), + helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT, ["batch", "buffer", 16]), + helper.make_tensor_value_info("past_gate_buffer", TensorProto.FLOAT, ["batch", "buffer", 16]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + compressed_shape = self._tensor_shape(outputs["present_compressed_key"]) + buffer_shape = self._tensor_shape(outputs["present_kv_buffer"]) + self.assertEqual(compressed_shape[::2], ["batch", 8]) + self.assertEqual(buffer_shape[::2], ["batch", 16]) + self.assertTrue(compressed_shape[1].startswith("SparseAttentionIndexer_")) + self.assertTrue(buffer_shape[1].startswith("SparseAttentionIndexer_")) + self.assertEqual(self._tensor_shape(outputs["present_gate_buffer"]), buffer_shape) + def test_unsqueeze_opset_11(self): graph = helper.make_graph( [ From d96d08f702d8c5cf1b5a53c4389caf3c8a504126 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:32:20 +0000 Subject: [PATCH 05/16] Implement SparseAttentionIndexer CUDA operator Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/OperatorKernels.md | 1 + .../cuda/sparse_attention_indexer.md | 364 +++++++ .../sparse/sparse_attention_indexer_common.h | 114 +++ .../contrib_ops/cuda/cuda_contrib_kernels.cc | 6 + .../cuda/sparse/sparse_attention_indexer.cc | 343 +++++++ .../cuda/sparse/sparse_attention_indexer.h | 35 + .../sparse/sparse_attention_indexer_impl.cu | 725 ++++++++++++++ .../sparse/sparse_attention_indexer_impl.h | 91 ++ .../core/graph/contrib_ops/bert_defs.cc | 395 ++++++++ onnxruntime/core/graph/contrib_ops/ms_opset.h | 2 + .../python/tools/symbolic_shape_infer.py | 74 ++ .../sparse_attention_indexer_op_test.cc | 927 ++++++++++++++++++ 12 files changed, 3077 insertions(+) create mode 100644 docs/contrib_ops/cuda/sparse_attention_indexer.md create mode 100644 onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h create mode 100644 onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index 750635e43677b..59a8b35f33420 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1143,6 +1143,7 @@ The **OpSet Version** column uses the following notation: |SkipLayerNormalization|*in* input:**T**
*in* skip:**T**
*in* gamma:**T**
*in* beta:**T**
*in* bias:**T**
*out* output:**T**
*out* mean:**U**
*out* inv_std_var:**U**
*out* input_skip_bias_sum:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| |SkipSimplifiedLayerNormalization|*in* input:**T**
*in* skip:**T**
*in* gamma:**T**
*in* bias:**T**
*out* output:**T**
*out* mean:**U**
*out* inv_std_var:**U**
*out* input_skip_bias_sum:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| |SparseAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* past_key:**T**
*in* past_value:**T**
*in* block_row_indices:**M**
*in* block_col_indices:**M**
*in* total_sequence_length:**M**
*in* key_total_sequence_lengths:**M**
*in* cos_cache:**T**
*in* sin_cache:**T**
*out* output:**T**
*out* present_key:**T**
*out* present_value:**T**|1+|**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float16)| +|SparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* mask:**TB**
*in* past_key:**T**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_compressed_key:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*out* selected_indices:**M**
*out* present_key:**T**
*out* present_compressed_key:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)
**TB** = tensor(bool)| |TransposeMatMul|*in* A:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(double), tensor(float), tensor(float16)| |Trilu|*in* X:**T**
*in* k:**tensor(int64)**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(bool), tensor(double), tensor(float), tensor(float16), tensor(int16), tensor(int32), tensor(int64), tensor(int8), tensor(uint16), tensor(uint32), tensor(uint64), tensor(uint8)| |UnfoldTensor|*in* input:**T**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(bool), tensor(double), tensor(float), tensor(float16), tensor(int16), tensor(int32), tensor(int64), tensor(int8), tensor(string), tensor(uint16), tensor(uint32), tensor(uint64), tensor(uint8)| diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md new file mode 100644 index 0000000000000..c3c2b422957a6 --- /dev/null +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -0,0 +1,364 @@ +# SparseAttentionIndexer — Operator Documentation + +This document describes the `com.microsoft::SparseAttentionIndexer` contrib operator: the two +indexer policies it implements, the schema and state contract, the CUDA kernel pipeline, and the +known limitations and performance follow-ups. + +The operator answers a single question for every query token: *which* keys is the following +attention operator allowed to read. It does not compute attention itself. Two policies are +supported, selected by the `policy_mode` attribute: + +| `policy_mode` | Reference implementation | Selection granularity | +|---|---|---| +| `qsa` | `Qwen4ExpTextQSAIndexer` | token indices, grouped in blocks of `compress_ratio` | +| `csa` | `DeepseekV4Indexer` / `DeepseekV4IndexerScorer` | compressed-entry indices | + +Source: +[bert_defs.cc](../../../onnxruntime/core/graph/contrib_ops/bert_defs.cc) (schema), +[sparse_attention_indexer_common.h](../../../onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h) +(slot map, capacity and window plan shared by the schema, the kernel and the tests), +[sparse_attention_indexer.cc](../../../onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc), +[sparse_attention_indexer_impl.cu](../../../onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu). + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Operator Schema](#2-operator-schema) +3. [State Contract](#3-state-contract) +4. [Policy `qsa`](#4-policy-qsa) +5. [Policy `csa`](#5-policy-csa) +6. [Rotary Embeddings](#6-rotary-embeddings) +7. [CUDA Kernel Pipeline](#7-cuda-kernel-pipeline) +8. [Numerics and Determinism](#8-numerics-and-determinism) +9. [Validation Rules](#9-validation-rules) +10. [Testing](#10-testing) +11. [Known Limitations and Performance Follow-ups](#11-known-limitations-and-performance-follow-ups) + +--- + +## 1. Overview + +Sparse-attention decoders run a small, cheap "indexer" attention next to the real attention. The +indexer has its own low-rank query/key projections, its own RMSNorm and its own rotary embedding, +and its only product is a set of indices. The real attention then reads just those keys. + +`SparseAttentionIndexer` implements that stage as one operator with three properties that matter +for a runtime: + +- **Fixed output capacity.** `selected_indices` always has shape + `(batch_size, sequence_length, capacity)` where `capacity` is a pure function of the attributes + (`token_budget + compress_ratio - 1` for `qsa`, `index_topk` for `csa`). Entries that are not + used are `-1`. No output size depends on tensor *data*, so the kernel never copies a count back + to the host and never allocates from a device-computed size. +- **Explicit, graph-visible state.** Everything that survives between calls — the concatenated + indexer key cache, the compressed-key cache and the partial-window buffers — is an input/output + pair. The operator caches nothing internally. +- **A single versioned schema.** Both policies share one schema; the policy-specific inputs and + outputs are optional slots at fixed indices and are validated strictly (see + [§9](#9-validation-rules)). + +## 2. Operator Schema + +### Attributes + +| Attribute | Type | Required | Description | +|---|---|---|---| +| `policy_mode` | string | yes | Exactly `qsa` or `csa`. Any other value is rejected. | +| `compress_ratio` | int | yes | Tokens folded into one block/entry. Must be `> 0`. | +| `token_budget` | int | `qsa` only | Maximum number of tokens taken from complete blocks. Must be `> 0` and divisible by `compress_ratio`. Must be absent for `csa`. | +| `index_topk` | int | `csa` only | Number of compressed entries selected per query. Must be `> 0`. Must be absent for `qsa`. | +| `epsilon` | float | no | RMSNorm epsilon of the compressed key. Default `1e-6`. | +| `scale` | float | no | Scale of the per-head ReLU score. Default `1/sqrt(head_size)`. | +| `head_weight_scale` | float | `csa` only | Scale applied to `head_weights`. Default `1/sqrt(num_heads)`. Must be absent for `qsa`. | + +### Inputs + +`B` = batch size, `S` = sequence length, `N` = `num_heads`, `D` = `head_size`, +`r` = `compress_ratio`, `P` = past sequence length, `T = P + S`, `R` = rotary width. + +| # | Name | Policy | Type | Shape | +|---|---|---|---|---| +| 0 | `query` | both | `T` | `(B, S, N, D)` | +| 1 | `key` | both | `T` | `(B, S, D)` for `qsa`, `(B, S, 2D)` for `csa` | +| 2 | `key_norm_weight` | both | `T` | `(D)` | +| 3 | `cos_cache` | both | `T` | `(B, max_rotary_sequence_length, R)` | +| 4 | `sin_cache` | both | `T` | same as `cos_cache` | +| 5 | `mask` | `qsa` | `TB` | `(B, 1, S, T)` or `(B, S, T)` | +| 6 | `past_key` | `qsa` | `T` | `(B, P, D)` | +| 7 | `gate` | `csa` | `T` | `(B, S, 2D)` | +| 8 | `position_bias` | `csa` | `T` | `(r, 2D)` | +| 9 | `head_weights` | `csa` | `T` | `(B, S, N)` | +| 10 | `position_ids` | `csa` | `I` | `(B, S)` | +| 11 | `past_compressed_key` | `csa` | `T` | `(B, Pc, D)` | +| 12 | `past_kv_buffer` | `csa` | `T` | `(B, Lb, 2D)`, `Lb` in `[0, 2r)` | +| 13 | `past_gate_buffer` | `csa` | `T` | same as `past_kv_buffer` | + +### Outputs + +| # | Name | Policy | Type | Shape | +|---|---|---|---|---| +| 0 | `selected_indices` | both | `M` | `(B, S, capacity)` | +| 1 | `present_key` | `qsa` | `T` | `(B, T, D)` | +| 2 | `present_compressed_key` | `csa` | `T` | `(B, Pc + W, D)` | +| 3 | `present_kv_buffer` | `csa` | `T` | `(B, Lb', 2D)` | +| 4 | `present_gate_buffer` | `csa` | `T` | same as `present_kv_buffer` | + +`W` is the number of complete windows closed by this call and `Lb'` the new buffer length; both +follow from `Lb`, `S` and `r` alone (see [§5](#5-policy-csa)). + +### Type constraints + +| Name | Allowed types | +|---|---| +| `T` | `tensor(float)`, `tensor(float16)`, `tensor(bfloat16)` | +| `TB` | `tensor(bool)` | +| `I` | `tensor(int64)` | +| `M` | `tensor(int32)` | + +Only the CUDA execution provider registers a kernel. There is no CPU kernel; the header under +`contrib_ops/cpu/sparse/` only holds the CUDA-free constants that the schema, the kernel and the +tests must agree on. + +### Output slot discipline + +A `qsa` node declares exactly 2 outputs (`selected_indices`, `present_key`). A `csa` node declares +exactly 5, leaving slot 1 as a missing optional so that the three `csa` state outputs keep their +fixed indices. Shape inference verifies the declared output count *before* touching any output, so +`getOutputType` is never called past the declared range. + +## 3. State Contract + +`SparseAttentionIndexer` is a pure function of its inputs. Every value it needs on the next call is +returned as an output, so the caller (or the ORT session binding) owns the buffers: + +| Policy | State pair | +|---|---| +| `qsa` | `past_key` → `present_key` | +| `csa` | `past_compressed_key` → `present_compressed_key` | +| `csa` | `past_kv_buffer` → `present_kv_buffer` | +| `csa` | `past_gate_buffer` → `present_gate_buffer` | + +`present_key` and `present_compressed_key` grow by a number of positions that is known from the +input shapes and the attributes, so the graph can pre-allocate them. The two `csa` buffers stay +bounded by `2r - 1` positions. + +## 4. Policy `qsa` + +For every `(b, s)`: + +1. **Visible set.** `visible = [t for t in range(T) if mask[b, s, t]]`, in ascending `t`. +2. **Complete blocks.** `nblocks = len(visible) // r`. Block `j` covers + `visible[j*r : (j+1)*r]`. +3. **Pooled key.** `k_j = mean` of the `r` raw `present_key` rows of block `j`, then + `RMSNorm(k_j) * key_norm_weight`, then rotary at absolute position `visible[j*r]`. +4. **Score.** `score_j = scale * sum_h ReLU(q_h · k_j)` where `q_h` is the rotated query head + at absolute position `P + s`. +5. **Selection.** `topk = min(token_budget // r, nblocks)` blocks, ordered by decreasing score. + Their tokens are emitted in that block order, `r` token indices per block. +6. **Tail.** The `len(visible) % r` tokens of the trailing incomplete block, + `visible[nblocks*r:]`, are appended unconditionally. +7. Remaining entries up to `capacity = token_budget + r - 1` are `-1`. + +The capacity is exactly `token_budget` selected tokens plus at most `r - 1` tail tokens. + +## 5. Policy `csa` + +### Window bookkeeping + +Let `ext = concat(past_kv_buffer, key)` along the sequence axis (and likewise for the gates). +The buffer is split as + +``` +overlap = (Lb >= r) ? r : 0 # previous complete window, the "Ca" source +leftover = Lb - overlap # tokens of the still-open window, always < r +pending = leftover + S +W = pending / r # complete windows closed by this call +``` + +Window `w` (`0 <= w < W`) covers `ext[overlap + w*r : overlap + (w+1)*r]`. Its `Ca` partner is the +preceding `r` tokens, which exist iff `w >= 1 || overlap == r`. Because `leftover < r` always holds, +a buffer length `>= r` unambiguously means "the previous complete window is present" — no extra +state tensor is needed to disambiguate. + +The new buffer starts at `overlap + (W-1)*r` when `W > 0` (the last complete window followed by the +new leftover) and at `0` otherwise, giving + +``` +Lb' = (W > 0) ? r + pending % r : Lb + S +``` + +`Lb'` is again in `[0, 2r)`. + +### Compression + +For window `w`, a `2r`-slot pooling window is built from `[B, 2r, D]` values and `[B, 2r, D]` gates: + +- slots `[r, 2r)` take the **`Cb`** half (channels `[D, 2D)`) of the window's own `r` tokens; +- slots `[0, r)` take the **`Ca`** half (channels `[0, D)`) of the preceding `r` tokens, or are + masked out (value `0`, gate `-inf`) when that window does not exist. + +`position_bias[slot % r]` is added to the raw gate (the buffers store the **raw** projection, so +the bias is re-applied at use time and never accumulates). A softmax over the `2r` slots is taken +**per channel `d`**, the values are weighted and summed, the result is RMS-normalized with +`key_norm_weight`, and finally rotated at absolute position `(Pc + w) * r`. + +### Scoring and selection + +``` +scores[b, s, e] = head_weight_scale * sum_h head_weights[b, s, h] * scale * ReLU(q_h · k_e) +threshold[b, s] = (position_ids[b, s] + 1) / r # integer division +scores[b, s, e] = -inf for e >= threshold[b, s] +``` + +The top `min(index_topk, Pc + W)` entries are emitted in decreasing score order; any emitted entry +whose index is `>= threshold` (which only happens when fewer than `index_topk` entries are +visible) is written as `-1`, and the remaining capacity is `-1`. + +## 6. Rotary Embeddings + +Both policies reuse the model's precomputed `cos_cache` / `sin_cache`, indexed by absolute +position. This keeps the exact rotary variant of each reference model without re-deriving +frequencies inside the kernel: + +- **`qsa` (leading, split-half).** `R = cos_cache.shape[2]` channels are rotated, and the rotation + is the split-half form + `out[i] = x[i]*cos[i] - x[i + R/2]*sin[i]`, `out[i + R/2] = x[i + R/2]*cos[i + R/2] + x[i]*sin[i + R/2]`, + applied to channels `[0, R)`. Channels `[R, D)` pass through. Feeding an MRoPE-expanded + `cos_cache`/`sin_cache` therefore reproduces the model's MRoPE exactly, because the operator + never recomputes the position→angle mapping. +- **`csa` (trailing, interleaved).** `2R` channels are rotated and they are the **last** `2R` + channels of the head. `cos`/`sin` are half-width, so entry `j >> 1` covers the channel pair + `(j, j+1)`, and the rotation is the interleaved form + `out[2i] = x[2i]*cos[i] - x[2i+1]*sin[i]`, `out[2i+1] = x[2i+1]*cos[i] + x[2i]*sin[i]`. + +Positions are clamped into `[0, max_rotary_sequence_length - 1]` inside the kernel, so an +out-of-range `position_ids` value cannot read out of bounds. + +### `key_norm_weight` and zero-centered gamma + +`key_norm_weight` is the **effective** multiplier: the kernel computes +`normalized * key_norm_weight`. Qwen's `Qwen4ExpTextRMSNorm` multiplies by `1 + gamma`. Exporters +targeting that model must fold the addition into the initializer (`key_norm_weight = 1 + gamma`). +DeepSeek's `DeepseekV4RMSNorm` uses a plain `weight *`, so its tensor is passed through unchanged. + +## 7. CUDA Kernel Pipeline + +All kernels use a fixed block of 128 threads (a power of two, required by the shared-memory block +reductions). Grid sizes are clamped to 65535 blocks and the elementwise kernels use grid-stride +loops, so no launch configuration depends on tensor data. + +### `qsa` + +| Stage | Kernel | Parallelism | +|---|---|---| +| 1 | `ConcatPastKeyKernel` | element | +| 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 | + +### `csa` + +| Stage | Kernel | Parallelism | +|---|---|---| +| 1 | `CsaCopyPastCompressedKernel` | element | +| 2 | `CsaCompressKernel` | one block per `(b, window)`; per-channel softmax over `2r` slots | +| 3 | `CsaCopyBufferKernel` | element | +| 4 | `RotateQueryKernel` | one block per `(b, s, h)` | +| 5 | `CsaScoreKernel` | one block per `(b, s, entry)` | +| 6 | `CsaSelectKernel` | one block per `(b, s)`; iterated block arg-max | + +### Workspaces + +| 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) | +| `csa` | float | `B*S*N*D` (rotated query) + `B*S*present_compressed_length` (scores) | + +Every size is derived from shapes and attributes only. + +### Selection without a visited bitmap + +`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. + +## 8. Numerics and Determinism + +- Pooling, softmax, RMS normalization, rotary and scoring are all done in `float32`; only the final + store is rounded to the tensor element type. This is a deliberate deviation from the reference + implementations, which for `qsa` run in the model dtype — the operator is strictly more accurate, + never less. +- The `2r`-slot softmax is a two-pass (max-subtracted) formulation, so a fully masked slot column + cannot produce `NaN`. +- Selection order is a total order, so the output is bitwise reproducible for a given input. +- `float16` and `bfloat16` are supported for all `T` tensors. `bfloat16` uses the conversion + helpers in `cu_inc/cuda_type_helper.cuh`, which are emulated on pre-`sm_80` devices, so no + architecture guard is required. + +## 9. Validation Rules + +Shape inference and the kernel both reject: + +- a `policy_mode` other than `qsa` / `csa`; +- `compress_ratio <= 0`; +- a `qsa` node that provides any `csa`-only input (slots 7–13) or attribute, and vice versa; +- a missing required input for the active policy; +- `token_budget` absent, `<= 0`, or not divisible by `compress_ratio` for `qsa`; +- `index_topk` absent or `<= 0` for `csa`; +- an output count other than 2 (`qsa`) or 5 (`csa`); +- `past_kv_buffer` / `past_gate_buffer` lengths outside `[0, 2 * compress_ratio)` or differing from + each other; +- rank or dimension mismatches between `query`, `key`, the caches and the buffers. + +Because the checks live in shape inference, most misuse fails at `Graph::Resolve()` with a clear +message rather than at kernel launch. + +## 10. Testing + +[`onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc`](../../../onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc) +contains: + +- **Shape-inference tests** that build a real `Model`, run `Graph::Resolve()` and assert the + inferred element type and shape of every output for both policies, including the buffer-only + `csa` case (`W == 0`). +- **Negative tests** that assert the exact validation message for each rule in + [§9](#9-validation-rules), including a `csa` node that declares too few outputs — the case that + would otherwise write past the declared output range. +- **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. + +Run them with: + +```bash +./build/Linux/Release/onnxruntime_provider_test --gtest_filter='SparseAttentionIndexer*' +``` + +## 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 the mask for every `s`. For long + contexts a batched exclusive scan over the whole `(B, S, T)` mask would be cheaper. +5. **`present_key` / `present_compressed_key` are copied every call.** An in-place cache with a + `past_sequence_length` input (as `GroupQueryAttention` does) would avoid the copy, at the cost of + a less explicit state contract. +6. **`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. +7. **No CPU kernel.** The operator is CUDA-only today. diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h new file mode 100644 index 0000000000000..d9eb3d1671cca --- /dev/null +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include +#include + +namespace onnxruntime { +namespace contrib { +namespace sparse_attention_indexer { + +// Values accepted by the policy_mode attribute of com.microsoft.SparseAttentionIndexer. +constexpr const char* kPolicyModeQsa = "qsa"; +constexpr const char* kPolicyModeCsa = "csa"; + +enum class Policy { + kQsa, + kCsa, +}; + +inline bool TryParsePolicy(const std::string& policy_mode, Policy& policy) { + if (policy_mode == kPolicyModeQsa) { + policy = Policy::kQsa; + return true; + } + if (policy_mode == kPolicyModeCsa) { + policy = Policy::kCsa; + return true; + } + return false; +} + +// Input slots. Slots 5-6 belong to policy_mode="qsa" and slots 7-13 to policy_mode="csa"; +// a slot that does not belong to the active policy must be omitted from the node. +enum InputIndex : int { + kQuery = 0, + kKey = 1, + kKeyNormWeight = 2, + kCosCache = 3, + kSinCache = 4, + kMask = 5, + kPastKey = 6, + kGate = 7, + kPositionBias = 8, + kHeadWeights = 9, + kPositionIds = 10, + kPastCompressedKey = 11, + kPastKvBuffer = 12, + kPastGateBuffer = 13, + kInputCount = 14, +}; + +// Output slots. Slot 1 belongs to policy_mode="qsa" and slots 2-4 to policy_mode="csa". +enum OutputIndex : int { + kSelectedIndices = 0, + kPresentKey = 1, + kPresentCompressedKey = 2, + kPresentKvBuffer = 3, + kPresentGateBuffer = 4, + kOutputCount = 5, +}; + +// A "qsa" node declares selected_indices + present_key; a "csa" node declares every slot so that +// the three csa state outputs keep their fixed indices (slot 1 is left as a missing optional). +constexpr int kQsaOutputCount = 2; +constexpr int kCsaOutputCount = 5; + +// Number of selected entries emitted per query. The capacity only depends on attributes, so it is +// a compile-time constant of the graph rather than a function of the data. +inline int64_t SelectedCapacity(Policy policy, int64_t token_budget, int64_t index_topk, + int64_t compress_ratio) { + return policy == Policy::kQsa ? token_budget + compress_ratio - 1 : index_topk; +} + +// How the "csa" token buffer is split and how many new compressed entries this call emits. +// +// past_kv_buffer / past_gate_buffer carry `overlap_length` tokens of the previous complete window +// (the Ca operand of the next window) followed by `leftover_length` tokens of an incomplete window. +// Since `leftover_length` is always < compress_ratio, a buffer length >= compress_ratio uniquely +// means "the previous complete window is present", so no extra state tensor is needed. +struct CsaWindowPlan { + int64_t overlap_length = 0; // compress_ratio, or 0 before the first complete window + int64_t leftover_length = 0; // tokens of the current incomplete window + int64_t new_window_count = 0; // complete windows closed by this call + int64_t present_buffer_length = 0; // length of present_kv_buffer / present_gate_buffer + int64_t present_buffer_start = 0; // offset of that buffer inside [past buffer | new tokens] +}; + +inline bool TryComputeCsaWindowPlan(int64_t past_buffer_length, int64_t sequence_length, + int64_t compress_ratio, CsaWindowPlan& plan) { + if (compress_ratio <= 0 || sequence_length < 0 || past_buffer_length < 0 || + past_buffer_length >= 2 * compress_ratio) { + return false; + } + + plan.overlap_length = past_buffer_length >= compress_ratio ? compress_ratio : 0; + plan.leftover_length = past_buffer_length - plan.overlap_length; + + const int64_t pending = plan.leftover_length + sequence_length; + plan.new_window_count = pending / compress_ratio; + if (plan.new_window_count > 0) { + plan.present_buffer_length = compress_ratio + pending % compress_ratio; + plan.present_buffer_start = plan.overlap_length + (plan.new_window_count - 1) * compress_ratio; + } else { + plan.present_buffer_length = past_buffer_length + sequence_length; + plan.present_buffer_start = 0; + } + return true; +} + +} // namespace sparse_attention_indexer +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc b/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc index 2199b2e07c0d4..f9165bb5074e1 100644 --- a/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc +++ b/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc @@ -253,6 +253,9 @@ class CUDA_MS_OP_TYPED_CLASS_NAME(1, MLFloat16_MLFloat16, DecoderMaskedMultiHead class CUDA_MS_OP_CLASS_NAME(1, GemmFloat8); class CUDA_MS_OP_TYPED_CLASS_NAME(1, MLFloat16, SparseAttention); class CUDA_MS_OP_TYPED_CLASS_NAME(1, BFloat16, SparseAttention); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, float, SparseAttentionIndexer); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, MLFloat16, SparseAttentionIndexer); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, BFloat16, SparseAttentionIndexer); class CUDA_MS_OP_THREE_TYPED_CLASS_NAME(1, uint8_t, float, int32_t, GatherBlockQuantized); class CUDA_MS_OP_THREE_TYPED_CLASS_NAME(1, uint8_t, MLFloat16, int32_t, GatherBlockQuantized); @@ -559,6 +562,9 @@ Status RegisterCudaContribKernels(KernelRegistry& kernel_registry) { BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, + BuildKernelCreateInfo, + BuildKernelCreateInfo, + BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc new file mode 100644 index 0000000000000..5a3d84eacb7a6 --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer.h" + +#include +#include + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" +#include "core/providers/cuda/cuda_common.h" +#include "core/providers/cuda/cuda_type_conversion.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +using namespace onnxruntime::cuda; +namespace sai = onnxruntime::contrib::sparse_attention_indexer; + +#define REGISTER_KERNEL_TYPED(T) \ + ONNX_OPERATOR_TYPED_KERNEL_EX( \ + SparseAttentionIndexer, \ + kMSDomain, \ + 1, \ + T, \ + kCudaExecutionProvider, \ + (*KernelDefBuilder::Create()) \ + .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("TB", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("M", DataTypeImpl::GetTensorType()), \ + SparseAttentionIndexer); + +REGISTER_KERNEL_TYPED(float) +REGISTER_KERNEL_TYPED(MLFloat16) +REGISTER_KERNEL_TYPED(BFloat16) + +#undef REGISTER_KERNEL_TYPED + +namespace { + +Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list expected) { + ORT_RETURN_IF(tensor == nullptr, "SparseAttentionIndexer: ", name, " is required"); + const TensorShape expected_shape(expected); + ORT_RETURN_IF_NOT(tensor->Shape() == expected_shape, "SparseAttentionIndexer: ", name, " must have shape ", + expected_shape.ToString(), ", got ", tensor->Shape().ToString()); + return Status::OK(); +} + +} // namespace + +template +SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : CudaKernel(info) { + std::string policy_mode; + ORT_ENFORCE(info.GetAttr("policy_mode", &policy_mode).IsOK(), + "SparseAttentionIndexer: policy_mode is required"); + ORT_ENFORCE(sai::TryParsePolicy(policy_mode, policy_), "SparseAttentionIndexer: policy_mode must be '", + sai::kPolicyModeQsa, "' or '", sai::kPolicyModeCsa, "', got '", policy_mode, "'"); + + ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), + "SparseAttentionIndexer: compress_ratio is required"); + ORT_ENFORCE(compress_ratio_ > 0, "SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio_); + + const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); + const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); + float head_weight_scale = 0.0f; + const bool has_head_weight_scale = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); + + if (policy_ == sai::Policy::kQsa) { + ORT_ENFORCE(has_token_budget, "SparseAttentionIndexer: token_budget is required when policy_mode is 'qsa'"); + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale, + "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); + ORT_ENFORCE(token_budget_ > 0 && token_budget_ % compress_ratio_ == 0, + "SparseAttentionIndexer: token_budget must be > 0 and divisible by compress_ratio, got token_budget=", + token_budget_, " compress_ratio=", compress_ratio_); + index_topk_ = 0; + } else { + ORT_ENFORCE(has_index_topk, "SparseAttentionIndexer: index_topk is required when policy_mode is 'csa'"); + ORT_ENFORCE(!has_token_budget, "SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); + ORT_ENFORCE(index_topk_ > 0, "SparseAttentionIndexer: index_topk must be > 0, got ", index_topk_); + token_budget_ = 0; + } + + epsilon_ = info.GetAttrOrDefault("epsilon", 1.0e-6f); + ORT_ENFORCE(epsilon_ >= 0.0f, "SparseAttentionIndexer: epsilon must be >= 0, got ", epsilon_); + scale_ = info.GetAttrOrDefault("scale", 0.0f); + head_weight_scale_ = has_head_weight_scale ? head_weight_scale : 0.0f; +} + +template +Status SparseAttentionIndexer::ComputeInternal(OpKernelContext* context) const { + const bool is_qsa = policy_ == sai::Policy::kQsa; + for (int index = sai::kMask; index < sai::kInputCount; ++index) { + const bool policy_owns_slot = is_qsa ? (index <= sai::kPastKey) : (index >= sai::kGate); + 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, "'"); + } + + return is_qsa ? ComputeQsa(context) : ComputeCsa(context); +} + +template +Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { + using CudaT = typename OrtToCudaType::type; + + const Tensor* query = context->Input(sai::kQuery); + const Tensor* key = context->Input(sai::kKey); + const Tensor* key_norm_weight = context->Input(sai::kKeyNormWeight); + const Tensor* cos_cache = context->Input(sai::kCosCache); + const Tensor* sin_cache = context->Input(sai::kSinCache); + const Tensor* mask = context->Input(sai::kMask); + const Tensor* past_key = context->Input(sai::kPastKey); + + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, + "SparseAttentionIndexer: query must have shape (batch_size, sequence_length, num_heads, head_size)" + ", got ", + query_shape.ToString()); + const int64_t batch_size = query_shape[0]; + const int64_t sequence_length = query_shape[1]; + const int64_t num_heads = query_shape[2]; + const int64_t head_size = query_shape[3]; + + 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_NOT(sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as " + "cos_cache"); + 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); + + const auto& past_shape = past_key->Shape(); + ORT_RETURN_IF_NOT(past_shape.NumDimensions() == 3 && past_shape[0] == batch_size && past_shape[2] == head_size, + "SparseAttentionIndexer: past_key must have shape (batch_size, past_sequence_length, head_size)" + ", got ", + past_shape.ToString()); + const int64_t past_sequence_length = past_shape[1]; + const int64_t total_sequence_length = past_sequence_length + sequence_length; + + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); + + const auto& mask_shape = mask->Shape(); + const bool mask_is_4d = mask_shape.NumDimensions() == 4; + ORT_RETURN_IF_NOT( + (mask_is_4d && mask_shape[0] == batch_size && mask_shape[1] == 1 && mask_shape[2] == sequence_length && + mask_shape[3] == total_sequence_length) || + (mask_shape.NumDimensions() == 3 && mask_shape[0] == batch_size && mask_shape[1] == sequence_length && + mask_shape[2] == total_sequence_length), + "SparseAttentionIndexer: mask must have shape (batch_size, 1, sequence_length, total_sequence_length) or " + "(batch_size, sequence_length, total_sequence_length) with total_sequence_length=", + total_sequence_length, ", got ", mask_shape.ToString()); + + SparseAttentionIndexerParams params; + params.batch_size = static_cast(batch_size); + params.sequence_length = static_cast(sequence_length); + params.num_heads = static_cast(num_heads); + params.head_size = static_cast(head_size); + params.rotary_width = static_cast(rotary_width); + params.max_rotary_length = static_cast(max_rotary_length); + params.compress_ratio = static_cast(compress_ratio_); + params.capacity = static_cast( + sai::SelectedCapacity(sai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_)); + params.epsilon = epsilon_; + params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.past_sequence_length = static_cast(past_sequence_length); + params.total_sequence_length = static_cast(total_sequence_length); + params.max_block_count = static_cast(total_sequence_length / compress_ratio_); + params.block_topk = static_cast(token_budget_ / compress_ratio_); + + Tensor* selected_indices = context->Output(sai::kSelectedIndices, + TensorShape({batch_size, sequence_length, params.capacity})); + Tensor* present_key = context->Output(sai::kPresentKey, TensorShape({batch_size, total_sequence_length, head_size})); + ORT_RETURN_IF(selected_indices == nullptr || present_key == nullptr, + "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); + + auto float_workspace = GetScratchBuffer(GetQsaWorkspaceFloatCount(params), context->GetComputeStream()); + auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), context->GetComputeStream()); + + return LaunchQsaSparseAttentionIndexer( + Stream(context), params, + reinterpret_cast(query->Data()), + reinterpret_cast(key->Data()), + reinterpret_cast(key_norm_weight->Data()), + reinterpret_cast(cos_cache->Data()), + reinterpret_cast(sin_cache->Data()), + mask->Data(), + reinterpret_cast(past_key->Data()), + selected_indices->MutableData(), + reinterpret_cast(present_key->MutableData()), + float_workspace.get(), + int_workspace.get()); +} + +template +Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { + using CudaT = typename OrtToCudaType::type; + + const Tensor* query = context->Input(sai::kQuery); + const Tensor* key = context->Input(sai::kKey); + const Tensor* key_norm_weight = context->Input(sai::kKeyNormWeight); + const Tensor* cos_cache = context->Input(sai::kCosCache); + const Tensor* sin_cache = context->Input(sai::kSinCache); + const Tensor* gate = context->Input(sai::kGate); + const Tensor* position_bias = context->Input(sai::kPositionBias); + const Tensor* head_weights = context->Input(sai::kHeadWeights); + const Tensor* position_ids = context->Input(sai::kPositionIds); + const Tensor* past_compressed_key = context->Input(sai::kPastCompressedKey); + const Tensor* past_kv_buffer = context->Input(sai::kPastKvBuffer); + const Tensor* past_gate_buffer = context->Input(sai::kPastGateBuffer); + + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, + "SparseAttentionIndexer: query must have shape (batch_size, sequence_length, num_heads, head_size)" + ", got ", + query_shape.ToString()); + const int64_t batch_size = query_shape[0]; + const int64_t sequence_length = query_shape[1]; + const int64_t num_heads = query_shape[2]; + const int64_t head_size = query_shape[3]; + 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_NOT(sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as " + "cos_cache"); + 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=", + rotary_width, " head_size=", head_size); + + const auto& past_compressed_shape = past_compressed_key->Shape(); + ORT_RETURN_IF_NOT(past_compressed_shape.NumDimensions() == 3 && past_compressed_shape[0] == batch_size && + past_compressed_shape[2] == head_size, + "SparseAttentionIndexer: past_compressed_key must have shape " + "(batch_size, past_compressed_length, head_size), got ", + past_compressed_shape.ToString()); + const int64_t past_compressed_length = past_compressed_shape[1]; + + const auto& past_buffer_shape = past_kv_buffer->Shape(); + ORT_RETURN_IF_NOT(past_buffer_shape.NumDimensions() == 3 && past_buffer_shape[0] == batch_size && + past_buffer_shape[2] == width, + "SparseAttentionIndexer: past_kv_buffer must have shape " + "(batch_size, buffer_length, 2 * head_size), got ", + past_buffer_shape.ToString()); + const int64_t past_buffer_length = past_buffer_shape[1]; + + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); + ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckShape(position_bias, "position_bias", {compress_ratio_, width})); + 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})); + ORT_RETURN_IF_ERROR(CheckShape(past_gate_buffer, "past_gate_buffer", + {batch_size, past_buffer_length, width})); + + sai::CsaWindowPlan plan; + ORT_RETURN_IF_NOT(sai::TryComputeCsaWindowPlan(past_buffer_length, sequence_length, compress_ratio_, plan), + "SparseAttentionIndexer: past_kv_buffer sequence length must be in [0, 2 * compress_ratio), got ", + past_buffer_length); + const int64_t present_compressed_length = past_compressed_length + plan.new_window_count; + + SparseAttentionIndexerParams params; + params.batch_size = static_cast(batch_size); + params.sequence_length = static_cast(sequence_length); + params.num_heads = static_cast(num_heads); + params.head_size = static_cast(head_size); + params.rotary_width = static_cast(rotary_width); + params.max_rotary_length = static_cast(max_rotary_length); + params.compress_ratio = static_cast(compress_ratio_); + params.capacity = static_cast( + sai::SelectedCapacity(sai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_)); + params.epsilon = epsilon_; + params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.past_compressed_length = static_cast(past_compressed_length); + params.present_compressed_length = static_cast(present_compressed_length); + params.past_buffer_length = static_cast(past_buffer_length); + params.overlap_length = static_cast(plan.overlap_length); + params.new_window_count = static_cast(plan.new_window_count); + params.present_buffer_length = static_cast(plan.present_buffer_length); + params.present_buffer_start = static_cast(plan.present_buffer_start); + params.index_topk = static_cast(index_topk_); + params.head_weight_scale = + head_weight_scale_ != 0.0f ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads)); + + Tensor* selected_indices = context->Output(sai::kSelectedIndices, + TensorShape({batch_size, sequence_length, params.capacity})); + Tensor* present_compressed_key = context->Output( + sai::kPresentCompressedKey, TensorShape({batch_size, present_compressed_length, head_size})); + Tensor* present_kv_buffer = + context->Output(sai::kPresentKvBuffer, TensorShape({batch_size, plan.present_buffer_length, width})); + Tensor* present_gate_buffer = + context->Output(sai::kPresentGateBuffer, TensorShape({batch_size, plan.present_buffer_length, width})); + ORT_RETURN_IF(selected_indices == nullptr || present_compressed_key == nullptr || present_kv_buffer == nullptr || + present_gate_buffer == nullptr, + "SparseAttentionIndexer: policy_mode 'csa' requires selected_indices, present_compressed_key, " + "present_kv_buffer and present_gate_buffer outputs"); + + auto float_workspace = GetScratchBuffer(GetCsaWorkspaceFloatCount(params), context->GetComputeStream()); + + const CudaT* empty = nullptr; + return LaunchCsaSparseAttentionIndexer( + Stream(context), params, + reinterpret_cast(query->Data()), + reinterpret_cast(key->Data()), + reinterpret_cast(key_norm_weight->Data()), + reinterpret_cast(cos_cache->Data()), + reinterpret_cast(sin_cache->Data()), + reinterpret_cast(gate->Data()), + reinterpret_cast(position_bias->Data()), + reinterpret_cast(head_weights->Data()), + position_ids->Data(), + past_compressed_length > 0 ? reinterpret_cast(past_compressed_key->Data()) : empty, + past_buffer_length > 0 ? reinterpret_cast(past_kv_buffer->Data()) : empty, + past_buffer_length > 0 ? reinterpret_cast(past_gate_buffer->Data()) : empty, + selected_indices->MutableData(), + present_compressed_length > 0 ? reinterpret_cast(present_compressed_key->MutableData()) : nullptr, + plan.present_buffer_length > 0 ? reinterpret_cast(present_kv_buffer->MutableData()) : nullptr, + plan.present_buffer_length > 0 ? reinterpret_cast(present_gate_buffer->MutableData()) : nullptr, + float_workspace.get()); +} + +template class SparseAttentionIndexer; +template class SparseAttentionIndexer; +template class SparseAttentionIndexer; + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h new file mode 100644 index 0000000000000..0abbb6064a3a4 --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" +#include "core/common/common.h" +#include "core/providers/cuda/cuda_kernel.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +template +class SparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel { + public: + explicit SparseAttentionIndexer(const OpKernelInfo& info); + Status ComputeInternal(OpKernelContext* context) const override; + + private: + Status ComputeQsa(OpKernelContext* context) const; + Status ComputeCsa(OpKernelContext* context) const; + + sparse_attention_indexer::Policy policy_; + int64_t compress_ratio_; + int64_t token_budget_; + int64_t index_topk_; + float epsilon_; + float scale_; // 0 means "derive from head_size" + float head_weight_scale_; // 0 means "derive from num_heads" +}; + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu new file mode 100644 index 0000000000000..ea05afcd7886e --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -0,0 +1,725 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Correctness-first implementation of com.microsoft.SparseAttentionIndexer. Every stage is a +// straightforward kernel that mirrors the reference semantics; see +// docs/contrib_ops/cuda/sparse_attention_indexer.md for the performance follow-ups. + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" + +#include +#include +#include +#include + +#include + +#include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +namespace { + +// The block reductions below halve the active thread count, so this must stay a power of two. +constexpr int kThreads = 128; +constexpr int64_t kMaxGridDimX = 2147483647; + +__device__ __forceinline__ float NegativeInfinity() { return -CUDART_INF_F; } + +int GridForElements(int64_t count) { + const int64_t blocks = (count + kThreads - 1) / kThreads; + return static_cast(std::clamp(blocks, 1, 65535)); +} + +// --------------------------------------------------------------------------------------------- +// Shared device helpers +// --------------------------------------------------------------------------------------------- + +__device__ __forceinline__ float BlockSum(float value, float* shared) { + shared[threadIdx.x] = value; + __syncthreads(); + for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) { + if (threadIdx.x < stride) { + shared[threadIdx.x] += shared[threadIdx.x + stride]; + } + __syncthreads(); + } + const float total = shared[0]; + __syncthreads(); + return total; +} + +// Reduces (value, index) pairs to the largest value, breaking ties towards the smaller index. +// A negative index marks an empty slot. shared_value/shared_index must already be filled and synced. +__device__ __forceinline__ void BlockArgMax(float* shared_value, int* shared_index) { + for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) { + if (threadIdx.x < stride) { + const int other_index = shared_index[threadIdx.x + stride]; + if (other_index >= 0) { + const int this_index = shared_index[threadIdx.x]; + const float other_value = shared_value[threadIdx.x + stride]; + const float this_value = shared_value[threadIdx.x]; + if (this_index < 0 || other_value > this_value || + (other_value == this_value && other_index < this_index)) { + shared_value[threadIdx.x] = other_value; + shared_index[threadIdx.x] = other_index; + } + } + } + __syncthreads(); + } +} + +// Per-thread scan for the best entry that comes strictly after (previous_score, previous_index) in +// the total order "score descending, then index ascending". Entries already emitted are therefore +// skipped without needing a visited bitmap. +__device__ __forceinline__ void ScanForNext(const float* scores, int count, float previous_score, + int previous_index, float* best_value, int* best_index) { + *best_index = -1; + *best_value = 0.0f; + for (int candidate = static_cast(threadIdx.x); candidate < count; + candidate += static_cast(blockDim.x)) { + const float value = scores[candidate]; + if (previous_index >= 0 && + !(value < previous_score || (value == previous_score && candidate > previous_index))) { + continue; + } + if (*best_index < 0 || value > *best_value || + (value == *best_value && candidate < *best_index)) { + *best_value = value; + *best_index = candidate; + } + } +} + +// Split-half rotary over the leading `rotary_width` channels (the convention used by the qsa +// reference). Channels beyond `rotary_width` pass through unchanged. +template +__device__ __forceinline__ float LeadingRope(const float* value, int rotary_width, const T* cos_row, + const T* sin_row, int d) { + if (d >= rotary_width) { + return value[d]; + } + const int half = rotary_width / 2; + const float paired = (d < half) ? -value[d + half] : value[d - half]; + return value[d] * to_float(cos_row[d]) + paired * to_float(sin_row[d]); +} + +// Interleaved rotary over the trailing 2 * rotary_width channels (the convention used by the csa +// reference). Each cos/sin entry covers one channel pair, matching repeat_interleave(2). +template +__device__ __forceinline__ float TrailingRope(const float* value, int head_size, int rotary_width, + const T* cos_row, const T* sin_row, int d) { + const int base = head_size - 2 * rotary_width; + if (d < base) { + return value[d]; + } + const int offset = d - base; + const float paired = ((offset & 1) == 0) ? -value[d + 1] : value[d - 1]; + return value[d] * to_float(cos_row[offset >> 1]) + paired * to_float(sin_row[offset >> 1]); +} + +// Highest compressed entry a query at `position` may attend to, matching (position + 1) // ratio. +__device__ __forceinline__ int64_t CausalThreshold(int64_t position, int compress_ratio) { + return position < 0 ? 0 : (position + 1) / compress_ratio; +} + +__device__ __forceinline__ int ClampPosition(int64_t position, int max_rotary_length) { + if (position < 0) { + return 0; + } + const int64_t limit = max_rotary_length - 1; + return static_cast(position < limit ? position : limit); +} + +// --------------------------------------------------------------------------------------------- +// Shared kernels +// --------------------------------------------------------------------------------------------- + +// One block per (batch, token, head). kUseLeadingRope selects the qsa convention; otherwise the +// csa convention with positions taken from position_ids. +template +__global__ void RotateQueryKernel(const T* query, const T* cos_cache, const T* sin_cache, + const int64_t* position_ids, float* query_rotated, + SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + const int64_t rows = static_cast(params.batch_size) * params.sequence_length * params.num_heads; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const int token = static_cast((row / params.num_heads) % params.sequence_length); + const int batch = static_cast(row / (static_cast(params.num_heads) * params.sequence_length)); + const int64_t base = row * params.head_size; + + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + shared[d] = to_float(query[base + d]); + } + __syncthreads(); + + const int64_t raw_position = kUseLeadingRope + ? static_cast(params.past_sequence_length) + token + : 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; + const T* cos_row = cos_cache + cache_offset; + const T* sin_row = sin_cache + cache_offset; + + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + query_rotated[base + d] = + kUseLeadingRope ? LeadingRope(shared, params.rotary_width, cos_row, sin_row, d) + : TrailingRope(shared, params.head_size, params.rotary_width, cos_row, sin_row, d); + } + __syncthreads(); + } +} + +// --------------------------------------------------------------------------------------------- +// policy_mode = "qsa" +// --------------------------------------------------------------------------------------------- + +template +__global__ void ConcatPastKeyKernel(const T* past_key, const T* key, T* present_key, + SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.total_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.total_sequence_length); + const int batch = static_cast(token_row / params.total_sequence_length); + present_key[index] = + token < params.past_sequence_length + ? past_key[(static_cast(batch) * params.past_sequence_length + token) * params.head_size + d] + : key[(static_cast(batch) * params.sequence_length + (token - params.past_sequence_length)) * + params.head_size + + d]; + } +} + +// One block per query row; compacts the visible key positions of that row into visible_indices. +__global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, int32_t* visible_count, + SparseAttentionIndexerParams params) { + extern __shared__ int32_t shared_scan[]; + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const bool* mask_row = mask + row * params.total_sequence_length; + int32_t* out_row = visible_indices + row * params.total_sequence_length; + int32_t offset = 0; + for (int base = 0; base < params.total_sequence_length; base += blockDim.x) { + const int position = base + static_cast(threadIdx.x); + const int32_t flag = (position < params.total_sequence_length && mask_row[position]) ? 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(); + } + if (flag != 0) { + out_row[offset + shared_scan[threadIdx.x] - 1] = position; + } + const int32_t tile_total = shared_scan[blockDim.x - 1]; + __syncthreads(); + offset += tile_total; + } + if (threadIdx.x == 0) { + visible_count[row] = offset; + } + __syncthreads(); + } +} + +// One block per (query row, block index). Pools compress_ratio visible keys, normalizes, rotates +// and scores the result against every query head. +template +__global__ void QsaBlockScoreKernel(const T* present_key, const T* key_norm_weight, const T* cos_cache, + const T* sin_cache, const float* query_rotated, + const int32_t* visible_indices, const int32_t* visible_count, + float* block_scores, SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* pooled = shared; + float* rotated = shared + params.head_size; + float* reduction = shared + 2 * params.head_size; + + 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 block_count = visible_count[row] / params.compress_ratio; + + if (block_index >= block_count) { + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = NegativeInfinity(); + } + 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 int64_t key_base = static_cast(batch) * params.total_sequence_length * 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]); + } + pooled[d] = sum / static_cast(params.compress_ratio); + } + __syncthreads(); + + float sum_squares = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = BlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + pooled[d] = pooled[d] * inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + + const int position = ClampPosition(group[0], params.max_rotary_length); + const int64_t cache_offset = + (static_cast(batch) * params.max_rotary_length + 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); + } + __syncthreads(); + + float score = 0.0f; + for (int head = 0; head < params.num_heads; ++head) { + const float* query_head = query_rotated + (row * params.num_heads + head) * params.head_size; + float partial = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + partial += query_head[d] * rotated[d]; + } + score += fmaxf(BlockSum(partial, reduction), 0.0f); + } + + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = score * params.scale; + } + __syncthreads(); + } +} + +// 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, + SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* shared_value = shared; + int* shared_index = reinterpret_cast(shared + blockDim.x); + + 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* out_row = selected_indices + row * params.capacity; + for (int position = threadIdx.x; position < params.capacity; position += blockDim.x) { + out_row[position] = -1; + } + __syncthreads(); + + const int visible = visible_count[row]; + 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; + } + 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]; + } + 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]; + } + __syncthreads(); + } +} + +// --------------------------------------------------------------------------------------------- +// policy_mode = "csa" +// --------------------------------------------------------------------------------------------- + +// Reads channel `channel` of token `position` of the virtual sequence [past buffer | new tokens]. +template +__device__ __forceinline__ float ExtendedValue(const T* past_buffer, const T* current, int batch, + int position, int channel, + const SparseAttentionIndexerParams& params) { + const int width = 2 * params.head_size; + if (position < params.past_buffer_length) { + return to_float( + past_buffer[(static_cast(batch) * params.past_buffer_length + position) * width + channel]); + } + return to_float( + current[(static_cast(batch) * params.sequence_length + (position - params.past_buffer_length)) * width + + channel]); +} + +// One block per (batch, new window). Softmax-pools the 2 * compress_ratio window slots, normalizes +// and rotates the result into present_compressed_key. +template +__global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_buffer, + const T* past_gate_buffer, const T* position_bias, + const T* key_norm_weight, const T* cos_cache, const T* sin_cache, + T* present_compressed_key, SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* pooled = shared; + float* reduction = shared + params.head_size; + + const int width = 2 * params.head_size; + const int64_t total = static_cast(params.batch_size) * params.new_window_count; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int window = static_cast(work % params.new_window_count); + const int batch = static_cast(work / params.new_window_count); + const bool has_previous = window >= 1 || params.overlap_length >= params.compress_ratio; + const int previous_base = params.overlap_length + (window - 1) * params.compress_ratio; + const int current_base = params.overlap_length + window * params.compress_ratio; + + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + float max_gate = NegativeInfinity(); + if (has_previous) { + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float value = + ExtendedValue(past_gate_buffer, gate, batch, previous_base + slot, d, params) + + to_float(position_bias[static_cast(slot) * width + d]); + max_gate = fmaxf(max_gate, value); + } + } + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float value = + ExtendedValue(past_gate_buffer, gate, batch, current_base + slot, params.head_size + d, params) + + to_float(position_bias[static_cast(slot) * width + params.head_size + d]); + max_gate = fmaxf(max_gate, value); + } + + float denominator = 0.0f; + float accumulator = 0.0f; + if (has_previous) { + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float logit = + ExtendedValue(past_gate_buffer, gate, batch, previous_base + slot, d, params) + + to_float(position_bias[static_cast(slot) * width + d]); + const float weight = __expf(logit - max_gate); + denominator += weight; + accumulator += weight * ExtendedValue(past_kv_buffer, key, batch, previous_base + slot, d, params); + } + } + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const float logit = + ExtendedValue(past_gate_buffer, gate, batch, current_base + slot, params.head_size + d, params) + + to_float(position_bias[static_cast(slot) * width + params.head_size + d]); + const float weight = __expf(logit - max_gate); + denominator += weight; + accumulator += + weight * ExtendedValue(past_kv_buffer, key, batch, current_base + slot, params.head_size + d, params); + } + pooled[d] = denominator > 0.0f ? accumulator / denominator : 0.0f; + } + __syncthreads(); + + float sum_squares = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = BlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + pooled[d] = pooled[d] * inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + + 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; + const int64_t out_base = + (static_cast(batch) * params.present_compressed_length + entry) * params.head_size; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + present_compressed_key[out_base + d] = from_float(TrailingRope( + pooled, params.head_size, params.rotary_width, cos_cache + cache_offset, sin_cache + cache_offset, d)); + } + __syncthreads(); + } +} + +template +__global__ void CsaCopyPastCompressedKernel(const T* past_compressed_key, T* present_compressed_key, + SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.past_compressed_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 int64_t entry_row = index / params.head_size; + const int entry = static_cast(entry_row % params.past_compressed_length); + const int batch = static_cast(entry_row / params.past_compressed_length); + const int d = static_cast(index % params.head_size); + present_compressed_key[(static_cast(batch) * params.present_compressed_length + entry) * params.head_size + + d] = past_compressed_key[index]; + } +} + +template +__global__ void CsaCopyBufferKernel(const T* key, const T* gate, const T* past_kv_buffer, + const T* past_gate_buffer, T* present_kv_buffer, T* present_gate_buffer, + SparseAttentionIndexerParams params) { + const int width = 2 * params.head_size; + const int64_t total = static_cast(params.batch_size) * params.present_buffer_length * width; + for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; + index += static_cast(gridDim.x) * blockDim.x) { + const int channel = static_cast(index % width); + const int64_t token_row = index / width; + const int token = static_cast(token_row % params.present_buffer_length); + const int batch = static_cast(token_row / params.present_buffer_length); + const int source = params.present_buffer_start + token; + present_kv_buffer[index] = from_float(ExtendedValue(past_kv_buffer, key, batch, source, channel, params)); + present_gate_buffer[index] = + from_float(ExtendedValue(past_gate_buffer, gate, batch, source, channel, params)); + } +} + +// One thread per (query row, compressed entry). Also applies the causal mask so that the selection +// kernel only has to read scores. +template +__global__ void CsaScoreKernel(const float* query_rotated, const T* present_compressed_key, + const T* head_weights, const int64_t* position_ids, float* scores, + SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.sequence_length * + params.present_compressed_length; + for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; + index += static_cast(gridDim.x) * blockDim.x) { + const int entry = static_cast(index % params.present_compressed_length); + const int64_t row = index / params.present_compressed_length; + const int batch = static_cast(row / params.sequence_length); + + const int64_t threshold = CausalThreshold(position_ids[row], params.compress_ratio); + if (static_cast(entry) >= threshold) { + scores[index] = NegativeInfinity(); + continue; + } + + const int64_t key_base = + (static_cast(batch) * params.present_compressed_length + entry) * params.head_size; + float total_score = 0.0f; + for (int head = 0; head < params.num_heads; ++head) { + const float* query_head = query_rotated + (row * params.num_heads + head) * params.head_size; + float dot = 0.0f; + for (int d = 0; d < params.head_size; ++d) { + dot += query_head[d] * to_float(present_compressed_key[key_base + d]); + } + total_score += fmaxf(dot, 0.0f) * to_float(head_weights[row * params.num_heads + head]); + } + scores[index] = total_score * params.scale * params.head_weight_scale; + } +} + +__global__ void CsaSelectKernel(const float* scores, const int64_t* position_ids, int32_t* selected_indices, + SparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* shared_value = shared; + int* shared_index = reinterpret_cast(shared + blockDim.x); + + 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* out_row = selected_indices + row * params.capacity; + for (int position = threadIdx.x; position < params.capacity; position += blockDim.x) { + out_row[position] = -1; + } + __syncthreads(); + + const int count = params.present_compressed_length; + const int selected = min(params.index_topk, count); + const int64_t threshold = CausalThreshold(position_ids[row], params.compress_ratio); + const float* scores_row = scores + row * count; + + float previous_score = 0.0f; + int previous_index = -1; + for (int rank = 0; rank < selected; ++rank) { + float best_value = 0.0f; + int best_index = -1; + ScanForNext(scores_row, 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; + } + if (threadIdx.x == 0 && static_cast(previous_index) < threshold) { + out_row[rank] = previous_index; + } + __syncthreads(); + } + } +} + +} // namespace + +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); +} + +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params) { + const size_t rows = static_cast(params.batch_size) * params.sequence_length; + return rows * std::max(params.total_sequence_length, 1) + rows; +} + +size_t GetCsaWorkspaceFloatCount(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.present_compressed_length, 1); +} + +template +Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentionIndexerParams& params, + const T* query, const T* key, const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, const bool* mask, + const T* past_key, int32_t* selected_indices, T* present_key, + float* float_workspace, int32_t* int_workspace) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + + // The present state is produced even when there is no query row to score, so that a zero-length step still + // forwards the incoming cache unchanged. + const int64_t present_key_elements = + static_cast(params.batch_size) * params.total_sequence_length * params.head_size; + if (present_key_elements > 0) { + ConcatPastKeyKernel<<>>(past_key, key, present_key, + params); + } + + if (rows == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + float* query_rotated = float_workspace; + float* block_scores = float_workspace + rows * params.num_heads * params.head_size; + int32_t* visible_indices = int_workspace; + int32_t* visible_count = int_workspace + rows * params.total_sequence_length; + + const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + + const int rotate_blocks = static_cast(std::min(rows * params.num_heads, kMaxGridDimX)); + RotateQueryKernel<<>>( + query, cos_cache, sin_cache, nullptr, query_rotated, params); + + const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); + CompactVisibleKernel<<>>( + mask, visible_indices, visible_count, params); + + if (params.max_block_count > 0) { + const int64_t block_work = rows * params.max_block_count; + const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); + QsaBlockScoreKernel<<>>( + present_key, key_norm_weight, cos_cache, sin_cache, query_rotated, visible_indices, visible_count, + block_scores, params); + } + + QsaSelectKernel<<>>( + block_scores, visible_indices, visible_count, selected_indices, params); + + return CUDA_CALL(cudaGetLastError()); +} + +template +Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentionIndexerParams& params, + const T* query, const T* key, const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, const T* gate, + const T* position_bias, const T* head_weights, + const int64_t* position_ids, const T* past_compressed_key, + const T* past_kv_buffer, const T* past_gate_buffer, + int32_t* selected_indices, T* present_compressed_key, + T* present_kv_buffer, T* present_gate_buffer, float* float_workspace) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + + // The present state is produced even when there is no query row to score, so that a zero-length step still + // forwards the incoming cache unchanged. + const int64_t past_compressed_elements = + static_cast(params.batch_size) * params.past_compressed_length * params.head_size; + if (past_compressed_elements > 0) { + CsaCopyPastCompressedKernel<<>>( + past_compressed_key, present_compressed_key, params); + } + + if (params.new_window_count > 0) { + const int compress_blocks = static_cast( + std::min(static_cast(params.batch_size) * params.new_window_count, kMaxGridDimX)); + CsaCompressKernel<<>>( + key, gate, past_kv_buffer, past_gate_buffer, position_bias, key_norm_weight, cos_cache, sin_cache, + present_compressed_key, params); + } + + const int64_t present_buffer_elements = + static_cast(params.batch_size) * params.present_buffer_length * 2 * params.head_size; + if (present_buffer_elements > 0) { + CsaCopyBufferKernel<<>>( + key, gate, past_kv_buffer, past_gate_buffer, present_kv_buffer, present_gate_buffer, params); + } + + if (rows == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + float* query_rotated = float_workspace; + float* scores = float_workspace + rows * params.num_heads * params.head_size; + + const int rotate_blocks = static_cast(std::min(rows * params.num_heads, kMaxGridDimX)); + RotateQueryKernel<<>>( + query, cos_cache, sin_cache, position_ids, query_rotated, params); + + if (params.present_compressed_length > 0) { + CsaScoreKernel<<>>( + query_rotated, present_compressed_key, head_weights, position_ids, scores, params); + } + + const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); + CsaSelectKernel<<>>( + scores, position_ids, selected_indices, params); + + return CUDA_CALL(cudaGetLastError()); +} + +#define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ + template Status LaunchQsaSparseAttentionIndexer(cudaStream_t, const SparseAttentionIndexerParams&, \ + const T*, const T*, const T*, const T*, const T*, \ + const bool*, const T*, int32_t*, T*, float*, int32_t*); \ + template Status LaunchCsaSparseAttentionIndexer( \ + cudaStream_t, const SparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, const T*, \ + const T*, const T*, const T*, const int64_t*, const T*, const T*, const T*, int32_t*, T*, T*, T*, float*); + +INSTANTIATE_SPARSE_ATTENTION_INDEXER(float) +INSTANTIATE_SPARSE_ATTENTION_INDEXER(half) +INSTANTIATE_SPARSE_ATTENTION_INDEXER(__nv_bfloat16) + +#undef INSTANTIATE_SPARSE_ATTENTION_INDEXER + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h new file mode 100644 index 0000000000000..e577208437d9a --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include + +#include "core/providers/cuda/cuda_common.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +// Everything the device code needs to know about a SparseAttentionIndexer call. All of it is +// derived from attributes and input shapes, so no device data is ever read on the host. +struct SparseAttentionIndexerParams { + int batch_size = 0; + int sequence_length = 0; + int num_heads = 0; + int head_size = 0; + int rotary_width = 0; // cos_cache.shape[2] + int max_rotary_length = 0; // cos_cache.shape[1] + int compress_ratio = 0; + int capacity = 0; // selected_indices.shape[2] + float epsilon = 1e-6f; + float scale = 0.0f; + + // policy_mode = "qsa" + int past_sequence_length = 0; + int total_sequence_length = 0; + int max_block_count = 0; // total_sequence_length / compress_ratio + int block_topk = 0; // token_budget / compress_ratio + + // policy_mode = "csa" + int past_compressed_length = 0; + int present_compressed_length = 0; + int past_buffer_length = 0; + int overlap_length = 0; + int new_window_count = 0; + int present_buffer_length = 0; + int present_buffer_start = 0; + int index_topk = 0; + float head_weight_scale = 0.0f; +}; + +// Scratch requirements, in elements. +size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params); +size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); + +template +Status LaunchQsaSparseAttentionIndexer( + cudaStream_t stream, + const SparseAttentionIndexerParams& params, + const T* query, + const T* key, + const T* key_norm_weight, + const T* cos_cache, + const T* sin_cache, + const bool* mask, + const T* past_key, + int32_t* selected_indices, + T* present_key, + float* float_workspace, + int32_t* int_workspace); + +template +Status LaunchCsaSparseAttentionIndexer( + cudaStream_t stream, + const SparseAttentionIndexerParams& params, + const T* query, + const T* key, + const T* key_norm_weight, + const T* cos_cache, + const T* sin_cache, + const T* gate, + const T* position_bias, + const T* head_weights, + const int64_t* position_ids, + const T* past_compressed_key, + const T* past_kv_buffer, + const T* past_gate_buffer, + int32_t* selected_indices, + T* present_compressed_key, + T* present_kv_buffer, + T* present_gate_buffer, + float* float_workspace); + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index faa9aca1f8f65..8066aaf118d03 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -11,6 +11,7 @@ #include "core/graph/contrib_ops/onnx_function_util.h" #include "core/graph/contrib_ops/shape_inference_functions.h" #include "contrib_ops/cpu/bert/attention_common.h" +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" // Suppress a warning: global initializer calls a non-constexpr function 'symbol' which is from // ONNX_OPERATOR_SET_SCHEMA_EX macro and only happens in debug build #if defined(_WIN32) && !defined(NDEBUG) @@ -1895,6 +1896,400 @@ ONNX_MS_OPERATOR_SET_SCHEMA( SparseAttentionTypeAndShapeInference(ctx, 3); })); +namespace sai = ::onnxruntime::contrib::sparse_attention_indexer; + +namespace { + +bool SparseAttentionIndexerHasInput(ONNX_NAMESPACE::InferenceContext& ctx, int index) { + return static_cast(index) < ctx.getNumInputs() && ctx.getInputType(index) != nullptr; +} + +// Copies a dimension (value or symbolic parameter) from an input shape into an output shape. +void SparseAttentionIndexerAppendDim(ONNX_NAMESPACE::TensorShapeProto& shape, + const ONNX_NAMESPACE::TensorShapeProto_Dimension& dim) { + *shape.add_dim() = dim; +} + +const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerShape(ONNX_NAMESPACE::InferenceContext& ctx, int index, + int expected_rank) { + if (!SparseAttentionIndexerHasInput(ctx, index) || !hasInputShape(ctx, index)) { + return nullptr; + } + const auto& shape = getInputShape(ctx, index); + if (shape.dim_size() != expected_rank) { + fail_shape_inference("SparseAttentionIndexer: input ", index, " must have rank ", expected_rank, + ", got rank ", shape.dim_size()); + } + return &shape; +} + +} // namespace + +void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx) { + const std::string policy_mode = getAttribute(ctx, "policy_mode", std::string()); + sai::Policy policy = sai::Policy::kQsa; + if (!sai::TryParsePolicy(policy_mode, policy)) { + fail_shape_inference("SparseAttentionIndexer: policy_mode must be 'qsa' or 'csa', got '", policy_mode, "'"); + } + const bool is_qsa = policy == sai::Policy::kQsa; + + const int64_t compress_ratio = getAttribute(ctx, "compress_ratio", static_cast(0)); + if (compress_ratio <= 0) { + fail_shape_inference("SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio); + } + + const int64_t token_budget = getAttribute(ctx, "token_budget", static_cast(0)); + const int64_t index_topk = getAttribute(ctx, "index_topk", static_cast(0)); + if (is_qsa) { + if (ctx.getAttribute("index_topk") != nullptr || ctx.getAttribute("head_weight_scale") != nullptr) { + fail_shape_inference( + "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); + } + if (token_budget <= 0 || token_budget % compress_ratio != 0) { + fail_shape_inference( + "SparseAttentionIndexer: policy_mode 'qsa' requires token_budget > 0 and divisible by " + "compress_ratio, got token_budget=", + token_budget, " compress_ratio=", compress_ratio); + } + } else { + if (ctx.getAttribute("token_budget") != nullptr) { + fail_shape_inference("SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); + } + if (index_topk <= 0) { + fail_shape_inference("SparseAttentionIndexer: policy_mode 'csa' requires index_topk > 0, got ", index_topk); + } + } + + // 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, sai::kPastKey}; + constexpr int kCsaOnlyInputs[] = {sai::kGate, sai::kPositionBias, sai::kHeadWeights, + sai::kPositionIds, sai::kPastCompressedKey, + sai::kPastKvBuffer, sai::kPastGateBuffer}; + for (int index = sai::kQuery; index <= sai::kSinCache; ++index) { + if (!SparseAttentionIndexerHasInput(ctx, index)) { + fail_shape_inference("SparseAttentionIndexer: input ", index, " 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'"); + } + } + for (int index : kCsaOnlyInputs) { + if (SparseAttentionIndexerHasInput(ctx, index) == is_qsa) { + fail_shape_inference("SparseAttentionIndexer: input ", index, + is_qsa ? " must be omitted when policy_mode is 'qsa'" + : " is required when policy_mode is 'csa'"); + } + } + + const size_t expected_outputs = is_qsa ? sai::kQsaOutputCount : sai::kCsaOutputCount; + if (ctx.getNumOutputs() != expected_outputs) { + fail_shape_inference("SparseAttentionIndexer: policy_mode '", policy_mode, "' requires exactly ", + expected_outputs, " declared outputs, got ", ctx.getNumOutputs()); + } + + updateOutputElemType(ctx, sai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); + + (void)SparseAttentionIndexerShape(ctx, sai::kKey, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kKeyNormWeight, 1); + (void)SparseAttentionIndexerShape(ctx, sai::kCosCache, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kSinCache, 3); + + const auto* query_shape = SparseAttentionIndexerShape(ctx, sai::kQuery, 4); + if (query_shape == nullptr) { + return; + } + const auto& batch_dim = query_shape->dim(0); + const auto& sequence_dim = query_shape->dim(1); + const auto& head_size_dim = query_shape->dim(3); + + const int64_t capacity = sai::SelectedCapacity(policy, token_budget, index_topk, compress_ratio); + ONNX_NAMESPACE::TensorShapeProto selected_shape; + SparseAttentionIndexerAppendDim(selected_shape, batch_dim); + SparseAttentionIndexerAppendDim(selected_shape, sequence_dim); + selected_shape.add_dim()->set_dim_value(capacity); + updateOutputShape(ctx, sai::kSelectedIndices, selected_shape); + + if (is_qsa) { + // ctx.getNumOutputs() == 2 was enforced above, so index 1 is in range. + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentKey); + const auto* past_key_shape = SparseAttentionIndexerShape(ctx, sai::kPastKey, 3); + if (past_key_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto present_shape; + SparseAttentionIndexerAppendDim(present_shape, batch_dim); + auto* total_dim = present_shape.add_dim(); + if (past_key_shape->dim(1).has_dim_value() && sequence_dim.has_dim_value()) { + total_dim->set_dim_value(past_key_shape->dim(1).dim_value() + sequence_dim.dim_value()); + } + SparseAttentionIndexerAppendDim(present_shape, head_size_dim); + updateOutputShape(ctx, sai::kPresentKey, present_shape); + } + return; + } + + // ctx.getNumOutputs() == 5 was enforced above, so indices 2, 3 and 4 are all in range. + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentCompressedKey); + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentKvBuffer); + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentGateBuffer); + + const auto* past_compressed_shape = SparseAttentionIndexerShape(ctx, sai::kPastCompressedKey, 3); + const auto* past_buffer_shape = SparseAttentionIndexerShape(ctx, sai::kPastKvBuffer, 3); + const auto* past_gate_shape = SparseAttentionIndexerShape(ctx, sai::kPastGateBuffer, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kGate, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kPositionBias, 2); + (void)SparseAttentionIndexerShape(ctx, sai::kHeadWeights, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kPositionIds, 2); + + if (past_buffer_shape != nullptr && past_gate_shape != nullptr) { + for (int axis = 0; axis < 3; ++axis) { + const auto& kv_dim = past_buffer_shape->dim(axis); + const auto& gate_dim = past_gate_shape->dim(axis); + if (kv_dim.has_dim_value() && gate_dim.has_dim_value() && kv_dim.dim_value() != gate_dim.dim_value()) { + fail_shape_inference( + "SparseAttentionIndexer: past_gate_buffer must have the same shape as past_kv_buffer, " + "but dimension ", + axis, " is ", gate_dim.dim_value(), " instead of ", kv_dim.dim_value()); + } + } + } + + sai::CsaWindowPlan plan; + const bool plan_known = past_buffer_shape != nullptr && past_buffer_shape->dim(1).has_dim_value() && + sequence_dim.has_dim_value() && + sai::TryComputeCsaWindowPlan(past_buffer_shape->dim(1).dim_value(), + sequence_dim.dim_value(), compress_ratio, plan); + if (past_buffer_shape != nullptr && past_buffer_shape->dim(1).has_dim_value() && + sequence_dim.has_dim_value() && !plan_known) { + fail_shape_inference( + "SparseAttentionIndexer: past_kv_buffer sequence length must be in [0, 2 * compress_ratio), got ", + past_buffer_shape->dim(1).dim_value()); + } + + if (past_compressed_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto present_shape; + SparseAttentionIndexerAppendDim(present_shape, batch_dim); + auto* entry_dim = present_shape.add_dim(); + if (plan_known && past_compressed_shape->dim(1).has_dim_value()) { + entry_dim->set_dim_value(past_compressed_shape->dim(1).dim_value() + plan.new_window_count); + } + SparseAttentionIndexerAppendDim(present_shape, head_size_dim); + updateOutputShape(ctx, sai::kPresentCompressedKey, present_shape); + } + + if (past_buffer_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto buffer_shape; + SparseAttentionIndexerAppendDim(buffer_shape, batch_dim); + auto* buffer_dim = buffer_shape.add_dim(); + if (plan_known) { + buffer_dim->set_dim_value(plan.present_buffer_length); + } + SparseAttentionIndexerAppendDim(buffer_shape, past_buffer_shape->dim(2)); + updateOutputShape(ctx, sai::kPresentKvBuffer, buffer_shape); + updateOutputShape(ctx, sai::kPresentGateBuffer, buffer_shape); + } +} + +constexpr const char* SparseAttentionIndexer_ver1_doc = R"DOC( +Selects, for every query token, the sparse-attention candidates that the following attention +operator is allowed to read. It covers the two indexer flavours used by recent sparse-attention +decoders, chosen with the policy_mode attribute: + + policy_mode = "qsa" ("query sparse attention" token indexer) + Groups the tokens that are visible to a query into complete blocks of compress_ratio tokens, + mean-pools the indexer keys of every block, normalizes and rotates the pooled key, scores it + against the query heads with sum_h ReLU(q_h . k), keeps the token_budget / compress_ratio + highest scoring blocks and emits the token indices of those blocks followed by the visible + tokens of the trailing incomplete block. + + policy_mode = "csa" ("compressed sparse attention" block indexer) + Compresses every compress_ratio consecutive tokens into one entry with a softmax-gated pooling + over a window of 2 * compress_ratio slots (the previous window contributes its "Ca" half and + the current window its "Cb" half), normalizes and rotates the entry, appends it to the + compressed-key state, scores the queries against every compressed entry with + sum_h w_h * ReLU(q_h . k), masks the entries a query may not attend to and emits the index_topk + highest scoring entry indices. + +Common contract: + * selected_indices is int32 with a fixed capacity that only depends on attributes: + token_budget + compress_ratio - 1 for "qsa" and index_topk for "csa". Unused entries are -1, + so no output size depends on the data and no device-to-host synchronization is required. + * All state is explicit in the graph. Nothing is cached inside the operator. + * Rotary embeddings reuse the precomputed cos_cache / sin_cache tables, which are indexed by + absolute key position. "qsa" applies the half-rotation of the model's (M)RoPE to the leading + rotary_dim = cos_cache.shape[2] channels. "csa" applies its trailing rotary to the last + 2 * cos_cache.shape[2] channels, with each cos/sin entry covering two consecutive channels. + * key_norm_weight is the effective RMSNorm multiplier. Models that store a zero-centered gamma + (the normalized value is multiplied by 1 + gamma) must fold the addition into this initializer. + * Accumulation, pooling, softmax, normalization and scoring are performed in float32 and the + result is rounded once to the tensor element type. + * Ties in the top-k selection are broken by the smaller entry index, and the emitted entries are + ordered by decreasing score, so the result is deterministic. + +State layout for policy_mode = "csa": past_kv_buffer / past_gate_buffer hold the tokens that have +not been folded into a compressed entry yet. When their length is >= compress_ratio, the first +compress_ratio tokens are the previous complete window (the "Ca" operand of the next window) and +the remainder is the current incomplete window; when it is < compress_ratio there is no previous +complete window and the whole buffer is the incomplete window. The length is therefore always in +[0, 2 * compress_ratio), and the number of compressed entries emitted by a call is known from the +input shapes alone. position_bias is re-applied to the buffered gates, so the buffers hold the raw +gate projection. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + SparseAttentionIndexer, 1, + OpSchema() + .SetDoc(SparseAttentionIndexer_ver1_doc) + .Attr("policy_mode", + "Indexer policy. Must be exactly 'qsa' (token indexer) or 'csa' (compressed block indexer).", + AttributeProto::STRING) + .Attr("compress_ratio", + "Number of consecutive tokens folded into one compressed block. Must be > 0.", + AttributeProto::INT) + .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'.", + AttributeProto::INT, + OPTIONAL_VALUE) + .Attr("index_topk", + "Only for policy_mode 'csa': number of compressed entries selected per query. Must be > 0. " + "Must be omitted when policy_mode is 'qsa'.", + AttributeProto::INT, + OPTIONAL_VALUE) + .Attr("epsilon", + "Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.", + AttributeProto::FLOAT, + 1.0e-6f) + .Attr("scale", + "Scale applied to the per-head ReLU scores. Default is 1/sqrt(head_size).", + AttributeProto::FLOAT, + OPTIONAL_VALUE) + .Attr("head_weight_scale", + "Only for policy_mode 'csa': scale applied to head_weights. Default is 1/sqrt(num_heads). " + "Must be omitted when policy_mode is 'qsa'.", + AttributeProto::FLOAT, + OPTIONAL_VALUE) + .Input(0, + "query", + "Indexer queries with shape (batch_size, sequence_length, num_heads, head_size), already " + "normalized but not yet rotated.", + "T") + .Input(1, + "key", + "Indexer key projection of the new tokens. Shape is (batch_size, sequence_length, head_size) " + "for policy_mode 'qsa' and (batch_size, sequence_length, 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") + .Input(2, + "key_norm_weight", + "Effective RMSNorm multiplier of the compressed keys, with shape (head_size).", + "T") + .Input(3, + "cos_cache", + "Cosine rotary table indexed by absolute key position, with shape " + "(batch_size, max_rotary_sequence_length, rotary_width).", + "T") + .Input(4, + "sin_cache", + "Sine rotary table with the same shape as cos_cache.", + "T") + .Input(5, + "mask", + "Only for policy_mode 'qsa': tokens visible to each query, with shape " + "(batch_size, 1, sequence_length, total_sequence_length) or " + "(batch_size, sequence_length, total_sequence_length). " + "total_sequence_length is past_sequence_length + sequence_length.", + "TB", + OpSchema::Optional) + .Input(6, + "past_key", + "Only for policy_mode 'qsa': cached indexer keys with shape " + "(batch_size, past_sequence_length, head_size).", + "T", + OpSchema::Optional) + .Input(7, + "gate", + "Only for policy_mode 'csa': gate projection of the new tokens with shape " + "(batch_size, sequence_length, 2 * head_size).", + "T", + OpSchema::Optional) + .Input(8, + "position_bias", + "Only for policy_mode 'csa': per-slot gate bias with shape (compress_ratio, 2 * head_size).", + "T", + OpSchema::Optional) + .Input(9, + "head_weights", + "Only for policy_mode 'csa': per-head score weights with shape " + "(batch_size, sequence_length, num_heads).", + "T", + OpSchema::Optional) + .Input(10, + "position_ids", + "Only for policy_mode 'csa': absolute position of every query with shape " + "(batch_size, sequence_length).", + "I", + OpSchema::Optional) + .Input(11, + "past_compressed_key", + "Only for policy_mode 'csa': compressed keys emitted by previous calls, with shape " + "(batch_size, past_compressed_length, head_size).", + "T", + OpSchema::Optional) + .Input(12, + "past_kv_buffer", + "Only for policy_mode 'csa': buffered key projections with shape " + "(batch_size, buffer_length, 2 * head_size), where buffer_length is in [0, 2 * compress_ratio).", + "T", + OpSchema::Optional) + .Input(13, + "past_gate_buffer", + "Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.", + "T", + OpSchema::Optional) + .Output(0, + "selected_indices", + "Selected entries with shape (batch_size, sequence_length, capacity). capacity is " + "token_budget + compress_ratio - 1 for policy_mode 'qsa', where the values are token indices " + "into the key cache, and index_topk for policy_mode 'csa', where the values are compressed " + "entry indices. Unused entries are -1.", + "M") + .Output(1, + "present_key", + "Only for policy_mode 'qsa': past_key concatenated with key, with shape " + "(batch_size, total_sequence_length, head_size).", + "T", + OpSchema::Optional) + .Output(2, + "present_compressed_key", + "Only for policy_mode 'csa': past_compressed_key concatenated with the entries emitted by " + "this call, with shape (batch_size, present_compressed_length, head_size).", + "T", + OpSchema::Optional) + .Output(3, + "present_kv_buffer", + "Only for policy_mode 'csa': updated key buffer with shape " + "(batch_size, present_buffer_length, 2 * head_size).", + "T", + OpSchema::Optional) + .Output(4, + "present_gate_buffer", + "Only for policy_mode 'csa': updated gate buffer with the same shape as present_kv_buffer.", + "T", + OpSchema::Optional) + .TypeConstraint("T", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain floating point tensors to float, float16 and bfloat16.") + .TypeConstraint("TB", {"tensor(bool)"}, "Constrain the visibility mask to boolean tensors.") + .TypeConstraint("I", {"tensor(int64)"}, "Constrain position ids to 64-bit integer tensors.") + .TypeConstraint("M", {"tensor(int32)"}, "Constrain selected indices to 32-bit integer tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + SparseAttentionIndexerTypeAndShapeInference(ctx); + })); + constexpr const char* Longformer_Attention_doc = R"DOC( Longformer Self Attention with a local context and a global context. Tokens attend locally: Each token attends to its W previous tokens and W succeeding tokens with W being the window length. A selected few tokens diff --git a/onnxruntime/core/graph/contrib_ops/ms_opset.h b/onnxruntime/core/graph/contrib_ops/ms_opset.h index 50e421b90125e..c607b7f81cb7a 100644 --- a/onnxruntime/core/graph/contrib_ops/ms_opset.h +++ b/onnxruntime/core/graph/contrib_ops/ms_opset.h @@ -116,6 +116,7 @@ class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SkipGroupNorm); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SkipLayerNormalization); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SkipSimplifiedLayerNormalization); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SparseAttention); +class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SparseAttentionIndexer); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, SparseToDenseMatMul); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, Tokenizer); class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Microsoft, 1, TorchEmbedding); @@ -242,6 +243,7 @@ class OpSet_Microsoft_ver1 { fn(GetOpSchema()); fn(GetOpSchema()); fn(GetOpSchema()); + fn(GetOpSchema()); fn(GetOpSchema()); fn(GetOpSchema()); fn(GetOpSchema()); diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index 1f40c0e729f40..ed2a384f30401 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -237,6 +237,7 @@ def __init__(self, int_max, auto_merge, guess_output_rank, verbose, prefix=""): "SkipLayerNormalization": self._infer_SkipLayerNormalization, "SkipSimplifiedLayerNormalization": self._infer_SkipLayerNormalization, "SparseAttention": self._infer_SparseAttention, + "SparseAttentionIndexer": self._infer_SparseAttentionIndexer, "UnfoldTensor": self._infer_UnfoldTensor, } self.aten_op_dispatcher_ = { @@ -496,6 +497,7 @@ def _onnx_infer_single_node(self, node): "SkipLayerNormalization", "SkipSimplifiedLayerNormalization", "SparseAttention", + "SparseAttentionIndexer", "SkipGroupNorm", "QLinearAdd", "QLinearMul", @@ -2626,6 +2628,75 @@ def _infer_GroupQueryAttention(self, node): # noqa: N802 def _infer_SparseAttention(self, node): # noqa: N802 self._infer_GroupQueryAttention(node) + def _infer_SparseAttentionIndexer(self, node): # noqa: N802 + policy_mode = get_attribute(node, "policy_mode", b"") + if isinstance(policy_mode, bytes): + policy_mode = policy_mode.decode("utf-8") + compress_ratio = get_attribute(node, "compress_ratio", 0) + query_shape = self._get_sympy_shape(node, 0) + output_dtype = self.known_vi_[node.input[0]].type.tensor_type.elem_type + + if policy_mode == "qsa": + capacity = get_attribute(node, "token_budget", 0) + compress_ratio - 1 + else: + capacity = get_attribute(node, "index_topk", 0) + + vi = self.known_vi_[node.output[0]] + vi.CopyFrom( + helper.make_tensor_value_info( + node.output[0], + onnx.TensorProto.INT32, + get_shape_from_sympy_shape([*query_shape[:2], capacity]), + ) + ) + + def set_output(index, sympy_shape): + if index >= len(node.output) or not node.output[index]: + return + out_vi = self.known_vi_[node.output[index]] + out_vi.CopyFrom( + helper.make_tensor_value_info(node.output[index], output_dtype, get_shape_from_sympy_shape(sympy_shape)) + ) + + def past_shape(index): + if index >= len(node.input) or not node.input[index]: + return None + return self._get_sympy_shape(node, index) + + if policy_mode == "qsa": + past_key_shape = past_shape(6) + past_length = past_key_shape[1] if past_key_shape else 0 + set_output(1, [query_shape[0], past_length + query_shape[1], query_shape[3]]) + return + + past_compressed_shape = past_shape(11) + past_buffer_shape = past_shape(12) + if past_compressed_shape is None or past_buffer_shape is None: + return + + buffer_length = past_buffer_shape[1] + sequence_length = query_shape[1] + + # The number of compressed entries emitted by this call is known as soon as the buffer and + # query lengths are; otherwise fall back to fresh symbolic dimensions. + if compress_ratio > 0 and is_literal(buffer_length) and is_literal(sequence_length): + buffer_length = int(buffer_length) + sequence_length = int(sequence_length) + overlap_length = compress_ratio if buffer_length >= compress_ratio else 0 + pending = buffer_length - overlap_length + sequence_length + new_window_count = pending // compress_ratio + present_buffer_length = ( + compress_ratio + pending % compress_ratio if new_window_count > 0 else buffer_length + sequence_length + ) + present_compressed_length = past_compressed_shape[1] + new_window_count + else: + present_compressed_length = self._new_symbolic_dim_from_output(node, 2, 1) + present_buffer_length = self._new_symbolic_dim_from_output(node, 3, 1) + + set_output(2, [query_shape[0], present_compressed_length, query_shape[3]]) + set_output(3, [query_shape[0], present_buffer_length, past_buffer_shape[2]]) + set_output(4, [query_shape[0], present_buffer_length, past_buffer_shape[2]]) + def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) if len(node.output) > 1: @@ -2875,6 +2946,9 @@ def get_prereq(node): # Skip symbolic shape inference for RotaryEmbedding functions that have extraneous outputs # generated by `export_modules_as_functions` continue + if not node.output[i_o]: + # A missing optional output is declared with an empty name and has no value info. + continue vi = self.known_vi_[node.output[i_o]] out_type = vi.type diff --git a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc new file mode 100644 index 0000000000000..ef8aadf3428ca --- /dev/null +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -0,0 +1,927 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Coverage for com.microsoft.SparseAttentionIndexer. +// +// The "ShapeInference" suite drives Graph::Resolve() directly, so it runs in every build: it pins +// the fixed selected_indices capacity, the policy-specific state outputs and the strict policy +// validation. Cases that are expected to fail shape inference call fail_shape_inference, which +// aborts in ORT_NO_EXCEPTIONS builds, so they are compiled out there. +// +// The numeric suite needs the CUDA execution provider (the operator has no CPU kernel) and is +// skipped when it is unavailable. Expectations come from a float reference in this file that +// mirrors the operator contract; the inputs are rounded to the tested element type first so the +// reference sees exactly what the kernel reads. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" + +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" +#include "core/graph/constants.h" +#include "core/graph/model.h" +#include "test/common/tensor_op_test_utils.h" +#include "test/providers/provider_test_utils.h" +#include "test/test_environment.h" +#include "test/unittest_util/graph_transform_test_builder.h" +#include "test/util/include/asserts.h" +#include "test/util/include/default_providers.h" + +namespace onnxruntime { +namespace test { + +namespace sai = ::onnxruntime::contrib::sparse_attention_indexer; + +namespace { + +constexpr int kOnnxOpsetVersion = 17; + +// --------------------------------------------------------------------------------------------- +// Shape inference helpers +// --------------------------------------------------------------------------------------------- + +Status BuildAndResolve(const std::function& add_node, + std::unique_ptr& model) { + std::unordered_map domain_to_version; + domain_to_version[kOnnxDomain] = kOnnxOpsetVersion; + domain_to_version[kMSDomain] = 1; + + model = std::unique_ptr(new Model("sparse_attention_indexer", /*is_onnx_domain_only=*/false, ModelMetaData(), + PathString(), IOnnxRuntimeOpSchemaRegistryList(), domain_to_version, {}, + DefaultLoggingManager().DefaultLogger())); + + ModelTestBuilder builder(model->MainGraph()); + add_node(builder); + builder.SetGraphOutputs(); + return model->MainGraph().Resolve(); +} + +void ExpectShape(const Graph& graph, const std::string& name, ONNX_NAMESPACE::TensorProto_DataType elem_type, + const std::vector& expected) { + const NodeArg* arg = graph.GetNodeArg(name); + ASSERT_NE(arg, nullptr); + const ONNX_NAMESPACE::TypeProto* type = arg->TypeAsProto(); + ASSERT_NE(type, nullptr); + ASSERT_TRUE(type->has_tensor_type()); + EXPECT_EQ(type->tensor_type().elem_type(), static_cast(elem_type)); + const ONNX_NAMESPACE::TensorShapeProto& shape = type->tensor_type().shape(); + ASSERT_EQ(shape.dim_size(), static_cast(expected.size())); + for (int i = 0; i < shape.dim_size(); ++i) { + ASSERT_TRUE(shape.dim(i).has_dim_value()) << "dimension " << i << " of " << name << " is not static"; + EXPECT_EQ(shape.dim(i).dim_value(), expected[static_cast(i)]) << "dimension " << i << " of " << name; + } +} + +struct QsaGraphOptions { + int64_t batch_size = 2; + int64_t sequence_length = 3; + int64_t num_heads = 2; + int64_t head_size = 8; + int64_t past_sequence_length = 4; + int64_t rotary_width = 8; + int64_t compress_ratio = 2; + int64_t token_budget = 4; + bool add_index_topk = false; + bool add_csa_inputs = false; + std::string policy_mode = sai::kPolicyModeQsa; +}; + +// Builds a "qsa" node whose csa-only input slots are left empty, as the schema requires. +void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { + const int64_t total = options.past_sequence_length + options.sequence_length; + NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); + std::vector inputs{ + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, options.num_heads, + options.head_size}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, 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, 1, options.sequence_length, total}), + builder.MakeInput(std::vector{options.batch_size, options.past_sequence_length, + options.head_size}), + }; + if (options.add_csa_inputs) { + inputs.push_back(builder.MakeInput( + std::vector{options.batch_size, options.sequence_length, 2 * options.head_size})); + } else { + inputs.push_back(&empty); + } + for (int slot = sai::kPositionBias; slot < sai::kInputCount; ++slot) { + inputs.push_back(&empty); + } + + std::vector outputs{builder.MakeOutput(), builder.MakeOutput()}; + Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); + node.AddAttribute("policy_mode", options.policy_mode); + node.AddAttribute("compress_ratio", options.compress_ratio); + node.AddAttribute("token_budget", options.token_budget); + if (options.add_index_topk) { + node.AddAttribute("index_topk", static_cast(4)); + } +} + +struct CsaGraphOptions { + int64_t batch_size = 2; + int64_t sequence_length = 5; + int64_t num_heads = 2; + int64_t head_size = 8; + int64_t rotary_width = 4; + int64_t compress_ratio = 4; + int64_t index_topk = 3; + int64_t past_compressed_length = 6; + int64_t past_buffer_length = 5; + int64_t output_count = sai::kCsaOutputCount; + bool add_token_budget = false; +}; + +void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { + const int64_t width = 2 * options.head_size; + NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); + std::vector inputs{ + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, options.num_heads, + options.head_size}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, width}), + 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}), + &empty, + &empty, + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, width}), + builder.MakeInput(std::vector{options.compress_ratio, width}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length, options.num_heads}), + builder.MakeInput(std::vector{options.batch_size, options.sequence_length}), + builder.MakeInput(std::vector{options.batch_size, options.past_compressed_length, + options.head_size}), + builder.MakeInput(std::vector{options.batch_size, options.past_buffer_length, width}), + builder.MakeInput(std::vector{options.batch_size, options.past_buffer_length, width}), + }; + + std::vector outputs{builder.MakeOutput()}; + for (int64_t slot = 1; slot < options.output_count; ++slot) { + outputs.push_back(slot == sai::kPresentKey ? &empty : builder.MakeOutput()); + } + + Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); + node.AddAttribute("policy_mode", std::string(sai::kPolicyModeCsa)); + node.AddAttribute("compress_ratio", options.compress_ratio); + node.AddAttribute("index_topk", options.index_topk); + if (options.add_token_budget) { + node.AddAttribute("token_budget", static_cast(8)); + } +} + +// --------------------------------------------------------------------------------------------- +// Numeric reference +// --------------------------------------------------------------------------------------------- + +// Deterministic values in [-1, 1]; distinct phases keep the per-block scores well separated so the +// selection order does not depend on rounding. +std::vector MakeWave(size_t count, float phase, float step) { + std::vector values(count); + for (size_t i = 0; i < count; ++i) { + values[i] = std::sin(phase + step * static_cast(i)); + } + return values; +} + +template +std::vector ToElementType(const std::vector& data) { + if constexpr (std::is_same_v) { + return ToFloat16(data); + } else if constexpr (std::is_same_v) { + return ToBFloat16(data); + } else { + return data; + } +} + +// Rounds through the tested element type so the reference consumes exactly the kernel's inputs. +template +std::vector RoundTrip(const std::vector& data) { + if constexpr (std::is_same_v) { + return data; + } else { + std::vector converted = ToElementType(data); + std::vector result(data.size()); + for (size_t i = 0; i < data.size(); ++i) { + result[i] = converted[i].ToFloat(); + } + return result; + } +} + +// Split-half rotary over the leading rotary_width channels. +std::vector LeadingRope(const std::vector& value, int rotary_width, const float* cos_row, + const float* sin_row) { + const int head_size = static_cast(value.size()); + const int half = rotary_width / 2; + std::vector result(value); + for (int d = 0; d < rotary_width && d < head_size; ++d) { + const float paired = (d < half) ? -value[static_cast(d + half)] : value[static_cast(d - half)]; + result[static_cast(d)] = value[static_cast(d)] * cos_row[d] + paired * sin_row[d]; + } + return result; +} + +// Interleaved rotary over the trailing 2 * rotary_width channels. +std::vector TrailingRope(const std::vector& value, int rotary_width, const float* cos_row, + const float* sin_row) { + const int head_size = static_cast(value.size()); + const int base = head_size - 2 * rotary_width; + std::vector result(value); + for (int d = base; d < head_size; ++d) { + const int offset = d - base; + const float paired = ((offset & 1) == 0) ? -value[static_cast(d + 1)] : value[static_cast(d - 1)]; + result[static_cast(d)] = + value[static_cast(d)] * cos_row[offset >> 1] + paired * sin_row[offset >> 1]; + } + return result; +} + +std::vector RmsNormalize(const std::vector& value, const std::vector& weight, float epsilon) { + float sum_squares = 0.0f; + for (float element : value) { + sum_squares += element * element; + } + const float inverse_rms = 1.0f / std::sqrt(sum_squares / static_cast(value.size()) + epsilon); + std::vector result(value.size()); + for (size_t d = 0; d < value.size(); ++d) { + result[d] = value[d] * inverse_rms * weight[d]; + } + return result; +} + +// Order used by both selection kernels: score descending, then entry index ascending. +std::vector RankByScore(const std::vector& scores, int count) { + std::vector order(static_cast(count)); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), [&scores](int left, int right) { + if (scores[static_cast(left)] != scores[static_cast(right)]) { + return scores[static_cast(left)] > scores[static_cast(right)]; + } + return left < right; + }); + return order; +} + +struct QsaProblem { + int batch_size = 1; + int sequence_length = 2; + int num_heads = 2; + int head_size = 4; + int past_sequence_length = 3; + int rotary_width = 4; + int compress_ratio = 2; + int token_budget = 4; + float epsilon = 1.0e-6f; + + std::vector query; + std::vector key; + std::vector key_norm_weight; + std::vector cos_cache; + std::vector sin_cache; + std::vector mask; + std::vector past_key; + + int TotalSequenceLength() const { return past_sequence_length + sequence_length; } + int MaxRotaryLength() const { return TotalSequenceLength(); } + int Capacity() const { return token_budget + compress_ratio - 1; } +}; + +void QsaReference(const QsaProblem& problem, std::vector& selected, std::vector& present_key) { + const int total = problem.TotalSequenceLength(); + const int head_size = problem.head_size; + const int capacity = problem.Capacity(); + const int block_topk = problem.token_budget / problem.compress_ratio; + const float scale = 1.0f / std::sqrt(static_cast(head_size)); + + present_key.assign(static_cast(problem.batch_size) * total * head_size, 0.0f); + for (int b = 0; b < problem.batch_size; ++b) { + for (int t = 0; t < total; ++t) { + for (int d = 0; d < head_size; ++d) { + present_key[(static_cast(b) * total + t) * head_size + d] = + t < problem.past_sequence_length + ? problem.past_key[(static_cast(b) * problem.past_sequence_length + t) * head_size + d] + : problem.key[(static_cast(b) * problem.sequence_length + t - problem.past_sequence_length) * + head_size + + d]; + } + } + } + + 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; + for (int s = 0; s < problem.sequence_length; ++s) { + const size_t row = static_cast(b) * problem.sequence_length + s; + + std::vector> rotated_query(static_cast(problem.num_heads)); + const int query_position = problem.past_sequence_length + s; + for (int h = 0; h < problem.num_heads; ++h) { + const size_t base = (row * problem.num_heads + h) * head_size; + std::vector head(problem.query.begin() + base, problem.query.begin() + base + head_size); + rotated_query[static_cast(h)] = + LeadingRope(head, problem.rotary_width, cos_base + query_position * problem.rotary_width, + sin_base + query_position * problem.rotary_width); + } + + std::vector visible; + for (int t = 0; t < total; ++t) { + if (problem.mask[row * total + t] != 0) { + visible.push_back(t); + } + } + const int block_count = static_cast(visible.size()) / problem.compress_ratio; + + std::vector scores(static_cast(block_count), 0.0f); + for (int block = 0; block < block_count; ++block) { + std::vector pooled(static_cast(head_size), 0.0f); + for (int t = 0; t < problem.compress_ratio; ++t) { + const int token = visible[static_cast(block * problem.compress_ratio + t)]; + for (int d = 0; d < head_size; ++d) { + pooled[static_cast(d)] += + present_key[(static_cast(b) * total + token) * head_size + d]; + } + } + for (float& element : pooled) { + element /= static_cast(problem.compress_ratio); + } + pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); + const int key_position = visible[static_cast(block * problem.compress_ratio)]; + pooled = LeadingRope(pooled, problem.rotary_width, cos_base + key_position * problem.rotary_width, + sin_base + key_position * problem.rotary_width); + + float score = 0.0f; + for (int h = 0; h < problem.num_heads; ++h) { + float dot = 0.0f; + for (int d = 0; d < head_size; ++d) { + dot += rotated_query[static_cast(h)][static_cast(d)] * pooled[static_cast(d)]; + } + score += std::max(dot, 0.0f); + } + scores[static_cast(block)] = score * scale; + } + + const std::vector order = RankByScore(scores, block_count); + const int emitted = std::min(block_topk, block_count); + int32_t* out_row = selected.data() + row * capacity; + for (int rank = 0; rank < emitted; ++rank) { + for (int t = 0; t < problem.compress_ratio; ++t) { + out_row[rank * problem.compress_ratio + t] = + visible[static_cast(order[static_cast(rank)] * problem.compress_ratio + t)]; + } + } + const int tail_start = block_count * problem.compress_ratio; + for (size_t t = static_cast(tail_start); t < visible.size(); ++t) { + out_row[emitted * problem.compress_ratio + static_cast(t) - tail_start] = visible[t]; + } + } + } +} + +struct CsaProblem { + int batch_size = 1; + int sequence_length = 3; + int num_heads = 2; + int head_size = 4; + int rotary_width = 2; + int compress_ratio = 2; + int index_topk = 2; + int past_compressed_length = 1; + int past_buffer_length = 3; + int max_rotary_length = 5; + float epsilon = 1.0e-6f; + + std::vector query; + std::vector key; + std::vector key_norm_weight; + std::vector cos_cache; + std::vector sin_cache; + std::vector gate; + std::vector position_bias; + std::vector head_weights; + std::vector position_ids; + std::vector past_compressed_key; + std::vector past_kv_buffer; + std::vector past_gate_buffer; + + int Width() const { return 2 * head_size; } +}; + +// Value of channel `channel` of token `position` of the virtual sequence [past buffer | new tokens]. +float ExtendedValue(const CsaProblem& problem, const std::vector& past, const std::vector& current, + int batch, int position, int channel) { + const int width = problem.Width(); + if (position < problem.past_buffer_length) { + return past[(static_cast(batch) * problem.past_buffer_length + position) * width + channel]; + } + return current[(static_cast(batch) * problem.sequence_length + position - problem.past_buffer_length) * + width + + channel]; +} + +void CsaReference(const CsaProblem& problem, std::vector& selected, + std::vector& present_compressed_key, std::vector& present_kv_buffer, + std::vector& present_gate_buffer) { + sai::CsaWindowPlan plan; + ASSERT_TRUE(sai::TryComputeCsaWindowPlan(problem.past_buffer_length, problem.sequence_length, + problem.compress_ratio, plan)); + + const int head_size = problem.head_size; + const int width = problem.Width(); + const int present_compressed_length = + problem.past_compressed_length + static_cast(plan.new_window_count); + const int present_buffer_length = static_cast(plan.present_buffer_length); + const float scale = 1.0f / std::sqrt(static_cast(head_size)); + const float head_weight_scale = 1.0f / std::sqrt(static_cast(problem.num_heads)); + + present_compressed_key.assign( + static_cast(problem.batch_size) * present_compressed_length * head_size, 0.0f); + present_kv_buffer.assign(static_cast(problem.batch_size) * present_buffer_length * width, 0.0f); + present_gate_buffer.assign(present_kv_buffer.size(), 0.0f); + selected.assign(static_cast(problem.batch_size) * problem.sequence_length * problem.index_topk, -1); + + for (int b = 0; b < problem.batch_size; ++b) { + for (int entry = 0; entry < problem.past_compressed_length; ++entry) { + for (int d = 0; d < head_size; ++d) { + present_compressed_key[(static_cast(b) * present_compressed_length + entry) * head_size + d] = + problem.past_compressed_key[(static_cast(b) * problem.past_compressed_length + entry) * head_size + + d]; + } + } + + const float* cos_base = + problem.cos_cache.data() + static_cast(b) * 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; + + for (int window = 0; window < plan.new_window_count; ++window) { + const bool has_previous = window >= 1 || plan.overlap_length >= problem.compress_ratio; + const int previous_base = static_cast(plan.overlap_length) + (window - 1) * problem.compress_ratio; + const int current_base = static_cast(plan.overlap_length) + window * problem.compress_ratio; + + std::vector pooled(static_cast(head_size), 0.0f); + for (int d = 0; d < head_size; ++d) { + std::vector logits; + std::vector values; + if (has_previous) { + for (int slot = 0; slot < problem.compress_ratio; ++slot) { + logits.push_back( + ExtendedValue(problem, problem.past_gate_buffer, problem.gate, b, previous_base + slot, d) + + problem.position_bias[static_cast(slot) * width + d]); + values.push_back( + ExtendedValue(problem, problem.past_kv_buffer, problem.key, b, previous_base + slot, d)); + } + } + for (int slot = 0; slot < problem.compress_ratio; ++slot) { + logits.push_back( + ExtendedValue(problem, problem.past_gate_buffer, problem.gate, b, current_base + slot, head_size + d) + + problem.position_bias[static_cast(slot) * width + head_size + d]); + values.push_back( + ExtendedValue(problem, problem.past_kv_buffer, problem.key, b, current_base + slot, head_size + d)); + } + + const float max_logit = *std::max_element(logits.begin(), logits.end()); + float denominator = 0.0f; + float accumulator = 0.0f; + for (size_t slot = 0; slot < logits.size(); ++slot) { + const float weight = std::exp(logits[slot] - max_logit); + denominator += weight; + accumulator += weight * values[slot]; + } + pooled[static_cast(d)] = accumulator / denominator; + } + + pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); + const int entry = problem.past_compressed_length + window; + const int position = std::min(entry * problem.compress_ratio, problem.max_rotary_length - 1); + pooled = TrailingRope(pooled, problem.rotary_width, cos_base + position * problem.rotary_width, + sin_base + position * problem.rotary_width); + for (int d = 0; d < head_size; ++d) { + present_compressed_key[(static_cast(b) * present_compressed_length + entry) * head_size + d] = + pooled[static_cast(d)]; + } + } + + for (int token = 0; token < present_buffer_length; ++token) { + const int source = static_cast(plan.present_buffer_start) + token; + for (int channel = 0; channel < width; ++channel) { + const size_t index = (static_cast(b) * present_buffer_length + token) * width + channel; + present_kv_buffer[index] = ExtendedValue(problem, problem.past_kv_buffer, problem.key, b, source, channel); + present_gate_buffer[index] = ExtendedValue(problem, problem.past_gate_buffer, problem.gate, b, source, channel); + } + } + + for (int s = 0; s < problem.sequence_length; ++s) { + const size_t row = static_cast(b) * problem.sequence_length + s; + const int64_t position = problem.position_ids[row]; + const int query_position = static_cast( + std::min(std::max(position, 0), problem.max_rotary_length - 1)); + + std::vector> rotated_query(static_cast(problem.num_heads)); + for (int h = 0; h < problem.num_heads; ++h) { + const size_t base = (row * problem.num_heads + h) * head_size; + std::vector head(problem.query.begin() + base, problem.query.begin() + base + head_size); + rotated_query[static_cast(h)] = + TrailingRope(head, problem.rotary_width, cos_base + query_position * problem.rotary_width, + sin_base + query_position * problem.rotary_width); + } + + const int64_t threshold = position < 0 ? 0 : (position + 1) / problem.compress_ratio; + std::vector scores(static_cast(present_compressed_length), 0.0f); + for (int entry = 0; entry < present_compressed_length; ++entry) { + if (static_cast(entry) >= threshold) { + scores[static_cast(entry)] = -std::numeric_limits::infinity(); + continue; + } + float total_score = 0.0f; + for (int h = 0; h < problem.num_heads; ++h) { + float dot = 0.0f; + for (int d = 0; d < head_size; ++d) { + dot += rotated_query[static_cast(h)][static_cast(d)] * + present_compressed_key[(static_cast(b) * present_compressed_length + entry) * head_size + d]; + } + total_score += std::max(dot, 0.0f) * problem.head_weights[row * problem.num_heads + h]; + } + scores[static_cast(entry)] = total_score * scale * head_weight_scale; + } + + const std::vector order = RankByScore(scores, present_compressed_length); + const int emitted = std::min(problem.index_topk, present_compressed_length); + int32_t* out_row = selected.data() + row * problem.index_topk; + for (int rank = 0; rank < emitted; ++rank) { + const int entry = order[static_cast(rank)]; + out_row[rank] = static_cast(entry) < threshold ? entry : -1; + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// Numeric runners +// --------------------------------------------------------------------------------------------- + +bool HasCudaProvider() { return DefaultCudaExecutionProvider() != nullptr; } + +void RunOnCuda(OpTester& test) { + std::vector> providers; + providers.push_back(DefaultCudaExecutionProvider()); + test.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); +} + +QsaProblem MakeQsaProblem() { + QsaProblem problem; + const int total = problem.TotalSequenceLength(); + problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * + problem.head_size, + 0.35f, 0.41f); + problem.key = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.head_size, + 1.10f, 0.29f); + 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); + problem.past_key = MakeWave( + static_cast(problem.batch_size) * problem.past_sequence_length * 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) * problem.sequence_length * total, 0); + for (int b = 0; b < problem.batch_size; ++b) { + for (int s = 0; s < problem.sequence_length; ++s) { + const size_t row = static_cast(b) * problem.sequence_length + s; + const int visible = problem.past_sequence_length + s + 1; + for (int t = 0; t < visible; ++t) { + problem.mask[row * total + t] = 1; + } + } + } + return problem; +} + +template +void RunQsaTest(float tolerance) { + if (!HasCudaProvider()) { + GTEST_SKIP() << "CUDA execution provider is not available"; + } + + QsaProblem problem = MakeQsaProblem(); + problem.query = RoundTrip(problem.query); + problem.key = RoundTrip(problem.key); + problem.key_norm_weight = RoundTrip(problem.key_norm_weight); + problem.cos_cache = RoundTrip(problem.cos_cache); + problem.sin_cache = RoundTrip(problem.sin_cache); + problem.past_key = RoundTrip(problem.past_key); + + std::vector selected; + std::vector present_key; + QsaReference(problem, selected, present_key); + + const int64_t batch_size = problem.batch_size; + const int64_t sequence_length = problem.sequence_length; + const int64_t total = problem.TotalSequenceLength(); + const int64_t head_size = problem.head_size; + + std::unique_ptr mask(new bool[problem.mask.size()]); + for (size_t i = 0; i < problem.mask.size(); ++i) { + mask[i] = problem.mask[i] != 0; + } + + OpTester test("SparseAttentionIndexer", 1, onnxruntime::kMSDomain); + test.AddAttribute("policy_mode", std::string(sai::kPolicyModeQsa)); + test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); + test.AddAttribute("token_budget", static_cast(problem.token_budget)); + test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, + ToElementType(problem.query)); + test.AddInput("key", {batch_size, sequence_length, head_size}, ToElementType(problem.key)); + 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, 1, sequence_length, total}, mask.get(), problem.mask.size()); + test.AddInput("past_key", {batch_size, problem.past_sequence_length, head_size}, + ToElementType(problem.past_key)); + test.AddOutput("selected_indices", {batch_size, sequence_length, problem.Capacity()}, selected); + test.AddOutput("present_key", {batch_size, total, head_size}, ToElementType(present_key), false, 0.0f, + tolerance); + RunOnCuda(test); +} + +CsaProblem MakeCsaProblem() { + CsaProblem problem; + const int width = problem.Width(); + problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * + problem.head_size, + 0.25f, 0.37f); + problem.key = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * width, 0.60f, 0.21f); + problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.45f, 0.31f); + problem.cos_cache = + MakeWave(static_cast(problem.batch_size) * 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); + 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( + static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads, 1.30f, 0.47f); + problem.past_compressed_key = MakeWave( + static_cast(problem.batch_size) * problem.past_compressed_length * problem.head_size, 0.50f, 0.39f); + problem.past_kv_buffer = + MakeWave(static_cast(problem.batch_size) * problem.past_buffer_length * width, 0.95f, 0.18f); + problem.past_gate_buffer = + MakeWave(static_cast(problem.batch_size) * problem.past_buffer_length * width, 1.45f, 0.22f); + + problem.position_ids.assign(static_cast(problem.batch_size) * problem.sequence_length, 0); + for (int b = 0; b < problem.batch_size; ++b) { + for (int s = 0; s < problem.sequence_length; ++s) { + problem.position_ids[static_cast(b) * problem.sequence_length + s] = 2 + s; + } + } + return problem; +} + +template +void RunCsaTest(const CsaProblem& base, float tolerance) { + if (!HasCudaProvider()) { + GTEST_SKIP() << "CUDA execution provider is not available"; + } + + CsaProblem problem = base; + problem.query = RoundTrip(problem.query); + problem.key = RoundTrip(problem.key); + problem.key_norm_weight = RoundTrip(problem.key_norm_weight); + problem.cos_cache = RoundTrip(problem.cos_cache); + problem.sin_cache = RoundTrip(problem.sin_cache); + problem.gate = RoundTrip(problem.gate); + problem.position_bias = RoundTrip(problem.position_bias); + problem.head_weights = RoundTrip(problem.head_weights); + problem.past_compressed_key = RoundTrip(problem.past_compressed_key); + problem.past_kv_buffer = RoundTrip(problem.past_kv_buffer); + problem.past_gate_buffer = RoundTrip(problem.past_gate_buffer); + + std::vector selected; + std::vector present_compressed_key; + std::vector present_kv_buffer; + std::vector present_gate_buffer; + CsaReference(problem, selected, present_compressed_key, present_kv_buffer, present_gate_buffer); + ASSERT_FALSE(::testing::Test::HasFatalFailure()); + + sai::CsaWindowPlan plan; + ASSERT_TRUE(sai::TryComputeCsaWindowPlan(problem.past_buffer_length, problem.sequence_length, + problem.compress_ratio, plan)); + const int64_t batch_size = problem.batch_size; + const int64_t sequence_length = problem.sequence_length; + const int64_t head_size = problem.head_size; + const int64_t width = problem.Width(); + const int64_t present_compressed_length = problem.past_compressed_length + plan.new_window_count; + + OpTester test("SparseAttentionIndexer", 1, onnxruntime::kMSDomain); + test.AddAttribute("policy_mode", std::string(sai::kPolicyModeCsa)); + test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); + test.AddAttribute("index_topk", static_cast(problem.index_topk)); + test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, + ToElementType(problem.query)); + test.AddInput("key", {batch_size, sequence_length, width}, ToElementType(problem.key)); + 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)); + test.AddOptionalInputEdge(); + test.AddOptionalInputEdge(); + test.AddInput("gate", {batch_size, sequence_length, width}, ToElementType(problem.gate)); + test.AddInput("position_bias", {problem.compress_ratio, width}, ToElementType(problem.position_bias)); + test.AddInput("head_weights", {batch_size, sequence_length, problem.num_heads}, + ToElementType(problem.head_weights)); + test.AddInput("position_ids", {batch_size, sequence_length}, problem.position_ids); + test.AddInput("past_compressed_key", {batch_size, problem.past_compressed_length, head_size}, + ToElementType(problem.past_compressed_key)); + test.AddInput("past_kv_buffer", {batch_size, problem.past_buffer_length, width}, + ToElementType(problem.past_kv_buffer)); + test.AddInput("past_gate_buffer", {batch_size, problem.past_buffer_length, width}, + ToElementType(problem.past_gate_buffer)); + + test.AddOutput("selected_indices", {batch_size, sequence_length, problem.index_topk}, selected); + test.AddOptionalOutputEdge(); + test.AddOutput("present_compressed_key", {batch_size, present_compressed_length, head_size}, + ToElementType(present_compressed_key), false, 0.0f, tolerance); + test.AddOutput("present_kv_buffer", {batch_size, plan.present_buffer_length, width}, + ToElementType(present_kv_buffer), false, 0.0f, tolerance); + test.AddOutput("present_gate_buffer", {batch_size, plan.present_buffer_length, width}, + ToElementType(present_gate_buffer), false, 0.0f, tolerance); + RunOnCuda(test); +} + +// A call whose tokens do not close a window: the buffer only grows and the compressed state is +// unchanged, so the queries score against the entries produced by earlier calls. +CsaProblem MakeCsaBufferOnlyProblem() { + CsaProblem problem; + problem.sequence_length = 1; + problem.num_heads = 1; + problem.compress_ratio = 4; + problem.past_compressed_length = 2; + problem.past_buffer_length = 2; + problem.max_rotary_length = 9; + const int width = problem.Width(); + + problem.query = MakeWave(static_cast(problem.num_heads) * problem.head_size, 0.31f, 0.43f); + problem.key = MakeWave(static_cast(problem.sequence_length) * width, 0.66f, 0.25f); + problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.41f, 0.35f); + problem.cos_cache = MakeWave(static_cast(problem.max_rotary_length) * problem.rotary_width, 0.12f, 0.29f); + problem.sin_cache = MakeWave(static_cast(problem.max_rotary_length) * problem.rotary_width, 1.02f, 0.36f); + problem.gate = MakeWave(static_cast(problem.sequence_length) * width, 0.84f, 0.26f); + problem.position_bias = MakeWave(static_cast(problem.compress_ratio) * width, 0.37f, 0.13f); + problem.head_weights = MakeWave(static_cast(problem.num_heads), 1.20f, 0.51f); + problem.past_compressed_key = + MakeWave(static_cast(problem.past_compressed_length) * problem.head_size, 0.52f, 0.41f); + problem.past_kv_buffer = MakeWave(static_cast(problem.past_buffer_length) * width, 0.97f, 0.20f); + problem.past_gate_buffer = MakeWave(static_cast(problem.past_buffer_length) * width, 1.48f, 0.24f); + problem.position_ids = {8}; + return problem; +} + +} // namespace + +// --------------------------------------------------------------------------------------------- +// Shape inference +// --------------------------------------------------------------------------------------------- + +TEST(SparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndPresentKey) { + QsaGraphOptions options; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + const int64_t capacity = options.token_budget + options.compress_ratio - 1; + ExpectShape(graph, node.OutputDefs()[sai::kSelectedIndices]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.batch_size, options.sequence_length, capacity}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentKey]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.past_sequence_length + options.sequence_length, options.head_size}); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { + CsaGraphOptions options; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, model)); + + // buffer_length 5 with compress_ratio 4 means one complete window is buffered and one token is + // pending, so the five new tokens close exactly one window and leave two pending. + sai::CsaWindowPlan plan; + ASSERT_TRUE(sai::TryComputeCsaWindowPlan(options.past_buffer_length, options.sequence_length, + options.compress_ratio, plan)); + ASSERT_EQ(plan.new_window_count, 1); + ASSERT_EQ(plan.present_buffer_length, 6); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + ExpectShape(graph, node.OutputDefs()[sai::kSelectedIndices]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.batch_size, options.sequence_length, options.index_topk}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentCompressedKey]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.past_compressed_length + plan.new_window_count, options.head_size}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentKvBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, plan.present_buffer_length, 2 * options.head_size}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentGateBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, plan.present_buffer_length, 2 * options.head_size}); +} + +#ifndef ORT_NO_EXCEPTIONS + +namespace { + +void ExpectResolveFailure(const std::function& add_node, + const std::string& expected_message) { + std::unique_ptr model; + const Status status = BuildAndResolve(add_node, model); + ASSERT_FALSE(status.IsOK()) << "expected shape inference to reject the node"; + EXPECT_NE(status.ErrorMessage().find(expected_message), std::string::npos) + << "actual message: " << status.ErrorMessage(); +} + +} // namespace + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsUnknownPolicyMode) { + QsaGraphOptions options; + options.policy_mode = "qsa_v2"; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "policy_mode must be 'qsa' or 'csa'"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaAttribute) { + QsaGraphOptions options; + options.add_index_topk = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "index_topk and head_weight_scale must not be set"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { + QsaGraphOptions options; + options.token_budget = 5; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "requires token_budget > 0 and divisible by compress_ratio"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { + QsaGraphOptions options; + options.add_csa_inputs = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "must be omitted when policy_mode is 'qsa'"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithQsaAttribute) { + CsaGraphOptions options; + options.add_token_budget = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "token_budget must not be set when policy_mode is 'csa'"); +} + +// A "csa" node must declare every state output. Rejecting the node before any output is written +// keeps inference from touching an output index the node does not have. +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithMissingStateOutputs) { + CsaGraphOptions options; + options.output_count = 3; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "requires exactly 5 declared outputs"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedCsaBuffer) { + CsaGraphOptions options; + options.past_buffer_length = 2 * options.compress_ratio; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "past_kv_buffer sequence length must be in [0, 2 * compress_ratio)"); +} + +#endif // ORT_NO_EXCEPTIONS + +// --------------------------------------------------------------------------------------------- +// Numeric behaviour (CUDA only) +// --------------------------------------------------------------------------------------------- + +TEST(SparseAttentionIndexerTest, QsaFloat) { RunQsaTest(1.0e-5f); } + +TEST(SparseAttentionIndexerTest, QsaFloat16) { RunQsaTest(2.0e-3f); } + +TEST(SparseAttentionIndexerTest, QsaBFloat16) { RunQsaTest(2.0e-2f); } + +TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } + +TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } + +TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProblem(), 3.0e-2f); } + +TEST(SparseAttentionIndexerTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f); } + +} // namespace test +} // namespace onnxruntime From b5e7985325113bf7e5b7fc4564a3fdd5b2c94b14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:12:36 +0000 Subject: [PATCH 06/16] Harden SparseAttentionIndexer validation and tests Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse_attention_indexer.md | 6 ++--- .../cuda/sparse/sparse_attention_indexer.cc | 2 ++ .../core/graph/contrib_ops/bert_defs.cc | 4 +++ .../sparse_attention_indexer_op_test.cc | 27 ++++++++++++++----- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index c3c2b422957a6..4a51d8859a349 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -246,8 +246,8 @@ DeepSeek's `DeepseekV4RMSNorm` uses a plain `weight *`, so its tensor is passed ## 7. CUDA Kernel Pipeline All kernels use a fixed block of 128 threads (a power of two, required by the shared-memory block -reductions). Grid sizes are clamped to 65535 blocks and the elementwise kernels use grid-stride -loops, so no launch configuration depends on tensor data. +reductions). Elementwise kernels clamp the grid to 65535 blocks and use grid-stride loops; kernels +that assign one block to each work item clamp the grid to the CUDA `gridDim.x` limit. ### `qsa` @@ -267,7 +267,7 @@ loops, so no launch configuration depends on tensor data. | 2 | `CsaCompressKernel` | one block per `(b, window)`; per-channel softmax over `2r` slots | | 3 | `CsaCopyBufferKernel` | element | | 4 | `RotateQueryKernel` | one block per `(b, s, h)` | -| 5 | `CsaScoreKernel` | one block per `(b, s, entry)` | +| 5 | `CsaScoreKernel` | one thread per `(b, s, entry)` | | 6 | `CsaSelectKernel` | one block per `(b, s)`; iterated block arg-max | ### Workspaces diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 5a3d84eacb7a6..c7955310afd57 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -122,6 +122,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { const int64_t sequence_length = query_shape[1]; const int64_t num_heads = query_shape[2]; const int64_t head_size = query_shape[3]; + ORT_RETURN_IF_NOT(num_heads > 0, "SparseAttentionIndexer: num_heads must be > 0, got ", num_heads); const auto& cos_shape = cos_cache->Shape(); ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, @@ -226,6 +227,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { const int64_t sequence_length = query_shape[1]; const int64_t num_heads = query_shape[2]; const int64_t head_size = query_shape[3]; + ORT_RETURN_IF_NOT(num_heads > 0, "SparseAttentionIndexer: num_heads must be > 0, got ", num_heads); const int64_t width = 2 * head_size; const auto& cos_shape = cos_cache->Shape(); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 8066aaf118d03..4d5e1a4b5aa34 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2005,7 +2005,11 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex } const auto& batch_dim = query_shape->dim(0); const auto& sequence_dim = query_shape->dim(1); + const auto& num_heads_dim = query_shape->dim(2); const auto& head_size_dim = query_shape->dim(3); + if (num_heads_dim.has_dim_value() && num_heads_dim.dim_value() <= 0) { + fail_shape_inference("SparseAttentionIndexer: num_heads must be > 0, got ", num_heads_dim.dim_value()); + } const int64_t capacity = sai::SelectedCapacity(policy, token_budget, index_topk, compress_ratio); ONNX_NAMESPACE::TensorShapeProto selected_shape; 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 ef8aadf3428ca..e18f7594cb392 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -275,7 +275,7 @@ std::vector RankByScore(const std::vector& scores, int count) { } struct QsaProblem { - int batch_size = 1; + int batch_size = 2; int sequence_length = 2; int num_heads = 2; int head_size = 4; @@ -393,7 +393,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std } struct CsaProblem { - int batch_size = 1; + int batch_size = 2; int sequence_length = 3; int num_heads = 2; int head_size = 4; @@ -582,8 +582,7 @@ void RunOnCuda(OpTester& test) { test.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); } -QsaProblem MakeQsaProblem() { - QsaProblem problem; +QsaProblem MakeQsaProblem(QsaProblem problem = {}) { const int total = problem.TotalSequenceLength(); problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * problem.head_size, @@ -611,12 +610,11 @@ QsaProblem MakeQsaProblem() { } template -void RunQsaTest(float tolerance) { +void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { if (!HasCudaProvider()) { GTEST_SKIP() << "CUDA execution provider is not available"; } - QsaProblem problem = MakeQsaProblem(); problem.query = RoundTrip(problem.query); problem.key = RoundTrip(problem.key); problem.key_norm_weight = RoundTrip(problem.key_norm_weight); @@ -767,6 +765,7 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { // unchanged, so the queries score against the entries produced by earlier calls. CsaProblem MakeCsaBufferOnlyProblem() { CsaProblem problem; + problem.batch_size = 1; problem.sequence_length = 1; problem.num_heads = 1; problem.compress_ratio = 4; @@ -866,6 +865,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaAttribute) { "index_topk and head_weight_scale must not be set"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { + QsaGraphOptions options; + options.num_heads = 0; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "num_heads must be > 0"); +} + TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { QsaGraphOptions options; options.token_budget = 5; @@ -915,6 +921,15 @@ TEST(SparseAttentionIndexerTest, QsaFloat16) { RunQsaTest(2.0e-3f); } TEST(SparseAttentionIndexerTest, QsaBFloat16) { RunQsaTest(2.0e-2f); } +TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { + QsaProblem problem; + problem.batch_size = 1; + problem.head_size = 192; + problem.past_sequence_length = 200; + problem.rotary_width = 4; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } From 213b7316947c7c6d08ec0495fbce9b83c9523f67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:16:08 +0000 Subject: [PATCH 07/16] Address SparseAttentionIndexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse/sparse_attention_indexer.cc | 27 ++-- .../cuda/sparse/sparse_attention_indexer.h | 6 +- .../sparse/sparse_attention_indexer_impl.cu | 4 +- .../core/graph/contrib_ops/bert_defs.cc | 17 +- .../sparse_attention_indexer_op_test.cc | 61 +++++++- ...untime_test_python_symbolic_shape_infer.py | 145 ++++++++++++++++++ 6 files changed, 232 insertions(+), 28 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index c7955310afd57..8ac96c38d69b1 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -4,6 +4,7 @@ #include "contrib_ops/cuda/sparse/sparse_attention_indexer.h" #include +#include #include #include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" @@ -59,32 +60,36 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : Cu ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), "SparseAttentionIndexer: compress_ratio is required"); - ORT_ENFORCE(compress_ratio_ > 0, "SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio_); + ORT_ENFORCE(compress_ratio_ > 0 && compress_ratio_ <= std::numeric_limits::max(), + "SparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", compress_ratio_); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); float head_weight_scale = 0.0f; - const bool has_head_weight_scale = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); + has_head_weight_scale_ = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); if (policy_ == sai::Policy::kQsa) { ORT_ENFORCE(has_token_budget, "SparseAttentionIndexer: token_budget is required when policy_mode is 'qsa'"); - ORT_ENFORCE(!has_index_topk && !has_head_weight_scale, + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale_, "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); - ORT_ENFORCE(token_budget_ > 0 && token_budget_ % compress_ratio_ == 0, - "SparseAttentionIndexer: token_budget must be > 0 and divisible by compress_ratio, got token_budget=", + ORT_ENFORCE(token_budget_ > 0 && token_budget_ % compress_ratio_ == 0 && + token_budget_ <= std::numeric_limits::max() - compress_ratio_ + 1, + "SparseAttentionIndexer: token_budget must be > 0, divisible by compress_ratio, and produce a " + "selected capacity no greater than INT_MAX, got token_budget=", token_budget_, " compress_ratio=", compress_ratio_); index_topk_ = 0; } else { ORT_ENFORCE(has_index_topk, "SparseAttentionIndexer: index_topk is required when policy_mode is 'csa'"); ORT_ENFORCE(!has_token_budget, "SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); - ORT_ENFORCE(index_topk_ > 0, "SparseAttentionIndexer: index_topk must be > 0, got ", index_topk_); + ORT_ENFORCE(index_topk_ > 0 && index_topk_ <= std::numeric_limits::max(), + "SparseAttentionIndexer: index_topk must be in (0, INT_MAX], got ", index_topk_); token_budget_ = 0; } epsilon_ = info.GetAttrOrDefault("epsilon", 1.0e-6f); ORT_ENFORCE(epsilon_ >= 0.0f, "SparseAttentionIndexer: epsilon must be >= 0, got ", epsilon_); - scale_ = info.GetAttrOrDefault("scale", 0.0f); - head_weight_scale_ = has_head_weight_scale ? head_weight_scale : 0.0f; + has_scale_ = info.GetAttr("scale", &scale_).IsOK(); + head_weight_scale_ = head_weight_scale; } template @@ -171,7 +176,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_)); params.epsilon = epsilon_; - params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.past_sequence_length = static_cast(past_sequence_length); params.total_sequence_length = static_cast(total_sequence_length); params.max_block_count = static_cast(total_sequence_length / compress_ratio_); @@ -287,7 +292,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_)); params.epsilon = epsilon_; - params.scale = scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.past_compressed_length = static_cast(past_compressed_length); params.present_compressed_length = static_cast(present_compressed_length); params.past_buffer_length = static_cast(past_buffer_length); @@ -297,7 +302,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.present_buffer_start = static_cast(plan.present_buffer_start); params.index_topk = static_cast(index_topk_); params.head_weight_scale = - head_weight_scale_ != 0.0f ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads)); + has_head_weight_scale_ ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads)); Tensor* selected_indices = context->Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, params.capacity})); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h index 0abbb6064a3a4..2976340232762 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.h @@ -26,8 +26,10 @@ class SparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel { int64_t token_budget_; int64_t index_topk_; float epsilon_; - float scale_; // 0 means "derive from head_size" - float head_weight_scale_; // 0 means "derive from num_heads" + float scale_; + float head_weight_scale_; + bool has_scale_; + bool has_head_weight_scale_; }; } // namespace cuda 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 ea05afcd7886e..ffc453754231b 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -123,7 +123,7 @@ __device__ __forceinline__ float TrailingRope(const float* value, int head_size, // Highest compressed entry a query at `position` may attend to, matching (position + 1) // ratio. __device__ __forceinline__ int64_t CausalThreshold(int64_t position, int compress_ratio) { - return position < 0 ? 0 : (position + 1) / compress_ratio; + return position < 0 ? 0 : position / compress_ratio + (position % compress_ratio == compress_ratio - 1); } __device__ __forceinline__ int ClampPosition(int64_t position, int max_rotary_length) { @@ -668,7 +668,7 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio past_compressed_key, present_compressed_key, params); } - if (params.new_window_count > 0) { + if (params.batch_size > 0 && params.new_window_count > 0) { const int compress_blocks = static_cast( std::min(static_cast(params.batch_size) * params.new_window_count, kMaxGridDimX)); CsaCompressKernel<<>>( diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 4d5e1a4b5aa34..ae311e6b6be5d 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include +#include #include #include @@ -1934,8 +1935,8 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex const bool is_qsa = policy == sai::Policy::kQsa; const int64_t compress_ratio = getAttribute(ctx, "compress_ratio", static_cast(0)); - if (compress_ratio <= 0) { - fail_shape_inference("SparseAttentionIndexer: compress_ratio must be > 0, got ", compress_ratio); + if (compress_ratio <= 0 || compress_ratio > std::numeric_limits::max()) { + fail_shape_inference("SparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", compress_ratio); } const int64_t token_budget = getAttribute(ctx, "token_budget", static_cast(0)); @@ -1945,18 +1946,20 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex fail_shape_inference( "SparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is 'qsa'"); } - if (token_budget <= 0 || token_budget % compress_ratio != 0) { + if (token_budget <= 0 || token_budget % compress_ratio != 0 || + token_budget > std::numeric_limits::max() - compress_ratio + 1) { fail_shape_inference( - "SparseAttentionIndexer: policy_mode 'qsa' requires token_budget > 0 and divisible by " - "compress_ratio, got token_budget=", + "SparseAttentionIndexer: policy_mode 'qsa' requires token_budget > 0, divisible by " + "compress_ratio, and a selected capacity no greater than INT_MAX, got token_budget=", token_budget, " compress_ratio=", compress_ratio); } } else { if (ctx.getAttribute("token_budget") != nullptr) { fail_shape_inference("SparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); } - if (index_topk <= 0) { - fail_shape_inference("SparseAttentionIndexer: policy_mode 'csa' requires index_topk > 0, got ", index_topk); + if (index_topk <= 0 || index_topk > std::numeric_limits::max()) { + fail_shape_inference("SparseAttentionIndexer: policy_mode 'csa' requires index_topk in (0, INT_MAX], got ", + index_topk); } } 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 e18f7594cb392..6c1668be5e72b 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -284,6 +285,7 @@ struct QsaProblem { int compress_ratio = 2; int token_budget = 4; float epsilon = 1.0e-6f; + std::optional scale; std::vector query; std::vector key; @@ -303,7 +305,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std const int head_size = problem.head_size; const int capacity = problem.Capacity(); const int block_topk = problem.token_budget / problem.compress_ratio; - const float scale = 1.0f / std::sqrt(static_cast(head_size)); + const float scale = problem.scale.value_or(1.0f / std::sqrt(static_cast(head_size))); present_key.assign(static_cast(problem.batch_size) * total * head_size, 0.0f); for (int b = 0; b < problem.batch_size; ++b) { @@ -404,6 +406,8 @@ struct CsaProblem { int past_buffer_length = 3; int max_rotary_length = 5; float epsilon = 1.0e-6f; + std::optional scale; + std::optional head_weight_scale; std::vector query; std::vector key; @@ -445,8 +449,9 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, const int present_compressed_length = problem.past_compressed_length + static_cast(plan.new_window_count); const int present_buffer_length = static_cast(plan.present_buffer_length); - const float scale = 1.0f / std::sqrt(static_cast(head_size)); - const float head_weight_scale = 1.0f / std::sqrt(static_cast(problem.num_heads)); + const float scale = problem.scale.value_or(1.0f / std::sqrt(static_cast(head_size))); + const float head_weight_scale = + problem.head_weight_scale.value_or(1.0f / std::sqrt(static_cast(problem.num_heads))); present_compressed_key.assign( static_cast(problem.batch_size) * present_compressed_length * head_size, 0.0f); @@ -540,7 +545,10 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, sin_base + query_position * problem.rotary_width); } - const int64_t threshold = position < 0 ? 0 : (position + 1) / problem.compress_ratio; + const int64_t threshold = + position < 0 ? 0 + : position / problem.compress_ratio + + (position % problem.compress_ratio == problem.compress_ratio - 1); std::vector scores(static_cast(present_compressed_length), 0.0f); for (int entry = 0; entry < present_compressed_length; ++entry) { if (static_cast(entry) >= threshold) { @@ -640,6 +648,9 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { test.AddAttribute("policy_mode", std::string(sai::kPolicyModeQsa)); test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); test.AddAttribute("token_budget", static_cast(problem.token_budget)); + if (problem.scale.has_value()) { + test.AddAttribute("scale", *problem.scale); + } test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, ToElementType(problem.query)); test.AddInput("key", {batch_size, sequence_length, head_size}, ToElementType(problem.key)); @@ -655,8 +666,7 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { RunOnCuda(test); } -CsaProblem MakeCsaProblem() { - CsaProblem problem; +CsaProblem MakeCsaProblem(CsaProblem problem = {}) { const int width = problem.Width(); problem.query = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.num_heads * problem.head_size, @@ -728,6 +738,12 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { test.AddAttribute("policy_mode", std::string(sai::kPolicyModeCsa)); test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); test.AddAttribute("index_topk", static_cast(problem.index_topk)); + if (problem.scale.has_value()) { + test.AddAttribute("scale", *problem.scale); + } + if (problem.head_weight_scale.has_value()) { + test.AddAttribute("head_weight_scale", *problem.head_weight_scale); + } test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, ToElementType(problem.query)); test.AddInput("key", {batch_size, sequence_length, width}, ToElementType(problem.key)); @@ -879,6 +895,14 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisible "requires token_budget > 0 and divisible by compress_ratio"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedQsaCapacity) { + QsaGraphOptions options; + options.compress_ratio = 2; + options.token_budget = std::numeric_limits::max() - 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "selected capacity no greater than INT_MAX"); +} + TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { QsaGraphOptions options; options.add_csa_inputs = true; @@ -930,6 +954,12 @@ TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { + QsaProblem problem = MakeQsaProblem(); + problem.scale = 0.0f; + RunQsaTest(1.0e-5f, std::move(problem)); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } @@ -938,5 +968,24 @@ TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProb TEST(SparseAttentionIndexerTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f); } +TEST(SparseAttentionIndexerTest, CsaExplicitZeroScales) { + CsaProblem problem = MakeCsaProblem(); + problem.scale = 0.0f; + problem.head_weight_scale = 0.0f; + RunCsaTest(problem, 1.0e-5f); +} + +TEST(SparseAttentionIndexerTest, CsaInt64MaxPosition) { + CsaProblem problem = MakeCsaProblem(); + problem.position_ids[0] = std::numeric_limits::max(); + RunCsaTest(problem, 1.0e-5f); +} + +TEST(SparseAttentionIndexerTest, CsaEmptyBatch) { + CsaProblem problem; + problem.batch_size = 0; + RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f); +} + } // namespace test } // namespace onnxruntime 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 0fdad07556db9..b3ac87e07284b 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -124,6 +124,151 @@ def _check_shapes(self, graph, inferred_graph, vis): # type: (GraphProto, Graph assert vi == inferred_vi, f"\n{vi}\n{inferred_vi}\n" raise AssertionError() + def _infer_sparse_attention_indexer(self, node, inputs): + outputs = [ + helper.make_tensor_value_info(name, TensorProto.UNDEFINED, None) + for name in node.output + if name + ] + graph = helper.make_graph([node], "SparseAttentionIndexer_Test", inputs, outputs) + model = helper.make_model( + graph, + opset_imports=[helper.make_opsetid("", 17), helper.make_opsetid("com.microsoft", 1)], + ) + return SymbolicShapeInference.infer_shapes(model, auto_merge=True) + + @staticmethod + def _tensor_shape(value_info): + return [ + dimension.dim_param if dimension.dim_param else dimension.dim_value + for dimension in value_info.type.tensor_type.shape.dim + ] + + def test_sparse_attention_indexer_qsa(self): + node = helper.make_node( + "SparseAttentionIndexer", + ["query", "key", "key_norm_weight", "cos_cache", "sin_cache", "mask", "past_key"], + ["selected_indices", "present_key"], + domain="com.microsoft", + policy_mode="qsa", + compress_ratio=4, + token_budget=8, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["batch", "sequence", 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["batch", "sequence", 8]), + helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), + helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, ["batch", "total", 8]), + helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT16, ["batch", "total", 8]), + helper.make_tensor_value_info("mask", TensorProto.BOOL, ["batch", 1, "sequence", "total"]), + helper.make_tensor_value_info("past_key", TensorProto.FLOAT16, ["batch", "past", 8]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(self._tensor_shape(outputs["selected_indices"]), ["batch", "sequence", 11]) + self.assertEqual(outputs["selected_indices"].type.tensor_type.elem_type, TensorProto.INT32) + self.assertEqual(self._tensor_shape(outputs["present_key"]), ["batch", "past + sequence", 8]) + self.assertEqual(outputs["present_key"].type.tensor_type.elem_type, TensorProto.FLOAT16) + + def test_sparse_attention_indexer_csa_static_with_empty_output(self): + node = helper.make_node( + "SparseAttentionIndexer", + [ + "query", + "key", + "key_norm_weight", + "cos_cache", + "sin_cache", + "", + "", + "gate", + "position_bias", + "head_weights", + "position_ids", + "past_compressed_key", + "past_kv_buffer", + "past_gate_buffer", + ], + ["selected_indices", "", "present_compressed_key", "present_kv_buffer", "present_gate_buffer"], + domain="com.microsoft", + policy_mode="csa", + compress_ratio=4, + index_topk=3, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT, [2, 5, 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT, [8]), + helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT, [2, 64, 4]), + helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT, [2, 64, 4]), + helper.make_tensor_value_info("gate", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("position_bias", TensorProto.FLOAT, [4, 16]), + helper.make_tensor_value_info("head_weights", TensorProto.FLOAT, [2, 5, 2]), + helper.make_tensor_value_info("position_ids", TensorProto.INT64, [2, 5]), + helper.make_tensor_value_info("past_compressed_key", TensorProto.FLOAT, [2, 6, 8]), + helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("past_gate_buffer", TensorProto.FLOAT, [2, 5, 16]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(list(inferred.graph.node[0].output), list(node.output)) + self.assertEqual(self._tensor_shape(outputs["selected_indices"]), [2, 5, 3]) + self.assertEqual(self._tensor_shape(outputs["present_compressed_key"]), [2, 7, 8]) + self.assertEqual(self._tensor_shape(outputs["present_kv_buffer"]), [2, 6, 16]) + self.assertEqual(self._tensor_shape(outputs["present_gate_buffer"]), [2, 6, 16]) + + def test_sparse_attention_indexer_csa_symbolic_fallback(self): + node = helper.make_node( + "SparseAttentionIndexer", + [ + "query", + "key", + "key_norm_weight", + "cos_cache", + "sin_cache", + "", + "", + "gate", + "position_bias", + "head_weights", + "position_ids", + "past_compressed_key", + "past_kv_buffer", + "past_gate_buffer", + ], + ["selected_indices", "", "present_compressed_key", "present_kv_buffer", "present_gate_buffer"], + domain="com.microsoft", + policy_mode="csa", + compress_ratio=4, + index_topk=3, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT, ["batch", "sequence", 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT, ["batch", "sequence", 16]), + helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT, [8]), + helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT, ["batch", 64, 4]), + helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT, ["batch", 64, 4]), + helper.make_tensor_value_info("gate", TensorProto.FLOAT, ["batch", "sequence", 16]), + helper.make_tensor_value_info("position_bias", TensorProto.FLOAT, [4, 16]), + helper.make_tensor_value_info("head_weights", TensorProto.FLOAT, ["batch", "sequence", 2]), + helper.make_tensor_value_info("position_ids", TensorProto.INT64, ["batch", "sequence"]), + helper.make_tensor_value_info("past_compressed_key", TensorProto.FLOAT, ["batch", "compressed", 8]), + helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT, ["batch", "buffer", 16]), + helper.make_tensor_value_info("past_gate_buffer", TensorProto.FLOAT, ["batch", "buffer", 16]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + compressed_shape = self._tensor_shape(outputs["present_compressed_key"]) + buffer_shape = self._tensor_shape(outputs["present_kv_buffer"]) + self.assertEqual(compressed_shape[::2], ["batch", 8]) + self.assertEqual(buffer_shape[::2], ["batch", 16]) + self.assertTrue(compressed_shape[1].startswith("SparseAttentionIndexer_")) + self.assertTrue(buffer_shape[1].startswith("SparseAttentionIndexer_")) + self.assertEqual(self._tensor_shape(outputs["present_gate_buffer"]), buffer_shape) + def test_unsqueeze_opset_11(self): graph = helper.make_graph( [ From f33028717edb4501e56e7b284879c2909677d7dc Mon Sep 17 00:00:00 2001 From: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:31:15 -0700 Subject: [PATCH 08/16] Update onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../python/onnxruntime_test_python_symbolic_shape_infer.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 b3ac87e07284b..9986fe62895ef 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -125,11 +125,7 @@ def _check_shapes(self, graph, inferred_graph, vis): # type: (GraphProto, Graph raise AssertionError() def _infer_sparse_attention_indexer(self, node, inputs): - outputs = [ - helper.make_tensor_value_info(name, TensorProto.UNDEFINED, None) - for name in node.output - if name - ] + outputs = [helper.make_tensor_value_info(name, TensorProto.UNDEFINED, None) for name in node.output if name] graph = helper.make_graph([node], "SparseAttentionIndexer_Test", inputs, outputs) model = helper.make_model( graph, From f74c55132fe05fc2e054dc99f71fd5a595c15760 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:51:57 +0000 Subject: [PATCH 09/16] Fix WebGPU SparseAttentionIndexer review issues Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../webgpu/bert/sparse_attention_indexer.cc | 35 ++++++++++++------- .../webgpu/bert/sparse_attention_indexer.h | 2 ++ .../sparse_attention_indexer_op_test.cc | 19 ++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 7e95aae61c274..929ce4524c68b 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -423,6 +423,21 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& const auto& selected = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); shader.AdditionalImplementation() + << "fn clamped_position(row: u32, limit: u32) -> u32 {\n" + << " let raw = " << position_ids.GetByOffset("row", true) << ";\n" + << " if ((raw.y & 0x80000000u) != 0u) { return 0u; }\n" + << " if (raw.y != 0u || raw.x > limit) { return limit; }\n" + << " return raw.x;\n" + << "}\n" + << "fn visible_entry_count(row: u32, count: u32) -> u32 {\n" + << " let raw = " << position_ids.GetByOffset("row", true) << ";\n" + << " if ((raw.y & 0x80000000u) != 0u) { return 0u; }\n" + << " if (raw.y != 0u) { return count; }\n" + << " let quotient = raw.x / uniforms.compress_ratio;\n" + << " if (quotient >= count) { return count; }\n" + << " let increment = select(0u, 1u, raw.x % uniforms.compress_ratio == uniforms.compress_ratio - 1u);\n" + << " return min(count, quotient + increment);\n" + << "}\n" << "fn query_value(row: u32, head: u32, d: u32) -> f32 {\n" << " let base = (row * uniforms.num_heads + head) * uniforms.head_size;\n" << " var value = f32(" << query.GetByOffset("base + d") << ");\n" @@ -433,8 +448,7 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let sign = select(1.0, -1.0, (offset & 1u) == 0u);\n" << " let paired = sign * f32(" << query.GetByOffset("base + pair_d") << ");\n" << " let batch = row / uniforms.sequence_length;\n" - << " let raw_position = max(" << position_ids.GetByOffset("row") << ", 0);\n" - << " let position = min(u32(raw_position), uniforms.max_rotary_length - 1u);\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" << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" << sin_cache.GetByOffset("cache") << ");\n" @@ -463,9 +477,8 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " for (var i = 0u; i < uniforms.capacity; i++) {\n" << " " << selected.SetByOffset("output_base + i", "-1") << "\n" << " }\n" - << " let position = " << position_ids.GetByOffset("row") << ";\n" - << " let threshold = select(0u, u32(position + 1) / uniforms.compress_ratio, position >= 0);\n" << " let count = uniforms.present_compressed_length;\n" + << " let threshold = visible_entry_count(row, count);\n" << " let ranks = min(uniforms.capacity, count);\n" << " var previous_score = 0.0;\n" << " var previous_index = -1i;\n" @@ -503,12 +516,12 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : WebGp const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); - float head_weight_scale = 0.0f; - const bool has_head_weight_scale = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); + has_scale_ = info.GetAttr("scale", &scale_).IsOK(); + has_head_weight_scale_ = info.GetAttr("head_weight_scale", &head_weight_scale_).IsOK(); if (policy_ == sai::Policy::kQsa) { ORT_ENFORCE(has_token_budget && token_budget_ > 0 && token_budget_ % compress_ratio_ == 0, "SparseAttentionIndexer: token_budget must be > 0 and divisible by compress_ratio for qsa"); - ORT_ENFORCE(!has_index_topk && !has_head_weight_scale, + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale_, "SparseAttentionIndexer: csa attributes must be omitted for qsa"); index_topk_ = 0; } else { @@ -519,8 +532,6 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : WebGp } epsilon_ = info.GetAttrOrDefault("epsilon", 1.0e-6f); ORT_ENFORCE(epsilon_ >= 0.0f, "SparseAttentionIndexer: epsilon must be >= 0"); - scale_ = info.GetAttrOrDefault("scale", 0.0f); - head_weight_scale_ = has_head_weight_scale ? head_weight_scale : 0.0f; } Status SparseAttentionIndexer::ComputeInternal(ComputeContext& context) const { @@ -627,7 +638,7 @@ Status SparseAttentionIndexer::ComputeQsa(ComputeContext& context) const { {ToUint32(total_length)}, {ToUint32(token_budget_ / compress_ratio_)}, {epsilon_}, - {scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}}); + {has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}}); return context.RunProgram(select); } @@ -800,8 +811,8 @@ Status SparseAttentionIndexer::ComputeCsa(ComputeContext& context) const { {ToUint32(compress_ratio_)}, {ToUint32(capacity)}, {ToUint32(present_compressed_length)}, - {scale_ != 0.0f ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}, - {head_weight_scale_ != 0.0f + {has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}, + {has_head_weight_scale_ ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads))}}); return context.RunProgram(select); diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index b39a903f67809..9466cc68c7e22 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -152,6 +152,8 @@ class SparseAttentionIndexer final : public WebGpuKernel { float epsilon_; float scale_; float head_weight_scale_; + bool has_scale_; + bool has_head_weight_scale_; }; } // namespace webgpu 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 126e0735ca49a..36da13c247d18 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1028,6 +1028,12 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaTest(4.0e-3f, MakeQsaProblem(), ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, QsaExplicitZeroScale) { + QsaProblem problem = MakeQsaProblem(); + problem.scale = 0.0f; + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f, ProviderKind::WebGpu); } @@ -1043,6 +1049,19 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaBufferOnlyStep) { TEST(SparseAttentionIndexerWebGpuTest, CsaNoCompressedEntry) { RunCsaTest(MakeCsaNoCompressedEntryProblem(), 1.0e-5f, ProviderKind::WebGpu); } + +TEST(SparseAttentionIndexerWebGpuTest, CsaExplicitZeroScales) { + CsaProblem problem = MakeCsaProblem(); + problem.scale = 0.0f; + problem.head_weight_scale = 0.0f; + RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); +} + +TEST(SparseAttentionIndexerWebGpuTest, CsaInt64MaxPosition) { + CsaProblem problem = MakeCsaProblem(); + problem.position_ids[0] = std::numeric_limits::max(); + RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); +} #endif } // namespace test } // namespace onnxruntime From c24495d5ee7ca5bb1b4ea2a007ae09df117ca0fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:31:08 +0000 Subject: [PATCH 10/16] Fix SparseAttentionIndexer CI failures Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../contrib_ops/cuda/sparse/sparse_attention_indexer.cc | 6 +++--- .../test/contrib_ops/sparse_attention_indexer_op_test.cc | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 8ac96c38d69b1..3d8de9cebc2dd 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -188,8 +188,8 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { ORT_RETURN_IF(selected_indices == nullptr || present_key == nullptr, "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); - auto float_workspace = GetScratchBuffer(GetQsaWorkspaceFloatCount(params), context->GetComputeStream()); - auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), context->GetComputeStream()); + auto float_workspace = GetScratchBuffer(GetQsaWorkspaceFloatCount(params), GetComputeStream(context)); + auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), GetComputeStream(context)); return LaunchQsaSparseAttentionIndexer( Stream(context), params, @@ -317,7 +317,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "SparseAttentionIndexer: policy_mode 'csa' requires selected_indices, present_compressed_key, " "present_kv_buffer and present_gate_buffer outputs"); - auto float_workspace = GetScratchBuffer(GetCsaWorkspaceFloatCount(params), context->GetComputeStream()); + auto float_workspace = GetScratchBuffer(GetCsaWorkspaceFloatCount(params), GetComputeStream(context)); const CudaT* empty = nullptr; return LaunchCsaSparseAttentionIndexer( 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 6c1668be5e72b..7e9f97def4d9b 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -892,13 +892,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisible QsaGraphOptions options; options.token_budget = 5; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, - "requires token_budget > 0 and divisible by compress_ratio"); + "requires token_budget > 0, divisible by compress_ratio"); } TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedQsaCapacity) { QsaGraphOptions options; options.compress_ratio = 2; - options.token_budget = std::numeric_limits::max() - 1; + options.token_budget = static_cast(std::numeric_limits::max()) + 1; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, "selected capacity no greater than INT_MAX"); } From e3ea06a05bd353a0ed1a4e4a43a48fb119420398 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:47:09 +0000 Subject: [PATCH 11/16] Fix SparseAttentionIndexer CI failures Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../contrib_ops/webgpu/bert/sparse_attention_indexer.cc | 6 +++--- .../contrib_ops/webgpu/bert/sparse_attention_indexer.h | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 929ce4524c68b..62567e4fa262c 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -534,7 +534,7 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : WebGp ORT_ENFORCE(epsilon_ >= 0.0f, "SparseAttentionIndexer: epsilon must be >= 0"); } -Status SparseAttentionIndexer::ComputeInternal(ComputeContext& context) const { +Status SparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const { const bool is_qsa = policy_ == sai::Policy::kQsa; for (int index = sai::kMask; index < sai::kInputCount; ++index) { const bool policy_owns_slot = is_qsa ? (index <= sai::kPastKey) : (index >= sai::kGate); @@ -546,7 +546,7 @@ Status SparseAttentionIndexer::ComputeInternal(ComputeContext& context) const { return is_qsa ? ComputeQsa(context) : ComputeCsa(context); } -Status SparseAttentionIndexer::ComputeQsa(ComputeContext& context) const { +Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& context) const { const Tensor* query = context.Input(sai::kQuery); const Tensor* key = context.Input(sai::kKey); const Tensor* norm = context.Input(sai::kKeyNormWeight); @@ -642,7 +642,7 @@ Status SparseAttentionIndexer::ComputeQsa(ComputeContext& context) const { return context.RunProgram(select); } -Status SparseAttentionIndexer::ComputeCsa(ComputeContext& context) const { +Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& context) const { const Tensor* query = context.Input(sai::kQuery); const Tensor* key = context.Input(sai::kKey); const Tensor* norm = context.Input(sai::kKeyNormWeight); diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index 9466cc68c7e22..7bc8e3db706d2 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -139,11 +139,11 @@ class SparseAttentionIndexerCsaSelectProgram final class SparseAttentionIndexer final : public WebGpuKernel { public: explicit SparseAttentionIndexer(const OpKernelInfo& info); - Status ComputeInternal(ComputeContext& context) const override; + Status ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const override; private: - Status ComputeQsa(ComputeContext& context) const; - Status ComputeCsa(ComputeContext& context) const; + Status ComputeQsa(onnxruntime::webgpu::ComputeContext& context) const; + Status ComputeCsa(onnxruntime::webgpu::ComputeContext& context) const; sparse_attention_indexer::Policy policy_; int64_t compress_ratio_; From 59bc669eeba56bbd022827b272e51989c4a00d45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:14:36 +0000 Subject: [PATCH 12/16] Gate FP8 XQA test on supported GPUs Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../python/transformers/test_paged_attention_int4.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/onnxruntime/test/python/transformers/test_paged_attention_int4.py b/onnxruntime/test/python/transformers/test_paged_attention_int4.py index 36abc8554ec01..9bbc7d88ccede 100644 --- a/onnxruntime/test/python/transformers/test_paged_attention_int4.py +++ b/onnxruntime/test/python/transformers/test_paged_attention_int4.py @@ -23,6 +23,13 @@ def has_sm80_cuda(): ) +def has_sm89_cuda(): + if not torch.cuda.is_available(): + return False + major, minor = torch.cuda.get_device_capability() + return major >= 9 or (major == 8 and minor >= 9) + + def int4_kernel_available(): if os.getenv("ORT_PAGED_ATTENTION_TEST_RUNNER"): return True @@ -750,6 +757,8 @@ def test_xqa_large_attention_scale_and_k_scale(self): # bounded to prevent that, and this table spans one binade so it stays on XQA. heads, width = 6, 256 for cache_dtype in (np.uint8, np.int8, ml_dtypes.float8_e4m3fn): + if cache_dtype == ml_dtypes.float8_e4m3fn and not has_sm89_cuda(): + continue for length in (1, 3): with self.subTest(cache_dtype=cache_dtype, length=length): model, feeds, _ = make_case( From 3d328b27138d70f101734766533ffd1d0388b42c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:55:12 +0000 Subject: [PATCH 13/16] Adapt WebGPU indexer to shared cache ABI Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/contrib_ops/webgpu/sparse_attention_indexer.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/contrib_ops/webgpu/sparse_attention_indexer.md b/docs/contrib_ops/webgpu/sparse_attention_indexer.md index 7d012bc66591a..72a10b50641f6 100644 --- a/docs/contrib_ops/webgpu/sparse_attention_indexer.md +++ b/docs/contrib_ops/webgpu/sparse_attention_indexer.md @@ -10,12 +10,13 @@ the provider-neutral schema and state ABI described in the - batched inputs; - `qsa` and `csa` policy modes; - `float32` and `float16`; -- explicit graph-visible key, compressed-key, and incomplete-window state; +- explicit graph-visible key and packed projection-buffer state; - arbitrary boolean QSA visibility masks; - deterministic score-descending, index-ascending TopK ties. -BF16 and packed/variable-length inputs are not registered by the WebGPU kernel. -Unknown policies and policy-incompatible inputs or attributes are rejected. +BF16, packed/variable-length inputs, and fixed-capacity caches using +`past_sequence_length` are not supported by the WebGPU kernel. Unknown policies +and policy-incompatible inputs or attributes are rejected. ## Execution @@ -34,6 +35,7 @@ limits and GPU-to-CPU synchronization at the cost of additional computation. ## Follow-up work - packed/variable-length input; +- fixed-capacity cache updates; - specialized large-candidate TopK; - subgroup-optimized reductions; - fused projection, pooling, and scoring; From fe9d0594ab6f448100214c9c005624a980c88f04 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 21:20:38 +0000 Subject: [PATCH 14/16] Fuse query normalization in WebGPU sparse indexer --- .../webgpu/sparse_attention_indexer.md | 6 +- .../webgpu/bert/sparse_attention_indexer.cc | 77 ++++++++++++++----- .../webgpu/bert/sparse_attention_indexer.h | 1 + 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/docs/contrib_ops/webgpu/sparse_attention_indexer.md b/docs/contrib_ops/webgpu/sparse_attention_indexer.md index 72a10b50641f6..839dd5963101e 100644 --- a/docs/contrib_ops/webgpu/sparse_attention_indexer.md +++ b/docs/contrib_ops/webgpu/sparse_attention_indexer.md @@ -21,12 +21,16 @@ and policy-incompatible inputs or attributes are rejected. ## Execution State concatenation, visible-token grouping, QSA pooling, CSA overlap -compression, RMS normalization, rotary embedding, scoring, selection, and +compression, query/key RMS normalization, rotary embedding, scoring, selection, and output padding execute in WGSL. The implementation does not map GPU buffers, read selected values back to the host, or retain state in the kernel object. All reductions and softmax calculations accumulate in FP32, including for FP16 inputs. +The rank-3 query projection is logically reshaped into heads inside the shader. Query and key +projections therefore both connect directly to the operator; their consecutive norm-weight inputs +are applied internally before rotary embedding. + The initial implementation prioritizes correctness and uses one independently writable workgroup per query or completed CSA window. Candidate scoring during selection is recomputed rather than materialized, avoiding candidate-count diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 8882f6b849187..1f0d4517e6880 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -93,7 +93,8 @@ Status SparseAttentionIndexerQsaConcatProgram::GenerateShaderCode(ShaderHelper& Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); const auto& present_key = shader.AddInput("present_key", ShaderUsage::UseUniform); - const auto& norm = shader.AddInput("key_norm_weight", ShaderUsage::UseUniform); + const auto& query_norm = shader.AddInput("query_norm_weight", ShaderUsage::UseUniform); + 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); @@ -117,14 +118,25 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << "fn clamp_position(position: u32) -> u32 {\n" << " return min(position, uniforms.max_rotary_length - 1u);\n" << "}\n" + << "fn normalized_query_value(row: u32, head: u32, d: u32) -> f32 {\n" + << " let base = (row * uniforms.num_heads + 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.GetByOffset("base + k") << ");\n" + << " square_sum += value * value;\n" + << " }\n" + << " return f32(" << query.GetByOffset("base + d") + << ") * inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" + << query_norm.GetByOffset("d") << ");\n" + << "}\n" << "fn query_value(row: u32, head: u32, d: u32) -> f32 {\n" << " let base = (row * uniforms.num_heads + head) * uniforms.head_size;\n" - << " var value = f32(" << query.GetByOffset("base + d") << ");\n" + << " var value = normalized_query_value(row, head, d);\n" << " if (d < uniforms.rotary_width) {\n" << " let half = uniforms.rotary_width / 2u;\n" << " let pair_d = select(d - half, d + half, d < half);\n" << " let sign = select(1.0, -1.0, d < half);\n" - << " let paired = sign * f32(" << query.GetByOffset("base + pair_d") << ");\n" + << " let paired = sign * normalized_query_value(row, head, pair_d);\n" << " let batch = row / uniforms.sequence_length;\n" << " let token = row % uniforms.sequence_length;\n" << " let position = clamp_position(uniforms.past_sequence_length + token);\n" @@ -152,7 +164,7 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << " }\n" << " return pooled_value(row, block, d) * inverseSqrt(square_sum / f32(uniforms.head_size) + " "uniforms.epsilon) * f32(" - << norm.GetByOffset("d") << ");\n" + << key_norm.GetByOffset("d") << ");\n" << "}\n" << "fn key_value(row: u32, block: u32, d: u32) -> f32 {\n" << " var value = normalized_value(row, block, d);\n" @@ -413,6 +425,7 @@ Status SparseAttentionIndexerCsaCopyBufferProgram::GenerateShaderCode(ShaderHelp Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); + const auto& query_norm = shader.AddInput("query_norm_weight", ShaderUsage::UseUniform); const auto& compressed_key = shader.AddInput("present_compressed_key", ShaderUsage::UseUniform); const auto& head_weights = shader.AddInput("head_weights", ShaderUsage::UseUniform); const auto& position_ids = shader.AddInput("position_ids", ShaderUsage::UseUniform); @@ -436,15 +449,26 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let increment = select(0u, 1u, raw.x % uniforms.compress_ratio == uniforms.compress_ratio - 1u);\n" << " return min(count, quotient + increment);\n" << "}\n" + << "fn normalized_query_value(row: u32, head: u32, d: u32) -> f32 {\n" + << " let base = (row * uniforms.num_heads + 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.GetByOffset("base + k") << ");\n" + << " square_sum += value * value;\n" + << " }\n" + << " return f32(" << query.GetByOffset("base + d") + << ") * inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" + << query_norm.GetByOffset("d") << ");\n" + << "}\n" << "fn query_value(row: u32, head: u32, d: u32) -> f32 {\n" << " let base = (row * uniforms.num_heads + head) * uniforms.head_size;\n" - << " var value = f32(" << query.GetByOffset("base + d") << ");\n" + << " var value = normalized_query_value(row, head, d);\n" << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" << " if (d >= rotary_base) {\n" << " let offset = d - rotary_base;\n" << " let pair_d = select(d - 1u, d + 1u, (offset & 1u) == 0u);\n" << " let sign = select(1.0, -1.0, (offset & 1u) == 0u);\n" - << " let paired = sign * f32(" << query.GetByOffset("base + pair_d") << ");\n" + << " 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" @@ -548,7 +572,8 @@ Status SparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::ComputeConte Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& context) const { const Tensor* query = context.Input(sai::kQuery); const Tensor* key = context.Input(sai::kKey); - const Tensor* norm = context.Input(sai::kKeyNormWeight); + const Tensor* query_norm = context.Input(sai::kQueryNormWeight); + const Tensor* key_norm = context.Input(sai::kKeyNormWeight); const Tensor* cos_cache = context.Input(sai::kCosCache); const Tensor* sin_cache = context.Input(sai::kSinCache); const Tensor* mask = context.Input(sai::kMask); @@ -558,11 +583,16 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c "SparseAttentionIndexer WebGPU does not support fixed-capacity caches"); const auto& query_shape = query->Shape(); - ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, "SparseAttentionIndexer: query must have rank 4"); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, "SparseAttentionIndexer: query must have rank 3"); const int64_t batch_size = query_shape[0]; const int64_t sequence_length = query_shape[1]; - const int64_t num_heads = query_shape[2]; - const int64_t head_size = query_shape[3]; + const auto& query_norm_shape = query_norm->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0, + "SparseAttentionIndexer: invalid query_norm_weight shape"); + const int64_t head_size = query_norm_shape[0]; + ORT_RETURN_IF_NOT(query_shape[2] > 0 && query_shape[2] % head_size == 0, + "SparseAttentionIndexer: query width must be positive and divisible by head_size"); + const int64_t num_heads = query_shape[2] / head_size; ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "SparseAttentionIndexer: invalid query dimensions"); const auto& past_shape = past_key->Shape(); ORT_RETURN_IF_NOT(past_shape.NumDimensions() == 3 && past_shape[0] == batch_size && past_shape[2] == head_size, @@ -570,7 +600,8 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c const int64_t past_length = past_shape[1]; const int64_t total_length = past_length + sequence_length; ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); - ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); + 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"); @@ -621,7 +652,8 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, {present, ProgramTensorMetadataDependency::Type}, - {norm, 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() + 3) / 4}, 4}) @@ -647,7 +679,8 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& context) const { const Tensor* query = context.Input(sai::kQuery); const Tensor* key = context.Input(sai::kKey); - const Tensor* norm = context.Input(sai::kKeyNormWeight); + const Tensor* query_norm = context.Input(sai::kQueryNormWeight); + const Tensor* key_norm = context.Input(sai::kKeyNormWeight); const Tensor* cos_cache = context.Input(sai::kCosCache); const Tensor* sin_cache = context.Input(sai::kSinCache); const Tensor* gate = context.Input(sai::kGate); @@ -661,15 +694,21 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c "SparseAttentionIndexer WebGPU does not support fixed-capacity caches"); const auto& query_shape = query->Shape(); - ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, "SparseAttentionIndexer: query must have rank 4"); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, "SparseAttentionIndexer: query must have rank 3"); const int64_t batch_size = query_shape[0]; const int64_t sequence_length = query_shape[1]; - const int64_t num_heads = query_shape[2]; - const int64_t head_size = query_shape[3]; + const auto& query_norm_shape = query_norm->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0, + "SparseAttentionIndexer: invalid query_norm_weight shape"); + const int64_t head_size = query_norm_shape[0]; + ORT_RETURN_IF_NOT(query_shape[2] > 0 && query_shape[2] % head_size == 0, + "SparseAttentionIndexer: query width must be positive and divisible by head_size"); + const int64_t num_heads = query_shape[2] / head_size; ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "SparseAttentionIndexer: invalid query dimensions"); const int64_t width = 2 * head_size; ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, width})); - ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); + ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); + ORT_RETURN_IF_ERROR(CheckShape(key_norm, "key_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {batch_size, sequence_length, width})); ORT_RETURN_IF_ERROR(CheckShape(bias, "position_bias", {compress_ratio_, width})); ORT_RETURN_IF_ERROR(CheckShape(head_weights, "head_weights", {batch_size, sequence_length, num_heads})); @@ -730,7 +769,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c compress.AddInput({past_proj, ProgramTensorMetadataDependency::Type}); } compress.AddInputs({{bias, ProgramTensorMetadataDependency::Type}, - {norm, ProgramTensorMetadataDependency::Type}, + {key_norm, ProgramTensorMetadataDependency::Type}, {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_compressed, ProgramTensorMetadataDependency::Type}) @@ -792,6 +831,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c SparseAttentionIndexerCsaSelectProgram select; select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, + {query_norm, ProgramTensorMetadataDependency::Type}, {present_compressed, ProgramTensorMetadataDependency::Type}, {head_weights, ProgramTensorMetadataDependency::Type}, {position_ids, ProgramTensorMetadataDependency::Type}, @@ -809,6 +849,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(compress_ratio_)}, {ToUint32(capacity)}, {ToUint32(present_compressed_length)}, + {epsilon_}, {has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}, {has_head_weight_scale_ ? head_weight_scale_ diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index 7bc8e3db706d2..d2e7aeccb1efc 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -132,6 +132,7 @@ class SparseAttentionIndexerCsaSelectProgram final {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"capacity", ProgramUniformVariableDataType::Uint32}, {"present_compressed_length", ProgramUniformVariableDataType::Uint32}, + {"epsilon", ProgramUniformVariableDataType::Float32}, {"scale", ProgramUniformVariableDataType::Float32}, {"head_weight_scale", ProgramUniformVariableDataType::Float32}); }; From bc922c259ab0344dab87b3abd018a1a6292c598d Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 23:07:23 +0000 Subject: [PATCH 15/16] Use integer masks in WebGPU sparse indexer --- .../webgpu/sparse_attention_indexer.md | 2 +- .../webgpu/bert/sparse_attention_indexer.cc | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/contrib_ops/webgpu/sparse_attention_indexer.md b/docs/contrib_ops/webgpu/sparse_attention_indexer.md index 839dd5963101e..bb2f5cbd9458d 100644 --- a/docs/contrib_ops/webgpu/sparse_attention_indexer.md +++ b/docs/contrib_ops/webgpu/sparse_attention_indexer.md @@ -11,7 +11,7 @@ the provider-neutral schema and state ABI described in the - `qsa` and `csa` policy modes; - `float32` and `float16`; - explicit graph-visible key and packed projection-buffer state; -- arbitrary boolean QSA visibility masks; +- rank-2 INT64 QSA padding masks with internally derived causal visibility; - deterministic score-descending, index-ascending TopK ties. BF16, packed/variable-length inputs, and fixed-capacity caches using diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 1f0d4517e6880..fac85b89113af 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -24,7 +24,7 @@ ONNX_OPERATOR_KERNEL_EX( kWebGpuExecutionProvider, (*KernelDefBuilder::Create()) .TypeConstraint("T", WebGpuSupportedFloatTypes()) - .TypeConstraint("TB", DataTypeImpl::GetTensorType()) + .TypeConstraint("TB", DataTypeImpl::GetTensorType()) .TypeConstraint("I", DataTypeImpl::GetTensorType()) .TypeConstraint("M", DataTypeImpl::GetTensorType()), SparseAttentionIndexer); @@ -102,8 +102,11 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader.AdditionalImplementation() << "fn visible(row: u32, token: u32) -> bool {\n" - << " let offset = row * uniforms.total_sequence_length + token;\n" - << " return " << mask.GetByOffset("offset / 4u") << "[offset % 4u];\n" + << " let batch = row / uniforms.sequence_length;\n" + << " let query = row % uniforms.sequence_length;\n" + << " let offset = batch * uniforms.total_sequence_length + token;\n" + << " return token <= uniforms.past_sequence_length + query && " + << mask.GetByOffset("offset") << " != 0;\n" << "}\n" << "fn visible_at(row: u32, ordinal: u32) -> u32 {\n" << " var seen = 0u;\n" @@ -612,11 +615,8 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c "SparseAttentionIndexer: invalid qsa rotary cache shape"); const auto& mask_shape = mask->Shape(); ORT_RETURN_IF_NOT( - (mask_shape.NumDimensions() == 4 && mask_shape[0] == batch_size && mask_shape[1] == 1 && - mask_shape[2] == sequence_length && mask_shape[3] == total_length) || - (mask_shape.NumDimensions() == 3 && mask_shape[0] == batch_size && - mask_shape[1] == sequence_length && mask_shape[2] == total_length), - "SparseAttentionIndexer: invalid qsa mask shape"); + 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_); Tensor* selected = @@ -656,7 +656,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c {key_norm, ProgramTensorMetadataDependency::Type}, {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}}) - .AddInput({mask, ProgramTensorMetadataDependency::Type, {(mask->Shape().Size() + 3) / 4}, 4}) + .AddInput({mask, ProgramTensorMetadataDependency::Type, {mask->Shape().Size()}, 1}) .AddOutput({selected, ProgramTensorMetadataDependency::Type}) .SetWorkgroupSize(kWorkgroupSize) .SetDispatchGroupSize(ToUint32(rows)) From 01dc6e163ca6b9dbc5012880e17c56a02ede72c8 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sun, 20 Sep 2026 18:06:51 +0000 Subject: [PATCH 16/16] Support packed QK in WebGPU sparse attention indexer --- .../webgpu/bert/sparse_attention_indexer.cc | 25 ++++++++++++++----- .../webgpu/bert/sparse_attention_indexer.h | 5 +++- .../sparse_attention_indexer_op_test.cc | 4 +++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index fac85b89113af..8ef9027691d36 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -84,7 +84,8 @@ Status SparseAttentionIndexerQsaConcatProgram::GenerateShaderCode(ShaderHelper& if (has_current_) { shader.MainFunctionBody() << " let current_token = token - uniforms.past_sequence_length;\n" - << " " << present.SetByOffset("global_idx", "present_key_element_t(" + current->GetByOffset("(batch * uniforms.sequence_length + current_token) * uniforms.head_size + d") + ")") + << " let current_row = batch * uniforms.sequence_length + current_token;\n" + << " " << present.SetByOffset("global_idx", "present_key_element_t(" + current->GetByOffset("current_row * uniforms.key_row_stride + uniforms.key_offset + d") + ")") << "\n"; } return Status::OK(); @@ -122,7 +123,7 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << " return min(position, uniforms.max_rotary_length - 1u);\n" << "}\n" << "fn normalized_query_value(row: u32, head: u32, d: u32) -> f32 {\n" - << " let base = (row * uniforms.num_heads + head) * uniforms.head_size;\n" + << " let base = row * 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.GetByOffset("base + k") << ");\n" @@ -561,6 +562,8 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : WebGp Status SparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const { 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, sai::kPositionIds, sai::kPastProjBuffer}) { const bool policy_owns_slot = is_qsa ? index == sai::kMask : index != sai::kMask; @@ -595,14 +598,20 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c const int64_t head_size = query_norm_shape[0]; ORT_RETURN_IF_NOT(query_shape[2] > 0 && query_shape[2] % head_size == 0, "SparseAttentionIndexer: query width must be positive and divisible by head_size"); - const int64_t num_heads = query_shape[2] / head_size; + const bool packed_qk = key == nullptr; + const int64_t packed_head_count = query_shape[2] / head_size; + ORT_RETURN_IF(packed_qk && packed_head_count < 2, + "SparseAttentionIndexer: packed QK input must contain at least one query head and one key"); + const int64_t num_heads = packed_head_count - (packed_qk ? 1 : 0); ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "SparseAttentionIndexer: invalid query dimensions"); const auto& past_shape = past_key->Shape(); ORT_RETURN_IF_NOT(past_shape.NumDimensions() == 3 && past_shape[0] == batch_size && past_shape[2] == head_size, "SparseAttentionIndexer: invalid past_key shape"); const int64_t past_length = past_shape[1]; const int64_t total_length = past_length + sequence_length; - ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); + if (!packed_qk) { + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); + } 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(); @@ -626,6 +635,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c if (present_elements > 0) { const bool has_past = past_length > 0; const bool has_current = sequence_length > 0; + const Tensor* current_key = packed_qk ? query : key; SparseAttentionIndexerQsaConcatProgram concat{has_past, has_current}; concat.CacheHint(has_past, has_current) .SetWorkgroupSize(kWorkgroupSize); @@ -633,7 +643,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c concat.AddInput({past_key, ProgramTensorMetadataDependency::Type}); } if (has_current) { - concat.AddInput({key, ProgramTensorMetadataDependency::Type}); + concat.AddInput({current_key, ProgramTensorMetadataDependency::Type}); } concat.AddOutput({present, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize((ToUint32(present_elements) + kWorkgroupSize - 1) / kWorkgroupSize) @@ -641,7 +651,9 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(sequence_length)}, {ToUint32(past_length)}, {ToUint32(total_length)}, - {ToUint32(head_size)}}); + {ToUint32(head_size)}, + {ToUint32(packed_qk ? query_shape[2] : head_size)}, + {ToUint32(packed_qk ? num_heads * head_size : 0)}}); ORT_RETURN_IF_ERROR(context.RunProgram(concat)); } const int64_t rows = batch_size * sequence_length; @@ -664,6 +676,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(sequence_length)}, {ToUint32(num_heads)}, {ToUint32(head_size)}, + {ToUint32(query_shape[2])}, {ToUint32(rotary_width)}, {ToUint32(max_rotary_length)}, {ToUint32(compress_ratio_)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index d2e7aeccb1efc..3e81fc865a9b1 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -32,7 +32,9 @@ class SparseAttentionIndexerQsaConcatProgram final {"sequence_length", ProgramUniformVariableDataType::Uint32}, {"past_sequence_length", ProgramUniformVariableDataType::Uint32}, {"total_sequence_length", ProgramUniformVariableDataType::Uint32}, - {"head_size", ProgramUniformVariableDataType::Uint32}); + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"key_row_stride", ProgramUniformVariableDataType::Uint32}, + {"key_offset", ProgramUniformVariableDataType::Uint32}); private: bool has_past_; @@ -49,6 +51,7 @@ class SparseAttentionIndexerQsaSelectProgram final {"sequence_length", ProgramUniformVariableDataType::Uint32}, {"num_heads", ProgramUniformVariableDataType::Uint32}, {"head_size", ProgramUniformVariableDataType::Uint32}, + {"query_row_stride", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, 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 17c562209bbf0..0ea79cde8d995 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1142,6 +1142,10 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaFloat) { RunQsaTest(1.0e-5f, MakeQsaProblem(), ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, QsaPackedQkFloat) { + RunQsaTest(1.0e-5f, MakeQsaProblem(), ProviderKind::WebGpu, true); +} + TEST(SparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaTest(4.0e-3f, MakeQsaProblem(), ProviderKind::WebGpu); }