@@ -32,6 +32,16 @@ $if V_CACHE_STORAGE == "buffer":
3232
3333#define NUM_WORKERS_PER_OUT 64
3434
35+ $if GQA:
36+ #define GQA
37+ // Maximum grouped-query-attention group size (Hq / Hkv) supported by the GQA
38+ // variant. The actual group size G is passed as a specialization constant and
39+ // must satisfy G <= MAX_GROUP_SIZE. The accumulator array is sized to
40+ // MAX_GROUP_SIZE (a compile-time constant) and the group loop is bounded by
41+ // the spec-const G so the driver can fully unroll it at pipeline creation
42+ // time. Group sizes seen in practice: Llama G=4, Phi G=3, Qwen G=2.
43+ #define MAX_GROUP_SIZE 8
44+
3545${define_required_extensions(IO_STORAGE, DTYPE)}
3646
3747layout (std430) buffer ;
@@ -49,6 +59,8 @@ ${layout_declare_ubo(B, "int", "input_pos")}
4959layout (local_size_x_id = 0 , local_size_y_id = 1 , local_size_z_id = 2 ) in ;
5060
5161${layout_declare_spec_const(C, "float ", "inv_scale", "1.0 ")}
62+ $if GQA:
63+ ${layout_declare_spec_const(C, "int ", "group_size", "1 ")}
5264
5365#include "sdpa_fp_attn_weight_tile_load.glslh"
5466#include "sdpa_fp_v_cache_tile_load.glslh"
@@ -57,6 +69,177 @@ ${layout_declare_spec_const(C, "float", "inv_scale", "1.0")}
5769
5870shared FPOutTile partial_sums[NUM_WORKERS_PER_OUT];
5971
72+ #ifdef GQA
73+
74+ /*
75+ * GQA-reuse variant of the AV coop GEMV.
76+ *
77+ * Grouped-query attention shares one KV head across G = Hq / Hkv query heads.
78+ * The AV computation out[q_h, d] = sum_c attn[c, q_h] * V[c, kv_h(q_h), d]
79+ * reads the SAME V cache for every query head in a group. In the per-query-head
80+ * coop variant each of the Hq heads gets its own workgroup and independently
81+ * re-loads the shared V cache, so V (the dominant traffic — head_dim wide per
82+ * context texel) is read G times. This variant assigns ONE workgroup per
83+ * (d4, kv_h): it loads each V texel once and reuses it across all G query heads
84+ * in the group, producing G output texels. This cuts V-cache traffic ~Gx for a
85+ * bandwidth-bound kernel. Dispatch sets the global wg z-dim to Hkv (not Hq).
86+ */
87+
88+ void main() {
89+ const int worker_id = int (gl_LocalInvocationID.y);
90+
91+ const int tile_idx_x = int (gl_GlobalInvocationID.x);
92+ // idx along the K/V head dim
93+ const int kv_h = int (gl_GlobalInvocationID.z);
94+
95+ // idx along the output head_dim dim
96+ const int d = tile_idx_x * TILE_N;
97+ const int d4 = div_4(d);
98+
99+ // idx along the output seq_len dim. Note that for this shader seq_len will be
100+ // 1.
101+ const int s = 0 ;
102+
103+ // texel size of head_dim
104+ const int D4 = div_up_4(q_sizes.x);
105+ // number of Q heads
106+ const int Q_H = q_sizes.y;
107+ // sequence length
108+ const int S = q_sizes.z;
109+ const int S_aligned = align_up_4(S);
110+
111+ // number of K/V heads
112+ const int KV_H = v_sizes.y;
113+ // Max context length
114+ const int C = v_sizes.z;
115+ const int C4 = div_up_4(C);
116+
117+ const int G = group_size;
118+ // First query head in this group.
119+ const int q_h_base = kv_h * G;
120+
121+ // current context length
122+ const int context_len = input_pos + S;
123+ const int context_texel_len = div_up_4(context_len);
124+
125+ // bounds check
126+ if (d4 >= D4 || s >= S || kv_h >= KV_H) {
127+ return ;
128+ }
129+
130+ // With head_dim output-tiling (TILE_N4 > 1) each workgroup owns TILE_N4
131+ // head_dim texels. Whether this workgroup owns a partial tile (only possible
132+ // on the final tile when D4 % TILE_N4 != 0). Uniform across the workgroup, so
133+ // the branch selecting the checked vs unchecked V load below is a uniform
134+ // branch. For the common even-D4 case (D=64 -> D4=16, D=128 -> D4=32, and
135+ // always for TILE_N4 == 1) this is false and the fast unchecked path is taken.
136+ const bool partial_n_tile = (d4 + TILE_N4) > D4;
137+
138+ FPOutTile out_tile[MAX_GROUP_SIZE];
139+ [[unroll]] for (int g = 0 ; g < MAX_GROUP_SIZE; ++ g) {
140+ if (g >= G) {
141+ break ;
142+ }
143+ initialize(out_tile[g]);
144+ }
145+
146+ FPInputTile attn_weight_tile;
147+ FPWeightTile w_tile;
148+
149+ const int context_len_aligned_down = context_len - mod_4(context_len);
150+ const int C4_limit = div_up_4(context_len_aligned_down);
151+
152+ // Main loop: each thread strides over context texels. The single V load per
153+ // texel is reused across all G query heads in the group.
154+ for (int c4 = worker_id; c4 < C4_limit; c4 += NUM_WORKERS_PER_OUT) {
155+ const int c = mul_4(c4);
156+
157+ // The TILE_N4 V texels are loaded once per context texel and reused across
158+ // all G query heads in the group. Bounds-check the head_dim index only on a
159+ // partial final tile (uniform branch, see partial_n_tile).
160+ if (partial_n_tile) {
161+ load_v_cache_tile_with_checks(
162+ w_tile, d4, c, kv_h, D4, context_len, C, KV_H);
163+ } else {
164+ load_v_cache_tile_no_checks(
165+ w_tile, d4, c, kv_h, D4, context_len, C, KV_H);
166+ }
167+
168+ [[unroll]] for (int g = 0 ; g < MAX_GROUP_SIZE; ++ g) {
169+ if (g >= G) {
170+ break ;
171+ }
172+ load_attn_weight_tile_no_checks(
173+ attn_weight_tile,
174+ c4,
175+ s,
176+ q_h_base + g,
177+ context_texel_len,
178+ S_aligned,
179+ Q_H);
180+ fp_accumulate_with_fp_weight(out_tile[g], attn_weight_tile, w_tile);
181+ }
182+ }
183+
184+ // first worker in the work group will handle final texel, which may contain
185+ // padding elements.
186+ if (worker_id == 0 ) {
187+ for (int c4 = C4_limit; c4 < context_texel_len; c4++ ) {
188+ const int c = mul_4(c4);
189+
190+ load_v_cache_tile_with_checks(
191+ w_tile, d4, c, kv_h, D4, context_len, C, KV_H);
192+
193+ [[unroll]] for (int g = 0 ; g < MAX_GROUP_SIZE; ++ g) {
194+ if (g >= G) {
195+ break ;
196+ }
197+ load_attn_weight_tile_with_checks(
198+ attn_weight_tile,
199+ c4,
200+ s,
201+ q_h_base + g,
202+ context_texel_len,
203+ S_aligned,
204+ Q_H);
205+ fp_accumulate_with_fp_weight(out_tile[g], attn_weight_tile, w_tile);
206+ }
207+ }
208+ }
209+
210+ // Combine the per-worker partial sums for each group with a shared-memory tree
211+ // reduction. partial_sums is reused across groups; no trailing barrier is
212+ // needed because each worker only writes its own slot and the next group's
213+ // leading barrier orders those writes after this group's reduction reads
214+ // (worker 0's store reads only slot 0, which only worker 0 writes).
215+ [[unroll]] for (int g = 0 ; g < MAX_GROUP_SIZE; ++ g) {
216+ if (g >= G) {
217+ break ;
218+ }
219+ partial_sums[worker_id] = out_tile[g];
220+
221+ memoryBarrierShared();
222+ barrier();
223+
224+ for (int i = NUM_WORKERS_PER_OUT / 2 ; i > 0 ; i /= 2 ) {
225+ if (worker_id < i) {
226+ accumulate_out_tile_with_out_tile(
227+ partial_sums[worker_id], partial_sums[worker_id + i]);
228+ }
229+ memoryBarrierShared();
230+ barrier();
231+ }
232+
233+ if (worker_id == 0 ) {
234+ out_tile[g] = partial_sums[0 ];
235+ store_sdpa_out_tile_with_checks(
236+ out_tile[g], d4, s, q_h_base + g, D4, S, Q_H);
237+ }
238+ }
239+ }
240+
241+ #else
242+
60243/*
61244 * See the tiled variant of this shader for the implemented behavior. This
62245 * shader is implements an optimization for cases where sequence length is 1; in
@@ -197,3 +380,5 @@ void main() {
197380 Q_H);
198381 }
199382}
383+
384+ #endif // GQA
0 commit comments