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 001/102] 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 002/102] 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 003/102] 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 004/102] 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 005/102] 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 006/102] 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 007/102] 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 008/102] 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 009/102] 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 010/102] 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 011/102] 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 814f5bf865a2538ba8133a9da9739dcedbd3eaa5 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 012/102] 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 580d9f050431964bef8af6ecee15286caa3d2e28 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 013/102] 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 d16575819a03f15e61a7e2635666a9841faa0bc1 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 014/102] 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 c695c8e80ff3b1d1aaff20a7cb921ff2abeedc13 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 015/102] 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 ea75f95656f527cb546f45cee686834a6ad395d1 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 016/102] 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 2b06f06a2e328a9ce70350e558c184c27430dcfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:59:49 +0000 Subject: [PATCH 017/102] Update generated contrib operator documentation Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 132 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index e92b02faa7f77..76495f0b96f20 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -118,6 +118,7 @@ Do not modify directly.* * com.microsoft.SkipSimplifiedLayerNormalization * com.microsoft.Snpe * com.microsoft.SparseAttention + * com.microsoft.SparseAttentionIndexer * com.microsoft.SparseToDenseMatMul * com.microsoft.Tokenizer * com.microsoft.TorchEmbedding @@ -6968,6 +6969,137 @@ This version of the operator has been available since version 1 of the 'com.micr +### **com.microsoft.SparseAttentionIndexer** + + 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. + +#### Version + +This version of the operator has been available since version 1 of the 'com.microsoft' operator set. + +#### Attributes + +
+
compress_ratio : int (required)
+
Number of consecutive tokens folded into one compressed block. Must be > 0.
+
epsilon : float
+
Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.
+
head_weight_scale : float
+
Only for policy_mode 'csa': scale applied to head_weights. Default is 1/sqrt(num_heads). Must be omitted when policy_mode is 'qsa'.
+
index_topk : int
+
Only for policy_mode 'csa': number of compressed entries selected per query. Must be > 0. Must be omitted when policy_mode is 'qsa'.
+
policy_mode : string (required)
+
Indexer policy. Must be exactly 'qsa' (token indexer) or 'csa' (compressed block indexer).
+
scale : float
+
Scale applied to the per-head ReLU scores. Default is 1/sqrt(head_size).
+
token_budget : int
+
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
+
+ +#### Inputs (5 - 14) + +
+
query : T
+
Indexer queries with shape (batch_size, sequence_length, num_heads, head_size), already normalized but not yet rotated.
+
key : T
+
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.
+
key_norm_weight : T
+
Effective RMSNorm multiplier of the compressed keys, with shape (head_size).
+
cos_cache : T
+
Cosine rotary table indexed by absolute key position, with shape (batch_size, max_rotary_sequence_length, rotary_width).
+
sin_cache : T
+
Sine rotary table with the same shape as cos_cache.
+
mask (optional) : TB
+
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.
+
past_key (optional) : T
+
Only for policy_mode 'qsa': cached indexer keys with shape (batch_size, past_sequence_length, head_size).
+
gate (optional) : T
+
Only for policy_mode 'csa': gate projection of the new tokens with shape (batch_size, sequence_length, 2 * head_size).
+
position_bias (optional) : T
+
Only for policy_mode 'csa': per-slot gate bias with shape (compress_ratio, 2 * head_size).
+
head_weights (optional) : T
+
Only for policy_mode 'csa': per-head score weights with shape (batch_size, sequence_length, num_heads).
+
position_ids (optional) : I
+
Only for policy_mode 'csa': absolute position of every query with shape (batch_size, sequence_length).
+
past_compressed_key (optional) : T
+
Only for policy_mode 'csa': compressed keys emitted by previous calls, with shape (batch_size, past_compressed_length, head_size).
+
past_kv_buffer (optional) : T
+
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).
+
past_gate_buffer (optional) : T
+
Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.
+
+ +#### Outputs (1 - 5) + +
+
selected_indices : M
+
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.
+
present_key (optional) : T
+
Only for policy_mode 'qsa': past_key concatenated with key, with shape (batch_size, total_sequence_length, head_size).
+
present_compressed_key (optional) : T
+
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).
+
present_kv_buffer (optional) : T
+
Only for policy_mode 'csa': updated key buffer with shape (batch_size, present_buffer_length, 2 * head_size).
+
present_gate_buffer (optional) : T
+
Only for policy_mode 'csa': updated gate buffer with the same shape as present_kv_buffer.
+
+ +#### Type Constraints + +
+
T : tensor(float), tensor(float16), tensor(bfloat16)
+
Constrain floating point tensors to float, float16 and bfloat16.
+
TB : tensor(bool)
+
Constrain the visibility mask to boolean tensors.
+
I : tensor(int64)
+
Constrain position ids to 64-bit integer tensors.
+
M : tensor(int32)
+
Constrain selected indices to 32-bit integer tensors.
+
+ + ### **com.microsoft.SparseToDenseMatMul** #### Version 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 018/102] 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 aaa8299706e42f6602a82d79a993ff4b1c54e52a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:16:57 +0000 Subject: [PATCH 019/102] Address SparseAttentionIndexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse/sparse_attention_indexer.cc | 34 +++++++++++++++++-- .../transformers/test_paged_attention_int4.py | 8 +++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 3d8de9cebc2dd..5862ed18ff9a9 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -48,6 +48,13 @@ Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list< return Status::OK(); } +Status CheckIntDimension(const char* name, int64_t value, bool allow_zero = true) { + ORT_RETURN_IF(value < (allow_zero ? 0 : 1) || value > std::numeric_limits::max(), + "SparseAttentionIndexer: ", name, " must be in ", allow_zero ? "[0, INT_MAX]" : "(0, INT_MAX]", + ", got ", value); + return Status::OK(); +} + } // namespace template @@ -127,7 +134,10 @@ 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); + ORT_RETURN_IF_ERROR(CheckIntDimension("batch_size", batch_size)); + ORT_RETURN_IF_ERROR(CheckIntDimension("sequence_length", sequence_length)); + ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); const auto& cos_shape = cos_cache->Shape(); ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, @@ -136,6 +146,8 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { cos_shape.ToString()); const int64_t max_rotary_length = cos_shape[1]; const int64_t rotary_width = cos_shape[2]; + ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", max_rotary_length, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", rotary_width, false)); ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape, "SparseAttentionIndexer: sin_cache must have the same shape as " "cos_cache"); @@ -149,6 +161,9 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { ", got ", past_shape.ToString()); const int64_t past_sequence_length = past_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("past_sequence_length", past_sequence_length)); + ORT_RETURN_IF(past_sequence_length > std::numeric_limits::max() - sequence_length, + "SparseAttentionIndexer: total_sequence_length must be no greater than INT_MAX"); const int64_t total_sequence_length = past_sequence_length + sequence_length; ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); @@ -232,7 +247,12 @@ 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); + ORT_RETURN_IF_ERROR(CheckIntDimension("batch_size", batch_size)); + ORT_RETURN_IF_ERROR(CheckIntDimension("sequence_length", sequence_length)); + ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); + ORT_RETURN_IF(head_size > std::numeric_limits::max() / 2, + "SparseAttentionIndexer: 2 * head_size must be no greater than INT_MAX"); const int64_t width = 2 * head_size; const auto& cos_shape = cos_cache->Shape(); @@ -242,6 +262,8 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { cos_shape.ToString()); const int64_t max_rotary_length = cos_shape[1]; const int64_t rotary_width = cos_shape[2]; + ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", max_rotary_length, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", rotary_width, false)); ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape, "SparseAttentionIndexer: sin_cache must have the same shape as " "cos_cache"); @@ -257,6 +279,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "(batch_size, past_compressed_length, head_size), got ", past_compressed_shape.ToString()); const int64_t past_compressed_length = past_compressed_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("past_compressed_length", past_compressed_length)); const auto& past_buffer_shape = past_kv_buffer->Shape(); ORT_RETURN_IF_NOT(past_buffer_shape.NumDimensions() == 3 && past_buffer_shape[0] == batch_size && @@ -265,6 +288,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "(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(CheckIntDimension("past_buffer_length", past_buffer_length)); 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})); @@ -279,6 +303,12 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { 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); + ORT_RETURN_IF_ERROR(CheckIntDimension("overlap_length", plan.overlap_length)); + ORT_RETURN_IF_ERROR(CheckIntDimension("new_window_count", plan.new_window_count)); + ORT_RETURN_IF_ERROR(CheckIntDimension("present_buffer_length", plan.present_buffer_length)); + ORT_RETURN_IF_ERROR(CheckIntDimension("present_buffer_start", plan.present_buffer_start)); + ORT_RETURN_IF(past_compressed_length > std::numeric_limits::max() - plan.new_window_count, + "SparseAttentionIndexer: present_compressed_length must be no greater than INT_MAX"); const int64_t present_compressed_length = past_compressed_length + plan.new_window_count; SparseAttentionIndexerParams params; diff --git a/onnxruntime/test/python/transformers/test_paged_attention_int4.py b/onnxruntime/test/python/transformers/test_paged_attention_int4.py index 36abc8554ec01..9ae5f12042112 100644 --- a/onnxruntime/test/python/transformers/test_paged_attention_int4.py +++ b/onnxruntime/test/python/transformers/test_paged_attention_int4.py @@ -23,6 +23,12 @@ def has_sm80_cuda(): ) +def has_sm89_cuda(): + return bool(os.getenv("ORT_PAGED_ATTENTION_TEST_RUNNER")) or ( + torch.cuda.is_available() and torch.cuda.get_device_capability() >= (8, 9) + ) + + def int4_kernel_available(): if os.getenv("ORT_PAGED_ATTENTION_TEST_RUNNER"): return True @@ -750,6 +756,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 a0e9d1ba42f5f795dd9b49995357308552188882 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:24:29 +0000 Subject: [PATCH 020/102] Gate XQA dispatch tests by CUDA architecture Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/python/transformers/test_paged_attention_int4.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/onnxruntime/test/python/transformers/test_paged_attention_int4.py b/onnxruntime/test/python/transformers/test_paged_attention_int4.py index 9ae5f12042112..9858cc430725f 100644 --- a/onnxruntime/test/python/transformers/test_paged_attention_int4.py +++ b/onnxruntime/test/python/transformers/test_paged_attention_int4.py @@ -750,6 +750,7 @@ def test_int4_xqa_large_per_channel_k_scale_matches_portable(self): accelerated["output"].astype(np.float32), portable["output"].astype(np.float32), atol=8e-4, rtol=5e-3 ) + @unittest.skipUnless(has_sm80_cuda(), "XQA requires an SM80 or newer GPU") def test_xqa_large_attention_scale_and_k_scale(self): # An attention scale above one together with a channel scale at FLT_MAX would make # attention_scale * normalizer overflow fp32 and every logit NaN. The normalizer exponent is @@ -897,6 +898,7 @@ def test_per_channel_k_keeps_int8_xqa(self): for side in ("key", "value"): np.testing.assert_array_equal(accelerated[f"{side}_cache_out"], portable[f"{side}_cache_out"]) + @unittest.skipUnless(has_sm80_cuda(), "XQA requires an SM80 or newer GPU") def test_scalar_k_per_channel_v_keeps_int8_xqa(self): for lengths in ((1, 1), (3, 1)): with self.subTest(lengths=lengths): From dbaeb30d6cc0a9be3fdc4318b30113f5dc6ab7b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:11:28 +0000 Subject: [PATCH 021/102] Initial plan From f039ff6dc238f51e4a97d14f97571b4e53502eda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:17:50 +0000 Subject: [PATCH 022/102] Support shared SparseAttentionIndexer caches Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 18 +-- docs/OperatorKernels.md | 2 +- .../cuda/sparse_attention_indexer.md | 21 +-- .../sparse/sparse_attention_indexer_common.h | 8 +- .../cuda/sparse/sparse_attention_indexer.cc | 90 +++++++++--- .../sparse/sparse_attention_indexer_impl.cu | 73 ++++++---- .../sparse/sparse_attention_indexer_impl.h | 4 + .../core/graph/contrib_ops/bert_defs.cc | 53 +++++-- .../python/tools/symbolic_shape_infer.py | 20 ++- .../sparse_attention_indexer_op_test.cc | 132 ++++++++++++++---- ...untime_test_python_symbolic_shape_infer.py | 88 ++++++++++++ 11 files changed, 402 insertions(+), 107 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 3cc7009d0e83e..ff389c592e145 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7038,7 +7038,7 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
-#### Inputs (5 - 14) +#### Inputs (5 - 16)
query : T
@@ -7054,7 +7054,7 @@ This version of the operator has been available since version 1 of the 'com.micr
mask (optional) : TB
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.
past_key (optional) : T
-
Only for policy_mode 'qsa': cached indexer keys with shape (batch_size, past_sequence_length, head_size).
+
Only for policy_mode 'qsa': cached indexer keys with shape (batch_size, past_sequence_length, head_size), or (batch_size, max_cache_length, head_size) when past_sequence_length input is provided.
gate (optional) : T
Only for policy_mode 'csa': gate projection of the new tokens with shape (batch_size, sequence_length, 2 * head_size).
position_bias (optional) : T
@@ -7064,11 +7064,15 @@ This version of the operator has been available since version 1 of the 'com.micr
position_ids (optional) : I
Only for policy_mode 'csa': absolute position of every query with shape (batch_size, sequence_length).
past_compressed_key (optional) : T
-
Only for policy_mode 'csa': compressed keys emitted by previous calls, with shape (batch_size, past_compressed_length, head_size).
+
Only for policy_mode 'csa': compressed keys emitted by previous calls, with shape (batch_size, past_compressed_length, head_size), or (batch_size, max_cache_length, head_size) when past_compressed_length input is provided.
past_kv_buffer (optional) : T
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).
past_gate_buffer (optional) : T
Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.
+
past_sequence_length (optional) : M
+
Only for policy_mode 'qsa': optional one-element CPU tensor containing the number of valid rows in past_key. When provided, past_key and present_key have the same max-capacity shape and may share their buffer.
+
past_compressed_length (optional) : M
+
Only for policy_mode 'csa': optional one-element CPU tensor containing the number of valid rows in past_compressed_key. When provided, past_compressed_key and present_compressed_key have the same max-capacity shape and may share their buffer.
#### Outputs (1 - 5) @@ -7077,9 +7081,9 @@ This version of the operator has been available since version 1 of the 'com.micr
selected_indices : M
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.
present_key (optional) : T
-
Only for policy_mode 'qsa': past_key concatenated with key, with shape (batch_size, total_sequence_length, head_size).
+
Only for policy_mode 'qsa': past_key concatenated with key, with shape (batch_size, total_sequence_length, head_size). When past_sequence_length is provided, the shape instead matches the max-capacity past_key and the two tensors may share a buffer.
present_compressed_key (optional) : T
-
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).
+
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). When past_compressed_length is provided, the shape instead matches the max-capacity past_compressed_key and the two tensors may share a buffer.
present_kv_buffer (optional) : T
Only for policy_mode 'csa': updated key buffer with shape (batch_size, present_buffer_length, 2 * head_size).
present_gate_buffer (optional) : T
@@ -7096,7 +7100,7 @@ This version of the operator has been available since version 1 of the 'com.micr
I : tensor(int64)
Constrain position ids to 64-bit integer tensors.
M : tensor(int32)
-
Constrain selected indices to 32-bit integer tensors.
+
Constrain indices and cache lengths to 32-bit integer tensors.
@@ -7814,5 +7818,3 @@ No versioning maintained for experimental ops.
T : tensor(float)
Constrain input and output types to float32 tensors.
- - diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index 59a8b35f33420..8c317b4c385bd 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1143,7 +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)| +|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**
*in* past_sequence_length:**M**
*in* past_compressed_length:**M**
*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 index 4a51d8859a349..ba32cb7819b80 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -140,9 +140,17 @@ returned as an output, so the caller (or the ORT session binding) owns the buffe | `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. +By default, `present_key` and `present_compressed_key` grow by a number of positions that is known +from the input shapes and attributes. For autoregressive decoding, the caller can instead provide a +max-capacity cache and the corresponding valid-length input: + +- `qsa`: `past_sequence_length` gives the valid rows in `past_key`. +- `csa`: `past_compressed_length` gives the valid rows in `past_compressed_key`. + +In this mode, the past and present tensors have the same shape and the CUDA allocation planner may +alias them. The kernel appends new rows at the valid length without copying existing rows. The +capacity must accommodate all rows emitted by the call. The two small `csa` token buffers retain +their ordinary bounded state contract and stay below `2r` positions. ## 4. Policy `qsa` @@ -356,9 +364,6 @@ The implementation is correctness-first. The following are known and deliberate: 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 +5. **`compress_ratio` and `head_size` are not specialized.** Templating the hot kernels on a small set of common values would remove the dynamic loop bounds. -7. **No CPU kernel.** The operator is CUDA-only today. +6. **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 index d9eb3d1671cca..3e0b4d1448754 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -31,8 +31,8 @@ inline bool TryParsePolicy(const std::string& policy_mode, Policy& policy) { 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. +// Input slots. Slots 5-6 and 14 belong to policy_mode="qsa"; slots 7-13 and 15 belong to +// policy_mode="csa". A slot that does not belong to the active policy must be omitted. enum InputIndex : int { kQuery = 0, kKey = 1, @@ -48,7 +48,9 @@ enum InputIndex : int { kPastCompressedKey = 11, kPastKvBuffer = 12, kPastGateBuffer = 13, - kInputCount = 14, + kPastSequenceLength = 14, + kPastCompressedLength = 15, + kInputCount = 16, }; // Output slots. Slot 1 belongs to policy_mode="qsa" and slots 2-4 to policy_mode="csa". diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 5862ed18ff9a9..185680f625ad2 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -18,18 +18,22 @@ 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()), \ +#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()) \ + .MayInplace(sai::kPastKey, sai::kPresentKey) \ + .MayInplace(sai::kPastCompressedKey, sai::kPresentCompressedKey) \ + .InputMemoryType(OrtMemTypeCPUInput, sai::kPastSequenceLength) \ + .InputMemoryType(OrtMemTypeCPUInput, sai::kPastCompressedLength), \ SparseAttentionIndexer); REGISTER_KERNEL_TYPED(float) @@ -55,6 +59,16 @@ Status CheckIntDimension(const char* name, int64_t value, bool allow_zero = true return Status::OK(); } +Status ReadOptionalLength(OpKernelContext* context, int index, const char* name, int64_t& value) { + const Tensor* tensor = index < context->InputCount() ? context->Input(index) : nullptr; + if (tensor == nullptr) { + return Status::OK(); + } + ORT_RETURN_IF_NOT(tensor->Shape().Size() == 1, "SparseAttentionIndexer: ", name, " must contain one element"); + value = static_cast(*tensor->Data()); + return CheckIntDimension(name, value); +} + } // namespace template @@ -103,8 +117,13 @@ 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 policy_owns_slot = + is_qsa ? (index <= sai::kPastKey || index == sai::kPastSequenceLength) + : (index >= sai::kGate && index <= sai::kPastGateBuffer) || index == sai::kPastCompressedLength; const bool provided = index < context->InputCount() && context->Input(index) != nullptr; + if (!provided && (index == sai::kPastSequenceLength || index == sai::kPastCompressedLength)) { + continue; + } 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, "'"); @@ -160,11 +179,19 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { "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]; - ORT_RETURN_IF_ERROR(CheckIntDimension("past_sequence_length", past_sequence_length)); + const int64_t key_cache_capacity = past_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("key_cache_capacity", key_cache_capacity)); + int64_t past_sequence_length = key_cache_capacity; + ORT_RETURN_IF_ERROR( + ReadOptionalLength(context, sai::kPastSequenceLength, "past_sequence_length", past_sequence_length)); ORT_RETURN_IF(past_sequence_length > std::numeric_limits::max() - sequence_length, "SparseAttentionIndexer: total_sequence_length must be no greater than INT_MAX"); const int64_t total_sequence_length = past_sequence_length + sequence_length; + ORT_RETURN_IF(total_sequence_length > key_cache_capacity && + context->InputCount() > sai::kPastSequenceLength && + context->Input(sai::kPastSequenceLength) != nullptr, + "SparseAttentionIndexer: total_sequence_length must not exceed key_cache_capacity when " + "past_sequence_length is provided"); 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})); @@ -194,12 +221,19 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { 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.past_key_capacity = static_cast(key_cache_capacity); + params.key_cache_capacity = static_cast( + context->InputCount() > sai::kPastSequenceLength && + context->Input(sai::kPastSequenceLength) != nullptr + ? key_cache_capacity + : 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})); + Tensor* present_key = + context->Output(sai::kPresentKey, TensorShape({batch_size, params.key_cache_capacity, head_size})); ORT_RETURN_IF(selected_indices == nullptr || present_key == nullptr, "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); @@ -278,8 +312,11 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "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]; - ORT_RETURN_IF_ERROR(CheckIntDimension("past_compressed_length", past_compressed_length)); + const int64_t compressed_cache_capacity = past_compressed_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("compressed_cache_capacity", compressed_cache_capacity)); + int64_t past_compressed_length = compressed_cache_capacity; + ORT_RETURN_IF_ERROR(ReadOptionalLength(context, sai::kPastCompressedLength, "past_compressed_length", + past_compressed_length)); const auto& past_buffer_shape = past_kv_buffer->Shape(); ORT_RETURN_IF_NOT(past_buffer_shape.NumDimensions() == 3 && past_buffer_shape[0] == batch_size && @@ -310,6 +347,11 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { ORT_RETURN_IF(past_compressed_length > std::numeric_limits::max() - plan.new_window_count, "SparseAttentionIndexer: present_compressed_length must be no greater than INT_MAX"); const int64_t present_compressed_length = past_compressed_length + plan.new_window_count; + ORT_RETURN_IF(present_compressed_length > compressed_cache_capacity && + context->InputCount() > sai::kPastCompressedLength && + context->Input(sai::kPastCompressedLength) != nullptr, + "SparseAttentionIndexer: present_compressed_length must not exceed compressed_cache_capacity when " + "past_compressed_length is provided"); SparseAttentionIndexerParams params; params.batch_size = static_cast(batch_size); @@ -325,6 +367,12 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { 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_compressed_capacity = static_cast(compressed_cache_capacity); + params.compressed_cache_capacity = static_cast( + context->InputCount() > sai::kPastCompressedLength && + context->Input(sai::kPastCompressedLength) != nullptr + ? compressed_cache_capacity + : 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); @@ -337,7 +385,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { 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})); + sai::kPresentCompressedKey, TensorShape({batch_size, params.compressed_cache_capacity, head_size})); Tensor* present_kv_buffer = context->Output(sai::kPresentKvBuffer, TensorShape({batch_size, plan.present_buffer_length, width})); Tensor* present_gate_buffer = @@ -361,11 +409,11 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { reinterpret_cast(position_bias->Data()), reinterpret_cast(head_weights->Data()), position_ids->Data(), - past_compressed_length > 0 ? reinterpret_cast(past_compressed_key->Data()) : empty, + compressed_cache_capacity > 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, + params.compressed_cache_capacity > 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()); 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 ffc453754231b..565c0c5173e95 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -179,21 +179,32 @@ __global__ void RotateQueryKernel(const T* query, const T* cos_cache, const T* s // --------------------------------------------------------------------------------------------- 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; +__global__ void CopyQsaPastKeyKernel(const T* past_key, T* present_key, SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.past_key_capacity * 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.past_key_capacity); + const int batch = static_cast(token_row / params.past_key_capacity); + const int64_t output_index = + (static_cast(batch) * params.key_cache_capacity + token) * params.head_size + d; + present_key[output_index] = past_key[index]; + } +} + +template +__global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.head_size; for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; index += static_cast(gridDim.x) * blockDim.x) { const int d = static_cast(index % params.head_size); const int64_t token_row = index / params.head_size; - const int token = static_cast(token_row % params.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]; + const int token = static_cast(token_row % params.sequence_length); + const int batch = static_cast(token_row / params.sequence_length); + present_key[(static_cast(batch) * params.key_cache_capacity + params.past_sequence_length + token) * + params.head_size + + d] = key[index]; } } @@ -259,7 +270,7 @@ __global__ void QsaBlockScoreKernel(const T* present_key, const T* key_norm_weig 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; + const int64_t key_base = static_cast(batch) * params.key_cache_capacity * params.head_size; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { float sum = 0.0f; @@ -459,7 +470,7 @@ __global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_ 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; + (static_cast(batch) * params.compressed_cache_capacity + 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)); @@ -471,15 +482,20 @@ __global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_ 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; + const int64_t total = + static_cast(params.batch_size) * params.past_compressed_capacity * 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 entry = static_cast(entry_row % params.past_compressed_capacity); + const int batch = static_cast(entry_row / params.past_compressed_capacity); 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]; + present_compressed_key[(static_cast(batch) * params.compressed_cache_capacity + entry) * + params.head_size + + d] = + past_compressed_key[(static_cast(batch) * params.past_compressed_capacity + entry) * + params.head_size + + d]; } } @@ -523,7 +539,7 @@ __global__ void CsaScoreKernel(const float* query_rotated, const T* present_comp } const int64_t key_base = - (static_cast(batch) * params.present_compressed_length + entry) * params.head_size; + (static_cast(batch) * params.compressed_cache_capacity + 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; @@ -607,11 +623,16 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio // 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); + const int64_t past_key_elements = + static_cast(params.batch_size) * params.past_key_capacity * params.head_size; + if (past_key_elements > 0 && past_key != present_key) { + CopyQsaPastKeyKernel<<>>( + past_key, present_key, params); + } + const int64_t new_key_elements = + static_cast(params.batch_size) * params.sequence_length * params.head_size; + if (new_key_elements > 0) { + AppendQsaKeyKernel<<>>(key, present_key, params); } if (rows == 0) { @@ -662,8 +683,8 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio // 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) { + static_cast(params.batch_size) * params.past_compressed_capacity * params.head_size; + if (past_compressed_elements > 0 && past_compressed_key != present_compressed_key) { CsaCopyPastCompressedKernel<<>>( past_compressed_key, present_compressed_key, params); } diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index e577208437d9a..ebc5f1f05945e 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -28,12 +28,16 @@ struct SparseAttentionIndexerParams { // policy_mode = "qsa" int past_sequence_length = 0; int total_sequence_length = 0; + int past_key_capacity = 0; + int key_cache_capacity = 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_compressed_capacity = 0; + int compressed_cache_capacity = 0; int past_buffer_length = 0; int overlap_length = 0; int new_window_count = 0; diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 7120200ba4751..64d5395f39751 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -1988,6 +1988,14 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex : " is required when policy_mode is 'csa'"); } } + if (SparseAttentionIndexerHasInput(ctx, sai::kPastSequenceLength) && !is_qsa) { + fail_shape_inference( + "SparseAttentionIndexer: past_sequence_length must be omitted when policy_mode is 'csa'"); + } + if (SparseAttentionIndexerHasInput(ctx, sai::kPastCompressedLength) && is_qsa) { + fail_shape_inference( + "SparseAttentionIndexer: past_compressed_length must be omitted when policy_mode is 'qsa'"); + } const size_t expected_outputs = is_qsa ? sai::kQsaOutputCount : sai::kCsaOutputCount; if (ctx.getNumOutputs() != expected_outputs) { @@ -2026,11 +2034,16 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentKey); const auto* past_key_shape = SparseAttentionIndexerShape(ctx, sai::kPastKey, 3); if (past_key_shape != nullptr) { + (void)SparseAttentionIndexerShape(ctx, sai::kPastSequenceLength, 1); 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()); + if (SparseAttentionIndexerHasInput(ctx, sai::kPastSequenceLength)) { + SparseAttentionIndexerAppendDim(present_shape, past_key_shape->dim(1)); + } else { + 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); @@ -2044,6 +2057,7 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentGateBuffer); const auto* past_compressed_shape = SparseAttentionIndexerShape(ctx, sai::kPastCompressedKey, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kPastCompressedLength, 1); 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); @@ -2080,7 +2094,9 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex 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()) { + if (SparseAttentionIndexerHasInput(ctx, sai::kPastCompressedLength)) { + *entry_dim = past_compressed_shape->dim(1); + } else 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); @@ -2214,7 +2230,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(6, "past_key", "Only for policy_mode 'qsa': cached indexer keys with shape " - "(batch_size, past_sequence_length, head_size).", + "(batch_size, past_sequence_length, head_size), or " + "(batch_size, max_cache_length, head_size) when past_sequence_length input is provided.", "T", OpSchema::Optional) .Input(7, @@ -2243,7 +2260,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .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).", + "(batch_size, past_compressed_length, head_size), or " + "(batch_size, max_cache_length, head_size) when past_compressed_length input is provided.", "T", OpSchema::Optional) .Input(12, @@ -2257,6 +2275,20 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.", "T", OpSchema::Optional) + .Input(14, + "past_sequence_length", + "Only for policy_mode 'qsa': optional one-element CPU tensor containing the number of valid rows in " + "past_key. When provided, past_key and present_key have the same max-capacity shape and may share " + "their buffer.", + "M", + OpSchema::Optional) + .Input(15, + "past_compressed_length", + "Only for policy_mode 'csa': optional one-element CPU tensor containing the number of valid rows in " + "past_compressed_key. When provided, past_compressed_key and present_compressed_key have the same " + "max-capacity shape and may share their buffer.", + "M", + OpSchema::Optional) .Output(0, "selected_indices", "Selected entries with shape (batch_size, sequence_length, capacity). capacity is " @@ -2267,13 +2299,16 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Output(1, "present_key", "Only for policy_mode 'qsa': past_key concatenated with key, with shape " - "(batch_size, total_sequence_length, head_size).", + "(batch_size, total_sequence_length, head_size). When past_sequence_length is provided, " + "the shape instead matches the max-capacity past_key and the two tensors may share a buffer.", "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).", + "this call, with shape (batch_size, present_compressed_length, head_size). When " + "past_compressed_length is provided, the shape instead matches the max-capacity " + "past_compressed_key and the two tensors may share a buffer.", "T", OpSchema::Optional) .Output(3, @@ -2292,7 +2327,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "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.") + .TypeConstraint("M", {"tensor(int32)"}, "Constrain indices and cache lengths to 32-bit integer tensors.") .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { SparseAttentionIndexerTypeAndShapeInference(ctx); })); diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index ed2a384f30401..d19b15b6a62f7 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -2665,8 +2665,12 @@ def past_shape(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]]) + if past_key_shape is None: + return + if len(node.input) > 14 and node.input[14]: + set_output(1, past_key_shape) + else: + set_output(1, [query_shape[0], past_key_shape[1] + query_shape[1], query_shape[3]]) return past_compressed_shape = past_shape(11) @@ -2688,9 +2692,17 @@ def past_shape(index): 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 + present_compressed_length = ( + past_compressed_shape[1] + if len(node.input) > 15 and node.input[15] + else past_compressed_shape[1] + new_window_count + ) else: - present_compressed_length = self._new_symbolic_dim_from_output(node, 2, 1) + present_compressed_length = ( + past_compressed_shape[1] + if len(node.input) > 15 and node.input[15] + else 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]]) 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 7e9f97def4d9b..78d1b4481d31c 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -88,17 +88,20 @@ struct QsaGraphOptions { int64_t num_heads = 2; int64_t head_size = 8; int64_t past_sequence_length = 4; + int64_t key_cache_capacity = 0; 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; + bool share_cache = 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; + const int64_t cache_capacity = options.share_cache ? options.key_cache_capacity : options.past_sequence_length; NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); std::vector inputs{ builder.MakeInput(std::vector{options.batch_size, options.sequence_length, options.num_heads, @@ -108,8 +111,7 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { 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}), + builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; if (options.add_csa_inputs) { inputs.push_back(builder.MakeInput( @@ -120,6 +122,9 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { for (int slot = sai::kPositionBias; slot < sai::kInputCount; ++slot) { inputs.push_back(&empty); } + if (options.share_cache) { + inputs[sai::kPastSequenceLength] = builder.MakeInput(std::vector{1}); + } std::vector outputs{builder.MakeOutput(), builder.MakeOutput()}; Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); @@ -140,9 +145,11 @@ struct CsaGraphOptions { int64_t compress_ratio = 4; int64_t index_topk = 3; int64_t past_compressed_length = 6; + int64_t compressed_cache_capacity = 0; int64_t past_buffer_length = 5; int64_t output_count = sai::kCsaOutputCount; bool add_token_budget = false; + bool share_cache = false; }; void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { @@ -161,11 +168,16 @@ void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { 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.share_cache ? options.compressed_cache_capacity + : 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}), }; + inputs.push_back(&empty); + inputs.push_back(options.share_cache ? builder.MakeInput(std::vector{1}) : &empty); std::vector outputs{builder.MakeOutput()}; for (int64_t slot = 1; slot < options.output_count; ++slot) { @@ -281,6 +293,7 @@ struct QsaProblem { int num_heads = 2; int head_size = 4; int past_sequence_length = 3; + int key_cache_capacity = 0; int rotary_width = 4; int compress_ratio = 2; int token_budget = 4; @@ -296,6 +309,8 @@ struct QsaProblem { std::vector past_key; int TotalSequenceLength() const { return past_sequence_length + sequence_length; } + int PastKeyCapacity() const { return key_cache_capacity > 0 ? key_cache_capacity : past_sequence_length; } + int PresentKeyCapacity() const { return key_cache_capacity > 0 ? key_cache_capacity : TotalSequenceLength(); } int MaxRotaryLength() const { return TotalSequenceLength(); } int Capacity() const { return token_budget + compress_ratio - 1; } }; @@ -307,16 +322,22 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std const int block_topk = problem.token_budget / problem.compress_ratio; 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); + const int past_capacity = problem.PastKeyCapacity(); + const int present_capacity = problem.PresentKeyCapacity(); + present_key.assign(static_cast(problem.batch_size) * present_capacity * head_size, 0.0f); for (int b = 0; b < problem.batch_size; ++b) { - for (int t = 0; t < total; ++t) { + for (int t = 0; t < present_capacity; ++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]; + const size_t output_index = (static_cast(b) * present_capacity + t) * head_size + d; + if (t >= problem.past_sequence_length && t < total) { + present_key[output_index] = + problem.key[(static_cast(b) * problem.sequence_length + t - problem.past_sequence_length) * + head_size + + d]; + } else if (t < past_capacity) { + present_key[output_index] = + problem.past_key[(static_cast(b) * past_capacity + t) * head_size + d]; + } } } } @@ -355,7 +376,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std 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]; + present_key[(static_cast(b) * present_capacity + token) * head_size + d]; } } for (float& element : pooled) { @@ -403,6 +424,7 @@ struct CsaProblem { int compress_ratio = 2; int index_topk = 2; int past_compressed_length = 1; + int compressed_cache_capacity = 0; int past_buffer_length = 3; int max_rotary_length = 5; float epsilon = 1.0e-6f; @@ -423,6 +445,9 @@ struct CsaProblem { std::vector past_gate_buffer; int Width() const { return 2 * head_size; } + int PastCompressedCapacity() const { + return compressed_cache_capacity > 0 ? compressed_cache_capacity : past_compressed_length; + } }; // Value of channel `channel` of token `position` of the virtual sequence [past buffer | new tokens]. @@ -448,23 +473,25 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, const int width = problem.Width(); const int present_compressed_length = problem.past_compressed_length + static_cast(plan.new_window_count); + const int compressed_capacity = + problem.compressed_cache_capacity > 0 ? problem.compressed_cache_capacity : present_compressed_length; + const int past_compressed_capacity = problem.PastCompressedCapacity(); const int present_buffer_length = static_cast(plan.present_buffer_length); 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); + static_cast(problem.batch_size) * compressed_capacity * 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 entry = 0; entry < past_compressed_capacity; ++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]; + present_compressed_key[(static_cast(b) * compressed_capacity + entry) * head_size + d] = + problem.past_compressed_key[(static_cast(b) * past_compressed_capacity + entry) * head_size + d]; } } @@ -516,7 +543,7 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, 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] = + present_compressed_key[(static_cast(b) * compressed_capacity + entry) * head_size + d] = pooled[static_cast(d)]; } } @@ -560,7 +587,7 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, 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]; + present_compressed_key[(static_cast(b) * compressed_capacity + entry) * head_size + d]; } total_score += std::max(dot, 0.0f) * problem.head_weights[row * problem.num_heads + h]; } @@ -601,7 +628,7 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { 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); + static_cast(problem.batch_size) * problem.PastKeyCapacity() * problem.head_size, 0.05f, 0.23f); // Row 0 sees four tokens (two complete blocks, no tail); row 1 sees five (two blocks plus a tail). problem.mask.assign(static_cast(problem.batch_size) * problem.sequence_length * total, 0); @@ -658,11 +685,17 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { 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}, + test.AddInput("past_key", {batch_size, problem.PastKeyCapacity(), head_size}, ToElementType(problem.past_key)); + for (int slot = sai::kGate; slot < sai::kPastSequenceLength; ++slot) { + test.AddOptionalInputEdge(); + } + if (problem.key_cache_capacity > 0) { + test.AddInput("past_sequence_length", {1}, {problem.past_sequence_length}); + } 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); + test.AddOutput("present_key", {batch_size, problem.PresentKeyCapacity(), head_size}, + ToElementType(present_key), false, 0.0f, tolerance); RunOnCuda(test); } @@ -684,7 +717,7 @@ CsaProblem MakeCsaProblem(CsaProblem problem = {}) { 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); + static_cast(problem.batch_size) * problem.PastCompressedCapacity() * 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 = @@ -759,16 +792,22 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { 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}, + test.AddInput("past_compressed_key", {batch_size, problem.PastCompressedCapacity(), 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.AddOptionalInputEdge(); + if (problem.compressed_cache_capacity > 0) { + test.AddInput("past_compressed_length", {1}, {problem.past_compressed_length}); + } 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}, + const int compressed_capacity = + problem.compressed_cache_capacity > 0 ? problem.compressed_cache_capacity : present_compressed_length; + test.AddOutput("present_compressed_key", {batch_size, compressed_capacity, 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); @@ -826,6 +865,19 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndPresentK {options.batch_size, options.past_sequence_length + options.sequence_length, options.head_size}); } +TEST(SparseAttentionIndexerShapeInferenceTest, QsaSharedCacheKeepsCapacity) { + QsaGraphOptions options; + options.share_cache = true; + options.key_cache_capacity = 32; + 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(); + ExpectShape(graph, node.OutputDefs()[sai::kPresentKey]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.key_cache_capacity, options.head_size}); +} + TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { CsaGraphOptions options; std::unique_ptr model; @@ -852,6 +904,20 @@ TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { {options.batch_size, plan.present_buffer_length, 2 * options.head_size}); } +TEST(SparseAttentionIndexerShapeInferenceTest, CsaSharedCacheKeepsCapacity) { + CsaGraphOptions options; + options.share_cache = true; + options.compressed_cache_capacity = 32; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, model)); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + ExpectShape(graph, node.OutputDefs()[sai::kPresentCompressedKey]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.compressed_cache_capacity, options.head_size}); +} + #ifndef ORT_NO_EXCEPTIONS namespace { @@ -960,6 +1026,12 @@ TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { RunQsaTest(1.0e-5f, std::move(problem)); } +TEST(SparseAttentionIndexerTest, QsaSharedCacheCapacity) { + QsaProblem problem; + problem.key_cache_capacity = 16; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } @@ -968,6 +1040,12 @@ TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProb TEST(SparseAttentionIndexerTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f); } +TEST(SparseAttentionIndexerTest, CsaSharedCacheCapacity) { + CsaProblem problem; + problem.compressed_cache_capacity = 16; + RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f); +} + TEST(SparseAttentionIndexerTest, CsaExplicitZeroScales) { CsaProblem problem = MakeCsaProblem(); problem.scale = 0.0f; 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 9986fe62895ef..05824ed0a11df 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -167,6 +167,47 @@ def test_sparse_attention_indexer_qsa(self): 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_qsa_shared_cache(self): + node = helper.make_node( + "SparseAttentionIndexer", + [ + "query", + "key", + "key_norm_weight", + "cos_cache", + "sin_cache", + "mask", + "past_key", + "", + "", + "", + "", + "", + "", + "", + "past_sequence_length", + ], + ["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", "capacity", 8]), + helper.make_tensor_value_info("past_sequence_length", TensorProto.INT32, [1]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(self._tensor_shape(outputs["present_key"]), ["batch", "capacity", 8]) + def test_sparse_attention_indexer_csa_static_with_empty_output(self): node = helper.make_node( "SparseAttentionIndexer", @@ -215,6 +256,53 @@ def test_sparse_attention_indexer_csa_static_with_empty_output(self): 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_shared_cache(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", + "", + "past_compressed_length", + ], + ["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, 32, 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]), + helper.make_tensor_value_info("past_compressed_length", TensorProto.INT32, [1]), + ] + + inferred = self._infer_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(self._tensor_shape(outputs["present_compressed_key"]), [2, 32, 8]) + def test_sparse_attention_indexer_csa_symbolic_fallback(self): node = helper.make_node( "SparseAttentionIndexer", From cf546932bfda635b0164eb88c38fc1b5da5b653a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:18:34 +0000 Subject: [PATCH 023/102] Implement packed sparse attention indexer Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 158 ++- docs/OperatorKernels.md | 1 + .../packed_sparse_attention_indexer.md | 267 +++++ .../webgpu/packed_sparse_attention_indexer.md | 48 + .../packed_sparse_attention_indexer_common.h | 85 ++ .../sparse/sparse_attention_indexer_common.h | 18 +- .../contrib_ops/cuda/cuda_contrib_kernels.cc | 6 + .../sparse/packed_sparse_attention_indexer.cc | 437 +++++++ .../sparse/packed_sparse_attention_indexer.h | 37 + .../packed_sparse_attention_indexer_impl.cu | 868 ++++++++++++++ .../packed_sparse_attention_indexer_impl.h | 106 ++ .../sparse_attention_indexer_device_math.cuh | 149 +++ .../sparse/sparse_attention_indexer_impl.cu | 96 +- .../bert/packed_sparse_attention_indexer.cc | 982 ++++++++++++++++ .../bert/packed_sparse_attention_indexer.h | 151 +++ .../webgpu/webgpu_contrib_kernels.cc | 2 + .../core/graph/contrib_ops/bert_defs.cc | 383 ++++++ onnxruntime/core/graph/contrib_ops/ms_opset.h | 2 + .../python/tools/symbolic_shape_infer.py | 51 + ...packed_sparse_attention_indexer_op_test.cc | 1044 +++++++++++++++++ ...untime_test_python_symbolic_shape_infer.py | 129 ++ 21 files changed, 4931 insertions(+), 89 deletions(-) create mode 100644 docs/contrib_ops/packed_sparse_attention_indexer.md create mode 100644 docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md create mode 100644 onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h create mode 100644 onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc create mode 100644 onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h create mode 100644 onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu create mode 100644 onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h create mode 100644 onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh create mode 100644 onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc create mode 100644 onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h create mode 100644 onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 3cc7009d0e83e..875a3483f87d4 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -79,6 +79,7 @@ Do not modify directly.* * com.microsoft.NhwcMaxPool * com.microsoft.PackedAttention * com.microsoft.PackedMultiHeadAttention + * com.microsoft.PackedSparseAttentionIndexer * com.microsoft.Pad * com.microsoft.PagedAttention * com.microsoft.QAttention @@ -4672,6 +4673,161 @@ This version of the operator has been available since version 1 of the 'com.micr +### **com.microsoft.PackedSparseAttentionIndexer** + + Packed/variable-length counterpart of SparseAttentionIndexer, for continuous-batching engines + (such as an OgaEngine-style PagedAttention model) that flatten every request's tokens into one + [total_tokens, ...] axis instead of a dense [batch_size, sequence_length, ...] axis. It selects, for + every packed query token, the sparse-attention candidates that a following SparsePagedAttention (or + similar) operator is allowed to read. + + Unlike SparseAttentionIndexer, this operator: + * takes packed query/key tensors plus cumulative_sequence_lengths (request boundaries) and + past_sequence_lengths (per-request past length) instead of a dense batch and a dense mask; + * derives ordinary causal visibility purely from that packed metadata -- there is no mask input; + * uses a single generic set of state slots (past_key_state / past_kv_buffer / past_gate_buffer / + past_state_lengths) for both policy_mode values, each with a shape that is fixed across calls + (state never grows and is never concatenated); state overflow beyond the fixed capacity is + rejected as a deterministic no-op on state rather than truncated or allowed to corrupt memory; + * additionally emits selected_counts, the exact number of active (non -1) entries per query, so + that no downstream consumer needs to scan selected_indices for its query's true count. + + Both policy_mode values keep the semantics of SparseAttentionIndexer, applied independently to each + request's own packed token range and fixed-capacity state slice: + + policy_mode = "qsa" ("query sparse attention" token indexer) + Processes each request's new tokens sequentially: appends raw indexer keys to the generic + pending buffer, and whenever it reaches compress_ratio tokens, mean-pools it, applies RMSNorm + and key_norm_weight, applies the leading/split-half rotary convention at the block's first + logical token position, and appends the prepared (already normalized and rotated) key to + key_state. Queries are scored against every causally visible complete block with + sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and + their token indices are emitted (request-local logical positions, i.e. the same numbering as + past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing + incomplete block. + + policy_mode = "csa" ("compressed sparse attention" block indexer) + Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window + count) independently per request, using that request's own buffer_length and new token count; + every newly closed window is compressed with the softmax-gated Ca/Cb pooling, normalized, + rotated and appended to key_state. Queries are scored against every causally visible compressed + entry with sum_h w_h * ReLU(q_h . k) and the index_topk highest scoring entry indices are + emitted. + + Common contract: + * selected_indices is int32 with a fixed capacity that only depends on attributes: + token_budget + compress_ratio - 1 for "qsa" (values are request-local token positions into the + main key/value cache, directly consumable by SparsePagedAttention configured with + attention_mode="selected_only", selected_kv_source="main") and index_topk for "csa" (values are + compressed-entry indices into key_state, directly consumable by SparsePagedAttention configured + with attention_mode="local_plus_selected", selected_kv_source="auxiliary"; key_state is + layout-compatible with a [batch_size, capacity, 1, head_size] auxiliary cache when K = V). + Unused entries are -1 and selected_counts holds the exact number of used entries. + * key_norm_weight is the effective RMSNorm multiplier, exactly as in SparseAttentionIndexer. + * 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. + * cos_cache / sin_cache may be shared across the batch ([max_position, rotary_width]) or + request-specific ([batch_size, max_position, rotary_width]). + * cumulative_sequence_lengths, past_sequence_lengths and past_state_lengths are read directly by + the device kernel; a zero-token request row (a repeated cumulative offset) is valid and simply + contributes no query rows for that request. + + OgaEngine integration note: this operator only defines the ORT operator; wiring + past_key_state / past_kv_buffer / past_gate_buffer / past_state_lengths as Engine-managed, + per-request fixed-size state (analogous to a paged auxiliary cache) is expected to happen in the + OgaEngine / Model Builder integration, which is out of scope for this operator definition. + +#### Version + +This version of the operator has been available since version 1 of the 'com.microsoft' operator set. + +#### Attributes + +
+
compress_ratio : int (required)
+
Number of consecutive tokens folded into one compressed/pooled entry. Must be > 0.
+
epsilon : float
+
Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.
+
head_weight_scale : float
+
Only for policy_mode 'csa': scale applied to head_weights. Default is 1/sqrt(num_heads). Must be omitted when policy_mode is 'qsa'.
+
index_topk : int
+
Only for policy_mode 'csa': number of compressed entries selected per query. Must be > 0. Must be omitted when policy_mode is 'qsa'.
+
policy_mode : string (required)
+
Indexer policy. Must be exactly 'qsa' (token indexer) or 'csa' (compressed block indexer).
+
scale : float
+
Scale applied to the per-head ReLU scores. Default is 1/sqrt(head_size).
+
state_capacity : int (required)
+
Fixed capacity (number of entries) of past_key_state / present_key_state. Must be > 0.
+
token_budget : int
+
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
+
+ +#### Inputs (10 - 15) + +
+
query : T
+
Packed indexer queries with shape (total_tokens, num_heads, head_size), already normalized but not yet rotated.
+
key : T
+
Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first head_size channels are the Ca series and the last head_size channels the Cb series.
+
key_norm_weight : T
+
Effective RMSNorm multiplier of the compressed keys, with shape (head_size).
+
cos_cache : T
+
Cosine rotary table indexed by absolute key position, shared across the batch with shape (max_rotary_sequence_length, rotary_width) or request-specific with shape (batch_size, max_rotary_sequence_length, rotary_width).
+
sin_cache : T
+
Sine rotary table with the same shape as cos_cache.
+
cumulative_sequence_lengths : M
+
Device-resident packed request boundaries with shape (batch_size + 1); cumulative_sequence_lengths[0] must be 0 and cumulative_sequence_lengths[batch_size] must equal total_tokens. Request b owns rows [cumulative_sequence_lengths[b], cumulative_sequence_lengths[b + 1]) of query/key (a repeated offset is a valid zero-token row).
+
past_sequence_lengths : M
+
Device-resident number of tokens already processed for each request before this call, with shape (batch_size). Used as the default absolute query position when position_ids is omitted (policy_mode 'qsa'), and to validate state consistency.
+
gate (optional) : T
+
Only for policy_mode 'csa': gate projection of the new tokens with shape (total_tokens, 2 * head_size).
+
position_bias (optional) : T
+
Only for policy_mode 'csa': per-slot gate bias with shape (compress_ratio, 2 * head_size).
+
head_weights (optional) : T
+
Only for policy_mode 'csa': per-head score weights with shape (total_tokens, num_heads).
+
position_ids (optional) : I
+
Optional for policy_mode 'qsa', required for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).
+
past_key_state : T
+
Generic fixed-capacity state: policy_mode 'qsa' stores prepared complete-block keys; policy_mode 'csa' stores compressed keys. Shape is (batch_size, state_capacity, head_size) and never changes across calls.
+
past_kv_buffer : T
+
Generic fixed-capacity pending-token buffer. Shape is (batch_size, 2 * compress_ratio - 1, head_size) for policy_mode 'qsa' (which only ever uses up to compress_ratio - 1 of these entries) and (batch_size, 2 * compress_ratio - 1, 2 * head_size) for policy_mode 'csa'.
+
past_gate_buffer (optional) : T
+
Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.
+
past_state_lengths : M
+
Generic per-request state length with shape (batch_size, 2). Column 0 is the key_state entry count (policy_mode 'qsa': complete-block count; 'csa': compressed-entry count); column 1 is the pending-buffer length (policy_mode 'qsa': incomplete-block length in [0, compress_ratio); 'csa': buffer length in [0, 2 * compress_ratio)).
+
+ +#### Outputs (6 - 6) + +
+
selected_indices : M
+
Selected entries with shape (total_tokens, capacity). capacity is token_budget + compress_ratio - 1 for policy_mode 'qsa' (request-local token positions into the main key/value cache) and index_topk for policy_mode 'csa' (compressed entry indices into key_state). Unused entries are -1.
+
selected_counts : M
+
Exact number of used (non -1) entries of selected_indices for every query, with shape (total_tokens).
+
present_key_state : T
+
Updated generic key state, with the same fixed shape as past_key_state.
+
present_kv_buffer : T
+
Updated generic pending-token buffer, with the same fixed shape as past_kv_buffer.
+
present_gate_buffer (optional) : T
+
Only for policy_mode 'csa': updated gate buffer with the same fixed shape as past_gate_buffer.
+
present_state_lengths : M
+
Updated generic per-request state length, with the same fixed shape as past_state_lengths.
+
+ +#### Type Constraints + +
+
T : tensor(float), tensor(float16), tensor(bfloat16)
+
Constrain floating point tensors to float, float16 and bfloat16.
+
I : tensor(int64)
+
Constrain position ids to 64-bit integer tensors.
+
M : tensor(int32)
+
Constrain packed metadata, generic state lengths and selected indices/counts to 32-bit integer tensors.
+
+ + ### **com.microsoft.Pad** Given `data` tensor, pads, mode, and value. @@ -7814,5 +7970,3 @@ No versioning maintained for experimental ops.
T : tensor(float)
Constrain input and output types to float32 tensors.
- - diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index 59a8b35f33420..9a72c7b1dac12 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1122,6 +1122,7 @@ The **OpSet Version** column uses the following notation: |NhwcConv|*in* X:**T**
*in* W:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedAttention|*in* input:**T**
*in* weights:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedMultiHeadAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| +|PackedSparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* cumulative_sequence_lengths:**M**
*in* past_sequence_lengths:**M**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_key_state:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*in* past_state_lengths:**M**
*out* selected_indices:**M**
*out* selected_counts:**M**
*out* present_key_state:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**
*out* present_state_lengths:**M**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| |PagedAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* key_cache:**T_CACHE**
*in* value_cache:**T_CACHE**
*in* cumulative_sequence_length:**S**
*in* past_seqlens:**S**
*in* block_table:**S**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* slot_mapping:**S**
*in* head_sink:**T**
*in* q_norm_weight:**T**
*in* k_norm_weight:**T**
*in* k_scale:**T_KV_SCALE**
*in* v_scale:**T_KV_SCALE**
*in* attention_metadata:**S**
*out* output:**T**
*out* key_cache_out:**T_CACHE**
*out* value_cache_out:**T_CACHE**|1+|**S** = tensor(int32)
**T** = tensor(bfloat16), tensor(float16)
**T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8), tensor(uint8)
**T_KV_SCALE** = tensor(float)| |QAttention|*in* input:**T1**
*in* weight:**T2**
*in* bias:**T3**
*in* input_scale:**T3**
*in* weight_scale:**T3**
*in* mask_index:**T4**
*in* input_zero_point:**T1**
*in* weight_zero_point:**T2**
*in* past:**T3**
*out* output:**T3**
*out* present:**T3**|1+|**T1** = tensor(int8)
**T2** = tensor(int8)
**T3** = tensor(float), tensor(float16)
**T4** = tensor(int32)| |QMoE|*in* input:**T**
*in* router_probs:**T**
*in* fc1_experts_weights:**T1**
*in* fc1_scales:**T2**
*in* fc1_experts_bias:**T**
*in* fc2_experts_weights:**T1**
*in* fc2_scales:**T2**
*in* fc2_experts_bias:**T**
*in* fc3_experts_weights:**T1**
*in* fc3_scales:**T2**
*in* fc3_experts_bias:**T**
*in* fc1_zero_points:**T1**
*in* fc2_zero_points:**T1**
*in* fc3_zero_points:**T1**
*in* router_weights:**T**
*in* fc1_global_scale:**T4**
*in* fc2_global_scale:**T4**
*in* fc1_act_scale:**T4**
*in* fc2_act_scale:**T4**
*in* fc1_act_block_scale:**T2**
*in* fc2_act_block_scale:**T2**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(float16)
**T1** = tensor(float8e4m3fn), tensor(uint8)
**T2** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(float8e8m0)
**T4** = tensor(float)| diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md new file mode 100644 index 0000000000000..712e73d2da278 --- /dev/null +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -0,0 +1,267 @@ +# PackedSparseAttentionIndexer — Operator Documentation + +This document describes the `com.microsoft::PackedSparseAttentionIndexer` contrib operator: the +packed/variable-length counterpart of `com.microsoft::SparseAttentionIndexer`, built for +continuous-batching (paged) inference engines such as an OgaEngine-style `PagedAttention` model. + +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) +(policy enum, selected-capacity formula and CSA window-plan arithmetic, shared unmodified with +`SparseAttentionIndexer`), +[packed_sparse_attention_indexer_common.h](../../onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h) +(fixed 15-input / 6-output slot map), +[packed_sparse_attention_indexer.cc](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc) / +[packed_sparse_attention_indexer_impl.cu](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu) +(CUDA), +[packed_sparse_attention_indexer.cc](../../onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc) +(WebGPU, see also [the WebGPU note](webgpu/packed_sparse_attention_indexer.md)). + +--- + +## 1. Why a separate operator + +`SparseAttentionIndexer` uses dense `[batch_size, sequence_length, ...]` tensors, an explicit dense +QSA visibility mask, and state that grows by concatenation every call +(`present_key = concat(past_key, key)`, etc.). A continuous-batching / paged engine instead: + +- flattens every request's tokens into one `[total_tokens, ...]` axis (packed layout); +- schedules a different number of new tokens per request per step; +- keeps every request's KV/indexer state in a **fixed-address, fixed-capacity** slot of a state + pool (so the engine can reuse buffers and support CUDA graph capture), never a tensor that grows; +- derives causal visibility purely from packed offsets, never from a materialized `[B, S, T]` mask. + +Restructuring `SparseAttentionIndexer` in place to support both contracts was judged more invasive +and risky than the value of the shared line count (roughly 25-35% of the dense implementation is +directly reusable without change; most of the rest needs new shapes, new state semantics, or a +different launch/index mapping). `PackedSparseAttentionIndexer` is therefore a new op, version 1, +that **does not change `SparseAttentionIndexer`'s schema or behavior**. See [§8](#8-what-is-shared-vs-packed-specific). + +## 2. Operator schema + +Attributes: + +| Attribute | Constraint | Meaning | +|---|---|---| +| `policy_mode` | required, `"qsa"` or `"csa"` | selects the indexer flavour | +| `compress_ratio` | required, `> 0` | tokens folded into one block/compressed entry | +| `state_capacity` | required, `> 0` | fixed capacity (entries) of `past_key_state` | +| `token_budget` | `qsa` only, `> 0`, divisible by `compress_ratio` | selected-token budget | +| `index_topk` | `csa` only, `> 0` | selected compressed-entry count | +| `epsilon` | default `1e-6` | RMSNorm epsilon | +| `scale` | default `1/sqrt(head_size)` | per-head score scale | +| `head_weight_scale` | `csa` only, default `1/sqrt(num_heads)` | head-weight score scale | + +Inputs are **fixed at 15 indices** for both policies (unlike the dense op, which uses a different +input/output count per policy). A slot not owned by the active policy is a *positional* optional: +its `NodeProto` input name is empty rather than the slot being removed from the list, so every +later slot keeps its fixed index. + +| # | Name | Shape | Type | Policy | +|---|---|---|---|---| +| 0 | `query` | `(total_tokens, num_heads, head_size)` | T | both | +| 1 | `key` | `(total_tokens, head_size)` qsa / `(total_tokens, 2*head_size)` csa | T | both | +| 2 | `key_norm_weight` | `(head_size)` | T | both | +| 3 | `cos_cache` | `(max_position, rotary_width)` or `(batch_size, max_position, rotary_width)` | T | both | +| 4 | `sin_cache` | same shape as `cos_cache` | T | both | +| 5 | `cumulative_sequence_lengths` | `(batch_size + 1)` | int32, device-resident | both | +| 6 | `past_sequence_lengths` | `(batch_size)` | int32, device-resident | both | +| 7 | `gate` | `(total_tokens, 2*head_size)` | T | csa only | +| 8 | `position_bias` | `(compress_ratio, 2*head_size)` | T | csa only | +| 9 | `head_weights` | `(total_tokens, num_heads)` | T | csa only | +| 10 | `position_ids` | `(total_tokens)` | int64 | optional qsa / required csa | +| 11 | `past_key_state` | `(batch_size, state_capacity, head_size)` | T | both (generic) | +| 12 | `past_kv_buffer` | `(batch_size, 2*compress_ratio-1, width)` | T | both (generic) | +| 13 | `past_gate_buffer` | same shape as `past_kv_buffer` | T | csa only | +| 14 | `past_state_lengths` | `(batch_size, 2)` | int32, device-resident | both (generic) | + +Outputs are **fixed at 6 indices** for both policies (`present_gate_buffer` is declared with an +empty output name for `qsa`, the same positional-optional convention as above): + +| # | Name | Shape | Type | Policy | +|---|---|---|---|---| +| 0 | `selected_indices` | `(total_tokens, capacity)` | int32 | both | +| 1 | `selected_counts` | `(total_tokens)` | int32 | both | +| 2 | `present_key_state` | same shape as `past_key_state` | T | both | +| 3 | `present_kv_buffer` | same shape as `past_kv_buffer` | T | both | +| 4 | `present_gate_buffer` | same shape as `past_gate_buffer` | T | csa only | +| 5 | `present_state_lengths` | same shape as `past_state_lengths` | int32 | both | + +`capacity` is `token_budget + compress_ratio - 1` for `qsa` and `index_topk` for `csa`, exactly the +same formula (`SelectedCapacity`) used by `SparseAttentionIndexer`. + +**No output shape depends on tensor data.** `total_tokens` and `batch_size` come from input +*shapes* (`query.shape[0]`, `cumulative_sequence_lengths.shape[0] - 1`); every state output has +exactly the same shape as its corresponding state input. This is what makes the fixed-capacity +state design load-bearing: a growing/concatenated state (as in the dense op) would require a +data-dependent output shape, which is incompatible with CUDA graph capture and with pre-allocated +paged state pools. + +## 3. Generic state, shared by both policies + +Both policies read and write the *same four* state slots — there is no separate +`past_compressed_key` vs. `past_key` naming split as in the dense op: + +- `past_key_state` / `present_key_state`: `qsa` stores prepared (already mean-pooled, RMSNorm'd and + rotated) complete-block keys; `csa` stores compressed keys. Layout-compatible with, or cheaply + reshaped to, a `[batch_size, capacity, 1, head_size]` auxiliary paged cache when K = V. +- `past_kv_buffer` / `present_kv_buffer` (and `past_gate_buffer` / `present_gate_buffer`, `csa` + only): the generic pending-token buffer, fixed capacity `2 * compress_ratio - 1`. `qsa` only ever + uses up to `compress_ratio - 1` of these entries (a raw, not-yet-pooled block); `csa` uses the + full range for the overlap ("Ca") plus leftover ("Cb") halves of the window-plan arithmetic + reused from `SparseAttentionIndexer`. +- `past_state_lengths` / `present_state_lengths`: `(batch_size, 2)`. Column 0 is the `key_state` + entry count (`qsa`: complete-block count; `csa`: compressed-entry count); column 1 is the pending + buffer length (`qsa`: incomplete-block length in `[0, compress_ratio)`; `csa`: buffer length in + `[0, 2 * compress_ratio)`, exactly the invariant already documented for + `CsaWindowPlan`/`TryComputeCsaWindowPlan`). + +State never grows. `present_*` always has exactly the same shape as `past_*`; only the *contents* +change. Input/output aliasing is supported: every kernel reads its sources (`past_kv_buffer` / +`key`, `past_gate_buffer` / `gate`) and never re-reads `present_*`, so it is correct whether +`present_*` is a distinct allocation or the same underlying buffer as `past_*`. + +**State overflow.** If a call would close more blocks/windows than +`state_capacity - old_entry_count` allows, that request's step is rejected as a deterministic +no-op: its state and state lengths remain unchanged, and its selection outputs stay empty. This +never reads or writes outside a tensor's fixed extent and never silently truncates state. + +## 4. Packed metadata and device-side safety + +`cumulative_sequence_lengths` and `past_sequence_lengths` are **device-resident** tensors, read +directly by the kernels — never copied to the host or synchronized on. The device-visible +invariants (validated by well-behaved callers; a malformed value never causes memory corruption, +see below) are: + +- `cumulative_sequence_lengths[0] == 0`; +- `cumulative_sequence_lengths[batch_size] == total_tokens`; +- `cumulative_sequence_lengths` is nondecreasing (a repeated offset — a zero-token request row — + is valid and simply contributes no query rows for that request); +- `past_sequence_lengths[b] >= 0` and, for `qsa`, consistent with `past_state_lengths[b]` + (`key_state_length == past_sequence_length / compress_ratio`, + `buffer_length == past_sequence_length % compress_ratio`); +- `past_state_lengths[b, 0] <= state_capacity` and `past_state_lengths[b, 1]` within its policy's + valid buffer range. + +Because there is no host synchronization, the kernels cannot literally raise a C++ exception when +one of these invariants is violated by the input data (as opposed to a mismatched tensor *shape*, +which the host-side `OpKernel::Compute` and the ONNX schema still check the ordinary way). Instead, +every per-request quantity read from these tensors is **clamped into its valid range before use** +(`old_key_len = clamp(past_state_lengths[b,0], 0, state_capacity)`, etc.), and the request-token +lookup (`PackedBatchOfToken`, a binary search over `cumulative_sequence_lengths`) always returns an +index in `[0, batch_size)`. The result is that malformed metadata can make the numeric result wrong +for the affected request, but it can never read or write outside a tensor's allocated extent and +never causes overlapping writes between requests. This mirrors the "prefer deterministic safe +outputs" guidance for EPs that cannot report device-side validation errors asynchronously. + +## 5. Policy `qsa` + +Each request's packed token range is processed independently, in the same three stages as the +dense `qsa` policy but against fixed-capacity state instead of a growing cache: + +1. **Update** (one launch per request): append each raw indexer key to the generic pending buffer; + whenever it reaches `compress_ratio` tokens, mean-pool it, apply RMSNorm and `key_norm_weight`, + apply the leading/split-half rotary convention at the block's first logical token position + (`entry * compress_ratio`, where `entry` is the block's absolute index in `key_state`), and + append the **prepared** (already normalized and rotated) key to `key_state`. This differs from + the dense kernel, which stores *raw* concatenated keys and repeats the pooling/RMSNorm/rotate + work for every query; storing the prepared key once, at update time, is possible only because + packed `key_state` never needs to be re-windowed the way a dense-mask query can. +2. **Score** (one launch per query token, per candidate block): every causally visible block + (`block index < min(key_state_length, causal_threshold(position))`) is scored directly against + `key_state` with `sum_h ReLU(q_h . k)` — a single dot product, no recomputation. +3. **Select** (one launch per query token): keeps the `token_budget / compress_ratio` highest + scoring blocks (ties broken by ascending index) and appends the request-local logical token + positions `[j * compress_ratio, ..., j * compress_ratio + compress_ratio - 1]` for each selected + block `j`, followed by every causally visible position of the current incomplete block, and + writes the exact active count to `selected_counts`. + +"Request-local logical token position" means the same numbering as +`past_sequence_lengths[b] + local_offset` — i.e. the request's own absolute token position, which +is exactly what a per-request main paged KV cache is addressed by. The output is therefore directly +consumable by `SparsePagedAttention` configured with `attention_mode="selected_only"`, +`selected_kv_source="main"`. + +## 6. Policy `csa` + +Reuses `SparseAttentionIndexer`'s CSA compression, rotary, scoring, causal threshold, and +deterministic TopK semantics verbatim (the *math* is unchanged); only the layout, state, and launch +mapping are packed: + +1. **Update** (one launch per request): computes the window plan + (`overlap_length`, `leftover_length`, `new_window_count`, `present_buffer_length`, + `present_buffer_start`) for that request's own `buffer_length` and packed token count using + `TryComputeCsaWindowPlan` — the exact same function used by `SparseAttentionIndexer`'s schema + and kernel, called directly on the device (it is a small `SAI_HOST_DEVICE` inline function with + no CUDA-specific code). Every closed window is compressed with the softmax-gated Ca/Cb pooling, + normalized, rotated with the trailing convention, and appended to `key_state`; the request + rejects (caps) new windows beyond `state_capacity` as described in [§3](#3-generic-state-shared-by-both-policies). +2. **Score** (one launch per query token, per compressed entry): every entry is scored with + `sum_h w_h * ReLU(q_h . k)` and masked by the causal threshold from `position_ids` (required for + `csa`, unlike `qsa` where it is an optional override of the default `past_sequence_length + + local offset`). +3. **Select** (one launch per query token): keeps the `index_topk` highest scoring, causally + visible entries and writes the exact active count to `selected_counts`. + +`selected_indices` values are compressed-entry indices into `key_state`, consumable by +`SparsePagedAttention` configured with `attention_mode="local_plus_selected"`, +`selected_kv_source="auxiliary"`; `key_state` is layout-compatible with, or cheaply reshaped to, the +auxiliary cache contract (`[batch_size, capacity, 1, head_size]` when K = V). + +## 7. Provider support + +CUDA and WebGPU both implement version 1 of this operator, for `float32`, `float16` (CUDA also +`bfloat16`). There is intentionally no CPU kernel (only the shared constants/helpers/schema are +CPU-agnostic); a production model that uses this op targets a paged-KV engine on an accelerator. +See [the WebGPU note](webgpu/packed_sparse_attention_indexer.md) for WebGPU-specific details. + +## 8. What is shared vs. packed-specific + +Shared with `SparseAttentionIndexer`, unmodified: + +- `Policy` enum, `TryParsePolicy`, `SelectedCapacity` (selected-capacity formula) — from + `sparse_attention_indexer_common.h`; +- `CsaWindowPlan` / `TryComputeCsaWindowPlan` (CSA window-plan arithmetic) — same header, now + additionally annotated `SAI_HOST_DEVICE` so CUDA device code can call it directly; +- the CUDA device math (`sparse_attention_indexer_device_math.cuh`, newly extracted from + `sparse_attention_indexer_impl.cu` with no behavior change): FP32 block reductions + (`SaiBlockSum`), deterministic argmax/selection (`SaiBlockArgMax`, `SaiScanForNext`), the leading + and trailing RoPE conventions (`SaiLeadingRope`, `SaiTrailingRope`), and the causal-threshold + formula (`SaiCausalThreshold`, which turns out to be exactly the "number of complete blocks fully + visible to a query" formula needed by both policies here, unifying what the dense implementation + computed two different ways). + +Deliberately **not** shared (packed-specific mechanics with no dense equivalent, or dense-only +mechanics with no packed equivalent): + +- packed metadata validation and the device-side per-token/per-request lookup + (`PackedBatchOfToken`); +- fully in-place fixed-capacity state update (no growing/concatenating state, no host-visible + data-dependent output shape); +- plain causal visibility derived from packed offsets (no dense `[B, 1, S, T]` mask input, no + mask-compaction step); +- the dense op's batch-major `[B, S, ...]` launch/index mapping and host wrappers, which do not + apply to a token-major `[total_tokens, ...]` tensor. + +## 9. Testing + +`onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc` covers: + +- shape inference for `qsa` and `csa` (fixed `selected_indices`/`selected_counts` shapes, fixed + state output shapes, the strict per-slot policy validation, and the always-6-outputs contract); +- multi-request packed batches with unequal token counts, a zero-token request row, and prefill + followed by decode with independent per-request state (CUDA/WebGPU, skipped without the + respective execution provider); +- `qsa` state-capacity overflow safety; +- FP32/FP16 (and CUDA-only BF16) numeric coverage against an in-file reference that mirrors this + document's contract. + +## 10. Known limitations and follow-ups + +- The reference CUDA/WebGPU kernels prioritize correctness over throughput (see the top-of-file + comments in the `.cu`/`.cc` implementations); they are not yet tuned for large `state_capacity` + or long packed batches. +- OgaEngine / Model Builder integration (declaring `past_key_state` etc. as Engine-managed, + per-request fixed-size state, analogous to a paged auxiliary cache) is out of scope for this + operator definition and is expected in a follow-up to `microsoft/onnxruntime-genai`. +- No CPU kernel is provided. diff --git a/docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md b/docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md new file mode 100644 index 0000000000000..c1ce8a1549358 --- /dev/null +++ b/docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md @@ -0,0 +1,48 @@ +# PackedSparseAttentionIndexer on WebGPU + +The WebGPU execution provider implements version 1 of +`com.microsoft.PackedSparseAttentionIndexer` for the `qsa` and `csa` policies. It uses the +provider-neutral schema and generic state ABI described in the +[operator documentation](../packed_sparse_attention_indexer.md). + +## Supported subset + +- packed (`total_tokens`-major) inputs, driven by device-resident + `cumulative_sequence_lengths` / `past_sequence_lengths`; +- `qsa` and `csa` policy modes; +- `float32` and `float16`; +- shared cos/sin rotary cache (`(max_position, rotary_width)`) or request-specific cache + (`(batch_size, max_position, rotary_width)`); +- generic fixed-capacity `past_key_state` / `past_kv_buffer` / `past_gate_buffer` / + `past_state_lengths` state, including input/output aliasing; +- deterministic score-descending, index-ascending top-k ties; +- `selected_counts`, the exact active-entry count per query. + +BF16 is not registered by the WebGPU kernel (CUDA only). Unknown policies and +policy-incompatible inputs or attributes are rejected. + +## Execution + +Every program is one invocation per row (one active thread per workgroup; the rest of the +workgroup is idle), exactly like the dense `SparseAttentionIndexer` WebGPU kernel: state-update +programs dispatch one row per **request**, and the score/select programs dispatch one row per +**query token**. As in the dense kernel, intermediate values (pooled/normalized/rotated keys, +per-candidate scores) are recomputed by small WGSL helper functions on demand rather than staged +into workgroup-shared arrays, both to keep every kernel correct without relying on WGSL arrays +sized by a runtime (uniform) `head_size`, and to keep the packed kernel's structure directly +comparable to the CUDA implementation's per-request/per-token update and score/select stages. All +reductions and softmax calculations accumulate in FP32, including for FP16 inputs. + +Per-request quantities (`cumulative_sequence_lengths`, `past_sequence_lengths`, +`past_state_lengths`) are read directly from device buffers inside the shaders — never on the +host — and are clamped into their valid ranges before use, so malformed packed metadata can never +cause an out-of-bounds buffer access (see the main document's device-side safety section). + +## Follow-up work + +- specialized large-candidate top-k; +- subgroup-optimized reductions; +- fused projection, pooling, and scoring; +- reduced recomputation and temporary-buffer use; +- BF16 support; +- WebGPU `SparsePagedAttention` end-to-end integration. diff --git a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h new file mode 100644 index 0000000000000..24b7ce1584071 --- /dev/null +++ b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Shared constants for com.microsoft.PackedSparseAttentionIndexer. This op reuses the policy +// enum, selected-capacity formula and CSA window-plan arithmetic already defined for +// com.microsoft.SparseAttentionIndexer in sparse_attention_indexer_common.h; it does not modify +// that header's input/output slot layout, which stays specific to the dense operator. +// +// PackedSparseAttentionIndexer instead uses packed [total_tokens, ...] query/key tensors, +// device-resident cumulative_sequence_lengths / past_sequence_lengths, and generic fixed-capacity +// state slots that are shared by both policy_mode values (unlike the dense op's policy-specific +// state names). + +#pragma once + +#include + +#include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" + +namespace onnxruntime { +namespace contrib { +namespace packed_sparse_attention_indexer { + +// Re-exported so callers only need to include this header. +using sparse_attention_indexer::CsaWindowPlan; +using sparse_attention_indexer::kPolicyModeCsa; +using sparse_attention_indexer::kPolicyModeQsa; +using sparse_attention_indexer::Policy; +using sparse_attention_indexer::SelectedCapacity; +using sparse_attention_indexer::TryComputeCsaWindowPlan; +using sparse_attention_indexer::TryParsePolicy; + +// Fixed input slots. Slots 7-9 belong to policy_mode="csa" only; slot 10 (position_ids) is +// optional for "qsa" and required for "csa". Every other slot is required for both policies. +enum InputIndex : int { + kQuery = 0, // [total_tokens, num_heads, head_size] + kKey = 1, // qsa: [total_tokens, head_size]; csa: [total_tokens, 2 * head_size] + kKeyNormWeight = 2, // [head_size] + kCosCache = 3, // [max_position, rotary_width] or [batch_size, max_position, rotary_width] + kSinCache = 4, // same shape as cos_cache + kCumulativeSequenceLengths = 5, // [batch_size + 1], int32 + kPastSequenceLengths = 6, // [batch_size], int32 + kGate = 7, // csa only: [total_tokens, 2 * head_size] + kPositionBias = 8, // csa only: [compress_ratio, 2 * head_size] + kHeadWeights = 9, // csa only: [total_tokens, num_heads] + kPositionIds = 10, // optional (qsa) / required (csa): [total_tokens], int64 + kPastKeyState = 11, // generic: [batch_size, state_capacity, head_size] + kPastKvBuffer = 12, // generic: [batch_size, 2 * compress_ratio - 1, width] + kPastGateBuffer = 13, // csa only: same shape as past_kv_buffer + kPastStateLengths = 14, // generic: [batch_size, 2], int32 + kInputCount = 15, +}; + +// Fixed output slots. present_gate_buffer is declared (with an empty name) but not produced for +// policy_mode="qsa". +enum OutputIndex : int { + kSelectedIndices = 0, // [total_tokens, selected_capacity], int32, unused entries -1 + kSelectedCounts = 1, // [total_tokens], int32 + kPresentKeyState = 2, // same shape as past_key_state + kPresentKvBuffer = 3, // same shape as past_kv_buffer + kPresentGateBuffer = 4, // csa only: same shape as past_gate_buffer + kPresentStateLengths = 5, // [batch_size, 2], int32 + kOutputCount = 6, +}; + +// Every PackedSparseAttentionIndexer node declares all 6 fixed outputs; present_gate_buffer is an +// empty-name optional output for policy_mode="qsa". +constexpr int kFixedOutputCount = kOutputCount; + +// Column layout of past_state_lengths / present_state_lengths. +enum StateLengthColumn : int { + kKeyStateLength = 0, // qsa: complete-block count; csa: compressed-entry count + kBufferLength = 1, // qsa: incomplete-block length in [0, compress_ratio); + // csa: pending buffer length in [0, 2 * compress_ratio) + kStateLengthColumns = 2, +}; + +// Generic pending-buffer capacity: qsa only ever uses up to compress_ratio - 1 of these slots. +SAI_HOST_DEVICE inline int64_t GenericBufferCapacity(int64_t compress_ratio) { + return 2 * compress_ratio - 1; +} + +} // namespace packed_sparse_attention_indexer +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h index d9eb3d1671cca..341315716a2ef 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -6,6 +6,16 @@ #include #include +// nvcc recognizes __host__/__device__ as built-in qualifiers in any translation unit it compiles +// (no CUDA header include required), but a plain host compiler does not know these tokens. This +// header is shared by CPU-only graph/schema code and by CUDA device code, so the annotation is +// only emitted when nvcc is compiling the translation unit that includes this header. +#if defined(__CUDACC__) +#define SAI_HOST_DEVICE __host__ __device__ +#else +#define SAI_HOST_DEVICE +#endif + namespace onnxruntime { namespace contrib { namespace sparse_attention_indexer { @@ -68,8 +78,8 @@ 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) { +SAI_HOST_DEVICE 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; } @@ -87,8 +97,8 @@ struct CsaWindowPlan { 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) { +SAI_HOST_DEVICE 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; diff --git a/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc b/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc index f9165bb5074e1..901bba840d994 100644 --- a/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc +++ b/onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc @@ -256,6 +256,9 @@ 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_TYPED_CLASS_NAME(1, float, PackedSparseAttentionIndexer); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, MLFloat16, PackedSparseAttentionIndexer); +class CUDA_MS_OP_TYPED_CLASS_NAME(1, BFloat16, PackedSparseAttentionIndexer); 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); @@ -565,6 +568,9 @@ Status RegisterCudaContribKernels(KernelRegistry& kernel_registry) { BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, + BuildKernelCreateInfo, + BuildKernelCreateInfo, + BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc new file mode 100644 index 0000000000000..eb23d13acd8b0 --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -0,0 +1,437 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include "contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h" + +#include +#include +#include +#include + +#include "contrib_ops/cuda/sparse/packed_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 psai = onnxruntime::contrib::packed_sparse_attention_indexer; + +#define REGISTER_KERNEL_TYPED(T) \ + ONNX_OPERATOR_TYPED_KERNEL_EX( \ + PackedSparseAttentionIndexer, \ + kMSDomain, \ + 1, \ + T, \ + kCudaExecutionProvider, \ + (*KernelDefBuilder::Create()) \ + .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("M", DataTypeImpl::GetTensorType()), \ + PackedSparseAttentionIndexer); + +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, "PackedSparseAttentionIndexer: ", name, " is required"); + const TensorShape expected_shape(expected); + ORT_RETURN_IF_NOT(tensor->Shape() == expected_shape, "PackedSparseAttentionIndexer: ", name, " must have shape ", + expected_shape.ToString(), ", got ", tensor->Shape().ToString()); + return Status::OK(); +} + +Status CheckIntDimension(const char* name, int64_t value, bool allow_zero = true) { + ORT_RETURN_IF(value < (allow_zero ? 0 : 1) || value > std::numeric_limits::max(), + "PackedSparseAttentionIndexer: ", name, " must be in ", allow_zero ? "[0, INT_MAX]" : "(0, INT_MAX]", + ", got ", value); + return Status::OK(); +} + +// cos_cache / sin_cache may be shared across the batch ([max_position, rotary_width]) or +// request-specific ([batch_size, max_position, rotary_width]). +struct RotaryCacheShape { + bool batched; + int64_t max_rotary_length; + int64_t rotary_width; +}; + +Status CheckRotaryCache(const Tensor* cos_cache, const Tensor* sin_cache, int64_t batch_size, + RotaryCacheShape& out) { + ORT_RETURN_IF(cos_cache == nullptr, "PackedSparseAttentionIndexer: cos_cache is required"); + const auto& cos_shape = cos_cache->Shape(); + out.batched = cos_shape.NumDimensions() == 3; + ORT_RETURN_IF_NOT( + (out.batched && cos_shape[0] == batch_size && cos_shape[1] > 0) || + (cos_shape.NumDimensions() == 2 && cos_shape[0] > 0), + "PackedSparseAttentionIndexer: cos_cache must have shape (max_position, rotary_width) or " + "(batch_size, max_position, rotary_width), got ", + cos_shape.ToString()); + out.max_rotary_length = out.batched ? cos_shape[1] : cos_shape[0]; + out.rotary_width = out.batched ? cos_shape[2] : cos_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", out.max_rotary_length, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", out.rotary_width, false)); + ORT_RETURN_IF_NOT(sin_cache != nullptr && sin_cache->Shape() == cos_shape, + "PackedSparseAttentionIndexer: sin_cache must have the same shape as cos_cache"); + return Status::OK(); +} + +} // namespace + +template +PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& info) : CudaKernel(info) { + std::string policy_mode; + ORT_ENFORCE(info.GetAttr("policy_mode", &policy_mode).IsOK(), + "PackedSparseAttentionIndexer: policy_mode is required"); + ORT_ENFORCE(psai::TryParsePolicy(policy_mode, policy_), "PackedSparseAttentionIndexer: policy_mode must be '", + psai::kPolicyModeQsa, "' or '", psai::kPolicyModeCsa, "', got '", policy_mode, "'"); + + ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), + "PackedSparseAttentionIndexer: compress_ratio is required"); + ORT_ENFORCE(compress_ratio_ > 0 && compress_ratio_ <= std::numeric_limits::max(), + "PackedSparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", compress_ratio_); + + int64_t state_capacity = 0; + ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity).IsOK(), + "PackedSparseAttentionIndexer: state_capacity is required"); + ORT_ENFORCE(state_capacity > 0 && state_capacity <= std::numeric_limits::max(), + "PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", state_capacity); + + 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; + has_head_weight_scale_ = info.GetAttr("head_weight_scale", &head_weight_scale).IsOK(); + + if (policy_ == psai::Policy::kQsa) { + ORT_ENFORCE(has_token_budget, + "PackedSparseAttentionIndexer: token_budget is required when policy_mode is 'qsa'"); + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale_, + "PackedSparseAttentionIndexer: 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 && + token_budget_ <= std::numeric_limits::max() - compress_ratio_ + 1, + "PackedSparseAttentionIndexer: 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, "PackedSparseAttentionIndexer: index_topk is required when policy_mode is 'csa'"); + ORT_ENFORCE(!has_token_budget, + "PackedSparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); + ORT_ENFORCE(index_topk_ > 0 && index_topk_ <= std::numeric_limits::max(), + "PackedSparseAttentionIndexer: 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, "PackedSparseAttentionIndexer: epsilon must be >= 0, got ", epsilon_); + has_scale_ = info.GetAttr("scale", &scale_).IsOK(); + head_weight_scale_ = head_weight_scale; +} + +template +Status PackedSparseAttentionIndexer::ComputeInternal(OpKernelContext* context) const { + const bool is_qsa = policy_ == psai::Policy::kQsa; + constexpr int kCsaOnlyInputs[] = {psai::kGate, psai::kPositionBias, psai::kHeadWeights}; + for (int index : kCsaOnlyInputs) { + const bool provided = index < context->InputCount() && context->Input(index) != nullptr; + ORT_RETURN_IF(provided != !is_qsa, "PackedSparseAttentionIndexer: input ", index, + provided ? " must be omitted for policy_mode 'qsa'" : " is required for policy_mode 'csa'"); + } + const bool position_ids_provided = + psai::kPositionIds < context->InputCount() && context->Input(psai::kPositionIds) != nullptr; + ORT_RETURN_IF(!is_qsa && !position_ids_provided, + "PackedSparseAttentionIndexer: input ", psai::kPositionIds, + " (position_ids) is required for policy_mode 'csa'"); + const bool gate_buffer_provided = + psai::kPastGateBuffer < context->InputCount() && context->Input(psai::kPastGateBuffer) != nullptr; + ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: input ", psai::kPastGateBuffer, + gate_buffer_provided ? " must be omitted for policy_mode 'qsa'" + : " is required for policy_mode 'csa'"); + + return is_qsa ? ComputeQsa(context) : ComputeCsa(context); +} + +template +Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { + using CudaT = typename OrtToCudaType::type; + + const Tensor* query = context->Input(psai::kQuery); + const Tensor* key = context->Input(psai::kKey); + const Tensor* key_norm_weight = context->Input(psai::kKeyNormWeight); + const Tensor* cos_cache = context->Input(psai::kCosCache); + const Tensor* sin_cache = context->Input(psai::kSinCache); + const Tensor* cumulative_sequence_lengths = context->Input(psai::kCumulativeSequenceLengths); + const Tensor* past_sequence_lengths = context->Input(psai::kPastSequenceLengths); + const Tensor* position_ids = context->Input(psai::kPositionIds); + const Tensor* past_key_state = context->Input(psai::kPastKeyState); + const Tensor* past_kv_buffer = context->Input(psai::kPastKvBuffer); + const Tensor* past_state_lengths = context->Input(psai::kPastStateLengths); + + ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, + "PackedSparseAttentionIndexer: query must have shape (total_tokens, num_heads, head_size), " + "got ", + query_shape.ToString()); + const int64_t total_tokens = query_shape[0]; + const int64_t num_heads = query_shape[1]; + const int64_t head_size = query_shape[2]; + ORT_RETURN_IF_ERROR(CheckIntDimension("total_tokens", total_tokens)); + ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); + + ORT_RETURN_IF(cumulative_sequence_lengths == nullptr, + "PackedSparseAttentionIndexer: cumulative_sequence_lengths is required"); + const auto& cu_shape = cumulative_sequence_lengths->Shape(); + ORT_RETURN_IF_NOT(cu_shape.NumDimensions() == 1 && cu_shape[0] >= 1, + "PackedSparseAttentionIndexer: cumulative_sequence_lengths must have shape (batch_size + 1), " + "got ", + cu_shape.ToString()); + const int64_t batch_size = cu_shape[0] - 1; + ORT_RETURN_IF_ERROR(CheckIntDimension("batch_size", batch_size)); + + ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); + if (position_ids != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); + } + + RotaryCacheShape rotary; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary)); + ORT_RETURN_IF_NOT( + rotary.rotary_width > 0 && rotary.rotary_width % 2 == 0 && rotary.rotary_width <= head_size, + "PackedSparseAttentionIndexer: policy_mode 'qsa' requires an even rotary_width in (0, head_size], got ", + rotary.rotary_width); + + ORT_RETURN_IF(past_key_state == nullptr, "PackedSparseAttentionIndexer: past_key_state is required"); + const auto& key_state_shape = past_key_state->Shape(); + ORT_RETURN_IF_NOT(key_state_shape.NumDimensions() == 3 && key_state_shape[0] == batch_size && + key_state_shape[2] == head_size, + "PackedSparseAttentionIndexer: past_key_state must have shape " + "(batch_size, state_capacity, head_size), got ", + key_state_shape.ToString()); + const int64_t state_capacity = key_state_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("state_capacity", state_capacity, false)); + + const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); + ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", + {batch_size, psai::kStateLengthColumns})); + + const int64_t capacity = psai::SelectedCapacity(psai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_); + + Tensor* selected_indices = context->Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); + Tensor* selected_counts = context->Output(psai::kSelectedCounts, TensorShape({total_tokens})); + Tensor* present_key_state = context->Output(psai::kPresentKeyState, key_state_shape); + Tensor* present_kv_buffer = + context->Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, head_size})); + Tensor* present_state_lengths = + context->Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + ORT_RETURN_IF(selected_indices == nullptr || selected_counts == nullptr || present_key_state == nullptr || + present_kv_buffer == nullptr || present_state_lengths == nullptr, + "PackedSparseAttentionIndexer: policy_mode 'qsa' requires selected_indices, selected_counts, " + "present_key_state, present_kv_buffer and present_state_lengths outputs"); + + PackedSparseAttentionIndexerParams params; + params.batch_size = static_cast(batch_size); + params.total_tokens = static_cast(total_tokens); + params.num_heads = static_cast(num_heads); + params.head_size = static_cast(head_size); + params.rotary_width = static_cast(rotary.rotary_width); + params.max_rotary_length = static_cast(rotary.max_rotary_length); + params.cos_cache_batched = rotary.batched; + params.compress_ratio = static_cast(compress_ratio_); + params.state_capacity = static_cast(state_capacity); + params.buffer_capacity = static_cast(buffer_capacity); + params.capacity = static_cast(capacity); + params.has_position_ids = position_ids != nullptr; + params.epsilon = epsilon_; + params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.block_topk = static_cast(token_budget_ / compress_ratio_); + + auto float_workspace = GetScratchBuffer(GetQsaPackedWorkspaceFloatCount(params), GetComputeStream(context)); + auto overflow_flags = + GetScratchBuffer(static_cast(std::max(batch_size, 1)), GetComputeStream(context)); + + return LaunchQsaPackedSparseAttentionIndexer( + 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()), + cumulative_sequence_lengths->Data(), + past_sequence_lengths->Data(), + position_ids != nullptr ? position_ids->Data() : nullptr, + reinterpret_cast(past_key_state->Data()), + reinterpret_cast(past_kv_buffer->Data()), + past_state_lengths->Data(), + selected_indices->MutableData(), + selected_counts->MutableData(), + reinterpret_cast(present_key_state->MutableData()), + reinterpret_cast(present_kv_buffer->MutableData()), + present_state_lengths->MutableData(), + float_workspace.get(), + overflow_flags.get()); +} + +template +Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { + using CudaT = typename OrtToCudaType::type; + + const Tensor* query = context->Input(psai::kQuery); + const Tensor* key = context->Input(psai::kKey); + const Tensor* key_norm_weight = context->Input(psai::kKeyNormWeight); + const Tensor* cos_cache = context->Input(psai::kCosCache); + const Tensor* sin_cache = context->Input(psai::kSinCache); + const Tensor* cumulative_sequence_lengths = context->Input(psai::kCumulativeSequenceLengths); + const Tensor* past_sequence_lengths = context->Input(psai::kPastSequenceLengths); + const Tensor* gate = context->Input(psai::kGate); + const Tensor* position_bias = context->Input(psai::kPositionBias); + const Tensor* head_weights = context->Input(psai::kHeadWeights); + const Tensor* position_ids = context->Input(psai::kPositionIds); + const Tensor* past_key_state = context->Input(psai::kPastKeyState); + const Tensor* past_kv_buffer = context->Input(psai::kPastKvBuffer); + const Tensor* past_gate_buffer = context->Input(psai::kPastGateBuffer); + const Tensor* past_state_lengths = context->Input(psai::kPastStateLengths); + + ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, + "PackedSparseAttentionIndexer: query must have shape (total_tokens, num_heads, head_size), " + "got ", + query_shape.ToString()); + const int64_t total_tokens = query_shape[0]; + const int64_t num_heads = query_shape[1]; + const int64_t head_size = query_shape[2]; + ORT_RETURN_IF_ERROR(CheckIntDimension("total_tokens", total_tokens)); + ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); + ORT_RETURN_IF(head_size > std::numeric_limits::max() / 2, + "PackedSparseAttentionIndexer: 2 * head_size must be no greater than INT_MAX"); + const int64_t width = 2 * head_size; + + ORT_RETURN_IF(cumulative_sequence_lengths == nullptr, + "PackedSparseAttentionIndexer: cumulative_sequence_lengths is required"); + const auto& cu_shape = cumulative_sequence_lengths->Shape(); + ORT_RETURN_IF_NOT(cu_shape.NumDimensions() == 1 && cu_shape[0] >= 1, + "PackedSparseAttentionIndexer: cumulative_sequence_lengths must have shape (batch_size + 1), " + "got ", + cu_shape.ToString()); + const int64_t batch_size = cu_shape[0] - 1; + ORT_RETURN_IF_ERROR(CheckIntDimension("batch_size", batch_size)); + + ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, width})); + ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); + ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {total_tokens, width})); + ORT_RETURN_IF_ERROR(CheckShape(position_bias, "position_bias", {compress_ratio_, width})); + ORT_RETURN_IF_ERROR(CheckShape(head_weights, "head_weights", {total_tokens, num_heads})); + ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); + + RotaryCacheShape rotary; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary)); + ORT_RETURN_IF_NOT(rotary.rotary_width > 0 && 2 * rotary.rotary_width <= head_size, + "PackedSparseAttentionIndexer: policy_mode 'csa' requires 0 < 2 * rotary_width <= head_size, " + "got rotary_width=", + rotary.rotary_width, " head_size=", head_size); + + ORT_RETURN_IF(past_key_state == nullptr, "PackedSparseAttentionIndexer: past_key_state is required"); + const auto& key_state_shape = past_key_state->Shape(); + ORT_RETURN_IF_NOT(key_state_shape.NumDimensions() == 3 && key_state_shape[0] == batch_size && + key_state_shape[2] == head_size, + "PackedSparseAttentionIndexer: past_key_state must have shape " + "(batch_size, state_capacity, head_size), got ", + key_state_shape.ToString()); + const int64_t state_capacity = key_state_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("state_capacity", state_capacity, false)); + + const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); + ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, width})); + ORT_RETURN_IF_ERROR(CheckShape(past_gate_buffer, "past_gate_buffer", {batch_size, buffer_capacity, width})); + ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", + {batch_size, psai::kStateLengthColumns})); + + const int64_t capacity = psai::SelectedCapacity(psai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_); + + Tensor* selected_indices = context->Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); + Tensor* selected_counts = context->Output(psai::kSelectedCounts, TensorShape({total_tokens})); + Tensor* present_key_state = context->Output(psai::kPresentKeyState, key_state_shape); + Tensor* present_kv_buffer = + context->Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, width})); + Tensor* present_gate_buffer = + context->Output(psai::kPresentGateBuffer, TensorShape({batch_size, buffer_capacity, width})); + Tensor* present_state_lengths = + context->Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + ORT_RETURN_IF(selected_indices == nullptr || selected_counts == nullptr || present_key_state == nullptr || + present_kv_buffer == nullptr || present_gate_buffer == nullptr || + present_state_lengths == nullptr, + "PackedSparseAttentionIndexer: policy_mode 'csa' requires selected_indices, selected_counts, " + "present_key_state, present_kv_buffer, present_gate_buffer and present_state_lengths outputs"); + + PackedSparseAttentionIndexerParams params; + params.batch_size = static_cast(batch_size); + params.total_tokens = static_cast(total_tokens); + params.num_heads = static_cast(num_heads); + params.head_size = static_cast(head_size); + params.rotary_width = static_cast(rotary.rotary_width); + params.max_rotary_length = static_cast(rotary.max_rotary_length); + params.cos_cache_batched = rotary.batched; + params.compress_ratio = static_cast(compress_ratio_); + params.state_capacity = static_cast(state_capacity); + params.buffer_capacity = static_cast(buffer_capacity); + params.capacity = static_cast(capacity); + params.has_position_ids = true; + params.epsilon = epsilon_; + params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); + params.index_topk = static_cast(index_topk_); + params.head_weight_scale = + has_head_weight_scale_ ? head_weight_scale_ : 1.0f / std::sqrt(static_cast(num_heads)); + + auto float_workspace = GetScratchBuffer(GetCsaPackedWorkspaceFloatCount(params), GetComputeStream(context)); + auto overflow_flags = + GetScratchBuffer(static_cast(std::max(batch_size, 1)), GetComputeStream(context)); + + return LaunchCsaPackedSparseAttentionIndexer( + 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()), + cumulative_sequence_lengths->Data(), + past_sequence_lengths->Data(), + position_ids->Data(), + reinterpret_cast(past_key_state->Data()), + reinterpret_cast(past_kv_buffer->Data()), + reinterpret_cast(past_gate_buffer->Data()), + past_state_lengths->Data(), + selected_indices->MutableData(), + selected_counts->MutableData(), + reinterpret_cast(present_key_state->MutableData()), + reinterpret_cast(present_kv_buffer->MutableData()), + reinterpret_cast(present_gate_buffer->MutableData()), + present_state_lengths->MutableData(), + float_workspace.get(), + overflow_flags.get()); +} + +template class PackedSparseAttentionIndexer; +template class PackedSparseAttentionIndexer; +template class PackedSparseAttentionIndexer; + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h new file mode 100644 index 0000000000000..b7ddfa9b480ce --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include "contrib_ops/cpu/sparse/packed_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 PackedSparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel { + public: + explicit PackedSparseAttentionIndexer(const OpKernelInfo& info); + Status ComputeInternal(OpKernelContext* context) const override; + + private: + Status ComputeQsa(OpKernelContext* context) const; + Status ComputeCsa(OpKernelContext* context) const; + + packed_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_; + bool has_scale_; + bool has_head_weight_scale_; +}; + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu new file mode 100644 index 0000000000000..36f1a6a521b9b --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -0,0 +1,868 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Correctness-first implementation of com.microsoft.PackedSparseAttentionIndexer. It shares its +// block reductions, deterministic argmax/selection helpers, RoPE math and causal-threshold formula +// with com.microsoft.SparseAttentionIndexer via sparse_attention_indexer_device_math.cuh, and +// reuses the CSA window-plan arithmetic directly on the device from +// sparse_attention_indexer_common.h (its helpers are SAI_HOST_DEVICE). What is packed-specific: +// token-major (not batch-major) layout, cumulative_sequence_lengths / past_sequence_lengths driven +// per-request bookkeeping, fully in-place fixed-capacity state update (no growing/concatenating +// state), and plain causal visibility derived from packed metadata (no dense mask). +// +// Device-side safety: every per-request quantity (past_sequence_lengths, past_state_lengths, +// cumulative offsets) is read directly from device memory inside the kernels below -- there is no +// host readback or stream synchronization. Values are always clamped into the fixed-capacity range +// before use, so malformed metadata can make the result semantically wrong but can never cause an +// out-of-bounds access or an overlapping write. State-capacity overflow is *rejected*, not +// silently truncated: if a request's new blocks/windows would not all fit in state_capacity this +// call, the update kernel applies none of them (present_state_lengths / present_key_state / +// present_kv_buffer / present_gate_buffer for that request are left exactly as their past_* +// counterparts) and records the rejection in a small overflow_flags workspace; the select kernels +// then force that request's selected_indices/selected_counts to the deterministic safe empty +// result (-1 / 0) for this call instead of selecting against a partially updated state. See +// QsaUpdateStateKernel / CsaUpdateStateKernel / QsaSelectKernel / CsaSelectKernel. +// +// See docs/contrib_ops/cuda/packed_sparse_attention_indexer.md for the full operator contract. + +#include "contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h" + +#include +#include +#include + +#include + +#include "contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h" +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh" +#include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; + +namespace { + +// The block reductions below halve the active thread count, so this must stay a power of two. +constexpr int kThreads = 128; + +// Largest b such that cumulative_sequence_lengths[b] <= token, assuming the array is nondecreasing. +// If the data itself is malformed this may attribute a token to the wrong request, but the result +// is always an index in [0, batch_size), so it can never cause an out-of-bounds access. +__device__ __forceinline__ int PackedBatchOfToken(const int32_t* cumulative_sequence_lengths, int batch_size, + int token) { + int lo = 0; + int hi = batch_size - 1; + while (lo < hi) { + const int mid = lo + (hi - lo + 1) / 2; + if (cumulative_sequence_lengths[mid] <= token) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; +} + +template +__global__ void ElementwiseCopyKernel(const T* src, T* dst, int64_t count) { + for (int64_t i = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; i < count; + i += static_cast(gridDim.x) * blockDim.x) { + dst[i] = src[i]; + } +} + +// --------------------------------------------------------------------------------------------- +// policy_mode = "qsa" +// --------------------------------------------------------------------------------------------- + +// One block per request: forms every newly-closed compress_ratio block (mean-pool -> RMSNorm -> +// leading RoPE -> append into fixed-capacity key_state) and publishes the raw trailing buffer. +// Reads only past_kv_buffer / key (never present_kv_buffer / present_key_state), so it is correct +// whether or not the present/past tensors are the same aliased allocation. +template +__global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, const T* cos_cache, + const T* sin_cache, const int32_t* cumulative_sequence_lengths, + const T* past_kv_buffer, const int32_t* past_state_lengths, + T* present_key_state, T* present_kv_buffer, + int32_t* present_state_lengths, int32_t* overflow_flags, + PackedSparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* pooled = shared; + float* rotated = shared + params.head_size; + float* reduction = shared + 2 * params.head_size; + + for (int b = static_cast(blockIdx.x); b < params.batch_size; b += static_cast(gridDim.x)) { + const int req_start = cumulative_sequence_lengths[b]; + const int req_end = cumulative_sequence_lengths[b + 1]; + const int req_len = req_end > req_start ? req_end - req_start : 0; + + const int old_key_len = min(max(past_state_lengths[b * 2 + psai::kKeyStateLength], 0), params.state_capacity); + const int old_buf_len = + min(max(past_state_lengths[b * 2 + psai::kBufferLength], 0), params.compress_ratio - 1); + + const int pending = old_buf_len + req_len; + const int full_new_block_count = pending / params.compress_ratio; + const int capacity_left = params.state_capacity - old_key_len; // >= 0 by construction of old_key_len + // Reject (do not partially apply) a step that would need more than the fixed state_capacity: + // no new blocks are formed and the buffer is left exactly as it was, so a rejected step is a + // deterministic no-op on state rather than a silent partial truncation. + const bool overflowed = full_new_block_count > capacity_left; + const int new_block_count = overflowed ? 0 : full_new_block_count; + const int new_buf_len = overflowed ? old_buf_len : (pending % params.compress_ratio); + + // Barrier: every thread has now read past_state_lengths (identically) before any thread below + // writes present_state_lengths, which keeps this correct even if the two tensors alias. + __syncthreads(); + + if (threadIdx.x == 0) { + present_state_lengths[b * 2 + psai::kKeyStateLength] = old_key_len + new_block_count; + present_state_lengths[b * 2 + psai::kBufferLength] = new_buf_len; + overflow_flags[b] = overflowed ? 1 : 0; + } + + for (int k = 0; k < new_block_count; ++k) { + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + float sum = 0.0f; + for (int t = 0; t < params.compress_ratio; ++t) { + const int virtual_pos = k * params.compress_ratio + t; + sum += virtual_pos < old_buf_len + ? to_float(past_kv_buffer[(static_cast(b) * params.buffer_capacity + virtual_pos) * + params.head_size + + d]) + : to_float(key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * + params.head_size + + d]); + } + pooled[d] = sum / static_cast(params.compress_ratio); + } + __syncthreads(); + + float sum_squares = 0.0f; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = SaiBlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + pooled[d] = pooled[d] * inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + + const int entry = old_key_len + k; + const int rope_position = + SaiClampPosition(static_cast(entry) * params.compress_ratio, params.max_rotary_length); + const int64_t cache_offset = + (static_cast(params.cos_cache_batched ? b : 0) * params.max_rotary_length + rope_position) * + params.rotary_width; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + rotated[d] = SaiLeadingRope(pooled, params.rotary_width, cos_cache + cache_offset, + sin_cache + cache_offset, d); + } + __syncthreads(); + + const int64_t out_base = (static_cast(b) * params.state_capacity + entry) * params.head_size; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + present_key_state[out_base + d] = from_float(rotated[d]); + } + __syncthreads(); + } + + // Publish the raw trailing buffer. Skipped entirely on overflow: present_kv_buffer already + // holds past_kv_buffer's contents unchanged (from the baseline copy in the Launch function + // below), which is exactly the prior valid buffer this rejected step must preserve. + if (!overflowed) { + for (int t = static_cast(threadIdx.x); t < new_buf_len; t += static_cast(blockDim.x)) { + const int virtual_pos = new_block_count * params.compress_ratio + t; + const int64_t out_base = (static_cast(b) * params.buffer_capacity + t) * params.head_size; + for (int d = 0; d < params.head_size; ++d) { + const float value = + virtual_pos < old_buf_len + ? to_float( + past_kv_buffer[(static_cast(b) * params.buffer_capacity + virtual_pos) * + params.head_size + + d]) + : to_float( + key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * params.head_size + d]); + present_kv_buffer[out_base + d] = from_float(value); + } + } + } + __syncthreads(); + } +} + +// One block per (token, head): rotates the query once so downstream scoring kernels only ever dot +// two already-rotated/prepared vectors. kUseLeadingRope selects the qsa convention (position +// defaults to past_sequence_lengths[batch] + request-local offset when position_ids is absent); +// otherwise the csa trailing convention with positions always taken from position_ids. +template +__global__ void PackedRotateQueryKernel(const T* query, const T* cos_cache, const T* sin_cache, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + float* query_rotated, PackedSparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + const int64_t rows = static_cast(params.total_tokens) * params.num_heads; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const int token = static_cast(row / params.num_heads); + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int64_t base = row * params.head_size; + + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + shared[d] = to_float(query[base + d]); + } + __syncthreads(); + + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int position = SaiClampPosition(abs_position, params.max_rotary_length); + const int64_t cache_offset = + (static_cast(params.cos_cache_batched ? batch : 0) * params.max_rotary_length + position) * + params.rotary_width; + const T* cos_row = cos_cache + cache_offset; + const T* sin_row = sin_cache + cache_offset; + + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + query_rotated[base + d] = kUseLeadingRope + ? SaiLeadingRope(shared, params.rotary_width, cos_row, sin_row, d) + : SaiTrailingRope(shared, params.head_size, params.rotary_width, cos_row, + sin_row, d); + } + __syncthreads(); + } +} + +// One block per (token, key_state slot). Scores are directly dotted against the already-prepared +// present_key_state entry (unlike the dense op, no per-query pooling/normalize/rotate is repeated +// here because the packed contract stores fully-prepared blocks in key_state). +template +__global__ void QsaBlockScoreKernel(const T* present_key_state, const float* query_rotated, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, float* block_scores, + PackedSparseAttentionIndexerParams params) { + extern __shared__ float reduction[]; + const int64_t total = static_cast(params.total_tokens) * params.state_capacity; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int token = static_cast(work / params.state_capacity); + const int block_index = static_cast(work % params.state_capacity); + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + + if (block_index >= key_len_after) { + if (threadIdx.x == 0) { + block_scores[work] = SaiNegativeInfinity(); + } + continue; + } + + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); + const int64_t visible_block_count = causal_count < key_len_after ? causal_count : key_len_after; + if (static_cast(block_index) >= visible_block_count) { + if (threadIdx.x == 0) { + block_scores[work] = SaiNegativeInfinity(); + } + continue; + } + + const int64_t key_base = (static_cast(batch) * params.state_capacity + block_index) * params.head_size; + float score = 0.0f; + for (int head = 0; head < params.num_heads; ++head) { + const float* query_head = + query_rotated + (static_cast(token) * params.num_heads + head) * params.head_size; + float partial = 0.0f; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + partial += query_head[d] * to_float(present_key_state[key_base + d]); + } + score += fmaxf(SaiBlockSum(partial, reduction), 0.0f); + } + if (threadIdx.x == 0) { + block_scores[work] = score * params.scale; + } + __syncthreads(); + } +} + +// One block per query token. Emits the token indices of the highest scoring blocks followed by the +// causally visible tokens of the trailing incomplete block, and the exact active count. A request +// whose update step was rejected for exceeding state_capacity this call (overflow_flags[batch] set) +// always gets the safe empty result: indices stay -1 (already reset below) and count is 0. +__global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, const int32_t* overflow_flags, + int32_t* selected_indices, int32_t* selected_counts, + PackedSparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* shared_value = shared; + int* shared_index = reinterpret_cast(shared + blockDim.x); + + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + int32_t* out_row = selected_indices + static_cast(token) * params.capacity; + for (int p = static_cast(threadIdx.x); p < params.capacity; p += static_cast(blockDim.x)) { + out_row[p] = -1; + } + __syncthreads(); + + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + if (overflow_flags[batch] != 0) { + if (threadIdx.x == 0) { + selected_counts[token] = 0; + } + __syncthreads(); + continue; + } + + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int visible_block_count = static_cast(visible_64 < 0 ? 0 : visible_64); + const int selected = params.block_topk < visible_block_count ? params.block_topk : visible_block_count; + + const float* scores_row = block_scores + static_cast(token) * params.state_capacity; + + float previous_score = 0.0f; + int previous_index = -1; + int emitted_blocks = 0; + for (int rank = 0; rank < selected; ++rank) { + float best_value = 0.0f; + int best_index = -1; + SaiScanForNext(scores_row, visible_block_count, previous_score, previous_index, &best_value, &best_index); + shared_value[threadIdx.x] = best_value; + shared_index[threadIdx.x] = best_index; + __syncthreads(); + SaiBlockArgMax(shared_value, shared_index); + previous_index = shared_index[0]; + previous_score = shared_value[0]; + __syncthreads(); + if (previous_index < 0) { + break; + } + for (int t = static_cast(threadIdx.x); t < params.compress_ratio; t += static_cast(blockDim.x)) { + out_row[rank * params.compress_ratio + t] = previous_index * params.compress_ratio + t; + } + emitted_blocks = rank + 1; + __syncthreads(); + } + + // The trailing incomplete block is always causally visible in full up to this query's own + // position; only its indices are needed (SparsePagedAttention reads the raw main cache). + const int64_t block_start = static_cast(visible_block_count) * params.compress_ratio; + const int64_t natural_tail = abs_position >= block_start ? (abs_position - block_start + 1) : 0; + const int remaining_capacity = params.capacity - emitted_blocks * params.compress_ratio; + const int64_t tail_count_64 = natural_tail < remaining_capacity ? natural_tail : remaining_capacity; + const int tail_count = static_cast(tail_count_64 < 0 ? 0 : tail_count_64); + for (int t = static_cast(threadIdx.x); t < tail_count; t += static_cast(blockDim.x)) { + out_row[emitted_blocks * params.compress_ratio + t] = static_cast(block_start + t); + } + if (threadIdx.x == 0) { + selected_counts[token] = emitted_blocks * params.compress_ratio + tail_count; + } + __syncthreads(); + } +} + +// --------------------------------------------------------------------------------------------- +// policy_mode = "csa" +// --------------------------------------------------------------------------------------------- + +// One block per request: closes every new compression window (softmax-gated pool -> RMSNorm -> +// trailing RoPE -> append into fixed-capacity key_state) using the shared CsaWindowPlan helper, +// and publishes the raw overlap+leftover buffer. Reads only past_kv_buffer / past_gate_buffer / +// key / gate (never the present_* tensors), so it is correct whether or not present/past alias. +template +__global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_norm_weight, const T* cos_cache, + const T* sin_cache, const T* position_bias, + const int32_t* cumulative_sequence_lengths, const T* past_kv_buffer, + const T* past_gate_buffer, const int32_t* past_state_lengths, + T* present_key_state, T* present_kv_buffer, T* present_gate_buffer, + int32_t* present_state_lengths, int32_t* overflow_flags, + PackedSparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* pooled = shared; + float* reduction = shared + params.head_size; + const int width = 2 * params.head_size; + + for (int b = static_cast(blockIdx.x); b < params.batch_size; b += static_cast(gridDim.x)) { + const int req_start = cumulative_sequence_lengths[b]; + const int req_end = cumulative_sequence_lengths[b + 1]; + const int req_len = req_end > req_start ? req_end - req_start : 0; + + const int old_key_len = min(max(past_state_lengths[b * 2 + psai::kKeyStateLength], 0), params.state_capacity); + const int old_buf_len = + min(max(past_state_lengths[b * 2 + psai::kBufferLength], 0), params.buffer_capacity); + + sai::CsaWindowPlan plan; + const bool plan_ok = sai::TryComputeCsaWindowPlan(old_buf_len, req_len, params.compress_ratio, plan); + // plan_ok is always true here: old_buf_len is clamped into [0, buffer_capacity) == + // [0, 2 * compress_ratio) and req_len >= 0, which are exactly the documented preconditions. + const int full_new_window_count = plan_ok ? static_cast(plan.new_window_count) : 0; + const int capacity_left_raw = params.state_capacity - old_key_len; + const int capacity_left = capacity_left_raw > 0 ? capacity_left_raw : 0; + // Reject (do not partially apply) a step that would need more than the fixed state_capacity: + // no new windows are closed and the buffer is left exactly as it was, so a rejected step is a + // deterministic no-op on state rather than a silent partial truncation. + const bool overflowed = full_new_window_count > capacity_left; + const int new_window_count = overflowed ? 0 : full_new_window_count; + const int present_buffer_length = + overflowed ? old_buf_len + : (static_cast(plan.present_buffer_length) < params.buffer_capacity + ? static_cast(plan.present_buffer_length) + : params.buffer_capacity); + const int present_buffer_start = overflowed ? 0 : static_cast(plan.present_buffer_start); + const int overlap_length = static_cast(plan.overlap_length); + + // Barrier: every thread has now read past_state_lengths / computed the plan (identically) + // before any thread below writes present_state_lengths, which keeps this correct even if the + // two tensors alias. + __syncthreads(); + + if (threadIdx.x == 0) { + present_state_lengths[b * 2 + psai::kKeyStateLength] = old_key_len + new_window_count; + present_state_lengths[b * 2 + psai::kBufferLength] = present_buffer_length; + overflow_flags[b] = overflowed ? 1 : 0; + } + + for (int k = 0; k < new_window_count; ++k) { + const bool has_previous = k >= 1 || overlap_length >= params.compress_ratio; + const int previous_base = overlap_length + (k - 1) * params.compress_ratio; + const int current_base = overlap_length + k * params.compress_ratio; + + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + float max_gate = SaiNegativeInfinity(); + if (has_previous) { + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const int virtual_pos = previous_base + slot; + const float value = + (virtual_pos < old_buf_len + ? to_float(past_gate_buffer[(static_cast(b) * params.buffer_capacity + + virtual_pos) * + width + + d]) + : to_float(gate[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + + d])) + + 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 int virtual_pos = current_base + slot; + const float value = + (virtual_pos < old_buf_len + ? to_float(past_gate_buffer[(static_cast(b) * params.buffer_capacity + virtual_pos) * + width + + params.head_size + d]) + : to_float(gate[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + + params.head_size + d])) + + 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 int virtual_pos = previous_base + slot; + const float logit = + (virtual_pos < old_buf_len + ? to_float(past_gate_buffer[(static_cast(b) * params.buffer_capacity + + virtual_pos) * + width + + d]) + : to_float(gate[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + + d])) + + to_float(position_bias[static_cast(slot) * width + d]); + const float weight = __expf(logit - max_gate); + denominator += weight; + accumulator += + weight * + (virtual_pos < old_buf_len + ? to_float(past_kv_buffer[(static_cast(b) * params.buffer_capacity + + virtual_pos) * + width + + d]) + : to_float(key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + + d])); + } + } + for (int slot = 0; slot < params.compress_ratio; ++slot) { + const int virtual_pos = current_base + slot; + const float logit = + (virtual_pos < old_buf_len + ? to_float(past_gate_buffer[(static_cast(b) * params.buffer_capacity + virtual_pos) * + width + + params.head_size + d]) + : to_float(gate[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + + params.head_size + d])) + + to_float(position_bias[static_cast(slot) * width + params.head_size + d]); + const float weight = __expf(logit - max_gate); + denominator += weight; + accumulator += + weight * (virtual_pos < old_buf_len + ? to_float(past_kv_buffer[(static_cast(b) * params.buffer_capacity + + virtual_pos) * + width + + params.head_size + d]) + : to_float(key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * + width + + params.head_size + d])); + } + pooled[d] = denominator > 0.0f ? accumulator / denominator : 0.0f; + } + __syncthreads(); + + float sum_squares = 0.0f; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = SaiBlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + pooled[d] = pooled[d] * inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + + const int entry = old_key_len + k; + const int rope_position = + SaiClampPosition(static_cast(entry) * params.compress_ratio, params.max_rotary_length); + const int64_t cache_offset = + (static_cast(params.cos_cache_batched ? b : 0) * params.max_rotary_length + rope_position) * + params.rotary_width; + const int64_t out_base = (static_cast(b) * params.state_capacity + entry) * params.head_size; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + present_key_state[out_base + d] = from_float(SaiTrailingRope( + pooled, params.head_size, params.rotary_width, cos_cache + cache_offset, sin_cache + cache_offset, d)); + } + __syncthreads(); + } + + // Publish the raw overlap+leftover buffer. Skipped entirely on overflow: present_kv_buffer / + // present_gate_buffer already hold past_kv_buffer's / past_gate_buffer's contents unchanged + // (from the baseline copy in the Launch function below), which is exactly the prior valid + // buffer this rejected step must preserve. + if (!overflowed) { + for (int t = static_cast(threadIdx.x); t < present_buffer_length; t += static_cast(blockDim.x)) { + const int virtual_pos = present_buffer_start + t; + const int64_t out_base = (static_cast(b) * params.buffer_capacity + t) * width; + for (int c = 0; c < width; ++c) { + const float key_value = + virtual_pos < old_buf_len + ? to_float( + past_kv_buffer[(static_cast(b) * params.buffer_capacity + virtual_pos) * width + c]) + : to_float(key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + c]); + const float gate_value = + virtual_pos < old_buf_len + ? to_float(past_gate_buffer[(static_cast(b) * params.buffer_capacity + virtual_pos) * + width + + c]) + : to_float(gate[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + c]); + present_kv_buffer[out_base + c] = from_float(key_value); + present_gate_buffer[out_base + c] = from_float(gate_value); + } + } + } + __syncthreads(); + } +} + +// One block per (token, key_state slot). Also applies the causal mask so the selection kernel only +// has to read scores. +template +__global__ void CsaScoreKernel(const T* present_key_state, const float* query_rotated, const T* head_weights, + const int32_t* cumulative_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, float* scores, + PackedSparseAttentionIndexerParams params) { + extern __shared__ float reduction[]; + const int64_t total = static_cast(params.total_tokens) * params.state_capacity; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int token = static_cast(work / params.state_capacity); + const int entry = static_cast(work % params.state_capacity); + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + + if (entry >= key_len_after) { + if (threadIdx.x == 0) { + scores[work] = SaiNegativeInfinity(); + } + continue; + } + const int64_t threshold = SaiCausalThreshold(position_ids[token], params.compress_ratio); + if (static_cast(entry) >= threshold) { + if (threadIdx.x == 0) { + scores[work] = SaiNegativeInfinity(); + } + continue; + } + + const int64_t key_base = (static_cast(batch) * params.state_capacity + entry) * params.head_size; + float total_score = 0.0f; + for (int head = 0; head < params.num_heads; ++head) { + const float* query_head = + query_rotated + (static_cast(token) * params.num_heads + head) * params.head_size; + float partial = 0.0f; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + partial += query_head[d] * to_float(present_key_state[key_base + d]); + } + const float dot = SaiBlockSum(partial, reduction); + if (threadIdx.x == 0) { + total_score += fmaxf(dot, 0.0f) * to_float(head_weights[static_cast(token) * params.num_heads + + head]); + } + __syncthreads(); + } + if (threadIdx.x == 0) { + scores[work] = total_score * params.scale * params.head_weight_scale; + } + __syncthreads(); + } +} + +// One block per query token. Selects the index_topk highest scoring, causally-visible compressed +// entries and the exact active count. A request whose update step was rejected for exceeding +// state_capacity this call (overflow_flags[batch] set) always gets the safe empty result: indices +// stay -1 (already reset below) and count is 0. +__global__ void CsaSelectKernel(const float* scores, const int32_t* cumulative_sequence_lengths, + const int64_t* position_ids, const int32_t* present_state_lengths, + const int32_t* overflow_flags, int32_t* selected_indices, + int32_t* selected_counts, PackedSparseAttentionIndexerParams params) { + extern __shared__ float shared[]; + float* shared_value = shared; + int* shared_index = reinterpret_cast(shared + blockDim.x); + + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + int32_t* out_row = selected_indices + static_cast(token) * params.capacity; + for (int p = static_cast(threadIdx.x); p < params.capacity; p += static_cast(blockDim.x)) { + out_row[p] = -1; + } + __syncthreads(); + + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + if (overflow_flags[batch] != 0) { + if (threadIdx.x == 0) { + selected_counts[token] = 0; + } + __syncthreads(); + continue; + } + + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t threshold = SaiCausalThreshold(position_ids[token], params.compress_ratio); + const int64_t visible_64 = threshold < key_len_after ? threshold : static_cast(key_len_after); + const int visible = static_cast(visible_64 < 0 ? 0 : visible_64); + const int selected = params.index_topk < visible ? params.index_topk : visible; + + const float* scores_row = scores + static_cast(token) * params.state_capacity; + 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; + SaiScanForNext(scores_row, visible, previous_score, previous_index, &best_value, &best_index); + shared_value[threadIdx.x] = best_value; + shared_index[threadIdx.x] = best_index; + __syncthreads(); + SaiBlockArgMax(shared_value, shared_index); + previous_index = shared_index[0]; + previous_score = shared_value[0]; + __syncthreads(); + if (previous_index < 0) { + break; + } + if (threadIdx.x == 0) { + out_row[rank] = previous_index; + } + emitted = rank + 1; + __syncthreads(); + } + if (threadIdx.x == 0) { + selected_counts[token] = emitted; + } + __syncthreads(); + } +} + +} // namespace + +size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { + const size_t rows = static_cast(params.total_tokens); + return rows * params.num_heads * params.head_size + rows * static_cast(std::max(params.state_capacity, 1)); +} + +size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { + const size_t rows = static_cast(params.total_tokens); + return rows * params.num_heads * params.head_size + rows * static_cast(std::max(params.state_capacity, 1)); +} + +template +Status LaunchQsaPackedSparseAttentionIndexer( + cudaStream_t stream, const PackedSparseAttentionIndexerParams& params, const T* query, const T* key, + const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, const T* past_key_state, + const T* past_kv_buffer, const int32_t* past_state_lengths, int32_t* selected_indices, + int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, + float* float_workspace, int32_t* overflow_flags) { + if (params.batch_size > 0) { + const int64_t key_state_elems = + static_cast(params.batch_size) * params.state_capacity * params.head_size; + if (key_state_elems > 0 && present_key_state != past_key_state) { + ElementwiseCopyKernel<<>>( + past_key_state, present_key_state, key_state_elems); + } + const int64_t buffer_elems = static_cast(params.batch_size) * params.buffer_capacity * params.head_size; + if (buffer_elems > 0 && present_kv_buffer != past_kv_buffer) { + ElementwiseCopyKernel<<>>( + past_kv_buffer, present_kv_buffer, buffer_elems); + } + if (present_state_lengths != past_state_lengths) { + const int64_t length_elems = static_cast(params.batch_size) * psai::kStateLengthColumns; + ElementwiseCopyKernel<<>>( + past_state_lengths, present_state_lengths, length_elems); + } + } + + if (params.batch_size == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); + QsaUpdateStateKernel<<>>( + key, key_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_kv_buffer, past_state_lengths, + present_key_state, present_kv_buffer, present_state_lengths, overflow_flags, params); + + if (params.total_tokens == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + float* query_rotated = float_workspace; + float* block_scores = + float_workspace + static_cast(params.total_tokens) * params.num_heads * params.head_size; + + const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; + const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); + PackedRotateQueryKernel<<>>( + query, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, + params); + + if (params.state_capacity > 0) { + const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; + const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); + QsaBlockScoreKernel<<>>( + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, block_scores, params); + } + + const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); + QsaSelectKernel<<>>( + block_scores, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, + overflow_flags, selected_indices, selected_counts, params); + + return CUDA_CALL(cudaGetLastError()); +} + +template +Status LaunchCsaPackedSparseAttentionIndexer( + cudaStream_t stream, const PackedSparseAttentionIndexerParams& 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 int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, + const int64_t* position_ids, const T* past_key_state, const T* past_kv_buffer, const T* past_gate_buffer, + const int32_t* past_state_lengths, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, + T* present_kv_buffer, T* present_gate_buffer, int32_t* present_state_lengths, float* float_workspace, + int32_t* overflow_flags) { + if (params.batch_size > 0) { + const int64_t key_state_elems = + static_cast(params.batch_size) * params.state_capacity * params.head_size; + if (key_state_elems > 0 && present_key_state != past_key_state) { + ElementwiseCopyKernel<<>>( + past_key_state, present_key_state, key_state_elems); + } + const int64_t buffer_elems = + static_cast(params.batch_size) * params.buffer_capacity * 2 * params.head_size; + if (buffer_elems > 0) { + if (present_kv_buffer != past_kv_buffer) { + ElementwiseCopyKernel<<>>( + past_kv_buffer, present_kv_buffer, buffer_elems); + } + if (present_gate_buffer != past_gate_buffer) { + ElementwiseCopyKernel<<>>( + past_gate_buffer, present_gate_buffer, buffer_elems); + } + } + if (present_state_lengths != past_state_lengths) { + const int64_t length_elems = static_cast(params.batch_size) * psai::kStateLengthColumns; + ElementwiseCopyKernel<<>>( + past_state_lengths, present_state_lengths, length_elems); + } + } + + if (params.batch_size == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); + CsaUpdateStateKernel<<>>( + key, gate, key_norm_weight, cos_cache, sin_cache, position_bias, cumulative_sequence_lengths, past_kv_buffer, + past_gate_buffer, past_state_lengths, present_key_state, present_kv_buffer, present_gate_buffer, + present_state_lengths, overflow_flags, params); + + if (params.total_tokens == 0) { + return CUDA_CALL(cudaGetLastError()); + } + + float* query_rotated = float_workspace; + float* scores = float_workspace + static_cast(params.total_tokens) * params.num_heads * params.head_size; + + const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; + const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); + PackedRotateQueryKernel<<>>( + query, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, + params); + + if (params.state_capacity > 0) { + const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; + const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); + CsaScoreKernel<<>>( + present_key_state, query_rotated, head_weights, cumulative_sequence_lengths, position_ids, + present_state_lengths, scores, params); + } + + const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); + CsaSelectKernel<<>>( + scores, cumulative_sequence_lengths, position_ids, present_state_lengths, overflow_flags, selected_indices, + selected_counts, params); + + return CUDA_CALL(cudaGetLastError()); +} + +#define INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(T) \ + template Status LaunchQsaPackedSparseAttentionIndexer( \ + cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ + const T*, const int32_t*, const int32_t*, const int64_t*, const T*, const T*, const int32_t*, int32_t*, \ + int32_t*, T*, T*, int32_t*, float*, int32_t*); \ + template Status LaunchCsaPackedSparseAttentionIndexer( \ + cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ + const T*, const T*, const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, \ + const T*, const T*, const int32_t*, int32_t*, int32_t*, T*, T*, T*, int32_t*, float*); + +INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(float) +INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(half) +INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(__nv_bfloat16) + +#undef INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h new file mode 100644 index 0000000000000..2eae2bdc4465a --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -0,0 +1,106 @@ +// 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 PackedSparseAttentionIndexer call. All of it is +// derived from attributes and input *shapes* (never input values), so no device data is ever read +// on the host; per-request quantities (past_sequence_lengths, past_state_lengths, cumulative +// offsets) are read directly by the kernels below from device memory. +struct PackedSparseAttentionIndexerParams { + int batch_size = 0; + int total_tokens = 0; + int num_heads = 0; + int head_size = 0; + int rotary_width = 0; // cos_cache.shape[-1] + int max_rotary_length = 0; // cos_cache.shape[-2] + bool cos_cache_batched = false; // cos_cache rank: 3 = [batch, pos, rot], 2 = [pos, rot] + int compress_ratio = 0; + int state_capacity = 0; // past_key_state.shape[1] + int buffer_capacity = 0; // past_kv_buffer.shape[1] == 2 * compress_ratio - 1 + int capacity = 0; // selected_indices.shape[1] + bool has_position_ids = false; + float epsilon = 1e-6f; + float scale = 0.0f; + + // policy_mode = "qsa" + int block_topk = 0; // token_budget / compress_ratio + + // policy_mode = "csa" + int index_topk = 0; + float head_weight_scale = 0.0f; +}; + +// Scratch requirements, in float elements. +size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params); +size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params); + +// `overflow_flags` is a caller-allocated int32 scratch buffer with at least `batch_size` elements +// (unused when batch_size == 0). The update kernel writes, per request, whether this call's new +// blocks/windows would exceed state_capacity; when it does, the whole step is rejected for that +// request (present_state_lengths / present_key_state / present_kv_buffer / present_gate_buffer for +// that request are left exactly as their past_* counterparts) and the select kernels force that +// request's selected_indices/selected_counts to the safe empty result (-1 / 0) rather than +// selecting against a partially updated state. +template +Status LaunchQsaPackedSparseAttentionIndexer( + cudaStream_t stream, + const PackedSparseAttentionIndexerParams& params, + const T* query, + const T* key, + const T* key_norm_weight, + const T* cos_cache, + const T* sin_cache, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, + const int64_t* position_ids, + const T* past_key_state, + const T* past_kv_buffer, + const int32_t* past_state_lengths, + int32_t* selected_indices, + int32_t* selected_counts, + T* present_key_state, + T* present_kv_buffer, + int32_t* present_state_lengths, + float* float_workspace, + int32_t* overflow_flags); + +template +Status LaunchCsaPackedSparseAttentionIndexer( + cudaStream_t stream, + const PackedSparseAttentionIndexerParams& 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 int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, + const int64_t* position_ids, + const T* past_key_state, + const T* past_kv_buffer, + const T* past_gate_buffer, + const int32_t* past_state_lengths, + int32_t* selected_indices, + int32_t* selected_counts, + T* present_key_state, + T* present_kv_buffer, + T* present_gate_buffer, + int32_t* present_state_lengths, + float* float_workspace, + int32_t* overflow_flags); + +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh new file mode 100644 index 0000000000000..de7c2d3b323ad --- /dev/null +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Device-side math shared by com.microsoft.SparseAttentionIndexer (dense, growing state) and +// com.microsoft.PackedSparseAttentionIndexer (packed, fixed-capacity state). Everything here is a +// small, self-contained helper with no dependency on either operator's tensor layout, so it is +// included (not linked) by both .cu translation units; the anonymous namespace gives each +// translation unit its own private copy, which is the normal, ODR-safe pattern for header-only +// CUDA device helpers. + +#pragma once + +#include +#include + +#include +#include + +#include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" + +namespace onnxruntime { +namespace contrib { +namespace cuda { + +namespace { + +constexpr int64_t kSaiMaxGridDimX = 2147483647; + +__device__ __forceinline__ float SaiNegativeInfinity() { return -CUDART_INF_F; } + +inline int SaiGridForElements(int64_t count, int threads) { + const int64_t blocks = (count + threads - 1) / threads; + return static_cast(std::clamp(blocks, 1, 65535)); +} + +// --------------------------------------------------------------------------------------------- +// FP32 block reductions +// --------------------------------------------------------------------------------------------- + +__device__ __forceinline__ float SaiBlockSum(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 SaiBlockArgMax(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 SaiScanForNext(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; + } + } +} + +// --------------------------------------------------------------------------------------------- +// Rotary embeddings +// --------------------------------------------------------------------------------------------- + +// 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 SaiLeadingRope(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 SaiTrailingRope(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]); +} + +// --------------------------------------------------------------------------------------------- +// Causal geometry +// --------------------------------------------------------------------------------------------- + +// Highest compressed entry a query at `position` may attend to, matching (position + 1) // ratio. +__device__ __forceinline__ int64_t SaiCausalThreshold(int64_t position, int compress_ratio) { + return position < 0 ? 0 : position / compress_ratio + (position % compress_ratio == compress_ratio - 1); +} + +__device__ __forceinline__ int SaiClampPosition(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); +} + +} // namespace + +} // 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 index ffc453754231b..40c07c1238f7a 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -14,6 +14,7 @@ #include +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh" #include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" namespace onnxruntime { @@ -24,114 +25,43 @@ 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; +constexpr int64_t kMaxGridDimX = kSaiMaxGridDimX; -__device__ __forceinline__ float NegativeInfinity() { return -CUDART_INF_F; } +// Thin, same-signature aliases over the device math shared with the packed indexer implementation +// (sparse_attention_indexer_device_math.cuh), so the kernels below are unchanged. +__device__ __forceinline__ float NegativeInfinity() { return SaiNegativeInfinity(); } -int GridForElements(int64_t count) { - const int64_t blocks = (count + kThreads - 1) / kThreads; - return static_cast(std::clamp(blocks, 1, 65535)); -} +int GridForElements(int64_t count) { return SaiGridForElements(count, kThreads); } -// --------------------------------------------------------------------------------------------- -// 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; -} +__device__ __forceinline__ float BlockSum(float value, float* shared) { return SaiBlockSum(value, shared); } -// 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(); - } + SaiBlockArgMax(shared_value, shared_index); } -// 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; - } - } + SaiScanForNext(scores, count, previous_score, previous_index, best_value, best_index); } -// 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]); + return SaiLeadingRope(value, rotary_width, cos_row, 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]); + return SaiTrailingRope(value, head_size, rotary_width, cos_row, sin_row, d); } -// 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 / compress_ratio + (position % compress_ratio == compress_ratio - 1); + return SaiCausalThreshold(position, 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); + return SaiClampPosition(position, max_rotary_length); } // --------------------------------------------------------------------------------------------- diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc new file mode 100644 index 0000000000000..e12d4ddbb5c1d --- /dev/null +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -0,0 +1,982 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// WebGPU implementation of com.microsoft.PackedSparseAttentionIndexer. Like its dense +// SparseAttentionIndexer counterpart, every program below is single-invocation-per-row +// (`if (... || local_idx != 0u) { return; }`) and recomputes reductions on demand rather than +// staging per-row intermediates in workgroup memory; this keeps every kernel correct without +// requiring WGSL arrays sized by a runtime (uniform) head_size, which WGSL does not support for +// function-local variables. See docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md and +// packed_sparse_attention_indexer_impl.cu (the CUDA implementation) for the full operator +// contract and the device-side metadata-safety argument, which apply unchanged here: every +// per-request quantity is read directly from device buffers inside the shader (never on the +// host), and values are clamped into the fixed-capacity range before use so malformed metadata +// can never cause an out-of-bounds access. + +#include "contrib_ops/webgpu/bert/packed_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 psai = onnxruntime::contrib::packed_sparse_attention_indexer; + +ONNX_OPERATOR_KERNEL_EX( + PackedSparseAttentionIndexer, + kMSDomain, + 1, + kWebGpuExecutionProvider, + (*KernelDefBuilder::Create()) + .TypeConstraint("T", WebGpuSupportedFloatTypes()) + .TypeConstraint("I", DataTypeImpl::GetTensorType()) + .TypeConstraint("M", DataTypeImpl::GetTensorType()), + PackedSparseAttentionIndexer); + +namespace { + +constexpr uint32_t kWorkgroupSize = 64; + +Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list expected) { + ORT_RETURN_IF(tensor == nullptr, "PackedSparseAttentionIndexer: ", name, " is required"); + const TensorShape expected_shape(expected); + ORT_RETURN_IF_NOT(tensor->Shape() == expected_shape, "PackedSparseAttentionIndexer: ", name, " must have shape ", + expected_shape.ToString(), ", got ", tensor->Shape().ToString()); + return Status::OK(); +} + +uint32_t ToUint32(int64_t value) { return onnxruntime::narrow(value); } + +struct RotaryCacheShape { + bool batched; + int64_t max_rotary_length; + int64_t rotary_width; +}; + +Status CheckRotaryCache(const Tensor* cos_cache, const Tensor* sin_cache, int64_t batch_size, + RotaryCacheShape& out) { + ORT_RETURN_IF(cos_cache == nullptr, "PackedSparseAttentionIndexer: cos_cache is required"); + const auto& cos_shape = cos_cache->Shape(); + out.batched = cos_shape.NumDimensions() == 3; + ORT_RETURN_IF_NOT( + (out.batched && cos_shape[0] == batch_size && cos_shape[1] > 0) || + (cos_shape.NumDimensions() == 2 && cos_shape[0] > 0), + "PackedSparseAttentionIndexer: cos_cache must have shape (max_position, rotary_width) or " + "(batch_size, max_position, rotary_width), got ", + cos_shape.ToString()); + out.max_rotary_length = out.batched ? cos_shape[1] : cos_shape[0]; + out.rotary_width = out.batched ? cos_shape[2] : cos_shape[1]; + ORT_RETURN_IF_NOT(out.max_rotary_length > 0 && out.rotary_width > 0, + "PackedSparseAttentionIndexer: invalid cos_cache shape ", cos_shape.ToString()); + ORT_RETURN_IF_NOT(sin_cache != nullptr && sin_cache->Shape() == cos_shape, + "PackedSparseAttentionIndexer: sin_cache must have the same shape as cos_cache"); + return Status::OK(); +} + +} // namespace + +Status PackedSparseAttentionIndexerCopyProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& src = shader.AddInput("src", ShaderUsage::UseUniform); + const auto& dst = shader.AddOutput("dst", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + shader.MainFunctionBody() + << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.total") + << " " << dst.SetByOffset("global_idx", "dst_element_t(" + src.GetByOffset("global_idx") + ")") << "\n"; + return Status::OK(); +} + +Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& key = shader.AddInput("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& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& past_kv_buffer = shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); + const auto& past_state_lengths = shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); + const auto& present_key_state = + shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& present_kv_buffer = + shader.AddOutput("present_kv_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); + const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); + + shader.AdditionalImplementation() + << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, d: u32) -> f32 {\n" + << " if (virtual_pos < old_buf_len) {\n" + << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * uniforms.head_size + d;\n" + << " return f32(" << past_kv_buffer.GetByOffset("idx") << ");\n" + << " }\n" + << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * uniforms.head_size + d;\n" + << " return f32(" << key.GetByOffset("idx2") << ");\n" + << "}\n" + << "fn pooled(b: u32, k: i32, old_buf_len: i32, req_start: i32, d: u32) -> f32 {\n" + << " var sum = 0.0;\n" + << " for (var t = 0u; t < uniforms.compress_ratio; t++) {\n" + << " sum += extended_key(b, k * i32(uniforms.compress_ratio) + i32(t), old_buf_len, req_start, d);\n" + << " }\n" + << " return sum / f32(uniforms.compress_ratio);\n" + << "}\n" + << "fn normalized(b: u32, k: i32, old_buf_len: i32, req_start: i32, d: u32) -> f32 {\n" + << " var square_sum = 0.0;\n" + << " for (var c = 0u; c < uniforms.head_size; c++) {\n" + << " let value = pooled(b, k, old_buf_len, req_start, c);\n" + << " square_sum += value * value;\n" + << " }\n" + << " return pooled(b, k, old_buf_len, req_start, d) * " + "inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" + << norm.GetByOffset("d") << ");\n" + << "}\n" + << "fn clamp_position(position: i32) -> u32 {\n" + << " if (position < 0) { return 0u; }\n" + << " return min(u32(position), uniforms.max_rotary_length - 1u);\n" + << "}\n" + << "fn rotated(b: u32, k: i32, old_buf_len: i32, req_start: i32, old_key_len: i32, d: u32) -> f32 {\n" + << " let value = normalized(b, k, old_buf_len, req_start, d);\n" + << " if (d >= uniforms.rotary_width) { return value; }\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(b, k, old_buf_len, req_start, pair_d);\n" + << " let entry = old_key_len + k;\n" + << " let position = clamp_position(entry * i32(uniforms.compress_ratio));\n"; + if (cos_cache_batched_) { + shader.AdditionalImplementation() + << " let cache = (b * uniforms.max_rotary_length + position) * uniforms.rotary_width + d;\n"; + } else { + shader.AdditionalImplementation() << " let cache = position * uniforms.rotary_width + d;\n"; + } + shader.AdditionalImplementation() + << " return value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" + << sin_cache.GetByOffset("cache") << ");\n" + << "}\n"; + + shader.MainFunctionBody() + << " let b = workgroup_idx;\n" + << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" + << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" + << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" + << " let req_len = max(req_end - req_start, 0);\n" + << " let old_key_len = clamp(" << past_state_lengths.GetByOffset("b * 2u") + << ", 0, i32(uniforms.state_capacity));\n" + << " let old_buf_len = clamp(" << past_state_lengths.GetByOffset("b * 2u + 1u") + << ", 0, i32(uniforms.compress_ratio) - 1);\n" + << " let pending = old_buf_len + req_len;\n" + << " let full_new_block_count = pending / i32(uniforms.compress_ratio);\n" + << " let capacity_left = max(i32(uniforms.state_capacity) - old_key_len, 0);\n" + << " let new_block_count = min(full_new_block_count, capacity_left);\n" + << " let overflowed = new_block_count < full_new_block_count;\n" + << " let new_buf_len = select(pending % i32(uniforms.compress_ratio), 0, overflowed);\n" + << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len + new_block_count") << "\n" + << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "new_buf_len") << "\n" + << " for (var k = 0; k < new_block_count; k++) {\n" + << " let entry = u32(old_key_len + k);\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " let value = rotated(b, k, old_buf_len, req_start, old_key_len, d);\n" + << " " + << present_key_state.SetByOffset("(b * uniforms.state_capacity + entry) * uniforms.head_size + d", + "present_key_state_element_t(value)") + << "\n" + << " }\n" + << " }\n" + << " for (var t = 0; t < new_buf_len; t++) {\n" + << " let virtual_pos = new_block_count * i32(uniforms.compress_ratio) + t;\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " let value = extended_key(b, virtual_pos, old_buf_len, req_start, d);\n" + << " " + << present_kv_buffer.SetByOffset("(b * uniforms.buffer_capacity + u32(t)) * uniforms.head_size + d", + "present_kv_buffer_element_t(value)") + << "\n" + << " }\n" + << " }\n"; + return Status::OK(); +} + +Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); + const auto& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); + const auto& cos_cache = shader.AddInput("cos_cache", ShaderUsage::UseUniform); + const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); + const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); + const ShaderVariableHelper* position_ids = nullptr; + if (has_position_ids_) { + position_ids = &shader.AddInput("position_ids", ShaderUsage::UseUniform); + } + const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); + const auto& selected_indices = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); + const auto& selected_counts = shader.AddOutput("selected_counts", ShaderUsage::UseUniform); + + shader.AdditionalImplementation() + << "fn batch_of_token(token: u32) -> u32 {\n" + << " var b = 0u;\n" + << " for (var i = 0u; i < uniforms.batch_size; i++) {\n" + << " if (u32(" << cu_seqlens.GetByOffset("i") << ") <= token) { b = i; } else { break; }\n" + << " }\n" + << " return b;\n" + << "}\n" + << "fn clamp_position(position: i32) -> u32 {\n" + << " if (position < 0) { return 0u; }\n" + << " return min(u32(position), uniforms.max_rotary_length - 1u);\n" + << "}\n" + << "fn causal_count(position: i32) -> u32 {\n" + << " if (position < 0) { return 0u; }\n" + << " let p = u32(position);\n" + << " let cr = uniforms.compress_ratio;\n" + << " return p / cr + select(0u, 1u, p % cr == cr - 1u);\n" + << "}\n"; + if (has_position_ids_) { + shader.AdditionalImplementation() + << "fn abs_position(token: u32, b: u32) -> i32 {\n" + << " let raw = " << position_ids->GetByOffset("token", true) << ";\n" + << " if ((raw.y & 0x80000000u) != 0u) { return -2147483648; }\n" + << " if (raw.y != 0u || raw.x > 2147483647u) { return 2147483647; }\n" + << " return i32(raw.x);\n" + << "}\n"; + } else { + shader.AdditionalImplementation() + << "fn abs_position(token: u32, b: u32) -> i32 {\n" + << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" + << " return " << past_seqlens.GetByOffset("b") << " + (i32(token) - req_start);\n" + << "}\n"; + } + shader.AdditionalImplementation() + << "fn query_value(token: u32, head: u32, d: u32, position: i32, b: u32) -> f32 {\n" + << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" + << " var value = f32(" << query.GetByOffset("base + d") << ");\n" + << " if (d >= uniforms.rotary_width) { return value; }\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 position_clamped = clamp_position(position);\n"; + if (cos_cache_batched_) { + shader.AdditionalImplementation() + << " let cache = (b * uniforms.max_rotary_length + position_clamped) * uniforms.rotary_width + d;\n"; + } else { + shader.AdditionalImplementation() << " let cache = position_clamped * uniforms.rotary_width + d;\n"; + } + shader.AdditionalImplementation() + << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" + << sin_cache.GetByOffset("cache") << ");\n" + << " return value;\n" + << "}\n" + << "fn block_score(token: u32, b: u32, block_index: u32, position: i32) -> f32 {\n" + << " let key_base = (b * uniforms.state_capacity + block_index) * 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(token, head, d, position, b) * f32(" + << present_key_state.GetByOffset("key_base + d") << ");\n" + << " }\n" + << " score += max(dot, 0.0);\n" + << " }\n" + << " return score * uniforms.scale;\n" + << "}\n"; + + shader.MainFunctionBody() + << " let token = workgroup_idx;\n" + << " if (token >= uniforms.total_tokens || local_idx != 0u) { return; }\n" + << " let output_base = token * uniforms.capacity;\n" + << " for (var i = 0u; i < uniforms.capacity; i++) {\n" + << " " << selected_indices.SetByOffset("output_base + i", "-1") << "\n" + << " }\n" + << " let b = batch_of_token(token);\n" + << " let key_len_after = u32(" << present_state_lengths.GetByOffset("b * 2u") << ");\n" + << " let position = abs_position(token, b);\n" + << " let causal = causal_count(position);\n" + << " let visible_block_count = min(key_len_after, causal);\n" + << " let selected = min(uniforms.block_topk, visible_block_count);\n" + << " var previous_score = 0.0;\n" + << " var previous_index = -1i;\n" + << " var emitted_blocks = 0u;\n" + << " for (var rank = 0u; rank < selected; rank++) {\n" + << " var best_score = 0.0;\n" + << " var best_index = -1i;\n" + << " for (var candidate = 0u; candidate < visible_block_count; candidate++) {\n" + << " let score = block_score(token, b, candidate, position);\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 t = 0u; t < uniforms.compress_ratio; t++) {\n" + << " " + << selected_indices.SetByOffset("output_base + rank * uniforms.compress_ratio + t", + "best_index * i32(uniforms.compress_ratio) + i32(t)") + << "\n" + << " }\n" + << " emitted_blocks = rank + 1u;\n" + << " previous_score = best_score;\n" + << " previous_index = best_index;\n" + << " }\n" + << " let block_start = i32(visible_block_count * uniforms.compress_ratio);\n" + << " let natural_tail = select(0, position - block_start + 1, position >= block_start);\n" + << " let remaining_capacity = i32(uniforms.capacity) - i32(emitted_blocks * uniforms.compress_ratio);\n" + << " var tail_count = min(natural_tail, remaining_capacity);\n" + << " tail_count = max(tail_count, 0);\n" + << " for (var t = 0; t < tail_count; t++) {\n" + << " " + << selected_indices.SetByOffset("output_base + emitted_blocks * uniforms.compress_ratio + u32(t)", + "block_start + t") + << "\n" + << " }\n" + << " " + << selected_counts.SetByOffset("token", "i32(emitted_blocks * uniforms.compress_ratio) + tail_count") + << "\n"; + return Status::OK(); +} + +Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& key = shader.AddInput("key", ShaderUsage::UseUniform); + const auto& gate = shader.AddInput("gate", 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& position_bias = shader.AddInput("position_bias", ShaderUsage::UseUniform); + const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& past_kv_buffer = shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); + const auto& past_gate_buffer = shader.AddInput("past_gate_buffer", ShaderUsage::UseUniform); + const auto& past_state_lengths = shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); + const auto& present_key_state = + shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& present_kv_buffer = + shader.AddOutput("present_kv_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& present_gate_buffer = + shader.AddOutput("present_gate_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); + + shader.AdditionalImplementation() + << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, channel: u32) -> f32 {\n" + << " let width = 2u * uniforms.head_size;\n" + << " if (virtual_pos < old_buf_len) {\n" + << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * width + channel;\n" + << " return f32(" << past_kv_buffer.GetByOffset("idx") << ");\n" + << " }\n" + << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * width + channel;\n" + << " return f32(" << key.GetByOffset("idx2") << ");\n" + << "}\n" + << "fn extended_gate(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, channel: u32) -> f32 {\n" + << " let width = 2u * uniforms.head_size;\n" + << " if (virtual_pos < old_buf_len) {\n" + << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * width + channel;\n" + << " return f32(" << past_gate_buffer.GetByOffset("idx") << ");\n" + << " }\n" + << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * width + channel;\n" + << " return f32(" << gate.GetByOffset("idx2") << ");\n" + << "}\n" + << "fn pooled(b: u32, k: i32, old_buf_len: i32, req_start: i32, overlap_length: i32, d: u32) -> f32 {\n" + << " let width = 2u * uniforms.head_size;\n" + << " let has_previous = k >= 1 || overlap_length >= i32(uniforms.compress_ratio);\n" + << " let previous_base = overlap_length + (k - 1) * i32(uniforms.compress_ratio);\n" + << " let current_base = overlap_length + k * i32(uniforms.compress_ratio);\n" + << " var max_gate = -3.4028234663852886e+38;\n" + << " if (has_previous) {\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let v = extended_gate(b, previous_base + i32(slot), old_buf_len, req_start, d) + f32(" + << position_bias.GetByOffset("slot * width + d") << ");\n" + << " max_gate = max(max_gate, v);\n" + << " }\n" + << " }\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let v = extended_gate(b, current_base + i32(slot), old_buf_len, req_start, " + "uniforms.head_size + d) + f32(" + << position_bias.GetByOffset("slot * width + uniforms.head_size + d") << ");\n" + << " max_gate = max(max_gate, v);\n" + << " }\n" + << " var denominator = 0.0;\n" + << " var accumulator = 0.0;\n" + << " if (has_previous) {\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let logit = extended_gate(b, previous_base + i32(slot), old_buf_len, req_start, d) + f32(" + << position_bias.GetByOffset("slot * width + d") << ");\n" + << " let weight = exp(logit - max_gate);\n" + << " denominator += weight;\n" + << " accumulator += weight * extended_key(b, previous_base + i32(slot), old_buf_len, req_start, d);\n" + << " }\n" + << " }\n" + << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" + << " let logit = extended_gate(b, current_base + i32(slot), old_buf_len, req_start, " + "uniforms.head_size + d) + f32(" + << position_bias.GetByOffset("slot * width + uniforms.head_size + d") << ");\n" + << " let weight = exp(logit - max_gate);\n" + << " denominator += weight;\n" + << " accumulator += weight * extended_key(b, current_base + i32(slot), old_buf_len, req_start, " + "uniforms.head_size + d);\n" + << " }\n" + << " return select(0.0, accumulator / denominator, denominator > 0.0);\n" + << "}\n" + << "fn clamp_position(position: i32) -> u32 {\n" + << " if (position < 0) { return 0u; }\n" + << " return min(u32(position), uniforms.max_rotary_length - 1u);\n" + << "}\n"; + + shader.MainFunctionBody() + << " let b = workgroup_idx;\n" + << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" + << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" + << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" + << " let req_len = max(req_end - req_start, 0);\n" + << " let old_key_len = clamp(" << past_state_lengths.GetByOffset("b * 2u") + << ", 0, i32(uniforms.state_capacity));\n" + << " let old_buf_len = clamp(" << past_state_lengths.GetByOffset("b * 2u + 1u") + << ", 0, i32(uniforms.buffer_capacity));\n" + << " let overlap_length = select(0, i32(uniforms.compress_ratio), old_buf_len >= " + "i32(uniforms.compress_ratio));\n" + << " let leftover_length = old_buf_len - overlap_length;\n" + << " let pending = leftover_length + req_len;\n" + << " let full_new_window_count = pending / i32(uniforms.compress_ratio);\n" + << " let capacity_left = max(i32(uniforms.state_capacity) - old_key_len, 0);\n" + << " let new_window_count = min(full_new_window_count, capacity_left);\n" + << " let overflowed = new_window_count < full_new_window_count;\n" + << " var present_buffer_length = 0;\n" + << " var present_buffer_start = 0;\n" + << " if (!overflowed) {\n" + << " if (new_window_count > 0) {\n" + << " present_buffer_length = i32(uniforms.compress_ratio) + pending % i32(uniforms.compress_ratio);\n" + << " present_buffer_start = overlap_length + (new_window_count - 1) * i32(uniforms.compress_ratio);\n" + << " } else {\n" + << " present_buffer_length = old_buf_len + req_len;\n" + << " present_buffer_start = 0;\n" + << " }\n" + << " present_buffer_length = min(present_buffer_length, i32(uniforms.buffer_capacity));\n" + << " }\n" + << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len + new_window_count") << "\n" + << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "present_buffer_length") << "\n" + << " for (var k = 0; k < new_window_count; k++) {\n" + << " var square_sum = 0.0;\n" + << " for (var c = 0u; c < uniforms.head_size; c++) {\n" + << " let v = pooled(b, k, old_buf_len, req_start, overlap_length, c);\n" + << " square_sum += v * v;\n" + << " }\n" + << " let inverse_rms = inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon);\n" + << " let entry = u32(old_key_len + k);\n" + << " let position = clamp_position(i32(entry) * i32(uniforms.compress_ratio));\n"; + if (cos_cache_batched_) { + shader.MainFunctionBody() + << " let cache_base = (b * uniforms.max_rotary_length + position) * uniforms.rotary_width;\n"; + } else { + shader.MainFunctionBody() << " let cache_base = position * uniforms.rotary_width;\n"; + } + shader.MainFunctionBody() + << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " var value = pooled(b, k, old_buf_len, req_start, overlap_length, 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(b, k, old_buf_len, req_start, overlap_length, 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" + << " " + << present_key_state.SetByOffset("(b * uniforms.state_capacity + entry) * uniforms.head_size + d", + "present_key_state_element_t(value)") + << "\n" + << " }\n" + << " }\n" + << " for (var t = 0; t < present_buffer_length; t++) {\n" + << " let virtual_pos = present_buffer_start + t;\n" + << " for (var c = 0u; c < 2u * uniforms.head_size; c++) {\n" + << " let key_value = extended_key(b, virtual_pos, old_buf_len, req_start, c);\n" + << " let gate_value = extended_gate(b, virtual_pos, old_buf_len, req_start, c);\n" + << " " + << present_kv_buffer.SetByOffset("(b * uniforms.buffer_capacity + u32(t)) * (2u * uniforms.head_size) + c", + "present_kv_buffer_element_t(key_value)") + << "\n" + << " " + << present_gate_buffer.SetByOffset( + "(b * uniforms.buffer_capacity + u32(t)) * (2u * uniforms.head_size) + c", + "present_gate_buffer_element_t(gate_value)") + << "\n" + << " }\n" + << " }\n"; + return Status::OK(); +} + +Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); + const auto& present_key_state = shader.AddInput("present_key_state", 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& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); + const auto& selected_indices = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); + const auto& selected_counts = shader.AddOutput("selected_counts", ShaderUsage::UseUniform); + + shader.AdditionalImplementation() + << "fn batch_of_token(token: u32) -> u32 {\n" + << " var b = 0u;\n" + << " for (var i = 0u; i < uniforms.batch_size; i++) {\n" + << " if (u32(" << cu_seqlens.GetByOffset("i") << ") <= token) { b = i; } else { break; }\n" + << " }\n" + << " return b;\n" + << "}\n" + << "fn clamped_position(raw: vec2) -> u32 {\n" + << " if ((raw.y & 0x80000000u) != 0u) { return 0u; }\n" + << " if (raw.y != 0u) { return 0xffffffffu; }\n" + << " return raw.x;\n" + << "}\n" + << "fn causal_count(raw: vec2) -> u32 {\n" + << " if ((raw.y & 0x80000000u) != 0u) { return 0u; }\n" + << " if (raw.y != 0u) { return 0xffffffffu; }\n" + << " let p = raw.x;\n" + << " let cr = uniforms.compress_ratio;\n" + << " return p / cr + select(0u, 1u, p % cr == cr - 1u);\n" + << "}\n" + << "fn query_value(token: u32, head: u32, d: u32, raw: vec2, b: u32) -> f32 {\n" + << " let base = (token * 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) { return value; }\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 position = min(clamped_position(raw), uniforms.max_rotary_length - 1u);\n"; + if (cos_cache_batched_) { + shader.AdditionalImplementation() + << " let cache = (b * uniforms.max_rotary_length + position) * uniforms.rotary_width + offset / 2u;\n"; + } else { + shader.AdditionalImplementation() + << " let cache = position * uniforms.rotary_width + offset / 2u;\n"; + } + shader.AdditionalImplementation() + << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" + << sin_cache.GetByOffset("cache") << ");\n" + << " return value;\n" + << "}\n" + << "fn entry_score(token: u32, b: u32, entry: u32, raw: vec2) -> f32 {\n" + << " let key_base = (b * uniforms.state_capacity + 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(token, head, d, raw, b) * f32(" + << present_key_state.GetByOffset("key_base + d") << ");\n" + << " }\n" + << " score += max(dot, 0.0) * f32(" << head_weights.GetByOffset("token * uniforms.num_heads + head") + << ");\n" + << " }\n" + << " return score * uniforms.scale * uniforms.head_weight_scale;\n" + << "}\n"; + + shader.MainFunctionBody() + << " let token = workgroup_idx;\n" + << " if (token >= uniforms.total_tokens || local_idx != 0u) { return; }\n" + << " let output_base = token * uniforms.capacity;\n" + << " for (var i = 0u; i < uniforms.capacity; i++) {\n" + << " " << selected_indices.SetByOffset("output_base + i", "-1") << "\n" + << " }\n" + << " let b = batch_of_token(token);\n" + << " let key_len_after = u32(" << present_state_lengths.GetByOffset("b * 2u") << ");\n" + << " let raw = " << position_ids.GetByOffset("token", true) << ";\n" + << " let threshold = min(causal_count(raw), key_len_after);\n" + << " let selected = min(uniforms.index_topk, threshold);\n" + << " var previous_score = 0.0;\n" + << " var previous_index = -1i;\n" + << " var emitted = 0u;\n" + << " for (var rank = 0u; rank < selected; rank++) {\n" + << " var best_score = 0.0;\n" + << " var best_index = -1i;\n" + << " for (var candidate = 0u; candidate < threshold; candidate++) {\n" + << " let score = entry_score(token, b, candidate, raw);\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" + << " " << selected_indices.SetByOffset("output_base + rank", "best_index") << "\n" + << " emitted = rank + 1u;\n" + << " previous_score = best_score;\n" + << " previous_index = best_index;\n" + << " }\n" + << " " << selected_counts.SetByOffset("token", "i32(emitted)") << "\n"; + return Status::OK(); +} + +PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& info) : WebGpuKernel(info) { + std::string policy_mode; + ORT_ENFORCE(info.GetAttr("policy_mode", &policy_mode).IsOK(), + "PackedSparseAttentionIndexer: policy_mode is required"); + ORT_ENFORCE(psai::TryParsePolicy(policy_mode, policy_), "PackedSparseAttentionIndexer: policy_mode must be '", + psai::kPolicyModeQsa, "' or '", psai::kPolicyModeCsa, "', got '", policy_mode, "'"); + ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), + "PackedSparseAttentionIndexer: compress_ratio is required"); + ORT_ENFORCE(compress_ratio_ > 0, "PackedSparseAttentionIndexer: 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(); + has_scale_ = info.GetAttr("scale", &scale_).IsOK(); + has_head_weight_scale_ = info.GetAttr("head_weight_scale", &head_weight_scale_).IsOK(); + if (policy_ == psai::Policy::kQsa) { + ORT_ENFORCE(has_token_budget && token_budget_ > 0 && token_budget_ % compress_ratio_ == 0, + "PackedSparseAttentionIndexer: token_budget must be > 0 and divisible by compress_ratio for qsa"); + ORT_ENFORCE(!has_index_topk && !has_head_weight_scale_, + "PackedSparseAttentionIndexer: csa attributes must be omitted for qsa"); + index_topk_ = 0; + } else { + ORT_ENFORCE(has_index_topk && index_topk_ > 0, "PackedSparseAttentionIndexer: index_topk must be > 0 for csa"); + ORT_ENFORCE(!has_token_budget, "PackedSparseAttentionIndexer: token_budget must be omitted for csa"); + token_budget_ = 0; + } + epsilon_ = info.GetAttrOrDefault("epsilon", 1.0e-6f); + ORT_ENFORCE(epsilon_ >= 0.0f, "PackedSparseAttentionIndexer: epsilon must be >= 0"); +} + +Status PackedSparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const { + const bool is_qsa = policy_ == psai::Policy::kQsa; + constexpr int kCsaOnlyInputs[] = {psai::kGate, psai::kPositionBias, psai::kHeadWeights}; + for (int index : kCsaOnlyInputs) { + const bool provided = index < context.InputCount() && context.Input(index) != nullptr; + ORT_RETURN_IF(provided != !is_qsa, "PackedSparseAttentionIndexer: input ", index, + provided ? " must be omitted for policy_mode 'qsa'" : " is required for policy_mode 'csa'"); + } + const bool position_ids_provided = + psai::kPositionIds < context.InputCount() && context.Input(psai::kPositionIds) != nullptr; + ORT_RETURN_IF(!is_qsa && !position_ids_provided, + "PackedSparseAttentionIndexer: position_ids is required for " + "policy_mode 'csa'"); + const bool gate_buffer_provided = + psai::kPastGateBuffer < context.InputCount() && context.Input(psai::kPastGateBuffer) != nullptr; + ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: past_gate_buffer ", + gate_buffer_provided ? "must be omitted for policy_mode 'qsa'" + : "is required for policy_mode 'csa'"); + return is_qsa ? ComputeQsa(context) : ComputeCsa(context); +} + +Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& context) const { + const Tensor* query = context.Input(psai::kQuery); + const Tensor* key = context.Input(psai::kKey); + const Tensor* norm = context.Input(psai::kKeyNormWeight); + const Tensor* cos_cache = context.Input(psai::kCosCache); + const Tensor* sin_cache = context.Input(psai::kSinCache); + const Tensor* cu_seqlens = context.Input(psai::kCumulativeSequenceLengths); + const Tensor* past_seqlens = context.Input(psai::kPastSequenceLengths); + const Tensor* position_ids = context.Input(psai::kPositionIds); + const Tensor* past_key_state = context.Input(psai::kPastKeyState); + const Tensor* past_kv_buffer = context.Input(psai::kPastKvBuffer); + const Tensor* past_state_lengths = context.Input(psai::kPastStateLengths); + + ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, "PackedSparseAttentionIndexer: query must have rank 3"); + const int64_t total_tokens = query_shape[0]; + const int64_t num_heads = query_shape[1]; + const int64_t head_size = query_shape[2]; + ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "PackedSparseAttentionIndexer: invalid query dimensions"); + + ORT_RETURN_IF(cu_seqlens == nullptr, "PackedSparseAttentionIndexer: cumulative_sequence_lengths is required"); + const auto& cu_shape = cu_seqlens->Shape(); + ORT_RETURN_IF_NOT(cu_shape.NumDimensions() == 1 && cu_shape[0] >= 1, + "PackedSparseAttentionIndexer: invalid cumulative_sequence_lengths shape"); + const int64_t batch_size = cu_shape[0] - 1; + + ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); + if (position_ids != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); + } + + RotaryCacheShape rotary; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary)); + ORT_RETURN_IF_NOT(rotary.rotary_width > 0 && rotary.rotary_width % 2 == 0 && rotary.rotary_width <= head_size, + "PackedSparseAttentionIndexer: invalid qsa rotary_width"); + + ORT_RETURN_IF(past_key_state == nullptr, "PackedSparseAttentionIndexer: past_key_state is required"); + const auto& key_state_shape = past_key_state->Shape(); + ORT_RETURN_IF_NOT(key_state_shape.NumDimensions() == 3 && key_state_shape[0] == batch_size && + key_state_shape[2] == head_size, + "PackedSparseAttentionIndexer: invalid past_key_state shape"); + const int64_t state_capacity = key_state_shape[1]; + + const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); + ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", + {batch_size, psai::kStateLengthColumns})); + + const int64_t capacity = psai::SelectedCapacity(psai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_); + Tensor* selected_indices = context.Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); + Tensor* selected_counts = context.Output(psai::kSelectedCounts, TensorShape({total_tokens})); + Tensor* present_key_state = context.Output(psai::kPresentKeyState, key_state_shape); + Tensor* present_kv_buffer = + context.Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, head_size})); + Tensor* present_state_lengths = + context.Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + + if (present_key_state->DataRaw() != past_key_state->DataRaw()) { + const int64_t total = present_key_state->Shape().Size(); + if (total > 0) { + PackedSparseAttentionIndexerCopyProgram copy; + copy.SetWorkgroupSize(kWorkgroupSize) + .AddInput({past_key_state, ProgramTensorMetadataDependency::Type}) + .AddOutput({present_key_state, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) + .AddUniformVariables({{ToUint32(total)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(copy)); + } + } + if (present_kv_buffer->DataRaw() != past_kv_buffer->DataRaw()) { + const int64_t total = present_kv_buffer->Shape().Size(); + if (total > 0) { + PackedSparseAttentionIndexerCopyProgram copy; + copy.SetWorkgroupSize(kWorkgroupSize) + .AddInput({past_kv_buffer, ProgramTensorMetadataDependency::Type}) + .AddOutput({present_kv_buffer, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) + .AddUniformVariables({{ToUint32(total)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(copy)); + } + } + if (present_state_lengths->DataRaw() != past_state_lengths->DataRaw()) { + const int64_t total = present_state_lengths->Shape().Size(); + if (total > 0) { + PackedSparseAttentionIndexerCopyProgram copy; + copy.SetWorkgroupSize(kWorkgroupSize) + .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) + .AddUniformVariables({{ToUint32(total)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(copy)); + } + } + + if (batch_size > 0) { + PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; + update.CacheHint(rotary.batched) + .SetWorkgroupSize(kWorkgroupSize) + .AddInputs({{key, ProgramTensorMetadataDependency::Type}, + {norm, ProgramTensorMetadataDependency::Type}, + {cos_cache, ProgramTensorMetadataDependency::Type}, + {sin_cache, ProgramTensorMetadataDependency::Type}, + {cu_seqlens, ProgramTensorMetadataDependency::Type}, + {past_kv_buffer, ProgramTensorMetadataDependency::Type}}) + .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, + {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) + .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32(batch_size)) + .AddUniformVariables({{ToUint32(batch_size)}, + {ToUint32(compress_ratio_)}, + {ToUint32(state_capacity)}, + {ToUint32(buffer_capacity)}, + {ToUint32(head_size)}, + {ToUint32(rotary.rotary_width)}, + {ToUint32(rotary.max_rotary_length)}, + {epsilon_}}); + ORT_RETURN_IF_ERROR(context.RunProgram(update)); + } + + if (total_tokens == 0) { + return Status::OK(); + } + + PackedSparseAttentionIndexerQsaSelectProgram select{rotary.batched, position_ids != nullptr}; + select.CacheHint(rotary.batched, position_ids != nullptr) + .SetWorkgroupSize(kWorkgroupSize) + .AddInputs({{query, ProgramTensorMetadataDependency::Type}, + {present_key_state, ProgramTensorMetadataDependency::Type}, + {cos_cache, ProgramTensorMetadataDependency::Type}, + {sin_cache, ProgramTensorMetadataDependency::Type}, + {cu_seqlens, ProgramTensorMetadataDependency::Type}, + {past_seqlens, ProgramTensorMetadataDependency::Type}}); + if (position_ids != nullptr) { + select.AddInput({position_ids, ProgramTensorMetadataDependency::Type}); + } + select.AddInput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddOutputs({{selected_indices, ProgramTensorMetadataDependency::Type}, + {selected_counts, ProgramTensorMetadataDependency::Type}}) + .SetDispatchGroupSize(ToUint32(total_tokens)) + .AddUniformVariables({{ToUint32(total_tokens)}, + {ToUint32(batch_size)}, + {ToUint32(num_heads)}, + {ToUint32(head_size)}, + {ToUint32(rotary.rotary_width)}, + {ToUint32(rotary.max_rotary_length)}, + {ToUint32(compress_ratio_)}, + {ToUint32(state_capacity)}, + {ToUint32(capacity)}, + {ToUint32(token_budget_ / compress_ratio_)}, + {epsilon_}, + {has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size))}}); + return context.RunProgram(select); +} + +Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& context) const { + const Tensor* query = context.Input(psai::kQuery); + const Tensor* key = context.Input(psai::kKey); + const Tensor* norm = context.Input(psai::kKeyNormWeight); + const Tensor* cos_cache = context.Input(psai::kCosCache); + const Tensor* sin_cache = context.Input(psai::kSinCache); + const Tensor* cu_seqlens = context.Input(psai::kCumulativeSequenceLengths); + const Tensor* past_seqlens = context.Input(psai::kPastSequenceLengths); + const Tensor* gate = context.Input(psai::kGate); + const Tensor* position_bias = context.Input(psai::kPositionBias); + const Tensor* head_weights = context.Input(psai::kHeadWeights); + const Tensor* position_ids = context.Input(psai::kPositionIds); + const Tensor* past_key_state = context.Input(psai::kPastKeyState); + const Tensor* past_kv_buffer = context.Input(psai::kPastKvBuffer); + const Tensor* past_gate_buffer = context.Input(psai::kPastGateBuffer); + const Tensor* past_state_lengths = context.Input(psai::kPastStateLengths); + + ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); + const auto& query_shape = query->Shape(); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, "PackedSparseAttentionIndexer: query must have rank 3"); + const int64_t total_tokens = query_shape[0]; + const int64_t num_heads = query_shape[1]; + const int64_t head_size = query_shape[2]; + ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "PackedSparseAttentionIndexer: invalid query dimensions"); + const int64_t width = 2 * head_size; + + ORT_RETURN_IF(cu_seqlens == nullptr, "PackedSparseAttentionIndexer: cumulative_sequence_lengths is required"); + const auto& cu_shape = cu_seqlens->Shape(); + ORT_RETURN_IF_NOT(cu_shape.NumDimensions() == 1 && cu_shape[0] >= 1, + "PackedSparseAttentionIndexer: invalid cumulative_sequence_lengths shape"); + const int64_t batch_size = cu_shape[0] - 1; + + ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, width})); + ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); + ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {total_tokens, width})); + ORT_RETURN_IF_ERROR(CheckShape(position_bias, "position_bias", {compress_ratio_, width})); + ORT_RETURN_IF_ERROR(CheckShape(head_weights, "head_weights", {total_tokens, num_heads})); + ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); + + RotaryCacheShape rotary; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary)); + ORT_RETURN_IF_NOT(rotary.rotary_width > 0 && 2 * rotary.rotary_width <= head_size, + "PackedSparseAttentionIndexer: invalid csa rotary_width"); + + ORT_RETURN_IF(past_key_state == nullptr, "PackedSparseAttentionIndexer: past_key_state is required"); + const auto& key_state_shape = past_key_state->Shape(); + ORT_RETURN_IF_NOT(key_state_shape.NumDimensions() == 3 && key_state_shape[0] == batch_size && + key_state_shape[2] == head_size, + "PackedSparseAttentionIndexer: invalid past_key_state shape"); + const int64_t state_capacity = key_state_shape[1]; + + const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); + ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, width})); + ORT_RETURN_IF_ERROR(CheckShape(past_gate_buffer, "past_gate_buffer", {batch_size, buffer_capacity, width})); + ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", + {batch_size, psai::kStateLengthColumns})); + + const int64_t capacity = psai::SelectedCapacity(psai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_); + Tensor* selected_indices = context.Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); + Tensor* selected_counts = context.Output(psai::kSelectedCounts, TensorShape({total_tokens})); + Tensor* present_key_state = context.Output(psai::kPresentKeyState, key_state_shape); + Tensor* present_kv_buffer = + context.Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, width})); + Tensor* present_gate_buffer = + context.Output(psai::kPresentGateBuffer, TensorShape({batch_size, buffer_capacity, width})); + Tensor* present_state_lengths = + context.Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + + auto copy_if_needed = [&](const Tensor* src, Tensor* dst) -> Status { + if (src->DataRaw() == dst->DataRaw()) { + return Status::OK(); + } + const int64_t total = dst->Shape().Size(); + if (total == 0) { + return Status::OK(); + } + PackedSparseAttentionIndexerCopyProgram copy; + copy.SetWorkgroupSize(kWorkgroupSize) + .AddInput({src, ProgramTensorMetadataDependency::Type}) + .AddOutput({dst, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) + .AddUniformVariables({{ToUint32(total)}}); + return context.RunProgram(copy); + }; + ORT_RETURN_IF_ERROR(copy_if_needed(past_key_state, present_key_state)); + ORT_RETURN_IF_ERROR(copy_if_needed(past_kv_buffer, present_kv_buffer)); + ORT_RETURN_IF_ERROR(copy_if_needed(past_gate_buffer, present_gate_buffer)); + ORT_RETURN_IF_ERROR(copy_if_needed(past_state_lengths, present_state_lengths)); + + if (batch_size > 0) { + PackedSparseAttentionIndexerCsaUpdateProgram update{rotary.batched}; + update.CacheHint(rotary.batched) + .SetWorkgroupSize(kWorkgroupSize) + .AddInputs({{key, ProgramTensorMetadataDependency::Type}, + {gate, ProgramTensorMetadataDependency::Type}, + {norm, ProgramTensorMetadataDependency::Type}, + {cos_cache, ProgramTensorMetadataDependency::Type}, + {sin_cache, ProgramTensorMetadataDependency::Type}, + {position_bias, ProgramTensorMetadataDependency::Type}, + {cu_seqlens, ProgramTensorMetadataDependency::Type}, + {past_kv_buffer, ProgramTensorMetadataDependency::Type}, + {past_gate_buffer, ProgramTensorMetadataDependency::Type}}) + .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, + {present_kv_buffer, ProgramTensorMetadataDependency::Type}, + {present_gate_buffer, ProgramTensorMetadataDependency::Type}}) + .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32(batch_size)) + .AddUniformVariables({{ToUint32(batch_size)}, + {ToUint32(compress_ratio_)}, + {ToUint32(state_capacity)}, + {ToUint32(buffer_capacity)}, + {ToUint32(head_size)}, + {ToUint32(rotary.rotary_width)}, + {ToUint32(rotary.max_rotary_length)}, + {epsilon_}}); + ORT_RETURN_IF_ERROR(context.RunProgram(update)); + } + + if (total_tokens == 0) { + return Status::OK(); + } + + PackedSparseAttentionIndexerCsaSelectProgram select{rotary.batched}; + select.CacheHint(rotary.batched) + .SetWorkgroupSize(kWorkgroupSize) + .AddInputs({{query, ProgramTensorMetadataDependency::Type}, + {present_key_state, ProgramTensorMetadataDependency::Type}, + {head_weights, ProgramTensorMetadataDependency::Type}, + {position_ids, ProgramTensorMetadataDependency::Type}, + {cos_cache, ProgramTensorMetadataDependency::Type}, + {sin_cache, ProgramTensorMetadataDependency::Type}, + {cu_seqlens, ProgramTensorMetadataDependency::Type}}) + .AddInput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddOutputs({{selected_indices, ProgramTensorMetadataDependency::Type}, + {selected_counts, ProgramTensorMetadataDependency::Type}}) + .SetDispatchGroupSize(ToUint32(total_tokens)) + .AddUniformVariables({{ToUint32(total_tokens)}, + {ToUint32(batch_size)}, + {ToUint32(num_heads)}, + {ToUint32(head_size)}, + {ToUint32(rotary.rotary_width)}, + {ToUint32(rotary.max_rotary_length)}, + {ToUint32(compress_ratio_)}, + {ToUint32(state_capacity)}, + {ToUint32(capacity)}, + {ToUint32(index_topk_)}, + {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); +} + +} // namespace webgpu +} // namespace contrib +} // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h new file mode 100644 index 0000000000000..c25b17b52e7ad --- /dev/null +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#pragma once + +#include "contrib_ops/cpu/sparse/packed_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; + +// Copies past_* state into present_* state unchanged (element-for-element); used as the baseline +// before the update programs below overwrite only the newly produced entries. Works for any +// tensor element type via UseElementTypeAlias, so it is reused for key_state / kv_buffer / +// gate_buffer (T) and state_lengths (int32). +class PackedSparseAttentionIndexerCopyProgram final + : public Program { + public: + PackedSparseAttentionIndexerCopyProgram() : Program{"PackedSparseAttentionIndexerCopy"} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"total", ProgramUniformVariableDataType::Uint32}); +}; + +// One invocation per request: forms every newly-closed compress_ratio block (mean-pool -> +// RMSNorm -> leading RoPE -> append) and publishes the raw trailing buffer. +class PackedSparseAttentionIndexerQsaUpdateProgram final + : public Program { + public: + explicit PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched) + : Program{"PackedSparseAttentionIndexerQsaUpdate"}, cos_cache_batched_{cos_cache_batched} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"state_capacity", ProgramUniformVariableDataType::Uint32}, + {"buffer_capacity", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"rotary_width", ProgramUniformVariableDataType::Uint32}, + {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"epsilon", ProgramUniformVariableDataType::Float32}); + + private: + bool cos_cache_batched_; +}; + +// One invocation per query token: rotates the query, scores it against every causally visible +// prepared key_state entry, selects the token_budget / compress_ratio highest scoring blocks, and +// appends the causally visible tokens of the trailing incomplete block. +class PackedSparseAttentionIndexerQsaSelectProgram final + : public Program { + public: + PackedSparseAttentionIndexerQsaSelectProgram(bool cos_cache_batched, bool has_position_ids) + : Program{"PackedSparseAttentionIndexerQsaSelect"}, + cos_cache_batched_{cos_cache_batched}, + has_position_ids_{has_position_ids} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"total_tokens", ProgramUniformVariableDataType::Uint32}, + {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"num_heads", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"rotary_width", ProgramUniformVariableDataType::Uint32}, + {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"state_capacity", ProgramUniformVariableDataType::Uint32}, + {"capacity", ProgramUniformVariableDataType::Uint32}, + {"block_topk", ProgramUniformVariableDataType::Uint32}, + {"epsilon", ProgramUniformVariableDataType::Float32}, + {"scale", ProgramUniformVariableDataType::Float32}); + + private: + bool cos_cache_batched_; + bool has_position_ids_; +}; + +// One invocation per request: closes every new compression window (softmax-gated pool -> RMSNorm +// -> trailing RoPE -> append) and publishes the raw overlap+leftover buffer. +class PackedSparseAttentionIndexerCsaUpdateProgram final + : public Program { + public: + explicit PackedSparseAttentionIndexerCsaUpdateProgram(bool cos_cache_batched) + : Program{"PackedSparseAttentionIndexerCsaUpdate"}, cos_cache_batched_{cos_cache_batched} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"state_capacity", ProgramUniformVariableDataType::Uint32}, + {"buffer_capacity", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"rotary_width", ProgramUniformVariableDataType::Uint32}, + {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"epsilon", ProgramUniformVariableDataType::Float32}); + + private: + bool cos_cache_batched_; +}; + +// One invocation per query token: rotates the query, scores it against every causally visible +// compressed key_state entry, and selects the index_topk highest scoring entries. +class PackedSparseAttentionIndexerCsaSelectProgram final + : public Program { + public: + explicit PackedSparseAttentionIndexerCsaSelectProgram(bool cos_cache_batched) + : Program{"PackedSparseAttentionIndexerCsaSelect"}, cos_cache_batched_{cos_cache_batched} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"total_tokens", ProgramUniformVariableDataType::Uint32}, + {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"num_heads", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"rotary_width", ProgramUniformVariableDataType::Uint32}, + {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"state_capacity", ProgramUniformVariableDataType::Uint32}, + {"capacity", ProgramUniformVariableDataType::Uint32}, + {"index_topk", ProgramUniformVariableDataType::Uint32}, + {"scale", ProgramUniformVariableDataType::Float32}, + {"head_weight_scale", ProgramUniformVariableDataType::Float32}); + + private: + bool cos_cache_batched_; +}; + +class PackedSparseAttentionIndexer final : public WebGpuKernel { + public: + explicit PackedSparseAttentionIndexer(const OpKernelInfo& info); + Status ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const override; + + private: + Status ComputeQsa(onnxruntime::webgpu::ComputeContext& context) const; + Status ComputeCsa(onnxruntime::webgpu::ComputeContext& context) const; + + packed_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_; + bool has_scale_; + bool has_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 8f6e2f458c526..1d932e849e451 100644 --- a/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc +++ b/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc @@ -12,6 +12,7 @@ #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 "contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h" #include "core/framework/op_kernel.h" @@ -54,6 +55,7 @@ static const BuildKernelCreateInfoFn build_kernel_create_info_function_table[] = BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, + BuildKernelCreateInfo, BuildKernelCreateInfo, BuildKernelCreateInfo, // LayerNormalization used to be a contrib op that (incorrectly) used kOnnxDomain so we need to version it diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 7120200ba4751..93435b40f8bc0 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -13,6 +13,7 @@ #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" +#include "contrib_ops/cpu/sparse/packed_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) @@ -2297,6 +2298,388 @@ ONNX_MS_OPERATOR_SET_SCHEMA( SparseAttentionIndexerTypeAndShapeInference(ctx); })); +namespace psai = ::onnxruntime::contrib::packed_sparse_attention_indexer; + +namespace { + +bool PackedSparseAttentionIndexerHasInput(ONNX_NAMESPACE::InferenceContext& ctx, int index) { + return static_cast(index) < ctx.getNumInputs() && ctx.getInputType(index) != nullptr; +} + +const ONNX_NAMESPACE::TensorShapeProto* PackedSparseAttentionIndexerShape(ONNX_NAMESPACE::InferenceContext& ctx, + int index, int expected_rank) { + if (!PackedSparseAttentionIndexerHasInput(ctx, index) || !hasInputShape(ctx, index)) { + return nullptr; + } + const auto& shape = getInputShape(ctx, index); + if (shape.dim_size() != expected_rank) { + fail_shape_inference("PackedSparseAttentionIndexer: input ", index, " must have rank ", expected_rank, + ", got rank ", shape.dim_size()); + } + return &shape; +} + +} // namespace + +void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx) { + const std::string policy_mode = getAttribute(ctx, "policy_mode", std::string()); + psai::Policy policy = psai::Policy::kQsa; + if (!psai::TryParsePolicy(policy_mode, policy)) { + fail_shape_inference("PackedSparseAttentionIndexer: policy_mode must be 'qsa' or 'csa', got '", policy_mode, + "'"); + } + const bool is_qsa = policy == psai::Policy::kQsa; + + const int64_t compress_ratio = getAttribute(ctx, "compress_ratio", static_cast(0)); + if (compress_ratio <= 0 || compress_ratio > std::numeric_limits::max()) { + fail_shape_inference("PackedSparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", + compress_ratio); + } + const int64_t state_capacity = getAttribute(ctx, "state_capacity", static_cast(0)); + if (state_capacity <= 0 || state_capacity > std::numeric_limits::max()) { + fail_shape_inference("PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", + state_capacity); + } + + const int64_t token_budget = getAttribute(ctx, "token_budget", static_cast(0)); + const int64_t index_topk = getAttribute(ctx, "index_topk", static_cast(0)); + if (is_qsa) { + if (ctx.getAttribute("index_topk") != nullptr || ctx.getAttribute("head_weight_scale") != nullptr) { + fail_shape_inference( + "PackedSparseAttentionIndexer: index_topk and head_weight_scale must not be set when policy_mode is " + "'qsa'"); + } + if (token_budget <= 0 || token_budget % compress_ratio != 0 || + token_budget > std::numeric_limits::max() - compress_ratio + 1) { + fail_shape_inference( + "PackedSparseAttentionIndexer: 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("PackedSparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); + } + if (index_topk <= 0 || index_topk > std::numeric_limits::max()) { + fail_shape_inference( + "PackedSparseAttentionIndexer: policy_mode 'csa' requires index_topk in (0, INT_MAX], got ", index_topk); + } + } + + // Strict policy input validation: every fixed slot required by every policy must be provided; + // csa-only slots must be provided iff policy_mode is 'csa'; position_ids is optional for 'qsa' + // and required for 'csa'. + for (int index : {psai::kQuery, psai::kKey, psai::kKeyNormWeight, psai::kCosCache, psai::kSinCache, + psai::kCumulativeSequenceLengths, psai::kPastSequenceLengths, psai::kPastKeyState, + psai::kPastKvBuffer, psai::kPastStateLengths}) { + if (!PackedSparseAttentionIndexerHasInput(ctx, index)) { + fail_shape_inference("PackedSparseAttentionIndexer: input ", index, " is required for every policy_mode"); + } + } + for (int index : {psai::kGate, psai::kPositionBias, psai::kHeadWeights, psai::kPastGateBuffer}) { + if (PackedSparseAttentionIndexerHasInput(ctx, index) == is_qsa) { + fail_shape_inference("PackedSparseAttentionIndexer: input ", index, + is_qsa ? " must be omitted when policy_mode is 'qsa'" + : " is required when policy_mode is 'csa'"); + } + } + if (!is_qsa && !PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { + fail_shape_inference("PackedSparseAttentionIndexer: input ", psai::kPositionIds, + " (position_ids) is required when policy_mode is 'csa'"); + } + + if (ctx.getNumOutputs() != static_cast(psai::kFixedOutputCount)) { + fail_shape_inference("PackedSparseAttentionIndexer: exactly ", psai::kFixedOutputCount, + " declared outputs are required, got ", ctx.getNumOutputs()); + } + + updateOutputElemType(ctx, psai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); + updateOutputElemType(ctx, psai::kSelectedCounts, ONNX_NAMESPACE::TensorProto_DataType_INT32); + updateOutputElemType(ctx, psai::kPresentStateLengths, ONNX_NAMESPACE::TensorProto_DataType_INT32); + propagateElemTypeFromInputToOutput(ctx, psai::kPastKeyState, psai::kPresentKeyState); + propagateElemTypeFromInputToOutput(ctx, psai::kPastKvBuffer, psai::kPresentKvBuffer); + if (!is_qsa) { + // present_gate_buffer keeps its fixed positional slot (with an empty name) for policy_mode + // 'qsa'; only propagate its type/shape when it is actually produced. + propagateElemTypeFromInputToOutput(ctx, psai::kPastGateBuffer, psai::kPresentGateBuffer); + } + + (void)PackedSparseAttentionIndexerShape(ctx, psai::kKeyNormWeight, 1); + (void)PackedSparseAttentionIndexerShape(ctx, psai::kKey, 2); + (void)PackedSparseAttentionIndexerShape(ctx, psai::kCumulativeSequenceLengths, 1); + (void)PackedSparseAttentionIndexerShape(ctx, psai::kPastSequenceLengths, 1); + if (!is_qsa) { + (void)PackedSparseAttentionIndexerShape(ctx, psai::kGate, 2); + (void)PackedSparseAttentionIndexerShape(ctx, psai::kPositionBias, 2); + (void)PackedSparseAttentionIndexerShape(ctx, psai::kHeadWeights, 2); + } + if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { + (void)PackedSparseAttentionIndexerShape(ctx, psai::kPositionIds, 1); + } + + const auto* query_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQuery, 3); + if (query_shape != nullptr) { + const auto& total_tokens_dim = query_shape->dim(0); + const auto& num_heads_dim = query_shape->dim(1); + if (num_heads_dim.has_dim_value() && num_heads_dim.dim_value() <= 0) { + fail_shape_inference("PackedSparseAttentionIndexer: num_heads must be > 0, got ", num_heads_dim.dim_value()); + } + + const int64_t capacity = psai::SelectedCapacity(policy, token_budget, index_topk, compress_ratio); + ONNX_NAMESPACE::TensorShapeProto selected_shape; + SparseAttentionIndexerAppendDim(selected_shape, total_tokens_dim); + selected_shape.add_dim()->set_dim_value(capacity); + updateOutputShape(ctx, psai::kSelectedIndices, selected_shape); + + ONNX_NAMESPACE::TensorShapeProto counts_shape; + SparseAttentionIndexerAppendDim(counts_shape, total_tokens_dim); + updateOutputShape(ctx, psai::kSelectedCounts, counts_shape); + } + + // State never grows: present_* always has exactly the same fixed shape as past_*. + const auto* key_state_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKeyState, 3); + if (key_state_shape != nullptr) { + updateOutputShape(ctx, psai::kPresentKeyState, *key_state_shape); + } + const auto* kv_buffer_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKvBuffer, 3); + if (kv_buffer_shape != nullptr) { + updateOutputShape(ctx, psai::kPresentKvBuffer, *kv_buffer_shape); + if (!is_qsa) { + updateOutputShape(ctx, psai::kPresentGateBuffer, *kv_buffer_shape); + } + } + const auto* state_lengths_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastStateLengths, 2); + if (state_lengths_shape != nullptr) { + updateOutputShape(ctx, psai::kPresentStateLengths, *state_lengths_shape); + } +} + +constexpr const char* PackedSparseAttentionIndexer_ver1_doc = R"DOC( +Packed/variable-length counterpart of SparseAttentionIndexer, for continuous-batching engines +(such as an OgaEngine-style PagedAttention model) that flatten every request's tokens into one +[total_tokens, ...] axis instead of a dense [batch_size, sequence_length, ...] axis. It selects, for +every packed query token, the sparse-attention candidates that a following SparsePagedAttention (or +similar) operator is allowed to read. + +Unlike SparseAttentionIndexer, this operator: + * takes packed query/key tensors plus cumulative_sequence_lengths (request boundaries) and + past_sequence_lengths (per-request past length) instead of a dense batch and a dense mask; + * derives ordinary causal visibility purely from that packed metadata -- there is no mask input; + * uses a single generic set of state slots (past_key_state / past_kv_buffer / past_gate_buffer / + past_state_lengths) for both policy_mode values, each with a shape that is fixed across calls + (state never grows and is never concatenated); a step that would overflow the fixed capacity is + rejected as a deterministic no-op on state rather than truncated or allowed to corrupt memory; + * additionally emits selected_counts, the exact number of active (non -1) entries per query, so + that no downstream consumer needs to scan selected_indices for its query's true count. + +Both policy_mode values keep the semantics of SparseAttentionIndexer, applied independently to each +request's own packed token range and fixed-capacity state slice: + + policy_mode = "qsa" ("query sparse attention" token indexer) + Processes each request's new tokens sequentially: appends raw indexer keys to the generic + pending buffer, and whenever it reaches compress_ratio tokens, mean-pools it, applies RMSNorm + and key_norm_weight, applies the leading/split-half rotary convention at the block's first + logical token position, and appends the prepared (already normalized and rotated) key to + key_state. Queries are scored against every causally visible complete block with + sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and + their token indices are emitted (request-local logical positions, i.e. the same numbering as + past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing + incomplete block. + + policy_mode = "csa" ("compressed sparse attention" block indexer) + Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window + count) independently per request, using that request's own buffer_length and new token count; + every newly closed window is compressed with the softmax-gated Ca/Cb pooling, normalized, + rotated and appended to key_state. Queries are scored against every causally visible compressed + entry with sum_h w_h * ReLU(q_h . k) and the index_topk highest scoring entry indices are + emitted. + +Common contract: + * selected_indices is int32 with a fixed capacity that only depends on attributes: + token_budget + compress_ratio - 1 for "qsa" (values are request-local token positions into the + main key/value cache, directly consumable by SparsePagedAttention configured with + attention_mode="selected_only", selected_kv_source="main") and index_topk for "csa" (values are + compressed-entry indices into key_state, directly consumable by SparsePagedAttention configured + with attention_mode="local_plus_selected", selected_kv_source="auxiliary"; key_state is + layout-compatible with a [batch_size, capacity, 1, head_size] auxiliary cache when K = V). + Unused entries are -1 and selected_counts holds the exact number of used entries. + * key_norm_weight is the effective RMSNorm multiplier, exactly as in SparseAttentionIndexer. + * 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. + * cos_cache / sin_cache may be shared across the batch ([max_position, rotary_width]) or + request-specific ([batch_size, max_position, rotary_width]). + * cumulative_sequence_lengths, past_sequence_lengths and past_state_lengths are read directly by + the device kernel; a zero-token request row (a repeated cumulative offset) is valid and simply + contributes no query rows for that request. + +OgaEngine integration note: this operator only defines the ORT contrib op; wiring +past_key_state / past_kv_buffer / past_gate_buffer / past_state_lengths as Engine-managed, +per-request fixed-size state (analogous to a paged auxiliary cache) is expected to happen in the +OgaEngine / Model Builder integration, which is out of scope for this operator definition. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + PackedSparseAttentionIndexer, 1, + OpSchema() + .SetDoc(PackedSparseAttentionIndexer_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/pooled entry. Must be > 0.", + AttributeProto::INT) + .Attr("state_capacity", + "Fixed capacity (number of entries) of past_key_state / present_key_state. 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", + "Packed indexer queries with shape (total_tokens, num_heads, head_size), already normalized but " + "not yet rotated.", + "T") + .Input(1, + "key", + "Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for " + "policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first " + "head_size channels are the Ca series and the last head_size channels the Cb series.", + "T") + .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, shared across the batch with shape " + "(max_rotary_sequence_length, rotary_width) or request-specific with shape " + "(batch_size, max_rotary_sequence_length, rotary_width).", + "T") + .Input(4, + "sin_cache", + "Sine rotary table with the same shape as cos_cache.", + "T") + .Input(5, + "cumulative_sequence_lengths", + "Device-resident packed request boundaries with shape (batch_size + 1); " + "cumulative_sequence_lengths[0] must be 0 and cumulative_sequence_lengths[batch_size] must equal " + "total_tokens. Request b owns rows [cumulative_sequence_lengths[b], " + "cumulative_sequence_lengths[b + 1]) of query/key (a repeated offset is a valid zero-token row).", + "M") + .Input(6, + "past_sequence_lengths", + "Device-resident number of tokens already processed for each request before this call, with " + "shape (batch_size). Used as the default absolute query position when position_ids is omitted " + "(policy_mode 'qsa'), and to validate state consistency.", + "M") + .Input(7, + "gate", + "Only for policy_mode 'csa': gate projection of the new tokens with shape " + "(total_tokens, 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 (total_tokens, num_heads).", + "T", + OpSchema::Optional) + .Input(10, + "position_ids", + "Optional for policy_mode 'qsa', required for policy_mode 'csa': absolute position of every " + "packed query, with shape (total_tokens).", + "I", + OpSchema::Optional) + .Input(11, + "past_key_state", + "Generic fixed-capacity state: policy_mode 'qsa' stores prepared complete-block keys; " + "policy_mode 'csa' stores compressed keys. Shape is (batch_size, state_capacity, head_size) and " + "never changes across calls.", + "T") + .Input(12, + "past_kv_buffer", + "Generic fixed-capacity pending-token buffer. Shape is (batch_size, 2 * compress_ratio - 1, " + "head_size) for policy_mode 'qsa' (which only ever uses up to compress_ratio - 1 of these " + "entries) and (batch_size, 2 * compress_ratio - 1, 2 * head_size) for policy_mode 'csa'.", + "T") + .Input(13, + "past_gate_buffer", + "Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.", + "T", + OpSchema::Optional) + .Input(14, + "past_state_lengths", + "Generic per-request state length with shape (batch_size, 2). Column 0 is the key_state entry " + "count (policy_mode 'qsa': complete-block count; 'csa': compressed-entry count); column 1 is the " + "pending-buffer length (policy_mode 'qsa': incomplete-block length in [0, compress_ratio); 'csa': " + "buffer length in [0, 2 * compress_ratio)).", + "M") + .Output(0, + "selected_indices", + "Selected entries with shape (total_tokens, capacity). capacity is " + "token_budget + compress_ratio - 1 for policy_mode 'qsa' (request-local token positions into the " + "main key/value cache) and index_topk for policy_mode 'csa' (compressed entry indices into " + "key_state). Unused entries are -1.", + "M") + .Output(1, + "selected_counts", + "Exact number of used (non -1) entries of selected_indices for every query, with shape " + "(total_tokens).", + "M") + .Output(2, + "present_key_state", + "Updated generic key state, with the same fixed shape as past_key_state.", + "T") + .Output(3, + "present_kv_buffer", + "Updated generic pending-token buffer, with the same fixed shape as past_kv_buffer.", + "T") + .Output(4, + "present_gate_buffer", + "Only for policy_mode 'csa': updated gate buffer with the same fixed shape as past_gate_buffer.", + "T", + OpSchema::Optional) + .Output(5, + "present_state_lengths", + "Updated generic per-request state length, with the same fixed shape as past_state_lengths.", + "M") + .TypeConstraint("T", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain floating point tensors to float, float16 and bfloat16.") + .TypeConstraint("I", {"tensor(int64)"}, "Constrain position ids to 64-bit integer tensors.") + .TypeConstraint("M", {"tensor(int32)"}, + "Constrain packed metadata, generic state lengths and selected indices/counts to 32-bit " + "integer tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + PackedSparseAttentionIndexerTypeAndShapeInference(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 c607b7f81cb7a..fdef7c98786f7 100644 --- a/onnxruntime/core/graph/contrib_ops/ms_opset.h +++ b/onnxruntime/core/graph/contrib_ops/ms_opset.h @@ -117,6 +117,7 @@ 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, PackedSparseAttentionIndexer); 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); @@ -244,6 +245,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 ed2a384f30401..1bbc0e2f64c0f 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -238,6 +238,7 @@ def __init__(self, int_max, auto_merge, guess_output_rank, verbose, prefix=""): "SkipSimplifiedLayerNormalization": self._infer_SkipLayerNormalization, "SparseAttention": self._infer_SparseAttention, "SparseAttentionIndexer": self._infer_SparseAttentionIndexer, + "PackedSparseAttentionIndexer": self._infer_PackedSparseAttentionIndexer, "UnfoldTensor": self._infer_UnfoldTensor, } self.aten_op_dispatcher_ = { @@ -498,6 +499,7 @@ def _onnx_infer_single_node(self, node): "SkipSimplifiedLayerNormalization", "SparseAttention", "SparseAttentionIndexer", + "PackedSparseAttentionIndexer", "SkipGroupNorm", "QLinearAdd", "QLinearMul", @@ -2697,6 +2699,55 @@ def past_shape(index): 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_PackedSparseAttentionIndexer(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) + total_tokens = query_shape[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([total_tokens, capacity]), + ) + ) + if len(node.output) > 1 and node.output[1]: + vi = self.known_vi_[node.output[1]] + vi.CopyFrom( + helper.make_tensor_value_info( + node.output[1], onnx.TensorProto.INT32, get_shape_from_sympy_shape([total_tokens]) + ) + ) + + def copy_state_output(output_index, input_index, dtype): + # State never grows: present_* always has exactly the same fixed shape as past_*, so + # shape inference can simply copy it, unlike the dense op's growing/concatenated state. + if output_index >= len(node.output) or not node.output[output_index]: + return + if input_index >= len(node.input) or not node.input[input_index]: + return + shape = self._get_sympy_shape(node, input_index) + out_vi = self.known_vi_[node.output[output_index]] + out_vi.CopyFrom( + helper.make_tensor_value_info(node.output[output_index], dtype, get_shape_from_sympy_shape(shape)) + ) + + copy_state_output(2, 11, output_dtype) # present_key_state <- past_key_state + copy_state_output(3, 12, output_dtype) # present_kv_buffer <- past_kv_buffer + if policy_mode != "qsa": + copy_state_output(4, 13, output_dtype) # present_gate_buffer <- past_gate_buffer + copy_state_output(5, 14, onnx.TensorProto.INT32) # present_state_lengths <- past_state_lengths + def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) if len(node.output) > 1: diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc new file mode 100644 index 0000000000000..e6197057f623d --- /dev/null +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -0,0 +1,1044 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Coverage for com.microsoft.PackedSparseAttentionIndexer. +// +// The "ShapeInference" suite drives Graph::Resolve() directly, so it runs in every build: it pins +// the fixed selected_indices/selected_counts shapes, the fixed (never data-dependent) state output +// shapes, 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 or WebGPU execution provider (the operator has no CPU kernel) +// and is skipped when neither is available. Expectations come from a float reference in this file +// that mirrors the operator contract (mean-pool/RMSNorm/RoPE for qsa, softmax-gated pooling for +// csa, deterministic top-k selection); 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 + +#include "gtest/gtest.h" + +#include "contrib_ops/cpu/sparse/packed_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 psai = ::onnxruntime::contrib::packed_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("packed_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 GraphOptions { + int64_t batch_size = 2; + int64_t total_tokens = 5; + int64_t num_heads = 2; + int64_t head_size = 8; + int64_t rotary_width = 8; + int64_t compress_ratio = 2; + int64_t state_capacity = 6; + int64_t token_budget = 4; + bool add_index_topk = false; + bool add_csa_inputs = false; + bool add_position_ids = false; + int output_count = psai::kFixedOutputCount; + std::string policy_mode = psai::kPolicyModeQsa; +}; + +int64_t BufferCapacity(int64_t compress_ratio) { return 2 * compress_ratio - 1; } + +// Builds a fixed 15-input node; csa-only slots are left empty for policy_mode "qsa", as the schema +// requires. position_ids (slot 10) is optional for "qsa" and forced on for "csa". +void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { + const bool is_csa = options.policy_mode == psai::kPolicyModeCsa; + const int64_t width = is_csa ? 2 * options.head_size : options.head_size; + const int64_t buffer_capacity = BufferCapacity(options.compress_ratio); + NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); + + std::vector inputs{ + builder.MakeInput( + std::vector{options.total_tokens, options.num_heads, options.head_size}), + builder.MakeInput(std::vector{options.total_tokens, width}), + builder.MakeInput(std::vector{options.head_size}), + builder.MakeInput(std::vector{64, options.rotary_width}), + builder.MakeInput(std::vector{64, options.rotary_width}), + builder.MakeInput(std::vector{options.batch_size + 1}), + builder.MakeInput(std::vector{options.batch_size}), + }; + if (is_csa || options.add_csa_inputs) { + inputs.push_back(builder.MakeInput(std::vector{options.total_tokens, width})); + inputs.push_back(builder.MakeInput(std::vector{options.compress_ratio, width})); + inputs.push_back(builder.MakeInput(std::vector{options.total_tokens, options.num_heads})); + } else { + inputs.push_back(&empty); + inputs.push_back(&empty); + inputs.push_back(&empty); + } + if (is_csa || options.add_position_ids) { + inputs.push_back(builder.MakeInput(std::vector{options.total_tokens})); + } else { + inputs.push_back(&empty); + } + inputs.push_back( + builder.MakeInput(std::vector{options.batch_size, options.state_capacity, options.head_size})); + inputs.push_back( + builder.MakeInput(std::vector{options.batch_size, buffer_capacity, width})); + if (is_csa) { + inputs.push_back(builder.MakeInput(std::vector{options.batch_size, buffer_capacity, width})); + } else { + inputs.push_back(&empty); + } + inputs.push_back(builder.MakeInput(std::vector{options.batch_size, 2})); + + std::vector outputs; + for (int i = 0; i < options.output_count; ++i) { + outputs.push_back(i == psai::kPresentGateBuffer && !is_csa ? &empty : builder.MakeOutput()); + } + Node& node = builder.AddNode("PackedSparseAttentionIndexer", inputs, outputs, kMSDomain); + node.AddAttribute("policy_mode", options.policy_mode); + node.AddAttribute("compress_ratio", options.compress_ratio); + node.AddAttribute("state_capacity", options.state_capacity); + if (is_csa || options.add_index_topk) { + node.AddAttribute("index_topk", static_cast(3)); + } else { + node.AddAttribute("token_budget", options.token_budget); + } +} + +} // namespace + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndState) { + GraphOptions options; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + const int64_t capacity = options.token_budget + options.compress_ratio - 1; + ExpectShape(graph, node.OutputDefs()[psai::kSelectedIndices]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.total_tokens, capacity}); + ExpectShape(graph, node.OutputDefs()[psai::kSelectedCounts]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.total_tokens}); + ExpectShape(graph, node.OutputDefs()[psai::kPresentKeyState]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.state_capacity, options.head_size}); + ExpectShape(graph, node.OutputDefs()[psai::kPresentKvBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, BufferCapacity(options.compress_ratio), options.head_size}); + ExpectShape(graph, node.OutputDefs()[psai::kPresentStateLengths]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_INT32, {options.batch_size, 2}); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndState) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + ExpectShape(graph, node.OutputDefs()[psai::kSelectedIndices]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.total_tokens, 3}); + ExpectShape(graph, node.OutputDefs()[psai::kSelectedCounts]->Name(), ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.total_tokens}); + ExpectShape(graph, node.OutputDefs()[psai::kPresentKeyState]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.state_capacity, options.head_size}); + ExpectShape(graph, node.OutputDefs()[psai::kPresentKvBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, BufferCapacity(options.compress_ratio), 2 * options.head_size}); + ExpectShape(graph, node.OutputDefs()[psai::kPresentGateBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, BufferCapacity(options.compress_ratio), 2 * options.head_size}); + ExpectShape(graph, node.OutputDefs()[psai::kPresentStateLengths]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_INT32, {options.batch_size, 2}); +} + +#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(PackedSparseAttentionIndexerShapeInferenceTest, RejectsUnknownPolicyMode) { + GraphOptions options; + options.policy_mode = "qsa_v2"; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "policy_mode must be 'qsa' or 'csa'"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaAttribute) { + GraphOptions options; + options.add_index_topk = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "index_topk and head_weight_scale must not be set"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { + GraphOptions options; + options.num_heads = 0; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "num_heads must be > 0"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { + GraphOptions options; + options.token_budget = 5; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "requires token_budget > 0, divisible by compress_ratio"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { + GraphOptions options; + options.add_csa_inputs = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "must be omitted when policy_mode is 'qsa'"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithPositionIdsAndCsaInputsMismatch) { + // position_ids alone is allowed for qsa (optional); only the csa-only slots must be omitted. + GraphOptions options; + options.add_position_ids = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingPositionIds) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + options.add_position_ids = false; + // Force the csa node to omit position_ids by rebuilding without it. + std::unique_ptr model; + const Status status = BuildAndResolve( + [&options](ModelTestBuilder& builder) { + GraphOptions local = options; + NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); + const int64_t width = 2 * local.head_size; + const int64_t buffer_capacity = BufferCapacity(local.compress_ratio); + std::vector inputs{ + builder.MakeInput(std::vector{local.total_tokens, local.num_heads, local.head_size}), + builder.MakeInput(std::vector{local.total_tokens, width}), + builder.MakeInput(std::vector{local.head_size}), + builder.MakeInput(std::vector{64, local.rotary_width}), + builder.MakeInput(std::vector{64, local.rotary_width}), + builder.MakeInput(std::vector{local.batch_size + 1}), + builder.MakeInput(std::vector{local.batch_size}), + builder.MakeInput(std::vector{local.total_tokens, width}), + builder.MakeInput(std::vector{local.compress_ratio, width}), + builder.MakeInput(std::vector{local.total_tokens, local.num_heads}), + &empty, // position_ids omitted: invalid for csa + builder.MakeInput( + std::vector{local.batch_size, local.state_capacity, local.head_size}), + builder.MakeInput(std::vector{local.batch_size, buffer_capacity, width}), + builder.MakeInput(std::vector{local.batch_size, buffer_capacity, width}), + builder.MakeInput(std::vector{local.batch_size, 2}), + }; + std::vector outputs; + for (int i = 0; i < psai::kFixedOutputCount; ++i) { + outputs.push_back(builder.MakeOutput()); + } + Node& node = builder.AddNode("PackedSparseAttentionIndexer", inputs, outputs, kMSDomain); + node.AddAttribute("policy_mode", local.policy_mode); + node.AddAttribute("compress_ratio", local.compress_ratio); + node.AddAttribute("state_capacity", local.state_capacity); + node.AddAttribute("index_topk", static_cast(3)); + }, + model); + ASSERT_FALSE(status.IsOK()); + EXPECT_NE(status.ErrorMessage().find("position_ids"), std::string::npos) << status.ErrorMessage(); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsWrongOutputCount) { + GraphOptions options; + options.output_count = 4; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "exactly 6 declared outputs"); +} + +#endif // ORT_NO_EXCEPTIONS + +// --------------------------------------------------------------------------------------------- +// Numeric behaviour (CUDA / WebGPU only) +// --------------------------------------------------------------------------------------------- + +namespace { + +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 RunOnProvider(OpTester& test, std::unique_ptr provider) { + std::vector> providers; + providers.push_back(std::move(provider)); + test.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); +} + +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; +} + +int CausalThreshold(int position, int compress_ratio) { + if (position < 0) return 0; + return position / compress_ratio + (position % compress_ratio == compress_ratio - 1 ? 1 : 0); +} + +struct QsaPackedProblem { + int batch_size = 2; + std::vector cumulative_sequence_lengths; // [batch + 1] + std::vector past_sequence_lengths; // [batch] + std::vector past_state_lengths; // [batch, 2] + int head_size = 4; + int num_heads = 2; + int rotary_width = 4; + int compress_ratio = 2; + int token_budget = 4; + int state_capacity = 4; + int max_position = 64; + float epsilon = 1.0e-6f; + std::optional scale; + + std::vector query; + std::vector key; + std::vector key_norm_weight; + std::vector cos_cache; // shared: [max_position, rotary_width] + std::vector sin_cache; + std::vector past_key_state; // [batch, state_capacity, head_size] + std::vector past_kv_buffer; // [batch, buffer_capacity, head_size] + + int TotalTokens() const { return cumulative_sequence_lengths.back(); } + int BufferCapacity() const { return 2 * compress_ratio - 1; } + int Capacity() const { return token_budget + compress_ratio - 1; } +}; + +struct QsaPackedResult { + std::vector selected_indices; + std::vector selected_counts; + std::vector present_key_state; + std::vector present_kv_buffer; + std::vector present_state_lengths; +}; + +void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { + const int total_tokens = p.TotalTokens(); + const int head_size = p.head_size; + const int block_topk = p.token_budget / p.compress_ratio; + const float scale = p.scale.value_or(1.0f / std::sqrt(static_cast(head_size))); + const int buffer_capacity = p.BufferCapacity(); + const int capacity = p.Capacity(); + + out.present_key_state = p.past_key_state; + out.present_kv_buffer.assign(static_cast(p.batch_size) * buffer_capacity * head_size, 0.0f); + out.present_state_lengths.assign(static_cast(p.batch_size) * 2, 0); + + std::vector key_len_after(static_cast(p.batch_size)); + for (int b = 0; b < p.batch_size; ++b) { + const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; + const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; + const int req_len = std::max(req_end - req_start, 0); + const int old_key_len = + std::clamp(p.past_state_lengths[static_cast(b) * 2 + 0], 0, p.state_capacity); + const int old_buf_len = + std::clamp(p.past_state_lengths[static_cast(b) * 2 + 1], 0, p.compress_ratio - 1); + const int pending = old_buf_len + req_len; + const int new_block_count = std::min(pending / p.compress_ratio, std::max(p.state_capacity - old_key_len, 0)); + const int new_buf_len = new_block_count < pending / p.compress_ratio ? 0 : pending % p.compress_ratio; + + auto raw_value = [&](int virtual_pos, int d) -> float { + return virtual_pos < old_buf_len + ? p.past_kv_buffer[(static_cast(b) * buffer_capacity + virtual_pos) * head_size + d] + : p.key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * head_size + d]; + }; + + for (int k = 0; k < new_block_count; ++k) { + std::vector pooled(static_cast(head_size), 0.0f); + for (int t = 0; t < p.compress_ratio; ++t) { + for (int d = 0; d < head_size; ++d) { + pooled[static_cast(d)] += raw_value(k * p.compress_ratio + t, d); + } + } + for (float& v : pooled) v /= static_cast(p.compress_ratio); + pooled = RmsNormalize(pooled, p.key_norm_weight, p.epsilon); + const int entry = old_key_len + k; + const int position = std::min(entry * p.compress_ratio, p.max_position - 1); + pooled = LeadingRope(pooled, p.rotary_width, p.cos_cache.data() + position * p.rotary_width, + p.sin_cache.data() + position * p.rotary_width); + for (int d = 0; d < head_size; ++d) { + out.present_key_state[(static_cast(b) * p.state_capacity + entry) * head_size + d] = + pooled[static_cast(d)]; + } + } + for (int t = 0; t < new_buf_len; ++t) { + for (int d = 0; d < head_size; ++d) { + out.present_kv_buffer[(static_cast(b) * buffer_capacity + t) * head_size + d] = + raw_value(new_block_count * p.compress_ratio + t, d); + } + } + out.present_state_lengths[static_cast(b) * 2 + 0] = old_key_len + new_block_count; + out.present_state_lengths[static_cast(b) * 2 + 1] = new_buf_len; + key_len_after[static_cast(b)] = old_key_len + new_block_count; + } + + out.selected_indices.assign(static_cast(total_tokens) * capacity, -1); + out.selected_counts.assign(static_cast(total_tokens), 0); + for (int b = 0; b < p.batch_size; ++b) { + const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; + const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; + for (int token = req_start; token < req_end; ++token) { + const int position = p.past_sequence_lengths[static_cast(b)] + (token - req_start); + const int causal_count = CausalThreshold(position, p.compress_ratio); + const int visible_block_count = std::min(key_len_after[static_cast(b)], causal_count); + const int selected = std::min(block_topk, visible_block_count); + + std::vector> rotated_query(static_cast(p.num_heads)); + for (int h = 0; h < p.num_heads; ++h) { + const size_t base = (static_cast(token) * p.num_heads + h) * head_size; + std::vector head(p.query.begin() + base, p.query.begin() + base + head_size); + const int clamped_position = std::min(std::max(position, 0), p.max_position - 1); + rotated_query[static_cast(h)] = + LeadingRope(head, p.rotary_width, p.cos_cache.data() + clamped_position * p.rotary_width, + p.sin_cache.data() + clamped_position * p.rotary_width); + } + + std::vector scores(static_cast(visible_block_count), 0.0f); + for (int j = 0; j < visible_block_count; ++j) { + float score = 0.0f; + for (int h = 0; h < p.num_heads; ++h) { + float dot = 0.0f; + for (int d = 0; d < head_size; ++d) { + dot += rotated_query[static_cast(h)][static_cast(d)] * + out.present_key_state[(static_cast(b) * p.state_capacity + j) * head_size + d]; + } + score += std::max(dot, 0.0f); + } + scores[static_cast(j)] = score * scale; + } + const std::vector order = RankByScore(scores, visible_block_count); + const int emitted_blocks = std::min(selected, visible_block_count); + int32_t* out_row = out.selected_indices.data() + static_cast(token) * capacity; + for (int rank = 0; rank < emitted_blocks; ++rank) { + for (int t = 0; t < p.compress_ratio; ++t) { + out_row[rank * p.compress_ratio + t] = order[static_cast(rank)] * p.compress_ratio + t; + } + } + const int block_start = visible_block_count * p.compress_ratio; + const int natural_tail = position >= block_start ? position - block_start + 1 : 0; + const int remaining_capacity = capacity - emitted_blocks * p.compress_ratio; + const int tail_count = std::clamp(natural_tail, 0, remaining_capacity); + for (int t = 0; t < tail_count; ++t) { + out_row[emitted_blocks * p.compress_ratio + t] = block_start + t; + } + out.selected_counts[static_cast(token)] = emitted_blocks * p.compress_ratio + tail_count; + } + } +} + +QsaPackedProblem MakeQsaPackedProblem(QsaPackedProblem problem = {}) { + if (problem.cumulative_sequence_lengths.empty()) { + // Default: 2 requests, 2 and 3 tokens respectively (unequal lengths). + problem.cumulative_sequence_lengths = {0, 2, 5}; + problem.past_sequence_lengths = {3, 0}; + } + const int total_tokens = problem.TotalTokens(); + const int buffer_capacity = problem.BufferCapacity(); + if (problem.past_state_lengths.empty()) { + problem.past_state_lengths.assign(static_cast(problem.batch_size) * 2, 0); + for (int b = 0; b < problem.batch_size; ++b) { + problem.past_state_lengths[static_cast(b) * 2 + 0] = problem.past_sequence_lengths[static_cast(b)] / problem.compress_ratio; + problem.past_state_lengths[static_cast(b) * 2 + 1] = problem.past_sequence_lengths[static_cast(b)] % problem.compress_ratio; + } + } + + problem.query = + MakeWave(static_cast(total_tokens) * problem.num_heads * problem.head_size, 0.35f, 0.41f); + problem.key = MakeWave(static_cast(total_tokens) * 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.max_position) * problem.rotary_width, 0.20f, 0.13f); + problem.sin_cache = MakeWave(static_cast(problem.max_position) * problem.rotary_width, 0.90f, 0.19f); + problem.past_key_state = + MakeWave(static_cast(problem.batch_size) * problem.state_capacity * problem.head_size, 0.05f, 0.23f); + problem.past_kv_buffer = + MakeWave(static_cast(problem.batch_size) * buffer_capacity * problem.head_size, 0.60f, 0.09f); + return problem; +} + +template +void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedProblem(), + 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); + 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_state = RoundTrip(problem.past_key_state); + problem.past_kv_buffer = RoundTrip(problem.past_kv_buffer); + + QsaPackedResult expected; + QsaPackedReference(problem, expected); + + const int64_t total_tokens = problem.TotalTokens(); + const int64_t batch_size = problem.batch_size; + const int64_t head_size = problem.head_size; + const int64_t buffer_capacity = problem.BufferCapacity(); + + OpTester test("PackedSparseAttentionIndexer", 1, onnxruntime::kMSDomain); + test.AddAttribute("policy_mode", std::string(psai::kPolicyModeQsa)); + test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); + test.AddAttribute("state_capacity", static_cast(problem.state_capacity)); + test.AddAttribute("token_budget", static_cast(problem.token_budget)); + if (problem.scale.has_value()) { + test.AddAttribute("scale", *problem.scale); + } + test.AddInput("query", {total_tokens, problem.num_heads, head_size}, ToElementType(problem.query)); + test.AddInput("key", {total_tokens, head_size}, ToElementType(problem.key)); + test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); + test.AddInput("cos_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.cos_cache)); + test.AddInput("sin_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.sin_cache)); + test.AddInput("cumulative_sequence_lengths", {batch_size + 1}, problem.cumulative_sequence_lengths); + test.AddInput("past_sequence_lengths", {batch_size}, problem.past_sequence_lengths); + test.AddOptionalInputEdge(); // gate + test.AddOptionalInputEdge(); // position_bias + test.AddOptionalInputEdge(); // head_weights + test.AddOptionalInputEdge(); // position_ids + test.AddInput("past_key_state", {batch_size, problem.state_capacity, head_size}, + ToElementType(problem.past_key_state)); + test.AddInput("past_kv_buffer", {batch_size, buffer_capacity, head_size}, + ToElementType(problem.past_kv_buffer)); + test.AddOptionalInputEdge(); // past_gate_buffer + test.AddInput("past_state_lengths", {batch_size, 2}, problem.past_state_lengths); + + test.AddOutput("selected_indices", {total_tokens, problem.Capacity()}, expected.selected_indices); + test.AddOutput("selected_counts", {total_tokens}, expected.selected_counts); + test.AddOutput("present_key_state", {batch_size, problem.state_capacity, head_size}, + ToElementType(expected.present_key_state), false, 0.0f, tolerance); + test.AddOutput("present_kv_buffer", {batch_size, buffer_capacity, head_size}, + ToElementType(expected.present_kv_buffer), false, 0.0f, tolerance); + test.AddOptionalOutputEdge(); // present_gate_buffer + test.AddOutput("present_state_lengths", {batch_size, 2}, expected.present_state_lengths); + RunOnProvider(test, std::move(provider)); +} + +} // namespace + +TEST(PackedSparseAttentionIndexerTest, QsaFloat) { RunQsaPackedTest(1.0e-5f); } + +TEST(PackedSparseAttentionIndexerTest, QsaFloat16) { RunQsaPackedTest(2.0e-3f); } + +TEST(PackedSparseAttentionIndexerTest, QsaBFloat16) { RunQsaPackedTest(2.0e-2f); } + +// Prefill followed by decode: request 0 continues an existing 3-token history, request 1 starts +// fresh; the two requests keep independent block/tail state. +TEST(PackedSparseAttentionIndexerTest, QsaPrefillThenDecodeIndependentState) { + QsaPackedProblem problem; + problem.cumulative_sequence_lengths = {0, 1, 4}; + problem.past_sequence_lengths = {5, 0}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); +} + +// A zero-token request row (repeated cumulative offset) must not affect the other request and +// must not read out of bounds. +TEST(PackedSparseAttentionIndexerTest, QsaZeroTokenRequestRow) { + QsaPackedProblem problem; + problem.batch_size = 3; + problem.cumulative_sequence_lengths = {0, 2, 2, 5}; + problem.past_sequence_lengths = {3, 7, 0}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); +} + +// state_capacity smaller than what the incoming tokens would naturally produce: the update must +// deterministically drop the overflowing blocks rather than corrupt memory. +TEST(PackedSparseAttentionIndexerTest, QsaStateCapacityOverflowIsSafe) { + QsaPackedProblem problem; + problem.cumulative_sequence_lengths = {0, 6}; + problem.past_sequence_lengths = {4}; + problem.batch_size = 1; + problem.state_capacity = 2; // only room for 2 - 2(already used) = 0 new complete blocks + problem.past_state_lengths = {2, 0}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); +} + +namespace { + +struct CsaPackedProblem { + int batch_size = 2; + std::vector cumulative_sequence_lengths; + std::vector past_sequence_lengths; // unused by csa math, kept for symmetry with qsa + std::vector past_state_lengths; // [batch, 2]: compressed count, buffer length + int head_size = 4; + int num_heads = 2; + int rotary_width = 2; + int compress_ratio = 2; + int index_topk = 3; + int state_capacity = 4; + int max_position = 64; + float epsilon = 1.0e-6f; + std::optional scale; + std::optional head_weight_scale; + + 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_key_state; + std::vector past_kv_buffer; + std::vector past_gate_buffer; + + int Width() const { return 2 * head_size; } + int TotalTokens() const { return cumulative_sequence_lengths.back(); } + int BufferCapacity() const { return 2 * compress_ratio - 1; } +}; + +struct CsaPackedResult { + std::vector selected_indices; + std::vector selected_counts; + std::vector present_key_state; + std::vector present_kv_buffer; + std::vector present_gate_buffer; + std::vector present_state_lengths; +}; + +void CsaPackedReference(const CsaPackedProblem& p, CsaPackedResult& out) { + const int total_tokens = p.TotalTokens(); + const int head_size = p.head_size; + const int width = p.Width(); + const int buffer_capacity = p.BufferCapacity(); + const int capacity = p.index_topk; + const float scale = p.scale.value_or(1.0f / std::sqrt(static_cast(head_size))); + const float head_weight_scale = p.head_weight_scale.value_or(1.0f / std::sqrt(static_cast(p.num_heads))); + + out.present_key_state = p.past_key_state; + out.present_kv_buffer.assign(static_cast(p.batch_size) * buffer_capacity * width, 0.0f); + out.present_gate_buffer.assign(static_cast(p.batch_size) * buffer_capacity * width, 0.0f); + out.present_state_lengths.assign(static_cast(p.batch_size) * 2, 0); + std::vector key_len_after(static_cast(p.batch_size)); + + for (int b = 0; b < p.batch_size; ++b) { + const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; + const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; + const int req_len = std::max(req_end - req_start, 0); + const int old_key_len = + std::clamp(p.past_state_lengths[static_cast(b) * 2 + 0], 0, p.state_capacity); + const int old_buf_len = + std::clamp(p.past_state_lengths[static_cast(b) * 2 + 1], 0, buffer_capacity); + const int overlap_length = old_buf_len >= p.compress_ratio ? p.compress_ratio : 0; + const int leftover_length = old_buf_len - overlap_length; + const int pending = leftover_length + req_len; + const int full_new_window_count = pending / p.compress_ratio; + const int new_window_count = + std::min(full_new_window_count, std::max(p.state_capacity - old_key_len, 0)); + const bool overflowed = new_window_count < full_new_window_count; + int present_buffer_length = 0; + int present_buffer_start = 0; + if (!overflowed) { + if (new_window_count > 0) { + present_buffer_length = p.compress_ratio + pending % p.compress_ratio; + present_buffer_start = overlap_length + (new_window_count - 1) * p.compress_ratio; + } else { + present_buffer_length = old_buf_len + req_len; + present_buffer_start = 0; + } + present_buffer_length = std::min(present_buffer_length, buffer_capacity); + } + + auto extended_key = [&](int virtual_pos, int channel) -> float { + return virtual_pos < old_buf_len + ? p.past_kv_buffer[(static_cast(b) * buffer_capacity + virtual_pos) * width + channel] + : p.key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + channel]; + }; + auto extended_gate = [&](int virtual_pos, int channel) -> float { + return virtual_pos < old_buf_len + ? p.past_gate_buffer[(static_cast(b) * buffer_capacity + virtual_pos) * width + channel] + : p.gate[(static_cast(req_start) + (virtual_pos - old_buf_len)) * width + channel]; + }; + + for (int k = 0; k < new_window_count; ++k) { + const bool has_previous = k >= 1 || overlap_length >= p.compress_ratio; + const int previous_base = overlap_length + (k - 1) * p.compress_ratio; + const int current_base = overlap_length + k * p.compress_ratio; + std::vector pooled(static_cast(head_size), 0.0f); + for (int d = 0; d < head_size; ++d) { + float max_gate = -std::numeric_limits::infinity(); + if (has_previous) { + for (int slot = 0; slot < p.compress_ratio; ++slot) { + max_gate = std::max(max_gate, extended_gate(previous_base + slot, d) + p.position_bias[static_cast(slot) * width + d]); + } + } + for (int slot = 0; slot < p.compress_ratio; ++slot) { + max_gate = std::max(max_gate, extended_gate(current_base + slot, head_size + d) + + p.position_bias[static_cast(slot) * width + head_size + d]); + } + float denom = 0.0f, acc = 0.0f; + if (has_previous) { + for (int slot = 0; slot < p.compress_ratio; ++slot) { + const float logit = extended_gate(previous_base + slot, d) + p.position_bias[static_cast(slot) * width + d]; + const float w = std::exp(logit - max_gate); + denom += w; + acc += w * extended_key(previous_base + slot, d); + } + } + for (int slot = 0; slot < p.compress_ratio; ++slot) { + const float logit = extended_gate(current_base + slot, head_size + d) + + p.position_bias[static_cast(slot) * width + head_size + d]; + const float w = std::exp(logit - max_gate); + denom += w; + acc += w * extended_key(current_base + slot, head_size + d); + } + pooled[static_cast(d)] = denom > 0.0f ? acc / denom : 0.0f; + } + pooled = RmsNormalize(pooled, p.key_norm_weight, p.epsilon); + const int entry = old_key_len + k; + const int position = std::min(entry * p.compress_ratio, p.max_position - 1); + pooled = TrailingRope(pooled, p.rotary_width, p.cos_cache.data() + position * p.rotary_width, + p.sin_cache.data() + position * p.rotary_width); + for (int d = 0; d < head_size; ++d) { + out.present_key_state[(static_cast(b) * p.state_capacity + entry) * head_size + d] = + pooled[static_cast(d)]; + } + } + for (int t = 0; t < present_buffer_length; ++t) { + const int virtual_pos = present_buffer_start + t; + for (int c = 0; c < width; ++c) { + out.present_kv_buffer[(static_cast(b) * buffer_capacity + t) * width + c] = extended_key(virtual_pos, c); + out.present_gate_buffer[(static_cast(b) * buffer_capacity + t) * width + c] = extended_gate(virtual_pos, c); + } + } + out.present_state_lengths[static_cast(b) * 2 + 0] = old_key_len + new_window_count; + out.present_state_lengths[static_cast(b) * 2 + 1] = present_buffer_length; + key_len_after[static_cast(b)] = old_key_len + new_window_count; + } + + out.selected_indices.assign(static_cast(total_tokens) * capacity, -1); + out.selected_counts.assign(static_cast(total_tokens), 0); + for (int b = 0; b < p.batch_size; ++b) { + const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; + const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; + for (int token = req_start; token < req_end; ++token) { + const int position = static_cast(p.position_ids[static_cast(token)]); + const int threshold = std::min(CausalThreshold(position, p.compress_ratio), key_len_after[static_cast(b)]); + const int selected = std::min(p.index_topk, threshold); + + std::vector> rotated_query(static_cast(p.num_heads)); + for (int h = 0; h < p.num_heads; ++h) { + const size_t base = (static_cast(token) * p.num_heads + h) * head_size; + std::vector head(p.query.begin() + base, p.query.begin() + base + head_size); + const int clamped_position = std::min(std::max(position, 0), p.max_position - 1); + rotated_query[static_cast(h)] = + TrailingRope(head, p.rotary_width, p.cos_cache.data() + clamped_position * p.rotary_width, + p.sin_cache.data() + clamped_position * p.rotary_width); + } + + std::vector scores(static_cast(threshold), 0.0f); + for (int e = 0; e < threshold; ++e) { + float score = 0.0f; + for (int h = 0; h < p.num_heads; ++h) { + float dot = 0.0f; + for (int d = 0; d < head_size; ++d) { + dot += rotated_query[static_cast(h)][static_cast(d)] * + out.present_key_state[(static_cast(b) * p.state_capacity + e) * head_size + d]; + } + score += std::max(dot, 0.0f) * p.head_weights[static_cast(token) * p.num_heads + h]; + } + scores[static_cast(e)] = score * scale * head_weight_scale; + } + const std::vector order = RankByScore(scores, threshold); + int32_t* out_row = out.selected_indices.data() + static_cast(token) * capacity; + for (int rank = 0; rank < selected; ++rank) { + out_row[rank] = order[static_cast(rank)]; + } + out.selected_counts[static_cast(token)] = selected; + } + } +} + +CsaPackedProblem MakeCsaPackedProblem(CsaPackedProblem problem = {}) { + if (problem.cumulative_sequence_lengths.empty()) { + problem.cumulative_sequence_lengths = {0, 2, 5}; + } + if (problem.past_state_lengths.empty()) { + problem.past_state_lengths.assign(static_cast(problem.batch_size) * 2, 0); + } + if (problem.past_sequence_lengths.empty()) { + problem.past_sequence_lengths.assign(static_cast(problem.batch_size), 0); + } + const int total_tokens = problem.TotalTokens(); + const int width = problem.Width(); + const int buffer_capacity = problem.BufferCapacity(); + + problem.query = MakeWave(static_cast(total_tokens) * problem.num_heads * problem.head_size, 0.25f, 0.37f); + problem.key = MakeWave(static_cast(total_tokens) * 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.max_position) * problem.rotary_width, 0.15f, 0.27f); + problem.sin_cache = MakeWave(static_cast(problem.max_position) * problem.rotary_width, 1.05f, 0.33f); + problem.gate = MakeWave(static_cast(total_tokens) * 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(total_tokens) * problem.num_heads, 1.30f, 0.47f); + problem.past_key_state = + MakeWave(static_cast(problem.batch_size) * problem.state_capacity * problem.head_size, 0.50f, 0.39f); + problem.past_kv_buffer = MakeWave(static_cast(problem.batch_size) * buffer_capacity * width, 0.95f, 0.18f); + problem.past_gate_buffer = MakeWave(static_cast(problem.batch_size) * buffer_capacity * width, 1.45f, 0.22f); + + if (problem.position_ids.empty()) { + problem.position_ids.assign(static_cast(total_tokens), 0); + for (int b = 0; b < problem.batch_size; ++b) { + const int req_start = problem.cumulative_sequence_lengths[static_cast(b)]; + const int req_end = problem.cumulative_sequence_lengths[static_cast(b) + 1]; + for (int token = req_start; token < req_end; ++token) { + problem.position_ids[static_cast(token)] = 2 + (token - req_start); + } + } + } + return problem; +} + +template +void RunCsaPackedTest(const CsaPackedProblem& 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"; + } + + CsaPackedProblem 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_key_state = RoundTrip(problem.past_key_state); + problem.past_kv_buffer = RoundTrip(problem.past_kv_buffer); + problem.past_gate_buffer = RoundTrip(problem.past_gate_buffer); + + CsaPackedResult expected; + CsaPackedReference(problem, expected); + + const int64_t total_tokens = problem.TotalTokens(); + const int64_t batch_size = problem.batch_size; + const int64_t head_size = problem.head_size; + const int64_t width = problem.Width(); + const int64_t buffer_capacity = problem.BufferCapacity(); + + OpTester test("PackedSparseAttentionIndexer", 1, onnxruntime::kMSDomain); + test.AddAttribute("policy_mode", std::string(psai::kPolicyModeCsa)); + test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); + test.AddAttribute("state_capacity", static_cast(problem.state_capacity)); + 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", {total_tokens, problem.num_heads, head_size}, ToElementType(problem.query)); + test.AddInput("key", {total_tokens, width}, ToElementType(problem.key)); + test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); + test.AddInput("cos_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.cos_cache)); + test.AddInput("sin_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.sin_cache)); + test.AddInput("cumulative_sequence_lengths", {batch_size + 1}, problem.cumulative_sequence_lengths); + test.AddInput("past_sequence_lengths", {batch_size}, problem.past_sequence_lengths); + test.AddInput("gate", {total_tokens, width}, ToElementType(problem.gate)); + test.AddInput("position_bias", {problem.compress_ratio, width}, ToElementType(problem.position_bias)); + test.AddInput("head_weights", {total_tokens, problem.num_heads}, ToElementType(problem.head_weights)); + test.AddInput("position_ids", {total_tokens}, problem.position_ids); + test.AddInput("past_key_state", {batch_size, problem.state_capacity, head_size}, + ToElementType(problem.past_key_state)); + test.AddInput("past_kv_buffer", {batch_size, buffer_capacity, width}, ToElementType(problem.past_kv_buffer)); + test.AddInput("past_gate_buffer", {batch_size, buffer_capacity, width}, + ToElementType(problem.past_gate_buffer)); + test.AddInput("past_state_lengths", {batch_size, 2}, problem.past_state_lengths); + + test.AddOutput("selected_indices", {total_tokens, problem.index_topk}, expected.selected_indices); + test.AddOutput("selected_counts", {total_tokens}, expected.selected_counts); + test.AddOutput("present_key_state", {batch_size, problem.state_capacity, head_size}, + ToElementType(expected.present_key_state), false, 0.0f, tolerance); + test.AddOutput("present_kv_buffer", {batch_size, buffer_capacity, width}, + ToElementType(expected.present_kv_buffer), false, 0.0f, tolerance); + test.AddOutput("present_gate_buffer", {batch_size, buffer_capacity, width}, + ToElementType(expected.present_gate_buffer), false, 0.0f, tolerance); + test.AddOutput("present_state_lengths", {batch_size, 2}, expected.present_state_lengths); + RunOnProvider(test, std::move(provider)); +} + +} // namespace + +TEST(PackedSparseAttentionIndexerTest, CsaFloat) { RunCsaPackedTest(MakeCsaPackedProblem(), 1.0e-5f); } + +TEST(PackedSparseAttentionIndexerTest, CsaFloat16) { RunCsaPackedTest(MakeCsaPackedProblem(), 4.0e-3f); } + +TEST(PackedSparseAttentionIndexerTest, CsaBFloat16) { RunCsaPackedTest(MakeCsaPackedProblem(), 3.0e-2f); } + +// Prefill followed by decode: continues a previously compressed entry and a partially filled +// buffer for one request, while another request starts fresh. +TEST(PackedSparseAttentionIndexerTest, CsaPrefillThenDecodeIndependentState) { + CsaPackedProblem problem; + problem.cumulative_sequence_lengths = {0, 1, 4}; + problem.past_state_lengths = {1, 1, 0, 0}; + RunCsaPackedTest(MakeCsaPackedProblem(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 9986fe62895ef..fc37603b42942 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -265,6 +265,135 @@ def test_sparse_attention_indexer_csa_symbolic_fallback(self): self.assertTrue(buffer_shape[1].startswith("SparseAttentionIndexer_")) self.assertEqual(self._tensor_shape(outputs["present_gate_buffer"]), buffer_shape) + def _infer_packed_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], "PackedSparseAttentionIndexer_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) + + def test_packed_sparse_attention_indexer_qsa(self): + node = helper.make_node( + "PackedSparseAttentionIndexer", + [ + "query", + "key", + "key_norm_weight", + "cos_cache", + "sin_cache", + "cumulative_sequence_lengths", + "past_sequence_lengths", + "", + "", + "", + "", + "past_key_state", + "past_kv_buffer", + "", + "past_state_lengths", + ], + [ + "selected_indices", + "selected_counts", + "present_key_state", + "present_kv_buffer", + "", + "present_state_lengths", + ], + domain="com.microsoft", + policy_mode="qsa", + compress_ratio=4, + state_capacity=5, + token_budget=8, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["total_tokens", 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["total_tokens", 8]), + helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), + helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, [64, 8]), + helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT16, [64, 8]), + helper.make_tensor_value_info("cumulative_sequence_lengths", TensorProto.INT32, [3]), + helper.make_tensor_value_info("past_sequence_lengths", TensorProto.INT32, [2]), + helper.make_tensor_value_info("past_key_state", TensorProto.FLOAT16, [2, 5, 8]), + helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT16, [2, 7, 8]), + helper.make_tensor_value_info("past_state_lengths", TensorProto.INT32, [2, 2]), + ] + + inferred = self._infer_packed_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(self._tensor_shape(outputs["selected_indices"]), ["total_tokens", 11]) + self.assertEqual(outputs["selected_indices"].type.tensor_type.elem_type, TensorProto.INT32) + self.assertEqual(self._tensor_shape(outputs["selected_counts"]), ["total_tokens"]) + self.assertEqual(outputs["selected_counts"].type.tensor_type.elem_type, TensorProto.INT32) + self.assertEqual(self._tensor_shape(outputs["present_key_state"]), [2, 5, 8]) + self.assertEqual(outputs["present_key_state"].type.tensor_type.elem_type, TensorProto.FLOAT16) + self.assertEqual(self._tensor_shape(outputs["present_kv_buffer"]), [2, 7, 8]) + self.assertEqual(self._tensor_shape(outputs["present_state_lengths"]), [2, 2]) + self.assertEqual(outputs["present_state_lengths"].type.tensor_type.elem_type, TensorProto.INT32) + + def test_packed_sparse_attention_indexer_csa(self): + node = helper.make_node( + "PackedSparseAttentionIndexer", + [ + "query", + "key", + "key_norm_weight", + "cos_cache", + "sin_cache", + "cumulative_sequence_lengths", + "past_sequence_lengths", + "gate", + "position_bias", + "head_weights", + "position_ids", + "past_key_state", + "past_kv_buffer", + "past_gate_buffer", + "past_state_lengths", + ], + [ + "selected_indices", + "selected_counts", + "present_key_state", + "present_kv_buffer", + "present_gate_buffer", + "present_state_lengths", + ], + domain="com.microsoft", + policy_mode="csa", + compress_ratio=4, + state_capacity=6, + index_topk=3, + ) + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT, ["total_tokens", 2, 8]), + helper.make_tensor_value_info("key", TensorProto.FLOAT, ["total_tokens", 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("cumulative_sequence_lengths", TensorProto.INT32, [3]), + helper.make_tensor_value_info("past_sequence_lengths", TensorProto.INT32, [2]), + helper.make_tensor_value_info("gate", TensorProto.FLOAT, ["total_tokens", 16]), + helper.make_tensor_value_info("position_bias", TensorProto.FLOAT, [4, 16]), + helper.make_tensor_value_info("head_weights", TensorProto.FLOAT, ["total_tokens", 2]), + helper.make_tensor_value_info("position_ids", TensorProto.INT64, ["total_tokens"]), + helper.make_tensor_value_info("past_key_state", TensorProto.FLOAT, [2, 6, 8]), + helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT, [2, 7, 16]), + helper.make_tensor_value_info("past_gate_buffer", TensorProto.FLOAT, [2, 7, 16]), + helper.make_tensor_value_info("past_state_lengths", TensorProto.INT32, [2, 2]), + ] + + inferred = self._infer_packed_sparse_attention_indexer(node, inputs) + outputs = {output.name: output for output in inferred.graph.output} + self.assertEqual(self._tensor_shape(outputs["selected_indices"]), ["total_tokens", 3]) + self.assertEqual(self._tensor_shape(outputs["selected_counts"]), ["total_tokens"]) + self.assertEqual(self._tensor_shape(outputs["present_key_state"]), [2, 6, 8]) + self.assertEqual(self._tensor_shape(outputs["present_kv_buffer"]), [2, 7, 16]) + self.assertEqual(self._tensor_shape(outputs["present_gate_buffer"]), [2, 7, 16]) + self.assertEqual(self._tensor_shape(outputs["present_state_lengths"]), [2, 2]) + def test_unsqueeze_opset_11(self): graph = helper.make_graph( [ From 6f8041c0358ccae3c04abad29bdb0afe3e9c02ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:25:01 +0000 Subject: [PATCH 024/102] Harden packed indexer state validation Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../sparse/packed_sparse_attention_indexer.cc | 39 ++++--- .../sparse/packed_sparse_attention_indexer.h | 1 + .../packed_sparse_attention_indexer_impl.cu | 77 ++++++++----- .../bert/packed_sparse_attention_indexer.cc | 105 ++++++++++++----- .../bert/packed_sparse_attention_indexer.h | 3 + .../core/graph/contrib_ops/bert_defs.cc | 4 + ...packed_sparse_attention_indexer_op_test.cc | 107 ++++++++++++++---- 7 files changed, 244 insertions(+), 92 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index eb23d13acd8b0..2b289bb80443e 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -19,17 +19,19 @@ namespace cuda { using namespace onnxruntime::cuda; namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; -#define REGISTER_KERNEL_TYPED(T) \ - ONNX_OPERATOR_TYPED_KERNEL_EX( \ - PackedSparseAttentionIndexer, \ - kMSDomain, \ - 1, \ - T, \ - kCudaExecutionProvider, \ - (*KernelDefBuilder::Create()) \ - .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("M", DataTypeImpl::GetTensorType()), \ +#define REGISTER_KERNEL_TYPED(T) \ + ONNX_OPERATOR_TYPED_KERNEL_EX( \ + PackedSparseAttentionIndexer, \ + kMSDomain, \ + 1, \ + T, \ + kCudaExecutionProvider, \ + (*KernelDefBuilder::Create()) \ + .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ + .MayInplace(11, 2) \ + .MayInplace(14, 5), \ PackedSparseAttentionIndexer); REGISTER_KERNEL_TYPED(float) @@ -98,11 +100,10 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo ORT_ENFORCE(compress_ratio_ > 0 && compress_ratio_ <= std::numeric_limits::max(), "PackedSparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", compress_ratio_); - int64_t state_capacity = 0; - ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity).IsOK(), + ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity_).IsOK(), "PackedSparseAttentionIndexer: state_capacity is required"); - ORT_ENFORCE(state_capacity > 0 && state_capacity <= std::numeric_limits::max(), - "PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", state_capacity); + ORT_ENFORCE(state_capacity_ > 0 && state_capacity_ <= std::numeric_limits::max(), + "PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", state_capacity_); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); @@ -197,6 +198,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con cu_shape.ToString()); const int64_t batch_size = cu_shape[0] - 1; ORT_RETURN_IF_ERROR(CheckIntDimension("batch_size", batch_size)); + ORT_RETURN_IF(batch_size == 0 && total_tokens != 0, + "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); @@ -221,6 +224,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con key_state_shape.ToString()); const int64_t state_capacity = key_state_shape[1]; ORT_RETURN_IF_ERROR(CheckIntDimension("state_capacity", state_capacity, false)); + ORT_RETURN_IF_NOT(state_capacity == state_capacity_, + "PackedSparseAttentionIndexer: past_key_state capacity must match the state_capacity attribute"); const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); @@ -329,6 +334,8 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con cu_shape.ToString()); const int64_t batch_size = cu_shape[0] - 1; ORT_RETURN_IF_ERROR(CheckIntDimension("batch_size", batch_size)); + ORT_RETURN_IF(batch_size == 0 && total_tokens != 0, + "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, width})); @@ -354,6 +361,8 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con key_state_shape.ToString()); const int64_t state_capacity = key_state_shape[1]; ORT_RETURN_IF_ERROR(CheckIntDimension("state_capacity", state_capacity, false)); + ORT_RETURN_IF_NOT(state_capacity == state_capacity_, + "PackedSparseAttentionIndexer: past_key_state capacity must match the state_capacity attribute"); const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, width})); diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h index b7ddfa9b480ce..c8f3ec19f2082 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h @@ -23,6 +23,7 @@ class PackedSparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel packed_sparse_attention_indexer::Policy policy_; int64_t compress_ratio_; + int64_t state_capacity_; int64_t token_budget_; int64_t index_topk_; float epsilon_; diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 36f1a6a521b9b..3e005ce00ba65 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -85,7 +85,8 @@ __global__ void ElementwiseCopyKernel(const T* src, T* dst, int64_t count) { template __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, - const T* past_kv_buffer, const int32_t* past_state_lengths, + const int32_t* past_sequence_lengths, const T* past_kv_buffer, + const int32_t* past_state_lengths, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, int32_t* overflow_flags, PackedSparseAttentionIndexerParams params) { @@ -97,11 +98,20 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con for (int b = static_cast(blockIdx.x); b < params.batch_size; b += static_cast(gridDim.x)) { const int req_start = cumulative_sequence_lengths[b]; const int req_end = cumulative_sequence_lengths[b + 1]; - const int req_len = req_end > req_start ? req_end - req_start : 0; - - const int old_key_len = min(max(past_state_lengths[b * 2 + psai::kKeyStateLength], 0), params.state_capacity); - const int old_buf_len = - min(max(past_state_lengths[b * 2 + psai::kBufferLength], 0), params.compress_ratio - 1); + const int raw_key_len = past_state_lengths[b * 2 + psai::kKeyStateLength]; + const int raw_buf_len = past_state_lengths[b * 2 + psai::kBufferLength]; + const int past_sequence_length = past_sequence_lengths[b]; + const bool invalid_metadata = + cumulative_sequence_lengths[0] != 0 || + cumulative_sequence_lengths[params.batch_size] != params.total_tokens || + req_start < 0 || req_end < req_start || req_end > params.total_tokens || + past_sequence_length < 0 || raw_key_len < 0 || raw_key_len > params.state_capacity || + raw_buf_len < 0 || raw_buf_len >= params.compress_ratio || + raw_key_len != past_sequence_length / params.compress_ratio || + raw_buf_len != past_sequence_length % params.compress_ratio; + const int req_len = invalid_metadata ? 0 : req_end - req_start; + const int old_key_len = min(max(raw_key_len, 0), params.state_capacity); + const int old_buf_len = min(max(raw_buf_len, 0), params.compress_ratio - 1); const int pending = old_buf_len + req_len; const int full_new_block_count = pending / params.compress_ratio; @@ -109,9 +119,9 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con // Reject (do not partially apply) a step that would need more than the fixed state_capacity: // no new blocks are formed and the buffer is left exactly as it was, so a rejected step is a // deterministic no-op on state rather than a silent partial truncation. - const bool overflowed = full_new_block_count > capacity_left; - const int new_block_count = overflowed ? 0 : full_new_block_count; - const int new_buf_len = overflowed ? old_buf_len : (pending % params.compress_ratio); + const bool rejected = invalid_metadata || full_new_block_count > capacity_left; + const int new_block_count = rejected ? 0 : full_new_block_count; + const int new_buf_len = rejected ? old_buf_len : (pending % params.compress_ratio); // Barrier: every thread has now read past_state_lengths (identically) before any thread below // writes present_state_lengths, which keeps this correct even if the two tensors alias. @@ -120,7 +130,7 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con if (threadIdx.x == 0) { present_state_lengths[b * 2 + psai::kKeyStateLength] = old_key_len + new_block_count; present_state_lengths[b * 2 + psai::kBufferLength] = new_buf_len; - overflow_flags[b] = overflowed ? 1 : 0; + overflow_flags[b] = rejected ? 1 : 0; } for (int k = 0; k < new_block_count; ++k) { @@ -384,7 +394,8 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula template __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const T* position_bias, - const int32_t* cumulative_sequence_lengths, const T* past_kv_buffer, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const T* past_kv_buffer, const T* past_gate_buffer, const int32_t* past_state_lengths, T* present_key_state, T* present_kv_buffer, T* present_gate_buffer, int32_t* present_state_lengths, int32_t* overflow_flags, @@ -397,11 +408,17 @@ __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_n for (int b = static_cast(blockIdx.x); b < params.batch_size; b += static_cast(gridDim.x)) { const int req_start = cumulative_sequence_lengths[b]; const int req_end = cumulative_sequence_lengths[b + 1]; - const int req_len = req_end > req_start ? req_end - req_start : 0; - - const int old_key_len = min(max(past_state_lengths[b * 2 + psai::kKeyStateLength], 0), params.state_capacity); - const int old_buf_len = - min(max(past_state_lengths[b * 2 + psai::kBufferLength], 0), params.buffer_capacity); + const int raw_key_len = past_state_lengths[b * 2 + psai::kKeyStateLength]; + const int raw_buf_len = past_state_lengths[b * 2 + psai::kBufferLength]; + const bool invalid_metadata = + cumulative_sequence_lengths[0] != 0 || + cumulative_sequence_lengths[params.batch_size] != params.total_tokens || + req_start < 0 || req_end < req_start || req_end > params.total_tokens || + past_sequence_lengths[b] < 0 || raw_key_len < 0 || raw_key_len > params.state_capacity || + raw_buf_len < 0 || raw_buf_len > params.buffer_capacity; + const int req_len = invalid_metadata ? 0 : req_end - req_start; + const int old_key_len = min(max(raw_key_len, 0), params.state_capacity); + const int old_buf_len = min(max(raw_buf_len, 0), params.buffer_capacity); sai::CsaWindowPlan plan; const bool plan_ok = sai::TryComputeCsaWindowPlan(old_buf_len, req_len, params.compress_ratio, plan); @@ -413,14 +430,14 @@ __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_n // Reject (do not partially apply) a step that would need more than the fixed state_capacity: // no new windows are closed and the buffer is left exactly as it was, so a rejected step is a // deterministic no-op on state rather than a silent partial truncation. - const bool overflowed = full_new_window_count > capacity_left; - const int new_window_count = overflowed ? 0 : full_new_window_count; + const bool rejected = invalid_metadata || full_new_window_count > capacity_left; + const int new_window_count = rejected ? 0 : full_new_window_count; const int present_buffer_length = - overflowed ? old_buf_len - : (static_cast(plan.present_buffer_length) < params.buffer_capacity - ? static_cast(plan.present_buffer_length) - : params.buffer_capacity); - const int present_buffer_start = overflowed ? 0 : static_cast(plan.present_buffer_start); + rejected ? old_buf_len + : (static_cast(plan.present_buffer_length) < params.buffer_capacity + ? static_cast(plan.present_buffer_length) + : params.buffer_capacity); + const int present_buffer_start = rejected ? 0 : static_cast(plan.present_buffer_start); const int overlap_length = static_cast(plan.overlap_length); // Barrier: every thread has now read past_state_lengths / computed the plan (identically) @@ -431,7 +448,7 @@ __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_n if (threadIdx.x == 0) { present_state_lengths[b * 2 + psai::kKeyStateLength] = old_key_len + new_window_count; present_state_lengths[b * 2 + psai::kBufferLength] = present_buffer_length; - overflow_flags[b] = overflowed ? 1 : 0; + overflow_flags[b] = rejected ? 1 : 0; } for (int k = 0; k < new_window_count; ++k) { @@ -739,8 +756,8 @@ Status LaunchQsaPackedSparseAttentionIndexer( const size_t value_bytes = static_cast(params.head_size) * sizeof(float); const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); QsaUpdateStateKernel<<>>( - key, key_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_kv_buffer, past_state_lengths, - present_key_state, present_kv_buffer, present_state_lengths, overflow_flags, params); + key, key_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, past_kv_buffer, + past_state_lengths, present_key_state, present_kv_buffer, present_state_lengths, overflow_flags, params); if (params.total_tokens == 0) { return CUDA_CALL(cudaGetLastError()); @@ -814,9 +831,9 @@ Status LaunchCsaPackedSparseAttentionIndexer( const size_t value_bytes = static_cast(params.head_size) * sizeof(float); const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); CsaUpdateStateKernel<<>>( - key, gate, key_norm_weight, cos_cache, sin_cache, position_bias, cumulative_sequence_lengths, past_kv_buffer, - past_gate_buffer, past_state_lengths, present_key_state, present_kv_buffer, present_gate_buffer, - present_state_lengths, overflow_flags, params); + key, gate, key_norm_weight, cos_cache, sin_cache, position_bias, cumulative_sequence_lengths, + past_sequence_lengths, past_kv_buffer, past_gate_buffer, past_state_lengths, present_key_state, + present_kv_buffer, present_gate_buffer, present_state_lengths, overflow_flags, params); if (params.total_tokens == 0) { return CUDA_CALL(cudaGetLastError()); @@ -855,7 +872,7 @@ Status LaunchCsaPackedSparseAttentionIndexer( template Status LaunchCsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ const T*, const T*, const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, \ - const T*, const T*, const int32_t*, int32_t*, int32_t*, T*, T*, T*, int32_t*, float*); + const T*, const T*, const int32_t*, int32_t*, int32_t*, T*, T*, T*, int32_t*, float*, int32_t*); INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(float) INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(half) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index e12d4ddbb5c1d..a277d7dc12264 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -97,6 +97,7 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& cos_cache = shader.AddInput("cos_cache", ShaderUsage::UseUniform); const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); const auto& past_kv_buffer = shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); const auto& past_state_lengths = shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); const auto& present_key_state = @@ -161,17 +162,29 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" - << " let req_len = max(req_end - req_start, 0);\n" - << " let old_key_len = clamp(" << past_state_lengths.GetByOffset("b * 2u") - << ", 0, i32(uniforms.state_capacity));\n" - << " let old_buf_len = clamp(" << past_state_lengths.GetByOffset("b * 2u + 1u") - << ", 0, i32(uniforms.compress_ratio) - 1);\n" + << " let old_key_len = " << past_state_lengths.GetByOffset("b * 2u") << ";\n" + << " let old_buf_len = " << past_state_lengths.GetByOffset("b * 2u + 1u") << ";\n" + << " let past_len = " << past_seqlens.GetByOffset("b") << ";\n" + << " let invalid = " << cu_seqlens.GetByOffset("0") << " != 0 || " + << cu_seqlens.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " + << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || past_len < 0 || " + << "old_key_len < 0 || old_key_len > i32(uniforms.state_capacity) || old_buf_len < 0 || " + << "old_buf_len >= i32(uniforms.compress_ratio) || " + << "old_key_len != past_len / i32(uniforms.compress_ratio) || " + << "old_buf_len != past_len % i32(uniforms.compress_ratio);\n" + << " if (invalid) {\n" + << " " << overflow_flags.SetByOffset("b", "1") << "\n" + << " return;\n" + << " }\n" + << " let req_len = req_end - req_start;\n" << " let pending = old_buf_len + req_len;\n" << " let full_new_block_count = pending / i32(uniforms.compress_ratio);\n" << " let capacity_left = max(i32(uniforms.state_capacity) - old_key_len, 0);\n" - << " let new_block_count = min(full_new_block_count, capacity_left);\n" - << " let overflowed = new_block_count < full_new_block_count;\n" - << " let new_buf_len = select(pending % i32(uniforms.compress_ratio), 0, overflowed);\n" + << " let overflowed = full_new_block_count > capacity_left;\n" + << " " << overflow_flags.SetByOffset("b", "select(0, 1, overflowed)") << "\n" + << " if (overflowed) { return; }\n" + << " let new_block_count = full_new_block_count;\n" + << " let new_buf_len = pending % i32(uniforms.compress_ratio);\n" << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len + new_block_count") << "\n" << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "new_buf_len") << "\n" << " for (var k = 0; k < new_block_count; k++) {\n" @@ -209,6 +222,7 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe position_ids = &shader.AddInput("position_ids", ShaderUsage::UseUniform); } const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); + const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); const auto& selected_indices = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); const auto& selected_counts = shader.AddOutput("selected_counts", ShaderUsage::UseUniform); @@ -288,6 +302,10 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << " " << selected_indices.SetByOffset("output_base + i", "-1") << "\n" << " }\n" << " let b = batch_of_token(token);\n" + << " if (" << overflow_flags.GetByOffset("b") << " != 0) {\n" + << " " << selected_counts.SetByOffset("token", "0") << "\n" + << " return;\n" + << " }\n" << " let key_len_after = u32(" << present_state_lengths.GetByOffset("b * 2u") << ");\n" << " let position = abs_position(token, b);\n" << " let causal = causal_count(position);\n" @@ -345,6 +363,7 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); const auto& position_bias = shader.AddInput("position_bias", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); const auto& past_kv_buffer = shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); const auto& past_gate_buffer = shader.AddInput("past_gate_buffer", ShaderUsage::UseUniform); const auto& past_state_lengths = shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); @@ -355,6 +374,7 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& present_gate_buffer = shader.AddOutput("present_gate_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); + const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); shader.AdditionalImplementation() << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, channel: u32) -> f32 {\n" @@ -426,31 +446,39 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" - << " let req_len = max(req_end - req_start, 0);\n" - << " let old_key_len = clamp(" << past_state_lengths.GetByOffset("b * 2u") - << ", 0, i32(uniforms.state_capacity));\n" - << " let old_buf_len = clamp(" << past_state_lengths.GetByOffset("b * 2u + 1u") - << ", 0, i32(uniforms.buffer_capacity));\n" + << " let old_key_len = " << past_state_lengths.GetByOffset("b * 2u") << ";\n" + << " let old_buf_len = " << past_state_lengths.GetByOffset("b * 2u + 1u") << ";\n" + << " let invalid = " << cu_seqlens.GetByOffset("0") << " != 0 || " + << cu_seqlens.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " + << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || " + << past_seqlens.GetByOffset("b") << " < 0 || old_key_len < 0 || " + << "old_key_len > i32(uniforms.state_capacity) || old_buf_len < 0 || " + << "old_buf_len > i32(uniforms.buffer_capacity);\n" + << " if (invalid) {\n" + << " " << overflow_flags.SetByOffset("b", "1") << "\n" + << " return;\n" + << " }\n" + << " let req_len = req_end - req_start;\n" << " let overlap_length = select(0, i32(uniforms.compress_ratio), old_buf_len >= " "i32(uniforms.compress_ratio));\n" << " let leftover_length = old_buf_len - overlap_length;\n" << " let pending = leftover_length + req_len;\n" << " let full_new_window_count = pending / i32(uniforms.compress_ratio);\n" << " let capacity_left = max(i32(uniforms.state_capacity) - old_key_len, 0);\n" - << " let new_window_count = min(full_new_window_count, capacity_left);\n" - << " let overflowed = new_window_count < full_new_window_count;\n" + << " let overflowed = full_new_window_count > capacity_left;\n" + << " " << overflow_flags.SetByOffset("b", "select(0, 1, overflowed)") << "\n" + << " if (overflowed) { return; }\n" + << " let new_window_count = full_new_window_count;\n" << " var present_buffer_length = 0;\n" << " var present_buffer_start = 0;\n" - << " if (!overflowed) {\n" - << " if (new_window_count > 0) {\n" - << " present_buffer_length = i32(uniforms.compress_ratio) + pending % i32(uniforms.compress_ratio);\n" - << " present_buffer_start = overlap_length + (new_window_count - 1) * i32(uniforms.compress_ratio);\n" - << " } else {\n" - << " present_buffer_length = old_buf_len + req_len;\n" - << " present_buffer_start = 0;\n" - << " }\n" - << " present_buffer_length = min(present_buffer_length, i32(uniforms.buffer_capacity));\n" + << " if (new_window_count > 0) {\n" + << " present_buffer_length = i32(uniforms.compress_ratio) + pending % i32(uniforms.compress_ratio);\n" + << " present_buffer_start = overlap_length + (new_window_count - 1) * i32(uniforms.compress_ratio);\n" + << " } else {\n" + << " present_buffer_length = old_buf_len + req_len;\n" + << " present_buffer_start = 0;\n" << " }\n" + << " present_buffer_length = min(present_buffer_length, i32(uniforms.buffer_capacity));\n" << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len + new_window_count") << "\n" << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "present_buffer_length") << "\n" << " for (var k = 0; k < new_window_count; k++) {\n" @@ -517,6 +545,7 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); + const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); const auto& selected_indices = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); const auto& selected_counts = shader.AddOutput("selected_counts", ShaderUsage::UseUniform); @@ -585,6 +614,10 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << " " << selected_indices.SetByOffset("output_base + i", "-1") << "\n" << " }\n" << " let b = batch_of_token(token);\n" + << " if (" << overflow_flags.GetByOffset("b") << " != 0) {\n" + << " " << selected_counts.SetByOffset("token", "0") << "\n" + << " return;\n" + << " }\n" << " let key_len_after = u32(" << present_state_lengths.GetByOffset("b * 2u") << ");\n" << " let raw = " << position_ids.GetByOffset("token", true) << ";\n" << " let threshold = min(causal_count(raw), key_len_after);\n" @@ -624,6 +657,8 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& i ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), "PackedSparseAttentionIndexer: compress_ratio is required"); ORT_ENFORCE(compress_ratio_ > 0, "PackedSparseAttentionIndexer: compress_ratio must be > 0"); + ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity_).IsOK() && state_capacity_ > 0, + "PackedSparseAttentionIndexer: state_capacity must be a positive integer"); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); @@ -691,6 +726,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_NOT(cu_shape.NumDimensions() == 1 && cu_shape[0] >= 1, "PackedSparseAttentionIndexer: invalid cumulative_sequence_lengths shape"); const int64_t batch_size = cu_shape[0] - 1; + ORT_RETURN_IF(batch_size == 0 && total_tokens != 0, + "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); @@ -710,6 +747,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont key_state_shape[2] == head_size, "PackedSparseAttentionIndexer: invalid past_key_state shape"); const int64_t state_capacity = key_state_shape[1]; + ORT_RETURN_IF_NOT(state_capacity == state_capacity_, + "PackedSparseAttentionIndexer: past_key_state capacity must match the state_capacity attribute"); const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); @@ -724,6 +763,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont context.Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, head_size})); Tensor* present_state_lengths = context.Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + Tensor overflow_flags = context.CreateGPUTensor(DataTypeImpl::GetType(), TensorShape({batch_size})); if (present_key_state->DataRaw() != past_key_state->DataRaw()) { const int64_t total = present_key_state->Shape().Size(); @@ -771,13 +811,16 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, + {past_seqlens, ProgramTensorMetadataDependency::Type}, {past_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) .AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32(batch_size)) .AddUniformVariables({{ToUint32(batch_size)}, + {ToUint32(total_tokens)}, {ToUint32(compress_ratio_)}, {ToUint32(state_capacity)}, {ToUint32(buffer_capacity)}, @@ -804,7 +847,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont if (position_ids != nullptr) { select.AddInput({position_ids, ProgramTensorMetadataDependency::Type}); } - select.AddInput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + select.AddInputs({{present_state_lengths, ProgramTensorMetadataDependency::Type}, + {&overflow_flags, ProgramTensorMetadataDependency::Type}}) .AddOutputs({{selected_indices, ProgramTensorMetadataDependency::Type}, {selected_counts, ProgramTensorMetadataDependency::Type}}) .SetDispatchGroupSize(ToUint32(total_tokens)) @@ -854,6 +898,8 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_NOT(cu_shape.NumDimensions() == 1 && cu_shape[0] >= 1, "PackedSparseAttentionIndexer: invalid cumulative_sequence_lengths shape"); const int64_t batch_size = cu_shape[0] - 1; + ORT_RETURN_IF(batch_size == 0 && total_tokens != 0, + "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, width})); @@ -874,6 +920,8 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont key_state_shape[2] == head_size, "PackedSparseAttentionIndexer: invalid past_key_state shape"); const int64_t state_capacity = key_state_shape[1]; + ORT_RETURN_IF_NOT(state_capacity == state_capacity_, + "PackedSparseAttentionIndexer: past_key_state capacity must match the state_capacity attribute"); const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio_); ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, width})); @@ -891,6 +939,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont context.Output(psai::kPresentGateBuffer, TensorShape({batch_size, buffer_capacity, width})); Tensor* present_state_lengths = context.Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + Tensor overflow_flags = context.CreateGPUTensor(DataTypeImpl::GetType(), TensorShape({batch_size})); auto copy_if_needed = [&](const Tensor* src, Tensor* dst) -> Status { if (src->DataRaw() == dst->DataRaw()) { @@ -924,6 +973,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont {sin_cache, ProgramTensorMetadataDependency::Type}, {position_bias, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, + {past_seqlens, ProgramTensorMetadataDependency::Type}, {past_kv_buffer, ProgramTensorMetadataDependency::Type}, {past_gate_buffer, ProgramTensorMetadataDependency::Type}}) .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) @@ -931,8 +981,10 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont {present_kv_buffer, ProgramTensorMetadataDependency::Type}, {present_gate_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32(batch_size)) .AddUniformVariables({{ToUint32(batch_size)}, + {ToUint32(total_tokens)}, {ToUint32(compress_ratio_)}, {ToUint32(state_capacity)}, {ToUint32(buffer_capacity)}, @@ -957,7 +1009,8 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}}) - .AddInput({present_state_lengths, ProgramTensorMetadataDependency::Type}) + .AddInputs({{present_state_lengths, ProgramTensorMetadataDependency::Type}, + {&overflow_flags, ProgramTensorMetadataDependency::Type}}) .AddOutputs({{selected_indices, ProgramTensorMetadataDependency::Type}, {selected_counts, ProgramTensorMetadataDependency::Type}}) .SetDispatchGroupSize(ToUint32(total_tokens)) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index c25b17b52e7ad..7be03c2a75310 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -36,6 +36,7 @@ class PackedSparseAttentionIndexerQsaUpdateProgram final Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"total_tokens", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"state_capacity", ProgramUniformVariableDataType::Uint32}, {"buffer_capacity", ProgramUniformVariableDataType::Uint32}, @@ -88,6 +89,7 @@ class PackedSparseAttentionIndexerCsaUpdateProgram final Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"total_tokens", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"state_capacity", ProgramUniformVariableDataType::Uint32}, {"buffer_capacity", ProgramUniformVariableDataType::Uint32}, @@ -137,6 +139,7 @@ class PackedSparseAttentionIndexer final : public WebGpuKernel { packed_sparse_attention_indexer::Policy policy_; int64_t compress_ratio_; + int64_t state_capacity_; int64_t token_budget_; int64_t index_topk_; float epsilon_; diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 93435b40f8bc0..cdc93a5cec6b2 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2439,6 +2439,10 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference // State never grows: present_* always has exactly the same fixed shape as past_*. const auto* key_state_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKeyState, 3); if (key_state_shape != nullptr) { + if (key_state_shape->dim(1).has_dim_value() && key_state_shape->dim(1).dim_value() != state_capacity) { + fail_shape_inference("PackedSparseAttentionIndexer: past_key_state dimension 1 must equal state_capacity (", + state_capacity, "), got ", key_state_shape->dim(1).dim_value()); + } updateOutputShape(ctx, psai::kPresentKeyState, *key_state_shape); } const auto* kv_buffer_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKvBuffer, 3); diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index e6197057f623d..331247e8ded5b 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -91,6 +91,7 @@ struct GraphOptions { int64_t rotary_width = 8; int64_t compress_ratio = 2; int64_t state_capacity = 6; + int64_t input_state_capacity = -1; int64_t token_budget = 4; bool add_index_topk = false; bool add_csa_inputs = false; @@ -133,8 +134,10 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { } else { inputs.push_back(&empty); } + const int64_t input_state_capacity = + options.input_state_capacity >= 0 ? options.input_state_capacity : options.state_capacity; inputs.push_back( - builder.MakeInput(std::vector{options.batch_size, options.state_capacity, options.head_size})); + builder.MakeInput(std::vector{options.batch_size, input_state_capacity, options.head_size})); inputs.push_back( builder.MakeInput(std::vector{options.batch_size, buffer_capacity, width})); if (is_csa) { @@ -181,6 +184,13 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndSt ONNX_NAMESPACE::TensorProto_DataType_INT32, {options.batch_size, 2}); } +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsStateCapacityShapeMismatch) { + GraphOptions options; + options.input_state_capacity = options.state_capacity + 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "past_key_state dimension 1 must equal state_capacity"); +} + TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndState) { GraphOptions options; options.policy_mode = psai::kPolicyModeCsa; @@ -481,10 +491,11 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { const int capacity = p.Capacity(); out.present_key_state = p.past_key_state; - out.present_kv_buffer.assign(static_cast(p.batch_size) * buffer_capacity * head_size, 0.0f); - out.present_state_lengths.assign(static_cast(p.batch_size) * 2, 0); + out.present_kv_buffer = p.past_kv_buffer; + out.present_state_lengths = p.past_state_lengths; std::vector key_len_after(static_cast(p.batch_size)); + std::vector overflowed(static_cast(p.batch_size), false); for (int b = 0; b < p.batch_size; ++b) { const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; @@ -494,8 +505,14 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { const int old_buf_len = std::clamp(p.past_state_lengths[static_cast(b) * 2 + 1], 0, p.compress_ratio - 1); const int pending = old_buf_len + req_len; - const int new_block_count = std::min(pending / p.compress_ratio, std::max(p.state_capacity - old_key_len, 0)); - const int new_buf_len = new_block_count < pending / p.compress_ratio ? 0 : pending % p.compress_ratio; + const int full_new_block_count = pending / p.compress_ratio; + overflowed[static_cast(b)] = full_new_block_count > std::max(p.state_capacity - old_key_len, 0); + if (overflowed[static_cast(b)]) { + key_len_after[static_cast(b)] = old_key_len; + continue; + } + const int new_block_count = full_new_block_count; + const int new_buf_len = pending % p.compress_ratio; auto raw_value = [&](int virtual_pos, int d) -> float { return virtual_pos < old_buf_len @@ -535,6 +552,9 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { out.selected_indices.assign(static_cast(total_tokens) * capacity, -1); out.selected_counts.assign(static_cast(total_tokens), 0); for (int b = 0; b < p.batch_size; ++b) { + if (overflowed[static_cast(b)]) { + continue; + } const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; for (int token = req_start; token < req_end; ++token) { @@ -705,7 +725,7 @@ TEST(PackedSparseAttentionIndexerTest, QsaZeroTokenRequestRow) { } // state_capacity smaller than what the incoming tokens would naturally produce: the update must -// deterministically drop the overflowing blocks rather than corrupt memory. +// reject the request's step without partially changing its state. TEST(PackedSparseAttentionIndexerTest, QsaStateCapacityOverflowIsSafe) { QsaPackedProblem problem; problem.cumulative_sequence_lengths = {0, 6}; @@ -716,6 +736,22 @@ TEST(PackedSparseAttentionIndexerTest, QsaStateCapacityOverflowIsSafe) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); } +#ifdef USE_WEBGPU +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat) { + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu); +} + +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateCapacityOverflowIsRejected) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 6}; + problem.past_sequence_lengths = {4}; + problem.state_capacity = 2; + problem.past_state_lengths = {2, 0}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); +} +#endif + namespace { struct CsaPackedProblem { @@ -771,10 +807,11 @@ void CsaPackedReference(const CsaPackedProblem& p, CsaPackedResult& out) { const float head_weight_scale = p.head_weight_scale.value_or(1.0f / std::sqrt(static_cast(p.num_heads))); out.present_key_state = p.past_key_state; - out.present_kv_buffer.assign(static_cast(p.batch_size) * buffer_capacity * width, 0.0f); - out.present_gate_buffer.assign(static_cast(p.batch_size) * buffer_capacity * width, 0.0f); - out.present_state_lengths.assign(static_cast(p.batch_size) * 2, 0); + out.present_kv_buffer = p.past_kv_buffer; + out.present_gate_buffer = p.past_gate_buffer; + out.present_state_lengths = p.past_state_lengths; std::vector key_len_after(static_cast(p.batch_size)); + std::vector overflowed(static_cast(p.batch_size), false); for (int b = 0; b < p.batch_size; ++b) { const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; @@ -788,21 +825,22 @@ void CsaPackedReference(const CsaPackedProblem& p, CsaPackedResult& out) { const int leftover_length = old_buf_len - overlap_length; const int pending = leftover_length + req_len; const int full_new_window_count = pending / p.compress_ratio; - const int new_window_count = - std::min(full_new_window_count, std::max(p.state_capacity - old_key_len, 0)); - const bool overflowed = new_window_count < full_new_window_count; + overflowed[static_cast(b)] = full_new_window_count > std::max(p.state_capacity - old_key_len, 0); + if (overflowed[static_cast(b)]) { + key_len_after[static_cast(b)] = old_key_len; + continue; + } + const int new_window_count = full_new_window_count; int present_buffer_length = 0; int present_buffer_start = 0; - if (!overflowed) { - if (new_window_count > 0) { - present_buffer_length = p.compress_ratio + pending % p.compress_ratio; - present_buffer_start = overlap_length + (new_window_count - 1) * p.compress_ratio; - } else { - present_buffer_length = old_buf_len + req_len; - present_buffer_start = 0; - } - present_buffer_length = std::min(present_buffer_length, buffer_capacity); + if (new_window_count > 0) { + present_buffer_length = p.compress_ratio + pending % p.compress_ratio; + present_buffer_start = overlap_length + (new_window_count - 1) * p.compress_ratio; + } else { + present_buffer_length = old_buf_len + req_len; + present_buffer_start = 0; } + present_buffer_length = std::min(present_buffer_length, buffer_capacity); auto extended_key = [&](int virtual_pos, int channel) -> float { return virtual_pos < old_buf_len @@ -874,6 +912,9 @@ void CsaPackedReference(const CsaPackedProblem& p, CsaPackedResult& out) { out.selected_indices.assign(static_cast(total_tokens) * capacity, -1); out.selected_counts.assign(static_cast(total_tokens), 0); for (int b = 0; b < p.batch_size; ++b) { + if (overflowed[static_cast(b)]) { + continue; + } const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; for (int token = req_start; token < req_end; ++token) { @@ -1040,5 +1081,29 @@ TEST(PackedSparseAttentionIndexerTest, CsaPrefillThenDecodeIndependentState) { RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f); } +TEST(PackedSparseAttentionIndexerTest, CsaStateCapacityOverflowIsRejected) { + CsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 4}; + problem.state_capacity = 1; + problem.past_state_lengths = {1, 0}; + RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f); +} + +#ifdef USE_WEBGPU +TEST(PackedSparseAttentionIndexerWebGpuTest, CsaFloat) { + RunCsaPackedTest(MakeCsaPackedProblem(), 1.0e-5f, ProviderKind::WebGpu); +} + +TEST(PackedSparseAttentionIndexerWebGpuTest, CsaStateCapacityOverflowIsRejected) { + CsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 4}; + problem.state_capacity = 1; + problem.past_state_lengths = {1, 0}; + RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f, ProviderKind::WebGpu); +} +#endif + } // namespace test } // namespace onnxruntime From 5b9d10ebd776c303c2d34c4980d1831f1aa8c4e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:35:03 +0000 Subject: [PATCH 025/102] Validate packed indexer capacity bounds Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 2 +- .../sparse/packed_sparse_attention_indexer.cc | 7 ++++-- .../bert/packed_sparse_attention_indexer.cc | 5 ++++- .../core/graph/contrib_ops/bert_defs.cc | 12 ++++++---- ...packed_sparse_attention_indexer_op_test.cc | 22 +++++++++++++------ 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 875a3483f87d4..f94548e123ae0 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -4747,7 +4747,7 @@ This version of the operator has been available since version 1 of the 'com.micr
compress_ratio : int (required)
-
Number of consecutive tokens folded into one compressed/pooled entry. Must be > 0.
+
Number of consecutive tokens folded into one compressed/pooled entry. Must be > 0 and 2 * compress_ratio - 1 must not exceed INT_MAX.
epsilon : float
Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.
head_weight_scale : float
diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 2b289bb80443e..7c4d32887f15c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -97,8 +97,11 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), "PackedSparseAttentionIndexer: compress_ratio is required"); - ORT_ENFORCE(compress_ratio_ > 0 && compress_ratio_ <= std::numeric_limits::max(), - "PackedSparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", compress_ratio_); + ORT_ENFORCE(compress_ratio_ > 0 && + compress_ratio_ <= (static_cast(std::numeric_limits::max()) + 1) / 2, + "PackedSparseAttentionIndexer: compress_ratio must be positive and produce a generic buffer capacity " + "no greater than INT_MAX, got ", + compress_ratio_); ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity_).IsOK(), "PackedSparseAttentionIndexer: state_capacity is required"); diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index a277d7dc12264..7c13924c84789 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -656,7 +656,10 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& i psai::kPolicyModeQsa, "' or '", psai::kPolicyModeCsa, "', got '", policy_mode, "'"); ORT_ENFORCE(info.GetAttr("compress_ratio", &compress_ratio_).IsOK(), "PackedSparseAttentionIndexer: compress_ratio is required"); - ORT_ENFORCE(compress_ratio_ > 0, "PackedSparseAttentionIndexer: compress_ratio must be > 0"); + ORT_ENFORCE(compress_ratio_ > 0 && + compress_ratio_ <= (static_cast(std::numeric_limits::max()) + 1) / 2, + "PackedSparseAttentionIndexer: compress_ratio must be positive and produce a generic buffer capacity " + "no greater than INT_MAX"); ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity_).IsOK() && state_capacity_ > 0, "PackedSparseAttentionIndexer: state_capacity must be a positive integer"); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index cdc93a5cec6b2..6c9058e4a39f2 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2331,9 +2331,12 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference const bool is_qsa = policy == psai::Policy::kQsa; const int64_t compress_ratio = getAttribute(ctx, "compress_ratio", static_cast(0)); - if (compress_ratio <= 0 || compress_ratio > std::numeric_limits::max()) { - fail_shape_inference("PackedSparseAttentionIndexer: compress_ratio must be in (0, INT_MAX], got ", - compress_ratio); + if (compress_ratio <= 0 || + compress_ratio > (static_cast(std::numeric_limits::max()) + 1) / 2) { + fail_shape_inference( + "PackedSparseAttentionIndexer: compress_ratio must be positive and produce a generic " + "buffer capacity no greater than INT_MAX, got ", + compress_ratio); } const int64_t state_capacity = getAttribute(ctx, "state_capacity", static_cast(0)); if (state_capacity <= 0 || state_capacity > std::numeric_limits::max()) { @@ -2532,7 +2535,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "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/pooled entry. Must be > 0.", + "Number of consecutive tokens folded into one compressed/pooled entry. Must be > 0 and " + "2 * compress_ratio - 1 must not exceed INT_MAX.", AttributeProto::INT) .Attr("state_capacity", "Fixed capacity (number of entries) of past_key_state / present_key_state. Must be > 0.", diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 331247e8ded5b..9836440c9cfa6 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -184,13 +184,6 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndSt ONNX_NAMESPACE::TensorProto_DataType_INT32, {options.batch_size, 2}); } -TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsStateCapacityShapeMismatch) { - GraphOptions options; - options.input_state_capacity = options.state_capacity + 1; - ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "past_key_state dimension 1 must equal state_capacity"); -} - TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndState) { GraphOptions options; options.policy_mode = psai::kPolicyModeCsa; @@ -228,6 +221,13 @@ void ExpectResolveFailure(const std::function& } // namespace +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsStateCapacityShapeMismatch) { + GraphOptions options; + options.input_state_capacity = options.state_capacity + 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "past_key_state dimension 1 must equal state_capacity"); +} + TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsUnknownPolicyMode) { GraphOptions options; options.policy_mode = "qsa_v2"; @@ -249,6 +249,14 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { "num_heads must be > 0"); } +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsGenericBufferCapacityOverflow) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + options.compress_ratio = static_cast(std::numeric_limits::max()) / 2 + 2; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "generic buffer capacity no greater than INT_MAX"); +} + TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { GraphOptions options; options.token_budget = 5; From de0e5874953f45033b1d7f170c76adb575856853 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:48:00 +0000 Subject: [PATCH 026/102] Fix packed indexer output-count test Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/contrib_ops/packed_sparse_attention_indexer_op_test.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 9836440c9cfa6..53bccaa1f83e3 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -328,7 +328,7 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsWrongOutputCount) { GraphOptions options; options.output_count = 4; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "exactly 6 declared outputs"); + "output size 4 not in range [min=6, max=6]"); } #endif // ORT_NO_EXCEPTIONS From 5a37cb63b48ed9729c8efba0b2d0911c4466d781 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:48:16 +0000 Subject: [PATCH 027/102] Consolidate SparseAttentionIndexer cache state Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 48 ++--- docs/OperatorKernels.md | 2 +- .../cuda/sparse_attention_indexer.md | 47 +++-- .../sparse/sparse_attention_indexer_common.h | 32 ++-- .../cuda/sparse/sparse_attention_indexer.cc | 118 ++++++------ .../core/graph/contrib_ops/bert_defs.cc | 169 +++++++----------- .../python/tools/symbolic_shape_infer.py | 19 +- .../sparse_attention_indexer_op_test.cc | 63 +++---- ...untime_test_python_symbolic_shape_infer.py | 68 +++---- 9 files changed, 242 insertions(+), 324 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index ff389c592e145..94f30ecdd110f 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7006,14 +7006,14 @@ This version of the operator has been available since version 1 of the 'com.micr * 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. + State layout for policy_mode = "csa": the two slices of past_proj_buffer hold the key and gate + projections 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 gate + projection plane stores the raw projection. #### Version @@ -7038,7 +7038,7 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
-#### Inputs (5 - 16) +#### Inputs (6 - 13)
query : T
@@ -7053,8 +7053,8 @@ This version of the operator has been available since version 1 of the 'com.micr
Sine rotary table with the same shape as cos_cache.
mask (optional) : TB
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.
-
past_key (optional) : T
-
Only for policy_mode 'qsa': cached indexer keys with shape (batch_size, past_sequence_length, head_size), or (batch_size, max_cache_length, head_size) when past_sequence_length input is provided.
+
past_key : T
+
Cached indexer keys. For policy_mode 'qsa', these are raw keys; for 'csa', they are compressed keys. Shape is (batch_size, past_sequence_length, head_size), or (batch_size, max_cache_length, head_size) when a valid past_sequence_length is provided.
gate (optional) : T
Only for policy_mode 'csa': gate projection of the new tokens with shape (batch_size, sequence_length, 2 * head_size).
position_bias (optional) : T
@@ -7063,31 +7063,21 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'csa': per-head score weights with shape (batch_size, sequence_length, num_heads).
position_ids (optional) : I
Only for policy_mode 'csa': absolute position of every query with shape (batch_size, sequence_length).
-
past_compressed_key (optional) : T
-
Only for policy_mode 'csa': compressed keys emitted by previous calls, with shape (batch_size, past_compressed_length, head_size), or (batch_size, max_cache_length, head_size) when past_compressed_length input is provided.
-
past_kv_buffer (optional) : T
-
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).
-
past_gate_buffer (optional) : T
-
Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.
past_sequence_length (optional) : M
-
Only for policy_mode 'qsa': optional one-element CPU tensor containing the number of valid rows in past_key. When provided, past_key and present_key have the same max-capacity shape and may share their buffer.
-
past_compressed_length (optional) : M
-
Only for policy_mode 'csa': optional one-element CPU tensor containing the number of valid rows in past_compressed_key. When provided, past_compressed_key and present_compressed_key have the same max-capacity shape and may share their buffer.
+
Optional one-element CPU tensor containing the number of valid rows in past_key. For policy_mode 'csa', the value is the number of compressed keys. When provided, past_key and present_key have the same max-capacity shape and may share their buffer.
+
past_proj_buffer (optional) : T
+
Only for policy_mode 'csa': buffered key and gate projections packed along dimension 0, with shape (2, batch_size, buffer_length, 2 * head_size). Slice 0 contains keys and slice 1 contains gates. buffer_length is in [0, 2 * compress_ratio).
-#### Outputs (1 - 5) +#### Outputs (2 - 3)
selected_indices : M
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.
-
present_key (optional) : T
-
Only for policy_mode 'qsa': past_key concatenated with key, with shape (batch_size, total_sequence_length, head_size). When past_sequence_length is provided, the shape instead matches the max-capacity past_key and the two tensors may share a buffer.
-
present_compressed_key (optional) : T
-
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). When past_compressed_length is provided, the shape instead matches the max-capacity past_compressed_key and the two tensors may share a buffer.
-
present_kv_buffer (optional) : T
-
Only for policy_mode 'csa': updated key buffer with shape (batch_size, present_buffer_length, 2 * head_size).
-
present_gate_buffer (optional) : T
-
Only for policy_mode 'csa': updated gate buffer with the same shape as present_kv_buffer.
+
present_key : T
+
Updated raw key cache for policy_mode 'qsa' or compressed-key cache for 'csa'. Without past_sequence_length its sequence dimension grows by the entries emitted by this call. When past_sequence_length is provided, its shape matches the max-capacity past_key and the two tensors may share a buffer.
+
present_proj_buffer (optional) : T
+
Only for policy_mode 'csa': updated packed key/gate projection buffer with shape (2, batch_size, present_buffer_length, 2 * head_size).
#### Type Constraints diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index 8c317b4c385bd..2469f53b7c6bb 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1143,7 +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**
*in* past_sequence_length:**M**
*in* past_compressed_length:**M**
*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)| +|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_sequence_length:**M**
*in* past_proj_buffer:**T**
*out* selected_indices:**M**
*out* present_key:**T**
*out* present_proj_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 index ba32cb7819b80..7841652f9fc84 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -86,24 +86,21 @@ for a runtime: | 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)` | +| 6 | `past_key` | both | `T` | `(B, P, D)`; raw keys for `qsa`, compressed keys for `csa` | | 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` | +| 11 | `past_sequence_length` | both | `M` | `(1)`; optional valid length for a max-capacity `past_key` | +| 12 | `past_proj_buffer` | `csa` | `T` | `(2, B, Lb, 2D)`, `Lb` in `[0, 2r)`; keys then gates | ### 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` | +| 1 | `present_key` | both | `T` | updated raw (`qsa`) or compressed (`csa`) key cache | +| 2 | `present_proj_buffer` | `csa` | `T` | `(2, B, Lb', 2D)` | `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)). @@ -124,9 +121,8 @@ 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. +exactly 3, adding `present_proj_buffer`. Shape inference verifies the declared output count before +touching any output, so `getOutputType` is never called past the declared range. ## 3. State Contract @@ -136,21 +132,18 @@ returned as an output, so the caller (or the ORT session binding) owns the buffe | 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` | +| `csa` | `past_key` → `present_key` | +| `csa` | `past_proj_buffer` → `present_proj_buffer` | -By default, `present_key` and `present_compressed_key` grow by a number of positions that is known -from the input shapes and attributes. For autoregressive decoding, the caller can instead provide a -max-capacity cache and the corresponding valid-length input: - -- `qsa`: `past_sequence_length` gives the valid rows in `past_key`. -- `csa`: `past_compressed_length` gives the valid rows in `past_compressed_key`. +By default, `present_key` grows by a number of positions that is known from the input shapes and +attributes. For autoregressive decoding, the caller can instead provide a max-capacity cache and +`past_sequence_length`, which gives the valid raw-key count for `qsa` or compressed-key count for +`csa`. In this mode, the past and present tensors have the same shape and the CUDA allocation planner may alias them. The kernel appends new rows at the valid length without copying existing rows. The -capacity must accommodate all rows emitted by the call. The two small `csa` token buffers retain -their ordinary bounded state contract and stay below `2r` positions. +capacity must accommodate all rows emitted by the call. The packed `csa` projection buffer retains +its ordinary bounded state contract and stays below `2r` positions. ## 4. Policy `qsa` @@ -175,7 +168,8 @@ The capacity is exactly `token_budget` selected tokens plus at most `r - 1` tail ### Window bookkeeping -Let `ext = concat(past_kv_buffer, key)` along the sequence axis (and likewise for the gates). +Let `ext = concat(past_proj_buffer[0], key)` along the sequence axis (and likewise use +`past_proj_buffer[1]` for the gates). The buffer is split as ``` @@ -315,13 +309,12 @@ 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 `qsa` node that provides any `csa`-only input (slots 7–10 or 12) 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; +- an output count other than 2 (`qsa`) or 3 (`csa`); +- a `past_proj_buffer` length outside `[0, 2 * compress_ratio)`; - 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 diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h index 3e0b4d1448754..c967921e3523d 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -31,8 +31,9 @@ inline bool TryParsePolicy(const std::string& policy_mode, Policy& policy) { return false; } -// Input slots. Slots 5-6 and 14 belong to policy_mode="qsa"; slots 7-13 and 15 belong to -// policy_mode="csa". A slot that does not belong to the active policy must be omitted. +// Input slots. mask belongs to policy_mode="qsa"; gate, position_bias, head_weights, position_ids, +// and past_proj_buffer belong to policy_mode="csa". The past_key and past_sequence_length state is +// shared by both policies. enum InputIndex : int { kQuery = 0, kKey = 1, @@ -45,28 +46,23 @@ enum InputIndex : int { kPositionBias = 8, kHeadWeights = 9, kPositionIds = 10, - kPastCompressedKey = 11, - kPastKvBuffer = 12, - kPastGateBuffer = 13, - kPastSequenceLength = 14, - kPastCompressedLength = 15, - kInputCount = 16, + kPastSequenceLength = 11, + kPastProjBuffer = 12, + kInputCount = 13, }; -// Output slots. Slot 1 belongs to policy_mode="qsa" and slots 2-4 to policy_mode="csa". +// present_key is shared by both policies; present_proj_buffer is only used by policy_mode="csa". enum OutputIndex : int { kSelectedIndices = 0, kPresentKey = 1, - kPresentCompressedKey = 2, - kPresentKvBuffer = 3, - kPresentGateBuffer = 4, - kOutputCount = 5, + kPresentProjBuffer = 2, + kOutputCount = 3, }; -// 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). +// A "qsa" node declares selected_indices + present_key. A "csa" node additionally declares +// present_proj_buffer. constexpr int kQsaOutputCount = 2; -constexpr int kCsaOutputCount = 5; +constexpr int kCsaOutputCount = 3; // 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. @@ -77,7 +73,7 @@ inline int64_t SelectedCapacity(Policy policy, int64_t token_budget, int64_t ind // 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 +// Each plane of past_proj_buffer carries `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. @@ -85,7 +81,7 @@ 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_length = 0; // sequence length of present_proj_buffer int64_t present_buffer_start = 0; // offset of that buffer inside [past buffer | new tokens] }; diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 185680f625ad2..7ab5a5c780a2a 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -18,22 +18,20 @@ 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()) \ - .MayInplace(sai::kPastKey, sai::kPresentKey) \ - .MayInplace(sai::kPastCompressedKey, sai::kPresentCompressedKey) \ - .InputMemoryType(OrtMemTypeCPUInput, sai::kPastSequenceLength) \ - .InputMemoryType(OrtMemTypeCPUInput, sai::kPastCompressedLength), \ +#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()) \ + .MayInplace(sai::kPastKey, sai::kPresentKey) \ + .InputMemoryType(OrtMemTypeCPUInput, sai::kPastSequenceLength), \ SparseAttentionIndexer); REGISTER_KERNEL_TYPED(float) @@ -116,14 +114,10 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : Cu 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::kPastSequenceLength) - : (index >= sai::kGate && index <= sai::kPastGateBuffer) || index == sai::kPastCompressedLength; + 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; const bool provided = index < context->InputCount() && context->Input(index) != nullptr; - if (!provided && (index == sai::kPastSequenceLength || index == sai::kPastCompressedLength)) { - continue; - } 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, "'"); @@ -268,9 +262,8 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { 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 Tensor* past_key = context->Input(sai::kPastKey); + const Tensor* past_proj_buffer = context->Input(sai::kPastProjBuffer); const auto& query_shape = query->Shape(); ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 4, @@ -306,25 +299,25 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "rotary_width=", rotary_width, " head_size=", head_size); - const auto& past_compressed_shape = past_compressed_key->Shape(); + const auto& past_compressed_shape = past_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 " + "SparseAttentionIndexer: past_key must have shape " "(batch_size, past_compressed_length, head_size), got ", past_compressed_shape.ToString()); const int64_t compressed_cache_capacity = past_compressed_shape[1]; ORT_RETURN_IF_ERROR(CheckIntDimension("compressed_cache_capacity", compressed_cache_capacity)); int64_t past_compressed_length = compressed_cache_capacity; - ORT_RETURN_IF_ERROR(ReadOptionalLength(context, sai::kPastCompressedLength, "past_compressed_length", - past_compressed_length)); - - 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 ", + ORT_RETURN_IF_ERROR( + ReadOptionalLength(context, sai::kPastSequenceLength, "past_sequence_length", past_compressed_length)); + + const auto& past_buffer_shape = past_proj_buffer->Shape(); + ORT_RETURN_IF_NOT(past_buffer_shape.NumDimensions() == 4 && past_buffer_shape[0] == 2 && + past_buffer_shape[1] == batch_size && past_buffer_shape[3] == width, + "SparseAttentionIndexer: past_proj_buffer must have shape " + "(2, batch_size, buffer_length, 2 * head_size), got ", past_buffer_shape.ToString()); - const int64_t past_buffer_length = past_buffer_shape[1]; + const int64_t past_buffer_length = past_buffer_shape[2]; ORT_RETURN_IF_ERROR(CheckIntDimension("past_buffer_length", past_buffer_length)); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, width})); @@ -333,12 +326,9 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { 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 ", + "SparseAttentionIndexer: past_proj_buffer sequence length must be in [0, 2 * compress_ratio), got ", past_buffer_length); ORT_RETURN_IF_ERROR(CheckIntDimension("overlap_length", plan.overlap_length)); ORT_RETURN_IF_ERROR(CheckIntDimension("new_window_count", plan.new_window_count)); @@ -348,10 +338,10 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "SparseAttentionIndexer: present_compressed_length must be no greater than INT_MAX"); const int64_t present_compressed_length = past_compressed_length + plan.new_window_count; ORT_RETURN_IF(present_compressed_length > compressed_cache_capacity && - context->InputCount() > sai::kPastCompressedLength && - context->Input(sai::kPastCompressedLength) != nullptr, + context->InputCount() > sai::kPastSequenceLength && + context->Input(sai::kPastSequenceLength) != nullptr, "SparseAttentionIndexer: present_compressed_length must not exceed compressed_cache_capacity when " - "past_compressed_length is provided"); + "past_sequence_length is provided"); SparseAttentionIndexerParams params; params.batch_size = static_cast(batch_size); @@ -369,8 +359,8 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.present_compressed_length = static_cast(present_compressed_length); params.past_compressed_capacity = static_cast(compressed_cache_capacity); params.compressed_cache_capacity = static_cast( - context->InputCount() > sai::kPastCompressedLength && - context->Input(sai::kPastCompressedLength) != nullptr + context->InputCount() > sai::kPastSequenceLength && + context->Input(sai::kPastSequenceLength) != nullptr ? compressed_cache_capacity : present_compressed_length); params.past_buffer_length = static_cast(past_buffer_length); @@ -384,20 +374,24 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { 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, params.compressed_cache_capacity, 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"); + Tensor* present_key = + context->Output(sai::kPresentKey, TensorShape({batch_size, params.compressed_cache_capacity, head_size})); + Tensor* present_proj_buffer = + context->Output(sai::kPresentProjBuffer, TensorShape({2, batch_size, plan.present_buffer_length, width})); + ORT_RETURN_IF(selected_indices == nullptr || present_key == nullptr || present_proj_buffer == nullptr, + "SparseAttentionIndexer: policy_mode 'csa' requires selected_indices, present_key and " + "present_proj_buffer outputs"); auto float_workspace = GetScratchBuffer(GetCsaWorkspaceFloatCount(params), GetComputeStream(context)); const CudaT* empty = nullptr; + const int64_t past_proj_plane_elements = batch_size * past_buffer_length * width; + const int64_t present_proj_plane_elements = batch_size * plan.present_buffer_length * width; + const CudaT* past_proj_data = + past_buffer_length > 0 ? reinterpret_cast(past_proj_buffer->Data()) : empty; + CudaT* present_proj_data = plan.present_buffer_length > 0 + ? reinterpret_cast(present_proj_buffer->MutableData()) + : nullptr; return LaunchCsaSparseAttentionIndexer( Stream(context), params, reinterpret_cast(query->Data()), @@ -409,13 +403,13 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { reinterpret_cast(position_bias->Data()), reinterpret_cast(head_weights->Data()), position_ids->Data(), - compressed_cache_capacity > 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, + compressed_cache_capacity > 0 ? reinterpret_cast(past_key->Data()) : empty, + past_proj_data, + past_buffer_length > 0 ? past_proj_data + past_proj_plane_elements : empty, selected_indices->MutableData(), - params.compressed_cache_capacity > 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, + params.compressed_cache_capacity > 0 ? reinterpret_cast(present_key->MutableData()) : nullptr, + present_proj_data, + plan.present_buffer_length > 0 ? present_proj_data + present_proj_plane_elements : nullptr, float_workspace.get()); } diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 64d5395f39751..fad7485a95d62 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -1965,15 +1965,17 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex // Strict policy input validation: every slot of the inactive policy must be omitted, and every // slot of the active policy must be provided. - constexpr int kQsaOnlyInputs[] = {sai::kMask, sai::kPastKey}; + constexpr int kQsaOnlyInputs[] = {sai::kMask}; constexpr int kCsaOnlyInputs[] = {sai::kGate, sai::kPositionBias, sai::kHeadWeights, - sai::kPositionIds, sai::kPastCompressedKey, - sai::kPastKvBuffer, sai::kPastGateBuffer}; + sai::kPositionIds, sai::kPastProjBuffer}; 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"); } } + if (!SparseAttentionIndexerHasInput(ctx, sai::kPastKey)) { + fail_shape_inference("SparseAttentionIndexer: past_key is required for every policy_mode"); + } for (int index : kQsaOnlyInputs) { if (SparseAttentionIndexerHasInput(ctx, index) != is_qsa) { fail_shape_inference("SparseAttentionIndexer: input ", index, @@ -1988,15 +1990,6 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex : " is required when policy_mode is 'csa'"); } } - if (SparseAttentionIndexerHasInput(ctx, sai::kPastSequenceLength) && !is_qsa) { - fail_shape_inference( - "SparseAttentionIndexer: past_sequence_length must be omitted when policy_mode is 'csa'"); - } - if (SparseAttentionIndexerHasInput(ctx, sai::kPastCompressedLength) && is_qsa) { - fail_shape_inference( - "SparseAttentionIndexer: past_compressed_length must be omitted when policy_mode is 'qsa'"); - } - 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 ", @@ -2021,6 +2014,10 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex 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()); } + if (!is_qsa && head_size_dim.has_dim_value() && + head_size_dim.dim_value() > std::numeric_limits::max() / 2) { + fail_shape_inference("SparseAttentionIndexer: 2 * head_size exceeds INT64_MAX"); + } const int64_t capacity = sai::SelectedCapacity(policy, token_budget, index_topk, compress_ratio); ONNX_NAMESPACE::TensorShapeProto selected_shape; @@ -2051,68 +2048,68 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex 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); + // ctx.getNumOutputs() == 3 was enforced above, so indices 1 and 2 are in range. + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentKey); + propagateElemTypeFromInputToOutput(ctx, sai::kQuery, sai::kPresentProjBuffer); - const auto* past_compressed_shape = SparseAttentionIndexerShape(ctx, sai::kPastCompressedKey, 3); - (void)SparseAttentionIndexerShape(ctx, sai::kPastCompressedLength, 1); - const auto* past_buffer_shape = SparseAttentionIndexerShape(ctx, sai::kPastKvBuffer, 3); - const auto* past_gate_shape = SparseAttentionIndexerShape(ctx, sai::kPastGateBuffer, 3); + const auto* past_compressed_shape = SparseAttentionIndexerShape(ctx, sai::kPastKey, 3); + (void)SparseAttentionIndexerShape(ctx, sai::kPastSequenceLength, 1); + const auto* past_buffer_shape = SparseAttentionIndexerShape(ctx, sai::kPastProjBuffer, 4); (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()); - } - } + if (past_buffer_shape != nullptr && past_buffer_shape->dim(0).has_dim_value() && + past_buffer_shape->dim(0).dim_value() != 2) { + fail_shape_inference("SparseAttentionIndexer: past_proj_buffer dimension 0 must be 2, got ", + past_buffer_shape->dim(0).dim_value()); + } + if (past_buffer_shape != nullptr && past_buffer_shape->dim(1).has_dim_value() && + batch_dim.has_dim_value() && past_buffer_shape->dim(1).dim_value() != batch_dim.dim_value()) { + fail_shape_inference("SparseAttentionIndexer: past_proj_buffer dimension 1 must equal batch_size"); + } + if (past_buffer_shape != nullptr && past_buffer_shape->dim(3).has_dim_value() && + head_size_dim.has_dim_value() && + past_buffer_shape->dim(3).dim_value() != 2 * head_size_dim.dim_value()) { + fail_shape_inference("SparseAttentionIndexer: past_proj_buffer dimension 3 must equal 2 * head_size"); } sai::CsaWindowPlan plan; - const bool plan_known = past_buffer_shape != nullptr && past_buffer_shape->dim(1).has_dim_value() && + const bool plan_known = past_buffer_shape != nullptr && past_buffer_shape->dim(2).has_dim_value() && sequence_dim.has_dim_value() && - sai::TryComputeCsaWindowPlan(past_buffer_shape->dim(1).dim_value(), + sai::TryComputeCsaWindowPlan(past_buffer_shape->dim(2).dim_value(), sequence_dim.dim_value(), compress_ratio, plan); - if (past_buffer_shape != nullptr && past_buffer_shape->dim(1).has_dim_value() && + if (past_buffer_shape != nullptr && past_buffer_shape->dim(2).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()); + "SparseAttentionIndexer: past_proj_buffer sequence length must be in [0, 2 * compress_ratio), got ", + past_buffer_shape->dim(2).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 (SparseAttentionIndexerHasInput(ctx, sai::kPastCompressedLength)) { + if (SparseAttentionIndexerHasInput(ctx, sai::kPastSequenceLength)) { *entry_dim = past_compressed_shape->dim(1); } else 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); + updateOutputShape(ctx, sai::kPresentKey, present_shape); } if (past_buffer_shape != nullptr) { ONNX_NAMESPACE::TensorShapeProto buffer_shape; + SparseAttentionIndexerAppendDim(buffer_shape, past_buffer_shape->dim(0)); 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); + SparseAttentionIndexerAppendDim(buffer_shape, past_buffer_shape->dim(3)); + updateOutputShape(ctx, sai::kPresentProjBuffer, buffer_shape); } } @@ -2152,14 +2149,14 @@ Common contract: * 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. +State layout for policy_mode = "csa": the two slices of past_proj_buffer hold the key and gate +projections 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 gate +projection plane stores the raw projection. )DOC"; ONNX_MS_OPERATOR_SET_SCHEMA( @@ -2229,11 +2226,10 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OpSchema::Optional) .Input(6, "past_key", - "Only for policy_mode 'qsa': cached indexer keys with shape " - "(batch_size, past_sequence_length, head_size), or " - "(batch_size, max_cache_length, head_size) when past_sequence_length input is provided.", - "T", - OpSchema::Optional) + "Cached indexer keys. For policy_mode 'qsa', these are raw keys; for 'csa', they are compressed " + "keys. Shape is (batch_size, past_sequence_length, head_size), or " + "(batch_size, max_cache_length, head_size) when a valid past_sequence_length is provided.", + "T") .Input(7, "gate", "Only for policy_mode 'csa': gate projection of the new tokens with shape " @@ -2258,36 +2254,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "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), or " - "(batch_size, max_cache_length, head_size) when past_compressed_length input is provided.", - "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) - .Input(14, "past_sequence_length", - "Only for policy_mode 'qsa': optional one-element CPU tensor containing the number of valid rows in " - "past_key. When provided, past_key and present_key have the same max-capacity shape and may share " - "their buffer.", + "Optional one-element CPU tensor containing the number of valid rows in past_key. For policy_mode " + "'csa', the value is the number of compressed keys. When provided, past_key and present_key have " + "the same max-capacity shape and may share their buffer.", "M", OpSchema::Optional) - .Input(15, - "past_compressed_length", - "Only for policy_mode 'csa': optional one-element CPU tensor containing the number of valid rows in " - "past_compressed_key. When provided, past_compressed_key and present_compressed_key have the same " - "max-capacity shape and may share their buffer.", - "M", + .Input(12, + "past_proj_buffer", + "Only for policy_mode 'csa': buffered key and gate projections packed along dimension 0, with shape " + "(2, batch_size, buffer_length, 2 * head_size). Slice 0 contains keys and slice 1 contains gates. " + "buffer_length is in [0, 2 * compress_ratio).", + "T", OpSchema::Optional) .Output(0, "selected_indices", @@ -2298,28 +2276,15 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "M") .Output(1, "present_key", - "Only for policy_mode 'qsa': past_key concatenated with key, with shape " - "(batch_size, total_sequence_length, head_size). When past_sequence_length is provided, " - "the shape instead matches the max-capacity past_key and the two tensors may share a buffer.", - "T", - OpSchema::Optional) + "Updated raw key cache for policy_mode 'qsa' or compressed-key cache for 'csa'. Without " + "past_sequence_length its sequence dimension grows by the entries emitted by this call. When " + "past_sequence_length is provided, its shape matches the max-capacity past_key and the two tensors " + "may share a buffer.", + "T") .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). When " - "past_compressed_length is provided, the shape instead matches the max-capacity " - "past_compressed_key and the two tensors may share a buffer.", - "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.", + "present_proj_buffer", + "Only for policy_mode 'csa': updated packed key/gate projection buffer with shape " + "(2, batch_size, present_buffer_length, 2 * head_size).", "T", OpSchema::Optional) .TypeConstraint("T", diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index d19b15b6a62f7..8365f039e7a8c 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -2667,18 +2667,18 @@ def past_shape(index): past_key_shape = past_shape(6) if past_key_shape is None: return - if len(node.input) > 14 and node.input[14]: + if len(node.input) > 11 and node.input[11]: set_output(1, past_key_shape) else: set_output(1, [query_shape[0], past_key_shape[1] + query_shape[1], query_shape[3]]) return - past_compressed_shape = past_shape(11) + past_compressed_shape = past_shape(6) 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] + buffer_length = past_buffer_shape[2] sequence_length = query_shape[1] # The number of compressed entries emitted by this call is known as soon as the buffer and @@ -2694,20 +2694,19 @@ def past_shape(index): ) present_compressed_length = ( past_compressed_shape[1] - if len(node.input) > 15 and node.input[15] + if len(node.input) > 11 and node.input[11] else past_compressed_shape[1] + new_window_count ) else: present_compressed_length = ( past_compressed_shape[1] - if len(node.input) > 15 and node.input[15] - else self._new_symbolic_dim_from_output(node, 2, 1) + if len(node.input) > 11 and node.input[11] + else self._new_symbolic_dim_from_output(node, 1, 1) ) - present_buffer_length = self._new_symbolic_dim_from_output(node, 3, 1) + present_buffer_length = self._new_symbolic_dim_from_output(node, 2, 2) - 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]]) + set_output(1, [query_shape[0], present_compressed_length, query_shape[3]]) + set_output(2, [2, query_shape[0], present_buffer_length, past_buffer_shape[3]]) def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) 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 78d1b4481d31c..f052d8cafac06 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -163,25 +163,22 @@ void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { 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.share_cache ? options.compressed_cache_capacity : 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}), + 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}), + options.share_cache ? builder.MakeInput(std::vector{1}) : &empty, + builder.MakeInput(std::vector{2, options.batch_size, options.past_buffer_length, width}), }; - inputs.push_back(&empty); - inputs.push_back(options.share_cache ? builder.MakeInput(std::vector{1}) : &empty); - std::vector outputs{builder.MakeOutput()}; - for (int64_t slot = 1; slot < options.output_count; ++slot) { - outputs.push_back(slot == sai::kPresentKey ? &empty : builder.MakeOutput()); + std::vector outputs; + for (int64_t slot = 0; slot < options.output_count; ++slot) { + outputs.push_back(builder.MakeOutput()); } Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); @@ -786,33 +783,31 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { test.AddInput("sin_cache", {batch_size, problem.max_rotary_length, problem.rotary_width}, ToElementType(problem.sin_cache)); test.AddOptionalInputEdge(); - test.AddOptionalInputEdge(); + test.AddInput("past_key", {batch_size, problem.PastCompressedCapacity(), head_size}, + ToElementType(problem.past_compressed_key)); 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.PastCompressedCapacity(), 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.AddOptionalInputEdge(); if (problem.compressed_cache_capacity > 0) { - test.AddInput("past_compressed_length", {1}, {problem.past_compressed_length}); + test.AddInput("past_sequence_length", {1}, {problem.past_compressed_length}); + } else { + test.AddOptionalInputEdge(); } + std::vector past_proj_buffer = problem.past_kv_buffer; + past_proj_buffer.insert(past_proj_buffer.end(), problem.past_gate_buffer.begin(), problem.past_gate_buffer.end()); + test.AddInput("past_proj_buffer", {2, batch_size, problem.past_buffer_length, width}, + ToElementType(past_proj_buffer)); test.AddOutput("selected_indices", {batch_size, sequence_length, problem.index_topk}, selected); - test.AddOptionalOutputEdge(); const int compressed_capacity = problem.compressed_cache_capacity > 0 ? problem.compressed_cache_capacity : present_compressed_length; - test.AddOutput("present_compressed_key", {batch_size, compressed_capacity, head_size}, + test.AddOutput("present_key", {batch_size, compressed_capacity, head_size}, ToElementType(present_compressed_key), false, 0.0f, tolerance); - test.AddOutput("present_kv_buffer", {batch_size, plan.present_buffer_length, width}, + present_kv_buffer.insert(present_kv_buffer.end(), present_gate_buffer.begin(), present_gate_buffer.end()); + test.AddOutput("present_proj_buffer", {2, 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); } @@ -895,13 +890,11 @@ TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { 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(), + ExpectShape(graph, node.OutputDefs()[sai::kPresentKey]->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}); + ExpectShape(graph, node.OutputDefs()[sai::kPresentProjBuffer]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {2, options.batch_size, plan.present_buffer_length, 2 * options.head_size}); } TEST(SparseAttentionIndexerShapeInferenceTest, CsaSharedCacheKeepsCapacity) { @@ -913,7 +906,7 @@ TEST(SparseAttentionIndexerShapeInferenceTest, CsaSharedCacheKeepsCapacity) { const Graph& graph = model->MainGraph(); const Node& node = *graph.Nodes().begin(); - ExpectShape(graph, node.OutputDefs()[sai::kPresentCompressedKey]->Name(), + ExpectShape(graph, node.OutputDefs()[sai::kPresentKey]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, {options.batch_size, options.compressed_cache_capacity, options.head_size}); } @@ -987,16 +980,16 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithQsaAttribute) { // keeps inference from touching an output index the node does not have. TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithMissingStateOutputs) { CsaGraphOptions options; - options.output_count = 3; + options.output_count = 2; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, - "requires exactly 5 declared outputs"); + "requires exactly 3 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)"); + "past_proj_buffer sequence length must be in [0, 2 * compress_ratio)"); } #endif // ORT_NO_EXCEPTIONS 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 05824ed0a11df..7aabc05004f03 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -182,9 +182,6 @@ def test_sparse_attention_indexer_qsa_shared_cache(self): "", "", "", - "", - "", - "", "past_sequence_length", ], ["selected_indices", "present_key"], @@ -208,7 +205,7 @@ def test_sparse_attention_indexer_qsa_shared_cache(self): outputs = {output.name: output for output in inferred.graph.output} self.assertEqual(self._tensor_shape(outputs["present_key"]), ["batch", "capacity", 8]) - def test_sparse_attention_indexer_csa_static_with_empty_output(self): + def test_sparse_attention_indexer_csa_static(self): node = helper.make_node( "SparseAttentionIndexer", [ @@ -218,16 +215,15 @@ def test_sparse_attention_indexer_csa_static_with_empty_output(self): "cos_cache", "sin_cache", "", - "", + "past_key", "gate", "position_bias", "head_weights", "position_ids", - "past_compressed_key", - "past_kv_buffer", - "past_gate_buffer", + "", + "past_proj_buffer", ], - ["selected_indices", "", "present_compressed_key", "present_kv_buffer", "present_gate_buffer"], + ["selected_indices", "present_key", "present_proj_buffer"], domain="com.microsoft", policy_mode="csa", compress_ratio=4, @@ -243,18 +239,16 @@ def test_sparse_attention_indexer_csa_static_with_empty_output(self): 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]), + helper.make_tensor_value_info("past_key", TensorProto.FLOAT, [2, 6, 8]), + helper.make_tensor_value_info("past_proj_buffer", TensorProto.FLOAT, [2, 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]) + self.assertEqual(self._tensor_shape(outputs["present_key"]), [2, 7, 8]) + self.assertEqual(self._tensor_shape(outputs["present_proj_buffer"]), [2, 2, 6, 16]) def test_sparse_attention_indexer_csa_shared_cache(self): node = helper.make_node( @@ -266,18 +260,15 @@ def test_sparse_attention_indexer_csa_shared_cache(self): "cos_cache", "sin_cache", "", - "", + "past_key", "gate", "position_bias", "head_weights", "position_ids", - "past_compressed_key", - "past_kv_buffer", - "past_gate_buffer", - "", - "past_compressed_length", + "past_sequence_length", + "past_proj_buffer", ], - ["selected_indices", "", "present_compressed_key", "present_kv_buffer", "present_gate_buffer"], + ["selected_indices", "present_key", "present_proj_buffer"], domain="com.microsoft", policy_mode="csa", compress_ratio=4, @@ -293,15 +284,14 @@ def test_sparse_attention_indexer_csa_shared_cache(self): 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, 32, 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]), - helper.make_tensor_value_info("past_compressed_length", TensorProto.INT32, [1]), + helper.make_tensor_value_info("past_key", TensorProto.FLOAT, [2, 32, 8]), + helper.make_tensor_value_info("past_sequence_length", TensorProto.INT32, [1]), + helper.make_tensor_value_info("past_proj_buffer", TensorProto.FLOAT, [2, 2, 5, 16]), ] inferred = self._infer_sparse_attention_indexer(node, inputs) outputs = {output.name: output for output in inferred.graph.output} - self.assertEqual(self._tensor_shape(outputs["present_compressed_key"]), [2, 32, 8]) + self.assertEqual(self._tensor_shape(outputs["present_key"]), [2, 32, 8]) def test_sparse_attention_indexer_csa_symbolic_fallback(self): node = helper.make_node( @@ -313,16 +303,15 @@ def test_sparse_attention_indexer_csa_symbolic_fallback(self): "cos_cache", "sin_cache", "", - "", + "past_key", "gate", "position_bias", "head_weights", "position_ids", - "past_compressed_key", - "past_kv_buffer", - "past_gate_buffer", + "", + "past_proj_buffer", ], - ["selected_indices", "", "present_compressed_key", "present_kv_buffer", "present_gate_buffer"], + ["selected_indices", "present_key", "present_proj_buffer"], domain="com.microsoft", policy_mode="csa", compress_ratio=4, @@ -338,20 +327,19 @@ def test_sparse_attention_indexer_csa_symbolic_fallback(self): 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]), + helper.make_tensor_value_info("past_key", TensorProto.FLOAT, ["batch", "compressed", 8]), + helper.make_tensor_value_info("past_proj_buffer", TensorProto.FLOAT, [2, "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"]) + compressed_shape = self._tensor_shape(outputs["present_key"]) + buffer_shape = self._tensor_shape(outputs["present_proj_buffer"]) self.assertEqual(compressed_shape[::2], ["batch", 8]) - self.assertEqual(buffer_shape[::2], ["batch", 16]) + self.assertEqual(buffer_shape[::3], [2, 16]) + self.assertEqual(buffer_shape[1], "batch") 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) + self.assertTrue(buffer_shape[2].startswith("SparseAttentionIndexer_")) def test_unsqueeze_opset_11(self): graph = helper.make_graph( From 76d6532b3cabd11c7e0d3871c8ef1b360224c203 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:36:51 +0000 Subject: [PATCH 028/102] Fix SparseAttentionIndexer test narrowing warning Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/contrib_ops/sparse_attention_indexer_op_test.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f052d8cafac06..69431b923a7ef 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -801,7 +801,7 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { ToElementType(past_proj_buffer)); test.AddOutput("selected_indices", {batch_size, sequence_length, problem.index_topk}, selected); - const int compressed_capacity = + const int64_t compressed_capacity = problem.compressed_cache_capacity > 0 ? problem.compressed_cache_capacity : present_compressed_length; test.AddOutput("present_key", {batch_size, compressed_capacity, head_size}, ToElementType(present_compressed_key), false, 0.0f, tolerance); From daf73e351e53cbec8c8f6372a0f0200525a25006 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:47:24 +0000 Subject: [PATCH 029/102] Update generated SparseAttentionIndexer docs Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 94f30ecdd110f..723065e7ecf09 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7038,7 +7038,7 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
-#### Inputs (6 - 13) +#### Inputs (7 - 13)
query : T
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 030/102] 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 df26284422c54c35f62761747c7a75323ec8868b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:08:55 +0000 Subject: [PATCH 031/102] Address packed indexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../packed_sparse_attention_indexer.md | 7 +- .../packed_sparse_attention_indexer_impl.cu | 8 +- .../bert/packed_sparse_attention_indexer.cc | 103 ++++++++--- .../bert/packed_sparse_attention_indexer.h | 22 ++- .../core/graph/contrib_ops/bert_defs.cc | 173 ++++++++++++++++-- ...packed_sparse_attention_indexer_op_test.cc | 147 +++++++++++++-- 6 files changed, 383 insertions(+), 77 deletions(-) diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md index 712e73d2da278..883a074a657aa 100644 --- a/docs/contrib_ops/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -117,9 +117,10 @@ Both policies read and write the *same four* state slots — there is no separat `CsaWindowPlan`/`TryComputeCsaWindowPlan`). State never grows. `present_*` always has exactly the same shape as `past_*`; only the *contents* -change. Input/output aliasing is supported: every kernel reads its sources (`past_kv_buffer` / -`key`, `past_gate_buffer` / `gate`) and never re-reads `present_*`, so it is correct whether -`present_*` is a distinct allocation or the same underlying buffer as `past_*`. +change. Input/output aliasing is supported. CUDA avoids unsafe buffer aliases; WebGPU omits an +aliased `past_*` read-only binding and reads the prior contents through the matching read-write +`present_*` binding. Each request is handled by one invocation, and buffer compaction reads entries +at or above the destination index before overwriting them. **State overflow.** If a call would close more blocks/windows than `state_capacity - old_entry_count` allows, that request's step is rejected as a deterministic diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 3e005ce00ba65..f2bcc3f29c2bf 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -183,7 +183,7 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con // Publish the raw trailing buffer. Skipped entirely on overflow: present_kv_buffer already // holds past_kv_buffer's contents unchanged (from the baseline copy in the Launch function // below), which is exactly the prior valid buffer this rejected step must preserve. - if (!overflowed) { + if (!rejected) { for (int t = static_cast(threadIdx.x); t < new_buf_len; t += static_cast(blockDim.x)) { const int virtual_pos = new_block_count * params.compress_ratio + t; const int64_t out_base = (static_cast(b) * params.buffer_capacity + t) * params.head_size; @@ -420,8 +420,8 @@ __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_n const int old_key_len = min(max(raw_key_len, 0), params.state_capacity); const int old_buf_len = min(max(raw_buf_len, 0), params.buffer_capacity); - sai::CsaWindowPlan plan; - const bool plan_ok = sai::TryComputeCsaWindowPlan(old_buf_len, req_len, params.compress_ratio, plan); + psai::CsaWindowPlan plan; + const bool plan_ok = psai::TryComputeCsaWindowPlan(old_buf_len, req_len, params.compress_ratio, plan); // plan_ok is always true here: old_buf_len is clamped into [0, buffer_capacity) == // [0, 2 * compress_ratio) and req_len >= 0, which are exactly the documented preconditions. const int full_new_window_count = plan_ok ? static_cast(plan.new_window_count) : 0; @@ -568,7 +568,7 @@ __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_n // present_gate_buffer already hold past_kv_buffer's / past_gate_buffer's contents unchanged // (from the baseline copy in the Launch function below), which is exactly the prior valid // buffer this rejected step must preserve. - if (!overflowed) { + if (!rejected) { for (int t = static_cast(threadIdx.x); t < present_buffer_length; t += static_cast(blockDim.x)) { const int virtual_pos = present_buffer_start + t; const int64_t out_base = (static_cast(b) * params.buffer_capacity + t) * width; diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 7c13924c84789..725e8a1c09d0f 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -37,7 +37,11 @@ ONNX_OPERATOR_KERNEL_EX( (*KernelDefBuilder::Create()) .TypeConstraint("T", WebGpuSupportedFloatTypes()) .TypeConstraint("I", DataTypeImpl::GetTensorType()) - .TypeConstraint("M", DataTypeImpl::GetTensorType()), + .TypeConstraint("M", DataTypeImpl::GetTensorType()) + .MayInplace(psai::kPastKeyState, psai::kPresentKeyState) + .MayInplace(psai::kPastKvBuffer, psai::kPresentKvBuffer) + .MayInplace(psai::kPastGateBuffer, psai::kPresentGateBuffer) + .MayInplace(psai::kPastStateLengths, psai::kPresentStateLengths), PackedSparseAttentionIndexer); namespace { @@ -98,20 +102,30 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); - const auto& past_kv_buffer = shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); - const auto& past_state_lengths = shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); + const ShaderVariableHelper* past_kv_buffer = nullptr; + if (!kv_buffer_aliases_) { + past_kv_buffer = &shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); + } + const ShaderVariableHelper* past_state_lengths = nullptr; + if (!state_lengths_aliases_) { + past_state_lengths = &shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); + } const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_kv_buffer = shader.AddOutput("present_kv_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); + const ShaderVariableHelper& kv_buffer_history = + kv_buffer_aliases_ ? present_kv_buffer : *past_kv_buffer; + const ShaderVariableHelper& state_lengths_history = + state_lengths_aliases_ ? present_state_lengths : *past_state_lengths; shader.AdditionalImplementation() << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, d: u32) -> f32 {\n" << " if (virtual_pos < old_buf_len) {\n" << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * uniforms.head_size + d;\n" - << " return f32(" << past_kv_buffer.GetByOffset("idx") << ");\n" + << " return f32(" << kv_buffer_history.GetByOffset("idx") << ");\n" << " }\n" << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * uniforms.head_size + d;\n" << " return f32(" << key.GetByOffset("idx2") << ");\n" @@ -162,8 +176,8 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" - << " let old_key_len = " << past_state_lengths.GetByOffset("b * 2u") << ";\n" - << " let old_buf_len = " << past_state_lengths.GetByOffset("b * 2u + 1u") << ";\n" + << " let old_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" + << " let old_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" << " let past_len = " << past_seqlens.GetByOffset("b") << ";\n" << " let invalid = " << cu_seqlens.GetByOffset("0") << " != 0 || " << cu_seqlens.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " @@ -364,9 +378,18 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& position_bias = shader.AddInput("position_bias", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); - const auto& past_kv_buffer = shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); - const auto& past_gate_buffer = shader.AddInput("past_gate_buffer", ShaderUsage::UseUniform); - const auto& past_state_lengths = shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); + const ShaderVariableHelper* past_kv_buffer = nullptr; + if (!kv_buffer_aliases_) { + past_kv_buffer = &shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); + } + const ShaderVariableHelper* past_gate_buffer = nullptr; + if (!gate_buffer_aliases_) { + past_gate_buffer = &shader.AddInput("past_gate_buffer", ShaderUsage::UseUniform); + } + const ShaderVariableHelper* past_state_lengths = nullptr; + if (!state_lengths_aliases_) { + past_state_lengths = &shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); + } const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_kv_buffer = @@ -375,13 +398,19 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe shader.AddOutput("present_gate_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); + const ShaderVariableHelper& kv_buffer_history = + kv_buffer_aliases_ ? present_kv_buffer : *past_kv_buffer; + const ShaderVariableHelper& gate_buffer_history = + gate_buffer_aliases_ ? present_gate_buffer : *past_gate_buffer; + const ShaderVariableHelper& state_lengths_history = + state_lengths_aliases_ ? present_state_lengths : *past_state_lengths; shader.AdditionalImplementation() << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, channel: u32) -> f32 {\n" << " let width = 2u * uniforms.head_size;\n" << " if (virtual_pos < old_buf_len) {\n" << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * width + channel;\n" - << " return f32(" << past_kv_buffer.GetByOffset("idx") << ");\n" + << " return f32(" << kv_buffer_history.GetByOffset("idx") << ");\n" << " }\n" << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * width + channel;\n" << " return f32(" << key.GetByOffset("idx2") << ");\n" @@ -390,7 +419,7 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " let width = 2u * uniforms.head_size;\n" << " if (virtual_pos < old_buf_len) {\n" << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * width + channel;\n" - << " return f32(" << past_gate_buffer.GetByOffset("idx") << ");\n" + << " return f32(" << gate_buffer_history.GetByOffset("idx") << ");\n" << " }\n" << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * width + channel;\n" << " return f32(" << gate.GetByOffset("idx2") << ");\n" @@ -446,8 +475,8 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" - << " let old_key_len = " << past_state_lengths.GetByOffset("b * 2u") << ";\n" - << " let old_buf_len = " << past_state_lengths.GetByOffset("b * 2u + 1u") << ";\n" + << " let old_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" + << " let old_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" << " let invalid = " << cu_seqlens.GetByOffset("0") << " != 0 || " << cu_seqlens.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || " @@ -806,19 +835,25 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont } if (batch_size > 0) { - PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; - update.CacheHint(rotary.batched) + const bool kv_buffer_aliases = present_kv_buffer->DataRaw() == past_kv_buffer->DataRaw(); + const bool state_lengths_aliases = present_state_lengths->DataRaw() == past_state_lengths->DataRaw(); + PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched, kv_buffer_aliases, state_lengths_aliases}; + update.CacheHint(rotary.batched, kv_buffer_aliases, state_lengths_aliases) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{key, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, - {past_seqlens, ProgramTensorMetadataDependency::Type}, - {past_kv_buffer, ProgramTensorMetadataDependency::Type}}) - .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) - .AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, - {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) + {past_seqlens, ProgramTensorMetadataDependency::Type}}); + if (!kv_buffer_aliases) { + update.AddInput({past_kv_buffer, ProgramTensorMetadataDependency::Type}); + } + if (!state_lengths_aliases) { + update.AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}); + } + update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, + {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32(batch_size)) @@ -966,8 +1001,12 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(copy_if_needed(past_state_lengths, present_state_lengths)); if (batch_size > 0) { - PackedSparseAttentionIndexerCsaUpdateProgram update{rotary.batched}; - update.CacheHint(rotary.batched) + const bool kv_buffer_aliases = present_kv_buffer->DataRaw() == past_kv_buffer->DataRaw(); + const bool gate_buffer_aliases = present_gate_buffer->DataRaw() == past_gate_buffer->DataRaw(); + const bool state_lengths_aliases = present_state_lengths->DataRaw() == past_state_lengths->DataRaw(); + PackedSparseAttentionIndexerCsaUpdateProgram update{ + rotary.batched, kv_buffer_aliases, gate_buffer_aliases, state_lengths_aliases}; + update.CacheHint(rotary.batched, kv_buffer_aliases, gate_buffer_aliases, state_lengths_aliases) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{key, ProgramTensorMetadataDependency::Type}, {gate, ProgramTensorMetadataDependency::Type}, @@ -976,13 +1015,19 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont {sin_cache, ProgramTensorMetadataDependency::Type}, {position_bias, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, - {past_seqlens, ProgramTensorMetadataDependency::Type}, - {past_kv_buffer, ProgramTensorMetadataDependency::Type}, - {past_gate_buffer, ProgramTensorMetadataDependency::Type}}) - .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) - .AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, - {present_kv_buffer, ProgramTensorMetadataDependency::Type}, - {present_gate_buffer, ProgramTensorMetadataDependency::Type}}) + {past_seqlens, ProgramTensorMetadataDependency::Type}}); + if (!kv_buffer_aliases) { + update.AddInput({past_kv_buffer, ProgramTensorMetadataDependency::Type}); + } + if (!gate_buffer_aliases) { + update.AddInput({past_gate_buffer, ProgramTensorMetadataDependency::Type}); + } + if (!state_lengths_aliases) { + update.AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}); + } + update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, + {present_kv_buffer, ProgramTensorMetadataDependency::Type}, + {present_gate_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32(batch_size)) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index 7be03c2a75310..1fafc10110861 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -31,8 +31,12 @@ class PackedSparseAttentionIndexerCopyProgram final class PackedSparseAttentionIndexerQsaUpdateProgram final : public Program { public: - explicit PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched) - : Program{"PackedSparseAttentionIndexerQsaUpdate"}, cos_cache_batched_{cos_cache_batched} {} + PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched, bool kv_buffer_aliases, + bool state_lengths_aliases) + : Program{"PackedSparseAttentionIndexerQsaUpdate"}, + cos_cache_batched_{cos_cache_batched}, + kv_buffer_aliases_{kv_buffer_aliases}, + state_lengths_aliases_{state_lengths_aliases} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, @@ -47,6 +51,8 @@ class PackedSparseAttentionIndexerQsaUpdateProgram final private: bool cos_cache_batched_; + bool kv_buffer_aliases_; + bool state_lengths_aliases_; }; // One invocation per query token: rotates the query, scores it against every causally visible @@ -84,8 +90,13 @@ class PackedSparseAttentionIndexerQsaSelectProgram final class PackedSparseAttentionIndexerCsaUpdateProgram final : public Program { public: - explicit PackedSparseAttentionIndexerCsaUpdateProgram(bool cos_cache_batched) - : Program{"PackedSparseAttentionIndexerCsaUpdate"}, cos_cache_batched_{cos_cache_batched} {} + PackedSparseAttentionIndexerCsaUpdateProgram(bool cos_cache_batched, bool kv_buffer_aliases, + bool gate_buffer_aliases, bool state_lengths_aliases) + : Program{"PackedSparseAttentionIndexerCsaUpdate"}, + cos_cache_batched_{cos_cache_batched}, + kv_buffer_aliases_{kv_buffer_aliases}, + gate_buffer_aliases_{gate_buffer_aliases}, + state_lengths_aliases_{state_lengths_aliases} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, @@ -100,6 +111,9 @@ class PackedSparseAttentionIndexerCsaUpdateProgram final private: bool cos_cache_batched_; + bool kv_buffer_aliases_; + bool gate_buffer_aliases_; + bool state_lengths_aliases_; }; // One invocation per query token: rotates the query, scores it against every causally visible diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index ae35cd73f11fe..dce092fd0a7b8 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "core/graph/constants.h" @@ -2395,6 +2396,11 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference fail_shape_inference("PackedSparseAttentionIndexer: exactly ", psai::kFixedOutputCount, " declared outputs are required, got ", ctx.getNumOutputs()); } + if (ctx.hasOutput(psai::kPresentGateBuffer) == is_qsa) { + fail_shape_inference("PackedSparseAttentionIndexer: output ", psai::kPresentGateBuffer, + is_qsa ? " must be omitted when policy_mode is 'qsa'" + : " is required when policy_mode is 'csa'"); + } updateOutputElemType(ctx, psai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); updateOutputElemType(ctx, psai::kSelectedCounts, ONNX_NAMESPACE::TensorProto_DataType_INT32); @@ -2407,20 +2413,156 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference propagateElemTypeFromInputToOutput(ctx, psai::kPastGateBuffer, psai::kPresentGateBuffer); } - (void)PackedSparseAttentionIndexerShape(ctx, psai::kKeyNormWeight, 1); - (void)PackedSparseAttentionIndexerShape(ctx, psai::kKey, 2); - (void)PackedSparseAttentionIndexerShape(ctx, psai::kCumulativeSequenceLengths, 1); - (void)PackedSparseAttentionIndexerShape(ctx, psai::kPastSequenceLengths, 1); + const auto* query_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQuery, 3); + const auto* key_shape = PackedSparseAttentionIndexerShape(ctx, psai::kKey, 2); + const auto* norm_shape = PackedSparseAttentionIndexerShape(ctx, psai::kKeyNormWeight, 1); + const auto* cumulative_shape = + PackedSparseAttentionIndexerShape(ctx, psai::kCumulativeSequenceLengths, 1); + const auto* past_sequence_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastSequenceLengths, 1); + const ONNX_NAMESPACE::TensorShapeProto* cos_shape = nullptr; + const ONNX_NAMESPACE::TensorShapeProto* sin_shape = nullptr; + for (const auto [index, name, shape_out] : + {std::tuple{ + psai::kCosCache, "cos_cache", &cos_shape}, + {psai::kSinCache, "sin_cache", &sin_shape}}) { + if (hasInputShape(ctx, index)) { + const auto& shape = getInputShape(ctx, index); + if (shape.dim_size() != 2 && shape.dim_size() != 3) { + fail_shape_inference("PackedSparseAttentionIndexer: ", name, " must have rank 2 or 3, got rank ", + shape.dim_size()); + } + *shape_out = &shape; + } + } + const auto* key_state_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKeyState, 3); + const auto* kv_buffer_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKvBuffer, 3); + const auto* state_lengths_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastStateLengths, 2); + const ONNX_NAMESPACE::TensorShapeProto* gate_shape = nullptr; + const ONNX_NAMESPACE::TensorShapeProto* position_bias_shape = nullptr; + const ONNX_NAMESPACE::TensorShapeProto* head_weights_shape = nullptr; + const ONNX_NAMESPACE::TensorShapeProto* gate_buffer_shape = nullptr; if (!is_qsa) { - (void)PackedSparseAttentionIndexerShape(ctx, psai::kGate, 2); - (void)PackedSparseAttentionIndexerShape(ctx, psai::kPositionBias, 2); - (void)PackedSparseAttentionIndexerShape(ctx, psai::kHeadWeights, 2); + gate_shape = PackedSparseAttentionIndexerShape(ctx, psai::kGate, 2); + position_bias_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPositionBias, 2); + head_weights_shape = PackedSparseAttentionIndexerShape(ctx, psai::kHeadWeights, 2); + gate_buffer_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastGateBuffer, 3); } + const ONNX_NAMESPACE::TensorShapeProto* position_ids_shape = nullptr; if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { - (void)PackedSparseAttentionIndexerShape(ctx, psai::kPositionIds, 1); + position_ids_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPositionIds, 1); + } + + auto require_equal_dims = [](const ONNX_NAMESPACE::TensorShapeProto* lhs, int lhs_index, + const ONNX_NAMESPACE::TensorShapeProto* rhs, int rhs_index, + const char* description) { + if (lhs != nullptr && rhs != nullptr && lhs->dim(lhs_index).has_dim_value() && + rhs->dim(rhs_index).has_dim_value() && + lhs->dim(lhs_index).dim_value() != rhs->dim(rhs_index).dim_value()) { + fail_shape_inference("PackedSparseAttentionIndexer: ", description); + } + }; + auto require_dim_value = [](const ONNX_NAMESPACE::TensorShapeProto* shape, int index, int64_t expected, + const char* description) { + if (shape != nullptr && shape->dim(index).has_dim_value() && shape->dim(index).dim_value() != expected) { + fail_shape_inference("PackedSparseAttentionIndexer: ", description, " (", expected, "), got ", + shape->dim(index).dim_value()); + } + }; + + const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio); + require_equal_dims(key_shape, 0, query_shape, 0, "key dimension 0 must equal query dimension 0"); + require_equal_dims(norm_shape, 0, query_shape, 2, "key_norm_weight dimension 0 must equal head_size"); + require_equal_dims(past_sequence_shape, 0, key_state_shape, 0, + "past_sequence_lengths dimension 0 must equal the state batch dimension"); + require_equal_dims(key_state_shape, 0, kv_buffer_shape, 0, + "past_key_state and past_kv_buffer batch dimensions must match"); + require_equal_dims(state_lengths_shape, 0, key_state_shape, 0, + "past_state_lengths dimension 0 must equal the state batch dimension"); + require_equal_dims(key_state_shape, 2, query_shape, 2, + "past_key_state dimension 2 must equal head_size"); + require_dim_value(key_state_shape, 1, state_capacity, "past_key_state dimension 1 must equal state_capacity"); + require_dim_value(kv_buffer_shape, 1, buffer_capacity, + "past_kv_buffer dimension 1 must equal 2 * compress_ratio - 1"); + require_dim_value(state_lengths_shape, 1, psai::kStateLengthColumns, + "past_state_lengths dimension 1 must equal 2"); + if (cumulative_shape != nullptr && past_sequence_shape != nullptr && + cumulative_shape->dim(0).has_dim_value() && past_sequence_shape->dim(0).has_dim_value() && + cumulative_shape->dim(0).dim_value() != past_sequence_shape->dim(0).dim_value() + 1) { + fail_shape_inference( + "PackedSparseAttentionIndexer: cumulative_sequence_lengths dimension 0 must equal batch_size + 1"); + } + + if (query_shape != nullptr && query_shape->dim(2).has_dim_value()) { + const int64_t head_size = query_shape->dim(2).dim_value(); + if (head_size <= 0) { + fail_shape_inference("PackedSparseAttentionIndexer: head_size must be > 0, got ", head_size); + } + if (!is_qsa && head_size > std::numeric_limits::max() / 2) { + fail_shape_inference("PackedSparseAttentionIndexer: 2 * head_size exceeds INT64_MAX"); + } + const int64_t width = is_qsa ? head_size : 2 * head_size; + require_dim_value(key_shape, 1, width, "key dimension 1 must equal the policy-specific width"); + require_dim_value(kv_buffer_shape, 2, width, + "past_kv_buffer dimension 2 must equal the policy-specific width"); + if (!is_qsa) { + require_dim_value(gate_shape, 1, width, "gate dimension 1 must equal 2 * head_size"); + require_dim_value(position_bias_shape, 1, width, "position_bias dimension 1 must equal 2 * head_size"); + require_dim_value(gate_buffer_shape, 2, width, "past_gate_buffer dimension 2 must equal 2 * head_size"); + } + } + if (!is_qsa) { + require_equal_dims(gate_shape, 0, query_shape, 0, "gate dimension 0 must equal query dimension 0"); + require_equal_dims(head_weights_shape, 0, query_shape, 0, + "head_weights dimension 0 must equal query dimension 0"); + require_equal_dims(head_weights_shape, 1, query_shape, 1, + "head_weights dimension 1 must equal num_heads"); + require_dim_value(position_bias_shape, 0, compress_ratio, + "position_bias dimension 0 must equal compress_ratio"); + require_equal_dims(gate_buffer_shape, 0, kv_buffer_shape, 0, + "past_gate_buffer and past_kv_buffer batch dimensions must match"); + require_equal_dims(gate_buffer_shape, 1, kv_buffer_shape, 1, + "past_gate_buffer and past_kv_buffer capacities must match"); + require_equal_dims(gate_buffer_shape, 2, kv_buffer_shape, 2, + "past_gate_buffer and past_kv_buffer widths must match"); + } + require_equal_dims(position_ids_shape, 0, query_shape, 0, + "position_ids dimension 0 must equal query dimension 0"); + if (cos_shape != nullptr && sin_shape != nullptr) { + if (cos_shape->dim_size() != sin_shape->dim_size()) { + fail_shape_inference("PackedSparseAttentionIndexer: cos_cache and sin_cache ranks must match"); + } + for (int i = 0; i < cos_shape->dim_size(); ++i) { + require_equal_dims(cos_shape, i, sin_shape, i, "cos_cache and sin_cache dimensions must match"); + } + } + for (const auto* cache_shape : {cos_shape, sin_shape}) { + if (cache_shape == nullptr) { + continue; + } + if (cache_shape->dim_size() == 3) { + require_equal_dims(cache_shape, 0, past_sequence_shape, 0, + "batched rotary cache dimension 0 must equal batch_size"); + } + const int position_dim = cache_shape->dim_size() - 2; + const int rotary_dim = cache_shape->dim_size() - 1; + if (cache_shape->dim(position_dim).has_dim_value() && cache_shape->dim(position_dim).dim_value() <= 0) { + fail_shape_inference("PackedSparseAttentionIndexer: rotary cache max_position must be > 0"); + } + if (cache_shape->dim(rotary_dim).has_dim_value()) { + const int64_t rotary_width = cache_shape->dim(rotary_dim).dim_value(); + if (rotary_width <= 0 || (is_qsa && rotary_width % 2 != 0)) { + fail_shape_inference("PackedSparseAttentionIndexer: invalid rotary cache width ", rotary_width); + } + if (query_shape != nullptr && query_shape->dim(2).has_dim_value()) { + const int64_t head_size = query_shape->dim(2).dim_value(); + if ((is_qsa && rotary_width > head_size) || + (!is_qsa && rotary_width > head_size / 2)) { + fail_shape_inference("PackedSparseAttentionIndexer: rotary cache width is incompatible with head_size"); + } + } + } } - const auto* query_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQuery, 3); if (query_shape != nullptr) { const auto& total_tokens_dim = query_shape->dim(0); const auto& num_heads_dim = query_shape->dim(1); @@ -2440,22 +2582,15 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference } // State never grows: present_* always has exactly the same fixed shape as past_*. - const auto* key_state_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKeyState, 3); if (key_state_shape != nullptr) { - if (key_state_shape->dim(1).has_dim_value() && key_state_shape->dim(1).dim_value() != state_capacity) { - fail_shape_inference("PackedSparseAttentionIndexer: past_key_state dimension 1 must equal state_capacity (", - state_capacity, "), got ", key_state_shape->dim(1).dim_value()); - } updateOutputShape(ctx, psai::kPresentKeyState, *key_state_shape); } - const auto* kv_buffer_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastKvBuffer, 3); if (kv_buffer_shape != nullptr) { updateOutputShape(ctx, psai::kPresentKvBuffer, *kv_buffer_shape); - if (!is_qsa) { - updateOutputShape(ctx, psai::kPresentGateBuffer, *kv_buffer_shape); - } } - const auto* state_lengths_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastStateLengths, 2); + if (gate_buffer_shape != nullptr) { + updateOutputShape(ctx, psai::kPresentGateBuffer, *gate_buffer_shape); + } if (state_lengths_shape != nullptr) { updateOutputShape(ctx, psai::kPresentStateLengths, *state_lengths_shape); } diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 53bccaa1f83e3..ba7c3baf2fca3 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -93,9 +93,13 @@ struct GraphOptions { int64_t state_capacity = 6; int64_t input_state_capacity = -1; int64_t token_budget = 4; + int64_t key_total_tokens = -1; + int64_t kv_buffer_capacity = -1; + int64_t gate_buffer_width = -1; bool add_index_topk = false; bool add_csa_inputs = false; bool add_position_ids = false; + bool invert_gate_output_presence = false; int output_count = psai::kFixedOutputCount; std::string policy_mode = psai::kPolicyModeQsa; }; @@ -113,7 +117,8 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { std::vector inputs{ builder.MakeInput( std::vector{options.total_tokens, options.num_heads, options.head_size}), - builder.MakeInput(std::vector{options.total_tokens, width}), + builder.MakeInput( + std::vector{options.key_total_tokens >= 0 ? options.key_total_tokens : options.total_tokens, width}), builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{64, options.rotary_width}), builder.MakeInput(std::vector{64, options.rotary_width}), @@ -139,9 +144,12 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { inputs.push_back( builder.MakeInput(std::vector{options.batch_size, input_state_capacity, options.head_size})); inputs.push_back( - builder.MakeInput(std::vector{options.batch_size, buffer_capacity, width})); + builder.MakeInput(std::vector{ + options.batch_size, options.kv_buffer_capacity >= 0 ? options.kv_buffer_capacity : buffer_capacity, width})); if (is_csa) { - inputs.push_back(builder.MakeInput(std::vector{options.batch_size, buffer_capacity, width})); + inputs.push_back(builder.MakeInput( + std::vector{options.batch_size, buffer_capacity, + options.gate_buffer_width >= 0 ? options.gate_buffer_width : width})); } else { inputs.push_back(&empty); } @@ -149,7 +157,8 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { std::vector outputs; for (int i = 0; i < options.output_count; ++i) { - outputs.push_back(i == psai::kPresentGateBuffer && !is_csa ? &empty : builder.MakeOutput()); + const bool gate_output_present = is_csa != options.invert_gate_output_presence; + outputs.push_back(i == psai::kPresentGateBuffer && !gate_output_present ? &empty : builder.MakeOutput()); } Node& node = builder.AddNode("PackedSparseAttentionIndexer", inputs, outputs, kMSDomain); node.AddAttribute("policy_mode", options.policy_mode); @@ -228,6 +237,43 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsStateCapacityShapeMi "past_key_state dimension 1 must equal state_capacity"); } +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaGateOutput) { + GraphOptions options; + options.invert_gate_output_presence = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "must be omitted when policy_mode is 'qsa'"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingGateOutput) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + options.invert_gate_output_presence = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "is required when policy_mode is 'csa'"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsMismatchedKeyTokenCount) { + GraphOptions options; + options.key_total_tokens = options.total_tokens + 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "key dimension 0 must equal query dimension 0"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsWrongKvBufferCapacity) { + GraphOptions options; + options.kv_buffer_capacity = BufferCapacity(options.compress_ratio) + 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "past_kv_buffer dimension 1 must equal 2 * compress_ratio - 1"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsMismatchedCsaGateBufferWidth) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + options.gate_buffer_width = 2 * options.head_size + 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "past_gate_buffer dimension 2 must equal 2 * head_size"); +} + TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsUnknownPolicyMode) { GraphOptions options; options.policy_mode = "qsa_v2"; @@ -395,6 +441,20 @@ std::vector RoundTrip(const std::vector& data) { } } +template +std::vector CopyTensorToFloat(const Tensor& tensor) { + const auto values = tensor.DataAsSpan(); + std::vector result(values.size()); + for (size_t i = 0; i < values.size(); ++i) { + if constexpr (std::is_same_v) { + result[i] = values[i]; + } else { + result[i] = values[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) { @@ -645,7 +705,7 @@ QsaPackedProblem MakeQsaPackedProblem(QsaPackedProblem problem = {}) { template void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedProblem(), - ProviderKind provider_kind = ProviderKind::Cuda) { + ProviderKind provider_kind = ProviderKind::Cuda, QsaPackedResult* actual = nullptr) { auto provider = CreateProvider(provider_kind); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") @@ -703,6 +763,13 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP test.AddOptionalOutputEdge(); // present_gate_buffer test.AddOutput("present_state_lengths", {batch_size, 2}, expected.present_state_lengths); RunOnProvider(test, std::move(provider)); + if (actual != nullptr) { + const auto& fetches = test.GetFetches(); + actual->present_key_state = CopyTensorToFloat(fetches[2].Get()); + actual->present_kv_buffer = CopyTensorToFloat(fetches[3].Get()); + actual->present_state_lengths.assign(fetches[4].Get().DataAsSpan().begin(), + fetches[4].Get().DataAsSpan().end()); + } } } // namespace @@ -713,13 +780,26 @@ TEST(PackedSparseAttentionIndexerTest, QsaFloat16) { RunQsaPackedTest TEST(PackedSparseAttentionIndexerTest, QsaBFloat16) { RunQsaPackedTest(2.0e-2f); } -// Prefill followed by decode: request 0 continues an existing 3-token history, request 1 starts -// fresh; the two requests keep independent block/tail state. TEST(PackedSparseAttentionIndexerTest, QsaPrefillThenDecodeIndependentState) { - QsaPackedProblem problem; - problem.cumulative_sequence_lengths = {0, 1, 4}; - problem.past_sequence_lengths = {5, 0}; - RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); + if (DefaultCudaExecutionProvider() == nullptr) { + GTEST_SKIP() << "CUDA execution provider is not available"; + } + + QsaPackedProblem prefill; + prefill.cumulative_sequence_lengths = {0, 3, 5}; + prefill.past_sequence_lengths = {0, 0}; + QsaPackedResult prefill_outputs; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(prefill)), ProviderKind::Cuda, + &prefill_outputs); + + QsaPackedProblem decode; + decode.cumulative_sequence_lengths = {0, 1, 3}; + decode.past_sequence_lengths = {3, 2}; + decode = MakeQsaPackedProblem(std::move(decode)); + decode.past_key_state = std::move(prefill_outputs.present_key_state); + decode.past_kv_buffer = std::move(prefill_outputs.present_kv_buffer); + decode.past_state_lengths = std::move(prefill_outputs.present_state_lengths); + RunQsaPackedTest(1.0e-5f, std::move(decode)); } // A zero-token request row (repeated cumulative offset) must not affect the other request and @@ -749,6 +829,10 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat16) { + RunQsaPackedTest(2.0e-3f, MakeQsaPackedProblem(), ProviderKind::WebGpu); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateCapacityOverflowIsRejected) { QsaPackedProblem problem; problem.batch_size = 1; @@ -1005,7 +1089,7 @@ CsaPackedProblem MakeCsaPackedProblem(CsaPackedProblem problem = {}) { template void RunCsaPackedTest(const CsaPackedProblem& base, float tolerance, - ProviderKind provider_kind = ProviderKind::Cuda) { + ProviderKind provider_kind = ProviderKind::Cuda, CsaPackedResult* actual = nullptr) { auto provider = CreateProvider(provider_kind); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") @@ -1070,6 +1154,14 @@ void RunCsaPackedTest(const CsaPackedProblem& base, float tolerance, ToElementType(expected.present_gate_buffer), false, 0.0f, tolerance); test.AddOutput("present_state_lengths", {batch_size, 2}, expected.present_state_lengths); RunOnProvider(test, std::move(provider)); + if (actual != nullptr) { + const auto& fetches = test.GetFetches(); + actual->present_key_state = CopyTensorToFloat(fetches[2].Get()); + actual->present_kv_buffer = CopyTensorToFloat(fetches[3].Get()); + actual->present_gate_buffer = CopyTensorToFloat(fetches[4].Get()); + actual->present_state_lengths.assign(fetches[5].Get().DataAsSpan().begin(), + fetches[5].Get().DataAsSpan().end()); + } } } // namespace @@ -1080,13 +1172,28 @@ TEST(PackedSparseAttentionIndexerTest, CsaFloat16) { RunCsaPackedTest TEST(PackedSparseAttentionIndexerTest, CsaBFloat16) { RunCsaPackedTest(MakeCsaPackedProblem(), 3.0e-2f); } -// Prefill followed by decode: continues a previously compressed entry and a partially filled -// buffer for one request, while another request starts fresh. TEST(PackedSparseAttentionIndexerTest, CsaPrefillThenDecodeIndependentState) { - CsaPackedProblem problem; - problem.cumulative_sequence_lengths = {0, 1, 4}; - problem.past_state_lengths = {1, 1, 0, 0}; - RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f); + if (DefaultCudaExecutionProvider() == nullptr) { + GTEST_SKIP() << "CUDA execution provider is not available"; + } + + CsaPackedProblem prefill; + prefill.cumulative_sequence_lengths = {0, 3, 5}; + prefill.past_sequence_lengths = {0, 0}; + CsaPackedResult prefill_outputs; + RunCsaPackedTest(MakeCsaPackedProblem(std::move(prefill)), 1.0e-5f, ProviderKind::Cuda, + &prefill_outputs); + + CsaPackedProblem decode; + decode.cumulative_sequence_lengths = {0, 1, 3}; + decode.past_sequence_lengths = {3, 2}; + decode.position_ids = {3, 2, 3}; + decode = MakeCsaPackedProblem(std::move(decode)); + decode.past_key_state = std::move(prefill_outputs.present_key_state); + decode.past_kv_buffer = std::move(prefill_outputs.present_kv_buffer); + decode.past_gate_buffer = std::move(prefill_outputs.present_gate_buffer); + decode.past_state_lengths = std::move(prefill_outputs.present_state_lengths); + RunCsaPackedTest(decode, 1.0e-5f); } TEST(PackedSparseAttentionIndexerTest, CsaStateCapacityOverflowIsRejected) { @@ -1103,6 +1210,10 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, CsaFloat) { RunCsaPackedTest(MakeCsaPackedProblem(), 1.0e-5f, ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, CsaFloat16) { + RunCsaPackedTest(MakeCsaPackedProblem(), 4.0e-3f, ProviderKind::WebGpu); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, CsaStateCapacityOverflowIsRejected) { CsaPackedProblem problem; problem.batch_size = 1; From ebc6bdfda71ea8c403e07a4d76fb0bf02f628b87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:04:36 +0000 Subject: [PATCH 032/102] Optimize CUDA QSA scoring and selection Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse_attention_indexer.md | 11 +- .../packed_sparse_attention_indexer.md | 5 +- .../packed_sparse_attention_indexer_impl.cu | 104 +++++++++++------- .../sparse_attention_indexer_device_math.cuh | 72 ++++++++++++ .../sparse/sparse_attention_indexer_impl.cu | 99 +++++++++++------ 5 files changed, 208 insertions(+), 83 deletions(-) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index e9238029c3c8f..7dcd24a1ea1ca 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -347,15 +347,14 @@ Run them with: 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`. +1. **Large TopK selection remains `O(topk * entries)` per query row.** The common case of at most + 32 selected blocks uses a single-read block TopK with deterministic score/index ordering. + Larger values retain the repeated-scan fallback to avoid excessive shared memory. 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. +3. **Query rotation is fused into QSA scoring.** This removes the separate rotation launch and the + `B*S*N*D` float workspace, at the cost of recomputing rotation for each scored 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. **`compress_ratio` and `head_size` are not specialized.** Templating the hot kernels on a small diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md index 883a074a657aa..09b5cfea0b974 100644 --- a/docs/contrib_ops/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -260,8 +260,9 @@ mechanics with no packed equivalent): ## 10. Known limitations and follow-ups - The reference CUDA/WebGPU kernels prioritize correctness over throughput (see the top-of-file - comments in the `.cu`/`.cc` implementations); they are not yet tuned for large `state_capacity` - or long packed batches. + comments in the `.cu`/`.cc` implementations). CUDA QSA fuses query rotation into scoring and + uses a single-read deterministic block TopK for up to 32 selected blocks; larger TopK values + retain the correctness-first repeated-scan fallback. - OgaEngine / Model Builder integration (declaring `past_key_state` etc. as Engine-managed, per-request fixed-size state, analogous to a paged auxiliary cache) is out of scope for this operator definition and is expected in a follow-up to `microsoft/onnxruntime-genai`. diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index f2bcc3f29c2bf..741073df7fe2d 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -250,12 +250,15 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* cos_cache, cons // present_key_state entry (unlike the dense op, no per-query pooling/normalize/rotate is repeated // here because the packed contract stores fully-prepared blocks in key_state). template -__global__ void QsaBlockScoreKernel(const T* present_key_state, const float* query_rotated, +__global__ void QsaBlockScoreKernel(const T* query, const T* cos_cache, const T* sin_cache, + const T* present_key_state, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, const int32_t* present_state_lengths, float* block_scores, PackedSparseAttentionIndexerParams params) { - extern __shared__ float reduction[]; + extern __shared__ float shared[]; + float* query_head = shared; + float* reduction = shared + params.head_size; const int64_t total = static_cast(params.total_tokens) * params.state_capacity; for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { const int token = static_cast(work / params.state_capacity); @@ -284,13 +287,24 @@ __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* que } const int64_t key_base = (static_cast(batch) * params.state_capacity + block_index) * params.head_size; + const int position = SaiClampPosition(abs_position, params.max_rotary_length); + const int64_t cache_offset = + (static_cast(params.cos_cache_batched ? batch : 0) * params.max_rotary_length + position) * + params.rotary_width; float score = 0.0f; for (int head = 0; head < params.num_heads; ++head) { - const float* query_head = - query_rotated + (static_cast(token) * params.num_heads + head) * params.head_size; + const int64_t query_base = + (static_cast(token) * params.num_heads + head) * params.head_size; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + query_head[d] = to_float(query[query_base + d]); + } + __syncthreads(); + float partial = 0.0f; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - partial += query_head[d] * to_float(present_key_state[key_base + d]); + partial += SaiLeadingRope(query_head, params.rotary_width, cos_cache + cache_offset, + sin_cache + cache_offset, d) * + to_float(present_key_state[key_base + d]); } score += fmaxf(SaiBlockSum(partial, reduction), 0.0f); } @@ -312,7 +326,10 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula PackedSparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* shared_value = shared; - int* shared_index = reinterpret_cast(shared + blockDim.x); + const bool use_fast_topk = params.block_topk <= kSaiFastTopKMax; + const int shared_entries = use_fast_topk ? static_cast(blockDim.x) * params.block_topk + : static_cast(blockDim.x); + int* shared_index = reinterpret_cast(shared + shared_entries); for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { int32_t* out_row = selected_indices + static_cast(token) * params.capacity; @@ -342,28 +359,42 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula const float* scores_row = block_scores + static_cast(token) * params.state_capacity; - float previous_score = 0.0f; - int previous_index = -1; - int emitted_blocks = 0; - for (int rank = 0; rank < selected; ++rank) { - float best_value = 0.0f; - int best_index = -1; - SaiScanForNext(scores_row, visible_block_count, previous_score, previous_index, &best_value, &best_index); - shared_value[threadIdx.x] = best_value; - shared_index[threadIdx.x] = best_index; - __syncthreads(); - SaiBlockArgMax(shared_value, shared_index); - previous_index = shared_index[0]; - previous_score = shared_value[0]; - __syncthreads(); - if (previous_index < 0) { - break; - } - for (int t = static_cast(threadIdx.x); t < params.compress_ratio; t += static_cast(blockDim.x)) { - out_row[rank * params.compress_ratio + t] = previous_index * params.compress_ratio + t; + int emitted_blocks = selected; + if (selected > 0 && use_fast_topk) { + SaiBlockTopK(scores_row, visible_block_count, selected, shared_value, shared_index); + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = shared_index[rank]; + for (int t = static_cast(threadIdx.x); t < params.compress_ratio; + t += static_cast(blockDim.x)) { + out_row[rank * params.compress_ratio + t] = selected_block * params.compress_ratio + t; + } } - emitted_blocks = rank + 1; __syncthreads(); + } else { + float previous_score = 0.0f; + int previous_index = -1; + emitted_blocks = 0; + for (int rank = 0; rank < selected; ++rank) { + float best_value = 0.0f; + int best_index = -1; + SaiScanForNext(scores_row, visible_block_count, previous_score, previous_index, &best_value, &best_index); + shared_value[threadIdx.x] = best_value; + shared_index[threadIdx.x] = best_index; + __syncthreads(); + SaiBlockArgMax(shared_value, shared_index); + previous_index = shared_index[0]; + previous_score = shared_value[0]; + __syncthreads(); + if (previous_index < 0) { + break; + } + for (int t = static_cast(threadIdx.x); t < params.compress_ratio; + t += static_cast(blockDim.x)) { + out_row[rank * params.compress_ratio + t] = previous_index * params.compress_ratio + t; + } + emitted_blocks = rank + 1; + __syncthreads(); + } } // The trailing incomplete block is always causally visible in full up to this query's own @@ -714,7 +745,7 @@ __global__ void CsaSelectKernel(const float* scores, const int32_t* cumulative_s size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { const size_t rows = static_cast(params.total_tokens); - return rows * params.num_heads * params.head_size + rows * static_cast(std::max(params.state_capacity, 1)); + return rows * static_cast(std::max(params.state_capacity, 1)); } size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { @@ -763,26 +794,21 @@ Status LaunchQsaPackedSparseAttentionIndexer( return CUDA_CALL(cudaGetLastError()); } - float* query_rotated = float_workspace; - float* block_scores = - float_workspace + static_cast(params.total_tokens) * params.num_heads * params.head_size; - - const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; - const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); - PackedRotateQueryKernel<<>>( - query, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, - params); + float* block_scores = float_workspace; if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); - QsaBlockScoreKernel<<>>( - present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + QsaBlockScoreKernel<<>>( + query, cos_cache, sin_cache, present_key_state, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, block_scores, params); } const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); - QsaSelectKernel<<>>( + const int topk_shared_entries = + params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; + QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( block_scores, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, overflow_flags, selected_indices, selected_counts, params); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh index de7c2d3b323ad..d09407c4b874c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh @@ -25,6 +25,7 @@ namespace cuda { namespace { constexpr int64_t kSaiMaxGridDimX = 2147483647; +constexpr int kSaiFastTopKMax = 32; __device__ __forceinline__ float SaiNegativeInfinity() { return -CUDART_INF_F; } @@ -94,6 +95,77 @@ __device__ __forceinline__ void SaiScanForNext(const float* scores, int count, f } } +__device__ __forceinline__ bool SaiTopKBetter(float lhs_value, int lhs_index, float rhs_value, int rhs_index) { + return lhs_index >= 0 && + (rhs_index < 0 || lhs_value > rhs_value || (lhs_value == rhs_value && lhs_index < rhs_index)); +} + +// Finds up to kSaiFastTopKMax entries with one global read of each score. Each thread first keeps +// a sorted local list, then adjacent threads merge their lists in shared memory. The final list is +// ordered by score descending and index ascending, matching SaiScanForNext/SaiBlockArgMax. +__device__ __forceinline__ void SaiBlockTopK(const float* scores, int count, int top_k, + float* shared_value, int* shared_index) { + float local_value[kSaiFastTopKMax]; + int local_index[kSaiFastTopKMax]; + for (int rank = 0; rank < top_k; ++rank) { + local_value[rank] = 0.0f; + local_index[rank] = -1; + } + + for (int candidate = static_cast(threadIdx.x); candidate < count; + candidate += static_cast(blockDim.x)) { + const float value = scores[candidate]; + if (!SaiTopKBetter(value, candidate, local_value[top_k - 1], local_index[top_k - 1])) { + continue; + } + int rank = top_k - 1; + while (rank > 0 && SaiTopKBetter(value, candidate, local_value[rank - 1], local_index[rank - 1])) { + local_value[rank] = local_value[rank - 1]; + local_index[rank] = local_index[rank - 1]; + --rank; + } + local_value[rank] = value; + local_index[rank] = candidate; + } + + const int thread_offset = static_cast(threadIdx.x) * top_k; + for (int rank = 0; rank < top_k; ++rank) { + shared_value[thread_offset + rank] = local_value[rank]; + shared_index[thread_offset + rank] = local_index[rank]; + } + __syncthreads(); + + for (int stride = 1; stride < static_cast(blockDim.x); stride <<= 1) { + if ((static_cast(threadIdx.x) % (2 * stride)) == 0) { + const int right_offset = (static_cast(threadIdx.x) + stride) * top_k; + for (int rank = 0; rank < top_k; ++rank) { + local_value[rank] = shared_value[thread_offset + rank]; + local_index[rank] = shared_index[thread_offset + rank]; + } + + int left_rank = 0; + int right_rank = 0; + for (int rank = 0; rank < top_k; ++rank) { + const bool take_right = + right_rank < top_k && + (left_rank == top_k || + SaiTopKBetter(shared_value[right_offset + right_rank], shared_index[right_offset + right_rank], + local_value[left_rank], local_index[left_rank])); + if (take_right) { + shared_value[thread_offset + rank] = shared_value[right_offset + right_rank]; + shared_index[thread_offset + rank] = shared_index[right_offset + right_rank]; + ++right_rank; + } else { + shared_value[thread_offset + rank] = local_value[left_rank]; + shared_index[thread_offset + rank] = local_index[left_rank]; + ++left_rank; + } + } + } + __syncthreads(); + } +} + // --------------------------------------------------------------------------------------------- // Rotary embeddings // --------------------------------------------------------------------------------------------- diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu index dc1bb86955d98..08236674b2721 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -175,14 +175,15 @@ __global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, // 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, +__global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, const int32_t* visible_indices, const int32_t* visible_count, float* block_scores, SparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* pooled = shared; float* rotated = shared + params.head_size; - float* reduction = shared + 2 * params.head_size; + float* query_head = shared + 2 * params.head_size; + float* reduction = shared + 3 * 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) { @@ -233,10 +234,22 @@ __global__ void QsaBlockScoreKernel(const T* present_key, const T* key_norm_weig 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; + const int64_t query_base = (row * params.num_heads + head) * params.head_size; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + query_head[d] = to_float(query[query_base + d]); + } + __syncthreads(); + + const int query_position = ClampPosition( + static_cast(params.past_sequence_length) + row % params.sequence_length, + params.max_rotary_length); + const int64_t query_cache_offset = + (static_cast(batch) * params.max_rotary_length + query_position) * params.rotary_width; float partial = 0.0f; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { - partial += query_head[d] * rotated[d]; + partial += LeadingRope(query_head, params.rotary_width, cos_cache + query_cache_offset, + sin_cache + query_cache_offset, d) * + rotated[d]; } score += fmaxf(BlockSum(partial, reduction), 0.0f); } @@ -255,7 +268,10 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl SparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* shared_value = shared; - int* shared_index = reinterpret_cast(shared + blockDim.x); + const bool use_fast_topk = params.block_topk <= kSaiFastTopKMax; + const int shared_entries = use_fast_topk ? static_cast(blockDim.x) * params.block_topk + : static_cast(blockDim.x); + int* shared_index = reinterpret_cast(shared + shared_entries); const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { @@ -271,28 +287,41 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl 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]; + int emitted = selected; + if (selected > 0 && use_fast_topk) { + SaiBlockTopK(scores_row, block_count, selected, shared_value, shared_index); + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = shared_index[rank]; + for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { + out_row[rank * params.compress_ratio + t] = + index_row[selected_block * params.compress_ratio + t]; + } } - emitted = rank + 1; __syncthreads(); + } else { + float previous_score = 0.0f; + int previous_index = -1; + emitted = 0; + for (int rank = 0; rank < selected; ++rank) { + float best_value = 0.0f; + int best_index = -1; + ScanForNext(scores_row, block_count, previous_score, previous_index, &best_value, &best_index); + shared_value[threadIdx.x] = best_value; + shared_index[threadIdx.x] = best_index; + __syncthreads(); + BlockArgMax(shared_value, shared_index); + previous_index = shared_index[0]; + previous_score = shared_value[0]; + __syncthreads(); + if (previous_index < 0) { + break; + } + for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { + out_row[rank * params.compress_ratio + t] = index_row[previous_index * params.compress_ratio + t]; + } + emitted = rank + 1; + __syncthreads(); + } } const int tail_start = block_count * params.compress_ratio; @@ -530,7 +559,7 @@ __global__ void CsaSelectKernel(const float* scores, const int64_t* position_ids size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { const size_t rows = static_cast(params.batch_size) * params.sequence_length; - return rows * params.num_heads * params.head_size + rows * std::max(params.max_block_count, 1); + return rows * std::max(params.max_block_count, 1); } size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params) { @@ -569,17 +598,12 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio return CUDA_CALL(cudaGetLastError()); } - float* query_rotated = float_workspace; - float* block_scores = float_workspace + rows * params.num_heads * params.head_size; + float* block_scores = float_workspace; 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); @@ -587,12 +611,15 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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, + QsaBlockScoreKernel<<>>( + query, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, block_scores, params); } - QsaSelectKernel<<>>( + const int topk_shared_entries = + params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; + QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( block_scores, visible_indices, visible_count, selected_indices, params); return CUDA_CALL(cudaGetLastError()); From ecabf39ff5f610e735666cba54d335e418b57c43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:05:12 +0000 Subject: [PATCH 033/102] Add single-pass TopK coverage Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/contrib_ops/sparse_attention_indexer_op_test.cc | 9 +++++++++ 1 file changed, 9 insertions(+) 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 65fa85bde696b..89a4a6b29fc5b 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1041,6 +1041,15 @@ TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaSinglePassTopK) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 300; + problem.token_budget = 32; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; From 8ece210cf3df0bc1c6b9ac883b4e2e99b65a2cf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:16:59 +0000 Subject: [PATCH 034/102] Fix packed indexer CI failures Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- onnxruntime/core/graph/contrib_ops/bert_defs.cc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index dce092fd0a7b8..37ce43b089b24 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2396,7 +2396,8 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference fail_shape_inference("PackedSparseAttentionIndexer: exactly ", psai::kFixedOutputCount, " declared outputs are required, got ", ctx.getNumOutputs()); } - if (ctx.hasOutput(psai::kPresentGateBuffer) == is_qsa) { + const bool has_gate_output = ctx.getOutputType(psai::kPresentGateBuffer) != nullptr; + if (has_gate_output == is_qsa) { fail_shape_inference("PackedSparseAttentionIndexer: output ", psai::kPresentGateBuffer, is_qsa ? " must be omitted when policy_mode is 'qsa'" : " is required when policy_mode is 'csa'"); @@ -2421,7 +2422,7 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference const auto* past_sequence_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastSequenceLengths, 1); const ONNX_NAMESPACE::TensorShapeProto* cos_shape = nullptr; const ONNX_NAMESPACE::TensorShapeProto* sin_shape = nullptr; - for (const auto [index, name, shape_out] : + for (const auto& [index, name, shape_out] : {std::tuple{ psai::kCosCache, "cos_cache", &cos_shape}, {psai::kSinCache, "sin_cache", &sin_shape}}) { From 8f506aab48bb1f02e656c4d61e05a7978ccf9816 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:33:43 +0000 Subject: [PATCH 035/102] Address CUDA QSA review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse/sparse_attention_indexer_impl.cu | 6 +++--- .../contrib_ops/sparse_attention_indexer_op_test.cc | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) 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 08236674b2721..92ae849041069 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -182,8 +182,8 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const extern __shared__ float shared[]; float* pooled = shared; float* rotated = shared + params.head_size; - float* query_head = shared + 2 * params.head_size; - float* reduction = shared + 3 * params.head_size; + float* query_head = pooled; + 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) { @@ -611,7 +611,7 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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<<>>( + QsaBlockScoreKernel<<>>( query, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, block_scores, params); } 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 89a4a6b29fc5b..174f5efe3dfbf 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1041,12 +1041,21 @@ TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } -TEST(SparseAttentionIndexerTest, QsaSinglePassTopK) { +TEST(SparseAttentionIndexerTest, QsaSinglePassTopKLimit) { QsaProblem problem; problem.batch_size = 1; problem.sequence_length = 1; problem.past_sequence_length = 300; - problem.token_budget = 32; + problem.token_budget = 64; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + +TEST(SparseAttentionIndexerTest, QsaRepeatedScanTopKFallback) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 300; + problem.token_budget = 66; RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } From d0b6fafab8ca67b9fcfaa7ffe1d44e06b7414ae9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:30:45 +0000 Subject: [PATCH 036/102] Fix packed indexer schema validation Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 138 +++++++++++++----- docs/OperatorKernels.md | 12 +- .../sparse/packed_sparse_attention_indexer.cc | 4 + .../bert/packed_sparse_attention_indexer.cc | 4 + .../core/graph/contrib_ops/bert_defs.cc | 7 - ...packed_sparse_attention_indexer_op_test.cc | 21 +-- 6 files changed, 114 insertions(+), 72 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 64988ff7f73c6..f3d97c1cc4599 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -1813,8 +1813,10 @@ This version of the operator has been available since version 1 of the 'com.micr gate = sigmoid(sign(dot) * sqrt(max(abs(dot), 1e-6))) where dot = sum(RMSNorm(key) * RMSNorm(query)) / sqrt(hidden_size). - The output is gate * value, broadcast across the hyper-connections. The final Engram residual - value + short_conv(value) is then expressed with RMSNorm, CausalConvWithState and Add. + The output is gate * value, broadcast across the hyper-connections. The optional gated_value_normed + output applies RMSNorm to gate * value with conv_norm_scale, which can feed a following + CausalConvWithState. The final Engram residual value + short_conv(value) is then expressed with + RMSNorm, CausalConvWithState and Add. #### Version @@ -1827,7 +1829,7 @@ This version of the operator has been available since version 1 of the 'com.micr
Epsilon used by both RMS normalization steps. Default is 1e-5.
-#### Inputs +#### Inputs (5 - 6)
key : T
@@ -1840,13 +1842,17 @@ This version of the operator has been available since version 1 of the 'com.micr
RMSNorm scale for keys with shape (hc_mult, hidden_size).
query_norm_scale : T
RMSNorm scale for queries with shape (hc_mult, hidden_size).
+
conv_norm_scale (optional) : T
+
Optional RMSNorm scale for the gated value, with shape (hc_mult, hidden_size). Required when gated_value_normed is requested.
-#### Outputs +#### Outputs (1 - 2)
output : T
Gated value tensor with shape (batch_size, sequence_length, hc_mult, hidden_size).
+
gated_value_normed (optional) : T
+
Optional RMS-normalized gated value tensor with shape (batch_size, sequence_length, hc_mult, hidden_size).
#### Type Constraints @@ -2435,13 +2441,22 @@ This version of the operator has been available since version 1 of the 'com.micr GatherBlockQuantized is a Gather with data quantized. It is similar to Gather (https://github.com/onnx/onnx/blob/main/docs/Operators.md#gather) with differences: 1. Input `data` is a constant. It is quantized block-wise along attribute `quantize_axis` with block size specified by attribute `block_size`. `block_size` must be a power of 2 and not smaller than 16, like 16, 32, 64, 128, ... + For an FP8 or FP4 `data` type (see point 6 below), `block_size` may also be 0, meaning the entire `quantize_axis` + dimension forms a single block (i.e. one scale per row). 2. Input `data`'s scale and zero point are specified by input `scales` and `zero_points`. `scales` and `zero_points` are also constants. If `zero_points` is not provided, the default value is 0 for int4/uint4, or 2^(bits-1) for uint8. + `zero_points` must not be provided when `data` is an FP8 or FP4 type: FP8/FP4 quantization is symmetric. 3. During the op execution, `data` and `indices` are first used to generate the quantized output. Then, `scales` and `zero_points` are used to dequantize the output. 4. The `output` and `scales` have the same type. The `data` and `zero_points` have the same type. 5. For uint8 data, the `gather_axis` must be 0. The supported `bits` values for uint8 data are 2, 4, and 8; for `bits` < 8 the values are packed along the last dimension (low-order bits first). + 6. `data` may also be an FP8 type (float8e4m3fn, float8e4m3fnuz, float8e5m2 or float8e5m2fnuz) or an FP4 type + (float4e2m1), rather than an integer block-quantized type. In that case `bits` is ignored, there is + no `zero_points` input, and dequantization is simply `output[...] = float(data[...]) * scales[block_index(...)]`. + On any axis other than `quantize_axis`, the corresponding `scales` dimension must either equal `data`'s + dimension, or be 1, in which case the scale is broadcast along that axis (e.g. a single scale shared by + every row, as with a per-tensor scale applied to an entire embedding table). #### Version @@ -2451,9 +2466,9 @@ This version of the operator has been available since version 1 of the 'com.micr
bits : int
-
Number of bits used for weight quantization. Must be 2, 4 or 8.
+
Number of bits used for weight quantization. Must be 2, 4 or 8. Ignored when `data` is an FP8 or FP4 type.
block_size : int
-
(Optional) block size used for weight quantization. It needs to be a power of 2 and not smaller than 16.
+
(Optional) block size used for weight quantization. It needs to be a power of 2 and not smaller than 16, or 0. A value of 0 is only valid for an FP8 or FP4 `data` type and means the entire `quantize_axis` dimension forms a single block.
gather_axis : int
(Optional) Which axis to gather on. Negative value means counting dimensions from the back. Accepted range is [-r, r-1] where r = rank(data).
quantize_axis : int
@@ -2466,11 +2481,11 @@ This version of the operator has been available since version 1 of the 'com.micr
data : T1
Tensor of rank r >= 1. Block-wise quantized.
indices : Tind
-
Tensor of int32/int64 indices, of any rank q. All index values are expected to be within bounds [-s, s-1] along axis of size s. It is an error if any of the index values are out of bounds.
+
Tensor of int32/int64 indices, of any rank q. Values in [-s, s-1] select elements along an axis of size s. Unlike ONNX Gather, an out-of-range index produces zeros for the corresponding output slice.
scales : T2
-
quantization scale
+
quantization scale. Same rank as data. On axes other than quantize_axis, a dimension of 1 broadcasts the scale along that axis (e.g. a single per-tensor scale for the whole table); only applicable when `data` is an FP8 or FP4 type.
zero_points (optional) : T1
-
quantization zero points
+
quantization zero points. Must not be provided when `data` is an FP8 or FP4 type.
#### Outputs @@ -2483,7 +2498,7 @@ This version of the operator has been available since version 1 of the 'com.micr #### Type Constraints
-
T1 : tensor(int4), tensor(uint4), tensor(uint8)
+
T1 : tensor(int4), tensor(uint4), tensor(uint8), tensor(float8e4m3fn), tensor(float8e4m3fnuz), tensor(float8e5m2), tensor(float8e5m2fnuz), tensor(float4e2m1)
Constrain quantized types.
T2 : tensor(float), tensor(float16), tensor(bfloat16)
Constrain dequantized types.
@@ -2925,6 +2940,25 @@ This version of the operator has been available since version 1 of the 'com.micr **Cache Format:** The past and present KV cache tensors are expected in a BNSH format: `(batch_size, num_heads, cache_sequence_length, head_size)`, where `cache_sequence_length` is the length of the cached key/value sequences, or the maximum sequence length when past and present buffer sharing is used. + **Windowed KV Cache (`sliding_window_cache` attribute):** + When `sliding_window_cache` is 1, the past/present buffers are window-sized instead of full-length and the operator evicts internally. Let `C` be the cache capacity (dimension 2 of `past_key`, which is also the sequence dimension of `present_key`), `W` be `local_window_size`, and `T` be the absolute number of tokens processed so far by this batch entry, i.e. `seqlens_k[b] + 1`. The scalar `total_sequence_length` input is only the batch maximum of `T`; the layout below is per batch entry, so a ragged batch gets a different resident range per entry. `C` must be at least `W`. + + After a step, rows `[0, L)` of `present_key` and `present_value` hold the `L` most recent positions in increasing position order, so row `i` holds absolute position `T - L + i`. The retained positions are always physically contiguous and start at row 0; the layout never wraps around, so a ring-buffer layout cannot be exposed through these outputs. Rows `[L, C)` are unspecified. The resident count `L` is a function of `T` alone: + + ``` + G = C - W + 1 + L(T) = T if T <= C + L(T) = T - G * ceil((T - C) / G) otherwise + ``` + + Hence `min(T, W) <= L(T) <= min(T, C)`: the whole window stays resident, and eviction reclaims `G` positions at once rather than one position per step, so consumers must not assume that the cache is kept full at `min(T, C)`. + + Because `L` depends only on `T`, the resulting layout is independent of how the tokens were split into steps: a multi-token step of `S` tokens (speculative decoding, chunked prefill) leaves exactly the layout that the same tokens would produce one at a time. Any `S >= 1` is accepted, including `S > C`; a step that would evict positions it still has to read is staged internally, so the capacity does not have to cover the step. When past context is present, the existing operator restriction still applies: `sequence_length > 1` requires `batch_size == 1`. + + An execution provider may accept only part of the `C >= W` range. A configuration with `C < W` (equivalently, `W > C`) is invalid and is rejected with `INVALID_ARGUMENT`. The CUDA implementation requires `C == W`, so there `G` is 1 and `L(T)` is `min(T, C)`; a larger capacity is rejected. The CPU implementation accepts any `C >= W`, and slack above the window amortizes compaction over `G` steps. + + To drop the last `k` tokens, for example after rejecting speculative draft tokens, re-run with the smaller `total_sequence_length` and `seqlens_k` and leave the buffer untouched. That is exact when `L(T - k) == L(T) - k`, which callers can evaluate with the formula above. Otherwise the shorter layout needs positions that have already been evicted, and the window has to be re-materialized. + **Quantization:** When quantization is enabled, `past_key` and `past_value` inputs can be of type `float8e4m3fn`, `uint8` or `int8`. The corresponding `k_scale` and `v_scale` tensors must be provided. The operator will output `present_key` and `present_value` in same format as the `past_key` and `past_value`. @@ -2968,7 +3002,7 @@ This version of the operator has been available since version 1 of the 'com.micr
scale : float
Custom scale will be used if specified. Default value is 1/sqrt(head_size)
sliding_window_cache : int
-
Set to 1 when the past/present KV buffers are window-sized instead of holding the whole sequence. The op then keeps only the min(total_sequence_length, cache_capacity) most recent tokens, contiguously, using cache-relative indexing and evicting from the front as needed. Requires local_window_size > 0 and a cache capacity of at least local_window_size. Multi-token steps may use a temporary staging buffer, so the capacity need not cover the entire step. Default value is 0 (full-length cache).
+
Set to 1 when the past/present KV buffers are window-sized instead of holding the whole sequence. The op then evicts internally and indexes the buffers in cache-relative coordinates, keeping the most recent positions contiguously at rows [0, L) with min(T, local_window_size) <= L <= min(T, capacity), where T is seqlens_k[b] + 1 for that batch entry. Requires local_window_size > 0 and a cache capacity of at least local_window_size; a smaller capacity (W > C) is rejected with INVALID_ARGUMENT. The CUDA implementation additionally requires the capacity to equal local_window_size. Multi-token steps of any length are supported and produce the same layout as single-token steps, so the capacity need not cover the entire step. When past context is present, sequence_length > 1 requires batch_size == 1. See the Windowed KV Cache section of the operator description for the exact resident-range, eviction and rollback contract. Default value is 0 (full-length cache).
smooth_softmax : int
Use a smooth factor in softmax.
softcap : float
@@ -2987,9 +3021,9 @@ This version of the operator has been available since version 1 of the 'com.micr
value (optional) : T
Value with shape (batch_size, kv_sequence_length, kv_hidden_size)
past_key (optional) : T_CACHE
-
past state key with support for format BNSH. When past_key uses same tensor as present_key(k-v cache), it is of length max_sequence_length... otherwise of length past_sequence_length.
+
past state key with support for format BNSH. When past_key uses same tensor as present_key(k-v cache), it is of length max_sequence_length... otherwise of length past_sequence_length. When sliding_window_cache is 1 this length is the window cache capacity C, which is chosen by the caller independently of the sequence length and must be at least local_window_size.
past_value (optional) : T_CACHE
-
past state value with support for format BNSH. When past_value uses same tensor as present_value(k-v cache), it is of length max_sequence_length... otherwise of length past_sequence_length.
+
past state value with support for format BNSH. When past_value uses same tensor as present_value(k-v cache), it is of length max_sequence_length... otherwise of length past_sequence_length. When sliding_window_cache is 1 this length is the window cache capacity C, which is chosen by the caller independently of the sequence length and must be at least local_window_size.
seqlens_k : M
1D Tensor of shape (batch_size). Equivalent to (total_sequence_lengths - 1).
total_sequence_length : M
@@ -3001,7 +3035,7 @@ This version of the operator has been available since version 1 of the 'com.micr
position_ids (optional) : tensor(int64)
2D tensor with shape (batch_size, sequence_length). When processing the first prompt the kernel uses only the first element
attention_bias (optional) : T
-
additional add to QxK' with shape (batch_size or 1, num_heads or 1, sequence_length, total_sequence_length)
+
additional add to QxK' with shape (batch_size or 1, num_heads or 1, sequence_length, total_sequence_length). The last dimension is indexed by absolute key position and stays total_sequence_length when sliding_window_cache is 1: it is not reduced to the cache capacity or to local_window_size. The operator reads the columns of the positions that are resident in the cache and ignores the rest. CPU supports this windowed absolute-column indexing; CUDA rejects attention_bias when sliding_window_cache is 1.
head_sink (optional) : T
1D tensor with shape (num_heads). Each head has a smooth factor adding to the denominator of softmax.
k_scale (optional) : T_KV_SCALE
@@ -3441,18 +3475,18 @@ This version of the operator has been available since version 1 of the 'com.micr ### **com.microsoft.MatMulBlockQuantizedFp8Weight** - Weight-only block-scaled FP8 (E4M3) matrix multiplication. + Block-scaled FP8 (E4M3) matrix multiplication with optional FP8 activation quantization. - The weight tensor B is FP8 E4M3 of shape [N, K] with one FP32 scale per `block_size` consecutive - K values (`b_scale` of shape [N, ceil(K / block_size)]). The dequantized weight value is - `fp8_e4m3(B[n, k]) * b_scale[n, k / block_size]`. The weight is dequantized to the activation - type (FP16/BF16) and multiplied with the FP16/BF16 activation A. This path is architecture - independent and runs on any CUDA architecture (SM80+). + The weight tensor B has shape [N, K] with one FP32 scale per `block_size` consecutive K values + (`b_scale` of shape [N, ceil(K / block_size)]). The scaled weight value is + `B_scaled[n, k] = fp8_e4m3(B[n, k]) * b_scale[n, k / block_size]`. - When the optional `a_scale` (a single fp32 scalar) is provided, the activation A is statically - quantized to FP8 E4M3 and dequantized back (`a_deq = fp8_e4m3(A / a_scale) * a_scale`) before the - matmul, realizing W8A8 activation numerics. When `a_scale` is omitted the activation is kept at - full FP16/BF16 precision (weight-only W8A16). + When the optional scalar `a_scale` is provided, the activation values used in the multiplication + are `A_scaled = fp8_e4m3(A / a_scale) * a_scale` (W8A8). Otherwise, A retains its FP16/BF16 + precision (weight-only W8A16). + + The operator multiplies the activation by the transpose of B_scaled and adds the optional bias. + The output has shape [..., N] and the same element type as A. #### Version @@ -3475,7 +3509,7 @@ This version of the operator has been available since version 1 of the 'com.micr
b_scale : T2
Per-block FP32 weight scales of shape [N, ceil(K / block_size)].
a_scale (optional) : T2
-
Optional global fp32 activation scale (scalar). When present, A is statically quantized to FP8 E4M3 with this scale and dequantized back before the matmul (W8A8 numerics); when absent, A stays in full FP16/BF16 precision.
+
Optional global fp32 activation scale (scalar). When present, A is statically quantized to FP8 E4M3 with this scale (W8A8 numerics); when absent, A retains its FP16/BF16 precision.
bias (optional) : T
Optional bias of shape [N].
@@ -4282,12 +4316,26 @@ This version of the operator has been available since version 1 of the 'com.micr across invocations (chunked prefill or autoregressive decode), the optional past_ids input carries those preceding ids and present_ids returns the ids to pass to the next call. Both have shape (batch_size, max_ngram_size - 1) and are right-aligned, so the last slot is the most recent id. - Positions before the start of the whole sequence use pad_id. Running the op once over a full sequence - and running it over consecutive chunks while threading present_ids into past_ids produce identical - hash ids. When past_ids is omitted the missing history is pad_id, which matches a fresh sequence. + Positions before the start of the whole sequence use pad_id, or eos_token_id when it is provided. + Running the op once over a full sequence and running it over consecutive chunks while threading + present_ids into past_ids produce identical hash ids, including when reset_on_eos is enabled. When + segment_ids is used, segment boundaries are applied only within the current input_ids chunk and are + not inferred from past_ids. When past_ids is omitted the missing history is pad_id, or eos_token_id + when it is provided. past_ids and present_ids may use the same allocation. Such in-place execution is transaction-safe only when the whole operator call is unconditionally committed; a caller that may select a prefix or roll back must preserve past_ids. + + Optional inputs add packed-sequence and Qwen4-Exp-style n-gram embedding support: + + - eos_token_id, when provided together with reset_on_eos != 0, causes causal history to reset at EOS + boundaries: any shifted position at or before the most recent EOS strictly before the current + position is replaced with eos_token_id instead of the real token. + - segment_ids, when provided, additionally resets causal history at any position whose segment id + differs from the immediately preceding position's segment id within input_ids. Segment boundaries + are not checked against past_ids history. + - head_offsets, when provided, adds a fixed per-output-head offset after the modulo by the head's + vocabulary size, letting all heads across all n-gram orders share one flat embedding table. #### Version @@ -4302,19 +4350,27 @@ This version of the operator has been available since version 1 of the 'com.micr
Number of hash heads emitted for each n-gram order.
pad_id : int (required)
Compressed tokenizer id used to pad causal shifts before the beginning of a sequence.
+
reset_on_eos : int
+
When non-zero and the eos_token_id input is provided, reset causal n-gram history at EOS boundaries as described in the op doc. Default is 0 (disabled), which preserves the original pad_id-only behavior.
-#### Inputs (3 - 4) +#### Inputs (3 - 7)
input_ids : M
Compressed tokenizer ids with shape (batch_size, sequence_length).
multipliers : M
-
Per-shift hash multipliers with shape (max_ngram_size). Conventionally odd, but any value is accepted.
+
Per-shift hash multipliers with shape at least (max_ngram_size). Conventionally odd, but any value is accepted.
vocab_sizes : M
Per-output-head vocabulary sizes, conventionally prime, with shape ((max_ngram_size - 1) * n_head_per_ngram). Every entry must be strictly positive. The CPU implementation rejects a non-positive entry; GPU implementations guard the modulo to avoid a device-side division by zero and emit a hash id of 0 for that head.
past_ids (optional) : M
-
Optional compressed tokenizer ids for the max_ngram_size - 1 positions that precede this call, with shape (batch_size, max_ngram_size - 1). Right-aligned, so the last slot is the most recent id. If omitted the history is pad_id.
+
Optional compressed tokenizer ids for the max_ngram_size - 1 positions that precede this call, with shape (batch_size, max_ngram_size - 1). Right-aligned, so the last slot is the most recent id. If omitted the history is pad_id, or eos_token_id when provided.
+
head_offsets (optional) : M
+
Optional per-output-head additive offset with shape ((max_ngram_size - 1) * n_head_per_ngram), added after the modulo.
+
eos_token_id (optional) : M
+
Optional scalar end-of-sequence token id, same type as input_ids. Required for reset_on_eos to take effect and for EOS-based substitution of unavailable prior context; see the op doc.
+
segment_ids (optional) : tensor(int32)
+
Optional per-token segment id with shape (batch_size, sequence_length), used to reset causal history at packed-sequence boundaries within input_ids.
#### Outputs (1 - 2) @@ -4687,14 +4743,14 @@ This version of the operator has been available since version 1 of the 'com.micr * derives ordinary causal visibility purely from that packed metadata -- there is no mask input; * uses a single generic set of state slots (past_key_state / past_kv_buffer / past_gate_buffer / past_state_lengths) for both policy_mode values, each with a shape that is fixed across calls - (state never grows and is never concatenated); state overflow beyond the fixed capacity is + (state never grows and is never concatenated); a step that would overflow the fixed capacity is rejected as a deterministic no-op on state rather than truncated or allowed to corrupt memory; * additionally emits selected_counts, the exact number of active (non -1) entries per query, so that no downstream consumer needs to scan selected_indices for its query's true count. - + Both policy_mode values keep the semantics of SparseAttentionIndexer, applied independently to each request's own packed token range and fixed-capacity state slice: - + policy_mode = "qsa" ("query sparse attention" token indexer) Processes each request's new tokens sequentially: appends raw indexer keys to the generic pending buffer, and whenever it reaches compress_ratio tokens, mean-pools it, applies RMSNorm @@ -4705,7 +4761,7 @@ This version of the operator has been available since version 1 of the 'com.micr their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing incomplete block. - + policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window count) independently per request, using that request's own buffer_length and new token count; @@ -4713,7 +4769,7 @@ This version of the operator has been available since version 1 of the 'com.micr rotated and appended to key_state. Queries are scored against every causally visible compressed entry with sum_h w_h * ReLU(q_h . k) and the index_topk highest scoring entry indices are emitted. - + Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: token_budget + compress_ratio - 1 for "qsa" (values are request-local token positions into the @@ -4733,8 +4789,8 @@ This version of the operator has been available since version 1 of the 'com.micr * cumulative_sequence_lengths, past_sequence_lengths and past_state_lengths are read directly by the device kernel; a zero-token request row (a repeated cumulative offset) is valid and simply contributes no query rows for that request. - - OgaEngine integration note: this operator only defines the ORT operator; wiring + + OgaEngine integration note: this operator only defines the ORT contrib op; wiring past_key_state / past_kv_buffer / past_gate_buffer / past_state_lengths as Engine-managed, per-request fixed-size state (analogous to a paged auxiliary cache) is expected to happen in the OgaEngine / Model Builder integration, which is out of scope for this operator definition. @@ -4764,7 +4820,7 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
-#### Inputs (10 - 15) +#### Inputs
query : T
@@ -4799,7 +4855,7 @@ This version of the operator has been available since version 1 of the 'com.micr
Generic per-request state length with shape (batch_size, 2). Column 0 is the key_state entry count (policy_mode 'qsa': complete-block count; 'csa': compressed-entry count); column 1 is the pending-buffer length (policy_mode 'qsa': incomplete-block length in [0, compress_ratio); 'csa': buffer length in [0, 2 * compress_ratio)).
-#### Outputs (6 - 6) +#### Outputs
selected_indices : M
@@ -7964,3 +8020,5 @@ No versioning maintained for experimental ops.
T : tensor(float)
Constrain input and output types to float32 tensors.
+ + diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index 1efbe5c280c2b..dcd3a06cc3314 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -582,7 +582,7 @@ The **OpSet Version** column uses the following notation: |DynamicQuantizeMatMul|*in* A:**T1**
*in* B:**T2**
*in* b_scale:**T1**
*in* b_zero_point:**T2**
*in* bias:**T1**
*out* Y:**T1**|1+|**T1** = tensor(float)
**T2** = tensor(int8), tensor(uint8)| |DynamicTimeWarping|*in* input:**F**
*out* output:**I**|1+|**F** = tensor(float)
**I** = tensor(int32)| |EmbedLayerNormalization|*in* input_ids:**T1**
*in* segment_ids:**T1**
*in* word_embedding:**T**
*in* position_embedding:**T**
*in* segment_embedding:**T**
*in* gamma:**T**
*in* beta:**T**
*in* mask:**T1**
*in* position_ids:**T1**
*out* output:**T**
*out* mask_index:**T1**
*out* embedding_sum:**T**|1+|**T** = tensor(float)| -|EngramGate|*in* key:**T**
*in* query:**T**
*in* value:**T**
*in* key_norm_scale:**T**
*in* query_norm_scale:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| +|EngramGate|*in* key:**T**
*in* query:**T**
*in* value:**T**
*in* key_norm_scale:**T**
*in* query_norm_scale:**T**
*in* conv_norm_scale:**T**
*out* output:**T**
*out* gated_value_normed:**T**|1+|**T** = tensor(float), tensor(float16)| |ExpandDims|*in* X:**T**
*in* axis:**tensor(int32)**
*out* Y:**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)
**axis** = tensor(int32)| |FastGelu|*in* X:**T**
*in* bias:**T**
*out* Y:**T**|1+|**T** = tensor(float)| |FusedConv|*in* X:**T**
*in* W:**T**
*in* B:**T**
*in* Z:**T**
*out* Y:**T**|1+|**T** = tensor(float)| @@ -590,7 +590,7 @@ The **OpSet Version** column uses the following notation: |FusedMatMul|*in* A:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(double), tensor(float)| |GatedAdd|*in* X:**T**
*in* Y:**T**
*in* gate:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| |GatedRMSNorm|*in* X:**T**
*in* scale:**T**
*in* gate:**T**
*out* Y:**T**|1+|**T** = tensor(float), tensor(float16)| -|GatherBlockQuantized|*in* data:**T1**
*in* indices:**Tind**
*in* scales:**T2**
*in* zero_points:**T1**
*out* output:**T2**|1+|**T1** = tensor(int4), tensor(uint4), tensor(uint8)
**T2** = tensor(float), tensor(float16)
**Tind** = tensor(int32), tensor(int64)| +|GatherBlockQuantized|*in* data:**T1**
*in* indices:**Tind**
*in* scales:**T2**
*in* zero_points:**T1**
*out* output:**T2**|1+|**T1** = tensor(float4e2m1), tensor(float8e4m3fn), tensor(float8e4m3fnuz), tensor(float8e5m2), tensor(float8e5m2fnuz), tensor(int4), tensor(uint4), tensor(uint8)
**T2** = tensor(float), tensor(float16)
**Tind** = tensor(int32), tensor(int64)| |GatherND|*in* data:**T**
*in* indices:**Tind**
*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)
**Tind** = tensor(int32), tensor(int64)| |Gelu|*in* X:**T**
*out* Y:**T**|1+|**T** = tensor(float)| |GreedySearch|*in* input_ids:**I**
*in* max_length:**I**
*in* min_length:**I**
*in* repetition_penalty:**T**
*in* vocab_mask:**I**
*in* prefix_vocab_mask:**I**
*in* attention_mask:**I**
*out* sequences:**I**|1+|**T** = tensor(float)| @@ -609,7 +609,7 @@ The **OpSet Version** column uses the following notation: |MoE|*in* input:**T**
*in* router_probs:**T**
*in* fc1_experts_weights:**T**
*in* fc1_experts_bias:**T**
*in* fc2_experts_weights:**T**
*in* fc2_experts_bias:**T**
*in* fc3_experts_weights:**T**
*in* fc3_experts_bias:**T**
*out* output:**T**|1+|**T** = tensor(float)| |MultiHeadAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* bias:**T**
*in* key_padding_mask:**M**
*in* attention_bias:**T**
*in* past_key:**T**
*in* past_value:**T**
*in* past_sequence_length:**M**
*in* cache_indirection:**M**
*out* output:**T**
*out* present_key:**T**
*out* present_value:**T**
*out* qk:**QK**|1+|**T** = tensor(float)| |MurmurHash3|*in* X:**T1**
*out* Y:**T2**|1+|**T1** = tensor(double), tensor(float), tensor(int32), tensor(int64), tensor(string), tensor(uint32), tensor(uint64)
**T2** = tensor(int32), tensor(uint32)| -|NGramHashMapping|*in* input_ids:**M**
*in* multipliers:**M**
*in* vocab_sizes:**M**
*in* past_ids:**M**
*out* hash_ids:**M**
*out* present_ids:**M**|1+|**M** = tensor(int32), tensor(int64)| +|NGramHashMapping|*in* input_ids:**M**
*in* multipliers:**M**
*in* vocab_sizes:**M**
*in* past_ids:**M**
*in* head_offsets:**M**
*in* eos_token_id:**M**
*in* segment_ids:**tensor(int32)**
*out* hash_ids:**M**
*out* present_ids:**M**|1+|**M** = tensor(int32), tensor(int64)| |NGramRepeatBlock|*in* input_ids:**Tid**
*in* scores:**T**
*out* scores_out:**T**|1+|**T** = tensor(float)
**Tid** = tensor(int64)| |NhwcMaxPool|*in* x:**T**
*out* y:**T**|1+|**T** = tensor(int8), tensor(uint8)| |Pad|*in* data:**T**
*in* pads:**tensor(int64)**
*in* value:**T**
*out* output:**T**|1+|**T** = tensor(float)| @@ -1089,7 +1089,7 @@ The **OpSet Version** column uses the following notation: |DequantizeWithOrder|*in* input:**Q**
*in* scale_input:**S**
*out* output:**F**|1+|**F** = tensor(float), tensor(float16)
**Q** = tensor(int8)
**S** = tensor(float)| |DynamicTimeWarping|*in* input:**F**
*out* output:**I**|1+|**F** = tensor(float)
**I** = tensor(int32)| |EmbedLayerNormalization|*in* input_ids:**T1**
*in* segment_ids:**T1**
*in* word_embedding:**T**
*in* position_embedding:**T**
*in* segment_embedding:**T**
*in* gamma:**T**
*in* beta:**T**
*in* mask:**T1**
*in* position_ids:**T1**
*out* output:**T**
*out* mask_index:**T1**
*out* embedding_sum:**T**|1+|**T** = tensor(float), tensor(float16)| -|EngramGate|*in* key:**T**
*in* query:**T**
*in* value:**T**
*in* key_norm_scale:**T**
*in* query_norm_scale:**T**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| +|EngramGate|*in* key:**T**
*in* query:**T**
*in* value:**T**
*in* key_norm_scale:**T**
*in* query_norm_scale:**T**
*in* conv_norm_scale:**T**
*out* output:**T**
*out* gated_value_normed:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| |FastGelu|*in* X:**T**
*in* bias:**T**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(double), tensor(float), tensor(float16)| |FusedConv|*in* X:**T**
*in* W:**T**
*in* B:**T**
*in* Z:**T**
*out* Y:**T**|1+|**T** = tensor(float)| |FusedMatMul|*in* A:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(double), tensor(float), tensor(float16)| @@ -1097,7 +1097,7 @@ The **OpSet Version** column uses the following notation: |GatedDeltaNet|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* cu_seqlens:**TI**
*in* decay:**TS**
*in* beta:**TS**
*in* initial_state:**TS**
*in* a_log:**TS**
*in* dt_bias:**TS**
*in* capture_count:**TI**
*in* state_update_active:**TI**
*out* output:**T**
*out* final_state:**TS**
*out* state_update:**TS**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)
**TI** = tensor(int32)
**TS** = tensor(float)| |GatedRMSNorm|*in* X:**T**
*in* scale:**T**
*in* gate:**T**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| |GatedRelativePositionBias|*in* query_layer:**T**
*in* query_bias:**T**
*in* rel_pos:**T**
*in* weight:**T**
*in* bias:**T**
*in* eco_a:**T**
*in* token_offset:**M**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| -|GatherBlockQuantized|*in* data:**T1**
*in* indices:**Tind**
*in* scales:**T2**
*in* zero_points:**T1**
*out* output:**T2**|1+|**T1** = tensor(int4), tensor(uint4), tensor(uint8)
**T2** = tensor(bfloat16), tensor(float), tensor(float16)
**Tind** = tensor(int32), tensor(int64)| +|GatherBlockQuantized|*in* data:**T1**
*in* indices:**Tind**
*in* scales:**T2**
*in* zero_points:**T1**
*out* output:**T2**|1+|**T1** = tensor(float4e2m1), tensor(float8e4m3fn), tensor(float8e4m3fnuz), tensor(float8e5m2), tensor(float8e5m2fnuz), tensor(int4), tensor(uint4), tensor(uint8)
**T2** = tensor(bfloat16), tensor(float), tensor(float16)
**Tind** = tensor(int32), tensor(int64)| |Gelu|*in* X:**T**
*out* Y:**T**|1+|**T** = tensor(bfloat16), tensor(double), tensor(float), tensor(float16)| |GemmFloat8|*in* A:**TA**
*in* B:**TB**
*in* C:**TC**
*in* scaleA:**TS**
*in* scaleB:**TS**
*in* scaleY:**TS**
*out* Y:**TR**|1+|**TA** = tensor(bfloat16), tensor(float), tensor(float16), tensor(float8e4m3fn), tensor(float8e5m2)
**TB** = tensor(bfloat16), tensor(float), tensor(float16), tensor(float8e4m3fn), tensor(float8e5m2)
**TR** = tensor(bfloat16), tensor(float), tensor(float16), tensor(float8e4m3fn), tensor(float8e5m2)
**TS** = tensor(float)| |GemmaRotaryEmbedding|*in* emb:**U**
*in* q:**T**
*in* q_rot:**T**
*in* k:**T**
*in* k_rot:**T**
*out* output1:**T**
*out* output2:**T**|1+|**T** = tensor(float16)
**U** = tensor(float)| @@ -1117,7 +1117,7 @@ The **OpSet Version** column uses the following notation: |MatMulNBits|*in* A:**T1**
*in* B:**T2**
*in* scales:**T1**
*in* zero_points:**T3**
*in* g_idx:**T4**
*in* bias:**T1**
*out* Y:**T1**|1+|**T1** = tensor(bfloat16), tensor(float), tensor(float16)
**T2** = tensor(uint8)
**T3** = tensor(bfloat16), tensor(float), tensor(float16), tensor(uint8)| |MoE|*in* input:**T**
*in* router_probs:**T**
*in* fc1_experts_weights:**T**
*in* fc1_experts_bias:**T**
*in* fc2_experts_weights:**T**
*in* fc2_experts_bias:**T**
*in* fc3_experts_weights:**T**
*in* fc3_experts_bias:**T**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)| |MultiHeadAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* bias:**T**
*in* key_padding_mask:**M**
*in* attention_bias:**T**
*in* past_key:**T**
*in* past_value:**T**
*in* past_sequence_length:**M**
*in* cache_indirection:**M**
*out* output:**T**
*out* present_key:**T**
*out* present_value:**T**
*out* qk:**QK**|1+|**QK** = tensor(bfloat16), tensor(float), tensor(float16)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| -|NGramHashMapping|*in* input_ids:**M**
*in* multipliers:**M**
*in* vocab_sizes:**M**
*in* past_ids:**M**
*out* hash_ids:**M**
*out* present_ids:**M**|1+|**M** = tensor(int32), tensor(int64)| +|NGramHashMapping|*in* input_ids:**M**
*in* multipliers:**M**
*in* vocab_sizes:**M**
*in* past_ids:**M**
*in* head_offsets:**M**
*in* eos_token_id:**M**
*in* segment_ids:**tensor(int32)**
*out* hash_ids:**M**
*out* present_ids:**M**|1+|**M** = tensor(int32), tensor(int64)| |NGramRepeatBlock|*in* input_ids:**Tid**
*in* scores:**T**
*out* scores_out:**T**|1+|**T** = tensor(float)
**Tid** = tensor(int64)| |NhwcConv|*in* X:**T**
*in* W:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedAttention|*in* input:**T**
*in* weights:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 7c4d32887f15c..19d69eb1cfc67 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -237,6 +237,10 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const int64_t capacity = psai::SelectedCapacity(psai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_); + Tensor* present_gate_buffer = context->Output(psai::kPresentGateBuffer, TensorShape({0})); + ORT_RETURN_IF(present_gate_buffer != nullptr, + "PackedSparseAttentionIndexer: output ", psai::kPresentGateBuffer, + " must be omitted for policy_mode 'qsa'"); Tensor* selected_indices = context->Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); Tensor* selected_counts = context->Output(psai::kSelectedCounts, TensorShape({total_tokens})); Tensor* present_key_state = context->Output(psai::kPresentKeyState, key_state_shape); diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 725e8a1c09d0f..3cdf07349d452 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -788,6 +788,10 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {batch_size, psai::kStateLengthColumns})); const int64_t capacity = psai::SelectedCapacity(psai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_); + Tensor* present_gate_buffer = context.Output(psai::kPresentGateBuffer, TensorShape({0})); + ORT_RETURN_IF(present_gate_buffer != nullptr, + "PackedSparseAttentionIndexer: output ", psai::kPresentGateBuffer, + " must be omitted for policy_mode 'qsa'"); Tensor* selected_indices = context.Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); Tensor* selected_counts = context.Output(psai::kSelectedCounts, TensorShape({total_tokens})); Tensor* present_key_state = context.Output(psai::kPresentKeyState, key_state_shape); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 37ce43b089b24..e50fbf90bbbd5 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2396,13 +2396,6 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference fail_shape_inference("PackedSparseAttentionIndexer: exactly ", psai::kFixedOutputCount, " declared outputs are required, got ", ctx.getNumOutputs()); } - const bool has_gate_output = ctx.getOutputType(psai::kPresentGateBuffer) != nullptr; - if (has_gate_output == is_qsa) { - fail_shape_inference("PackedSparseAttentionIndexer: output ", psai::kPresentGateBuffer, - is_qsa ? " must be omitted when policy_mode is 'qsa'" - : " is required when policy_mode is 'csa'"); - } - updateOutputElemType(ctx, psai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); updateOutputElemType(ctx, psai::kSelectedCounts, ONNX_NAMESPACE::TensorProto_DataType_INT32); updateOutputElemType(ctx, psai::kPresentStateLengths, ONNX_NAMESPACE::TensorProto_DataType_INT32); diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index ba7c3baf2fca3..f97f2bfaeb513 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -88,7 +88,7 @@ struct GraphOptions { int64_t total_tokens = 5; int64_t num_heads = 2; int64_t head_size = 8; - int64_t rotary_width = 8; + int64_t rotary_width = 4; int64_t compress_ratio = 2; int64_t state_capacity = 6; int64_t input_state_capacity = -1; @@ -99,7 +99,6 @@ struct GraphOptions { bool add_index_topk = false; bool add_csa_inputs = false; bool add_position_ids = false; - bool invert_gate_output_presence = false; int output_count = psai::kFixedOutputCount; std::string policy_mode = psai::kPolicyModeQsa; }; @@ -157,8 +156,7 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { std::vector outputs; for (int i = 0; i < options.output_count; ++i) { - const bool gate_output_present = is_csa != options.invert_gate_output_presence; - outputs.push_back(i == psai::kPresentGateBuffer && !gate_output_present ? &empty : builder.MakeOutput()); + outputs.push_back(i == psai::kPresentGateBuffer && !is_csa ? &empty : builder.MakeOutput()); } Node& node = builder.AddNode("PackedSparseAttentionIndexer", inputs, outputs, kMSDomain); node.AddAttribute("policy_mode", options.policy_mode); @@ -237,21 +235,6 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsStateCapacityShapeMi "past_key_state dimension 1 must equal state_capacity"); } -TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaGateOutput) { - GraphOptions options; - options.invert_gate_output_presence = true; - ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "must be omitted when policy_mode is 'qsa'"); -} - -TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingGateOutput) { - GraphOptions options; - options.policy_mode = psai::kPolicyModeCsa; - options.invert_gate_output_presence = true; - ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "is required when policy_mode is 'csa'"); -} - TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsMismatchedKeyTokenCount) { GraphOptions options; options.key_total_tokens = options.total_tokens + 1; From e81906b1db7df7d9bdd7d387ab26a78fabf19466 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:59:00 +0000 Subject: [PATCH 037/102] Reduce packed indexer WebGPU bindings Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../bert/packed_sparse_attention_indexer.cc | 189 +++++++++--------- .../bert/packed_sparse_attention_indexer.h | 23 +-- 2 files changed, 96 insertions(+), 116 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 3cdf07349d452..b4cc3c0dd7a8a 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -91,35 +91,48 @@ Status PackedSparseAttentionIndexerCopyProgram::GenerateShaderCode(ShaderHelper& const auto& dst = shader.AddOutput("dst", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); shader.MainFunctionBody() << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.total") - << " " << dst.SetByOffset("global_idx", "dst_element_t(" + src.GetByOffset("global_idx") + ")") << "\n"; + << " " + << dst.SetByOffset("uniforms.dst_offset + global_idx", + "dst_element_t(" + src.GetByOffset("global_idx") + ")") + << "\n"; + return Status::OK(); +} + +static Status PackTwoTensors(onnxruntime::webgpu::ComputeContext& context, + const Tensor& first, + const Tensor& second, + Tensor& packed) { + uint32_t dst_offset = 0; + for (const Tensor* source : {&first, &second}) { + const uint32_t total = ToUint32(source->Shape().Size()); + if (total > 0) { + PackedSparseAttentionIndexerCopyProgram copy; + copy.SetWorkgroupSize(kWorkgroupSize) + .AddInput({source, ProgramTensorMetadataDependency::Type}) + .AddOutput({&packed, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize((total + kWorkgroupSize - 1) / kWorkgroupSize) + .AddUniformVariables({{total}, {dst_offset}}); + ORT_RETURN_IF_ERROR(context.RunProgram(copy)); + } + dst_offset += total; + } return Status::OK(); } Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& key = shader.AddInput("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& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); - const ShaderVariableHelper* past_kv_buffer = nullptr; - if (!kv_buffer_aliases_) { - past_kv_buffer = &shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); - } - const ShaderVariableHelper* past_state_lengths = nullptr; - if (!state_lengths_aliases_) { - past_state_lengths = &shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); - } const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_kv_buffer = shader.AddOutput("present_kv_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); - const ShaderVariableHelper& kv_buffer_history = - kv_buffer_aliases_ ? present_kv_buffer : *past_kv_buffer; - const ShaderVariableHelper& state_lengths_history = - state_lengths_aliases_ ? present_state_lengths : *past_state_lengths; + const ShaderVariableHelper& kv_buffer_history = present_kv_buffer; + const ShaderVariableHelper& state_lengths_history = present_state_lengths; shader.AdditionalImplementation() << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, d: u32) -> f32 {\n" @@ -167,8 +180,10 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe shader.AdditionalImplementation() << " let cache = position * uniforms.rotary_width + d;\n"; } shader.AdditionalImplementation() - << " return value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" - << sin_cache.GetByOffset("cache") << ");\n" + << " let cache_size = select(1u, uniforms.batch_size, " << (cos_cache_batched_ ? "true" : "false") + << ") * uniforms.max_rotary_length * uniforms.rotary_width;\n" + << " return value * f32(" << rotary_cache.GetByOffset("cache") << ") + paired * f32(" + << rotary_cache.GetByOffset("cache_size + cache") << ");\n" << "}\n"; shader.MainFunctionBody() @@ -227,8 +242,7 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); - const auto& cos_cache = shader.AddInput("cos_cache", ShaderUsage::UseUniform); - const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); + const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); const ShaderVariableHelper* position_ids = nullptr; @@ -290,8 +304,10 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe shader.AdditionalImplementation() << " let cache = position_clamped * uniforms.rotary_width + d;\n"; } shader.AdditionalImplementation() - << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" - << sin_cache.GetByOffset("cache") << ");\n" + << " let cache_size = select(1u, uniforms.batch_size, " << (cos_cache_batched_ ? "true" : "false") + << ") * uniforms.max_rotary_length * uniforms.rotary_width;\n" + << " value = value * f32(" << rotary_cache.GetByOffset("cache") << ") + paired * f32(" + << rotary_cache.GetByOffset("cache_size + cache") << ");\n" << " return value;\n" << "}\n" << "fn block_score(token: u32, b: u32, block_index: u32, position: i32) -> f32 {\n" @@ -370,26 +386,11 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe } Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHelper& shader) const { - const auto& key = shader.AddInput("key", ShaderUsage::UseUniform); - const auto& gate = shader.AddInput("gate", ShaderUsage::UseUniform); + const auto& key_gate = shader.AddInput("key_gate", 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& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& position_bias = shader.AddInput("position_bias", ShaderUsage::UseUniform); - const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); - const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); - const ShaderVariableHelper* past_kv_buffer = nullptr; - if (!kv_buffer_aliases_) { - past_kv_buffer = &shader.AddInput("past_kv_buffer", ShaderUsage::UseUniform); - } - const ShaderVariableHelper* past_gate_buffer = nullptr; - if (!gate_buffer_aliases_) { - past_gate_buffer = &shader.AddInput("past_gate_buffer", ShaderUsage::UseUniform); - } - const ShaderVariableHelper* past_state_lengths = nullptr; - if (!state_lengths_aliases_) { - past_state_lengths = &shader.AddInput("past_state_lengths", ShaderUsage::UseUniform); - } + const auto& sequence_metadata = shader.AddInput("sequence_metadata", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_kv_buffer = @@ -398,12 +399,9 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe shader.AddOutput("present_gate_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); - const ShaderVariableHelper& kv_buffer_history = - kv_buffer_aliases_ ? present_kv_buffer : *past_kv_buffer; - const ShaderVariableHelper& gate_buffer_history = - gate_buffer_aliases_ ? present_gate_buffer : *past_gate_buffer; - const ShaderVariableHelper& state_lengths_history = - state_lengths_aliases_ ? present_state_lengths : *past_state_lengths; + const ShaderVariableHelper& kv_buffer_history = present_kv_buffer; + const ShaderVariableHelper& gate_buffer_history = present_gate_buffer; + const ShaderVariableHelper& state_lengths_history = present_state_lengths; shader.AdditionalImplementation() << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, channel: u32) -> f32 {\n" @@ -413,7 +411,7 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " return f32(" << kv_buffer_history.GetByOffset("idx") << ");\n" << " }\n" << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * width + channel;\n" - << " return f32(" << key.GetByOffset("idx2") << ");\n" + << " return f32(" << key_gate.GetByOffset("idx2") << ");\n" << "}\n" << "fn extended_gate(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, channel: u32) -> f32 {\n" << " let width = 2u * uniforms.head_size;\n" @@ -422,7 +420,8 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " return f32(" << gate_buffer_history.GetByOffset("idx") << ");\n" << " }\n" << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * width + channel;\n" - << " return f32(" << gate.GetByOffset("idx2") << ");\n" + << " let gate_base = uniforms.total_tokens * 2u * uniforms.head_size;\n" + << " return f32(" << key_gate.GetByOffset("gate_base + idx2") << ");\n" << "}\n" << "fn pooled(b: u32, k: i32, old_buf_len: i32, req_start: i32, overlap_length: i32, d: u32) -> f32 {\n" << " let width = 2u * uniforms.head_size;\n" @@ -473,14 +472,14 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe shader.MainFunctionBody() << " let b = workgroup_idx;\n" << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" - << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" - << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" + << " let req_start = " << sequence_metadata.GetByOffset("b") << ";\n" + << " let req_end = " << sequence_metadata.GetByOffset("b + 1u") << ";\n" << " let old_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" << " let old_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" - << " let invalid = " << cu_seqlens.GetByOffset("0") << " != 0 || " - << cu_seqlens.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " + << " let invalid = " << sequence_metadata.GetByOffset("0") << " != 0 || " + << sequence_metadata.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || " - << past_seqlens.GetByOffset("b") << " < 0 || old_key_len < 0 || " + << sequence_metadata.GetByOffset("uniforms.batch_size + 1u + b") << " < 0 || old_key_len < 0 || " << "old_key_len > i32(uniforms.state_capacity) || old_buf_len < 0 || " << "old_buf_len > i32(uniforms.buffer_capacity);\n" << " if (invalid) {\n" @@ -537,8 +536,12 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " let paired = sign * pooled(b, k, old_buf_len, req_start, overlap_length, 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" + << " let cache_size = select(1u, uniforms.batch_size, " + << (cos_cache_batched_ ? "true" : "false") + << ") * uniforms.max_rotary_length * uniforms.rotary_width;\n" + << " value = value * f32(" << rotary_cache.GetByOffset("cache_base + offset / 2u") + << ") + paired * f32(" + << rotary_cache.GetByOffset("cache_size + cache_base + offset / 2u") << ");\n" << " }\n" << " " << present_key_state.SetByOffset("(b * uniforms.state_capacity + entry) * uniforms.head_size + d", @@ -570,8 +573,7 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe const auto& present_key_state = shader.AddInput("present_key_state", 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& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); @@ -616,8 +618,10 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << " let cache = position * uniforms.rotary_width + offset / 2u;\n"; } shader.AdditionalImplementation() - << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" - << sin_cache.GetByOffset("cache") << ");\n" + << " let cache_size = select(1u, uniforms.batch_size, " << (cos_cache_batched_ ? "true" : "false") + << ") * uniforms.max_rotary_length * uniforms.rotary_width;\n" + << " value = value * f32(" << rotary_cache.GetByOffset("cache") << ") + paired * f32(" + << rotary_cache.GetByOffset("cache_size + cache") << ");\n" << " return value;\n" << "}\n" << "fn entry_score(token: u32, b: u32, entry: u32, raw: vec2) -> f32 {\n" @@ -787,6 +791,10 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", {batch_size, psai::kStateLengthColumns})); + Tensor rotary_cache = + context.CreateGPUTensor(cos_cache->DataType(), TensorShape({2 * cos_cache->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cos_cache, *sin_cache, rotary_cache)); + const int64_t capacity = psai::SelectedCapacity(psai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_); Tensor* present_gate_buffer = context.Output(psai::kPresentGateBuffer, TensorShape({0})); ORT_RETURN_IF(present_gate_buffer != nullptr, @@ -809,7 +817,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont .AddInput({past_key_state, ProgramTensorMetadataDependency::Type}) .AddOutput({present_key_state, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) - .AddUniformVariables({{ToUint32(total)}}); + .AddUniformVariables({{ToUint32(total)}, {0u}}); ORT_RETURN_IF_ERROR(context.RunProgram(copy)); } } @@ -821,7 +829,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont .AddInput({past_kv_buffer, ProgramTensorMetadataDependency::Type}) .AddOutput({present_kv_buffer, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) - .AddUniformVariables({{ToUint32(total)}}); + .AddUniformVariables({{ToUint32(total)}, {0u}}); ORT_RETURN_IF_ERROR(context.RunProgram(copy)); } } @@ -833,29 +841,20 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont .AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) - .AddUniformVariables({{ToUint32(total)}}); + .AddUniformVariables({{ToUint32(total)}, {0u}}); ORT_RETURN_IF_ERROR(context.RunProgram(copy)); } } if (batch_size > 0) { - const bool kv_buffer_aliases = present_kv_buffer->DataRaw() == past_kv_buffer->DataRaw(); - const bool state_lengths_aliases = present_state_lengths->DataRaw() == past_state_lengths->DataRaw(); - PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched, kv_buffer_aliases, state_lengths_aliases}; - update.CacheHint(rotary.batched, kv_buffer_aliases, state_lengths_aliases) + PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; + update.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{key, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, - {cos_cache, ProgramTensorMetadataDependency::Type}, - {sin_cache, ProgramTensorMetadataDependency::Type}, + {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, {past_seqlens, ProgramTensorMetadataDependency::Type}}); - if (!kv_buffer_aliases) { - update.AddInput({past_kv_buffer, ProgramTensorMetadataDependency::Type}); - } - if (!state_lengths_aliases) { - update.AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}); - } update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) @@ -882,8 +881,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, {present_key_state, ProgramTensorMetadataDependency::Type}, - {cos_cache, ProgramTensorMetadataDependency::Type}, - {sin_cache, ProgramTensorMetadataDependency::Type}, + {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, {past_seqlens, ProgramTensorMetadataDependency::Type}}); if (position_ids != nullptr) { @@ -971,6 +969,16 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", {batch_size, psai::kStateLengthColumns})); + Tensor key_gate = + context.CreateGPUTensor(key->DataType(), TensorShape({key->Shape().Size() + gate->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *key, *gate, key_gate)); + Tensor rotary_cache = + context.CreateGPUTensor(cos_cache->DataType(), TensorShape({2 * cos_cache->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cos_cache, *sin_cache, rotary_cache)); + Tensor sequence_metadata = context.CreateGPUTensor( + cu_seqlens->DataType(), TensorShape({cu_seqlens->Shape().Size() + past_seqlens->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cu_seqlens, *past_seqlens, sequence_metadata)); + const int64_t capacity = psai::SelectedCapacity(psai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_); Tensor* selected_indices = context.Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); Tensor* selected_counts = context.Output(psai::kSelectedCounts, TensorShape({total_tokens})); @@ -996,7 +1004,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont .AddInput({src, ProgramTensorMetadataDependency::Type}) .AddOutput({dst, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32((total + kWorkgroupSize - 1) / kWorkgroupSize)) - .AddUniformVariables({{ToUint32(total)}}); + .AddUniformVariables({{ToUint32(total)}, {0u}}); return context.RunProgram(copy); }; ORT_RETURN_IF_ERROR(copy_if_needed(past_key_state, present_key_state)); @@ -1005,30 +1013,14 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(copy_if_needed(past_state_lengths, present_state_lengths)); if (batch_size > 0) { - const bool kv_buffer_aliases = present_kv_buffer->DataRaw() == past_kv_buffer->DataRaw(); - const bool gate_buffer_aliases = present_gate_buffer->DataRaw() == past_gate_buffer->DataRaw(); - const bool state_lengths_aliases = present_state_lengths->DataRaw() == past_state_lengths->DataRaw(); - PackedSparseAttentionIndexerCsaUpdateProgram update{ - rotary.batched, kv_buffer_aliases, gate_buffer_aliases, state_lengths_aliases}; - update.CacheHint(rotary.batched, kv_buffer_aliases, gate_buffer_aliases, state_lengths_aliases) + PackedSparseAttentionIndexerCsaUpdateProgram update{rotary.batched}; + update.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) - .AddInputs({{key, ProgramTensorMetadataDependency::Type}, - {gate, ProgramTensorMetadataDependency::Type}, + .AddInputs({{&key_gate, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, - {cos_cache, ProgramTensorMetadataDependency::Type}, - {sin_cache, ProgramTensorMetadataDependency::Type}, + {&rotary_cache, ProgramTensorMetadataDependency::Type}, {position_bias, ProgramTensorMetadataDependency::Type}, - {cu_seqlens, ProgramTensorMetadataDependency::Type}, - {past_seqlens, ProgramTensorMetadataDependency::Type}}); - if (!kv_buffer_aliases) { - update.AddInput({past_kv_buffer, ProgramTensorMetadataDependency::Type}); - } - if (!gate_buffer_aliases) { - update.AddInput({past_gate_buffer, ProgramTensorMetadataDependency::Type}); - } - if (!state_lengths_aliases) { - update.AddInput({past_state_lengths, ProgramTensorMetadataDependency::Type}); - } + {&sequence_metadata, ProgramTensorMetadataDependency::Type}}); update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}, {present_gate_buffer, ProgramTensorMetadataDependency::Type}}) @@ -1058,8 +1050,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont {present_key_state, ProgramTensorMetadataDependency::Type}, {head_weights, ProgramTensorMetadataDependency::Type}, {position_ids, ProgramTensorMetadataDependency::Type}, - {cos_cache, ProgramTensorMetadataDependency::Type}, - {sin_cache, ProgramTensorMetadataDependency::Type}, + {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}}) .AddInputs({{present_state_lengths, ProgramTensorMetadataDependency::Type}, {&overflow_flags, ProgramTensorMetadataDependency::Type}}) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index 1fafc10110861..2607a1492e48f 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -23,7 +23,8 @@ class PackedSparseAttentionIndexerCopyProgram final PackedSparseAttentionIndexerCopyProgram() : Program{"PackedSparseAttentionIndexerCopy"} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( - {"total", ProgramUniformVariableDataType::Uint32}); + {"total", ProgramUniformVariableDataType::Uint32}, + {"dst_offset", ProgramUniformVariableDataType::Uint32}); }; // One invocation per request: forms every newly-closed compress_ratio block (mean-pool -> @@ -31,12 +32,9 @@ class PackedSparseAttentionIndexerCopyProgram final class PackedSparseAttentionIndexerQsaUpdateProgram final : public Program { public: - PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched, bool kv_buffer_aliases, - bool state_lengths_aliases) + explicit PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched) : Program{"PackedSparseAttentionIndexerQsaUpdate"}, - cos_cache_batched_{cos_cache_batched}, - kv_buffer_aliases_{kv_buffer_aliases}, - state_lengths_aliases_{state_lengths_aliases} {} + cos_cache_batched_{cos_cache_batched} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, @@ -51,8 +49,6 @@ class PackedSparseAttentionIndexerQsaUpdateProgram final private: bool cos_cache_batched_; - bool kv_buffer_aliases_; - bool state_lengths_aliases_; }; // One invocation per query token: rotates the query, scores it against every causally visible @@ -90,13 +86,9 @@ class PackedSparseAttentionIndexerQsaSelectProgram final class PackedSparseAttentionIndexerCsaUpdateProgram final : public Program { public: - PackedSparseAttentionIndexerCsaUpdateProgram(bool cos_cache_batched, bool kv_buffer_aliases, - bool gate_buffer_aliases, bool state_lengths_aliases) + explicit PackedSparseAttentionIndexerCsaUpdateProgram(bool cos_cache_batched) : Program{"PackedSparseAttentionIndexerCsaUpdate"}, - cos_cache_batched_{cos_cache_batched}, - kv_buffer_aliases_{kv_buffer_aliases}, - gate_buffer_aliases_{gate_buffer_aliases}, - state_lengths_aliases_{state_lengths_aliases} {} + cos_cache_batched_{cos_cache_batched} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, @@ -111,9 +103,6 @@ class PackedSparseAttentionIndexerCsaUpdateProgram final private: bool cos_cache_batched_; - bool kv_buffer_aliases_; - bool gate_buffer_aliases_; - bool state_lengths_aliases_; }; // One invocation per query token: rotates the query, scores it against every causally visible From 24bccfdf656c426a080428509d5424c5aabdb568 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:08:10 +0000 Subject: [PATCH 038/102] Implement profile-driven QSA selection optimizations Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse_attention_indexer.md | 24 ++-- .../cuda/sparse/sparse_attention_indexer.cc | 2 +- .../sparse/sparse_attention_indexer_impl.cu | 110 ++++++++++++++---- .../sparse/sparse_attention_indexer_impl.h | 2 + .../sparse_attention_indexer_op_test.cc | 10 ++ 5 files changed, 115 insertions(+), 33 deletions(-) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index 7dcd24a1ea1ca..1498fd5b96716 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -277,19 +277,18 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l | Policy | Buffer | Elements | |---|---|---| -| `qsa` | float | `B*S*N*D` (rotated query) + `B*S*max_block_count` (block scores) | +| `qsa` | float | `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 +### Selection -`QsaSelectKernel` and `CsaSelectKernel` repeatedly scan for the next element in the total order -"score descending, index ascending", using the previously emitted `(score, index)` pair as the -cursor. That avoids an `O(entries)` bitmap in shared memory, keeps the selection deterministic and -makes ties resolve to the smaller index. The cost is `O(topk * entries)` per query row, which is -the main performance follow-up below. +For QSA rows with at least 512 candidate blocks, a stable segmented radix sort orders all block +scores in `O(entries)` work before `QsaSelectKernel` emits the requested blocks. Initial indices +are ascending, so the stable sort preserves the deterministic "score descending, index ascending" +order for ties. Smaller QSA rows and CSA use the in-kernel block TopK or repeated-scan path. ## 8. Numerics and Determinism @@ -347,16 +346,17 @@ Run them with: The implementation is correctness-first. The following are known and deliberate: -1. **Large TopK selection remains `O(topk * entries)` per query row.** The common case of at most - 32 selected blocks uses a single-read block TopK with deterministic score/index ordering. - Larger values retain the repeated-scan fallback to avoid excessive shared memory. +1. **Large QSA TopK uses a segmented radix sort.** This removes the `O(topk * entries)` repeated + scans at long context lengths. Rows below 512 candidate blocks retain the in-kernel block TopK + or repeated-scan fallback because radix-sort setup can cost more than selection at small sizes. 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. **Query rotation is fused into QSA scoring.** This removes the separate rotation launch and the `B*S*N*D` float workspace, at the cost of recomputing rotation for each scored 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. +4. **`CompactVisibleKernel` is `O(T)` per query row** and re-reads the mask for every `s`. Its + warp-ballot scan reduces synchronization overhead, but reusing visibility information across + query rows would require assumptions about mask structure that the operator schema does not make. 5. **`compress_ratio` and `head_size` are not specialized.** Templating the hot kernels on a small set of common values would remove the dynamic loop bounds. 6. **No CPU kernel.** The operator is CUDA-only today. diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 7ab5a5c780a2a..e23fc1a35e5b5 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -235,7 +235,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), GetComputeStream(context)); return LaunchQsaSparseAttentionIndexer( - Stream(context), params, + this, Stream(context), GetComputeStream(context), params, reinterpret_cast(query->Data()), reinterpret_cast(key->Data()), reinterpret_cast(key_norm_weight->Data()), 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 92ae849041069..5623941ada22b 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -11,8 +11,10 @@ #include #include #include +#include #include +#include #include "contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh" #include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" @@ -25,7 +27,9 @@ namespace { // The block reductions below halve the active thread count, so this must stay a power of two. constexpr int kThreads = 128; +constexpr int kWarpSize = 32; constexpr int64_t kMaxGridDimX = kSaiMaxGridDimX; +constexpr int kRadixSortMinBlockCount = 512; // Thin, same-signature aliases over the device math shared with the packed indexer implementation // (sparse_attention_indexer_device_math.cuh), so the kernels below are unchanged. @@ -141,7 +145,7 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention // One block per query row; compacts the visible key positions of that row into visible_indices. __global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, int32_t* visible_count, SparseAttentionIndexerParams params) { - extern __shared__ int32_t shared_scan[]; + __shared__ int32_t warp_offsets[kThreads / kWarpSize + 1]; const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { const bool* mask_row = mask + row * params.total_sequence_length; @@ -149,20 +153,30 @@ __global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, 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(); + const bool visible = position < params.total_sequence_length && mask_row[position]; + const unsigned int warp_mask = __ballot_sync(0xffffffffu, visible); + const int lane = threadIdx.x % kWarpSize; + const int warp = threadIdx.x / kWarpSize; + const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); + if (lane == 0) { + warp_offsets[warp] = __popc(warp_mask); } - if (flag != 0) { - out_row[offset + shared_scan[threadIdx.x] - 1] = position; + __syncthreads(); + if (threadIdx.x == 0) { + int32_t tile_total = 0; + for (int i = 0; i < kThreads / kWarpSize; ++i) { + const int32_t warp_count = warp_offsets[i]; + warp_offsets[i] = tile_total; + tile_total += warp_count; + } + warp_offsets[kThreads / kWarpSize] = tile_total; } - const int32_t tile_total = shared_scan[blockDim.x - 1]; __syncthreads(); + const int32_t warp_offset = warp_offsets[warp]; + const int32_t tile_total = warp_offsets[kThreads / kWarpSize]; + if (visible) { + out_row[offset + warp_offset + lane_rank] = position; + } offset += tile_total; } if (threadIdx.x == 0) { @@ -264,7 +278,8 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const // One block per query row. Emits the token indices of the highest scoring blocks followed by the // visible tokens of the trailing incomplete block. __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visible_indices, - const int32_t* visible_count, int32_t* selected_indices, + const int32_t* visible_count, const int32_t* topk_indices, + int32_t* selected_indices, SparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* shared_value = shared; @@ -288,7 +303,29 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl const int32_t* index_row = visible_indices + row * params.total_sequence_length; int emitted = selected; - if (selected > 0 && use_fast_topk) { + if (topk_indices != nullptr) { + const int32_t* topk_row = topk_indices + row * params.max_block_count; + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = static_cast(topk_row[rank]); + for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { + out_row[rank * params.compress_ratio + t] = + index_row[selected_block * params.compress_ratio + t]; + } + + __global__ void SetupQsaSortKernel(int32_t* indices, int32_t* offsets, int num_items, + int rows, int dimension) { + for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + index < num_items; index += static_cast(gridDim.x) * blockDim.x) { + indices[index] = index % dimension; + } + for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + index <= rows; index += static_cast(gridDim.x) * blockDim.x) { + offsets[index] = index * dimension; + } + } + } + __syncthreads(); + } else if (selected > 0 && use_fast_topk) { SaiBlockTopK(scores_row, block_count, selected, shared_value, shared_index); for (int rank = 0; rank < selected; ++rank) { const int selected_block = shared_index[rank]; @@ -573,7 +610,8 @@ size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { } template -Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentionIndexerParams& params, +Status LaunchQsaSparseAttentionIndexer(const CudaKernel* kernel, cudaStream_t stream, void* alloc_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, @@ -605,7 +643,7 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const size_t value_bytes = static_cast(params.head_size) * sizeof(float); const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); - CompactVisibleKernel<<>>( + CompactVisibleKernel<<>>( mask, visible_indices, visible_count, params); if (params.max_block_count > 0) { @@ -616,11 +654,42 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio block_scores, params); } - const int topk_shared_entries = - params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; + const int64_t sort_items_64 = rows * params.max_block_count; + const bool use_radix_sort = + params.max_block_count >= kRadixSortMinBlockCount && + sort_items_64 <= std::numeric_limits::max(); + IAllocatorUniquePtr sorted_scores; + IAllocatorUniquePtr sort_indices_in; + IAllocatorUniquePtr sort_indices_out; + IAllocatorUniquePtr sort_offsets; + if (use_radix_sort) { + const int sort_items = static_cast(sort_items_64); + const int sort_rows = static_cast(rows); + sorted_scores = kernel->GetScratchBuffer(sort_items, alloc_stream); + sort_indices_in = kernel->GetScratchBuffer(sort_items, alloc_stream); + sort_indices_out = kernel->GetScratchBuffer(sort_items, alloc_stream); + sort_offsets = kernel->GetScratchBuffer(static_cast(sort_rows) + 1, alloc_stream); + SetupQsaSortKernel<<>>( + sort_indices_in.get(), sort_offsets.get(), sort_items, sort_rows, params.max_block_count); + + size_t temp_storage_bytes = 0; + ORT_RETURN_IF_ERROR(CUDA_CALL(cub::DeviceSegmentedRadixSort::SortPairsDescending( + nullptr, temp_storage_bytes, block_scores, sorted_scores.get(), + sort_indices_in.get(), sort_indices_out.get(), sort_items, sort_rows, + sort_offsets.get(), sort_offsets.get() + 1, 0, sizeof(float) * 8, stream))); + auto temp_storage = kernel->GetScratchBuffer(temp_storage_bytes, alloc_stream); + ORT_RETURN_IF_ERROR(CUDA_CALL(cub::DeviceSegmentedRadixSort::SortPairsDescending( + temp_storage.get(), temp_storage_bytes, block_scores, sorted_scores.get(), + sort_indices_in.get(), sort_indices_out.get(), sort_items, sort_rows, + sort_offsets.get(), sort_offsets.get() + 1, 0, sizeof(float) * 8, stream))); + } + + const int topk_shared_entries = !use_radix_sort && params.block_topk <= kSaiFastTopKMax + ? kThreads * params.block_topk + : kThreads; QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( - block_scores, visible_indices, visible_count, selected_indices, params); + block_scores, visible_indices, visible_count, sort_indices_out.get(), selected_indices, params); return CUDA_CALL(cudaGetLastError()); } @@ -685,7 +754,8 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio } #define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ - template Status LaunchQsaSparseAttentionIndexer(cudaStream_t, const SparseAttentionIndexerParams&, \ + template Status LaunchQsaSparseAttentionIndexer(const CudaKernel*, cudaStream_t, void*, \ + 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( \ diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index ebc5f1f05945e..0c2e74e2a960b 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -54,7 +54,9 @@ size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); template Status LaunchQsaSparseAttentionIndexer( + const CudaKernel* kernel, cudaStream_t stream, + void* alloc_stream, const SparseAttentionIndexerParams& params, const T* query, const T* key, 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 174f5efe3dfbf..ca19574838faf 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1059,6 +1059,16 @@ TEST(SparseAttentionIndexerTest, QsaRepeatedScanTopKFallback) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaLargeContextTopK) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 65536; + problem.compress_ratio = 16; + problem.token_budget = 8192; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; From 1593b64eedbfdc439a01f838f2afe13cabc1a8af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:12:40 +0000 Subject: [PATCH 039/102] Fix and format QSA radix selection Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../sparse/sparse_attention_indexer_impl.cu | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) 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 5623941ada22b..8c7ca01ad52fd 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -269,7 +269,8 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const } if (threadIdx.x == 0) { - block_scores[row * params.max_block_count + block_index] = score * params.scale; + const float scaled_score = score * params.scale; + block_scores[row * params.max_block_count + block_index] = scaled_score == 0.0f ? 0.0f : scaled_score; } __syncthreads(); } @@ -311,18 +312,6 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl out_row[rank * params.compress_ratio + t] = index_row[selected_block * params.compress_ratio + t]; } - - __global__ void SetupQsaSortKernel(int32_t* indices, int32_t* offsets, int num_items, - int rows, int dimension) { - for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; - index < num_items; index += static_cast(gridDim.x) * blockDim.x) { - indices[index] = index % dimension; - } - for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; - index <= rows; index += static_cast(gridDim.x) * blockDim.x) { - offsets[index] = index * dimension; - } - } } __syncthreads(); } else if (selected > 0 && use_fast_topk) { @@ -369,6 +358,18 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl } } +__global__ void SetupQsaSortKernel(int32_t* indices, int32_t* offsets, int num_items, + int rows, int dimension) { + for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + index < num_items; index += static_cast(gridDim.x) * blockDim.x) { + indices[index] = index % dimension; + } + for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + index <= rows; index += static_cast(gridDim.x) * blockDim.x) { + offsets[index] = index * dimension; + } +} + // --------------------------------------------------------------------------------------------- // policy_mode = "csa" // --------------------------------------------------------------------------------------------- From 5b77441d082be3be7b54fe27d2f0185e0d8d3cab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:15:13 +0000 Subject: [PATCH 040/102] Include CUDA kernel scratch API Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h | 1 + 1 file changed, 1 insertion(+) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 0c2e74e2a960b..5f84e99ad1d34 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -6,6 +6,7 @@ #include #include "core/providers/cuda/cuda_common.h" +#include "core/providers/cuda/cuda_kernel.h" namespace onnxruntime { namespace contrib { From 1f123608cc53f97c7f2f1ab74740541a34d4d198 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Thu, 17 Sep 2026 16:39:54 +0000 Subject: [PATCH 041/102] Fix sparse attention indexer CUDA kernel type --- .../cuda/sparse/sparse_attention_indexer_impl.cu | 13 +++++++------ .../cuda/sparse/sparse_attention_indexer_impl.h | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) 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 8c7ca01ad52fd..21122792f31d6 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -611,7 +611,8 @@ size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { } template -Status LaunchQsaSparseAttentionIndexer(const CudaKernel* kernel, cudaStream_t stream, void* alloc_stream, +Status LaunchQsaSparseAttentionIndexer(const onnxruntime::cuda::CudaKernel* kernel, cudaStream_t stream, + void* alloc_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, @@ -754,11 +755,11 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio return CUDA_CALL(cudaGetLastError()); } -#define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ - template Status LaunchQsaSparseAttentionIndexer(const CudaKernel*, cudaStream_t, void*, \ - const SparseAttentionIndexerParams&, \ - const T*, const T*, const T*, const T*, const T*, \ - const bool*, const T*, int32_t*, T*, float*, int32_t*); \ +#define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ + template Status LaunchQsaSparseAttentionIndexer(const onnxruntime::cuda::CudaKernel*, cudaStream_t, void*, \ + 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*); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 5f84e99ad1d34..74935b09a773e 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -55,7 +55,7 @@ size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); template Status LaunchQsaSparseAttentionIndexer( - const CudaKernel* kernel, + const onnxruntime::cuda::CudaKernel* kernel, cudaStream_t stream, void* alloc_stream, const SparseAttentionIndexerParams& params, From d2de2583444355923e9af1bd539c4a38e1d4507f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:33:24 +0000 Subject: [PATCH 042/102] Implement bounded exact QSA selection Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- cmake/onnxruntime_unittests.cmake | 1 + .../cuda/sparse_attention_indexer.md | 38 ++- .../cuda/sparse/sparse_attention_indexer.cc | 2 +- .../sparse/sparse_attention_indexer_impl.cu | 232 +++++++++++++----- .../sparse/sparse_attention_indexer_impl.h | 3 - .../sparse_attention_indexer_op_test.cc | 143 ++++++++++- .../sparse_attention_indexer.cc | 160 ++++++++++++ 7 files changed, 496 insertions(+), 83 deletions(-) create mode 100644 onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc diff --git a/cmake/onnxruntime_unittests.cmake b/cmake/onnxruntime_unittests.cmake index 5bb149d72096c..e1602637306f0 100644 --- a/cmake/onnxruntime_unittests.cmake +++ b/cmake/onnxruntime_unittests.cmake @@ -1567,6 +1567,7 @@ if (NOT onnxruntime_ENABLE_TRAINING_TORCH_INTEROP) ${BENCHMARK_DIR}/quantize.cc ${BENCHMARK_DIR}/reduceminmax.cc ${BENCHMARK_DIR}/layer_normalization.cc + $<$:${BENCHMARK_DIR}/sparse_attention_indexer.cc> $<$:${BENCHMARK_DIR}/paged_attention.cc>) target_include_directories(onnxruntime_benchmark PRIVATE ${ONNXRUNTIME_ROOT} ${onnxruntime_graph_header} ${ONNXRUNTIME_ROOT}/core/mlas/inc) target_compile_definitions(onnxruntime_benchmark PRIVATE BENCHMARK_STATIC_DEFINE) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index 1498fd5b96716..3ce7af184554c 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -258,9 +258,9 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l |---|---|---| | 1 | `ConcatPastKeyKernel` | element | | 2 | `RotateQueryKernel` | one block per `(b, s, h)` | -| 3 | `CompactVisibleKernel` | one block per `(b, s)`; Hillis–Steele scan in shared memory | +| 3 | `AnalyzeVisibleKernel` / `CompactNonPrefixVisibleKernel` | one block per `(b, s)`; prefix-causal rows avoid index materialization | | 4 | `QsaBlockScoreKernel` | one block per `(b, s, block)`; mean-pool → RMSNorm → rotary → score | -| 5 | `QsaSelectKernel` | one block per `(b, s)`; iterated block arg-max | +| 5 | `QsaBoundedTopKKernel` / `QsaSelectKernel` | one block per `(b, s)`; fixed-capacity radix selection and emission | ### `csa` @@ -278,17 +278,18 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l | Policy | Buffer | Elements | |---|---|---| | `qsa` | float | `B*S*max_block_count` (block scores) | -| `qsa` | int32 | `B*S*T` (visible indices) + `B*S` (visible counts) | +| `qsa` | int32 | `B*S*T` (arbitrary-mask visible indices) + `B*S` (visible counts) + up to `B*S*block_topk` (TopK indices) | | `csa` | float | `B*S*N*D` (rotated query) + `B*S*present_compressed_length` (scores) | Every size is derived from shapes and attributes only. ### Selection -For QSA rows with at least 512 candidate blocks, a stable segmented radix sort orders all block -scores in `O(entries)` work before `QsaSelectKernel` emits the requested blocks. Initial indices -are ascending, so the stable sort preserves the deterministic "score descending, index ascending" -order for ties. Smaller QSA rows and CSA use the in-kernel block TopK or repeated-scan path. +For QSA `block_topk` values from 33 through 512, `QsaBoundedTopKKernel` finds the stable TopK with +eight fixed-size radix-histogram passes and sorts only the selected candidates. Selection storage +and output traffic are therefore `O(block_topk)`, rather than `O(entries)`, while the packed +score/index key preserves deterministic "score descending, index ascending" ordering. Smaller QSA +rows use the in-kernel block TopK; larger configured TopK values retain the repeated-scan fallback. ## 8. Numerics and Determinism @@ -335,6 +336,8 @@ contains: 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. +- **Long-context parity tests** at 8K, 32K, 64K, 128K and 256K for + `compress_ratio=4, token_budget=2048`, plus fixed-address CUDA graph capture and replay. Run them with: @@ -342,21 +345,30 @@ Run them with: ./build/Linux/Release/onnxruntime_provider_test --gtest_filter='SparseAttentionIndexer*' ``` +With benchmarks enabled in a CUDA build, run the decode sweep with: + +```bash +./build/Linux/Release/onnxruntime_benchmark \ + --benchmark_filter=SparseAttentionIndexerQsaDecode +``` + ## 11. Known Limitations and Performance Follow-ups The implementation is correctness-first. The following are known and deliberate: -1. **Large QSA TopK uses a segmented radix sort.** This removes the `O(topk * entries)` repeated - scans at long context lengths. Rows below 512 candidate blocks retain the in-kernel block TopK - or repeated-scan fallback because radix-sort setup can cost more than selection at small sizes. +1. **Exact query-specific scoring remains `O(visible_tokens / compress_ratio)`.** Every decode query + can change and the schema permits an arbitrary mask per query, so an exact implementation must + inspect every eligible block; persistent candidates from an earlier query cannot preserve QSA + semantics. The bounded radix path removes full-context sorting and candidate buffers, but not + this scoring lower bound. 2. **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. **Query rotation is fused into QSA scoring.** This removes the separate rotation launch and the `B*S*N*D` float workspace, at the cost of recomputing rotation for each scored block. -4. **`CompactVisibleKernel` is `O(T)` per query row** and re-reads the mask for every `s`. Its - warp-ballot scan reduces synchronization overhead, but reusing visibility information across - query rows would require assumptions about mask structure that the operator schema does not make. +4. **Mask analysis remains `O(T)` per query row.** Prefix-causal masks avoid visible-index writes + and tile-by-tile scan synchronization. Arbitrary masks still require exact compaction because + block membership is defined by visible rank, not absolute position. 5. **`compress_ratio` and `head_size` are not specialized.** Templating the hot kernels on a small set of common values would remove the dynamic loop bounds. 6. **No CPU kernel.** The operator is CUDA-only today. diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index e23fc1a35e5b5..7ab5a5c780a2a 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -235,7 +235,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), GetComputeStream(context)); return LaunchQsaSparseAttentionIndexer( - this, Stream(context), GetComputeStream(context), params, + Stream(context), params, reinterpret_cast(query->Data()), reinterpret_cast(key->Data()), reinterpret_cast(key_norm_weight->Data()), 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 21122792f31d6..86d364d9c419f 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -11,13 +11,13 @@ #include #include #include -#include +#include #include -#include #include "contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh" #include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" +#include "core/providers/cuda/cu_inc/topk_warp_sort.cuh" namespace onnxruntime { namespace contrib { @@ -29,7 +29,8 @@ namespace { constexpr int kThreads = 128; constexpr int kWarpSize = 32; constexpr int64_t kMaxGridDimX = kSaiMaxGridDimX; -constexpr int kRadixSortMinBlockCount = 512; +constexpr int kBoundedTopKMax = 512; +constexpr int kBoundedTopKItemsPerThread = kBoundedTopKMax / kThreads; // Thin, same-signature aliases over the device math shared with the packed indexer implementation // (sparse_attention_indexer_device_math.cuh), so the kernels below are unchanged. @@ -142,12 +143,52 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention } } -// One block per query row; compacts the visible key positions of that row into visible_indices. -__global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, int32_t* visible_count, +// Encodes a prefix-visible row as -count-1. QSA can then index that row directly without +// materializing its visible positions. Non-prefix rows keep a nonnegative count and are compacted +// by CompactNonPrefixVisibleKernel. +__global__ void AnalyzeVisibleKernel(const bool* mask, int32_t* visible_count, SparseAttentionIndexerParams params) { + __shared__ int32_t counts[kThreads]; + __shared__ int32_t maxima[kThreads]; + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const bool* mask_row = mask + row * params.total_sequence_length; + int32_t count = 0; + int32_t maximum = -1; + for (int position = threadIdx.x; position < params.total_sequence_length; position += blockDim.x) { + if (mask_row[position]) { + ++count; + maximum = position; + } + } + counts[threadIdx.x] = count; + maxima[threadIdx.x] = maximum; + __syncthreads(); + for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) { + if (threadIdx.x < stride) { + counts[threadIdx.x] += counts[threadIdx.x + stride]; + maxima[threadIdx.x] = max(maxima[threadIdx.x], maxima[threadIdx.x + stride]); + } + __syncthreads(); + } + if (threadIdx.x == 0) { + const int32_t visible = counts[0]; + visible_count[row] = maximum == visible - 1 ? -visible - 1 : visible; + } + __syncthreads(); + } +} + +// One block per non-prefix query row; compacts visible key positions into visible_indices. +__global__ void CompactNonPrefixVisibleKernel(const bool* mask, int32_t* visible_indices, + const int32_t* visible_count, + SparseAttentionIndexerParams params) { __shared__ int32_t warp_offsets[kThreads / kWarpSize + 1]; const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + if (visible_count[row] < 0) { + continue; + } 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; @@ -179,9 +220,6 @@ __global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, } offset += tile_total; } - if (threadIdx.x == 0) { - visible_count[row] = offset; - } __syncthreads(); } } @@ -204,7 +242,10 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const const int block_index = static_cast(work % params.max_block_count); const int64_t row = work / params.max_block_count; const int batch = static_cast(row / params.sequence_length); - const int block_count = visible_count[row] / params.compress_ratio; + const int encoded_visible = visible_count[row]; + const bool prefix_visible = encoded_visible < 0; + const int visible = prefix_visible ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; if (block_index >= block_count) { if (threadIdx.x == 0) { @@ -220,7 +261,8 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { float sum = 0.0f; for (int t = 0; t < params.compress_ratio; ++t) { - sum += to_float(present_key[key_base + static_cast(group[t]) * params.head_size + d]); + const int position = prefix_visible ? block_index * params.compress_ratio + t : group[t]; + sum += to_float(present_key[key_base + static_cast(position) * params.head_size + d]); } pooled[d] = sum / static_cast(params.compress_ratio); } @@ -237,7 +279,8 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const } __syncthreads(); - const int position = ClampPosition(group[0], params.max_rotary_length); + const int first_position = prefix_visible ? block_index * params.compress_ratio : group[0]; + const int position = ClampPosition(first_position, params.max_rotary_length); const int64_t cache_offset = (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { @@ -297,7 +340,9 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl } __syncthreads(); - const int visible = visible_count[row]; + const int encoded_visible = visible_count[row]; + const bool prefix_visible = encoded_visible < 0; + const int visible = prefix_visible ? -encoded_visible - 1 : encoded_visible; const int block_count = visible / params.compress_ratio; const int selected = min(params.block_topk, block_count); const float* scores_row = block_scores + row * params.max_block_count; @@ -305,12 +350,13 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl int emitted = selected; if (topk_indices != nullptr) { - const int32_t* topk_row = topk_indices + row * params.max_block_count; + const int32_t* topk_row = topk_indices + row * params.block_topk; for (int rank = 0; rank < selected; ++rank) { const int selected_block = static_cast(topk_row[rank]); for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { out_row[rank * params.compress_ratio + t] = - index_row[selected_block * params.compress_ratio + t]; + prefix_visible ? selected_block * params.compress_ratio + t + : index_row[selected_block * params.compress_ratio + t]; } } __syncthreads(); @@ -320,7 +366,8 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl const int selected_block = shared_index[rank]; for (int t = threadIdx.x; t < params.compress_ratio; t += blockDim.x) { out_row[rank * params.compress_ratio + t] = - index_row[selected_block * params.compress_ratio + t]; + prefix_visible ? selected_block * params.compress_ratio + t + : index_row[selected_block * params.compress_ratio + t]; } } __syncthreads(); @@ -343,7 +390,9 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl 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]; + out_row[rank * params.compress_ratio + t] = + prefix_visible ? previous_index * params.compress_ratio + t + : index_row[previous_index * params.compress_ratio + t]; } emitted = rank + 1; __syncthreads(); @@ -352,21 +401,102 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl const int tail_start = block_count * params.compress_ratio; for (int t = threadIdx.x; t < visible - tail_start; t += blockDim.x) { - out_row[emitted * params.compress_ratio + t] = index_row[tail_start + t]; + out_row[emitted * params.compress_ratio + t] = + prefix_visible ? tail_start + t : index_row[tail_start + t]; } __syncthreads(); } } -__global__ void SetupQsaSortKernel(int32_t* indices, int32_t* offsets, int num_items, - int rows, int dimension) { - for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; - index < num_items; index += static_cast(gridDim.x) * blockDim.x) { - indices[index] = index % dimension; +// One block per query row. Finds at most kBoundedTopKMax stable TopK indices with a fixed-size +// radix histogram and output buffer. Work remains linear in the number of scored blocks, but +// selection storage and writes are bounded by block_topk instead of the full context. +__global__ void QsaBoundedTopKKernel(const float* block_scores, const int32_t* visible_count, + int32_t* topk_indices, SparseAttentionIndexerParams params) { + using Sort = cub::BlockRadixSort; + __shared__ union { + uint32_t histogram[256]; + typename Sort::TempStorage sort; + } temp; + __shared__ uint64_t selected_keys[kBoundedTopKMax]; + __shared__ uint64_t prefix; + __shared__ int remaining; + __shared__ int gathered; + + const int64_t row = blockIdx.x; + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + const int selected = min(params.block_topk, block_count); + if (selected == 0) { + return; + } + const float* scores = block_scores + row * params.max_block_count; + + if (threadIdx.x == 0) { + prefix = 0; + remaining = selected; + } + __syncthreads(); + + for (int shift = 56; shift >= 0; shift -= 8) { + for (int bucket = threadIdx.x; bucket < 256; bucket += blockDim.x) { + temp.histogram[bucket] = 0; + } + __syncthreads(); + const uint64_t current_prefix = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint64_t key = topk::PackStableSortKey(scores[index], index); + if (shift == 56 || (key >> (shift + 8)) == (current_prefix >> (shift + 8))) { + atomicAdd(&temp.histogram[(key >> shift) & 0xffu], 1u); + } + } + __syncthreads(); + if (threadIdx.x == 0) { + int rank = remaining; + for (int bucket = 255; bucket >= 0; --bucket) { + const int count = static_cast(temp.histogram[bucket]); + if (rank > count) { + rank -= count; + } else { + prefix |= static_cast(bucket) << shift; + remaining = rank; + break; + } + } + } + __syncthreads(); + } + + if (threadIdx.x == 0) { + gathered = 0; } - for (int index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; - index <= rows; index += static_cast(gridDim.x) * blockDim.x) { - offsets[index] = index * dimension; + __syncthreads(); + const uint64_t threshold = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint64_t key = topk::PackStableSortKey(scores[index], index); + if (key >= threshold) { + const int slot = atomicAdd(&gathered, 1); + if (slot < selected) { + selected_keys[slot] = key; + } + } + } + __syncthreads(); + + uint64_t keys[kBoundedTopKItemsPerThread]; +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; + } + Sort(temp.sort).SortDescending(keys); +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + if (rank < selected) { + topk_indices[row * params.block_topk + rank] = topk::UnpackStableSortIndex(keys[item]); + } } } @@ -602,7 +732,8 @@ size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { 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; + return rows * std::max(params.total_sequence_length, 1) + rows + + rows * std::min(params.block_topk, kBoundedTopKMax); } size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { @@ -611,9 +742,7 @@ size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { } template -Status LaunchQsaSparseAttentionIndexer(const onnxruntime::cuda::CudaKernel* kernel, cudaStream_t stream, - void* alloc_stream, - const SparseAttentionIndexerParams& params, +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, @@ -641,11 +770,13 @@ Status LaunchQsaSparseAttentionIndexer(const onnxruntime::cuda::CudaKernel* kern float* block_scores = float_workspace; int32_t* visible_indices = int_workspace; int32_t* visible_count = int_workspace + rows * params.total_sequence_length; + int32_t* topk_indices = visible_count + rows; const size_t value_bytes = static_cast(params.head_size) * sizeof(float); const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); - CompactVisibleKernel<<>>( + AnalyzeVisibleKernel<<>>(mask, visible_count, params); + CompactNonPrefixVisibleKernel<<>>( mask, visible_indices, visible_count, params); if (params.max_block_count > 0) { @@ -656,42 +787,20 @@ Status LaunchQsaSparseAttentionIndexer(const onnxruntime::cuda::CudaKernel* kern block_scores, params); } - const int64_t sort_items_64 = rows * params.max_block_count; - const bool use_radix_sort = - params.max_block_count >= kRadixSortMinBlockCount && - sort_items_64 <= std::numeric_limits::max(); - IAllocatorUniquePtr sorted_scores; - IAllocatorUniquePtr sort_indices_in; - IAllocatorUniquePtr sort_indices_out; - IAllocatorUniquePtr sort_offsets; - if (use_radix_sort) { - const int sort_items = static_cast(sort_items_64); - const int sort_rows = static_cast(rows); - sorted_scores = kernel->GetScratchBuffer(sort_items, alloc_stream); - sort_indices_in = kernel->GetScratchBuffer(sort_items, alloc_stream); - sort_indices_out = kernel->GetScratchBuffer(sort_items, alloc_stream); - sort_offsets = kernel->GetScratchBuffer(static_cast(sort_rows) + 1, alloc_stream); - SetupQsaSortKernel<<>>( - sort_indices_in.get(), sort_offsets.get(), sort_items, sort_rows, params.max_block_count); - - size_t temp_storage_bytes = 0; - ORT_RETURN_IF_ERROR(CUDA_CALL(cub::DeviceSegmentedRadixSort::SortPairsDescending( - nullptr, temp_storage_bytes, block_scores, sorted_scores.get(), - sort_indices_in.get(), sort_indices_out.get(), sort_items, sort_rows, - sort_offsets.get(), sort_offsets.get() + 1, 0, sizeof(float) * 8, stream))); - auto temp_storage = kernel->GetScratchBuffer(temp_storage_bytes, alloc_stream); - ORT_RETURN_IF_ERROR(CUDA_CALL(cub::DeviceSegmentedRadixSort::SortPairsDescending( - temp_storage.get(), temp_storage_bytes, block_scores, sorted_scores.get(), - sort_indices_in.get(), sort_indices_out.get(), sort_items, sort_rows, - sort_offsets.get(), sort_offsets.get() + 1, 0, sizeof(float) * 8, stream))); + const bool use_bounded_topk = + params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; + if (use_bounded_topk) { + QsaBoundedTopKKernel<<>>( + block_scores, visible_count, topk_indices, params); } - const int topk_shared_entries = !use_radix_sort && params.block_topk <= kSaiFastTopKMax + const int topk_shared_entries = !use_bounded_topk && params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( - block_scores, visible_indices, visible_count, sort_indices_out.get(), selected_indices, params); + block_scores, visible_indices, visible_count, use_bounded_topk ? topk_indices : nullptr, + selected_indices, params); return CUDA_CALL(cudaGetLastError()); } @@ -756,8 +865,7 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio } #define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ - template Status LaunchQsaSparseAttentionIndexer(const onnxruntime::cuda::CudaKernel*, cudaStream_t, void*, \ - const SparseAttentionIndexerParams&, \ + 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( \ diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 74935b09a773e..ebc5f1f05945e 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -6,7 +6,6 @@ #include #include "core/providers/cuda/cuda_common.h" -#include "core/providers/cuda/cuda_kernel.h" namespace onnxruntime { namespace contrib { @@ -55,9 +54,7 @@ size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); template Status LaunchQsaSparseAttentionIndexer( - const onnxruntime::cuda::CudaKernel* kernel, cudaStream_t stream, - void* alloc_stream, const SparseAttentionIndexerParams& params, const T* query, const T* key, 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 ca19574838faf..314d55981d1ca 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -37,6 +37,12 @@ #include "test/util/include/asserts.h" #include "test/util/include/default_providers.h" +#ifdef USE_CUDA +#include + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" +#endif + namespace onnxruntime { namespace test { @@ -1059,16 +1065,145 @@ TEST(SparseAttentionIndexerTest, QsaRepeatedScanTopKFallback) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } -TEST(SparseAttentionIndexerTest, QsaLargeContextTopK) { +TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKParity) { + for (const int context_length : {8192, 32768, 65536, 131072, 262144}) { + SCOPED_TRACE("context_length=" + std::to_string(context_length)); + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = context_length - 1; + problem.compress_ratio = 4; + problem.token_budget = 2048; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); + } +} + +TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKTies) { QsaProblem problem; problem.batch_size = 1; problem.sequence_length = 1; - problem.past_sequence_length = 65536; - problem.compress_ratio = 16; - problem.token_budget = 8192; + problem.past_sequence_length = 8191; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem.scale = 0.0f; RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +#ifdef USE_CUDA +TEST(SparseAttentionIndexerTest, QsaDecodeCudaGraphCaptureAndReplay) { + constexpr int kContextLength = 8192; + constexpr int kHeadSize = 4; + constexpr int kNumHeads = 2; + constexpr int kTokenBudget = 2048; + constexpr int kCompressRatio = 4; + + contrib::cuda::SparseAttentionIndexerParams params; + params.batch_size = 1; + params.sequence_length = 1; + params.num_heads = kNumHeads; + params.head_size = kHeadSize; + params.rotary_width = kHeadSize; + params.max_rotary_length = kContextLength; + params.compress_ratio = kCompressRatio; + params.capacity = kTokenBudget + kCompressRatio - 1; + params.scale = 0.5f; + params.past_sequence_length = kContextLength - 1; + params.total_sequence_length = kContextLength; + params.past_key_capacity = kContextLength; + params.key_cache_capacity = kContextLength; + params.max_block_count = kContextLength / kCompressRatio; + params.block_topk = kTokenBudget / kCompressRatio; + + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamCreate(&stream)); + + float* query = nullptr; + float* key = nullptr; + float* weight = nullptr; + float* cosine = nullptr; + float* sine = nullptr; + bool* mask = nullptr; + float* key_cache = nullptr; + int32_t* selected = nullptr; + float* float_workspace = nullptr; + int32_t* int_workspace = nullptr; + ASSERT_EQ(cudaSuccess, cudaMalloc(&query, kNumHeads * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&key, kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&weight, kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&cosine, kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&sine, kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&mask, kContextLength * sizeof(bool))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&key_cache, kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&selected, params.capacity * sizeof(int32_t))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&float_workspace, + contrib::cuda::GetQsaWorkspaceFloatCount(params) * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(&int_workspace, + contrib::cuda::GetQsaWorkspaceIntCount(params) * sizeof(int32_t))); + + std::vector ones(kContextLength * kHeadSize, 1.0f); + std::vector zeros(kContextLength * kHeadSize, 0.0f); + std::unique_ptr visible(new bool[kContextLength]); + std::fill_n(visible.get(), kContextLength, true); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(query, ones.data(), kNumHeads * kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key, ones.data(), kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(weight, ones.data(), kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(cosine, ones.data(), ones.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(sine, zeros.data(), zeros.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(mask, visible.get(), kContextLength * sizeof(bool), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key_cache, ones.data(), ones.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + const auto launch = [&]() { + return contrib::cuda::LaunchQsaSparseAttentionIndexer( + stream, params, query, key, weight, cosine, sine, mask, key_cache, selected, key_cache, + float_workspace, int_workspace); + }; + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + cudaGraph_t graph = nullptr; + cudaGraphExec_t graph_exec = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal)); + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamEndCapture(stream, &graph)); + ASSERT_EQ(cudaSuccess, cudaGraphInstantiate(&graph_exec, graph, nullptr, nullptr, 0)); + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + std::vector actual(params.capacity); + ASSERT_EQ(cudaSuccess, cudaMemcpy(actual.data(), selected, actual.size() * sizeof(int32_t), + cudaMemcpyDeviceToHost)); + for (int i = 0; i < kTokenBudget; ++i) { + EXPECT_EQ(actual[i], i); + } + for (int i = kTokenBudget; i < params.capacity; ++i) { + EXPECT_EQ(actual[i], -1); + } + + ASSERT_EQ(cudaSuccess, cudaGraphExecDestroy(graph_exec)); + ASSERT_EQ(cudaSuccess, cudaGraphDestroy(graph)); + ASSERT_EQ(cudaSuccess, cudaFree(int_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(float_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(selected)); + ASSERT_EQ(cudaSuccess, cudaFree(key_cache)); + ASSERT_EQ(cudaSuccess, cudaFree(mask)); + ASSERT_EQ(cudaSuccess, cudaFree(sine)); + ASSERT_EQ(cudaSuccess, cudaFree(cosine)); + ASSERT_EQ(cudaSuccess, cudaFree(weight)); + ASSERT_EQ(cudaSuccess, cudaFree(key)); + ASSERT_EQ(cudaSuccess, cudaFree(query)); + ASSERT_EQ(cudaSuccess, cudaStreamDestroy(stream)); +} +#endif + TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; diff --git a/onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc b/onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc new file mode 100644 index 0000000000000..4aefac84e73e7 --- /dev/null +++ b/onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include +#include + +#include + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" + +namespace onnxruntime { +namespace test { +namespace { + +using contrib::cuda::GetQsaWorkspaceFloatCount; +using contrib::cuda::GetQsaWorkspaceIntCount; +using contrib::cuda::LaunchQsaSparseAttentionIndexer; +using contrib::cuda::SparseAttentionIndexerParams; + +struct QsaBuffers { + float* query{}; + float* key{}; + float* weight{}; + float* cosine{}; + float* sine{}; + bool* mask{}; + float* key_cache{}; + int32_t* selected{}; + float* float_workspace{}; + int32_t* int_workspace{}; + + ~QsaBuffers() { + cudaFree(int_workspace); + cudaFree(float_workspace); + cudaFree(selected); + cudaFree(key_cache); + cudaFree(mask); + cudaFree(sine); + cudaFree(cosine); + cudaFree(weight); + cudaFree(key); + cudaFree(query); + } +}; + +bool Allocate(void** output, size_t bytes, benchmark::State& state) { + const cudaError_t error = cudaMalloc(output, bytes); + if (error != cudaSuccess) { + state.SkipWithError(cudaGetErrorString(error)); + return false; + } + return true; +} + +void BM_SparseAttentionIndexerQsaDecode(benchmark::State& state) { + int device_count = 0; + if (cudaGetDeviceCount(&device_count) != cudaSuccess || device_count == 0) { + state.SkipWithError("CUDA device unavailable"); + return; + } + + constexpr int kHeadSize = 128; + constexpr int kNumHeads = 4; + constexpr int kTokenBudget = 2048; + constexpr int kCompressRatio = 4; + const int context_length = static_cast(state.range(0)); + + SparseAttentionIndexerParams params; + params.batch_size = 1; + params.sequence_length = 1; + params.num_heads = kNumHeads; + params.head_size = kHeadSize; + params.rotary_width = kHeadSize; + params.max_rotary_length = context_length; + params.compress_ratio = kCompressRatio; + params.capacity = kTokenBudget + kCompressRatio - 1; + params.scale = 1.0f; + params.past_sequence_length = context_length - 1; + params.total_sequence_length = context_length; + params.past_key_capacity = context_length; + params.key_cache_capacity = context_length; + params.max_block_count = context_length / kCompressRatio; + params.block_topk = kTokenBudget / kCompressRatio; + + QsaBuffers buffers; + if (!Allocate(reinterpret_cast(&buffers.query), kNumHeads * kHeadSize * sizeof(float), state) || + !Allocate(reinterpret_cast(&buffers.key), kHeadSize * sizeof(float), state) || + !Allocate(reinterpret_cast(&buffers.weight), kHeadSize * sizeof(float), state) || + !Allocate(reinterpret_cast(&buffers.cosine), + static_cast(context_length) * kHeadSize * sizeof(float), state) || + !Allocate(reinterpret_cast(&buffers.sine), + static_cast(context_length) * kHeadSize * sizeof(float), state) || + !Allocate(reinterpret_cast(&buffers.mask), context_length * sizeof(bool), state) || + !Allocate(reinterpret_cast(&buffers.key_cache), + static_cast(context_length) * kHeadSize * sizeof(float), state) || + !Allocate(reinterpret_cast(&buffers.selected), params.capacity * sizeof(int32_t), state) || + !Allocate(reinterpret_cast(&buffers.float_workspace), + GetQsaWorkspaceFloatCount(params) * sizeof(float), state) || + !Allocate(reinterpret_cast(&buffers.int_workspace), + GetQsaWorkspaceIntCount(params) * sizeof(int32_t), state)) { + return; + } + + cudaStream_t stream = nullptr; + if (cudaStreamCreate(&stream) != cudaSuccess) { + state.SkipWithError("cudaStreamCreate failed"); + return; + } + cudaMemsetAsync(buffers.query, 0, kNumHeads * kHeadSize * sizeof(float), stream); + cudaMemsetAsync(buffers.key, 0, kHeadSize * sizeof(float), stream); + cudaMemsetAsync(buffers.weight, 0, kHeadSize * sizeof(float), stream); + cudaMemsetAsync(buffers.cosine, 0, static_cast(context_length) * kHeadSize * sizeof(float), stream); + cudaMemsetAsync(buffers.sine, 0, static_cast(context_length) * kHeadSize * sizeof(float), stream); + cudaMemsetAsync(buffers.mask, 1, context_length * sizeof(bool), stream); + cudaMemsetAsync(buffers.key_cache, 0, static_cast(context_length) * kHeadSize * sizeof(float), stream); + cudaStreamSynchronize(stream); + + const auto launch = [&]() { + return LaunchQsaSparseAttentionIndexer( + stream, params, buffers.query, buffers.key, buffers.weight, buffers.cosine, buffers.sine, + buffers.mask, buffers.key_cache, buffers.selected, buffers.key_cache, + buffers.float_workspace, buffers.int_workspace); + }; + for (int i = 0; i < 5; ++i) { + if (!launch().IsOK()) { + state.SkipWithError("SparseAttentionIndexer warmup failed"); + cudaStreamDestroy(stream); + return; + } + } + cudaStreamSynchronize(stream); + + for (auto _ : state) { + const auto start = std::chrono::steady_clock::now(); + if (!launch().IsOK() || cudaStreamSynchronize(stream) != cudaSuccess) { + state.SkipWithError("SparseAttentionIndexer execution failed"); + break; + } + const auto end = std::chrono::steady_clock::now(); + state.SetIterationTime(std::chrono::duration(end - start).count()); + } + + state.counters["context"] = context_length; + state.counters["selected"] = kTokenBudget; + cudaStreamDestroy(stream); +} + +BENCHMARK(BM_SparseAttentionIndexerQsaDecode) + ->ArgName("context") + ->Arg(8192) + ->Arg(32768) + ->Arg(65536) + ->Arg(131072) + ->Arg(262144) + ->Unit(benchmark::kMicrosecond) + ->UseManualTime(); + +} // namespace +} // namespace test +} // namespace onnxruntime From a6c7873df11ed3bec7c51be9c1513558140e1c9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:44:19 +0000 Subject: [PATCH 043/102] Finalize bounded QSA decode path Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- cmake/onnxruntime_unittests.cmake | 1 - .../cuda/sparse_attention_indexer.md | 9 +- .../sparse/sparse_attention_indexer_impl.cu | 135 ++++++++------- .../microbench/sparse_attention_indexer.py | 114 +++++++++++++ .../sparse_attention_indexer_kernel_test.cc | 136 +++++++++++++++ .../sparse_attention_indexer_op_test.cc | 143 +++------------- .../sparse_attention_indexer.cc | 160 ------------------ 7 files changed, 347 insertions(+), 351 deletions(-) create mode 100644 onnxruntime/python/tools/microbench/sparse_attention_indexer.py create mode 100644 onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc delete mode 100644 onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc diff --git a/cmake/onnxruntime_unittests.cmake b/cmake/onnxruntime_unittests.cmake index e1602637306f0..5bb149d72096c 100644 --- a/cmake/onnxruntime_unittests.cmake +++ b/cmake/onnxruntime_unittests.cmake @@ -1567,7 +1567,6 @@ if (NOT onnxruntime_ENABLE_TRAINING_TORCH_INTEROP) ${BENCHMARK_DIR}/quantize.cc ${BENCHMARK_DIR}/reduceminmax.cc ${BENCHMARK_DIR}/layer_normalization.cc - $<$:${BENCHMARK_DIR}/sparse_attention_indexer.cc> $<$:${BENCHMARK_DIR}/paged_attention.cc>) target_include_directories(onnxruntime_benchmark PRIVATE ${ONNXRUNTIME_ROOT} ${onnxruntime_graph_header} ${ONNXRUNTIME_ROOT}/core/mlas/inc) target_compile_definitions(onnxruntime_benchmark PRIVATE BENCHMARK_STATIC_DEFINE) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index 3ce7af184554c..f2517da97d26b 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -337,7 +337,9 @@ contains: 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. - **Long-context parity tests** at 8K, 32K, 64K, 128K and 256K for - `compress_ratio=4, token_budget=2048`, plus fixed-address CUDA graph capture and replay. + `compress_ratio=4, token_budget=2048`. +- **Fixed-address CUDA graph capture and replay** in + `onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc`. Run them with: @@ -345,11 +347,10 @@ Run them with: ./build/Linux/Release/onnxruntime_provider_test --gtest_filter='SparseAttentionIndexer*' ``` -With benchmarks enabled in a CUDA build, run the decode sweep with: +Run the CUDA decode sweep against a built Python package with: ```bash -./build/Linux/Release/onnxruntime_benchmark \ - --benchmark_filter=SparseAttentionIndexerQsaDecode +python onnxruntime/python/tools/microbench/sparse_attention_indexer.py ``` ## 11. Known Limitations and Performance Follow-ups 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 86d364d9c419f..b7d667fb4727c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -23,6 +23,8 @@ namespace onnxruntime { namespace contrib { namespace cuda { +namespace topk = onnxruntime::cuda::topk; + namespace { // The block reductions below halve the active thread count, so this must stay a power of two. @@ -173,7 +175,7 @@ __global__ void AnalyzeVisibleKernel(const bool* mask, int32_t* visible_count, } if (threadIdx.x == 0) { const int32_t visible = counts[0]; - visible_count[row] = maximum == visible - 1 ? -visible - 1 : visible; + visible_count[row] = maxima[0] == visible - 1 ? -visible - 1 : visible; } __syncthreads(); } @@ -423,80 +425,83 @@ __global__ void QsaBoundedTopKKernel(const float* block_scores, const int32_t* v __shared__ int remaining; __shared__ int gathered; - const int64_t row = blockIdx.x; - const int encoded_visible = visible_count[row]; - const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; - const int block_count = visible / params.compress_ratio; - const int selected = min(params.block_topk, block_count); - if (selected == 0) { - return; - } - const float* scores = block_scores + row * params.max_block_count; - - if (threadIdx.x == 0) { - prefix = 0; - remaining = selected; - } - __syncthreads(); + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + const int selected = min(params.block_topk, block_count); + if (selected == 0) { + continue; + } + const float* scores = block_scores + row * params.max_block_count; - for (int shift = 56; shift >= 0; shift -= 8) { - for (int bucket = threadIdx.x; bucket < 256; bucket += blockDim.x) { - temp.histogram[bucket] = 0; + if (threadIdx.x == 0) { + prefix = 0; + remaining = selected; } __syncthreads(); - const uint64_t current_prefix = prefix; - for (int index = threadIdx.x; index < block_count; index += blockDim.x) { - const uint64_t key = topk::PackStableSortKey(scores[index], index); - if (shift == 56 || (key >> (shift + 8)) == (current_prefix >> (shift + 8))) { - atomicAdd(&temp.histogram[(key >> shift) & 0xffu], 1u); + + for (int shift = 56; shift >= 0; shift -= 8) { + for (int bucket = threadIdx.x; bucket < 256; bucket += blockDim.x) { + temp.histogram[bucket] = 0; } - } - __syncthreads(); - if (threadIdx.x == 0) { - int rank = remaining; - for (int bucket = 255; bucket >= 0; --bucket) { - const int count = static_cast(temp.histogram[bucket]); - if (rank > count) { - rank -= count; - } else { - prefix |= static_cast(bucket) << shift; - remaining = rank; - break; + __syncthreads(); + const uint64_t current_prefix = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint64_t key = topk::PackStableSortKey(scores[index], index); + if (shift == 56 || (key >> (shift + 8)) == (current_prefix >> (shift + 8))) { + atomicAdd(&temp.histogram[(key >> shift) & 0xffu], 1u); } } + __syncthreads(); + if (threadIdx.x == 0) { + int rank = remaining; + for (int bucket = 255; bucket >= 0; --bucket) { + const int count = static_cast(temp.histogram[bucket]); + if (rank > count) { + rank -= count; + } else { + prefix |= static_cast(bucket) << shift; + remaining = rank; + break; + } + } + } + __syncthreads(); } - __syncthreads(); - } - if (threadIdx.x == 0) { - gathered = 0; - } - __syncthreads(); - const uint64_t threshold = prefix; - for (int index = threadIdx.x; index < block_count; index += blockDim.x) { - const uint64_t key = topk::PackStableSortKey(scores[index], index); - if (key >= threshold) { - const int slot = atomicAdd(&gathered, 1); - if (slot < selected) { - selected_keys[slot] = key; + if (threadIdx.x == 0) { + gathered = 0; + } + __syncthreads(); + const uint64_t threshold = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint64_t key = topk::PackStableSortKey(scores[index], index); + if (key >= threshold) { + const int slot = atomicAdd(&gathered, 1); + if (slot < selected) { + selected_keys[slot] = key; + } } } - } - __syncthreads(); + __syncthreads(); - uint64_t keys[kBoundedTopKItemsPerThread]; + uint64_t keys[kBoundedTopKItemsPerThread]; #pragma unroll - for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { - const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; - keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; - } - Sort(temp.sort).SortDescending(keys); + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; + } + Sort(temp.sort).SortDescending(keys); #pragma unroll - for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { - const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; - if (rank < selected) { - topk_indices[row * params.block_topk + rank] = topk::UnpackStableSortIndex(keys[item]); + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + if (rank < selected) { + topk_indices[row * params.block_topk + rank] = topk::UnpackStableSortIndex(keys[item]); + } } + __syncthreads(); } } @@ -864,10 +869,10 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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*); \ +#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*); diff --git a/onnxruntime/python/tools/microbench/sparse_attention_indexer.py b/onnxruntime/python/tools/microbench/sparse_attention_indexer.py new file mode 100644 index 0000000000000..fe136c9542ca8 --- /dev/null +++ b/onnxruntime/python/tools/microbench/sparse_attention_indexer.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import argparse +import time + +import numpy as np +import onnx +import onnxruntime as ort +from onnx import TensorProto, helper + + +def create_model(context_length: int, head_size: int, num_heads: int) -> bytes: + token_budget = 2048 + compress_ratio = 4 + capacity = token_budget + compress_ratio - 1 + inputs = [ + helper.make_tensor_value_info("query", TensorProto.FLOAT16, [1, 1, num_heads, head_size]), + helper.make_tensor_value_info("key", TensorProto.FLOAT16, [1, 1, head_size]), + helper.make_tensor_value_info("weight", TensorProto.FLOAT16, [head_size]), + helper.make_tensor_value_info("cosine", TensorProto.FLOAT16, [1, context_length, head_size]), + helper.make_tensor_value_info("sine", TensorProto.FLOAT16, [1, context_length, head_size]), + helper.make_tensor_value_info("mask", TensorProto.BOOL, [1, 1, 1, context_length]), + helper.make_tensor_value_info("key_cache", TensorProto.FLOAT16, [1, context_length, head_size]), + helper.make_tensor_value_info("past_sequence_length", TensorProto.INT32, [1]), + ] + outputs = [ + helper.make_tensor_value_info("selected", TensorProto.INT32, [1, 1, capacity]), + helper.make_tensor_value_info("present_key", TensorProto.FLOAT16, [1, context_length, head_size]), + ] + node = helper.make_node( + "SparseAttentionIndexer", + [ + "query", + "key", + "weight", + "cosine", + "sine", + "mask", + "key_cache", + "", + "", + "", + "", + "past_sequence_length", + ], + ["selected", "present_key"], + domain="com.microsoft", + policy_mode="qsa", + compress_ratio=compress_ratio, + token_budget=token_budget, + ) + graph = helper.make_graph([node], "sparse_attention_indexer_benchmark", inputs, outputs) + model = helper.make_model( + graph, + opset_imports=[helper.make_opsetid("", 17), helper.make_opsetid("com.microsoft", 1)], + ir_version=10, + ) + return model.SerializeToString() + + +def benchmark(context_length: int, warmup: int, iterations: int) -> float: + head_size = 128 + num_heads = 4 + session = ort.InferenceSession( + create_model(context_length, head_size, num_heads), + providers=["CUDAExecutionProvider"], + ) + inputs = { + "query": np.zeros((1, 1, num_heads, head_size), dtype=np.float16), + "key": np.zeros((1, 1, head_size), dtype=np.float16), + "weight": np.ones((head_size,), dtype=np.float16), + "cosine": np.ones((1, context_length, head_size), dtype=np.float16), + "sine": np.zeros((1, context_length, head_size), dtype=np.float16), + "mask": np.ones((1, 1, 1, context_length), dtype=bool), + "key_cache": np.zeros((1, context_length, head_size), dtype=np.float16), + "past_sequence_length": np.array([context_length - 1], dtype=np.int32), + } + io_binding = session.io_binding() + device_inputs = {} + for name, value in inputs.items(): + device_inputs[name] = ort.OrtValue.ortvalue_from_numpy(value, "cuda", 0) + io_binding.bind_ortvalue_input(name, device_inputs[name]) + selected = ort.OrtValue.ortvalue_from_shape_and_type([1, 1, 2051], np.int32, "cuda", 0) + io_binding.bind_ortvalue_output("selected", selected) + io_binding.bind_ortvalue_output("present_key", device_inputs["key_cache"]) + + for _ in range(warmup): + session.run_with_iobinding(io_binding) + io_binding.synchronize_outputs() + + start = time.perf_counter() + for _ in range(iterations): + session.run_with_iobinding(io_binding) + io_binding.synchronize_outputs() + return (time.perf_counter() - start) * 1000.0 / iterations + + +def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark CUDA QSA SparseAttentionIndexer decode") + parser.add_argument("--contexts", nargs="+", type=int, default=[8192, 32768, 65536, 131072, 262144]) + parser.add_argument("--warmup", type=int, default=10) + parser.add_argument("--iterations", type=int, default=100) + args = parser.parse_args() + + if "CUDAExecutionProvider" not in ort.get_available_providers(): + raise RuntimeError("CUDAExecutionProvider is unavailable") + for context_length in args.contexts: + latency = benchmark(context_length, args.warmup, args.iterations) + print(f"context={context_length:>6} latency_ms={latency:.3f}") + + +if __name__ == "__main__": + main() diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc new file mode 100644 index 0000000000000..00680078a8428 --- /dev/null +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include + +#include +#include +#include + +#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" +#include "gtest/gtest.h" + +namespace onnxruntime { +namespace contrib { +namespace cuda { +namespace { + +TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { + constexpr int kContextLength = 8192; + constexpr int kHeadSize = 4; + constexpr int kNumHeads = 2; + constexpr int kTokenBudget = 2048; + constexpr int kCompressRatio = 4; + + SparseAttentionIndexerParams params; + params.batch_size = 1; + params.sequence_length = 1; + params.num_heads = kNumHeads; + params.head_size = kHeadSize; + params.rotary_width = kHeadSize; + params.max_rotary_length = kContextLength; + params.compress_ratio = kCompressRatio; + params.capacity = kTokenBudget + kCompressRatio - 1; + params.scale = 0.5f; + params.past_sequence_length = kContextLength - 1; + params.total_sequence_length = kContextLength; + params.past_key_capacity = kContextLength; + params.key_cache_capacity = kContextLength; + params.max_block_count = kContextLength / kCompressRatio; + params.block_topk = kTokenBudget / kCompressRatio; + + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamCreate(&stream)); + + float* query = nullptr; + float* key = nullptr; + float* weight = nullptr; + float* cosine = nullptr; + float* sine = nullptr; + bool* mask = nullptr; + float* key_cache = nullptr; + int32_t* selected = nullptr; + float* float_workspace = nullptr; + int32_t* int_workspace = nullptr; + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&query), kNumHeads * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&key), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&weight), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&cosine), kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&sine), kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&mask), kContextLength * sizeof(bool))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&key_cache), kContextLength * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&selected), params.capacity * sizeof(int32_t))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&float_workspace), + GetQsaWorkspaceFloatCount(params) * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&int_workspace), + GetQsaWorkspaceIntCount(params) * sizeof(int32_t))); + + std::vector ones(kContextLength * kHeadSize, 1.0f); + std::vector zeros(kContextLength * kHeadSize, 0.0f); + std::unique_ptr visible(new bool[kContextLength]); + std::fill_n(visible.get(), kContextLength, true); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(query, ones.data(), kNumHeads * kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key, ones.data(), kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(weight, ones.data(), kHeadSize * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(cosine, ones.data(), ones.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(sine, zeros.data(), zeros.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(mask, visible.get(), kContextLength * sizeof(bool), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key_cache, ones.data(), ones.size() * sizeof(float), + cudaMemcpyHostToDevice, stream)); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + const auto launch = [&]() { + return LaunchQsaSparseAttentionIndexer( + stream, params, query, key, weight, cosine, sine, mask, key_cache, selected, key_cache, + float_workspace, int_workspace); + }; + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + cudaGraph_t graph = nullptr; + cudaGraphExec_t graph_exec = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal)); + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamEndCapture(stream, &graph)); + ASSERT_EQ(cudaSuccess, cudaGraphInstantiate(&graph_exec, graph, nullptr, nullptr, 0)); + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + std::vector actual(params.capacity); + ASSERT_EQ(cudaSuccess, cudaMemcpy(actual.data(), selected, actual.size() * sizeof(int32_t), + cudaMemcpyDeviceToHost)); + for (int i = 0; i < kTokenBudget; ++i) { + EXPECT_EQ(actual[i], i); + } + for (int i = kTokenBudget; i < params.capacity; ++i) { + EXPECT_EQ(actual[i], -1); + } + + ASSERT_EQ(cudaSuccess, cudaGraphExecDestroy(graph_exec)); + ASSERT_EQ(cudaSuccess, cudaGraphDestroy(graph)); + ASSERT_EQ(cudaSuccess, cudaFree(int_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(float_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(selected)); + ASSERT_EQ(cudaSuccess, cudaFree(key_cache)); + ASSERT_EQ(cudaSuccess, cudaFree(mask)); + ASSERT_EQ(cudaSuccess, cudaFree(sine)); + ASSERT_EQ(cudaSuccess, cudaFree(cosine)); + ASSERT_EQ(cudaSuccess, cudaFree(weight)); + ASSERT_EQ(cudaSuccess, cudaFree(key)); + ASSERT_EQ(cudaSuccess, cudaFree(query)); + ASSERT_EQ(cudaSuccess, cudaStreamDestroy(stream)); +} + +} // namespace +} // namespace cuda +} // namespace contrib +} // namespace onnxruntime 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 314d55981d1ca..43aea18f93946 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -37,12 +37,6 @@ #include "test/util/include/asserts.h" #include "test/util/include/default_providers.h" -#ifdef USE_CUDA -#include - -#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" -#endif - namespace onnxruntime { namespace test { @@ -1047,6 +1041,28 @@ TEST(SparseAttentionIndexerTest, QsaMultiTileAndStridedChannels) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaNonPrefixMask) { + QsaProblem problem = MakeQsaProblem(); + const int total = problem.TotalSequenceLength(); + problem.mask[1] = 0; + problem.mask[total - 1] = 1; + RunQsaTest(1.0e-5f, std::move(problem)); +} + +TEST(SparseAttentionIndexerTest, QsaMultiTileNonPrefixMask) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.past_sequence_length = 299; + problem.token_budget = 64; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 0); + std::fill_n(problem.mask.begin(), 127, 1); + problem.mask[128] = 1; + problem.mask[200] = 1; + RunQsaTest(1.0e-5f, std::move(problem)); +} + TEST(SparseAttentionIndexerTest, QsaSinglePassTopKLimit) { QsaProblem problem; problem.batch_size = 1; @@ -1089,121 +1105,6 @@ TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKTies) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } -#ifdef USE_CUDA -TEST(SparseAttentionIndexerTest, QsaDecodeCudaGraphCaptureAndReplay) { - constexpr int kContextLength = 8192; - constexpr int kHeadSize = 4; - constexpr int kNumHeads = 2; - constexpr int kTokenBudget = 2048; - constexpr int kCompressRatio = 4; - - contrib::cuda::SparseAttentionIndexerParams params; - params.batch_size = 1; - params.sequence_length = 1; - params.num_heads = kNumHeads; - params.head_size = kHeadSize; - params.rotary_width = kHeadSize; - params.max_rotary_length = kContextLength; - params.compress_ratio = kCompressRatio; - params.capacity = kTokenBudget + kCompressRatio - 1; - params.scale = 0.5f; - params.past_sequence_length = kContextLength - 1; - params.total_sequence_length = kContextLength; - params.past_key_capacity = kContextLength; - params.key_cache_capacity = kContextLength; - params.max_block_count = kContextLength / kCompressRatio; - params.block_topk = kTokenBudget / kCompressRatio; - - cudaStream_t stream = nullptr; - ASSERT_EQ(cudaSuccess, cudaStreamCreate(&stream)); - - float* query = nullptr; - float* key = nullptr; - float* weight = nullptr; - float* cosine = nullptr; - float* sine = nullptr; - bool* mask = nullptr; - float* key_cache = nullptr; - int32_t* selected = nullptr; - float* float_workspace = nullptr; - int32_t* int_workspace = nullptr; - ASSERT_EQ(cudaSuccess, cudaMalloc(&query, kNumHeads * kHeadSize * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&key, kHeadSize * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&weight, kHeadSize * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&cosine, kContextLength * kHeadSize * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&sine, kContextLength * kHeadSize * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&mask, kContextLength * sizeof(bool))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&key_cache, kContextLength * kHeadSize * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&selected, params.capacity * sizeof(int32_t))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&float_workspace, - contrib::cuda::GetQsaWorkspaceFloatCount(params) * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(&int_workspace, - contrib::cuda::GetQsaWorkspaceIntCount(params) * sizeof(int32_t))); - - std::vector ones(kContextLength * kHeadSize, 1.0f); - std::vector zeros(kContextLength * kHeadSize, 0.0f); - std::unique_ptr visible(new bool[kContextLength]); - std::fill_n(visible.get(), kContextLength, true); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(query, ones.data(), kNumHeads * kHeadSize * sizeof(float), - cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key, ones.data(), kHeadSize * sizeof(float), - cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(weight, ones.data(), kHeadSize * sizeof(float), - cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(cosine, ones.data(), ones.size() * sizeof(float), - cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(sine, zeros.data(), zeros.size() * sizeof(float), - cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(mask, visible.get(), kContextLength * sizeof(bool), - cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key_cache, ones.data(), ones.size() * sizeof(float), - cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); - - const auto launch = [&]() { - return contrib::cuda::LaunchQsaSparseAttentionIndexer( - stream, params, query, key, weight, cosine, sine, mask, key_cache, selected, key_cache, - float_workspace, int_workspace); - }; - ASSERT_TRUE(launch().IsOK()); - ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); - - cudaGraph_t graph = nullptr; - cudaGraphExec_t graph_exec = nullptr; - ASSERT_EQ(cudaSuccess, cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal)); - ASSERT_TRUE(launch().IsOK()); - ASSERT_EQ(cudaSuccess, cudaStreamEndCapture(stream, &graph)); - ASSERT_EQ(cudaSuccess, cudaGraphInstantiate(&graph_exec, graph, nullptr, nullptr, 0)); - ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); - ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); - ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); - - std::vector actual(params.capacity); - ASSERT_EQ(cudaSuccess, cudaMemcpy(actual.data(), selected, actual.size() * sizeof(int32_t), - cudaMemcpyDeviceToHost)); - for (int i = 0; i < kTokenBudget; ++i) { - EXPECT_EQ(actual[i], i); - } - for (int i = kTokenBudget; i < params.capacity; ++i) { - EXPECT_EQ(actual[i], -1); - } - - ASSERT_EQ(cudaSuccess, cudaGraphExecDestroy(graph_exec)); - ASSERT_EQ(cudaSuccess, cudaGraphDestroy(graph)); - ASSERT_EQ(cudaSuccess, cudaFree(int_workspace)); - ASSERT_EQ(cudaSuccess, cudaFree(float_workspace)); - ASSERT_EQ(cudaSuccess, cudaFree(selected)); - ASSERT_EQ(cudaSuccess, cudaFree(key_cache)); - ASSERT_EQ(cudaSuccess, cudaFree(mask)); - ASSERT_EQ(cudaSuccess, cudaFree(sine)); - ASSERT_EQ(cudaSuccess, cudaFree(cosine)); - ASSERT_EQ(cudaSuccess, cudaFree(weight)); - ASSERT_EQ(cudaSuccess, cudaFree(key)); - ASSERT_EQ(cudaSuccess, cudaFree(query)); - ASSERT_EQ(cudaSuccess, cudaStreamDestroy(stream)); -} -#endif - TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; diff --git a/onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc b/onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc deleted file mode 100644 index 4aefac84e73e7..0000000000000 --- a/onnxruntime/test/onnx/microbenchmark/sparse_attention_indexer.cc +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -#include -#include - -#include - -#include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" - -namespace onnxruntime { -namespace test { -namespace { - -using contrib::cuda::GetQsaWorkspaceFloatCount; -using contrib::cuda::GetQsaWorkspaceIntCount; -using contrib::cuda::LaunchQsaSparseAttentionIndexer; -using contrib::cuda::SparseAttentionIndexerParams; - -struct QsaBuffers { - float* query{}; - float* key{}; - float* weight{}; - float* cosine{}; - float* sine{}; - bool* mask{}; - float* key_cache{}; - int32_t* selected{}; - float* float_workspace{}; - int32_t* int_workspace{}; - - ~QsaBuffers() { - cudaFree(int_workspace); - cudaFree(float_workspace); - cudaFree(selected); - cudaFree(key_cache); - cudaFree(mask); - cudaFree(sine); - cudaFree(cosine); - cudaFree(weight); - cudaFree(key); - cudaFree(query); - } -}; - -bool Allocate(void** output, size_t bytes, benchmark::State& state) { - const cudaError_t error = cudaMalloc(output, bytes); - if (error != cudaSuccess) { - state.SkipWithError(cudaGetErrorString(error)); - return false; - } - return true; -} - -void BM_SparseAttentionIndexerQsaDecode(benchmark::State& state) { - int device_count = 0; - if (cudaGetDeviceCount(&device_count) != cudaSuccess || device_count == 0) { - state.SkipWithError("CUDA device unavailable"); - return; - } - - constexpr int kHeadSize = 128; - constexpr int kNumHeads = 4; - constexpr int kTokenBudget = 2048; - constexpr int kCompressRatio = 4; - const int context_length = static_cast(state.range(0)); - - SparseAttentionIndexerParams params; - params.batch_size = 1; - params.sequence_length = 1; - params.num_heads = kNumHeads; - params.head_size = kHeadSize; - params.rotary_width = kHeadSize; - params.max_rotary_length = context_length; - params.compress_ratio = kCompressRatio; - params.capacity = kTokenBudget + kCompressRatio - 1; - params.scale = 1.0f; - params.past_sequence_length = context_length - 1; - params.total_sequence_length = context_length; - params.past_key_capacity = context_length; - params.key_cache_capacity = context_length; - params.max_block_count = context_length / kCompressRatio; - params.block_topk = kTokenBudget / kCompressRatio; - - QsaBuffers buffers; - if (!Allocate(reinterpret_cast(&buffers.query), kNumHeads * kHeadSize * sizeof(float), state) || - !Allocate(reinterpret_cast(&buffers.key), kHeadSize * sizeof(float), state) || - !Allocate(reinterpret_cast(&buffers.weight), kHeadSize * sizeof(float), state) || - !Allocate(reinterpret_cast(&buffers.cosine), - static_cast(context_length) * kHeadSize * sizeof(float), state) || - !Allocate(reinterpret_cast(&buffers.sine), - static_cast(context_length) * kHeadSize * sizeof(float), state) || - !Allocate(reinterpret_cast(&buffers.mask), context_length * sizeof(bool), state) || - !Allocate(reinterpret_cast(&buffers.key_cache), - static_cast(context_length) * kHeadSize * sizeof(float), state) || - !Allocate(reinterpret_cast(&buffers.selected), params.capacity * sizeof(int32_t), state) || - !Allocate(reinterpret_cast(&buffers.float_workspace), - GetQsaWorkspaceFloatCount(params) * sizeof(float), state) || - !Allocate(reinterpret_cast(&buffers.int_workspace), - GetQsaWorkspaceIntCount(params) * sizeof(int32_t), state)) { - return; - } - - cudaStream_t stream = nullptr; - if (cudaStreamCreate(&stream) != cudaSuccess) { - state.SkipWithError("cudaStreamCreate failed"); - return; - } - cudaMemsetAsync(buffers.query, 0, kNumHeads * kHeadSize * sizeof(float), stream); - cudaMemsetAsync(buffers.key, 0, kHeadSize * sizeof(float), stream); - cudaMemsetAsync(buffers.weight, 0, kHeadSize * sizeof(float), stream); - cudaMemsetAsync(buffers.cosine, 0, static_cast(context_length) * kHeadSize * sizeof(float), stream); - cudaMemsetAsync(buffers.sine, 0, static_cast(context_length) * kHeadSize * sizeof(float), stream); - cudaMemsetAsync(buffers.mask, 1, context_length * sizeof(bool), stream); - cudaMemsetAsync(buffers.key_cache, 0, static_cast(context_length) * kHeadSize * sizeof(float), stream); - cudaStreamSynchronize(stream); - - const auto launch = [&]() { - return LaunchQsaSparseAttentionIndexer( - stream, params, buffers.query, buffers.key, buffers.weight, buffers.cosine, buffers.sine, - buffers.mask, buffers.key_cache, buffers.selected, buffers.key_cache, - buffers.float_workspace, buffers.int_workspace); - }; - for (int i = 0; i < 5; ++i) { - if (!launch().IsOK()) { - state.SkipWithError("SparseAttentionIndexer warmup failed"); - cudaStreamDestroy(stream); - return; - } - } - cudaStreamSynchronize(stream); - - for (auto _ : state) { - const auto start = std::chrono::steady_clock::now(); - if (!launch().IsOK() || cudaStreamSynchronize(stream) != cudaSuccess) { - state.SkipWithError("SparseAttentionIndexer execution failed"); - break; - } - const auto end = std::chrono::steady_clock::now(); - state.SetIterationTime(std::chrono::duration(end - start).count()); - } - - state.counters["context"] = context_length; - state.counters["selected"] = kTokenBudget; - cudaStreamDestroy(stream); -} - -BENCHMARK(BM_SparseAttentionIndexerQsaDecode) - ->ArgName("context") - ->Arg(8192) - ->Arg(32768) - ->Arg(65536) - ->Arg(131072) - ->Arg(262144) - ->Unit(benchmark::kMicrosecond) - ->UseManualTime(); - -} // namespace -} // namespace test -} // namespace onnxruntime From 15d73bae6f6d084232113dd3cbb11a86c263ef9a Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 10:07:03 +0000 Subject: [PATCH 044/102] Support shared rotary caches in sparse indexer --- docs/ContribOperators.md | 2 +- .../cuda/sparse_attention_indexer.md | 8 +- .../cuda/sparse/sparse_attention_indexer.cc | 60 +++++---- .../sparse/sparse_attention_indexer_impl.cu | 8 +- .../sparse/sparse_attention_indexer_impl.h | 5 +- .../webgpu/bert/sparse_attention_indexer.cc | 58 +++++--- .../webgpu/bert/sparse_attention_indexer.h | 3 + .../core/graph/contrib_ops/bert_defs.cc | 20 ++- .../sparse_attention_indexer_op_test.cc | 126 ++++++++++++++---- 9 files changed, 213 insertions(+), 77 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index f3d97c1cc4599..ff4a62b8e4ae3 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7260,7 +7260,7 @@ This version of the operator has been available since version 1 of the 'com.micr
key_norm_weight : T
Effective RMSNorm multiplier of the compressed keys, with shape (head_size).
cos_cache : T
-
Cosine rotary table indexed by absolute key position, with shape (batch_size, max_rotary_sequence_length, rotary_width).
+
Cosine rotary table indexed by absolute key position, shared across the batch with shape (max_rotary_sequence_length, rotary_width) or request-specific with shape (batch_size, max_rotary_sequence_length, rotary_width).
sin_cache : T
Sine rotary table with the same shape as cos_cache.
mask (optional) : TB
diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index f2517da97d26b..aa62615f12e93 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -83,7 +83,7 @@ for a runtime: | 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)` | +| 3 | `cos_cache` | both | `T` | `(max_rotary_sequence_length, R)` when shared across the batch, or `(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` | both | `T` | `(B, P, D)`; raw keys for `qsa`, compressed keys for `csa` | @@ -316,7 +316,8 @@ Shape inference and the kernel both reject: - `index_topk` absent or `<= 0` for `csa`; - an output count other than 2 (`qsa`) or 3 (`csa`); - a `past_proj_buffer` length outside `[0, 2 * compress_ratio)`; -- rank or dimension mismatches between `query`, `key`, the caches and the buffers. +- rank or dimension mismatches between `query`, `key`, the caches and the buffers. Rotary caches + may be shared rank-2 tables or request-specific rank-3 tables. Because the checks live in shape inference, most misuse fails at `Graph::Resolve()` with a clear message rather than at kernel launch. @@ -335,7 +336,8 @@ contains: - **Numeric tests** (`float`, `float16`, `bfloat16`) that run the CUDA kernel against a float reference implementation of both policies written directly from the reference semantics. Inputs are round-tripped through the tested element type before the reference runs, so the reference - sees exactly the values the kernel reads. These tests skip when no CUDA EP is available. + sees exactly the values the kernel reads. Shared rank-2 rotary caches are covered for both CUDA + and WebGPU. These tests skip when the requested EP is unavailable. - **Long-context parity tests** at 8K, 32K, 64K, 128K and 256K for `compress_ratio=4, token_budget=2048`. - **Fixed-address CUDA graph capture and replay** in diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 7ab5a5c780a2a..f189e74471c22 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -57,6 +57,32 @@ Status CheckIntDimension(const char* name, int64_t value, bool allow_zero = true return Status::OK(); } +struct RotaryCacheShape { + bool batched; + int64_t max_rotary_length; + int64_t rotary_width; +}; + +Status CheckRotaryCache(const Tensor* cos_cache, const Tensor* sin_cache, int64_t batch_size, + RotaryCacheShape& out) { + ORT_RETURN_IF(cos_cache == nullptr, "SparseAttentionIndexer: cos_cache is required"); + const auto& cos_shape = cos_cache->Shape(); + out.batched = cos_shape.NumDimensions() == 3; + ORT_RETURN_IF_NOT( + (out.batched && cos_shape[0] == batch_size && cos_shape[1] > 0) || + (cos_shape.NumDimensions() == 2 && cos_shape[0] > 0), + "SparseAttentionIndexer: cos_cache must have shape (max_rotary_sequence_length, rotary_width) or " + "(batch_size, max_rotary_sequence_length, rotary_width), got ", + cos_shape.ToString()); + out.max_rotary_length = out.batched ? cos_shape[1] : cos_shape[0]; + out.rotary_width = out.batched ? cos_shape[2] : cos_shape[1]; + ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", out.max_rotary_length, false)); + ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", out.rotary_width, false)); + ORT_RETURN_IF_NOT(sin_cache != nullptr && sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as cos_cache"); + return Status::OK(); +} + Status ReadOptionalLength(OpKernelContext* context, int index, const char* name, int64_t& value) { const Tensor* tensor = index < context->InputCount() ? context->Input(index) : nullptr; if (tensor == nullptr) { @@ -152,18 +178,10 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); - const auto& cos_shape = cos_cache->Shape(); - ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, - "SparseAttentionIndexer: cos_cache must have shape " - "(batch_size, max_rotary_sequence_length, rotary_width), got ", - cos_shape.ToString()); - const int64_t max_rotary_length = cos_shape[1]; - const int64_t rotary_width = cos_shape[2]; - ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", max_rotary_length, false)); - ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", rotary_width, false)); - ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape, - "SparseAttentionIndexer: sin_cache must have the same shape as " - "cos_cache"); + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; ORT_RETURN_IF_NOT(rotary_width > 0 && rotary_width % 2 == 0 && rotary_width <= head_size, "SparseAttentionIndexer: policy_mode 'qsa' requires an even rotary_width in (0, head_size], got ", rotary_width); @@ -208,6 +226,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { params.head_size = static_cast(head_size); params.rotary_width = static_cast(rotary_width); params.max_rotary_length = static_cast(max_rotary_length); + params.rotary_cache_batch_stride = rotary_cache_shape.batched ? params.max_rotary_length : 0; params.compress_ratio = static_cast(compress_ratio_); params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_)); @@ -282,18 +301,10 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { "SparseAttentionIndexer: 2 * head_size must be no greater than INT_MAX"); const int64_t width = 2 * head_size; - const auto& cos_shape = cos_cache->Shape(); - ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, - "SparseAttentionIndexer: cos_cache must have shape " - "(batch_size, max_rotary_sequence_length, rotary_width), got ", - cos_shape.ToString()); - const int64_t max_rotary_length = cos_shape[1]; - const int64_t rotary_width = cos_shape[2]; - ORT_RETURN_IF_ERROR(CheckIntDimension("max_rotary_sequence_length", max_rotary_length, false)); - ORT_RETURN_IF_ERROR(CheckIntDimension("rotary_width", rotary_width, false)); - ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape, - "SparseAttentionIndexer: sin_cache must have the same shape as " - "cos_cache"); + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; ORT_RETURN_IF_NOT(rotary_width > 0 && 2 * rotary_width <= head_size, "SparseAttentionIndexer: policy_mode 'csa' requires 0 < 2 * rotary_width <= head_size, got " "rotary_width=", @@ -350,6 +361,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.head_size = static_cast(head_size); params.rotary_width = static_cast(rotary_width); params.max_rotary_length = static_cast(max_rotary_length); + params.rotary_cache_batch_stride = rotary_cache_shape.batched ? params.max_rotary_length : 0; params.compress_ratio = static_cast(compress_ratio_); params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_)); 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 b7d667fb4727c..c894256d9adea 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -98,7 +98,7 @@ __global__ void RotateQueryKernel(const T* query, const T* cos_cache, const T* s : position_ids[static_cast(batch) * params.sequence_length + token]; const int position = ClampPosition(raw_position, params.max_rotary_length); const int64_t cache_offset = - (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; const T* cos_row = cos_cache + cache_offset; const T* sin_row = sin_cache + cache_offset; @@ -284,7 +284,7 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const const int first_position = prefix_visible ? block_index * params.compress_ratio : group[0]; const int position = ClampPosition(first_position, params.max_rotary_length); const int64_t cache_offset = - (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { rotated[d] = LeadingRope(pooled, params.rotary_width, cos_cache + cache_offset, sin_cache + cache_offset, d); @@ -303,7 +303,7 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, const static_cast(params.past_sequence_length) + row % params.sequence_length, params.max_rotary_length); const int64_t query_cache_offset = - (static_cast(batch) * params.max_rotary_length + query_position) * params.rotary_width; + (static_cast(batch) * params.rotary_cache_batch_stride + query_position) * params.rotary_width; float partial = 0.0f; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { partial += LeadingRope(query_head, params.rotary_width, cos_cache + query_cache_offset, @@ -600,7 +600,7 @@ __global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_ const int64_t entry = static_cast(params.past_compressed_length) + window; const int position = ClampPosition(entry * params.compress_ratio, params.max_rotary_length); const int64_t cache_offset = - (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; const int64_t out_base = (static_cast(batch) * params.compressed_cache_capacity + entry) * params.head_size; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index ebc5f1f05945e..8b28acdaebc17 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -18,8 +18,9 @@ struct SparseAttentionIndexerParams { 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 rotary_width = 0; + int max_rotary_length = 0; + int rotary_cache_batch_stride = 0; int compress_ratio = 0; int capacity = 0; // selected_indices.shape[2] float epsilon = 1e-6f; diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 8882f6b849187..2c4c2841d978b 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -41,6 +41,30 @@ Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list< return Status::OK(); } +struct RotaryCacheShape { + bool batched; + int64_t max_rotary_length; + int64_t rotary_width; +}; + +Status CheckRotaryCache(const Tensor* cos_cache, const Tensor* sin_cache, int64_t batch_size, + RotaryCacheShape& out) { + ORT_RETURN_IF(cos_cache == nullptr, "SparseAttentionIndexer: cos_cache is required"); + const auto& cos_shape = cos_cache->Shape(); + out.batched = cos_shape.NumDimensions() == 3; + ORT_RETURN_IF_NOT( + (out.batched && cos_shape[0] == batch_size && cos_shape[1] > 0) || + (cos_shape.NumDimensions() == 2 && cos_shape[0] > 0), + "SparseAttentionIndexer: cos_cache must have shape (max_rotary_sequence_length, rotary_width) or " + "(batch_size, max_rotary_sequence_length, rotary_width), got ", + cos_shape.ToString()); + out.max_rotary_length = out.batched ? cos_shape[1] : cos_shape[0]; + out.rotary_width = out.batched ? cos_shape[2] : cos_shape[1]; + ORT_RETURN_IF_NOT(sin_cache != nullptr && sin_cache->Shape() == cos_shape, + "SparseAttentionIndexer: sin_cache must have the same shape as cos_cache"); + return Status::OK(); +} + uint32_t ToUint32(int64_t value) { return onnxruntime::narrow(value); } @@ -128,7 +152,7 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let batch = row / uniforms.sequence_length;\n" << " let token = row % uniforms.sequence_length;\n" << " let position = clamp_position(uniforms.past_sequence_length + token);\n" - << " let cache = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width + d;\n" + << " let cache = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width + d;\n" << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" << sin_cache.GetByOffset("cache") << ");\n" << " }\n" @@ -163,7 +187,7 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let paired = sign * normalized_value(row, block, pair_d);\n" << " let batch = row / uniforms.sequence_length;\n" << " let position = clamp_position(visible_at(row, block * uniforms.compress_ratio));\n" - << " let cache = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width + d;\n" + << " let cache = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width + d;\n" << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" << sin_cache.GetByOffset("cache") << ");\n" << " }\n" @@ -341,7 +365,7 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper << " 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 cache_base = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width;\n" << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" << " for (var d = 0u; d < uniforms.head_size; d++) {\n" << " var value = pooled_value(batch, window, d) * inverse_rms * f32(" << norm.GetByOffset("d") << ");\n" @@ -447,7 +471,7 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let paired = sign * f32(" << query.GetByOffset("base + pair_d") << ");\n" << " let batch = row / uniforms.sequence_length;\n" << " let position = clamped_position(row, uniforms.max_rotary_length - 1u);\n" - << " let cache = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width + offset / 2u;\n" + << " let cache = (batch * uniforms.rotary_cache_batch_stride + position) * uniforms.rotary_width + offset / 2u;\n" << " value = value * f32(" << cos_cache.GetByOffset("cache") << ") + paired * f32(" << sin_cache.GetByOffset("cache") << ");\n" << " }\n" @@ -571,13 +595,11 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c 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, + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; + ORT_RETURN_IF_NOT(rotary_width > 0 && rotary_width % 2 == 0 && rotary_width <= head_size, "SparseAttentionIndexer: invalid qsa rotary cache shape"); const auto& mask_shape = mask->Shape(); ORT_RETURN_IF_NOT( @@ -634,6 +656,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(head_size)}, {ToUint32(rotary_width)}, {ToUint32(max_rotary_length)}, + {rotary_cache_shape.batched ? ToUint32(max_rotary_length) : 0u}, {ToUint32(compress_ratio_)}, {ToUint32(capacity)}, {ToUint32(past_length)}, @@ -675,12 +698,11 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c ORT_RETURN_IF_ERROR(CheckShape(head_weights, "head_weights", {batch_size, sequence_length, num_heads})); ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {batch_size, sequence_length})); - const auto& cos_shape = cos_cache->Shape(); - ORT_RETURN_IF_NOT(cos_shape.NumDimensions() == 3 && cos_shape[0] == batch_size && cos_shape[1] > 0, - "SparseAttentionIndexer: invalid cos_cache shape"); - const int64_t max_rotary_length = cos_shape[1]; - const int64_t rotary_width = cos_shape[2]; - ORT_RETURN_IF_NOT(sin_cache->Shape() == cos_shape && rotary_width > 0 && 2 * rotary_width <= head_size, + RotaryCacheShape rotary_cache_shape; + ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary_cache_shape)); + const int64_t max_rotary_length = rotary_cache_shape.max_rotary_length; + const int64_t rotary_width = rotary_cache_shape.rotary_width; + ORT_RETURN_IF_NOT(rotary_width > 0 && 2 * rotary_width <= head_size, "SparseAttentionIndexer: invalid csa rotary cache shape"); const auto& past_compressed_shape = past_compressed->Shape(); ORT_RETURN_IF_NOT(past_compressed_shape.NumDimensions() == 3 && @@ -741,6 +763,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(head_size)}, {ToUint32(rotary_width)}, {ToUint32(max_rotary_length)}, + {rotary_cache_shape.batched ? ToUint32(max_rotary_length) : 0u}, {ToUint32(compress_ratio_)}, {ToUint32(past_compressed_length)}, {ToUint32(present_compressed_length)}, @@ -806,6 +829,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {ToUint32(head_size)}, {ToUint32(rotary_width)}, {ToUint32(max_rotary_length)}, + {rotary_cache_shape.batched ? ToUint32(max_rotary_length) : 0u}, {ToUint32(compress_ratio_)}, {ToUint32(capacity)}, {ToUint32(present_compressed_length)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index 7bc8e3db706d2..6bc13e27f6b0f 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -51,6 +51,7 @@ class SparseAttentionIndexerQsaSelectProgram final {"head_size", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"rotary_cache_batch_stride", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"capacity", ProgramUniformVariableDataType::Uint32}, {"past_sequence_length", ProgramUniformVariableDataType::Uint32}, @@ -84,6 +85,7 @@ class SparseAttentionIndexerCsaCompressProgram final {"head_size", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"rotary_cache_batch_stride", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"past_compressed_length", ProgramUniformVariableDataType::Uint32}, {"present_compressed_length", ProgramUniformVariableDataType::Uint32}, @@ -129,6 +131,7 @@ class SparseAttentionIndexerCsaSelectProgram final {"head_size", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"rotary_cache_batch_stride", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"capacity", ProgramUniformVariableDataType::Uint32}, {"present_compressed_length", ProgramUniformVariableDataType::Uint32}, diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index e50fbf90bbbd5..420160db32fdf 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -1926,6 +1926,19 @@ const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerShape(ONNX_NAMESPA return &shape; } +const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerRotaryCacheShape( + ONNX_NAMESPACE::InferenceContext& ctx, int index) { + if (!SparseAttentionIndexerHasInput(ctx, index) || !hasInputShape(ctx, index)) { + return nullptr; + } + const auto& shape = getInputShape(ctx, index); + if (shape.dim_size() != 2 && shape.dim_size() != 3) { + fail_shape_inference("SparseAttentionIndexer: input ", index, " must have rank 2 or 3, got rank ", + shape.dim_size()); + } + return &shape; +} + } // namespace void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx) { @@ -2002,8 +2015,8 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex (void)SparseAttentionIndexerShape(ctx, sai::kKey, 3); (void)SparseAttentionIndexerShape(ctx, sai::kKeyNormWeight, 1); - (void)SparseAttentionIndexerShape(ctx, sai::kCosCache, 3); - (void)SparseAttentionIndexerShape(ctx, sai::kSinCache, 3); + (void)SparseAttentionIndexerRotaryCacheShape(ctx, sai::kCosCache); + (void)SparseAttentionIndexerRotaryCacheShape(ctx, sai::kSinCache); const auto* query_shape = SparseAttentionIndexerShape(ctx, sai::kQuery, 4); if (query_shape == nullptr) { @@ -2211,7 +2224,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "T") .Input(3, "cos_cache", - "Cosine rotary table indexed by absolute key position, with shape " + "Cosine rotary table indexed by absolute key position, shared across the batch with shape " + "(max_rotary_sequence_length, rotary_width) or request-specific with shape " "(batch_size, max_rotary_sequence_length, rotary_width).", "T") .Input(4, 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 43aea18f93946..1ee9a73ae4dc3 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -95,6 +95,7 @@ struct QsaGraphOptions { bool add_index_topk = false; bool add_csa_inputs = false; bool share_cache = false; + bool shared_rotary_cache = false; std::string policy_mode = sai::kPolicyModeQsa; }; @@ -108,8 +109,12 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { 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(options.shared_rotary_cache + ? std::vector{total, options.rotary_width} + : std::vector{options.batch_size, total, options.rotary_width}), + builder.MakeInput(options.shared_rotary_cache + ? std::vector{total, options.rotary_width} + : std::vector{options.batch_size, total, options.rotary_width}), builder.MakeInput(std::vector{options.batch_size, 1, options.sequence_length, total}), builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; @@ -150,6 +155,7 @@ struct CsaGraphOptions { int64_t output_count = sai::kCsaOutputCount; bool add_token_budget = false; bool share_cache = false; + bool shared_rotary_cache = false; }; void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { @@ -160,8 +166,12 @@ void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { 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}), + builder.MakeInput(options.shared_rotary_cache + ? std::vector{64, options.rotary_width} + : std::vector{options.batch_size, 64, options.rotary_width}), + builder.MakeInput(options.shared_rotary_cache + ? std::vector{64, options.rotary_width} + : std::vector{options.batch_size, 64, options.rotary_width}), &empty, builder.MakeInput( std::vector{options.batch_size, @@ -296,6 +306,7 @@ struct QsaProblem { int token_budget = 4; float epsilon = 1.0e-6f; std::optional scale; + bool shared_rotary_cache = false; std::vector query; std::vector key; @@ -341,10 +352,11 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std selected.assign(static_cast(problem.batch_size) * problem.sequence_length * capacity, -1); for (int b = 0; b < problem.batch_size; ++b) { - const float* cos_base = problem.cos_cache.data() + - static_cast(b) * problem.MaxRotaryLength() * problem.rotary_width; - const float* sin_base = problem.sin_cache.data() + - static_cast(b) * problem.MaxRotaryLength() * problem.rotary_width; + const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); + const float* cos_base = + problem.cos_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; + const float* sin_base = + problem.sin_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; for (int s = 0; s < problem.sequence_length; ++s) { const size_t row = static_cast(b) * problem.sequence_length + s; @@ -427,6 +439,7 @@ struct CsaProblem { float epsilon = 1.0e-6f; std::optional scale; std::optional head_weight_scale; + bool shared_rotary_cache = false; std::vector query; std::vector key; @@ -492,10 +505,11 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, } } + const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); const float* cos_base = - problem.cos_cache.data() + static_cast(b) * problem.max_rotary_length * problem.rotary_width; + problem.cos_cache.data() + cache_batch * problem.max_rotary_length * problem.rotary_width; const float* sin_base = - problem.sin_cache.data() + static_cast(b) * problem.max_rotary_length * problem.rotary_width; + problem.sin_cache.data() + cache_batch * problem.max_rotary_length * problem.rotary_width; for (int window = 0; window < plan.new_window_count; ++window) { const bool has_previous = window >= 1 || plan.overlap_length >= problem.compress_ratio; @@ -636,8 +650,9 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { 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); + const size_t cache_batches = problem.shared_rotary_cache ? 1 : static_cast(problem.batch_size); + problem.cos_cache = MakeWave(cache_batches * total * problem.rotary_width, 0.20f, 0.13f); + problem.sin_cache = MakeWave(cache_batches * total * problem.rotary_width, 0.90f, 0.19f); problem.past_key = MakeWave( static_cast(problem.batch_size) * problem.PastKeyCapacity() * problem.head_size, 0.05f, 0.23f); @@ -696,8 +711,11 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), 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)); + const std::vector rotary_cache_shape = problem.shared_rotary_cache + ? std::vector{total, problem.rotary_width} + : std::vector{batch_size, total, problem.rotary_width}; + test.AddInput("cos_cache", rotary_cache_shape, ToElementType(problem.cos_cache)); + test.AddInput("sin_cache", rotary_cache_shape, ToElementType(problem.sin_cache)); test.AddInput("mask", {batch_size, 1, sequence_length, total}, mask.get(), problem.mask.size()); test.AddInput("past_key", {batch_size, problem.PastKeyCapacity(), head_size}, ToElementType(problem.past_key)); @@ -720,12 +738,11 @@ CsaProblem MakeCsaProblem(CsaProblem problem = {}) { 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); + const size_t cache_batches = problem.shared_rotary_cache ? 1 : static_cast(problem.batch_size); problem.cos_cache = - MakeWave(static_cast(problem.batch_size) * problem.max_rotary_length * problem.rotary_width, 0.15f, - 0.27f); + MakeWave(cache_batches * problem.max_rotary_length * problem.rotary_width, 0.15f, 0.27f); problem.sin_cache = - MakeWave(static_cast(problem.batch_size) * problem.max_rotary_length * problem.rotary_width, 1.05f, - 0.33f); + MakeWave(cache_batches * problem.max_rotary_length * problem.rotary_width, 1.05f, 0.33f); problem.gate = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * width, 0.80f, 0.24f); problem.position_bias = MakeWave(static_cast(problem.compress_ratio) * width, 0.33f, 0.11f); problem.head_weights = MakeWave( @@ -798,10 +815,12 @@ void RunCsaTest(const CsaProblem& base, float tolerance, 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)); + const std::vector rotary_cache_shape = + problem.shared_rotary_cache + ? std::vector{problem.max_rotary_length, problem.rotary_width} + : std::vector{batch_size, problem.max_rotary_length, problem.rotary_width}; + test.AddInput("cos_cache", rotary_cache_shape, ToElementType(problem.cos_cache)); + test.AddInput("sin_cache", rotary_cache_shape, ToElementType(problem.sin_cache)); test.AddOptionalInputEdge(); test.AddInput("past_key", {batch_size, problem.PastCompressedCapacity(), head_size}, ToElementType(problem.past_compressed_key)); @@ -901,6 +920,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaSharedCacheKeepsCapacity) { {options.batch_size, options.key_cache_capacity, options.head_size}); } +TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsSharedRotaryCache) { + QsaGraphOptions options; + options.shared_rotary_cache = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); +} + TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { CsaGraphOptions options; std::unique_ptr model; @@ -939,6 +965,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, CsaSharedCacheKeepsCapacity) { {options.batch_size, options.compressed_cache_capacity, options.head_size}); } +TEST(SparseAttentionIndexerShapeInferenceTest, CsaAcceptsSharedRotaryCache) { + CsaGraphOptions options; + options.shared_rotary_cache = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, model)); +} + #ifndef ORT_NO_EXCEPTIONS namespace { @@ -1090,7 +1123,30 @@ TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKParity) { problem.past_sequence_length = context_length - 1; problem.compress_ratio = 4; problem.token_budget = 2048; - RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); + problem = MakeQsaProblem(std::move(problem)); + + std::fill(problem.query.begin(), problem.query.end(), 0.0f); + for (int head = 0; head < problem.num_heads; ++head) { + problem.query[static_cast(head) * problem.head_size] = 1.0f; + } + std::fill(problem.key_norm_weight.begin(), problem.key_norm_weight.end(), 1.0f); + std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); + std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); + std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); + std::fill(problem.key.begin(), problem.key.end(), 0.0f); + + const int block_count = context_length / problem.compress_ratio; + for (int position = 0; position < context_length; ++position) { + const float rank = static_cast(position / problem.compress_ratio + 1) / + static_cast(block_count); + std::vector& cache = position < problem.past_sequence_length ? problem.past_key : problem.key; + const int cache_position = position < problem.past_sequence_length ? position : 0; + const size_t offset = static_cast(cache_position) * problem.head_size; + cache[offset] = rank; + cache[offset + 1] = 1.0f; + } + + RunQsaTest(1.0e-5f, std::move(problem)); } } @@ -1117,6 +1173,12 @@ TEST(SparseAttentionIndexerTest, QsaSharedCacheCapacity) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaSharedRotaryCache) { + QsaProblem problem; + problem.shared_rotary_cache = true; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } @@ -1135,6 +1197,12 @@ TEST(SparseAttentionIndexerTest, CsaSharedCacheCapacity) { RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f); } +TEST(SparseAttentionIndexerTest, CsaSharedRotaryCache) { + CsaProblem problem; + problem.shared_rotary_cache = true; + RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f); +} + TEST(SparseAttentionIndexerTest, CsaExplicitZeroScales) { CsaProblem problem = MakeCsaProblem(); problem.scale = 0.0f; @@ -1169,6 +1237,12 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaExplicitZeroScale) { RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, QsaSharedRotaryCache) { + QsaProblem problem; + problem.shared_rotary_cache = true; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem)), ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f, ProviderKind::WebGpu); } @@ -1177,6 +1251,12 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 6.0e-3f, ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, CsaSharedRotaryCache) { + CsaProblem problem; + problem.shared_rotary_cache = true; + RunCsaTest(MakeCsaProblem(std::move(problem)), 1.0e-5f, ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f, ProviderKind::WebGpu); } From 3d6bf1aaa91cc2a8af9fcae1395e752c17bbf565 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 10:28:21 +0000 Subject: [PATCH 045/102] Accept padding masks in sparse attention indexer --- docs/ContribOperators.md | 6 +-- .../cuda/sparse_attention_indexer.md | 7 ++- .../webgpu/sparse_attention_indexer.md | 3 +- .../cuda/sparse/sparse_attention_indexer.cc | 20 +++++--- .../sparse/sparse_attention_indexer_impl.cu | 46 ++++++++++++++----- .../sparse/sparse_attention_indexer_impl.h | 3 +- .../webgpu/bert/sparse_attention_indexer.cc | 44 ++++++++++++------ .../webgpu/bert/sparse_attention_indexer.h | 6 ++- .../core/graph/contrib_ops/bert_defs.cc | 22 +++++++-- .../sparse_attention_indexer_op_test.cc | 46 +++++++++++++++++-- 10 files changed, 157 insertions(+), 46 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index ff4a62b8e4ae3..80537c5abdbb6 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7264,7 +7264,7 @@ This version of the operator has been available since version 1 of the 'com.micr
sin_cache : T
Sine rotary table with the same shape as cos_cache.
mask (optional) : TB
-
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.
+
Only for policy_mode 'qsa': an INT64 padding mask with shape (batch_size, total_sequence_length), for which causal visibility is derived internally, or a BOOL explicit visibility mask 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.
past_key : T
Cached indexer keys. For policy_mode 'qsa', these are raw keys; for 'csa', they are compressed keys. Shape is (batch_size, past_sequence_length, head_size), or (batch_size, max_cache_length, head_size) when a valid past_sequence_length is provided.
gate (optional) : T
@@ -7297,8 +7297,8 @@ This version of the operator has been available since version 1 of the 'com.micr
T : tensor(float), tensor(float16), tensor(bfloat16)
Constrain floating point tensors to float, float16 and bfloat16.
-
TB : tensor(bool)
-
Constrain the visibility mask to boolean tensors.
+
TB : tensor(bool), tensor(int64)
+
Constrain the mask to boolean or int64 tensors.
I : tensor(int64)
Constrain position ids to 64-bit integer tensors.
M : tensor(int32)
diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index aa62615f12e93..ce9b3f8900407 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -85,7 +85,7 @@ for a runtime: | 2 | `key_norm_weight` | both | `T` | `(D)` | | 3 | `cos_cache` | both | `T` | `(max_rotary_sequence_length, R)` when shared across the batch, or `(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)` | +| 5 | `mask` | `qsa` | `TB` | INT64 padding mask `(B, T)`, or BOOL explicit visibility `(B, 1, S, T)` / `(B, S, T)` | | 6 | `past_key` | both | `T` | `(B, P, D)`; raw keys for `qsa`, compressed keys for `csa` | | 7 | `gate` | `csa` | `T` | `(B, S, 2D)` | | 8 | `position_bias` | `csa` | `T` | `(r, 2D)` | @@ -150,7 +150,10 @@ its ordinary bounded state contract and stays below `2r` positions. For every `(b, s)`: -1. **Visible set.** `visible = [t for t in range(T) if mask[b, s, t]]`, in ascending `t`. +1. **Visible set.** For an INT64 rank-2 padding mask, + `visible = [t for t in range(T) if mask[b, t] != 0 and t <= P + s]`. For a BOOL rank-3/4 + explicit mask, `visible = [t for t in range(T) if mask[b, s, t]]`. Positions remain in + ascending `t` order. 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 diff --git a/docs/contrib_ops/webgpu/sparse_attention_indexer.md b/docs/contrib_ops/webgpu/sparse_attention_indexer.md index 72a10b50641f6..8c863b1d7075f 100644 --- a/docs/contrib_ops/webgpu/sparse_attention_indexer.md +++ b/docs/contrib_ops/webgpu/sparse_attention_indexer.md @@ -11,7 +11,8 @@ 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; +- arbitrary boolean rank-3/4 QSA visibility masks; - deterministic score-descending, index-ascending TopK ties. BF16, packed/variable-length inputs, and fixed-capacity caches using diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index f189e74471c22..861984300b40d 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -27,7 +27,8 @@ namespace sai = onnxruntime::contrib::sparse_attention_indexer; kCudaExecutionProvider, \ (*KernelDefBuilder::Create()) \ .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("TB", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("TB", {DataTypeImpl::GetTensorType(), \ + DataTypeImpl::GetTensorType()}) \ .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ .MayInplace(sai::kPastKey, sai::kPresentKey) \ @@ -209,14 +210,18 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); const auto& mask_shape = mask->Shape(); + const bool mask_is_2d = mask_shape.NumDimensions() == 2; 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_is_2d && mask->DataType() == DataTypeImpl::GetType() && mask_shape[0] == batch_size && + mask_shape[1] == total_sequence_length) || + (mask_is_4d && mask->DataType() == DataTypeImpl::GetType() && 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=", + mask_shape[2] == total_sequence_length && mask->DataType() == DataTypeImpl::GetType()), + "SparseAttentionIndexer: mask must be INT64 with shape (batch_size, total_sequence_length), BOOL with shape " + "(batch_size, 1, sequence_length, total_sequence_length), or BOOL with shape " + "(batch_size, sequence_length, total_sequence_length), with total_sequence_length=", total_sequence_length, ", got ", mask_shape.ToString()); SparseAttentionIndexerParams params; @@ -227,6 +232,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { params.rotary_width = static_cast(rotary_width); params.max_rotary_length = static_cast(max_rotary_length); params.rotary_cache_batch_stride = rotary_cache_shape.batched ? params.max_rotary_length : 0; + params.mask_is_2d = mask_is_2d; params.compress_ratio = static_cast(compress_ratio_); params.capacity = static_cast( sai::SelectedCapacity(sai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_)); @@ -260,7 +266,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), - mask->Data(), + mask->DataRaw(), reinterpret_cast(past_key->Data()), selected_indices->MutableData(), reinterpret_cast(present_key->MutableData()), 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 c894256d9adea..d873235bfbae3 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -148,17 +148,30 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention // Encodes a prefix-visible row as -count-1. QSA can then index that row directly without // materializing its visible positions. Non-prefix rows keep a nonnegative count and are compacted // by CompactNonPrefixVisibleKernel. -__global__ void AnalyzeVisibleKernel(const bool* mask, int32_t* visible_count, +template +__device__ __forceinline__ bool IsVisible(const MaskT* mask, int64_t row, int position, + const SparseAttentionIndexerParams& params) { + if constexpr (IsPaddingMask) { + const int batch = static_cast(row / params.sequence_length); + const int query = static_cast(row % params.sequence_length); + return mask[static_cast(batch) * params.total_sequence_length + position] != 0 && + position <= params.past_sequence_length + query; + } else { + return mask[row * params.total_sequence_length + position]; + } +} + +template +__global__ void AnalyzeVisibleKernel(const MaskT* mask, int32_t* visible_count, SparseAttentionIndexerParams params) { __shared__ int32_t counts[kThreads]; __shared__ int32_t maxima[kThreads]; const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { - const bool* mask_row = mask + row * params.total_sequence_length; int32_t count = 0; int32_t maximum = -1; for (int position = threadIdx.x; position < params.total_sequence_length; position += blockDim.x) { - if (mask_row[position]) { + if (IsVisible(mask, row, position, params)) { ++count; maximum = position; } @@ -182,7 +195,8 @@ __global__ void AnalyzeVisibleKernel(const bool* mask, int32_t* visible_count, } // One block per non-prefix query row; compacts visible key positions into visible_indices. -__global__ void CompactNonPrefixVisibleKernel(const bool* mask, int32_t* visible_indices, +template +__global__ void CompactNonPrefixVisibleKernel(const MaskT* mask, int32_t* visible_indices, const int32_t* visible_count, SparseAttentionIndexerParams params) { __shared__ int32_t warp_offsets[kThreads / kWarpSize + 1]; @@ -191,12 +205,12 @@ __global__ void CompactNonPrefixVisibleKernel(const bool* mask, int32_t* visible if (visible_count[row] < 0) { continue; } - 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 bool visible = position < params.total_sequence_length && mask_row[position]; + const bool visible = position < params.total_sequence_length && + IsVisible(mask, row, position, params); const unsigned int warp_mask = __ballot_sync(0xffffffffu, visible); const int lane = threadIdx.x % kWarpSize; const int warp = threadIdx.x / kWarpSize; @@ -749,7 +763,7 @@ 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* cos_cache, const T* sin_cache, const void* 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; @@ -780,9 +794,19 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const size_t value_bytes = static_cast(params.head_size) * sizeof(float); const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); - AnalyzeVisibleKernel<<>>(mask, visible_count, params); - CompactNonPrefixVisibleKernel<<>>( - mask, visible_indices, visible_count, params); + if (params.mask_is_2d) { + const int64_t* padding_mask = static_cast(mask); + AnalyzeVisibleKernel<<>>( + padding_mask, visible_count, params); + CompactNonPrefixVisibleKernel<<>>( + padding_mask, visible_indices, visible_count, params); + } else { + const bool* visibility_mask = static_cast(mask); + AnalyzeVisibleKernel<<>>( + visibility_mask, visible_count, params); + CompactNonPrefixVisibleKernel<<>>( + visibility_mask, visible_indices, visible_count, params); + } if (params.max_block_count > 0) { const int64_t block_work = rows * params.max_block_count; @@ -872,7 +896,7 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio #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*); \ + const void*, 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*); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 8b28acdaebc17..45990895ca010 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -21,6 +21,7 @@ struct SparseAttentionIndexerParams { int rotary_width = 0; int max_rotary_length = 0; int rotary_cache_batch_stride = 0; + bool mask_is_2d = false; int compress_ratio = 0; int capacity = 0; // selected_indices.shape[2] float epsilon = 1e-6f; @@ -62,7 +63,7 @@ Status LaunchQsaSparseAttentionIndexer( const T* key_norm_weight, const T* cos_cache, const T* sin_cache, - const bool* mask, + const void* mask, const T* past_key, int32_t* selected_indices, T* present_key, diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 2c4c2841d978b..0e42883b1e223 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(), DataTypeImpl::GetTensorType()}) .TypeConstraint("I", DataTypeImpl::GetTensorType()) .TypeConstraint("M", DataTypeImpl::GetTensorType()), SparseAttentionIndexer); @@ -123,10 +123,20 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& 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"; + if (mask_is_2d_) { + shader.AdditionalImplementation() + << " 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"; + } else { + shader.AdditionalImplementation() + << " let offset = row * uniforms.total_sequence_length + token;\n" + << " return " << mask.GetByOffset("offset / 4u") << "[offset % 4u];\n"; + } 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" @@ -602,11 +612,15 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c ORT_RETURN_IF_NOT(rotary_width > 0 && rotary_width % 2 == 0 && rotary_width <= head_size, "SparseAttentionIndexer: invalid qsa rotary cache shape"); const auto& mask_shape = mask->Shape(); + const bool mask_is_2d = mask_shape.NumDimensions() == 2; 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), + (mask_is_2d && mask->DataType() == DataTypeImpl::GetType() && mask_shape[0] == batch_size && + mask_shape[1] == total_length) || + (mask_shape.NumDimensions() == 4 && mask->DataType() == DataTypeImpl::GetType() && + mask_shape[0] == batch_size && mask_shape[1] == 1 && mask_shape[2] == sequence_length && + mask_shape[3] == total_length) || + (mask_shape.NumDimensions() == 3 && mask->DataType() == DataTypeImpl::GetType() && + 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_); @@ -639,15 +653,19 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c if (rows == 0) { return Status::OK(); } - SparseAttentionIndexerQsaSelectProgram select; - select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity) + SparseAttentionIndexerQsaSelectProgram select{mask_is_2d}; + select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity, mask_is_2d) .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}) + {sin_cache, ProgramTensorMetadataDependency::Type}}); + if (mask_is_2d) { + select.AddInput({mask, ProgramTensorMetadataDependency::Type, {mask->Shape().Size()}, 1}); + } else { + select.AddInput({mask, ProgramTensorMetadataDependency::Type, {(mask->Shape().Size() + 3) / 4}, 4}); + } + select.AddOutput({selected, ProgramTensorMetadataDependency::Type}) .SetWorkgroupSize(kWorkgroupSize) .SetDispatchGroupSize(ToUint32(rows)) .AddUniformVariables({{ToUint32(rows)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index 6bc13e27f6b0f..76fc3932bc80f 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -42,7 +42,8 @@ class SparseAttentionIndexerQsaConcatProgram final class SparseAttentionIndexerQsaSelectProgram final : public Program { public: - SparseAttentionIndexerQsaSelectProgram() : Program{"SparseAttentionIndexerQsaSelect"} {} + explicit SparseAttentionIndexerQsaSelectProgram(bool mask_is_2d) + : Program{"SparseAttentionIndexerQsaSelect"}, mask_is_2d_{mask_is_2d} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"rows", ProgramUniformVariableDataType::Uint32}, @@ -59,6 +60,9 @@ class SparseAttentionIndexerQsaSelectProgram final {"block_topk", ProgramUniformVariableDataType::Uint32}, {"epsilon", ProgramUniformVariableDataType::Float32}, {"scale", ProgramUniformVariableDataType::Float32}); + + private: + bool mask_is_2d_; }; class SparseAttentionIndexerCsaCopyCompressedProgram final diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 420160db32fdf..7e5b565e1df89 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -1939,6 +1939,19 @@ const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerRotaryCacheShape( return &shape; } +const ONNX_NAMESPACE::TensorShapeProto* SparseAttentionIndexerMaskShape( + ONNX_NAMESPACE::InferenceContext& ctx, int index) { + if (!SparseAttentionIndexerHasInput(ctx, index) || !hasInputShape(ctx, index)) { + return nullptr; + } + const auto& shape = getInputShape(ctx, index); + if (shape.dim_size() < 2 || shape.dim_size() > 4) { + fail_shape_inference("SparseAttentionIndexer: input ", index, " must have rank 2, 3, or 4, got rank ", + shape.dim_size()); + } + return &shape; +} + } // namespace void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx) { @@ -2042,6 +2055,7 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex updateOutputShape(ctx, sai::kSelectedIndices, selected_shape); if (is_qsa) { + (void)SparseAttentionIndexerMaskShape(ctx, sai::kMask); // 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); @@ -2234,8 +2248,9 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "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 " + "Only for policy_mode 'qsa': an INT64 padding mask with shape " + "(batch_size, total_sequence_length), for which causal visibility is derived internally, or a BOOL " + "explicit visibility mask 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", @@ -2306,7 +2321,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .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("TB", {"tensor(bool)", "tensor(int64)"}, + "Constrain the mask to boolean or int64 tensors.") .TypeConstraint("I", {"tensor(int64)"}, "Constrain position ids to 64-bit integer tensors.") .TypeConstraint("M", {"tensor(int32)"}, "Constrain indices and cache lengths to 32-bit integer tensors.") .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { 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 1ee9a73ae4dc3..15fdbdb3e1b3f 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -96,6 +96,7 @@ struct QsaGraphOptions { bool add_csa_inputs = false; bool share_cache = false; bool shared_rotary_cache = false; + bool use_padding_mask = false; std::string policy_mode = sai::kPolicyModeQsa; }; @@ -115,7 +116,9 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { builder.MakeInput(options.shared_rotary_cache ? std::vector{total, options.rotary_width} : std::vector{options.batch_size, total, options.rotary_width}), - builder.MakeInput(std::vector{options.batch_size, 1, options.sequence_length, total}), + options.use_padding_mask + ? builder.MakeInput(std::vector{options.batch_size, total}) + : builder.MakeInput(std::vector{options.batch_size, 1, options.sequence_length, total}), builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; if (options.add_csa_inputs) { @@ -307,6 +310,7 @@ struct QsaProblem { float epsilon = 1.0e-6f; std::optional scale; bool shared_rotary_cache = false; + bool use_padding_mask = false; std::vector query; std::vector key; @@ -314,6 +318,7 @@ struct QsaProblem { std::vector cos_cache; std::vector sin_cache; std::vector mask; + std::vector padding_mask; std::vector past_key; int TotalSequenceLength() const { return past_sequence_length + sequence_length; } @@ -656,14 +661,24 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { problem.past_key = MakeWave( static_cast(problem.batch_size) * problem.PastKeyCapacity() * problem.head_size, 0.05f, 0.23f); - // Row 0 sees four tokens (two complete blocks, no tail); row 1 sees five (two blocks plus a tail). + if (problem.use_padding_mask) { + problem.padding_mask.assign(static_cast(problem.batch_size) * total, 1); + for (int b = 0; b < problem.batch_size; ++b) { + for (int t = 0; t <= b && t < problem.past_sequence_length; ++t) { + problem.padding_mask[static_cast(b) * total + t] = 0; + } + } + } + + // Materialize the reference visibility. The provider receives only padding_mask for the rank-2 path. 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; + problem.mask[row * total + t] = + !problem.use_padding_mask || problem.padding_mask[static_cast(b) * total + t] != 0; } } } @@ -716,7 +731,11 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), : std::vector{batch_size, total, problem.rotary_width}; test.AddInput("cos_cache", rotary_cache_shape, ToElementType(problem.cos_cache)); test.AddInput("sin_cache", rotary_cache_shape, ToElementType(problem.sin_cache)); - test.AddInput("mask", {batch_size, 1, sequence_length, total}, mask.get(), problem.mask.size()); + if (problem.use_padding_mask) { + test.AddInput("mask", {batch_size, total}, problem.padding_mask); + } else { + test.AddInput("mask", {batch_size, 1, sequence_length, total}, mask.get(), problem.mask.size()); + } test.AddInput("past_key", {batch_size, problem.PastKeyCapacity(), head_size}, ToElementType(problem.past_key)); for (int slot = sai::kGate; slot < sai::kPastSequenceLength; ++slot) { @@ -927,6 +946,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsSharedRotaryCache) { ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); } +TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsInt64PaddingMask) { + QsaGraphOptions options; + options.use_padding_mask = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); +} + TEST(SparseAttentionIndexerShapeInferenceTest, CsaInfersCompressedStateShapes) { CsaGraphOptions options; std::unique_ptr model; @@ -1179,6 +1205,12 @@ TEST(SparseAttentionIndexerTest, QsaSharedRotaryCache) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +TEST(SparseAttentionIndexerTest, QsaInt64PaddingMask) { + QsaProblem problem; + problem.use_padding_mask = true; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); +} + TEST(SparseAttentionIndexerTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 4.0e-3f); } @@ -1243,6 +1275,12 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaSharedRotaryCache) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem)), ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, QsaInt64PaddingMask) { + QsaProblem problem; + problem.use_padding_mask = true; + RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem)), ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f, ProviderKind::WebGpu); } From c6efe240924083dd9026d66138a0f808d4bb6976 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 21:18:00 +0000 Subject: [PATCH 046/102] Fuse query normalization into SparseAttentionIndexer --- .../cuda/sparse_attention_indexer.md | 43 +++++----- .../sparse/sparse_attention_indexer_common.h | 25 +++--- .../cuda/sparse/sparse_attention_indexer.cc | 34 ++++++-- .../sparse/sparse_attention_indexer_impl.cu | 82 +++++++++++++------ .../sparse/sparse_attention_indexer_impl.h | 2 + .../core/graph/contrib_ops/bert_defs.cc | 56 ++++++++----- .../sparse_attention_indexer_op_test.cc | 27 ++++-- 7 files changed, 178 insertions(+), 91 deletions(-) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index 7841652f9fc84..5a4916556cb10 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -69,7 +69,7 @@ for a runtime: | `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`. | +| `epsilon` | float | no | RMSNorm epsilon of queries and compressed keys. 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`. | @@ -80,19 +80,20 @@ for a runtime: | # | Name | Policy | Type | Shape | |---|---|---|---|---| -| 0 | `query` | both | `T` | `(B, S, N, D)` | +| 0 | `query` | both | `T` | `(B, S, N*D)`; logically reshaped and normalized inside the operator | | 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` | both | `T` | `(B, P, D)`; raw keys for `qsa`, compressed keys for `csa` | -| 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_sequence_length` | both | `M` | `(1)`; optional valid length for a max-capacity `past_key` | -| 12 | `past_proj_buffer` | `csa` | `T` | `(2, B, Lb, 2D)`, `Lb` in `[0, 2r)`; keys then gates | +| 2 | `query_norm_weight` | both | `T` | `(D)` | +| 3 | `key_norm_weight` | both | `T` | `(D)` | +| 4 | `cos_cache` | both | `T` | `(B, max_rotary_sequence_length, R)` | +| 5 | `sin_cache` | both | `T` | same as `cos_cache` | +| 6 | `mask` | `qsa` | `TB` | `(B, 1, S, T)` or `(B, S, T)` | +| 7 | `past_key` | both | `T` | `(B, P, D)`; raw keys for `qsa`, compressed keys for `csa` | +| 8 | `gate` | `csa` | `T` | `(B, S, 2D)` | +| 9 | `position_bias` | `csa` | `T` | `(r, 2D)` | +| 10 | `head_weights` | `csa` | `T` | `(B, S, N)` | +| 11 | `position_ids` | `csa` | `I` | `(B, S)` | +| 12 | `past_sequence_length` | both | `M` | `(1)`; optional valid length for a max-capacity `past_key` | +| 13 | `past_proj_buffer` | `csa` | `T` | `(2, B, Lb, 2D)`, `Lb` in `[0, 2r)`; keys then gates | ### Outputs @@ -154,8 +155,9 @@ For every `(b, s)`: `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`. +4. **Score.** Logically reshape each raw query row from `(N*D)` to `(N,D)`, apply + `RMSNorm(q_h) * query_norm_weight`, and rotate it at absolute position `P + s`. Then + `score_j = scale * sum_h ReLU(q_h · k_j)`. 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, @@ -209,6 +211,7 @@ the bias is re-applied at use time and never accumulates). A softmax over the `2 ### Scoring and selection ``` +q_h = RoPE(RMSNorm(query[b, s, h, :]) * query_norm_weight) 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] @@ -238,11 +241,11 @@ frequencies inside the kernel: 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 +### Norm weights 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`). +`query_norm_weight` and `key_norm_weight` are the **effective** multipliers: the kernel computes +`normalized * norm_weight`. Qwen's `Qwen4ExpTextRMSNorm` multiplies by `1 + gamma`. Exporters +targeting that model must fold the addition into both initializers (`norm_weight = 1 + gamma`). DeepSeek's `DeepseekV4RMSNorm` uses a plain `weight *`, so its tensor is passed through unchanged. ## 7. CUDA Kernel Pipeline @@ -309,7 +312,7 @@ 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–10 or 12) or attribute, and vice versa; +- a `qsa` node that provides any `csa`-only input (slots 8–11 or 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`; diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h index c967921e3523d..64a24ed3ec74c 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -37,18 +37,19 @@ inline bool TryParsePolicy(const std::string& policy_mode, Policy& policy) { 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, - kPastSequenceLength = 11, - kPastProjBuffer = 12, - kInputCount = 13, + kQueryNormWeight = 2, + kKeyNormWeight = 3, + kCosCache = 4, + kSinCache = 5, + kMask = 6, + kPastKey = 7, + kGate = 8, + kPositionBias = 9, + kHeadWeights = 10, + kPositionIds = 11, + kPastSequenceLength = 12, + kPastProjBuffer = 13, + kInputCount = 14, }; // present_key is shared by both policies; present_proj_buffer is only used by policy_mode="csa". diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 7ab5a5c780a2a..8ef7222bbc58c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -132,6 +132,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { const Tensor* query = context->Input(sai::kQuery); const Tensor* key = context->Input(sai::kKey); + const Tensor* query_norm_weight = context->Input(sai::kQueryNormWeight); 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); @@ -139,14 +140,20 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { 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)" + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, + "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& query_norm_shape = query_norm_weight->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0, + "SparseAttentionIndexer: query_norm_weight must have a non-empty rank-1 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_ERROR(CheckIntDimension("batch_size", batch_size)); ORT_RETURN_IF_ERROR(CheckIntDimension("sequence_length", sequence_length)); ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); @@ -188,6 +195,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { "past_sequence_length is provided"); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); const auto& mask_shape = mask->Shape(); @@ -238,6 +246,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { Stream(context), params, reinterpret_cast(query->Data()), reinterpret_cast(key->Data()), + reinterpret_cast(query_norm_weight->Data()), reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), @@ -255,6 +264,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { const Tensor* query = context->Input(sai::kQuery); const Tensor* key = context->Input(sai::kKey); + const Tensor* query_norm_weight = context->Input(sai::kQueryNormWeight); 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); @@ -266,14 +276,20 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { const Tensor* past_proj_buffer = context->Input(sai::kPastProjBuffer); 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)" + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, + "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& query_norm_shape = query_norm_weight->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0, + "SparseAttentionIndexer: query_norm_weight must have a non-empty rank-1 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_ERROR(CheckIntDimension("batch_size", batch_size)); ORT_RETURN_IF_ERROR(CheckIntDimension("sequence_length", sequence_length)); ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); @@ -321,6 +337,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { ORT_RETURN_IF_ERROR(CheckIntDimension("past_buffer_length", past_buffer_length)); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {batch_size, sequence_length, width})); ORT_RETURN_IF_ERROR(CheckShape(position_bias, "position_bias", {compress_ratio_, width})); @@ -396,6 +413,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { Stream(context), params, reinterpret_cast(query->Data()), reinterpret_cast(key->Data()), + reinterpret_cast(query_norm_weight->Data()), reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), 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 565c0c5173e95..9c681492a7b35 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -141,10 +141,12 @@ __device__ __forceinline__ int ClampPosition(int64_t position, int max_rotary_le // 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, +__global__ void RotateQueryKernel(const T* query, const T* query_norm_weight, + const T* cos_cache, const T* sin_cache, const int64_t* position_ids, float* query_rotated, SparseAttentionIndexerParams params) { extern __shared__ float shared[]; + float* reduction = shared + params.head_size; 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); @@ -156,6 +158,17 @@ __global__ void RotateQueryKernel(const T* query, const T* cos_cache, const T* s } __syncthreads(); + float sum_squares = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + sum_squares += shared[d] * shared[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) { + shared[d] = shared[d] * inverse_rms * to_float(query_norm_weight[d]); + } + __syncthreads(); + const int64_t raw_position = kUseLeadingRope ? static_cast(params.past_sequence_length) + token : position_ids[static_cast(batch) * params.sequence_length + token]; @@ -245,14 +258,16 @@ __global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, // 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, +__global__ void QsaBlockScoreKernel(const T* query, const T* present_key, + const T* query_norm_weight, const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, const int32_t* visible_indices, const int32_t* visible_count, float* block_scores, SparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* pooled = shared; float* rotated = shared + params.head_size; - float* reduction = shared + 2 * params.head_size; + float* query_head = shared + 2 * params.head_size; + float* reduction = shared + 3 * 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) { @@ -303,10 +318,34 @@ __global__ void QsaBlockScoreKernel(const T* present_key, const T* key_norm_weig 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; + const int64_t query_base = (row * params.num_heads + head) * params.head_size; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + query_head[d] = to_float(query[query_base + d]); + } + __syncthreads(); + + float query_sum_squares = 0.0f; + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + query_sum_squares += query_head[d] * query_head[d]; + } + query_sum_squares = BlockSum(query_sum_squares, reduction); + const float query_inverse_rms = + rsqrtf(query_sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { + query_head[d] = query_head[d] * query_inverse_rms * to_float(query_norm_weight[d]); + } + __syncthreads(); + + const int query_position = ClampPosition( + static_cast(params.past_sequence_length) + row % params.sequence_length, + params.max_rotary_length); + const int64_t query_cache_offset = + (static_cast(batch) * params.max_rotary_length + query_position) * params.rotary_width; float partial = 0.0f; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { - partial += query_head[d] * rotated[d]; + partial += LeadingRope(query_head, params.rotary_width, cos_cache + query_cache_offset, + sin_cache + query_cache_offset, d) * + rotated[d]; } score += fmaxf(BlockSum(partial, reduction), 0.0f); } @@ -615,7 +654,8 @@ 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* query, const T* key, const T* query_norm_weight, + 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) { @@ -639,17 +679,12 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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); @@ -657,8 +692,8 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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, + QsaBlockScoreKernel<<>>( + query, present_key, query_norm_weight, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, block_scores, params); } @@ -670,7 +705,8 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio template Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentionIndexerParams& params, - const T* query, const T* key, const T* key_norm_weight, + const T* query, const T* key, const T* query_norm_weight, + const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const T* gate, const T* position_bias, const T* head_weights, const int64_t* position_ids, const T* past_compressed_key, @@ -712,8 +748,8 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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); + RotateQueryKernel<<>>( + query, query_norm_weight, cos_cache, sin_cache, position_ids, query_rotated, params); if (params.present_compressed_length > 0) { CsaScoreKernel<<>>( @@ -727,12 +763,12 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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*, \ +#define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ + template Status LaunchQsaSparseAttentionIndexer(cudaStream_t, const SparseAttentionIndexerParams&, \ + const T*, 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 T*, const int64_t*, const T*, const T*, const T*, int32_t*, T*, T*, T*, float*); INSTANTIATE_SPARSE_ATTENTION_INDEXER(float) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index ebc5f1f05945e..67f53a90fc14d 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -58,6 +58,7 @@ Status LaunchQsaSparseAttentionIndexer( const SparseAttentionIndexerParams& params, const T* query, const T* key, + const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, @@ -74,6 +75,7 @@ Status LaunchCsaSparseAttentionIndexer( const SparseAttentionIndexerParams& params, const T* query, const T* key, + const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index fad7485a95d62..f468f3ca9f8e3 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2003,19 +2003,29 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex (void)SparseAttentionIndexerShape(ctx, sai::kCosCache, 3); (void)SparseAttentionIndexerShape(ctx, sai::kSinCache, 3); - const auto* query_shape = SparseAttentionIndexerShape(ctx, sai::kQuery, 4); + const auto* query_shape = SparseAttentionIndexerShape(ctx, sai::kQuery, 3); if (query_shape == nullptr) { return; } 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 auto& query_width_dim = query_shape->dim(2); + const auto* query_norm_shape = SparseAttentionIndexerShape(ctx, sai::kQueryNormWeight, 1); + if (query_width_dim.has_dim_value() && query_width_dim.dim_value() <= 0) { + fail_shape_inference("SparseAttentionIndexer: query width must be > 0, got ", query_width_dim.dim_value()); } - if (!is_qsa && head_size_dim.has_dim_value() && - head_size_dim.dim_value() > std::numeric_limits::max() / 2) { + if (query_norm_shape == nullptr) { + return; + } + const auto& head_size_dim = query_norm_shape->dim(0); + if (head_size_dim.has_dim_value() && head_size_dim.dim_value() <= 0) { + fail_shape_inference("SparseAttentionIndexer: head_size must be > 0, got ", head_size_dim.dim_value()); + } + if (query_width_dim.has_dim_value() && head_size_dim.has_dim_value() && head_size_dim.dim_value() > 0 && + query_width_dim.dim_value() % head_size_dim.dim_value() != 0) { + fail_shape_inference("SparseAttentionIndexer: query width must be divisible by head_size"); + } + if (!is_qsa && head_size_dim.has_dim_value() && head_size_dim.dim_value() > std::numeric_limits::max() / 2) { fail_shape_inference("SparseAttentionIndexer: 2 * head_size exceeds INT64_MAX"); } @@ -2142,7 +2152,7 @@ Common contract: 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 + * query_norm_weight and key_norm_weight are the effective RMSNorm multipliers. 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. @@ -2180,7 +2190,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( AttributeProto::INT, OPTIONAL_VALUE) .Attr("epsilon", - "Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.", + "Epsilon of the RMS normalization applied to queries and compressed keys. Default is 1e-6.", AttributeProto::FLOAT, 1.0e-6f) .Attr("scale", @@ -2194,8 +2204,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OPTIONAL_VALUE) .Input(0, "query", - "Indexer queries with shape (batch_size, sequence_length, num_heads, head_size), already " - "normalized but not yet rotated.", + "Indexer queries with shape (batch_size, sequence_length, num_heads * head_size), before " + "normalization, logical reshape, and rotary embedding.", "T") .Input(1, "key", @@ -2204,19 +2214,23 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "where the first head_size channels are the Ca series and the last head_size channels the Cb series.", "T") .Input(2, + "query_norm_weight", + "Effective RMSNorm multiplier of the queries, with shape (head_size).", + "T") + .Input(3, "key_norm_weight", "Effective RMSNorm multiplier of the compressed keys, with shape (head_size).", "T") - .Input(3, + .Input(4, "cos_cache", "Cosine rotary table indexed by absolute key position, with shape " "(batch_size, max_rotary_sequence_length, rotary_width).", "T") - .Input(4, + .Input(5, "sin_cache", "Sine rotary table with the same shape as cos_cache.", "T") - .Input(5, + .Input(6, "mask", "Only for policy_mode 'qsa': tokens visible to each query, with shape " "(batch_size, 1, sequence_length, total_sequence_length) or " @@ -2224,43 +2238,43 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "total_sequence_length is past_sequence_length + sequence_length.", "TB", OpSchema::Optional) - .Input(6, + .Input(7, "past_key", "Cached indexer keys. For policy_mode 'qsa', these are raw keys; for 'csa', they are compressed " "keys. Shape is (batch_size, past_sequence_length, head_size), or " "(batch_size, max_cache_length, head_size) when a valid past_sequence_length is provided.", "T") - .Input(7, + .Input(8, "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, + .Input(9, "position_bias", "Only for policy_mode 'csa': per-slot gate bias with shape (compress_ratio, 2 * head_size).", "T", OpSchema::Optional) - .Input(9, + .Input(10, "head_weights", "Only for policy_mode 'csa': per-head score weights with shape " "(batch_size, sequence_length, num_heads).", "T", OpSchema::Optional) - .Input(10, + .Input(11, "position_ids", "Only for policy_mode 'csa': absolute position of every query with shape " "(batch_size, sequence_length).", "I", OpSchema::Optional) - .Input(11, + .Input(12, "past_sequence_length", "Optional one-element CPU tensor containing the number of valid rows in past_key. For policy_mode " "'csa', the value is the number of compressed keys. When provided, past_key and present_key have " "the same max-capacity shape and may share their buffer.", "M", OpSchema::Optional) - .Input(12, + .Input(13, "past_proj_buffer", "Only for policy_mode 'csa': buffered key and gate projections packed along dimension 0, with shape " "(2, batch_size, buffer_length, 2 * head_size). Slice 0 contains keys and slice 1 contains gates. " 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 69431b923a7ef..ae6a2b106d08b 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -104,10 +104,11 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { const int64_t cache_capacity = options.share_cache ? options.key_cache_capacity : options.past_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.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.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}), @@ -156,10 +157,11 @@ 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, 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.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, @@ -299,6 +301,7 @@ struct QsaProblem { std::vector query; std::vector key; + std::vector query_norm_weight; std::vector key_norm_weight; std::vector cos_cache; std::vector sin_cache; @@ -353,6 +356,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std 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); + head = RmsNormalize(head, problem.query_norm_weight, problem.epsilon); rotated_query[static_cast(h)] = LeadingRope(head, problem.rotary_width, cos_base + query_position * problem.rotary_width, sin_base + query_position * problem.rotary_width); @@ -430,6 +434,7 @@ struct CsaProblem { std::vector query; std::vector key; + std::vector query_norm_weight; std::vector key_norm_weight; std::vector cos_cache; std::vector sin_cache; @@ -564,6 +569,7 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, 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); + head = RmsNormalize(head, problem.query_norm_weight, problem.epsilon); rotated_query[static_cast(h)] = TrailingRope(head, problem.rotary_width, cos_base + query_position * problem.rotary_width, sin_base + query_position * problem.rotary_width); @@ -621,6 +627,7 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { 0.35f, 0.41f); problem.key = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * problem.head_size, 1.10f, 0.29f); + problem.query_norm_weight = MakeWave(static_cast(problem.head_size), 1.20f, 0.23f); problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.70f, 0.17f); problem.cos_cache = MakeWave(static_cast(problem.batch_size) * total * problem.rotary_width, 0.20f, 0.13f); problem.sin_cache = MakeWave(static_cast(problem.batch_size) * total * problem.rotary_width, 0.90f, 0.19f); @@ -649,6 +656,7 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { problem.query = RoundTrip(problem.query); problem.key = RoundTrip(problem.key); + problem.query_norm_weight = RoundTrip(problem.query_norm_weight); problem.key_norm_weight = RoundTrip(problem.key_norm_weight); problem.cos_cache = RoundTrip(problem.cos_cache); problem.sin_cache = RoundTrip(problem.sin_cache); @@ -675,9 +683,10 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { if (problem.scale.has_value()) { test.AddAttribute("scale", *problem.scale); } - test.AddInput("query", {batch_size, sequence_length, problem.num_heads, head_size}, + 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("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); test.AddInput("cos_cache", {batch_size, total, problem.rotary_width}, ToElementType(problem.cos_cache)); test.AddInput("sin_cache", {batch_size, total, problem.rotary_width}, ToElementType(problem.sin_cache)); @@ -702,6 +711,7 @@ CsaProblem MakeCsaProblem(CsaProblem problem = {}) { problem.head_size, 0.25f, 0.37f); problem.key = MakeWave(static_cast(problem.batch_size) * problem.sequence_length * width, 0.60f, 0.21f); + problem.query_norm_weight = MakeWave(static_cast(problem.head_size), 1.10f, 0.19f); problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.45f, 0.31f); problem.cos_cache = MakeWave(static_cast(problem.batch_size) * problem.max_rotary_length * problem.rotary_width, 0.15f, @@ -738,6 +748,7 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { CsaProblem problem = base; problem.query = RoundTrip(problem.query); problem.key = RoundTrip(problem.key); + problem.query_norm_weight = RoundTrip(problem.query_norm_weight); problem.key_norm_weight = RoundTrip(problem.key_norm_weight); problem.cos_cache = RoundTrip(problem.cos_cache); problem.sin_cache = RoundTrip(problem.sin_cache); @@ -774,9 +785,10 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { 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}, + 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("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); test.AddInput("cos_cache", {batch_size, problem.max_rotary_length, problem.rotary_width}, ToElementType(problem.cos_cache)); @@ -826,6 +838,7 @@ CsaProblem MakeCsaBufferOnlyProblem() { 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.query_norm_weight = MakeWave(static_cast(problem.head_size), 1.15f, 0.21f); 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); @@ -944,7 +957,7 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { QsaGraphOptions options; options.num_heads = 0; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, - "num_heads must be > 0"); + "query width must be > 0"); } TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { From fe9d0594ab6f448100214c9c005624a980c88f04 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 21:20:38 +0000 Subject: [PATCH 047/102] 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 f32b9bff11fb914ff1a651e34033c1a8f69aa742 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 21:35:11 +0000 Subject: [PATCH 048/102] Fuse query normalization into PackedSparseAttentionIndexer --- docs/ContribOperators.md | 6 +- .../packed_sparse_attention_indexer.md | 33 ++++---- .../webgpu/packed_sparse_attention_indexer.md | 2 + .../packed_sparse_attention_indexer_common.h | 33 ++++---- .../sparse/packed_sparse_attention_indexer.cc | 36 +++++++-- .../packed_sparse_attention_indexer_impl.cu | 50 ++++++++---- .../packed_sparse_attention_indexer_impl.h | 2 + .../bert/packed_sparse_attention_indexer.cc | 61 ++++++++++++--- .../core/graph/contrib_ops/bert_defs.cc | 77 ++++++++++++------- ...packed_sparse_attention_indexer_op_test.cc | 26 +++++-- 10 files changed, 221 insertions(+), 105 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index f3d97c1cc4599..2ac4253ac4cfe 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -4805,7 +4805,7 @@ This version of the operator has been available since version 1 of the 'com.micr
compress_ratio : int (required)
Number of consecutive tokens folded into one compressed/pooled entry. Must be > 0 and 2 * compress_ratio - 1 must not exceed INT_MAX.
epsilon : float
-
Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.
+
Epsilon of the RMS normalization applied to queries and compressed keys. Default is 1e-6.
head_weight_scale : float
Only for policy_mode 'csa': scale applied to head_weights. Default is 1/sqrt(num_heads). Must be omitted when policy_mode is 'qsa'.
index_topk : int
@@ -4824,9 +4824,11 @@ This version of the operator has been available since version 1 of the 'com.micr
query : T
-
Packed indexer queries with shape (total_tokens, num_heads, head_size), already normalized but not yet rotated.
+
Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, logical reshape, and rotary embedding.
key : T
Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first head_size channels are the Ca series and the last head_size channels the Cb series.
+
query_norm_weight : T
+
Effective RMSNorm multiplier of the queries, with shape (head_size).
key_norm_weight : T
Effective RMSNorm multiplier of the compressed keys, with shape (head_size).
cos_cache : T
diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md index 883a074a657aa..6f7f7e8ba0bf5 100644 --- a/docs/contrib_ops/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -10,7 +10,7 @@ Source: (policy enum, selected-capacity formula and CSA window-plan arithmetic, shared unmodified with `SparseAttentionIndexer`), [packed_sparse_attention_indexer_common.h](../../onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h) -(fixed 15-input / 6-output slot map), +(fixed 16-input / 6-output slot map), [packed_sparse_attention_indexer.cc](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc) / [packed_sparse_attention_indexer_impl.cu](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu) (CUDA), @@ -52,28 +52,29 @@ Attributes: | `scale` | default `1/sqrt(head_size)` | per-head score scale | | `head_weight_scale` | `csa` only, default `1/sqrt(num_heads)` | head-weight score scale | -Inputs are **fixed at 15 indices** for both policies (unlike the dense op, which uses a different +Inputs are **fixed at 16 indices** for both policies (unlike the dense op, which uses a different input/output count per policy). A slot not owned by the active policy is a *positional* optional: its `NodeProto` input name is empty rather than the slot being removed from the list, so every later slot keeps its fixed index. | # | Name | Shape | Type | Policy | |---|---|---|---|---| -| 0 | `query` | `(total_tokens, num_heads, head_size)` | T | both | +| 0 | `query` | `(total_tokens, num_heads*head_size)` | T | both | | 1 | `key` | `(total_tokens, head_size)` qsa / `(total_tokens, 2*head_size)` csa | T | both | -| 2 | `key_norm_weight` | `(head_size)` | T | both | -| 3 | `cos_cache` | `(max_position, rotary_width)` or `(batch_size, max_position, rotary_width)` | T | both | -| 4 | `sin_cache` | same shape as `cos_cache` | T | both | -| 5 | `cumulative_sequence_lengths` | `(batch_size + 1)` | int32, device-resident | both | -| 6 | `past_sequence_lengths` | `(batch_size)` | int32, device-resident | both | -| 7 | `gate` | `(total_tokens, 2*head_size)` | T | csa only | -| 8 | `position_bias` | `(compress_ratio, 2*head_size)` | T | csa only | -| 9 | `head_weights` | `(total_tokens, num_heads)` | T | csa only | -| 10 | `position_ids` | `(total_tokens)` | int64 | optional qsa / required csa | -| 11 | `past_key_state` | `(batch_size, state_capacity, head_size)` | T | both (generic) | -| 12 | `past_kv_buffer` | `(batch_size, 2*compress_ratio-1, width)` | T | both (generic) | -| 13 | `past_gate_buffer` | same shape as `past_kv_buffer` | T | csa only | -| 14 | `past_state_lengths` | `(batch_size, 2)` | int32, device-resident | both (generic) | +| 2 | `query_norm_weight` | `(head_size)` | T | both | +| 3 | `key_norm_weight` | `(head_size)` | T | both | +| 4 | `cos_cache` | `(max_position, rotary_width)` or `(batch_size, max_position, rotary_width)` | T | both | +| 5 | `sin_cache` | same shape as `cos_cache` | T | both | +| 6 | `cumulative_sequence_lengths` | `(batch_size + 1)` | int32, device-resident | both | +| 7 | `past_sequence_lengths` | `(batch_size)` | int32, device-resident | both | +| 8 | `gate` | `(total_tokens, 2*head_size)` | T | csa only | +| 9 | `position_bias` | `(compress_ratio, 2*head_size)` | T | csa only | +| 10 | `head_weights` | `(total_tokens, num_heads)` | T | csa only | +| 11 | `position_ids` | `(total_tokens)` | int64 | optional qsa / required csa | +| 12 | `past_key_state` | `(batch_size, state_capacity, head_size)` | T | both (generic) | +| 13 | `past_kv_buffer` | `(batch_size, 2*compress_ratio-1, width)` | T | both (generic) | +| 14 | `past_gate_buffer` | same shape as `past_kv_buffer` | T | csa only | +| 15 | `past_state_lengths` | `(batch_size, 2)` | int32, device-resident | both (generic) | Outputs are **fixed at 6 indices** for both policies (`present_gate_buffer` is declared with an empty output name for `qsa`, the same positional-optional convention as above): diff --git a/docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md b/docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md index c1ce8a1549358..33ade715ef995 100644 --- a/docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/webgpu/packed_sparse_attention_indexer.md @@ -32,6 +32,8 @@ into workgroup-shared arrays, both to keep every kernel correct without relying sized by a runtime (uniform) `head_size`, and to keep the packed kernel's structure directly comparable to the CUDA implementation's per-request/per-token update and score/select stages. All reductions and softmax calculations accumulate in FP32, including for FP16 inputs. +Raw flattened queries are RMS-normalized per logical head with `query_norm_weight` before the +policy-specific rotary embedding, matching the CUDA implementation. Per-request quantities (`cumulative_sequence_lengths`, `past_sequence_lengths`, `past_state_lengths`) are read directly from device buffers inside the shaders — never on the diff --git a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h index 24b7ce1584071..0d55ae4c5e5e5 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h @@ -30,25 +30,26 @@ using sparse_attention_indexer::SelectedCapacity; using sparse_attention_indexer::TryComputeCsaWindowPlan; using sparse_attention_indexer::TryParsePolicy; -// Fixed input slots. Slots 7-9 belong to policy_mode="csa" only; slot 10 (position_ids) is +// Fixed input slots. Slots 8-10 belong to policy_mode="csa" only; slot 11 (position_ids) is // optional for "qsa" and required for "csa". Every other slot is required for both policies. enum InputIndex : int { - kQuery = 0, // [total_tokens, num_heads, head_size] + kQuery = 0, // [total_tokens, num_heads * head_size] kKey = 1, // qsa: [total_tokens, head_size]; csa: [total_tokens, 2 * head_size] - kKeyNormWeight = 2, // [head_size] - kCosCache = 3, // [max_position, rotary_width] or [batch_size, max_position, rotary_width] - kSinCache = 4, // same shape as cos_cache - kCumulativeSequenceLengths = 5, // [batch_size + 1], int32 - kPastSequenceLengths = 6, // [batch_size], int32 - kGate = 7, // csa only: [total_tokens, 2 * head_size] - kPositionBias = 8, // csa only: [compress_ratio, 2 * head_size] - kHeadWeights = 9, // csa only: [total_tokens, num_heads] - kPositionIds = 10, // optional (qsa) / required (csa): [total_tokens], int64 - kPastKeyState = 11, // generic: [batch_size, state_capacity, head_size] - kPastKvBuffer = 12, // generic: [batch_size, 2 * compress_ratio - 1, width] - kPastGateBuffer = 13, // csa only: same shape as past_kv_buffer - kPastStateLengths = 14, // generic: [batch_size, 2], int32 - kInputCount = 15, + kQueryNormWeight = 2, // [head_size] + kKeyNormWeight = 3, // [head_size] + kCosCache = 4, // [max_position, rotary_width] or [batch_size, max_position, rotary_width] + kSinCache = 5, // same shape as cos_cache + kCumulativeSequenceLengths = 6, // [batch_size + 1], int32 + kPastSequenceLengths = 7, // [batch_size], int32 + kGate = 8, // csa only: [total_tokens, 2 * head_size] + kPositionBias = 9, // csa only: [compress_ratio, 2 * head_size] + kHeadWeights = 10, // csa only: [total_tokens, num_heads] + kPositionIds = 11, // optional (qsa) / required (csa): [total_tokens], int64 + kPastKeyState = 12, // generic: [batch_size, state_capacity, head_size] + kPastKvBuffer = 13, // generic: [batch_size, 2 * compress_ratio - 1, width] + kPastGateBuffer = 14, // csa only: same shape as past_kv_buffer + kPastStateLengths = 15, // generic: [batch_size, 2], int32 + kInputCount = 16, }; // Fixed output slots. present_gate_buffer is declared (with an empty name) but not produced for diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 19d69eb1cfc67..6e01ecf2c6d6a 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -169,6 +169,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const Tensor* query = context->Input(psai::kQuery); const Tensor* key = context->Input(psai::kKey); + const Tensor* query_norm_weight = context->Input(psai::kQueryNormWeight); const Tensor* key_norm_weight = context->Input(psai::kKeyNormWeight); const Tensor* cos_cache = context->Input(psai::kCosCache); const Tensor* sin_cache = context->Input(psai::kSinCache); @@ -181,13 +182,20 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); - ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, - "PackedSparseAttentionIndexer: query must have shape (total_tokens, num_heads, head_size), " + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 2, + "PackedSparseAttentionIndexer: query must have shape (total_tokens, num_heads * head_size), " "got ", query_shape.ToString()); const int64_t total_tokens = query_shape[0]; - const int64_t num_heads = query_shape[1]; - const int64_t head_size = query_shape[2]; + ORT_RETURN_IF(query_norm_weight == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); + const auto& query_norm_shape = query_norm_weight->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1, + "PackedSparseAttentionIndexer: query_norm_weight must have shape (head_size), got ", + query_norm_shape.ToString()); + const int64_t head_size = query_norm_shape[0]; + ORT_RETURN_IF(head_size <= 0 || query_shape[1] % head_size != 0, + "PackedSparseAttentionIndexer: query width must be divisible by head_size"); + const int64_t num_heads = query_shape[1] / head_size; ORT_RETURN_IF_ERROR(CheckIntDimension("total_tokens", total_tokens)); ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); @@ -206,6 +214,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); if (position_ids != nullptr) { ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); @@ -278,6 +287,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con Stream(context), params, reinterpret_cast(query->Data()), reinterpret_cast(key->Data()), + reinterpret_cast(query_norm_weight->Data()), reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), @@ -302,6 +312,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con const Tensor* query = context->Input(psai::kQuery); const Tensor* key = context->Input(psai::kKey); + const Tensor* query_norm_weight = context->Input(psai::kQueryNormWeight); const Tensor* key_norm_weight = context->Input(psai::kKeyNormWeight); const Tensor* cos_cache = context->Input(psai::kCosCache); const Tensor* sin_cache = context->Input(psai::kSinCache); @@ -318,13 +329,20 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); - ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, - "PackedSparseAttentionIndexer: query must have shape (total_tokens, num_heads, head_size), " + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 2, + "PackedSparseAttentionIndexer: query must have shape (total_tokens, num_heads * head_size), " "got ", query_shape.ToString()); const int64_t total_tokens = query_shape[0]; - const int64_t num_heads = query_shape[1]; - const int64_t head_size = query_shape[2]; + ORT_RETURN_IF(query_norm_weight == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); + const auto& query_norm_shape = query_norm_weight->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1, + "PackedSparseAttentionIndexer: query_norm_weight must have shape (head_size), got ", + query_norm_shape.ToString()); + const int64_t head_size = query_norm_shape[0]; + ORT_RETURN_IF(head_size <= 0 || query_shape[1] % head_size != 0, + "PackedSparseAttentionIndexer: query width must be divisible by head_size"); + const int64_t num_heads = query_shape[1] / head_size; ORT_RETURN_IF_ERROR(CheckIntDimension("total_tokens", total_tokens)); ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); @@ -346,6 +364,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, width})); + ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {total_tokens, width})); ORT_RETURN_IF_ERROR(CheckShape(position_bias, "position_bias", {compress_ratio_, width})); @@ -421,6 +440,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con Stream(context), params, reinterpret_cast(query->Data()), reinterpret_cast(key->Data()), + reinterpret_cast(query_norm_weight->Data()), reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index f2bcc3f29c2bf..620247fc41d48 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -209,11 +209,13 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con // defaults to past_sequence_lengths[batch] + request-local offset when position_ids is absent); // otherwise the csa trailing convention with positions always taken from position_ids. template -__global__ void PackedRotateQueryKernel(const T* query, const T* cos_cache, const T* sin_cache, +__global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weight, + const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, float* query_rotated, PackedSparseAttentionIndexerParams params) { extern __shared__ float shared[]; + float* reduction = shared + params.head_size; const int64_t rows = static_cast(params.total_tokens) * params.num_heads; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { const int token = static_cast(row / params.num_heads); @@ -225,6 +227,17 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* cos_cache, cons } __syncthreads(); + float sum_squares = 0.0f; + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + sum_squares += shared[d] * shared[d]; + } + sum_squares = SaiBlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(params.head_size) + params.epsilon); + for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { + shared[d] = shared[d] * inverse_rms * to_float(query_norm_weight[d]); + } + __syncthreads(); + const int64_t abs_position = params.has_position_ids ? position_ids[token] @@ -725,7 +738,8 @@ size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& template Status LaunchQsaPackedSparseAttentionIndexer( cudaStream_t stream, const PackedSparseAttentionIndexerParams& params, const T* query, const T* key, - const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, + const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, + const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, const T* past_key_state, const T* past_kv_buffer, const int32_t* past_state_lengths, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, @@ -769,9 +783,9 @@ Status LaunchQsaPackedSparseAttentionIndexer( const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); - PackedRotateQueryKernel<<>>( - query, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, - params); + PackedRotateQueryKernel<<>>( + query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, + position_ids, query_rotated, params); if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; @@ -792,7 +806,8 @@ Status LaunchQsaPackedSparseAttentionIndexer( template Status LaunchCsaPackedSparseAttentionIndexer( cudaStream_t stream, const PackedSparseAttentionIndexerParams& 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* query_norm_weight, 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 int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, const T* past_key_state, const T* past_kv_buffer, const T* past_gate_buffer, const int32_t* past_state_lengths, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, @@ -844,9 +859,9 @@ Status LaunchCsaPackedSparseAttentionIndexer( const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); - PackedRotateQueryKernel<<>>( - query, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, - params); + PackedRotateQueryKernel<<>>( + query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, + position_ids, query_rotated, params); if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; @@ -864,14 +879,15 @@ Status LaunchCsaPackedSparseAttentionIndexer( return CUDA_CALL(cudaGetLastError()); } -#define INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(T) \ - template Status LaunchQsaPackedSparseAttentionIndexer( \ - cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ - const T*, const int32_t*, const int32_t*, const int64_t*, const T*, const T*, const int32_t*, int32_t*, \ - int32_t*, T*, T*, int32_t*, float*, int32_t*); \ - template Status LaunchCsaPackedSparseAttentionIndexer( \ - cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ - const T*, const T*, const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, \ +#define INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(T) \ + template Status LaunchQsaPackedSparseAttentionIndexer( \ + cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ + const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, const T*, const int32_t*, \ + int32_t*, \ + int32_t*, T*, T*, int32_t*, float*, int32_t*); \ + template Status LaunchCsaPackedSparseAttentionIndexer( \ + cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ + const T*, const T*, const T*, const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, \ const T*, const T*, const int32_t*, int32_t*, int32_t*, T*, T*, T*, int32_t*, float*, int32_t*); INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(float) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index 2eae2bdc4465a..9d92076c2ff51 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -56,6 +56,7 @@ Status LaunchQsaPackedSparseAttentionIndexer( const PackedSparseAttentionIndexerParams& params, const T* query, const T* key, + const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, @@ -79,6 +80,7 @@ Status LaunchCsaPackedSparseAttentionIndexer( const PackedSparseAttentionIndexerParams& params, const T* query, const T* key, + const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index b4cc3c0dd7a8a..461cc03666525 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -241,6 +241,7 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe Status PackedSparseAttentionIndexerQsaSelectProgram::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& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); @@ -288,14 +289,25 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << "}\n"; } shader.AdditionalImplementation() + << "fn normalized_query_value(token: u32, head: u32, d: u32) -> f32 {\n" + << " let base = (token * 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(token: u32, head: u32, d: u32, position: i32, b: u32) -> f32 {\n" << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" - << " var value = f32(" << query.GetByOffset("base + d") << ");\n" + << " var value = normalized_query_value(token, head, d);\n" << " if (d >= uniforms.rotary_width) { return value; }\n" << " let half = uniforms.rotary_width / 2u;\n" << " 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(token, head, pair_d);\n" << " let position_clamped = clamp_position(position);\n"; if (cos_cache_batched_) { shader.AdditionalImplementation() @@ -570,6 +582,7 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe Status PackedSparseAttentionIndexerCsaSelectProgram::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& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); const auto& head_weights = shader.AddInput("head_weights", ShaderUsage::UseUniform); const auto& position_ids = shader.AddInput("position_ids", ShaderUsage::UseUniform); @@ -600,15 +613,26 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << " let cr = uniforms.compress_ratio;\n" << " return p / cr + select(0u, 1u, p % cr == cr - 1u);\n" << "}\n" + << "fn normalized_query_value(token: u32, head: u32, d: u32) -> f32 {\n" + << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" + << " 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(token: u32, head: u32, d: u32, raw: vec2, b: u32) -> f32 {\n" << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" - << " var value = f32(" << query.GetByOffset("base + d") << ");\n" + << " var value = normalized_query_value(token, head, d);\n" << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" << " if (d < rotary_base) { return value; }\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(token, head, pair_d);\n" << " let position = min(clamped_position(raw), uniforms.max_rotary_length - 1u);\n"; if (cos_cache_batched_) { shader.AdditionalImplementation() @@ -739,6 +763,7 @@ Status PackedSparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::Comput Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& context) const { const Tensor* query = context.Input(psai::kQuery); const Tensor* key = context.Input(psai::kKey); + const Tensor* query_norm = context.Input(psai::kQueryNormWeight); const Tensor* norm = context.Input(psai::kKeyNormWeight); const Tensor* cos_cache = context.Input(psai::kCosCache); const Tensor* sin_cache = context.Input(psai::kSinCache); @@ -751,10 +776,15 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); - ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, "PackedSparseAttentionIndexer: query must have rank 3"); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 2, "PackedSparseAttentionIndexer: query must have rank 2"); const int64_t total_tokens = query_shape[0]; - const int64_t num_heads = query_shape[1]; - const int64_t head_size = query_shape[2]; + ORT_RETURN_IF(query_norm == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); + const auto& query_norm_shape = query_norm->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0 && + query_shape[1] % query_norm_shape[0] == 0, + "PackedSparseAttentionIndexer: invalid flattened query dimensions"); + const int64_t head_size = query_norm_shape[0]; + const int64_t num_heads = query_shape[1] / head_size; ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "PackedSparseAttentionIndexer: invalid query dimensions"); ORT_RETURN_IF(cu_seqlens == nullptr, "PackedSparseAttentionIndexer: cumulative_sequence_lengths is required"); @@ -767,6 +797,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); if (position_ids != nullptr) { ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); @@ -880,6 +911,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont select.CacheHint(rotary.batched, position_ids != nullptr) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, + {query_norm, ProgramTensorMetadataDependency::Type}, {present_key_state, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, @@ -910,6 +942,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& context) const { const Tensor* query = context.Input(psai::kQuery); const Tensor* key = context.Input(psai::kKey); + const Tensor* query_norm = context.Input(psai::kQueryNormWeight); const Tensor* norm = context.Input(psai::kKeyNormWeight); const Tensor* cos_cache = context.Input(psai::kCosCache); const Tensor* sin_cache = context.Input(psai::kSinCache); @@ -926,10 +959,15 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); - ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 3, "PackedSparseAttentionIndexer: query must have rank 3"); + ORT_RETURN_IF_NOT(query_shape.NumDimensions() == 2, "PackedSparseAttentionIndexer: query must have rank 2"); const int64_t total_tokens = query_shape[0]; - const int64_t num_heads = query_shape[1]; - const int64_t head_size = query_shape[2]; + ORT_RETURN_IF(query_norm == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); + const auto& query_norm_shape = query_norm->Shape(); + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0 && + query_shape[1] % query_norm_shape[0] == 0, + "PackedSparseAttentionIndexer: invalid flattened query dimensions"); + const int64_t head_size = query_norm_shape[0]; + const int64_t num_heads = query_shape[1] / head_size; ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "PackedSparseAttentionIndexer: invalid query dimensions"); const int64_t width = 2 * head_size; @@ -943,6 +981,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, width})); + ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(gate, "gate", {total_tokens, width})); ORT_RETURN_IF_ERROR(CheckShape(position_bias, "position_bias", {compress_ratio_, width})); @@ -1047,6 +1086,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont select.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, + {query_norm, ProgramTensorMetadataDependency::Type}, {present_key_state, ProgramTensorMetadataDependency::Type}, {head_weights, ProgramTensorMetadataDependency::Type}, {position_ids, ProgramTensorMetadataDependency::Type}, @@ -1067,6 +1107,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont {ToUint32(state_capacity)}, {ToUint32(capacity)}, {ToUint32(index_topk_)}, + {epsilon_}, {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))}}); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 386432c01e52f..870110b3f2dac 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2421,9 +2421,10 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference propagateElemTypeFromInputToOutput(ctx, psai::kPastGateBuffer, psai::kPresentGateBuffer); } - const auto* query_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQuery, 3); + const auto* query_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQuery, 2); const auto* key_shape = PackedSparseAttentionIndexerShape(ctx, psai::kKey, 2); - const auto* norm_shape = PackedSparseAttentionIndexerShape(ctx, psai::kKeyNormWeight, 1); + const auto* query_norm_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQueryNormWeight, 1); + const auto* key_norm_shape = PackedSparseAttentionIndexerShape(ctx, psai::kKeyNormWeight, 1); const auto* cumulative_shape = PackedSparseAttentionIndexerShape(ctx, psai::kCumulativeSequenceLengths, 1); const auto* past_sequence_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPastSequenceLengths, 1); @@ -2479,14 +2480,15 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference const int64_t buffer_capacity = psai::GenericBufferCapacity(compress_ratio); require_equal_dims(key_shape, 0, query_shape, 0, "key dimension 0 must equal query dimension 0"); - require_equal_dims(norm_shape, 0, query_shape, 2, "key_norm_weight dimension 0 must equal head_size"); + require_equal_dims(query_norm_shape, 0, key_norm_shape, 0, + "query_norm_weight and key_norm_weight dimensions must match"); require_equal_dims(past_sequence_shape, 0, key_state_shape, 0, "past_sequence_lengths dimension 0 must equal the state batch dimension"); require_equal_dims(key_state_shape, 0, kv_buffer_shape, 0, "past_key_state and past_kv_buffer batch dimensions must match"); require_equal_dims(state_lengths_shape, 0, key_state_shape, 0, "past_state_lengths dimension 0 must equal the state batch dimension"); - require_equal_dims(key_state_shape, 2, query_shape, 2, + require_equal_dims(key_state_shape, 2, query_norm_shape, 0, "past_key_state dimension 2 must equal head_size"); require_dim_value(key_state_shape, 1, state_capacity, "past_key_state dimension 1 must equal state_capacity"); require_dim_value(kv_buffer_shape, 1, buffer_capacity, @@ -2500,8 +2502,8 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference "PackedSparseAttentionIndexer: cumulative_sequence_lengths dimension 0 must equal batch_size + 1"); } - if (query_shape != nullptr && query_shape->dim(2).has_dim_value()) { - const int64_t head_size = query_shape->dim(2).dim_value(); + if (query_norm_shape != nullptr && query_norm_shape->dim(0).has_dim_value()) { + const int64_t head_size = query_norm_shape->dim(0).dim_value(); if (head_size <= 0) { fail_shape_inference("PackedSparseAttentionIndexer: head_size must be > 0, got ", head_size); } @@ -2522,8 +2524,14 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference require_equal_dims(gate_shape, 0, query_shape, 0, "gate dimension 0 must equal query dimension 0"); require_equal_dims(head_weights_shape, 0, query_shape, 0, "head_weights dimension 0 must equal query dimension 0"); - require_equal_dims(head_weights_shape, 1, query_shape, 1, - "head_weights dimension 1 must equal num_heads"); + if (head_weights_shape != nullptr && query_shape != nullptr && + head_weights_shape->dim(1).has_dim_value() && query_shape->dim(1).has_dim_value() && + query_norm_shape != nullptr && query_norm_shape->dim(0).has_dim_value() && + query_norm_shape->dim(0).dim_value() > 0 && + query_shape->dim(1).dim_value() / query_norm_shape->dim(0).dim_value() != + head_weights_shape->dim(1).dim_value()) { + fail_shape_inference("PackedSparseAttentionIndexer: head_weights dimension 1 must equal num_heads"); + } require_dim_value(position_bias_shape, 0, compress_ratio, "position_bias dimension 0 must equal compress_ratio"); require_equal_dims(gate_buffer_shape, 0, kv_buffer_shape, 0, @@ -2561,8 +2569,8 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (rotary_width <= 0 || (is_qsa && rotary_width % 2 != 0)) { fail_shape_inference("PackedSparseAttentionIndexer: invalid rotary cache width ", rotary_width); } - if (query_shape != nullptr && query_shape->dim(2).has_dim_value()) { - const int64_t head_size = query_shape->dim(2).dim_value(); + if (query_norm_shape != nullptr && query_norm_shape->dim(0).has_dim_value()) { + const int64_t head_size = query_norm_shape->dim(0).dim_value(); if ((is_qsa && rotary_width > head_size) || (!is_qsa && rotary_width > head_size / 2)) { fail_shape_inference("PackedSparseAttentionIndexer: rotary cache width is incompatible with head_size"); @@ -2573,9 +2581,15 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (query_shape != nullptr) { const auto& total_tokens_dim = query_shape->dim(0); - const auto& num_heads_dim = query_shape->dim(1); - if (num_heads_dim.has_dim_value() && num_heads_dim.dim_value() <= 0) { - fail_shape_inference("PackedSparseAttentionIndexer: num_heads must be > 0, got ", num_heads_dim.dim_value()); + const auto& query_width_dim = query_shape->dim(1); + if (query_width_dim.has_dim_value() && query_width_dim.dim_value() <= 0) { + fail_shape_inference("PackedSparseAttentionIndexer: query width must be > 0, got ", + query_width_dim.dim_value()); + } + if (query_width_dim.has_dim_value() && query_norm_shape != nullptr && + query_norm_shape->dim(0).has_dim_value() && query_norm_shape->dim(0).dim_value() > 0 && + query_width_dim.dim_value() % query_norm_shape->dim(0).dim_value() != 0) { + fail_shape_inference("PackedSparseAttentionIndexer: query width must be divisible by head_size"); } const int64_t capacity = psai::SelectedCapacity(policy, token_budget, index_topk, compress_ratio); @@ -2653,7 +2667,8 @@ Common contract: with attention_mode="local_plus_selected", selected_kv_source="auxiliary"; key_state is layout-compatible with a [batch_size, capacity, 1, head_size] auxiliary cache when K = V). Unused entries are -1 and selected_counts holds the exact number of used entries. - * key_norm_weight is the effective RMSNorm multiplier, exactly as in SparseAttentionIndexer. + * query_norm_weight and key_norm_weight are the effective RMSNorm multipliers, exactly as in + SparseAttentionIndexer. * 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 @@ -2695,7 +2710,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( AttributeProto::INT, OPTIONAL_VALUE) .Attr("epsilon", - "Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.", + "Epsilon of the RMS normalization applied to queries and compressed keys. Default is 1e-6.", AttributeProto::FLOAT, 1.0e-6f) .Attr("scale", @@ -2709,8 +2724,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OPTIONAL_VALUE) .Input(0, "query", - "Packed indexer queries with shape (total_tokens, num_heads, head_size), already normalized but " - "not yet rotated.", + "Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, " + "logical reshape, and rotary embedding.", "T") .Input(1, "key", @@ -2719,72 +2734,76 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "head_size channels are the Ca series and the last head_size channels the Cb series.", "T") .Input(2, + "query_norm_weight", + "Effective RMSNorm multiplier of the queries, with shape (head_size).", + "T") + .Input(3, "key_norm_weight", "Effective RMSNorm multiplier of the compressed keys, with shape (head_size).", "T") - .Input(3, + .Input(4, "cos_cache", "Cosine rotary table indexed by absolute key position, shared across the batch with shape " "(max_rotary_sequence_length, rotary_width) or request-specific with shape " "(batch_size, max_rotary_sequence_length, rotary_width).", "T") - .Input(4, + .Input(5, "sin_cache", "Sine rotary table with the same shape as cos_cache.", "T") - .Input(5, + .Input(6, "cumulative_sequence_lengths", "Device-resident packed request boundaries with shape (batch_size + 1); " "cumulative_sequence_lengths[0] must be 0 and cumulative_sequence_lengths[batch_size] must equal " "total_tokens. Request b owns rows [cumulative_sequence_lengths[b], " "cumulative_sequence_lengths[b + 1]) of query/key (a repeated offset is a valid zero-token row).", "M") - .Input(6, + .Input(7, "past_sequence_lengths", "Device-resident number of tokens already processed for each request before this call, with " "shape (batch_size). Used as the default absolute query position when position_ids is omitted " "(policy_mode 'qsa'), and to validate state consistency.", "M") - .Input(7, + .Input(8, "gate", "Only for policy_mode 'csa': gate projection of the new tokens with shape " "(total_tokens, 2 * head_size).", "T", OpSchema::Optional) - .Input(8, + .Input(9, "position_bias", "Only for policy_mode 'csa': per-slot gate bias with shape (compress_ratio, 2 * head_size).", "T", OpSchema::Optional) - .Input(9, + .Input(10, "head_weights", "Only for policy_mode 'csa': per-head score weights with shape (total_tokens, num_heads).", "T", OpSchema::Optional) - .Input(10, + .Input(11, "position_ids", "Optional for policy_mode 'qsa', required for policy_mode 'csa': absolute position of every " "packed query, with shape (total_tokens).", "I", OpSchema::Optional) - .Input(11, + .Input(12, "past_key_state", "Generic fixed-capacity state: policy_mode 'qsa' stores prepared complete-block keys; " "policy_mode 'csa' stores compressed keys. Shape is (batch_size, state_capacity, head_size) and " "never changes across calls.", "T") - .Input(12, + .Input(13, "past_kv_buffer", "Generic fixed-capacity pending-token buffer. Shape is (batch_size, 2 * compress_ratio - 1, " "head_size) for policy_mode 'qsa' (which only ever uses up to compress_ratio - 1 of these " "entries) and (batch_size, 2 * compress_ratio - 1, 2 * head_size) for policy_mode 'csa'.", "T") - .Input(13, + .Input(14, "past_gate_buffer", "Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.", "T", OpSchema::Optional) - .Input(14, + .Input(15, "past_state_lengths", "Generic per-request state length with shape (batch_size, 2). Column 0 is the key_state entry " "count (policy_mode 'qsa': complete-block count; 'csa': compressed-entry count); column 1 is the " diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index f97f2bfaeb513..c455ee1b64a44 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -105,8 +105,8 @@ struct GraphOptions { int64_t BufferCapacity(int64_t compress_ratio) { return 2 * compress_ratio - 1; } -// Builds a fixed 15-input node; csa-only slots are left empty for policy_mode "qsa", as the schema -// requires. position_ids (slot 10) is optional for "qsa" and forced on for "csa". +// Builds a fixed 16-input node; csa-only slots are left empty for policy_mode "qsa", as the schema +// requires. position_ids (slot 11) is optional for "qsa" and forced on for "csa". void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { const bool is_csa = options.policy_mode == psai::kPolicyModeCsa; const int64_t width = is_csa ? 2 * options.head_size : options.head_size; @@ -115,10 +115,11 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { std::vector inputs{ builder.MakeInput( - std::vector{options.total_tokens, options.num_heads, options.head_size}), + std::vector{options.total_tokens, options.num_heads * options.head_size}), builder.MakeInput( std::vector{options.key_total_tokens >= 0 ? options.key_total_tokens : options.total_tokens, width}), builder.MakeInput(std::vector{options.head_size}), + builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{64, options.rotary_width}), builder.MakeInput(std::vector{64, options.rotary_width}), builder.MakeInput(std::vector{options.batch_size + 1}), @@ -275,7 +276,7 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { GraphOptions options; options.num_heads = 0; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "num_heads must be > 0"); + "query width must be > 0"); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsGenericBufferCapacityOverflow) { @@ -321,9 +322,10 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingPositionId const int64_t width = 2 * local.head_size; const int64_t buffer_capacity = BufferCapacity(local.compress_ratio); std::vector inputs{ - builder.MakeInput(std::vector{local.total_tokens, local.num_heads, local.head_size}), + builder.MakeInput(std::vector{local.total_tokens, local.num_heads * local.head_size}), builder.MakeInput(std::vector{local.total_tokens, width}), builder.MakeInput(std::vector{local.head_size}), + builder.MakeInput(std::vector{local.head_size}), builder.MakeInput(std::vector{64, local.rotary_width}), builder.MakeInput(std::vector{64, local.rotary_width}), builder.MakeInput(std::vector{local.batch_size + 1}), @@ -514,6 +516,7 @@ struct QsaPackedProblem { std::vector query; std::vector key; + std::vector query_norm_weight; std::vector key_norm_weight; std::vector cos_cache; // shared: [max_position, rotary_width] std::vector sin_cache; @@ -618,6 +621,7 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { for (int h = 0; h < p.num_heads; ++h) { const size_t base = (static_cast(token) * p.num_heads + h) * head_size; std::vector head(p.query.begin() + base, p.query.begin() + base + head_size); + head = RmsNormalize(head, p.query_norm_weight, p.epsilon); const int clamped_position = std::min(std::max(position, 0), p.max_position - 1); rotated_query[static_cast(h)] = LeadingRope(head, p.rotary_width, p.cos_cache.data() + clamped_position * p.rotary_width, @@ -676,6 +680,7 @@ QsaPackedProblem MakeQsaPackedProblem(QsaPackedProblem problem = {}) { problem.query = MakeWave(static_cast(total_tokens) * problem.num_heads * problem.head_size, 0.35f, 0.41f); problem.key = MakeWave(static_cast(total_tokens) * problem.head_size, 1.10f, 0.29f); + problem.query_norm_weight = MakeWave(static_cast(problem.head_size), 1.30f, 0.23f); problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.70f, 0.17f); problem.cos_cache = MakeWave(static_cast(problem.max_position) * problem.rotary_width, 0.20f, 0.13f); problem.sin_cache = MakeWave(static_cast(problem.max_position) * problem.rotary_width, 0.90f, 0.19f); @@ -697,6 +702,7 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP problem.query = RoundTrip(problem.query); problem.key = RoundTrip(problem.key); + problem.query_norm_weight = RoundTrip(problem.query_norm_weight); problem.key_norm_weight = RoundTrip(problem.key_norm_weight); problem.cos_cache = RoundTrip(problem.cos_cache); problem.sin_cache = RoundTrip(problem.sin_cache); @@ -719,8 +725,9 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP if (problem.scale.has_value()) { test.AddAttribute("scale", *problem.scale); } - test.AddInput("query", {total_tokens, problem.num_heads, head_size}, ToElementType(problem.query)); + test.AddInput("query", {total_tokens, problem.num_heads * head_size}, ToElementType(problem.query)); test.AddInput("key", {total_tokens, head_size}, ToElementType(problem.key)); + test.AddInput("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); test.AddInput("cos_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.cos_cache)); test.AddInput("sin_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.sin_cache)); @@ -847,6 +854,7 @@ struct CsaPackedProblem { std::vector query; std::vector key; + std::vector query_norm_weight; std::vector key_norm_weight; std::vector cos_cache; std::vector sin_cache; @@ -1001,6 +1009,7 @@ void CsaPackedReference(const CsaPackedProblem& p, CsaPackedResult& out) { for (int h = 0; h < p.num_heads; ++h) { const size_t base = (static_cast(token) * p.num_heads + h) * head_size; std::vector head(p.query.begin() + base, p.query.begin() + base + head_size); + head = RmsNormalize(head, p.query_norm_weight, p.epsilon); const int clamped_position = std::min(std::max(position, 0), p.max_position - 1); rotated_query[static_cast(h)] = TrailingRope(head, p.rotary_width, p.cos_cache.data() + clamped_position * p.rotary_width, @@ -1046,6 +1055,7 @@ CsaPackedProblem MakeCsaPackedProblem(CsaPackedProblem problem = {}) { problem.query = MakeWave(static_cast(total_tokens) * problem.num_heads * problem.head_size, 0.25f, 0.37f); problem.key = MakeWave(static_cast(total_tokens) * width, 0.60f, 0.21f); + problem.query_norm_weight = MakeWave(static_cast(problem.head_size), 1.25f, 0.19f); problem.key_norm_weight = MakeWave(static_cast(problem.head_size), 0.45f, 0.31f); problem.cos_cache = MakeWave(static_cast(problem.max_position) * problem.rotary_width, 0.15f, 0.27f); problem.sin_cache = MakeWave(static_cast(problem.max_position) * problem.rotary_width, 1.05f, 0.33f); @@ -1082,6 +1092,7 @@ void RunCsaPackedTest(const CsaPackedProblem& base, float tolerance, CsaPackedProblem problem = base; problem.query = RoundTrip(problem.query); problem.key = RoundTrip(problem.key); + problem.query_norm_weight = RoundTrip(problem.query_norm_weight); problem.key_norm_weight = RoundTrip(problem.key_norm_weight); problem.cos_cache = RoundTrip(problem.cos_cache); problem.sin_cache = RoundTrip(problem.sin_cache); @@ -1109,8 +1120,9 @@ void RunCsaPackedTest(const CsaPackedProblem& base, float tolerance, 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", {total_tokens, problem.num_heads, head_size}, ToElementType(problem.query)); + test.AddInput("query", {total_tokens, problem.num_heads * head_size}, ToElementType(problem.query)); test.AddInput("key", {total_tokens, width}, ToElementType(problem.key)); + test.AddInput("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); test.AddInput("cos_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.cos_cache)); test.AddInput("sin_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.sin_cache)); From 9c8ed58ed0331f0cbe4318f376cfe95670ed8ec6 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 23:06:14 +0000 Subject: [PATCH 049/102] Restrict SparseAttentionIndexer to integer masks --- docs/ContribOperators.md | 6 +-- .../cuda/sparse_attention_indexer.md | 10 ++--- .../cuda/sparse/sparse_attention_indexer.cc | 14 +++---- .../sparse/sparse_attention_indexer_impl.cu | 16 +++++--- .../sparse/sparse_attention_indexer_impl.h | 2 +- .../core/graph/contrib_ops/bert_defs.cc | 8 ++-- .../sparse_attention_indexer_op_test.cc | 38 ++++++++++--------- 7 files changed, 50 insertions(+), 44 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 71eda5db383bc..ae4a2c5ad8ad4 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7112,7 +7112,7 @@ This version of the operator has been available since version 1 of the 'com.micr
sin_cache : T
Sine rotary table with the same shape as cos_cache.
mask (optional) : TB
-
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.
+
Only for policy_mode 'qsa': INT64 padding mask with shape (batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. total_sequence_length is past_sequence_length + sequence_length.
past_key : T
Cached indexer keys. For policy_mode 'qsa', these are raw keys; for 'csa', they are compressed keys. Shape is (batch_size, past_sequence_length, head_size), or (batch_size, max_cache_length, head_size) when a valid past_sequence_length is provided.
gate (optional) : T
@@ -7145,8 +7145,8 @@ This version of the operator has been available since version 1 of the 'com.micr
T : tensor(float), tensor(float16), tensor(bfloat16)
Constrain floating point tensors to float, float16 and bfloat16.
-
TB : tensor(bool)
-
Constrain the visibility mask to boolean tensors.
+
TB : tensor(int64)
+
Constrain the mask to 64-bit integer tensors.
I : tensor(int64)
Constrain position ids to 64-bit integer tensors.
M : tensor(int32)
diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index 5a4916556cb10..8b87daa617909 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -86,7 +86,7 @@ for a runtime: | 3 | `key_norm_weight` | both | `T` | `(D)` | | 4 | `cos_cache` | both | `T` | `(B, max_rotary_sequence_length, R)` | | 5 | `sin_cache` | both | `T` | same as `cos_cache` | -| 6 | `mask` | `qsa` | `TB` | `(B, 1, S, T)` or `(B, S, T)` | +| 6 | `mask` | `qsa` | `TB` | INT64 padding mask `(B, T)` | | 7 | `past_key` | both | `T` | `(B, P, D)`; raw keys for `qsa`, compressed keys for `csa` | | 8 | `gate` | `csa` | `T` | `(B, S, 2D)` | | 9 | `position_bias` | `csa` | `T` | `(r, 2D)` | @@ -111,7 +111,7 @@ follow from `Lb`, `S` and `r` alone (see [§5](#5-policy-csa)). | Name | Allowed types | |---|---| | `T` | `tensor(float)`, `tensor(float16)`, `tensor(bfloat16)` | -| `TB` | `tensor(bool)` | +| `TB` | `tensor(int64)` | | `I` | `tensor(int64)` | | `M` | `tensor(int32)` | @@ -150,7 +150,7 @@ its ordinary bounded state contract and stays below `2r` positions. For every `(b, s)`: -1. **Visible set.** `visible = [t for t in range(T) if mask[b, s, t]]`, in ascending `t`. +1. **Visible set.** `visible = [t for t in range(T) if mask[b, t] != 0 and t <= P + s]`, 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 @@ -358,8 +358,8 @@ The implementation is correctness-first. The following are known and deliberate: 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. +4. **`CompactVisibleKernel` is `O(T)` per query row** and re-reads each batch's `(B, T)` padding + mask for every `s`. Sharing work across query rows would reduce that traffic for long contexts. 5. **`compress_ratio` and `head_size` are not specialized.** Templating the hot kernels on a small set of common values would remove the dynamic loop bounds. 6. **No CPU kernel.** The operator is CUDA-only today. diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 8ef7222bbc58c..39414461ceaae 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -27,7 +27,7 @@ namespace sai = onnxruntime::contrib::sparse_attention_indexer; kCudaExecutionProvider, \ (*KernelDefBuilder::Create()) \ .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("TB", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("TB", DataTypeImpl::GetTensorType()) \ .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ .MayInplace(sai::kPastKey, sai::kPresentKey) \ @@ -199,14 +199,10 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { 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=", + mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_sequence_length, + "SparseAttentionIndexer: mask must be INT64 with shape (batch_size, total_sequence_length), with " + "total_sequence_length=", total_sequence_length, ", got ", mask_shape.ToString()); SparseAttentionIndexerParams params; @@ -250,7 +246,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), - mask->Data(), + mask->Data(), reinterpret_cast(past_key->Data()), selected_indices->MutableData(), reinterpret_cast(present_key->MutableData()), 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 9c681492a7b35..b293626bd0618 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -222,17 +222,23 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention } // One block per query row; compacts the visible key positions of that row into visible_indices. -__global__ void CompactVisibleKernel(const bool* mask, int32_t* visible_indices, int32_t* visible_count, +__global__ void CompactVisibleKernel(const int64_t* 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; + const int batch = static_cast(row / params.sequence_length); + const int query = static_cast(row % params.sequence_length); + const int64_t* mask_row = mask + static_cast(batch) * 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; + const int32_t flag = + (position < params.total_sequence_length && position <= params.past_sequence_length + query && + mask_row[position] != 0) + ? 1 + : 0; shared_scan[threadIdx.x] = flag; __syncthreads(); for (int stride = 1; stride < blockDim.x; stride <<= 1) { @@ -656,7 +662,7 @@ template Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentionIndexerParams& params, const T* query, const T* key, const T* query_norm_weight, const T* key_norm_weight, - const T* cos_cache, const T* sin_cache, const bool* mask, + const T* cos_cache, const T* sin_cache, const int64_t* 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; @@ -766,7 +772,7 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio #define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ template Status LaunchQsaSparseAttentionIndexer(cudaStream_t, const SparseAttentionIndexerParams&, \ const T*, const T*, const T*, const T*, const T*, const T*, \ - const bool*, const T*, int32_t*, T*, float*, int32_t*); \ + const int64_t*, 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 T*, const int64_t*, const T*, const T*, const T*, int32_t*, T*, T*, T*, float*); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 67f53a90fc14d..779e73b721b2c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -62,7 +62,7 @@ Status LaunchQsaSparseAttentionIndexer( const T* key_norm_weight, const T* cos_cache, const T* sin_cache, - const bool* mask, + const int64_t* mask, const T* past_key, int32_t* selected_indices, T* present_key, diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 805a302b9af38..9d5dfd5cf7a5d 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2071,6 +2071,7 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex updateOutputShape(ctx, sai::kSelectedIndices, selected_shape); if (is_qsa) { + (void)SparseAttentionIndexerShape(ctx, sai::kMask, 2); // 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); @@ -2266,9 +2267,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "T") .Input(6, "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). " + "Only for policy_mode 'qsa': INT64 padding mask with shape " + "(batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. " "total_sequence_length is past_sequence_length + sequence_length.", "TB", OpSchema::Optional) @@ -2338,7 +2338,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .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("TB", {"tensor(int64)"}, "Constrain the mask to 64-bit integer tensors.") .TypeConstraint("I", {"tensor(int64)"}, "Constrain position ids to 64-bit integer tensors.") .TypeConstraint("M", {"tensor(int32)"}, "Constrain indices and cache lengths to 32-bit integer tensors.") .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { 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 ae6a2b106d08b..b5e0916057bb2 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -94,6 +94,7 @@ struct QsaGraphOptions { int64_t token_budget = 4; bool add_index_topk = false; bool add_csa_inputs = false; + bool use_boolean_mask = false; bool share_cache = false; std::string policy_mode = sai::kPolicyModeQsa; }; @@ -111,7 +112,9 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { 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}), + options.use_boolean_mask + ? builder.MakeInput(std::vector{options.batch_size, total}) + : builder.MakeInput(std::vector{options.batch_size, total}), builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; if (options.add_csa_inputs) { @@ -305,7 +308,7 @@ struct QsaProblem { std::vector key_norm_weight; std::vector cos_cache; std::vector sin_cache; - std::vector mask; + std::vector mask; std::vector past_key; int TotalSequenceLength() const { return past_sequence_length + sequence_length; } @@ -364,7 +367,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std std::vector visible; for (int t = 0; t < total; ++t) { - if (problem.mask[row * total + t] != 0) { + if (t <= problem.past_sequence_length + s && problem.mask[static_cast(b) * total + t] != 0) { visible.push_back(t); } } @@ -635,14 +638,10 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { static_cast(problem.batch_size) * problem.PastKeyCapacity() * problem.head_size, 0.05f, 0.23f); // Row 0 sees four tokens (two complete blocks, no tail); row 1 sees five (two blocks plus a tail). - problem.mask.assign(static_cast(problem.batch_size) * problem.sequence_length * total, 0); + problem.mask.assign(static_cast(problem.batch_size) * total, 1); 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; - } + for (int t = 0; t <= b && t < problem.past_sequence_length; ++t) { + problem.mask[static_cast(b) * total + t] = 0; } } return problem; @@ -671,11 +670,6 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { 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)); @@ -690,7 +684,7 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { 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("mask", {batch_size, total}, problem.mask); test.AddInput("past_key", {batch_size, problem.PastKeyCapacity(), head_size}, ToElementType(problem.past_key)); for (int slot = sai::kGate; slot < sai::kPastSequenceLength; ++slot) { @@ -794,7 +788,7 @@ void RunCsaTest(const CsaProblem& base, float tolerance) { 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("past_key", {batch_size, problem.PastCompressedCapacity(), head_size}, ToElementType(problem.past_compressed_key)); test.AddInput("gate", {batch_size, sequence_length, width}, ToElementType(problem.gate)); @@ -873,6 +867,16 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndPresentK {options.batch_size, options.past_sequence_length + options.sequence_length, options.head_size}); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsBooleanMask) { + QsaGraphOptions options; + options.use_boolean_mask = true; + std::unique_ptr model; + const Status status = BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model); + ASSERT_FALSE(status.IsOK()); + EXPECT_NE(status.ErrorMessage().find("Type parameter (TB)"), std::string::npos) + << "actual message: " << status.ErrorMessage(); +} + TEST(SparseAttentionIndexerShapeInferenceTest, QsaSharedCacheKeepsCapacity) { QsaGraphOptions options; options.share_cache = true; From bc922c259ab0344dab87b3abd018a1a6292c598d Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Fri, 18 Sep 2026 23:07:23 +0000 Subject: [PATCH 050/102] 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 0e649ff951d25246882bdbabcb4382405b9e4223 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sat, 19 Sep 2026 01:21:38 +0000 Subject: [PATCH 051/102] Hoist QSA query preparation out of block scoring --- .../sparse/sparse_attention_indexer_impl.cu | 45 ++++++------------- 1 file changed, 13 insertions(+), 32 deletions(-) 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 b293626bd0618..21bf6d4bc5dff 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -264,16 +264,15 @@ __global__ void CompactVisibleKernel(const int64_t* mask, int32_t* visible_indic // 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* query, const T* present_key, - const T* query_norm_weight, const T* key_norm_weight, +__global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present_key, + const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* visible_indices, const int32_t* visible_count, float* block_scores, SparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* pooled = shared; float* rotated = shared + params.head_size; - float* query_head = shared + 2 * params.head_size; - float* reduction = shared + 3 * 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) { @@ -325,33 +324,9 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* present_key, float score = 0.0f; for (int head = 0; head < params.num_heads; ++head) { const int64_t query_base = (row * params.num_heads + head) * params.head_size; - for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { - query_head[d] = to_float(query[query_base + d]); - } - __syncthreads(); - - float query_sum_squares = 0.0f; - for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { - query_sum_squares += query_head[d] * query_head[d]; - } - query_sum_squares = BlockSum(query_sum_squares, reduction); - const float query_inverse_rms = - rsqrtf(query_sum_squares / static_cast(params.head_size) + params.epsilon); - for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { - query_head[d] = query_head[d] * query_inverse_rms * to_float(query_norm_weight[d]); - } - __syncthreads(); - - const int query_position = ClampPosition( - static_cast(params.past_sequence_length) + row % params.sequence_length, - params.max_rotary_length); - const int64_t query_cache_offset = - (static_cast(batch) * params.max_rotary_length + query_position) * params.rotary_width; float partial = 0.0f; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { - partial += LeadingRope(query_head, params.rotary_width, cos_cache + query_cache_offset, - sin_cache + query_cache_offset, d) * - rotated[d]; + partial += query_rotated[query_base + d] * rotated[d]; } score += fmaxf(BlockSum(partial, reduction), 0.0f); } @@ -685,7 +660,8 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio return CUDA_CALL(cudaGetLastError()); } - float* block_scores = float_workspace + rows * params.num_heads * params.head_size; + float* query_rotated = float_workspace; + float* block_scores = query_rotated + rows * params.num_heads * params.head_size; int32_t* visible_indices = int_workspace; int32_t* visible_count = int_workspace + rows * params.total_sequence_length; @@ -695,11 +671,16 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio CompactVisibleKernel<<>>( mask, visible_indices, visible_count, params); + const int64_t query_rows = rows * params.num_heads; + const int rotate_blocks = static_cast(std::min(query_rows, kMaxGridDimX)); + RotateQueryKernel<<>>( + query, query_norm_weight, cos_cache, sin_cache, nullptr, query_rotated, 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<<>>( - query, present_key, query_norm_weight, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, + QsaBlockScoreKernel<<>>( + query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, block_scores, params); } From 04c7a92ea3d64879d52cfb46ff646636059aae67 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sat, 19 Sep 2026 01:42:37 +0000 Subject: [PATCH 052/102] Hoist packed QSA query preparation out of scoring --- .../packed_sparse_attention_indexer_impl.cu | 56 +++++++------------ 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 6c7de7dd89b10..3b892f6c969c3 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -263,16 +263,12 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig // present_key_state entry (unlike the dense op, no per-query pooling/normalize/rotate is repeated // here because the packed contract stores fully-prepared blocks in key_state). template -__global__ void QsaBlockScoreKernel(const T* query, const T* query_norm_weight, - const T* cos_cache, const T* sin_cache, - const T* present_key_state, +__global__ void QsaBlockScoreKernel(const T* present_key_state, const float* query_rotated, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, const int32_t* present_state_lengths, float* block_scores, PackedSparseAttentionIndexerParams params) { - extern __shared__ float shared[]; - float* query_head = shared; - float* reduction = shared + params.head_size; + extern __shared__ float reduction[]; const int64_t total = static_cast(params.total_tokens) * params.state_capacity; for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { const int token = static_cast(work / params.state_capacity); @@ -301,36 +297,13 @@ __global__ void QsaBlockScoreKernel(const T* query, const T* query_norm_weight, } const int64_t key_base = (static_cast(batch) * params.state_capacity + block_index) * params.head_size; - const int position = SaiClampPosition(abs_position, params.max_rotary_length); - const int64_t cache_offset = - (static_cast(params.cos_cache_batched ? batch : 0) * params.max_rotary_length + position) * - params.rotary_width; float score = 0.0f; for (int head = 0; head < params.num_heads; ++head) { - const int64_t query_base = - (static_cast(token) * params.num_heads + head) * params.head_size; - for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - query_head[d] = to_float(query[query_base + d]); - } - __syncthreads(); - - float query_sum_squares = 0.0f; - for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - query_sum_squares += query_head[d] * query_head[d]; - } - query_sum_squares = SaiBlockSum(query_sum_squares, reduction); - const float query_inverse_rms = - rsqrtf(query_sum_squares / static_cast(params.head_size) + params.epsilon); - for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - query_head[d] = query_head[d] * query_inverse_rms * to_float(query_norm_weight[d]); - } - __syncthreads(); - + const float* query_head = + query_rotated + (static_cast(token) * params.num_heads + head) * params.head_size; float partial = 0.0f; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - partial += SaiLeadingRope(query_head, params.rotary_width, cos_cache + cache_offset, - sin_cache + cache_offset, d) * - to_float(present_key_state[key_base + d]); + partial += query_head[d] * to_float(present_key_state[key_base + d]); } score += fmaxf(SaiBlockSum(partial, reduction), 0.0f); } @@ -771,7 +744,8 @@ __global__ void CsaSelectKernel(const float* scores, const int32_t* cumulative_s size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { const size_t rows = static_cast(params.total_tokens); - return rows * static_cast(std::max(params.state_capacity, 1)); + return rows * params.num_heads * params.head_size + + rows * static_cast(std::max(params.state_capacity, 1)); } size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { @@ -821,14 +795,22 @@ Status LaunchQsaPackedSparseAttentionIndexer( return CUDA_CALL(cudaGetLastError()); } - float* block_scores = float_workspace; + float* query_rotated = float_workspace; + float* block_scores = + query_rotated + static_cast(params.total_tokens) * params.num_heads * params.head_size; + + const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; + const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); + PackedRotateQueryKernel<<>>( + query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, + position_ids, query_rotated, params); if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); - QsaBlockScoreKernel<<>>( - query, query_norm_weight, cos_cache, sin_cache, present_key_state, cumulative_sequence_lengths, - past_sequence_lengths, position_ids, present_state_lengths, block_scores, params); + QsaBlockScoreKernel<<>>( + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, block_scores, params); } const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); From cdbf1fedb8fe28d36c74406d4013ac66dba36458 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sat, 19 Sep 2026 01:55:19 +0000 Subject: [PATCH 053/102] Guard QSA decode performance in CUDA tests --- .../sparse_attention_indexer_kernel_test.cc | 92 ++++++++++++++++--- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index 00680078a8428..e9f53e6dba451 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -4,7 +4,7 @@ #include #include -#include +#include #include #include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" @@ -15,12 +15,29 @@ namespace contrib { namespace cuda { namespace { -TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { +TEST(SparseAttentionIndexerCudaKernelTest, QsaWorkspaceIncludesPreparedQuery) { + SparseAttentionIndexerParams params; + params.batch_size = 2; + params.sequence_length = 3; + params.num_heads = 4; + params.head_size = 128; + params.max_block_count = 2048; + + constexpr size_t kRows = 6; + constexpr size_t kPreparedQueryElements = kRows * 4 * 128; + constexpr size_t kBlockScoreElements = kRows * 2048; + EXPECT_EQ(GetQsaWorkspaceFloatCount(params), kPreparedQueryElements + kBlockScoreElements); +} + +TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) { constexpr int kContextLength = 8192; - constexpr int kHeadSize = 4; - constexpr int kNumHeads = 2; + constexpr int kHeadSize = 128; + constexpr int kNumHeads = 4; constexpr int kTokenBudget = 2048; constexpr int kCompressRatio = 4; + constexpr int kThreads = 128; + constexpr int kWarmupIterations = 10; + constexpr int kTimedIterations = 50; SparseAttentionIndexerParams params; params.batch_size = 1; @@ -47,7 +64,7 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { float* weight = nullptr; float* cosine = nullptr; float* sine = nullptr; - bool* mask = nullptr; + int64_t* mask = nullptr; float* key_cache = nullptr; int32_t* selected = nullptr; float* float_workspace = nullptr; @@ -58,7 +75,7 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&cosine), kContextLength * kHeadSize * sizeof(float))); ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&sine), kContextLength * kHeadSize * sizeof(float))); - ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&mask), kContextLength * sizeof(bool))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&mask), kContextLength * sizeof(int64_t))); ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&key_cache), kContextLength * kHeadSize * sizeof(float))); ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&selected), params.capacity * sizeof(int32_t))); @@ -69,8 +86,7 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { std::vector ones(kContextLength * kHeadSize, 1.0f); std::vector zeros(kContextLength * kHeadSize, 0.0f); - std::unique_ptr visible(new bool[kContextLength]); - std::fill_n(visible.get(), kContextLength, true); + std::vector visible(kContextLength, 1); ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(query, ones.data(), kNumHeads * kHeadSize * sizeof(float), cudaMemcpyHostToDevice, stream)); ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key, ones.data(), kHeadSize * sizeof(float), @@ -81,7 +97,7 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { cudaMemcpyHostToDevice, stream)); ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(sine, zeros.data(), zeros.size() * sizeof(float), cudaMemcpyHostToDevice, stream)); - ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(mask, visible.get(), kContextLength * sizeof(bool), + ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(mask, visible.data(), kContextLength * sizeof(int64_t), cudaMemcpyHostToDevice, stream)); ASSERT_EQ(cudaSuccess, cudaMemcpyAsync(key_cache, ones.data(), ones.size() * sizeof(float), cudaMemcpyHostToDevice, stream)); @@ -89,7 +105,7 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { const auto launch = [&]() { return LaunchQsaSparseAttentionIndexer( - stream, params, query, key, weight, cosine, sine, mask, key_cache, selected, key_cache, + stream, params, query, key, weight, weight, cosine, sine, mask, key_cache, selected, key_cache, float_workspace, int_workspace); }; ASSERT_TRUE(launch().IsOK()); @@ -100,10 +116,58 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { ASSERT_EQ(cudaSuccess, cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal)); ASSERT_TRUE(launch().IsOK()); ASSERT_EQ(cudaSuccess, cudaStreamEndCapture(stream, &graph)); + + size_t node_count = 0; + ASSERT_EQ(cudaSuccess, cudaGraphGetNodes(graph, nullptr, &node_count)); + std::vector nodes(node_count); + ASSERT_EQ(cudaSuccess, cudaGraphGetNodes(graph, nodes.data(), &node_count)); + + int query_prepare_nodes = 0; + int block_score_nodes = 0; + for (cudaGraphNode_t node : nodes) { + cudaGraphNodeType node_type; + ASSERT_EQ(cudaSuccess, cudaGraphNodeGetType(node, &node_type)); + if (node_type != cudaGraphNodeTypeKernel) { + continue; + } + + cudaKernelNodeParams kernel_params{}; + ASSERT_EQ(cudaSuccess, cudaGraphKernelNodeGetParams(node, &kernel_params)); + if (kernel_params.blockDim.x != kThreads) { + continue; + } + + const size_t query_prepare_shared_bytes = (kHeadSize + kThreads) * sizeof(float); + const size_t block_score_shared_bytes = (2 * kHeadSize + kThreads) * sizeof(float); + if (kernel_params.gridDim.x == kNumHeads && kernel_params.sharedMemBytes == query_prepare_shared_bytes) { + ++query_prepare_nodes; + } + if (kernel_params.gridDim.x == params.max_block_count && + kernel_params.sharedMemBytes == block_score_shared_bytes) { + ++block_score_nodes; + } + } + EXPECT_EQ(query_prepare_nodes, 1) << "QSA query RMSNorm and RoPE must run once per query head"; + EXPECT_EQ(block_score_nodes, 1) << "QSA scoring must consume prepared queries without extra shared state"; + ASSERT_EQ(cudaSuccess, cudaGraphInstantiate(&graph_exec, graph, nullptr, nullptr, 0)); - ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); - ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); - ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + for (int i = 0; i < kWarmupIterations; ++i) { + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + } + + cudaEvent_t start = nullptr; + cudaEvent_t stop = nullptr; + ASSERT_EQ(cudaSuccess, cudaEventCreate(&start)); + ASSERT_EQ(cudaSuccess, cudaEventCreate(&stop)); + ASSERT_EQ(cudaSuccess, cudaEventRecord(start, stream)); + for (int i = 0; i < kTimedIterations; ++i) { + ASSERT_EQ(cudaSuccess, cudaGraphLaunch(graph_exec, stream)); + } + ASSERT_EQ(cudaSuccess, cudaEventRecord(stop, stream)); + ASSERT_EQ(cudaSuccess, cudaEventSynchronize(stop)); + float elapsed_ms = 0.0f; + ASSERT_EQ(cudaSuccess, cudaEventElapsedTime(&elapsed_ms, start, stop)); + RecordProperty("qsa_8k_decode_us", std::to_string(elapsed_ms * 1000.0f / kTimedIterations)); std::vector actual(params.capacity); ASSERT_EQ(cudaSuccess, cudaMemcpy(actual.data(), selected, actual.size() * sizeof(int32_t), @@ -115,6 +179,8 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaDecodeGraphCaptureAndReplay) { EXPECT_EQ(actual[i], -1); } + ASSERT_EQ(cudaSuccess, cudaEventDestroy(stop)); + ASSERT_EQ(cudaSuccess, cudaEventDestroy(start)); ASSERT_EQ(cudaSuccess, cudaGraphExecDestroy(graph_exec)); ASSERT_EQ(cudaSuccess, cudaGraphDestroy(graph)); ASSERT_EQ(cudaSuccess, cudaFree(int_workspace)); From 7b9d8bce31005ef8bbe9fd0f8b1e5908f8e8f5d4 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sat, 19 Sep 2026 01:56:15 +0000 Subject: [PATCH 054/102] Enforce QSA decode latency budgets --- .../sparse_attention_indexer_kernel_test.cc | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index e9f53e6dba451..155f0ef993f25 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -167,7 +167,18 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) ASSERT_EQ(cudaSuccess, cudaEventSynchronize(stop)); float elapsed_ms = 0.0f; ASSERT_EQ(cudaSuccess, cudaEventElapsedTime(&elapsed_ms, start, stop)); - RecordProperty("qsa_8k_decode_us", std::to_string(elapsed_ms * 1000.0f / kTimedIterations)); + const float average_us = elapsed_ms * 1000.0f / kTimedIterations; + RecordProperty("qsa_8k_decode_us", std::to_string(average_us)); + + int device = 0; + cudaDeviceProp device_properties{}; + ASSERT_EQ(cudaSuccess, cudaGetDevice(&device)); + ASSERT_EQ(cudaSuccess, cudaGetDeviceProperties(&device_properties, device)); + const float latency_limit_us = device_properties.major >= 9 ? 1000.0f + : device_properties.major >= 8 ? 2000.0f + : 5000.0f; + EXPECT_LT(average_us, latency_limit_us) + << "8K QSA decode latency regressed on " << device_properties.name; std::vector actual(params.capacity); ASSERT_EQ(cudaSuccess, cudaMemcpy(actual.data(), selected, actual.size() * sizeof(int32_t), From 1aec0ffd8337b447680ef43934bdf92580350d7b Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sun, 20 Sep 2026 17:57:24 +0000 Subject: [PATCH 055/102] Support packed QK in CUDA sparse attention indexer --- .../cuda/sparse/sparse_attention_indexer.cc | 23 +++++-- .../sparse/sparse_attention_indexer_impl.cu | 6 +- .../sparse/sparse_attention_indexer_impl.h | 2 + .../core/graph/contrib_ops/bert_defs.cc | 26 ++++++-- .../sparse_attention_indexer_op_test.cc | 62 ++++++++++++++----- 5 files changed, 91 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 39414461ceaae..23db2ebed94bc 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -114,6 +114,8 @@ SparseAttentionIndexer::SparseAttentionIndexer(const OpKernelInfo& info) : Cu template Status SparseAttentionIndexer::ComputeInternal(OpKernelContext* 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; @@ -153,7 +155,11 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { 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_ERROR(CheckIntDimension("batch_size", batch_size)); ORT_RETURN_IF_ERROR(CheckIntDimension("sequence_length", sequence_length)); ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); @@ -194,7 +200,9 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { "SparseAttentionIndexer: total_sequence_length must not exceed key_cache_capacity when " "past_sequence_length is provided"); - 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_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); @@ -210,6 +218,8 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { params.sequence_length = static_cast(sequence_length); params.num_heads = static_cast(num_heads); params.head_size = static_cast(head_size); + params.query_row_stride = static_cast(query_shape[2]); + params.key_row_stride = packed_qk ? static_cast(query_shape[2]) : static_cast(head_size); params.rotary_width = static_cast(rotary_width); params.max_rotary_length = static_cast(max_rotary_length); params.compress_ratio = static_cast(compress_ratio_); @@ -238,10 +248,13 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { auto float_workspace = GetScratchBuffer(GetQsaWorkspaceFloatCount(params), GetComputeStream(context)); auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), GetComputeStream(context)); + const CudaT* query_data = reinterpret_cast(query->Data()); + const CudaT* key_data = packed_qk ? query_data + num_heads * head_size + : reinterpret_cast(key->Data()); return LaunchQsaSparseAttentionIndexer( Stream(context), params, - reinterpret_cast(query->Data()), - reinterpret_cast(key->Data()), + query_data, + key_data, reinterpret_cast(query_norm_weight->Data()), reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), @@ -361,6 +374,8 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { params.sequence_length = static_cast(sequence_length); params.num_heads = static_cast(num_heads); params.head_size = static_cast(head_size); + params.query_row_stride = static_cast(query_shape[2]); + params.key_row_stride = static_cast(key->Shape()[2]); params.rotary_width = static_cast(rotary_width); params.max_rotary_length = static_cast(max_rotary_length); params.compress_ratio = static_cast(compress_ratio_); 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 21bf6d4bc5dff..a897fd04a2a63 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -152,9 +152,11 @@ __global__ void RotateQueryKernel(const T* query, const T* query_norm_weight, 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; + const int64_t source_base = (row / params.num_heads) * params.query_row_stride + + (row % params.num_heads) * params.head_size; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { - shared[d] = to_float(query[base + d]); + shared[d] = to_float(query[source_base + d]); } __syncthreads(); @@ -217,7 +219,7 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention const int batch = static_cast(token_row / params.sequence_length); present_key[(static_cast(batch) * params.key_cache_capacity + params.past_sequence_length + token) * params.head_size + - d] = key[index]; + d] = key[token_row * params.key_row_stride + d]; } } diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 779e73b721b2c..f245303ccec7d 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -18,6 +18,8 @@ struct SparseAttentionIndexerParams { int sequence_length = 0; int num_heads = 0; int head_size = 0; + int query_row_stride = 0; + int key_row_stride = 0; int rotary_width = 0; // cos_cache.shape[2] int max_rotary_length = 0; // cos_cache.shape[1] int compress_ratio = 0; diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 9d5dfd5cf7a5d..deba6cfe244dd 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2003,6 +2003,9 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex constexpr int kCsaOnlyInputs[] = {sai::kGate, sai::kPositionBias, sai::kHeadWeights, sai::kPositionIds, sai::kPastProjBuffer}; for (int index = sai::kQuery; index <= sai::kSinCache; ++index) { + if (is_qsa && index == sai::kKey) { + continue; + } if (!SparseAttentionIndexerHasInput(ctx, index)) { fail_shape_inference("SparseAttentionIndexer: input ", index, " is required for every policy_mode"); } @@ -2032,6 +2035,7 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex updateOutputElemType(ctx, sai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); + const bool has_key = SparseAttentionIndexerHasInput(ctx, sai::kKey); (void)SparseAttentionIndexerShape(ctx, sai::kKey, 3); (void)SparseAttentionIndexerShape(ctx, sai::kKeyNormWeight, 1); (void)SparseAttentionIndexerShape(ctx, sai::kCosCache, 3); @@ -2055,9 +2059,15 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex if (head_size_dim.has_dim_value() && head_size_dim.dim_value() <= 0) { fail_shape_inference("SparseAttentionIndexer: head_size must be > 0, got ", head_size_dim.dim_value()); } - if (query_width_dim.has_dim_value() && head_size_dim.has_dim_value() && head_size_dim.dim_value() > 0 && - query_width_dim.dim_value() % head_size_dim.dim_value() != 0) { - fail_shape_inference("SparseAttentionIndexer: query width must be divisible by head_size"); + if (query_width_dim.has_dim_value() && head_size_dim.has_dim_value() && head_size_dim.dim_value() > 0) { + const int64_t query_width = query_width_dim.dim_value(); + const int64_t head_size = head_size_dim.dim_value(); + if (query_width % head_size != 0) { + fail_shape_inference("SparseAttentionIndexer: query width must be divisible by head_size"); + } + if (is_qsa && !has_key && query_width / head_size < 2) { + fail_shape_inference("SparseAttentionIndexer: packed QK input must contain at least one query head and one key"); + } } if (!is_qsa && head_size_dim.has_dim_value() && head_size_dim.dim_value() > std::numeric_limits::max() / 2) { fail_shape_inference("SparseAttentionIndexer: 2 * head_size exceeds INT64_MAX"); @@ -2240,14 +2250,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(0, "query", "Indexer queries with shape (batch_size, sequence_length, num_heads * head_size), before " - "normalization, logical reshape, and rotary embedding.", + "normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', when key is omitted, " + "this input instead packs query followed by key along the last dimension and has shape " + "(batch_size, sequence_length, (num_heads + 1) * head_size).", "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") + "where the first head_size channels are the Ca series and the last head_size channels the Cb series. " + "May be omitted for policy_mode 'qsa' when query contains packed QK.", + "T", + OpSchema::Optional) .Input(2, "query_norm_weight", "Effective RMSNorm multiplier of the queries, with shape (head_size).", 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 b5e0916057bb2..ea360886d3bf3 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -94,8 +94,8 @@ struct QsaGraphOptions { int64_t token_budget = 4; bool add_index_topk = false; bool add_csa_inputs = false; - bool use_boolean_mask = false; bool share_cache = false; + bool packed_qk = false; std::string policy_mode = sai::kPolicyModeQsa; }; @@ -106,15 +106,17 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { 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}), + std::vector{options.batch_size, options.sequence_length, + (options.num_heads + (options.packed_qk ? 1 : 0)) * options.head_size}), + options.packed_qk + ? &empty + : 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.head_size}), builder.MakeInput(std::vector{options.batch_size, total, options.rotary_width}), builder.MakeInput(std::vector{options.batch_size, total, options.rotary_width}), - options.use_boolean_mask - ? builder.MakeInput(std::vector{options.batch_size, total}) - : builder.MakeInput(std::vector{options.batch_size, total}), + builder.MakeInput(std::vector{options.batch_size, total}), builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; if (options.add_csa_inputs) { @@ -648,7 +650,7 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { } template -void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { +void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), bool packed_qk = false) { if (!HasCudaProvider()) { GTEST_SKIP() << "CUDA execution provider is not available"; } @@ -677,9 +679,25 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem()) { 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)); + if (packed_qk) { + std::vector packed; + const size_t query_row_size = static_cast(problem.num_heads * problem.head_size); + const size_t key_row_size = static_cast(problem.head_size); + packed.reserve(static_cast(batch_size * sequence_length) * (query_row_size + key_row_size)); + for (int64_t row = 0; row < batch_size * sequence_length; ++row) { + packed.insert(packed.end(), problem.query.begin() + row * query_row_size, + problem.query.begin() + (row + 1) * query_row_size); + packed.insert(packed.end(), problem.key.begin() + row * key_row_size, + problem.key.begin() + (row + 1) * key_row_size); + } + test.AddInput("query", {batch_size, sequence_length, (problem.num_heads + 1) * head_size}, + ToElementType(packed)); + test.AddOptionalInputEdge(); + } else { + 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("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); test.AddInput("cos_cache", {batch_size, total, problem.rotary_width}, ToElementType(problem.cos_cache)); @@ -867,14 +885,14 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndPresentK {options.batch_size, options.past_sequence_length + options.sequence_length, options.head_size}); } -TEST(SparseAttentionIndexerShapeInferenceTest, RejectsBooleanMask) { +TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsPackedQk) { QsaGraphOptions options; - options.use_boolean_mask = true; + options.packed_qk = true; std::unique_ptr model; - const Status status = BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model); - ASSERT_FALSE(status.IsOK()); - EXPECT_NE(status.ErrorMessage().find("Type parameter (TB)"), std::string::npos) - << "actual message: " << status.ErrorMessage(); + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); + ExpectShape(model->MainGraph(), model->MainGraph().GetOutputs()[0]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_INT32, + {options.batch_size, options.sequence_length, options.token_budget + options.compress_ratio - 1}); } TEST(SparseAttentionIndexerShapeInferenceTest, QsaSharedCacheKeepsCapacity) { @@ -964,6 +982,14 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { "query width must be > 0"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsPackedQkWithoutQueryHead) { + QsaGraphOptions options; + options.packed_qk = true; + options.num_heads = 0; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "packed QK input must contain at least one query head and one key"); +} + TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaTokenBudgetNotDivisibleByCompressRatio) { QsaGraphOptions options; options.token_budget = 5; @@ -1017,6 +1043,10 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedCsaBuffer) { TEST(SparseAttentionIndexerTest, QsaFloat) { RunQsaTest(1.0e-5f); } +TEST(SparseAttentionIndexerTest, QsaPackedQkFloat) { + RunQsaTest(1.0e-5f, MakeQsaProblem(), true); +} + TEST(SparseAttentionIndexerTest, QsaFloat16) { RunQsaTest(2.0e-3f); } TEST(SparseAttentionIndexerTest, QsaBFloat16) { RunQsaTest(2.0e-2f); } From 01dc6e163ca6b9dbc5012880e17c56a02ede72c8 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sun, 20 Sep 2026 18:06:51 +0000 Subject: [PATCH 056/102] 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); } From 59253d6a2f16bca4e98ddaf5af0266d54120225a Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Mon, 21 Sep 2026 00:04:07 +0000 Subject: [PATCH 057/102] Optimize prefix-causal sparse attention indexing --- .../cuda/sparse/sparse_attention_indexer.cc | 22 +++++++++----- .../sparse/sparse_attention_indexer_impl.cu | 28 ++++++++++++----- .../sparse/sparse_attention_indexer_impl.h | 2 +- .../core/graph/contrib_ops/bert_defs.cc | 30 +++++++++---------- .../sparse_attention_indexer_kernel_test.cc | 2 +- .../sparse_attention_indexer_op_test.cc | 27 +++++++++++++++-- 6 files changed, 75 insertions(+), 36 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index a619fe4bf7bb7..13558f6a2da9e 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -144,6 +144,9 @@ Status SparseAttentionIndexer::ComputeInternal(OpKernelContext* context) cons "SparseAttentionIndexer: key is required for policy_mode 'csa'"); for (int index : {sai::kMask, sai::kGate, sai::kPositionBias, sai::kHeadWeights, sai::kPositionIds, sai::kPastProjBuffer}) { + if (is_qsa && index == sai::kMask) { + continue; + } const bool policy_owns_slot = is_qsa ? index == sai::kMask : index != sai::kMask; const bool provided = index < context->InputCount() && context->Input(index) != nullptr; ORT_RETURN_IF(provided != policy_owns_slot, "SparseAttentionIndexer: input ", index, @@ -224,12 +227,14 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); - const auto& mask_shape = mask->Shape(); - ORT_RETURN_IF_NOT( - mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_sequence_length, - "SparseAttentionIndexer: mask must be INT64 with shape (batch_size, total_sequence_length), with " - "total_sequence_length=", - total_sequence_length, ", got ", mask_shape.ToString()); + if (mask != nullptr) { + const auto& mask_shape = mask->Shape(); + ORT_RETURN_IF_NOT( + mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_sequence_length, + "SparseAttentionIndexer: mask must be INT64 with shape (batch_size, total_sequence_length), with " + "total_sequence_length=", + total_sequence_length, ", got ", mask_shape.ToString()); + } SparseAttentionIndexerParams params; params.batch_size = static_cast(batch_size); @@ -265,7 +270,8 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); auto float_workspace = GetScratchBuffer(GetQsaWorkspaceFloatCount(params), GetComputeStream(context)); - auto int_workspace = GetScratchBuffer(GetQsaWorkspaceIntCount(params), GetComputeStream(context)); + auto int_workspace = + GetScratchBuffer(GetQsaWorkspaceIntCount(params, mask != nullptr), GetComputeStream(context)); const CudaT* query_data = reinterpret_cast(query->Data()); const CudaT* key_data = packed_qk ? query_data + num_heads * head_size @@ -278,7 +284,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), reinterpret_cast(sin_cache->Data()), - mask->Data(), + mask == nullptr ? nullptr : mask->Data(), reinterpret_cast(past_key->Data()), selected_indices->MutableData(), reinterpret_cast(present_key->MutableData()), 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 5456068c2b2d1..db4e07d53cb52 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -203,6 +203,16 @@ __global__ void AnalyzeVisibleKernel(const int64_t* mask, int32_t* visible_count } } +__global__ void InitializePrefixVisibleCountKernel(int32_t* visible_count, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; row < rows; + row += static_cast(gridDim.x) * blockDim.x) { + const int query = static_cast(row % params.sequence_length); + visible_count[row] = -(params.past_sequence_length + query + 1) - 1; + } +} + // One block per non-prefix query row; compacts visible key positions into visible_indices. __global__ void CompactNonPrefixVisibleKernel(const int64_t* mask, int32_t* visible_indices, const int32_t* visible_count, @@ -745,9 +755,9 @@ size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { return rows * params.num_heads * params.head_size + rows * std::max(params.max_block_count, 1); } -size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params) { +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params, bool has_mask) { const size_t rows = static_cast(params.batch_size) * params.sequence_length; - return rows * std::max(params.total_sequence_length, 1) + rows + + return (has_mask ? rows * std::max(params.total_sequence_length, 1) : 0) + rows + rows * std::min(params.block_topk, kBoundedTopKMax); } @@ -785,16 +795,20 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio float* query_rotated = float_workspace; float* block_scores = query_rotated + rows * params.num_heads * params.head_size; - int32_t* visible_indices = int_workspace; - int32_t* visible_count = int_workspace + rows * params.total_sequence_length; + int32_t* visible_indices = mask == nullptr ? nullptr : int_workspace; + int32_t* visible_count = mask == nullptr ? int_workspace : int_workspace + rows * params.total_sequence_length; int32_t* topk_indices = visible_count + rows; const size_t value_bytes = static_cast(params.head_size) * sizeof(float); const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); - AnalyzeVisibleKernel<<>>(mask, visible_count, params); - CompactNonPrefixVisibleKernel<<>>( - mask, visible_indices, visible_count, params); + if (mask == nullptr) { + InitializePrefixVisibleCountKernel<<>>(visible_count, params); + } else { + AnalyzeVisibleKernel<<>>(mask, visible_count, params); + CompactNonPrefixVisibleKernel<<>>( + mask, visible_indices, visible_count, params); + } const int64_t query_rows = rows * params.num_heads; const int rotate_blocks = static_cast(std::min(query_rows, kMaxGridDimX)); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 29685fe587c04..cd90fbd6f581d 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -52,7 +52,7 @@ struct SparseAttentionIndexerParams { // Scratch requirements, in elements. size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); -size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params); +size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params, bool has_mask); size_t GetCsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params); template diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 8e46b61b0a1dd..f71844e4417c7 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2013,8 +2013,8 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex } // Strict policy input validation: every slot of the inactive policy must be omitted, and every - // slot of the active policy must be provided. - constexpr int kQsaOnlyInputs[] = {sai::kMask}; + // required slot of the active policy must be provided. The QSA mask is optional because an + // omitted mask requests prefix-causal visibility. constexpr int kCsaOnlyInputs[] = {sai::kGate, sai::kPositionBias, sai::kHeadWeights, sai::kPositionIds, sai::kPastProjBuffer}; for (int index = sai::kQuery; index <= sai::kSinCache; ++index) { @@ -2028,12 +2028,9 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex if (!SparseAttentionIndexerHasInput(ctx, sai::kPastKey)) { fail_shape_inference("SparseAttentionIndexer: past_key is required for every policy_mode"); } - for (int index : kQsaOnlyInputs) { - if (SparseAttentionIndexerHasInput(ctx, index) != is_qsa) { - fail_shape_inference("SparseAttentionIndexer: input ", index, - is_qsa ? " is required when policy_mode is 'qsa'" - : " must be omitted when policy_mode is 'csa'"); - } + if (!is_qsa && SparseAttentionIndexerHasInput(ctx, sai::kMask)) { + fail_shape_inference("SparseAttentionIndexer: input ", sai::kMask, + " must be omitted when policy_mode is 'csa'"); } for (int index : kCsaOnlyInputs) { if (SparseAttentionIndexerHasInput(ctx, index) == is_qsa) { @@ -2265,18 +2262,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(0, "query", "Indexer queries with shape (batch_size, sequence_length, num_heads * head_size), before " - "normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', when key is omitted, " - "this input instead packs query followed by key along the last dimension and has shape " - "(batch_size, sequence_length, (num_heads + 1) * head_size).", + "normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', when key is omitted, " + "this input instead packs query followed by key along the last dimension and has shape " + "(batch_size, sequence_length, (num_heads + 1) * head_size).", "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. " - "May be omitted for policy_mode 'qsa' when query contains packed QK.", - "T", - OpSchema::Optional) + "where the first head_size channels are the Ca series and the last head_size channels the Cb series. " + "May be omitted for policy_mode 'qsa' when query contains packed QK.", + "T", + OpSchema::Optional) .Input(2, "query_norm_weight", "Effective RMSNorm multiplier of the queries, with shape (head_size).", @@ -2299,7 +2296,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "mask", "Only for policy_mode 'qsa': INT64 padding mask with shape " "(batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. " - "total_sequence_length is past_sequence_length + sequence_length.", + "total_sequence_length is past_sequence_length + sequence_length. When omitted, every position " + "through past_sequence_length plus the current query index is visible.", "TB", OpSchema::Optional) .Input(7, diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index 155f0ef993f25..ad9ccabff6809 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -82,7 +82,7 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&float_workspace), GetQsaWorkspaceFloatCount(params) * sizeof(float))); ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&int_workspace), - GetQsaWorkspaceIntCount(params) * sizeof(int32_t))); + GetQsaWorkspaceIntCount(params, true) * sizeof(int32_t))); std::vector ones(kContextLength * kHeadSize, 1.0f); std::vector zeros(kContextLength * kHeadSize, 0.0f); 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 149e1d9700480..811b41bb33197 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -97,6 +97,7 @@ struct QsaGraphOptions { bool share_cache = false; bool shared_rotary_cache = false; bool packed_qk = false; + bool add_mask = true; std::string policy_mode = sai::kPolicyModeQsa; }; @@ -121,7 +122,9 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { builder.MakeInput(options.shared_rotary_cache ? std::vector{total, options.rotary_width} : std::vector{options.batch_size, total, options.rotary_width}), - builder.MakeInput(std::vector{options.batch_size, total}), + options.add_mask + ? builder.MakeInput(std::vector{options.batch_size, total}) + : &empty, builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; if (options.add_csa_inputs) { @@ -679,7 +682,8 @@ QsaProblem MakeQsaProblem(QsaProblem problem = {}) { template void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), - ProviderKind provider_kind = ProviderKind::Cuda, bool packed_qk = false) { + ProviderKind provider_kind = ProviderKind::Cuda, bool packed_qk = false, + bool omit_mask = false) { auto provider = CreateProvider(provider_kind); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") @@ -736,7 +740,11 @@ void RunQsaTest(float tolerance, QsaProblem problem = MakeQsaProblem(), : std::vector{batch_size, total, problem.rotary_width}; test.AddInput("cos_cache", rotary_cache_shape, ToElementType(problem.cos_cache)); test.AddInput("sin_cache", rotary_cache_shape, ToElementType(problem.sin_cache)); - test.AddInput("mask", {batch_size, total}, problem.mask); + if (omit_mask) { + test.AddOptionalInputEdge(); + } else { + test.AddInput("mask", {batch_size, total}, problem.mask); + } test.AddInput("past_key", {batch_size, problem.PastKeyCapacity(), head_size}, ToElementType(problem.past_key)); for (int slot = sai::kGate; slot < sai::kPastSequenceLength; ++slot) { @@ -941,6 +949,13 @@ TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsPackedQk) { {options.batch_size, options.sequence_length, options.token_budget + options.compress_ratio - 1}); } +TEST(SparseAttentionIndexerShapeInferenceTest, QsaAcceptsOmittedMask) { + QsaGraphOptions options; + options.add_mask = false; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, model)); +} + TEST(SparseAttentionIndexerShapeInferenceTest, QsaSharedCacheKeepsCapacity) { QsaGraphOptions options; options.share_cache = true; @@ -1107,6 +1122,12 @@ TEST(SparseAttentionIndexerTest, QsaPackedQkFloat) { RunQsaTest(1.0e-5f, MakeQsaProblem(), ProviderKind::Cuda, true); } +TEST(SparseAttentionIndexerTest, QsaMasklessPrefixCausal) { + QsaProblem problem = MakeQsaProblem(); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + TEST(SparseAttentionIndexerTest, QsaFloat16) { RunQsaTest(2.0e-3f); } TEST(SparseAttentionIndexerTest, QsaBFloat16) { RunQsaTest(2.0e-2f); } From 548ff666c27107cb2e83bd69a3bc17dba5da6870 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Mon, 21 Sep 2026 00:29:26 +0000 Subject: [PATCH 058/102] Optimize QSA TopK and block scoring --- .../cuda/sparse_attention_indexer.md | 43 ++-- .../sparse/sparse_attention_indexer_impl.cu | 218 ++++++++++++++++-- .../microbench/sparse_attention_indexer.py | 29 ++- .../sparse_attention_indexer_op_test.cc | 28 +++ 4 files changed, 264 insertions(+), 54 deletions(-) diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index d79a9d056104b..59acb13dbf6f3 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -259,11 +259,11 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l | Stage | Kernel | Parallelism | |---|---|---| -| 1 | `ConcatPastKeyKernel` | element | +| 1 | `CopyQsaPastKeyKernel` / `AppendQsaKeyKernel` | element; the copy is skipped for aliased fixed-capacity caches | | 2 | `RotateQueryKernel` | one block per `(b, s, h)` | -| 3 | `AnalyzeVisibleKernel` / `CompactNonPrefixVisibleKernel` | one block per `(b, s)`; prefix-causal rows avoid index materialization | -| 4 | `QsaBlockScoreKernel` | one block per `(b, s, block)`; mean-pool → RMSNorm → rotary → score | -| 5 | `QsaBoundedTopKKernel` / `QsaSelectKernel` | one block per `(b, s)`; fixed-capacity radix selection and emission | +| 3 | `InitializePrefixVisibleCountKernel` or mask analysis/compaction | omitted masks initialize prefix counts directly; irregular masks use one block per `(b, s)` | +| 4 | `QsaBlockScoreQwenKernel` / `QsaBlockScoreKernel` | one block per `(b, s, block)`; the Qwen `D=128, r=4, N=4` path assigns one warp per query head | +| 5 | `QsaPartialTopKKernel` / `QsaSelectKernel` | one block per `(b, s)`; score-threshold radix selection and bounded winner sorting | ### `csa` @@ -280,19 +280,20 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l | Policy | Buffer | Elements | |---|---|---| -| `qsa` | float | `B*S*max_block_count` (block scores) | -| `qsa` | int32 | `B*S*T` (arbitrary-mask visible indices) + `B*S` (visible counts) + up to `B*S*block_topk` (TopK indices) | +| `qsa` | float | `B*S*N*D` (rotated query) + `B*S*max_block_count` (block scores) | +| `qsa` | int32 | optional `B*S*T` (arbitrary-mask visible indices) + `B*S` (visible counts) + up to `B*S*block_topk` (TopK indices) | | `csa` | float | `B*S*N*D` (rotated query) + `B*S*present_compressed_length` (scores) | Every size is derived from shapes and attributes only. ### Selection -For QSA `block_topk` values from 33 through 512, `QsaBoundedTopKKernel` finds the stable TopK with -eight fixed-size radix-histogram passes and sorts only the selected candidates. Selection storage -and output traffic are therefore `O(block_topk)`, rather than `O(entries)`, while the packed -score/index key preserves deterministic "score descending, index ascending" ordering. Smaller QSA -rows use the in-kernel block TopK; larger configured TopK values retain the repeated-scan fallback. +For QSA `block_topk` values from 33 through 512, `QsaPartialTopKKernel` finds the score threshold +with four byte-wise radix-histogram passes, gathers exactly the winning candidates, resolves +threshold ties by ascending index, and sorts only that bounded set. Selection storage and output +traffic are therefore `O(block_topk)`, rather than `O(entries)`, while preserving deterministic +"score descending, index ascending" ordering. Smaller QSA rows use the in-kernel block TopK; +larger configured TopK values retain the repeated-scan fallback. ## 8. Numerics and Determinism @@ -366,14 +367,14 @@ The implementation is correctness-first. The following are known and deliberate: can change, so an exact implementation must inspect every eligible block; persistent candidates from an earlier query cannot preserve QSA semantics. The bounded radix path removes full-context sorting and candidate buffers, but not this scoring lower bound. -2. **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. **Query rotation is fused into QSA scoring.** This removes the separate rotation launch and the - `B*S*N*D` float workspace, at the cost of recomputing rotation for each scored block. -4. **Mask analysis remains `O(T)` per query row.** Prefix-causal masks avoid visible-index writes - and tile-by-tile scan synchronization. Padding masks with holes still require exact compaction - because block membership is defined by visible rank, not absolute position. -5. **`compress_ratio` and `head_size` are not specialized.** Templating the hot kernels on a small - set of common values would remove the dynamic loop bounds. +2. **Generic scoring is not tensor-core accelerated.** The common Qwen `D=128, r=4, N=4` path uses + vectorized loads and one warp per query head. Other QSA shapes and `CsaScoreKernel` retain the + generic shared-memory reductions. +3. **Rotated queries use an FP32 workspace.** Query normalization and rotation are computed once + per `(b, s, h)` before block scoring instead of being recomputed for every candidate block. +4. **Irregular-mask analysis remains `O(T)` per query row.** Omitting the mask declares + prefix-causal visibility and skips analysis and compaction. Padding masks with holes still + require exact compaction because block membership is defined by visible rank, not position. +5. **Only the common Qwen dimensions are specialized.** Other `compress_ratio`, `head_size`, and + head-count combinations retain dynamic loop bounds in the generic scoring kernel. 6. **No CPU kernel.** The operator is CUDA-only today. diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu index db4e07d53cb52..628de31b9e5b0 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -33,6 +33,21 @@ constexpr int kWarpSize = 32; constexpr int64_t kMaxGridDimX = kSaiMaxGridDimX; constexpr int kBoundedTopKMax = 512; constexpr int kBoundedTopKItemsPerThread = kBoundedTopKMax / kThreads; +constexpr int kQwenHeadSize = 128; +constexpr int kQwenCompressRatio = 4; +constexpr int kQwenNumHeads = 4; + +template +struct alignas(sizeof(T) * 4) QsaVector4 { + T values[4]; +}; + +template +__device__ __forceinline__ float4 LoadQsaVector4(const T* source) { + const QsaVector4 packed = *reinterpret_cast*>(source); + return make_float4(to_float(packed.values[0]), to_float(packed.values[1]), + to_float(packed.values[2]), to_float(packed.values[3])); +} // Thin, same-signature aliases over the device math shared with the packed indexer implementation // (sparse_attention_indexer_device_math.cuh), so the kernels below are unchanged. @@ -288,8 +303,9 @@ __global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present continue; } - const int32_t* index_row = visible_indices + row * params.total_sequence_length; - const int32_t* group = index_row + static_cast(block_index) * params.compress_ratio; + const int32_t* index_row = prefix_visible ? nullptr : visible_indices + row * params.total_sequence_length; + const int32_t* group = prefix_visible ? nullptr + : index_row + static_cast(block_index) * params.compress_ratio; const int64_t key_base = static_cast(batch) * params.key_cache_capacity * params.head_size; for (int d = threadIdx.x; d < params.head_size; d += blockDim.x) { @@ -341,6 +357,121 @@ __global__ void QsaBlockScoreKernel(const float* query_rotated, const T* present } } +// Qwen's common QSA layout has four query heads of width 128 and pools four keys per block. +// One warp prepares the vectorized pooled key, then each query head is scored by one warp. +template +__global__ void QsaBlockScoreQwenKernel(const float* query_rotated, const T* present_key, + const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, + const int32_t* visible_indices, const int32_t* visible_count, + float* block_scores, SparseAttentionIndexerParams params) { + __shared__ float pooled[kQwenHeadSize]; + __shared__ float rotated[kQwenHeadSize]; + __shared__ float head_scores[kQwenNumHeads]; + __shared__ float inverse_rms; + + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.max_block_count; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int block_index = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + const int batch = static_cast(row / params.sequence_length); + const int encoded_visible = visible_count[row]; + const bool prefix_visible = encoded_visible < 0; + const int visible = prefix_visible ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / kQwenCompressRatio; + + if (block_index >= block_count) { + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = NegativeInfinity(); + } + continue; + } + + const int32_t* group = prefix_visible + ? nullptr + : visible_indices + row * params.total_sequence_length + + static_cast(block_index) * kQwenCompressRatio; + const int64_t key_base = static_cast(batch) * params.key_cache_capacity * kQwenHeadSize; + + if (warp == 0) { + const int d = lane * 4; + float4 sum = make_float4(0.0f, 0.0f, 0.0f, 0.0f); +#pragma unroll + for (int token = 0; token < kQwenCompressRatio; ++token) { + const int position = prefix_visible ? block_index * kQwenCompressRatio + token : group[token]; + const float4 value = LoadQsaVector4( + present_key + key_base + static_cast(position) * kQwenHeadSize + d); + sum.x += value.x; + sum.y += value.y; + sum.z += value.z; + sum.w += value.w; + } + constexpr float kInverseCompressRatio = 1.0f / static_cast(kQwenCompressRatio); + pooled[d] = sum.x * kInverseCompressRatio; + pooled[d + 1] = sum.y * kInverseCompressRatio; + pooled[d + 2] = sum.z * kInverseCompressRatio; + pooled[d + 3] = sum.w * kInverseCompressRatio; + + float sum_squares = pooled[d] * pooled[d] + pooled[d + 1] * pooled[d + 1] + + pooled[d + 2] * pooled[d + 2] + pooled[d + 3] * pooled[d + 3]; + sum_squares = topk::WarpReduceSum(sum_squares); + if (lane == 0) { + inverse_rms = rsqrtf(sum_squares / static_cast(kQwenHeadSize) + params.epsilon); + } + } + __syncthreads(); + + if (warp == 0) { + const int d = lane * 4; + const float4 weight = LoadQsaVector4(key_norm_weight + d); + pooled[d] *= inverse_rms * weight.x; + pooled[d + 1] *= inverse_rms * weight.y; + pooled[d + 2] *= inverse_rms * weight.z; + pooled[d + 3] *= inverse_rms * weight.w; + } + __syncthreads(); + + if (warp == 0) { + const int d = lane * 4; + const int first_position = prefix_visible ? block_index * kQwenCompressRatio : group[0]; + const int position = ClampPosition(first_position, params.max_rotary_length); + const int64_t cache_offset = + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; +#pragma unroll + for (int element = 0; element < 4; ++element) { + rotated[d + element] = LeadingRope(pooled, params.rotary_width, cos_cache + cache_offset, + sin_cache + cache_offset, d + element); + } + } + __syncthreads(); + + const int d = lane * 4; + const int64_t query_base = (row * kQwenNumHeads + warp) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(rotated + d); + float dot = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + dot = topk::WarpReduceSum(dot); + if (lane == 0) { + head_scores[warp] = fmaxf(dot, 0.0f); + } + __syncthreads(); + + if (threadIdx.x == 0) { + float score = 0.0f; +#pragma unroll + for (int head = 0; head < kQwenNumHeads; ++head) { + score += head_scores[head]; + } + const float scaled_score = score * params.scale; + block_scores[row * params.max_block_count + block_index] = scaled_score == 0.0f ? 0.0f : scaled_score; + } + __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, @@ -430,10 +561,9 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl } } -// One block per query row. Finds at most kBoundedTopKMax stable TopK indices with a fixed-size -// radix histogram and output buffer. Work remains linear in the number of scored blocks, but -// selection storage and writes are bounded by block_topk instead of the full context. -__global__ void QsaBoundedTopKKernel(const float* block_scores, const int32_t* visible_count, +// One block per query row. Four radix passes find the TopK score threshold, then the winners are +// gathered with stable ties and only that bounded set is sorted by score and index. +__global__ void QsaPartialTopKKernel(const float* block_scores, const int32_t* visible_count, int32_t* topk_indices, SparseAttentionIndexerParams params) { using Sort = cub::BlockRadixSort; __shared__ union { @@ -441,9 +571,11 @@ __global__ void QsaBoundedTopKKernel(const float* block_scores, const int32_t* v typename Sort::TempStorage sort; } temp; __shared__ uint64_t selected_keys[kBoundedTopKMax]; - __shared__ uint64_t prefix; + __shared__ int32_t equal_warp_offsets[kThreads / kWarpSize + 1]; + __shared__ uint32_t prefix; __shared__ int remaining; __shared__ int gathered; + __shared__ int equal_seen; const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { @@ -462,16 +594,16 @@ __global__ void QsaBoundedTopKKernel(const float* block_scores, const int32_t* v } __syncthreads(); - for (int shift = 56; shift >= 0; shift -= 8) { + for (int shift = 24; shift >= 0; shift -= 8) { for (int bucket = threadIdx.x; bucket < 256; bucket += blockDim.x) { temp.histogram[bucket] = 0; } __syncthreads(); - const uint64_t current_prefix = prefix; + const uint32_t current_prefix = prefix; for (int index = threadIdx.x; index < block_count; index += blockDim.x) { - const uint64_t key = topk::PackStableSortKey(scores[index], index); - if (shift == 56 || (key >> (shift + 8)) == (current_prefix >> (shift + 8))) { - atomicAdd(&temp.histogram[(key >> shift) & 0xffu], 1u); + const uint32_t score_key = static_cast(topk::PackStableSortKey(scores[index], 0) >> 32); + if (shift == 24 || (score_key >> (shift + 8)) == (current_prefix >> (shift + 8))) { + atomicAdd(&temp.histogram[(score_key >> shift) & 0xffu], 1u); } } __syncthreads(); @@ -482,7 +614,7 @@ __global__ void QsaBoundedTopKKernel(const float* block_scores, const int32_t* v if (rank > count) { rank -= count; } else { - prefix |= static_cast(bucket) << shift; + prefix |= static_cast(bucket) << shift; remaining = rank; break; } @@ -495,18 +627,53 @@ __global__ void QsaBoundedTopKKernel(const float* block_scores, const int32_t* v gathered = 0; } __syncthreads(); - const uint64_t threshold = prefix; + const uint32_t threshold = prefix; for (int index = threadIdx.x; index < block_count; index += blockDim.x) { const uint64_t key = topk::PackStableSortKey(scores[index], index); - if (key >= threshold) { + if (static_cast(key >> 32) > threshold) { const int slot = atomicAdd(&gathered, 1); - if (slot < selected) { - selected_keys[slot] = key; - } + selected_keys[slot] = key; } } __syncthreads(); + if (threadIdx.x == 0) { + equal_seen = 0; + } + __syncthreads(); + for (int base = 0; base < block_count && equal_seen < remaining; base += blockDim.x) { + const int index = base + static_cast(threadIdx.x); + const bool equal = index < block_count && + static_cast(topk::PackStableSortKey(scores[index], 0) >> 32) == threshold; + const unsigned int warp_mask = __ballot_sync(0xffffffffu, equal); + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); + if (lane == 0) { + equal_warp_offsets[warp] = __popc(warp_mask); + } + __syncthreads(); + if (threadIdx.x == 0) { + int tile_total = 0; + for (int i = 0; i < kThreads / kWarpSize; ++i) { + const int warp_count = equal_warp_offsets[i]; + equal_warp_offsets[i] = tile_total; + tile_total += warp_count; + } + equal_warp_offsets[kThreads / kWarpSize] = tile_total; + } + __syncthreads(); + const int equal_rank = equal_seen + equal_warp_offsets[warp] + lane_rank; + if (equal && equal_rank < remaining) { + selected_keys[gathered + equal_rank] = topk::PackStableSortKey(scores[index], index); + } + __syncthreads(); + if (threadIdx.x == 0) { + equal_seen += equal_warp_offsets[kThreads / kWarpSize]; + } + __syncthreads(); + } + uint64_t keys[kBoundedTopKItemsPerThread]; #pragma unroll for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { @@ -818,15 +985,22 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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<<>>( - query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, - block_scores, params); + if (params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads) { + QsaBlockScoreQwenKernel<<>>( + query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, + block_scores, params); + } else { + QsaBlockScoreKernel<<>>( + query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, + block_scores, params); + } } const bool use_bounded_topk = params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; if (use_bounded_topk) { - QsaBoundedTopKKernel<<>>( + QsaPartialTopKKernel<<>>( block_scores, visible_count, topk_indices, params); } diff --git a/onnxruntime/python/tools/microbench/sparse_attention_indexer.py b/onnxruntime/python/tools/microbench/sparse_attention_indexer.py index fe136c9542ca8..6e281b0fa999e 100644 --- a/onnxruntime/python/tools/microbench/sparse_attention_indexer.py +++ b/onnxruntime/python/tools/microbench/sparse_attention_indexer.py @@ -10,20 +10,25 @@ from onnx import TensorProto, helper -def create_model(context_length: int, head_size: int, num_heads: int) -> bytes: +def create_model(context_length: int, head_size: int, num_heads: int, use_mask: bool) -> bytes: token_budget = 2048 compress_ratio = 4 capacity = token_budget + compress_ratio - 1 inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT16, [1, 1, num_heads, head_size]), + helper.make_tensor_value_info("query", TensorProto.FLOAT16, [1, 1, num_heads * head_size]), helper.make_tensor_value_info("key", TensorProto.FLOAT16, [1, 1, head_size]), helper.make_tensor_value_info("weight", TensorProto.FLOAT16, [head_size]), helper.make_tensor_value_info("cosine", TensorProto.FLOAT16, [1, context_length, head_size]), helper.make_tensor_value_info("sine", TensorProto.FLOAT16, [1, context_length, head_size]), - helper.make_tensor_value_info("mask", TensorProto.BOOL, [1, 1, 1, context_length]), - helper.make_tensor_value_info("key_cache", TensorProto.FLOAT16, [1, context_length, head_size]), - helper.make_tensor_value_info("past_sequence_length", TensorProto.INT32, [1]), ] + if use_mask: + inputs.append(helper.make_tensor_value_info("mask", TensorProto.INT64, [1, context_length])) + inputs.extend( + [ + helper.make_tensor_value_info("key_cache", TensorProto.FLOAT16, [1, context_length, head_size]), + helper.make_tensor_value_info("past_sequence_length", TensorProto.INT32, [1]), + ] + ) outputs = [ helper.make_tensor_value_info("selected", TensorProto.INT32, [1, 1, capacity]), helper.make_tensor_value_info("present_key", TensorProto.FLOAT16, [1, context_length, head_size]), @@ -36,7 +41,7 @@ def create_model(context_length: int, head_size: int, num_heads: int) -> bytes: "weight", "cosine", "sine", - "mask", + "mask" if use_mask else "", "key_cache", "", "", @@ -59,23 +64,24 @@ def create_model(context_length: int, head_size: int, num_heads: int) -> bytes: return model.SerializeToString() -def benchmark(context_length: int, warmup: int, iterations: int) -> float: +def benchmark(context_length: int, warmup: int, iterations: int, use_mask: bool) -> float: head_size = 128 num_heads = 4 session = ort.InferenceSession( - create_model(context_length, head_size, num_heads), + create_model(context_length, head_size, num_heads, use_mask), providers=["CUDAExecutionProvider"], ) inputs = { - "query": np.zeros((1, 1, num_heads, head_size), dtype=np.float16), + "query": np.zeros((1, 1, num_heads * head_size), dtype=np.float16), "key": np.zeros((1, 1, head_size), dtype=np.float16), "weight": np.ones((head_size,), dtype=np.float16), "cosine": np.ones((1, context_length, head_size), dtype=np.float16), "sine": np.zeros((1, context_length, head_size), dtype=np.float16), - "mask": np.ones((1, 1, 1, context_length), dtype=bool), "key_cache": np.zeros((1, context_length, head_size), dtype=np.float16), "past_sequence_length": np.array([context_length - 1], dtype=np.int32), } + if use_mask: + inputs["mask"] = np.ones((1, context_length), dtype=np.int64) io_binding = session.io_binding() device_inputs = {} for name, value in inputs.items(): @@ -101,12 +107,13 @@ def main() -> None: parser.add_argument("--contexts", nargs="+", type=int, default=[8192, 32768, 65536, 131072, 262144]) parser.add_argument("--warmup", type=int, default=10) parser.add_argument("--iterations", type=int, default=100) + parser.add_argument("--use-mask", action="store_true") args = parser.parse_args() if "CUDAExecutionProvider" not in ort.get_available_providers(): raise RuntimeError("CUDAExecutionProvider is unavailable") for context_length in args.contexts: - latency = benchmark(context_length, args.warmup, args.iterations) + latency = benchmark(context_length, args.warmup, args.iterations, args.use_mask) print(f"context={context_length:>6} latency_ms={latency:.3f}") 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 811b41bb33197..ff130634c3682 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1228,6 +1228,34 @@ TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKTies) { RunQsaTest(1.0e-5f, MakeQsaProblem(std::move(problem))); } +template +void RunQsaQwenSpecializedScoreAndPartialTopK(float tolerance) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 4095; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(tolerance, std::move(problem), ProviderKind::Cuda, false, true); +} + +TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKFloat) { + RunQsaQwenSpecializedScoreAndPartialTopK(1.0e-5f); +} + +TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKFloat16) { + RunQsaQwenSpecializedScoreAndPartialTopK(2.0e-3f); +} + +TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKBFloat16) { + RunQsaQwenSpecializedScoreAndPartialTopK(2.0e-2f); +} + TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; From 5a01fadbff9d5a0fca86dac911368dac3e38b73b Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Mon, 21 Sep 2026 00:47:44 +0000 Subject: [PATCH 059/102] Test multi-row QSA partial TopK --- .../test/contrib_ops/sparse_attention_indexer_op_test.cc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ff130634c3682..50df87f2decca 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1231,11 +1231,11 @@ TEST(SparseAttentionIndexerTest, QsaLongContextBoundedTopKTies) { template void RunQsaQwenSpecializedScoreAndPartialTopK(float tolerance) { QsaProblem problem; - problem.batch_size = 1; - problem.sequence_length = 1; + problem.batch_size = 2; + problem.sequence_length = 2; problem.num_heads = 4; problem.head_size = 128; - problem.past_sequence_length = 4095; + problem.past_sequence_length = 4094; problem.rotary_width = 32; problem.compress_ratio = 4; problem.token_budget = 2048; From c09591212c93f71d2361c51b7d272f0a427221d6 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Mon, 21 Sep 2026 09:42:28 +0000 Subject: [PATCH 060/102] Distribute QSA TopK and cache block representatives --- .../cuda/sparse/sparse_attention_indexer.cc | 4 + .../sparse/sparse_attention_indexer_impl.cu | 367 +++++++++++++++++- .../sparse/sparse_attention_indexer_impl.h | 1 + .../sparse_attention_indexer_op_test.cc | 81 ++++ 4 files changed, 443 insertions(+), 10 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 13558f6a2da9e..d2a2cf6a479c0 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -261,6 +261,10 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { : total_sequence_length); params.max_block_count = static_cast(total_sequence_length / compress_ratio_); params.block_topk = static_cast(token_budget_ / compress_ratio_); + params.use_block_representatives = + mask == nullptr && context->InputCount() > sai::kPastSequenceLength && + context->Input(sai::kPastSequenceLength) != nullptr && head_size == 128 && + compress_ratio_ == 4 && num_heads == 4; 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_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu index 628de31b9e5b0..f292ca7244f66 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -33,6 +33,8 @@ constexpr int kWarpSize = 32; constexpr int64_t kMaxGridDimX = kSaiMaxGridDimX; constexpr int kBoundedTopKMax = 512; constexpr int kBoundedTopKItemsPerThread = kBoundedTopKMax / kThreads; +constexpr int kDistributedTopKMaxRows = 64; +constexpr int kDistributedTopKMinBlocks = 2048; constexpr int kQwenHeadSize = 128; constexpr int kQwenCompressRatio = 4; constexpr int kQwenNumHeads = 4; @@ -55,6 +57,15 @@ __device__ __forceinline__ float NegativeInfinity() { return SaiNegativeInfinity int GridForElements(int64_t count) { return SaiGridForElements(count, kThreads); } +bool UseDistributedQsaTopK(const SparseAttentionIndexerParams& params, bool has_mask) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + return !has_mask && rows > 0 && rows <= kDistributedTopKMaxRows && + params.max_block_count >= kDistributedTopKMinBlocks && + params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads && + params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; +} + __device__ __forceinline__ float BlockSum(float value, float* shared) { return SaiBlockSum(value, shared); } __device__ __forceinline__ void BlockArgMax(float* shared_value, int* shared_index) { @@ -175,6 +186,55 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention } } +// Shared-buffer Qwen caches are opaque state. Once a four-token group becomes complete, replace +// its first key with the normalized and rotated pooled representative. The other three slots are +// retained only to preserve the public cache shape; future decode steps score the representative. +template +__global__ void QsaBuildBlockRepresentativesKernel(T* present_key, const T* key_norm_weight, + const T* cos_cache, const T* sin_cache, + SparseAttentionIndexerParams params) { + __shared__ float pooled[kQwenHeadSize]; + __shared__ float reduction[kThreads]; + const int first_block = params.past_sequence_length / kQwenCompressRatio; + const int completed_blocks = params.total_sequence_length / kQwenCompressRatio; + const int blocks_per_batch = completed_blocks - first_block; + const int total_blocks = params.batch_size * blocks_per_batch; + for (int work = blockIdx.x; work < total_blocks; work += gridDim.x) { + const int batch = work / blocks_per_batch; + const int block = first_block + work % blocks_per_batch; + const int first_position = block * kQwenCompressRatio; + const int64_t key_base = + (static_cast(batch) * params.key_cache_capacity + first_position) * kQwenHeadSize; + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + float sum = 0.0f; +#pragma unroll + for (int token = 0; token < kQwenCompressRatio; ++token) { + sum += to_float(present_key[key_base + token * kQwenHeadSize + d]); + } + pooled[d] = sum * (1.0f / static_cast(kQwenCompressRatio)); + } + __syncthreads(); + float sum_squares = 0.0f; + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + sum_squares += pooled[d] * pooled[d]; + } + sum_squares = BlockSum(sum_squares, reduction); + const float inverse_rms = rsqrtf(sum_squares / static_cast(kQwenHeadSize) + params.epsilon); + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + pooled[d] *= inverse_rms * to_float(key_norm_weight[d]); + } + __syncthreads(); + const int position = ClampPosition(first_position, params.max_rotary_length); + const int64_t rotary_offset = + (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; + for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { + present_key[key_base + d] = from_float(LeadingRope( + pooled, params.rotary_width, cos_cache + rotary_offset, sin_cache + rotary_offset, d)); + } + __syncthreads(); + } +} + // Encodes a prefix-visible row as -count-1. QSA can then index that row directly without // materializing its visible positions. Non-prefix rows keep a nonnegative count and are compacted // by CompactNonPrefixVisibleKernel. @@ -364,7 +424,8 @@ __global__ void QsaBlockScoreQwenKernel(const float* query_rotated, const T* pre const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* visible_indices, const int32_t* visible_count, - float* block_scores, SparseAttentionIndexerParams params) { + float* block_scores, uint32_t* high_histogram, + SparseAttentionIndexerParams params) { __shared__ float pooled[kQwenHeadSize]; __shared__ float rotated[kQwenHeadSize]; __shared__ float head_scores[kQwenNumHeads]; @@ -466,7 +527,246 @@ __global__ void QsaBlockScoreQwenKernel(const float* query_rotated, const T* pre score += head_scores[head]; } const float scaled_score = score * params.scale; - block_scores[row * params.max_block_count + block_index] = scaled_score == 0.0f ? 0.0f : scaled_score; + const float stored_score = scaled_score == 0.0f ? 0.0f : scaled_score; + block_scores[row * params.max_block_count + block_index] = stored_score; + if (high_histogram != nullptr) { + const uint32_t score_key = static_cast(topk::PackStableSortKey(stored_score, 0) >> 32); + atomicAdd(high_histogram + row * 256 + (score_key >> 24), 1u); + } + } + __syncthreads(); + } +} + +template +__global__ void QsaBlockScoreQwenCachedKernel(const float* query_rotated, const T* present_key, + const int32_t* visible_count, float* block_scores, + uint32_t* high_histogram, + SparseAttentionIndexerParams params) { + __shared__ float head_scores[kQwenNumHeads]; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.max_block_count; + for (int64_t work = blockIdx.x; work < total; work += gridDim.x) { + const int block_index = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + const int batch = static_cast(row / params.sequence_length); + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / kQwenCompressRatio; + if (block_index >= block_count) { + if (threadIdx.x == 0) { + block_scores[row * params.max_block_count + block_index] = NegativeInfinity(); + } + continue; + } + + const int d = lane * 4; + const int64_t query_base = (row * kQwenNumHeads + warp) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.key_cache_capacity + block_index * kQwenCompressRatio) * + kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key + key_base + d); + float dot = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + dot = topk::WarpReduceSum(dot); + if (lane == 0) { + head_scores[warp] = fmaxf(dot, 0.0f); + } + __syncthreads(); + if (threadIdx.x == 0) { + float score = 0.0f; +#pragma unroll + for (int head = 0; head < kQwenNumHeads; ++head) { + score += head_scores[head]; + } + const float scaled_score = score * params.scale; + const float stored_score = scaled_score == 0.0f ? 0.0f : scaled_score; + block_scores[row * params.max_block_count + block_index] = stored_score; + if (high_histogram != nullptr) { + const uint32_t score_key = static_cast(topk::PackStableSortKey(stored_score, 0) >> 32); + atomicAdd(high_histogram + row * 256 + (score_key >> 24), 1u); + } + } + __syncthreads(); + } +} + +__global__ void QsaChooseRadixBucketKernel(const uint32_t* histogram, uint32_t* prefixes, + int32_t* remaining, int shift, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + if (threadIdx.x == 0) { + int rank = shift == 24 ? min(params.block_topk, params.max_block_count) : remaining[row]; + uint32_t prefix = shift == 24 ? 0u : prefixes[row]; + for (int bucket = 255; bucket >= 0; --bucket) { + const int count = static_cast(histogram[row * 256 + bucket]); + if (rank > count) { + rank -= count; + } else { + prefix |= static_cast(bucket) << shift; + prefixes[row] = prefix; + remaining[row] = rank; + break; + } + } + } + } +} + +__global__ void QsaCompactHighBucketKernel(const float* block_scores, const int32_t* visible_count, + const uint32_t* prefixes, int32_t* candidate_counts, + int32_t* candidate_indices, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + const int64_t total = rows * params.max_block_count; + for (int64_t work = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; work < total; + work += static_cast(gridDim.x) * blockDim.x) { + const int index = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + if (index < block_count) { + const float score = block_scores[row * params.max_block_count + index]; + const uint32_t score_key = static_cast(topk::PackStableSortKey(score, 0) >> 32); + if ((score_key >> 24) >= (prefixes[row] >> 24)) { + const int slot = atomicAdd(candidate_counts + row, 1); + candidate_indices[row * params.max_block_count + slot] = index; + } + } + } +} + +__global__ void QsaCandidateHistogramKernel(const float* block_scores, const int32_t* candidate_counts, + const int32_t* candidate_indices, const uint32_t* prefixes, + uint32_t* histogram, int shift, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + const int64_t total = rows * params.max_block_count; + for (int64_t work = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; work < total; + work += static_cast(gridDim.x) * blockDim.x) { + const int slot = static_cast(work % params.max_block_count); + const int64_t row = work / params.max_block_count; + if (slot < candidate_counts[row]) { + const int index = candidate_indices[row * params.max_block_count + slot]; + const float score = block_scores[row * params.max_block_count + index]; + const uint32_t score_key = static_cast(topk::PackStableSortKey(score, 0) >> 32); + if ((score_key >> (shift + 8)) == (prefixes[row] >> (shift + 8))) { + atomicAdd(histogram + row * 256 + ((score_key >> shift) & 0xffu), 1u); + } + } + } +} + +// Gathers the bounded winner set from the compacted high radix bucket, sorts it, and writes token +// indices directly. Stable score ties prefer lower block indices, matching the reference path. +__global__ void QsaDistributedSelectKernel(const float* block_scores, const int32_t* visible_count, + const int32_t* candidate_counts, + const int32_t* candidate_indices, + const uint32_t* prefixes, const int32_t* remaining, + int32_t* selected_indices, + SparseAttentionIndexerParams params) { + using Sort = cub::BlockRadixSort; + __shared__ union { + typename Sort::TempStorage sort; + int32_t equal_warp_offsets[kThreads / kWarpSize + 1]; + } temp; + __shared__ uint64_t selected_keys[kBoundedTopKMax]; + __shared__ int gathered; + __shared__ int equal_seen; + + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + int32_t* output = selected_indices + row * params.capacity; + for (int position = threadIdx.x; position < params.capacity; position += blockDim.x) { + output[position] = -1; + } + if (threadIdx.x == 0) { + gathered = 0; + } + __syncthreads(); + + const uint32_t threshold = prefixes[row]; + const int candidate_count = candidate_counts[row]; + for (int slot = threadIdx.x; slot < candidate_count; slot += blockDim.x) { + const int index = candidate_indices[row * params.max_block_count + slot]; + const uint64_t key = topk::PackStableSortKey( + block_scores[row * params.max_block_count + index], index); + if (static_cast(key >> 32) > threshold) { + const int output_slot = atomicAdd(&gathered, 1); + selected_keys[output_slot] = key; + } + } + __syncthreads(); + + if (threadIdx.x == 0) { + equal_seen = 0; + } + __syncthreads(); + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + for (int base = 0; base < block_count && equal_seen < remaining[row]; base += blockDim.x) { + const int index = base + static_cast(threadIdx.x); + const bool equal = index < block_count && + static_cast(topk::PackStableSortKey( + block_scores[row * params.max_block_count + index], 0) >> + 32) == threshold; + const unsigned int warp_mask = __ballot_sync(0xffffffffu, equal); + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); + if (lane == 0) { + temp.equal_warp_offsets[warp] = __popc(warp_mask); + } + __syncthreads(); + if (threadIdx.x == 0) { + int tile_total = 0; + for (int i = 0; i < kThreads / kWarpSize; ++i) { + const int warp_count = temp.equal_warp_offsets[i]; + temp.equal_warp_offsets[i] = tile_total; + tile_total += warp_count; + } + temp.equal_warp_offsets[kThreads / kWarpSize] = tile_total; + } + __syncthreads(); + const int equal_rank = equal_seen + temp.equal_warp_offsets[warp] + lane_rank; + if (equal && equal_rank < remaining[row]) { + selected_keys[gathered + equal_rank] = topk::PackStableSortKey( + block_scores[row * params.max_block_count + index], index); + } + __syncthreads(); + if (threadIdx.x == 0) { + equal_seen += temp.equal_warp_offsets[kThreads / kWarpSize]; + } + __syncthreads(); + } + + const int selected = min(params.block_topk, block_count); + uint64_t keys[kBoundedTopKItemsPerThread]; +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; + } + Sort(temp.sort).SortDescending(keys); +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + if (rank < selected) { + const int selected_block = topk::UnpackStableSortIndex(keys[item]); + for (int token = 0; token < params.compress_ratio; ++token) { + output[rank * params.compress_ratio + token] = + selected_block * params.compress_ratio + token; + } + } + } + const int tail_start = block_count * params.compress_ratio; + for (int token = threadIdx.x; token < visible - tail_start; token += blockDim.x) { + output[selected * params.compress_ratio + token] = tail_start + token; } __syncthreads(); } @@ -924,6 +1224,9 @@ size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params, bool has_mask) { const size_t rows = static_cast(params.batch_size) * params.sequence_length; + if (UseDistributedQsaTopK(params, has_mask)) { + return rows + rows * std::max(params.max_block_count, 1) + rows * 256 + rows * 3; + } return (has_mask ? rows * std::max(params.total_sequence_length, 1) : 0) + rows + rows * std::min(params.block_topk, kBoundedTopKMax); } @@ -956,6 +1259,16 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio AppendQsaKeyKernel<<>>(key, present_key, params); } + if (params.use_block_representatives) { + const int first_block = params.past_sequence_length / kQwenCompressRatio; + const int completed_blocks = params.total_sequence_length / kQwenCompressRatio; + const int64_t new_blocks = static_cast(params.batch_size) * (completed_blocks - first_block); + if (new_blocks > 0) { + QsaBuildBlockRepresentativesKernel<<>>( + present_key, key_norm_weight, cos_cache, sin_cache, params); + } + } + if (rows == 0) { return CUDA_CALL(cudaGetLastError()); } @@ -965,6 +1278,14 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio int32_t* visible_indices = mask == nullptr ? nullptr : int_workspace; int32_t* visible_count = mask == nullptr ? int_workspace : int_workspace + rows * params.total_sequence_length; int32_t* topk_indices = visible_count + rows; + const bool use_distributed_topk = UseDistributedQsaTopK(params, mask != nullptr); + int32_t* candidate_indices = use_distributed_topk ? topk_indices : nullptr; + uint32_t* radix_histogram = use_distributed_topk + ? reinterpret_cast(candidate_indices + rows * params.max_block_count) + : nullptr; + uint32_t* radix_prefixes = use_distributed_topk ? radix_histogram + rows * 256 : nullptr; + int32_t* radix_remaining = use_distributed_topk ? reinterpret_cast(radix_prefixes + rows) : nullptr; + int32_t* candidate_counts = use_distributed_topk ? radix_remaining + rows : nullptr; const size_t value_bytes = static_cast(params.head_size) * sizeof(float); @@ -983,13 +1304,22 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio query, query_norm_weight, cos_cache, sin_cache, nullptr, query_rotated, params); if (params.max_block_count > 0) { + if (use_distributed_topk) { + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(radix_histogram, 0, rows * 256 * sizeof(uint32_t), stream)); + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(candidate_counts, 0, rows * sizeof(int32_t), stream)); + } const int64_t block_work = rows * params.max_block_count; const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); if (params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && params.num_heads == kQwenNumHeads) { - QsaBlockScoreQwenKernel<<>>( - query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, - block_scores, params); + if (params.use_block_representatives) { + QsaBlockScoreQwenCachedKernel<<>>( + query_rotated, present_key, visible_count, block_scores, radix_histogram, params); + } else { + QsaBlockScoreQwenKernel<<>>( + query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, + block_scores, radix_histogram, params); + } } else { QsaBlockScoreKernel<<>>( query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, @@ -999,7 +1329,22 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const bool use_bounded_topk = params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; - if (use_bounded_topk) { + if (use_distributed_topk) { + QsaChooseRadixBucketKernel<<>>( + radix_histogram, radix_prefixes, radix_remaining, 24, params); + QsaCompactHighBucketKernel<<>>( + block_scores, visible_count, radix_prefixes, candidate_counts, candidate_indices, params); + for (int shift = 16; shift >= 0; shift -= 8) { + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(radix_histogram, 0, rows * 256 * sizeof(uint32_t), stream)); + QsaCandidateHistogramKernel<<>>( + block_scores, candidate_counts, candidate_indices, radix_prefixes, radix_histogram, shift, params); + QsaChooseRadixBucketKernel<<>>( + radix_histogram, radix_prefixes, radix_remaining, shift, params); + } + QsaDistributedSelectKernel<<>>( + block_scores, visible_count, candidate_counts, candidate_indices, radix_prefixes, radix_remaining, + selected_indices, params); + } else if (use_bounded_topk) { QsaPartialTopKKernel<<>>( block_scores, visible_count, topk_indices, params); } @@ -1007,10 +1352,12 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const int topk_shared_entries = !use_bounded_topk && params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; - QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( - block_scores, visible_indices, visible_count, use_bounded_topk ? topk_indices : nullptr, - selected_indices, params); + if (!use_distributed_topk) { + QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( + block_scores, visible_indices, visible_count, use_bounded_topk ? topk_indices : nullptr, + selected_indices, params); + } return CUDA_CALL(cudaGetLastError()); } diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index cd90fbd6f581d..f4f5ab7b5dfbb 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -35,6 +35,7 @@ struct SparseAttentionIndexerParams { int key_cache_capacity = 0; int max_block_count = 0; // total_sequence_length / compress_ratio int block_topk = 0; // token_budget / compress_ratio + bool use_block_representatives = false; // policy_mode = "csa" int past_compressed_length = 0; 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 50df87f2decca..09a7ea1b372d7 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -317,6 +317,7 @@ struct QsaProblem { float epsilon = 1.0e-6f; std::optional scale; bool shared_rotary_cache = false; + bool cache_block_representatives = false; std::vector query; std::vector key; @@ -434,6 +435,36 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std } } } + + if (problem.cache_block_representatives) { + const int completed_blocks = problem.TotalSequenceLength() / problem.compress_ratio; + for (int b = 0; b < problem.batch_size; ++b) { + const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); + const float* cos_base = + problem.cos_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; + const float* sin_base = + problem.sin_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; + for (int block = 0; block < completed_blocks; ++block) { + const int first_position = block * problem.compress_ratio; + std::vector pooled(static_cast(head_size), 0.0f); + for (int token = 0; token < problem.compress_ratio; ++token) { + for (int d = 0; d < head_size; ++d) { + pooled[static_cast(d)] += + present_key[(static_cast(b) * present_capacity + first_position + token) * head_size + d]; + } + } + for (float& value : pooled) { + value /= static_cast(problem.compress_ratio); + } + pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); + pooled = LeadingRope(pooled, problem.rotary_width, cos_base + first_position * problem.rotary_width, + sin_base + first_position * problem.rotary_width); + std::copy(pooled.begin(), pooled.end(), + present_key.begin() + + (static_cast(b) * present_capacity + first_position) * head_size); + } + } + } } struct CsaProblem { @@ -1256,6 +1287,56 @@ TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKBFloat16) RunQsaQwenSpecializedScoreAndPartialTopK(2.0e-2f); } +TEST(SparseAttentionIndexerTest, QsaQwenDistributedPartialTopK) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 8191; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + +TEST(SparseAttentionIndexerTest, QsaQwenDistributedPartialTopKTies) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 65535; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + std::fill(problem.query.begin(), problem.query.end(), 0.0f); + std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); + std::fill(problem.key.begin(), problem.key.end(), 0.0f); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + +TEST(SparseAttentionIndexerTest, QsaQwenSharedBlockRepresentatives) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 8; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 0; + problem.key_cache_capacity = 16; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 8; + problem.cache_block_representatives = true; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; From cbc04636253d4518e841b58527aad8099830c73f Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Tue, 22 Sep 2026 06:35:20 +0000 Subject: [PATCH 061/102] Implement hierarchical exact QSA selection --- .../sparse/sparse_attention_indexer_impl.cu | 393 +++++++++++++----- .../sparse_attention_indexer_kernel_test.cc | 107 +++++ .../sparse_attention_indexer_op_test.cc | 190 +++++++-- 3 files changed, 549 insertions(+), 141 deletions(-) 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 f292ca7244f66..885b372dd6bac 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -35,6 +35,9 @@ constexpr int kBoundedTopKMax = 512; constexpr int kBoundedTopKItemsPerThread = kBoundedTopKMax / kThreads; constexpr int kDistributedTopKMaxRows = 64; constexpr int kDistributedTopKMinBlocks = 2048; +constexpr int kHierarchicalTileBlocks = 8; +constexpr int kHierarchicalScoreThreads = 1024; +constexpr int kHierarchicalMergeThreads = 256; constexpr int kQwenHeadSize = 128; constexpr int kQwenCompressRatio = 4; constexpr int kQwenNumHeads = 4; @@ -66,6 +69,14 @@ bool UseDistributedQsaTopK(const SparseAttentionIndexerParams& params, bool has_ params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; } +bool UseHierarchicalQsaTopK(const SparseAttentionIndexerParams& params, bool has_mask) { + return params.use_block_representatives && UseDistributedQsaTopK(params, has_mask); +} + +int GetHierarchicalTileCount(const SparseAttentionIndexerParams& params) { + return (params.max_block_count + kHierarchicalTileBlocks - 1) / kHierarchicalTileBlocks; +} + __device__ __forceinline__ float BlockSum(float value, float* shared) { return SaiBlockSum(value, shared); } __device__ __forceinline__ void BlockArgMax(float* shared_value, int* shared_index) { @@ -186,11 +197,31 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention } } -// Shared-buffer Qwen caches are opaque state. Once a four-token group becomes complete, replace -// its first key with the normalized and rotated pooled representative. The other three slots are -// retained only to preserve the public cache shape; future decode steps score the representative. template -__global__ void QsaBuildBlockRepresentativesKernel(T* present_key, const T* key_norm_weight, +__global__ void AppendQsaKeyTailKernel(const T* key, T* present_key, SparseAttentionIndexerParams params) { + const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.head_size; + for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; + index += static_cast(gridDim.x) * blockDim.x) { + const int d = static_cast(index % params.head_size); + const int64_t token_row = index / params.head_size; + const int token = static_cast(token_row % params.sequence_length); + const int batch = static_cast(token_row / params.sequence_length); + const int position = params.past_sequence_length + token; + const int completed_tokens = params.total_sequence_length / kQwenCompressRatio * kQwenCompressRatio; + if (position >= completed_tokens) { + const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; + const int cache_position = representative_capacity + position % kQwenCompressRatio; + present_key[(static_cast(batch) * params.key_cache_capacity + cache_position) * params.head_size + d] = + key[token_row * params.key_row_stride + d]; + } + } +} + +// Shared-buffer Qwen caches are opaque state. Completed block representatives occupy a contiguous +// prefix, followed by four scratch slots that carry the current incomplete raw block. +template +__global__ void QsaBuildBlockRepresentativesKernel(const T* past_key, const T* key, T* present_key, + const T* key_norm_weight, const T* cos_cache, const T* sin_cache, SparseAttentionIndexerParams params) { __shared__ float pooled[kQwenHeadSize]; @@ -203,13 +234,21 @@ __global__ void QsaBuildBlockRepresentativesKernel(T* present_key, const T* key_ const int batch = work / blocks_per_batch; const int block = first_block + work % blocks_per_batch; const int first_position = block * kQwenCompressRatio; - const int64_t key_base = - (static_cast(batch) * params.key_cache_capacity + first_position) * kQwenHeadSize; + const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { float sum = 0.0f; #pragma unroll for (int token = 0; token < kQwenCompressRatio; ++token) { - sum += to_float(present_key[key_base + token * kQwenHeadSize + d]); + const int position = first_position + token; + if (position < params.past_sequence_length) { + const int cache_position = representative_capacity + position % kQwenCompressRatio; + sum += to_float(past_key[(static_cast(batch) * params.past_key_capacity + cache_position) * + kQwenHeadSize + d]); + } else { + const int key_token = position - params.past_sequence_length; + sum += to_float(key[(static_cast(batch) * params.sequence_length + key_token) * + params.key_row_stride + d]); + } } pooled[d] = sum * (1.0f / static_cast(kQwenCompressRatio)); } @@ -228,7 +267,9 @@ __global__ void QsaBuildBlockRepresentativesKernel(T* present_key, const T* key_ const int64_t rotary_offset = (static_cast(batch) * params.rotary_cache_batch_stride + position) * params.rotary_width; for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { - present_key[key_base + d] = from_float(LeadingRope( + const int64_t output_base = + (static_cast(batch) * params.key_cache_capacity + block) * kQwenHeadSize; + present_key[output_base + d] = from_float(LeadingRope( pooled, params.rotary_width, cos_cache + rotary_offset, sin_cache + rotary_offset, d)); } __syncthreads(); @@ -564,7 +605,7 @@ __global__ void QsaBlockScoreQwenCachedKernel(const float* query_rotated, const const int d = lane * 4; const int64_t query_base = (row * kQwenNumHeads + warp) * kQwenHeadSize; const int64_t key_base = - (static_cast(batch) * params.key_cache_capacity + block_index * kQwenCompressRatio) * + (static_cast(batch) * params.key_cache_capacity + block_index) * kQwenHeadSize; const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); const float4 key_value = LoadQsaVector4(present_key + key_base + d); @@ -593,20 +634,166 @@ __global__ void QsaBlockScoreQwenCachedKernel(const float* query_rotated, const } } -__global__ void QsaChooseRadixBucketKernel(const uint32_t* histogram, uint32_t* prefixes, +template +__global__ void QsaScoreTileTopKKernel(const float* query_rotated, const T* present_key, + const int32_t* visible_count, uint64_t* tile_keys, + int tile_count, SparseAttentionIndexerParams params) { + __shared__ uint64_t keys[kHierarchicalTileBlocks]; + __shared__ float head_scores[kHierarchicalTileBlocks][kQwenNumHeads]; + + const int64_t total_tiles = + static_cast(params.batch_size) * params.sequence_length * tile_count; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int block_slot = warp / kQwenNumHeads; + const int head = warp % kQwenNumHeads; + for (int64_t work = blockIdx.x; work < total_tiles; work += gridDim.x) { + const int tile = static_cast(work % tile_count); + const int64_t row = work / tile_count; + const int batch = static_cast(row / params.sequence_length); + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / kQwenCompressRatio; + + const int block_index = tile * kHierarchicalTileBlocks + block_slot; + float score = 0.0f; + if (block_index < block_count) { + const int d = lane * 4; + const int64_t query_base = (row * kQwenNumHeads + head) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.key_cache_capacity + block_index) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key + key_base + d); + score = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + score = topk::WarpReduceSum(score); + } + if (lane == 0) { + head_scores[block_slot][head] = fmaxf(score, 0.0f); + } + __syncthreads(); + if (threadIdx.x < kHierarchicalTileBlocks) { + uint64_t key = topk::kPaddingSortKey; + if (block_index - block_slot + threadIdx.x < block_count) { + float total_score = 0.0f; +#pragma unroll + for (int query_head = 0; query_head < kQwenNumHeads; ++query_head) { + total_score += head_scores[threadIdx.x][query_head]; + } + const float scaled_score = total_score * params.scale; + key = topk::PackStableSortKey(scaled_score == 0.0f ? 0.0f : scaled_score, + block_index - block_slot + threadIdx.x); + } + keys[threadIdx.x] = key; + } + __syncthreads(); + if (threadIdx.x == 0) { + for (int index = 1; index < kHierarchicalTileBlocks; ++index) { + const uint64_t key = keys[index]; + int insertion = index; + while (insertion > 0 && key > keys[insertion - 1]) { + keys[insertion] = keys[insertion - 1]; + --insertion; + } + keys[insertion] = key; + } + } + __syncthreads(); + const int64_t output_base = work * kHierarchicalTileBlocks; + if (threadIdx.x < kHierarchicalTileBlocks) { + tile_keys[output_base + threadIdx.x] = keys[threadIdx.x]; + } + __syncthreads(); + } +} + +__device__ __forceinline__ uint64_t QsaMergeRank(const uint64_t* left, int left_count, + const uint64_t* right, int right_count, int rank) { + int low = max(0, rank - right_count); + int high = min(rank, left_count); + while (low <= high) { + const int left_rank = (low + high) / 2; + const int right_rank = rank - left_rank; + if (left_rank > 0 && right_rank < right_count && left[left_rank - 1] < right[right_rank]) { + high = left_rank - 1; + } else if (right_rank > 0 && left_rank < left_count && right[right_rank - 1] < left[left_rank]) { + low = left_rank + 1; + } else { + const uint64_t left_key = left_rank < left_count ? left[left_rank] : topk::kPaddingSortKey; + const uint64_t right_key = right_rank < right_count ? right[right_rank] : topk::kPaddingSortKey; + return max(left_key, right_key); + } + } + return topk::kPaddingSortKey; +} + +__global__ void QsaMergeTileTopKKernel(const uint64_t* input_keys, uint64_t* output_keys, + int key_stride, int list_width, + SparseAttentionIndexerParams params) { + const int pairs_per_row = (key_stride + 2 * list_width - 1) / (2 * list_width); + const int64_t total_pairs = + static_cast(params.batch_size) * params.sequence_length * pairs_per_row; + for (int64_t work = blockIdx.x; work < total_pairs; work += gridDim.x) { + const int pair = static_cast(work % pairs_per_row); + const int64_t row = work / pairs_per_row; + const int left_start = pair * 2 * list_width; + const int right_start = left_start + list_width; + const int left_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - left_start)); + const int right_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - right_start)); + const int output_count = min(left_count + right_count, kBoundedTopKMax); + const int64_t row_base = row * key_stride; + for (int rank = threadIdx.x; rank < output_count; rank += blockDim.x) { + output_keys[row_base + left_start + rank] = + QsaMergeRank(input_keys + row_base + left_start, left_count, + input_keys + row_base + right_start, right_count, rank); + } + } +} + +__global__ void QsaEmitHierarchicalTopKKernel(const uint64_t* tile_keys, + const int32_t* visible_count, + int key_stride, + int32_t* selected_indices, + SparseAttentionIndexerParams params) { + const int64_t rows = static_cast(params.batch_size) * params.sequence_length; + for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { + int32_t* output = selected_indices + row * params.capacity; + const int encoded_visible = visible_count[row]; + const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; + const int block_count = visible / params.compress_ratio; + const int selected = min(params.block_topk, block_count); + for (int rank = threadIdx.x; rank < selected; rank += blockDim.x) { + const int selected_block = topk::UnpackStableSortIndex( + tile_keys[row * key_stride + rank]); + for (int token = 0; token < params.compress_ratio; ++token) { + output[rank * params.compress_ratio + token] = selected_block * params.compress_ratio + token; + } + } + const int tail_start = block_count * params.compress_ratio; + for (int token = threadIdx.x; token < visible - tail_start; token += blockDim.x) { + output[selected * params.compress_ratio + token] = tail_start + token; + } + for (int position = selected * params.compress_ratio + visible - tail_start + threadIdx.x; + position < params.capacity; position += blockDim.x) { + output[position] = -1; + } + } +} + +__global__ void QsaChooseRadixBucketKernel(const uint32_t* histogram, uint64_t* prefixes, int32_t* remaining, int shift, SparseAttentionIndexerParams params) { const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { if (threadIdx.x == 0) { - int rank = shift == 24 ? min(params.block_topk, params.max_block_count) : remaining[row]; - uint32_t prefix = shift == 24 ? 0u : prefixes[row]; + int rank = shift == 56 ? min(params.block_topk, params.max_block_count) : remaining[row]; + uint64_t prefix = shift == 56 ? 0u : prefixes[row]; for (int bucket = 255; bucket >= 0; --bucket) { const int count = static_cast(histogram[row * 256 + bucket]); if (rank > count) { rank -= count; } else { - prefix |= static_cast(bucket) << shift; + prefix |= static_cast(bucket) << shift; prefixes[row] = prefix; remaining[row] = rank; break; @@ -617,7 +804,7 @@ __global__ void QsaChooseRadixBucketKernel(const uint32_t* histogram, uint32_t* } __global__ void QsaCompactHighBucketKernel(const float* block_scores, const int32_t* visible_count, - const uint32_t* prefixes, int32_t* candidate_counts, + const uint64_t* prefixes, int32_t* candidate_counts, int32_t* candidate_indices, SparseAttentionIndexerParams params) { const int64_t rows = static_cast(params.batch_size) * params.sequence_length; @@ -631,8 +818,8 @@ __global__ void QsaCompactHighBucketKernel(const float* block_scores, const int3 const int block_count = visible / params.compress_ratio; if (index < block_count) { const float score = block_scores[row * params.max_block_count + index]; - const uint32_t score_key = static_cast(topk::PackStableSortKey(score, 0) >> 32); - if ((score_key >> 24) >= (prefixes[row] >> 24)) { + const uint64_t key = topk::PackStableSortKey(score, index); + if ((key >> 56) >= (prefixes[row] >> 56)) { const int slot = atomicAdd(candidate_counts + row, 1); candidate_indices[row * params.max_block_count + slot] = index; } @@ -641,7 +828,7 @@ __global__ void QsaCompactHighBucketKernel(const float* block_scores, const int3 } __global__ void QsaCandidateHistogramKernel(const float* block_scores, const int32_t* candidate_counts, - const int32_t* candidate_indices, const uint32_t* prefixes, + const int32_t* candidate_indices, const uint64_t* prefixes, uint32_t* histogram, int shift, SparseAttentionIndexerParams params) { const int64_t rows = static_cast(params.batch_size) * params.sequence_length; @@ -653,9 +840,9 @@ __global__ void QsaCandidateHistogramKernel(const float* block_scores, const int if (slot < candidate_counts[row]) { const int index = candidate_indices[row * params.max_block_count + slot]; const float score = block_scores[row * params.max_block_count + index]; - const uint32_t score_key = static_cast(topk::PackStableSortKey(score, 0) >> 32); - if ((score_key >> (shift + 8)) == (prefixes[row] >> (shift + 8))) { - atomicAdd(histogram + row * 256 + ((score_key >> shift) & 0xffu), 1u); + const uint64_t key = topk::PackStableSortKey(score, index); + if ((key >> (shift + 8)) == (prefixes[row] >> (shift + 8))) { + atomicAdd(histogram + row * 256 + ((key >> shift) & 0xffu), 1u); } } } @@ -666,17 +853,13 @@ __global__ void QsaCandidateHistogramKernel(const float* block_scores, const int __global__ void QsaDistributedSelectKernel(const float* block_scores, const int32_t* visible_count, const int32_t* candidate_counts, const int32_t* candidate_indices, - const uint32_t* prefixes, const int32_t* remaining, + const uint64_t* prefixes, int32_t* selected_indices, SparseAttentionIndexerParams params) { using Sort = cub::BlockRadixSort; - __shared__ union { - typename Sort::TempStorage sort; - int32_t equal_warp_offsets[kThreads / kWarpSize + 1]; - } temp; + __shared__ typename Sort::TempStorage sort_temp; __shared__ uint64_t selected_keys[kBoundedTopKMax]; __shared__ int gathered; - __shared__ int equal_seen; const int64_t rows = static_cast(params.batch_size) * params.sequence_length; for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { @@ -689,62 +872,22 @@ __global__ void QsaDistributedSelectKernel(const float* block_scores, const int3 } __syncthreads(); - const uint32_t threshold = prefixes[row]; + const uint64_t threshold = prefixes[row]; const int candidate_count = candidate_counts[row]; for (int slot = threadIdx.x; slot < candidate_count; slot += blockDim.x) { const int index = candidate_indices[row * params.max_block_count + slot]; const uint64_t key = topk::PackStableSortKey( block_scores[row * params.max_block_count + index], index); - if (static_cast(key >> 32) > threshold) { + if (key >= threshold) { const int output_slot = atomicAdd(&gathered, 1); selected_keys[output_slot] = key; } } __syncthreads(); - if (threadIdx.x == 0) { - equal_seen = 0; - } - __syncthreads(); const int encoded_visible = visible_count[row]; const int visible = encoded_visible < 0 ? -encoded_visible - 1 : encoded_visible; const int block_count = visible / params.compress_ratio; - for (int base = 0; base < block_count && equal_seen < remaining[row]; base += blockDim.x) { - const int index = base + static_cast(threadIdx.x); - const bool equal = index < block_count && - static_cast(topk::PackStableSortKey( - block_scores[row * params.max_block_count + index], 0) >> - 32) == threshold; - const unsigned int warp_mask = __ballot_sync(0xffffffffu, equal); - const int lane = static_cast(threadIdx.x) % kWarpSize; - const int warp = static_cast(threadIdx.x) / kWarpSize; - const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); - if (lane == 0) { - temp.equal_warp_offsets[warp] = __popc(warp_mask); - } - __syncthreads(); - if (threadIdx.x == 0) { - int tile_total = 0; - for (int i = 0; i < kThreads / kWarpSize; ++i) { - const int warp_count = temp.equal_warp_offsets[i]; - temp.equal_warp_offsets[i] = tile_total; - tile_total += warp_count; - } - temp.equal_warp_offsets[kThreads / kWarpSize] = tile_total; - } - __syncthreads(); - const int equal_rank = equal_seen + temp.equal_warp_offsets[warp] + lane_rank; - if (equal && equal_rank < remaining[row]) { - selected_keys[gathered + equal_rank] = topk::PackStableSortKey( - block_scores[row * params.max_block_count + index], index); - } - __syncthreads(); - if (threadIdx.x == 0) { - equal_seen += temp.equal_warp_offsets[kThreads / kWarpSize]; - } - __syncthreads(); - } - const int selected = min(params.block_topk, block_count); uint64_t keys[kBoundedTopKItemsPerThread]; #pragma unroll @@ -752,7 +895,7 @@ __global__ void QsaDistributedSelectKernel(const float* block_scores, const int3 const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; } - Sort(temp.sort).SortDescending(keys); + Sort(sort_temp).SortDescending(keys); #pragma unroll for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; @@ -1219,13 +1362,20 @@ __global__ void CsaSelectKernel(const float* scores, const int64_t* position_ids size_t GetQsaWorkspaceFloatCount(const SparseAttentionIndexerParams& params) { const size_t rows = static_cast(params.batch_size) * params.sequence_length; - return rows * params.num_heads * params.head_size + rows * std::max(params.max_block_count, 1); + const size_t query_count = rows * params.num_heads * params.head_size; + return UseHierarchicalQsaTopK(params, false) + ? query_count + : query_count + rows * std::max(params.max_block_count, 1); } size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params, bool has_mask) { const size_t rows = static_cast(params.batch_size) * params.sequence_length; + if (UseHierarchicalQsaTopK(params, has_mask)) { + const size_t key_count = rows * GetHierarchicalTileCount(params) * kHierarchicalTileBlocks; + return rows + 1 + 4 * key_count; + } if (UseDistributedQsaTopK(params, has_mask)) { - return rows + rows * std::max(params.max_block_count, 1) + rows * 256 + rows * 3; + return rows + rows * std::max(params.max_block_count, 1) + rows * 256 + rows * 4 + 1; } return (has_mask ? rows * std::max(params.total_sequence_length, 1) : 0) + rows + rows * std::min(params.block_topk, kBoundedTopKMax); @@ -1253,19 +1403,25 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio CopyQsaPastKeyKernel<<>>( past_key, present_key, params); } - const int64_t new_key_elements = - static_cast(params.batch_size) * params.sequence_length * params.head_size; - if (new_key_elements > 0) { - AppendQsaKeyKernel<<>>(key, present_key, params); - } - if (params.use_block_representatives) { const int first_block = params.past_sequence_length / kQwenCompressRatio; const int completed_blocks = params.total_sequence_length / kQwenCompressRatio; const int64_t new_blocks = static_cast(params.batch_size) * (completed_blocks - first_block); if (new_blocks > 0) { QsaBuildBlockRepresentativesKernel<<>>( - present_key, key_norm_weight, cos_cache, sin_cache, params); + past_key, key, present_key, key_norm_weight, cos_cache, sin_cache, params); + } + const int64_t new_key_elements = + static_cast(params.batch_size) * params.sequence_length * params.head_size; + if (new_key_elements > 0) { + AppendQsaKeyTailKernel<<>>( + key, present_key, params); + } + } else { + const int64_t new_key_elements = + static_cast(params.batch_size) * params.sequence_length * params.head_size; + if (new_key_elements > 0) { + AppendQsaKeyKernel<<>>(key, present_key, params); } } @@ -1278,14 +1434,29 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio int32_t* visible_indices = mask == nullptr ? nullptr : int_workspace; int32_t* visible_count = mask == nullptr ? int_workspace : int_workspace + rows * params.total_sequence_length; int32_t* topk_indices = visible_count + rows; + const bool use_hierarchical_topk = UseHierarchicalQsaTopK(params, mask != nullptr); const bool use_distributed_topk = UseDistributedQsaTopK(params, mask != nullptr); - int32_t* candidate_indices = use_distributed_topk ? topk_indices : nullptr; - uint32_t* radix_histogram = use_distributed_topk + uint64_t* hierarchical_keys = use_hierarchical_topk + ? reinterpret_cast((reinterpret_cast(topk_indices) + + alignof(uint64_t) - 1) & + ~(alignof(uint64_t) - 1)) + : nullptr; + const size_t hierarchical_key_count = use_hierarchical_topk + ? static_cast(rows) * GetHierarchicalTileCount(params) * + kHierarchicalTileBlocks + : 0; + uint64_t* hierarchical_scratch = use_hierarchical_topk ? hierarchical_keys + hierarchical_key_count : nullptr; + int32_t* candidate_indices = use_distributed_topk && !use_hierarchical_topk ? topk_indices : nullptr; + uint32_t* radix_histogram = use_distributed_topk && !use_hierarchical_topk ? reinterpret_cast(candidate_indices + rows * params.max_block_count) : nullptr; - uint32_t* radix_prefixes = use_distributed_topk ? radix_histogram + rows * 256 : nullptr; - int32_t* radix_remaining = use_distributed_topk ? reinterpret_cast(radix_prefixes + rows) : nullptr; - int32_t* candidate_counts = use_distributed_topk ? radix_remaining + rows : nullptr; + uint64_t* radix_prefixes = use_distributed_topk && !use_hierarchical_topk + ? reinterpret_cast((reinterpret_cast(radix_histogram + rows * 256) + + alignof(uint64_t) - 1) & + ~(alignof(uint64_t) - 1)) + : nullptr; + int32_t* radix_remaining = radix_prefixes ? reinterpret_cast(radix_prefixes + rows) : nullptr; + int32_t* candidate_counts = radix_remaining ? radix_remaining + rows : nullptr; const size_t value_bytes = static_cast(params.head_size) * sizeof(float); @@ -1304,37 +1475,59 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio query, query_norm_weight, cos_cache, sin_cache, nullptr, query_rotated, params); if (params.max_block_count > 0) { - if (use_distributed_topk) { + if (use_distributed_topk && !use_hierarchical_topk) { CUDA_RETURN_IF_ERROR(cudaMemsetAsync(radix_histogram, 0, rows * 256 * sizeof(uint32_t), stream)); CUDA_RETURN_IF_ERROR(cudaMemsetAsync(candidate_counts, 0, rows * sizeof(int32_t), stream)); } - const int64_t block_work = rows * params.max_block_count; - const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); - if (params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + if (use_hierarchical_topk) { + const int tile_count = GetHierarchicalTileCount(params); + const int64_t tile_work = rows * tile_count; + const int tile_blocks = static_cast(std::min(tile_work, kMaxGridDimX)); + QsaScoreTileTopKKernel<<>>( + query_rotated, present_key, visible_count, hierarchical_keys, tile_count, params); + } else { + const int64_t block_work = rows * params.max_block_count; + const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); + if (params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && params.num_heads == kQwenNumHeads) { - if (params.use_block_representatives) { - QsaBlockScoreQwenCachedKernel<<>>( - query_rotated, present_key, visible_count, block_scores, radix_histogram, params); + if (params.use_block_representatives) { + QsaBlockScoreQwenCachedKernel<<>>( + query_rotated, present_key, visible_count, block_scores, radix_histogram, params); + } else { + QsaBlockScoreQwenKernel<<>>( + query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, + block_scores, radix_histogram, params); + } } else { - QsaBlockScoreQwenKernel<<>>( + QsaBlockScoreKernel<<>>( query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, - block_scores, radix_histogram, params); + block_scores, params); } - } else { - QsaBlockScoreKernel<<>>( - query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, - block_scores, params); } } const bool use_bounded_topk = params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; - if (use_distributed_topk) { + if (use_hierarchical_topk) { + const int block_count = GetHierarchicalTileCount(params) * kHierarchicalTileBlocks; + uint64_t* merge_input = hierarchical_keys; + uint64_t* merge_output = hierarchical_scratch; + for (int list_width = kHierarchicalTileBlocks; list_width < block_count; list_width *= 2) { + const int pairs_per_row = (block_count + 2 * list_width - 1) / (2 * list_width); + const int64_t merge_work = rows * pairs_per_row; + QsaMergeTileTopKKernel<<(std::min(merge_work, kMaxGridDimX)), + kHierarchicalMergeThreads, 0, stream>>>( + merge_input, merge_output, block_count, list_width, params); + std::swap(merge_input, merge_output); + } + QsaEmitHierarchicalTopKKernel<<>>( + merge_input, visible_count, block_count, selected_indices, params); + } else if (use_distributed_topk) { QsaChooseRadixBucketKernel<<>>( - radix_histogram, radix_prefixes, radix_remaining, 24, params); + radix_histogram, radix_prefixes, radix_remaining, 56, params); QsaCompactHighBucketKernel<<>>( block_scores, visible_count, radix_prefixes, candidate_counts, candidate_indices, params); - for (int shift = 16; shift >= 0; shift -= 8) { + for (int shift = 48; shift >= 0; shift -= 8) { CUDA_RETURN_IF_ERROR(cudaMemsetAsync(radix_histogram, 0, rows * 256 * sizeof(uint32_t), stream)); QsaCandidateHistogramKernel<<>>( block_scores, candidate_counts, candidate_indices, radix_prefixes, radix_histogram, shift, params); @@ -1342,7 +1535,7 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio radix_histogram, radix_prefixes, radix_remaining, shift, params); } QsaDistributedSelectKernel<<>>( - block_scores, visible_count, candidate_counts, candidate_indices, radix_prefixes, radix_remaining, + block_scores, visible_count, candidate_counts, candidate_indices, radix_prefixes, selected_indices, params); } else if (use_bounded_topk) { QsaPartialTopKKernel<<>>( diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index ad9ccabff6809..52baecce1dde3 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include @@ -207,6 +209,111 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) ASSERT_EQ(cudaSuccess, cudaStreamDestroy(stream)); } +TEST(SparseAttentionIndexerCudaKernelTest, DISABLED_QsaHierarchicalLongContextBenchmark) { + constexpr int kHeadSize = 128; + constexpr int kNumHeads = 4; + constexpr int kTokenBudget = 2048; + constexpr int kCompressRatio = 4; + constexpr int kWarmupIterations = 10; + constexpr int kTimedIterations = 100; + const char* context_environment = std::getenv("ORT_QSA_BENCH_CONTEXT"); + const int context_length = context_environment == nullptr ? 65536 : std::stoi(context_environment); + + SparseAttentionIndexerParams params; + params.batch_size = 1; + params.sequence_length = 1; + params.num_heads = kNumHeads; + params.head_size = kHeadSize; + params.rotary_width = kHeadSize; + params.max_rotary_length = context_length; + params.compress_ratio = kCompressRatio; + params.capacity = kTokenBudget + kCompressRatio - 1; + params.scale = 0.5f; + params.past_sequence_length = context_length - 1; + params.total_sequence_length = context_length; + params.past_key_capacity = context_length; + params.key_cache_capacity = context_length; + params.max_block_count = context_length / kCompressRatio; + params.block_topk = kTokenBudget / kCompressRatio; + params.use_block_representatives = true; + + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaSuccess, cudaStreamCreate(&stream)); + float* query = nullptr; + float* key = nullptr; + float* weight = nullptr; + float* cosine = nullptr; + float* sine = nullptr; + float* key_cache = nullptr; + int32_t* selected = nullptr; + float* float_workspace = nullptr; + int32_t* int_workspace = nullptr; + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&query), kNumHeads * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&key), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&weight), kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&cosine), context_length * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&sine), context_length * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, + cudaMalloc(reinterpret_cast(&key_cache), context_length * kHeadSize * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&selected), params.capacity * sizeof(int32_t))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&float_workspace), + GetQsaWorkspaceFloatCount(params) * sizeof(float))); + ASSERT_EQ(cudaSuccess, cudaMalloc(reinterpret_cast(&int_workspace), + GetQsaWorkspaceIntCount(params, false) * sizeof(int32_t))); + + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(query, 0, kNumHeads * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(key, 0, kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(weight, 0, kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(cosine, 0, context_length * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(sine, 0, context_length * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaMemsetAsync(key_cache, 0, context_length * kHeadSize * sizeof(float), stream)); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + + const auto launch = [&]() { + return LaunchQsaSparseAttentionIndexer( + stream, params, query, key, weight, weight, cosine, sine, nullptr, key_cache, selected, key_cache, + float_workspace, int_workspace); + }; + ASSERT_TRUE(launch().IsOK()); + ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); + for (int iteration = 0; iteration < kWarmupIterations; ++iteration) { + ASSERT_TRUE(launch().IsOK()); + } + + cudaEvent_t start = nullptr; + cudaEvent_t stop = nullptr; + ASSERT_EQ(cudaSuccess, cudaEventCreate(&start)); + ASSERT_EQ(cudaSuccess, cudaEventCreate(&stop)); + ASSERT_EQ(cudaSuccess, cudaEventRecord(start, stream)); + for (int iteration = 0; iteration < kTimedIterations; ++iteration) { + ASSERT_TRUE(launch().IsOK()); + } + ASSERT_EQ(cudaSuccess, cudaEventRecord(stop, stream)); + ASSERT_EQ(cudaSuccess, cudaEventSynchronize(stop)); + float elapsed_ms = 0.0f; + ASSERT_EQ(cudaSuccess, cudaEventElapsedTime(&elapsed_ms, start, stop)); + const float average_ms = elapsed_ms / kTimedIterations; + std::cout << "QSA_HIERARCHICAL context=" << context_length << " average_ms=" << average_ms + << " blocks_scanned=" << params.max_block_count + << " tile_count=" << (params.max_block_count + 7) / 8 + << " selected_entries=" << params.block_topk << std::endl; + RecordProperty("qsa_hierarchical_ms", std::to_string(average_ms)); + + ASSERT_EQ(cudaSuccess, cudaEventDestroy(stop)); + ASSERT_EQ(cudaSuccess, cudaEventDestroy(start)); + ASSERT_EQ(cudaSuccess, cudaFree(int_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(float_workspace)); + ASSERT_EQ(cudaSuccess, cudaFree(selected)); + ASSERT_EQ(cudaSuccess, cudaFree(key_cache)); + ASSERT_EQ(cudaSuccess, cudaFree(sine)); + ASSERT_EQ(cudaSuccess, cudaFree(cosine)); + ASSERT_EQ(cudaSuccess, cudaFree(weight)); + ASSERT_EQ(cudaSuccess, cudaFree(key)); + ASSERT_EQ(cudaSuccess, cudaFree(query)); + ASSERT_EQ(cudaSuccess, cudaStreamDestroy(stream)); +} + } // namespace } // namespace cuda } // namespace contrib 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 09a7ea1b372d7..1d0088351cec1 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -349,7 +350,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std for (int t = 0; t < present_capacity; ++t) { for (int d = 0; d < head_size; ++d) { const size_t output_index = (static_cast(b) * present_capacity + t) * head_size + d; - if (t >= problem.past_sequence_length && t < total) { + if (!problem.cache_block_representatives && t >= problem.past_sequence_length && t < total) { present_key[output_index] = problem.key[(static_cast(b) * problem.sequence_length + t - problem.past_sequence_length) * head_size + @@ -362,6 +363,55 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std } } + if (problem.cache_block_representatives) { + const int representative_capacity = present_capacity / problem.compress_ratio; + const int first_block = problem.past_sequence_length / problem.compress_ratio; + const int completed_blocks = total / problem.compress_ratio; + for (int b = 0; b < problem.batch_size; ++b) { + const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); + const float* cos_base = + problem.cos_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; + const float* sin_base = + problem.sin_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; + for (int block = first_block; block < completed_blocks; ++block) { + const int first_position = block * problem.compress_ratio; + std::vector pooled(static_cast(head_size), 0.0f); + for (int token = 0; token < problem.compress_ratio; ++token) { + const int position = first_position + token; + for (int d = 0; d < head_size; ++d) { + if (position < problem.past_sequence_length) { + const int cache_position = representative_capacity + position % problem.compress_ratio; + pooled[static_cast(d)] += + problem.past_key[(static_cast(b) * past_capacity + cache_position) * head_size + d]; + } else { + pooled[static_cast(d)] += + problem.key[(static_cast(b) * problem.sequence_length + + position - problem.past_sequence_length) * head_size + d]; + } + } + } + for (float& value : pooled) { + value /= static_cast(problem.compress_ratio); + } + pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); + pooled = LeadingRope(pooled, problem.rotary_width, cos_base + first_position * problem.rotary_width, + sin_base + first_position * problem.rotary_width); + std::copy(pooled.begin(), pooled.end(), + present_key.begin() + (static_cast(b) * present_capacity + block) * head_size); + } + const int completed_tokens = completed_blocks * problem.compress_ratio; + for (int position = std::max(problem.past_sequence_length, completed_tokens); position < total; ++position) { + const int cache_position = representative_capacity + position % problem.compress_ratio; + const int key_token = position - problem.past_sequence_length; + std::copy_n(problem.key.begin() + + (static_cast(b) * problem.sequence_length + key_token) * head_size, + head_size, + present_key.begin() + + (static_cast(b) * present_capacity + cache_position) * head_size); + } + } + } + selected.assign(static_cast(problem.batch_size) * problem.sequence_length * capacity, -1); for (int b = 0; b < problem.batch_size; ++b) { const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); @@ -394,20 +444,27 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std 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)]; + if (problem.cache_block_representatives) { for (int d = 0; d < head_size; ++d) { - pooled[static_cast(d)] += - present_key[(static_cast(b) * present_capacity + token) * head_size + d]; + pooled[static_cast(d)] = + present_key[(static_cast(b) * present_capacity + block) * head_size + d]; } + } else { + 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) * present_capacity + 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); } - 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) { @@ -436,35 +493,6 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std } } - if (problem.cache_block_representatives) { - const int completed_blocks = problem.TotalSequenceLength() / problem.compress_ratio; - for (int b = 0; b < problem.batch_size; ++b) { - const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); - const float* cos_base = - problem.cos_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; - const float* sin_base = - problem.sin_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; - for (int block = 0; block < completed_blocks; ++block) { - const int first_position = block * problem.compress_ratio; - std::vector pooled(static_cast(head_size), 0.0f); - for (int token = 0; token < problem.compress_ratio; ++token) { - for (int d = 0; d < head_size; ++d) { - pooled[static_cast(d)] += - present_key[(static_cast(b) * present_capacity + first_position + token) * head_size + d]; - } - } - for (float& value : pooled) { - value /= static_cast(problem.compress_ratio); - } - pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); - pooled = LeadingRope(pooled, problem.rotary_width, cos_base + first_position * problem.rotary_width, - sin_base + first_position * problem.rotary_width); - std::copy(pooled.begin(), pooled.end(), - present_key.begin() + - (static_cast(b) * present_capacity + first_position) * head_size); - } - } - } } struct CsaProblem { @@ -1337,6 +1365,86 @@ TEST(SparseAttentionIndexerTest, QsaQwenSharedBlockRepresentatives) { RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); } +TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKAllEqual) { + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 8191; + problem.key_cache_capacity = 8192; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem.cache_block_representatives = true; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + std::fill(problem.query.begin(), problem.query.end(), 0.0f); + std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); + std::fill(problem.key.begin(), problem.key.end(), 0.0f); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + +void RunQsaQwenHierarchicalTopKRepresentativeScores(bool with_ties) { + const char* context_environment = std::getenv("ORT_QSA_TEST_CONTEXT"); + const int context_length = context_environment == nullptr ? 8192 : std::stoi(context_environment); + QsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = context_length - 1; + problem.key_cache_capacity = context_length; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem.cache_block_representatives = true; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + std::fill(problem.query.begin(), problem.query.end(), 0.0f); + std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); + std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); + std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); + std::fill(problem.key.begin(), problem.key.end(), 0.0f); + for (int head = 0; head < problem.num_heads; ++head) { + problem.query[static_cast(head) * problem.head_size] = 1.0f; + } + const int completed_past_blocks = problem.past_sequence_length / problem.compress_ratio; + for (int block = 0; block < completed_past_blocks; ++block) { + problem.past_key[static_cast(block) * problem.head_size] = + static_cast(with_ties ? block % 17 : block + 1); + } + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + +TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKUniqueScores) { + RunQsaQwenHierarchicalTopKRepresentativeScores(false); +} + +TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKModerateTies) { + RunQsaQwenHierarchicalTopKRepresentativeScores(true); +} + +TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKMultiRowPartialTile) { + QsaProblem problem; + problem.batch_size = 2; + problem.sequence_length = 1; + problem.num_heads = 4; + problem.head_size = 128; + problem.past_sequence_length = 8202; + problem.key_cache_capacity = 8203; + problem.rotary_width = 32; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem.cache_block_representatives = true; + problem = MakeQsaProblem(std::move(problem)); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + std::fill(problem.query.begin(), problem.query.end(), 0.0f); + std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); + std::fill(problem.key.begin(), problem.key.end(), 0.0f); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); +} + TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; From b9699f175e584b5eb2df3118cab3d6e675988467 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Tue, 22 Sep 2026 07:12:34 +0000 Subject: [PATCH 062/102] Use Env for sparse attention test variables --- .../cuda_kernels/sparse_attention_indexer_kernel_test.cc | 6 +++--- .../test/contrib_ops/sparse_attention_indexer_op_test.cc | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index 52baecce1dde3..82bd974e8dc70 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -4,12 +4,12 @@ #include #include -#include #include #include #include #include "contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h" +#include "core/platform/env.h" #include "gtest/gtest.h" namespace onnxruntime { @@ -216,8 +216,8 @@ TEST(SparseAttentionIndexerCudaKernelTest, DISABLED_QsaHierarchicalLongContextBe constexpr int kCompressRatio = 4; constexpr int kWarmupIterations = 10; constexpr int kTimedIterations = 100; - const char* context_environment = std::getenv("ORT_QSA_BENCH_CONTEXT"); - const int context_length = context_environment == nullptr ? 65536 : std::stoi(context_environment); + const std::string context_environment = Env::Default().GetEnvironmentVar("ORT_QSA_BENCH_CONTEXT"); + const int context_length = context_environment.empty() ? 65536 : std::stoi(context_environment); SparseAttentionIndexerParams params; params.batch_size = 1; 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 1d0088351cec1..999cf22637ef7 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -31,6 +30,7 @@ #include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" #include "core/graph/constants.h" #include "core/graph/model.h" +#include "core/platform/env.h" #include "test/common/tensor_op_test_utils.h" #include "test/providers/provider_test_utils.h" #include "test/test_environment.h" @@ -1386,8 +1386,8 @@ TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKAllEqual) { } void RunQsaQwenHierarchicalTopKRepresentativeScores(bool with_ties) { - const char* context_environment = std::getenv("ORT_QSA_TEST_CONTEXT"); - const int context_length = context_environment == nullptr ? 8192 : std::stoi(context_environment); + const std::string context_environment = Env::Default().GetEnvironmentVar("ORT_QSA_TEST_CONTEXT"); + const int context_length = context_environment.empty() ? 8192 : std::stoi(context_environment); QsaProblem problem; problem.batch_size = 1; problem.sequence_length = 1; From 6fa55954eb3c8d1dc16dec9363af97473e156103 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sun, 27 Sep 2026 20:15:37 +0000 Subject: [PATCH 063/102] Optimize packed QSA selection --- .../packed_sparse_attention_indexer_impl.cu | 419 +++++++++++++++++- ...packed_sparse_attention_indexer_op_test.cc | 35 ++ 2 files changed, 442 insertions(+), 12 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 3b892f6c969c3..3b048ad5fb9b4 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -30,23 +30,61 @@ #include #include #include +#include #include #include "contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h" #include "contrib_ops/cuda/sparse/sparse_attention_indexer_device_math.cuh" #include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" +#include "core/providers/cuda/cu_inc/topk_warp_sort.cuh" namespace onnxruntime { namespace contrib { namespace cuda { namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; +namespace topk = onnxruntime::cuda::topk; namespace { // The block reductions below halve the active thread count, so this must stay a power of two. constexpr int kThreads = 128; +constexpr int kWarpSize = 32; +constexpr int kBoundedTopKMax = 512; +constexpr int kBoundedTopKItemsPerThread = kBoundedTopKMax / kThreads; +constexpr int kDistributedTopKMaxRows = 64; +constexpr int kDistributedTopKMinBlocks = 2048; +constexpr int kHierarchicalTileBlocks = 8; +constexpr int kHierarchicalScoreThreads = 1024; +constexpr int kHierarchicalMergeThreads = 256; +constexpr int kQwenHeadSize = 128; +constexpr int kQwenCompressRatio = 4; +constexpr int kQwenNumHeads = 4; + +bool UseHierarchicalQsaTopK(const PackedSparseAttentionIndexerParams& params) { + return params.total_tokens > 0 && params.total_tokens <= kDistributedTopKMaxRows && + params.state_capacity >= kDistributedTopKMinBlocks && + params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads && params.block_topk > kSaiFastTopKMax && + params.block_topk <= kBoundedTopKMax; +} + +int GetHierarchicalTileCount(const PackedSparseAttentionIndexerParams& params) { + return (params.state_capacity + kHierarchicalTileBlocks - 1) / kHierarchicalTileBlocks; +} + +template +struct alignas(sizeof(T) * 4) QsaVector4 { + T values[4]; +}; + +template +__device__ __forceinline__ float4 LoadQsaVector4(const T* source) { + const QsaVector4 packed = *reinterpret_cast*>(source); + return make_float4(to_float(packed.values[0]), to_float(packed.values[1]), + to_float(packed.values[2]), to_float(packed.values[3])); +} // Largest b such that cumulative_sequence_lengths[b] <= token, assuming the array is nondecreasing. // If the data itself is malformed this may attribute a token to the wrong request, but the result @@ -314,11 +352,178 @@ __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* que } } +template +__global__ void QsaScoreTileTopKKernel(const T* present_key_state, const float* query_rotated, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, const int32_t* overflow_flags, + uint64_t* tile_keys, int tile_count, + PackedSparseAttentionIndexerParams params) { + __shared__ uint64_t keys[kHierarchicalTileBlocks]; + __shared__ float head_scores[kHierarchicalTileBlocks][kQwenNumHeads]; + + const int64_t total_tiles = static_cast(params.total_tokens) * tile_count; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int block_slot = warp / kQwenNumHeads; + const int head = warp % kQwenNumHeads; + for (int64_t work = blockIdx.x; work < total_tiles; work += gridDim.x) { + const int tile = static_cast(work % tile_count); + const int token = static_cast(work / tile_count); + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, kQwenCompressRatio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = overflow_flags[batch] == 0 ? static_cast(visible_64 < 0 ? 0 : visible_64) : 0; + + const int block_index = tile * kHierarchicalTileBlocks + block_slot; + float score = 0.0f; + if (block_index < block_count) { + const int d = lane * 4; + const int64_t query_base = + (static_cast(token) * kQwenNumHeads + head) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.state_capacity + block_index) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key_state + key_base + d); + score = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + score = topk::WarpReduceSum(score); + } + if (lane == 0) { + head_scores[block_slot][head] = fmaxf(score, 0.0f); + } + __syncthreads(); + if (threadIdx.x < kHierarchicalTileBlocks) { + const int tile_block = tile * kHierarchicalTileBlocks + static_cast(threadIdx.x); + uint64_t key = topk::kPaddingSortKey; + if (tile_block < block_count) { + float total_score = 0.0f; +#pragma unroll + for (int query_head = 0; query_head < kQwenNumHeads; ++query_head) { + total_score += head_scores[threadIdx.x][query_head]; + } + const float scaled_score = total_score * params.scale; + key = topk::PackStableSortKey(scaled_score == 0.0f ? 0.0f : scaled_score, tile_block); + } + keys[threadIdx.x] = key; + } + __syncthreads(); + if (threadIdx.x == 0) { + for (int index = 1; index < kHierarchicalTileBlocks; ++index) { + const uint64_t key = keys[index]; + int insertion = index; + while (insertion > 0 && key > keys[insertion - 1]) { + keys[insertion] = keys[insertion - 1]; + --insertion; + } + keys[insertion] = key; + } + } + __syncthreads(); + const int64_t output_base = work * kHierarchicalTileBlocks; + if (threadIdx.x < kHierarchicalTileBlocks) { + tile_keys[output_base + threadIdx.x] = keys[threadIdx.x]; + } + __syncthreads(); + } +} + +__device__ __forceinline__ uint64_t QsaMergeRank(const uint64_t* left, int left_count, + const uint64_t* right, int right_count, int rank) { + int low = max(0, rank - right_count); + int high = min(rank, left_count); + while (low <= high) { + const int left_rank = (low + high) / 2; + const int right_rank = rank - left_rank; + if (left_rank > 0 && right_rank < right_count && left[left_rank - 1] < right[right_rank]) { + high = left_rank - 1; + } else if (right_rank > 0 && left_rank < left_count && right[right_rank - 1] < left[left_rank]) { + low = left_rank + 1; + } else { + const uint64_t left_key = left_rank < left_count ? left[left_rank] : topk::kPaddingSortKey; + const uint64_t right_key = right_rank < right_count ? right[right_rank] : topk::kPaddingSortKey; + return max(left_key, right_key); + } + } + return topk::kPaddingSortKey; +} + +__global__ void QsaMergeTileTopKKernel(const uint64_t* input_keys, uint64_t* output_keys, + int key_stride, int list_width, int rows) { + const int pairs_per_row = (key_stride + 2 * list_width - 1) / (2 * list_width); + const int64_t total_pairs = static_cast(rows) * pairs_per_row; + for (int64_t work = blockIdx.x; work < total_pairs; work += gridDim.x) { + const int pair = static_cast(work % pairs_per_row); + const int64_t row = work / pairs_per_row; + const int left_start = pair * 2 * list_width; + const int right_start = left_start + list_width; + const int left_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - left_start)); + const int right_count = min(min(list_width, kBoundedTopKMax), max(0, key_stride - right_start)); + const int output_count = min(left_count + right_count, kBoundedTopKMax); + const int64_t row_base = row * key_stride; + for (int rank = threadIdx.x; rank < output_count; rank += blockDim.x) { + output_keys[row_base + left_start + rank] = + QsaMergeRank(input_keys + row_base + left_start, left_count, + input_keys + row_base + right_start, right_count, rank); + } + } +} + +__global__ void QsaEmitHierarchicalTopKKernel(const uint64_t* tile_keys, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, + const int64_t* position_ids, + const int32_t* present_state_lengths, + const int32_t* overflow_flags, int key_stride, + int32_t* selected_indices, int32_t* selected_counts, + PackedSparseAttentionIndexerParams params) { + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + int32_t* output = selected_indices + static_cast(token) * params.capacity; + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = overflow_flags[batch] == 0 ? static_cast(visible_64 < 0 ? 0 : visible_64) : 0; + const int selected = min(params.block_topk, block_count); + for (int rank = threadIdx.x; rank < selected; rank += blockDim.x) { + const int selected_block = topk::UnpackStableSortIndex(tile_keys[static_cast(token) * key_stride + rank]); + for (int t = 0; t < params.compress_ratio; ++t) { + output[rank * params.compress_ratio + t] = selected_block * params.compress_ratio + t; + } + } + const int64_t block_start = static_cast(block_count) * params.compress_ratio; + const int64_t natural_tail = abs_position >= block_start ? abs_position - block_start + 1 : 0; + const int remaining_capacity = params.capacity - selected * params.compress_ratio; + const int64_t tail_count_64 = natural_tail < remaining_capacity ? natural_tail : remaining_capacity; + const int tail_count = overflow_flags[batch] == 0 ? static_cast(tail_count_64 < 0 ? 0 : tail_count_64) : 0; + for (int t = threadIdx.x; t < tail_count; t += blockDim.x) { + output[selected * params.compress_ratio + t] = static_cast(block_start + t); + } + for (int position = selected * params.compress_ratio + tail_count + threadIdx.x; + position < params.capacity; position += blockDim.x) { + output[position] = -1; + } + if (threadIdx.x == 0) { + selected_counts[token] = selected * params.compress_ratio + tail_count; + } + } +} + // One block per query token. Emits the token indices of the highest scoring blocks followed by the // causally visible tokens of the trailing incomplete block, and the exact active count. A request // whose update step was rejected for exceeding state_capacity this call (overflow_flags[batch] set) // always gets the safe empty result: indices stay -1 (already reset below) and count is 0. -__global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumulative_sequence_lengths, +__global__ void QsaSelectKernel(const float* block_scores, const int32_t* topk_indices, + const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, const int32_t* present_state_lengths, const int32_t* overflow_flags, int32_t* selected_indices, int32_t* selected_counts, @@ -359,7 +564,17 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula const float* scores_row = block_scores + static_cast(token) * params.state_capacity; int emitted_blocks = selected; - if (selected > 0 && use_fast_topk) { + if (topk_indices != nullptr) { + const int32_t* topk_row = topk_indices + static_cast(token) * params.state_capacity; + for (int rank = 0; rank < selected; ++rank) { + const int selected_block = topk_row[rank]; + for (int t = static_cast(threadIdx.x); t < params.compress_ratio; + t += static_cast(blockDim.x)) { + out_row[rank * params.compress_ratio + t] = selected_block * params.compress_ratio + t; + } + } + __syncthreads(); + } else if (selected > 0 && use_fast_topk) { SaiBlockTopK(scores_row, visible_block_count, selected, shared_value, shared_index); for (int rank = 0; rank < selected; ++rank) { const int selected_block = shared_index[rank]; @@ -413,6 +628,148 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula } } +// One block per packed query token. Radix-selects a bounded candidate set, then sorts only that +// set. The output aliases the corresponding score row after all scores for that row have been read. +__global__ void QsaPartialTopKKernel(float* block_scores, const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, const int32_t* overflow_flags, + PackedSparseAttentionIndexerParams params) { + using Sort = cub::BlockRadixSort; + __shared__ union { + uint32_t histogram[256]; + typename Sort::TempStorage sort; + } temp; + __shared__ uint64_t selected_keys[kBoundedTopKMax]; + __shared__ int32_t equal_warp_offsets[kThreads / kWarpSize + 1]; + __shared__ uint32_t prefix; + __shared__ int remaining; + __shared__ int gathered; + __shared__ int equal_seen; + + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + if (overflow_flags[batch] != 0) { + continue; + } + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = static_cast(visible_64 < 0 ? 0 : visible_64); + const int selected = min(params.block_topk, block_count); + if (selected == 0) { + continue; + } + float* scores = block_scores + static_cast(token) * params.state_capacity; + + if (threadIdx.x == 0) { + prefix = 0; + remaining = selected; + } + __syncthreads(); + + for (int shift = 24; shift >= 0; shift -= 8) { + for (int bucket = threadIdx.x; bucket < 256; bucket += blockDim.x) { + temp.histogram[bucket] = 0; + } + __syncthreads(); + const uint32_t current_prefix = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint32_t score_key = static_cast(topk::PackStableSortKey(scores[index], 0) >> 32); + if (shift == 24 || (score_key >> (shift + 8)) == (current_prefix >> (shift + 8))) { + atomicAdd(&temp.histogram[(score_key >> shift) & 0xffu], 1u); + } + } + __syncthreads(); + if (threadIdx.x == 0) { + int rank = remaining; + for (int bucket = 255; bucket >= 0; --bucket) { + const int count = static_cast(temp.histogram[bucket]); + if (rank > count) { + rank -= count; + } else { + prefix |= static_cast(bucket) << shift; + remaining = rank; + break; + } + } + } + __syncthreads(); + } + + if (threadIdx.x == 0) { + gathered = 0; + } + __syncthreads(); + const uint32_t threshold = prefix; + for (int index = threadIdx.x; index < block_count; index += blockDim.x) { + const uint64_t key = topk::PackStableSortKey(scores[index], index); + if (static_cast(key >> 32) > threshold) { + const int slot = atomicAdd(&gathered, 1); + selected_keys[slot] = key; + } + } + __syncthreads(); + + if (threadIdx.x == 0) { + equal_seen = 0; + } + __syncthreads(); + for (int base = 0; base < block_count && equal_seen < remaining; base += blockDim.x) { + const int index = base + static_cast(threadIdx.x); + const bool equal = index < block_count && + static_cast(topk::PackStableSortKey(scores[index], 0) >> 32) == threshold; + const unsigned int warp_mask = __ballot_sync(0xffffffffu, equal); + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane_rank = __popc(warp_mask & (lane == 0 ? 0u : (1u << lane) - 1u)); + if (lane == 0) { + equal_warp_offsets[warp] = __popc(warp_mask); + } + __syncthreads(); + if (threadIdx.x == 0) { + int tile_total = 0; + for (int i = 0; i < kThreads / kWarpSize; ++i) { + const int warp_count = equal_warp_offsets[i]; + equal_warp_offsets[i] = tile_total; + tile_total += warp_count; + } + equal_warp_offsets[kThreads / kWarpSize] = tile_total; + } + __syncthreads(); + const int equal_rank = equal_seen + equal_warp_offsets[warp] + lane_rank; + if (equal && equal_rank < remaining) { + selected_keys[gathered + equal_rank] = topk::PackStableSortKey(scores[index], index); + } + __syncthreads(); + if (threadIdx.x == 0) { + equal_seen += equal_warp_offsets[kThreads / kWarpSize]; + } + __syncthreads(); + } + + uint64_t keys[kBoundedTopKItemsPerThread]; +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + keys[item] = rank < selected ? selected_keys[rank] : topk::kPaddingSortKey; + } + Sort(temp.sort).SortDescending(keys); + int32_t* topk_row = reinterpret_cast(scores); +#pragma unroll + for (int item = 0; item < kBoundedTopKItemsPerThread; ++item) { + const int rank = threadIdx.x * kBoundedTopKItemsPerThread + item; + if (rank < selected) { + topk_row[rank] = topk::UnpackStableSortIndex(keys[item]); + } + } + __syncthreads(); + } +} + // --------------------------------------------------------------------------------------------- // policy_mode = "csa" // --------------------------------------------------------------------------------------------- @@ -744,8 +1101,12 @@ __global__ void CsaSelectKernel(const float* scores, const int32_t* cumulative_s size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { const size_t rows = static_cast(params.total_tokens); - return rows * params.num_heads * params.head_size + - rows * static_cast(std::max(params.state_capacity, 1)); + const size_t query_count = rows * params.num_heads * params.head_size; + if (UseHierarchicalQsaTopK(params)) { + const size_t key_count = rows * GetHierarchicalTileCount(params) * kHierarchicalTileBlocks; + return query_count + 4 * key_count; + } + return query_count + rows * static_cast(std::max(params.state_capacity, 1)); } size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params) { @@ -796,8 +1157,10 @@ Status LaunchQsaPackedSparseAttentionIndexer( } float* query_rotated = float_workspace; - float* block_scores = + float* selection_workspace = query_rotated + static_cast(params.total_tokens) * params.num_heads * params.head_size; + const bool use_hierarchical_topk = UseHierarchicalQsaTopK(params); + float* block_scores = use_hierarchical_topk ? nullptr : selection_workspace; const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); @@ -805,7 +1168,30 @@ Status LaunchQsaPackedSparseAttentionIndexer( query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, params); - if (params.state_capacity > 0) { + const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); + if (use_hierarchical_topk) { + const int tile_count = GetHierarchicalTileCount(params); + const int key_stride = tile_count * kHierarchicalTileBlocks; + const size_t key_count = static_cast(params.total_tokens) * key_stride; + uint64_t* merge_input = reinterpret_cast(selection_workspace); + uint64_t* merge_output = merge_input + key_count; + const int64_t tile_work = static_cast(params.total_tokens) * tile_count; + QsaScoreTileTopKKernel<<(std::min(tile_work, kSaiMaxGridDimX)), + kHierarchicalScoreThreads, 0, stream>>>( + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, overflow_flags, merge_input, tile_count, params); + for (int list_width = kHierarchicalTileBlocks; list_width < key_stride; list_width *= 2) { + const int pairs_per_row = (key_stride + 2 * list_width - 1) / (2 * list_width); + const int64_t merge_work = static_cast(params.total_tokens) * pairs_per_row; + QsaMergeTileTopKKernel<<(std::min(merge_work, kSaiMaxGridDimX)), + kHierarchicalMergeThreads, 0, stream>>>( + merge_input, merge_output, key_stride, list_width, params.total_tokens); + std::swap(merge_input, merge_output); + } + QsaEmitHierarchicalTopKKernel<<>>( + merge_input, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, + overflow_flags, key_stride, selected_indices, selected_counts, params); + } else if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); QsaBlockScoreKernel<<>>( @@ -813,13 +1199,22 @@ Status LaunchQsaPackedSparseAttentionIndexer( present_state_lengths, block_scores, params); } - const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); + const bool use_bounded_topk = + !use_hierarchical_topk && params.block_topk > kSaiFastTopKMax && params.block_topk <= kBoundedTopKMax; + int32_t* topk_indices = use_bounded_topk ? reinterpret_cast(block_scores) : nullptr; + if (use_bounded_topk) { + QsaPartialTopKKernel<<>>( + block_scores, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, + overflow_flags, params); + } const int topk_shared_entries = - params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; - QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( - block_scores, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, - overflow_flags, selected_indices, selected_counts, params); + !use_bounded_topk && params.block_topk <= kSaiFastTopKMax ? kThreads * params.block_topk : kThreads; + if (!use_hierarchical_topk) { + QsaSelectKernel<<(topk_shared_entries) * (sizeof(float) + sizeof(int)), stream>>>( + block_scores, topk_indices, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, overflow_flags, selected_indices, selected_counts, params); + } return CUDA_CALL(cudaGetLastError()); } diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index c455ee1b64a44..fdc69e6b033f4 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -814,6 +814,41 @@ TEST(PackedSparseAttentionIndexerTest, QsaStateCapacityOverflowIsSafe) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); } +TEST(PackedSparseAttentionIndexerTest, QsaBoundedTopKMaximum) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {1040}; + problem.compress_ratio = 2; + problem.token_budget = 1024; + problem.state_capacity = 520; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); +} + +TEST(PackedSparseAttentionIndexerTest, QsaHierarchicalTopK) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {8204}; + problem.head_size = 128; + problem.num_heads = 4; + problem.rotary_width = 64; + problem.compress_ratio = 4; + problem.token_budget = 2048; + problem.state_capacity = 2051; + problem = MakeQsaPackedProblem(std::move(problem)); + std::fill(problem.query.begin(), problem.query.end(), 1.0f); + std::fill(problem.query_norm_weight.begin(), problem.query_norm_weight.end(), 1.0f); + std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); + std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); + for (int block = 0; block < problem.state_capacity; ++block) { + const float value = static_cast(block + 1) / problem.state_capacity; + std::fill_n(problem.past_key_state.begin() + static_cast(block) * problem.head_size, + problem.head_size, value); + } + RunQsaPackedTest(1.0e-5f, std::move(problem)); +} + #ifdef USE_WEBGPU TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu); From e675bfe1fa3e21eee2f7d88cfce88f9f9e3278e9 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sun, 27 Sep 2026 23:53:57 +0000 Subject: [PATCH 064/102] Support packed QK indexer inputs --- .../packed_sparse_attention_indexer.md | 13 ++--- .../sparse/packed_sparse_attention_indexer.cc | 52 +++++++++++------- .../packed_sparse_attention_indexer_impl.cu | 18 ++++--- .../packed_sparse_attention_indexer_impl.h | 2 + .../bert/packed_sparse_attention_indexer.cc | 24 ++++++--- .../bert/packed_sparse_attention_indexer.h | 3 ++ .../core/graph/contrib_ops/bert_defs.cc | 24 ++++++--- .../python/tools/symbolic_shape_infer.py | 8 +-- ...packed_sparse_attention_indexer_op_test.cc | 54 ++++++++++++++++--- ...untime_test_python_symbolic_shape_infer.py | 11 ++-- 10 files changed, 147 insertions(+), 62 deletions(-) diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md index 04581c38d3eb0..9e546413f87ab 100644 --- a/docs/contrib_ops/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -59,8 +59,8 @@ later slot keeps its fixed index. | # | Name | Shape | Type | Policy | |---|---|---|---|---| -| 0 | `query` | `(total_tokens, num_heads*head_size)` | T | both | -| 1 | `key` | `(total_tokens, head_size)` qsa / `(total_tokens, 2*head_size)` csa | T | both | +| 0 | `query` | `(total_tokens, num_heads*head_size)`; when qsa omits `key`, `(total_tokens, (num_heads+1)*head_size)` containing row-wise `[query \| key]` | T | both | +| 1 | `key` | `(total_tokens, head_size)` qsa / `(total_tokens, 2*head_size)` csa; optional for qsa when packed into `query` | T | both | | 2 | `query_norm_weight` | `(head_size)` | T | both | | 3 | `key_norm_weight` | `(head_size)` | T | both | | 4 | `cos_cache` | `(max_position, rotary_width)` or `(batch_size, max_position, rotary_width)` | T | both | @@ -118,10 +118,11 @@ Both policies read and write the *same four* state slots — there is no separat `CsaWindowPlan`/`TryComputeCsaWindowPlan`). State never grows. `present_*` always has exactly the same shape as `past_*`; only the *contents* -change. Input/output aliasing is supported. CUDA avoids unsafe buffer aliases; WebGPU omits an -aliased `past_*` read-only binding and reads the prior contents through the matching read-write -`present_*` binding. Each request is handled by one invocation, and buffer compaction reads entries -at or above the destination index before overwriting them. +change. CUDA supports aliasing `past_key_state` with `present_key_state` and `past_state_lengths` +with `present_state_lengths`; it skips the baseline key-state copy when those buffers alias. +WebGPU supports aliasing every corresponding `past_*` / `present_*` pair and reads prior contents +through the matching read-write `present_*` binding. Each request is handled by one invocation, +and buffer compaction reads entries at or above the destination index before overwriting them. **State overflow.** If a call would close more blocks/windows than `state_capacity - old_entry_count` allows, that request's step is rejected as a deterministic diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 6e01ecf2c6d6a..0d6d2f5b325c4 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -19,19 +19,19 @@ namespace cuda { using namespace onnxruntime::cuda; namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; -#define REGISTER_KERNEL_TYPED(T) \ - ONNX_OPERATOR_TYPED_KERNEL_EX( \ - PackedSparseAttentionIndexer, \ - kMSDomain, \ - 1, \ - T, \ - kCudaExecutionProvider, \ - (*KernelDefBuilder::Create()) \ - .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ - .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ - .MayInplace(11, 2) \ - .MayInplace(14, 5), \ +#define REGISTER_KERNEL_TYPED(T) \ + ONNX_OPERATOR_TYPED_KERNEL_EX( \ + PackedSparseAttentionIndexer, \ + kMSDomain, \ + 1, \ + T, \ + kCudaExecutionProvider, \ + (*KernelDefBuilder::Create()) \ + .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ + .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ + .MayInplace(psai::kPastKeyState, psai::kPresentKeyState) \ + .MayInplace(psai::kPastStateLengths, psai::kPresentStateLengths), \ PackedSparseAttentionIndexer); REGISTER_KERNEL_TYPED(float) @@ -189,13 +189,16 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const int64_t total_tokens = query_shape[0]; ORT_RETURN_IF(query_norm_weight == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); const auto& query_norm_shape = query_norm_weight->Shape(); - ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1, + ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0, "PackedSparseAttentionIndexer: query_norm_weight must have shape (head_size), got ", query_norm_shape.ToString()); const int64_t head_size = query_norm_shape[0]; - ORT_RETURN_IF(head_size <= 0 || query_shape[1] % head_size != 0, - "PackedSparseAttentionIndexer: query width must be divisible by head_size"); - const int64_t num_heads = query_shape[1] / head_size; + const bool has_separate_key = key != nullptr; + const int64_t query_width = query_shape[1] - (has_separate_key ? 0 : head_size); + ORT_RETURN_IF(query_width <= 0 || query_width % head_size != 0, + "PackedSparseAttentionIndexer: query width must contain a positive multiple of head_size", + has_separate_key ? "" : " followed by one packed key head"); + const int64_t num_heads = query_width / head_size; ORT_RETURN_IF_ERROR(CheckIntDimension("total_tokens", total_tokens)); ORT_RETURN_IF_ERROR(CheckIntDimension("num_heads", num_heads, false)); ORT_RETURN_IF_ERROR(CheckIntDimension("head_size", head_size, false)); @@ -213,7 +216,9 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); - ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + if (has_separate_key) { + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + } ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); if (position_ids != nullptr) { @@ -267,6 +272,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con params.total_tokens = static_cast(total_tokens); params.num_heads = static_cast(num_heads); params.head_size = static_cast(head_size); + params.query_row_stride = static_cast(query_shape[1]); + params.key_row_stride = static_cast(has_separate_key ? head_size : query_shape[1]); params.rotary_width = static_cast(rotary.rotary_width); params.max_rotary_length = static_cast(rotary.max_rotary_length); params.cos_cache_batched = rotary.batched; @@ -283,10 +290,14 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con auto overflow_flags = GetScratchBuffer(static_cast(std::max(batch_size, 1)), GetComputeStream(context)); + const CudaT* query_data = reinterpret_cast(query->Data()); + const CudaT* key_data = has_separate_key + ? reinterpret_cast(key->Data()) + : query_data + query_width; return LaunchQsaPackedSparseAttentionIndexer( Stream(context), params, - reinterpret_cast(query->Data()), - reinterpret_cast(key->Data()), + query_data, + key_data, reinterpret_cast(query_norm_weight->Data()), reinterpret_cast(key_norm_weight->Data()), reinterpret_cast(cos_cache->Data()), @@ -418,6 +429,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con params.total_tokens = static_cast(total_tokens); params.num_heads = static_cast(num_heads); params.head_size = static_cast(head_size); + params.query_row_stride = static_cast(query_shape[1]); params.rotary_width = static_cast(rotary.rotary_width); params.max_rotary_length = static_cast(rotary.max_rotary_length); params.cos_cache_batched = rotary.batched; diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 3b048ad5fb9b4..fb14438b05d14 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -181,7 +181,7 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con params.head_size + d]) : to_float(key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * - params.head_size + + params.key_row_stride + d]); } pooled[d] = sum / static_cast(params.compress_ratio); @@ -233,7 +233,9 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con params.head_size + d]) : to_float( - key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * params.head_size + d]); + key[(static_cast(req_start) + (virtual_pos - old_buf_len)) * + params.key_row_stride + + d]); present_kv_buffer[out_base + d] = from_float(value); } } @@ -258,10 +260,11 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) { const int token = static_cast(row / params.num_heads); const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); - const int64_t base = row * params.head_size; + const int64_t input_base = static_cast(token) * params.query_row_stride + + static_cast(row % params.num_heads) * params.head_size; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - shared[d] = to_float(query[base + d]); + shared[d] = to_float(query[input_base + d]); } __syncthreads(); @@ -288,10 +291,9 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig const T* sin_row = sin_cache + cache_offset; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { - query_rotated[base + d] = kUseLeadingRope - ? SaiLeadingRope(shared, params.rotary_width, cos_row, sin_row, d) - : SaiTrailingRope(shared, params.head_size, params.rotary_width, cos_row, - sin_row, d); + query_rotated[row * params.head_size + d] = + kUseLeadingRope ? SaiLeadingRope(shared, params.rotary_width, cos_row, sin_row, d) + : SaiTrailingRope(shared, params.head_size, params.rotary_width, cos_row, sin_row, d); } __syncthreads(); } diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index 9d92076c2ff51..caf3d55647d35 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -20,6 +20,8 @@ struct PackedSparseAttentionIndexerParams { int total_tokens = 0; int num_heads = 0; int head_size = 0; + int query_row_stride = 0; + int key_row_stride = 0; int rotary_width = 0; // cos_cache.shape[-1] int max_rotary_length = 0; // cos_cache.shape[-2] bool cos_cache_batched = false; // cos_cache rank: 3 = [batch, pos, rot], 2 = [pos, rot] diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 461cc03666525..7f7b47ad0cf76 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -140,7 +140,8 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe << " let idx = (b * uniforms.buffer_capacity + u32(virtual_pos)) * uniforms.head_size + d;\n" << " return f32(" << kv_buffer_history.GetByOffset("idx") << ");\n" << " }\n" - << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * uniforms.head_size + d;\n" + << " let idx2 = u32(req_start + virtual_pos - old_buf_len) * uniforms.key_row_stride + " + "uniforms.key_offset + d;\n" << " return f32(" << key.GetByOffset("idx2") << ");\n" << "}\n" << "fn pooled(b: u32, k: i32, old_buf_len: i32, req_start: i32, d: u32) -> f32 {\n" @@ -290,7 +291,7 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe } shader.AdditionalImplementation() << "fn normalized_query_value(token: u32, head: u32, d: u32) -> f32 {\n" - << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" + << " let base = token * uniforms.query_row_stride + head * uniforms.head_size;\n" << " var square_sum = 0.0;\n" << " for (var k = 0u; k < uniforms.head_size; k++) {\n" << " let value = f32(" << query.GetByOffset("base + k") << ");\n" @@ -301,7 +302,7 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << query_norm.GetByOffset("d") << ");\n" << "}\n" << "fn query_value(token: u32, head: u32, d: u32, position: i32, b: u32) -> f32 {\n" - << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" + << " let base = token * uniforms.query_row_stride + head * uniforms.head_size;\n" << " var value = normalized_query_value(token, head, d);\n" << " if (d >= uniforms.rotary_width) { return value; }\n" << " let half = uniforms.rotary_width / 2u;\n" @@ -781,10 +782,13 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF(query_norm == nullptr, "PackedSparseAttentionIndexer: query_norm_weight is required"); const auto& query_norm_shape = query_norm->Shape(); ORT_RETURN_IF_NOT(query_norm_shape.NumDimensions() == 1 && query_norm_shape[0] > 0 && - query_shape[1] % query_norm_shape[0] == 0, + (query_shape[1] - (key != nullptr ? 0 : query_norm_shape[0])) > 0 && + (query_shape[1] - (key != nullptr ? 0 : query_norm_shape[0])) % query_norm_shape[0] == 0, "PackedSparseAttentionIndexer: invalid flattened query dimensions"); const int64_t head_size = query_norm_shape[0]; - const int64_t num_heads = query_shape[1] / head_size; + const bool has_separate_key = key != nullptr; + const int64_t query_width = query_shape[1] - (has_separate_key ? 0 : head_size); + const int64_t num_heads = query_width / head_size; ORT_RETURN_IF_NOT(num_heads > 0 && head_size > 0, "PackedSparseAttentionIndexer: invalid query dimensions"); ORT_RETURN_IF(cu_seqlens == nullptr, "PackedSparseAttentionIndexer: cumulative_sequence_lengths is required"); @@ -796,7 +800,9 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_seqlens, "past_sequence_lengths", {batch_size})); - ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + if (has_separate_key) { + ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); + } ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); if (position_ids != nullptr) { @@ -878,10 +884,11 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont } if (batch_size > 0) { + const Tensor* key_source = has_separate_key ? key : query; PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; update.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) - .AddInputs({{key, ProgramTensorMetadataDependency::Type}, + .AddInputs({{key_source, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, @@ -893,6 +900,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont .SetDispatchGroupSize(ToUint32(batch_size)) .AddUniformVariables({{ToUint32(batch_size)}, {ToUint32(total_tokens)}, + {ToUint32(has_separate_key ? head_size : query_shape[1])}, + {ToUint32(has_separate_key ? 0 : query_width)}, {ToUint32(compress_ratio_)}, {ToUint32(state_capacity)}, {ToUint32(buffer_capacity)}, @@ -928,6 +937,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {ToUint32(batch_size)}, {ToUint32(num_heads)}, {ToUint32(head_size)}, + {ToUint32(query_shape[1])}, {ToUint32(rotary.rotary_width)}, {ToUint32(rotary.max_rotary_length)}, {ToUint32(compress_ratio_)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index 2607a1492e48f..bb9ceb3ca8eba 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -39,6 +39,8 @@ class PackedSparseAttentionIndexerQsaUpdateProgram final WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, {"total_tokens", ProgramUniformVariableDataType::Uint32}, + {"key_row_stride", ProgramUniformVariableDataType::Uint32}, + {"key_offset", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"state_capacity", ProgramUniformVariableDataType::Uint32}, {"buffer_capacity", ProgramUniformVariableDataType::Uint32}, @@ -67,6 +69,7 @@ class PackedSparseAttentionIndexerQsaSelectProgram final {"batch_size", 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/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index f71844e4417c7..9863966809689 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2447,13 +2447,17 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference // Strict policy input validation: every fixed slot required by every policy must be provided; // csa-only slots must be provided iff policy_mode is 'csa'; position_ids is optional for 'qsa' // and required for 'csa'. - for (int index : {psai::kQuery, psai::kKey, psai::kKeyNormWeight, psai::kCosCache, psai::kSinCache, + for (int index : {psai::kQuery, psai::kQueryNormWeight, psai::kKeyNormWeight, psai::kCosCache, psai::kSinCache, psai::kCumulativeSequenceLengths, psai::kPastSequenceLengths, psai::kPastKeyState, psai::kPastKvBuffer, psai::kPastStateLengths}) { if (!PackedSparseAttentionIndexerHasInput(ctx, index)) { fail_shape_inference("PackedSparseAttentionIndexer: input ", index, " is required for every policy_mode"); } } + const bool has_key = PackedSparseAttentionIndexerHasInput(ctx, psai::kKey); + if (!is_qsa && !has_key) { + fail_shape_inference("PackedSparseAttentionIndexer: key is required for policy_mode 'csa'"); + } for (int index : {psai::kGate, psai::kPositionBias, psai::kHeadWeights, psai::kPastGateBuffer}) { if (PackedSparseAttentionIndexerHasInput(ctx, index) == is_qsa) { fail_shape_inference("PackedSparseAttentionIndexer: input ", index, @@ -2642,8 +2646,12 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (query_shape != nullptr) { const auto& total_tokens_dim = query_shape->dim(0); const auto& query_width_dim = query_shape->dim(1); - if (query_width_dim.has_dim_value() && query_width_dim.dim_value() <= 0) { - fail_shape_inference("PackedSparseAttentionIndexer: query width must be > 0, got ", + const int64_t packed_key_heads = is_qsa && !has_key ? 1 : 0; + if (query_width_dim.has_dim_value() && query_norm_shape != nullptr && + query_norm_shape->dim(0).has_dim_value() && + query_width_dim.dim_value() <= packed_key_heads * query_norm_shape->dim(0).dim_value()) { + fail_shape_inference("PackedSparseAttentionIndexer: query width must contain at least one query head", + has_key ? "" : " followed by one packed key head", ", got ", query_width_dim.dim_value()); } if (query_width_dim.has_dim_value() && query_norm_shape != nullptr && @@ -2785,14 +2793,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(0, "query", "Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, " - "logical reshape, and rotary embedding.", + "logical reshape, and rotary embedding. For policy_mode 'qsa', key may be omitted and query then " + "contains row-wise concatenated query and key projections with shape " + "(total_tokens, (num_heads + 1) * head_size).", "T") .Input(1, "key", "Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for " "policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first " - "head_size channels are the Ca series and the last head_size channels the Cb series.", - "T") + "head_size channels are the Ca series and the last head_size channels the Cb series. May be " + "omitted for policy_mode 'qsa' when query contains the packed query/key projection.", + "T", + OpSchema::Optional) .Input(2, "query_norm_weight", "Effective RMSNorm multiplier of the queries, with shape (head_size).", diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index e3aec0594ed39..7e2b1cf667b7b 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -2753,11 +2753,11 @@ def copy_state_output(output_index, input_index, dtype): helper.make_tensor_value_info(node.output[output_index], dtype, get_shape_from_sympy_shape(shape)) ) - copy_state_output(2, 11, output_dtype) # present_key_state <- past_key_state - copy_state_output(3, 12, output_dtype) # present_kv_buffer <- past_kv_buffer + copy_state_output(2, 12, output_dtype) # present_key_state <- past_key_state + copy_state_output(3, 13, output_dtype) # present_kv_buffer <- past_kv_buffer if policy_mode != "qsa": - copy_state_output(4, 13, output_dtype) # present_gate_buffer <- past_gate_buffer - copy_state_output(5, 14, onnx.TensorProto.INT32) # present_state_lengths <- past_state_lengths + copy_state_output(4, 14, output_dtype) # present_gate_buffer <- past_gate_buffer + copy_state_output(5, 15, onnx.TensorProto.INT32) # present_state_lengths <- past_state_lengths def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index fdc69e6b033f4..e9da79e57cc82 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -96,6 +96,7 @@ struct GraphOptions { int64_t key_total_tokens = -1; int64_t kv_buffer_capacity = -1; int64_t gate_buffer_width = -1; + bool packed_qk = false; bool add_index_topk = false; bool add_csa_inputs = false; bool add_position_ids = false; @@ -115,9 +116,12 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { std::vector inputs{ builder.MakeInput( - std::vector{options.total_tokens, options.num_heads * options.head_size}), - builder.MakeInput( - std::vector{options.key_total_tokens >= 0 ? options.key_total_tokens : options.total_tokens, width}), + std::vector{options.total_tokens, + (options.num_heads + (options.packed_qk ? 1 : 0)) * options.head_size}), + options.packed_qk + ? &empty + : builder.MakeInput(std::vector{ + options.key_total_tokens >= 0 ? options.key_total_tokens : options.total_tokens, width}), builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{64, options.rotary_width}), @@ -192,6 +196,19 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaInfersFixedCapacityAndSt ONNX_NAMESPACE::TensorProto_DataType_INT32, {options.batch_size, 2}); } +TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaAcceptsPackedQueryKey) { + GraphOptions options; + options.packed_qk = true; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + + const Node& node = *model->MainGraph().Nodes().begin(); + EXPECT_FALSE(node.InputDefs()[psai::kKey]->Exists()); + ExpectShape(model->MainGraph(), node.OutputDefs()[psai::kPresentKeyState]->Name(), + ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.state_capacity, options.head_size}); +} + TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndState) { GraphOptions options; options.policy_mode = psai::kPolicyModeCsa; @@ -276,7 +293,7 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsZeroNumHeads) { GraphOptions options; options.num_heads = 0; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "query width must be > 0"); + "query width must contain at least one query head"); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsGenericBufferCapacityOverflow) { @@ -693,7 +710,8 @@ QsaPackedProblem MakeQsaPackedProblem(QsaPackedProblem problem = {}) { template void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedProblem(), - ProviderKind provider_kind = ProviderKind::Cuda, QsaPackedResult* actual = nullptr) { + ProviderKind provider_kind = ProviderKind::Cuda, QsaPackedResult* actual = nullptr, + bool packed_qk = false) { auto provider = CreateProvider(provider_kind); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") @@ -725,8 +743,22 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP if (problem.scale.has_value()) { test.AddAttribute("scale", *problem.scale); } - test.AddInput("query", {total_tokens, problem.num_heads * head_size}, ToElementType(problem.query)); - test.AddInput("key", {total_tokens, head_size}, ToElementType(problem.key)); + if (packed_qk) { + const int64_t query_width = problem.num_heads * head_size; + const int64_t packed_width = query_width + head_size; + std::vector query_key(static_cast(total_tokens * packed_width)); + for (int64_t token = 0; token < total_tokens; ++token) { + std::copy_n(problem.query.begin() + token * query_width, query_width, + query_key.begin() + token * packed_width); + std::copy_n(problem.key.begin() + token * head_size, head_size, + query_key.begin() + token * packed_width + query_width); + } + test.AddInput("query_key", {total_tokens, packed_width}, ToElementType(query_key)); + test.AddOptionalInputEdge(); + } else { + test.AddInput("query", {total_tokens, problem.num_heads * head_size}, ToElementType(problem.query)); + test.AddInput("key", {total_tokens, head_size}, ToElementType(problem.key)); + } test.AddInput("query_norm_weight", {head_size}, ToElementType(problem.query_norm_weight)); test.AddInput("key_norm_weight", {head_size}, ToElementType(problem.key_norm_weight)); test.AddInput("cos_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.cos_cache)); @@ -770,6 +802,10 @@ TEST(PackedSparseAttentionIndexerTest, QsaFloat16) { RunQsaPackedTest TEST(PackedSparseAttentionIndexerTest, QsaBFloat16) { RunQsaPackedTest(2.0e-2f); } +TEST(PackedSparseAttentionIndexerTest, QsaPackedQueryKey) { + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::Cuda, nullptr, true); +} + TEST(PackedSparseAttentionIndexerTest, QsaPrefillThenDecodeIndependentState) { if (DefaultCudaExecutionProvider() == nullptr) { GTEST_SKIP() << "CUDA execution provider is not available"; @@ -858,6 +894,10 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaPackedTest(2.0e-3f, MakeQsaPackedProblem(), ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaPackedQueryKey) { + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu, nullptr, true); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateCapacityOverflowIsRejected) { QsaPackedProblem problem; problem.batch_size = 1; diff --git a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py index d1e936529a0d9..eb419c8fd2170 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -354,8 +354,9 @@ def test_packed_sparse_attention_indexer_qsa(self): node = helper.make_node( "PackedSparseAttentionIndexer", [ - "query", - "key", + "query_key", + "", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -385,8 +386,8 @@ def test_packed_sparse_attention_indexer_qsa(self): token_budget=8, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["total_tokens", 2, 8]), - helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["total_tokens", 8]), + helper.make_tensor_value_info("query_key", TensorProto.FLOAT16, ["total_tokens", 24]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, [64, 8]), helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT16, [64, 8]), @@ -415,6 +416,7 @@ def test_packed_sparse_attention_indexer_csa(self): [ "query", "key", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -446,6 +448,7 @@ def test_packed_sparse_attention_indexer_csa(self): inputs = [ helper.make_tensor_value_info("query", TensorProto.FLOAT, ["total_tokens", 2, 8]), helper.make_tensor_value_info("key", TensorProto.FLOAT, ["total_tokens", 16]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT, [8]), 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]), From eecf5ffa0fa657adfcf3cae032eb8aee13d35d24 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Mon, 28 Sep 2026 07:46:00 +0000 Subject: [PATCH 065/102] Optimize packed QSA prefill scoring --- .../packed_sparse_attention_indexer_impl.cu | 71 +++++++++++++++++++ ...packed_sparse_attention_indexer_op_test.cc | 19 +++++ 2 files changed, 90 insertions(+) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index fb14438b05d14..1e5673cf188c8 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -70,6 +70,12 @@ bool UseHierarchicalQsaTopK(const PackedSparseAttentionIndexerParams& params) { params.block_topk <= kBoundedTopKMax; } +bool UseRaggedQsaPrefill(const PackedSparseAttentionIndexerParams& params) { + return params.total_tokens > kDistributedTopKMaxRows && + params.head_size == kQwenHeadSize && params.compress_ratio == kQwenCompressRatio && + params.num_heads == kQwenNumHeads; +} + int GetHierarchicalTileCount(const PackedSparseAttentionIndexerParams& params) { return (params.state_capacity + kHierarchicalTileBlocks - 1) / kHierarchicalTileBlocks; } @@ -354,6 +360,66 @@ __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* que } } +// One CTA per packed query row. Eight state blocks are scored concurrently, with one warp per +// (state block, query head). Unlike the generic rectangular launch, each CTA stops at its row's +// live causal block count and never launches or writes work for unused fixed-capacity slots. +template +__global__ void QsaRaggedPrefillScoreKernel(const T* present_key_state, const float* query_rotated, + const int32_t* cumulative_sequence_lengths, + const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* present_state_lengths, const int32_t* overflow_flags, + float* block_scores, PackedSparseAttentionIndexerParams params) { + __shared__ float head_scores[kHierarchicalTileBlocks][kQwenNumHeads]; + + const int warp = static_cast(threadIdx.x) / kWarpSize; + const int lane = static_cast(threadIdx.x) % kWarpSize; + const int block_slot = warp / kQwenNumHeads; + const int head = warp % kQwenNumHeads; + for (int token = static_cast(blockIdx.x); token < params.total_tokens; token += static_cast(gridDim.x)) { + const int batch = PackedBatchOfToken(cumulative_sequence_lengths, params.batch_size, token); + const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; + const int64_t abs_position = + params.has_position_ids + ? position_ids[token] + : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + const int64_t causal_count = SaiCausalThreshold(abs_position, kQwenCompressRatio); + const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); + const int block_count = overflow_flags[batch] == 0 ? static_cast(visible_64 < 0 ? 0 : visible_64) : 0; + + for (int tile_start = 0; tile_start < block_count; tile_start += kHierarchicalTileBlocks) { + const int block_index = tile_start + block_slot; + float score = 0.0f; + if (block_index < block_count) { + const int d = lane * 4; + const int64_t query_base = + (static_cast(token) * kQwenNumHeads + head) * kQwenHeadSize; + const int64_t key_base = + (static_cast(batch) * params.state_capacity + block_index) * kQwenHeadSize; + const float4 query_value = LoadQsaVector4(query_rotated + query_base + d); + const float4 key_value = LoadQsaVector4(present_key_state + key_base + d); + score = query_value.x * key_value.x + query_value.y * key_value.y + + query_value.z * key_value.z + query_value.w * key_value.w; + score = topk::WarpReduceSum(score); + } + if (lane == 0) { + head_scores[block_slot][head] = fmaxf(score, 0.0f); + } + __syncthreads(); + if (threadIdx.x < kHierarchicalTileBlocks && tile_start + threadIdx.x < block_count) { + float total_score = 0.0f; +#pragma unroll + for (int query_head = 0; query_head < kQwenNumHeads; ++query_head) { + total_score += head_scores[threadIdx.x][query_head]; + } + const float scaled_score = total_score * params.scale; + block_scores[static_cast(token) * params.state_capacity + tile_start + threadIdx.x] = + scaled_score == 0.0f ? 0.0f : scaled_score; + } + __syncthreads(); + } + } +} + template __global__ void QsaScoreTileTopKKernel(const T* present_key_state, const float* query_rotated, const int32_t* cumulative_sequence_lengths, @@ -1162,6 +1228,7 @@ Status LaunchQsaPackedSparseAttentionIndexer( float* selection_workspace = query_rotated + static_cast(params.total_tokens) * params.num_heads * params.head_size; const bool use_hierarchical_topk = UseHierarchicalQsaTopK(params); + const bool use_ragged_prefill = UseRaggedQsaPrefill(params); float* block_scores = use_hierarchical_topk ? nullptr : selection_workspace; const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; @@ -1193,6 +1260,10 @@ Status LaunchQsaPackedSparseAttentionIndexer( QsaEmitHierarchicalTopKKernel<<>>( merge_input, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, overflow_flags, key_stride, selected_indices, selected_counts, params); + } else if (use_ragged_prefill) { + QsaRaggedPrefillScoreKernel<<>>( + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_state_lengths, overflow_flags, block_scores, params); } else if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index e9da79e57cc82..9f01b89da3fc3 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -885,6 +885,25 @@ TEST(PackedSparseAttentionIndexerTest, QsaHierarchicalTopK) { RunQsaPackedTest(1.0e-5f, std::move(problem)); } +TEST(PackedSparseAttentionIndexerTest, QsaRaggedPrefill) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 65}; + problem.past_sequence_lengths = {1024}; + problem.head_size = 128; + problem.num_heads = 4; + problem.rotary_width = 64; + problem.compress_ratio = 4; + problem.token_budget = 128; + problem.state_capacity = 512; + problem.max_position = 2048; + problem = MakeQsaPackedProblem(std::move(problem)); + std::fill(problem.query_norm_weight.begin(), problem.query_norm_weight.end(), 1.0f); + std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); + std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); + RunQsaPackedTest(1.0e-4f, std::move(problem)); +} + #ifdef USE_WEBGPU TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu); From 7ae32c1a259e5e4e0c15b44170e87d29484df6cc Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Mon, 28 Sep 2026 17:52:36 +0000 Subject: [PATCH 066/102] Capture packed indexer state updates --- .../packed_sparse_attention_indexer_common.h | 12 +- .../sparse/packed_sparse_attention_indexer.cc | 43 +++++++ .../sparse/packed_sparse_attention_indexer.h | 1 + .../packed_sparse_attention_indexer_impl.cu | 40 ++++-- .../packed_sparse_attention_indexer_impl.h | 4 + .../bert/packed_sparse_attention_indexer.cc | 111 ++++++++++++++++- .../bert/packed_sparse_attention_indexer.h | 11 +- .../core/graph/contrib_ops/bert_defs.cc | 68 ++++++++++- .../python/tools/symbolic_shape_infer.py | 14 +++ ...packed_sparse_attention_indexer_op_test.cc | 115 +++++++++++++++++- ...untime_test_python_symbolic_shape_infer.py | 8 ++ 11 files changed, 405 insertions(+), 22 deletions(-) diff --git a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h index 0d55ae4c5e5e5..b4ef0bc97664d 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h @@ -49,7 +49,9 @@ enum InputIndex : int { kPastKvBuffer = 13, // generic: [batch_size, 2 * compress_ratio - 1, width] kPastGateBuffer = 14, // csa only: same shape as past_kv_buffer kPastStateLengths = 15, // generic: [batch_size, 2], int32 - kInputCount = 16, + kStateUpdateCaptureCount = 16, // qsa only, optional: [batch_size], int32 + kStateUpdateActive = 17, // qsa only, optional: [1], int32 + kInputCount = 18, }; // Fixed output slots. present_gate_buffer is declared (with an empty name) but not produced for @@ -61,12 +63,14 @@ enum OutputIndex : int { kPresentKvBuffer = 3, // same shape as past_kv_buffer kPresentGateBuffer = 4, // csa only: same shape as past_gate_buffer kPresentStateLengths = 5, // [batch_size, 2], int32 - kOutputCount = 6, + kStateUpdate = 6, // qsa only, optional: [batch_size, state_update_capacity, head_size] + kOutputCount = 7, }; // Every PackedSparseAttentionIndexer node declares all 6 fixed outputs; present_gate_buffer is an -// empty-name optional output for policy_mode="qsa". -constexpr int kFixedOutputCount = kOutputCount; +// empty-name optional output for policy_mode="qsa". State update capture is an optional appended +// seventh output so existing nodes retain their output layout. +constexpr int kFixedOutputCount = kStateUpdate; // Column layout of past_state_lengths / present_state_lengths. enum StateLengthColumn : int { diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 0d6d2f5b325c4..81d4778f56b7c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -107,6 +107,10 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo "PackedSparseAttentionIndexer: state_capacity is required"); ORT_ENFORCE(state_capacity_ > 0 && state_capacity_ <= std::numeric_limits::max(), "PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", state_capacity_); + state_update_capacity_ = info.GetAttrOrDefault("state_update_capacity", 0); + ORT_ENFORCE(state_update_capacity_ >= 0 && state_update_capacity_ <= 8, + "PackedSparseAttentionIndexer: state_update_capacity must be in [0, 8], got ", + state_update_capacity_); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); @@ -126,6 +130,8 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo token_budget_, " compress_ratio=", compress_ratio_); index_topk_ = 0; } else { + ORT_ENFORCE(state_update_capacity_ == 0, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); ORT_ENFORCE(has_index_topk, "PackedSparseAttentionIndexer: index_topk is required when policy_mode is 'csa'"); ORT_ENFORCE(!has_token_budget, "PackedSparseAttentionIndexer: token_budget must not be set when policy_mode is 'csa'"); @@ -159,6 +165,15 @@ Status PackedSparseAttentionIndexer::ComputeInternal(OpKernelContext* context ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: input ", psai::kPastGateBuffer, gate_buffer_provided ? " must be omitted for policy_mode 'qsa'" : " is required for policy_mode 'csa'"); + if (!is_qsa) { + for (int index : {psai::kStateUpdateCaptureCount, psai::kStateUpdateActive}) { + ORT_RETURN_IF(index < context->InputCount() && context->Input(index) != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } + Tensor* state_update = context->Output(psai::kStateUpdate, TensorShape({0})); + ORT_RETURN_IF(state_update != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } return is_qsa ? ComputeQsa(context) : ComputeCsa(context); } @@ -179,6 +194,16 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const Tensor* past_key_state = context->Input(psai::kPastKeyState); const Tensor* past_kv_buffer = context->Input(psai::kPastKvBuffer); const Tensor* past_state_lengths = context->Input(psai::kPastStateLengths); + const Tensor* state_update_capture_count = + context->InputCount() > psai::kStateUpdateCaptureCount + ? context->Input(psai::kStateUpdateCaptureCount) + : nullptr; + const Tensor* state_update_active = + context->InputCount() > psai::kStateUpdateActive ? context->Input(psai::kStateUpdateActive) : nullptr; + + ORT_RETURN_IF(state_update_capacity_ > 0 && state_update_capture_count == nullptr, + "PackedSparseAttentionIndexer: state_update_capture_count is required when " + "state_update_capacity is positive"); ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); @@ -248,6 +273,12 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", {batch_size, psai::kStateLengthColumns})); + if (state_update_capture_count != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_capture_count, "state_update_capture_count", {batch_size})); + } + if (state_update_active != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_active, "state_update_active", {1})); + } const int64_t capacity = psai::SelectedCapacity(psai::Policy::kQsa, token_budget_, index_topk_, compress_ratio_); @@ -262,10 +293,18 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con context->Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, head_size})); Tensor* present_state_lengths = context->Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + Tensor* state_update = context->OutputCount() > psai::kStateUpdate + ? context->Output( + psai::kStateUpdate, TensorShape({batch_size, state_update_capacity_, head_size})) + : nullptr; ORT_RETURN_IF(selected_indices == nullptr || selected_counts == nullptr || present_key_state == nullptr || present_kv_buffer == nullptr || present_state_lengths == nullptr, "PackedSparseAttentionIndexer: policy_mode 'qsa' requires selected_indices, selected_counts, " "present_key_state, present_kv_buffer and present_state_lengths outputs"); + if (state_update != nullptr && state_update->SizeInBytes() > 0) { + CUDA_RETURN_IF_ERROR(cudaMemsetAsync(state_update->MutableDataRaw(), 0, state_update->SizeInBytes(), + Stream(context))); + } PackedSparseAttentionIndexerParams params; params.batch_size = static_cast(batch_size); @@ -285,6 +324,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con params.epsilon = epsilon_; params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.block_topk = static_cast(token_budget_ / compress_ratio_); + params.state_update_capacity = static_cast(state_update_capacity_); auto float_workspace = GetScratchBuffer(GetQsaPackedWorkspaceFloatCount(params), GetComputeStream(context)); auto overflow_flags = @@ -308,11 +348,14 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con reinterpret_cast(past_key_state->Data()), reinterpret_cast(past_kv_buffer->Data()), past_state_lengths->Data(), + state_update_capture_count != nullptr ? state_update_capture_count->Data() : nullptr, + state_update_active != nullptr ? state_update_active->Data() : nullptr, selected_indices->MutableData(), selected_counts->MutableData(), reinterpret_cast(present_key_state->MutableData()), reinterpret_cast(present_kv_buffer->MutableData()), present_state_lengths->MutableData(), + state_update != nullptr ? reinterpret_cast(state_update->MutableData()) : nullptr, float_workspace.get(), overflow_flags.get()); } diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h index c8f3ec19f2082..112be6e3f46af 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.h @@ -24,6 +24,7 @@ class PackedSparseAttentionIndexer final : public onnxruntime::cuda::CudaKernel packed_sparse_attention_indexer::Policy policy_; int64_t compress_ratio_; int64_t state_capacity_; + int64_t state_update_capacity_; int64_t token_budget_; int64_t index_topk_; float epsilon_; diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 1e5673cf188c8..3a47d4bd200ff 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -130,9 +130,10 @@ template __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const T* past_kv_buffer, - const int32_t* past_state_lengths, + const int32_t* past_state_lengths, const int32_t* state_update_capture_count, + const int32_t* state_update_active, T* present_key_state, T* present_kv_buffer, - int32_t* present_state_lengths, int32_t* overflow_flags, + int32_t* present_state_lengths, T* state_update, int32_t* overflow_flags, PackedSparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* pooled = shared; @@ -166,6 +167,12 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con const bool rejected = invalid_metadata || full_new_block_count > capacity_left; const int new_block_count = rejected ? 0 : full_new_block_count; const int new_buf_len = rejected ? old_buf_len : (pending % params.compress_ratio); + const bool capture_active = !rejected && state_update != nullptr && state_update_capture_count != nullptr && + (state_update_active == nullptr || state_update_active[0] != 0); + const int capture_count = capture_active + ? min(max(state_update_capture_count[b], 0), + min(req_len, params.state_update_capacity)) + : 0; // Barrier: every thread has now read past_state_lengths (identically) before any thread below // writes present_state_lengths, which keeps this correct even if the two tensors alias. @@ -177,6 +184,18 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con overflow_flags[b] = rejected ? 1 : 0; } + for (int t = static_cast(threadIdx.x); t < capture_count; t += static_cast(blockDim.x)) { + if ((old_buf_len + t + 1) % params.compress_ratio != 0) { + const int64_t capture_base = + (static_cast(b) * params.state_update_capacity + t) * params.head_size; + const int64_t key_base = (static_cast(req_start) + t) * params.key_row_stride; + for (int d = 0; d < params.head_size; ++d) { + state_update[capture_base + d] = key[key_base + d]; + } + } + } + __syncthreads(); + for (int k = 0; k < new_block_count; ++k) { for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { float sum = 0.0f; @@ -218,8 +237,14 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con __syncthreads(); const int64_t out_base = (static_cast(b) * params.state_capacity + entry) * params.head_size; + const int completion_token = (k + 1) * params.compress_ratio - old_buf_len - 1; + const int64_t capture_base = + (static_cast(b) * params.state_update_capacity + completion_token) * params.head_size; for (int d = static_cast(threadIdx.x); d < params.head_size; d += static_cast(blockDim.x)) { present_key_state[out_base + d] = from_float(rotated[d]); + if (completion_token < capture_count) { + state_update[capture_base + d] = from_float(rotated[d]); + } } __syncthreads(); } @@ -1188,9 +1213,10 @@ Status LaunchQsaPackedSparseAttentionIndexer( const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, const T* past_key_state, - const T* past_kv_buffer, const int32_t* past_state_lengths, int32_t* selected_indices, + const T* past_kv_buffer, const int32_t* past_state_lengths, const int32_t* state_update_capture_count, + const int32_t* state_update_active, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, - float* float_workspace, int32_t* overflow_flags) { + T* state_update, float* float_workspace, int32_t* overflow_flags) { if (params.batch_size > 0) { const int64_t key_state_elems = static_cast(params.batch_size) * params.state_capacity * params.head_size; @@ -1218,7 +1244,8 @@ Status LaunchQsaPackedSparseAttentionIndexer( const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); QsaUpdateStateKernel<<>>( key, key_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, past_kv_buffer, - past_state_lengths, present_key_state, present_kv_buffer, present_state_lengths, overflow_flags, params); + past_state_lengths, state_update_capture_count, state_update_active, present_key_state, present_kv_buffer, + present_state_lengths, state_update, overflow_flags, params); if (params.total_tokens == 0) { return CUDA_CALL(cudaGetLastError()); @@ -1372,8 +1399,7 @@ Status LaunchCsaPackedSparseAttentionIndexer( template Status LaunchQsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, const T*, const int32_t*, \ - int32_t*, \ - int32_t*, T*, T*, int32_t*, float*, int32_t*); \ + const int32_t*, const int32_t*, int32_t*, int32_t*, T*, T*, int32_t*, T*, float*, int32_t*); \ template Status LaunchCsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ const T*, const T*, const T*, const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, \ diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index caf3d55647d35..8e485b05addab 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -35,6 +35,7 @@ struct PackedSparseAttentionIndexerParams { // policy_mode = "qsa" int block_topk = 0; // token_budget / compress_ratio + int state_update_capacity = 0; // policy_mode = "csa" int index_topk = 0; @@ -68,11 +69,14 @@ Status LaunchQsaPackedSparseAttentionIndexer( const T* past_key_state, const T* past_kv_buffer, const int32_t* past_state_lengths, + const int32_t* state_update_capture_count, + const int32_t* state_update_active, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, + T* state_update, float* float_workspace, int32_t* overflow_flags); diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 7f7b47ad0cf76..2f98cf7356b77 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -125,12 +125,25 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); + const ShaderVariableHelper* capture_count = nullptr; + const ShaderVariableHelper* state_update_active = nullptr; + if (capture_state_update_) { + capture_count = &shader.AddInput("state_update_capture_count", ShaderUsage::UseUniform); + if (has_state_update_active_) { + state_update_active = &shader.AddInput("state_update_active", ShaderUsage::UseUniform); + } + } const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_kv_buffer = shader.AddOutput("present_kv_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); + const ShaderVariableHelper* state_update = nullptr; + if (capture_state_update_) { + state_update = &shader.AddOutput( + "state_update", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + } const ShaderVariableHelper& kv_buffer_history = present_kv_buffer; const ShaderVariableHelper& state_lengths_history = present_state_lengths; @@ -216,15 +229,54 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe << " let new_block_count = full_new_block_count;\n" << " let new_buf_len = pending % i32(uniforms.compress_ratio);\n" << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len + new_block_count") << "\n" - << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "new_buf_len") << "\n" + << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "new_buf_len") << "\n"; + if (capture_state_update_) { + shader.MainFunctionBody() + << " let capture_is_active = " + << (has_state_update_active_ ? state_update_active->GetByOffset("0") + " != 0" : "true") << ";\n" + << " let capture_tokens = select(0, min(max(" << capture_count->GetByOffset("b") + << ", 0), min(req_len, i32(uniforms.state_update_capacity))), capture_is_active);\n"; + } else { + shader.MainFunctionBody() << " let capture_tokens = 0;\n"; + } + shader.MainFunctionBody() << " for (var k = 0; k < new_block_count; k++) {\n" << " let entry = u32(old_key_len + k);\n" + << " let completion_token = (k + 1) * i32(uniforms.compress_ratio) - old_buf_len - 1;\n" << " for (var d = 0u; d < uniforms.head_size; d++) {\n" << " let value = rotated(b, k, old_buf_len, req_start, old_key_len, d);\n" << " " << present_key_state.SetByOffset("(b * uniforms.state_capacity + entry) * uniforms.head_size + d", "present_key_state_element_t(value)") - << "\n" + << "\n"; + if (capture_state_update_) { + shader.MainFunctionBody() + << " if (completion_token < capture_tokens) {\n" + << " " + << state_update->SetByOffset( + "(b * uniforms.state_update_capacity + u32(completion_token)) * uniforms.head_size + d", + "state_update_element_t(value)") + << "\n" + << " }\n"; + } + shader.MainFunctionBody() + << " }\n" + << " }\n" + << " for (var t = 0; t < capture_tokens; t++) {\n" + << " if ((old_buf_len + t + 1) % i32(uniforms.compress_ratio) != 0) {\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n"; + if (capture_state_update_) { + shader.MainFunctionBody() + << " let value = " + << key.GetByOffset("u32(req_start + t) * uniforms.key_row_stride + uniforms.key_offset + d") << ";\n" + << " " + << state_update->SetByOffset( + "(b * uniforms.state_update_capacity + u32(t)) * uniforms.head_size + d", + "state_update_element_t(value)") + << "\n"; + } + shader.MainFunctionBody() + << " }\n" << " }\n" << " }\n" << " for (var t = 0; t < new_buf_len; t++) {\n" @@ -720,6 +772,9 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& i "no greater than INT_MAX"); ORT_ENFORCE(info.GetAttr("state_capacity", &state_capacity_).IsOK() && state_capacity_ > 0, "PackedSparseAttentionIndexer: state_capacity must be a positive integer"); + state_update_capacity_ = info.GetAttrOrDefault("state_update_capacity", 0); + ORT_ENFORCE(state_update_capacity_ >= 0 && state_update_capacity_ <= 8, + "PackedSparseAttentionIndexer: state_update_capacity must be in [0, 8]"); const bool has_token_budget = info.GetAttr("token_budget", &token_budget_).IsOK(); const bool has_index_topk = info.GetAttr("index_topk", &index_topk_).IsOK(); @@ -732,6 +787,8 @@ PackedSparseAttentionIndexer::PackedSparseAttentionIndexer(const OpKernelInfo& i "PackedSparseAttentionIndexer: csa attributes must be omitted for qsa"); index_topk_ = 0; } else { + ORT_ENFORCE(state_update_capacity_ == 0, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); ORT_ENFORCE(has_index_topk && index_topk_ > 0, "PackedSparseAttentionIndexer: index_topk must be > 0 for csa"); ORT_ENFORCE(!has_token_budget, "PackedSparseAttentionIndexer: token_budget must be omitted for csa"); token_budget_ = 0; @@ -758,6 +815,16 @@ Status PackedSparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::Comput ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: past_gate_buffer ", gate_buffer_provided ? "must be omitted for policy_mode 'qsa'" : "is required for policy_mode 'csa'"); + if (!is_qsa) { + for (int index : {psai::kStateUpdateCaptureCount, psai::kStateUpdateActive}) { + ORT_RETURN_IF(index < context.InputCount() && context.Input(index) != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } + Tensor* state_update = + context.OutputCount() > psai::kStateUpdate ? context.Output(psai::kStateUpdate, TensorShape({0})) : nullptr; + ORT_RETURN_IF(state_update != nullptr, + "PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } return is_qsa ? ComputeQsa(context) : ComputeCsa(context); } @@ -774,6 +841,14 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont const Tensor* past_key_state = context.Input(psai::kPastKeyState); const Tensor* past_kv_buffer = context.Input(psai::kPastKvBuffer); const Tensor* past_state_lengths = context.Input(psai::kPastStateLengths); + const Tensor* state_update_capture_count = + context.InputCount() > psai::kStateUpdateCaptureCount ? context.Input(psai::kStateUpdateCaptureCount) : nullptr; + const Tensor* state_update_active = + context.InputCount() > psai::kStateUpdateActive ? context.Input(psai::kStateUpdateActive) : nullptr; + + ORT_RETURN_IF(state_update_capacity_ > 0 && state_update_capture_count == nullptr, + "PackedSparseAttentionIndexer: state_update_capture_count is required when " + "state_update_capacity is positive"); ORT_RETURN_IF(query == nullptr, "PackedSparseAttentionIndexer: query is required"); const auto& query_shape = query->Shape(); @@ -827,6 +902,12 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(past_kv_buffer, "past_kv_buffer", {batch_size, buffer_capacity, head_size})); ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", {batch_size, psai::kStateLengthColumns})); + if (state_update_capture_count != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_capture_count, "state_update_capture_count", {batch_size})); + } + if (state_update_active != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(state_update_active, "state_update_active", {1})); + } Tensor rotary_cache = context.CreateGPUTensor(cos_cache->DataType(), TensorShape({2 * cos_cache->Shape().Size()})); @@ -844,6 +925,13 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont context.Output(psai::kPresentKvBuffer, TensorShape({batch_size, buffer_capacity, head_size})); Tensor* present_state_lengths = context.Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); + Tensor* state_update = context.OutputCount() > psai::kStateUpdate + ? context.Output(psai::kStateUpdate, + TensorShape({batch_size, state_update_capacity_, head_size})) + : nullptr; + if (state_update != nullptr) { + context.FillZero(*state_update); + } Tensor overflow_flags = context.CreateGPUTensor(DataTypeImpl::GetType(), TensorShape({batch_size})); if (present_key_state->DataRaw() != past_key_state->DataRaw()) { @@ -885,18 +973,30 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont if (batch_size > 0) { const Tensor* key_source = has_separate_key ? key : query; - PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; - update.CacheHint(rotary.batched) + const bool capture_state_update = state_update != nullptr && state_update_capacity_ > 0; + PackedSparseAttentionIndexerQsaUpdateProgram update{ + rotary.batched, capture_state_update, state_update_active != nullptr}; + update.CacheHint(rotary.batched, capture_state_update, state_update_active != nullptr) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{key_source, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, {past_seqlens, ProgramTensorMetadataDependency::Type}}); + if (capture_state_update) { + update.AddInput({state_update_capture_count, ProgramTensorMetadataDependency::Type}); + if (state_update_active != nullptr) { + update.AddInput({state_update_active, ProgramTensorMetadataDependency::Type}); + } + } update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) - .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}) + .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}); + if (capture_state_update) { + update.AddOutput({state_update, ProgramTensorMetadataDependency::Type}); + } + update .SetDispatchGroupSize(ToUint32(batch_size)) .AddUniformVariables({{ToUint32(batch_size)}, {ToUint32(total_tokens)}, @@ -908,6 +1008,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {ToUint32(head_size)}, {ToUint32(rotary.rotary_width)}, {ToUint32(rotary.max_rotary_length)}, + {ToUint32(state_update_capacity_)}, {epsilon_}}); ORT_RETURN_IF_ERROR(context.RunProgram(update)); } diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index bb9ceb3ca8eba..8122df0a255bd 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -32,9 +32,12 @@ class PackedSparseAttentionIndexerCopyProgram final class PackedSparseAttentionIndexerQsaUpdateProgram final : public Program { public: - explicit PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched) + PackedSparseAttentionIndexerQsaUpdateProgram(bool cos_cache_batched, bool capture_state_update, + bool has_state_update_active) : Program{"PackedSparseAttentionIndexerQsaUpdate"}, - cos_cache_batched_{cos_cache_batched} {} + cos_cache_batched_{cos_cache_batched}, + capture_state_update_{capture_state_update}, + has_state_update_active_{has_state_update_active} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"batch_size", ProgramUniformVariableDataType::Uint32}, @@ -47,10 +50,13 @@ class PackedSparseAttentionIndexerQsaUpdateProgram final {"head_size", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, + {"state_update_capacity", ProgramUniformVariableDataType::Uint32}, {"epsilon", ProgramUniformVariableDataType::Float32}); private: bool cos_cache_batched_; + bool capture_state_update_; + bool has_state_update_active_; }; // One invocation per query token: rotates the query, scores it against every causally visible @@ -146,6 +152,7 @@ class PackedSparseAttentionIndexer final : public WebGpuKernel { packed_sparse_attention_indexer::Policy policy_; int64_t compress_ratio_; int64_t state_capacity_; + int64_t state_update_capacity_; int64_t token_budget_; int64_t index_topk_; float epsilon_; diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 9863966809689..b5978dcc758b5 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2418,6 +2418,12 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference fail_shape_inference("PackedSparseAttentionIndexer: state_capacity must be in (0, INT_MAX], got ", state_capacity); } + const int64_t state_update_capacity = + getAttribute(ctx, "state_update_capacity", static_cast(0)); + if (state_update_capacity < 0 || state_update_capacity > 8) { + fail_shape_inference("PackedSparseAttentionIndexer: state_update_capacity must be in [0, 8], got ", + state_update_capacity); + } const int64_t token_budget = getAttribute(ctx, "token_budget", static_cast(0)); const int64_t index_topk = getAttribute(ctx, "index_topk", static_cast(0)); @@ -2469,10 +2475,24 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference fail_shape_inference("PackedSparseAttentionIndexer: input ", psai::kPositionIds, " (position_ids) is required when policy_mode is 'csa'"); } + const bool has_capture_count = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateCaptureCount); + const bool has_state_update_active = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateActive); + const bool has_state_update_output = + ctx.getNumOutputs() > static_cast(psai::kStateUpdate) && ctx.getOutputType(psai::kStateUpdate) != nullptr; + if (!is_qsa && (ctx.getAttribute("state_update_capacity") != nullptr || has_capture_count || + has_state_update_active || has_state_update_output)) { + fail_shape_inference("PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); + } + if (state_update_capacity > 0 && !has_capture_count) { + fail_shape_inference( + "PackedSparseAttentionIndexer: state_update_capture_count is required when " + "state_update_capacity is positive"); + } - if (ctx.getNumOutputs() != static_cast(psai::kFixedOutputCount)) { - fail_shape_inference("PackedSparseAttentionIndexer: exactly ", psai::kFixedOutputCount, - " declared outputs are required, got ", ctx.getNumOutputs()); + if (ctx.getNumOutputs() < static_cast(psai::kFixedOutputCount) || + ctx.getNumOutputs() > static_cast(psai::kOutputCount)) { + fail_shape_inference("PackedSparseAttentionIndexer: expected ", psai::kFixedOutputCount, " or ", + psai::kOutputCount, " declared outputs, got ", ctx.getNumOutputs()); } updateOutputElemType(ctx, psai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); updateOutputElemType(ctx, psai::kSelectedCounts, ONNX_NAMESPACE::TensorProto_DataType_INT32); @@ -2484,6 +2504,9 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference // 'qsa'; only propagate its type/shape when it is actually produced. propagateElemTypeFromInputToOutput(ctx, psai::kPastGateBuffer, psai::kPresentGateBuffer); } + if (has_state_update_output) { + propagateElemTypeFromInputToOutput(ctx, psai::kPastKeyState, psai::kStateUpdate); + } const auto* query_shape = PackedSparseAttentionIndexerShape(ctx, psai::kQuery, 2); const auto* key_shape = PackedSparseAttentionIndexerShape(ctx, psai::kKey, 2); @@ -2524,6 +2547,10 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { position_ids_shape = PackedSparseAttentionIndexerShape(ctx, psai::kPositionIds, 1); } + const auto* capture_count_shape = + PackedSparseAttentionIndexerShape(ctx, psai::kStateUpdateCaptureCount, 1); + const auto* state_update_active_shape = + PackedSparseAttentionIndexerShape(ctx, psai::kStateUpdateActive, 1); auto require_equal_dims = [](const ONNX_NAMESPACE::TensorShapeProto* lhs, int lhs_index, const ONNX_NAMESPACE::TensorShapeProto* rhs, int rhs_index, @@ -2552,6 +2579,9 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference "past_key_state and past_kv_buffer batch dimensions must match"); require_equal_dims(state_lengths_shape, 0, key_state_shape, 0, "past_state_lengths dimension 0 must equal the state batch dimension"); + require_equal_dims(capture_count_shape, 0, key_state_shape, 0, + "state_update_capture_count dimension 0 must equal the state batch dimension"); + require_dim_value(state_update_active_shape, 0, 1, "state_update_active dimension 0 must equal 1"); require_equal_dims(key_state_shape, 2, query_norm_shape, 0, "past_key_state dimension 2 must equal head_size"); require_dim_value(key_state_shape, 1, state_capacity, "past_key_state dimension 1 must equal state_capacity"); @@ -2684,6 +2714,13 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference if (state_lengths_shape != nullptr) { updateOutputShape(ctx, psai::kPresentStateLengths, *state_lengths_shape); } + if (has_state_update_output && key_state_shape != nullptr) { + ONNX_NAMESPACE::TensorShapeProto state_update_shape; + *state_update_shape.add_dim() = key_state_shape->dim(0); + state_update_shape.add_dim()->set_dim_value(state_update_capacity); + *state_update_shape.add_dim() = key_state_shape->dim(2); + updateOutputShape(ctx, psai::kStateUpdate, state_update_shape); + } } constexpr const char* PackedSparseAttentionIndexer_ver1_doc = R"DOC( @@ -2767,6 +2804,11 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Attr("state_capacity", "Fixed capacity (number of entries) of past_key_state / present_key_state. Must be > 0.", AttributeProto::INT) + .Attr("state_update_capacity", + "Only for policy_mode 'qsa': maximum number of leading token transitions captured per request. " + "Must be in [0, 8]. Default is 0.", + AttributeProto::INT, + static_cast(0)) .Attr("token_budget", "Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. " "Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.", @@ -2882,6 +2924,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "pending-buffer length (policy_mode 'qsa': incomplete-block length in [0, compress_ratio); 'csa': " "buffer length in [0, 2 * compress_ratio)).", "M") + .Input(16, + "state_update_capture_count", + "Only for policy_mode 'qsa': number of leading token transitions to capture for each request, " + "with shape (batch_size). Values are clamped to the request length and state_update_capacity. " + "Required when state_update_capacity is positive.", + "M", + OpSchema::Optional) + .Input(17, + "state_update_active", + "Only for policy_mode 'qsa': optional capture gate with shape (1). A zero value disables capture.", + "M", + OpSchema::Optional) .Output(0, "selected_indices", "Selected entries with shape (total_tokens, capacity). capacity is " @@ -2911,6 +2965,14 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "present_state_lengths", "Updated generic per-request state length, with the same fixed shape as past_state_lengths.", "M") + .Output(6, + "state_update", + "Only for policy_mode 'qsa': compact transition payloads with shape " + "(batch_size, state_update_capacity, head_size). A token that completes a compression block " + "stores the prepared block representative; any other captured token stores its raw key. " + "Inactive and unused slots are zero.", + "T", + OpSchema::Optional) .TypeConstraint("T", {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, "Constrain floating point tensors to float, float16 and bfloat16.") diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index 7e2b1cf667b7b..1ef2288155680 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -2758,6 +2758,20 @@ def copy_state_output(output_index, input_index, dtype): if policy_mode != "qsa": copy_state_output(4, 14, output_dtype) # present_gate_buffer <- past_gate_buffer copy_state_output(5, 15, onnx.TensorProto.INT32) # present_state_lengths <- past_state_lengths + if policy_mode == "qsa" and len(node.output) > 6 and node.output[6]: + past_key_shape = past_shape(12) + if past_key_shape is not None: + state_update_capacity = get_attribute(node, "state_update_capacity", 0) + vi = self.known_vi_[node.output[6]] + vi.CopyFrom( + helper.make_tensor_value_info( + node.output[6], + output_dtype, + get_shape_from_sympy_shape( + [past_key_shape[0], state_update_capacity, past_key_shape[2]] + ), + ) + ) def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 9f01b89da3fc3..9ebad8e66f345 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -100,6 +100,9 @@ struct GraphOptions { bool add_index_topk = false; bool add_csa_inputs = false; bool add_position_ids = false; + bool add_capture_count = false; + bool add_state_update_active = false; + int64_t state_update_capacity = 0; int output_count = psai::kFixedOutputCount; std::string policy_mode = psai::kPolicyModeQsa; }; @@ -158,6 +161,14 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { inputs.push_back(&empty); } inputs.push_back(builder.MakeInput(std::vector{options.batch_size, 2})); + if (options.add_capture_count || options.add_state_update_active) { + inputs.push_back(options.add_capture_count + ? builder.MakeInput(std::vector{options.batch_size}) + : &empty); + } + if (options.add_state_update_active) { + inputs.push_back(builder.MakeInput(std::vector{1})); + } std::vector outputs; for (int i = 0; i < options.output_count; ++i) { @@ -167,6 +178,9 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { node.AddAttribute("policy_mode", options.policy_mode); node.AddAttribute("compress_ratio", options.compress_ratio); node.AddAttribute("state_capacity", options.state_capacity); + if (options.state_update_capacity != 0) { + node.AddAttribute("state_update_capacity", options.state_update_capacity); + } if (is_csa || options.add_index_topk) { node.AddAttribute("index_topk", static_cast(3)); } else { @@ -209,6 +223,21 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaAcceptsPackedQueryKey) { {options.batch_size, options.state_capacity, options.head_size}); } +TEST(PackedSparseAttentionIndexerShapeInferenceTest, QsaInfersStateUpdate) { + GraphOptions options; + options.state_update_capacity = 4; + options.add_capture_count = true; + options.add_state_update_active = true; + options.output_count = psai::kOutputCount; + std::unique_ptr model; + ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + + const Graph& graph = model->MainGraph(); + const Node& node = *graph.Nodes().begin(); + ExpectShape(graph, node.OutputDefs()[psai::kStateUpdate]->Name(), ONNX_NAMESPACE::TensorProto_DataType_FLOAT, + {options.batch_size, options.state_update_capacity, options.head_size}); +} + TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndState) { GraphOptions options; options.policy_mode = psai::kPolicyModeCsa; @@ -376,7 +405,25 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsWrongOutputCount) { GraphOptions options; options.output_count = 4; ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "output size 4 not in range [min=6, max=6]"); + "output size 4 not in range [min=6, max=7]"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsStateUpdateCapacityWithoutCaptureCount) { + GraphOptions options; + options.state_update_capacity = 4; + options.output_count = psai::kOutputCount; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "state_update_capture_count is required"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaStateUpdateCapture) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + options.state_update_capacity = 4; + options.add_capture_count = true; + options.output_count = psai::kOutputCount; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "only valid for policy_mode 'qsa'"); } #endif // ORT_NO_EXCEPTIONS @@ -530,6 +577,9 @@ struct QsaPackedProblem { int max_position = 64; float epsilon = 1.0e-6f; std::optional scale; + int state_update_capacity = 0; + std::vector state_update_capture_count; + std::optional state_update_active; std::vector query; std::vector key; @@ -551,6 +601,7 @@ struct QsaPackedResult { std::vector present_key_state; std::vector present_kv_buffer; std::vector present_state_lengths; + std::vector state_update; }; void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { @@ -564,6 +615,8 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { out.present_key_state = p.past_key_state; out.present_kv_buffer = p.past_kv_buffer; out.present_state_lengths = p.past_state_lengths; + out.state_update.assign( + static_cast(p.batch_size) * p.state_update_capacity * p.head_size, 0.0f); std::vector key_len_after(static_cast(p.batch_size)); std::vector overflowed(static_cast(p.batch_size), false); @@ -618,6 +671,30 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { out.present_state_lengths[static_cast(b) * 2 + 0] = old_key_len + new_block_count; out.present_state_lengths[static_cast(b) * 2 + 1] = new_buf_len; key_len_after[static_cast(b)] = old_key_len + new_block_count; + + const bool capture_active = p.state_update_capacity > 0 && !p.state_update_capture_count.empty() && + (!p.state_update_active.has_value() || *p.state_update_active != 0); + const int capture_count = capture_active + ? std::clamp(p.state_update_capture_count[static_cast(b)], 0, + std::min(req_len, p.state_update_capacity)) + : 0; + for (int t = 0; t < capture_count; ++t) { + const bool completes_block = (old_buf_len + t + 1) % p.compress_ratio == 0; + for (int d = 0; d < head_size; ++d) { + const size_t capture_offset = + (static_cast(b) * p.state_update_capacity + t) * head_size + d; + if (completes_block) { + const int block = (old_buf_len + t + 1) / p.compress_ratio - 1; + out.state_update[capture_offset] = + out.present_key_state[(static_cast(b) * p.state_capacity + old_key_len + block) * + head_size + + d]; + } else { + out.state_update[capture_offset] = + p.key[(static_cast(req_start) + t) * head_size + d]; + } + } + } } out.selected_indices.assign(static_cast(total_tokens) * capacity, -1); @@ -740,6 +817,9 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP test.AddAttribute("compress_ratio", static_cast(problem.compress_ratio)); test.AddAttribute("state_capacity", static_cast(problem.state_capacity)); test.AddAttribute("token_budget", static_cast(problem.token_budget)); + if (problem.state_update_capacity > 0) { + test.AddAttribute("state_update_capacity", static_cast(problem.state_update_capacity)); + } if (problem.scale.has_value()) { test.AddAttribute("scale", *problem.scale); } @@ -775,6 +855,12 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP ToElementType(problem.past_kv_buffer)); test.AddOptionalInputEdge(); // past_gate_buffer test.AddInput("past_state_lengths", {batch_size, 2}, problem.past_state_lengths); + if (problem.state_update_capacity > 0) { + test.AddInput("state_update_capture_count", {batch_size}, problem.state_update_capture_count); + if (problem.state_update_active.has_value()) { + test.AddInput("state_update_active", {1}, {*problem.state_update_active}); + } + } test.AddOutput("selected_indices", {total_tokens, problem.Capacity()}, expected.selected_indices); test.AddOutput("selected_counts", {total_tokens}, expected.selected_counts); @@ -784,6 +870,10 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP ToElementType(expected.present_kv_buffer), false, 0.0f, tolerance); test.AddOptionalOutputEdge(); // present_gate_buffer test.AddOutput("present_state_lengths", {batch_size, 2}, expected.present_state_lengths); + if (problem.state_update_capacity > 0) { + test.AddOutput("state_update", {batch_size, problem.state_update_capacity, head_size}, + ToElementType(expected.state_update), false, 0.0f, tolerance); + } RunOnProvider(test, std::move(provider)); if (actual != nullptr) { const auto& fetches = test.GetFetches(); @@ -850,6 +940,29 @@ TEST(PackedSparseAttentionIndexerTest, QsaStateCapacityOverflowIsSafe) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); } +TEST(PackedSparseAttentionIndexerTest, QsaStateUpdateCapturesRawAndBlockTransitions) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 3}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::Cuda); +} + +TEST(PackedSparseAttentionIndexerTest, QsaStateUpdateActiveZeroClearsAllSlots) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 5}; + problem.state_update_active = 0; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::Cuda); +} + TEST(PackedSparseAttentionIndexerTest, QsaBoundedTopKMaximum) { QsaPackedProblem problem; problem.batch_size = 1; diff --git a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py index eb419c8fd2170..8f688d8b7ff5b 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -370,6 +370,8 @@ def test_packed_sparse_attention_indexer_qsa(self): "past_kv_buffer", "", "past_state_lengths", + "state_update_capture_count", + "state_update_active", ], [ "selected_indices", @@ -378,12 +380,14 @@ def test_packed_sparse_attention_indexer_qsa(self): "present_kv_buffer", "", "present_state_lengths", + "state_update", ], domain="com.microsoft", policy_mode="qsa", compress_ratio=4, state_capacity=5, token_budget=8, + state_update_capacity=4, ) inputs = [ helper.make_tensor_value_info("query_key", TensorProto.FLOAT16, ["total_tokens", 24]), @@ -396,6 +400,8 @@ def test_packed_sparse_attention_indexer_qsa(self): helper.make_tensor_value_info("past_key_state", TensorProto.FLOAT16, [2, 5, 8]), helper.make_tensor_value_info("past_kv_buffer", TensorProto.FLOAT16, [2, 7, 8]), helper.make_tensor_value_info("past_state_lengths", TensorProto.INT32, [2, 2]), + helper.make_tensor_value_info("state_update_capture_count", TensorProto.INT32, [2]), + helper.make_tensor_value_info("state_update_active", TensorProto.INT32, [1]), ] inferred = self._infer_packed_sparse_attention_indexer(node, inputs) @@ -409,6 +415,8 @@ def test_packed_sparse_attention_indexer_qsa(self): self.assertEqual(self._tensor_shape(outputs["present_kv_buffer"]), [2, 7, 8]) self.assertEqual(self._tensor_shape(outputs["present_state_lengths"]), [2, 2]) self.assertEqual(outputs["present_state_lengths"].type.tensor_type.elem_type, TensorProto.INT32) + self.assertEqual(self._tensor_shape(outputs["state_update"]), [2, 4, 8]) + self.assertEqual(outputs["state_update"].type.tensor_type.elem_type, TensorProto.FLOAT16) def test_packed_sparse_attention_indexer_csa(self): node = helper.make_node( From 4626d73ac3e4d608bc3830284f3550e2bd3bfc1f Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Mon, 28 Sep 2026 18:51:47 +0000 Subject: [PATCH 067/102] Preserve legacy CSA schema compatibility --- onnxruntime/core/graph/contrib_ops/bert_defs.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index b5978dcc758b5..3d3bf35cb2c12 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2479,7 +2479,7 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference const bool has_state_update_active = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateActive); const bool has_state_update_output = ctx.getNumOutputs() > static_cast(psai::kStateUpdate) && ctx.getOutputType(psai::kStateUpdate) != nullptr; - if (!is_qsa && (ctx.getAttribute("state_update_capacity") != nullptr || has_capture_count || + if (!is_qsa && (state_update_capacity > 0 || has_capture_count || has_state_update_active || has_state_update_output)) { fail_shape_inference("PackedSparseAttentionIndexer: state update capture is only valid for policy_mode 'qsa'"); } From ea4b1b2d9bfe8ac99abd8d360136be63ee91eca3 Mon Sep 17 00:00:00 2001 From: Akshay Sonawane Date: Fri, 25 Sep 2026 16:19:48 +0000 Subject: [PATCH 068/102] Add transactional QSA state updates Expose compact per-token QSA cache transitions and fuse their capture into the existing CUDA cache update kernels for speculative accepted-prefix commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sparse/sparse_attention_indexer_common.h | 4 +- .../cuda/sparse/sparse_attention_indexer.cc | 9 ++ .../sparse/sparse_attention_indexer_impl.cu | 89 +++++++++++++++++-- .../sparse/sparse_attention_indexer_impl.h | 2 + .../core/graph/contrib_ops/bert_defs.cc | 31 ++++++- 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h index 02f652d7a858f..bf526e942151d 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -67,12 +67,14 @@ enum OutputIndex : int { kSelectedIndices = 0, kPresentKey = 1, kPresentProjBuffer = 2, - kOutputCount = 3, + kStateUpdateRows = 3, + kOutputCount = 4, }; // A "qsa" node declares selected_indices + present_key. A "csa" node additionally declares // present_proj_buffer. constexpr int kQsaOutputCount = 2; +constexpr int kQsaStateUpdateOutputCount = 4; constexpr int kCsaOutputCount = 3; // Number of selected entries emitted per query. The capacity only depends on attributes, so it is diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index d2a2cf6a479c0..a26aaa0e08b01 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -270,6 +270,10 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { TensorShape({batch_size, sequence_length, params.capacity})); Tensor* present_key = context->Output(sai::kPresentKey, TensorShape({batch_size, params.key_cache_capacity, head_size})); + Tensor* state_update_values = + context->Output(sai::kPresentProjBuffer, TensorShape({batch_size, sequence_length, head_size})); + Tensor* state_update_rows = + context->Output(sai::kStateUpdateRows, TensorShape({batch_size, sequence_length})); ORT_RETURN_IF(selected_indices == nullptr || present_key == nullptr, "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); @@ -292,6 +296,11 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { reinterpret_cast(past_key->Data()), selected_indices->MutableData(), reinterpret_cast(present_key->MutableData()), + state_update_values == nullptr + ? nullptr + : reinterpret_cast(state_update_values->MutableData()), + state_update_rows == nullptr ? nullptr + : state_update_rows->MutableData(), float_workspace.get(), int_workspace.get()); } 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 885b372dd6bac..4b43dbda13c67 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -198,7 +198,9 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention } template -__global__ void AppendQsaKeyTailKernel(const T* key, T* present_key, SparseAttentionIndexerParams params) { +__global__ void AppendQsaKeyTailKernel( + const T* key, T* present_key, T* state_update_values, + int32_t* state_update_rows, SparseAttentionIndexerParams params) { const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.head_size; for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; index += static_cast(gridDim.x) * blockDim.x) { @@ -207,9 +209,18 @@ __global__ void AppendQsaKeyTailKernel(const T* key, T* present_key, SparseAtten const int token = static_cast(token_row % params.sequence_length); const int batch = static_cast(token_row / params.sequence_length); const int position = params.past_sequence_length + token; + const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; + if (state_update_values != nullptr && + position % kQwenCompressRatio != kQwenCompressRatio - 1) { + state_update_values[index] = + key[token_row * params.key_row_stride + d]; + if (d == 0) { + state_update_rows[token_row] = + representative_capacity + position % kQwenCompressRatio; + } + } const int completed_tokens = params.total_sequence_length / kQwenCompressRatio * kQwenCompressRatio; if (position >= completed_tokens) { - const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; const int cache_position = representative_capacity + position % kQwenCompressRatio; present_key[(static_cast(batch) * params.key_cache_capacity + cache_position) * params.head_size + d] = key[token_row * params.key_row_stride + d]; @@ -223,6 +234,8 @@ template __global__ void QsaBuildBlockRepresentativesKernel(const T* past_key, const T* key, T* present_key, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, + T* state_update_values, + int32_t* state_update_rows, SparseAttentionIndexerParams params) { __shared__ float pooled[kQwenHeadSize]; __shared__ float reduction[kThreads]; @@ -269,13 +282,66 @@ __global__ void QsaBuildBlockRepresentativesKernel(const T* past_key, const T* k for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { const int64_t output_base = (static_cast(batch) * params.key_cache_capacity + block) * kQwenHeadSize; - present_key[output_base + d] = from_float(LeadingRope( + const T value = from_float(LeadingRope( pooled, params.rotary_width, cos_cache + rotary_offset, sin_cache + rotary_offset, d)); + present_key[output_base + d] = value; + const int token = + first_position + kQwenCompressRatio - 1 - + params.past_sequence_length; + if (state_update_values != nullptr && token >= 0 && + token < params.sequence_length) { + const int64_t token_row = + static_cast(batch) * params.sequence_length + token; + state_update_values[token_row * params.head_size + d] = value; + if (d == 0) { + state_update_rows[token_row] = block; + } + } } __syncthreads(); } } +template +__global__ void CaptureQsaStateUpdatesKernel( + const T* key, const T* present_key, T* state_update_values, + int32_t* state_update_rows, SparseAttentionIndexerParams params) { + const int64_t total = + static_cast(params.batch_size) * params.sequence_length * + params.head_size; + for (int64_t index = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + index < total; + index += static_cast(gridDim.x) * blockDim.x) { + const int d = static_cast(index % params.head_size); + const int64_t token_row = index / params.head_size; + const int token = static_cast(token_row % params.sequence_length); + const int batch = static_cast(token_row / params.sequence_length); + const int position = params.past_sequence_length + token; + + int cache_row = position; + const T* source = key + token_row * params.key_row_stride; + if (params.use_block_representatives) { + if (position % kQwenCompressRatio == kQwenCompressRatio - 1) { + cache_row = position / kQwenCompressRatio; + source = present_key + + (static_cast(batch) * params.key_cache_capacity + + cache_row) * + params.head_size; + } else { + const int representative_capacity = + params.key_cache_capacity / kQwenCompressRatio; + cache_row = + representative_capacity + position % kQwenCompressRatio; + } + } + state_update_values[index] = source[d]; + if (d == 0) { + state_update_rows[token_row] = cache_row; + } + } +} + // Encodes a prefix-visible row as -count-1. QSA can then index that row directly without // materializing its visible positions. Non-prefix rows keep a nonnegative count and are compacted // by CompactNonPrefixVisibleKernel. @@ -1392,6 +1458,7 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int64_t* mask, const T* past_key, int32_t* selected_indices, T* present_key, + T* state_update_values, int32_t* state_update_rows, float* float_workspace, int32_t* int_workspace) { const int64_t rows = static_cast(params.batch_size) * params.sequence_length; @@ -1409,13 +1476,14 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const int64_t new_blocks = static_cast(params.batch_size) * (completed_blocks - first_block); if (new_blocks > 0) { QsaBuildBlockRepresentativesKernel<<>>( - past_key, key, present_key, key_norm_weight, cos_cache, sin_cache, params); + past_key, key, present_key, key_norm_weight, cos_cache, sin_cache, + state_update_values, state_update_rows, params); } const int64_t new_key_elements = static_cast(params.batch_size) * params.sequence_length * params.head_size; if (new_key_elements > 0) { AppendQsaKeyTailKernel<<>>( - key, present_key, params); + key, present_key, state_update_values, state_update_rows, params); } } else { const int64_t new_key_elements = @@ -1424,6 +1492,14 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio AppendQsaKeyKernel<<>>(key, present_key, params); } } + if (!params.use_block_representatives && + state_update_values != nullptr && state_update_rows != nullptr && + rows > 0) { + const int64_t update_elements = rows * params.head_size; + CaptureQsaStateUpdatesKernel + <<>>( + key, present_key, state_update_values, state_update_rows, params); + } if (rows == 0) { return CUDA_CALL(cudaGetLastError()); @@ -1618,7 +1694,8 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio #define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ template Status LaunchQsaSparseAttentionIndexer(cudaStream_t, const SparseAttentionIndexerParams&, \ const T*, const T*, const T*, const T*, const T*, const T*, \ - const int64_t*, const T*, int32_t*, T*, float*, int32_t*); \ + const int64_t*, const T*, int32_t*, T*, T*, int32_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 T*, const int64_t*, const T*, const T*, const T*, int32_t*, T*, T*, T*, float*); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index f4f5ab7b5dfbb..8e2ce55efd7bc 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -70,6 +70,8 @@ Status LaunchQsaSparseAttentionIndexer( const T* past_key, int32_t* selected_indices, T* present_key, + T* state_update_values, + int32_t* state_update_rows, float* float_workspace, int32_t* int_workspace); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 3d3bf35cb2c12..c27d3bdd5ac72 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2040,7 +2040,8 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex } } const size_t expected_outputs = is_qsa ? sai::kQsaOutputCount : sai::kCsaOutputCount; - if (ctx.getNumOutputs() != expected_outputs) { + if (ctx.getNumOutputs() != expected_outputs && + !(is_qsa && ctx.getNumOutputs() == sai::kQsaStateUpdateOutputCount)) { fail_shape_inference("SparseAttentionIndexer: policy_mode '", policy_mode, "' requires exactly ", expected_outputs, " declared outputs, got ", ctx.getNumOutputs()); } @@ -2112,6 +2113,23 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex SparseAttentionIndexerAppendDim(present_shape, head_size_dim); updateOutputShape(ctx, sai::kPresentKey, present_shape); } + if (ctx.getNumOutputs() == sai::kQsaStateUpdateOutputCount) { + propagateElemTypeFromInputToOutput( + ctx, sai::kQuery, sai::kPresentProjBuffer); + ONNX_NAMESPACE::TensorShapeProto update_shape; + SparseAttentionIndexerAppendDim(update_shape, batch_dim); + SparseAttentionIndexerAppendDim(update_shape, sequence_dim); + SparseAttentionIndexerAppendDim(update_shape, head_size_dim); + updateOutputShape(ctx, sai::kPresentProjBuffer, update_shape); + + updateOutputElemType( + ctx, sai::kStateUpdateRows, + ONNX_NAMESPACE::TensorProto_DataType_INT32); + ONNX_NAMESPACE::TensorShapeProto row_shape; + SparseAttentionIndexerAppendDim(row_shape, batch_dim); + SparseAttentionIndexerAppendDim(row_shape, sequence_dim); + updateOutputShape(ctx, sai::kStateUpdateRows, row_shape); + } return; } @@ -2359,10 +2377,17 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "T") .Output(2, "present_proj_buffer", - "Only for policy_mode 'csa': updated packed key/gate projection buffer with shape " - "(2, batch_size, present_buffer_length, 2 * head_size).", + "For policy_mode 'csa', updated packed key/gate projection buffer. For policy_mode " + "'qsa', optional compact per-token state-update values with shape " + "(batch_size, sequence_length, head_size).", "T", OpSchema::Optional) + .Output(3, + "state_update_rows", + "Only for policy_mode 'qsa': optional cache row written by each new token, with " + "shape (batch_size, sequence_length).", + "M", + OpSchema::Optional) .TypeConstraint("T", {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, "Constrain floating point tensors to float, float16 and bfloat16.") From ab24124edd38af70169fe9240c578a605bbf96be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:20:32 +0000 Subject: [PATCH 069/102] Address SparseAttentionIndexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 12 +-- docs/OperatorKernels.md | 2 +- .../cuda/sparse_attention_indexer.md | 13 +-- .../sparse/sparse_attention_indexer_common.h | 4 + .../sparse/sparse_attention_indexer_impl.cu | 86 +++++++++++++----- .../core/graph/contrib_ops/bert_defs.cc | 33 +++++-- .../python/tools/symbolic_shape_infer.py | 17 ++-- .../sparse_attention_indexer_op_test.cc | 90 +++++++++++++++++-- ...untime_test_python_symbolic_shape_infer.py | 34 +++++-- 9 files changed, 226 insertions(+), 65 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index ae4a2c5ad8ad4..0d3d299dbc3c9 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7085,7 +7085,7 @@ This version of the operator has been available since version 1 of the 'com.micr
compress_ratio : int (required)
Number of consecutive tokens folded into one compressed block. Must be > 0.
epsilon : float
-
Epsilon of the RMS normalization applied to the compressed keys. Default is 1e-6.
+
Epsilon of the RMS normalization applied to queries and compressed keys. Default is 1e-6.
head_weight_scale : float
Only for policy_mode 'csa': scale applied to head_weights. Default is 1/sqrt(num_heads). Must be omitted when policy_mode is 'qsa'.
index_topk : int
@@ -7098,13 +7098,15 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
-#### Inputs (7 - 13) +#### Inputs (8 - 14)
query : T
-
Indexer queries with shape (batch_size, sequence_length, num_heads, head_size), already normalized but not yet rotated.
-
key : T
-
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.
+
Indexer queries with shape (batch_size, sequence_length, num_heads * head_size), before normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', when key is omitted, this input instead packs query followed by key along the last dimension and has shape (batch_size, sequence_length, (num_heads + 1) * head_size).
+
key (optional) : T
+
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. May be omitted for policy_mode 'qsa' when query contains packed QK.
+
query_norm_weight : T
+
Effective RMSNorm multiplier of the queries, with shape (head_size).
key_norm_weight : T
Effective RMSNorm multiplier of the compressed keys, with shape (head_size).
cos_cache : T
diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index d253fecc316b3..c74d0147f9db1 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1143,7 +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_sequence_length:**M**
*in* past_proj_buffer:**T**
*out* selected_indices:**M**
*out* present_key:**T**
*out* present_proj_buffer:**T**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)
**TB** = tensor(bool)| +|SparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* query_norm_weight:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* mask:**TB**
*in* past_key:**T**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_sequence_length:**M**
*in* past_proj_buffer:**T**
*out* selected_indices:**M**
*out* present_key:**T**
*out* present_proj_buffer:**T**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)
**TB** = tensor(int64)| |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 index 8b87daa617909..ff062cb226270 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -258,7 +258,7 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l | Stage | Kernel | Parallelism | |---|---|---| -| 1 | `ConcatPastKeyKernel` | element | +| 1 | `CopyQsaPastKeyKernel` / `AppendQsaKeyKernel` | 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 | @@ -281,7 +281,7 @@ that assign one block to each work item clamp the grid to the CUDA `gridDim.x` l |---|---|---| | `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) | +| `csa` | float | `B*S*N*D` (rotated query) + `B*S*present_compressed_length` (scores) + `B*W*D` (new compressed keys before persistent-cache rounding) | Every size is derived from shapes and attributes only. @@ -308,7 +308,8 @@ the main performance follow-up below. ## 9. Validation Rules -Shape inference and the kernel both reject: +Shape inference rejects policy, attribute, required-input/output, rank, and statically known state-layout errors. +The CUDA kernel additionally validates concrete runtime dimensions and cross-input shape equality. Together they reject: - a `policy_mode` other than `qsa` / `csa`; - `compress_ratio <= 0`; @@ -318,10 +319,10 @@ Shape inference and the kernel both reject: - `index_topk` absent or `<= 0` for `csa`; - an output count other than 2 (`qsa`) or 3 (`csa`); - a `past_proj_buffer` length outside `[0, 2 * compress_ratio)`; -- rank or dimension mismatches between `query`, `key`, the caches and the buffers. +- rank mismatches and incompatible concrete dimensions 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. +Schema and statically knowable errors fail at `Graph::Resolve()`; checks that require concrete runtime dimensions fail +before any CUDA kernel is launched. ## 10. Testing diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h index 64a24ed3ec74c..a7a69a8573ab0 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -4,6 +4,7 @@ #pragma once #include +#include #include namespace onnxruntime { @@ -95,6 +96,9 @@ inline bool TryComputeCsaWindowPlan(int64_t past_buffer_length, int64_t sequence plan.overlap_length = past_buffer_length >= compress_ratio ? compress_ratio : 0; plan.leftover_length = past_buffer_length - plan.overlap_length; + if (sequence_length > std::numeric_limits::max() - plan.leftover_length) { + return false; + } const int64_t pending = plan.leftover_length + sequence_length; plan.new_window_count = pending / compress_ratio; 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 a897fd04a2a63..3ebec773aed90 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -13,6 +13,7 @@ #include #include +#include #include "core/providers/cuda/cu_inc/cuda_type_helper.cuh" @@ -26,6 +27,20 @@ namespace { constexpr int kThreads = 128; constexpr int64_t kMaxGridDimX = 2147483647; +template +Status ConfigureDynamicSharedMemory(Kernel kernel, size_t bytes, const char* kernel_name) { + int device = 0; + int max_bytes = 0; + CUDA_RETURN_IF_ERROR(cudaGetDevice(&device)); + CUDA_RETURN_IF_ERROR( + cudaDeviceGetAttribute(&max_bytes, cudaDevAttrMaxSharedMemoryPerBlockOptin, device)); + ORT_RETURN_IF(bytes > static_cast(max_bytes), + "SparseAttentionIndexer: ", kernel_name, " requires ", bytes, + " bytes of dynamic shared memory, but the device supports at most ", max_bytes); + return CUDA_CALL( + cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, static_cast(bytes))); +} + __device__ __forceinline__ float NegativeInfinity() { return -CUDART_INF_F; } int GridForElements(int64_t count) { @@ -123,7 +138,12 @@ __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 / compress_ratio + (position % compress_ratio == compress_ratio - 1); + if (position < 0) { + return 0; + } + const int64_t quotient = position / compress_ratio; + const bool round_up = position % compress_ratio == compress_ratio - 1; + return round_up && quotient < std::numeric_limits::max() ? quotient + 1 : quotient; } __device__ __forceinline__ int ClampPosition(int64_t position, int max_rotary_length) { @@ -234,8 +254,8 @@ __global__ void CompactVisibleKernel(const int64_t* mask, int32_t* visible_indic const int64_t* mask_row = mask + static_cast(batch) * 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); + for (int64_t base = 0; base < params.total_sequence_length; base += blockDim.x) { + const int64_t position = base + threadIdx.x; const int32_t flag = (position < params.total_sequence_length && position <= params.past_sequence_length + query && mask_row[position] != 0) @@ -250,7 +270,7 @@ __global__ void CompactVisibleKernel(const int64_t* mask, int32_t* visible_indic __syncthreads(); } if (flag != 0) { - out_row[offset + shared_scan[threadIdx.x] - 1] = position; + out_row[offset + shared_scan[threadIdx.x] - 1] = static_cast(position); } const int32_t tile_total = shared_scan[blockDim.x - 1]; __syncthreads(); @@ -352,7 +372,7 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* visibl 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) { + for (int64_t position = threadIdx.x; position < params.capacity; position += blockDim.x) { out_row[position] = -1; } __syncthreads(); @@ -420,7 +440,8 @@ 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) { + T* present_compressed_key, float* new_compressed_key, + SparseAttentionIndexerParams params) { extern __shared__ float shared[]; float* pooled = shared; float* reduction = shared + params.head_size; @@ -493,9 +514,13 @@ __global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_ (static_cast(batch) * params.max_rotary_length + position) * params.rotary_width; const int64_t out_base = (static_cast(batch) * params.compressed_cache_capacity + entry) * params.head_size; + const int64_t workspace_base = + (static_cast(batch) * params.new_window_count + window) * 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)); + const float rotated = TrailingRope( + pooled, params.head_size, params.rotary_width, cos_cache + cache_offset, sin_cache + cache_offset, d); + new_compressed_key[workspace_base + d] = rotated; + present_compressed_key[out_base + d] = from_float(rotated); } __syncthreads(); } @@ -544,6 +569,7 @@ __global__ void CsaCopyBufferKernel(const T* key, const T* gate, const T* past_k // kernel only has to read scores. template __global__ void CsaScoreKernel(const float* query_rotated, const T* present_compressed_key, + const float* new_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 * @@ -562,12 +588,18 @@ __global__ void CsaScoreKernel(const float* query_rotated, const T* present_comp const int64_t key_base = (static_cast(batch) * params.compressed_cache_capacity + entry) * params.head_size; + const bool is_new_entry = entry >= params.past_compressed_length; + const int64_t new_key_base = + (static_cast(batch) * params.new_window_count + entry - params.past_compressed_length) * + 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]); + const float key_value = + is_new_entry ? new_compressed_key[new_key_base + d] : to_float(present_compressed_key[key_base + d]); + dot += query_head[d] * key_value; } total_score += fmaxf(dot, 0.0f) * to_float(head_weights[row * params.num_heads + head]); } @@ -584,7 +616,7 @@ __global__ void CsaSelectKernel(const float* scores, const int64_t* position_ids 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) { + for (int64_t position = threadIdx.x; position < params.capacity; position += blockDim.x) { out_row[position] = -1; } __syncthreads(); @@ -632,7 +664,9 @@ size_t GetQsaWorkspaceIntCount(const SparseAttentionIndexerParams& params) { 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); + return rows * params.num_heads * params.head_size + + rows * std::max(params.present_compressed_length, 1) + + static_cast(params.batch_size) * params.new_window_count * params.head_size; } template @@ -668,6 +702,10 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio int32_t* visible_count = int_workspace + rows * params.total_sequence_length; const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + const size_t rotate_shared_bytes = value_bytes + kThreads * sizeof(float); + const size_t score_shared_bytes = 2 * value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(RotateQueryKernel, rotate_shared_bytes, "RotateQueryKernel")); const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); CompactVisibleKernel<<>>( @@ -675,13 +713,15 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const int64_t query_rows = rows * params.num_heads; const int rotate_blocks = static_cast(std::min(query_rows, kMaxGridDimX)); - RotateQueryKernel<<>>( + RotateQueryKernel<<>>( query, query_norm_weight, cos_cache, sin_cache, nullptr, query_rotated, params); if (params.max_block_count > 0) { + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(QsaBlockScoreKernel, score_shared_bytes, "QsaBlockScoreKernel")); const int64_t block_work = rows * params.max_block_count; const int score_blocks = static_cast(std::min(block_work, kMaxGridDimX)); - QsaBlockScoreKernel<<>>( + QsaBlockScoreKernel<<>>( query_rotated, present_key, key_norm_weight, cos_cache, sin_cache, visible_indices, visible_count, block_scores, params); } @@ -704,6 +744,11 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio 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); + const size_t value_shared_bytes = value_bytes + kThreads * sizeof(float); + + float* query_rotated = float_workspace; + float* scores = query_rotated + rows * params.num_heads * params.head_size; + float* new_compressed_key = scores + rows * std::max(params.present_compressed_length, 1); // 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. @@ -715,11 +760,13 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio } if (params.batch_size > 0 && params.new_window_count > 0) { + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(CsaCompressKernel, value_shared_bytes, "CsaCompressKernel")); const int compress_blocks = static_cast( std::min(static_cast(params.batch_size) * params.new_window_count, kMaxGridDimX)); - CsaCompressKernel<<>>( + CsaCompressKernel<<>>( key, gate, past_kv_buffer, past_gate_buffer, position_bias, key_norm_weight, cos_cache, sin_cache, - present_compressed_key, params); + present_compressed_key, new_compressed_key, params); } const int64_t present_buffer_elements = @@ -733,16 +780,15 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio return CUDA_CALL(cudaGetLastError()); } - float* query_rotated = float_workspace; - float* scores = float_workspace + rows * params.num_heads * params.head_size; - + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(RotateQueryKernel, value_shared_bytes, "RotateQueryKernel")); const int rotate_blocks = static_cast(std::min(rows * params.num_heads, kMaxGridDimX)); - RotateQueryKernel<<>>( + RotateQueryKernel<<>>( query, query_norm_weight, 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); + query_rotated, present_compressed_key, new_compressed_key, head_weights, position_ids, scores, params); } const int row_blocks = static_cast(std::min(rows, kMaxGridDimX)); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index deba6cfe244dd..ad98f5dc1d397 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2032,6 +2032,12 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex fail_shape_inference("SparseAttentionIndexer: policy_mode '", policy_mode, "' requires exactly ", expected_outputs, " declared outputs, got ", ctx.getNumOutputs()); } + for (size_t index = 0; index < expected_outputs; ++index) { + if (ctx.getOutputType(index) == nullptr) { + fail_shape_inference("SparseAttentionIndexer: output ", index, " is required for policy_mode '", + policy_mode, "'"); + } + } updateOutputElemType(ctx, sai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); @@ -2094,7 +2100,12 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex } else { 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()); + const int64_t past_length = past_key_shape->dim(1).dim_value(); + const int64_t sequence_length = sequence_dim.dim_value(); + if (past_length > std::numeric_limits::max() - sequence_length) { + fail_shape_inference("SparseAttentionIndexer: present key sequence length exceeds INT64_MAX"); + } + total_dim->set_dim_value(past_length + sequence_length); } } SparseAttentionIndexerAppendDim(present_shape, head_size_dim); @@ -2149,7 +2160,11 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex if (SparseAttentionIndexerHasInput(ctx, sai::kPastSequenceLength)) { *entry_dim = past_compressed_shape->dim(1); } else 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); + const int64_t past_length = past_compressed_shape->dim(1).dim_value(); + if (past_length > std::numeric_limits::max() - plan.new_window_count) { + fail_shape_inference("SparseAttentionIndexer: present compressed-key length exceeds INT64_MAX"); + } + entry_dim->set_dim_value(past_length + plan.new_window_count); } SparseAttentionIndexerAppendDim(present_shape, head_size_dim); updateOutputShape(ctx, sai::kPresentKey, present_shape); @@ -2250,18 +2265,18 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(0, "query", "Indexer queries with shape (batch_size, sequence_length, num_heads * head_size), before " - "normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', when key is omitted, " - "this input instead packs query followed by key along the last dimension and has shape " - "(batch_size, sequence_length, (num_heads + 1) * head_size).", + "normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', when key is omitted, " + "this input instead packs query followed by key along the last dimension and has shape " + "(batch_size, sequence_length, (num_heads + 1) * head_size).", "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. " - "May be omitted for policy_mode 'qsa' when query contains packed QK.", - "T", - OpSchema::Optional) + "where the first head_size channels are the Ca series and the last head_size channels the Cb series. " + "May be omitted for policy_mode 'qsa' when query contains packed QK.", + "T", + OpSchema::Optional) .Input(2, "query_norm_weight", "Effective RMSNorm multiplier of the queries, with shape (head_size).", diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index 8365f039e7a8c..0db6674ce6c5f 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -2634,6 +2634,7 @@ def _infer_SparseAttentionIndexer(self, node): # noqa: N802 policy_mode = policy_mode.decode("utf-8") compress_ratio = get_attribute(node, "compress_ratio", 0) query_shape = self._get_sympy_shape(node, 0) + head_size = self._get_sympy_shape(node, 2)[0] output_dtype = self.known_vi_[node.input[0]].type.tensor_type.elem_type if policy_mode == "qsa": @@ -2664,17 +2665,17 @@ def past_shape(index): return self._get_sympy_shape(node, index) if policy_mode == "qsa": - past_key_shape = past_shape(6) + past_key_shape = past_shape(7) if past_key_shape is None: return - if len(node.input) > 11 and node.input[11]: + if len(node.input) > 12 and node.input[12]: set_output(1, past_key_shape) else: - set_output(1, [query_shape[0], past_key_shape[1] + query_shape[1], query_shape[3]]) + set_output(1, [query_shape[0], past_key_shape[1] + query_shape[1], head_size]) return - past_compressed_shape = past_shape(6) - past_buffer_shape = past_shape(12) + past_compressed_shape = past_shape(7) + past_buffer_shape = past_shape(13) if past_compressed_shape is None or past_buffer_shape is None: return @@ -2694,18 +2695,18 @@ def past_shape(index): ) present_compressed_length = ( past_compressed_shape[1] - if len(node.input) > 11 and node.input[11] + if len(node.input) > 12 and node.input[12] else past_compressed_shape[1] + new_window_count ) else: present_compressed_length = ( past_compressed_shape[1] - if len(node.input) > 11 and node.input[11] + if len(node.input) > 12 and node.input[12] else self._new_symbolic_dim_from_output(node, 1, 1) ) present_buffer_length = self._new_symbolic_dim_from_output(node, 2, 2) - set_output(1, [query_shape[0], present_compressed_length, query_shape[3]]) + set_output(1, [query_shape[0], present_compressed_length, head_size]) set_output(2, [2, query_shape[0], present_buffer_length, past_buffer_shape[3]]) def _infer_SkipGroupNorm(self, node): # noqa: N802 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 ea360886d3bf3..51dc7eca630b5 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -101,7 +101,10 @@ struct QsaGraphOptions { // 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; + const int64_t total = + options.past_sequence_length > std::numeric_limits::max() - options.sequence_length + ? std::numeric_limits::max() + : options.past_sequence_length + options.sequence_length; const int64_t cache_capacity = options.share_cache ? options.key_cache_capacity : options.past_sequence_length; NodeArg& empty = builder.graph_.GetOrCreateNodeArg("", nullptr); std::vector inputs{ @@ -116,7 +119,7 @@ void AddQsaNode(ModelTestBuilder& builder, const QsaGraphOptions& options) { builder.MakeInput(std::vector{options.head_size}), builder.MakeInput(std::vector{options.batch_size, total, options.rotary_width}), builder.MakeInput(std::vector{options.batch_size, total, options.rotary_width}), - builder.MakeInput(std::vector{options.batch_size, total}), + builder.MakeInput(std::vector{options.batch_size, total}), builder.MakeInput(std::vector{options.batch_size, cache_capacity, options.head_size}), }; if (options.add_csa_inputs) { @@ -156,6 +159,7 @@ struct CsaGraphOptions { int64_t output_count = sai::kCsaOutputCount; bool add_token_budget = false; bool share_cache = false; + bool omit_present_proj_buffer = false; }; void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { @@ -185,7 +189,9 @@ void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { std::vector outputs; for (int64_t slot = 0; slot < options.output_count; ++slot) { - outputs.push_back(builder.MakeOutput()); + outputs.push_back(options.omit_present_proj_buffer && slot == sai::kPresentProjBuffer + ? &empty + : builder.MakeOutput()); } Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); @@ -580,10 +586,14 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, sin_base + query_position * problem.rotary_width); } - const int64_t threshold = - position < 0 ? 0 - : position / problem.compress_ratio + - (position % problem.compress_ratio == problem.compress_ratio - 1); + int64_t threshold = 0; + if (position >= 0) { + threshold = position / problem.compress_ratio; + if (position % problem.compress_ratio == problem.compress_ratio - 1 && + threshold < std::numeric_limits::max()) { + ++threshold; + } + } std::vector scores(static_cast(present_compressed_length), 0.0f); for (int entry = 0; entry < present_compressed_length; ++entry) { if (static_cast(entry) >= threshold) { @@ -1028,6 +1038,40 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithMissingStateOutputs "requires exactly 3 declared outputs"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithEmptyStateOutput) { + CsaGraphOptions options; + options.omit_present_proj_buffer = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "output 2 is required for policy_mode 'csa'"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaPresentLengthOverflow) { + QsaGraphOptions options; + options.past_sequence_length = std::numeric_limits::max(); + options.sequence_length = 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "present key sequence length exceeds INT64_MAX"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWindowPlanOverflow) { + CsaGraphOptions options; + options.compress_ratio = 2; + options.past_buffer_length = 1; + options.sequence_length = std::numeric_limits::max(); + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "past_proj_buffer sequence length must be in [0, 2 * compress_ratio)"); +} + +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaPresentLengthOverflow) { + CsaGraphOptions options; + options.compress_ratio = 1; + options.past_buffer_length = 0; + options.sequence_length = std::numeric_limits::max(); + options.past_compressed_length = 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "present compressed-key length exceeds INT64_MAX"); +} + TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedCsaBuffer) { CsaGraphOptions options; options.past_buffer_length = 2 * options.compress_ratio; @@ -1078,6 +1122,33 @@ TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProb TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProblem(), 3.0e-2f); } +TEST(SparseAttentionIndexerTest, CsaFloat16ScoresNewKeysBeforeCacheRounding) { + CsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 1; + problem.head_size = 2; + problem.rotary_width = 1; + problem.compress_ratio = 1; + problem.index_topk = 2; + problem.past_compressed_length = 1; + problem.past_buffer_length = 0; + problem.max_rotary_length = 4; + problem.query = {1.0f, 3.0e-4f}; + problem.key = {0.0f, 0.0f, 1.0f, 3.0e-4f}; + problem.query_norm_weight = {1.0f, 1.0f}; + problem.key_norm_weight = {1.0f, 1.0f}; + problem.cos_cache.assign(4, 1.0f); + problem.sin_cache.assign(4, 0.0f); + problem.gate.assign(4, 0.0f); + problem.position_bias.assign(4, 0.0f); + problem.head_weights = {1.0f}; + problem.position_ids = {2}; + problem.past_compressed_key = + RoundTrip(RmsNormalize({1.0f, 3.0e-4f}, problem.key_norm_weight, problem.epsilon)); + RunCsaTest(problem, 2.0e-3f); +} + TEST(SparseAttentionIndexerTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f); } TEST(SparseAttentionIndexerTest, CsaSharedCacheCapacity) { @@ -1094,7 +1165,10 @@ TEST(SparseAttentionIndexerTest, CsaExplicitZeroScales) { } TEST(SparseAttentionIndexerTest, CsaInt64MaxPosition) { - CsaProblem problem = MakeCsaProblem(); + CsaProblem seed; + seed.compress_ratio = 1; + seed.past_buffer_length = 0; + CsaProblem problem = MakeCsaProblem(std::move(seed)); problem.position_ids[0] = std::numeric_limits::max(); RunCsaTest(problem, 1.0e-5f); } 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 7aabc05004f03..56ed5658ce338 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -143,7 +143,16 @@ def _tensor_shape(value_info): def test_sparse_attention_indexer_qsa(self): node = helper.make_node( "SparseAttentionIndexer", - ["query", "key", "key_norm_weight", "cos_cache", "sin_cache", "mask", "past_key"], + [ + "query", + "key", + "query_norm_weight", + "key_norm_weight", + "cos_cache", + "sin_cache", + "mask", + "past_key", + ], ["selected_indices", "present_key"], domain="com.microsoft", policy_mode="qsa", @@ -151,12 +160,13 @@ def test_sparse_attention_indexer_qsa(self): token_budget=8, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["batch", "sequence", 2, 8]), + helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["batch", "sequence", 16]), helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["batch", "sequence", 8]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, ["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("mask", TensorProto.INT64, ["batch", "total"]), helper.make_tensor_value_info("past_key", TensorProto.FLOAT16, ["batch", "past", 8]), ] @@ -173,6 +183,7 @@ def test_sparse_attention_indexer_qsa_shared_cache(self): [ "query", "key", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -191,12 +202,13 @@ def test_sparse_attention_indexer_qsa_shared_cache(self): token_budget=8, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["batch", "sequence", 2, 8]), + helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["batch", "sequence", 16]), helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["batch", "sequence", 8]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, ["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("mask", TensorProto.INT64, ["batch", "total"]), helper.make_tensor_value_info("past_key", TensorProto.FLOAT16, ["batch", "capacity", 8]), helper.make_tensor_value_info("past_sequence_length", TensorProto.INT32, [1]), ] @@ -211,6 +223,7 @@ def test_sparse_attention_indexer_csa_static(self): [ "query", "key", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -230,8 +243,9 @@ def test_sparse_attention_indexer_csa_static(self): index_topk=3, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT, [2, 5, 2, 8]), + helper.make_tensor_value_info("query", TensorProto.FLOAT, [2, 5, 16]), helper.make_tensor_value_info("key", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT, [8]), 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]), @@ -256,6 +270,7 @@ def test_sparse_attention_indexer_csa_shared_cache(self): [ "query", "key", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -275,8 +290,9 @@ def test_sparse_attention_indexer_csa_shared_cache(self): index_topk=3, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT, [2, 5, 2, 8]), + helper.make_tensor_value_info("query", TensorProto.FLOAT, [2, 5, 16]), helper.make_tensor_value_info("key", TensorProto.FLOAT, [2, 5, 16]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT, [8]), 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]), @@ -299,6 +315,7 @@ def test_sparse_attention_indexer_csa_symbolic_fallback(self): [ "query", "key", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -318,8 +335,9 @@ def test_sparse_attention_indexer_csa_symbolic_fallback(self): index_topk=3, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT, ["batch", "sequence", 2, 8]), + helper.make_tensor_value_info("query", TensorProto.FLOAT, ["batch", "sequence", 16]), helper.make_tensor_value_info("key", TensorProto.FLOAT, ["batch", "sequence", 16]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT, [8]), 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]), From 5def544f3e611069225750fa69e272ff14688c78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:12:41 +0000 Subject: [PATCH 070/102] Fix SparseAttentionIndexer shape inference test Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- onnxruntime/core/graph/contrib_ops/bert_defs.cc | 7 ------- .../contrib_ops/sparse_attention_indexer_op_test.cc | 12 +----------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 69948e7ea3d8a..94027a476e46e 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2056,13 +2056,6 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex fail_shape_inference("SparseAttentionIndexer: policy_mode '", policy_mode, "' requires exactly ", expected_outputs, " declared outputs, got ", ctx.getNumOutputs()); } - for (size_t index = 0; index < expected_outputs; ++index) { - if (ctx.getOutputType(index) == nullptr) { - fail_shape_inference("SparseAttentionIndexer: output ", index, " is required for policy_mode '", - policy_mode, "'"); - } - } - updateOutputElemType(ctx, sai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); const bool has_key = SparseAttentionIndexerHasInput(ctx, sai::kKey); 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 51dc7eca630b5..cb7515cd9c9d2 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -159,7 +159,6 @@ struct CsaGraphOptions { int64_t output_count = sai::kCsaOutputCount; bool add_token_budget = false; bool share_cache = false; - bool omit_present_proj_buffer = false; }; void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { @@ -189,9 +188,7 @@ void AddCsaNode(ModelTestBuilder& builder, const CsaGraphOptions& options) { std::vector outputs; for (int64_t slot = 0; slot < options.output_count; ++slot) { - outputs.push_back(options.omit_present_proj_buffer && slot == sai::kPresentProjBuffer - ? &empty - : builder.MakeOutput()); + outputs.push_back(builder.MakeOutput()); } Node& node = builder.AddNode("SparseAttentionIndexer", inputs, outputs, kMSDomain); @@ -1038,13 +1035,6 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithMissingStateOutputs "requires exactly 3 declared outputs"); } -TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithEmptyStateOutput) { - CsaGraphOptions options; - options.omit_present_proj_buffer = true; - ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, - "output 2 is required for policy_mode 'csa'"); -} - TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaPresentLengthOverflow) { QsaGraphOptions options; options.past_sequence_length = std::numeric_limits::max(); From 2217d9a765d6026f5870e52214ed9bc31cee68fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:24:24 +0000 Subject: [PATCH 071/102] Address SparseAttentionIndexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../webgpu/bert/sparse_attention_indexer.cc | 3 ++- .../contrib_ops/webgpu/bert/sparse_attention_indexer.h | 8 ++++---- .../contrib_ops/sparse_attention_indexer_op_test.cc | 10 ++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 8ef9027691d36..085e0ffa1aa53 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -106,8 +106,9 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let batch = row / uniforms.sequence_length;\n" << " let query = row % uniforms.sequence_length;\n" << " let offset = batch * uniforms.total_sequence_length + token;\n" + << " let mask_value = " << mask.GetByOffset("offset", true) << ";\n" << " return token <= uniforms.past_sequence_length + query && " - << mask.GetByOffset("offset") << " != 0;\n" + "(mask_value.x != 0u || mask_value.y != 0u);\n" << "}\n" << "fn visible_at(row: u32, ordinal: u32) -> u32 {\n" << " var seen = 0u;\n" diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index 3e81fc865a9b1..73f9ac656c0c2 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -32,9 +32,9 @@ class SparseAttentionIndexerQsaConcatProgram final {"sequence_length", ProgramUniformVariableDataType::Uint32}, {"past_sequence_length", ProgramUniformVariableDataType::Uint32}, {"total_sequence_length", ProgramUniformVariableDataType::Uint32}, - {"head_size", ProgramUniformVariableDataType::Uint32}, - {"key_row_stride", ProgramUniformVariableDataType::Uint32}, - {"key_offset", ProgramUniformVariableDataType::Uint32}); + {"head_size", ProgramUniformVariableDataType::Uint32}, + {"key_row_stride", ProgramUniformVariableDataType::Uint32}, + {"key_offset", ProgramUniformVariableDataType::Uint32}); private: bool has_past_; @@ -51,7 +51,7 @@ class SparseAttentionIndexerQsaSelectProgram final {"sequence_length", ProgramUniformVariableDataType::Uint32}, {"num_heads", ProgramUniformVariableDataType::Uint32}, {"head_size", ProgramUniformVariableDataType::Uint32}, - {"query_row_stride", 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 7cea657e939d0..52a86f40fa6cf 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1220,6 +1220,16 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaExplicitZeroScale) { RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, QsaInt64HighWordMask) { + QsaProblem problem = MakeQsaProblem(); + for (int64_t& mask_value : problem.mask) { + if (mask_value != 0) { + mask_value = int64_t{1} << 32; + } + } + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaFloat) { RunCsaTest(MakeCsaProblem(), 1.0e-5f, ProviderKind::WebGpu); } From 34013505f2644445ece9d20b910f0b7939bce1c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:38:50 +0000 Subject: [PATCH 072/102] Fix packed indexer registrations and docs Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 26 +++++-------------- docs/OperatorKernels.md | 2 +- .../sparse/packed_sparse_attention_indexer.cc | 7 +++-- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 8469d5e8f2c24..d4860820e927b 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -4339,18 +4339,6 @@ This version of the operator has been available since version 1 of the 'com.micr past_ids and present_ids may use the same allocation. Such in-place execution is transaction-safe only when the whole operator call is unconditionally committed; a caller that may select a prefix or roll back must preserve past_ids. - - Optional inputs add packed-sequence and Qwen4-Exp-style n-gram embedding support: - - - eos_token_id, when provided together with reset_on_eos != 0, causes causal history to reset at EOS - boundaries: any shifted position at or before the most recent EOS strictly before the current - position is replaced with eos_token_id instead of the real token. - - segment_ids, when provided, additionally resets causal history at any position whose segment id - differs from the immediately preceding position's segment id within input_ids. Segment boundaries - are not checked against past_ids history. - - head_offsets, when provided, adds a fixed per-output-head offset after the modulo by the head's - vocabulary size, letting all heads across all n-gram orders share one flat embedding table. - Optional inputs add packed-sequence and Qwen4-Exp-style n-gram embedding support: - eos_token_id, when provided together with reset_on_eos != 0, causes causal history to reset at EOS @@ -4761,7 +4749,7 @@ This version of the operator has been available since version 1 of the 'com.micr [total_tokens, ...] axis instead of a dense [batch_size, sequence_length, ...] axis. It selects, for every packed query token, the sparse-attention candidates that a following SparsePagedAttention (or similar) operator is allowed to read. - + Unlike SparseAttentionIndexer, this operator: * takes packed query/key tensors plus cumulative_sequence_lengths (request boundaries) and past_sequence_lengths (per-request past length) instead of a dense batch and a dense mask; @@ -4772,10 +4760,10 @@ This version of the operator has been available since version 1 of the 'com.micr rejected as a deterministic no-op on state rather than truncated or allowed to corrupt memory; * additionally emits selected_counts, the exact number of active (non -1) entries per query, so that no downstream consumer needs to scan selected_indices for its query's true count. - + Both policy_mode values keep the semantics of SparseAttentionIndexer, applied independently to each request's own packed token range and fixed-capacity state slice: - + policy_mode = "qsa" ("query sparse attention" token indexer) Processes each request's new tokens sequentially: appends raw indexer keys to the generic pending buffer, and whenever it reaches compress_ratio tokens, mean-pools it, applies RMSNorm @@ -4786,7 +4774,7 @@ This version of the operator has been available since version 1 of the 'com.micr their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing incomplete block. - + policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window count) independently per request, using that request's own buffer_length and new token count; @@ -4794,7 +4782,7 @@ This version of the operator has been available since version 1 of the 'com.micr rotated and appended to key_state. Queries are scored against every causally visible compressed entry with sum_h w_h * ReLU(q_h . k) and the index_topk highest scoring entry indices are emitted. - + Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: token_budget + compress_ratio - 1 for "qsa" (values are request-local token positions into the @@ -4814,7 +4802,7 @@ This version of the operator has been available since version 1 of the 'com.micr * cumulative_sequence_lengths, past_sequence_lengths and past_state_lengths are read directly by the device kernel; a zero-token request row (a repeated cumulative offset) is valid and simply contributes no query rows for that request. - + OgaEngine integration note: this operator only defines the ORT contrib op; wiring past_key_state / past_kv_buffer / past_gate_buffer / past_state_lengths as Engine-managed, per-request fixed-size state (analogous to a paged auxiliary cache) is expected to happen in the @@ -8068,5 +8056,3 @@ No versioning maintained for experimental ops.
T : tensor(float)
Constrain input and output types to float32 tensors.
- - diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index f3971bdd299d6..c15126cfb4f32 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1125,7 +1125,7 @@ The **OpSet Version** column uses the following notation: |NhwcConv|*in* X:**T**
*in* W:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedAttention|*in* input:**T**
*in* weights:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedMultiHeadAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| -|PackedSparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* cumulative_sequence_lengths:**M**
*in* past_sequence_lengths:**M**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_key_state:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*in* past_state_lengths:**M**
*out* selected_indices:**M**
*out* selected_counts:**M**
*out* present_key_state:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**
*out* present_state_lengths:**M**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| +|PackedSparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* query_norm_weight:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* cumulative_sequence_lengths:**M**
*in* past_sequence_lengths:**M**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_key_state:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*in* past_state_lengths:**M**
*out* selected_indices:**M**
*out* selected_counts:**M**
*out* present_key_state:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**
*out* present_state_lengths:**M**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| |PagedAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* key_cache:**T_CACHE**
*in* value_cache:**T_CACHE**
*in* cumulative_sequence_length:**S**
*in* past_seqlens:**S**
*in* block_table:**S**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* slot_mapping:**S**
*in* head_sink:**T**
*in* q_norm_weight:**T**
*in* k_norm_weight:**T**
*in* k_scale:**T_KV_SCALE**
*in* v_scale:**T_KV_SCALE**
*in* attention_metadata:**S**
*out* output:**T**
*out* key_cache_out:**T_CACHE**
*out* value_cache_out:**T_CACHE**|1+|**S** = tensor(int32)
**T** = tensor(bfloat16), tensor(float16)
**T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8), tensor(uint8)
**T_KV_SCALE** = tensor(float)| |QAttention|*in* input:**T1**
*in* weight:**T2**
*in* bias:**T3**
*in* input_scale:**T3**
*in* weight_scale:**T3**
*in* mask_index:**T4**
*in* input_zero_point:**T1**
*in* weight_zero_point:**T2**
*in* past:**T3**
*out* output:**T3**
*out* present:**T3**|1+|**T1** = tensor(int8)
**T2** = tensor(int8)
**T3** = tensor(float), tensor(float16)
**T4** = tensor(int32)| |QMoE|*in* input:**T**
*in* router_probs:**T**
*in* fc1_experts_weights:**T1**
*in* fc1_scales:**T2**
*in* fc1_experts_bias:**T**
*in* fc2_experts_weights:**T1**
*in* fc2_scales:**T2**
*in* fc2_experts_bias:**T**
*in* fc3_experts_weights:**T1**
*in* fc3_scales:**T2**
*in* fc3_experts_bias:**T**
*in* fc1_zero_points:**T1**
*in* fc2_zero_points:**T1**
*in* fc3_zero_points:**T1**
*in* router_weights:**T**
*in* fc1_global_scale:**T4**
*in* fc2_global_scale:**T4**
*in* fc1_act_scale:**T4**
*in* fc2_act_scale:**T4**
*in* fc1_act_block_scale:**T2**
*in* fc2_act_block_scale:**T2**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(float16)
**T1** = tensor(float8e4m3fn), tensor(uint8)
**T2** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(float8e8m0)
**T4** = tensor(float)| diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 6e01ecf2c6d6a..ea30d4402381b 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -30,8 +30,11 @@ namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ - .MayInplace(11, 2) \ - .MayInplace(14, 5), \ + .MayInplace(psai::kPastKeyState, psai::kPresentKeyState) \ + .MayInplace(psai::kPastKvBuffer, psai::kPresentKvBuffer) \ + .MayInplace(psai::kPastGateBuffer, psai::kPresentGateBuffer) \ + .MayInplace(psai::kPastStateLengths, \ + psai::kPresentStateLengths), \ PackedSparseAttentionIndexer); REGISTER_KERNEL_TYPED(float) From 4e580ccb607419b2f5909fd14d1438c9c3645fde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:47:07 +0000 Subject: [PATCH 073/102] Fix packed indexer state inference Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../bert/packed_sparse_attention_indexer.cc | 30 ++++--- .../python/tools/symbolic_shape_infer.py | 8 +- ...packed_sparse_attention_indexer_op_test.cc | 86 ++++++++++++++++--- ...untime_test_python_symbolic_shape_infer.py | 8 +- 4 files changed, 103 insertions(+), 29 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 461cc03666525..a2bdb6ac0d0a3 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -191,17 +191,21 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" - << " let old_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" - << " let old_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" + << " let raw_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" + << " let raw_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" + << " let old_key_len = clamp(raw_key_len, 0, i32(uniforms.state_capacity));\n" + << " let old_buf_len = clamp(raw_buf_len, 0, i32(uniforms.compress_ratio) - 1);\n" << " let past_len = " << past_seqlens.GetByOffset("b") << ";\n" << " let invalid = " << cu_seqlens.GetByOffset("0") << " != 0 || " << cu_seqlens.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || past_len < 0 || " - << "old_key_len < 0 || old_key_len > i32(uniforms.state_capacity) || old_buf_len < 0 || " - << "old_buf_len >= i32(uniforms.compress_ratio) || " - << "old_key_len != past_len / i32(uniforms.compress_ratio) || " - << "old_buf_len != past_len % i32(uniforms.compress_ratio);\n" + << "raw_key_len < 0 || raw_key_len > i32(uniforms.state_capacity) || raw_buf_len < 0 || " + << "raw_buf_len >= i32(uniforms.compress_ratio) || " + << "raw_key_len != past_len / i32(uniforms.compress_ratio) || " + << "raw_buf_len != past_len % i32(uniforms.compress_ratio);\n" << " if (invalid) {\n" + << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len") << "\n" + << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "old_buf_len") << "\n" << " " << overflow_flags.SetByOffset("b", "1") << "\n" << " return;\n" << " }\n" @@ -486,15 +490,19 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" << " let req_start = " << sequence_metadata.GetByOffset("b") << ";\n" << " let req_end = " << sequence_metadata.GetByOffset("b + 1u") << ";\n" - << " let old_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" - << " let old_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" + << " let raw_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" + << " let raw_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" + << " let old_key_len = clamp(raw_key_len, 0, i32(uniforms.state_capacity));\n" + << " let old_buf_len = clamp(raw_buf_len, 0, i32(uniforms.buffer_capacity));\n" << " let invalid = " << sequence_metadata.GetByOffset("0") << " != 0 || " << sequence_metadata.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || " - << sequence_metadata.GetByOffset("uniforms.batch_size + 1u + b") << " < 0 || old_key_len < 0 || " - << "old_key_len > i32(uniforms.state_capacity) || old_buf_len < 0 || " - << "old_buf_len > i32(uniforms.buffer_capacity);\n" + << sequence_metadata.GetByOffset("uniforms.batch_size + 1u + b") << " < 0 || raw_key_len < 0 || " + << "raw_key_len > i32(uniforms.state_capacity) || raw_buf_len < 0 || " + << "raw_buf_len > i32(uniforms.buffer_capacity);\n" << " if (invalid) {\n" + << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len") << "\n" + << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "old_buf_len") << "\n" << " " << overflow_flags.SetByOffset("b", "1") << "\n" << " return;\n" << " }\n" diff --git a/onnxruntime/python/tools/symbolic_shape_infer.py b/onnxruntime/python/tools/symbolic_shape_infer.py index 5d10889ed66c3..628364cd100a2 100755 --- a/onnxruntime/python/tools/symbolic_shape_infer.py +++ b/onnxruntime/python/tools/symbolic_shape_infer.py @@ -2754,11 +2754,11 @@ def copy_state_output(output_index, input_index, dtype): helper.make_tensor_value_info(node.output[output_index], dtype, get_shape_from_sympy_shape(shape)) ) - copy_state_output(2, 11, output_dtype) # present_key_state <- past_key_state - copy_state_output(3, 12, output_dtype) # present_kv_buffer <- past_kv_buffer + copy_state_output(2, 12, output_dtype) # present_key_state <- past_key_state + copy_state_output(3, 13, output_dtype) # present_kv_buffer <- past_kv_buffer if policy_mode != "qsa": - copy_state_output(4, 13, output_dtype) # present_gate_buffer <- past_gate_buffer - copy_state_output(5, 14, onnx.TensorProto.INT32) # present_state_lengths <- past_state_lengths + copy_state_output(4, 14, output_dtype) # present_gate_buffer <- past_gate_buffer + copy_state_output(5, 15, onnx.TensorProto.INT32) # present_state_lengths <- past_state_lengths def _infer_SkipGroupNorm(self, node): # noqa: N802 self._propagate_shape_and_type(node, 0, 0) diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index c455ee1b64a44..ff195803756e9 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -553,15 +553,27 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { for (int b = 0; b < p.batch_size; ++b) { const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; - const int req_len = std::max(req_end - req_start, 0); - const int old_key_len = - std::clamp(p.past_state_lengths[static_cast(b) * 2 + 0], 0, p.state_capacity); - const int old_buf_len = - std::clamp(p.past_state_lengths[static_cast(b) * 2 + 1], 0, p.compress_ratio - 1); + const int raw_key_len = p.past_state_lengths[static_cast(b) * 2 + 0]; + const int raw_buf_len = p.past_state_lengths[static_cast(b) * 2 + 1]; + const int past_sequence_length = p.past_sequence_lengths[static_cast(b)]; + const bool invalid_metadata = + p.cumulative_sequence_lengths[0] != 0 || + p.cumulative_sequence_lengths[static_cast(p.batch_size)] != total_tokens || + req_start < 0 || req_end < req_start || req_end > total_tokens || + past_sequence_length < 0 || raw_key_len < 0 || raw_key_len > p.state_capacity || + raw_buf_len < 0 || raw_buf_len >= p.compress_ratio || + raw_key_len != past_sequence_length / p.compress_ratio || + raw_buf_len != past_sequence_length % p.compress_ratio; + const int req_len = invalid_metadata ? 0 : req_end - req_start; + const int old_key_len = std::clamp(raw_key_len, 0, p.state_capacity); + const int old_buf_len = std::clamp(raw_buf_len, 0, p.compress_ratio - 1); const int pending = old_buf_len + req_len; const int full_new_block_count = pending / p.compress_ratio; - overflowed[static_cast(b)] = full_new_block_count > std::max(p.state_capacity - old_key_len, 0); + overflowed[static_cast(b)] = + invalid_metadata || full_new_block_count > std::max(p.state_capacity - old_key_len, 0); if (overflowed[static_cast(b)]) { + out.present_state_lengths[static_cast(b) * 2 + 0] = old_key_len; + out.present_state_lengths[static_cast(b) * 2 + 1] = old_buf_len; key_len_after[static_cast(b)] = old_key_len; continue; } @@ -814,6 +826,16 @@ TEST(PackedSparseAttentionIndexerTest, QsaStateCapacityOverflowIsSafe) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); } +TEST(PackedSparseAttentionIndexerTest, QsaMalformedStateLengthsAreClamped) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {4}; + problem.state_capacity = 2; + problem.past_state_lengths = {3, -1}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem))); +} + #ifdef USE_WEBGPU TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu); @@ -832,6 +854,16 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateCapacityOverflowIsRejected) problem.past_state_lengths = {2, 0}; RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); } + +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaMalformedStateLengthsAreClamped) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {4}; + problem.state_capacity = 2; + problem.past_state_lengths = {3, -1}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); +} #endif namespace { @@ -899,17 +931,27 @@ void CsaPackedReference(const CsaPackedProblem& p, CsaPackedResult& out) { for (int b = 0; b < p.batch_size; ++b) { const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; - const int req_len = std::max(req_end - req_start, 0); - const int old_key_len = - std::clamp(p.past_state_lengths[static_cast(b) * 2 + 0], 0, p.state_capacity); - const int old_buf_len = - std::clamp(p.past_state_lengths[static_cast(b) * 2 + 1], 0, buffer_capacity); + const int raw_key_len = p.past_state_lengths[static_cast(b) * 2 + 0]; + const int raw_buf_len = p.past_state_lengths[static_cast(b) * 2 + 1]; + const bool invalid_metadata = + p.cumulative_sequence_lengths[0] != 0 || + p.cumulative_sequence_lengths[static_cast(p.batch_size)] != total_tokens || + req_start < 0 || req_end < req_start || req_end > total_tokens || + p.past_sequence_lengths[static_cast(b)] < 0 || + raw_key_len < 0 || raw_key_len > p.state_capacity || + raw_buf_len < 0 || raw_buf_len > buffer_capacity; + const int req_len = invalid_metadata ? 0 : req_end - req_start; + const int old_key_len = std::clamp(raw_key_len, 0, p.state_capacity); + const int old_buf_len = std::clamp(raw_buf_len, 0, buffer_capacity); const int overlap_length = old_buf_len >= p.compress_ratio ? p.compress_ratio : 0; const int leftover_length = old_buf_len - overlap_length; const int pending = leftover_length + req_len; const int full_new_window_count = pending / p.compress_ratio; - overflowed[static_cast(b)] = full_new_window_count > std::max(p.state_capacity - old_key_len, 0); + overflowed[static_cast(b)] = + invalid_metadata || full_new_window_count > std::max(p.state_capacity - old_key_len, 0); if (overflowed[static_cast(b)]) { + out.present_state_lengths[static_cast(b) * 2 + 0] = old_key_len; + out.present_state_lengths[static_cast(b) * 2 + 1] = old_buf_len; key_len_after[static_cast(b)] = old_key_len; continue; } @@ -1200,6 +1242,16 @@ TEST(PackedSparseAttentionIndexerTest, CsaStateCapacityOverflowIsRejected) { RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f); } +TEST(PackedSparseAttentionIndexerTest, CsaMalformedStateLengthsAreClamped) { + CsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {0}; + problem.state_capacity = 2; + problem.past_state_lengths = {3, 8}; + RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f); +} + #ifdef USE_WEBGPU TEST(PackedSparseAttentionIndexerWebGpuTest, CsaFloat) { RunCsaPackedTest(MakeCsaPackedProblem(), 1.0e-5f, ProviderKind::WebGpu); @@ -1217,6 +1269,16 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, CsaStateCapacityOverflowIsRejected) problem.past_state_lengths = {1, 0}; RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f, ProviderKind::WebGpu); } + +TEST(PackedSparseAttentionIndexerWebGpuTest, CsaMalformedStateLengthsAreClamped) { + CsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 1}; + problem.past_sequence_lengths = {0}; + problem.state_capacity = 2; + problem.past_state_lengths = {3, 8}; + RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f, ProviderKind::WebGpu); +} #endif } // namespace test 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 1a566087bcba7..f568f9d650b03 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -374,6 +374,7 @@ def test_packed_sparse_attention_indexer_qsa(self): [ "query", "key", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -403,8 +404,9 @@ def test_packed_sparse_attention_indexer_qsa(self): token_budget=8, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["total_tokens", 2, 8]), + helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["total_tokens", 16]), helper.make_tensor_value_info("key", TensorProto.FLOAT16, ["total_tokens", 8]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("key_norm_weight", TensorProto.FLOAT16, [8]), helper.make_tensor_value_info("cos_cache", TensorProto.FLOAT16, [64, 8]), helper.make_tensor_value_info("sin_cache", TensorProto.FLOAT16, [64, 8]), @@ -433,6 +435,7 @@ def test_packed_sparse_attention_indexer_csa(self): [ "query", "key", + "query_norm_weight", "key_norm_weight", "cos_cache", "sin_cache", @@ -462,8 +465,9 @@ def test_packed_sparse_attention_indexer_csa(self): index_topk=3, ) inputs = [ - helper.make_tensor_value_info("query", TensorProto.FLOAT, ["total_tokens", 2, 8]), + helper.make_tensor_value_info("query", TensorProto.FLOAT, ["total_tokens", 16]), helper.make_tensor_value_info("key", TensorProto.FLOAT, ["total_tokens", 16]), + helper.make_tensor_value_info("query_norm_weight", TensorProto.FLOAT, [8]), 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]), From 2e1d2ac01bd60c1ddc7823cd1dfe3536d4c9bba6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:19:15 +0000 Subject: [PATCH 074/102] Revert "Add transactional QSA state updates" This reverts commit ea4b1b2d9bfe8ac99abd8d360136be63ee91eca3. Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../sparse/sparse_attention_indexer_common.h | 4 +- .../cuda/sparse/sparse_attention_indexer.cc | 9 -- .../sparse/sparse_attention_indexer_impl.cu | 89 ++----------------- .../sparse/sparse_attention_indexer_impl.h | 2 - .../core/graph/contrib_ops/bert_defs.cc | 31 +------ 5 files changed, 10 insertions(+), 125 deletions(-) diff --git a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h index 2c6c4b0ea37a9..a9868a8c772e4 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/sparse_attention_indexer_common.h @@ -68,14 +68,12 @@ enum OutputIndex : int { kSelectedIndices = 0, kPresentKey = 1, kPresentProjBuffer = 2, - kStateUpdateRows = 3, - kOutputCount = 4, + kOutputCount = 3, }; // A "qsa" node declares selected_indices + present_key. A "csa" node additionally declares // present_proj_buffer. constexpr int kQsaOutputCount = 2; -constexpr int kQsaStateUpdateOutputCount = 4; constexpr int kCsaOutputCount = 3; // Number of selected entries emitted per query. The capacity only depends on attributes, so it is diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index a26aaa0e08b01..d2a2cf6a479c0 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -270,10 +270,6 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { TensorShape({batch_size, sequence_length, params.capacity})); Tensor* present_key = context->Output(sai::kPresentKey, TensorShape({batch_size, params.key_cache_capacity, head_size})); - Tensor* state_update_values = - context->Output(sai::kPresentProjBuffer, TensorShape({batch_size, sequence_length, head_size})); - Tensor* state_update_rows = - context->Output(sai::kStateUpdateRows, TensorShape({batch_size, sequence_length})); ORT_RETURN_IF(selected_indices == nullptr || present_key == nullptr, "SparseAttentionIndexer: policy_mode 'qsa' requires both selected_indices and present_key outputs"); @@ -296,11 +292,6 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { reinterpret_cast(past_key->Data()), selected_indices->MutableData(), reinterpret_cast(present_key->MutableData()), - state_update_values == nullptr - ? nullptr - : reinterpret_cast(state_update_values->MutableData()), - state_update_rows == nullptr ? nullptr - : state_update_rows->MutableData(), float_workspace.get(), int_workspace.get()); } 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 ffea31c3b1857..5f94d6a27a41e 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -211,9 +211,7 @@ __global__ void AppendQsaKeyKernel(const T* key, T* present_key, SparseAttention } template -__global__ void AppendQsaKeyTailKernel( - const T* key, T* present_key, T* state_update_values, - int32_t* state_update_rows, SparseAttentionIndexerParams params) { +__global__ void AppendQsaKeyTailKernel(const T* key, T* present_key, SparseAttentionIndexerParams params) { const int64_t total = static_cast(params.batch_size) * params.sequence_length * params.head_size; for (int64_t index = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; index < total; index += static_cast(gridDim.x) * blockDim.x) { @@ -222,18 +220,9 @@ __global__ void AppendQsaKeyTailKernel( const int token = static_cast(token_row % params.sequence_length); const int batch = static_cast(token_row / params.sequence_length); const int position = params.past_sequence_length + token; - const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; - if (state_update_values != nullptr && - position % kQwenCompressRatio != kQwenCompressRatio - 1) { - state_update_values[index] = - key[token_row * params.key_row_stride + d]; - if (d == 0) { - state_update_rows[token_row] = - representative_capacity + position % kQwenCompressRatio; - } - } const int completed_tokens = params.total_sequence_length / kQwenCompressRatio * kQwenCompressRatio; if (position >= completed_tokens) { + const int representative_capacity = params.key_cache_capacity / kQwenCompressRatio; const int cache_position = representative_capacity + position % kQwenCompressRatio; present_key[(static_cast(batch) * params.key_cache_capacity + cache_position) * params.head_size + d] = key[token_row * params.key_row_stride + d]; @@ -247,8 +236,6 @@ template __global__ void QsaBuildBlockRepresentativesKernel(const T* past_key, const T* key, T* present_key, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, - T* state_update_values, - int32_t* state_update_rows, SparseAttentionIndexerParams params) { __shared__ float pooled[kQwenHeadSize]; __shared__ float reduction[kThreads]; @@ -297,66 +284,13 @@ __global__ void QsaBuildBlockRepresentativesKernel(const T* past_key, const T* k for (int d = threadIdx.x; d < kQwenHeadSize; d += blockDim.x) { const int64_t output_base = (static_cast(batch) * params.key_cache_capacity + block) * kQwenHeadSize; - const T value = from_float(LeadingRope( + present_key[output_base + d] = from_float(LeadingRope( pooled, params.rotary_width, cos_cache + rotary_offset, sin_cache + rotary_offset, d)); - present_key[output_base + d] = value; - const int token = - first_position + kQwenCompressRatio - 1 - - params.past_sequence_length; - if (state_update_values != nullptr && token >= 0 && - token < params.sequence_length) { - const int64_t token_row = - static_cast(batch) * params.sequence_length + token; - state_update_values[token_row * params.head_size + d] = value; - if (d == 0) { - state_update_rows[token_row] = block; - } - } } __syncthreads(); } } -template -__global__ void CaptureQsaStateUpdatesKernel( - const T* key, const T* present_key, T* state_update_values, - int32_t* state_update_rows, SparseAttentionIndexerParams params) { - const int64_t total = - static_cast(params.batch_size) * params.sequence_length * - params.head_size; - for (int64_t index = - static_cast(blockIdx.x) * blockDim.x + threadIdx.x; - index < total; - index += static_cast(gridDim.x) * blockDim.x) { - const int d = static_cast(index % params.head_size); - const int64_t token_row = index / params.head_size; - const int token = static_cast(token_row % params.sequence_length); - const int batch = static_cast(token_row / params.sequence_length); - const int position = params.past_sequence_length + token; - - int cache_row = position; - const T* source = key + token_row * params.key_row_stride; - if (params.use_block_representatives) { - if (position % kQwenCompressRatio == kQwenCompressRatio - 1) { - cache_row = position / kQwenCompressRatio; - source = present_key + - (static_cast(batch) * params.key_cache_capacity + - cache_row) * - params.head_size; - } else { - const int representative_capacity = - params.key_cache_capacity / kQwenCompressRatio; - cache_row = - representative_capacity + position % kQwenCompressRatio; - } - } - state_update_values[index] = source[d]; - if (d == 0) { - state_update_rows[token_row] = cache_row; - } - } -} - // Encodes a prefix-visible row as -count-1. QSA can then index that row directly without // materializing its visible positions. Non-prefix rows keep a nonnegative count and are compacted // by CompactNonPrefixVisibleKernel. @@ -1487,7 +1421,6 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int64_t* mask, const T* past_key, int32_t* selected_indices, T* present_key, - T* state_update_values, int32_t* state_update_rows, float* float_workspace, int32_t* int_workspace) { const int64_t rows = static_cast(params.batch_size) * params.sequence_length; @@ -1505,14 +1438,13 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio const int64_t new_blocks = static_cast(params.batch_size) * (completed_blocks - first_block); if (new_blocks > 0) { QsaBuildBlockRepresentativesKernel<<>>( - past_key, key, present_key, key_norm_weight, cos_cache, sin_cache, - state_update_values, state_update_rows, params); + past_key, key, present_key, key_norm_weight, cos_cache, sin_cache, params); } const int64_t new_key_elements = static_cast(params.batch_size) * params.sequence_length * params.head_size; if (new_key_elements > 0) { AppendQsaKeyTailKernel<<>>( - key, present_key, state_update_values, state_update_rows, params); + key, present_key, params); } } else { const int64_t new_key_elements = @@ -1521,14 +1453,6 @@ Status LaunchQsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio AppendQsaKeyKernel<<>>(key, present_key, params); } } - if (!params.use_block_representatives && - state_update_values != nullptr && state_update_rows != nullptr && - rows > 0) { - const int64_t update_elements = rows * params.head_size; - CaptureQsaStateUpdatesKernel - <<>>( - key, present_key, state_update_values, state_update_rows, params); - } if (rows == 0) { return CUDA_CALL(cudaGetLastError()); @@ -1735,8 +1659,7 @@ Status LaunchCsaSparseAttentionIndexer(cudaStream_t stream, const SparseAttentio #define INSTANTIATE_SPARSE_ATTENTION_INDEXER(T) \ template Status LaunchQsaSparseAttentionIndexer(cudaStream_t, const SparseAttentionIndexerParams&, \ const T*, const T*, const T*, const T*, const T*, const T*, \ - const int64_t*, const T*, int32_t*, T*, T*, int32_t*, float*, \ - int32_t*); \ + const int64_t*, 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 T*, const int64_t*, const T*, const T*, const T*, int32_t*, T*, T*, T*, float*); diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h index 8e2ce55efd7bc..f4f5ab7b5dfbb 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.h @@ -70,8 +70,6 @@ Status LaunchQsaSparseAttentionIndexer( const T* past_key, int32_t* selected_indices, T* present_key, - T* state_update_values, - int32_t* state_update_rows, float* float_workspace, int32_t* int_workspace); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 9b3868c700cbe..d8dd74ed0f28c 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2064,8 +2064,7 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex } } const size_t expected_outputs = is_qsa ? sai::kQsaOutputCount : sai::kCsaOutputCount; - if (ctx.getNumOutputs() != expected_outputs && - !(is_qsa && ctx.getNumOutputs() == sai::kQsaStateUpdateOutputCount)) { + if (ctx.getNumOutputs() != expected_outputs) { fail_shape_inference("SparseAttentionIndexer: policy_mode '", policy_mode, "' requires exactly ", expected_outputs, " declared outputs, got ", ctx.getNumOutputs()); } @@ -2141,23 +2140,6 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex SparseAttentionIndexerAppendDim(present_shape, head_size_dim); updateOutputShape(ctx, sai::kPresentKey, present_shape); } - if (ctx.getNumOutputs() == sai::kQsaStateUpdateOutputCount) { - propagateElemTypeFromInputToOutput( - ctx, sai::kQuery, sai::kPresentProjBuffer); - ONNX_NAMESPACE::TensorShapeProto update_shape; - SparseAttentionIndexerAppendDim(update_shape, batch_dim); - SparseAttentionIndexerAppendDim(update_shape, sequence_dim); - SparseAttentionIndexerAppendDim(update_shape, head_size_dim); - updateOutputShape(ctx, sai::kPresentProjBuffer, update_shape); - - updateOutputElemType( - ctx, sai::kStateUpdateRows, - ONNX_NAMESPACE::TensorProto_DataType_INT32); - ONNX_NAMESPACE::TensorShapeProto row_shape; - SparseAttentionIndexerAppendDim(row_shape, batch_dim); - SparseAttentionIndexerAppendDim(row_shape, sequence_dim); - updateOutputShape(ctx, sai::kStateUpdateRows, row_shape); - } return; } @@ -2409,17 +2391,10 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "T") .Output(2, "present_proj_buffer", - "For policy_mode 'csa', updated packed key/gate projection buffer. For policy_mode " - "'qsa', optional compact per-token state-update values with shape " - "(batch_size, sequence_length, head_size).", + "Only for policy_mode 'csa': updated packed key/gate projection buffer with shape " + "(2, batch_size, present_buffer_length, 2 * head_size).", "T", OpSchema::Optional) - .Output(3, - "state_update_rows", - "Only for policy_mode 'qsa': optional cache row written by each new token, with " - "shape (batch_size, sequence_length).", - "M", - OpSchema::Optional) .TypeConstraint("T", {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, "Constrain floating point tensors to float, float16 and bfloat16.") From 7ea6017b895e976cb949acc1b1477940da227905 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:54:01 +0000 Subject: [PATCH 075/102] Reduce packed CSA select bindings Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../bert/packed_sparse_attention_indexer.cc | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index a2bdb6ac0d0a3..3e53a50314719 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -590,9 +590,8 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe Status PackedSparseAttentionIndexerCsaSelectProgram::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& query_metadata = shader.AddInput("query_metadata", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); - const auto& head_weights = shader.AddInput("head_weights", ShaderUsage::UseUniform); const auto& position_ids = shader.AddInput("position_ids", ShaderUsage::UseUniform); const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); @@ -630,7 +629,7 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << " }\n" << " return f32(" << query.GetByOffset("base + d") << ") * inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" - << query_norm.GetByOffset("d") << ");\n" + << query_metadata.GetByOffset("d") << ");\n" << "}\n" << "fn query_value(token: u32, head: u32, d: u32, raw: vec2, b: u32) -> f32 {\n" << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" @@ -665,7 +664,8 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << " dot += query_value(token, head, d, raw, b) * f32(" << present_key_state.GetByOffset("key_base + d") << ");\n" << " }\n" - << " score += max(dot, 0.0) * f32(" << head_weights.GetByOffset("token * uniforms.num_heads + head") + << " score += max(dot, 0.0) * f32(" + << query_metadata.GetByOffset("uniforms.head_size + token * uniforms.num_heads + head") << ");\n" << " }\n" << " return score * uniforms.scale * uniforms.head_weight_scale;\n" @@ -1025,6 +1025,10 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont Tensor sequence_metadata = context.CreateGPUTensor( cu_seqlens->DataType(), TensorShape({cu_seqlens->Shape().Size() + past_seqlens->Shape().Size()})); ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cu_seqlens, *past_seqlens, sequence_metadata)); + // Keep the CSA selection shader within Metal's 10-storage-buffer-per-stage limit. + Tensor query_metadata = context.CreateGPUTensor( + query_norm->DataType(), TensorShape({query_norm->Shape().Size() + head_weights->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *query_norm, *head_weights, query_metadata)); const int64_t capacity = psai::SelectedCapacity(psai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_); Tensor* selected_indices = context.Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); @@ -1094,9 +1098,8 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont select.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, - {query_norm, ProgramTensorMetadataDependency::Type}, + {&query_metadata, ProgramTensorMetadataDependency::Type}, {present_key_state, ProgramTensorMetadataDependency::Type}, - {head_weights, ProgramTensorMetadataDependency::Type}, {position_ids, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}}) From 7e41f054d3869a17d87dd12eac5f3364ea210dfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:05:09 +0000 Subject: [PATCH 076/102] Fix CUDA test signed comparison Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda_kernels/sparse_attention_indexer_kernel_test.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index 82bd974e8dc70..9313818ff602e 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -144,7 +144,7 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) if (kernel_params.gridDim.x == kNumHeads && kernel_params.sharedMemBytes == query_prepare_shared_bytes) { ++query_prepare_nodes; } - if (kernel_params.gridDim.x == params.max_block_count && + if (kernel_params.gridDim.x == static_cast(params.max_block_count) && kernel_params.sharedMemBytes == block_score_shared_bytes) { ++block_score_nodes; } From d2bc9bc24cf377448db72e98c401c45e27d7d4ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:45:27 +0000 Subject: [PATCH 077/102] Regenerate operator documentation Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 245672c44a728..b1d068ed883f4 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -2943,29 +2943,29 @@ This version of the operator has been available since version 1 of the 'com.micr This operator implements grouped-query attention with past state (KV cache) support. It also supports optional float8, int8 or int4 quantization for the KV cache to reduce memory footprint. - + **Cache Format:** The past and present KV cache tensors are expected in a BNSH format: `(batch_size, num_heads, cache_sequence_length, head_size)`, where `cache_sequence_length` is the length of the cached key/value sequences, or the maximum sequence length when past and present buffer sharing is used. - + **Windowed KV Cache (`sliding_window_cache` attribute):** When `sliding_window_cache` is 1, the past/present buffers are window-sized instead of full-length and the operator evicts internally. Let `C` be the cache capacity (dimension 2 of `past_key`, which is also the sequence dimension of `present_key`), `W` be `local_window_size`, and `T` be the absolute number of tokens processed so far by this batch entry, i.e. `seqlens_k[b] + 1`. The scalar `total_sequence_length` input is only the batch maximum of `T`; the layout below is per batch entry, so a ragged batch gets a different resident range per entry. `C` must be at least `W`. - + After a step, rows `[0, L)` of `present_key` and `present_value` hold the `L` most recent positions in increasing position order, so row `i` holds absolute position `T - L + i`. The retained positions are always physically contiguous and start at row 0; the layout never wraps around, so a ring-buffer layout cannot be exposed through these outputs. Rows `[L, C)` are unspecified. The resident count `L` is a function of `T` alone: - + ``` G = C - W + 1 L(T) = T if T <= C L(T) = T - G * ceil((T - C) / G) otherwise ``` - + Hence `min(T, W) <= L(T) <= min(T, C)`: the whole window stays resident, and eviction reclaims `G` positions at once rather than one position per step, so consumers must not assume that the cache is kept full at `min(T, C)`. - + Because `L` depends only on `T`, the resulting layout is independent of how the tokens were split into steps: a multi-token step of `S` tokens (speculative decoding, chunked prefill) leaves exactly the layout that the same tokens would produce one at a time. Any `S >= 1` is accepted, including `S > C`; a step that would evict positions it still has to read is staged internally, so the capacity does not have to cover the step. When past context is present, the existing operator restriction still applies: `sequence_length > 1` requires `batch_size == 1`. - + An execution provider may accept only part of the `C >= W` range. A configuration with `C < W` (equivalently, `W > C`) is invalid and is rejected with `INVALID_ARGUMENT`. The CUDA implementation requires `C == W`, so there `G` is 1 and `L(T)` is `min(T, C)`; a larger capacity is rejected. The CPU implementation accepts any `C >= W`, and slack above the window amortizes compaction over `G` steps. - + To drop the last `k` tokens, for example after rejecting speculative draft tokens, re-run with the smaller `total_sequence_length` and `seqlens_k` and leave the buffer untouched. That is exact when `L(T - k) == L(T) - k`, which callers can evaluate with the formula above. Otherwise the shorter layout needs positions that have already been evicted, and the window has to be re-materialized. - + **Quantization:** When quantization is enabled, `past_key` and `past_value` inputs can be of type `float8e4m3fn`, `uint8` or `int8`. The corresponding `k_scale` and `v_scale` tensors must be provided. The operator will output `present_key` and `present_value` in same format as the `past_key` and `past_value`. @@ -3483,7 +3483,7 @@ This version of the operator has been available since version 1 of the 'com.micr ### **com.microsoft.MatMulBlockQuantizedFp8Weight** Block-scaled FP8 (E4M3) matrix multiplication with optional FP8 activation quantization. - + The weight tensor B has shape [N, K] with one FP32 scale per `block_size` consecutive K values (`b_scale` of shape [N, ceil(K / block_size)]). The scaled weight value is `B_scaled[n, k] = fp8_e4m3(B[n, k]) * b_scale[n, k / block_size]`. @@ -4338,9 +4338,9 @@ This version of the operator has been available since version 1 of the 'com.micr past_ids and present_ids may use the same allocation. Such in-place execution is transaction-safe only when the whole operator call is unconditionally committed; a caller that may select a prefix or roll back must preserve past_ids. - + Optional inputs add packed-sequence and Qwen4-Exp-style n-gram embedding support: - + - eos_token_id, when provided together with reset_on_eos != 0, causes causal history to reset at EOS boundaries: any shifted position at or before the most recent EOS strictly before the current position is replaced with eos_token_id instead of the real token. @@ -7088,7 +7088,7 @@ This version of the operator has been available since version 1 of the 'com.micr 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 + * query_norm_weight and key_norm_weight are the effective RMSNorm multipliers. 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. @@ -7899,3 +7899,5 @@ No versioning maintained for experimental ops.
T : tensor(float)
Constrain input and output types to float32 tensors.
+ + From 5550a25bae15195a23e4b7882bda7bdada706c18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:08:14 +0000 Subject: [PATCH 078/102] Fix packed CSA WebGPU uniforms and docs Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 16 +++++++++------- .../bert/packed_sparse_attention_indexer.h | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index d943a0ecc3230..0f0a285caf760 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -4750,7 +4750,7 @@ This version of the operator has been available since version 1 of the 'com.micr [total_tokens, ...] axis instead of a dense [batch_size, sequence_length, ...] axis. It selects, for every packed query token, the sparse-attention candidates that a following SparsePagedAttention (or similar) operator is allowed to read. - + Unlike SparseAttentionIndexer, this operator: * takes packed query/key tensors plus cumulative_sequence_lengths (request boundaries) and past_sequence_lengths (per-request past length) instead of a dense batch and a dense mask; @@ -4761,10 +4761,10 @@ This version of the operator has been available since version 1 of the 'com.micr rejected as a deterministic no-op on state rather than truncated or allowed to corrupt memory; * additionally emits selected_counts, the exact number of active (non -1) entries per query, so that no downstream consumer needs to scan selected_indices for its query's true count. - + Both policy_mode values keep the semantics of SparseAttentionIndexer, applied independently to each request's own packed token range and fixed-capacity state slice: - + policy_mode = "qsa" ("query sparse attention" token indexer) Processes each request's new tokens sequentially: appends raw indexer keys to the generic pending buffer, and whenever it reaches compress_ratio tokens, mean-pools it, applies RMSNorm @@ -4775,7 +4775,7 @@ This version of the operator has been available since version 1 of the 'com.micr their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing incomplete block. - + policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window count) independently per request, using that request's own buffer_length and new token count; @@ -4783,7 +4783,7 @@ This version of the operator has been available since version 1 of the 'com.micr rotated and appended to key_state. Queries are scored against every causally visible compressed entry with sum_h w_h * ReLU(q_h . k) and the index_topk highest scoring entry indices are emitted. - + Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: token_budget + compress_ratio - 1 for "qsa" (values are request-local token positions into the @@ -4793,7 +4793,8 @@ This version of the operator has been available since version 1 of the 'com.micr with attention_mode="local_plus_selected", selected_kv_source="auxiliary"; key_state is layout-compatible with a [batch_size, capacity, 1, head_size] auxiliary cache when K = V). Unused entries are -1 and selected_counts holds the exact number of used entries. - * key_norm_weight is the effective RMSNorm multiplier, exactly as in SparseAttentionIndexer. + * query_norm_weight and key_norm_weight are the effective RMSNorm multipliers, exactly as in + SparseAttentionIndexer. * 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 @@ -4803,7 +4804,7 @@ This version of the operator has been available since version 1 of the 'com.micr * cumulative_sequence_lengths, past_sequence_lengths and past_state_lengths are read directly by the device kernel; a zero-token request row (a repeated cumulative offset) is valid and simply contributes no query rows for that request. - + OgaEngine integration note: this operator only defines the ORT contrib op; wiring past_key_state / past_kv_buffer / past_gate_buffer / past_state_lengths as Engine-managed, per-request fixed-size state (analogous to a paged auxiliary cache) is expected to happen in the @@ -8058,3 +8059,4 @@ No versioning maintained for experimental ops.
Constrain input and output types to float32 tensors.
+ diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index 2607a1492e48f..c2f2238162091 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -124,6 +124,7 @@ class PackedSparseAttentionIndexerCsaSelectProgram final {"state_capacity", ProgramUniformVariableDataType::Uint32}, {"capacity", ProgramUniformVariableDataType::Uint32}, {"index_topk", ProgramUniformVariableDataType::Uint32}, + {"epsilon", ProgramUniformVariableDataType::Float32}, {"scale", ProgramUniformVariableDataType::Float32}, {"head_weight_scale", ProgramUniformVariableDataType::Float32}); From f9d719e1d5b05069e733673b03108a66a0404904 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:20:15 +0000 Subject: [PATCH 079/102] Sync target and fix deterministic CI failures Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 20 +++++++++++++------ docs/OperatorKernels.md | 2 +- .../sparse_attention_indexer_kernel_test.cc | 3 +-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index e5dc62675c6ef..8a592b49df071 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -4831,17 +4831,19 @@ This version of the operator has been available since version 1 of the 'com.micr
Scale applied to the per-head ReLU scores. Default is 1/sqrt(head_size).
state_capacity : int (required)
Fixed capacity (number of entries) of past_key_state / present_key_state. Must be > 0.
+
state_update_capacity : int
+
Only for policy_mode 'qsa': maximum number of leading token transitions captured per request. Must be in [0, 8]. Default is 0.
token_budget : int
Only for policy_mode 'qsa': maximum number of tokens selected from complete blocks. Must be > 0 and divisible by compress_ratio. Must be omitted when policy_mode is 'csa'.
-#### Inputs +#### Inputs (16 - 18)
query : T
-
Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, logical reshape, and rotary embedding.
-
key : T
-
Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first head_size channels are the Ca series and the last head_size channels the Cb series.
+
Packed indexer queries with shape (total_tokens, num_heads * head_size), before normalization, logical reshape, and rotary embedding. For policy_mode 'qsa', key may be omitted and query then contains row-wise concatenated query and key projections with shape (total_tokens, (num_heads + 1) * head_size).
+
key (optional) : T
+
Packed indexer key projection of the new tokens. Shape is (total_tokens, head_size) for policy_mode 'qsa' and (total_tokens, 2 * head_size) for policy_mode 'csa', where the first head_size channels are the Ca series and the last head_size channels the Cb series. May be omitted for policy_mode 'qsa' when query contains the packed query/key projection.
query_norm_weight : T
Effective RMSNorm multiplier of the queries, with shape (head_size).
key_norm_weight : T
@@ -4870,9 +4872,13 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'csa': buffered gate projections with the same shape as past_kv_buffer.
past_state_lengths : M
Generic per-request state length with shape (batch_size, 2). Column 0 is the key_state entry count (policy_mode 'qsa': complete-block count; 'csa': compressed-entry count); column 1 is the pending-buffer length (policy_mode 'qsa': incomplete-block length in [0, compress_ratio); 'csa': buffer length in [0, 2 * compress_ratio)).
+
state_update_capture_count (optional) : M
+
Only for policy_mode 'qsa': number of leading token transitions to capture for each request, with shape (batch_size). Values are clamped to the request length and state_update_capacity. Required when state_update_capacity is positive.
+
state_update_active (optional) : M
+
Only for policy_mode 'qsa': optional capture gate with shape (1). A zero value disables capture.
-#### Outputs +#### Outputs (6 - 7)
selected_indices : M
@@ -4887,6 +4893,8 @@ This version of the operator has been available since version 1 of the 'com.micr
Only for policy_mode 'csa': updated gate buffer with the same fixed shape as past_gate_buffer.
present_state_lengths : M
Updated generic per-request state length, with the same fixed shape as past_state_lengths.
+
state_update (optional) : T
+
Only for policy_mode 'qsa': compact transition payloads with shape (batch_size, state_update_capacity, head_size). A token that completes a compression block stores the prepared block representative; any other captured token stores its raw key. Inactive and unused slots are zero.
#### Type Constraints @@ -7302,7 +7310,7 @@ This version of the operator has been available since version 1 of the 'com.micr
sin_cache : T
Sine rotary table with the same shape as cos_cache.
mask (optional) : TB
-
Only for policy_mode 'qsa': INT64 padding mask with shape (batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. total_sequence_length is past_sequence_length + sequence_length.
+
Only for policy_mode 'qsa': INT64 padding mask with shape (batch_size, total_sequence_length). Nonzero entries are visible subject to causal masking. total_sequence_length is past_sequence_length + sequence_length. When omitted, every position through past_sequence_length plus the current query index is visible.
past_key : T
Cached indexer keys. For policy_mode 'qsa', these are raw keys; for 'csa', they are compressed keys. Shape is (batch_size, past_sequence_length, head_size), or (batch_size, max_cache_length, head_size) when a valid past_sequence_length is provided.
gate (optional) : T
diff --git a/docs/OperatorKernels.md b/docs/OperatorKernels.md index c15126cfb4f32..26800a4f14117 100644 --- a/docs/OperatorKernels.md +++ b/docs/OperatorKernels.md @@ -1125,7 +1125,7 @@ The **OpSet Version** column uses the following notation: |NhwcConv|*in* X:**T**
*in* W:**T**
*in* B:**T**
*out* Y:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedAttention|*in* input:**T**
*in* weights:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| |PackedMultiHeadAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* bias:**T**
*in* token_offset:**M**
*in* cumulative_sequence_length:**M**
*in* attention_bias:**T**
*out* output:**T**|1+|**T** = tensor(float), tensor(float16)| -|PackedSparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* query_norm_weight:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* cumulative_sequence_lengths:**M**
*in* past_sequence_lengths:**M**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_key_state:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*in* past_state_lengths:**M**
*out* selected_indices:**M**
*out* selected_counts:**M**
*out* present_key_state:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**
*out* present_state_lengths:**M**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| +|PackedSparseAttentionIndexer|*in* query:**T**
*in* key:**T**
*in* query_norm_weight:**T**
*in* key_norm_weight:**T**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* cumulative_sequence_lengths:**M**
*in* past_sequence_lengths:**M**
*in* gate:**T**
*in* position_bias:**T**
*in* head_weights:**T**
*in* position_ids:**I**
*in* past_key_state:**T**
*in* past_kv_buffer:**T**
*in* past_gate_buffer:**T**
*in* past_state_lengths:**M**
*in* state_update_capture_count:**M**
*in* state_update_active:**M**
*out* selected_indices:**M**
*out* selected_counts:**M**
*out* present_key_state:**T**
*out* present_kv_buffer:**T**
*out* present_gate_buffer:**T**
*out* present_state_lengths:**M**
*out* state_update:**T**|1+|**I** = tensor(int64)
**M** = tensor(int32)
**T** = tensor(bfloat16), tensor(float), tensor(float16)| |PagedAttention|*in* query:**T**
*in* key:**T**
*in* value:**T**
*in* key_cache:**T_CACHE**
*in* value_cache:**T_CACHE**
*in* cumulative_sequence_length:**S**
*in* past_seqlens:**S**
*in* block_table:**S**
*in* cos_cache:**T**
*in* sin_cache:**T**
*in* slot_mapping:**S**
*in* head_sink:**T**
*in* q_norm_weight:**T**
*in* k_norm_weight:**T**
*in* k_scale:**T_KV_SCALE**
*in* v_scale:**T_KV_SCALE**
*in* attention_metadata:**S**
*out* output:**T**
*out* key_cache_out:**T_CACHE**
*out* value_cache_out:**T_CACHE**|1+|**S** = tensor(int32)
**T** = tensor(bfloat16), tensor(float16)
**T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8), tensor(uint8)
**T_KV_SCALE** = tensor(float)| |QAttention|*in* input:**T1**
*in* weight:**T2**
*in* bias:**T3**
*in* input_scale:**T3**
*in* weight_scale:**T3**
*in* mask_index:**T4**
*in* input_zero_point:**T1**
*in* weight_zero_point:**T2**
*in* past:**T3**
*out* output:**T3**
*out* present:**T3**|1+|**T1** = tensor(int8)
**T2** = tensor(int8)
**T3** = tensor(float), tensor(float16)
**T4** = tensor(int32)| |QMoE|*in* input:**T**
*in* router_probs:**T**
*in* fc1_experts_weights:**T1**
*in* fc1_scales:**T2**
*in* fc1_experts_bias:**T**
*in* fc2_experts_weights:**T1**
*in* fc2_scales:**T2**
*in* fc2_experts_bias:**T**
*in* fc3_experts_weights:**T1**
*in* fc3_scales:**T2**
*in* fc3_experts_bias:**T**
*in* fc1_zero_points:**T1**
*in* fc2_zero_points:**T1**
*in* fc3_zero_points:**T1**
*in* router_weights:**T**
*in* fc1_global_scale:**T4**
*in* fc2_global_scale:**T4**
*in* fc1_act_scale:**T4**
*in* fc2_act_scale:**T4**
*in* fc1_act_block_scale:**T2**
*in* fc2_act_block_scale:**T2**
*out* output:**T**|1+|**T** = tensor(bfloat16), tensor(float16)
**T1** = tensor(float8e4m3fn), tensor(uint8)
**T2** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(float8e8m0)
**T4** = tensor(float)| diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index 9313818ff602e..0d2062c99ad49 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -140,12 +140,11 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) } const size_t query_prepare_shared_bytes = (kHeadSize + kThreads) * sizeof(float); - const size_t block_score_shared_bytes = (2 * kHeadSize + kThreads) * sizeof(float); if (kernel_params.gridDim.x == kNumHeads && kernel_params.sharedMemBytes == query_prepare_shared_bytes) { ++query_prepare_nodes; } if (kernel_params.gridDim.x == static_cast(params.max_block_count) && - kernel_params.sharedMemBytes == block_score_shared_bytes) { + kernel_params.sharedMemBytes == 0) { ++block_score_nodes; } } From c52a153ee955feba523ff58c66484d8006ffc035 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:17:52 +0000 Subject: [PATCH 080/102] Address SparseAttentionIndexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../cuda/sparse/sparse_attention_indexer.cc | 2 ++ .../sparse/sparse_attention_indexer_impl.cu | 8 +++----- onnxruntime/core/graph/contrib_ops/bert_defs.cc | 3 +++ .../sparse_attention_indexer_op_test.cc | 17 +++++++++++++++++ 4 files changed, 25 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 23db2ebed94bc..0e99c050b7767 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -155,6 +155,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { 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"); + ORT_RETURN_IF_ERROR(CheckIntDimension("query width", query_shape[2], false)); 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, @@ -298,6 +299,7 @@ Status SparseAttentionIndexer::ComputeCsa(OpKernelContext* context) const { 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"); + ORT_RETURN_IF_ERROR(CheckIntDimension("query width", query_shape[2], false)); const int64_t num_heads = query_shape[2] / head_size; ORT_RETURN_IF_ERROR(CheckIntDimension("batch_size", batch_size)); ORT_RETURN_IF_ERROR(CheckIntDimension("sequence_length", sequence_length)); 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 3ebec773aed90..54d0f6a43fc7b 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -37,8 +37,7 @@ Status ConfigureDynamicSharedMemory(Kernel kernel, size_t bytes, const char* ker ORT_RETURN_IF(bytes > static_cast(max_bytes), "SparseAttentionIndexer: ", kernel_name, " requires ", bytes, " bytes of dynamic shared memory, but the device supports at most ", max_bytes); - return CUDA_CALL( - cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, static_cast(bytes))); + return CUDA_CALL(cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, max_bytes)); } __device__ __forceinline__ float NegativeInfinity() { return -CUDART_INF_F; } @@ -94,8 +93,7 @@ __device__ __forceinline__ void ScanForNext(const float* scores, int count, floa 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)) { + for (int64_t candidate = threadIdx.x; candidate < count; candidate += blockDim.x) { const float value = scores[candidate]; if (previous_index >= 0 && !(value < previous_score || (value == previous_score && candidate > previous_index))) { @@ -104,7 +102,7 @@ __device__ __forceinline__ void ScanForNext(const float* scores, int count, floa if (*best_index < 0 || value > *best_value || (value == *best_value && candidate < *best_index)) { *best_value = value; - *best_index = candidate; + *best_index = static_cast(candidate); } } } diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 94027a476e46e..bf6a0fbd36a58 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2075,6 +2075,9 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex if (query_width_dim.has_dim_value() && query_width_dim.dim_value() <= 0) { fail_shape_inference("SparseAttentionIndexer: query width must be > 0, got ", query_width_dim.dim_value()); } + if (query_width_dim.has_dim_value() && query_width_dim.dim_value() > std::numeric_limits::max()) { + fail_shape_inference("SparseAttentionIndexer: query width must be no greater than INT_MAX"); + } if (query_norm_shape == nullptr) { return; } 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 cb7515cd9c9d2..341b612f7e5fb 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1012,6 +1012,14 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedQsaCapacity) { "selected capacity no greater than INT_MAX"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedQsaQueryWidth) { + QsaGraphOptions options; + options.num_heads = static_cast(std::numeric_limits::max()) / 2 + 1; + options.head_size = 2; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddQsaNode(builder, options); }, + "query width must be no greater than INT_MAX"); +} + TEST(SparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { QsaGraphOptions options; options.add_csa_inputs = true; @@ -1026,6 +1034,15 @@ TEST(SparseAttentionIndexerShapeInferenceTest, RejectsCsaWithQsaAttribute) { "token_budget must not be set when policy_mode is 'csa'"); } +TEST(SparseAttentionIndexerShapeInferenceTest, RejectsOversizedCsaQueryWidth) { + CsaGraphOptions options; + options.num_heads = static_cast(std::numeric_limits::max()) / 2 + 1; + options.head_size = 2; + options.rotary_width = 1; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddCsaNode(builder, options); }, + "query width must be no greater than INT_MAX"); +} + // 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) { From 755e2894c919e08ba39bb211f35be6b4bb55f0d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:38:55 +0000 Subject: [PATCH 081/102] Address non-finite CSA softmax review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 4 ++-- .../cuda/sparse_attention_indexer.md | 8 ++++--- .../sparse/sparse_attention_indexer_impl.cu | 2 +- .../core/graph/contrib_ops/bert_defs.cc | 3 ++- .../sparse_attention_indexer_op_test.cc | 24 ++++++++++++++++++- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index b1d068ed883f4..f479ac50b1d7a 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7077,7 +7077,8 @@ This version of the operator has been available since version 1 of the 'com.micr 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. + highest scoring entry indices. If a channel's softmax denominator is non-positive or non-finite, + its pooled value is zero. Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: @@ -7900,4 +7901,3 @@ No versioning maintained for experimental ops.
Constrain input and output types to float32 tensors.
- diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index ff062cb226270..fe84597da48c2 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -206,7 +206,9 @@ For window `w`, a `2r`-slot pooling window is built from `[B, 2r, D]` values and `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`. +`key_norm_weight`, and finally rotated at absolute position `(Pc + w) * r`. If the softmax +denominator is non-positive or non-finite (for example, all logits are `-inf`), the pooled value +for that channel is zero. ### Scoring and selection @@ -299,8 +301,8 @@ the main performance follow-up below. 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`. +- The `2r`-slot softmax is a two-pass (max-subtracted) formulation. A non-positive or non-finite + denominator falls back to a zero pooled value, so non-finite gate columns do not 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 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 54d0f6a43fc7b..ea80d8027150e 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer_impl.cu @@ -491,7 +491,7 @@ __global__ void CsaCompressKernel(const T* key, const T* gate, const T* past_kv_ 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; + pooled[d] = denominator > 0.0f && isfinite(denominator) ? accumulator / denominator : 0.0f; } __syncthreads(); diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index bf6a0fbd36a58..6aa40b74c068d 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2221,7 +2221,8 @@ decoders, chosen with the policy_mode attribute: 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. + highest scoring entry indices. If a channel's softmax denominator is non-positive or non-finite, + its pooled value is zero. Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: 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 341b612f7e5fb..0d52a226b5297 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -544,7 +544,8 @@ void CsaReference(const CsaProblem& problem, std::vector& selected, denominator += weight; accumulator += weight * values[slot]; } - pooled[static_cast(d)] = accumulator / denominator; + pooled[static_cast(d)] = + denominator > 0.0f && std::isfinite(denominator) ? accumulator / denominator : 0.0f; } pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); @@ -1129,6 +1130,27 @@ TEST(SparseAttentionIndexerTest, CsaFloat16) { RunCsaTest(MakeCsaProb TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProblem(), 3.0e-2f); } +template +void RunCsaNonFiniteSoftmaxTest(float tolerance) { + CsaProblem problem = MakeCsaProblem(); + problem.gate.assign(problem.gate.size(), -std::numeric_limits::infinity()); + problem.past_gate_buffer.assign(problem.past_gate_buffer.size(), -std::numeric_limits::infinity()); + problem.position_bias.assign(problem.position_bias.size(), 0.0f); + RunCsaTest(problem, tolerance); +} + +TEST(SparseAttentionIndexerTest, CsaFloatNonFiniteSoftmaxFallsBackToZero) { + RunCsaNonFiniteSoftmaxTest(1.0e-5f); +} + +TEST(SparseAttentionIndexerTest, CsaFloat16NonFiniteSoftmaxFallsBackToZero) { + RunCsaNonFiniteSoftmaxTest(4.0e-3f); +} + +TEST(SparseAttentionIndexerTest, CsaBFloat16NonFiniteSoftmaxFallsBackToZero) { + RunCsaNonFiniteSoftmaxTest(3.0e-2f); +} + TEST(SparseAttentionIndexerTest, CsaFloat16ScoresNewKeysBeforeCacheRounding) { CsaProblem problem; problem.batch_size = 1; From 2ab40d9d713b5aa80a77dbb62c4dea28d664a4d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:40:19 +0000 Subject: [PATCH 082/102] Cover positive infinity in CSA softmax tests Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 5 ++--- docs/contrib_ops/cuda/sparse_attention_indexer.md | 4 ++-- onnxruntime/core/graph/contrib_ops/bert_defs.cc | 4 ++-- .../contrib_ops/sparse_attention_indexer_op_test.cc | 13 ++++++++----- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index f479ac50b1d7a..2d866a8b96a55 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -7077,8 +7077,8 @@ This version of the operator has been available since version 1 of the 'com.micr 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. If a channel's softmax denominator is non-positive or non-finite, - its pooled value is zero. + highest scoring entry indices. If a channel's softmax denominator is non-positive or non-finite + (for example, all logits are -inf or any logit is +inf), its pooled value is zero. Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: @@ -7900,4 +7900,3 @@ No versioning maintained for experimental ops.
T : tensor(float)
Constrain input and output types to float32 tensors.
- diff --git a/docs/contrib_ops/cuda/sparse_attention_indexer.md b/docs/contrib_ops/cuda/sparse_attention_indexer.md index fe84597da48c2..8d297856e86a2 100644 --- a/docs/contrib_ops/cuda/sparse_attention_indexer.md +++ b/docs/contrib_ops/cuda/sparse_attention_indexer.md @@ -207,8 +207,8 @@ For window `w`, a `2r`-slot pooling window is built from `[B, 2r, D]` values and 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`. If the softmax -denominator is non-positive or non-finite (for example, all logits are `-inf`), the pooled value -for that channel is zero. +denominator is non-positive or non-finite (for example, all logits are `-inf` or any logit is +`+inf`), the pooled value for that channel is zero. ### Scoring and selection diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 6aa40b74c068d..cbdad74e4604a 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2221,8 +2221,8 @@ decoders, chosen with the policy_mode attribute: 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. If a channel's softmax denominator is non-positive or non-finite, - its pooled value is zero. + highest scoring entry indices. If a channel's softmax denominator is non-positive or non-finite + (for example, all logits are -inf or any logit is +inf), its pooled value is zero. Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: 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 0d52a226b5297..cceb1b2285eeb 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1132,11 +1132,14 @@ TEST(SparseAttentionIndexerTest, CsaBFloat16) { RunCsaTest(MakeCsaProb template void RunCsaNonFiniteSoftmaxTest(float tolerance) { - CsaProblem problem = MakeCsaProblem(); - problem.gate.assign(problem.gate.size(), -std::numeric_limits::infinity()); - problem.past_gate_buffer.assign(problem.past_gate_buffer.size(), -std::numeric_limits::infinity()); - problem.position_bias.assign(problem.position_bias.size(), 0.0f); - RunCsaTest(problem, tolerance); + for (const float non_finite : {-std::numeric_limits::infinity(), + std::numeric_limits::infinity()}) { + CsaProblem problem = MakeCsaProblem(); + problem.gate.assign(problem.gate.size(), non_finite); + problem.past_gate_buffer.assign(problem.past_gate_buffer.size(), non_finite); + problem.position_bias.assign(problem.position_bias.size(), 0.0f); + RunCsaTest(problem, tolerance); + } } TEST(SparseAttentionIndexerTest, CsaFloatNonFiniteSoftmaxFallsBackToZero) { From a9237ca3182a80f2bee41a7a2bf179a053eacd3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:58:09 +0000 Subject: [PATCH 083/102] Fix symbolic shape test formatting Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/python/onnxruntime_test_python_symbolic_shape_infer.py | 2 ++ 1 file changed, 2 insertions(+) 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 6a12403e9a920..dd83fb98af0a7 100644 --- a/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py +++ b/onnxruntime/test/python/onnxruntime_test_python_symbolic_shape_infer.py @@ -358,6 +358,7 @@ def test_sparse_attention_indexer_csa_symbolic_fallback(self): self.assertEqual(buffer_shape[1], "batch") self.assertTrue(compressed_shape[1].startswith("SparseAttentionIndexer_")) self.assertTrue(buffer_shape[2].startswith("SparseAttentionIndexer_")) + def test_dynamic_sparse_attention_separate_qkv_with_past(self): inputs = [ helper.make_tensor_value_info("query", TensorProto.FLOAT16, ["b", "s", 32]), @@ -533,6 +534,7 @@ def infer(query_width, packed): separate_graph = infer(33, False) self.assertEqual(separate_graph.output[0].type.tensor_type.shape.dim[2].dim_value, 33) self.assertFalse(separate_graph.output[1].type.tensor_type.HasField("shape")) + def test_unsqueeze_opset_11(self): graph = helper.make_graph( [ From fd809b3841ae0570a0b2a7fb2d0a7195e636f747 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:33:39 +0000 Subject: [PATCH 084/102] Address packed indexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 7 ++- .../packed_sparse_attention_indexer.md | 9 +-- .../sparse/packed_sparse_attention_indexer.cc | 12 +--- .../packed_sparse_attention_indexer_impl.cu | 60 ++++++++++++------- .../packed_sparse_attention_indexer_impl.h | 10 +++- .../bert/packed_sparse_attention_indexer.cc | 41 +++---------- .../bert/packed_sparse_attention_indexer.h | 6 +- .../core/graph/contrib_ops/bert_defs.cc | 18 +++--- ...packed_sparse_attention_indexer_op_test.cc | 25 ++++++-- 9 files changed, 100 insertions(+), 88 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 6b9739f9ed2aa..21932e82b87cb 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -4894,7 +4894,8 @@ This version of the operator has been available since version 1 of the 'com.micr sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. + incomplete block. QSA positions are always the request-local logical cache positions derived + from past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -4973,7 +4974,7 @@ This version of the operator has been available since version 1 of the 'com.micr
cumulative_sequence_lengths : M
Device-resident packed request boundaries with shape (batch_size + 1); cumulative_sequence_lengths[0] must be 0 and cumulative_sequence_lengths[batch_size] must equal total_tokens. Request b owns rows [cumulative_sequence_lengths[b], cumulative_sequence_lengths[b + 1]) of query/key (a repeated offset is a valid zero-token row).
past_sequence_lengths : M
-
Device-resident number of tokens already processed for each request before this call, with shape (batch_size). Used as the default absolute query position when position_ids is omitted (policy_mode 'qsa'), and to validate state consistency.
+
Device-resident number of tokens already processed for each request before this call, with shape (batch_size). Used to derive request-local logical query positions for policy_mode 'qsa' and to validate state consistency.
gate (optional) : T
Only for policy_mode 'csa': gate projection of the new tokens with shape (total_tokens, 2 * head_size).
position_bias (optional) : T
@@ -4981,7 +4982,7 @@ This version of the operator has been available since version 1 of the 'com.micr
head_weights (optional) : T
Only for policy_mode 'csa': per-head score weights with shape (total_tokens, num_heads).
position_ids (optional) : I
-
Optional for policy_mode 'qsa', required for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).
+
Only for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).
past_key_state : T
Generic fixed-capacity state: policy_mode 'qsa' stores prepared complete-block keys; policy_mode 'csa' stores compressed keys. Shape is (batch_size, state_capacity, head_size) and never changes across calls.
past_kv_buffer : T
diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md index 6f7f7e8ba0bf5..b6d4c0b3fefed 100644 --- a/docs/contrib_ops/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -70,7 +70,7 @@ later slot keeps its fixed index. | 8 | `gate` | `(total_tokens, 2*head_size)` | T | csa only | | 9 | `position_bias` | `(compress_ratio, 2*head_size)` | T | csa only | | 10 | `head_weights` | `(total_tokens, num_heads)` | T | csa only | -| 11 | `position_ids` | `(total_tokens)` | int64 | optional qsa / required csa | +| 11 | `position_ids` | `(total_tokens)` | int64 | omitted qsa / required csa | | 12 | `past_key_state` | `(batch_size, state_capacity, head_size)` | T | both (generic) | | 13 | `past_kv_buffer` | `(batch_size, 2*compress_ratio-1, width)` | T | both (generic) | | 14 | `past_gate_buffer` | same shape as `past_kv_buffer` | T | csa only | @@ -182,7 +182,9 @@ dense `qsa` policy but against fixed-capacity state instead of a growing cache: `past_sequence_lengths[b] + local_offset` — i.e. the request's own absolute token position, which is exactly what a per-request main paged KV cache is addressed by. The output is therefore directly consumable by `SparsePagedAttention` configured with `attention_mode="selected_only"`, -`selected_kv_source="main"`. +`selected_kv_source="main"`. `position_ids` must be omitted for `qsa`; allowing an unrelated +absolute-position space would make these cache indices ambiguous and would rotate queries +inconsistently with prepared block keys. ## 6. Policy `csa` @@ -200,8 +202,7 @@ mapping are packed: rejects (caps) new windows beyond `state_capacity` as described in [§3](#3-generic-state-shared-by-both-policies). 2. **Score** (one launch per query token, per compressed entry): every entry is scored with `sum_h w_h * ReLU(q_h . k)` and masked by the causal threshold from `position_ids` (required for - `csa`, unlike `qsa` where it is an optional override of the default `past_sequence_length + - local offset`). + `csa`; `qsa` always derives request-local logical positions from `past_sequence_lengths`). 3. **Select** (one launch per query token): keeps the `index_topk` highest scoring, causally visible entries and writes the exact active count to `selected_counts`. diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index ea30d4402381b..0e9e3e2d8325c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -154,9 +154,10 @@ Status PackedSparseAttentionIndexer::ComputeInternal(OpKernelContext* context } const bool position_ids_provided = psai::kPositionIds < context->InputCount() && context->Input(psai::kPositionIds) != nullptr; - ORT_RETURN_IF(!is_qsa && !position_ids_provided, + ORT_RETURN_IF(position_ids_provided == is_qsa, "PackedSparseAttentionIndexer: input ", psai::kPositionIds, - " (position_ids) is required for policy_mode 'csa'"); + is_qsa ? " (position_ids) must be omitted for policy_mode 'qsa'" + : " (position_ids) is required for policy_mode 'csa'"); const bool gate_buffer_provided = psai::kPastGateBuffer < context->InputCount() && context->Input(psai::kPastGateBuffer) != nullptr; ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: input ", psai::kPastGateBuffer, @@ -178,7 +179,6 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const Tensor* sin_cache = context->Input(psai::kSinCache); const Tensor* cumulative_sequence_lengths = context->Input(psai::kCumulativeSequenceLengths); const Tensor* past_sequence_lengths = context->Input(psai::kPastSequenceLengths); - const Tensor* position_ids = context->Input(psai::kPositionIds); const Tensor* past_key_state = context->Input(psai::kPastKeyState); const Tensor* past_kv_buffer = context->Input(psai::kPastKvBuffer); const Tensor* past_state_lengths = context->Input(psai::kPastStateLengths); @@ -219,9 +219,6 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); ORT_RETURN_IF_ERROR(CheckShape(query_norm_weight, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(key_norm_weight, "key_norm_weight", {head_size})); - if (position_ids != nullptr) { - ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); - } RotaryCacheShape rotary; ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary)); @@ -277,7 +274,6 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con params.state_capacity = static_cast(state_capacity); params.buffer_capacity = static_cast(buffer_capacity); params.capacity = static_cast(capacity); - params.has_position_ids = position_ids != nullptr; params.epsilon = epsilon_; params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.block_topk = static_cast(token_budget_ / compress_ratio_); @@ -296,7 +292,6 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con reinterpret_cast(sin_cache->Data()), cumulative_sequence_lengths->Data(), past_sequence_lengths->Data(), - position_ids != nullptr ? position_ids->Data() : nullptr, reinterpret_cast(past_key_state->Data()), reinterpret_cast(past_kv_buffer->Data()), past_state_lengths->Data(), @@ -428,7 +423,6 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con params.state_capacity = static_cast(state_capacity); params.buffer_capacity = static_cast(buffer_capacity); params.capacity = static_cast(capacity); - params.has_position_ids = true; params.epsilon = epsilon_; params.scale = has_scale_ ? scale_ : 1.0f / std::sqrt(static_cast(head_size)); params.index_topk = static_cast(index_topk_); diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 620247fc41d48..4996ec2ccead5 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -48,6 +48,18 @@ namespace { // The block reductions below halve the active thread count, so this must stay a power of two. constexpr int kThreads = 128; +template +Status ConfigureDynamicSharedMemory(Kernel kernel, size_t bytes, const char* kernel_name) { + int device = 0; + int max_bytes = 0; + CUDA_RETURN_IF_ERROR(cudaGetDevice(&device)); + CUDA_RETURN_IF_ERROR( + cudaDeviceGetAttribute(&max_bytes, cudaDevAttrMaxSharedMemoryPerBlockOptin, device)); + ORT_RETURN_IF_ERROR(ValidatePackedSparseAttentionIndexerDynamicSharedMemory( + bytes, static_cast(max_bytes), kernel_name)); + return CUDA_CALL(cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, max_bytes)); +} + // Largest b such that cumulative_sequence_lengths[b] <= token, assuming the array is nondecreasing. // If the data itself is malformed this may attribute a token to the wrong request, but the result // is always an index in [0, batch_size), so it can never cause an out-of-bounds access. @@ -239,9 +251,9 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig __syncthreads(); const int64_t abs_position = - params.has_position_ids - ? position_ids[token] - : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + kUseLeadingRope + ? static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]) + : position_ids[token]; const int position = SaiClampPosition(abs_position, params.max_rotary_length); const int64_t cache_offset = (static_cast(params.cos_cache_batched ? batch : 0) * params.max_rotary_length + position) * @@ -265,7 +277,7 @@ __global__ void PackedRotateQueryKernel(const T* query, const T* query_norm_weig template __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* query_rotated, const int32_t* cumulative_sequence_lengths, - const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* past_sequence_lengths, const int32_t* present_state_lengths, float* block_scores, PackedSparseAttentionIndexerParams params) { extern __shared__ float reduction[]; @@ -284,9 +296,7 @@ __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* que } const int64_t abs_position = - params.has_position_ids - ? position_ids[token] - : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); const int64_t visible_block_count = causal_count < key_len_after ? causal_count : key_len_after; if (static_cast(block_index) >= visible_block_count) { @@ -319,7 +329,7 @@ __global__ void QsaBlockScoreKernel(const T* present_key_state, const float* que // whose update step was rejected for exceeding state_capacity this call (overflow_flags[batch] set) // always gets the safe empty result: indices stay -1 (already reset below) and count is 0. __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumulative_sequence_lengths, - const int32_t* past_sequence_lengths, const int64_t* position_ids, + const int32_t* past_sequence_lengths, const int32_t* present_state_lengths, const int32_t* overflow_flags, int32_t* selected_indices, int32_t* selected_counts, PackedSparseAttentionIndexerParams params) { @@ -345,9 +355,7 @@ __global__ void QsaSelectKernel(const float* block_scores, const int32_t* cumula const int key_len_after = present_state_lengths[batch * 2 + psai::kKeyStateLength]; const int64_t abs_position = - params.has_position_ids - ? position_ids[token] - : static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); + static_cast(past_sequence_lengths[batch]) + (token - cumulative_sequence_lengths[batch]); const int64_t causal_count = SaiCausalThreshold(abs_position, params.compress_ratio); const int64_t visible_64 = causal_count < key_len_after ? causal_count : static_cast(key_len_after); const int visible_block_count = static_cast(visible_64 < 0 ? 0 : visible_64); @@ -740,7 +748,7 @@ Status LaunchQsaPackedSparseAttentionIndexer( cudaStream_t stream, const PackedSparseAttentionIndexerParams& params, const T* query, const T* key, const T* query_norm_weight, const T* key_norm_weight, const T* cos_cache, const T* sin_cache, const int32_t* cumulative_sequence_lengths, - const int32_t* past_sequence_lengths, const int64_t* position_ids, const T* past_key_state, + const int32_t* past_sequence_lengths, const T* past_key_state, const T* past_kv_buffer, const int32_t* past_state_lengths, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, float* float_workspace, int32_t* overflow_flags) { @@ -768,8 +776,11 @@ Status LaunchQsaPackedSparseAttentionIndexer( } const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + const size_t update_shared_bytes = 2 * value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(QsaUpdateStateKernel, update_shared_bytes, "QsaUpdateStateKernel")); const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); - QsaUpdateStateKernel<<>>( + QsaUpdateStateKernel<<>>( key, key_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, past_kv_buffer, past_state_lengths, present_key_state, present_kv_buffer, present_state_lengths, overflow_flags, params); @@ -783,21 +794,24 @@ Status LaunchQsaPackedSparseAttentionIndexer( const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); - PackedRotateQueryKernel<<>>( + const size_t rotate_shared_bytes = value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR(ConfigureDynamicSharedMemory( + PackedRotateQueryKernel, rotate_shared_bytes, "PackedRotateQueryKernel")); + PackedRotateQueryKernel<<>>( query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, - position_ids, query_rotated, params); + nullptr, query_rotated, params); if (params.state_capacity > 0) { const int64_t score_work = static_cast(params.total_tokens) * params.state_capacity; const int score_blocks = static_cast(std::min(score_work, kSaiMaxGridDimX)); QsaBlockScoreKernel<<>>( - present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, position_ids, + present_key_state, query_rotated, cumulative_sequence_lengths, past_sequence_lengths, present_state_lengths, block_scores, params); } const int token_blocks = static_cast(std::min(params.total_tokens, kSaiMaxGridDimX)); QsaSelectKernel<<>>( - block_scores, cumulative_sequence_lengths, past_sequence_lengths, position_ids, present_state_lengths, + block_scores, cumulative_sequence_lengths, past_sequence_lengths, present_state_lengths, overflow_flags, selected_indices, selected_counts, params); return CUDA_CALL(cudaGetLastError()); @@ -844,8 +858,11 @@ Status LaunchCsaPackedSparseAttentionIndexer( } const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + const size_t update_shared_bytes = value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(CsaUpdateStateKernel, update_shared_bytes, "CsaUpdateStateKernel")); const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); - CsaUpdateStateKernel<<>>( + CsaUpdateStateKernel<<>>( key, gate, key_norm_weight, cos_cache, sin_cache, position_bias, cumulative_sequence_lengths, past_sequence_lengths, past_kv_buffer, past_gate_buffer, past_state_lengths, present_key_state, present_kv_buffer, present_gate_buffer, present_state_lengths, overflow_flags, params); @@ -859,7 +876,10 @@ Status LaunchCsaPackedSparseAttentionIndexer( const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); - PackedRotateQueryKernel<<>>( + const size_t rotate_shared_bytes = value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR(ConfigureDynamicSharedMemory( + PackedRotateQueryKernel, rotate_shared_bytes, "PackedRotateQueryKernel")); + PackedRotateQueryKernel<<>>( query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, params); @@ -882,7 +902,7 @@ Status LaunchCsaPackedSparseAttentionIndexer( #define INSTANTIATE_PACKED_SPARSE_ATTENTION_INDEXER(T) \ template Status LaunchQsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ - const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, const T*, const int32_t*, \ + const T*, const T*, const int32_t*, const int32_t*, const T*, const T*, const int32_t*, \ int32_t*, \ int32_t*, T*, T*, int32_t*, float*, int32_t*); \ template Status LaunchCsaPackedSparseAttentionIndexer( \ diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index 9d92076c2ff51..7da7f4a2b8cd4 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include "core/providers/cuda/cuda_common.h" @@ -27,7 +28,6 @@ struct PackedSparseAttentionIndexerParams { int state_capacity = 0; // past_key_state.shape[1] int buffer_capacity = 0; // past_kv_buffer.shape[1] == 2 * compress_ratio - 1 int capacity = 0; // selected_indices.shape[1] - bool has_position_ids = false; float epsilon = 1e-6f; float scale = 0.0f; @@ -42,6 +42,13 @@ struct PackedSparseAttentionIndexerParams { // Scratch requirements, in float elements. size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params); size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params); +inline Status ValidatePackedSparseAttentionIndexerDynamicSharedMemory(size_t required_bytes, size_t supported_bytes, + const char* kernel_name) { + ORT_RETURN_IF(required_bytes > supported_bytes, + "PackedSparseAttentionIndexer: ", kernel_name, " requires ", required_bytes, + " bytes of dynamic shared memory, but the device supports at most ", supported_bytes); + return Status::OK(); +} // `overflow_flags` is a caller-allocated int32 scratch buffer with at least `batch_size` elements // (unused when batch_size == 0). The update kernel writes, per request, whether this call's new @@ -62,7 +69,6 @@ Status LaunchQsaPackedSparseAttentionIndexer( const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, - const int64_t* position_ids, const T* past_key_state, const T* past_kv_buffer, const int32_t* past_state_lengths, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 3e53a50314719..f38f8969307b7 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -250,10 +250,6 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); - const ShaderVariableHelper* position_ids = nullptr; - if (has_position_ids_) { - position_ids = &shader.AddInput("position_ids", ShaderUsage::UseUniform); - } const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); const auto& selected_indices = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); @@ -276,23 +272,11 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << " let p = u32(position);\n" << " let cr = uniforms.compress_ratio;\n" << " return p / cr + select(0u, 1u, p % cr == cr - 1u);\n" - << "}\n"; - if (has_position_ids_) { - shader.AdditionalImplementation() - << "fn abs_position(token: u32, b: u32) -> i32 {\n" - << " let raw = " << position_ids->GetByOffset("token", true) << ";\n" - << " if ((raw.y & 0x80000000u) != 0u) { return -2147483648; }\n" - << " if (raw.y != 0u || raw.x > 2147483647u) { return 2147483647; }\n" - << " return i32(raw.x);\n" - << "}\n"; - } else { - shader.AdditionalImplementation() - << "fn abs_position(token: u32, b: u32) -> i32 {\n" - << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" - << " return " << past_seqlens.GetByOffset("b") << " + (i32(token) - req_start);\n" - << "}\n"; - } - shader.AdditionalImplementation() + << "}\n" + << "fn abs_position(token: u32, b: u32) -> i32 {\n" + << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" + << " return " << past_seqlens.GetByOffset("b") << " + (i32(token) - req_start);\n" + << "}\n" << "fn normalized_query_value(token: u32, head: u32, d: u32) -> f32 {\n" << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" << " var square_sum = 0.0;\n" @@ -757,9 +741,9 @@ Status PackedSparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::Comput } const bool position_ids_provided = psai::kPositionIds < context.InputCount() && context.Input(psai::kPositionIds) != nullptr; - ORT_RETURN_IF(!is_qsa && !position_ids_provided, + ORT_RETURN_IF(position_ids_provided == is_qsa, "PackedSparseAttentionIndexer: position_ids is required for " - "policy_mode 'csa'"); + "policy_mode 'csa' and must be omitted for policy_mode 'qsa'"); const bool gate_buffer_provided = psai::kPastGateBuffer < context.InputCount() && context.Input(psai::kPastGateBuffer) != nullptr; ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: past_gate_buffer ", @@ -777,7 +761,6 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont const Tensor* sin_cache = context.Input(psai::kSinCache); const Tensor* cu_seqlens = context.Input(psai::kCumulativeSequenceLengths); const Tensor* past_seqlens = context.Input(psai::kPastSequenceLengths); - const Tensor* position_ids = context.Input(psai::kPositionIds); const Tensor* past_key_state = context.Input(psai::kPastKeyState); const Tensor* past_kv_buffer = context.Input(psai::kPastKvBuffer); const Tensor* past_state_lengths = context.Input(psai::kPastStateLengths); @@ -807,9 +790,6 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); ORT_RETURN_IF_ERROR(CheckShape(norm, "key_norm_weight", {head_size})); - if (position_ids != nullptr) { - ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); - } RotaryCacheShape rotary; ORT_RETURN_IF_ERROR(CheckRotaryCache(cos_cache, sin_cache, batch_size, rotary)); @@ -915,8 +895,8 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont return Status::OK(); } - PackedSparseAttentionIndexerQsaSelectProgram select{rotary.batched, position_ids != nullptr}; - select.CacheHint(rotary.batched, position_ids != nullptr) + PackedSparseAttentionIndexerQsaSelectProgram select{rotary.batched}; + select.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, {query_norm, ProgramTensorMetadataDependency::Type}, @@ -924,9 +904,6 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, {past_seqlens, ProgramTensorMetadataDependency::Type}}); - if (position_ids != nullptr) { - select.AddInput({position_ids, ProgramTensorMetadataDependency::Type}); - } select.AddInputs({{present_state_lengths, ProgramTensorMetadataDependency::Type}, {&overflow_flags, ProgramTensorMetadataDependency::Type}}) .AddOutputs({{selected_indices, ProgramTensorMetadataDependency::Type}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index c2f2238162091..cc6adf7d79b2a 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -57,10 +57,9 @@ class PackedSparseAttentionIndexerQsaUpdateProgram final class PackedSparseAttentionIndexerQsaSelectProgram final : public Program { public: - PackedSparseAttentionIndexerQsaSelectProgram(bool cos_cache_batched, bool has_position_ids) + explicit PackedSparseAttentionIndexerQsaSelectProgram(bool cos_cache_batched) : Program{"PackedSparseAttentionIndexerQsaSelect"}, - cos_cache_batched_{cos_cache_batched}, - has_position_ids_{has_position_ids} {} + cos_cache_batched_{cos_cache_batched} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"total_tokens", ProgramUniformVariableDataType::Uint32}, @@ -78,7 +77,6 @@ class PackedSparseAttentionIndexerQsaSelectProgram final private: bool cos_cache_batched_; - bool has_position_ids_; }; // One invocation per request: closes every new compression window (softmax-gated pool -> RMSNorm diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index b1c04a31096be..90a9d774289f1 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2639,8 +2639,7 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference } // Strict policy input validation: every fixed slot required by every policy must be provided; - // csa-only slots must be provided iff policy_mode is 'csa'; position_ids is optional for 'qsa' - // and required for 'csa'. + // csa-only slots must be provided iff policy_mode is 'csa'. for (int index : {psai::kQuery, psai::kKey, psai::kKeyNormWeight, psai::kCosCache, psai::kSinCache, psai::kCumulativeSequenceLengths, psai::kPastSequenceLengths, psai::kPastKeyState, psai::kPastKvBuffer, psai::kPastStateLengths}) { @@ -2655,9 +2654,10 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference : " is required when policy_mode is 'csa'"); } } - if (!is_qsa && !PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { + if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds) == is_qsa) { fail_shape_inference("PackedSparseAttentionIndexer: input ", psai::kPositionIds, - " (position_ids) is required when policy_mode is 'csa'"); + is_qsa ? " (position_ids) must be omitted when policy_mode is 'qsa'" + : " (position_ids) is required when policy_mode is 'csa'"); } if (ctx.getNumOutputs() != static_cast(psai::kFixedOutputCount)) { @@ -2902,7 +2902,8 @@ request's own packed token range and fixed-capacity state slice: sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. + incomplete block. QSA positions are always the request-local logical cache positions derived + from past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -3015,8 +3016,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(7, "past_sequence_lengths", "Device-resident number of tokens already processed for each request before this call, with " - "shape (batch_size). Used as the default absolute query position when position_ids is omitted " - "(policy_mode 'qsa'), and to validate state consistency.", + "shape (batch_size). Used to derive request-local logical query positions for policy_mode 'qsa' " + "and to validate state consistency.", "M") .Input(8, "gate", @@ -3036,8 +3037,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OpSchema::Optional) .Input(11, "position_ids", - "Optional for policy_mode 'qsa', required for policy_mode 'csa': absolute position of every " - "packed query, with shape (total_tokens).", + "Only for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).", "I", OpSchema::Optional) .Input(12, diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index ff195803756e9..c96e567a37028 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -29,6 +29,9 @@ #include "gtest/gtest.h" #include "contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h" +#ifdef USE_CUDA +#include "contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h" +#endif #include "core/graph/constants.h" #include "core/graph/model.h" #include "test/common/tensor_op_test_utils.h" @@ -106,7 +109,7 @@ struct GraphOptions { int64_t BufferCapacity(int64_t compress_ratio) { return 2 * compress_ratio - 1; } // Builds a fixed 16-input node; csa-only slots are left empty for policy_mode "qsa", as the schema -// requires. position_ids (slot 11) is optional for "qsa" and forced on for "csa". +// requires. position_ids (slot 11) is forbidden for "qsa" and forced on for "csa". void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { const bool is_csa = options.policy_mode == psai::kPolicyModeCsa; const int64_t width = is_csa ? 2 * options.head_size : options.head_size; @@ -214,6 +217,19 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndSt ONNX_NAMESPACE::TensorProto_DataType_INT32, {options.batch_size, 2}); } +#ifdef USE_CUDA +TEST(PackedSparseAttentionIndexerCudaTest, DynamicSharedMemoryBoundaryValidation) { + constexpr size_t supported_bytes = 64 * 1024; + EXPECT_STATUS_OK(contrib::cuda::ValidatePackedSparseAttentionIndexerDynamicSharedMemory( + supported_bytes, supported_bytes, "BoundaryKernel")); + const Status status = contrib::cuda::ValidatePackedSparseAttentionIndexerDynamicSharedMemory( + supported_bytes + 1, supported_bytes, "BoundaryKernel"); + ASSERT_FALSE(status.IsOK()); + EXPECT_NE(status.ErrorMessage().find("requires 65537 bytes"), std::string::npos); + EXPECT_NE(status.ErrorMessage().find("supports at most 65536"), std::string::npos); +} +#endif + #ifndef ORT_NO_EXCEPTIONS namespace { @@ -301,12 +317,11 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { "must be omitted when policy_mode is 'qsa'"); } -TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithPositionIdsAndCsaInputsMismatch) { - // position_ids alone is allowed for qsa (optional); only the csa-only slots must be omitted. +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithPositionIds) { GraphOptions options; options.add_position_ids = true; - std::unique_ptr model; - ASSERT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "position_ids) must be omitted when policy_mode is 'qsa'"); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingPositionIds) { From 761e54be392f0c220b32bd0fc0d9a5771296de3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:36:40 +0000 Subject: [PATCH 085/102] Fix packed indexer CUDA test compilation Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../sparse/packed_sparse_attention_indexer_common.h | 13 +++++++++++-- .../sparse/packed_sparse_attention_indexer_impl.cu | 2 +- .../sparse/packed_sparse_attention_indexer_impl.h | 8 -------- .../packed_sparse_attention_indexer_op_test.cc | 8 ++------ 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h index 0d55ae4c5e5e5..c457179e40a8c 100644 --- a/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h +++ b/onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h @@ -13,9 +13,11 @@ #pragma once +#include #include #include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" +#include "core/common/common.h" namespace onnxruntime { namespace contrib { @@ -31,7 +33,7 @@ using sparse_attention_indexer::TryComputeCsaWindowPlan; using sparse_attention_indexer::TryParsePolicy; // Fixed input slots. Slots 8-10 belong to policy_mode="csa" only; slot 11 (position_ids) is -// optional for "qsa" and required for "csa". Every other slot is required for both policies. +// forbidden for "qsa" and required for "csa". Every other slot is required for both policies. enum InputIndex : int { kQuery = 0, // [total_tokens, num_heads * head_size] kKey = 1, // qsa: [total_tokens, head_size]; csa: [total_tokens, 2 * head_size] @@ -44,7 +46,7 @@ enum InputIndex : int { kGate = 8, // csa only: [total_tokens, 2 * head_size] kPositionBias = 9, // csa only: [compress_ratio, 2 * head_size] kHeadWeights = 10, // csa only: [total_tokens, num_heads] - kPositionIds = 11, // optional (qsa) / required (csa): [total_tokens], int64 + kPositionIds = 11, // forbidden (qsa) / required (csa): [total_tokens], int64 kPastKeyState = 12, // generic: [batch_size, state_capacity, head_size] kPastKvBuffer = 13, // generic: [batch_size, 2 * compress_ratio - 1, width] kPastGateBuffer = 14, // csa only: same shape as past_kv_buffer @@ -81,6 +83,13 @@ SAI_HOST_DEVICE inline int64_t GenericBufferCapacity(int64_t compress_ratio) { return 2 * compress_ratio - 1; } +inline Status ValidateDynamicSharedMemory(size_t required_bytes, size_t supported_bytes, const char* kernel_name) { + ORT_RETURN_IF(required_bytes > supported_bytes, + "PackedSparseAttentionIndexer: ", kernel_name, " requires ", required_bytes, + " bytes of dynamic shared memory, but the device supports at most ", supported_bytes); + return Status::OK(); +} + } // namespace packed_sparse_attention_indexer } // namespace contrib } // namespace onnxruntime diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 4996ec2ccead5..038e6f7f24123 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -55,7 +55,7 @@ Status ConfigureDynamicSharedMemory(Kernel kernel, size_t bytes, const char* ker CUDA_RETURN_IF_ERROR(cudaGetDevice(&device)); CUDA_RETURN_IF_ERROR( cudaDeviceGetAttribute(&max_bytes, cudaDevAttrMaxSharedMemoryPerBlockOptin, device)); - ORT_RETURN_IF_ERROR(ValidatePackedSparseAttentionIndexerDynamicSharedMemory( + ORT_RETURN_IF_ERROR(psai::ValidateDynamicSharedMemory( bytes, static_cast(max_bytes), kernel_name)); return CUDA_CALL(cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, max_bytes)); } diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index 7da7f4a2b8cd4..08f67953af4d9 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -42,14 +42,6 @@ struct PackedSparseAttentionIndexerParams { // Scratch requirements, in float elements. size_t GetQsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params); size_t GetCsaPackedWorkspaceFloatCount(const PackedSparseAttentionIndexerParams& params); -inline Status ValidatePackedSparseAttentionIndexerDynamicSharedMemory(size_t required_bytes, size_t supported_bytes, - const char* kernel_name) { - ORT_RETURN_IF(required_bytes > supported_bytes, - "PackedSparseAttentionIndexer: ", kernel_name, " requires ", required_bytes, - " bytes of dynamic shared memory, but the device supports at most ", supported_bytes); - return Status::OK(); -} - // `overflow_flags` is a caller-allocated int32 scratch buffer with at least `batch_size` elements // (unused when batch_size == 0). The update kernel writes, per request, whether this call's new // blocks/windows would exceed state_capacity; when it does, the whole step is rejected for that diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index c96e567a37028..4280d27d11ca8 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -29,9 +29,6 @@ #include "gtest/gtest.h" #include "contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h" -#ifdef USE_CUDA -#include "contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h" -#endif #include "core/graph/constants.h" #include "core/graph/model.h" #include "test/common/tensor_op_test_utils.h" @@ -220,9 +217,8 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, CsaInfersFixedCapacityAndSt #ifdef USE_CUDA TEST(PackedSparseAttentionIndexerCudaTest, DynamicSharedMemoryBoundaryValidation) { constexpr size_t supported_bytes = 64 * 1024; - EXPECT_STATUS_OK(contrib::cuda::ValidatePackedSparseAttentionIndexerDynamicSharedMemory( - supported_bytes, supported_bytes, "BoundaryKernel")); - const Status status = contrib::cuda::ValidatePackedSparseAttentionIndexerDynamicSharedMemory( + EXPECT_STATUS_OK(psai::ValidateDynamicSharedMemory(supported_bytes, supported_bytes, "BoundaryKernel")); + const Status status = psai::ValidateDynamicSharedMemory( supported_bytes + 1, supported_bytes, "BoundaryKernel"); ASSERT_FALSE(status.IsOK()); EXPECT_NE(status.ErrorMessage().find("requires 65537 bytes"), std::string::npos); From d7edd06f3ecfacc280c05e944e5579b9784807f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:44:48 +0000 Subject: [PATCH 086/102] Address sparse indexer review feedback Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../packed_sparse_attention_indexer.md | 31 ++- .../cuda/sparse/sparse_attention_indexer.cc | 5 +- .../webgpu/bert/sparse_attention_indexer.cc | 50 +++-- .../webgpu/bert/sparse_attention_indexer.h | 6 +- .../sparse_attention_indexer_kernel_test.cc | 10 - ...packed_sparse_attention_indexer_op_test.cc | 23 +++ .../sparse_attention_indexer_op_test.cc | 189 ++---------------- 7 files changed, 104 insertions(+), 210 deletions(-) diff --git a/docs/contrib_ops/packed_sparse_attention_indexer.md b/docs/contrib_ops/packed_sparse_attention_indexer.md index 97163b7b6d521..bd6877cf32189 100644 --- a/docs/contrib_ops/packed_sparse_attention_indexer.md +++ b/docs/contrib_ops/packed_sparse_attention_indexer.md @@ -10,7 +10,7 @@ Source: (policy enum, selected-capacity formula and CSA window-plan arithmetic, shared unmodified with `SparseAttentionIndexer`), [packed_sparse_attention_indexer_common.h](../../onnxruntime/contrib_ops/cpu/sparse/packed_sparse_attention_indexer_common.h) -(fixed 16-input / 6-output slot map), +(fixed 18-input / 7-output slot map), [packed_sparse_attention_indexer.cc](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc) / [packed_sparse_attention_indexer_impl.cu](../../onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu) (CUDA), @@ -51,8 +51,9 @@ Attributes: | `epsilon` | default `1e-6` | RMSNorm epsilon | | `scale` | default `1/sqrt(head_size)` | per-head score scale | | `head_weight_scale` | `csa` only, default `1/sqrt(num_heads)` | head-weight score scale | +| `state_update_capacity` | `qsa` only, default `0`, range `[0, 8]` | maximum captured transitions per request | -Inputs are **fixed at 16 indices** for both policies (unlike the dense op, which uses a different +Inputs are **fixed at 18 indices** for both policies (unlike the dense op, which uses a different input/output count per policy). A slot not owned by the active policy is a *positional* optional: its `NodeProto` input name is empty rather than the slot being removed from the list, so every later slot keeps its fixed index. @@ -75,9 +76,11 @@ later slot keeps its fixed index. | 13 | `past_kv_buffer` | `(batch_size, 2*compress_ratio-1, width)` | T | both (generic) | | 14 | `past_gate_buffer` | same shape as `past_kv_buffer` | T | csa only | | 15 | `past_state_lengths` | `(batch_size, 2)` | int32, device-resident | both (generic) | +| 16 | `state_update_capture_count` | `(batch_size)` | int32, device-resident | qsa capture only | +| 17 | `state_update_active` | `(1)` | int32, device-resident | optional qsa capture gate | -Outputs are **fixed at 6 indices** for both policies (`present_gate_buffer` is declared with an -empty output name for `qsa`, the same positional-optional convention as above): +Outputs have six fixed indices plus optional slot 6. `present_gate_buffer` is declared with an +empty output name for `qsa`; `state_update` is declared only when QSA capture is enabled: | # | Name | Shape | Type | Policy | |---|---|---|---|---| @@ -87,6 +90,7 @@ empty output name for `qsa`, the same positional-optional convention as above): | 3 | `present_kv_buffer` | same shape as `past_kv_buffer` | T | both | | 4 | `present_gate_buffer` | same shape as `past_gate_buffer` | T | csa only | | 5 | `present_state_lengths` | same shape as `past_state_lengths` | int32 | both | +| 6 | `state_update` | `(batch_size, state_update_capacity, head_size)` | T | qsa capture only | `capacity` is `token_budget + compress_ratio - 1` for `qsa` and `index_topk` for `csa`, exactly the same formula (`SelectedCapacity`) used by `SparseAttentionIndexer`. @@ -129,6 +133,15 @@ and buffer compaction reads entries at or above the destination index before ove no-op: its state and state lengths remain unchanged, and its selection outputs stay empty. This never reads or writes outside a tensor's fixed extent and never silently truncates state. +**Transactional QSA capture.** When `state_update_capacity > 0`, +`state_update_capture_count[b]` requests capture of that request's leading transitions, clamped to +the request length and the configured capacity. `state_update_active == 0` disables capture for the +call. Disabled, unused, and out-of-range slots are zero. A captured token that completes a +compression block stores the prepared block representative; other tokens store their raw key. +After restoring a pre-call state snapshot, a runtime can replay the accepted prefix in order: +append raw entries to the pending buffer, and replace each block-completion transition with its +prepared representative in `key_state`. + ## 4. Packed metadata and device-side safety `cumulative_sequence_lengths` and `past_sequence_lengths` are **device-resident** tensors, read @@ -252,20 +265,22 @@ mechanics with no packed equivalent): `onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc` covers: - shape inference for `qsa` and `csa` (fixed `selected_indices`/`selected_counts` shapes, fixed - state output shapes, the strict per-slot policy validation, and the always-6-outputs contract); + state output shapes, strict per-slot policy validation, and optional capture output); - multi-request packed batches with unequal token counts, a zero-token request row, and prefill followed by decode with independent per-request state (CUDA/WebGPU, skipped without the respective execution provider); - `qsa` state-capacity overflow safety; +- active and disabled transactional QSA capture on CUDA and WebGPU; - FP32/FP16 (and CUDA-only BF16) numeric coverage against an in-file reference that mirrors this document's contract. ## 10. Known limitations and follow-ups - The reference CUDA/WebGPU kernels prioritize correctness over throughput (see the top-of-file - comments in the `.cu`/`.cc` implementations). CUDA QSA fuses query rotation into scoring and - uses a single-read deterministic block TopK for up to 32 selected blocks; larger TopK values - retain the correctness-first repeated-scan fallback. + comments in the `.cu`/`.cc` implementations). CUDA QSA fuses query rotation into scoring, uses + the shared-memory TopK path for up to 32 selected blocks, bounded partial TopK for 33–512 blocks, + and a hierarchical TopK path for common long-context Qwen shapes. Values above 512 that do not + match the hierarchical specialization retain the correctness-first repeated-scan fallback. - OgaEngine / Model Builder integration (declaring `past_key_state` etc. as Engine-managed, per-request fixed-size state, analogous to a paged auxiliary cache) is out of scope for this operator definition and is expected in a follow-up to `microsoft/onnxruntime-genai`. diff --git a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc index 8a29a2cebecab..9620206ec730a 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/sparse_attention_indexer.cc @@ -262,10 +262,7 @@ Status SparseAttentionIndexer::ComputeQsa(OpKernelContext* context) const { : total_sequence_length); params.max_block_count = static_cast(total_sequence_length / compress_ratio_); params.block_topk = static_cast(token_budget_ / compress_ratio_); - params.use_block_representatives = - mask == nullptr && context->InputCount() > sai::kPastSequenceLength && - context->Input(sai::kPastSequenceLength) != nullptr && head_size == 128 && - compress_ratio_ == 4 && num_heads == 4; + params.use_block_representatives = false; Tensor* selected_indices = context->Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, params.capacity})); diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 06da6185c0636..48adb11637446 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -122,17 +122,24 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& const auto& key_norm = shader.AddInput("key_norm_weight", ShaderUsage::UseUniform); const auto& cos_cache = shader.AddInput("cos_cache", ShaderUsage::UseUniform); const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); - const auto& mask = shader.AddInput("mask", ShaderUsage::UseUniform); const auto& selected = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); shader.AdditionalImplementation() << "fn visible(row: u32, token: u32) -> bool {\n" - << " let batch = row / uniforms.sequence_length;\n" - << " let query = row % uniforms.sequence_length;\n" - << " let offset = batch * uniforms.total_sequence_length + token;\n" - << " let mask_value = " << mask.GetByOffset("offset", true) << ";\n" - << " return token <= uniforms.past_sequence_length + query && " - "(mask_value.x != 0u || mask_value.y != 0u);\n" + << " let query = row % uniforms.sequence_length;\n"; + if (has_mask_) { + const auto& mask = shader.AddInput("mask", ShaderUsage::UseUniform); + shader.AdditionalImplementation() + << " let batch = row / uniforms.sequence_length;\n" + << " let offset = batch * uniforms.total_sequence_length + token;\n" + << " let mask_value = " << mask.GetByOffset("offset", true) << ";\n" + << " return token <= uniforms.past_sequence_length + query && " + "(mask_value.x != 0u || mask_value.y != 0u);\n"; + } else { + shader.AdditionalImplementation() + << " return token <= uniforms.past_sequence_length + query;\n"; + } + shader.AdditionalImplementation() << "}\n" << "fn visible_at(row: u32, ordinal: u32) -> u32 {\n" << " var seen = 0u;\n" @@ -589,14 +596,16 @@ Status SparseAttentionIndexer::ComputeInternal(onnxruntime::webgpu::ComputeConte const bool is_qsa = policy_ == sai::Policy::kQsa; ORT_RETURN_IF(!is_qsa && context.Input(sai::kKey) == nullptr, "SparseAttentionIndexer: key is required for policy_mode 'csa'"); - for (int index : {sai::kMask, sai::kGate, sai::kPositionBias, sai::kHeadWeights, + for (int index : {sai::kGate, sai::kPositionBias, sai::kHeadWeights, sai::kPositionIds, sai::kPastProjBuffer}) { - const bool policy_owns_slot = is_qsa ? index == sai::kMask : index != sai::kMask; + const bool policy_owns_slot = !is_qsa; const bool provided = index < context.InputCount() && context.Input(index) != nullptr; ORT_RETURN_IF(provided != policy_owns_slot, "SparseAttentionIndexer: input ", index, provided ? " must be omitted for policy_mode '" : " is required for policy_mode '", is_qsa ? sai::kPolicyModeQsa : sai::kPolicyModeCsa, "'"); } + ORT_RETURN_IF(!is_qsa && context.Input(sai::kMask) != nullptr, + "SparseAttentionIndexer: mask must be omitted for policy_mode 'csa'"); return is_qsa ? ComputeQsa(context) : ComputeCsa(context); } @@ -645,10 +654,12 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c const int64_t rotary_width = rotary_cache_shape.rotary_width; ORT_RETURN_IF_NOT(rotary_width > 0 && rotary_width % 2 == 0 && rotary_width <= head_size, "SparseAttentionIndexer: invalid qsa rotary cache shape"); - const auto& mask_shape = mask->Shape(); - ORT_RETURN_IF_NOT( - mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_length, - "SparseAttentionIndexer: qsa mask must be INT64 with shape (batch_size, total_sequence_length)"); + if (mask != nullptr) { + const auto& mask_shape = mask->Shape(); + ORT_RETURN_IF_NOT( + mask_shape.NumDimensions() == 2 && mask_shape[0] == batch_size && mask_shape[1] == total_length, + "SparseAttentionIndexer: qsa mask must be INT64 with shape (batch_size, total_sequence_length)"); + } const int64_t capacity = sai::SelectedCapacity(policy_, token_budget_, index_topk_, compress_ratio_); Tensor* selected = @@ -683,16 +694,19 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c if (rows == 0) { return Status::OK(); } - SparseAttentionIndexerQsaSelectProgram select; - select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity) + SparseAttentionIndexerQsaSelectProgram select{mask != nullptr}; + select.CacheHint(query->GetElementType(), num_heads, head_size, rotary_width, compress_ratio_, capacity, + mask != nullptr) .AddInputs({{query, ProgramTensorMetadataDependency::Type}, {present, ProgramTensorMetadataDependency::Type}, {query_norm, ProgramTensorMetadataDependency::Type}, {key_norm, ProgramTensorMetadataDependency::Type}, {cos_cache, ProgramTensorMetadataDependency::Type}, - {sin_cache, ProgramTensorMetadataDependency::Type}}) - .AddInput({mask, ProgramTensorMetadataDependency::Type, {mask->Shape().Size()}, 1}) - .AddOutput({selected, ProgramTensorMetadataDependency::Type}) + {sin_cache, ProgramTensorMetadataDependency::Type}}); + if (mask != nullptr) { + select.AddInput({mask, ProgramTensorMetadataDependency::Type, {mask->Shape().Size()}, 1}); + } + select.AddOutput({selected, ProgramTensorMetadataDependency::Type}) .SetWorkgroupSize(kWorkgroupSize) .SetDispatchGroupSize(ToUint32(rows)) .AddUniformVariables({{ToUint32(rows)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index d63fbe872cfc7..0fbfd764eb276 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -44,7 +44,8 @@ class SparseAttentionIndexerQsaConcatProgram final class SparseAttentionIndexerQsaSelectProgram final : public Program { public: - SparseAttentionIndexerQsaSelectProgram() : Program{"SparseAttentionIndexerQsaSelect"} {} + explicit SparseAttentionIndexerQsaSelectProgram(bool has_mask) + : Program{"SparseAttentionIndexerQsaSelect"}, has_mask_{has_mask} {} Status GenerateShaderCode(ShaderHelper& shader) const override; WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( {"rows", ProgramUniformVariableDataType::Uint32}, @@ -62,6 +63,9 @@ class SparseAttentionIndexerQsaSelectProgram final {"block_topk", ProgramUniformVariableDataType::Uint32}, {"epsilon", ProgramUniformVariableDataType::Float32}, {"scale", ProgramUniformVariableDataType::Float32}); + + private: + bool has_mask_; }; class SparseAttentionIndexerCsaCopyCompressedProgram final diff --git a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc index 0d2062c99ad49..4fa3727adcb53 100644 --- a/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc +++ b/onnxruntime/test/contrib_ops/cuda_kernels/sparse_attention_indexer_kernel_test.cc @@ -171,16 +171,6 @@ TEST(SparseAttentionIndexerCudaKernelTest, QsaLongContextPerformanceRegression) const float average_us = elapsed_ms * 1000.0f / kTimedIterations; RecordProperty("qsa_8k_decode_us", std::to_string(average_us)); - int device = 0; - cudaDeviceProp device_properties{}; - ASSERT_EQ(cudaSuccess, cudaGetDevice(&device)); - ASSERT_EQ(cudaSuccess, cudaGetDeviceProperties(&device_properties, device)); - const float latency_limit_us = device_properties.major >= 9 ? 1000.0f - : device_properties.major >= 8 ? 2000.0f - : 5000.0f; - EXPECT_LT(average_us, latency_limit_us) - << "8K QSA decode latency regressed on " << device_properties.name; - std::vector actual(params.capacity); ASSERT_EQ(cudaSuccess, cudaMemcpy(actual.data(), selected, actual.size() * sizeof(int32_t), cudaMemcpyDeviceToHost)); diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index bba7ff52f52ea..1008ff24ce3dd 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -1063,6 +1063,29 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, QsaPackedQueryKey) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu, nullptr, true); } +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateUpdateCapturesRawAndBlockTransitions) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 3}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); +} + +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateUpdateActiveZeroClearsAllSlots) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 5}; + problem.state_update_active = 0; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateCapacityOverflowIsRejected) { QsaPackedProblem problem; problem.batch_size = 1; 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 e6fcd3cb040fa..bebf9b83911d8 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -321,7 +321,6 @@ struct QsaProblem { float epsilon = 1.0e-6f; std::optional scale; bool shared_rotary_cache = false; - bool cache_block_representatives = false; std::vector query; std::vector key; @@ -353,7 +352,7 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std for (int t = 0; t < present_capacity; ++t) { for (int d = 0; d < head_size; ++d) { const size_t output_index = (static_cast(b) * present_capacity + t) * head_size + d; - if (!problem.cache_block_representatives && t >= problem.past_sequence_length && t < total) { + if (t >= problem.past_sequence_length && t < total) { present_key[output_index] = problem.key[(static_cast(b) * problem.sequence_length + t - problem.past_sequence_length) * head_size + @@ -366,57 +365,6 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std } } - if (problem.cache_block_representatives) { - const int representative_capacity = present_capacity / problem.compress_ratio; - const int first_block = problem.past_sequence_length / problem.compress_ratio; - const int completed_blocks = total / problem.compress_ratio; - for (int b = 0; b < problem.batch_size; ++b) { - const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); - const float* cos_base = - problem.cos_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; - const float* sin_base = - problem.sin_cache.data() + cache_batch * problem.MaxRotaryLength() * problem.rotary_width; - for (int block = first_block; block < completed_blocks; ++block) { - const int first_position = block * problem.compress_ratio; - std::vector pooled(static_cast(head_size), 0.0f); - for (int token = 0; token < problem.compress_ratio; ++token) { - const int position = first_position + token; - for (int d = 0; d < head_size; ++d) { - if (position < problem.past_sequence_length) { - const int cache_position = representative_capacity + position % problem.compress_ratio; - pooled[static_cast(d)] += - problem.past_key[(static_cast(b) * past_capacity + cache_position) * head_size + d]; - } else { - pooled[static_cast(d)] += - problem.key[(static_cast(b) * problem.sequence_length + - position - problem.past_sequence_length) * - head_size + - d]; - } - } - } - for (float& value : pooled) { - value /= static_cast(problem.compress_ratio); - } - pooled = RmsNormalize(pooled, problem.key_norm_weight, problem.epsilon); - pooled = LeadingRope(pooled, problem.rotary_width, cos_base + first_position * problem.rotary_width, - sin_base + first_position * problem.rotary_width); - std::copy(pooled.begin(), pooled.end(), - present_key.begin() + (static_cast(b) * present_capacity + block) * head_size); - } - const int completed_tokens = completed_blocks * problem.compress_ratio; - for (int position = std::max(problem.past_sequence_length, completed_tokens); position < total; ++position) { - const int cache_position = representative_capacity + position % problem.compress_ratio; - const int key_token = position - problem.past_sequence_length; - std::copy_n(problem.key.begin() + - (static_cast(b) * problem.sequence_length + key_token) * head_size, - head_size, - present_key.begin() + - (static_cast(b) * present_capacity + cache_position) * head_size); - } - } - } - selected.assign(static_cast(problem.batch_size) * problem.sequence_length * capacity, -1); for (int b = 0; b < problem.batch_size; ++b) { const size_t cache_batch = problem.shared_rotary_cache ? 0 : static_cast(b); @@ -449,27 +397,20 @@ void QsaReference(const QsaProblem& problem, std::vector& selected, std 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); - if (problem.cache_block_representatives) { + 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) * present_capacity + block) * head_size + d]; + pooled[static_cast(d)] += + present_key[(static_cast(b) * present_capacity + token) * head_size + d]; } - } else { - 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) * present_capacity + 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); } + 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) { @@ -1368,13 +1309,14 @@ TEST(SparseAttentionIndexerTest, QsaQwenSpecializedScoreAndPartialTopKBFloat16) RunQsaQwenSpecializedScoreAndPartialTopK(2.0e-2f); } -TEST(SparseAttentionIndexerTest, QsaQwenDistributedPartialTopK) { +TEST(SparseAttentionIndexerTest, QsaQwenFixedCapacityRawCacheDistributedTopK) { QsaProblem problem; problem.batch_size = 1; problem.sequence_length = 1; problem.num_heads = 4; problem.head_size = 128; problem.past_sequence_length = 8191; + problem.key_cache_capacity = 8192; problem.rotary_width = 32; problem.compress_ratio = 4; problem.token_budget = 2048; @@ -1401,103 +1343,6 @@ TEST(SparseAttentionIndexerTest, QsaQwenDistributedPartialTopKTies) { RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); } -TEST(SparseAttentionIndexerTest, QsaQwenSharedBlockRepresentatives) { - QsaProblem problem; - problem.batch_size = 1; - problem.sequence_length = 8; - problem.num_heads = 4; - problem.head_size = 128; - problem.past_sequence_length = 0; - problem.key_cache_capacity = 16; - problem.rotary_width = 32; - problem.compress_ratio = 4; - problem.token_budget = 8; - problem.cache_block_representatives = true; - problem = MakeQsaProblem(std::move(problem)); - std::fill(problem.mask.begin(), problem.mask.end(), 1); - RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); -} - -TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKAllEqual) { - QsaProblem problem; - problem.batch_size = 1; - problem.sequence_length = 1; - problem.num_heads = 4; - problem.head_size = 128; - problem.past_sequence_length = 8191; - problem.key_cache_capacity = 8192; - problem.rotary_width = 32; - problem.compress_ratio = 4; - problem.token_budget = 2048; - problem.cache_block_representatives = true; - problem = MakeQsaProblem(std::move(problem)); - std::fill(problem.mask.begin(), problem.mask.end(), 1); - std::fill(problem.query.begin(), problem.query.end(), 0.0f); - std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); - std::fill(problem.key.begin(), problem.key.end(), 0.0f); - RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); -} - -void RunQsaQwenHierarchicalTopKRepresentativeScores(bool with_ties) { - const std::string context_environment = Env::Default().GetEnvironmentVar("ORT_QSA_TEST_CONTEXT"); - const int context_length = context_environment.empty() ? 8192 : std::stoi(context_environment); - QsaProblem problem; - problem.batch_size = 1; - problem.sequence_length = 1; - problem.num_heads = 4; - problem.head_size = 128; - problem.past_sequence_length = context_length - 1; - problem.key_cache_capacity = context_length; - problem.rotary_width = 32; - problem.compress_ratio = 4; - problem.token_budget = 2048; - problem.cache_block_representatives = true; - problem = MakeQsaProblem(std::move(problem)); - std::fill(problem.mask.begin(), problem.mask.end(), 1); - std::fill(problem.query.begin(), problem.query.end(), 0.0f); - std::fill(problem.cos_cache.begin(), problem.cos_cache.end(), 1.0f); - std::fill(problem.sin_cache.begin(), problem.sin_cache.end(), 0.0f); - std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); - std::fill(problem.key.begin(), problem.key.end(), 0.0f); - for (int head = 0; head < problem.num_heads; ++head) { - problem.query[static_cast(head) * problem.head_size] = 1.0f; - } - const int completed_past_blocks = problem.past_sequence_length / problem.compress_ratio; - for (int block = 0; block < completed_past_blocks; ++block) { - problem.past_key[static_cast(block) * problem.head_size] = - static_cast(with_ties ? block % 17 : block + 1); - } - RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); -} - -TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKUniqueScores) { - RunQsaQwenHierarchicalTopKRepresentativeScores(false); -} - -TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKModerateTies) { - RunQsaQwenHierarchicalTopKRepresentativeScores(true); -} - -TEST(SparseAttentionIndexerTest, QsaQwenHierarchicalTopKMultiRowPartialTile) { - QsaProblem problem; - problem.batch_size = 2; - problem.sequence_length = 1; - problem.num_heads = 4; - problem.head_size = 128; - problem.past_sequence_length = 8202; - problem.key_cache_capacity = 8203; - problem.rotary_width = 32; - problem.compress_ratio = 4; - problem.token_budget = 2048; - problem.cache_block_representatives = true; - problem = MakeQsaProblem(std::move(problem)); - std::fill(problem.mask.begin(), problem.mask.end(), 1); - std::fill(problem.query.begin(), problem.query.end(), 0.0f); - std::fill(problem.past_key.begin(), problem.past_key.end(), 0.0f); - std::fill(problem.key.begin(), problem.key.end(), 0.0f); - RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::Cuda, false, true); -} - TEST(SparseAttentionIndexerTest, QsaExplicitZeroScale) { QsaProblem problem = MakeQsaProblem(); problem.scale = 0.0f; @@ -1622,6 +1467,12 @@ TEST(SparseAttentionIndexerWebGpuTest, QsaPackedQkFloat) { RunQsaTest(1.0e-5f, MakeQsaProblem(), ProviderKind::WebGpu, true); } +TEST(SparseAttentionIndexerWebGpuTest, QsaMasklessPrefixCausal) { + QsaProblem problem = MakeQsaProblem(); + std::fill(problem.mask.begin(), problem.mask.end(), 1); + RunQsaTest(1.0e-5f, std::move(problem), ProviderKind::WebGpu, false, true); +} + TEST(SparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaTest(4.0e-3f, MakeQsaProblem(), ProviderKind::WebGpu); } From c802d65490d6ffe28b5bf5c721274272d08a052e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:15:01 +0000 Subject: [PATCH 087/102] Restore merged contrib op schemas Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../core/graph/contrib_ops/bert_defs.cc | 802 ++++++++++++++++-- 1 file changed, 726 insertions(+), 76 deletions(-) diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index d8dd74ed0f28c..6de121f7673cf 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -485,6 +485,84 @@ void GroupQueryAttentionTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& total_sequence_length_index); } +void DynamicSparseAttentionTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx) { + ONNX_NAMESPACE::propagateElemTypeFromInputToOutput(ctx, 0, 0); + + const int64_t num_heads = getAttribute(ctx, "num_heads", 0); + const int64_t kv_num_heads = getAttribute(ctx, "kv_num_heads", 0); + if (num_heads <= 0 || num_heads > std::numeric_limits::max()) { + fail_shape_inference("num_heads must be a positive int"); + } + if (kv_num_heads <= 0 || kv_num_heads > std::numeric_limits::max() || + num_heads % kv_num_heads != 0) { + fail_shape_inference("kv_num_heads must be positive and divide num_heads"); + } + + if (hasInputShape(ctx, 0)) { + const auto& query_shape = getInputShape(ctx, 0); + const auto& query_dims = query_shape.dim(); + if (query_dims.size() != 3) { + fail_shape_inference("The query input shall be 3 dimensions"); + } + + auto output_shape = query_shape; + if (ctx.getInputType(2) == nullptr) { + output_shape.mutable_dim(2)->Clear(); + if (query_dims[2].has_dim_value()) { + const int64_t packed_heads = num_heads + 2 * kv_num_heads; + const int64_t packed_hidden_size = query_dims[2].dim_value(); + if (packed_hidden_size % packed_heads != 0) { + fail_shape_inference("Packed QKV hidden size must be divisible by num_heads + 2 * kv_num_heads"); + } + output_shape.mutable_dim(2)->set_dim_value(num_heads * (packed_hidden_size / packed_heads)); + } + } else if (query_dims[2].has_dim_value() && query_dims[2].dim_value() % num_heads != 0) { + fail_shape_inference("Query hidden size must be divisible by the number of query heads"); + } + updateOutputShape(ctx, 0, output_shape); + } + + for (int output_index = 1; output_index <= 2; ++output_index) { + const int input_index = output_index + 2; + if (ctx.getNumOutputs() <= static_cast(output_index) || !ctx.hasOutput(output_index)) { + continue; + } + + ONNX_NAMESPACE::propagateElemTypeFromInputToOutput(ctx, 0, output_index); + if (hasInputShape(ctx, input_index)) { + ONNX_NAMESPACE::propagateShapeFromInputToOutput(ctx, input_index, output_index); + continue; + } + + if (hasInputShape(ctx, 0)) { + const auto& query_dims = getInputShape(ctx, 0).dim(); + if (query_dims.size() == 3 && query_dims[2].has_dim_value()) { + const bool is_packed = ctx.getInputType(2) == nullptr; + const int64_t divisor = is_packed ? num_heads + 2 * kv_num_heads : num_heads; + if (query_dims[2].dim_value() % divisor != 0) { + fail_shape_inference("Query hidden size must be divisible by the number of query heads"); + } + const int64_t head_size = query_dims[2].dim_value() / divisor; + ONNX_NAMESPACE::TensorShapeProto present_shape; + *present_shape.add_dim() = query_dims[0]; + present_shape.add_dim()->set_dim_value(kv_num_heads); + const auto* total_length_data = ctx.getInputData(10); + if (total_length_data != nullptr) { + const auto total_lengths = ParseData(total_length_data); + if (total_lengths.size() != 1) { + fail_shape_inference("total_sequence_length must contain exactly one element"); + } + present_shape.add_dim()->set_dim_value(total_lengths[0]); + } else { + present_shape.add_dim(); + } + present_shape.add_dim()->set_dim_value(head_size); + updateOutputShape(ctx, output_index, present_shape); + } + } + } +} + void SparseAttentionTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx, int past_key_index) { constexpr int use_max_past_present_buffer = 1; constexpr int qk_output_index = -1; @@ -493,6 +571,98 @@ void SparseAttentionTypeAndShapeInference(ONNX_NAMESPACE::InferenceContext& ctx, total_sequence_length_index); } +constexpr const char* DynamicSparseAttention_ver1_doc = R"DOC( +Model-neutral sparse grouped-query attention with a contiguous main KV cache. + +`selected_indices` and `selected_counts` are produced by an external selector. The operator never computes selection +scores or TopK indices. The first `selected_counts[q]` entries in each selected-index row are valid; remaining entries +must be -1. Valid entries must be unique, non-negative request-local positions in the selected source. + +`attention_mode="selected_only"` attends only to selected entries. `attention_mode="local_plus_selected"` jointly +normalizes causally valid main-cache entries in `local_window_size` and selected auxiliary entries. An optional +per-query-head sink can participate in either mode; it contributes to the shared softmax denominator but has no value +vector. A row with no entries and no sink produces zero output. + +Supported mode/source combinations: + +- `selected_only` + `main`: Qwen4-Exp QSA-compatible execution. +- `local_plus_selected` + `auxiliary`: DeepSeek V4 CSA-compatible execution and requires `local_window_size > 0`. + +The main cache uses BNSH layout and ordinary contiguous append semantics. Selection changes reads, not cache writes. +Auxiliary K/V use BNSH layout and are read-only. When `auxiliary_kv_shared=1`, auxiliary_value may be omitted and +auxiliary_key is used as both K and V. DeepSeek-style post-attention inverse/conjugate RoPE and output projection remain +the exporter's responsibility. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + DynamicSparseAttention, 1, + OpSchema() + .SetDoc(DynamicSparseAttention_ver1_doc) + .Attr("num_heads", "Number of query heads.", AttributeProto::INT) + .Attr("kv_num_heads", "Number of main and auxiliary KV heads.", AttributeProto::INT) + .Attr("scale", "Scaling factor applied to QK. Defaults to 1/sqrt(head_size).", + AttributeProto::FLOAT, OPTIONAL_VALUE) + .Attr("is_causal", "Must be 1. DynamicSparseAttention version 1 supports causal attention only.", + AttributeProto::INT, static_cast(1)) + .Attr("local_window_size", "Number of causally visible main-cache entries in local_plus_selected mode.", + AttributeProto::INT, static_cast(-1)) + .Attr("attention_mode", "One of 'selected_only' or 'local_plus_selected'.", + AttributeProto::STRING, std::string("selected_only")) + .Attr("selected_kv_source", "Source addressed by selected indices: 'main' or 'auxiliary'.", + AttributeProto::STRING, std::string("main")) + .Attr("do_rotary", "Whether to apply rotary embedding to Q and newly appended K.", + AttributeProto::INT, static_cast(0)) + .Attr("rotary_interleaved", "Whether rotary pairs use interleaved layout.", + AttributeProto::INT, static_cast(0)) + .Attr("rotary_offset", + "First head channel covered by rotary embedding. Must be nonnegative, a multiple of 8, and leave room " + "for the rotary dimensions within head_size.", + AttributeProto::INT, static_cast(0)) + .Attr("qk_norm_epsilon", "Epsilon for optional per-head Q/K RMS normalization.", + AttributeProto::FLOAT, 1e-6f) + .Attr("smooth_softmax", "Add a zero-valued sink logit when no explicit head_sink is supplied.", + AttributeProto::INT, static_cast(0)) + .Attr("auxiliary_kv_shared", "Use auxiliary_key as both key and value when auxiliary_value is omitted.", + AttributeProto::INT, static_cast(0)) + .Input(0, "query", + "Query [batch, sequence, num_heads * head_size]. When key and value are omitted, each row contains " + "Q, K, then V concatenated with total width (num_heads + 2 * kv_num_heads) * head_size.", + "T") + .Input(1, "key", "Current main key [batch, sequence, kv_num_heads * head_size].", "T", + OpSchema::Optional) + .Input(2, "value", "Current main value [batch, sequence, kv_num_heads * head_size].", "T", + OpSchema::Optional) + .Input(3, "past_key", "Main key cache in BNSH layout.", "T", OpSchema::Optional) + .Input(4, "past_value", "Main value cache in BNSH layout.", "T", OpSchema::Optional) + .Input(5, "auxiliary_key", "Read-only auxiliary key sequence in BNSH layout.", "T", + OpSchema::Optional) + .Input(6, "auxiliary_value", "Read-only auxiliary value sequence in BNSH layout.", "T", + OpSchema::Optional) + .Input(7, "selected_indices", "Selected request-local source positions [batch * sequence, max_selected].", + "M") + .Input(8, "selected_counts", "Number of valid entries per selected-index row [batch * sequence].", "M") + .Input(9, "seqlens_k", "Total logical main sequence length minus one for each batch entry.", "M") + .Input(10, "total_sequence_length", "Maximum total main sequence length, as a scalar or one-element tensor.", + "M") + .Input(11, "cos_cache", "Rotary cosine cache [max_sequence_length, rotary_dim / 2].", "T", + OpSchema::Optional) + .Input(12, "sin_cache", "Rotary sine cache [max_sequence_length, rotary_dim / 2].", "T", + OpSchema::Optional) + .Input(13, "position_ids", "Optional rotary positions [batch, sequence].", "tensor(int64)", + OpSchema::Optional) + .Input(14, "q_norm_weight", "Optional Q RMSNorm weight [head_size].", "T", OpSchema::Optional) + .Input(15, "k_norm_weight", "Optional K RMSNorm weight [head_size].", "T", OpSchema::Optional) + .Input(16, "head_sink", + "Optional sink logit [num_heads]. Participates in the shared softmax denominator in both attention modes.", + "T", OpSchema::Optional) + .Output(0, "output", "Attention output [batch, sequence, num_heads * head_size].", "T") + .Output(1, "present_key", "Updated main key cache in BNSH layout.", "T", OpSchema::Optional) + .Output(2, "present_value", "Updated main value cache in BNSH layout.", "T", OpSchema::Optional) + .TypeConstraint("T", {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain all floating-point inputs and outputs to one element type.") + .TypeConstraint("M", {"tensor(int32)"}, "Constrain selection and sequence metadata to int32.") + .TypeAndShapeInferenceFunction(DynamicSparseAttentionTypeAndShapeInference)); + constexpr const char* Attention_ver1_doc = R"DOC( Multi-Head Attention that can be either unidirectional (like GPT-2) or bidirectional (like BERT). @@ -2087,6 +2257,9 @@ void SparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::InferenceContex if (query_width_dim.has_dim_value() && query_width_dim.dim_value() <= 0) { fail_shape_inference("SparseAttentionIndexer: query width must be > 0, got ", query_width_dim.dim_value()); } + if (query_width_dim.has_dim_value() && query_width_dim.dim_value() > std::numeric_limits::max()) { + fail_shape_inference("SparseAttentionIndexer: query width must be no greater than INT_MAX"); + } if (query_norm_shape == nullptr) { return; } @@ -2230,7 +2403,8 @@ decoders, chosen with the policy_mode attribute: 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. + highest scoring entry indices. If a channel's softmax denominator is non-positive or non-finite + (for example, all logits are -inf or any logit is +inf), its pooled value is zero. Common contract: * selected_indices is int32 with a fixed capacity that only depends on attributes: @@ -2503,9 +2677,10 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference : " is required when policy_mode is 'csa'"); } } - if (!is_qsa && !PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { + if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds) == is_qsa) { fail_shape_inference("PackedSparseAttentionIndexer: input ", psai::kPositionIds, - " (position_ids) is required when policy_mode is 'csa'"); + is_qsa ? " (position_ids) must be omitted when policy_mode is 'qsa'" + : " (position_ids) is required when policy_mode is 'csa'"); } const bool has_capture_count = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateCaptureCount); const bool has_state_update_active = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateActive); @@ -2785,7 +2960,8 @@ request's own packed token range and fixed-capacity state slice: sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. + incomplete block. QSA positions are always the request-local logical cache positions derived + from past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -2907,8 +3083,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( .Input(7, "past_sequence_lengths", "Device-resident number of tokens already processed for each request before this call, with " - "shape (batch_size). Used as the default absolute query position when position_ids is omitted " - "(policy_mode 'qsa'), and to validate state consistency.", + "shape (batch_size). Used to derive request-local logical query positions for policy_mode 'qsa' " + "and to validate state consistency.", "M") .Input(8, "gate", @@ -2928,8 +3104,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OpSchema::Optional) .Input(11, "position_ids", - "Optional for policy_mode 'qsa', required for policy_mode 'csa': absolute position of every " - "packed query, with shape (total_tokens).", + "Only for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).", "I", OpSchema::Optional) .Input(12, @@ -3676,42 +3851,6 @@ ONNX_MS_OPERATOR_SET_SCHEMA( } })); -constexpr const char* CausalConvWithState_ver1_doc = R"DOC( -Stateful causal depthwise convolution, generalized to N spatial dimensions. - -Used by Gated DeltaNet (Qwen3.5) and Mamba (Jamba, FalconMamba) as a preprocessing step. -Replaces the 3-op pattern (Concat + Conv + Slice) with a single fused operation. - -The convolution is causal (looks only at current and past positions along the last -spatial dimension) and depthwise (each channel is convolved independently with its own kernel). - -Input layout is channels-first: (batch_size, channels, ...). -Weight layout: (channels, 1, k_1, ...) for depthwise convolution. -The carry state stores the last (k-1) positions along the causal axis for incremental decode. - -The ndim attribute generalizes the op to 1D, 2D, or 3D spatial dimensions. Causality is -enforced on the last spatial dimension only. - -The optional activation attribute supports fused SiLU/Swish activation. - -The dilation attribute spaces the kernel taps along the causal axis: output position t reads -input positions t - (k_1 - 1 - j) * dilation for tap j. The receptive field therefore spans -(k_1 - 1) * dilation positions before the current one, and the carry state grows to match: -past_state and present_state hold (k_1 - 1) * dilation positions instead of k_1 - 1. Dilation 1 -(the default) is the undilated case and keeps the original state length, so models exported -before the attribute existed are unaffected. - -The channels_last attribute selects a sequence-major layout for the activations and the carry -state, so a model that already produces channels-last activations does not have to transpose into -and out of the channels-first layout. With channels_last = 1 and ndim = 1, input and output are -(batch_size, sequence_length, d_1, ..., d_n) and the state tensors are -(batch_size, state_length, d_1, ..., d_n), where channels = d_1 * ... * d_n. Any number of trailing -channel axes is accepted, so an activation that keeps hyper-connections and hidden size as separate -axes needs no reshape either. weight and bias keep their channels-first (channels, 1, k_1) and -(channels) shapes because they have no sequence axis. The computed values are identical to the -channels-first layout; only the memory layout differs. -)DOC"; - constexpr const char* NGramHashMapping_ver1_doc = R"DOC( Computes Engram n-gram hash ids from pre-compressed tokenizer ids. @@ -3829,7 +3968,6 @@ ONNX_MS_OPERATOR_SET_SCHEMA( if (ctx.getNumOutputs() > 1) { propagateElemTypeFromInputToOutput(ctx, 0, 1); } - const int64_t max_ngram_size = getAttribute(ctx, "max_ngram_size", int64_t{-1}); const int64_t n_head_per_ngram = getAttribute(ctx, "n_head_per_ngram", int64_t{-1}); if (max_ngram_size < 2) { @@ -3909,6 +4047,289 @@ ONNX_MS_OPERATOR_SET_SCHEMA( } })); +constexpr const char* VarlenNGramHashMapping_ver1_doc = R"DOC( +Computes Engram n-gram hash ids from pre-compressed tokenizer ids over a packed, token-major batch +of variable-length sequences. + +input_ids has shape (total_tokens) and hash_ids has shape (total_tokens, (max_ngram_size - 1) * +n_head_per_ngram). cumulative_sequence_length is a device-resident int32 tensor of shape +(batch_size + 1); request i occupies [cumulative_sequence_length[i], cumulative_sequence_length[i + +1]) of the packed buffer. Every request contributes at least one token. + +For n in [2, max_ngram_size], the op creates causal shifts of each request's own tokens, padding +positions before that request's start with pad_id (or its own past_ids history, see below), and +computes mix = shifted_0 * multipliers[0] xor ... xor shifted_(n-1) * multipliers[n-1]. For every +head of that n-gram order it emits mix modulo the corresponding head vocabulary size. The n-gram +window never reads tokens belonging to a different packed request; it is clamped at each request's +own boundary exactly like VarlenCausalConvWithState clamps its causal convolution. + +An n-gram window reaches max_ngram_size - 1 positions before the current token. To keep the op +causal across invocations (chunked prefill or autoregressive decode) for every concurrent request in +the packed batch, the optional past_ids input carries those preceding ids per request and +present_ids returns the ids to pass to the next call. Both have shape (batch_size, max_ngram_size - +1) and are right-aligned, so the last slot is the most recent id, and are indexed by request +(batch_size), not by position in the packed buffer. Positions before the start of a request's whole +sequence use pad_id, or eos_token_id when provided. Running NGramHashMapping once per sequence and running this op once over those +sequences packed together (optionally split into packed chunks with present_ids threaded into +past_ids) produce identical hash ids. + +Optional inputs add Qwen4-Exp-style n-gram embedding support: + +- eos_token_id, when provided together with reset_on_eos != 0, causes causal history to reset at EOS + boundaries. Missing history is also filled with eos_token_id. +- segment_ids, when provided, additionally resets causal history when adjacent tokens within one + packed request have different segment ids. Thread present_segment_ids into past_segment_ids on + subsequent calls to preserve boundaries across chunked prefill and decode calls. +- head_offsets, when provided, adds a fixed per-output-head offset after the modulo. Addition wraps + in the input id type on overflow. +- capture_count enables compact prefix state capture when state_update_capacity is positive. For + each request, state_update contains the trailing state after each captured prefix token. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + VarlenNGramHashMapping, 1, + OpSchema() + .SetDoc(VarlenNGramHashMapping_ver1_doc) + .Attr("max_ngram_size", + "Maximum n-gram order. Must be at least 2.", + AttributeProto::INT) + .Attr("n_head_per_ngram", + "Number of hash heads emitted for each n-gram order.", + AttributeProto::INT) + .Attr("pad_id", + "Compressed tokenizer id used to pad causal shifts before the beginning of a request's " + "sequence.", + AttributeProto::INT) + .Attr("reset_on_eos", + "When non-zero and eos_token_id is provided, reset causal n-gram history at EOS " + "boundaries. Default is 0.", + AttributeProto::INT, + static_cast(0)) + .Attr("state_update_capacity", + "Static number of compact contiguous-prefix states to expose per request. Valid range " + "is [0, 8]. capture_count is required exactly when this is positive.", + AttributeProto::INT, + static_cast(0)) + .Input(0, + "input_ids", + "Token-major packed compressed tokenizer ids with shape (total_tokens).", + "M") + .Input(1, + "multipliers", + "Per-shift hash multipliers with at least max_ngram_size elements. Conventionally odd, " + "but any value is accepted.", + "M") + .Input(2, + "vocab_sizes", + "Per-output-head vocabulary sizes, conventionally prime, with shape " + "((max_ngram_size - 1) * n_head_per_ngram). Every entry must be strictly positive. " + "The CPU implementation rejects a non-positive entry; GPU implementations guard the " + "modulo to avoid a device-side division by zero and emit a hash id of 0 for that head.", + "M") + .Input(3, + "cumulative_sequence_length", + "Device tensor with shape (batch_size + 1) giving the half-open packed token range " + "of each request.", + "S") + .Input(4, + "past_ids", + "Optional compressed tokenizer ids for the max_ngram_size - 1 positions that precede " + "this call, with shape (batch_size, max_ngram_size - 1). Right-aligned, so the last " + "slot is the most recent id, and indexed by request rather than by packed position. " + "If omitted the history is pad_id, or eos_token_id when provided.", + "M", + OpSchema::Optional) + .Input(5, + "head_offsets", + "Optional per-output-head additive offset with shape " + "((max_ngram_size - 1) * n_head_per_ngram), added after the modulo with wrapping " + "arithmetic in the input id type.", + "M", + OpSchema::Optional) + .Input(6, + "eos_token_id", + "Optional scalar end-of-sequence token id. When provided it replaces pad_id for " + "missing history and enables reset_on_eos.", + "M", + OpSchema::Optional) + .Input(7, + "segment_ids", + "Optional token-major segment ids with shape (total_tokens), used to reset causal " + "history at segment boundaries within each packed request.", + "tensor(int32)", + OpSchema::Optional) + .Input(8, + "past_segment_ids", + "Optional segment ids corresponding to past_ids, with shape " + "(batch_size, max_ngram_size - 1). Thread present_segment_ids from the previous call " + "into this input to preserve segment boundaries across calls.", + "S", + OpSchema::Optional) + .Input(9, + "capture_count", + "Optional device int32 tensor with shape (batch_size). For each request, captures " + "that many local prefix states, clamped to the request length and " + "state_update_capacity. Required exactly when state_update_capacity is positive.", + "S", + OpSchema::Optional) + .Output(0, + "hash_ids", + "Token-major packed hash ids with shape (total_tokens, " + "(max_ngram_size - 1) * n_head_per_ngram).", + "M") + .Output(1, + "present_ids", + "Trailing max_ngram_size - 1 ids of past_ids followed by each request's own tokens, " + "with shape (batch_size, max_ngram_size - 1). Feed this back as past_ids on the next " + "call.", + "M", + OpSchema::Optional) + .Output(2, + "present_segment_ids", + "Trailing max_ngram_size - 1 segment ids corresponding to present_ids, with shape " + "(batch_size, max_ngram_size - 1). Feed this back as past_segment_ids on the next call.", + "S", + OpSchema::Optional) + .Output(3, + "state_update", + "Optional trailing id states after each captured prefix token, with shape " + "(batch_size, state_update_capacity, max_ngram_size - 1). Unused slots are pad_id.", + "M", + OpSchema::Optional) + .TypeConstraint("M", + {"tensor(int32)", "tensor(int64)"}, + "Constrain ids, multipliers, vocabulary sizes, and output ids to integer tensors.") + .TypeConstraint("S", + {"tensor(int32)"}, + "Constrain cumulative_sequence_length and segment ids to device int32 tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + propagateElemTypeFromInputToOutput(ctx, 0, 0); + if (ctx.getNumOutputs() > 1) { + propagateElemTypeFromInputToOutput(ctx, 0, 1); + } + if (ctx.getNumOutputs() > 2) { + propagateElemTypeFromInputToOutput(ctx, 3, 2); + } + if (ctx.getNumOutputs() > 3) { + propagateElemTypeFromInputToOutput(ctx, 0, 3); + } + + const int64_t max_ngram_size = getAttribute(ctx, "max_ngram_size", int64_t{-1}); + const int64_t n_head_per_ngram = getAttribute(ctx, "n_head_per_ngram", int64_t{-1}); + const int64_t state_update_capacity = getAttribute(ctx, "state_update_capacity", int64_t{0}); + if (max_ngram_size < 2) { + fail_shape_inference("VarlenNGramHashMapping: max_ngram_size must be at least 2"); + } + if (n_head_per_ngram < 1) { + fail_shape_inference("VarlenNGramHashMapping: n_head_per_ngram must be positive"); + } + if (state_update_capacity < 0 || state_update_capacity > 8) { + fail_shape_inference("VarlenNGramHashMapping: state_update_capacity must be in [0, 8]"); + } + // max_ngram_size and n_head_per_ngram are model-controlled attributes with only + // lower-bound checks above, so (max_ngram_size - 1) * n_head_per_ngram must be + // multiplied with an overflow check: e.g. max_ngram_size == INT64_MAX would otherwise + // silently wrap this signed multiplication before it ever reaches tensor-shape + // validation. + const int64_t state_length = max_ngram_size - 1; + if (state_length != 0 && n_head_per_ngram > std::numeric_limits::max() / state_length) { + fail_shape_inference( + "VarlenNGramHashMapping: (max_ngram_size - 1) * n_head_per_ngram overflows int64_t"); + } + const int64_t num_heads = state_length * n_head_per_ngram; + + if (hasInputShape(ctx, 0)) { + const auto& input_shape = getInputShape(ctx, 0); + if (input_shape.dim_size() != 1) { + fail_shape_inference("VarlenNGramHashMapping: input_ids must have rank 1"); + } + TensorShapeProto output_shape; + *output_shape.add_dim() = input_shape.dim(0); + output_shape.add_dim()->set_dim_value(num_heads); + updateOutputShape(ctx, 0, output_shape); + } + + if (hasInputShape(ctx, 3)) { + const auto& cu_seqlen_shape = getInputShape(ctx, 3); + if (cu_seqlen_shape.dim_size() != 1) { + fail_shape_inference("VarlenNGramHashMapping: cumulative_sequence_length must have rank 1"); + } + const auto& cu_dim = cu_seqlen_shape.dim(0); + if (cu_dim.has_dim_value() && cu_dim.dim_value() < 2) { + fail_shape_inference( + "VarlenNGramHashMapping: cumulative_sequence_length must have at least 2 elements"); + } + if (ctx.getNumOutputs() > 1) { + TensorShapeProto present_shape; + if (cu_dim.has_dim_value()) { + // Runtime requires at least 2 elements (batch_size >= 1); a declared static shape of + // [0] or [1] would otherwise infer a present_ids batch dimension of -1 or 0. + present_shape.add_dim()->set_dim_value(cu_dim.dim_value() - 1); + } else { + present_shape.add_dim(); // unknown batch size + } + present_shape.add_dim()->set_dim_value(max_ngram_size - 1); + updateOutputShape(ctx, 1, present_shape); + if (ctx.getNumOutputs() > 2) { + updateOutputShape(ctx, 2, present_shape); + } + if (ctx.getNumOutputs() > 3) { + TensorShapeProto state_update_shape; + *state_update_shape.add_dim() = present_shape.dim(0); + state_update_shape.add_dim()->set_dim_value(state_update_capacity); + state_update_shape.add_dim()->set_dim_value(max_ngram_size - 1); + updateOutputShape(ctx, 3, state_update_shape); + } + } + if (hasInputShape(ctx, 9)) { + const auto& capture_count_shape = getInputShape(ctx, 9); + if (capture_count_shape.dim_size() != 1) { + fail_shape_inference("VarlenNGramHashMapping: capture_count must have rank 1"); + } + if (cu_dim.has_dim_value() && capture_count_shape.dim(0).has_dim_value() && + capture_count_shape.dim(0).dim_value() != cu_dim.dim_value() - 1) { + fail_shape_inference("VarlenNGramHashMapping: capture_count must have shape (batch_size)"); + } + } + } + })); + +constexpr const char* CausalConvWithState_ver1_doc = R"DOC( +Stateful causal depthwise convolution, generalized to N spatial dimensions. + +Used by Gated DeltaNet (Qwen3.5) and Mamba (Jamba, FalconMamba) as a preprocessing step. +Replaces the 3-op pattern (Concat + Conv + Slice) with a single fused operation. + +The convolution is causal (looks only at current and past positions along the last +spatial dimension) and depthwise (each channel is convolved independently with its own kernel). + +Input layout is channels-first: (batch_size, channels, ...). +Weight layout: (channels, 1, k_1, ...) for depthwise convolution. +The carry state stores the last (k-1) positions along the causal axis for incremental decode. + +The ndim attribute generalizes the op to 1D, 2D, or 3D spatial dimensions. Causality is +enforced on the last spatial dimension only. + +The optional activation attribute supports fused SiLU/Swish activation. + +The dilation attribute spaces the kernel taps along the causal axis: output position t reads +input positions t - (k_1 - 1 - j) * dilation for tap j. The receptive field therefore spans +(k_1 - 1) * dilation positions before the current one, and the carry state grows to match: +past_state and present_state hold (k_1 - 1) * dilation positions instead of k_1 - 1. Dilation 1 +(the default) is the undilated case and keeps the original state length, so models exported +before the attribute existed are unaffected. + +The channels_last attribute selects a sequence-major layout for the activations and the carry +state, so a model that already produces channels-last activations does not have to transpose into +and out of the channels-first layout. With channels_last = 1 and ndim = 1, input and output are +(batch_size, sequence_length, d_1, ..., d_n) and the state tensors are +(batch_size, state_length, d_1, ..., d_n), where channels = d_1 * ... * d_n. Any number of trailing +channel axes is accepted, so an activation that keeps hyper-connections and hidden size as separate +axes needs no reshape either. weight and bias keep their channels-first (channels, 1, k_1) and +(channels) shapes because they have no sequence axis. The computed values are identical to the +channels-first layout; only the memory layout differs. +)DOC"; + constexpr const char* EngramGate_ver1_doc = R"DOC( Fuses the Engram gate. @@ -4592,6 +5013,10 @@ rather than from attributes: key [total_tokens, num_heads_k, head_size_qk] value [total_tokens, num_heads_v, head_size_v] +Alternatively, key and value may be omitted and query contains packed QKV with shape +`[total_tokens, 2 * num_heads_q * head_size_qk + num_heads_v * head_size_v]`. +`initial_state` is required for packed QKV to determine the head counts and head sizes. + The leading token axis may instead be spelled as an explicit `[batch_size, sequence_length]` pair, making query/key/value (and the output) rank 4 and decay/beta rank 3. The memory layout is identical; the rank-4 spelling exists so an exporter can round-trip a `[B, S, H*D]` @@ -4684,9 +5109,11 @@ ONNX_MS_OPERATOR_SET_SCHEMA( "Capacity C for compact contiguous-prefix transition capture, in [0, 8]. " "0 (default) disables compact state-update outputs.", AttributeProto::INT, static_cast(0)) - .Input(0, "query", "Query, shape (total_tokens, num_heads_q, head_size_qk)", "T") - .Input(1, "key", "Key, shape (total_tokens, num_heads_k, head_size_qk)", "T") - .Input(2, "value", "Value, shape (total_tokens, num_heads_v, head_size_v)", "T") + .Input(0, "query", "Query or packed QKV, shaped as described above.", "T") + .Input(1, "key", "Key, shape (total_tokens, num_heads_k, head_size_qk)", "T", + OpSchema::Optional) + .Input(2, "value", "Value, shape (total_tokens, num_heads_v, head_size_v)", "T", + OpSchema::Optional) .Input(3, "cu_seqlens", "Exclusive prefix sums of the per-request token counts, shape (batch_size + 1). " "Absent means uniform packing.", @@ -4699,7 +5126,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OpSchema::Optional) .Input(6, "initial_state", "Recurrent state, shape (batch_size, num_heads_v, head_size_v, head_size_qk), " - "V-major. May alias final_state.", + "V-major. Required for packed QKV. May alias final_state.", "TS", OpSchema::Optional) .Input(7, "a_log", "Per-head A_log, shape (num_heads_v). Requires gate_activation=qwen.", @@ -4742,40 +5169,116 @@ ONNX_MS_OPERATOR_SET_SCHEMA( updateOutputElemType(ctx, 2, ONNX_NAMESPACE::TensorProto_DataType_FLOAT); } - if (!hasInputShape(ctx, 0) || !hasInputShape(ctx, 2)) { + const bool has_key = ctx.getNumInputs() > 1 && ctx.getInputType(1) != nullptr; + const bool has_value = ctx.getNumInputs() > 2 && ctx.getInputType(2) != nullptr; + if (has_key != has_value) { + fail_shape_inference("GatedDeltaNet: key and value must both be present or both be omitted"); + } + const bool packed_qkv = !has_key; + const bool has_state = ctx.getNumInputs() > 6 && ctx.getInputType(6) != nullptr; + if (packed_qkv && !has_state) { + fail_shape_inference("GatedDeltaNet: initial_state is required for packed QKV"); + } + if (!hasInputShape(ctx, 0)) { return; } const auto& query_shape = getInputShape(ctx, 0); - const auto& value_shape = getInputShape(ctx, 2); const int rank = query_shape.dim_size(); - if ((rank != 3 && rank != 4) || value_shape.dim_size() != rank) { + const int token_dims = rank - (packed_qkv ? 1 : 2); + if ((packed_qkv && rank != 2 && rank != 3) || + (!packed_qkv && rank != 3 && rank != 4)) { fail_shape_inference( - "GatedDeltaNet: query and value must both have rank 3 or both have rank 4"); + "GatedDeltaNet: packed QKV must have rank 2 or 3, and separate " + "query, key and value inputs must have rank 3 or 4"); + } + + const ONNX_NAMESPACE::TensorShapeProto* value_shape = nullptr; + if (!packed_qkv) { + if (!hasInputShape(ctx, 2)) { + return; + } + value_shape = &getInputShape(ctx, 2); + if (value_shape->dim_size() != rank) { + fail_shape_inference("GatedDeltaNet: query and value must have the same rank"); + } + } + + const ONNX_NAMESPACE::TensorShapeProto* state_shape = nullptr; + if (ctx.getNumInputs() > 6 && hasInputShape(ctx, 6)) { + state_shape = &getInputShape(ctx, 6); + if (state_shape->dim_size() != 4) { + fail_shape_inference("GatedDeltaNet: initial_state must have rank 4"); + } + } + + if (packed_qkv) { + if (state_shape == nullptr) { + return; + } + if (query_shape.dim(token_dims).has_dim_value() && + state_shape->dim(1).has_dim_value() && state_shape->dim(2).has_dim_value() && + state_shape->dim(3).has_dim_value()) { + const int64_t packed_size = query_shape.dim(token_dims).dim_value(); + const int64_t num_heads_v = state_shape->dim(1).dim_value(); + const int64_t head_size_v = state_shape->dim(2).dim_value(); + const int64_t head_size_qk = state_shape->dim(3).dim_value(); + constexpr int64_t max_dimension = std::numeric_limits::max(); + if (num_heads_v <= 0 || head_size_v <= 0 || head_size_qk <= 0) { + fail_shape_inference("GatedDeltaNet: packed QKV head counts and head sizes must be positive"); + } + if (num_heads_v > max_dimension / head_size_v || head_size_qk > max_dimension / 2) { + fail_shape_inference("GatedDeltaNet: packed QKV dimensions overflow int64"); + } + const int64_t value_size = num_heads_v * head_size_v; + const int64_t query_key_size = 2 * head_size_qk; + if (packed_size <= value_size || (packed_size - value_size) % query_key_size != 0) { + fail_shape_inference( + "GatedDeltaNet: packed QKV last dimension must be 2 * num_heads_q * head_size_qk + " + "num_heads_v * head_size_v"); + } + const int64_t num_heads_q = (packed_size - value_size) / query_key_size; + if (num_heads_v % num_heads_q != 0) { + fail_shape_inference("GatedDeltaNet: num_heads_v must be a positive multiple of num_heads_q"); + } + } + const bool has_cu_seqlens = ctx.getNumInputs() > 3 && ctx.getInputType(3) != nullptr; + if (!has_cu_seqlens && token_dims == 1 && query_shape.dim(0).has_dim_value() && + state_shape->dim(0).has_dim_value()) { + const int64_t total_tokens = query_shape.dim(0).dim_value(); + const int64_t batch = state_shape->dim(0).dim_value(); + if (batch <= 0 || total_tokens <= 0 || total_tokens % batch != 0) { + fail_shape_inference( + "GatedDeltaNet: total_tokens must be divisible by batch, with positive token count and batch size"); + } + } } - const int token_dims = rank - 2; ONNX_NAMESPACE::TensorShapeProto out_shape; for (int i = 0; i < token_dims; ++i) { *out_shape.add_dim() = query_shape.dim(i); } - if (query_shape.dim(token_dims).has_dim_value() && - value_shape.dim(token_dims).has_dim_value()) { - out_shape.add_dim()->set_dim_value(std::max(query_shape.dim(token_dims).dim_value(), - value_shape.dim(token_dims).dim_value())); + if (packed_qkv) { + *out_shape.add_dim() = state_shape->dim(1); + *out_shape.add_dim() = state_shape->dim(2); } else { - out_shape.add_dim(); + if (query_shape.dim(token_dims).has_dim_value() && + value_shape->dim(token_dims).has_dim_value()) { + out_shape.add_dim()->set_dim_value(std::max(query_shape.dim(token_dims).dim_value(), + value_shape->dim(token_dims).dim_value())); + } else { + out_shape.add_dim(); + } + *out_shape.add_dim() = value_shape->dim(token_dims + 1); } - *out_shape.add_dim() = value_shape.dim(token_dims + 1); updateOutputShape(ctx, 0, out_shape); auto add_batch_dim = [&](ONNX_NAMESPACE::TensorShapeProto& shape) { - if (hasInputShape(ctx, 9) && getInputShape(ctx, 9).dim_size() == 1) { + if (ctx.getNumInputs() > 9 && hasInputShape(ctx, 9) && getInputShape(ctx, 9).dim_size() == 1) { *shape.add_dim() = getInputShape(ctx, 9).dim(0); - } else if (rank == 4) { + } else if (rank == (packed_qkv ? 3 : 4)) { *shape.add_dim() = query_shape.dim(0); - } else if (hasInputShape(ctx, 6) && getInputShape(ctx, 6).dim_size() >= 4) { - const auto& state_shape = getInputShape(ctx, 6); - *shape.add_dim() = state_shape.dim(state_shape.dim_size() - 4); + } else if (state_shape != nullptr) { + *shape.add_dim() = state_shape->dim(0); } else { shape.add_dim(); } @@ -4791,15 +5294,15 @@ ONNX_MS_OPERATOR_SET_SCHEMA( ONNX_NAMESPACE::TensorShapeProto capsule_shape; add_batch_dim(capsule_shape); auto* width = capsule_shape.add_dim(); - if (query_shape.dim(token_dims).has_dim_value() && + if (!packed_qkv && query_shape.dim(token_dims).has_dim_value() && query_shape.dim(token_dims + 1).has_dim_value() && - value_shape.dim(token_dims).has_dim_value() && - value_shape.dim(token_dims + 1).has_dim_value()) { + value_shape->dim(token_dims).has_dim_value() && + value_shape->dim(token_dims + 1).has_dim_value()) { // num_heads_k is constrained to equal num_heads_q, so query supplies it. const int64_t num_heads_k = query_shape.dim(token_dims).dim_value(); const int64_t head_size_qk = query_shape.dim(token_dims + 1).dim_value(); - const int64_t num_heads_v = value_shape.dim(token_dims).dim_value(); - const int64_t head_size_v = value_shape.dim(token_dims + 1).dim_value(); + const int64_t num_heads_v = value_shape->dim(token_dims).dim_value(); + const int64_t head_size_v = value_shape->dim(token_dims + 1).dim_value(); if (num_heads_k <= 0 || head_size_qk <= 0 || num_heads_v <= 0 || head_size_v <= 0) { fail_shape_inference( "GatedDeltaNet: head counts and head sizes must be positive"); @@ -4829,13 +5332,9 @@ ONNX_MS_OPERATOR_SET_SCHEMA( updateOutputShape(ctx, 2, capsule_shape); } - if (hasInputShape(ctx, 6)) { - const auto& in_state = getInputShape(ctx, 6); - if (in_state.dim_size() != 4) { - fail_shape_inference("GatedDeltaNet: initial_state must have rank 4"); - } + if (state_shape != nullptr) { if (ctx.getNumOutputs() > 1) { - updateOutputShape(ctx, 1, in_state); + updateOutputShape(ctx, 1, *state_shape); } } })); @@ -4965,6 +5464,157 @@ ONNX_MS_OPERATOR_SET_SCHEMA( propagateShapeFromInputToOutput(ctx, 0, 0); })); +constexpr const char* BranchwiseRMSNorm_ver1_doc = R"DOC( +Applies RMS normalization independently to each branch. X may use grouped shape +(..., C, H), or flattened shape (..., C * H) when num_branches is specified. +The optional scale may have shape (C * H), (C, H), or (H). Arithmetic is +performed in float32 and the result is converted to T. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + BranchwiseRMSNorm, 1, + OpSchema() + .SetDoc(BranchwiseRMSNorm_ver1_doc) + .Attr("epsilon", "Epsilon added before reciprocal square root.", + AttributeProto::FLOAT, 1e-5f) + .Attr("num_branches", + "Number of branches for flattened input. Omit or set to zero for grouped input.", + AttributeProto::INT, static_cast(0)) + .Input(0, "X", "Grouped (..., C, H) or flattened (..., C * H) input.", "T") + .Input(1, "scale", "Optional scale with shape (C * H), (C, H), or (H).", + "M", OpSchema::Optional) + .Output(0, "Y", "RMS-normalized output with the same shape as X.", "T") + .TypeConstraint("T", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain input and output to floating-point tensors.") + .TypeConstraint("M", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain scale to floating-point tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + propagateElemTypeFromInputToOutput(ctx, 0, 0); + propagateShapeFromInputToOutput(ctx, 0, 0); + })); + +constexpr const char* ScaledSiLU_ver1_doc = R"DOC( +Computes SiLU after scaling, with explicit T rounding: +Z_T = cast_T(effective_scale * X), S_T = cast_T(sigmoid(Z_T)), and +Y = cast_T(Z_T * S_T). effective_scale is the scalar input when present, +otherwise it is the alpha attribute. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + ScaledSiLU, 1, + OpSchema() + .SetDoc(ScaledSiLU_ver1_doc) + .Attr("alpha", "Scale used when the optional scale input is absent.", + AttributeProto::FLOAT, 1.0f) + .Input(0, "X", "Input tensor.", "T") + .Input(1, "scale", "Optional scalar scale that overrides alpha.", "M", OpSchema::Optional) + .Output(0, "Y", "Output with the same shape as X.", "T") + .TypeConstraint("T", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain input and output to floating-point tensors.") + .TypeConstraint("M", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain the optional scale to floating-point tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + propagateElemTypeFromInputToOutput(ctx, 0, 0); + propagateShapeFromInputToOutput(ctx, 0, 0); + })); + +constexpr const char* HyperConnectionPreMix_ver1_doc = R"DOC( +Reduces C streams to one feature tensor without applying an activation: +Y[..., h] = reduction_scale * sum_c(X[..., c, h] * pre_mix[..., c, h]). +pre_mix may have shape (..., C), (..., C, 1), (..., C, H), or (..., C * H) +for flattened X. X may be grouped (..., C, H), or flattened (..., C * H) +when num_branches is specified. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + HyperConnectionPreMix, 1, + OpSchema() + .SetDoc(HyperConnectionPreMix_ver1_doc) + .Attr("num_branches", + "Number of branches for flattened streams. Omit or set to zero for grouped streams.", + AttributeProto::INT, static_cast(0)) + .Attr("reduction_scale", "Multiplier applied to the branch reduction.", + AttributeProto::FLOAT, 1.0f) + .Input(0, "streams", "Grouped (..., C, H) or flattened (..., C * H) streams.", "T") + .Input(1, "pre_mix", "Branch or feature gates with shape (..., C), (..., C, 1), (..., C, H), or (..., C * H) for flattened streams.", "M") + .Output(0, "output", "Reduced feature tensor with shape (..., H).", "T") + .TypeConstraint("T", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain streams and output to floating-point tensors.") + .TypeConstraint("M", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain mixing weights to floating-point tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + propagateElemTypeFromInputToOutput(ctx, 0, 0); + if (!hasInputShape(ctx, 0)) { + return; + } + const auto& input_shape = getInputShape(ctx, 0); + auto* output_shape = getOutputShape(ctx, 0); + const auto branches = getAttribute(ctx, "num_branches", 0); + const int rank = input_shape.dim_size(); + if (branches == 0) { + if (rank < 2) { + fail_shape_inference("Grouped streams must have rank at least 2."); + } + for (int i = 0; i < rank - 2; ++i) { + *output_shape->add_dim() = input_shape.dim(i); + } + *output_shape->add_dim() = input_shape.dim(rank - 1); + } else { + if (branches < 1 || rank < 1) { + fail_shape_inference("num_branches must be positive for flattened streams."); + } + for (int i = 0; i < rank - 1; ++i) { + *output_shape->add_dim() = input_shape.dim(i); + } + auto* last = output_shape->add_dim(); + const auto& input_last = input_shape.dim(rank - 1); + if (input_last.has_dim_value()) { + if (input_last.dim_value() % branches != 0) { + fail_shape_inference("The flattened stream width must be divisible by num_branches."); + } + last->set_dim_value(input_last.dim_value() / branches); + } + } + })); + +constexpr const char* HyperConnectionPostMix_ver1_doc = R"DOC( +Mixes existing streams and injects one branch output: +Y[..., k, h] = sum_c(stream_mix[..., c, k] * streams[..., c, h]) + + post_mix[..., k, h] * block_output[..., h]. +stream_mix is optional and defaults to the identity. post_mix has shape +(..., C), (..., C, 1), or (..., C, H). No activation is applied. +)DOC"; + +ONNX_MS_OPERATOR_SET_SCHEMA( + HyperConnectionPostMix, 1, + OpSchema() + .SetDoc(HyperConnectionPostMix_ver1_doc) + .Attr("num_branches", + "Number of branches for flattened streams. Omit or set to zero for grouped streams.", + AttributeProto::INT, static_cast(0)) + .Input(0, "streams", "Grouped (..., C, H) or flattened (..., C * H) streams.", "T") + .Input(1, "block_output", "Feature tensor with shape (..., H).", "T") + .Input(2, "post_mix", "Branch or feature injection gates.", "M") + .Input(3, "stream_mix", "Optional stream matrix with shape (..., C, C).", + "M", OpSchema::Optional) + .Output(0, "output", "Mixed streams with the same shape as streams.", "T") + .TypeConstraint("T", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain streams, block output, and output to floating-point tensors.") + .TypeConstraint("M", + {"tensor(float)", "tensor(float16)", "tensor(bfloat16)"}, + "Constrain mixing weights to floating-point tensors.") + .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) { + propagateElemTypeFromInputToOutput(ctx, 0, 0); + propagateShapeFromInputToOutput(ctx, 0, 0); + })); + constexpr const char* GatedAdd_ver1_doc = R"DOC( Adds one tensor to another tensor scaled by a per-row gate: From 5a5a47460a024eaa694fe9df05356b46f458a292 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:20:48 +0000 Subject: [PATCH 088/102] Fix packed sparse indexer CI failures Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../sparse/packed_sparse_attention_indexer.cc | 12 ++- .../packed_sparse_attention_indexer_impl.h | 2 + .../bert/packed_sparse_attention_indexer.cc | 95 ++++++++++++++++--- .../bert/packed_sparse_attention_indexer.h | 21 ++++ ...packed_sparse_attention_indexer_op_test.cc | 33 +++++-- 5 files changed, 137 insertions(+), 26 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index 7b2779198ef6c..f15da84ec84c9 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -159,10 +159,9 @@ Status PackedSparseAttentionIndexer::ComputeInternal(OpKernelContext* context } const bool position_ids_provided = psai::kPositionIds < context->InputCount() && context->Input(psai::kPositionIds) != nullptr; - ORT_RETURN_IF(position_ids_provided == is_qsa, + ORT_RETURN_IF(!is_qsa && !position_ids_provided, "PackedSparseAttentionIndexer: input ", psai::kPositionIds, - is_qsa ? " (position_ids) must be omitted for policy_mode 'qsa'" - : " (position_ids) is required for policy_mode 'csa'"); + " (position_ids) is required for policy_mode 'csa'"); const bool gate_buffer_provided = psai::kPastGateBuffer < context->InputCount() && context->Input(psai::kPastGateBuffer) != nullptr; ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: input ", psai::kPastGateBuffer, @@ -193,6 +192,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con const Tensor* sin_cache = context->Input(psai::kSinCache); const Tensor* cumulative_sequence_lengths = context->Input(psai::kCumulativeSequenceLengths); const Tensor* past_sequence_lengths = context->Input(psai::kPastSequenceLengths); + const Tensor* position_ids = context->Input(psai::kPositionIds); const Tensor* past_key_state = context->Input(psai::kPastKeyState); const Tensor* past_kv_buffer = context->Input(psai::kPastKvBuffer); const Tensor* past_state_lengths = context->Input(psai::kPastStateLengths); @@ -243,6 +243,9 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con "PackedSparseAttentionIndexer: total_tokens must be 0 when batch_size is 0"); ORT_RETURN_IF_ERROR(CheckShape(past_sequence_lengths, "past_sequence_lengths", {batch_size})); + if (position_ids != nullptr) { + ORT_RETURN_IF_ERROR(CheckShape(position_ids, "position_ids", {total_tokens})); + } if (has_separate_key) { ORT_RETURN_IF_ERROR(CheckShape(key, "key", {total_tokens, head_size})); } @@ -315,6 +318,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con params.rotary_width = static_cast(rotary.rotary_width); params.max_rotary_length = static_cast(rotary.max_rotary_length); params.cos_cache_batched = rotary.batched; + params.has_position_ids = position_ids != nullptr; params.compress_ratio = static_cast(compress_ratio_); params.state_capacity = static_cast(state_capacity); params.buffer_capacity = static_cast(buffer_capacity); @@ -342,6 +346,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con reinterpret_cast(sin_cache->Data()), cumulative_sequence_lengths->Data(), past_sequence_lengths->Data(), + position_ids != nullptr ? position_ids->Data() : nullptr, reinterpret_cast(past_key_state->Data()), reinterpret_cast(past_kv_buffer->Data()), past_state_lengths->Data(), @@ -473,6 +478,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(OpKernelContext* context) con params.rotary_width = static_cast(rotary.rotary_width); params.max_rotary_length = static_cast(rotary.max_rotary_length); params.cos_cache_batched = rotary.batched; + params.has_position_ids = true; params.compress_ratio = static_cast(compress_ratio_); params.state_capacity = static_cast(state_capacity); params.buffer_capacity = static_cast(buffer_capacity); diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index 43aa79ec967ee..d78f12c2c9c98 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -26,6 +26,7 @@ struct PackedSparseAttentionIndexerParams { int rotary_width = 0; // cos_cache.shape[-1] int max_rotary_length = 0; // cos_cache.shape[-2] bool cos_cache_batched = false; // cos_cache rank: 3 = [batch, pos, rot], 2 = [pos, rot] + bool has_position_ids = false; int compress_ratio = 0; int state_capacity = 0; // past_key_state.shape[1] int buffer_capacity = 0; // past_kv_buffer.shape[1] == 2 * compress_ratio - 1 @@ -64,6 +65,7 @@ Status LaunchQsaPackedSparseAttentionIndexer( const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, + const int64_t* position_ids, const T* past_key_state, const T* past_kv_buffer, const int32_t* past_state_lengths, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index bee66696a336b..930e9ab4aaf15 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -296,6 +296,60 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe return Status::OK(); } +Status PackedSparseAttentionIndexerQsaCaptureProgram::GenerateShaderCode(ShaderHelper& shader) const { + const auto& key = shader.AddInput("key", ShaderUsage::UseUniform); + const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); + const auto& capture_count = shader.AddInput("state_update_capture_count", ShaderUsage::UseUniform); + const auto& present_key_state = + shader.AddInput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); + const ShaderVariableHelper* state_update_active = nullptr; + if (has_state_update_active_) { + state_update_active = &shader.AddInput("state_update_active", ShaderUsage::UseUniform); + } + const auto& state_update = + shader.AddOutput("state_update", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + + shader.MainFunctionBody() + << " let b = workgroup_idx;\n" + << " if (b >= uniforms.batch_size || local_idx != 0u || " << overflow_flags.GetByOffset("b") + << " != 0) { return; }\n"; + if (has_state_update_active_) { + shader.MainFunctionBody() + << " if (" << state_update_active->GetByOffset("0") << " == 0) { return; }\n"; + } + shader.MainFunctionBody() + << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" + << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" + << " let req_len = max(req_end - req_start, 0);\n" + << " let past_len = max(" << past_seqlens.GetByOffset("b") << ", 0);\n" + << " let old_key_len = past_len / i32(uniforms.compress_ratio);\n" + << " let old_buf_len = past_len % i32(uniforms.compress_ratio);\n" + << " let capture_tokens = min(max(" << capture_count.GetByOffset("b") + << ", 0), min(req_len, i32(uniforms.state_update_capacity)));\n" + << " for (var t = 0; t < capture_tokens; t++) {\n" + << " let completes_block = (old_buf_len + t + 1) % i32(uniforms.compress_ratio) == 0;\n" + << " let completed_entry = old_key_len + (old_buf_len + t + 1) / i32(uniforms.compress_ratio) - 1;\n" + << " for (var d = 0u; d < uniforms.head_size; d++) {\n" + << " var value = f32(" + << key.GetByOffset("u32(req_start + t) * uniforms.key_row_stride + uniforms.key_offset + d") << ");\n" + << " if (completes_block && completed_entry >= 0 && completed_entry < i32(uniforms.state_capacity)) {\n" + << " value = f32(" + << present_key_state.GetByOffset( + "(b * uniforms.state_capacity + u32(completed_entry)) * uniforms.head_size + d") + << ");\n" + << " }\n" + << " " + << state_update.SetByOffset( + "(b * uniforms.state_update_capacity + u32(t)) * uniforms.head_size + d", + "state_update_element_t(value)") + << "\n" + << " }\n" + << " }\n"; + return Status::OK(); +} + Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); const auto& query_norm = shader.AddInput("query_norm_weight", ShaderUsage::UseUniform); @@ -961,29 +1015,18 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont if (batch_size > 0) { const Tensor* key_source = has_separate_key ? key : query; - const bool capture_state_update = state_update != nullptr && state_update_capacity_ > 0; - PackedSparseAttentionIndexerQsaUpdateProgram update{ - rotary.batched, capture_state_update, state_update_active != nullptr}; - update.CacheHint(rotary.batched, capture_state_update, state_update_active != nullptr) + PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched, false, false}; + update.CacheHint(rotary.batched, false, false) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{key_source, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, {cu_seqlens, ProgramTensorMetadataDependency::Type}, {past_seqlens, ProgramTensorMetadataDependency::Type}}); - if (capture_state_update) { - update.AddInput({state_update_capture_count, ProgramTensorMetadataDependency::Type}); - if (state_update_active != nullptr) { - update.AddInput({state_update_active, ProgramTensorMetadataDependency::Type}); - } - } update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}); - if (capture_state_update) { - update.AddOutput({state_update, ProgramTensorMetadataDependency::Type}); - } update .SetDispatchGroupSize(ToUint32(batch_size)) .AddUniformVariables({{ToUint32(batch_size)}, @@ -999,6 +1042,32 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {ToUint32(state_update_capacity_)}, {epsilon_}}); ORT_RETURN_IF_ERROR(context.RunProgram(update)); + + if (state_update != nullptr && state_update_capacity_ > 0) { + PackedSparseAttentionIndexerQsaCaptureProgram capture{state_update_active != nullptr}; + capture.CacheHint(state_update_active != nullptr) + .SetWorkgroupSize(kWorkgroupSize) + .AddInputs({{key_source, ProgramTensorMetadataDependency::Type}, + {cu_seqlens, ProgramTensorMetadataDependency::Type}, + {past_seqlens, ProgramTensorMetadataDependency::Type}, + {state_update_capture_count, ProgramTensorMetadataDependency::Type}, + {present_key_state, ProgramTensorMetadataDependency::Type}, + {&overflow_flags, ProgramTensorMetadataDependency::Type}}); + if (state_update_active != nullptr) { + capture.AddInput({state_update_active, ProgramTensorMetadataDependency::Type}); + } + capture.AddOutput({state_update, ProgramTensorMetadataDependency::Type}) + .SetDispatchGroupSize(ToUint32(batch_size)) + .AddUniformVariables({{ToUint32(batch_size)}, + {ToUint32(total_tokens)}, + {ToUint32(has_separate_key ? head_size : query_shape[1])}, + {ToUint32(has_separate_key ? 0 : query_width)}, + {ToUint32(compress_ratio_)}, + {ToUint32(state_capacity)}, + {ToUint32(state_update_capacity_)}, + {ToUint32(head_size)}}); + ORT_RETURN_IF_ERROR(context.RunProgram(capture)); + } } if (total_tokens == 0) { diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index 74a8589f8f6e2..d9e3c3607b7a9 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -112,6 +112,27 @@ class PackedSparseAttentionIndexerCsaUpdateProgram final bool cos_cache_batched_; }; +class PackedSparseAttentionIndexerQsaCaptureProgram final + : public Program { + public: + explicit PackedSparseAttentionIndexerQsaCaptureProgram(bool has_state_update_active) + : Program{"PackedSparseAttentionIndexerQsaCapture"}, + has_state_update_active_{has_state_update_active} {} + Status GenerateShaderCode(ShaderHelper& shader) const override; + WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES( + {"batch_size", ProgramUniformVariableDataType::Uint32}, + {"total_tokens", ProgramUniformVariableDataType::Uint32}, + {"key_row_stride", ProgramUniformVariableDataType::Uint32}, + {"key_offset", ProgramUniformVariableDataType::Uint32}, + {"compress_ratio", ProgramUniformVariableDataType::Uint32}, + {"state_capacity", ProgramUniformVariableDataType::Uint32}, + {"state_update_capacity", ProgramUniformVariableDataType::Uint32}, + {"head_size", ProgramUniformVariableDataType::Uint32}); + + private: + bool has_state_update_active_; +}; + // One invocation per query token: rotates the query, scores it against every causally visible // compressed key_state entry, and selects the index_topk highest scoring entries. class PackedSparseAttentionIndexerCsaSelectProgram final diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 1008ff24ce3dd..9b01ee34aee30 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -109,8 +109,8 @@ struct GraphOptions { int64_t BufferCapacity(int64_t compress_ratio) { return 2 * compress_ratio - 1; } -// Builds a fixed 16-input node; csa-only slots are left empty for policy_mode "qsa", as the schema -// requires. position_ids (slot 11) is forbidden for "qsa" and forced on for "csa". +// Builds a fixed-input node; csa-only slots are left empty for policy_mode "qsa", as the schema +// requires. position_ids (slot 11) is optional for "qsa" and forced on for "csa". void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { const bool is_csa = options.policy_mode == psai::kPolicyModeCsa; const int64_t width = is_csa ? 2 * options.head_size : options.head_size; @@ -359,11 +359,11 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { "must be omitted when policy_mode is 'qsa'"); } -TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithPositionIds) { +TEST(PackedSparseAttentionIndexerShapeInferenceTest, AllowsQsaWithPositionIds) { GraphOptions options; options.add_position_ids = true; - ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, - "position_ids) must be omitted when policy_mode is 'qsa'"); + std::unique_ptr model; + EXPECT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingPositionIds) { @@ -598,6 +598,7 @@ struct QsaPackedProblem { std::vector key_norm_weight; std::vector cos_cache; // shared: [max_position, rotary_width] std::vector sin_cache; + std::vector position_ids; std::vector past_key_state; // [batch, state_capacity, head_size] std::vector past_kv_buffer; // [batch, buffer_capacity, head_size] @@ -729,7 +730,9 @@ void QsaPackedReference(const QsaPackedProblem& p, QsaPackedResult& out) { const int req_start = p.cumulative_sequence_lengths[static_cast(b)]; const int req_end = p.cumulative_sequence_lengths[static_cast(b) + 1]; for (int token = req_start; token < req_end; ++token) { - const int position = p.past_sequence_lengths[static_cast(b)] + (token - req_start); + const int position = p.position_ids.empty() + ? p.past_sequence_lengths[static_cast(b)] + (token - req_start) + : static_cast(p.position_ids[static_cast(token)]); const int causal_count = CausalThreshold(position, p.compress_ratio); const int visible_block_count = std::min(key_len_after[static_cast(b)], causal_count); const int selected = std::min(block_topk, visible_block_count); @@ -868,10 +871,14 @@ void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedP test.AddInput("sin_cache", {problem.max_position, problem.rotary_width}, ToElementType(problem.sin_cache)); test.AddInput("cumulative_sequence_lengths", {batch_size + 1}, problem.cumulative_sequence_lengths); test.AddInput("past_sequence_lengths", {batch_size}, problem.past_sequence_lengths); - test.AddOptionalInputEdge(); // gate - test.AddOptionalInputEdge(); // position_bias - test.AddOptionalInputEdge(); // head_weights - test.AddOptionalInputEdge(); // position_ids + test.AddOptionalInputEdge(); // gate + test.AddOptionalInputEdge(); // position_bias + test.AddOptionalInputEdge(); // head_weights + if (problem.position_ids.empty()) { + test.AddOptionalInputEdge(); + } else { + test.AddInput("position_ids", {total_tokens}, problem.position_ids); + } test.AddInput("past_key_state", {batch_size, problem.state_capacity, head_size}, ToElementType(problem.past_key_state)); test.AddInput("past_kv_buffer", {batch_size, buffer_capacity, head_size}, @@ -919,6 +926,12 @@ TEST(PackedSparseAttentionIndexerTest, QsaPackedQueryKey) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::Cuda, nullptr, true); } +TEST(PackedSparseAttentionIndexerTest, QsaPositionIds) { + QsaPackedProblem problem = MakeQsaPackedProblem(); + problem.position_ids = {7, 9, 3, 4, 8}; + RunQsaPackedTest(1.0e-5f, std::move(problem)); +} + TEST(PackedSparseAttentionIndexerTest, QsaPrefillThenDecodeIndependentState) { if (DefaultCudaExecutionProvider() == nullptr) { GTEST_SKIP() << "CUDA execution provider is not available"; From 66e121fafb7812e489d5138ec8ca3981c920e8a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:42:11 +0000 Subject: [PATCH 089/102] Allow optional packed QSA position IDs Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 7 ++++--- onnxruntime/core/graph/contrib_ops/bert_defs.cc | 13 +++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 7bacdd5f9f6e6..7acded9cc581d 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -5044,8 +5044,9 @@ This version of the operator has been available since version 1 of the 'com.micr sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. QSA positions are always the request-local logical cache positions derived - from past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. + incomplete block. QSA uses position_ids when provided; otherwise positions are the + request-local logical cache positions derived from past_sequence_lengths and + cumulative_sequence_lengths. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -5134,7 +5135,7 @@ This version of the operator has been available since version 1 of the 'com.micr
head_weights (optional) : T
Only for policy_mode 'csa': per-head score weights with shape (total_tokens, num_heads).
position_ids (optional) : I
-
Only for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).
+
Absolute position of every packed query, with shape (total_tokens). Required for policy_mode 'csa' and optional for policy_mode 'qsa'.
past_key_state : T
Generic fixed-capacity state: policy_mode 'qsa' stores prepared complete-block keys; policy_mode 'csa' stores compressed keys. Shape is (batch_size, state_capacity, head_size) and never changes across calls.
past_kv_buffer : T
diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 6de121f7673cf..00997463771d8 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2677,10 +2677,9 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference : " is required when policy_mode is 'csa'"); } } - if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds) == is_qsa) { + if (!is_qsa && !PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { fail_shape_inference("PackedSparseAttentionIndexer: input ", psai::kPositionIds, - is_qsa ? " (position_ids) must be omitted when policy_mode is 'qsa'" - : " (position_ids) is required when policy_mode is 'csa'"); + " (position_ids) is required when policy_mode is 'csa'"); } const bool has_capture_count = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateCaptureCount); const bool has_state_update_active = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateActive); @@ -2960,8 +2959,9 @@ request's own packed token range and fixed-capacity state slice: sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. QSA positions are always the request-local logical cache positions derived - from past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. + incomplete block. QSA uses position_ids when provided; otherwise positions are the + request-local logical cache positions derived from past_sequence_lengths and + cumulative_sequence_lengths. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -3104,7 +3104,8 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OpSchema::Optional) .Input(11, "position_ids", - "Only for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).", + "Absolute position of every packed query, with shape (total_tokens). Required for policy_mode 'csa' " + "and optional for policy_mode 'qsa'.", "I", OpSchema::Optional) .Input(12, From 46e741b1e0dd465b2e4dcc592c22fcd0a20b3f21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:41:16 +0000 Subject: [PATCH 090/102] Address SparseAttentionIndexer review suggestions Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../webgpu/bert/sparse_attention_indexer.cc | 135 +++++++++++++++--- .../webgpu/bert/sparse_attention_indexer.h | 5 +- .../sparse_attention_indexer_op_test.cc | 11 +- 3 files changed, 130 insertions(+), 21 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 085e0ffa1aa53..630a4835d81c1 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -6,6 +6,7 @@ #include #include #include +#include #include "contrib_ops/webgpu/webgpu_contrib_kernels.h" #include "core/providers/webgpu/shader_helper.h" @@ -16,6 +17,10 @@ namespace contrib { namespace webgpu { namespace sai = onnxruntime::contrib::sparse_attention_indexer; +using onnxruntime::webgpu::ProgramTensorMetadataDependency; +using onnxruntime::webgpu::ShaderUsage; +using onnxruntime::webgpu::ShaderVariableHelper; +using onnxruntime::webgpu::WebGpuSupportedFloatTypes; ONNX_OPERATOR_KERNEL_EX( SparseAttentionIndexer, @@ -31,7 +36,10 @@ ONNX_OPERATOR_KERNEL_EX( namespace { -constexpr uint32_t kWorkgroupSize = 64; +constexpr uint32_t kElementwiseWorkgroupSize = 64; +constexpr uint32_t kSerialWorkgroupSize = 1; +constexpr std::string_view kWgslNegativeMax = + "const NEGATIVE_MAX_F32: f32 = -3.4028234663852886e+38;\n"; Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list expected) { ORT_RETURN_IF(tensor == nullptr, "SparseAttentionIndexer: ", name, " is required"); @@ -41,8 +49,31 @@ Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list< return Status::OK(); } +Status CheckUint32(std::initializer_list> values) { + for (const auto& [value, name] : values) { + ORT_RETURN_IF(value < 0 || static_cast(value) > std::numeric_limits::max(), + "SparseAttentionIndexer WebGPU: ", name, " must fit in uint32, got ", value); + } + return Status::OK(); +} + +Status CheckUint32Product(const char* name, std::initializer_list factors) { + uint64_t product = 1; + for (const int64_t factor : factors) { + ORT_RETURN_IF(factor < 0 || (factor != 0 && product > std::numeric_limits::max() / + static_cast(factor)), + "SparseAttentionIndexer WebGPU: ", name, " must fit in uint32"); + product *= static_cast(factor); + } + return Status::OK(); +} + uint32_t ToUint32(int64_t value) { - return onnxruntime::narrow(value); + return static_cast(value); +} + +uint32_t DivideRoundUp(uint32_t value, uint32_t divisor) { + return value / divisor + (value % divisor != 0 ? 1 : 0); } } // namespace @@ -200,7 +231,7 @@ Status SparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHelper& shader.MainFunctionBody() << " let row = workgroup_idx;\n" - << " if (row >= uniforms.rows || local_idx != 0u) { return; }\n" + << " if (row >= uniforms.rows) { 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" @@ -272,6 +303,7 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); shader.AdditionalImplementation() + << kWgslNegativeMax << "fn kv_value(batch: u32, position: u32, channel: u32) -> f32 {\n"; if (has_past_buffer_) { shader.AdditionalImplementation() @@ -312,7 +344,7 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper << " 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" + << " var max_gate = NEGATIVE_MAX_F32;\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" @@ -347,7 +379,7 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper shader.MainFunctionBody() << " let work = workgroup_idx;\n" - << " if (work >= uniforms.work_items || local_idx != 0u) { return; }\n" + << " if (work >= uniforms.work_items) { return; }\n" << " let window = work % uniforms.new_window_count;\n" << " let batch = work / uniforms.new_window_count;\n" << " var square_sum = 0.0;\n" @@ -357,7 +389,9 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper << " }\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 max_position = uniforms.max_rotary_length - 1u;\n" + << " let position = select(entry * uniforms.compress_ratio, max_position, " + "entry > max_position / uniforms.compress_ratio);\n" << " let cache_base = (batch * uniforms.max_rotary_length + position) * uniforms.rotary_width;\n" << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" << " for (var d = 0u; d < uniforms.head_size; d++) {\n" @@ -439,6 +473,7 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& const auto& selected = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); shader.AdditionalImplementation() + << kWgslNegativeMax << "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" @@ -499,7 +534,7 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& shader.MainFunctionBody() << " let row = workgroup_idx;\n" - << " if (row >= uniforms.rows || local_idx != 0u) { return; }\n" + << " if (row >= uniforms.rows) { 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" @@ -507,13 +542,14 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " let count = uniforms.present_compressed_length;\n" << " let threshold = visible_entry_count(row, count);\n" << " let ranks = min(uniforms.capacity, count);\n" + << " if (threshold == 0u || ranks == 0u) { return; }\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" + << " let score = select(NEGATIVE_MAX_F32, 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" @@ -609,6 +645,16 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c 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]; + ORT_RETURN_IF_ERROR(CheckUint32({{batch_size, "batch_size"}, + {sequence_length, "sequence_length"}, + {num_heads, "num_heads"}, + {head_size, "head_size"}, + {query_shape[2], "query width"}, + {past_length, "past sequence length"}, + {compress_ratio_, "compress_ratio"}, + {token_budget_, "token_budget"}})); + ORT_RETURN_IF(past_length > std::numeric_limits::max() - sequence_length, + "SparseAttentionIndexer WebGPU: total sequence length must fit in uint32"); const int64_t total_length = past_length + sequence_length; if (!packed_qk) { ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, head_size})); @@ -629,6 +675,18 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c "SparseAttentionIndexer: qsa mask must be INT64 with shape (batch_size, total_sequence_length)"); const int64_t capacity = sai::SelectedCapacity(policy_, token_budget_, index_topk_, compress_ratio_); + ORT_RETURN_IF_ERROR(CheckUint32({{max_rotary_length, "rotary cache length"}, + {rotary_width, "rotary width"}, + {total_length, "total sequence length"}, + {capacity, "selected capacity"}})); + ORT_RETURN_IF(total_length > std::numeric_limits::max(), + "SparseAttentionIndexer WebGPU: qsa selected-index range must fit in int32"); + ORT_RETURN_IF_ERROR(CheckUint32Product("query element count", {batch_size, sequence_length, query_shape[2]})); + ORT_RETURN_IF_ERROR(CheckUint32Product("present key element count", {batch_size, total_length, head_size})); + ORT_RETURN_IF_ERROR(CheckUint32Product("selected index element count", {batch_size, sequence_length, capacity})); + ORT_RETURN_IF_ERROR(CheckUint32Product("rotary cache element count", + {batch_size, max_rotary_length, rotary_width})); + ORT_RETURN_IF_ERROR(CheckUint32Product("mask element count", {batch_size, total_length})); 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})); @@ -639,7 +697,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c const Tensor* current_key = packed_qk ? query : key; SparseAttentionIndexerQsaConcatProgram concat{has_past, has_current}; concat.CacheHint(has_past, has_current) - .SetWorkgroupSize(kWorkgroupSize); + .SetWorkgroupSize(kElementwiseWorkgroupSize); if (has_past) { concat.AddInput({past_key, ProgramTensorMetadataDependency::Type}); } @@ -647,7 +705,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c concat.AddInput({current_key, ProgramTensorMetadataDependency::Type}); } concat.AddOutput({present, ProgramTensorMetadataDependency::Type}) - .SetDispatchGroupSize((ToUint32(present_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .SetDispatchGroupSize(DivideRoundUp(ToUint32(present_elements), kElementwiseWorkgroupSize)) .AddUniformVariables({{ToUint32(present_elements)}, {ToUint32(sequence_length)}, {ToUint32(past_length)}, @@ -671,7 +729,7 @@ Status SparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeContext& c {sin_cache, ProgramTensorMetadataDependency::Type}}) .AddInput({mask, ProgramTensorMetadataDependency::Type, {mask->Shape().Size()}, 1}) .AddOutput({selected, ProgramTensorMetadataDependency::Type}) - .SetWorkgroupSize(kWorkgroupSize) + .SetWorkgroupSize(kSerialWorkgroupSize) .SetDispatchGroupSize(ToUint32(rows)) .AddUniformVariables({{ToUint32(rows)}, {ToUint32(sequence_length)}, @@ -719,6 +777,8 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c "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"); + ORT_RETURN_IF(head_size > std::numeric_limits::max() / 2, + "SparseAttentionIndexer WebGPU: twice head_size must fit in uint32"); const int64_t width = 2 * head_size; ORT_RETURN_IF_ERROR(CheckShape(key, "key", {batch_size, sequence_length, width})); ORT_RETURN_IF_ERROR(CheckShape(query_norm, "query_norm_weight", {head_size})); @@ -745,12 +805,49 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c past_buffer_shape[1] == batch_size && past_buffer_shape[3] == width, "SparseAttentionIndexer: invalid past_proj_buffer shape"); const int64_t past_buffer_length = past_buffer_shape[2]; + ORT_RETURN_IF_ERROR(CheckUint32({{batch_size, "batch_size"}, + {sequence_length, "sequence_length"}, + {num_heads, "num_heads"}, + {head_size, "head_size"}, + {width, "projection width"}, + {max_rotary_length, "rotary cache length"}, + {rotary_width, "rotary width"}, + {compress_ratio_, "compress_ratio"}, + {index_topk_, "index_topk"}, + {past_compressed_length, "past compressed length"}, + {past_buffer_length, "past projection buffer length"}})); + ORT_RETURN_IF(past_buffer_length > std::numeric_limits::max() - sequence_length, + "SparseAttentionIndexer WebGPU: combined projection sequence length must fit in uint32"); 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_); + ORT_RETURN_IF_ERROR(CheckUint32({{plan.overlap_length, "overlap length"}, + {plan.new_window_count, "new window count"}, + {plan.present_buffer_length, "present projection buffer length"}, + {plan.present_buffer_start, "present projection buffer start"}, + {present_compressed_length, "present compressed length"}, + {capacity, "selected capacity"}})); + ORT_RETURN_IF(present_compressed_length > std::numeric_limits::max(), + "SparseAttentionIndexer WebGPU: csa selected-index range must fit in int32"); + ORT_RETURN_IF_ERROR(CheckUint32Product("query element count", {batch_size, sequence_length, query_shape[2]})); + ORT_RETURN_IF_ERROR(CheckUint32Product("key and gate element count", {batch_size, sequence_length, width})); + ORT_RETURN_IF_ERROR(CheckUint32Product("position bias element count", {compress_ratio_, width})); + ORT_RETURN_IF_ERROR(CheckUint32Product("head weights element count", {batch_size, sequence_length, num_heads})); + ORT_RETURN_IF_ERROR(CheckUint32Product("position IDs element count", {batch_size, sequence_length})); + ORT_RETURN_IF_ERROR( + CheckUint32Product("past compressed key element count", {batch_size, past_compressed_length, head_size})); + ORT_RETURN_IF_ERROR( + CheckUint32Product("past projection buffer element count", {2, batch_size, past_buffer_length, width})); + ORT_RETURN_IF_ERROR( + CheckUint32Product("compressed key element count", {batch_size, present_compressed_length, head_size})); + ORT_RETURN_IF_ERROR( + CheckUint32Product("projection buffer element count", {2, batch_size, plan.present_buffer_length, width})); + ORT_RETURN_IF_ERROR(CheckUint32Product("selected index element count", {batch_size, sequence_length, capacity})); + ORT_RETURN_IF_ERROR(CheckUint32Product("rotary cache element count", + {batch_size, max_rotary_length, rotary_width})); Tensor* selected = context.Output(sai::kSelectedIndices, TensorShape({batch_size, sequence_length, capacity})); Tensor* present_compressed = @@ -764,8 +861,8 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c copy.CacheHint(query->GetElementType()) .AddInput({past_compressed, ProgramTensorMetadataDependency::Type}) .AddOutput({present_compressed, ProgramTensorMetadataDependency::Type}) - .SetWorkgroupSize(kWorkgroupSize) - .SetDispatchGroupSize((ToUint32(past_compressed_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .SetWorkgroupSize(kElementwiseWorkgroupSize) + .SetDispatchGroupSize(DivideRoundUp(ToUint32(past_compressed_elements), kElementwiseWorkgroupSize)) .AddUniformVariables({{ToUint32(past_compressed_elements)}, {ToUint32(head_size)}, {ToUint32(past_compressed_length)}, @@ -787,7 +884,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_compressed, ProgramTensorMetadataDependency::Type}) - .SetWorkgroupSize(kWorkgroupSize) + .SetWorkgroupSize(kSerialWorkgroupSize) .SetDispatchGroupSize(ToUint32(batch_size * plan.new_window_count)) .AddUniformVariables({{ToUint32(batch_size * plan.new_window_count)}, {ToUint32(sequence_length)}, @@ -818,8 +915,8 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c copy_buffer.AddInput({past_proj, ProgramTensorMetadataDependency::Type}); } copy_buffer.AddOutput({present_proj, ProgramTensorMetadataDependency::Type}) - .SetWorkgroupSize(kWorkgroupSize) - .SetDispatchGroupSize((ToUint32(present_buffer_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .SetWorkgroupSize(kElementwiseWorkgroupSize) + .SetDispatchGroupSize(DivideRoundUp(ToUint32(present_buffer_elements), kElementwiseWorkgroupSize)) .AddUniformVariables({{ToUint32(present_buffer_elements)}, {ToUint32(sequence_length)}, {ToUint32(head_size)}, @@ -837,8 +934,8 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c const int64_t output_elements = selected->Shape().Size(); SparseAttentionIndexerFillProgram fill; fill.AddOutput({selected, ProgramTensorMetadataDependency::None}) - .SetWorkgroupSize(kWorkgroupSize) - .SetDispatchGroupSize((ToUint32(output_elements) + kWorkgroupSize - 1) / kWorkgroupSize) + .SetWorkgroupSize(kElementwiseWorkgroupSize) + .SetDispatchGroupSize(DivideRoundUp(ToUint32(output_elements), kElementwiseWorkgroupSize)) .AddUniformVariable({ToUint32(output_elements)}); return context.RunProgram(fill); } @@ -852,7 +949,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}}) .AddOutput({selected, ProgramTensorMetadataDependency::Type}) - .SetWorkgroupSize(kWorkgroupSize) + .SetWorkgroupSize(kSerialWorkgroupSize) .SetDispatchGroupSize(ToUint32(rows)) .AddUniformVariables({{ToUint32(rows)}, {ToUint32(sequence_length)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h index 73f9ac656c0c2..f8751b0283ea0 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.h @@ -11,7 +11,10 @@ namespace onnxruntime { namespace contrib { namespace webgpu { -using namespace onnxruntime::webgpu; +using onnxruntime::webgpu::Program; +using onnxruntime::webgpu::ProgramUniformVariableDataType; +using onnxruntime::webgpu::ShaderHelper; +using onnxruntime::webgpu::WebGpuKernel; class SparseAttentionIndexerFillProgram final : public Program { public: 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 17ab09a18d24a..54f0439c3647e 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -644,7 +644,10 @@ std::unique_ptr CreateProvider(ProviderKind provider_kind) { void RunOnProvider(OpTester& test, std::unique_ptr provider) { std::vector> providers; providers.push_back(std::move(provider)); - test.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); + SessionOptions session_options; + ASSERT_STATUS_OK( + session_options.config_options.AddConfigEntry(kOrtSessionOptionsDisableCPUEPFallback, "1")); + test.Run(std::move(session_options), OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &providers); } QsaProblem MakeQsaProblem(QsaProblem problem = {}) { @@ -1300,6 +1303,12 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaInt64MaxPosition) { problem.position_ids[0] = std::numeric_limits::max(); RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); } + +TEST(SparseAttentionIndexerWebGpuTest, CsaNegativePosition) { + CsaProblem problem = MakeCsaProblem(); + problem.position_ids[0] = -1; + RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); +} #endif } // namespace test } // namespace onnxruntime From f5f5b7103fd31f8a5f1e1d2fa372c57525054b41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:22:06 +0000 Subject: [PATCH 091/102] Merge latest main and implement CSA review fixes Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../webgpu/bert/sparse_attention_indexer.cc | 46 +++++++++++---- .../sparse_attention_indexer_op_test.cc | 56 +++++++++++++++++++ 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc index 630a4835d81c1..dc2279ca04716 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -276,6 +276,8 @@ Status SparseAttentionIndexerCsaCopyCompressedProgram::GenerateShaderCode(Shader const auto& past = shader.AddInput("past_compressed_key", ShaderUsage::UseUniform); const auto& present = shader.AddOutput("present_compressed_key", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& scoring_keys = shader.AddOutput("scoring_compressed_keys", + ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); shader.MainFunctionBody() << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.total") << " let d = global_idx % uniforms.head_size;\n" @@ -284,6 +286,8 @@ Status SparseAttentionIndexerCsaCopyCompressedProgram::GenerateShaderCode(Shader << " 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" + << " " << scoring_keys.SetByOffset("output", "scoring_compressed_keys_element_t(" + past.GetByOffset("global_idx") + ")") << "\n"; return Status::OK(); } @@ -301,6 +305,8 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper const auto& sin_cache = shader.AddInput("sin_cache", ShaderUsage::UseUniform); const auto& present = shader.AddOutput("present_compressed_key", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); + const auto& scoring_keys = shader.AddOutput("scoring_compressed_keys", + ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); shader.AdditionalImplementation() << kWgslNegativeMax @@ -407,6 +413,7 @@ Status SparseAttentionIndexerCsaCompressProgram::GenerateShaderCode(ShaderHelper << " }\n" << " let output = (batch * uniforms.present_compressed_length + entry) * uniforms.head_size + d;\n" << " " << present.SetByOffset("output", "present_compressed_key_element_t(value)") << "\n" + << " " << scoring_keys.SetByOffset("output", "scoring_compressed_keys_element_t(value)") << "\n" << " }\n"; return Status::OK(); } @@ -465,7 +472,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& compressed_key = shader.AddInput("scoring_compressed_keys", 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); @@ -481,13 +488,25 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " return raw.x;\n" << "}\n" << "fn visible_entry_count(row: u32, count: u32) -> u32 {\n" - << " let raw = " << position_ids.GetByOffset("row", true) << ";\n" + << " var 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" + << " raw.x += 1u;\n" + << " raw.y += select(0u, 1u, raw.x == 0u);\n" + << " if (raw.y / uniforms.compress_ratio != 0u) { return count; }\n" + << " var remainder = raw.y;\n" + << " var quotient = 0u;\n" + << " for (var bit_index = 32u; bit_index > 0u; bit_index--) {\n" + << " let bit = bit_index - 1u;\n" + << " let carry = remainder >> 31u;\n" + << " let doubled = (remainder << 1u) | ((raw.x >> bit) & 1u);\n" + << " if (carry != 0u || doubled >= uniforms.compress_ratio) {\n" + << " remainder = doubled - uniforms.compress_ratio;\n" + << " quotient |= 1u << bit;\n" + << " } else {\n" + << " remainder = doubled;\n" + << " }\n" + << " }\n" + << " return min(count, quotient);\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" @@ -854,13 +873,19 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c context.Output(sai::kPresentKey, TensorShape({batch_size, present_compressed_length, head_size})); Tensor* present_proj = context.Output( sai::kPresentProjBuffer, TensorShape({2, batch_size, plan.present_buffer_length, width})); + std::optional scoring_compressed_keys; + if (present_compressed_length > 0) { + scoring_compressed_keys.emplace(context.CreateGPUTensor( + DataTypeImpl::GetType(), TensorShape({batch_size, present_compressed_length, head_size}))); + } 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}) + .AddOutputs({{present_compressed, ProgramTensorMetadataDependency::Type}, + {&*scoring_compressed_keys, ProgramTensorMetadataDependency::Type}}) .SetWorkgroupSize(kElementwiseWorkgroupSize) .SetDispatchGroupSize(DivideRoundUp(ToUint32(past_compressed_elements), kElementwiseWorkgroupSize)) .AddUniformVariables({{ToUint32(past_compressed_elements)}, @@ -883,7 +908,8 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c {key_norm, ProgramTensorMetadataDependency::Type}, {cos_cache, ProgramTensorMetadataDependency::Type}, {sin_cache, ProgramTensorMetadataDependency::Type}}) - .AddOutput({present_compressed, ProgramTensorMetadataDependency::Type}) + .AddOutputs({{present_compressed, ProgramTensorMetadataDependency::Type}, + {&*scoring_compressed_keys, ProgramTensorMetadataDependency::Type}}) .SetWorkgroupSize(kSerialWorkgroupSize) .SetDispatchGroupSize(ToUint32(batch_size * plan.new_window_count)) .AddUniformVariables({{ToUint32(batch_size * plan.new_window_count)}, @@ -943,7 +969,7 @@ Status SparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeContext& c 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}, + {&*scoring_compressed_keys, ProgramTensorMetadataDependency::Type}, {head_weights, ProgramTensorMetadataDependency::Type}, {position_ids, ProgramTensorMetadataDependency::Type}, {cos_cache, ProgramTensorMetadataDependency::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 index 54f0439c3647e..cfe46cd705462 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -30,6 +30,7 @@ #include "contrib_ops/cpu/sparse/sparse_attention_indexer_common.h" #include "core/graph/constants.h" #include "core/graph/model.h" +#include "core/session/onnxruntime_session_options_config_keys.h" #include "test/common/tensor_op_test_utils.h" #include "test/providers/provider_test_utils.h" #include "test/test_environment.h" @@ -1283,6 +1284,33 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaFloat16) { RunCsaTest(MakeCsaProblem(), 6.0e-3f, ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, CsaFloat16ScoresNewKeysBeforeCacheRounding) { + CsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 1; + problem.head_size = 2; + problem.rotary_width = 1; + problem.compress_ratio = 1; + problem.index_topk = 2; + problem.past_compressed_length = 1; + problem.past_buffer_length = 0; + problem.max_rotary_length = 4; + problem.query = {1.0f, 3.0e-4f}; + problem.key = {0.0f, 0.0f, 1.0f, 3.0e-4f}; + problem.query_norm_weight = {1.0f, 1.0f}; + problem.key_norm_weight = {1.0f, 1.0f}; + problem.cos_cache.assign(4, 1.0f); + problem.sin_cache.assign(4, 0.0f); + problem.gate.assign(4, 0.0f); + problem.position_bias.assign(4, 0.0f); + problem.head_weights = {1.0f}; + problem.position_ids = {2}; + problem.past_compressed_key = + RoundTrip(RmsNormalize({1.0f, 3.0e-4f}, problem.key_norm_weight, problem.epsilon)); + RunCsaTest(problem, 2.0e-3f, ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaBufferOnlyStep) { RunCsaTest(MakeCsaBufferOnlyProblem(), 1.0e-5f, ProviderKind::WebGpu); } @@ -1304,6 +1332,34 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaInt64MaxPosition) { RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, CsaInt64PositionVisibilityBoundary) { + CsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 1; + problem.head_size = 2; + problem.rotary_width = 1; + problem.compress_ratio = 65536; + problem.index_topk = 1; + problem.past_compressed_length = 65538; + problem.past_buffer_length = 0; + problem.max_rotary_length = 1; + problem.query = {1.0f, 0.0f}; + problem.key.assign(4, 0.0f); + problem.query_norm_weight = {1.0f, 1.0f}; + problem.key_norm_weight = {1.0f, 1.0f}; + problem.cos_cache = {1.0f}; + problem.sin_cache = {0.0f}; + problem.gate.assign(4, 0.0f); + problem.position_bias.assign(static_cast(problem.compress_ratio) * 4, 0.0f); + problem.head_weights = {1.0f}; + problem.position_ids = {int64_t{1} << 32}; + problem.past_compressed_key.assign(static_cast(problem.past_compressed_length) * 2, 0.0f); + problem.past_compressed_key[65535 * 2] = 1.0f; + problem.past_compressed_key[65537 * 2] = 2.0f; + RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaNegativePosition) { CsaProblem problem = MakeCsaProblem(); problem.position_ids[0] = -1; From 47695edba948c2bdc67fde53805a770c83976e0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:15:38 +0000 Subject: [PATCH 092/102] Fix masked CSA candidate selection Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../webgpu/bert/sparse_attention_indexer.cc | 11 +++----- .../sparse_attention_indexer_op_test.cc | 26 +++++++++++++++++++ 2 files changed, 30 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 dc2279ca04716..39ee7857def52 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/sparse_attention_indexer.cc @@ -480,7 +480,6 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& const auto& selected = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); shader.AdditionalImplementation() - << kWgslNegativeMax << "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" @@ -560,15 +559,15 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " }\n" << " let count = uniforms.present_compressed_length;\n" << " let threshold = visible_entry_count(row, count);\n" - << " let ranks = min(uniforms.capacity, count);\n" + << " let ranks = min(uniforms.capacity, threshold);\n" << " if (threshold == 0u || ranks == 0u) { return; }\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(NEGATIVE_MAX_F32, entry_score(row, candidate), candidate < threshold);\n" + << " for (var candidate = 0u; candidate < threshold; candidate++) {\n" + << " let score = entry_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" @@ -577,9 +576,7 @@ Status SparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& << " }\n" << " }\n" << " if (best_index < 0) { break; }\n" - << " if (u32(best_index) < threshold) {\n" - << " " << selected.SetByOffset("output_base + rank", "best_index") << "\n" - << " }\n" + << " " << selected.SetByOffset("output_base + rank", "best_index") << "\n" << " previous_score = best_score;\n" << " previous_index = best_index;\n" << " }\n"; 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 cfe46cd705462..0a2c6068d65ac 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1360,6 +1360,32 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaInt64PositionVisibilityBoundary) { RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); } +TEST(SparseAttentionIndexerWebGpuTest, CsaMaskedCandidatesDoNotOutrankNegativeInfinity) { + CsaProblem problem; + problem.batch_size = 1; + problem.sequence_length = 1; + problem.num_heads = 1; + problem.head_size = 2; + problem.rotary_width = 1; + problem.compress_ratio = 1; + problem.index_topk = 1; + problem.past_compressed_length = 2; + problem.past_buffer_length = 0; + problem.max_rotary_length = 1; + problem.query = {1.0f, 0.0f}; + problem.key.assign(4, 0.0f); + problem.query_norm_weight = {1.0f, 1.0f}; + problem.key_norm_weight = {1.0f, 1.0f}; + problem.cos_cache = {1.0f}; + problem.sin_cache = {0.0f}; + problem.gate.assign(4, 0.0f); + problem.position_bias.assign(4, 0.0f); + problem.head_weights = {-std::numeric_limits::infinity()}; + problem.position_ids = {0}; + problem.past_compressed_key = {1.0f, 0.0f, 2.0f, 0.0f}; + RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); +} + TEST(SparseAttentionIndexerWebGpuTest, CsaNegativePosition) { CsaProblem problem = MakeCsaProblem(); problem.position_ids[0] = -1; From df34d437f2e18937f6b09967ffed3363cb90fb30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:57:11 +0000 Subject: [PATCH 093/102] Reduce WebGPU int64 visibility test workload Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/contrib_ops/sparse_attention_indexer_op_test.cc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 0a2c6068d65ac..c98aaf244b548 100644 --- a/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/sparse_attention_indexer_op_test.cc @@ -1339,9 +1339,9 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaInt64PositionVisibilityBoundary) { problem.num_heads = 1; problem.head_size = 2; problem.rotary_width = 1; - problem.compress_ratio = 65536; + problem.compress_ratio = 2; problem.index_topk = 1; - problem.past_compressed_length = 65538; + problem.past_compressed_length = 2; problem.past_buffer_length = 0; problem.max_rotary_length = 1; problem.query = {1.0f, 0.0f}; @@ -1354,9 +1354,7 @@ TEST(SparseAttentionIndexerWebGpuTest, CsaInt64PositionVisibilityBoundary) { problem.position_bias.assign(static_cast(problem.compress_ratio) * 4, 0.0f); problem.head_weights = {1.0f}; problem.position_ids = {int64_t{1} << 32}; - problem.past_compressed_key.assign(static_cast(problem.past_compressed_length) * 2, 0.0f); - problem.past_compressed_key[65535 * 2] = 1.0f; - problem.past_compressed_key[65537 * 2] = 2.0f; + problem.past_compressed_key = {1.0f, 0.0f, 2.0f, 0.0f}; RunCsaTest(problem, 1.0e-5f, ProviderKind::WebGpu); } From c8bd7cf1e38d61826024f14de4b62b7d72c5c115 Mon Sep 17 00:00:00 2001 From: Kunal Vaishnavi Date: Sat, 3 Oct 2026 22:30:34 +0000 Subject: [PATCH 094/102] Keep packed sparse indexer state-update gate on CPU for CUDA graphs --- .../cuda/sparse/packed_sparse_attention_indexer.cc | 3 ++- .../sparse/packed_sparse_attention_indexer_impl.cu | 8 ++++---- .../sparse/packed_sparse_attention_indexer_impl.h | 2 +- .../packed_sparse_attention_indexer_op_test.cc | 12 ++++++++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index f15da84ec84c9..b3f49c031705c 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -30,6 +30,7 @@ namespace psai = onnxruntime::contrib::packed_sparse_attention_indexer; .TypeConstraint("T", DataTypeImpl::GetTensorType()) \ .TypeConstraint("I", DataTypeImpl::GetTensorType()) \ .TypeConstraint("M", DataTypeImpl::GetTensorType()) \ + .InputMemoryType(OrtMemTypeCPUInput, psai::kStateUpdateActive) \ .MayInplace(psai::kPastKeyState, psai::kPresentKeyState) \ .MayInplace(psai::kPastKvBuffer, psai::kPresentKvBuffer) \ .MayInplace(psai::kPastGateBuffer, psai::kPresentGateBuffer) \ @@ -351,7 +352,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(OpKernelContext* context) con reinterpret_cast(past_kv_buffer->Data()), past_state_lengths->Data(), state_update_capture_count != nullptr ? state_update_capture_count->Data() : nullptr, - state_update_active != nullptr ? state_update_active->Data() : nullptr, + state_update_active == nullptr || state_update_active->Data()[0] != 0, selected_indices->MutableData(), selected_counts->MutableData(), reinterpret_cast(present_key_state->MutableData()), diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 3a47d4bd200ff..73fa744a18321 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -131,7 +131,7 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con const T* sin_cache, const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const T* past_kv_buffer, const int32_t* past_state_lengths, const int32_t* state_update_capture_count, - const int32_t* state_update_active, + bool state_update_active, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, T* state_update, int32_t* overflow_flags, PackedSparseAttentionIndexerParams params) { @@ -168,7 +168,7 @@ __global__ void QsaUpdateStateKernel(const T* key, const T* key_norm_weight, con const int new_block_count = rejected ? 0 : full_new_block_count; const int new_buf_len = rejected ? old_buf_len : (pending % params.compress_ratio); const bool capture_active = !rejected && state_update != nullptr && state_update_capture_count != nullptr && - (state_update_active == nullptr || state_update_active[0] != 0); + state_update_active; const int capture_count = capture_active ? min(max(state_update_capture_count[b], 0), min(req_len, params.state_update_capacity)) @@ -1214,7 +1214,7 @@ Status LaunchQsaPackedSparseAttentionIndexer( const int32_t* cumulative_sequence_lengths, const int32_t* past_sequence_lengths, const int64_t* position_ids, const T* past_key_state, const T* past_kv_buffer, const int32_t* past_state_lengths, const int32_t* state_update_capture_count, - const int32_t* state_update_active, int32_t* selected_indices, + bool state_update_active, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, T* present_kv_buffer, int32_t* present_state_lengths, T* state_update, float* float_workspace, int32_t* overflow_flags) { if (params.batch_size > 0) { @@ -1399,7 +1399,7 @@ Status LaunchCsaPackedSparseAttentionIndexer( template Status LaunchQsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, const T*, const int32_t*, \ - const int32_t*, const int32_t*, int32_t*, int32_t*, T*, T*, int32_t*, T*, float*, int32_t*); \ + const int32_t*, bool, int32_t*, int32_t*, T*, T*, int32_t*, T*, float*, int32_t*); \ template Status LaunchCsaPackedSparseAttentionIndexer( \ cudaStream_t, const PackedSparseAttentionIndexerParams&, const T*, const T*, const T*, const T*, \ const T*, const T*, const T*, const T*, const T*, const int32_t*, const int32_t*, const int64_t*, const T*, \ diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h index d78f12c2c9c98..aa8b346d044c6 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.h @@ -70,7 +70,7 @@ Status LaunchQsaPackedSparseAttentionIndexer( const T* past_kv_buffer, const int32_t* past_state_lengths, const int32_t* state_update_capture_count, - const int32_t* state_update_active, + bool state_update_active, int32_t* selected_indices, int32_t* selected_counts, T* present_key_state, diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 9b01ee34aee30..1369a56e63f95 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -999,6 +999,18 @@ TEST(PackedSparseAttentionIndexerTest, QsaStateUpdateActiveZeroClearsAllSlots) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::Cuda); } +TEST(PackedSparseAttentionIndexerTest, QsaStateUpdateActiveOneCapturesRawAndBlockTransitions) { + QsaPackedProblem problem; + problem.batch_size = 2; + problem.compress_ratio = 4; + problem.cumulative_sequence_lengths = {0, 8, 13}; + problem.past_sequence_lengths = {0, 3}; + problem.state_update_capacity = 8; + problem.state_update_capture_count = {8, 5}; + problem.state_update_active = 1; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::Cuda); +} + TEST(PackedSparseAttentionIndexerTest, QsaBoundedTopKMaximum) { QsaPackedProblem problem; problem.batch_size = 1; From 8b9acbb1630194e1a591ab553fecad416265a845 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:50:19 +0000 Subject: [PATCH 095/102] Fix packed sparse indexer review issues Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../packed_sparse_attention_indexer_impl.cu | 9 +- .../bert/packed_sparse_attention_indexer.cc | 88 ++++++++++++++----- .../core/graph/contrib_ops/bert_defs.cc | 5 ++ ...packed_sparse_attention_indexer_op_test.cc | 51 ++++++++++- 4 files changed, 127 insertions(+), 26 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index 038e6f7f24123..9f882bf732e04 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -590,10 +590,13 @@ __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_n // (from the baseline copy in the Launch function below), which is exactly the prior valid // buffer this rejected step must preserve. if (!rejected) { - for (int t = static_cast(threadIdx.x); t < present_buffer_length; t += static_cast(blockDim.x)) { + // Process slots in ascending order with a barrier between slots. When past and present alias, + // the destination slot precedes its source slot, so this is the device-wide equivalent of + // a forward memmove and prevents one thread from overwriting another thread's source. + for (int t = 0; t < present_buffer_length; ++t) { const int virtual_pos = present_buffer_start + t; const int64_t out_base = (static_cast(b) * params.buffer_capacity + t) * width; - for (int c = 0; c < width; ++c) { + for (int c = static_cast(threadIdx.x); c < width; c += static_cast(blockDim.x)) { const float key_value = virtual_pos < old_buf_len ? to_float( @@ -608,9 +611,9 @@ __global__ void CsaUpdateStateKernel(const T* key, const T* gate, const T* key_n present_kv_buffer[out_base + c] = from_float(key_value); present_gate_buffer[out_base + c] = from_float(gate_value); } + __syncthreads(); } } - __syncthreads(); } } diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index f38f8969307b7..b7347faaf78e5 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -48,6 +48,17 @@ namespace { constexpr uint32_t kWorkgroupSize = 64; +uint32_t StorageBindingSegments(const Tensor* tensor, uint64_t max_binding_size) { + if (tensor == nullptr) { + return 0; + } + const uint64_t bytes = tensor->SizeInBytes(); + if (max_binding_size == 0 || bytes <= max_binding_size) { + return 1; + } + return static_cast((bytes + max_binding_size - 1) / max_binding_size); +} + Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list expected) { ORT_RETURN_IF(tensor == nullptr, "PackedSparseAttentionIndexer: ", name, " is required"); const TensorShape expected_shape(expected); @@ -56,6 +67,24 @@ Status CheckShape(const Tensor* tensor, const char* name, std::initializer_list< return Status::OK(); } +Status CheckStorageBindingLimit(const onnxruntime::webgpu::ComputeContext& context, + const char* program_name, + std::initializer_list tensors) { + const auto& limits = context.DeviceLimits(); + uint32_t binding_count = 0; + for (const Tensor* tensor : tensors) { + ORT_RETURN_IF(tensor == nullptr, "PackedSparseAttentionIndexer: missing tensor for ", program_name); + binding_count += StorageBindingSegments(tensor, limits.maxStorageBufferBindingSize); + } + if (binding_count > limits.maxStorageBuffersPerShaderStage) { + return ORT_MAKE_STATUS(ONNXRUNTIME, NOT_IMPLEMENTED, + "PackedSparseAttentionIndexer (WebGPU): ", program_name, " requires ", binding_count, + " storage-buffer bindings after segmentation, but this adapter supports only ", + limits.maxStorageBuffersPerShaderStage, " per shader stage."); + } + return Status::OK(); +} + uint32_t ToUint32(int64_t value) { return onnxruntime::narrow(value); } struct RotaryCacheShape { @@ -865,7 +894,15 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont } } + if (total_tokens == 0) { + return Status::OK(); + } + if (batch_size > 0) { + ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( + context, "QSA update", + {key, norm, &rotary_cache, cu_seqlens, past_seqlens, present_key_state, present_kv_buffer, + present_state_lengths, &overflow_flags})); PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; update.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) @@ -891,10 +928,10 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(context.RunProgram(update)); } - if (total_tokens == 0) { - return Status::OK(); - } - + ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( + context, "QSA selection", + {query, query_norm, present_key_state, &rotary_cache, cu_seqlens, past_seqlens, present_state_lengths, + &overflow_flags, selected_indices, selected_counts})); PackedSparseAttentionIndexerQsaSelectProgram select{rotary.batched}; select.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) @@ -993,20 +1030,6 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckShape(past_state_lengths, "past_state_lengths", {batch_size, psai::kStateLengthColumns})); - Tensor key_gate = - context.CreateGPUTensor(key->DataType(), TensorShape({key->Shape().Size() + gate->Shape().Size()})); - ORT_RETURN_IF_ERROR(PackTwoTensors(context, *key, *gate, key_gate)); - Tensor rotary_cache = - context.CreateGPUTensor(cos_cache->DataType(), TensorShape({2 * cos_cache->Shape().Size()})); - ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cos_cache, *sin_cache, rotary_cache)); - Tensor sequence_metadata = context.CreateGPUTensor( - cu_seqlens->DataType(), TensorShape({cu_seqlens->Shape().Size() + past_seqlens->Shape().Size()})); - ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cu_seqlens, *past_seqlens, sequence_metadata)); - // Keep the CSA selection shader within Metal's 10-storage-buffer-per-stage limit. - Tensor query_metadata = context.CreateGPUTensor( - query_norm->DataType(), TensorShape({query_norm->Shape().Size() + head_weights->Shape().Size()})); - ORT_RETURN_IF_ERROR(PackTwoTensors(context, *query_norm, *head_weights, query_metadata)); - const int64_t capacity = psai::SelectedCapacity(psai::Policy::kCsa, token_budget_, index_topk_, compress_ratio_); Tensor* selected_indices = context.Output(psai::kSelectedIndices, TensorShape({total_tokens, capacity})); Tensor* selected_counts = context.Output(psai::kSelectedCounts, TensorShape({total_tokens})); @@ -1040,7 +1063,28 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(copy_if_needed(past_gate_buffer, present_gate_buffer)); ORT_RETURN_IF_ERROR(copy_if_needed(past_state_lengths, present_state_lengths)); + if (total_tokens == 0) { + return Status::OK(); + } + + Tensor key_gate = + context.CreateGPUTensor(key->DataType(), TensorShape({key->Shape().Size() + gate->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *key, *gate, key_gate)); + Tensor rotary_cache = + context.CreateGPUTensor(cos_cache->DataType(), TensorShape({2 * cos_cache->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cos_cache, *sin_cache, rotary_cache)); + Tensor sequence_metadata = context.CreateGPUTensor( + cu_seqlens->DataType(), TensorShape({cu_seqlens->Shape().Size() + past_seqlens->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cu_seqlens, *past_seqlens, sequence_metadata)); + Tensor query_metadata = context.CreateGPUTensor( + query_norm->DataType(), TensorShape({query_norm->Shape().Size() + head_weights->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *query_norm, *head_weights, query_metadata)); + if (batch_size > 0) { + ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( + context, "CSA update", + {&key_gate, norm, &rotary_cache, position_bias, &sequence_metadata, present_key_state, present_kv_buffer, + present_gate_buffer, present_state_lengths, &overflow_flags})); PackedSparseAttentionIndexerCsaUpdateProgram update{rotary.batched}; update.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) @@ -1067,10 +1111,10 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(context.RunProgram(update)); } - if (total_tokens == 0) { - return Status::OK(); - } - + ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( + context, "CSA selection", + {query, &query_metadata, present_key_state, position_ids, &rotary_cache, cu_seqlens, present_state_lengths, + &overflow_flags, selected_indices, selected_counts})); PackedSparseAttentionIndexerCsaSelectProgram select{rotary.batched}; select.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 187e26f58945d..3d056a5b033dc 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2748,6 +2748,11 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference fail_shape_inference("PackedSparseAttentionIndexer: exactly ", psai::kFixedOutputCount, " declared outputs are required, got ", ctx.getNumOutputs()); } + if (ctx.hasOutput(psai::kPresentGateBuffer) == is_qsa) { + fail_shape_inference("PackedSparseAttentionIndexer: output ", psai::kPresentGateBuffer, + is_qsa ? " (present_gate_buffer) must be omitted when policy_mode is 'qsa'" + : " (present_gate_buffer) is required when policy_mode is 'csa'"); + } updateOutputElemType(ctx, psai::kSelectedIndices, ONNX_NAMESPACE::TensorProto_DataType_INT32); updateOutputElemType(ctx, psai::kSelectedCounts, ONNX_NAMESPACE::TensorProto_DataType_INT32); updateOutputElemType(ctx, psai::kPresentStateLengths, ONNX_NAMESPACE::TensorProto_DataType_INT32); diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 4280d27d11ca8..df461eb55c47b 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -99,6 +99,8 @@ struct GraphOptions { bool add_index_topk = false; bool add_csa_inputs = false; bool add_position_ids = false; + bool add_qsa_gate_output = false; + bool omit_csa_gate_output = false; int output_count = psai::kFixedOutputCount; std::string policy_mode = psai::kPolicyModeQsa; }; @@ -157,7 +159,10 @@ void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { std::vector outputs; for (int i = 0; i < options.output_count; ++i) { - outputs.push_back(i == psai::kPresentGateBuffer && !is_csa ? &empty : builder.MakeOutput()); + const bool omit_gate_output = + i == psai::kPresentGateBuffer && + ((is_csa && options.omit_csa_gate_output) || (!is_csa && !options.add_qsa_gate_output)); + outputs.push_back(omit_gate_output ? &empty : builder.MakeOutput()); } Node& node = builder.AddNode("PackedSparseAttentionIndexer", inputs, outputs, kMSDomain); node.AddAttribute("policy_mode", options.policy_mode); @@ -373,6 +378,21 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsWrongOutputCount) { "output size 4 not in range [min=6, max=6]"); } +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaPresentGateBuffer) { + GraphOptions options; + options.add_qsa_gate_output = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "present_gate_buffer) must be omitted"); +} + +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsMissingCsaPresentGateBuffer) { + GraphOptions options; + options.policy_mode = psai::kPolicyModeCsa; + options.omit_csa_gate_output = true; + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "present_gate_buffer) is required"); +} + #endif // ORT_NO_EXCEPTIONS // --------------------------------------------------------------------------------------------- @@ -856,6 +876,15 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaPackedTest(2.0e-3f, MakeQsaPackedProblem(), ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaAllEmptyBatchPreservesState) { + QsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 0}; + problem.past_sequence_lengths = {5}; + problem.past_state_lengths = {2, 1}; + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(std::move(problem)), ProviderKind::WebGpu); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, QsaStateCapacityOverflowIsRejected) { QsaPackedProblem problem; problem.batch_size = 1; @@ -1220,6 +1249,17 @@ TEST(PackedSparseAttentionIndexerTest, CsaFloat16) { RunCsaPackedTest TEST(PackedSparseAttentionIndexerTest, CsaBFloat16) { RunCsaPackedTest(MakeCsaPackedProblem(), 3.0e-2f); } +TEST(PackedSparseAttentionIndexerTest, CsaAliasedBufferCompaction) { + CsaPackedProblem problem; + problem.batch_size = 1; + problem.compress_ratio = 64; + problem.cumulative_sequence_lengths = {0, 64}; + problem.past_sequence_lengths = {65}; + problem.state_capacity = 4; + problem.past_state_lengths = {0, 65}; + RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f); +} + TEST(PackedSparseAttentionIndexerTest, CsaPrefillThenDecodeIndependentState) { if (DefaultCudaExecutionProvider() == nullptr) { GTEST_SKIP() << "CUDA execution provider is not available"; @@ -1272,6 +1312,15 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, CsaFloat16) { RunCsaPackedTest(MakeCsaPackedProblem(), 4.0e-3f, ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, CsaAllEmptyBatchPreservesState) { + CsaPackedProblem problem; + problem.batch_size = 1; + problem.cumulative_sequence_lengths = {0, 0}; + problem.past_sequence_lengths = {3}; + problem.past_state_lengths = {1, 1}; + RunCsaPackedTest(MakeCsaPackedProblem(std::move(problem)), 1.0e-5f, ProviderKind::WebGpu); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, CsaStateCapacityOverflowIsRejected) { CsaPackedProblem problem; problem.batch_size = 1; From 8dc299885f71e4bed00691b06b80dabb4355225f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:53:56 +0000 Subject: [PATCH 096/102] Clarify large-ratio compaction coverage Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/contrib_ops/packed_sparse_attention_indexer_op_test.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index df461eb55c47b..4ccd4444cf94f 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -1249,7 +1249,7 @@ TEST(PackedSparseAttentionIndexerTest, CsaFloat16) { RunCsaPackedTest TEST(PackedSparseAttentionIndexerTest, CsaBFloat16) { RunCsaPackedTest(MakeCsaPackedProblem(), 3.0e-2f); } -TEST(PackedSparseAttentionIndexerTest, CsaAliasedBufferCompaction) { +TEST(PackedSparseAttentionIndexerTest, CsaLargeRatioCompaction) { CsaPackedProblem problem; problem.batch_size = 1; problem.compress_ratio = 64; From 01820176d7d89d1a79ddae6a92585c324e4694bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:45:17 +0000 Subject: [PATCH 097/102] Reduce packed indexer WebGPU bindings Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../bert/packed_sparse_attention_indexer.cc | 170 ++++++++++-------- .../bert/packed_sparse_attention_indexer.h | 1 + ...packed_sparse_attention_indexer_op_test.cc | 32 +++- 3 files changed, 124 insertions(+), 79 deletions(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index b7347faaf78e5..cca50c4a3aadd 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -127,12 +127,11 @@ Status PackedSparseAttentionIndexerCopyProgram::GenerateShaderCode(ShaderHelper& return Status::OK(); } -static Status PackTwoTensors(onnxruntime::webgpu::ComputeContext& context, - const Tensor& first, - const Tensor& second, - Tensor& packed) { +static Status PackTensors(onnxruntime::webgpu::ComputeContext& context, + std::initializer_list sources, + Tensor& packed) { uint32_t dst_offset = 0; - for (const Tensor* source : {&first, &second}) { + for (const Tensor* source : sources) { const uint32_t total = ToUint32(source->Shape().Size()); if (total > 0) { PackedSparseAttentionIndexerCopyProgram copy; @@ -148,12 +147,18 @@ static Status PackTwoTensors(onnxruntime::webgpu::ComputeContext& context, return Status::OK(); } +static Status PackTwoTensors(onnxruntime::webgpu::ComputeContext& context, + const Tensor& first, + const Tensor& second, + Tensor& packed) { + return PackTensors(context, {&first, &second}, packed); +} + Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& key = shader.AddInput("key", ShaderUsage::UseUniform); const auto& norm = shader.AddInput("key_norm_weight", ShaderUsage::UseUniform); const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); - const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); - const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); + const auto& sequence_metadata = shader.AddInput("sequence_metadata", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_kv_buffer = @@ -218,15 +223,15 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe shader.MainFunctionBody() << " let b = workgroup_idx;\n" << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" - << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" - << " let req_end = " << cu_seqlens.GetByOffset("b + 1u") << ";\n" + << " let req_start = " << sequence_metadata.GetByOffset("b") << ";\n" + << " let req_end = " << sequence_metadata.GetByOffset("b + 1u") << ";\n" << " let raw_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" << " let raw_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" << " let old_key_len = clamp(raw_key_len, 0, i32(uniforms.state_capacity));\n" << " let old_buf_len = clamp(raw_buf_len, 0, i32(uniforms.compress_ratio) - 1);\n" - << " let past_len = " << past_seqlens.GetByOffset("b") << ";\n" - << " let invalid = " << cu_seqlens.GetByOffset("0") << " != 0 || " - << cu_seqlens.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " + << " let past_len = " << sequence_metadata.GetByOffset("uniforms.batch_size + 1u + b") << ";\n" + << " let invalid = " << sequence_metadata.GetByOffset("0") << " != 0 || " + << sequence_metadata.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || past_len < 0 || " << "raw_key_len < 0 || raw_key_len > i32(uniforms.state_capacity) || raw_buf_len < 0 || " << "raw_buf_len >= i32(uniforms.compress_ratio) || " @@ -273,12 +278,10 @@ Status PackedSparseAttentionIndexerQsaUpdateProgram::GenerateShaderCode(ShaderHe } Status PackedSparseAttentionIndexerQsaSelectProgram::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& query_metadata = shader.AddInput("query_metadata", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); - const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); - const auto& past_seqlens = shader.AddInput("past_sequence_lengths", ShaderUsage::UseUniform); + const auto& sequence_metadata = shader.AddInput("sequence_metadata", ShaderUsage::UseUniform); const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); const auto& selected_indices = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); @@ -288,7 +291,7 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << "fn batch_of_token(token: u32) -> u32 {\n" << " var b = 0u;\n" << " for (var i = 0u; i < uniforms.batch_size; i++) {\n" - << " if (u32(" << cu_seqlens.GetByOffset("i") << ") <= token) { b = i; } else { break; }\n" + << " if (u32(" << sequence_metadata.GetByOffset("i") << ") <= token) { b = i; } else { break; }\n" << " }\n" << " return b;\n" << "}\n" @@ -303,19 +306,21 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << " return p / cr + select(0u, 1u, p % cr == cr - 1u);\n" << "}\n" << "fn abs_position(token: u32, b: u32) -> i32 {\n" - << " let req_start = " << cu_seqlens.GetByOffset("b") << ";\n" - << " return " << past_seqlens.GetByOffset("b") << " + (i32(token) - req_start);\n" + << " let req_start = " << sequence_metadata.GetByOffset("b") << ";\n" + << " return " << sequence_metadata.GetByOffset("uniforms.batch_size + 1u + b") + << " + (i32(token) - req_start);\n" << "}\n" << "fn normalized_query_value(token: u32, head: u32, d: u32) -> f32 {\n" << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" << " var square_sum = 0.0;\n" << " for (var k = 0u; k < uniforms.head_size; k++) {\n" - << " let value = f32(" << query.GetByOffset("base + k") << ");\n" + << " let value = f32(" << query_metadata.GetByOffset("base + k") << ");\n" << " square_sum += value * value;\n" << " }\n" - << " return f32(" << query.GetByOffset("base + d") + << " return f32(" << query_metadata.GetByOffset("base + d") << ") * inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" - << query_norm.GetByOffset("d") << ");\n" + << query_metadata.GetByOffset("uniforms.total_tokens * uniforms.num_heads * uniforms.head_size + d") + << ");\n" << "}\n" << "fn query_value(token: u32, head: u32, d: u32, position: i32, b: u32) -> f32 {\n" << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" @@ -416,9 +421,8 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHelper& shader) const { const auto& key_gate = shader.AddInput("key_gate", ShaderUsage::UseUniform); - const auto& norm = shader.AddInput("key_norm_weight", ShaderUsage::UseUniform); + const auto& norm_bias = shader.AddInput("norm_bias", ShaderUsage::UseUniform); const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); - const auto& position_bias = shader.AddInput("position_bias", ShaderUsage::UseUniform); const auto& sequence_metadata = shader.AddInput("sequence_metadata", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddOutput("present_key_state", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); @@ -427,10 +431,8 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe const auto& present_gate_buffer = shader.AddOutput("present_gate_buffer", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias); const auto& present_state_lengths = shader.AddOutput("present_state_lengths", ShaderUsage::UseUniform); - const auto& overflow_flags = shader.AddOutput("overflow_flags", ShaderUsage::UseUniform); const ShaderVariableHelper& kv_buffer_history = present_kv_buffer; const ShaderVariableHelper& gate_buffer_history = present_gate_buffer; - const ShaderVariableHelper& state_lengths_history = present_state_lengths; shader.AdditionalImplementation() << "fn extended_key(b: u32, virtual_pos: i32, old_buf_len: i32, req_start: i32, channel: u32) -> f32 {\n" @@ -461,14 +463,14 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (has_previous) {\n" << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" << " let v = extended_gate(b, previous_base + i32(slot), old_buf_len, req_start, d) + f32(" - << position_bias.GetByOffset("slot * width + d") << ");\n" + << norm_bias.GetByOffset("uniforms.head_size + slot * width + d") << ");\n" << " max_gate = max(max_gate, v);\n" << " }\n" << " }\n" << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" << " let v = extended_gate(b, current_base + i32(slot), old_buf_len, req_start, " "uniforms.head_size + d) + f32(" - << position_bias.GetByOffset("slot * width + uniforms.head_size + d") << ");\n" + << norm_bias.GetByOffset("uniforms.head_size + slot * width + uniforms.head_size + d") << ");\n" << " max_gate = max(max_gate, v);\n" << " }\n" << " var denominator = 0.0;\n" @@ -476,7 +478,7 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (has_previous) {\n" << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" << " let logit = extended_gate(b, previous_base + i32(slot), old_buf_len, req_start, d) + f32(" - << position_bias.GetByOffset("slot * width + d") << ");\n" + << norm_bias.GetByOffset("uniforms.head_size + slot * width + d") << ");\n" << " let weight = exp(logit - max_gate);\n" << " denominator += weight;\n" << " accumulator += weight * extended_key(b, previous_base + i32(slot), old_buf_len, req_start, d);\n" @@ -485,7 +487,7 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " for (var slot = 0u; slot < uniforms.compress_ratio; slot++) {\n" << " let logit = extended_gate(b, current_base + i32(slot), old_buf_len, req_start, " "uniforms.head_size + d) + f32(" - << position_bias.GetByOffset("slot * width + uniforms.head_size + d") << ");\n" + << norm_bias.GetByOffset("uniforms.head_size + slot * width + uniforms.head_size + d") << ");\n" << " let weight = exp(logit - max_gate);\n" << " denominator += weight;\n" << " accumulator += weight * extended_key(b, current_base + i32(slot), old_buf_len, req_start, " @@ -503,8 +505,9 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (b >= uniforms.batch_size || local_idx != 0u) { return; }\n" << " let req_start = " << sequence_metadata.GetByOffset("b") << ";\n" << " let req_end = " << sequence_metadata.GetByOffset("b + 1u") << ";\n" - << " let raw_key_len = " << state_lengths_history.GetByOffset("b * 2u") << ";\n" - << " let raw_buf_len = " << state_lengths_history.GetByOffset("b * 2u + 1u") << ";\n" + << " let state_lengths_offset = 2u * uniforms.batch_size + 1u + b * 2u;\n" + << " let raw_key_len = " << sequence_metadata.GetByOffset("state_lengths_offset") << ";\n" + << " let raw_buf_len = " << sequence_metadata.GetByOffset("state_lengths_offset + 1u") << ";\n" << " let old_key_len = clamp(raw_key_len, 0, i32(uniforms.state_capacity));\n" << " let old_buf_len = clamp(raw_buf_len, 0, i32(uniforms.buffer_capacity));\n" << " let invalid = " << sequence_metadata.GetByOffset("0") << " != 0 || " @@ -516,7 +519,6 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " if (invalid) {\n" << " " << present_state_lengths.SetByOffset("b * 2u", "old_key_len") << "\n" << " " << present_state_lengths.SetByOffset("b * 2u + 1u", "old_buf_len") << "\n" - << " " << overflow_flags.SetByOffset("b", "1") << "\n" << " return;\n" << " }\n" << " let req_len = req_end - req_start;\n" @@ -527,7 +529,6 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " let full_new_window_count = pending / i32(uniforms.compress_ratio);\n" << " let capacity_left = max(i32(uniforms.state_capacity) - old_key_len, 0);\n" << " let overflowed = full_new_window_count > capacity_left;\n" - << " " << overflow_flags.SetByOffset("b", "select(0, 1, overflowed)") << "\n" << " if (overflowed) { return; }\n" << " let new_window_count = full_new_window_count;\n" << " var present_buffer_length = 0;\n" @@ -561,14 +562,14 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe << " let rotary_base = uniforms.head_size - 2u * uniforms.rotary_width;\n" << " for (var d = 0u; d < uniforms.head_size; d++) {\n" << " var value = pooled(b, k, old_buf_len, req_start, overlap_length, d) * inverse_rms * f32(" - << norm.GetByOffset("d") << ");\n" + << norm_bias.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(b, k, old_buf_len, req_start, overlap_length, pair_d) * " "inverse_rms * f32(" - << norm.GetByOffset("pair_d") << ");\n" + << norm_bias.GetByOffset("pair_d") << ");\n" << " let cache_size = select(1u, uniforms.batch_size, " << (cos_cache_batched_ ? "true" : "false") << ") * uniforms.max_rotary_length * uniforms.rotary_width;\n" @@ -602,14 +603,12 @@ Status PackedSparseAttentionIndexerCsaUpdateProgram::GenerateShaderCode(ShaderHe } Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHelper& shader) const { - const auto& query = shader.AddInput("query", ShaderUsage::UseUniform); const auto& query_metadata = shader.AddInput("query_metadata", ShaderUsage::UseUniform); const auto& present_key_state = shader.AddInput("present_key_state", ShaderUsage::UseUniform); const auto& position_ids = shader.AddInput("position_ids", ShaderUsage::UseUniform); const auto& rotary_cache = shader.AddInput("rotary_cache", ShaderUsage::UseUniform); - const auto& cu_seqlens = shader.AddInput("cumulative_sequence_lengths", ShaderUsage::UseUniform); + const auto& sequence_metadata = shader.AddInput("sequence_metadata", ShaderUsage::UseUniform); const auto& present_state_lengths = shader.AddInput("present_state_lengths", ShaderUsage::UseUniform); - const auto& overflow_flags = shader.AddInput("overflow_flags", ShaderUsage::UseUniform); const auto& selected_indices = shader.AddOutput("selected_indices", ShaderUsage::UseUniform); const auto& selected_counts = shader.AddOutput("selected_counts", ShaderUsage::UseUniform); @@ -617,7 +616,7 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << "fn batch_of_token(token: u32) -> u32 {\n" << " var b = 0u;\n" << " for (var i = 0u; i < uniforms.batch_size; i++) {\n" - << " if (u32(" << cu_seqlens.GetByOffset("i") << ") <= token) { b = i; } else { break; }\n" + << " if (u32(" << sequence_metadata.GetByOffset("i") << ") <= token) { b = i; } else { break; }\n" << " }\n" << " return b;\n" << "}\n" @@ -637,12 +636,13 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << " let base = (token * 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" + << " let value = f32(" << query_metadata.GetByOffset("base + k") << ");\n" << " square_sum += value * value;\n" << " }\n" - << " return f32(" << query.GetByOffset("base + d") + << " let query_elements = uniforms.total_tokens * uniforms.num_heads * uniforms.head_size;\n" + << " return f32(" << query_metadata.GetByOffset("base + d") << ") * inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" - << query_metadata.GetByOffset("d") << ");\n" + << query_metadata.GetByOffset("query_elements + d") << ");\n" << "}\n" << "fn query_value(token: u32, head: u32, d: u32, raw: vec2, b: u32) -> f32 {\n" << " let base = (token * uniforms.num_heads + head) * uniforms.head_size;\n" @@ -678,7 +678,9 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << present_key_state.GetByOffset("key_base + d") << ");\n" << " }\n" << " score += max(dot, 0.0) * f32(" - << query_metadata.GetByOffset("uniforms.head_size + token * uniforms.num_heads + head") + << query_metadata.GetByOffset( + "uniforms.total_tokens * uniforms.num_heads * uniforms.head_size + uniforms.head_size + " + "token * uniforms.num_heads + head") << ");\n" << " }\n" << " return score * uniforms.scale * uniforms.head_weight_scale;\n" @@ -692,7 +694,25 @@ Status PackedSparseAttentionIndexerCsaSelectProgram::GenerateShaderCode(ShaderHe << " " << selected_indices.SetByOffset("output_base + i", "-1") << "\n" << " }\n" << " let b = batch_of_token(token);\n" - << " if (" << overflow_flags.GetByOffset("b") << " != 0) {\n" + << " let req_start = " << sequence_metadata.GetByOffset("b") << ";\n" + << " let req_end = " << sequence_metadata.GetByOffset("b + 1u") << ";\n" + << " let state_lengths_offset = 2u * uniforms.batch_size + 1u + b * 2u;\n" + << " let raw_key_len = " << sequence_metadata.GetByOffset("state_lengths_offset") << ";\n" + << " let raw_buf_len = " << sequence_metadata.GetByOffset("state_lengths_offset + 1u") << ";\n" + << " let old_key_len = clamp(raw_key_len, 0, i32(uniforms.state_capacity));\n" + << " let old_buf_len = clamp(raw_buf_len, 0, i32(uniforms.buffer_capacity));\n" + << " let invalid = " << sequence_metadata.GetByOffset("0") << " != 0 || " + << sequence_metadata.GetByOffset("uniforms.batch_size") << " != i32(uniforms.total_tokens) || " + << "req_start < 0 || req_end < req_start || req_end > i32(uniforms.total_tokens) || " + << sequence_metadata.GetByOffset("uniforms.batch_size + 1u + b") << " < 0 || raw_key_len < 0 || " + << "raw_key_len > i32(uniforms.state_capacity) || raw_buf_len < 0 || " + << "raw_buf_len > i32(uniforms.buffer_capacity);\n" + << " let overlap_length = select(0, i32(uniforms.compress_ratio), old_buf_len >= " + "i32(uniforms.compress_ratio));\n" + << " let pending = old_buf_len - overlap_length + req_end - req_start;\n" + << " let full_new_window_count = pending / i32(uniforms.compress_ratio);\n" + << " let overflowed = full_new_window_count > max(i32(uniforms.state_capacity) - old_key_len, 0);\n" + << " if (invalid || overflowed) {\n" << " " << selected_counts.SetByOffset("token", "0") << "\n" << " return;\n" << " }\n" @@ -898,19 +918,25 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont return Status::OK(); } + Tensor sequence_metadata = context.CreateGPUTensor( + cu_seqlens->DataType(), TensorShape({cu_seqlens->Shape().Size() + past_seqlens->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cu_seqlens, *past_seqlens, sequence_metadata)); + Tensor query_metadata = context.CreateGPUTensor( + query->DataType(), TensorShape({query->Shape().Size() + query_norm->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *query, *query_norm, query_metadata)); + if (batch_size > 0) { ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( context, "QSA update", - {key, norm, &rotary_cache, cu_seqlens, past_seqlens, present_key_state, present_kv_buffer, - present_state_lengths, &overflow_flags})); + {key, norm, &rotary_cache, &sequence_metadata, present_key_state, present_kv_buffer, present_state_lengths, + &overflow_flags})); PackedSparseAttentionIndexerQsaUpdateProgram update{rotary.batched}; update.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{key, ProgramTensorMetadataDependency::Type}, {norm, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, - {cu_seqlens, ProgramTensorMetadataDependency::Type}, - {past_seqlens, ProgramTensorMetadataDependency::Type}}); + {&sequence_metadata, ProgramTensorMetadataDependency::Type}}); update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) @@ -930,17 +956,15 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( context, "QSA selection", - {query, query_norm, present_key_state, &rotary_cache, cu_seqlens, past_seqlens, present_state_lengths, - &overflow_flags, selected_indices, selected_counts})); + {&query_metadata, present_key_state, &rotary_cache, &sequence_metadata, present_state_lengths, &overflow_flags, + selected_indices, selected_counts})); PackedSparseAttentionIndexerQsaSelectProgram select{rotary.batched}; select.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) - .AddInputs({{query, ProgramTensorMetadataDependency::Type}, - {query_norm, ProgramTensorMetadataDependency::Type}, + .AddInputs({{&query_metadata, ProgramTensorMetadataDependency::Type}, {present_key_state, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, - {cu_seqlens, ProgramTensorMetadataDependency::Type}, - {past_seqlens, ProgramTensorMetadataDependency::Type}}); + {&sequence_metadata, ProgramTensorMetadataDependency::Type}}); select.AddInputs({{present_state_lengths, ProgramTensorMetadataDependency::Type}, {&overflow_flags, ProgramTensorMetadataDependency::Type}}) .AddOutputs({{selected_indices, ProgramTensorMetadataDependency::Type}, @@ -1040,8 +1064,6 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont context.Output(psai::kPresentGateBuffer, TensorShape({batch_size, buffer_capacity, width})); Tensor* present_state_lengths = context.Output(psai::kPresentStateLengths, TensorShape({batch_size, psai::kStateLengthColumns})); - Tensor overflow_flags = context.CreateGPUTensor(DataTypeImpl::GetType(), TensorShape({batch_size})); - auto copy_if_needed = [&](const Tensor* src, Tensor* dst) -> Status { if (src->DataRaw() == dst->DataRaw()) { return Status::OK(); @@ -1074,30 +1096,33 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont context.CreateGPUTensor(cos_cache->DataType(), TensorShape({2 * cos_cache->Shape().Size()})); ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cos_cache, *sin_cache, rotary_cache)); Tensor sequence_metadata = context.CreateGPUTensor( - cu_seqlens->DataType(), TensorShape({cu_seqlens->Shape().Size() + past_seqlens->Shape().Size()})); - ORT_RETURN_IF_ERROR(PackTwoTensors(context, *cu_seqlens, *past_seqlens, sequence_metadata)); + cu_seqlens->DataType(), + TensorShape({cu_seqlens->Shape().Size() + past_seqlens->Shape().Size() + past_state_lengths->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTensors(context, {cu_seqlens, past_seqlens, past_state_lengths}, sequence_metadata)); + Tensor norm_bias = context.CreateGPUTensor( + norm->DataType(), TensorShape({norm->Shape().Size() + position_bias->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTwoTensors(context, *norm, *position_bias, norm_bias)); Tensor query_metadata = context.CreateGPUTensor( - query_norm->DataType(), TensorShape({query_norm->Shape().Size() + head_weights->Shape().Size()})); - ORT_RETURN_IF_ERROR(PackTwoTensors(context, *query_norm, *head_weights, query_metadata)); + query->DataType(), + TensorShape({query->Shape().Size() + query_norm->Shape().Size() + head_weights->Shape().Size()})); + ORT_RETURN_IF_ERROR(PackTensors(context, {query, query_norm, head_weights}, query_metadata)); if (batch_size > 0) { ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( context, "CSA update", - {&key_gate, norm, &rotary_cache, position_bias, &sequence_metadata, present_key_state, present_kv_buffer, - present_gate_buffer, present_state_lengths, &overflow_flags})); + {&key_gate, &norm_bias, &rotary_cache, &sequence_metadata, present_key_state, present_kv_buffer, + present_gate_buffer, present_state_lengths})); PackedSparseAttentionIndexerCsaUpdateProgram update{rotary.batched}; update.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) .AddInputs({{&key_gate, ProgramTensorMetadataDependency::Type}, - {norm, ProgramTensorMetadataDependency::Type}, + {&norm_bias, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, - {position_bias, ProgramTensorMetadataDependency::Type}, {&sequence_metadata, ProgramTensorMetadataDependency::Type}}); update.AddOutputs({{present_key_state, ProgramTensorMetadataDependency::Type}, {present_kv_buffer, ProgramTensorMetadataDependency::Type}, {present_gate_buffer, ProgramTensorMetadataDependency::Type}}) .AddOutput({present_state_lengths, ProgramTensorMetadataDependency::Type}) - .AddOutput({&overflow_flags, ProgramTensorMetadataDependency::Type}) .SetDispatchGroupSize(ToUint32(batch_size)) .AddUniformVariables({{ToUint32(batch_size)}, {ToUint32(total_tokens)}, @@ -1113,19 +1138,17 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont ORT_RETURN_IF_ERROR(CheckStorageBindingLimit( context, "CSA selection", - {query, &query_metadata, present_key_state, position_ids, &rotary_cache, cu_seqlens, present_state_lengths, - &overflow_flags, selected_indices, selected_counts})); + {&query_metadata, present_key_state, position_ids, &rotary_cache, &sequence_metadata, present_state_lengths, + selected_indices, selected_counts})); PackedSparseAttentionIndexerCsaSelectProgram select{rotary.batched}; select.CacheHint(rotary.batched) .SetWorkgroupSize(kWorkgroupSize) - .AddInputs({{query, ProgramTensorMetadataDependency::Type}, - {&query_metadata, ProgramTensorMetadataDependency::Type}, + .AddInputs({{&query_metadata, ProgramTensorMetadataDependency::Type}, {present_key_state, ProgramTensorMetadataDependency::Type}, {position_ids, ProgramTensorMetadataDependency::Type}, {&rotary_cache, ProgramTensorMetadataDependency::Type}, - {cu_seqlens, ProgramTensorMetadataDependency::Type}}) - .AddInputs({{present_state_lengths, ProgramTensorMetadataDependency::Type}, - {&overflow_flags, ProgramTensorMetadataDependency::Type}}) + {&sequence_metadata, ProgramTensorMetadataDependency::Type}, + {present_state_lengths, ProgramTensorMetadataDependency::Type}}) .AddOutputs({{selected_indices, ProgramTensorMetadataDependency::Type}, {selected_counts, ProgramTensorMetadataDependency::Type}}) .SetDispatchGroupSize(ToUint32(total_tokens)) @@ -1137,6 +1160,7 @@ Status PackedSparseAttentionIndexer::ComputeCsa(onnxruntime::webgpu::ComputeCont {ToUint32(rotary.max_rotary_length)}, {ToUint32(compress_ratio_)}, {ToUint32(state_capacity)}, + {ToUint32(buffer_capacity)}, {ToUint32(capacity)}, {ToUint32(index_topk_)}, {epsilon_}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index cc6adf7d79b2a..bb75938a81ff2 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -120,6 +120,7 @@ class PackedSparseAttentionIndexerCsaSelectProgram final {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, {"state_capacity", ProgramUniformVariableDataType::Uint32}, + {"buffer_capacity", ProgramUniformVariableDataType::Uint32}, {"capacity", ProgramUniformVariableDataType::Uint32}, {"index_topk", ProgramUniformVariableDataType::Uint32}, {"epsilon", ProgramUniformVariableDataType::Float32}, diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 4ccd4444cf94f..9d232a3b99555 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -38,6 +38,10 @@ #include "test/util/include/asserts.h" #include "test/util/include/default_providers.h" +#ifdef USE_WEBGPU +#include "core/providers/webgpu/webgpu_provider_options.h" +#endif + namespace onnxruntime { namespace test { @@ -406,12 +410,14 @@ enum class ProviderKind { WebGpu, }; -std::unique_ptr CreateProvider(ProviderKind provider_kind) { +std::unique_ptr CreateProvider(ProviderKind provider_kind, + const ConfigOptions* webgpu_options = nullptr) { if (provider_kind == ProviderKind::Cuda) { return DefaultCudaExecutionProvider(); } #ifdef USE_WEBGPU - return DefaultWebGpuExecutionProvider(); + return webgpu_options == nullptr ? DefaultWebGpuExecutionProvider() + : WebGpuExecutionProviderWithOptions(*webgpu_options); #else return nullptr; #endif @@ -736,8 +742,9 @@ QsaPackedProblem MakeQsaPackedProblem(QsaPackedProblem problem = {}) { template void RunQsaPackedTest(float tolerance, QsaPackedProblem problem = MakeQsaPackedProblem(), - ProviderKind provider_kind = ProviderKind::Cuda, QsaPackedResult* actual = nullptr) { - auto provider = CreateProvider(provider_kind); + ProviderKind provider_kind = ProviderKind::Cuda, QsaPackedResult* actual = nullptr, + const ConfigOptions* webgpu_options = nullptr) { + auto provider = CreateProvider(provider_kind, webgpu_options); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") << " execution provider is not available"; @@ -876,6 +883,12 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, QsaFloat16) { RunQsaPackedTest(2.0e-3f, MakeQsaPackedProblem(), ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, QsaPortableStorageBindingLimit) { + ConfigOptions config_options; + ASSERT_STATUS_OK(config_options.AddConfigEntry(webgpu::options::kMaxStorageBuffersPerShaderStage, "8")); + RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::WebGpu, nullptr, &config_options); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, QsaAllEmptyBatchPreservesState) { QsaPackedProblem problem; problem.batch_size = 1; @@ -1164,8 +1177,9 @@ CsaPackedProblem MakeCsaPackedProblem(CsaPackedProblem problem = {}) { template void RunCsaPackedTest(const CsaPackedProblem& base, float tolerance, - ProviderKind provider_kind = ProviderKind::Cuda, CsaPackedResult* actual = nullptr) { - auto provider = CreateProvider(provider_kind); + ProviderKind provider_kind = ProviderKind::Cuda, CsaPackedResult* actual = nullptr, + const ConfigOptions* webgpu_options = nullptr) { + auto provider = CreateProvider(provider_kind, webgpu_options); if (provider == nullptr) { GTEST_SKIP() << (provider_kind == ProviderKind::Cuda ? "CUDA" : "WebGPU") << " execution provider is not available"; @@ -1312,6 +1326,12 @@ TEST(PackedSparseAttentionIndexerWebGpuTest, CsaFloat16) { RunCsaPackedTest(MakeCsaPackedProblem(), 4.0e-3f, ProviderKind::WebGpu); } +TEST(PackedSparseAttentionIndexerWebGpuTest, CsaPortableStorageBindingLimit) { + ConfigOptions config_options; + ASSERT_STATUS_OK(config_options.AddConfigEntry(webgpu::options::kMaxStorageBuffersPerShaderStage, "8")); + RunCsaPackedTest(MakeCsaPackedProblem(), 1.0e-5f, ProviderKind::WebGpu, nullptr, &config_options); +} + TEST(PackedSparseAttentionIndexerWebGpuTest, CsaAllEmptyBatchPreservesState) { CsaPackedProblem problem; problem.batch_size = 1; From b6741945f6686decec6c169af15146cc504394fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:59:05 +0000 Subject: [PATCH 098/102] Keep packed QSA indices cache-local Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- docs/ContribOperators.md | 7 +++---- .../sparse/packed_sparse_attention_indexer.cc | 6 +++--- onnxruntime/core/graph/contrib_ops/bert_defs.cc | 16 +++++++--------- .../packed_sparse_attention_indexer_op_test.cc | 14 ++++---------- 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md index 1e75a1b7d7717..3c1afcc34ed24 100644 --- a/docs/ContribOperators.md +++ b/docs/ContribOperators.md @@ -5046,9 +5046,8 @@ This version of the operator has been available since version 1 of the 'com.micr sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. QSA uses position_ids when provided; otherwise positions are the - request-local logical cache positions derived from past_sequence_lengths and - cumulative_sequence_lengths. + incomplete block. QSA positions are the request-local logical cache positions derived from + past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -5137,7 +5136,7 @@ This version of the operator has been available since version 1 of the 'com.micr
head_weights (optional) : T
Only for policy_mode 'csa': per-head score weights with shape (total_tokens, num_heads).
position_ids (optional) : I
-
Absolute position of every packed query, with shape (total_tokens). Required for policy_mode 'csa' and optional for policy_mode 'qsa'.
+
Only for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).
past_key_state : T
Generic fixed-capacity state: policy_mode 'qsa' stores prepared complete-block keys; policy_mode 'csa' stores compressed keys. Shape is (batch_size, state_capacity, head_size) and never changes across calls.
past_kv_buffer : T
diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc index b3f49c031705c..4e2bed76767d6 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer.cc @@ -160,9 +160,9 @@ Status PackedSparseAttentionIndexer::ComputeInternal(OpKernelContext* context } const bool position_ids_provided = psai::kPositionIds < context->InputCount() && context->Input(psai::kPositionIds) != nullptr; - ORT_RETURN_IF(!is_qsa && !position_ids_provided, - "PackedSparseAttentionIndexer: input ", psai::kPositionIds, - " (position_ids) is required for policy_mode 'csa'"); + ORT_RETURN_IF(position_ids_provided == is_qsa, + "PackedSparseAttentionIndexer: position_ids is required for " + "policy_mode 'csa' and must be omitted for policy_mode 'qsa'"); const bool gate_buffer_provided = psai::kPastGateBuffer < context->InputCount() && context->Input(psai::kPastGateBuffer) != nullptr; ORT_RETURN_IF(gate_buffer_provided != !is_qsa, "PackedSparseAttentionIndexer: input ", psai::kPastGateBuffer, diff --git a/onnxruntime/core/graph/contrib_ops/bert_defs.cc b/onnxruntime/core/graph/contrib_ops/bert_defs.cc index 2c2e8319a7446..c86dcf948bdae 100644 --- a/onnxruntime/core/graph/contrib_ops/bert_defs.cc +++ b/onnxruntime/core/graph/contrib_ops/bert_defs.cc @@ -2741,8 +2741,7 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference } // Strict policy input validation: every fixed slot required by every policy must be provided; - // csa-only slots must be provided iff policy_mode is 'csa'; position_ids is optional for 'qsa' - // and required for 'csa'. + // csa-only slots must be provided iff policy_mode is 'csa'. for (int index : {psai::kQuery, psai::kQueryNormWeight, psai::kKeyNormWeight, psai::kCosCache, psai::kSinCache, psai::kCumulativeSequenceLengths, psai::kPastSequenceLengths, psai::kPastKeyState, psai::kPastKvBuffer, psai::kPastStateLengths}) { @@ -2761,9 +2760,10 @@ void PackedSparseAttentionIndexerTypeAndShapeInference(ONNX_NAMESPACE::Inference : " is required when policy_mode is 'csa'"); } } - if (!is_qsa && !PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds)) { + if (PackedSparseAttentionIndexerHasInput(ctx, psai::kPositionIds) == is_qsa) { fail_shape_inference("PackedSparseAttentionIndexer: input ", psai::kPositionIds, - " (position_ids) is required when policy_mode is 'csa'"); + is_qsa ? " (position_ids) must be omitted when policy_mode is 'qsa'" + : " (position_ids) is required when policy_mode is 'csa'"); } const bool has_capture_count = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateCaptureCount); const bool has_state_update_active = PackedSparseAttentionIndexerHasInput(ctx, psai::kStateUpdateActive); @@ -3048,9 +3048,8 @@ request's own packed token range and fixed-capacity state slice: sum_h ReLU(q_h . k), the token_budget / compress_ratio highest scoring blocks are kept, and their token indices are emitted (request-local logical positions, i.e. the same numbering as past_sequence_lengths + local offset) followed by the causally visible tokens of the trailing - incomplete block. QSA uses position_ids when provided; otherwise positions are the - request-local logical cache positions derived from past_sequence_lengths and - cumulative_sequence_lengths. + incomplete block. QSA positions are the request-local logical cache positions derived from + past_sequence_lengths and cumulative_sequence_lengths; position_ids must be omitted. policy_mode = "csa" ("compressed sparse attention" block indexer) Applies the same window-plan arithmetic as SparseAttentionIndexer (overlap/leftover/new window @@ -3193,8 +3192,7 @@ ONNX_MS_OPERATOR_SET_SCHEMA( OpSchema::Optional) .Input(11, "position_ids", - "Absolute position of every packed query, with shape (total_tokens). Required for policy_mode 'csa' " - "and optional for policy_mode 'qsa'.", + "Only for policy_mode 'csa': absolute position of every packed query, with shape (total_tokens).", "I", OpSchema::Optional) .Input(12, diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 501874195a30b..a170d6e0e5f6d 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -116,7 +116,7 @@ struct GraphOptions { int64_t BufferCapacity(int64_t compress_ratio) { return 2 * compress_ratio - 1; } // Builds a fixed-input node; csa-only slots are left empty for policy_mode "qsa", as the schema -// requires. position_ids (slot 11) is optional for "qsa" and forced on for "csa". +// requires. position_ids (slot 11) is omitted for "qsa" and forced on for "csa". void AddNode(ModelTestBuilder& builder, const GraphOptions& options) { const bool is_csa = options.policy_mode == psai::kPolicyModeCsa; const int64_t width = is_csa ? 2 * options.head_size : options.head_size; @@ -368,11 +368,11 @@ TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithCsaInput) { "must be omitted when policy_mode is 'qsa'"); } -TEST(PackedSparseAttentionIndexerShapeInferenceTest, AllowsQsaWithPositionIds) { +TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsQsaWithPositionIds) { GraphOptions options; options.add_position_ids = true; - std::unique_ptr model; - EXPECT_STATUS_OK(BuildAndResolve([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, model)); + ExpectResolveFailure([&options](ModelTestBuilder& builder) { AddNode(builder, options); }, + "must be omitted when policy_mode is 'qsa'"); } TEST(PackedSparseAttentionIndexerShapeInferenceTest, RejectsCsaMissingPositionIds) { @@ -952,12 +952,6 @@ TEST(PackedSparseAttentionIndexerTest, QsaPackedQueryKey) { RunQsaPackedTest(1.0e-5f, MakeQsaPackedProblem(), ProviderKind::Cuda, nullptr, true); } -TEST(PackedSparseAttentionIndexerTest, QsaPositionIds) { - QsaPackedProblem problem = MakeQsaPackedProblem(); - problem.position_ids = {7, 9, 3, 4, 8}; - RunQsaPackedTest(1.0e-5f, std::move(problem)); -} - TEST(PackedSparseAttentionIndexerTest, QsaPrefillThenDecodeIndependentState) { if (DefaultCudaExecutionProvider() == nullptr) { GTEST_SKIP() << "CUDA execution provider is not available"; From 3eb4630ff1f881b546ce49ad1356013d3c6b8f91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:06:42 +0000 Subject: [PATCH 099/102] Fix non-WebGPU test warning Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../test/contrib_ops/packed_sparse_attention_indexer_op_test.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc index 9d232a3b99555..c5fc1e1996493 100644 --- a/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc +++ b/onnxruntime/test/contrib_ops/packed_sparse_attention_indexer_op_test.cc @@ -419,6 +419,7 @@ std::unique_ptr CreateProvider(ProviderKind provider_kind, return webgpu_options == nullptr ? DefaultWebGpuExecutionProvider() : WebGpuExecutionProviderWithOptions(*webgpu_options); #else + ORT_UNUSED_PARAMETER(webgpu_options); return nullptr; #endif } From 8f8aac9639efe821da350bad0d2df453d4c9c368 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:29:23 +0000 Subject: [PATCH 100/102] Fix optional output presence inference Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- onnxruntime/core/graph/graph.cc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/onnxruntime/core/graph/graph.cc b/onnxruntime/core/graph/graph.cc index 5953f36610c84..b52a04d38590e 100644 --- a/onnxruntime/core/graph/graph.cc +++ b/onnxruntime/core/graph/graph.cc @@ -2755,6 +2755,15 @@ class InferenceContextImpl : public ONNX_NAMESPACE::InferenceContext { return &node_output_types_[index]; } + bool hasOutput(size_t index) override { + if (index >= node_.OutputDefs().size()) { + return false; + } + + const auto* node_arg = node_.OutputDefs()[index]; + return node_arg != nullptr && node_arg->Exists(); + } + const TensorProto* getInputData(size_t index) const override { // A schema-optional input that's omitted (not even an empty placeholder) shrinks InputDefs(), // so callers can pass an index the node doesn't actually have. From ab6b7552446f1f898c8dbee0359a06df2fa2bece Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:12:08 +0000 Subject: [PATCH 101/102] Fix packed WebGPU query norm offset Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc | 3 ++- .../contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc index 793405026d2c2..e147d806b2666 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.cc @@ -426,7 +426,7 @@ Status PackedSparseAttentionIndexerQsaSelectProgram::GenerateShaderCode(ShaderHe << " }\n" << " return f32(" << query_metadata.GetByOffset("base + d") << ") * inverseSqrt(square_sum / f32(uniforms.head_size) + uniforms.epsilon) * f32(" - << query_metadata.GetByOffset("uniforms.total_tokens * uniforms.num_heads * uniforms.head_size + d") + << query_metadata.GetByOffset("uniforms.query_norm_offset + d") << ");\n" << "}\n" << "fn query_value(token: u32, head: u32, d: u32, position: i32, b: u32) -> f32 {\n" @@ -1154,6 +1154,7 @@ Status PackedSparseAttentionIndexer::ComputeQsa(onnxruntime::webgpu::ComputeCont {ToUint32(num_heads)}, {ToUint32(head_size)}, {ToUint32(query_shape[1])}, + {ToUint32(query_shape.Size())}, {ToUint32(rotary.rotary_width)}, {ToUint32(rotary.max_rotary_length)}, {ToUint32(compress_ratio_)}, diff --git a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h index eb17b22f36d08..69f590b73a2bd 100644 --- a/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h +++ b/onnxruntime/contrib_ops/webgpu/bert/packed_sparse_attention_indexer.h @@ -75,6 +75,7 @@ class PackedSparseAttentionIndexerQsaSelectProgram final {"num_heads", ProgramUniformVariableDataType::Uint32}, {"head_size", ProgramUniformVariableDataType::Uint32}, {"query_row_stride", ProgramUniformVariableDataType::Uint32}, + {"query_norm_offset", ProgramUniformVariableDataType::Uint32}, {"rotary_width", ProgramUniformVariableDataType::Uint32}, {"max_rotary_length", ProgramUniformVariableDataType::Uint32}, {"compress_ratio", ProgramUniformVariableDataType::Uint32}, From 54c3ea6a939d7c7afc7e6ed492b658462343b468 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:58:28 +0000 Subject: [PATCH 102/102] Restore packed indexer shared memory opt-in Co-authored-by: kunal-vaishnavi <115581922+kunal-vaishnavi@users.noreply.github.com> --- .../packed_sparse_attention_indexer_impl.cu | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu index bceecedd8f844..a5ed4610319e9 100644 --- a/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu +++ b/onnxruntime/contrib_ops/cuda/sparse/packed_sparse_attention_indexer_impl.cu @@ -62,6 +62,18 @@ constexpr int kQwenHeadSize = 128; constexpr int kQwenCompressRatio = 4; constexpr int kQwenNumHeads = 4; +template +Status ConfigureDynamicSharedMemory(Kernel kernel, size_t bytes, const char* kernel_name) { + int device = 0; + int max_bytes = 0; + CUDA_RETURN_IF_ERROR(cudaGetDevice(&device)); + CUDA_RETURN_IF_ERROR( + cudaDeviceGetAttribute(&max_bytes, cudaDevAttrMaxSharedMemoryPerBlockOptin, device)); + ORT_RETURN_IF_ERROR(psai::ValidateDynamicSharedMemory( + bytes, static_cast(max_bytes), kernel_name)); + return CUDA_CALL(cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, max_bytes)); +} + bool UseHierarchicalQsaTopK(const PackedSparseAttentionIndexerParams& params) { return params.total_tokens > 0 && params.total_tokens <= kDistributedTopKMaxRows && params.state_capacity >= kDistributedTopKMinBlocks && @@ -1244,8 +1256,11 @@ Status LaunchQsaPackedSparseAttentionIndexer( } const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + const size_t update_shared_bytes = 2 * value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(QsaUpdateStateKernel, update_shared_bytes, "QsaUpdateStateKernel")); const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); - QsaUpdateStateKernel<<>>( + QsaUpdateStateKernel<<>>( key, key_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, past_kv_buffer, past_state_lengths, state_update_capture_count, state_update_active, present_key_state, present_kv_buffer, present_state_lengths, state_update, overflow_flags, params); @@ -1263,7 +1278,10 @@ Status LaunchQsaPackedSparseAttentionIndexer( const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); - PackedRotateQueryKernel<<>>( + const size_t rotate_shared_bytes = value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR(ConfigureDynamicSharedMemory( + PackedRotateQueryKernel, rotate_shared_bytes, "PackedRotateQueryKernel")); + PackedRotateQueryKernel<<>>( query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, params); @@ -1363,8 +1381,11 @@ Status LaunchCsaPackedSparseAttentionIndexer( } const size_t value_bytes = static_cast(params.head_size) * sizeof(float); + const size_t update_shared_bytes = value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR( + ConfigureDynamicSharedMemory(CsaUpdateStateKernel, update_shared_bytes, "CsaUpdateStateKernel")); const int state_blocks = static_cast(std::min(params.batch_size, kSaiMaxGridDimX)); - CsaUpdateStateKernel<<>>( + CsaUpdateStateKernel<<>>( key, gate, key_norm_weight, cos_cache, sin_cache, position_bias, cumulative_sequence_lengths, past_sequence_lengths, past_kv_buffer, past_gate_buffer, past_state_lengths, present_key_state, present_kv_buffer, present_gate_buffer, present_state_lengths, overflow_flags, params); @@ -1378,7 +1399,10 @@ Status LaunchCsaPackedSparseAttentionIndexer( const int64_t rotate_rows = static_cast(params.total_tokens) * params.num_heads; const int rotate_blocks = static_cast(std::min(rotate_rows, kSaiMaxGridDimX)); - PackedRotateQueryKernel<<>>( + const size_t rotate_shared_bytes = value_bytes + kThreads * sizeof(float); + ORT_RETURN_IF_ERROR(ConfigureDynamicSharedMemory( + PackedRotateQueryKernel, rotate_shared_bytes, "PackedRotateQueryKernel")); + PackedRotateQueryKernel<<>>( query, query_norm_weight, cos_cache, sin_cache, cumulative_sequence_lengths, past_sequence_lengths, position_ids, query_rotated, params);