Xenova HF Staff commited on
Commit
2157e54
·
verified ·
1 Parent(s): 36d2a82

sync 91d990483a17

Browse files
README.md CHANGED
@@ -12,25 +12,25 @@ tags:
12
 
13
  ## Description
14
 
15
- Computes multi-head self- or cross-attention over explicit query, key, and value tensors, with an optional fused QKV projection bias and additive attention bias. Supports causal masking through `unidirectional` and a configurable score scale that defaults to `1 / sqrt(head_size)`. The current kernels require query, key, and value to have the same hidden width; distinct value-head widths, KV-cache, key-padding-mask, and diagnostic-QK modes are not yet implemented.
16
 
17
  See the [ONNX Runtime `MultiHeadAttention` contrib-operator spec](https://github.com/microsoft/onnxruntime/blob/main/docs/ContribOperators.md#com.microsoft.MultiHeadAttention) for the reference semantics.
18
 
19
  ## Inputs
20
 
21
- | Name | Bind key | Logical dtype | Rank | Shape | Description | Presence |
22
  | --- | --- | --- | --- | --- | --- | --- |
23
- | `query` | `queryT` | `T` | `3` | — | Query tensor of shape `(batch_size, sequence_length, hidden_size)`. | required |
24
- | `key` | `keyT` | `T` | `3` | — | Key tensor of shape `(batch_size, kv_sequence_length, hidden_size)`; the current kernels require its hidden width to equal the query hidden width. | required |
25
- | `value` | `valueT` | `T` | `3` | — | Value tensor of shape `(batch_size, kv_sequence_length, v_hidden_size)`; the current supported subset requires `v_hidden_size` to equal the query hidden width. | required |
26
- | `bias` | `biasT` | `T` | `1` | — | Optional fused bias from input projection with shape `(hidden_size + hidden_size + v_hidden_size)`. | optional |
27
- | `attention_bias` | `attentionBiasT` | `T` | `4` | — | Optional additive bias applied to the attention scores before softmax, of shape `(batch_size or 1, num_heads or 1, sequence_length, kv_sequence_length)`; the first two dimensions broadcast. | optional |
28
 
29
  ## Outputs
30
 
31
- | Name | Bind key | Logical dtype | Rank | Shape | Description | Presence |
32
  | --- | --- | --- | --- | --- | --- | --- |
33
- | `output` | `outputT` | `T` | `3` | `[query[0], query[1], value[2]]` | Attention output of shape `(batch_size, sequence_length, v_hidden_size)`. | required |
34
 
35
  ## Attributes
36
 
@@ -38,9 +38,9 @@ Attributes and default values (overridable per request):
38
 
39
  | Attribute | Default | Description |
40
  | --- | --- | --- |
41
- | `unidirectional` | `0` | When 1, applies an upper-left causal mask: query position `i` attends only to key positions `0..min(i, kv_sequence_length - 1)`. |
42
  | `num_heads` | — | Number of attention heads. |
43
  | `scale` | — | Optional score scale; zero or omission selects `1 / sqrt(head_size)`. |
 
44
 
45
  ## Type constraints
46
 
@@ -48,13 +48,56 @@ Attributes and default values (overridable per request):
48
  | --- | --- |
49
  | `T` | `float32`, `float16` |
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  ## Device requirements
52
 
53
  Some implementation variants require `subgroup-matrix`, `shader-f16`, and `subgroups`. These are route-specific capabilities, not package-wide requirements; availability also depends on the request shape and dtype.
54
 
55
  ## Files
56
 
57
- - [`metadata.json`](build/webgpu/metadata.json) — kernel metadata (id, digests, provenance)
58
  - [`manifest.json`](build/webgpu/manifest.json) — the op contract (source of truth)
59
  - [`test.json`](build/webgpu/test.json) — correctness cases
60
  - [`bench.json`](build/webgpu/bench.json) — benchmark + tuning cases
@@ -71,14 +114,19 @@ Some implementation variants require `subgroup-matrix`, `shader-f16`, and `subgr
71
  - [`attn-materialized-softmax-f32.wgsl.jinja`](build/webgpu/attn-materialized-softmax-f32.wgsl.jinja)
72
  - [`attn-online-scalar.wgsl.jinja`](build/webgpu/attn-online-scalar.wgsl.jinja)
73
  - [`attn-small-head-parallel.wgsl.jinja`](build/webgpu/attn-small-head-parallel.wgsl.jinja)
 
74
  - [`mha-small-seq.wgsl.jinja`](build/webgpu/mha-small-seq.wgsl.jinja)
75
 
76
  ## Use with `@huggingface/kernels`
77
 
78
- The loader derives every required output's shape and logical dtype from the manifest contract and this call.
79
- It then allocates the result tensors automatically.
 
 
 
80
 
81
  The `version: 1` option selects the published kernel contract; it is independent of any operator opset, contrib `since_version`, or model version.
 
82
 
83
  Replace each `*Data` placeholder with a typed array containing the corresponding input data.
84
 
 
12
 
13
  ## Description
14
 
15
+ Computes multi-head self- or cross-attention over explicit query, key, and value tensors, with an optional fused QKV projection bias and additive attention bias. Supports causal masking through `unidirectional` and a configurable score scale that defaults to `1 / sqrt(head_size)`. Query, key, and value must have the same hidden width; distinct value-head widths, KV-cache, key-padding-mask, and diagnostic-QK modes are unsupported.
16
 
17
  See the [ONNX Runtime `MultiHeadAttention` contrib-operator spec](https://github.com/microsoft/onnxruntime/blob/main/docs/ContribOperators.md#com.microsoft.MultiHeadAttention) for the reference semantics.
18
 
19
  ## Inputs
20
 
21
+ | Name | Upstream name | Logical dtype | Rank | Shape | Description | Presence |
22
  | --- | --- | --- | --- | --- | --- | --- |
23
+ | `queryT` | `query` | `T` | `3` | — | Query tensor of shape `(batch_size, sequence_length, hidden_size)`. | required |
24
+ | `keyT` | `key` | `T` | `3` | — | Key tensor of shape `(batch_size, kv_sequence_length, hidden_size)`; its hidden width must equal the query hidden width. | required |
25
+ | `valueT` | `value` | `T` | `3` | — | Value tensor of shape `(batch_size, kv_sequence_length, v_hidden_size)`; `v_hidden_size` must equal the query hidden width. | required |
26
+ | `biasT` | `bias` | `T` | `1` | — | Optional fused bias from input projection with shape `(hidden_size + hidden_size + v_hidden_size)`. | optional |
27
+ | `attentionBiasT` | `attention_bias` | `T` | `4` | — | Optional additive bias applied to the attention scores before softmax, of shape `(batch_size or 1, num_heads or 1, sequence_length, kv_sequence_length)`; the first two dimensions broadcast. | optional |
28
 
29
  ## Outputs
30
 
31
+ | Name | Upstream name | Logical dtype | Rank | Shape | Description | Presence |
32
  | --- | --- | --- | --- | --- | --- | --- |
33
+ | `outputT` | `output` | `T` | `3` | `[queryT[0], queryT[1], valueT[2]]` | Attention output of shape `(batch_size, sequence_length, v_hidden_size)`. | required |
34
 
35
  ## Attributes
36
 
 
38
 
39
  | Attribute | Default | Description |
40
  | --- | --- | --- |
 
41
  | `num_heads` | — | Number of attention heads. |
42
  | `scale` | — | Optional score scale; zero or omission selects `1 / sqrt(head_size)`. |
43
+ | `unidirectional` | `0` | When 1, applies an upper-left causal mask: query position `i` attends only to key positions `0..min(i, kv_sequence_length - 1)`. |
44
 
45
  ## Type constraints
46
 
 
48
  | --- | --- |
49
  | `T` | `float32`, `float16` |
50
 
51
+ ## Implementation variants
52
+
53
+ One implementation is selected per call from the device capabilities, the request shapes and the dtypes; these notes say what each one covers.
54
+
55
+ - `qkv_bias_small_seq_blocked` — Blocked whole-head attention for short key sequences with bias and optional causal masking. A workgroup stages one head's K and V once, then assigns each query in a small block to a group of key lanes whose register-resident online-softmax partials are merged in one pass.
56
+ - `qkv_no_bias_small_seq_blocked` — Blocked whole-head attention for short key sequences without bias and with optional causal masking. A workgroup stages one head's K and V once, then assigns each query in a small block to a group of key lanes whose register-resident online-softmax partials are merged in one pass.
57
+ - `qkv_no_bias_small_seq` — Whole-head attention for short bidirectional float32 requests without bias. One workgroup stages a head's complete key and value planes, and each participating invocation owns one query row through scoring, normalization, and context accumulation.
58
+ - `qkv_no_bias_tiled_nosg` — Portable tiled prefill route for devices without an admissible subgroup path. It computes attention online without materializing the full score matrix.
59
+ - `qkv_no_bias_tiled_attn_bias_nosg` — Portable tiled prefill route for additive attention bias when no subgroup path is admissible. It computes attention online without materializing the full score matrix.
60
+ - `qkv_no_bias_materialized_sgmat_fused_f32` — Materializes FP32 scores for subgroup-matrix score and value products, then folds softmax normalization into the apply pass using row statistics emitted by the score pass.
61
+ - `qkv_bias_materialized_sgmat_fused_f32` — Materializes FP32 scores for subgroup-matrix score and value products, then folds softmax normalization into the apply pass using row statistics emitted by the score pass.
62
+ - `qkv_no_bias_materialized_sgmat_fused_f16` — Materializes FP32 scores for subgroup-matrix score and value products, then folds softmax normalization into the apply pass using row statistics emitted by the score pass.
63
+ - `qkv_no_bias_flash_cluster_lpq4_nosg` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
64
+ - `qkv_no_bias_flash_cluster_nosg` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
65
+ - `qkv_bias_flash_cluster_nosg` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
66
+ - `qkv_no_bias_flash_cluster_lpq4` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
67
+ - `qkv_no_bias_flash_cluster` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
68
+ - `qkv_bias_flash_cluster` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
69
+ - `qkv_no_bias_flash_cluster_attn_bias` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
70
+ - `qkv_bias_flash_cluster_attn_bias` — Keeps query/key tiles and online-softmax state inside one workgroup instead of materializing all scores. The family supplies subgroup and portable forms for compatible prefill shapes.
71
+ - `qkv_no_bias_flash_splitk_nosg` — Partitions the key reduction across workgroups and merges partial softmax statistics and values. Used when decode or another short query has too few independent query/head workgroups; the merge reassociates the key reduction.
72
+ - `qkv_bias_flash_splitk_nosg` — Partitions the key reduction across workgroups and merges partial softmax statistics and values. Used when decode or another short query has too few independent query/head workgroups; the merge reassociates the key reduction.
73
+ - `qkv_no_bias_flash_splitk` — Partitions the key reduction across workgroups and merges partial softmax statistics and values. Used when decode or another short query has too few independent query/head workgroups; the merge reassociates the key reduction.
74
+ - `qkv_bias_flash_splitk` — Partitions the key reduction across workgroups and merges partial softmax statistics and values. Used when decode or another short query has too few independent query/head workgroups; the merge reassociates the key reduction.
75
+ - `qkv_no_bias_materialized_gemm_f32` — Materializes float32 scores and applies tiled score, softmax, and value passes without requiring subgroup-matrix support.
76
+ - `qkv_bias_materialized_gemm_f32` — Materializes float32 scores and applies tiled score, softmax, and value passes without requiring subgroup-matrix support.
77
+ - `qkv_no_bias_materialized_gemm_fused_f32` — Materializes FP32 scores while publishing each row's maximum, combines those row statistics, then normalizes and applies the scores in one pass. This route is eligible for score matrices above the configured size threshold.
78
+ - `qkv_bias_materialized_gemm_fused_f32` — Materializes FP32 scores while publishing each row's maximum, combines those row statistics, then normalizes and applies the scores in one pass. This route is eligible for score matrices above the configured size threshold.
79
+ - `qkv_no_bias_attn_bias` — Portable scalar online attention with bias and additive-mask support. It applies online softmax while accumulating context, without materializing a score tensor.
80
+ - `qkv_bias_attn_bias` — Portable scalar online attention with bias and additive-mask support. It applies online softmax while accumulating context, without materializing a score tensor.
81
+ - `qkv_no_bias` — Portable scalar online attention with bias and additive-mask support. It applies online softmax while accumulating context, without materializing a score tensor.
82
+ - `qkv_bias` — Portable scalar online attention with bias and additive-mask support. It applies online softmax while accumulating context, without materializing a score tensor.
83
+ - `qkv_no_bias_flash` — Computes one-pass online-softmax attention without score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
84
+ - `qkv_bias_flash` — Computes one-pass online-softmax attention with fused projection bias and no score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
85
+ - `qkv_no_bias_flash_nosg` — Computes one-pass online-softmax attention without score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
86
+ - `qkv_bias_flash_nosg` — Computes one-pass online-softmax attention with fused projection bias and no score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
87
+ - `qkv_no_bias_flash_attn_bias` — Computes one-pass online-softmax attention without score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
88
+ - `qkv_bias_flash_attn_bias` — Computes one-pass online-softmax attention with fused projection bias and no score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
89
+ - `qkv_no_bias_flash_attn_bias_nosg` — Computes one-pass online-softmax attention without score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
90
+ - `qkv_bias_flash_attn_bias_nosg` — Computes one-pass online-softmax attention with fused projection bias and no score scratch. Family suffixes select subgroup, portable, and additive-attention-bias forms without changing the algorithm.
91
+ - `qkv_no_bias_materialized_sgmat_f32` — Materializes FP32 scores for subgroup-matrix score and value products, with normalization performed by a separate softmax pass.
92
+ - `qkv_bias_materialized_sgmat_f32` — Materializes FP32 scores for subgroup-matrix score and value products, with normalization performed by a separate softmax pass.
93
+
94
  ## Device requirements
95
 
96
  Some implementation variants require `subgroup-matrix`, `shader-f16`, and `subgroups`. These are route-specific capabilities, not package-wide requirements; availability also depends on the request shape and dtype.
97
 
98
  ## Files
99
 
100
+ - [`metadata.json`](build/webgpu/metadata.json) — kernel metadata (id, digests, per-variant templates, provenance)
101
  - [`manifest.json`](build/webgpu/manifest.json) — the op contract (source of truth)
102
  - [`test.json`](build/webgpu/test.json) — correctness cases
103
  - [`bench.json`](build/webgpu/bench.json) — benchmark + tuning cases
 
114
  - [`attn-materialized-softmax-f32.wgsl.jinja`](build/webgpu/attn-materialized-softmax-f32.wgsl.jinja)
115
  - [`attn-online-scalar.wgsl.jinja`](build/webgpu/attn-online-scalar.wgsl.jinja)
116
  - [`attn-small-head-parallel.wgsl.jinja`](build/webgpu/attn-small-head-parallel.wgsl.jinja)
117
+ - [`mha-small-seq-blocked.wgsl.jinja`](build/webgpu/mha-small-seq-blocked.wgsl.jinja)
118
  - [`mha-small-seq.wgsl.jinja`](build/webgpu/mha-small-seq.wgsl.jinja)
119
 
120
  ## Use with `@huggingface/kernels`
121
 
122
+ ```sh
123
+ npm install --save-exact @huggingface/kernels@0.0.1-preview.2
124
+ ```
125
+
126
+ Required output shapes and logical data types are inferred from the supplied inputs and attributes; result tensors are allocated automatically.
127
 
128
  The `version: 1` option selects the published kernel contract; it is independent of any operator opset, contrib `since_version`, or model version.
129
+ It follows the `v1` branch as fixes land. To pin exact artifact bytes, pass a 40-character commit `revision` instead of `version`.
130
 
131
  Replace each `*Data` placeholder with a typed array containing the corresponding input data.
132
 
build/webgpu/attention-rank4-tiled.wgsl.jinja CHANGED
@@ -1,19 +1,15 @@
1
- {% if usesF16 %}
2
- enable f16;
3
- {% endif %}
4
  {{ env.wgsl.resourceDeclarations }}
5
 
6
  // Thread-per-query online-softmax flash fallback for rank-4 (BNSH) attention with
7
  // no optional feature requirements. Each invocation owns one full query row and walks the keys
8
  // independently, avoiding per-key workgroup barriers and tree reductions.
9
  //
10
- // BLOCK_M consecutive queries of one (batch, q_head) are tiled into a workgroup so
11
- // they stream the same K/V rows at the same key index, so those global loads hit the
12
- // L2 cache (read once per head, broadcast across the tile). The per-query online
13
  // accumulator lives in shared memory laid out d-major (d*BLOCK_M + tid) so the
14
- // BLOCK_M threads touch consecutive addresses for a fixed d (no bank conflict) and
15
- // nothing spills to registers. The value-head width is capped so acc fits in
16
- // the workgroup-storage budget;
17
  // larger heads use the scalar online-attention fallback.
18
  const BLOCK_M: u32 = {{ blockM }}u;
19
 
@@ -34,13 +30,12 @@ fn kv_head(q_head: u32) -> u32 {
34
  @compute @workgroup_size(BLOCK_M, 1, 1)
35
  fn main(
36
  @builtin(workgroup_id) wg: vec3<u32>,
37
- @builtin(num_workgroups) nwg: vec3<u32>,
38
  @builtin(local_invocation_id) lid: vec3<u32>
39
  ) {
40
  let tid = lid.x;
41
  // 2D-folded tile index over (batch, q_head, q_seq tile); wg.y carries the high
42
- // bits past the maxComputeWorkgroupsPerDimension dispatch limit.
43
- let tile = wg.x + wg.y * nwg.x;
44
  let qTiles = (params.qSeq + BLOCK_M - 1u) / BLOCK_M;
45
  // count = numel(Y) = batch·qHeads·qSeq·vHeadSize, so count/(vHeadSize·qSeq) =
46
  // batch·qHeads. Drop the over-dispatched tail from the 2D fold.
@@ -56,8 +51,14 @@ fn main(
56
  if (qs >= params.qSeq) { return; }
57
  let kh = kv_head(qh);
58
 
 
59
  // Token-major packed QKV: row = (batch*seq + token)*HIDDEN + head*head_dim.
60
  let qBase = (batch * params.qSeq + qs) * params.qHidden + qh * params.headSize;
 
 
 
 
 
61
  let scale = scale_value();
62
 
63
  for (var d: u32 = 0u; d < params.vHeadSize; d = d + 1u) {
@@ -82,10 +83,14 @@ fn main(
82
  // A rejected bool-mask key has zero softmax mass. Skipping it is safe here:
83
  // this thread-per-query kernel has no barriers inside the key loop.
84
  if (masked) { continue; }
85
- // The K/V row is shared across the tile, so these loads broadcast from L2.
86
  var score: f32 = -3.4028234663852886e38;
87
  if (!masked) {
 
88
  let kRow = (batch * params.kvSeq + ks) * params.kvHidden + kh * params.headSize;
 
 
 
89
  var dot: f32 = 0.0;
90
  for (var d: u32 = 0u; d < params.headSize; d = d + 1u) {
91
  dot = dot + f32(q[qBase + d]) * f32(k[kRow + d]);
@@ -105,14 +110,22 @@ fn main(
105
  let weight = exp(score - next_max);
106
  running_max = next_max;
107
  running_denom = running_denom * prev_scale + weight;
 
108
  let vRow = (batch * params.kvSeq + ks) * params.vHidden + kh * params.vHeadSize;
 
 
 
109
  for (var d: u32 = 0u; d < params.vHeadSize; d = d + 1u) {
110
  acc[d * BLOCK_M + tid] = acc[d * BLOCK_M + tid] * prev_scale + weight * f32(v[vRow + d]);
111
  }
112
  }
113
 
114
  let inv_denom = select(0.0, 1.0 / running_denom, running_denom > 0.0);
 
115
  let yBase = (batch * params.qSeq + qs) * (params.qHeads * params.vHeadSize) + qh * params.vHeadSize;
 
 
 
116
  for (var d: u32 = 0u; d < params.vHeadSize; d = d + 1u) {
117
  y[yBase + d] = {{ scalar }}(acc[d * BLOCK_M + tid] * inv_denom);
118
  }
 
1
+ {% set layout = layout if layout is defined else "bnsh" %}
 
 
2
  {{ env.wgsl.resourceDeclarations }}
3
 
4
  // Thread-per-query online-softmax flash fallback for rank-4 (BNSH) attention with
5
  // no optional feature requirements. Each invocation owns one full query row and walks the keys
6
  // independently, avoiding per-key workgroup barriers and tree reductions.
7
  //
8
+ // BLOCK_M consecutive queries of one (batch, q_head) are tiled into a workgroup,
9
+ // so adjacent query threads reuse the same K/V row at each key index. The per-query online
 
10
  // accumulator lives in shared memory laid out d-major (d*BLOCK_M + tid) so the
11
+ // BLOCK_M threads touch consecutive addresses for a fixed d. The value-head
12
+ // width is capped so the accumulator fits in the workgroup-storage budget;
 
13
  // larger heads use the scalar online-attention fallback.
14
  const BLOCK_M: u32 = {{ blockM }}u;
15
 
 
30
  @compute @workgroup_size(BLOCK_M, 1, 1)
31
  fn main(
32
  @builtin(workgroup_id) wg: vec3<u32>,
 
33
  @builtin(local_invocation_id) lid: vec3<u32>
34
  ) {
35
  let tid = lid.x;
36
  // 2D-folded tile index over (batch, q_head, q_seq tile); wg.y carries the high
37
+ // bits past the per-axis dispatch fold width.
38
+ let tile = wg.x + wg.y * {{ DISPATCH_FOLD_WIDTH }}u;
39
  let qTiles = (params.qSeq + BLOCK_M - 1u) / BLOCK_M;
40
  // count = numel(Y) = batch·qHeads·qSeq·vHeadSize, so count/(vHeadSize·qSeq) =
41
  // batch·qHeads. Drop the over-dispatched tail from the 2D fold.
 
51
  if (qs >= params.qSeq) { return; }
52
  let kh = kv_head(qh);
53
 
54
+ {% if layout == "bsh" %}
55
  // Token-major packed QKV: row = (batch*seq + token)*HIDDEN + head*head_dim.
56
  let qBase = (batch * params.qSeq + qs) * params.qHidden + qh * params.headSize;
57
+ {% else %}
58
+ let qBase = ((batch * params.qHeads + qh) * params.qSeq + qs) * params.headSize;
59
+ let kBase = (batch * params.kvHeads + kh) * params.kvSeq;
60
+ let vBase = (batch * params.kvHeads + kh) * params.kvSeq;
61
+ {% endif %}
62
  let scale = scale_value();
63
 
64
  for (var d: u32 = 0u; d < params.vHeadSize; d = d + 1u) {
 
83
  // A rejected bool-mask key has zero softmax mass. Skipping it is safe here:
84
  // this thread-per-query kernel has no barriers inside the key loop.
85
  if (masked) { continue; }
86
+ // Adjacent query threads load the same K/V row for this key.
87
  var score: f32 = -3.4028234663852886e38;
88
  if (!masked) {
89
+ {% if layout == "bsh" %}
90
  let kRow = (batch * params.kvSeq + ks) * params.kvHidden + kh * params.headSize;
91
+ {% else %}
92
+ let kRow = (kBase + ks) * params.headSize;
93
+ {% endif %}
94
  var dot: f32 = 0.0;
95
  for (var d: u32 = 0u; d < params.headSize; d = d + 1u) {
96
  dot = dot + f32(q[qBase + d]) * f32(k[kRow + d]);
 
110
  let weight = exp(score - next_max);
111
  running_max = next_max;
112
  running_denom = running_denom * prev_scale + weight;
113
+ {% if layout == "bsh" %}
114
  let vRow = (batch * params.kvSeq + ks) * params.vHidden + kh * params.vHeadSize;
115
+ {% else %}
116
+ let vRow = (vBase + ks) * params.vHeadSize;
117
+ {% endif %}
118
  for (var d: u32 = 0u; d < params.vHeadSize; d = d + 1u) {
119
  acc[d * BLOCK_M + tid] = acc[d * BLOCK_M + tid] * prev_scale + weight * f32(v[vRow + d]);
120
  }
121
  }
122
 
123
  let inv_denom = select(0.0, 1.0 / running_denom, running_denom > 0.0);
124
+ {% if layout == "bsh" %}
125
  let yBase = (batch * params.qSeq + qs) * (params.qHeads * params.vHeadSize) + qh * params.vHeadSize;
126
+ {% else %}
127
+ let yBase = ((batch * params.qHeads + qh) * params.qSeq + qs) * params.vHeadSize;
128
+ {% endif %}
129
  for (var d: u32 = 0u; d < params.vHeadSize; d = d + 1u) {
130
  y[yBase + d] = {{ scalar }}(acc[d * BLOCK_M + tid] * inv_denom);
131
  }
build/webgpu/attn-flash-decode-splitk-merge.wgsl.jinja CHANGED
@@ -3,6 +3,7 @@ enable f16;
3
  {% endif %}
4
  {% if splitQueries is not defined %}{% set splitQueries = false %}{% endif %}
5
  {% if hasGate is not defined %}{% set hasGate = false %}{% endif %}
 
6
  {{ env.wgsl.resourceDeclarations }}
7
 
8
  // Split-K flash decode, pass 2 of 2. Combines the per-split un-normalized online
@@ -105,7 +106,7 @@ fn main(
105
  outValue = outValue + vec4<f32>(bias[vBiasBase], bias[vBiasBase + 1u], bias[vBiasBase + 2u], bias[vBiasBase + 3u]);
106
  {% endif %}
107
  {% if hasGate %}
108
- // Qwen3.5 full attention gates the normalized attention output elementwise.
109
  let gateV = vec4<f32>(gate[qBaseV4 + d4]);
110
  outValue = outValue * (vec4<f32>(1.0) / (vec4<f32>(1.0) + exp(-gateV)));
111
  {% endif %}
 
3
  {% endif %}
4
  {% if splitQueries is not defined %}{% set splitQueries = false %}{% endif %}
5
  {% if hasGate is not defined %}{% set hasGate = false %}{% endif %}
6
+ {% set qSeq = qSeq | default(0) %}
7
  {{ env.wgsl.resourceDeclarations }}
8
 
9
  // Split-K flash decode, pass 2 of 2. Combines the per-split un-normalized online
 
106
  outValue = outValue + vec4<f32>(bias[vBiasBase], bias[vBiasBase + 1u], bias[vBiasBase + 2u], bias[vBiasBase + 3u]);
107
  {% endif %}
108
  {% if hasGate %}
109
+ // The gated route multiplies the normalized attention output elementwise by its gate.
110
  let gateV = vec4<f32>(gate[qBaseV4 + d4]);
111
  outValue = outValue * (vec4<f32>(1.0) / (vec4<f32>(1.0) + exp(-gateV)));
112
  {% endif %}
build/webgpu/attn-flash-decode-splitk.wgsl.jinja CHANGED
@@ -4,17 +4,17 @@
4
  {% if cacheSeqlens is not defined %}{% set cacheSeqlens = false %}{% endif %}
5
  {% if hasMask is not defined %}{% set hasMask = false %}{% endif %}
6
  {% if maskIsBool is not defined %}{% set maskIsBool = false %}{% endif %}
7
- {% set splitKWorkgroupSize = source.workgroupSize if source.workgroupSize is defined else tunables.WORKGROUP_SIZE %}
 
 
 
8
  {% if useSubgroups %}
9
  enable subgroups;
10
  {% endif %}
11
- {% if usesF16 %}
12
- enable f16;
13
- {% endif %}
14
  {{ env.wgsl.resourceDeclarations }}
15
 
16
- // Split-K flash attention, pass 1 of 2; the merge pass follows. Shared by
17
- // dense-attention decode and short-query/long-context prefill paths.
18
  //
19
  // The non-split flash decode launches only `batch * numHeads` workgroups, each
20
  // sweeping the whole KV sequence serially in WG-key tiles. This pass splits the
@@ -81,20 +81,22 @@ var<workgroup> vacc_sh: array<vec4<f32>, WG>;
81
  // calls before their shared partial storage is reused.
82
  {% set combineSubgroups = combineSubgroups is defined and combineSubgroups %}
83
  {% if combineSubgroups %}
84
- // Per-subgroup partials are published into a deterministic slot: the subgroup's
85
- // ordinal index within the workgroup (lidx / sgSize). The online (m, d) merge
86
- // is not float-associative, so thread 0 must fold partials in a fixed order.
87
- // Subgroups partition a workgroup into contiguous ordinal ranges on supported
88
- // backends, so the ordinal slot is unique per subgroup and every slot in
89
- // [0, subgroupCount) is written (each subgroup elects one leader).
90
- // Sized for the worst case of one partial per invocation.
 
91
  var<workgroup> partialM: array<f32, WG>;
92
  var<workgroup> partialD: array<f32, WG>;
 
93
  var<workgroup> combinedMD: vec2<f32>;
94
 
95
  // When the whole workgroup is one subgroup the subgroup reduce already covers
96
- // it (no barriers, no shared state); otherwise subgroup leaders publish
97
- // partials through shared memory and thread 0 folds them in ordinal order.
98
  fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
99
  let sgM = subgroupMax(m);
100
  // A lane with no elements contributes d == 0 (exact identity). A +inf
@@ -105,31 +107,24 @@ fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
105
  if (sgSize == WG) {
106
  return vec2<f32>(sgM, sgD);
107
  }
108
- let subgroupCount = (WG + sgSize - 1u) / sgSize;
109
- // Pre-seed every fold slot with the (max, denom) identity. The fold below reads a
110
- // fixed subgroupCount slots in ordinal order (for determinism), but a slot whose
111
- // subgroup elects no leader this call — e.g. a fully out-of-window key tile in the
112
- // flash-attention loop that re-uses this shared memory each iteration — would
113
- // otherwise read stale shared memory. Identity makes such a slot a no-op.
114
- // (max identity = -FLT_MAX, denom identity = 0.)
115
- if (lidx < subgroupCount) {
116
- partialM[lidx] = -FLT_MAX;
117
- partialD[lidx] = 0.0;
118
- }
119
- workgroupBarrier();
120
  if (subgroupElect()) {
121
- let slot = lidx / sgSize;
122
- partialM[slot] = sgM;
123
- partialD[slot] = sgD;
124
  }
125
  workgroupBarrier();
126
  if (lidx == 0u) {
127
  var accM = -FLT_MAX;
128
  var accD = 0.0;
129
- for (var i = 0u; i < subgroupCount; i = i + 1u) {
130
- let mNew = max(accM, partialM[i]);
131
- accD = accD * exp_shift(accM, mNew) + partialD[i] * exp_shift(partialM[i], mNew);
132
- accM = mNew;
 
 
 
 
 
133
  }
134
  combinedMD = vec2<f32>(accM, accD);
135
  }
@@ -144,9 +139,8 @@ var<workgroup> partialM: array<f32, {{ mdExtent }}>;
144
  var<workgroup> partialD: array<f32, {{ mdExtent }}>;
145
  {% if mdStreamed %}
146
 
147
- // In-place fold of {{ mdStreams }} streams. The caller stores its per-thread
148
- // partials into partialM/partialD first and reads the merged pair of stream s
149
- // from slot s * WG afterwards.
150
  fn combine_partials_streams(lidx: u32) {
151
  workgroupBarrier();
152
  var stride = WG / 2u;
 
4
  {% if cacheSeqlens is not defined %}{% set cacheSeqlens = false %}{% endif %}
5
  {% if hasMask is not defined %}{% set hasMask = false %}{% endif %}
6
  {% if maskIsBool is not defined %}{% set maskIsBool = false %}{% endif %}
7
+ {% set scale = scale | default("0.0") %}
8
+ {% if hasRotary is not defined %}{% set hasRotary = false %}{% endif %}
9
+ {% set qSeq = qSeq | default(0) %}
10
+ {% set splitKWorkgroupSize = workgroupSizeSpec if workgroupSizeSpec is defined else tunables.WORKGROUP_SIZE %}
11
  {% if useSubgroups %}
12
  enable subgroups;
13
  {% endif %}
 
 
 
14
  {{ env.wgsl.resourceDeclarations }}
15
 
16
+ // Split-K flash attention, pass 1 of 2; the merge pass follows. This geometry
17
+ // handles decode and short-query, long-context prefill inputs.
18
  //
19
  // The non-split flash decode launches only `batch * numHeads` workgroups, each
20
  // sweeping the whole KV sequence serially in WG-key tiles. This pass splits the
 
81
  // calls before their shared partial storage is reused.
82
  {% set combineSubgroups = combineSubgroups is defined and combineSubgroups %}
83
  {% if combineSubgroups %}
84
+ // Cross-subgroup merge that assumes nothing about which invocations share a
85
+ // subgroup or how many subgroups there are: each subgroup's elected lane
86
+ // publishes the subgroup pair in the slot at its OWN invocation index and sets
87
+ // that index's bit in a workgroup bitmask; thread 0 then folds exactly the
88
+ // published slots, in ascending index order (the online (m, d) merge is not
89
+ // float-associative, so the order is fixed), and clears the mask for the next
90
+ // call as it reads it. Workgroup memory starts zeroed, so the mask needs no
91
+ // setup. Same three collectives as a single-subgroup reduce, two barriers.
92
  var<workgroup> partialM: array<f32, WG>;
93
  var<workgroup> partialD: array<f32, WG>;
94
+ var<workgroup> leaderMask: array<atomic<u32>, (WG + 31u) / 32u>;
95
  var<workgroup> combinedMD: vec2<f32>;
96
 
97
  // When the whole workgroup is one subgroup the subgroup reduce already covers
98
+ // it (no barriers, no shared state). `subgroup_size` is the size of the current
99
+ // subgroup and uniform, so the test is exact and may guard the barriers below.
100
  fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
101
  let sgM = subgroupMax(m);
102
  // A lane with no elements contributes d == 0 (exact identity). A +inf
 
107
  if (sgSize == WG) {
108
  return vec2<f32>(sgM, sgD);
109
  }
 
 
 
 
 
 
 
 
 
 
 
 
110
  if (subgroupElect()) {
111
+ partialM[lidx] = sgM;
112
+ partialD[lidx] = sgD;
113
+ atomicOr(&leaderMask[lidx / 32u], 1u << (lidx % 32u));
114
  }
115
  workgroupBarrier();
116
  if (lidx == 0u) {
117
  var accM = -FLT_MAX;
118
  var accD = 0.0;
119
+ for (var w = 0u; w < (WG + 31u) / 32u; w = w + 1u) {
120
+ var bits = atomicExchange(&leaderMask[w], 0u);
121
+ while (bits != 0u) {
122
+ let slot = w * 32u + firstTrailingBit(bits);
123
+ bits = bits & (bits - 1u);
124
+ let mNew = max(accM, partialM[slot]);
125
+ accD = accD * exp_shift(accM, mNew) + partialD[slot] * exp_shift(partialM[slot], mNew);
126
+ accM = mNew;
127
+ }
128
  }
129
  combinedMD = vec2<f32>(accM, accD);
130
  }
 
139
  var<workgroup> partialD: array<f32, {{ mdExtent }}>;
140
  {% if mdStreamed %}
141
 
142
+ // In-place fold of {{ mdStreams }} streams. Input partials occupy
143
+ // partialM/partialD; stream s returns its merged pair in slot s * WG.
 
144
  fn combine_partials_streams(lidx: u32) {
145
  workgroupBarrier();
146
  var stride = WG / 2u;
build/webgpu/attn-flash-online.wgsl.jinja CHANGED
@@ -1,13 +1,10 @@
1
- {% if usesF16 %}
2
- enable f16;
3
- {% endif %}
4
  {% if combineSubgroups %}
5
  enable subgroups;
6
  {% endif %}
7
  {{ env.wgsl.resourceDeclarations }}
8
 
9
- // Flash-style tiled online-softmax attention, shared by every dense
10
- // attention op with vec4-aligned head dims. One workgroup per
11
  // (batch, head, query token); the workgroup sweeps the KV sequence in tiles
12
  // of WG keys, each thread owning one key of the tile (full q·k dot in
13
  // registers, vec4 loads), so a tile needs a single fused (max, sum-exp)
@@ -16,9 +13,6 @@ enable subgroups;
16
  // per tile; the full score matrix is never materialized.
17
  // Layout: token-major [batch, seq, heads * headDim]; Q and KV hidden strides
18
  // are compiled constants.
19
- {% if headsFromParams %}
20
- // Q/KV head counts are uniforms; the GQA mapping is h / (qHeads / kvHeads).
21
- {% endif %}
22
  {% if hasBias %}
23
  // Packed [Q; K; V] bias rows. The K bias adds the same dot(q, biasK) to every
24
  // key score, which softmax cancels, so only the Q and V biases are applied.
@@ -32,10 +26,8 @@ const HEAD_DIM: u32 = {{ headDim }}u;
32
  const HEAD_DIM_V4: u32 = {{ headDimV4 }}u;
33
  const Q_HIDDEN_V4: u32 = {{ qHiddenV4 }}u;
34
  const KV_HIDDEN_V4: u32 = {{ kvHiddenV4 }}u;
35
- {% if not headsFromParams %}
36
  const Q_HEADS: u32 = {{ qNumHeads }}u;
37
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
38
- {% endif %}
39
  {% set qHeads = "params.qHeads" if headsFromParams else "Q_HEADS" %}
40
  {% set kvHeads = "params.kvHeads" if headsFromParams else "KV_HEADS" %}
41
  const WG: u32 = {{ tunables.WORKGROUP_SIZE }}u;
@@ -73,20 +65,22 @@ var<workgroup> probs: array<f32, WG>;
73
  // calls before their shared partial storage is reused.
74
  {% set combineSubgroups = combineSubgroups is defined and combineSubgroups %}
75
  {% if combineSubgroups %}
76
- // Per-subgroup partials are published into a deterministic slot: the subgroup's
77
- // ordinal index within the workgroup (lidx / sgSize). The online (m, d) merge
78
- // is not float-associative, so thread 0 must fold partials in a fixed order.
79
- // Subgroups partition a workgroup into contiguous ordinal ranges on supported
80
- // backends, so the ordinal slot is unique per subgroup and every slot in
81
- // [0, subgroupCount) is written (each subgroup elects one leader).
82
- // Sized for the worst case of one partial per invocation.
 
83
  var<workgroup> partialM: array<f32, WG>;
84
  var<workgroup> partialD: array<f32, WG>;
 
85
  var<workgroup> combinedMD: vec2<f32>;
86
 
87
  // When the whole workgroup is one subgroup the subgroup reduce already covers
88
- // it (no barriers, no shared state); otherwise subgroup leaders publish
89
- // partials through shared memory and thread 0 folds them in ordinal order.
90
  fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
91
  let sgM = subgroupMax(m);
92
  // A lane with no elements contributes d == 0 (exact identity). A +inf
@@ -97,31 +91,24 @@ fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
97
  if (sgSize == WG) {
98
  return vec2<f32>(sgM, sgD);
99
  }
100
- let subgroupCount = (WG + sgSize - 1u) / sgSize;
101
- // Pre-seed every fold slot with the (max, denom) identity. The fold below reads a
102
- // fixed subgroupCount slots in ordinal order (for determinism), but a slot whose
103
- // subgroup elects no leader this call — e.g. a fully out-of-window key tile in the
104
- // flash-attention loop that re-uses this shared memory each iteration — would
105
- // otherwise read stale shared memory. Identity makes such a slot a no-op.
106
- // (max identity = -FLT_MAX, denom identity = 0.)
107
- if (lidx < subgroupCount) {
108
- partialM[lidx] = -FLT_MAX;
109
- partialD[lidx] = 0.0;
110
- }
111
- workgroupBarrier();
112
  if (subgroupElect()) {
113
- let slot = lidx / sgSize;
114
- partialM[slot] = sgM;
115
- partialD[slot] = sgD;
116
  }
117
  workgroupBarrier();
118
  if (lidx == 0u) {
119
  var accM = -FLT_MAX;
120
  var accD = 0.0;
121
- for (var i = 0u; i < subgroupCount; i = i + 1u) {
122
- let mNew = max(accM, partialM[i]);
123
- accD = accD * exp_shift(accM, mNew) + partialD[i] * exp_shift(partialM[i], mNew);
124
- accM = mNew;
 
 
 
 
 
125
  }
126
  combinedMD = vec2<f32>(accM, accD);
127
  }
@@ -136,9 +123,8 @@ var<workgroup> partialM: array<f32, {{ mdExtent }}>;
136
  var<workgroup> partialD: array<f32, {{ mdExtent }}>;
137
  {% if mdStreamed %}
138
 
139
- // In-place fold of {{ mdStreams }} streams. The caller stores its per-thread
140
- // partials into partialM/partialD first and reads the merged pair of stream s
141
- // from slot s * WG afterwards.
142
  fn combine_partials_streams(lidx: u32) {
143
  workgroupBarrier();
144
  var stride = WG / 2u;
@@ -197,8 +183,8 @@ fn combine_partials(m: f32, d: f32, lidx: u32) -> vec2<f32> {
197
  {% endif %}
198
 
199
 
200
- // Omitted ONNX scale uses 1/sqrt(headDim). Explicit zero is handled by
201
- // specialization because zero is the runtime omitted-value sentinel.
202
  {% if ATTN_SCALE_DIM is not defined %}{% set ATTN_SCALE_DIM = "HEAD_DIM" %}{% endif %}
203
  fn scale_value() -> f32 {
204
  if (params.scale != 0.0) { return params.scale; }
@@ -257,11 +243,7 @@ fn main(
257
  // Causal upper bound: query qi attends only keys 0..qi, so stop after the tile
258
  // containing qi and skip the unattended tail. Non-causal keeps the full kvSeq
259
  // sweep.
260
- {% if hasCausal %}
261
  var keyBoundV = select(params.kvSeq, min(params.kvSeq, qi + 1u), params.isCausal != 0u);
262
- {% else %}
263
- var keyBoundV = params.kvSeq;
264
- {% endif %}
265
  var keyFloor: u32 = 0u;
266
  let keyBound = keyBoundV;
267
  // Start at the WG-aligned tile containing keyFloor (sub-floor lanes mask out).
@@ -279,7 +261,7 @@ fn main(
279
  var score = -FLT_MAX;
280
  var m = -FLT_MAX;
281
  var dPart = 0.0;
282
- var keyAllowed = kj < keyBound{% if hasCausal %} && (params.isCausal == 0u || kj <= qi){% endif %};
283
  if (keyAllowed) {
284
  let kRowV4 = kvBaseV4 + kj * kvTokenStrideV4;
285
  {% if hasMask %}
@@ -287,17 +269,6 @@ fn main(
287
  // [q, k] masks set batch/head strides to 0).
288
  let maskIndex = b * params.maskBatchStride + h * params.maskHeadStride + qi * params.maskSeqStride + kj;
289
  {% endif %}
290
- {% if hasMask and maskIsBool %}
291
- if (attn_mask[maskIndex] == 0u) {
292
- keyAllowed = false;
293
- } else {
294
- var acc: f32 = 0.0;
295
- for (var d4: u32 = 0u; d4 < HEAD_DIM_V4; d4 = d4 + 1u) {
296
- acc = acc + dot(q_shared[d4], vec4<f32>(key[kRowV4 + d4]));
297
- }
298
- score = acc * scale;
299
- }
300
- {% else %}
301
  var acc: f32 = 0.0;
302
  for (var d4: u32 = 0u; d4 < HEAD_DIM_V4; d4 = d4 + 1u) {
303
  acc = acc + dot(q_shared[d4], vec4<f32>(key[kRowV4 + d4]));
@@ -305,7 +276,6 @@ fn main(
305
  score = acc * scale;
306
  {% if hasMask %}
307
  score = score + f32(attn_mask[maskIndex]);
308
- {% endif %}
309
  {% endif %}
310
  m = score;
311
  dPart = select(0.0, 1.0, keyAllowed);
 
 
 
 
1
  {% if combineSubgroups %}
2
  enable subgroups;
3
  {% endif %}
4
  {{ env.wgsl.resourceDeclarations }}
5
 
6
+ // Flash-style tiled online-softmax attention for vec4-aligned head dimensions.
7
+ // One workgroup per
8
  // (batch, head, query token); the workgroup sweeps the KV sequence in tiles
9
  // of WG keys, each thread owning one key of the tile (full q·k dot in
10
  // registers, vec4 loads), so a tile needs a single fused (max, sum-exp)
 
13
  // per tile; the full score matrix is never materialized.
14
  // Layout: token-major [batch, seq, heads * headDim]; Q and KV hidden strides
15
  // are compiled constants.
 
 
 
16
  {% if hasBias %}
17
  // Packed [Q; K; V] bias rows. The K bias adds the same dot(q, biasK) to every
18
  // key score, which softmax cancels, so only the Q and V biases are applied.
 
26
  const HEAD_DIM_V4: u32 = {{ headDimV4 }}u;
27
  const Q_HIDDEN_V4: u32 = {{ qHiddenV4 }}u;
28
  const KV_HIDDEN_V4: u32 = {{ kvHiddenV4 }}u;
 
29
  const Q_HEADS: u32 = {{ qNumHeads }}u;
30
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
 
31
  {% set qHeads = "params.qHeads" if headsFromParams else "Q_HEADS" %}
32
  {% set kvHeads = "params.kvHeads" if headsFromParams else "KV_HEADS" %}
33
  const WG: u32 = {{ tunables.WORKGROUP_SIZE }}u;
 
65
  // calls before their shared partial storage is reused.
66
  {% set combineSubgroups = combineSubgroups is defined and combineSubgroups %}
67
  {% if combineSubgroups %}
68
+ // Cross-subgroup merge that assumes nothing about which invocations share a
69
+ // subgroup or how many subgroups there are: each subgroup's elected lane
70
+ // publishes the subgroup pair in the slot at its OWN invocation index and sets
71
+ // that index's bit in a workgroup bitmask; thread 0 then folds exactly the
72
+ // published slots, in ascending index order (the online (m, d) merge is not
73
+ // float-associative, so the order is fixed), and clears the mask for the next
74
+ // call as it reads it. Workgroup memory starts zeroed, so the mask needs no
75
+ // setup. Same three collectives as a single-subgroup reduce, two barriers.
76
  var<workgroup> partialM: array<f32, WG>;
77
  var<workgroup> partialD: array<f32, WG>;
78
+ var<workgroup> leaderMask: array<atomic<u32>, (WG + 31u) / 32u>;
79
  var<workgroup> combinedMD: vec2<f32>;
80
 
81
  // When the whole workgroup is one subgroup the subgroup reduce already covers
82
+ // it (no barriers, no shared state). `subgroup_size` is the size of the current
83
+ // subgroup and uniform, so the test is exact and may guard the barriers below.
84
  fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
85
  let sgM = subgroupMax(m);
86
  // A lane with no elements contributes d == 0 (exact identity). A +inf
 
91
  if (sgSize == WG) {
92
  return vec2<f32>(sgM, sgD);
93
  }
 
 
 
 
 
 
 
 
 
 
 
 
94
  if (subgroupElect()) {
95
+ partialM[lidx] = sgM;
96
+ partialD[lidx] = sgD;
97
+ atomicOr(&leaderMask[lidx / 32u], 1u << (lidx % 32u));
98
  }
99
  workgroupBarrier();
100
  if (lidx == 0u) {
101
  var accM = -FLT_MAX;
102
  var accD = 0.0;
103
+ for (var w = 0u; w < (WG + 31u) / 32u; w = w + 1u) {
104
+ var bits = atomicExchange(&leaderMask[w], 0u);
105
+ while (bits != 0u) {
106
+ let slot = w * 32u + firstTrailingBit(bits);
107
+ bits = bits & (bits - 1u);
108
+ let mNew = max(accM, partialM[slot]);
109
+ accD = accD * exp_shift(accM, mNew) + partialD[slot] * exp_shift(partialM[slot], mNew);
110
+ accM = mNew;
111
+ }
112
  }
113
  combinedMD = vec2<f32>(accM, accD);
114
  }
 
123
  var<workgroup> partialD: array<f32, {{ mdExtent }}>;
124
  {% if mdStreamed %}
125
 
126
+ // In-place fold of {{ mdStreams }} streams. Input partials occupy
127
+ // partialM/partialD; stream s returns its merged pair in slot s * WG.
 
128
  fn combine_partials_streams(lidx: u32) {
129
  workgroupBarrier();
130
  var stride = WG / 2u;
 
183
  {% endif %}
184
 
185
 
186
+ // An explicit-zero specialization bakes the scale as 0. Otherwise,
187
+ // params.scale == 0 encodes an omitted scale and selects 1/sqrt(headDim).
188
  {% if ATTN_SCALE_DIM is not defined %}{% set ATTN_SCALE_DIM = "HEAD_DIM" %}{% endif %}
189
  fn scale_value() -> f32 {
190
  if (params.scale != 0.0) { return params.scale; }
 
243
  // Causal upper bound: query qi attends only keys 0..qi, so stop after the tile
244
  // containing qi and skip the unattended tail. Non-causal keeps the full kvSeq
245
  // sweep.
 
246
  var keyBoundV = select(params.kvSeq, min(params.kvSeq, qi + 1u), params.isCausal != 0u);
 
 
 
247
  var keyFloor: u32 = 0u;
248
  let keyBound = keyBoundV;
249
  // Start at the WG-aligned tile containing keyFloor (sub-floor lanes mask out).
 
261
  var score = -FLT_MAX;
262
  var m = -FLT_MAX;
263
  var dPart = 0.0;
264
+ var keyAllowed = kj < keyBound && (params.isCausal == 0u || kj <= qi);
265
  if (keyAllowed) {
266
  let kRowV4 = kvBaseV4 + kj * kvTokenStrideV4;
267
  {% if hasMask %}
 
269
  // [q, k] masks set batch/head strides to 0).
270
  let maskIndex = b * params.maskBatchStride + h * params.maskHeadStride + qi * params.maskSeqStride + kj;
271
  {% endif %}
 
 
 
 
 
 
 
 
 
 
 
272
  var acc: f32 = 0.0;
273
  for (var d4: u32 = 0u; d4 < HEAD_DIM_V4; d4 = d4 + 1u) {
274
  acc = acc + dot(q_shared[d4], vec4<f32>(key[kRowV4 + d4]));
 
276
  score = acc * scale;
277
  {% if hasMask %}
278
  score = score + f32(attn_mask[maskIndex]);
 
279
  {% endif %}
280
  m = score;
281
  dPart = select(0.0, 1.0, keyAllowed);
build/webgpu/attn-flash-prefill-cluster.wgsl.jinja CHANGED
@@ -1,4 +1,6 @@
1
- {% set sourceProfile = source.sourceProfile if source.sourceProfile is defined else 0 %}
 
 
2
  {% set QSEQ = "params.seq_len" if sourceProfile == 1 else "params.qSeq" %}
3
  {% set KVSEQ = "(params.past_len + params.seq_len)" if sourceProfile == 1 else "params.kvSeq" %}
4
  {% set IS_CAUSAL = "1u" if sourceProfile == 1 else "params.isCausal" %}
@@ -7,6 +9,7 @@
7
  {% set KEY = "cache_keys" if sourceProfile == 1 else "key" %}
8
  {% set VALUE = "cache_values" if sourceProfile == 1 else "value" %}
9
  {% set OUTPUT = "attn_out" if sourceProfile == 1 else "output" %}
 
10
  {% if useSubgroups is not defined %}{% set useSubgroups = true %}{% endif %}
11
  {% if batchNoSgReduction is not defined %}{% set batchNoSgReduction = false %}{% endif %}
12
  {% if hasMask is not defined %}{% set hasMask = false %}{% endif %}
@@ -27,9 +30,6 @@
27
  {% if useSubgroups %}
28
  enable subgroups;
29
  {% endif %}
30
- {% if usesF16 %}
31
- enable f16;
32
- {% endif %}
33
  {{ env.wgsl.resourceDeclarations }}
34
  {% set ST = "f16" if usesF16 else "f32" %}
35
  // K/V tiles are staged as f16 when this specialization uses f16, reducing
@@ -43,12 +43,14 @@ enable f16;
43
  {% set MASK_ELEMENT = "maskValue" if STAGE_MASK else "attn_mask[maskIndex]" %}
44
  {% set MASK_ADDITIVE = "maskValue" if STAGE_MASK else "f32(attn_mask[maskIndex])" %}
45
  {% set SLICE_COUNT = ((headDimV4 / LPQ) | int) %}
 
 
46
 
47
  // Tiled flash prefill attention with configurable-width query clusters for
48
  // token-major [batch, seq, heads*headDim] attention ops. Each workgroup covers
49
  // TILE_Q queries with one LPQ-lane cluster per query; K/V are staged in
50
- // workgroup memory once per TILE_K tile. Within a cluster, each lane holds q/o
51
- // register slices of HEAD_DIM/LPQ dimensions. Per-lane dot fragments are
52
  // combined with subgroup shuffles when available, or shared-memory reductions
53
  // on compatibility variants. Dot products, online softmax, and weighted-value
54
  // sums accumulate in f32. Optional masks are broadcast-strided; the
@@ -62,7 +64,8 @@ enable f16;
62
  const HEAD_DIM: u32 = {{ headDim }}u;
63
  const HEAD_DIM_V4: u32 = {{ headDimV4 }}u;
64
  const Q_HIDDEN_V4: u32 = {{ qHiddenV4 }}u; // bsh token-major hidden stride
65
- const KV_HIDDEN_V4: u32 = {{ kvHiddenV4 }}u;
 
66
  const Q_HEADS: u32 = {{ qNumHeads }}u;
67
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
68
  const TILE_Q: u32 = {{ TILE_Q }}u;
@@ -70,7 +73,12 @@ const TILE_Q: u32 = {{ TILE_Q }}u;
70
  const LPQ: u32 = {{ LPQ }}u;
71
  const SLICE: u32 = HEAD_DIM / (4u * LPQ); // vec4s per lane slice
72
  const TILE_K: u32 = {{ TILE_K }}u;
 
 
 
 
73
  const WG: u32 = TILE_Q * LPQ;
 
74
  {% if MASK_IS_INT %}
75
  // Key-keep masks in contrib attention use a finite low logit for a rejected
76
  // key. Logical ONNX bool masks use the exclusion sentinel instead, so a fully
@@ -103,8 +111,12 @@ var<workgroup> red: array<f32, WG>;
103
 
104
  {% if ATTN_SCALE_DIM is not defined %}{% set ATTN_SCALE_DIM = "HEAD_DIM" %}{% endif %}
105
  fn scale_value() -> f32 {
 
 
 
106
  if (params.scale != 0.0) { return params.scale; }
107
  return inverseSqrt(f32({{ ATTN_SCALE_DIM }}));
 
108
  }
109
 
110
  {% if quantCacheFormat %}
@@ -171,36 +183,48 @@ fn main(
171
  // portability.
172
  if (sgSize < LPQ || sgSize % LPQ != 0u) { return; }
173
  {% endif %}
 
 
 
174
  let qSub = tid / LPQ;
 
175
  let lane8 = tid % LPQ;
176
- let qIdx = wg.x * TILE_Q + qSub;
177
- let qValid = qIdx < {{ QSEQ }} && h < Q_HEADS;
 
 
178
  let hKv = h / (Q_HEADS / KV_HEADS);
179
  let SCALE = scale_value();
180
 
181
- // Per-thread q slice + output accumulator (SLICE vec4s each) in registers.
182
- let qClamped = min(qIdx, {{ QSEQ }} - 1u);
183
- let qBase4 = (b * {{ QSEQ }} + qClamped) * {{ Q_STRIDE }} + h * HEAD_DIM_V4 + lane8 * SLICE;
 
184
  {% for c in range(SLICE_COUNT) %}
185
- var qr{{ c }} = vec4<f32>({{ QUERY }}[qBase4 + {{ c }}u]);
186
  {% if hasBias %}
187
- qr{{ c }} = qr{{ c }} + load_bias4(h * HEAD_DIM, lane8 * SLICE + {{ c }}u);
188
  {% endif %}
189
- var o{{ c }} = vec4<f32>(0.0);
 
 
 
 
 
190
  {% endfor %}
191
- var m: f32 = NEG_INF;
192
- var l: f32 = 0.0;
193
  {% if useSeqlens %}
194
 
195
  // Resident rows of a windowed cache: the survivors were shifted down to [0, kvActive),
196
  // so query/key DISTANCE is unchanged and every bound below reads as if the cache were
197
  // exactly kvActive long. Rotary is excluded from this path (it would need the absolute
198
  // position, not the cache-relative one), so pastLenForRope keeps the physical length.
199
- let kvActive = min({{ KVSEQ }}, u32(seqlens_k[b]) + 1u);
 
 
 
200
  {% endif %}
201
  // Causal ceiling per query; the key loop runs over the workgroup's union range
202
  // (uniform trip count), masking out-of-range (query, key) pairs.
203
- {% if hasCausal or hasWindow %}
204
  // Upper-left causal and/or sliding-window bounds. Query qIdx sits at
205
  // absolute position p = kvSeq - qSeq + qIdx, so it attends keys [minKj, maxKj):
206
  // the causal ceiling p+1 (clamped to kvSeq for non-square chunks) and, when
@@ -209,36 +233,26 @@ fn main(
209
  // pastLen = kvSeq - qSeq: query tokens are the last qSeq positions of the cache;
210
  // saturate so an out-of-contract qSeq > kvSeq can't underflow u32. Used for the
211
  // sliding window always, and for the causal ceiling only when right-aligned.
212
- {% if hasCausal %}
213
- var maxKjV = select({{ KVA }}, min(qIdx + 1u, {{ KVA }}), {{ IS_CAUSAL }} != 0u);
214
- {% else %}
215
- var maxKjV = {{ KVA }};
216
- {% endif %}
217
- var minKjV: u32 = 0u;
218
  let lastQ = min(wg.x * TILE_Q + TILE_Q - 1u, {{ QSEQ }} - 1u);
219
- {% if hasCausal %}
220
  var wgEndV = select({{ KVA }}, min(lastQ + 1u, {{ KVA }}), {{ IS_CAUSAL }} != 0u);
221
- {% else %}
222
- var wgEndV = {{ KVA }};
223
- {% endif %}
224
  var wgStartV: u32 = 0u;
225
- let maxKj = maxKjV;
226
- let minKj = minKjV;
 
 
227
  let wgEnd = wgEndV;
228
  let wgStart = wgStartV;
229
- {% else %}
230
- let maxKj = {{ KVA }};
231
- let minKj: u32 = 0u;
232
- let wgEnd = {{ KVA }};
233
- let wgStart: u32 = 0u;
234
- {% endif %}
235
  let kvBatch4 = b * {{ KVSEQ }} * KV_HIDDEN_V4 + hKv * HEAD_DIM_V4;
236
 
237
  var kStart: u32 = wgStart;
238
  loop {
239
  if (kStart >= wgEnd) { break; }
240
 
241
- // Cooperative K/V tile load (vec4-coalesced; OOB keys zero-filled).
242
  workgroupBarrier();
243
  for (var i: u32 = tid; i < TILE_K * HEAD_DIM_V4; i = i + WG) {
244
  let slot = i / HEAD_DIM_V4;
@@ -274,8 +288,7 @@ fn main(
274
  }
275
  {% endif %}
276
  workgroupBarrier();
277
- // TILE_K remains a small constant-trip loop: compilers can unroll it without
278
- // multiplying source size, while the q/o register slices remain named.
279
  {% if not useSubgroups and batchNoSgReduction %}
280
  // First publish every key's partial dot without intervening barriers.
281
  var s: array<f32, TILE_K>;
@@ -330,6 +343,67 @@ fn main(
330
  {% endif %}
331
  }
332
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  {% else %}
334
  var s: array<f32, TILE_K>;
335
  for (var kk: u32 = 0u; kk < TILE_K; kk = kk + 1u) {
@@ -392,30 +466,55 @@ fn main(
392
 
393
  // Per-thread online softmax over the tile. s[kk] is reused to hold the
394
  // exponentiated probabilities for the PV accumulation below.
395
- var tileMax: f32 = s[0];
 
396
  for (var kk: u32 = 1u; kk < TILE_K; kk = kk + 1u) {
397
- tileMax = max(tileMax, s[kk]);
398
  }
399
- let newMax = max(m, tileMax);
400
- let corr = select(exp(m - newMax), 0.0, m == NEG_INF);
401
- var pSum: f32 = 0.0;
402
  for (var kk: u32 = 0u; kk < TILE_K; kk = kk + 1u) {
403
- let pk = select(0.0, exp(s[kk] - newMax), s[kk] != NEG_INF);
404
- s[kk] = pk;
405
- pSum = pSum + pk;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  }
407
- l = l * corr + pSum;
408
- m = newMax;
409
- // A boundary tile holds keys outside this query's attended range, and their staged V rows can
410
- // be anything — a dynamic-rows prefill legitimately leaves pad rows unwritten, so stale NaN is
411
- // reachable. An excluded key's weight is exactly 0.0, but 0.0 * NaN is NaN, so the multiply
412
- // cannot be trusted to drop it: the guarded loop selects the V operand away instead. Interior
413
- // tiles keep the unguarded FMA chain; the guard changes nothing there anyway (for finite v,
414
- // acc + 0.0 * v == acc bit-for-bit), it only costs the extra select.
415
- // Scope: only RANGE exclusion (minKj/maxKj) needs this. Every op that binds
416
- // a mask supplies materialized K/V, so a mask-rejected key's exact zero
417
- // weight multiplies finite data. If a masked op ever grows a producer that
418
- // skips rows, that argument dies and the mask path needs the guard.
419
  let tileInterior = kStart >= minKj && kStart + TILE_K <= maxKj;
420
  {% for c in range(SLICE_COUNT) %}
421
  {
@@ -432,31 +531,34 @@ fn main(
432
  o{{ c }} = acc;
433
  }
434
  {% endfor %}
 
435
 
436
  kStart = kStart + TILE_K;
437
  }
438
 
439
- if (qValid) {
440
- let outBase4 = (b * {{ QSEQ }} + qIdx) * Q_HIDDEN_V4 + h * HEAD_DIM_V4 + lane8 * SLICE;
 
 
441
  {% if hasHeadSink %}
442
  // The head sink is a learned logit that competes with the keys but carries
443
  // no value, so it enters the denominator only and the weighted sum above is
444
- // untouched. Renormalizing against max(m, sink) keeps the exponentials in
445
  // range when the sink dominates a fully-masked row.
446
  let sink = f32(head_sink[h]);
447
- let finalM = max(m, sink);
448
- let accScale = exp(m - finalM);
449
- let inv = accScale / (exp(sink - finalM) + l * accScale);
450
  {% else %}
451
- // l == 0 means this query had no probability-bearing key: either its
452
  // causal/window range is empty or its logical bool mask rejects every key.
453
  // Emit 0 rather than 0/0. Other attention paths enforce the same empty-row
454
  // contract by selecting on positive global mass.
455
- let inv = select(0.0, 1.0 / l, l > 0.0);
456
  {% endif %}
457
- {% macro attention_value(c) %}o{{ c }} * inv{% if hasBias %} + load_bias4(2u * {{ Q_HIDDEN }}u + h * HEAD_DIM, lane8 * SLICE + {{ c }}u){% endif %}{% endmacro %}
458
  {% for c in range(SLICE_COUNT) %}
459
- {{ OUTPUT }}[outBase4 + {{ c }}u] = vec4<{{ scalar }}>({{ attention_value(c) }});
460
  {% endfor %}
461
  }
 
462
  }
 
1
+ {% set sourceProfile = sourceProfile if sourceProfile is defined else 0 %}
2
+ {% set scaling = scaling | default("0.0") %}
3
+ {% set qkvStrideV4 = qkvStrideV4 | default(0) %}
4
  {% set QSEQ = "params.seq_len" if sourceProfile == 1 else "params.qSeq" %}
5
  {% set KVSEQ = "(params.past_len + params.seq_len)" if sourceProfile == 1 else "params.kvSeq" %}
6
  {% set IS_CAUSAL = "1u" if sourceProfile == 1 else "params.isCausal" %}
 
9
  {% set KEY = "cache_keys" if sourceProfile == 1 else "key" %}
10
  {% set VALUE = "cache_values" if sourceProfile == 1 else "value" %}
11
  {% set OUTPUT = "attn_out" if sourceProfile == 1 else "output" %}
12
+ {% if sourceProfile == 1 %}{% set ATTN_SCALE_OVERRIDE = scaling %}{% endif %}
13
  {% if useSubgroups is not defined %}{% set useSubgroups = true %}{% endif %}
14
  {% if batchNoSgReduction is not defined %}{% set batchNoSgReduction = false %}{% endif %}
15
  {% if hasMask is not defined %}{% set hasMask = false %}{% endif %}
 
30
  {% if useSubgroups %}
31
  enable subgroups;
32
  {% endif %}
 
 
 
33
  {{ env.wgsl.resourceDeclarations }}
34
  {% set ST = "f16" if usesF16 else "f32" %}
35
  // K/V tiles are staged as f16 when this specialization uses f16, reducing
 
43
  {% set MASK_ELEMENT = "maskValue" if STAGE_MASK else "attn_mask[maskIndex]" %}
44
  {% set MASK_ADDITIVE = "maskValue" if STAGE_MASK else "f32(attn_mask[maskIndex])" %}
45
  {% set SLICE_COUNT = ((headDimV4 / LPQ) | int) %}
46
+ {% set QPL = (2 if (not usesF16 and SLICE_COUNT <= 4 and TILE_K <= 16 and TILE_Q % 2 == 0) else 1) if useSubgroups else 1 %}
47
+ {% macro qn(name, qi) %}{{ name }}{% if qi > 0 %}_q{{ qi }}{% endif %}{% endmacro %}
48
 
49
  // Tiled flash prefill attention with configurable-width query clusters for
50
  // token-major [batch, seq, heads*headDim] attention ops. Each workgroup covers
51
  // TILE_Q queries with one LPQ-lane cluster per query; K/V are staged in
52
+ // workgroup memory once per TILE_K tile. Within a cluster, each lane holds named
53
+ // q/o slices of HEAD_DIM/LPQ dimensions. Per-lane dot fragments are
54
  // combined with subgroup shuffles when available, or shared-memory reductions
55
  // on compatibility variants. Dot products, online softmax, and weighted-value
56
  // sums accumulate in f32. Optional masks are broadcast-strided; the
 
64
  const HEAD_DIM: u32 = {{ headDim }}u;
65
  const HEAD_DIM_V4: u32 = {{ headDimV4 }}u;
66
  const Q_HIDDEN_V4: u32 = {{ qHiddenV4 }}u; // bsh token-major hidden stride
67
+ {% if sourceProfile == 1 %}const QKV_STRIDE_V4: u32 = {{ qkvStrideV4 }}u; // packed [Q; K; V] input row stride
68
+ {% endif %}const KV_HIDDEN_V4: u32 = {{ kvHiddenV4 }}u;
69
  const Q_HEADS: u32 = {{ qNumHeads }}u;
70
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
71
  const TILE_Q: u32 = {{ TILE_Q }}u;
 
73
  const LPQ: u32 = {{ LPQ }}u;
74
  const SLICE: u32 = HEAD_DIM / (4u * LPQ); // vec4s per lane slice
75
  const TILE_K: u32 = {{ TILE_K }}u;
76
+ {% if QPL > 1 %}
77
+ const QPL: u32 = {{ QPL }}u;
78
+ const WG: u32 = (TILE_Q / QPL) * LPQ;
79
+ {% else %}
80
  const WG: u32 = TILE_Q * LPQ;
81
+ {% endif %}
82
  {% if MASK_IS_INT %}
83
  // Key-keep masks in contrib attention use a finite low logit for a rejected
84
  // key. Logical ONNX bool masks use the exclusion sentinel instead, so a fully
 
111
 
112
  {% if ATTN_SCALE_DIM is not defined %}{% set ATTN_SCALE_DIM = "HEAD_DIM" %}{% endif %}
113
  fn scale_value() -> f32 {
114
+ {% if ATTN_SCALE_OVERRIDE is defined %}
115
+ return {{ ATTN_SCALE_OVERRIDE }};
116
+ {% else %}
117
  if (params.scale != 0.0) { return params.scale; }
118
  return inverseSqrt(f32({{ ATTN_SCALE_DIM }}));
119
+ {% endif %}
120
  }
121
 
122
  {% if quantCacheFormat %}
 
183
  // portability.
184
  if (sgSize < LPQ || sgSize % LPQ != 0u) { return; }
185
  {% endif %}
186
+ {% if QPL > 1 %}
187
+ let qSub = (tid / LPQ) * QPL;
188
+ {% else %}
189
  let qSub = tid / LPQ;
190
+ {% endif %}
191
  let lane8 = tid % LPQ;
192
+ {% for qi in range(QPL) %}
193
+ let {{ qn("qIdx", qi) }} = wg.x * TILE_Q + qSub{% if qi > 0 %} + {{ qi }}u{% endif %};
194
+ let {{ qn("qValid", qi) }} = {{ qn("qIdx", qi) }} < {{ QSEQ }} && h < Q_HEADS;
195
+ {% endfor %}
196
  let hKv = h / (Q_HEADS / KV_HEADS);
197
  let SCALE = scale_value();
198
 
199
+ // Per-thread q slice and output accumulator, each stored in SLICE named vec4 values.
200
+ {% for qi in range(QPL) %}
201
+ let {{ qn("qClamped", qi) }} = min({{ qn("qIdx", qi) }}, {{ QSEQ }} - 1u);
202
+ let {{ qn("qBase4", qi) }} = (b * {{ QSEQ }} + {{ qn("qClamped", qi) }}) * {{ Q_STRIDE }} + h * HEAD_DIM_V4 + lane8 * SLICE;
203
  {% for c in range(SLICE_COUNT) %}
204
+ var {{ qn("qr" ~ c, qi) }} = vec4<f32>({{ QUERY }}[{{ qn("qBase4", qi) }} + {{ c }}u]);
205
  {% if hasBias %}
206
+ {{ qn("qr" ~ c, qi) }} = {{ qn("qr" ~ c, qi) }} + load_bias4(h * HEAD_DIM, lane8 * SLICE + {{ c }}u);
207
  {% endif %}
208
+ var {{ qn("o" ~ c, qi) }} = vec4<f32>(0.0);
209
+ {% endfor %}
210
+ {% endfor %}
211
+ {% for qi in range(QPL) %}
212
+ var {{ qn("m", qi) }}: f32 = NEG_INF;
213
+ var {{ qn("l", qi) }}: f32 = 0.0;
214
  {% endfor %}
 
 
215
  {% if useSeqlens %}
216
 
217
  // Resident rows of a windowed cache: the survivors were shifted down to [0, kvActive),
218
  // so query/key DISTANCE is unchanged and every bound below reads as if the cache were
219
  // exactly kvActive long. Rotary is excluded from this path (it would need the absolute
220
  // position, not the cache-relative one), so pastLenForRope keeps the physical length.
221
+ // Clamping below by the query count matches the cache-update passes and the scalar
222
+ // path: a right-padded batch (seqlens_k[b]+1 < qSeq) still appends its whole chunk,
223
+ // so its queries score against all of it.
224
+ let kvActive = min({{ KVSEQ }}, max({{ QSEQ }}, u32(seqlens_k[b]) + 1u));
225
  {% endif %}
226
  // Causal ceiling per query; the key loop runs over the workgroup's union range
227
  // (uniform trip count), masking out-of-range (query, key) pairs.
 
228
  // Upper-left causal and/or sliding-window bounds. Query qIdx sits at
229
  // absolute position p = kvSeq - qSeq + qIdx, so it attends keys [minKj, maxKj):
230
  // the causal ceiling p+1 (clamped to kvSeq for non-square chunks) and, when
 
233
  // pastLen = kvSeq - qSeq: query tokens are the last qSeq positions of the cache;
234
  // saturate so an out-of-contract qSeq > kvSeq can't underflow u32. Used for the
235
  // sliding window always, and for the causal ceiling only when right-aligned.
236
+ {% for qi in range(QPL) %}
237
+ var {{ qn("maxKjV", qi) }} = select({{ KVA }}, min({{ qn("qIdx", qi) }} + 1u, {{ KVA }}), {{ IS_CAUSAL }} != 0u);
238
+ var {{ qn("minKjV", qi) }}: u32 = 0u;
239
+ {% endfor %}
 
 
240
  let lastQ = min(wg.x * TILE_Q + TILE_Q - 1u, {{ QSEQ }} - 1u);
 
241
  var wgEndV = select({{ KVA }}, min(lastQ + 1u, {{ KVA }}), {{ IS_CAUSAL }} != 0u);
 
 
 
242
  var wgStartV: u32 = 0u;
243
+ {% for qi in range(QPL) %}
244
+ let {{ qn("maxKj", qi) }} = {{ qn("maxKjV", qi) }};
245
+ let {{ qn("minKj", qi) }} = {{ qn("minKjV", qi) }};
246
+ {% endfor %}
247
  let wgEnd = wgEndV;
248
  let wgStart = wgStartV;
 
 
 
 
 
 
249
  let kvBatch4 = b * {{ KVSEQ }} * KV_HIDDEN_V4 + hKv * HEAD_DIM_V4;
250
 
251
  var kStart: u32 = wgStart;
252
  loop {
253
  if (kStart >= wgEnd) { break; }
254
 
255
+ // Cooperative K/V tile load with out-of-bounds keys zero-filled.
256
  workgroupBarrier();
257
  for (var i: u32 = tid; i < TILE_K * HEAD_DIM_V4; i = i + WG) {
258
  let slot = i / HEAD_DIM_V4;
 
288
  }
289
  {% endif %}
290
  workgroupBarrier();
291
+ // TILE_K is a small shader constant; the loop updates the named q/o slices in place.
 
292
  {% if not useSubgroups and batchNoSgReduction %}
293
  // First publish every key's partial dot without intervening barriers.
294
  var s: array<f32, TILE_K>;
 
343
  {% endif %}
344
  }
345
  }
346
+ {% elif QPL > 1 %}
347
+ {% for qi in range(QPL) %}
348
+ var {{ qn("s", qi) }}: array<f32, TILE_K>;
349
+ {% endfor %}
350
+ for (var kk: u32 = 0u; kk < TILE_K; kk = kk + 1u) {
351
+ let kj = kStart + kk;
352
+ {% for qi in range(QPL) %}
353
+ {{ qn("s", qi) }}[kk] = NEG_INF;
354
+ var {{ qn("part", qi) }}: f32 = 0.0;
355
+ {% endfor %}
356
+ let kb = kk * HEAD_DIM_V4 + lane8 * SLICE;
357
+ {% for c in range(SLICE_COUNT) %}
358
+ let kw{{ c }} = vec4<f32>(k_tile[kb + {{ c }}u]);
359
+ {% for qi in range(QPL) %}
360
+ {{ qn("part", qi) }} = {{ qn("part", qi) }} + dot({{ qn("qr" ~ c, qi) }}, kw{{ c }});
361
+ {% endfor %}
362
+ {% endfor %}
363
+ // One butterfly carries every query's partial: the shuffles cost the same
364
+ // whether the payload is one score or QPL of them.
365
+ var partv = vec{{ QPL }}<f32>({% for qi in range(QPL) %}{{ qn("part", qi) }}{% if not loop.last %}, {% endif %}{% endfor %});
366
+ partv = partv + subgroupShuffleXor(partv, 1u);
367
+ {% for shift in [2, 4] %}
368
+ {% if LPQ > shift %}
369
+ partv = partv + subgroupShuffleXor(partv, {{ shift }}u);
370
+ {% endif %}
371
+ {% endfor %}
372
+ {% for qi in range(QPL) %}
373
+ {
374
+ let sc = partv[{{ qi }}];
375
+ if (kj >= {{ qn("minKj", qi) }} && kj < {{ qn("maxKj", qi) }}) {
376
+ {% if hasMask %}
377
+ {% if STAGE_MASK %}
378
+ let maskValue = mask_tile[(qSub + {{ qi }}u) * TILE_K + kk];
379
+ {% else %}
380
+ // Broadcast-strided mask address (a 0 stride collapses that axis; rank-2
381
+ // [q, k] masks set batch/head strides to 0). {{ qn("qClamped", qi) }} keeps the seq index
382
+ // in-bounds for padding queries in the last tile (their output is dropped).
383
+ let maskIndex = b * params.maskBatchStride + h * params.maskHeadStride + {{ qn("qClamped", qi) }} * params.maskSeqStride + kj;
384
+ {% endif %}
385
+ {% if maskIsKeyKeep %}
386
+ // A broadcast key mask uses 1 for a retained key and 0 for padding.
387
+ {{ qn("s", qi) }}[kk] = {{ score_expr("sc") }} + (1.0 - f32({{ MASK_ELEMENT }})) * MASK_NEG;
388
+ {% elif maskIsBool %}
389
+ // Logical bool: a rejected key contributes no softmax mass. Leaving
390
+ // the initialized NEG_INF sentinel in place makes a fully masked row
391
+ // land on the zero-denominator output guard below.
392
+ if ({{ MASK_ELEMENT }} != 0u) {
393
+ {{ qn("s", qi) }}[kk] = {{ score_expr("sc") }};
394
+ } else {
395
+ {{ qn("s", qi) }}[kk] = MASK_NEG;
396
+ }
397
+ {% else %}
398
+ {{ qn("s", qi) }}[kk] = {{ score_expr("sc") }} + {{ MASK_ADDITIVE }};
399
+ {% endif %}
400
+ {% else %}
401
+ {{ qn("s", qi) }}[kk] = {{ score_expr("sc") }};
402
+ {% endif %}
403
+ }
404
+ }
405
+ {% endfor %}
406
+ }
407
  {% else %}
408
  var s: array<f32, TILE_K>;
409
  for (var kk: u32 = 0u; kk < TILE_K; kk = kk + 1u) {
 
466
 
467
  // Per-thread online softmax over the tile. s[kk] is reused to hold the
468
  // exponentiated probabilities for the PV accumulation below.
469
+ {% for qi in range(QPL) %}
470
+ var {{ qn("tileMax", qi) }}: f32 = {{ qn("s", qi) }}[0];
471
  for (var kk: u32 = 1u; kk < TILE_K; kk = kk + 1u) {
472
+ {{ qn("tileMax", qi) }} = max({{ qn("tileMax", qi) }}, {{ qn("s", qi) }}[kk]);
473
  }
474
+ let {{ qn("newMax", qi) }} = max({{ qn("m", qi) }}, {{ qn("tileMax", qi) }});
475
+ let {{ qn("corr", qi) }} = select(exp({{ qn("m", qi) }} - {{ qn("newMax", qi) }}), 0.0, {{ qn("m", qi) }} == NEG_INF);
476
+ var {{ qn("pSum", qi) }}: f32 = 0.0;
477
  for (var kk: u32 = 0u; kk < TILE_K; kk = kk + 1u) {
478
+ let pk = select(0.0, exp({{ qn("s", qi) }}[kk] - {{ qn("newMax", qi) }}), {{ qn("s", qi) }}[kk] != NEG_INF);
479
+ {{ qn("s", qi) }}[kk] = pk;
480
+ {{ qn("pSum", qi) }} = {{ qn("pSum", qi) }} + pk;
481
+ }
482
+ {{ qn("l", qi) }} = {{ qn("l", qi) }} * {{ qn("corr", qi) }} + {{ qn("pSum", qi) }};
483
+ {{ qn("m", qi) }} = {{ qn("newMax", qi) }};
484
+ {% endfor %}
485
+ // A boundary tile can address V rows outside a query's attended range, and
486
+ // a dynamic-row producer may leave those rows unwritten. Because 0 * NaN is
487
+ // NaN, the guarded loop selects the V operand away for range-excluded keys.
488
+ // Interior tiles use the unguarded FMA chain. Mask exclusion applies to
489
+ // materialized V rows and does not require this range guard.
490
+ {% if QPL > 1 %}
491
+ let tileInterior = {% for qi in range(QPL) %}(kStart >= {{ qn("minKj", qi) }} && kStart + TILE_K <= {{ qn("maxKj", qi) }}){% if not loop.last %} && {% endif %}{% endfor %};
492
+ {% for c in range(SLICE_COUNT) %}
493
+ {
494
+ {% for qi in range(QPL) %}
495
+ var {{ qn("acc", qi) }} = {{ qn("o" ~ c, qi) }} * {{ qn("corr", qi) }};
496
+ {% endfor %}
497
+ if (tileInterior) {
498
+ for (var kk: u32 = 0u; kk < TILE_K; kk = kk + 1u) {
499
+ let vw = vec4<f32>(v_tile[kk * HEAD_DIM_V4 + lane8 * SLICE + {{ c }}u]);
500
+ {% for qi in range(QPL) %}
501
+ {{ qn("acc", qi) }} = {{ qn("acc", qi) }} + {{ qn("s", qi) }}[kk] * vw;
502
+ {% endfor %}
503
+ }
504
+ } else {
505
+ for (var kk: u32 = 0u; kk < TILE_K; kk = kk + 1u) {
506
+ let vw = vec4<f32>(v_tile[kk * HEAD_DIM_V4 + lane8 * SLICE + {{ c }}u]);
507
+ {% for qi in range(QPL) %}
508
+ {{ qn("acc", qi) }} = {{ qn("acc", qi) }} + {{ qn("s", qi) }}[kk] * select(vec4<f32>(), vw, {{ qn("s", qi) }}[kk] != 0.0);
509
+ {% endfor %}
510
+ }
511
+ }
512
+ {% for qi in range(QPL) %}
513
+ {{ qn("o" ~ c, qi) }} = {{ qn("acc", qi) }};
514
+ {% endfor %}
515
  }
516
+ {% endfor %}
517
+ {% else %}
 
 
 
 
 
 
 
 
 
 
518
  let tileInterior = kStart >= minKj && kStart + TILE_K <= maxKj;
519
  {% for c in range(SLICE_COUNT) %}
520
  {
 
531
  o{{ c }} = acc;
532
  }
533
  {% endfor %}
534
+ {% endif %}
535
 
536
  kStart = kStart + TILE_K;
537
  }
538
 
539
+ {% macro attention_value(c, qi) %}{{ qn("o" ~ c, qi) }} * inv{% if hasBias %} + load_bias4(2u * {{ Q_HIDDEN }}u + h * HEAD_DIM, lane8 * SLICE + {{ c }}u){% endif %}{% endmacro %}
540
+ {% for qi in range(QPL) %}
541
+ if ({{ qn("qValid", qi) }}) {
542
+ let outBase4 = (b * {{ QSEQ }} + {{ qn("qIdx", qi) }}) * Q_HIDDEN_V4 + h * HEAD_DIM_V4 + lane8 * SLICE;
543
  {% if hasHeadSink %}
544
  // The head sink is a learned logit that competes with the keys but carries
545
  // no value, so it enters the denominator only and the weighted sum above is
546
+ // untouched. Renormalizing against max({{ qn("m", qi) }}, sink) keeps the exponentials in
547
  // range when the sink dominates a fully-masked row.
548
  let sink = f32(head_sink[h]);
549
+ let finalM = max({{ qn("m", qi) }}, sink);
550
+ let accScale = exp({{ qn("m", qi) }} - finalM);
551
+ let inv = accScale / (exp(sink - finalM) + {{ qn("l", qi) }} * accScale);
552
  {% else %}
553
+ // {{ qn("l", qi) }} == 0 means this query had no probability-bearing key: either its
554
  // causal/window range is empty or its logical bool mask rejects every key.
555
  // Emit 0 rather than 0/0. Other attention paths enforce the same empty-row
556
  // contract by selecting on positive global mass.
557
+ let inv = select(0.0, 1.0 / {{ qn("l", qi) }}, {{ qn("l", qi) }} > 0.0);
558
  {% endif %}
 
559
  {% for c in range(SLICE_COUNT) %}
560
+ {{ OUTPUT }}[outBase4 + {{ c }}u] = vec4<{{ scalar }}>({{ attention_value(c, qi) }});
561
  {% endfor %}
562
  }
563
+ {% endfor %}
564
  }
build/webgpu/attn-flash-q32-broadcast.wgsl.jinja CHANGED
@@ -9,12 +9,9 @@
9
  // cancels in softmax. Arbitrary attention masks use the fallback routes.
10
  {% set ST = "f16" if usesF16 else "f32" %}
11
  {% set components = ["x", "y", "z", "w"] %}
12
- {% set USE_SUBGROUPS = source.useSubgroups if source.useSubgroups is defined else true %}
13
  {% set Q_STEP = qStep if qStep is defined else 32 %}
14
  {% set CAUSAL = false if (hasCausal is defined and not hasCausal) else true %}
15
- {% if usesF16 %}
16
- enable f16;
17
- {% endif %}
18
  {% if USE_SUBGROUPS %}
19
  enable subgroups;
20
  {% endif %}
 
9
  // cancels in softmax. Arbitrary attention masks use the fallback routes.
10
  {% set ST = "f16" if usesF16 else "f32" %}
11
  {% set components = ["x", "y", "z", "w"] %}
12
+ {% set USE_SUBGROUPS = useSubgroups if useSubgroups is defined else true %}
13
  {% set Q_STEP = qStep if qStep is defined else 32 %}
14
  {% set CAUSAL = false if (hasCausal is defined and not hasCausal) else true %}
 
 
 
15
  {% if USE_SUBGROUPS %}
16
  enable subgroups;
17
  {% endif %}
build/webgpu/attn-materialized-apply-f32.wgsl.jinja CHANGED
@@ -1,16 +1,14 @@
1
  {{ env.wgsl.resourceDeclarations }}
2
 
3
- // Register-blocked normalized-scores @ V GEMM for BHSD Attention and BSH MHA.
4
  // Score rows are head-major regardless of the external tensor layout.
5
  //
6
  // V is stored row-major in the head dimension, so its tile groups four of those
7
  // per vector word and the micro-tile accumulates four output columns at a time.
8
- // The score tile groups four KEYS per word -- the axis this GEMM reduces over --
9
- // so one step over four keys reads TM + TN vector words for TM * TN * 4 fused
10
- // multiply-adds. Grouping the reduction axis is what keeps the read-per-FMA
11
- // ratio at the score GEMM's: a key-scalar tile costs three times as many
12
- // workgroup reads for the same arithmetic.
13
- {% set FUSED_SOFTMAX = source.fusedSoftmax is defined and source.fusedSoftmax %}
14
 
15
  const Q_HEADS: u32 = {{ qNumHeads }}u;
16
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
 
1
  {{ env.wgsl.resourceDeclarations }}
2
 
3
+ // Register-blocked normalized-scores @ V GEMM with BHSD and BSH specializations.
4
  // Score rows are head-major regardless of the external tensor layout.
5
  //
6
  // V is stored row-major in the head dimension, so its tile groups four of those
7
  // per vector word and the micro-tile accumulates four output columns at a time.
8
+ // The score tile groups four keys per word along the reduction axis. One step
9
+ // over four keys reads TM + TN vector words for TM * TN * 4 fused
10
+ // multiply-adds.
11
+ {% set FUSED_SOFTMAX = fusedSoftmax is defined and fusedSoftmax %}
 
 
12
 
13
  const Q_HEADS: u32 = {{ qNumHeads }}u;
14
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
build/webgpu/attn-materialized-rowstats-combine-f32.wgsl.jinja CHANGED
@@ -12,7 +12,7 @@
12
  // `maxOnly` means the producer published only a row max per slot because
13
  // computing the denominator there would double the exp count. Fold maxima and
14
  // leave the denominator to the apply pass, which sees every row element anyway.
15
- {% set MAX_ONLY = source.maxOnly is defined and source.maxOnly %}
16
  {{ env.wgsl.resourceDeclarations }}
17
 
18
  const SLOTS: u32 = {{ statSlots }}u;
@@ -52,10 +52,9 @@ fn exp_shift(value: f32, maxValue: f32) -> f32 {
52
 
53
  @compute @workgroup_size(WG, 1, 1)
54
  fn main(
55
- @builtin(global_invocation_id) gid: vec3<u32>,
56
- @builtin(num_workgroups) nwg: vec3<u32>
57
  ) {
58
- let row = gid.x + gid.y * nwg.x * WG;
59
  if (row >= params.rows) { return; }
60
 
61
  // `row` already runs over (batch, head, query) together, and the partial
 
12
  // `maxOnly` means the producer published only a row max per slot because
13
  // computing the denominator there would double the exp count. Fold maxima and
14
  // leave the denominator to the apply pass, which sees every row element anyway.
15
+ {% set MAX_ONLY = maxOnly is defined and maxOnly %}
16
  {{ env.wgsl.resourceDeclarations }}
17
 
18
  const SLOTS: u32 = {{ statSlots }}u;
 
52
 
53
  @compute @workgroup_size(WG, 1, 1)
54
  fn main(
55
+ @builtin(global_invocation_id) gid: vec3<u32>
 
56
  ) {
57
+ let row = gid.x + gid.y * {{ DISPATCH_FOLD_WIDTH }}u * WG;
58
  if (row >= params.rows) { return; }
59
 
60
  // `row` already runs over (batch, head, query) together, and the partial
build/webgpu/attn-materialized-score-f32.wgsl.jinja CHANGED
@@ -1,21 +1,20 @@
1
  {{ env.wgsl.resourceDeclarations }}
2
 
3
- // f32 prefill score GEMM for rank-4 Attention (BHSD) and packed MHA (BSH).
4
- // Materializing the score matrix is reserved for large, compute-bound prefills:
5
- // it turns the shuffle/reduction-heavy online QK loop into a regular 64x64
6
- // register-blocked GEMM. Q and K are both stored row-major in the head
7
  // dimension — the axis this GEMM reduces over — so both tiles group four of
8
  // its values per vector word, every staging read is contiguous, and the
9
  // micro-tile accumulates through dot(): one step reads TM + TN vector words
10
  // for TM * TN * 4 fused multiply-adds. K keeps a one-word shared stride
11
- // padding, which avoids the worst transpose-read bank conflicts.
 
12
  {% if scalarAccumulators is not defined %}{% set scalarAccumulators = false %}{% endif %}
13
  {% if maskIsKeyKeep is not defined %}{% set maskIsKeyKeep = false %}{% endif %}
14
  {% if scalarAccumulators %}
15
  {% set TM_JINJA = (materializedQueryTile / materializedWorkgroupDim)|int %}
16
  {% set TN_JINJA = (materializedKeyTile / materializedWorkgroupDim)|int %}
17
  {% endif %}
18
- {% set EMIT_ROW_STATS = source.emitRowStats is defined and source.emitRowStats %}
19
 
20
  const Q_HEADS: u32 = {{ qNumHeads }}u;
21
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
@@ -109,8 +108,7 @@ fn main(
109
  let li = lid.y * WG_DIM + lid.x;
110
 
111
  {% if scalarAccumulators %}
112
- // Keep every register-tile cell statically addressable. A dynamically indexed
113
- // private array may fail to scalarize and spill hot GEMM accumulators to memory.
114
  {% for i in range(TM_JINJA) %}
115
  {% for j in range(TN_JINJA) %}
116
  var acc{{ i }}_{{ j }}: f32 = 0.0;
 
1
  {{ env.wgsl.resourceDeclarations }}
2
 
3
+ // f32 prefill score GEMM with BHSD and packed BSH specializations. It computes
4
+ // the score matrix as a 64x64 register-blocked product. Q and K are both stored row-major in the head
 
 
5
  // dimension — the axis this GEMM reduces over — so both tiles group four of
6
  // its values per vector word, every staging read is contiguous, and the
7
  // micro-tile accumulates through dot(): one step reads TM + TN vector words
8
  // for TM * TN * 4 fused multiply-adds. K keeps a one-word shared stride
9
+ // padding, which changes the transposed workgroup-memory stride and reduces
10
+ // bank-conflict risk on banked implementations.
11
  {% if scalarAccumulators is not defined %}{% set scalarAccumulators = false %}{% endif %}
12
  {% if maskIsKeyKeep is not defined %}{% set maskIsKeyKeep = false %}{% endif %}
13
  {% if scalarAccumulators %}
14
  {% set TM_JINJA = (materializedQueryTile / materializedWorkgroupDim)|int %}
15
  {% set TN_JINJA = (materializedKeyTile / materializedWorkgroupDim)|int %}
16
  {% endif %}
17
+ {% set EMIT_ROW_STATS = emitRowStats is defined and emitRowStats %}
18
 
19
  const Q_HEADS: u32 = {{ qNumHeads }}u;
20
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
 
108
  let li = lid.y * WG_DIM + lid.x;
109
 
110
  {% if scalarAccumulators %}
111
+ // Keep every register-tile cell statically addressable.
 
112
  {% for i in range(TM_JINJA) %}
113
  {% for j in range(TN_JINJA) %}
114
  var acc{{ i }}_{{ j }}: f32 = 0.0;
build/webgpu/attn-materialized-sgmat-f32.wgsl.jinja CHANGED
@@ -9,17 +9,18 @@ enable subgroup_size_control;
9
  enable chromium_experimental_subgroup_matrix;
10
  diagnostic(off, chromium.subgroup_matrix_uniformity);
11
 
 
12
  {{ env.wgsl.resourceDeclarations }}
13
 
14
- {% set layout = source.layout | default("bsh") %}
15
  {% set headMajor = layout == "bhsd" %}
16
- {% set kvHeadMajor = (source.kvLayout | default(layout)) == "bhsd" %}
17
- {% macro q_index(row, d) %}(b * params.qSeq + {{ row }}) * HIDDEN + h * HEAD_DIM + {{ d }}{% endmacro %}
18
- {% macro kv_index(seq, d) %}(b * params.kvSeq + {{ seq }}) * KV_HIDDEN + h_kv * HEAD_DIM + {{ d }}{% endmacro %}
19
  {% set OUT_ROW_STRIDE = "HEAD_DIM" if headMajor else "HIDDEN" %}
20
  {% set KV_ROW_STRIDE = "HEAD_DIM" if kvHeadMajor else "KV_HIDDEN" %}
21
- {% set scorePhase = source.phase == "score" %}
22
- {% set FUSED_SOFTMAX = source.fusedSoftmax is defined and source.fusedSoftmax %}
23
  {% set PRIVATE_ROW_STATS = FUSED_SOFTMAX and (materializedSgmatPrivateRowStats is defined and materializedSgmatPrivateRowStats) %}
24
  {% macro score_value(index, guard) %}
25
  {% if FUSED_SOFTMAX %}
@@ -44,7 +45,7 @@ select(0.0, scores[{{ index }}], {{ guard[1] }})
44
  {% set SUBGROUP_COUNT = SUBGROUP_ROWS * SUBGROUP_COLS %}
45
  {% set WORKGROUP_THREADS = SUBGROUP_COUNT * 32 %}
46
  {% if hasBias is not defined %}{% set hasBias = false %}{% endif %}
47
- {% set EMIT_ROW_STATS = source.emitRowStats is defined and source.emitRowStats %}
48
  {% set DIRECT_SCORE_STORE = materializedSgmatDirectScoreStore and not EMIT_ROW_STATS %}
49
  {% set DIRECT_APPLY_STORE = materializedSgmatDirectApplyStore and not hasBias and MT == "f32" %}
50
  {% set DIRECT_OUTPUT_STORE = DIRECT_SCORE_STORE if scorePhase else DIRECT_APPLY_STORE %}
@@ -63,7 +64,9 @@ const KV_HEADS: u32 = {{ kvNumHeads }}u;
63
  const KV_HEADS: u32 = HEADS;
64
  {% endif %}
65
  const HEAD_DIM: u32 = {{ headDim }}u;
 
66
  const HIDDEN: u32 = {{ qHidden }}u;
 
67
  {% if hasBias %}
68
  /* Packed [Q; K; V] bias. Q folds into the query tile before the GEMM. K is
69
  * omitted: expanding (q + bq).(k + bk) leaves a (q + bq).bk term that is
@@ -71,9 +74,11 @@ const HIDDEN: u32 = {{ qHidden }}u;
71
  * constant. V is token-independent, so sum_k p[k] * bv = bv after
72
  * normalization; it is added after the apply GEMM. */
73
  {% endif %}
 
74
  // Packed K/V rows span KV_HEADS heads, so they are narrower than the query
75
  // row whenever queries are grouped. Q and the output keep HIDDEN.
76
  const KV_HIDDEN: u32 = KV_HEADS * HEAD_DIM;
 
77
  {% if EMIT_ROW_STATS %}
78
  const STAT_SLOTS: u32 = {{ statSlots }}u;
79
  {% endif %}
@@ -191,7 +196,7 @@ fn main(
191
  {% endif %}
192
  {% endif %}
193
  for (var k_base = 0u; k_base < inner; k_base += TILE_K) {
194
- {% if source.phase == "apply" and not FUSED_SOFTMAX and MT == "f32" %}
195
  // Full interior PV tiles can be loaded directly from storage. Query,
196
  // reduction, and output-dimension tails use the guarded shared path below.
197
  if (
@@ -229,10 +234,8 @@ fn main(
229
  {% endif %}
230
 
231
  {% if TILE_M_VALUE == 64 and TILE_N_VALUE == 64 and TILE_K_VALUE == 32 and WORKGROUP_THREADS == 256 %}
232
- // BM64/BN64/WG256 is the high-throughput geometry on wide devices. Four
233
- // adjacent threads each own one contiguous eight-element segment of a
234
- // row, preserving the original coalesced load schedule while retaining
235
- // the generalized K-tail guards.
236
  let a_row = li / 4u;
237
  let a_col = (li % 4u) * 8u;
238
  for (var i = 0u; i < 8u; i++) {
@@ -304,10 +307,10 @@ fn main(
304
  let loaded =
305
  {{ "f16(" if MT == "f16" else "" }}{{ score_value("score_base + row * params.kvSeq + k", ["tile_row", "row < params.qSeq && k < params.kvSeq"]) }}{{ ")" if MT == "f16" else "" }};
306
  {% else %}
307
- var loaded = {{ "0.0h" if MT == "f16" else "0.0" }};
308
  if (row < params.qSeq && k < params.kvSeq) {
309
  let score_base = (b * HEADS + h) * params.qSeq * params.kvSeq;
310
- loaded = {{ "f16(" if MT == "f16" else "" }}scores[score_base + row * params.kvSeq + k]{{ ")" if MT == "f16" else "" }};
311
  }
312
  {% endif %}
313
  tile_A[idx] = loaded;
 
9
  enable chromium_experimental_subgroup_matrix;
10
  diagnostic(off, chromium.subgroup_matrix_uniformity);
11
 
12
+
13
  {{ env.wgsl.resourceDeclarations }}
14
 
15
+ {% set layout = layout | default("bsh") %}
16
  {% set headMajor = layout == "bhsd" %}
17
+ {% set kvHeadMajor = (kvLayout | default(layout)) == "bhsd" %}
18
+ {% macro q_index(row, d) %}{% if headMajor %}((b * HEADS + h) * params.qSeq + {{ row }}) * HEAD_DIM + {{ d }}{% else %}(b * params.qSeq + {{ row }}) * HIDDEN + h * HEAD_DIM + {{ d }}{% endif %}{% endmacro %}
19
+ {% macro kv_index(seq, d) %}{% if kvHeadMajor %}((b * KV_HEADS + h_kv) * params.kvSeq + {{ seq }}) * HEAD_DIM + {{ d }}{% else %}(b * params.kvSeq + {{ seq }}) * KV_HIDDEN + h_kv * HEAD_DIM + {{ d }}{% endif %}{% endmacro %}
20
  {% set OUT_ROW_STRIDE = "HEAD_DIM" if headMajor else "HIDDEN" %}
21
  {% set KV_ROW_STRIDE = "HEAD_DIM" if kvHeadMajor else "KV_HIDDEN" %}
22
+ {% set scorePhase = phase == "score" %}
23
+ {% set FUSED_SOFTMAX = fusedSoftmax is defined and fusedSoftmax %}
24
  {% set PRIVATE_ROW_STATS = FUSED_SOFTMAX and (materializedSgmatPrivateRowStats is defined and materializedSgmatPrivateRowStats) %}
25
  {% macro score_value(index, guard) %}
26
  {% if FUSED_SOFTMAX %}
 
45
  {% set SUBGROUP_COUNT = SUBGROUP_ROWS * SUBGROUP_COLS %}
46
  {% set WORKGROUP_THREADS = SUBGROUP_COUNT * 32 %}
47
  {% if hasBias is not defined %}{% set hasBias = false %}{% endif %}
48
+ {% set EMIT_ROW_STATS = emitRowStats is defined and emitRowStats %}
49
  {% set DIRECT_SCORE_STORE = materializedSgmatDirectScoreStore and not EMIT_ROW_STATS %}
50
  {% set DIRECT_APPLY_STORE = materializedSgmatDirectApplyStore and not hasBias and MT == "f32" %}
51
  {% set DIRECT_OUTPUT_STORE = DIRECT_SCORE_STORE if scorePhase else DIRECT_APPLY_STORE %}
 
64
  const KV_HEADS: u32 = HEADS;
65
  {% endif %}
66
  const HEAD_DIM: u32 = {{ headDim }}u;
67
+ {% if layout != "bhsd" or hasBias %}
68
  const HIDDEN: u32 = {{ qHidden }}u;
69
+ {% endif %}
70
  {% if hasBias %}
71
  /* Packed [Q; K; V] bias. Q folds into the query tile before the GEMM. K is
72
  * omitted: expanding (q + bq).(k + bk) leaves a (q + bq).bk term that is
 
74
  * constant. V is token-independent, so sum_k p[k] * bv = bv after
75
  * normalization; it is added after the apply GEMM. */
76
  {% endif %}
77
+ {% if not kvHeadMajor %}
78
  // Packed K/V rows span KV_HEADS heads, so they are narrower than the query
79
  // row whenever queries are grouped. Q and the output keep HIDDEN.
80
  const KV_HIDDEN: u32 = KV_HEADS * HEAD_DIM;
81
+ {% endif %}
82
  {% if EMIT_ROW_STATS %}
83
  const STAT_SLOTS: u32 = {{ statSlots }}u;
84
  {% endif %}
 
196
  {% endif %}
197
  {% endif %}
198
  for (var k_base = 0u; k_base < inner; k_base += TILE_K) {
199
+ {% if phase == "apply" and not FUSED_SOFTMAX and MT == "f32" %}
200
  // Full interior PV tiles can be loaded directly from storage. Query,
201
  // reduction, and output-dimension tails use the guarded shared path below.
202
  if (
 
234
  {% endif %}
235
 
236
  {% if TILE_M_VALUE == 64 and TILE_N_VALUE == 64 and TILE_K_VALUE == 32 and WORKGROUP_THREADS == 256 %}
237
+ // In the BM64/BN64/BK32/WG256 geometry, four adjacent threads each own one
238
+ // contiguous eight-element segment of a row. K-tail guards remain active.
 
 
239
  let a_row = li / 4u;
240
  let a_col = (li % 4u) * 8u;
241
  for (var i = 0u; i < 8u; i++) {
 
307
  let loaded =
308
  {{ "f16(" if MT == "f16" else "" }}{{ score_value("score_base + row * params.kvSeq + k", ["tile_row", "row < params.qSeq && k < params.kvSeq"]) }}{{ ")" if MT == "f16" else "" }};
309
  {% else %}
310
+ var loaded = 0.0;
311
  if (row < params.qSeq && k < params.kvSeq) {
312
  let score_base = (b * HEADS + h) * params.qSeq * params.kvSeq;
313
+ loaded = scores[score_base + row * params.kvSeq + k];
314
  }
315
  {% endif %}
316
  tile_A[idx] = loaded;
build/webgpu/attn-materialized-softmax-f32.wgsl.jinja CHANGED
@@ -1,4 +1,4 @@
1
- {% set CACHE_VEC4 = source.cacheVec4 if source.cacheVec4 is defined else false %}
2
  {% set CAUSAL_ROWS = causalRows is defined and causalRows %}
3
  {% set SCALE_IN = scaleInSoftmax is defined and scaleInSoftmax %}
4
  {% set RUNTIME_COLS = colsFromParams is defined and colsFromParams %}
@@ -50,20 +50,22 @@ fn exp_shift(value: f32, maxValue: f32) -> f32 {
50
  // calls before their shared partial storage is reused.
51
  {% set combineSubgroups = combineSubgroups is defined and combineSubgroups %}
52
  {% if combineSubgroups %}
53
- // Per-subgroup partials are published into a deterministic slot: the subgroup's
54
- // ordinal index within the workgroup (lidx / sgSize). The online (m, d) merge
55
- // is not float-associative, so thread 0 must fold partials in a fixed order.
56
- // Subgroups partition a workgroup into contiguous ordinal ranges on supported
57
- // backends, so the ordinal slot is unique per subgroup and every slot in
58
- // [0, subgroupCount) is written (each subgroup elects one leader).
59
- // Sized for the worst case of one partial per invocation.
 
60
  var<workgroup> partialM: array<f32, WG>;
61
  var<workgroup> partialD: array<f32, WG>;
 
62
  var<workgroup> combinedMD: vec2<f32>;
63
 
64
  // When the whole workgroup is one subgroup the subgroup reduce already covers
65
- // it (no barriers, no shared state); otherwise subgroup leaders publish
66
- // partials through shared memory and thread 0 folds them in ordinal order.
67
  fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
68
  let sgM = subgroupMax(m);
69
  // A lane with no elements contributes d == 0 (exact identity). A +inf
@@ -74,31 +76,24 @@ fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
74
  if (sgSize == WG) {
75
  return vec2<f32>(sgM, sgD);
76
  }
77
- let subgroupCount = (WG + sgSize - 1u) / sgSize;
78
- // Pre-seed every fold slot with the (max, denom) identity. The fold below reads a
79
- // fixed subgroupCount slots in ordinal order (for determinism), but a slot whose
80
- // subgroup elects no leader this call — e.g. a fully out-of-window key tile in the
81
- // flash-attention loop that re-uses this shared memory each iteration — would
82
- // otherwise read stale shared memory. Identity makes such a slot a no-op.
83
- // (max identity = -FLT_MAX, denom identity = 0.)
84
- if (lidx < subgroupCount) {
85
- partialM[lidx] = -FLT_MAX;
86
- partialD[lidx] = 0.0;
87
- }
88
- workgroupBarrier();
89
  if (subgroupElect()) {
90
- let slot = lidx / sgSize;
91
- partialM[slot] = sgM;
92
- partialD[slot] = sgD;
93
  }
94
  workgroupBarrier();
95
  if (lidx == 0u) {
96
  var accM = -FLT_MAX;
97
  var accD = 0.0;
98
- for (var i = 0u; i < subgroupCount; i = i + 1u) {
99
- let mNew = max(accM, partialM[i]);
100
- accD = accD * exp_shift(accM, mNew) + partialD[i] * exp_shift(partialM[i], mNew);
101
- accM = mNew;
 
 
 
 
 
102
  }
103
  combinedMD = vec2<f32>(accM, accD);
104
  }
@@ -113,9 +108,8 @@ var<workgroup> partialM: array<f32, {{ mdExtent }}>;
113
  var<workgroup> partialD: array<f32, {{ mdExtent }}>;
114
  {% if mdStreamed %}
115
 
116
- // In-place fold of {{ mdStreams }} streams. The caller stores its per-thread
117
- // partials into partialM/partialD first and reads the merged pair of stream s
118
- // from slot s * WG afterwards.
119
  fn combine_partials_streams(lidx: u32) {
120
  workgroupBarrier();
121
  var stride = WG / 2u;
@@ -177,12 +171,11 @@ fn combine_partials(m: f32, d: f32, lidx: u32) -> vec2<f32> {
177
  @compute @workgroup_size(WG, 1, 1)
178
  fn main(
179
  @builtin(workgroup_id) wg: vec3<u32>,
180
- @builtin(num_workgroups) nwg: vec3<u32>,
181
  @builtin(local_invocation_id) lid: vec3<u32>{% if useSubgroups %},
182
  @builtin(subgroup_size) sgSize: u32
183
  {% endif %}
184
  ) {
185
- let row = wg.x + wg.y * nwg.x;
186
  if (row >= {{ ROWS_EXPR }}) { return; }
187
  {% if CACHE_VEC4 %}
188
  let base4 = row * COLS4;
 
1
+ {% set CACHE_VEC4 = cacheVec4 if cacheVec4 is defined else false %}
2
  {% set CAUSAL_ROWS = causalRows is defined and causalRows %}
3
  {% set SCALE_IN = scaleInSoftmax is defined and scaleInSoftmax %}
4
  {% set RUNTIME_COLS = colsFromParams is defined and colsFromParams %}
 
50
  // calls before their shared partial storage is reused.
51
  {% set combineSubgroups = combineSubgroups is defined and combineSubgroups %}
52
  {% if combineSubgroups %}
53
+ // Cross-subgroup merge that assumes nothing about which invocations share a
54
+ // subgroup or how many subgroups there are: each subgroup's elected lane
55
+ // publishes the subgroup pair in the slot at its OWN invocation index and sets
56
+ // that index's bit in a workgroup bitmask; thread 0 then folds exactly the
57
+ // published slots, in ascending index order (the online (m, d) merge is not
58
+ // float-associative, so the order is fixed), and clears the mask for the next
59
+ // call as it reads it. Workgroup memory starts zeroed, so the mask needs no
60
+ // setup. Same three collectives as a single-subgroup reduce, two barriers.
61
  var<workgroup> partialM: array<f32, WG>;
62
  var<workgroup> partialD: array<f32, WG>;
63
+ var<workgroup> leaderMask: array<atomic<u32>, (WG + 31u) / 32u>;
64
  var<workgroup> combinedMD: vec2<f32>;
65
 
66
  // When the whole workgroup is one subgroup the subgroup reduce already covers
67
+ // it (no barriers, no shared state). `subgroup_size` is the size of the current
68
+ // subgroup and uniform, so the test is exact and may guard the barriers below.
69
  fn combine_partials(m: f32, d: f32, lidx: u32, sgSize: u32) -> vec2<f32> {
70
  let sgM = subgroupMax(m);
71
  // A lane with no elements contributes d == 0 (exact identity). A +inf
 
76
  if (sgSize == WG) {
77
  return vec2<f32>(sgM, sgD);
78
  }
 
 
 
 
 
 
 
 
 
 
 
 
79
  if (subgroupElect()) {
80
+ partialM[lidx] = sgM;
81
+ partialD[lidx] = sgD;
82
+ atomicOr(&leaderMask[lidx / 32u], 1u << (lidx % 32u));
83
  }
84
  workgroupBarrier();
85
  if (lidx == 0u) {
86
  var accM = -FLT_MAX;
87
  var accD = 0.0;
88
+ for (var w = 0u; w < (WG + 31u) / 32u; w = w + 1u) {
89
+ var bits = atomicExchange(&leaderMask[w], 0u);
90
+ while (bits != 0u) {
91
+ let slot = w * 32u + firstTrailingBit(bits);
92
+ bits = bits & (bits - 1u);
93
+ let mNew = max(accM, partialM[slot]);
94
+ accD = accD * exp_shift(accM, mNew) + partialD[slot] * exp_shift(partialM[slot], mNew);
95
+ accM = mNew;
96
+ }
97
  }
98
  combinedMD = vec2<f32>(accM, accD);
99
  }
 
108
  var<workgroup> partialD: array<f32, {{ mdExtent }}>;
109
  {% if mdStreamed %}
110
 
111
+ // In-place fold of {{ mdStreams }} streams. Input partials occupy
112
+ // partialM/partialD; stream s returns its merged pair in slot s * WG.
 
113
  fn combine_partials_streams(lidx: u32) {
114
  workgroupBarrier();
115
  var stride = WG / 2u;
 
171
  @compute @workgroup_size(WG, 1, 1)
172
  fn main(
173
  @builtin(workgroup_id) wg: vec3<u32>,
 
174
  @builtin(local_invocation_id) lid: vec3<u32>{% if useSubgroups %},
175
  @builtin(subgroup_size) sgSize: u32
176
  {% endif %}
177
  ) {
178
+ let row = wg.x + wg.y * {{ DISPATCH_FOLD_WIDTH }}u;
179
  if (row >= {{ ROWS_EXPR }}) { return; }
180
  {% if CACHE_VEC4 %}
181
  let base4 = row * COLS4;
build/webgpu/attn-online-scalar.wgsl.jinja CHANGED
@@ -1,25 +1,14 @@
1
- {% if usesF16 %}
2
- enable f16;
3
- {% endif %}
4
  {{ env.wgsl.resourceDeclarations }}
5
- {% set MASK_BATCH = "batch * params.maskBatchStride + " if source.layout == "bsh" else "" %}
6
 
7
  // Online-softmax attention fallback with no feature requirements: one
8
  // workgroup per (batch, head, query token) walks the keys serially; the
9
  // workgroup cooperates on each q·k dot (tree reduction) and on the running
10
- // V accumulator, with the online rescale applied per key. It is the portable
11
- // path for any workgroup size and device tier.
12
  // Layout: rank-3 token-major [batch, seq, heads * headDim].
13
- {% if headsFromParams %}
14
- // Q/KV head counts are uniforms; the GQA mapping is h / (qHeads / kvHeads).
15
- {% endif %}
16
- {% if hasKeyLimit %}
17
- // params.keyLimit caps the key range when nonzero.
18
- {% endif %}
19
- {% if scaleFallbackRsqrt %}
20
  // An omitted scale uses 1/sqrt(headDim); explicit zero remains zero through
21
  // the scaleIsExplicitZero specialization.
22
- {% endif %}
23
  {% if hasBias %}
24
  // Packed [Q; K; V] bias rows are applied during the serial key walk.
25
  {% endif %}
@@ -30,10 +19,8 @@ enable f16;
30
  const HEAD_DIM: u32 = {{ headDim }}u;
31
  const Q_HIDDEN: u32 = {{ qHidden }}u;
32
  const KV_HIDDEN: u32 = {{ kvHidden }}u;
33
- {% if not headsFromParams %}
34
  const Q_HEADS: u32 = {{ qNumHeads }}u;
35
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
36
- {% endif %}
37
  {% set qHeads = "params.qHeads" if headsFromParams else "Q_HEADS" %}
38
  {% set kvHeads = "params.kvHeads" if headsFromParams else "KV_HEADS" %}
39
  {% set scale = scale | default("0.0") %}
@@ -80,18 +67,12 @@ fn {{ name }}(value: f32, tid: u32) -> f32 {
80
 
81
  {{ wgsl_tree_reduce_f32("reduce_sum", "add", "partial", "WG") }}
82
 
83
- {% if scaleFallbackRsqrt %}
84
  {% if ATTN_SCALE_DIM is not defined %}{% set ATTN_SCALE_DIM = "HEAD_DIM" %}{% endif %}
85
  fn scale_value() -> f32 {
86
  if (params.scale != 0.0) { return params.scale; }
87
  return inverseSqrt(f32({{ ATTN_SCALE_DIM }}));
88
  }
89
 
90
- {% else %}
91
- fn scale_value() -> f32 {
92
- return {{ scale }};
93
- }
94
- {% endif %}
95
 
96
  @compute @workgroup_size(WG, 1, 1)
97
  fn main(
@@ -132,12 +113,7 @@ fn main(
132
 
133
  var maxKj = params.kvSeq;
134
  var minKj: u32 = 0u;
135
- {% if hasCausal %}
136
  maxKj = min(maxKj, select(params.kvSeq, query_token + 1u, params.isCausal != 0u));
137
- {% endif %}
138
- {% if hasKeyLimit %}
139
- maxKj = min(maxKj, select(params.kvSeq, params.keyLimit, params.keyLimit > 0u));
140
- {% endif %}
141
 
142
  for (var key_token: u32 = minKj; key_token < maxKj; key_token = key_token + 1u) {
143
  let kRow = kvBase + key_token * kvTokenStride;
@@ -183,7 +159,9 @@ fn main(
183
  workgroupBarrier();
184
  }
185
 
186
- let inv_denom = 1.0 / running_denom;
 
 
187
  for (var d: u32 = tid; d < HEAD_DIM; d = d + WG) {
188
  output[qBase + d] = {{ outputScalar }}(running_out[d] * inv_denom);
189
  }
 
 
 
 
1
  {{ env.wgsl.resourceDeclarations }}
2
+ {% set MASK_BATCH = "batch * params.maskBatchStride + " if layout == "bsh" else "" %}
3
 
4
  // Online-softmax attention fallback with no feature requirements: one
5
  // workgroup per (batch, head, query token) walks the keys serially; the
6
  // workgroup cooperates on each q·k dot (tree reduction) and on the running
7
+ // V accumulator, with the online rescale applied per key. This path requires
8
+ // no subgroup or subgroup-matrix features.
9
  // Layout: rank-3 token-major [batch, seq, heads * headDim].
 
 
 
 
 
 
 
10
  // An omitted scale uses 1/sqrt(headDim); explicit zero remains zero through
11
  // the scaleIsExplicitZero specialization.
 
12
  {% if hasBias %}
13
  // Packed [Q; K; V] bias rows are applied during the serial key walk.
14
  {% endif %}
 
19
  const HEAD_DIM: u32 = {{ headDim }}u;
20
  const Q_HIDDEN: u32 = {{ qHidden }}u;
21
  const KV_HIDDEN: u32 = {{ kvHidden }}u;
 
22
  const Q_HEADS: u32 = {{ qNumHeads }}u;
23
  const KV_HEADS: u32 = {{ kvNumHeads }}u;
 
24
  {% set qHeads = "params.qHeads" if headsFromParams else "Q_HEADS" %}
25
  {% set kvHeads = "params.kvHeads" if headsFromParams else "KV_HEADS" %}
26
  {% set scale = scale | default("0.0") %}
 
67
 
68
  {{ wgsl_tree_reduce_f32("reduce_sum", "add", "partial", "WG") }}
69
 
 
70
  {% if ATTN_SCALE_DIM is not defined %}{% set ATTN_SCALE_DIM = "HEAD_DIM" %}{% endif %}
71
  fn scale_value() -> f32 {
72
  if (params.scale != 0.0) { return params.scale; }
73
  return inverseSqrt(f32({{ ATTN_SCALE_DIM }}));
74
  }
75
 
 
 
 
 
 
76
 
77
  @compute @workgroup_size(WG, 1, 1)
78
  fn main(
 
113
 
114
  var maxKj = params.kvSeq;
115
  var minKj: u32 = 0u;
 
116
  maxKj = min(maxKj, select(params.kvSeq, query_token + 1u, params.isCausal != 0u));
 
 
 
 
117
 
118
  for (var key_token: u32 = minKj; key_token < maxKj; key_token = key_token + 1u) {
119
  let kRow = kvBase + key_token * kvTokenStride;
 
159
  workgroupBarrier();
160
  }
161
 
162
+ // A query whose every key is masked to -inf (or whose key range is empty)
163
+ // leaves running_denom at zero; emit a zero row rather than 0 * (1/0) = NaN.
164
+ let inv_denom = select(0.0, 1.0 / running_denom, running_denom > 0.0);
165
  for (var d: u32 = tid; d < HEAD_DIM; d = d + WG) {
166
  output[qBase + d] = {{ outputScalar }}(running_out[d] * inv_denom);
167
  }
build/webgpu/attn-small-head-parallel.wgsl.jinja CHANGED
@@ -1,6 +1,3 @@
1
- {% if usesF16 %}
2
- enable f16;
3
- {% endif %}
4
  {{ env.wgsl.resourceDeclarations }}
5
 
6
  // Small-head attention is dominated by the per-key barriers in the generic
 
 
 
 
1
  {{ env.wgsl.resourceDeclarations }}
2
 
3
  // Small-head attention is dominated by the per-key barriers in the generic
build/webgpu/bench.json CHANGED
@@ -1,10 +1,9 @@
1
  {
2
- "op": "com.microsoft.MultiHeadAttention",
3
  "cases": [
4
  {
5
  "name": "minilm-self-attention-b1-s20-h12-d32",
6
  "provenance": {
7
- "notes": "Upper boundary of the whole-head small-sequence route at 12 heads and sequence length 20. Paired with the sequence-24 case, it checks that the mutually exclusive route predicates meet without a gap."
8
  },
9
  "vars": { "batch": 1, "qSeq": 20, "kvSeq": 20, "heads": 12, "headDim": 32 },
10
  "attrs": { "num_heads": 12 },
 
1
  {
 
2
  "cases": [
3
  {
4
  "name": "minilm-self-attention-b1-s20-h12-d32",
5
  "provenance": {
6
+ "notes": "Twelve heads at sequence length 20 mark the upper boundary of the whole-head small-sequence route; `minilm-self-attention-b1-s24-h12-d32` exercises the adjacent flash route."
7
  },
8
  "vars": { "batch": 1, "qSeq": 20, "kvSeq": 20, "heads": 12, "headDim": 32 },
9
  "attrs": { "num_heads": 12 },
build/webgpu/manifest.json CHANGED
The diff for this file is too large to render. See raw diff
 
build/webgpu/metadata.json CHANGED
@@ -1,31 +1,78 @@
1
  {
2
  "name": "com.microsoft.MultiHeadAttention",
3
- "id": "_com_microsoft_multiheadattention_webgpu_03bfaa6",
4
  "version": 1,
5
  "license": "Apache-2.0",
6
  "backend": { "type": "webgpu" },
7
  "digest": {
8
  "algorithm": "sha256",
9
  "files": {
10
- "attention-rank4-tiled.wgsl.jinja": "EmYf/jV5lG4hmXr5wP+Qo6hZlY3LDd1aeAQhFXtDJEc=",
11
- "attn-flash-decode-splitk-merge.wgsl.jinja": "8RI/yP6118n/rLRo0OG9NJ+kZ2xdmJyj093Tw8C07nc=",
12
- "attn-flash-decode-splitk.wgsl.jinja": "Pyt7ZMMGU2H8ap4T4ggVRAhW38fotpNyJKr7z+iVsBU=",
13
- "attn-flash-online.wgsl.jinja": "uJ68uHbQN9k8TLdgtZtdkw78iXYgTnE1UpWdt1LGq7w=",
14
- "attn-flash-prefill-cluster.wgsl.jinja": "TO+SM9D9yK/TC4LOL/3U4/xHd0HS6xB61S+POu9p2QQ=",
15
- "attn-flash-q32-broadcast.wgsl.jinja": "evSdaiYgvrikYZ8hpb5m4gxf+rIryKkHOTiKgyF7ioI=",
16
- "attn-materialized-apply-f32.wgsl.jinja": "qbrsh9S14QYuAM+xF49dgcooO6f19yNg9lC8eoBeWFQ=",
17
- "attn-materialized-rowstats-combine-f32.wgsl.jinja": "rdpdnh6y3m0tBwFMVCc903j2cvGsnGTcT8EAWRr5TRk=",
18
- "attn-materialized-score-f32.wgsl.jinja": "EAhU/wHSgmJZ/YHCnwG/takpgfMAtGqKXo3Cf8TfvxU=",
19
- "attn-materialized-sgmat-f32.wgsl.jinja": "7fJFDK+kWEhN4rqFs3fE9uPkZdHh5C9xl7PXe9vZze4=",
20
- "attn-materialized-softmax-f32.wgsl.jinja": "7z288syeGhUUyBpaZzVVqdtFVMQALuN88sNfytvClWw=",
21
- "attn-online-scalar.wgsl.jinja": "CW0ru5yg8YFpIv31XnnD5IuKBXppsKc+2BU7u2uAQv4=",
22
- "attn-small-head-parallel.wgsl.jinja": "ejotx9NjdqElO3EIQWj8E3hJk2iW8Exq1vUjxK4FUk0=",
23
- "bench.json": "VV4kVBFJ112r3U2RGQlQd1Z5eAzgm+y30+oGG6TpK3g=",
24
- "manifest.json": "kaCZGh3/PB8EmCgBc5QCc0aVGSrjf55xB6OM4s0yhgg=",
25
- "mha-small-seq.wgsl.jinja": "F825nrB6RLu55ziDnDI7BTJ3JSdT1GwsuMYmtrHQSic=",
26
- "test.json": "6PCZYazz1hXA9+nbVJmd0/btWsIkkp29/xYqWZ4OSrs="
 
27
  }
28
  },
29
- "provenance": { "kernel": { "sha": "c928d21e6cc1310861cba3bafb75f5f679ecf5f3", "dirty": false } },
30
- "webgpu": { "manifestSpec": "1.0", "specialized": true, "opPath": "ops/com.microsoft.MultiHeadAttention" }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
 
1
  {
2
  "name": "com.microsoft.MultiHeadAttention",
3
+ "id": "_com_microsoft_multiheadattention_webgpu_5144476",
4
  "version": 1,
5
  "license": "Apache-2.0",
6
  "backend": { "type": "webgpu" },
7
  "digest": {
8
  "algorithm": "sha256",
9
  "files": {
10
+ "attention-rank4-tiled.wgsl.jinja": "gYl58ecIVTxJJkvnK/6lNcbPKaRT24cfz5Yx0MxxxxE=",
11
+ "attn-flash-decode-splitk-merge.wgsl.jinja": "XSAMF/uzQKJg9pZZDeqmViaVXuj47Vd7Pk404Z1J48I=",
12
+ "attn-flash-decode-splitk.wgsl.jinja": "dqxX8taw1tpo/QQkFr2zJ7TEEtQtOspTYEYHADXtESs=",
13
+ "attn-flash-online.wgsl.jinja": "hCxO/6VGkyixwyoT/kuYeHgcEkFoRwk/ZmiLlnwa0jM=",
14
+ "attn-flash-prefill-cluster.wgsl.jinja": "igbsg5xRuFz7alnwmL74hhh1JUqfDB5PfUslsEz5rjA=",
15
+ "attn-flash-q32-broadcast.wgsl.jinja": "8qgKXydwiH2shac/fkdHi+FR+c8pOMDFbNp9Zs8KAnc=",
16
+ "attn-materialized-apply-f32.wgsl.jinja": "aF7JSJj8rQN8wGTV7IWmxkqMDbXOnGBSI3tsI1CxZNk=",
17
+ "attn-materialized-rowstats-combine-f32.wgsl.jinja": "+/5m03sN44zTn9q7cquDea2UNhEO1ujzm1J7yPai4OY=",
18
+ "attn-materialized-score-f32.wgsl.jinja": "gCvBC1T1CLEmNxcpo04CLBQdFBkEVMJ9Xhv1wbQxCsI=",
19
+ "attn-materialized-sgmat-f32.wgsl.jinja": "Ez9UJggPXMZ6OyzUQu8xLtg8bZd+lTCoEGDwLkNHNL0=",
20
+ "attn-materialized-softmax-f32.wgsl.jinja": "DCuz4gFKXDCdR9UMnfD+mYFjBghnv12GxtEsPKH52mE=",
21
+ "attn-online-scalar.wgsl.jinja": "6+hU7MzwHz69fjciv94xsPxN+o9rwm42u9Cin2C+AL8=",
22
+ "attn-small-head-parallel.wgsl.jinja": "oxFE6hQZKTRugz9hwuS+cob00PskT8iYqP4e+YWtHKA=",
23
+ "bench.json": "b/WdaAhEP5NpRXNB5w1mivRNTfALrBDJisDONxTf4Yo=",
24
+ "manifest.json": "+/bNCAjkG5OoutFq7O4vQJqb2yMxzICsKvFXsRE50bA=",
25
+ "mha-small-seq-blocked.wgsl.jinja": "zznGERrPco5uH9tKiQ2QE+ArO3vho/nEv69wT8IgY1U=",
26
+ "mha-small-seq.wgsl.jinja": "UsbUIkvgWL/RgOBSm7lhPGIatA36yTQR4Z2gpHVLqx4=",
27
+ "test.json": "NlMA/wlmGSpogAuhSZ/kcQ01dd3M+CvkPF0d/VBmIpM="
28
  }
29
  },
30
+ "provenance": { "kernel": { "sha": "91d990483a174128daf7673f3f37a7c890493ae1", "dirty": false } },
31
+ "webgpu": {
32
+ "manifestSpec": "2.0",
33
+ "variants": {
34
+ "qkv_bias_small_seq_blocked": ["mha-small-seq-blocked.wgsl.jinja"],
35
+ "qkv_no_bias_small_seq_blocked": ["mha-small-seq-blocked.wgsl.jinja"],
36
+ "qkv_no_bias_small_seq": ["mha-small-seq.wgsl.jinja"],
37
+ "qkv_no_bias_tiled_nosg": ["attention-rank4-tiled.wgsl.jinja"],
38
+ "qkv_bias_flash_q32_broadcast_f32_d128": ["attn-flash-q32-broadcast.wgsl.jinja"],
39
+ "qkv_no_bias_small_head_parallel": ["attn-small-head-parallel.wgsl.jinja"],
40
+ "qkv_no_bias_tiled_attn_bias_nosg": ["attention-rank4-tiled.wgsl.jinja"],
41
+ "qkv_no_bias_materialized_sgmat_fused_f32": ["attn-materialized-rowstats-combine-f32.wgsl.jinja", "attn-materialized-sgmat-f32.wgsl.jinja"],
42
+ "qkv_bias_materialized_sgmat_fused_f32": ["attn-materialized-rowstats-combine-f32.wgsl.jinja", "attn-materialized-sgmat-f32.wgsl.jinja"],
43
+ "qkv_no_bias_materialized_sgmat_fused_f16": ["attn-materialized-rowstats-combine-f32.wgsl.jinja", "attn-materialized-sgmat-f32.wgsl.jinja"],
44
+ "qkv_no_bias_flash_cluster_lpq4_nosg": ["attn-flash-prefill-cluster.wgsl.jinja"],
45
+ "qkv_no_bias_flash_cluster_nosg": ["attn-flash-prefill-cluster.wgsl.jinja"],
46
+ "qkv_bias_flash_cluster_nosg": ["attn-flash-prefill-cluster.wgsl.jinja"],
47
+ "qkv_no_bias_flash_cluster_lpq4": ["attn-flash-prefill-cluster.wgsl.jinja"],
48
+ "qkv_no_bias_flash_cluster": ["attn-flash-prefill-cluster.wgsl.jinja"],
49
+ "qkv_bias_flash_cluster": ["attn-flash-prefill-cluster.wgsl.jinja"],
50
+ "qkv_no_bias_flash_cluster_attn_bias": ["attn-flash-prefill-cluster.wgsl.jinja"],
51
+ "qkv_bias_flash_cluster_attn_bias": ["attn-flash-prefill-cluster.wgsl.jinja"],
52
+ "qkv_no_bias_flash_splitk_nosg": ["attn-flash-decode-splitk-merge.wgsl.jinja", "attn-flash-decode-splitk.wgsl.jinja"],
53
+ "qkv_bias_flash_splitk_nosg": ["attn-flash-decode-splitk-merge.wgsl.jinja", "attn-flash-decode-splitk.wgsl.jinja"],
54
+ "qkv_no_bias_flash_splitk": ["attn-flash-decode-splitk-merge.wgsl.jinja", "attn-flash-decode-splitk.wgsl.jinja"],
55
+ "qkv_bias_flash_splitk": ["attn-flash-decode-splitk-merge.wgsl.jinja", "attn-flash-decode-splitk.wgsl.jinja"],
56
+ "qkv_no_bias_materialized_gemm_f32": ["attn-materialized-apply-f32.wgsl.jinja", "attn-materialized-score-f32.wgsl.jinja", "attn-materialized-softmax-f32.wgsl.jinja"],
57
+ "qkv_bias_materialized_gemm_f32": ["attn-materialized-apply-f32.wgsl.jinja", "attn-materialized-score-f32.wgsl.jinja", "attn-materialized-softmax-f32.wgsl.jinja"],
58
+ "qkv_no_bias_materialized_gemm_fused_f32": ["attn-materialized-apply-f32.wgsl.jinja", "attn-materialized-rowstats-combine-f32.wgsl.jinja", "attn-materialized-score-f32.wgsl.jinja"],
59
+ "qkv_bias_materialized_gemm_fused_f32": ["attn-materialized-apply-f32.wgsl.jinja", "attn-materialized-rowstats-combine-f32.wgsl.jinja", "attn-materialized-score-f32.wgsl.jinja"],
60
+ "qkv_no_bias_flash_q32_broadcast": ["attn-flash-q32-broadcast.wgsl.jinja"],
61
+ "qkv_no_bias_flash_q32_shared": ["attn-flash-q32-broadcast.wgsl.jinja"],
62
+ "qkv_no_bias_attn_bias": ["attn-online-scalar.wgsl.jinja"],
63
+ "qkv_bias_attn_bias": ["attn-online-scalar.wgsl.jinja"],
64
+ "qkv_no_bias": ["attn-online-scalar.wgsl.jinja"],
65
+ "qkv_bias": ["attn-online-scalar.wgsl.jinja"],
66
+ "qkv_no_bias_flash": ["attn-flash-online.wgsl.jinja"],
67
+ "qkv_bias_flash": ["attn-flash-online.wgsl.jinja"],
68
+ "qkv_no_bias_flash_nosg": ["attn-flash-online.wgsl.jinja"],
69
+ "qkv_bias_flash_nosg": ["attn-flash-online.wgsl.jinja"],
70
+ "qkv_no_bias_flash_attn_bias": ["attn-flash-online.wgsl.jinja"],
71
+ "qkv_bias_flash_attn_bias": ["attn-flash-online.wgsl.jinja"],
72
+ "qkv_no_bias_flash_attn_bias_nosg": ["attn-flash-online.wgsl.jinja"],
73
+ "qkv_bias_flash_attn_bias_nosg": ["attn-flash-online.wgsl.jinja"],
74
+ "qkv_no_bias_materialized_sgmat_f32": ["attn-materialized-sgmat-f32.wgsl.jinja", "attn-materialized-softmax-f32.wgsl.jinja"],
75
+ "qkv_bias_materialized_sgmat_f32": ["attn-materialized-sgmat-f32.wgsl.jinja", "attn-materialized-softmax-f32.wgsl.jinja"]
76
+ }
77
+ }
78
  }
build/webgpu/mha-small-seq-blocked.wgsl.jinja ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{ env.wgsl.resourceDeclarations }}
2
+
3
+ // Blocked whole-head attention for short key sequences with optional projection
4
+ // bias and causal masking. One workgroup owns
5
+ // (batch, head) and a block of QUERY_BLOCK query rows; it stages the head's
6
+ // complete K and V planes in workgroup memory once, then gives every query
7
+ // KEY_LANES lanes: lane (q, kl) scores keys kl, kl + KEY_LANES, ... against
8
+ // its private Q row, keeps an online-softmax (max, denominator, context)
9
+ // partial in registers, and the query's KEY_LANES partials merge through
10
+ // workgroup memory in one pass. The full score row never exists.
11
+ //
12
+ // The complete (query, key) block is folded with one reduction round per query
13
+ // block. KEY_LANES independently accumulate interleaved keys before that fold.
14
+ const HEAD_DIM: u32 = {{ headDim }}u;
15
+ const HEAD_DIM_V4: u32 = {{ headDimV4 }}u;
16
+ const HIDDEN_V4: u32 = {{ hiddenV4 }}u;
17
+ const KV_SEQ: u32 = {{ kvSeq }}u;
18
+ const QUERY_BLOCK: u32 = {{ queryBlock }}u;
19
+ const KEY_LANES: u32 = {{ keyLanes }}u;
20
+ const WG: u32 = QUERY_BLOCK * KEY_LANES;
21
+ // FLT_MAX, not -inf, as the online (m, d) accumulator init: merges must keep
22
+ // `m - m` finite so an empty lane / all--inf row contributes the exact
23
+ // accumulator identity (m, d) = (-FLT_MAX, 0). Operator epilogues interpret
24
+ // a zero final denominator according to their public semantics. Using -inf
25
+ // here changes +inf-row behavior.
26
+ const FLT_MAX: f32 = 3.4028234663852886e38;
27
+
28
+ fn is_finite_f32(value: f32) -> bool {
29
+ return select(false, value <= FLT_MAX, value >= -FLT_MAX);
30
+ }
31
+
32
+ // x - m that is exactly 0 when x equals a finite m, so exp(shifted) == 1
33
+ // exactly at the row max. `x - x` on an infinite max is a legal fast-math
34
+ // fold to 0, which would silently turn +inf rows finite — the explicit
35
+ // equality test keeps the NaN propagation of the serial kernels.
36
+ fn shifted_value(value: f32, maxValue: f32) -> f32 {
37
+ let equalFiniteMax = select(false, value == maxValue, is_finite_f32(maxValue));
38
+ return select(value - maxValue, 0.0, equalFiniteMax);
39
+ }
40
+ fn exp_shift(value: f32, maxValue: f32) -> f32 {
41
+ return exp(shifted_value(value, maxValue));
42
+ }
43
+ {% if ATTN_SCALE_DIM is not defined %}{% set ATTN_SCALE_DIM = "HEAD_DIM" %}{% endif %}
44
+ fn scale_value() -> f32 {
45
+ if (params.scale != 0.0) { return params.scale; }
46
+ return inverseSqrt(f32({{ ATTN_SCALE_DIM }}));
47
+ }
48
+
49
+ {% if hasBias %}
50
+ fn load_bias4(base: u32, d4: u32) -> vec4<f32> {
51
+ let offset = base + d4 * 4u;
52
+ return vec4<f32>(bias[offset], bias[offset + 1u], bias[offset + 2u], bias[offset + 3u]);
53
+ }
54
+ {% endif %}
55
+
56
+ var<workgroup> kShared: array<vec4<f32>, KV_SEQ * HEAD_DIM_V4>;
57
+ var<workgroup> vShared: array<vec4<f32>, KV_SEQ * HEAD_DIM_V4>;
58
+ // Per-lane partials for the merge: (max, denom) and the context row.
59
+ var<workgroup> partM: array<f32, WG>;
60
+ var<workgroup> partD: array<f32, WG>;
61
+ var<workgroup> partO: array<vec4<f32>, WG * HEAD_DIM_V4>;
62
+
63
+ @compute @workgroup_size({{ queryBlock * keyLanes }}, 1, 1)
64
+ fn main(
65
+ @builtin(workgroup_id) wid: vec3<u32>,
66
+ @builtin(local_invocation_id) lid: vec3<u32>
67
+ ) {
68
+ let head = wid.y;
69
+ let batch = wid.z;
70
+ let tid = lid.x;
71
+ let ql = tid / KEY_LANES;
72
+ let kl = tid % KEY_LANES;
73
+ let qi = wid.x * QUERY_BLOCK + ql;
74
+ let headV4 = head * HEAD_DIM_V4;
75
+
76
+ // Lanes stage this head's K and V planes from consecutive vec4 elements,
77
+ // advancing by the workgroup width even when their query row is absent.
78
+ let kvBase = batch * KV_SEQ * HIDDEN_V4;
79
+ for (var i = tid; i < KV_SEQ * HEAD_DIM_V4; i = i + WG) {
80
+ let s = i / HEAD_DIM_V4;
81
+ let d4 = i % HEAD_DIM_V4;
82
+ let src = kvBase + s * HIDDEN_V4 + headV4 + d4;
83
+ kShared[i] = vec4<f32>(key[src]);
84
+ vShared[i] = vec4<f32>(value[src]);
85
+ }
86
+ workgroupBarrier();
87
+
88
+ let scale = scale_value();
89
+ let rowValid = qi < params.qSeq;
90
+ // A query past the end scores no keys: its partials are the identity and it
91
+ // stores nothing, so every barrier below stays uniform. For a causal request,
92
+ // query qi attends keys 0 through qi.
93
+ let keyBound = select(0u, select(KV_SEQ, min(KV_SEQ, qi + 1u), params.isCausal != 0u), rowValid);
94
+
95
+ // Private Q row (with the Q bias folded in), read from the clamped row so an
96
+ // absent query still reads in bounds.
97
+ let qRowV4 = (batch * params.qSeq + min(qi, params.qSeq - 1u)) * HIDDEN_V4 + headV4;
98
+ {% for d in range(headDimV4) %}
99
+ var q{{ d }} = vec4<f32>(query[qRowV4 + {{ d }}u]);
100
+ {% if hasBias %}
101
+ q{{ d }} = q{{ d }} + load_bias4(head * HEAD_DIM, {{ d }}u);
102
+ {% endif %}
103
+ {% endfor %}
104
+
105
+ // Lane-local online softmax over keys kl, kl + KEY_LANES, ...
106
+ var m = -FLT_MAX;
107
+ var d = 0.0;
108
+ {% for c in range(headDimV4) %}
109
+ var o{{ c }} = vec4<f32>(0.0);
110
+ {% endfor %}
111
+ for (var kj = kl; kj < keyBound; kj = kj + KEY_LANES) {
112
+ let kBase = kj * HEAD_DIM_V4;
113
+ var s = 0.0;
114
+ {% for c in range(headDimV4) %}
115
+ s = s + dot(q{{ c }}, kShared[kBase + {{ c }}u]);
116
+ {% endfor %}
117
+ s = s * scale;
118
+ let mNew = max(m, s);
119
+ let corr = exp_shift(m, mNew);
120
+ let p = exp_shift(s, mNew);
121
+ d = d * corr + p;
122
+ {% for c in range(headDimV4) %}
123
+ o{{ c }} = o{{ c }} * corr + p * vShared[kBase + {{ c }}u];
124
+ {% endfor %}
125
+ m = mNew;
126
+ }
127
+
128
+ partM[tid] = m;
129
+ partD[tid] = d;
130
+ {% for c in range(headDimV4) %}
131
+ partO[tid * HEAD_DIM_V4 + {{ c }}u] = o{{ c }};
132
+ {% endfor %}
133
+ workgroupBarrier();
134
+
135
+ // The query's leader lane merges its KEY_LANES partials with the stable
136
+ // online rule, then applies the V bias once and stores the row.
137
+ if (kl == 0u && rowValid) {
138
+ let base = ql * KEY_LANES;
139
+ var mAll = -FLT_MAX;
140
+ for (var i = 0u; i < KEY_LANES; i = i + 1u) {
141
+ mAll = max(mAll, partM[base + i]);
142
+ }
143
+ var dAll = 0.0;
144
+ {% for c in range(headDimV4) %}
145
+ var out{{ c }} = vec4<f32>(0.0);
146
+ {% endfor %}
147
+ for (var i = 0u; i < KEY_LANES; i = i + 1u) {
148
+ let w = exp_shift(partM[base + i], mAll);
149
+ dAll = dAll + partD[base + i] * w;
150
+ {% for c in range(headDimV4) %}
151
+ out{{ c }} = out{{ c }} + partO[(base + i) * HEAD_DIM_V4 + {{ c }}u] * w;
152
+ {% endfor %}
153
+ }
154
+ let hasMass = dAll > 0.0;
155
+ let inv = select(0.0, 1.0 / dAll, hasMass);
156
+ let outRowV4 = (batch * params.qSeq + qi) * HIDDEN_V4 + headV4;
157
+ {% for c in range(headDimV4) %}
158
+ var v{{ c }} = out{{ c }} * inv;
159
+ {% if hasBias %}
160
+ // V bias is token-independent, so add it once after the softmax-weighted sum.
161
+ v{{ c }} = v{{ c }} + load_bias4(2u * {{ hidden }}u + head * HEAD_DIM, {{ c }}u) * select(0.0, 1.0, hasMass);
162
+ {% endif %}
163
+ output[outRowV4 + {{ c }}u] = v{{ c }};
164
+ {% endfor %}
165
+ }
166
+ }
build/webgpu/mha-small-seq.wgsl.jinja CHANGED
@@ -28,9 +28,9 @@ fn main(
28
  let batch = wid.y;
29
  let headBase = head * HEAD_DIM;
30
 
31
- // Cooperative, coalesced load of this head's K and V planes. Every thread
32
- // participates regardless of whether it owns a query row, and the barrier is
33
- // reached by the whole workgroup before any row exits.
34
  for (var i = lid.x; i < KV_SEQ * HEAD_DIM; i += WG) {
35
  let s = i / HEAD_DIM;
36
  let d = i % HEAD_DIM;
 
28
  let batch = wid.y;
29
  let headBase = head * HEAD_DIM;
30
 
31
+ // Lanes stage this head's K and V planes from consecutive flattened elements,
32
+ // advancing by the workgroup width. Every lane reaches the barrier before a
33
+ // lane without a query row exits.
34
  for (var i = lid.x; i < KV_SEQ * HEAD_DIM; i += WG) {
35
  let s = i / HEAD_DIM;
36
  let d = i % HEAD_DIM;
build/webgpu/test.json CHANGED
@@ -1,5 +1,4 @@
1
  {
2
- "op": "com.microsoft.MultiHeadAttention",
3
  "cases": [
4
  {
5
  "name": "cross_attention_no_bias",
@@ -186,7 +185,7 @@
186
  "provenance": {
187
  "source": "onnxruntime/test/contrib_ops/multihead_attention_op_test.cc",
188
  "test": "MultiHeadAttentionTest.CrossAttention_Batch1_HeadSize16",
189
- "notes": "No-bias companion for ORT's batch=1, q_seq=2, kv_seq=3, num_heads=2, head_size=16 cross-attention shape."
190
  },
191
  "attrs": { "num_heads": 2 },
192
  "inputs": {
@@ -279,7 +278,7 @@
279
  "valueT": {
280
  "dtype": "float32",
281
  "shape": [1, 64, 256],
282
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.007, "cosStep": 0.041 }
283
  }
284
  },
285
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 64, 256], "tolerance": 0.0002 } }
@@ -350,7 +349,7 @@
350
  "valueT": {
351
  "dtype": "float32",
352
  "shape": [1, 512, 256],
353
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.007, "cosStep": 0.041 }
354
  }
355
  },
356
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 1, 256], "tolerance": 0.0002 } }
@@ -399,7 +398,7 @@
399
  "valueT": {
400
  "dtype": "float32",
401
  "shape": [1, 2048, 256],
402
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.029, "cosStep": 0.017 }
403
  }
404
  },
405
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 1, 256], "tolerance": 0.0003 } }
@@ -407,7 +406,7 @@
407
  {
408
  "name": "splitk_decode_head_dim128_no_bias_kv2048",
409
  "provenance": {
410
- "notes": "Compact companion for bench case decode-h8-d128-kv2048, covering headDim=128 decode split-K selection."
411
  },
412
  "attrs": { "num_heads": 2, "scale": 0.08838834764831845 },
413
  "inputs": {
@@ -424,7 +423,7 @@
424
  "valueT": {
425
  "dtype": "float32",
426
  "shape": [1, 2048, 256],
427
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.029, "cosStep": 0.017 }
428
  }
429
  },
430
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 1, 256], "tolerance": 0.0003 } }
@@ -446,7 +445,7 @@
446
  "valueT": {
447
  "dtype": "float32",
448
  "shape": [2, 768, 512],
449
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.029, "cosStep": 0.017 }
450
  }
451
  },
452
  "outputs": { "outputT": { "dtype": "float32", "shape": [2, 1, 512], "tolerance": 0.0003 } }
@@ -508,7 +507,7 @@
508
  {
509
  "name": "flash_self_attention_head_dim128_no_bias_f16",
510
  "provenance": {
511
- "notes": "f16 headDim=128 prefill coverage for q32-broadcast benchmarks; full tier selects qkv_no_bias_flash_q32_broadcast and no-subgroups selects qkv_no_bias_flash_q32_shared."
512
  },
513
  "attrs": { "num_heads": 4, "scale": 0.08838834764831845 },
514
  "inputs": {
@@ -525,10 +524,10 @@
525
  "valueT": {
526
  "dtype": "float16",
527
  "shape": [1, 64, 512],
528
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.007, "cosStep": 0.041 }
529
  }
530
  },
531
- "outputs": { "outputT": { "dtype": "float16", "shape": [1, 64, 512], "tolerance": 0.03 } }
532
  },
533
  {
534
  "name": "flash_decode_head_dim64_no_bias_f16",
@@ -547,10 +546,10 @@
547
  "valueT": {
548
  "dtype": "float16",
549
  "shape": [1, 512, 256],
550
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.007, "cosStep": 0.041 }
551
  }
552
  },
553
- "outputs": { "outputT": { "dtype": "float16", "shape": [1, 1, 256], "tolerance": 0.03 } }
554
  },
555
  {
556
  "name": "cross_attention_projection_bias_f16",
@@ -583,7 +582,7 @@
583
  "name": "causal_cross_attention_q2_kv4_exact",
584
  "provenance": {
585
  "source": "onnxruntime/contrib_ops/cpu/bert/attention_helper.h",
586
- "notes": "Exact upper-left causal witness. ORT CPU skips masking the final query row when qSeq < kvSeq and returns 3.75 instead of 1.5 there."
587
  },
588
  "attrs": { "num_heads": 1, "unidirectional": 1 },
589
  "inputs": {
@@ -662,7 +661,7 @@
662
  {
663
  "name": "dispatch_cliff_qseq_65537",
664
  "provenance": {
665
- "notes": "query sequence 65537 exceeds the flash-path cap and exercises the scalar fallback's folded dispatch. x is clamped to 65535 and the grid-stride loop handles the final two queries on its second iteration. kvSeq=2 keeps the reference compact, while distinct query rows expose an indexing error."
666
  },
667
  "attrs": { "num_heads": 1 },
668
  "inputs": {
@@ -758,7 +757,7 @@
758
  {
759
  "name": "cluster_prefill_qseq64_h16_d64_compact",
760
  "provenance": {
761
- "notes": "Compact companion for bench case prefill-cluster-qseq64-h16-d64-HEALTHY, which exposes the baseline/no-subgroups tier gap in the cluster prefill route."
762
  },
763
  "attrs": { "num_heads": 16, "scale": 0.125 },
764
  "inputs": {
@@ -785,7 +784,7 @@
785
  {
786
  "name": "flux2_joint_d128_q32_h8_compact",
787
  "provenance": {
788
- "notes": "Compact companion for bench case flux2-joint-attn-img512-s1536-24h-d128, preserving headDim=128 and qSeq*heads>=256 so the flash-cluster path and no-subgroups fallback remain eligible."
789
  },
790
  "attrs": { "num_heads": 8, "scale": 0.08838834764831845 },
791
  "inputs": {
@@ -812,7 +811,7 @@
812
  {
813
  "name": "flux2_joint_d128_q32_h8_bias_compact",
814
  "provenance": {
815
- "notes": "Compact correctness companion for the fused-bias benchmark at the 32-vec4, 32-lane register-topology boundary."
816
  },
817
  "attrs": { "num_heads": 8, "scale": 0.08838834764831845 },
818
  "inputs": {
@@ -869,7 +868,7 @@
869
  {
870
  "name": "q31_kv511_h8_d64_double_threshold_compact",
871
  "provenance": {
872
- "notes": "Compact lock immediately below both flash admission boundaries: qSeq*heads=31*8=248 (<256) and kvSeq=511 (<512). It guards the realistic scalar fallback that the paired benchmark exposes."
873
  },
874
  "attrs": { "num_heads": 8, "scale": 0.125 },
875
  "inputs": {
@@ -886,7 +885,7 @@
886
  "valueT": {
887
  "dtype": "float32",
888
  "shape": [1, 511, 512],
889
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.007, "cosStep": 0.041 }
890
  }
891
  },
892
  "outputs": {
@@ -913,7 +912,7 @@
913
  "valueT": {
914
  "dtype": "float32",
915
  "shape": [1, 2048, 128],
916
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.007, "cosStep": 0.041 }
917
  }
918
  },
919
  "outputs": {
@@ -938,7 +937,7 @@
938
  "valueT": {
939
  "dtype": "float32",
940
  "shape": [1, 512, 128],
941
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.029, "cosStep": 0.017 }
942
  }
943
  },
944
  "outputs": {
@@ -948,7 +947,7 @@
948
  {
949
  "name": "small_head_parallel_q3_kv65_h2_d6_compact",
950
  "provenance": {
951
- "notes": "Compact correctness lock for the D<32 key-parallel fallback. The paired q128/kv2048 benchmark exposes the per-key-barrier pathology this path removes."
952
  },
953
  "attrs": { "num_heads": 2, "scale": 0.408248290463863 },
954
  "inputs": {
@@ -975,7 +974,7 @@
975
  {
976
  "name": "small_head_parallel_f16_q3_kv65_h2_d6",
977
  "provenance": {
978
- "notes": "Float16 coverage for the D<32 key-parallel fallback. Values are scaled to O(1) while queries and keys remain small, keeping the convex-combination output near one so the tolerance detects meaningful scale errors."
979
  },
980
  "attrs": { "num_heads": 2, "scale": 0.408248290463863 },
981
  "inputs": {
@@ -1000,7 +999,7 @@
1000
  {
1001
  "name": "short_query_splitk_single_partition_q16_kv2048_h8_d32",
1002
  "provenance": {
1003
- "notes": "Locks the adaptive one-partition short-query flash path once batch*queries*heads already supplies 128 workgroups; the paired q16/kv4096 benchmark measures the same occupancy regime."
1004
  },
1005
  "attrs": { "num_heads": 8, "scale": 0.17677669529663687 },
1006
  "inputs": {
@@ -1017,7 +1016,7 @@
1017
  "valueT": {
1018
  "dtype": "float32",
1019
  "shape": [1, 2048, 256],
1020
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.029, "cosStep": 0.017 }
1021
  }
1022
  },
1023
  "outputs": {
@@ -1044,7 +1043,7 @@
1044
  "valueT": {
1045
  "dtype": "float32",
1046
  "shape": [1, 512, 128],
1047
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.029, "cosStep": 0.017 }
1048
  }
1049
  },
1050
  "outputs": {
@@ -1079,7 +1078,7 @@
1079
  {
1080
  "name": "materialized_sgmat_fused_f16_q1024_h4_d80_no_bias",
1081
  "provenance": {
1082
- "notes": "The d=80 twin of the f16 fused materialized fixture: a head dim that is not a multiple of 32 takes the guarded staging forms instead of the unguarded selects, on the same 16MB score-bytes admission floor."
1083
  },
1084
  "attrs": { "num_heads": 4, "scale": 0.125 },
1085
  "inputs": {
@@ -1104,7 +1103,7 @@
1104
  {
1105
  "name": "materialized_sgmat_q512_kv512_h1_d128_bias",
1106
  "provenance": {
1107
- "notes": "Bias twin of the d128 subgroup-matrix prefill. headDim % 32 == 0 takes the select-form Q tile load, so this locks the fused Q bias on that branch and the post-normalization V bias in the compact store epilogue. The K bias is deliberately absent from the kernel and present in the reference: it shifts every logit in a row by one constant, so softmax cancels it, and a divergence here would prove otherwise."
1108
  },
1109
  "attrs": { "num_heads": 1, "scale": 0.08838834764831845 },
1110
  "inputs": {
@@ -1136,7 +1135,7 @@
1136
  {
1137
  "name": "portable_head_dim320_no_bias",
1138
  "provenance": {
1139
- "notes": "Locks the device-derived portable fallback above the former hard-coded headDim<=256 ceiling; D320 occurs in wide vision and multimodal attention blocks."
1140
  },
1141
  "attrs": { "num_heads": 1, "scale": 0.05590169943749474 },
1142
  "inputs": {
@@ -1163,7 +1162,7 @@
1163
  {
1164
  "name": "splitk_decode_q1_kv1500_h6_d64_underfilled_wide_subgroup",
1165
  "provenance": {
1166
- "notes": "A six-head f32 decode geometry where d64 supplies only 16 vec4 dot products to a 32-lane subgroup; the selector should prefer the portable split-K kernel instead of an underfilled cooperative QK subgroup."
1167
  },
1168
  "attrs": { "num_heads": 6, "scale": 0.125 },
1169
  "inputs": {
@@ -1180,7 +1179,7 @@
1180
  "valueT": {
1181
  "dtype": "float32",
1182
  "shape": [1, 1500, 384],
1183
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.029, "cosStep": 0.017 }
1184
  }
1185
  },
1186
  "outputs": {
@@ -1207,7 +1206,7 @@
1207
  "valueT": {
1208
  "dtype": "float32",
1209
  "shape": [1, 1500, 128],
1210
- "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.031, "cosStep": 0.019 }
1211
  }
1212
  },
1213
  "outputs": {
@@ -1217,7 +1216,7 @@
1217
  {
1218
  "name": "materialized_prefill_q512_h1_d64_bias",
1219
  "provenance": {
1220
- "notes": "Bias companion for the portable materialized f32 path; the even KV length exercises its register-cached vec4 softmax while locking fused Q bias and post-normalization V bias."
1221
  },
1222
  "attrs": { "num_heads": 1, "scale": 0.125 },
1223
  "inputs": {
@@ -1281,7 +1280,7 @@
1281
  {
1282
  "name": "materialized_sgmat_cached_q520_kv516_h2_d80_all_tails",
1283
  "provenance": {
1284
- "notes": "Generalized subgroup-matrix coverage: d80 exercises the BK32 tail, q520/kv516 exercise both matrix tile tails, and kv516 keeps the register-cached vec4 softmax eligible."
1285
  },
1286
  "attrs": { "num_heads": 2, "scale": 0.11180339887498948 },
1287
  "inputs": {
@@ -1298,7 +1297,7 @@
1298
  "valueT": {
1299
  "dtype": "float32",
1300
  "shape": [1, 516, 160],
1301
- "data": { "kind": "fillFloat32", "scale": 0.06, "sinStep": 0.029, "cosStep": 0.017 }
1302
  }
1303
  },
1304
  "outputs": {
@@ -1308,7 +1307,7 @@
1308
  {
1309
  "name": "materialized_sgmat_q520_kv516_h2_d80_all_tails_bias",
1310
  "provenance": {
1311
- "notes": "Bias twin on the guarded Q tile load: d80 is not a multiple of 32, so the select-form branch is unavailable and the bounds-checked load carries the bias instead. q520/kv516 keep both matrix tile tails live, where a bias applied to a padded row would leak into the result."
1312
  },
1313
  "attrs": { "num_heads": 2, "scale": 0.11180339887498948 },
1314
  "inputs": {
@@ -1357,7 +1356,7 @@
1357
  "valueT": {
1358
  "dtype": "float32",
1359
  "shape": [1, 515, 80],
1360
- "data": { "kind": "fillFloat32", "scale": 0.06, "sinStep": 0.031, "cosStep": 0.019 }
1361
  }
1362
  },
1363
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 513, 80], "tolerance": 0.001, "relTolerance": 0.001 } }
@@ -1365,7 +1364,7 @@
1365
  {
1366
  "name": "materialized_sgmat_cached_batch2_q512_kv512_h1_d32",
1367
  "provenance": {
1368
- "notes": "Batch and narrow-head coverage for the generalized selector; two batches also exercise the workgroup-z to batch/head mapping."
1369
  },
1370
  "attrs": { "num_heads": 1, "scale": 0.17677669529663687 },
1371
  "inputs": {
@@ -1382,7 +1381,7 @@
1382
  "valueT": {
1383
  "dtype": "float32",
1384
  "shape": [2, 512, 32],
1385
- "data": { "kind": "fillFloat32", "scale": 0.06, "sinStep": 0.025, "cosStep": 0.013 }
1386
  }
1387
  },
1388
  "outputs": { "outputT": { "dtype": "float32", "shape": [2, 512, 32], "tolerance": 0.001, "relTolerance": 0.001 } }
@@ -1645,6 +1644,76 @@
1645
  },
1646
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 4, 12], "tolerance": 0.00002 } }
1647
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1648
  {
1649
  "name": "attn_bias_flash_f16_b1_h8_s32",
1650
  "attrs": { "num_heads": 8 },
@@ -1662,7 +1731,7 @@
1662
  "valueT": {
1663
  "dtype": "float16",
1664
  "shape": [1, 32, 256],
1665
- "data": { "kind": "fillFloat32", "sinStep": 0.07, "cosStep": 0.41 }
1666
  },
1667
  "attentionBiasT": {
1668
  "dtype": "float16",
@@ -1675,7 +1744,7 @@
1675
  {
1676
  "name": "flash_q30_below_cluster_h9_d64_no_bias",
1677
  "provenance": {
1678
- "notes": "One query row below the cooperative cluster threshold (qSeq 30 < 31) while qSeq*heads=270 still clears the flash occupancy floor, and kvSeq stays far under the split-K thresholds. Locks the one-query-row-per-workgroup flash kernel: the subgroup form on fixed-width tiers, the portable form on tiers without subgroups."
1679
  },
1680
  "attrs": { "num_heads": 9, "scale": 0.125 },
1681
  "inputs": {
@@ -1702,7 +1771,7 @@
1702
  {
1703
  "name": "flash_q30_below_cluster_h9_d64_bias",
1704
  "provenance": {
1705
- "notes": "Fused-QKV-bias sibling of flash_q30_below_cluster_h9_d64_no_bias: same sub-cluster query count, so the biased flash kernel runs per query row instead of the clustered or split-K forms."
1706
  },
1707
  "attrs": { "num_heads": 9, "scale": 0.125 },
1708
  "inputs": {
@@ -1734,7 +1803,7 @@
1734
  {
1735
  "name": "flash_q30_below_cluster_h9_d64_attn_bias",
1736
  "provenance": {
1737
- "notes": "Additive attention-bias sibling of flash_q30_below_cluster_h9_d64_no_bias: qSeq 30 keeps the tiled prefill kernel (qSeq >= 31) out, so the per-query-row attention-bias flash kernel runs on tiers without subgroups."
1738
  },
1739
  "attrs": { "num_heads": 9, "scale": 0.125 },
1740
  "inputs": {
@@ -1891,7 +1960,7 @@
1891
  {
1892
  "name": "materialized_sgmat_q512_kv512_h2_d64_offset_value_scale_lock",
1893
  "provenance": {
1894
- "notes": "The other materialized-sgmat fixtures draw V from a zero-mean sinusoid, so a softmax average over hundreds of keys cancels to ~1e-5 and the 4e-4 absolute tolerance swamps any multiplicative error: halving the apply output passes every one of them. Offsetting V to oscillate about 1.0 makes the attention average O(1), so the normalization itself is under test - the denominator the row-stats pass publishes, and the divide the apply performs with it."
1895
  },
1896
  "attrs": { "num_heads": 2, "scale": 0.125 },
1897
  "inputs": {
@@ -1918,7 +1987,7 @@
1918
  {
1919
  "name": "materialized_sgmat_fused_q1024_kv1024_h4_d64_offset_value_scale_lock",
1920
  "provenance": {
1921
- "notes": "Above the fused-softmax score-byte threshold (4 heads x 1024 x 1024 x 4B = 16.8 MiB), so this is the row-stats route rather than the separate softmax pass. V oscillates about 1.0 so the attention average is O(1) and the normalization is actually under test: halving the apply output, or doubling either the per-tile or the combined denominator, fails this and passes every zero-mean fixture."
1922
  },
1923
  "attrs": { "num_heads": 4, "scale": 0.125 },
1924
  "inputs": {
@@ -1977,7 +2046,7 @@
1977
  {
1978
  "name": "splitk_decode_h4_d64_kv2048_no_bias_ramp_value_scale_lock",
1979
  "provenance": {
1980
- "notes": "Every no-bias decode split-K fixture draws V from a zero-mean sinusoid, so the softmax average over 2048 keys cancels to ~5e-5 while the absolute tolerance is 3e-4: halving the apply output, or dropping the exp-weighted rescale in the cross-partition combine, passes all nine of them (the bias siblings only escape because the V bias survives the average). A monotone V ramp makes each output element 0.5 + 1.5 * (weighted mean key index) / kvSeq, which is O(1) - so a uniform scale error is visible - and is a direct function of the per-partition weights, so a combine that mixes partitions with the wrong exponent offset moves it too."
1981
  },
1982
  "attrs": { "num_heads": 4, "scale": 0.125 },
1983
  "inputs": {
@@ -2002,7 +2071,7 @@
2002
  {
2003
  "name": "splitk_decode_h4_d64_kv2048_no_bias_f16_ramp_value_scale_lock",
2004
  "provenance": {
2005
- "notes": "The f16 arm of the no-bias decode split-K kernel is a separate compile (usesF16) and its only coverage was flash_decode_head_dim64_no_bias_f16, whose 0.03 absolute tolerance against a ~1e-4 expected output cannot detect a 295x scale error - the single blindest fixture in the op. Same ramped V as the f32 sibling, so the f16 accumulate/rescale path is scale-locked too."
2006
  },
2007
  "attrs": { "num_heads": 4, "scale": 0.125 },
2008
  "inputs": {
@@ -2027,7 +2096,7 @@
2027
  {
2028
  "name": "flash_q32_h4_d128_no_bias_f16_ramp_value_scale_lock",
2029
  "provenance": {
2030
- "notes": "qkv_no_bias_flash_q32_broadcast and qkv_no_bias_flash_q32_shared are reached by exactly one fixture, flash_self_attention_head_dim128_no_bias_f16, whose zero-mean V averages to ~1.8e-3 under a 0.03 absolute tolerance - a 16x scale error passes. Same shape, dtype and route with a monotone V ramp so each query row lands at 0.5 + 1.5 * (weighted mean key index) / 64: the register-blocked q32 epilogue divide is now under test, and the per-query variation also catches a broadcast that staged the wrong lane's row."
2031
  },
2032
  "attrs": { "num_heads": 4, "scale": 0.08838834764831845 },
2033
  "inputs": {
@@ -2052,7 +2121,7 @@
2052
  {
2053
  "name": "short_query_splitk_single_partition_q16_kv2048_h8_d32_ramp_value_scale_lock",
2054
  "provenance": {
2055
- "notes": "The adaptive short-query path uses one partition when batch*queries*heads already supplies enough work, so normalization stays in-kernel instead of using the cross-partition combine. A ramped V keeps each of the 16 query-row outputs O(1), making scale errors observable where a zero-mean companion averages to 5.8e-5 under a 5e-4 tolerance."
2056
  },
2057
  "attrs": { "num_heads": 8, "scale": 0.17677669529663687 },
2058
  "inputs": {
@@ -2073,6 +2142,179 @@
2073
  }
2074
  },
2075
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 16, 256], "tolerance": 0.00005 } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2076
  }
2077
  ]
2078
  }
 
1
  {
 
2
  "cases": [
3
  {
4
  "name": "cross_attention_no_bias",
 
185
  "provenance": {
186
  "source": "onnxruntime/test/contrib_ops/multihead_attention_op_test.cc",
187
  "test": "MultiHeadAttentionTest.CrossAttention_Batch1_HeadSize16",
188
+ "notes": "ORT cross-attention with batch 1, two query rows, three key/value rows, two heads of width 16, and no bias."
189
  },
190
  "attrs": { "num_heads": 2 },
191
  "inputs": {
 
278
  "valueT": {
279
  "dtype": "float32",
280
  "shape": [1, 64, 256],
281
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.02454369261, "cosStep": 0.0007669903939 }
282
  }
283
  },
284
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 64, 256], "tolerance": 0.0002 } }
 
349
  "valueT": {
350
  "dtype": "float32",
351
  "shape": [1, 512, 256],
352
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.02454369261, "cosStep": 0.00009587379924 }
353
  }
354
  },
355
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 1, 256], "tolerance": 0.0002 } }
 
398
  "valueT": {
399
  "dtype": "float32",
400
  "shape": [1, 2048, 256],
401
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.02454369261, "cosStep": 0.00002396844981 }
402
  }
403
  },
404
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 1, 256], "tolerance": 0.0003 } }
 
406
  {
407
  "name": "splitk_decode_head_dim128_no_bias_kv2048",
408
  "provenance": {
409
+ "notes": "A compact decode with eight heads, head size 128, and a long KV sequence exercises split-K attention."
410
  },
411
  "attrs": { "num_heads": 2, "scale": 0.08838834764831845 },
412
  "inputs": {
 
423
  "valueT": {
424
  "dtype": "float32",
425
  "shape": [1, 2048, 256],
426
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.02454369261, "cosStep": 0.00002396844981 }
427
  }
428
  },
429
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 1, 256], "tolerance": 0.0003 } }
 
445
  "valueT": {
446
  "dtype": "float32",
447
  "shape": [2, 768, 512],
448
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.0122718463, "cosStep": 0.00001597896654 }
449
  }
450
  },
451
  "outputs": { "outputT": { "dtype": "float32", "shape": [2, 1, 512], "tolerance": 0.0003 } }
 
507
  {
508
  "name": "flash_self_attention_head_dim128_no_bias_f16",
509
  "provenance": {
510
+ "notes": "A float16 prefill with head size 128 exercises the q32 broadcast route with subgroups and the shared-memory q32 route without them."
511
  },
512
  "attrs": { "num_heads": 4, "scale": 0.08838834764831845 },
513
  "inputs": {
 
524
  "valueT": {
525
  "dtype": "float16",
526
  "shape": [1, 64, 512],
527
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.0122718463, "cosStep": 0.000383495197 }
528
  }
529
  },
530
+ "outputs": { "outputT": { "dtype": "float16", "shape": [1, 64, 512], "tolerance": 0.002 } }
531
  },
532
  {
533
  "name": "flash_decode_head_dim64_no_bias_f16",
 
546
  "valueT": {
547
  "dtype": "float16",
548
  "shape": [1, 512, 256],
549
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.02454369261, "cosStep": 0.00009587379924 }
550
  }
551
  },
552
+ "outputs": { "outputT": { "dtype": "float16", "shape": [1, 1, 256], "tolerance": 0.002 } }
553
  },
554
  {
555
  "name": "cross_attention_projection_bias_f16",
 
582
  "name": "causal_cross_attention_q2_kv4_exact",
583
  "provenance": {
584
  "source": "onnxruntime/contrib_ops/cpu/bert/attention_helper.h",
585
+ "notes": "Upper-left causal masking with two queries and four keys. The second query averages only the first two values, producing exactly 1.5."
586
  },
587
  "attrs": { "num_heads": 1, "unidirectional": 1 },
588
  "inputs": {
 
661
  {
662
  "name": "dispatch_cliff_qseq_65537",
663
  "provenance": {
664
+ "notes": "A 65,537-row query exceeds the flash-path cap and exercises the scalar fallback's folded dispatch. The x dimension is capped at 65,535, so a grid-stride iteration handles the final two distinct rows; two keys keep expected-value evaluation compact."
665
  },
666
  "attrs": { "num_heads": 1 },
667
  "inputs": {
 
757
  {
758
  "name": "cluster_prefill_qseq64_h16_d64_compact",
759
  "provenance": {
760
+ "notes": "A compact sequence-64, 16-head prefill exercises the clustered route and its no-subgroups fallback."
761
  },
762
  "attrs": { "num_heads": 16, "scale": 0.125 },
763
  "inputs": {
 
784
  {
785
  "name": "flux2_joint_d128_q32_h8_compact",
786
  "provenance": {
787
+ "notes": "Head size 128 and `qSeq * heads >= 256` keep the flash-cluster route and its no-subgroups fallback eligible at a compact scale."
788
  },
789
  "attrs": { "num_heads": 8, "scale": 0.08838834764831845 },
790
  "inputs": {
 
811
  {
812
  "name": "flux2_joint_d128_q32_h8_bias_compact",
813
  "provenance": {
814
+ "notes": "A compact fused-bias case exercises the boundary of 32 four-wide head segments across 32 lanes."
815
  },
816
  "attrs": { "num_heads": 8, "scale": 0.08838834764831845 },
817
  "inputs": {
 
868
  {
869
  "name": "q31_kv511_h8_d64_double_threshold_compact",
870
  "provenance": {
871
+ "notes": "`qSeq * heads = 248` and `kvSeq = 511` lie immediately below the two flash admission boundaries, selecting the scalar fallback."
872
  },
873
  "attrs": { "num_heads": 8, "scale": 0.125 },
874
  "inputs": {
 
885
  "valueT": {
886
  "dtype": "float32",
887
  "shape": [1, 511, 512],
888
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.0122718463, "cosStep": 0.0000480307096 }
889
  }
890
  },
891
  "outputs": {
 
912
  "valueT": {
913
  "dtype": "float32",
914
  "shape": [1, 2048, 128],
915
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.04908738521, "cosStep": 0.00004793689962 }
916
  }
917
  },
918
  "outputs": {
 
937
  "valueT": {
938
  "dtype": "float32",
939
  "shape": [1, 512, 128],
940
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.04908738521, "cosStep": 0.0001917475985 }
941
  }
942
  },
943
  "outputs": {
 
947
  {
948
  "name": "small_head_parallel_q3_kv65_h2_d6_compact",
949
  "provenance": {
950
+ "notes": "Head size 6 exercises the key-parallel fallback with multiple keys per lane and a compact expected output."
951
  },
952
  "attrs": { "num_heads": 2, "scale": 0.408248290463863 },
953
  "inputs": {
 
974
  {
975
  "name": "small_head_parallel_f16_q3_kv65_h2_d6",
976
  "provenance": {
977
+ "notes": "Float16 head size 6 exercises the key-parallel fallback. Values remain near one while queries and keys stay small, keeping scale errors observable."
978
  },
979
  "attrs": { "num_heads": 2, "scale": 0.408248290463863 },
980
  "inputs": {
 
999
  {
1000
  "name": "short_query_splitk_single_partition_q16_kv2048_h8_d32",
1001
  "provenance": {
1002
+ "notes": "Sixteen queries over eight heads supply 128 workgroups, so the adaptive short-query flash path uses one K partition and normalizes in-kernel."
1003
  },
1004
  "attrs": { "num_heads": 8, "scale": 0.17677669529663687 },
1005
  "inputs": {
 
1016
  "valueT": {
1017
  "dtype": "float32",
1018
  "shape": [1, 2048, 256],
1019
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.02454369261, "cosStep": 0.00002396844981 }
1020
  }
1021
  },
1022
  "outputs": {
 
1043
  "valueT": {
1044
  "dtype": "float32",
1045
  "shape": [1, 512, 128],
1046
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.04908738521, "cosStep": 0.0001917475985 }
1047
  }
1048
  },
1049
  "outputs": {
 
1078
  {
1079
  "name": "materialized_sgmat_fused_f16_q1024_h4_d80_no_bias",
1080
  "provenance": {
1081
+ "notes": "Float16 head size 80 is not divisible by 32, selecting guarded tile staging on the fused materialized route at the 16 MiB score threshold."
1082
  },
1083
  "attrs": { "num_heads": 4, "scale": 0.125 },
1084
  "inputs": {
 
1103
  {
1104
  "name": "materialized_sgmat_q512_kv512_h1_d128_bias",
1105
  "provenance": {
1106
+ "notes": "Head size 128 selects the subgroup-matrix prefill's unguarded Q tile load. Q bias is fused into that load and V bias follows normalization; K bias adds one constant to every row logit and must cancel under softmax."
1107
  },
1108
  "attrs": { "num_heads": 1, "scale": 0.08838834764831845 },
1109
  "inputs": {
 
1135
  {
1136
  "name": "portable_head_dim320_no_bias",
1137
  "provenance": {
1138
+ "notes": "Head size 320 exercises the portable route used by wide vision and multimodal attention blocks."
1139
  },
1140
  "attrs": { "num_heads": 1, "scale": 0.05590169943749474 },
1141
  "inputs": {
 
1162
  {
1163
  "name": "splitk_decode_q1_kv1500_h6_d64_underfilled_wide_subgroup",
1164
  "provenance": {
1165
+ "notes": "A six-head f32 decode geometry where head size 64 supplies only 16 vec4 dot products to a 32-lane subgroup. The case exercises the portable split-K route instead of an underfilled cooperative QK subgroup."
1166
  },
1167
  "attrs": { "num_heads": 6, "scale": 0.125 },
1168
  "inputs": {
 
1179
  "valueT": {
1180
  "dtype": "float32",
1181
  "shape": [1, 1500, 384],
1182
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.01636246174, "cosStep": 0.00002181661565 }
1183
  }
1184
  },
1185
  "outputs": {
 
1206
  "valueT": {
1207
  "dtype": "float32",
1208
  "shape": [1, 1500, 128],
1209
+ "data": { "kind": "fillFloat32", "scale": 0.08, "sinStep": 0.04908738521, "cosStep": 0.00006544984695 }
1210
  }
1211
  },
1212
  "outputs": {
 
1216
  {
1217
  "name": "materialized_prefill_q512_h1_d64_bias",
1218
  "provenance": {
1219
+ "notes": "An even key/value length exercises register-cached vec4 softmax on the portable materialized route with fused Q bias and post-normalization V bias."
1220
  },
1221
  "attrs": { "num_heads": 1, "scale": 0.125 },
1222
  "inputs": {
 
1280
  {
1281
  "name": "materialized_sgmat_cached_q520_kv516_h2_d80_all_tails",
1282
  "provenance": {
1283
+ "notes": "Head size 80 exercises the BK32 tail, query length 520 and key/value length 516 exercise both matrix tile tails, and the even key count keeps register-cached vec4 softmax eligible."
1284
  },
1285
  "attrs": { "num_heads": 2, "scale": 0.11180339887498948 },
1286
  "inputs": {
 
1297
  "valueT": {
1298
  "dtype": "float32",
1299
  "shape": [1, 516, 160],
1300
+ "data": { "kind": "fillFloat32", "scale": 0.06, "sinStep": 0.03926990817, "cosStep": 0.0001522089464 }
1301
  }
1302
  },
1303
  "outputs": {
 
1307
  {
1308
  "name": "materialized_sgmat_q520_kv516_h2_d80_all_tails_bias",
1309
  "provenance": {
1310
+ "notes": "Head size 80 selects the bounds-checked Q tile load with fused bias. Query length 520 and key/value length 516 keep both matrix tails live, exposing any bias applied to padded rows."
1311
  },
1312
  "attrs": { "num_heads": 2, "scale": 0.11180339887498948 },
1313
  "inputs": {
 
1356
  "valueT": {
1357
  "dtype": "float32",
1358
  "shape": [1, 515, 80],
1359
+ "data": { "kind": "fillFloat32", "scale": 0.06, "sinStep": 0.07853981634, "cosStep": 0.0003050089955 }
1360
  }
1361
  },
1362
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 513, 80], "tolerance": 0.001, "relTolerance": 0.001 } }
 
1364
  {
1365
  "name": "materialized_sgmat_cached_batch2_q512_kv512_h1_d32",
1366
  "provenance": {
1367
+ "notes": "Two batches and head size 32 exercise the narrow-head subgroup-matrix route and the workgroup-z mapping to batch and head."
1368
  },
1369
  "attrs": { "num_heads": 1, "scale": 0.17677669529663687 },
1370
  "inputs": {
 
1381
  "valueT": {
1382
  "dtype": "float32",
1383
  "shape": [2, 512, 32],
1384
+ "data": { "kind": "fillFloat32", "scale": 0.06, "sinStep": 0.1963495408, "cosStep": 0.000383495197 }
1385
  }
1386
  },
1387
  "outputs": { "outputT": { "dtype": "float32", "shape": [2, 512, 32], "tolerance": 0.001, "relTolerance": 0.001 } }
 
1644
  },
1645
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 4, 12], "tolerance": 0.00002 } }
1646
  },
1647
+ {
1648
+ "name": "attn_bias_scalar_all_neg_inf_row_zero_q2_kv32_h8_d64",
1649
+ "provenance": {
1650
+ "notes": "BSH float32 Q/K/V uses eight heads of width 64, two query tokens, and 32 keys. Rank-4 attention bias is broadcast over batch and heads; query row 1 is -Infinity at every key while row 0 is finite. The portable scalar online-attention route must return zero for every head in row 1 without forming NaN from a zero denominator; row 0 follows the ordinary softmax-weighted value calculation."
1651
+ },
1652
+ "attrs": { "num_heads": 8 },
1653
+ "inputs": {
1654
+ "queryT": {
1655
+ "dtype": "float32",
1656
+ "shape": [1, 2, 512],
1657
+ "data": { "kind": "fillFloat32", "scale": 0.25, "sinStep": 0.017, "cosStep": 0.031 }
1658
+ },
1659
+ "keyT": {
1660
+ "dtype": "float32",
1661
+ "shape": [1, 32, 512],
1662
+ "data": { "kind": "fillFloat32", "scale": 0.25, "sinStep": 0.011, "cosStep": 0.023 }
1663
+ },
1664
+ "valueT": {
1665
+ "dtype": "float32",
1666
+ "shape": [1, 32, 512],
1667
+ "data": { "kind": "fillFloat32", "scale": 0.25, "sinStep": 0.007, "cosStep": 0.041 }
1668
+ },
1669
+ "attentionBiasT": {
1670
+ "dtype": "float32",
1671
+ "shape": [1, 1, 2, 32],
1672
+ "data": {
1673
+ "kind": "values",
1674
+ "values": [0.0, 0.193, 0.296, 0.259, 0.1, -0.105, -0.261, -0.295, -0.189, 0.005, 0.197, 0.296, 0.256, 0.096, -0.11, -0.264, -0.294, -0.185, 0.01, 0.201, 0.297, 0.254, 0.091, -0.115, -0.266, -0.293, -0.181, 0.015, 0.205, 0.298, 0.251, 0.086, "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity"]
1675
+ }
1676
+ }
1677
+ },
1678
+ "outputs": {
1679
+ "outputT": { "dtype": "float32", "shape": [1, 2, 512], "tolerance": 0.00002, "relTolerance": 0.00002 }
1680
+ }
1681
+ },
1682
+ {
1683
+ "name": "attn_bias_scalar_single_finite_key_q1_kv32_h8_d64",
1684
+ "provenance": {
1685
+ "notes": "Rank-4 bias has one finite entry at key 3 and -Infinity elsewhere. The scalar softmax therefore places all mass on key 3, so every head returns value row 3. This distinguishes a valid one-key denominator from `attn_bias_scalar_all_neg_inf_row_zero_q2_kv32_h8_d64`."
1686
+ },
1687
+ "attrs": { "num_heads": 8 },
1688
+ "inputs": {
1689
+ "queryT": {
1690
+ "dtype": "float32",
1691
+ "shape": [1, 1, 512],
1692
+ "data": { "kind": "fillFloat32", "scale": 0.25, "sinStep": 0.017, "cosStep": 0.031 }
1693
+ },
1694
+ "keyT": {
1695
+ "dtype": "float32",
1696
+ "shape": [1, 32, 512],
1697
+ "data": { "kind": "fillFloat32", "scale": 0.25, "sinStep": 0.011, "cosStep": 0.023 }
1698
+ },
1699
+ "valueT": {
1700
+ "dtype": "float32",
1701
+ "shape": [1, 32, 512],
1702
+ "data": { "kind": "fillFloat32", "scale": 0.25, "sinStep": 0.007, "cosStep": 0.041 }
1703
+ },
1704
+ "attentionBiasT": {
1705
+ "dtype": "float32",
1706
+ "shape": [1, 1, 1, 32],
1707
+ "data": {
1708
+ "kind": "values",
1709
+ "values": ["-Infinity", "-Infinity", "-Infinity", 0.0, "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity", "-Infinity"]
1710
+ }
1711
+ }
1712
+ },
1713
+ "outputs": {
1714
+ "outputT": { "dtype": "float32", "shape": [1, 1, 512], "tolerance": 0.00001, "relTolerance": 0.00001 }
1715
+ }
1716
+ },
1717
  {
1718
  "name": "attn_bias_flash_f16_b1_h8_s32",
1719
  "attrs": { "num_heads": 8 },
 
1731
  "valueT": {
1732
  "dtype": "float16",
1733
  "shape": [1, 32, 256],
1734
+ "data": { "kind": "fillFloat32", "sinStep": 0.02454369261, "cosStep": 0.001533980788 }
1735
  },
1736
  "attentionBiasT": {
1737
  "dtype": "float16",
 
1744
  {
1745
  "name": "flash_q30_below_cluster_h9_d64_no_bias",
1746
  "provenance": {
1747
+ "notes": "Query length 30 is below the cooperative-cluster threshold while 270 query-head rows clear the flash admission floor. The case exercises one-query-row-per-workgroup flash with and without subgroups."
1748
  },
1749
  "attrs": { "num_heads": 9, "scale": 0.125 },
1750
  "inputs": {
 
1771
  {
1772
  "name": "flash_q30_below_cluster_h9_d64_bias",
1773
  "provenance": {
1774
+ "notes": "Fused QKV bias with query length 30 exercises the biased one-query-row flash route below the clustered and split-K thresholds."
1775
  },
1776
  "attrs": { "num_heads": 9, "scale": 0.125 },
1777
  "inputs": {
 
1803
  {
1804
  "name": "flash_q30_below_cluster_h9_d64_attn_bias",
1805
  "provenance": {
1806
+ "notes": "Additive attention bias with query length 30 exercises the per-query-row flash route below the tiled-prefill threshold on tiers without subgroups."
1807
  },
1808
  "attrs": { "num_heads": 9, "scale": 0.125 },
1809
  "inputs": {
 
1960
  {
1961
  "name": "materialized_sgmat_q512_kv512_h2_d64_offset_value_scale_lock",
1962
  "provenance": {
1963
+ "notes": "Values oscillate around 1.0, keeping the attention average at order-one magnitude. This makes both the denominator published by row statistics and the division in the subgroup-matrix apply pass observable."
1964
  },
1965
  "attrs": { "num_heads": 2, "scale": 0.125 },
1966
  "inputs": {
 
1987
  {
1988
  "name": "materialized_sgmat_fused_q1024_kv1024_h4_d64_offset_value_scale_lock",
1989
  "provenance": {
1990
+ "notes": "The 16.8 MiB score tensor selects the fused row-statistics route. Values oscillate around 1.0 so a per-tile, combined-denominator, or apply-scale error remains observable."
1991
  },
1992
  "attrs": { "num_heads": 4, "scale": 0.125 },
1993
  "inputs": {
 
2046
  {
2047
  "name": "splitk_decode_h4_d64_kv2048_no_bias_ramp_value_scale_lock",
2048
  "provenance": {
2049
+ "notes": "A monotone value ramp makes each output an order-one function of the weighted key index. This exposes both uniform scale errors and incorrect exponential offsets in the cross-partition split-K combine."
2050
  },
2051
  "attrs": { "num_heads": 4, "scale": 0.125 },
2052
  "inputs": {
 
2071
  {
2072
  "name": "splitk_decode_h4_d64_kv2048_no_bias_f16_ramp_value_scale_lock",
2073
  "provenance": {
2074
+ "notes": "A monotone value ramp keeps float16 decode outputs at order-one magnitude, making split-K accumulation, cross-partition rescaling, and final normalization observable."
2075
  },
2076
  "attrs": { "num_heads": 4, "scale": 0.125 },
2077
  "inputs": {
 
2096
  {
2097
  "name": "flash_q32_h4_d128_no_bias_f16_ramp_value_scale_lock",
2098
  "provenance": {
2099
+ "notes": "A monotone value ramp gives each float16 q32 row a distinct order-one output, exposing the register-blocked epilogue division and an incorrect broadcast lane."
2100
  },
2101
  "attrs": { "num_heads": 4, "scale": 0.08838834764831845 },
2102
  "inputs": {
 
2121
  {
2122
  "name": "short_query_splitk_single_partition_q16_kv2048_h8_d32_ramp_value_scale_lock",
2123
  "provenance": {
2124
+ "notes": "The adaptive short-query path uses one partition because query-head parallelism already supplies enough work. A value ramp keeps all 16 outputs at order-one magnitude, exposing in-kernel normalization errors."
2125
  },
2126
  "attrs": { "num_heads": 8, "scale": 0.17677669529663687 },
2127
  "inputs": {
 
2142
  }
2143
  },
2144
  "outputs": { "outputT": { "dtype": "float32", "shape": [1, 16, 256], "tolerance": 0.00005 } }
2145
+ },
2146
+ {
2147
+ "name": "small_seq_blocked_bias_causal_1x64x64_h4d16",
2148
+ "provenance": {
2149
+ "notes": "Short-sequence attention with 64 queries, 64 keys, four heads of width 16, fused bias, and causal masking."
2150
+ },
2151
+ "attrs": { "num_heads": 4, "unidirectional": 1 },
2152
+ "inputs": {
2153
+ "queryT": {
2154
+ "dtype": "float32",
2155
+ "shape": [1, 64, 64],
2156
+ "data": { "kind": "fillFloat32", "sinStep": 0.17, "cosStep": 0.31 }
2157
+ },
2158
+ "keyT": {
2159
+ "dtype": "float32",
2160
+ "shape": [1, 64, 64],
2161
+ "data": { "kind": "fillFloat32", "sinStep": 0.11, "cosStep": 0.23 }
2162
+ },
2163
+ "valueT": {
2164
+ "dtype": "float32",
2165
+ "shape": [1, 64, 64],
2166
+ "data": { "kind": "fillFloat32", "sinStep": 0.07, "cosStep": 0.41 }
2167
+ },
2168
+ "biasT": {
2169
+ "dtype": "float32",
2170
+ "shape": [192],
2171
+ "data": { "kind": "fillFloat32", "sinStep": 0.19, "cosStep": 0.13 }
2172
+ }
2173
+ },
2174
+ "outputs": { "outputT": { "dtype": "float32", "shape": [1, 64, 64], "tolerance": 0.00002 } }
2175
+ },
2176
+ {
2177
+ "name": "small_seq_blocked_no_bias_causal_q13_kv40_h2d16",
2178
+ "provenance": {
2179
+ "notes": "Thirteen queries leave five active rows in the final eight-row block, and the key count is not divisible by sixteen. Causal masking makes out-of-range lanes and inactive rows contribute the identity partial."
2180
+ },
2181
+ "attrs": { "num_heads": 2, "unidirectional": 1 },
2182
+ "inputs": {
2183
+ "queryT": {
2184
+ "dtype": "float32",
2185
+ "shape": [1, 13, 32],
2186
+ "data": { "kind": "fillFloat32", "sinStep": 0.17, "cosStep": 0.31 }
2187
+ },
2188
+ "keyT": {
2189
+ "dtype": "float32",
2190
+ "shape": [1, 40, 32],
2191
+ "data": { "kind": "fillFloat32", "sinStep": 0.11, "cosStep": 0.23 }
2192
+ },
2193
+ "valueT": {
2194
+ "dtype": "float32",
2195
+ "shape": [1, 40, 32],
2196
+ "data": { "kind": "fillFloat32", "sinStep": 0.07, "cosStep": 0.41 }
2197
+ }
2198
+ },
2199
+ "outputs": { "outputT": { "dtype": "float32", "shape": [1, 13, 32], "tolerance": 0.00002 } }
2200
+ },
2201
+ {
2202
+ "name": "small_seq_blocked_bias_bidirectional_q24_kv40_h3d32",
2203
+ "provenance": {
2204
+ "notes": "Head dim 32 (eight vec4 per row, the route's register ceiling) with bias and no causal mask; kv > q so every query sees all forty keys."
2205
+ },
2206
+ "attrs": { "num_heads": 3, "unidirectional": 0 },
2207
+ "inputs": {
2208
+ "queryT": {
2209
+ "dtype": "float32",
2210
+ "shape": [1, 24, 96],
2211
+ "data": { "kind": "fillFloat32", "sinStep": 0.17, "cosStep": 0.31 }
2212
+ },
2213
+ "keyT": {
2214
+ "dtype": "float32",
2215
+ "shape": [1, 40, 96],
2216
+ "data": { "kind": "fillFloat32", "sinStep": 0.11, "cosStep": 0.23 }
2217
+ },
2218
+ "valueT": {
2219
+ "dtype": "float32",
2220
+ "shape": [1, 40, 96],
2221
+ "data": { "kind": "fillFloat32", "sinStep": 0.07, "cosStep": 0.41 }
2222
+ },
2223
+ "biasT": {
2224
+ "dtype": "float32",
2225
+ "shape": [288],
2226
+ "data": { "kind": "fillFloat32", "sinStep": 0.19, "cosStep": 0.13 }
2227
+ }
2228
+ },
2229
+ "outputs": { "outputT": { "dtype": "float32", "shape": [1, 24, 96], "tolerance": 0.00002 } }
2230
+ },
2231
+ {
2232
+ "name": "small_seq_blocked_no_bias_bidirectional_q3_kv5_h1d8",
2233
+ "provenance": {
2234
+ "notes": "Fewer keys than key lanes (5 keys, 8 lanes after the power-of-two floor) and fewer queries than one block: most lanes hold no key and merge identities."
2235
+ },
2236
+ "attrs": { "num_heads": 1, "unidirectional": 0 },
2237
+ "inputs": {
2238
+ "queryT": {
2239
+ "dtype": "float32",
2240
+ "shape": [1, 3, 8],
2241
+ "data": { "kind": "fillFloat32", "sinStep": 0.17, "cosStep": 0.31 }
2242
+ },
2243
+ "keyT": {
2244
+ "dtype": "float32",
2245
+ "shape": [1, 5, 8],
2246
+ "data": { "kind": "fillFloat32", "sinStep": 0.11, "cosStep": 0.23 }
2247
+ },
2248
+ "valueT": {
2249
+ "dtype": "float32",
2250
+ "shape": [1, 5, 8],
2251
+ "data": { "kind": "fillFloat32", "sinStep": 0.07, "cosStep": 0.41 }
2252
+ }
2253
+ },
2254
+ "outputs": { "outputT": { "dtype": "float32", "shape": [1, 3, 8], "tolerance": 0.00002 } }
2255
+ },
2256
+ {
2257
+ "name": "small_seq_blocked_bias_causal_batch2_q16_kv16_h2d16",
2258
+ "provenance": {
2259
+ "notes": "Two batches of two heads at exactly two query blocks; the batch axis rides dispatch z and the bias is shared across batches."
2260
+ },
2261
+ "attrs": { "num_heads": 2, "unidirectional": 1 },
2262
+ "inputs": {
2263
+ "queryT": {
2264
+ "dtype": "float32",
2265
+ "shape": [2, 16, 32],
2266
+ "data": { "kind": "fillFloat32", "sinStep": 0.17, "cosStep": 0.31 }
2267
+ },
2268
+ "keyT": {
2269
+ "dtype": "float32",
2270
+ "shape": [2, 16, 32],
2271
+ "data": { "kind": "fillFloat32", "sinStep": 0.11, "cosStep": 0.23 }
2272
+ },
2273
+ "valueT": {
2274
+ "dtype": "float32",
2275
+ "shape": [2, 16, 32],
2276
+ "data": { "kind": "fillFloat32", "sinStep": 0.07, "cosStep": 0.41 }
2277
+ },
2278
+ "biasT": {
2279
+ "dtype": "float32",
2280
+ "shape": [96],
2281
+ "data": { "kind": "fillFloat32", "sinStep": 0.19, "cosStep": 0.13 }
2282
+ }
2283
+ },
2284
+ "outputs": { "outputT": { "dtype": "float32", "shape": [2, 16, 32], "tolerance": 0.00002 } }
2285
+ },
2286
+ {
2287
+ "name": "small_seq_positive_infinity_query",
2288
+ "provenance": {
2289
+ "notes": "The unmasked whole-head route always includes an `exp(0)` term, so a finite row cannot have a zero denominator. A positive-infinity query produces non-finite scores and the expected NaN result."
2290
+ },
2291
+ "attrs": { "num_heads": 2 },
2292
+ "inputs": {
2293
+ "queryT": {
2294
+ "dtype": "float32",
2295
+ "shape": [1, 4, 8],
2296
+ "data": { "kind": "cycle", "values": ["Infinity", 0.5, -0.25, 0.75, 0.125, -0.5, 0.25, 1.0] }
2297
+ },
2298
+ "keyT": {
2299
+ "dtype": "float32",
2300
+ "shape": [1, 4, 8],
2301
+ "data": { "kind": "fillFloat32", "sinStep": 0.011, "cosStep": 0.023, "scale": 0.4 }
2302
+ },
2303
+ "valueT": {
2304
+ "dtype": "float32",
2305
+ "shape": [1, 4, 8],
2306
+ "data": { "kind": "fillFloat32", "sinStep": 0.007, "cosStep": 0.041, "scale": 0.4 }
2307
+ }
2308
+ },
2309
+ "outputs": {
2310
+ "outputT": {
2311
+ "dtype": "float32",
2312
+ "shape": [1, 4, 8],
2313
+ "tolerance": 0.00002,
2314
+ "relTolerance": 0.00002,
2315
+ "allowNaN": true
2316
+ }
2317
+ }
2318
  }
2319
  ]
2320
  }