Skip to main content

oxicuda_webgpu/
shader.rs

1//! WGSL shader source generation for common compute kernels.
2//!
3//! Each function returns a complete, self-contained WGSL source string
4//! suitable for passing to `device.create_shader_module()`.
5
6/// Generate WGSL source for a tiled GEMM kernel: `C = alpha * op(A) * op(B) + beta * C`.
7///
8/// Uses `tile_size × tile_size` workgroup tiles with shared-memory staging.
9///
10/// `op(A)` is the logical `m × k` left operand, `op(B)` the logical `k × n`
11/// right operand.  The physical layout of the stored buffers depends on the
12/// transpose flags carried in `GemmParams`:
13///
14/// * `trans_a == 0` — `a` is stored row-major as `m × k`; element `(r, i)` is
15///   at `a[r * lda + i]`.
16/// * `trans_a != 0` — `a` is stored row-major as `k × m` (the transpose of the
17///   logical operand); element `(r, i)` of `op(A)` is at `a[i * lda + r]`.
18/// * `trans_b == 0` — `b` is stored row-major as `k × n`; element `(i, c)` is
19///   at `b[i * ldb + c]`.
20/// * `trans_b != 0` — `b` is stored row-major as `n × k`; element `(i, c)` of
21///   `op(B)` is at `b[c * ldb + i]`.
22///
23/// `lda` / `ldb` / `ldc` are the physical row strides (leading dimensions) of
24/// the stored buffers, carried in `GemmParams`.  They default to the packed
25/// width but may be larger to address a padded buffer or sub-matrix view; `C`
26/// is written at `c[row * ldc + col]`.
27///
28/// The transpose flags are runtime uniforms, so a single shader module serves
29/// all four NN / NT / TN / TT combinations.
30///
31/// # Arguments
32///
33/// * `tile_size` — workgroup tile dimension.  Because the workgroup is
34///   `tile_size × tile_size`, `tile_size * tile_size` must not exceed WebGPU's
35///   baseline `maxComputeInvocationsPerWorkgroup` of 256, so 16 is the portable
36///   maximum (e.g. 8 or 16).
37pub fn gemm_wgsl(tile_size: u32) -> String {
38    format!(
39        r#"
40struct GemmParams {{
41    m:       u32,
42    n:       u32,
43    k:       u32,
44    alpha:   f32,
45    beta:    f32,
46    trans_a: u32,
47    trans_b: u32,
48    lda:     u32,
49    ldb:     u32,
50    ldc:     u32,
51    _pad0:   u32,
52    _pad1:   u32,
53}}
54
55@group(0) @binding(0) var<storage, read>       a:      array<f32>;
56@group(0) @binding(1) var<storage, read>       b:      array<f32>;
57@group(0) @binding(2) var<storage, read_write> c:      array<f32>;
58@group(0) @binding(3) var<uniform>             params: GemmParams;
59
60var<workgroup> tile_a: array<array<f32, {ts}>, {ts}>;
61var<workgroup> tile_b: array<array<f32, {ts}>, {ts}>;
62
63// op(A)[r, i] — logical m×k left operand.  `lda` is the physical row stride of
64// the stored buffer (>= the packed width), supporting padded / sub-matrix views.
65fn load_a(r: u32, i: u32) -> f32 {{
66    if (r >= params.m || i >= params.k) {{ return 0.0; }}
67    if (params.trans_a == 0u) {{
68        return a[r * params.lda + i];
69    }}
70    return a[i * params.lda + r];
71}}
72
73// op(B)[i, col] — logical k×n right operand.  `ldb` is the physical row stride.
74fn load_b(i: u32, col: u32) -> f32 {{
75    if (i >= params.k || col >= params.n) {{ return 0.0; }}
76    if (params.trans_b == 0u) {{
77        return b[i * params.ldb + col];
78    }}
79    return b[col * params.ldb + i];
80}}
81
82@compute @workgroup_size({ts}, {ts})
83fn main(
84    @builtin(global_invocation_id) gid: vec3<u32>,
85    @builtin(local_invocation_id)  lid: vec3<u32>,
86) {{
87    let row = gid.y;
88    let col = gid.x;
89    let lr  = lid.y;
90    let lc  = lid.x;
91
92    var acc: f32 = 0.0;
93    let num_tiles = (params.k + {ts}u - 1u) / {ts}u;
94    for (var t: u32 = 0u; t < num_tiles; t = t + 1u) {{
95        let a_col = t * {ts}u + lc;
96        let b_row = t * {ts}u + lr;
97        tile_a[lr][lc] = load_a(row, a_col);
98        tile_b[lr][lc] = load_b(b_row, col);
99        workgroupBarrier();
100
101        for (var e: u32 = 0u; e < {ts}u; e = e + 1u) {{
102            acc += tile_a[lr][e] * tile_b[e][lc];
103        }}
104        workgroupBarrier();
105    }}
106
107    if (row >= params.m || col >= params.n) {{ return; }}
108    let idx = row * params.ldc + col;
109    c[idx] = params.alpha * acc + params.beta * c[idx];
110}}
111"#,
112        ts = tile_size
113    )
114}
115
116/// Generate WGSL source for a batched (strided) GEMM kernel.
117///
118/// For each batch `b` in `0..batch_count`:
119///   `C_b = alpha * op(A_b) * op(B_b) + beta * C_b`
120/// where `A_b` starts at `a[b * stride_a]`, etc.
121///
122/// Uses `tile_size × tile_size` workgroup tiles with shared-memory staging and
123/// Z = batch_count.  Transpose handling matches [`gemm_wgsl`]: the `trans_a` /
124/// `trans_b` uniforms select a row-major (`m × k` / `k × n`) or column-major
125/// (`k × m` / `n × k`) physical layout for each per-batch operand, with the
126/// physical row strides carried as `lda` / `ldb` / `ldc`.
127///
128/// # Arguments
129///
130/// * `tile_size` — workgroup tile dimension.  `tile_size * tile_size` must not
131///   exceed WebGPU's baseline `maxComputeInvocationsPerWorkgroup` of 256, so 16
132///   is the portable maximum (e.g. 8 or 16).
133pub fn batched_gemm_wgsl(tile_size: u32) -> String {
134    format!(
135        r#"
136struct BatchedGemmParams {{
137    m:        u32,
138    n:        u32,
139    k:        u32,
140    alpha:    f32,
141    beta:     f32,
142    batch_count: u32,
143    stride_a: u32,
144    stride_b: u32,
145    stride_c: u32,
146    trans_a:  u32,
147    trans_b:  u32,
148    lda:      u32,
149    ldb:      u32,
150    ldc:      u32,
151    _pad0:    u32,
152    _pad1:    u32,
153}}
154
155@group(0) @binding(0) var<storage, read>       a:      array<f32>;
156@group(0) @binding(1) var<storage, read>       b:      array<f32>;
157@group(0) @binding(2) var<storage, read_write> c:      array<f32>;
158@group(0) @binding(3) var<uniform>             params: BatchedGemmParams;
159
160var<workgroup> tile_a: array<array<f32, {ts}>, {ts}>;
161var<workgroup> tile_b: array<array<f32, {ts}>, {ts}>;
162
163// op(A_b)[r, i] — logical m×k left operand for batch `a_offset`.  `lda` is the
164// physical per-batch row stride of the stored buffer (>= the packed width).
165fn load_a(a_offset: u32, r: u32, i: u32) -> f32 {{
166    if (r >= params.m || i >= params.k) {{ return 0.0; }}
167    if (params.trans_a == 0u) {{
168        return a[a_offset + r * params.lda + i];
169    }}
170    return a[a_offset + i * params.lda + r];
171}}
172
173// op(B_b)[i, col] — logical k×n right operand for batch `b_offset`.  `ldb` is
174// the physical per-batch row stride.
175fn load_b(b_offset: u32, i: u32, col: u32) -> f32 {{
176    if (i >= params.k || col >= params.n) {{ return 0.0; }}
177    if (params.trans_b == 0u) {{
178        return b[b_offset + i * params.ldb + col];
179    }}
180    return b[b_offset + col * params.ldb + i];
181}}
182
183@compute @workgroup_size({ts}, {ts})
184fn main(
185    @builtin(global_invocation_id) gid: vec3<u32>,
186    @builtin(local_invocation_id)  lid: vec3<u32>,
187) {{
188    let row = gid.y;
189    let col = gid.x;
190    let batch_index = gid.z;
191    let lr  = lid.y;
192    let lc  = lid.x;
193    if (batch_index >= params.batch_count) {{ return; }}
194
195    let a_offset = batch_index * params.stride_a;
196    let b_offset = batch_index * params.stride_b;
197    let c_offset = batch_index * params.stride_c;
198
199    var acc: f32 = 0.0;
200    let num_tiles = (params.k + {ts}u - 1u) / {ts}u;
201    for (var t: u32 = 0u; t < num_tiles; t = t + 1u) {{
202        let a_col = t * {ts}u + lc;
203        let b_row = t * {ts}u + lr;
204        tile_a[lr][lc] = load_a(a_offset, row, a_col);
205        tile_b[lr][lc] = load_b(b_offset, b_row, col);
206        workgroupBarrier();
207
208        for (var e: u32 = 0u; e < {ts}u; e = e + 1u) {{
209            acc += tile_a[lr][e] * tile_b[e][lc];
210        }}
211        workgroupBarrier();
212    }}
213
214    if (row >= params.m || col >= params.n) {{ return; }}
215    let idx = c_offset + row * params.ldc + col;
216    c[idx] = params.alpha * acc + params.beta * c[idx];
217}}
218"#,
219        ts = tile_size
220    )
221}
222
223/// Generate WGSL source for a tiled GEMM kernel using FP16 storage:
224/// `C = alpha * op(A) * op(B) + beta * C`.
225///
226/// Uses `enable f16;` WGSL extension and `tile_size × tile_size` workgroup
227/// tiles with shared-memory staging — the same tiling structure as
228/// [`gemm_wgsl`], with two differences: the storage buffers *and* the
229/// `var<workgroup>` tiles hold `f16` (halving both global- and
230/// workgroup-memory traffic relative to staging `f32`), while the inner
231/// accumulate loop widens each tile element to `f32` before multiplying, so
232/// accumulation is still done in f32 for precision. `lda` / `ldb` / `ldc`
233/// (physical row strides) and the `trans_a` / `trans_b` transpose flags are
234/// runtime uniforms honoured via the same `load_a` / `load_b` indexing
235/// convention as [`gemm_wgsl`] — see that function's doc for the exact
236/// row-major / column-major index forms.
237///
238/// Requires the device to have enabled the `SHADER_F16` feature; otherwise the
239/// module fails validation for the missing capability.
240///
241/// # Arguments
242///
243/// * `tile_size` — workgroup tile dimension.  Because the workgroup is
244///   `tile_size × tile_size`, `tile_size * tile_size` must not exceed WebGPU's
245///   baseline `maxComputeInvocationsPerWorkgroup` of 256, so 16 is the
246///   portable maximum (e.g. 8 or 16).
247pub fn gemm_wgsl_f16(tile_size: u32) -> String {
248    format!(
249        r#"
250enable f16;
251
252struct GemmParams {{
253    m:       u32,
254    n:       u32,
255    k:       u32,
256    alpha:   f32,
257    beta:    f32,
258    trans_a: u32,
259    trans_b: u32,
260    lda:     u32,
261    ldb:     u32,
262    ldc:     u32,
263    _pad0:   u32,
264    _pad1:   u32,
265}}
266
267@group(0) @binding(0) var<storage, read>       a:      array<f16>;
268@group(0) @binding(1) var<storage, read>       b:      array<f16>;
269@group(0) @binding(2) var<storage, read_write> c:      array<f16>;
270@group(0) @binding(3) var<uniform>             params: GemmParams;
271
272var<workgroup> tile_a: array<array<f16, {ts}>, {ts}>;
273var<workgroup> tile_b: array<array<f16, {ts}>, {ts}>;
274
275// op(A)[r, i] — logical m×k left operand.  `lda` is the physical row stride of
276// the stored buffer (>= the packed width), supporting padded / sub-matrix
277// views. The `r`/`i` bounds guard is required here — unlike a per-thread
278// dot-product loop that only ever indexes `i < params.k` — because the tiled
279// staging loop below reads `t * {ts}u + lc` / `t * {ts}u + lr`, which
280// routinely exceeds `params.k` / `params.m` on the last (partial) tile; an
281// unguarded read would silently pull in a neighbouring row/column via WGSL's
282// robust-access semantics instead of contributing a proper zero pad.
283fn load_a(r: u32, i: u32) -> f16 {{
284    if (r >= params.m || i >= params.k) {{ return 0.0h; }}
285    if (params.trans_a == 0u) {{
286        return a[r * params.lda + i];
287    }}
288    return a[i * params.lda + r];
289}}
290
291// op(B)[i, col] — logical k×n right operand.  `ldb` is the physical row
292// stride.  Same last-tile bounds guard as `load_a`.
293fn load_b(i: u32, col: u32) -> f16 {{
294    if (i >= params.k || col >= params.n) {{ return 0.0h; }}
295    if (params.trans_b == 0u) {{
296        return b[i * params.ldb + col];
297    }}
298    return b[col * params.ldb + i];
299}}
300
301@compute @workgroup_size({ts}, {ts})
302fn main(
303    @builtin(global_invocation_id) gid: vec3<u32>,
304    @builtin(local_invocation_id)  lid: vec3<u32>,
305) {{
306    let row = gid.y;
307    let col = gid.x;
308    let lr  = lid.y;
309    let lc  = lid.x;
310
311    var acc: f32 = 0.0;
312    let num_tiles = (params.k + {ts}u - 1u) / {ts}u;
313    for (var t: u32 = 0u; t < num_tiles; t = t + 1u) {{
314        let a_col = t * {ts}u + lc;
315        let b_row = t * {ts}u + lr;
316        tile_a[lr][lc] = load_a(row, a_col);
317        tile_b[lr][lc] = load_b(b_row, col);
318        workgroupBarrier();
319
320        for (var e: u32 = 0u; e < {ts}u; e = e + 1u) {{
321            acc += f32(tile_a[lr][e]) * f32(tile_b[e][lc]);
322        }}
323        workgroupBarrier();
324    }}
325
326    if (row >= params.m || col >= params.n) {{ return; }}
327    let idx = row * params.ldc + col;
328    let prev = f32(c[idx]);
329    c[idx] = f16(params.alpha * acc + params.beta * prev);
330}}
331"#,
332        ts = tile_size
333    )
334}
335
336/// Generate WGSL source for an element-wise unary operation.
337///
338/// The shader reads `n` elements from `input`, applies the operation, and
339/// writes the results to `output`.  Both buffers have `arrayLength` elements.
340///
341/// # Arguments
342///
343/// * `op` — one of: `"relu"`, `"sigmoid"`, `"tanh"`, `"exp"`, `"log"`,
344///   `"sqrt"`, `"abs"`, `"neg"`.  Unknown ops are treated as identity.
345pub fn elementwise_wgsl(op: &str) -> String {
346    let op_expr = match op {
347        "relu" => "max(x, 0.0)",
348        "sigmoid" => "1.0 / (1.0 + exp(-x))",
349        "tanh" => "tanh(x)",
350        "exp" => "exp(x)",
351        "log" => "log(x)",
352        "sqrt" => "sqrt(x)",
353        "abs" => "abs(x)",
354        "neg" => "-x",
355        _ => "x",
356    };
357
358    format!(
359        r#"
360@group(0) @binding(0) var<storage, read>       input:  array<f32>;
361@group(0) @binding(1) var<storage, read_write> output: array<f32>;
362
363@compute @workgroup_size(256)
364fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
365    let i = gid.x;
366    if (i >= arrayLength(&input)) {{ return; }}
367    let x = input[i];
368    output[i] = {op};
369}}
370"#,
371        op = op_expr
372    )
373}
374
375/// Generate WGSL source for an element-wise binary operation.
376///
377/// The shader reads `n` elements from two input buffers (`lhs` and `rhs`),
378/// applies the operation, and writes the results to `output`.
379///
380/// # Arguments
381///
382/// * `op` — one of: `"add"`, `"sub"`, `"mul"`, `"div"`, `"max"`, `"min"`,
383///   `"pow"`.  Unknown ops fall back to identity on `lhs`.
384pub fn binary_wgsl(op: &str) -> String {
385    let op_expr = match op {
386        "add" => "a + b",
387        "sub" => "a - b",
388        "mul" => "a * b",
389        "div" => "a / b",
390        "max" => "max(a, b)",
391        "min" => "min(a, b)",
392        "pow" => "pow(a, b)",
393        _ => "a",
394    };
395
396    format!(
397        r#"
398@group(0) @binding(0) var<storage, read>       lhs:    array<f32>;
399@group(0) @binding(1) var<storage, read>       rhs:    array<f32>;
400@group(0) @binding(2) var<storage, read_write> output: array<f32>;
401
402@compute @workgroup_size(256)
403fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
404    let i = gid.x;
405    if (i >= arrayLength(&lhs)) {{ return; }}
406    let a = lhs[i];
407    let b = rhs[i];
408    output[i] = {op};
409}}
410"#,
411        op = op_expr
412    )
413}
414
415/// Generate WGSL source for a parallel workgroup-level reduction.
416///
417/// Performs a two-pass approach: each workgroup of 256 threads reduces its
418/// tile to a single value in shared memory, then the results are written to
419/// a partial-sums buffer.  A second dispatch (with a single workgroup) then
420/// reduces the partial-sums to the final scalar.
421///
422/// # Arguments
423///
424/// * `op` — one of: `"sum"`, `"max"`, `"min"`, `"mean"`.  `"mean"` behaves
425///   like `"sum"` in the shader; the CPU is responsible for dividing by N.
426///   Unknown ops fall back to `"sum"`.
427pub fn reduction_wgsl(op: &str) -> String {
428    // Neutral elements and combine expressions for each operation.  `max` /
429    // `min` use the exact IEEE-754 infinities (`bitcast<f32>` from the
430    // sign+all-ones-exponent+zero-mantissa bit pattern) rather than an
431    // arbitrary finite `±1e38` sentinel, so a true extremum below/above that
432    // magnitude is never masked by the neutral element.
433    let (neutral, combine) = match op {
434        "max" => ("bitcast<f32>(0xFF800000u)", "max(acc, val)"),
435        "min" => ("bitcast<f32>(0x7F800000u)", "min(acc, val)"),
436        // "sum" and "mean" use the same reduction body.
437        _ => ("f32(0.0)", "acc + val"),
438    };
439
440    format!(
441        r#"
442// Reduction params: total element count.
443struct ReduceParams {{
444    n: u32,
445}}
446
447@group(0) @binding(0) var<storage, read>       input:        array<f32>;
448@group(0) @binding(1) var<storage, read_write> partial_sums: array<f32>;
449@group(0) @binding(2) var<uniform>             params:       ReduceParams;
450
451var<workgroup> shared_data: array<f32, 256>;
452
453@compute @workgroup_size(256)
454fn main(
455    @builtin(global_invocation_id) gid:  vec3<u32>,
456    @builtin(local_invocation_id)  lid:  vec3<u32>,
457    @builtin(workgroup_id)         wgid: vec3<u32>,
458) {{
459    let tid         = lid.x;
460    let global_idx  = gid.x;
461
462    // Load or use neutral element when out of range.
463    if (global_idx < params.n) {{
464        shared_data[tid] = input[global_idx];
465    }} else {{
466        shared_data[tid] = {neutral};
467    }}
468    workgroupBarrier();
469
470    // Parallel tree reduction within the workgroup.
471    var stride: u32 = 128u;
472    loop {{
473        if (stride == 0u) {{ break; }}
474        if (tid < stride) {{
475            let acc = shared_data[tid];
476            let val = shared_data[tid + stride];
477            shared_data[tid] = {combine};
478        }}
479        workgroupBarrier();
480        stride = stride >> 1u;
481    }}
482
483    // Thread 0 writes the workgroup result to the partial-sums buffer.
484    if (tid == 0u) {{
485        partial_sums[wgid.x] = shared_data[0];
486    }}
487}}
488"#,
489        neutral = neutral,
490        combine = combine,
491    )
492}
493
494/// Generate a WGSL compute shader for 2D convolution in NCHW format.
495///
496/// The shader reads from `input` (NCHW) and `filter` (K×C×FH×FW), writing
497/// the result to `output` (N×K×OH×OW).  Padding is handled via bounds
498/// checking — out-of-range input positions contribute zero.
499///
500/// # Arguments
501///
502/// * `n` — batch size
503/// * `c_in` — number of input channels
504/// * `h_in`, `w_in` — spatial input dimensions
505/// * `k_out` — number of output channels (filters)
506/// * `fh`, `fw` — filter height / width
507/// * `oh`, `ow` — output height / width
508/// * `stride_h`, `stride_w` — convolution strides
509/// * `pad_h`, `pad_w` — zero-padding applied to the input
510#[allow(clippy::too_many_arguments)]
511pub fn conv2d_wgsl(
512    n: u32,
513    c_in: u32,
514    h_in: u32,
515    w_in: u32,
516    k_out: u32,
517    fh: u32,
518    fw: u32,
519    oh: u32,
520    ow: u32,
521    stride_h: u32,
522    stride_w: u32,
523    pad_h: u32,
524    pad_w: u32,
525) -> String {
526    format!(
527        r#"
528// Conv2D NCHW — generated by oxicuda-webgpu
529// input   : [{n}, {c_in}, {h_in}, {w_in}]
530// kernel_w: [{k_out}, {c_in}, {fh}, {fw}]
531// output  : [{n}, {k_out}, {oh}, {ow}]
532
533@group(0) @binding(0) var<storage, read>       input:    array<f32>;
534@group(0) @binding(1) var<storage, read>       kernel_w: array<f32>;
535@group(0) @binding(2) var<storage, read_write> output:   array<f32>;
536
537@compute @workgroup_size(8, 8)
538fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
539    // gid.x = output x (ox mapped across batches*k_out*oh)
540    // We flatten (batch, k, oy) into gid.y and ox into gid.x
541    let ox = gid.x;
542    let linear_y = gid.y;
543
544    let batch_k_oh = {n}u * {k_out}u * {oh}u;
545    if (ox >= {ow}u || linear_y >= batch_k_oh) {{ return; }}
546
547    let b  = linear_y / ({k_out}u * {oh}u);
548    let rem = linear_y % ({k_out}u * {oh}u);
549    let kf = rem / {oh}u;
550    let oy = rem % {oh}u;
551
552    var acc: f32 = 0.0;
553    for (var ci: u32 = 0u; ci < {c_in}u; ci = ci + 1u) {{
554        for (var fy: u32 = 0u; fy < {fh}u; fy = fy + 1u) {{
555            for (var fx: u32 = 0u; fx < {fw}u; fx = fx + 1u) {{
556                let iy_raw = i32(oy * {stride_h}u + fy) - i32({pad_h}u);
557                let ix_raw = i32(ox * {stride_w}u + fx) - i32({pad_w}u);
558                if (iy_raw >= 0 && iy_raw < i32({h_in}u) && ix_raw >= 0 && ix_raw < i32({w_in}u)) {{
559                    let iy = u32(iy_raw);
560                    let ix = u32(ix_raw);
561                    let in_idx = ((b * {c_in}u + ci) * {h_in}u + iy) * {w_in}u + ix;
562                    let f_idx  = ((kf * {c_in}u + ci) * {fh}u + fy) * {fw}u + fx;
563                    acc += input[in_idx] * kernel_w[f_idx];
564                }}
565            }}
566        }}
567    }}
568
569    let o_idx = ((b * {k_out}u + kf) * {oh}u + oy) * {ow}u + ox;
570    output[o_idx] = acc;
571}}
572"#,
573        n = n,
574        c_in = c_in,
575        h_in = h_in,
576        w_in = w_in,
577        k_out = k_out,
578        fh = fh,
579        fw = fw,
580        oh = oh,
581        ow = ow,
582        stride_h = stride_h,
583        stride_w = stride_w,
584        pad_h = pad_h,
585        pad_w = pad_w,
586    )
587}
588
589/// Generate a WGSL compute shader for scaled dot-product attention.
590///
591/// Implements: `O = softmax(Q·K^T * scale [+ causal_mask]) · V`
592///
593/// The softmax is numerically stable (subtracts max before exp).
594/// When `causal` is true, positions where `sk > sq` are masked to −∞.
595///
596/// # Arguments
597///
598/// * `batch_heads` — combined batch × heads dimension
599/// * `seq_q` — query sequence length
600/// * `seq_kv` — key/value sequence length
601/// * `head_dim` — dimension of each head
602/// * `scale` — scaling factor (typically `1 / sqrt(head_dim)`)
603/// * `causal` — whether to apply a causal (upper-triangular) mask
604pub fn attention_wgsl(
605    batch_heads: u32,
606    seq_q: u32,
607    seq_kv: u32,
608    head_dim: u32,
609    scale: f32,
610    causal: bool,
611) -> String {
612    // True IEEE-754 negative infinity (naga-validated: `bitcast<f32>` from a
613    // sign+all-ones-exponent+zero-mantissa u32 pattern), not an arbitrary
614    // finite `-1e38` sentinel.  This makes the causal mask exact: a masked
615    // score is genuinely unreachable by `max`, and if every key were somehow
616    // masked (impossible today — `sk == 0` is never masked — but the shader
617    // does not assume that invariant), `masked_score - max_score` becomes
618    // `-inf - (-inf) == NaN`, and `NaN > 0.0` is `false`, so pass 3's `else`
619    // branch (zero-fill) is a genuine backstop, not dead defensive code.
620    let neg_inf = "bitcast<f32>(0xFF800000u)";
621
622    let causal_check = if causal {
623        "if (sk > sq) { score = bitcast<f32>(0xFF800000u); } else {"
624    } else {
625        "{"
626    };
627
628    format!(
629        r#"
630// Scaled dot-product attention — generated by oxicuda-webgpu
631// Q, K, V : [{batch_heads}, seq, {head_dim}]
632// O       : [{batch_heads}, {seq_q}, {head_dim}]
633// scale   : {scale}
634// causal  : {causal}
635
636@group(0) @binding(0) var<storage, read>       q_buf: array<f32>;
637@group(0) @binding(1) var<storage, read>       k_buf: array<f32>;
638@group(0) @binding(2) var<storage, read>       v_buf: array<f32>;
639@group(0) @binding(3) var<storage, read_write> o_buf: array<f32>;
640
641@compute @workgroup_size(64)
642fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
643    let linear = gid.x;
644    let total = {batch_heads}u * {seq_q}u;
645    if (linear >= total) {{ return; }}
646
647    let bh = linear / {seq_q}u;
648    let sq = linear % {seq_q}u;
649
650    let q_base = (bh * {seq_q}u + sq) * {head_dim}u;
651    // Q and O share the same [batch_heads, seq_q, head_dim] shape/strides.
652    let o_base = q_base;
653
654    // Zero-initialise this thread's output row *before* accumulating below.
655    // Pass 2 does a read-modify-write (`o_buf[..] += ..`) so a reused
656    // (non-fresh) output buffer must not leak stale contents into the sum.
657    for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
658        o_buf[o_base + d] = 0.0;
659    }}
660
661    // Pass 1: find max score for numerical stability
662    var max_score: f32 = {neg_inf};
663    for (var sk: u32 = 0u; sk < {seq_kv}u; sk = sk + 1u) {{
664        var score: f32 = 0.0;
665        {causal_check}
666            let k_base = (bh * {seq_kv}u + sk) * {head_dim}u;
667            for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
668                score += q_buf[q_base + d] * k_buf[k_base + d];
669            }}
670            score *= f32({scale});
671        }}
672        if (score > max_score) {{ max_score = score; }}
673    }}
674
675    // Pass 2: compute exp(score - max), accumulate weighted V
676    var sum_exp: f32 = 0.0;
677    for (var sk: u32 = 0u; sk < {seq_kv}u; sk = sk + 1u) {{
678        var score: f32 = 0.0;
679        {causal_check}
680            let k_base = (bh * {seq_kv}u + sk) * {head_dim}u;
681            for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
682                score += q_buf[q_base + d] * k_buf[k_base + d];
683            }}
684            score *= f32({scale});
685        }}
686        let w = exp(score - max_score);
687        sum_exp += w;
688        let v_base = (bh * {seq_kv}u + sk) * {head_dim}u;
689        for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
690            // Accumulate in-place (we normalise after the loop).
691            o_buf[o_base + d] += w * v_buf[v_base + d];
692        }}
693    }}
694
695    // Pass 3: normalise, or write zeros if no key contributed (`sum_exp` is
696    // `0.0` or `NaN`; `sum_exp > 0.0` is false for both, so this else branch
697    // catches both without a separate NaN check).
698    if (sum_exp > 0.0) {{
699        for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
700            o_buf[o_base + d] /= sum_exp;
701        }}
702    }} else {{
703        for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
704            o_buf[o_base + d] = 0.0;
705        }}
706    }}
707}}
708"#,
709        batch_heads = batch_heads,
710        seq_q = seq_q,
711        seq_kv = seq_kv,
712        head_dim = head_dim,
713        scale = scale,
714        causal = causal,
715        causal_check = causal_check,
716        neg_inf = neg_inf,
717    )
718}
719
720/// Generate WGSL source for an N-D reduction along a single axis.
721///
722/// The tensor is logically reshaped to `[outer, dk, inner]`, where the reduce
723/// axis spans `dk` elements, `outer` is the product of dimensions before the
724/// axis, and `inner` is the product of dimensions after the axis.
725///
726/// Output shape is `[outer, inner]` (flattened to a 1-D buffer of length
727/// `outer * inner`).  Each output slot is computed by a full workgroup of
728/// 256 threads, which cooperatively reduce the `dk` elements via a strided
729/// loop and a shared-memory tree reduction.
730///
731/// Dispatch must be 2-D: `(grid_x, ceil((outer * inner) / grid_x), 1)` to
732/// stay below WebGPU's per-axis limit of 65 535 workgroups.  The shader
733/// decodes its slot via `wgid.y * params.grid_x + wgid.x` and early-returns
734/// if `slot >= outer * inner`.
735///
736/// For `Mean`, the shader divides each output by `dk` directly (no host-side
737/// post-processing required).
738///
739/// # Arguments
740///
741/// * `op` — one of: `"sum"`, `"max"`, `"min"`, `"mean"`.  Unknown ops fall
742///   back to `"sum"`.
743pub fn reduction_nd_wgsl(op: &str) -> String {
744    // The first form combines `acc` with `val` (per-thread strided loop).
745    // The second form combines `acc2` with `val` (in-shared-memory tree
746    // reduction).  Listing both explicitly is more robust than string-
747    // substitution for future ops.
748    let (neutral, combine, combine_alias) = match op {
749        "max" => (
750            "bitcast<f32>(0xFF800000u)",
751            "max(acc, val)",
752            "max(acc2, val)",
753        ),
754        "min" => (
755            "bitcast<f32>(0x7F800000u)",
756            "min(acc, val)",
757            "min(acc2, val)",
758        ),
759        // "sum" and "mean" use the same combine; "mean" divides at the end.
760        _ => ("f32(0.0)", "acc + val", "acc2 + val"),
761    };
762
763    // For "mean", divide the final reduced value by dk; otherwise pass-through.
764    let final_expr = if op == "mean" {
765        "shared_data[0] / f32(params.dk)"
766    } else {
767        "shared_data[0]"
768    };
769
770    format!(
771        r#"
772struct ReduceNdParams {{
773    outer:        u32,
774    dk:           u32,
775    inner:        u32,
776    outer_stride: u32,
777    dk_stride:    u32,
778    inner_stride: u32,
779    grid_x:       u32,
780    _pad:         u32,
781}}
782
783@group(0) @binding(0) var<storage, read>       input:  array<f32>;
784@group(0) @binding(1) var<storage, read_write> output: array<f32>;
785@group(0) @binding(2) var<uniform>             params: ReduceNdParams;
786
787var<workgroup> shared_data: array<f32, 256>;
788
789@compute @workgroup_size(256)
790fn main(
791    @builtin(local_invocation_id) lid:  vec3<u32>,
792    @builtin(workgroup_id)        wgid: vec3<u32>,
793) {{
794    let tid = lid.x;
795    let total = params.outer * params.inner;
796
797    // Decode 2-D workgroup id back to a linear output slot.
798    let slot = wgid.y * params.grid_x + wgid.x;
799    if (slot >= total) {{ return; }}
800
801    let o = slot / params.inner;
802    let j = slot % params.inner;
803    let base = o * params.outer_stride + j * params.inner_stride;
804
805    // Strided per-thread reduction across the dk axis.
806    var acc: f32 = {neutral};
807    var i: u32 = tid;
808    loop {{
809        if (i >= params.dk) {{ break; }}
810        let val = input[base + i * params.dk_stride];
811        acc = {combine};
812        i = i + 256u;
813    }}
814
815    shared_data[tid] = acc;
816    workgroupBarrier();
817
818    // Tree reduction within the workgroup.
819    var stride: u32 = 128u;
820    loop {{
821        if (stride == 0u) {{ break; }}
822        if (tid < stride) {{
823            let acc2 = shared_data[tid];
824            let val  = shared_data[tid + stride];
825            shared_data[tid] = {combine_alias};
826        }}
827        workgroupBarrier();
828        stride = stride >> 1u;
829    }}
830
831    if (tid == 0u) {{
832        output[slot] = {final_expr};
833    }}
834}}
835"#,
836        neutral = neutral,
837        combine = combine,
838        combine_alias = combine_alias,
839        final_expr = final_expr,
840    )
841}
842
843/// Generate WGSL for the final scalar reduction of partial sums.
844///
845/// Takes a `partial_sums` array of length `num_groups` and reduces it to a
846/// single value at `output[0]`.  Dispatched with a single workgroup of 256
847/// threads: each thread first folds every partial it owns via a grid-stride
848/// loop (`partial_sums[tid]`, `partial_sums[tid + 256]`, …) so that an
849/// arbitrary `num_groups` — not just the first 256 — is reduced, then the 256
850/// per-thread accumulators are combined with a shared-memory tree reduction.
851pub fn reduction_final_wgsl(op: &str) -> String {
852    let (neutral, combine) = match op {
853        "max" => ("bitcast<f32>(0xFF800000u)", "max(acc, val)"),
854        "min" => ("bitcast<f32>(0x7F800000u)", "min(acc, val)"),
855        _ => ("f32(0.0)", "acc + val"),
856    };
857
858    format!(
859        r#"
860struct FinalReduceParams {{
861    num_groups: u32,
862}}
863
864@group(0) @binding(0) var<storage, read>       partial_sums: array<f32>;
865@group(0) @binding(1) var<storage, read_write> output:       array<f32>;
866@group(0) @binding(2) var<uniform>             params:       FinalReduceParams;
867
868var<workgroup> shared_data: array<f32, 256>;
869
870@compute @workgroup_size(256)
871fn main(
872    @builtin(local_invocation_id) lid: vec3<u32>,
873) {{
874    let tid = lid.x;
875
876    // Grid-stride fold: each of the 256 threads accumulates every partial at
877    // index tid, tid+256, tid+512, …  Without this, partials beyond index 255
878    // (num_groups > 256, i.e. > 65 536 input elements) would be silently
879    // dropped.
880    var acc: f32 = {neutral};
881    var i: u32 = tid;
882    loop {{
883        if (i >= params.num_groups) {{ break; }}
884        let val = partial_sums[i];
885        acc = {combine};
886        i = i + 256u;
887    }}
888    shared_data[tid] = acc;
889    workgroupBarrier();
890
891    var stride: u32 = 128u;
892    loop {{
893        if (stride == 0u) {{ break; }}
894        if (tid < stride) {{
895            let acc = shared_data[tid];
896            let val = shared_data[tid + stride];
897            shared_data[tid] = {combine};
898        }}
899        workgroupBarrier();
900        stride = stride >> 1u;
901    }}
902
903    if (tid == 0u) {{
904        output[0] = shared_data[0];
905    }}
906}}
907"#,
908        neutral = neutral,
909        combine = combine,
910    )
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916
917    #[test]
918    fn wgsl_gemm_contains_workgroup() {
919        let src = gemm_wgsl(16);
920        assert!(src.contains("@compute @workgroup_size(16, 16)"));
921        assert!(src.contains("GemmParams"));
922        assert!(src.contains("alpha"));
923        assert!(src.contains("beta"));
924    }
925
926    #[test]
927    fn wgsl_gemm_tile_size_embedded() {
928        let src8 = gemm_wgsl(8);
929        assert!(src8.contains("@workgroup_size(8, 8)"));
930        let src32 = gemm_wgsl(32);
931        assert!(src32.contains("@workgroup_size(32, 32)"));
932    }
933
934    #[test]
935    fn wgsl_gemm_has_transpose_flags() {
936        let src = gemm_wgsl(8);
937        // Transpose flags live in the uniform struct.
938        assert!(src.contains("trans_a: u32"));
939        assert!(src.contains("trans_b: u32"));
940        // Leading-dimension uniforms drive the physical row strides.
941        assert!(src.contains("lda:     u32"));
942        assert!(src.contains("ldb:     u32"));
943        assert!(src.contains("ldc:     u32"));
944        // Both row-major and column-major index forms must be present, keyed on
945        // the leading dimensions rather than the packed extents.
946        assert!(src.contains("a[r * params.lda + i]"));
947        assert!(src.contains("a[i * params.lda + r]"));
948        assert!(src.contains("b[i * params.ldb + col]"));
949        assert!(src.contains("b[col * params.ldb + i]"));
950        assert!(src.contains("row * params.ldc + col"));
951    }
952
953    #[test]
954    fn wgsl_gemm_uses_shared_memory_tiling() {
955        let src = gemm_wgsl(16);
956        assert!(src.contains("var<workgroup> tile_a"));
957        assert!(src.contains("var<workgroup> tile_b"));
958        assert!(src.contains("workgroupBarrier"));
959        // Tile dimension is embedded in the workgroup-array declaration.
960        assert!(src.contains("array<array<f32, 16>, 16>"));
961    }
962
963    #[test]
964    fn wgsl_elementwise_relu_contains_max() {
965        let src = elementwise_wgsl("relu");
966        assert!(src.contains("max(x, 0.0)"));
967    }
968
969    #[test]
970    fn wgsl_elementwise_all_ops() {
971        assert!(elementwise_wgsl("sigmoid").contains("exp(-x)"));
972        assert!(elementwise_wgsl("tanh").contains("tanh(x)"));
973        assert!(elementwise_wgsl("exp").contains("exp(x)"));
974        assert!(elementwise_wgsl("log").contains("log(x)"));
975        assert!(elementwise_wgsl("sqrt").contains("sqrt(x)"));
976        assert!(elementwise_wgsl("abs").contains("abs(x)"));
977        assert!(elementwise_wgsl("neg").contains("-x"));
978        // Unknown op is identity.
979        assert!(elementwise_wgsl("identity_op").contains("output[i] = x;"));
980    }
981
982    #[test]
983    fn wgsl_reduction_sum_contains_addition() {
984        let src = reduction_wgsl("sum");
985        assert!(src.contains("acc + val"));
986        assert!(src.contains("workgroupBarrier"));
987    }
988
989    #[test]
990    fn wgsl_reduction_max_uses_max_fn() {
991        let src = reduction_wgsl("max");
992        assert!(src.contains("max(acc, val)"));
993    }
994
995    #[test]
996    fn wgsl_reduction_min_uses_min_fn() {
997        let src = reduction_wgsl("min");
998        assert!(src.contains("min(acc, val)"));
999    }
1000
1001    #[test]
1002    fn wgsl_reduction_mean_same_as_sum() {
1003        // "mean" divides on the CPU side; the shader is identical to sum.
1004        let sum_src = reduction_wgsl("sum");
1005        let mean_src = reduction_wgsl("mean");
1006        assert_eq!(sum_src, mean_src);
1007    }
1008
1009    #[test]
1010    fn wgsl_reduction_final_sum() {
1011        let src = reduction_final_wgsl("sum");
1012        assert!(src.contains("num_groups"));
1013        assert!(src.contains("output[0]"));
1014    }
1015
1016    #[test]
1017    fn wgsl_reduction_final_grid_strides_over_all_groups() {
1018        // The final pass must fold *every* partial via a grid-stride loop, not
1019        // just the first 256 (which would drop reductions over > 65 536
1020        // elements).  Verify the strided loop is present for all ops.
1021        for op in ["sum", "max", "min", "mean"] {
1022            let src = reduction_final_wgsl(op);
1023            assert!(
1024                src.contains("i = i + 256u"),
1025                "final reduction for {op} lacks the grid-stride loop"
1026            );
1027            assert!(
1028                src.contains("if (i >= params.num_groups)"),
1029                "final reduction for {op} lacks the num_groups loop bound"
1030            );
1031        }
1032    }
1033
1034    // ── reduction_nd_wgsl tests ───────────────────────────────────────────
1035
1036    #[test]
1037    fn wgsl_reduction_nd_sum_contains_addition() {
1038        let src = reduction_nd_wgsl("sum");
1039        assert!(src.contains("acc + val"));
1040        // Tree-step reuses the same combine with renamed lhs.
1041        assert!(src.contains("acc2 + val"));
1042        assert!(src.contains("workgroupBarrier"));
1043        assert!(src.contains("ReduceNdParams"));
1044    }
1045
1046    #[test]
1047    fn wgsl_reduction_nd_max_uses_max_fn() {
1048        let src = reduction_nd_wgsl("max");
1049        assert!(src.contains("max(acc, val)"));
1050        assert!(src.contains("max(acc2, val)"));
1051    }
1052
1053    #[test]
1054    fn wgsl_reduction_nd_min_uses_min_fn() {
1055        let src = reduction_nd_wgsl("min");
1056        assert!(src.contains("min(acc, val)"));
1057        assert!(src.contains("min(acc2, val)"));
1058    }
1059
1060    #[test]
1061    fn wgsl_reduction_nd_mean_divides_by_dk() {
1062        let src = reduction_nd_wgsl("mean");
1063        assert!(src.contains("shared_data[0] / f32(params.dk)"));
1064        assert!(src.contains("acc + val"));
1065    }
1066
1067    #[test]
1068    fn wgsl_reduction_nd_sum_does_not_divide() {
1069        let src = reduction_nd_wgsl("sum");
1070        assert!(!src.contains("/ f32(params.dk)"));
1071    }
1072
1073    #[test]
1074    fn wgsl_reduction_nd_decodes_2d_dispatch() {
1075        let src = reduction_nd_wgsl("sum");
1076        assert!(src.contains("wgid.y * params.grid_x + wgid.x"));
1077    }
1078
1079    #[test]
1080    fn wgsl_reduction_nd_uses_strided_loop() {
1081        let src = reduction_nd_wgsl("sum");
1082        assert!(src.contains("i = i + 256u"));
1083    }
1084
1085    // ── binary_wgsl tests ─────────────────────────────────────────────────
1086
1087    #[test]
1088    fn wgsl_binary_add() {
1089        let src = binary_wgsl("add");
1090        assert!(src.contains("a + b"));
1091        assert!(src.contains("lhs"));
1092        assert!(src.contains("rhs"));
1093    }
1094
1095    #[test]
1096    fn wgsl_binary_all_ops() {
1097        assert!(binary_wgsl("sub").contains("a - b"));
1098        assert!(binary_wgsl("mul").contains("a * b"));
1099        assert!(binary_wgsl("div").contains("a / b"));
1100        assert!(binary_wgsl("max").contains("max(a, b)"));
1101        assert!(binary_wgsl("min").contains("min(a, b)"));
1102        assert!(binary_wgsl("pow").contains("pow(a, b)"));
1103        // Unknown op is identity on lhs.
1104        assert!(binary_wgsl("unknown_op").contains("output[i] = a;"));
1105    }
1106
1107    #[test]
1108    fn wgsl_binary_workgroup_size() {
1109        let src = binary_wgsl("add");
1110        assert!(src.contains("@workgroup_size(256)"));
1111    }
1112
1113    // ── conv2d_wgsl tests ─────────────────────────────────────────────────
1114
1115    #[test]
1116    fn wgsl_conv2d_contains_workgroup() {
1117        let src = conv2d_wgsl(1, 3, 32, 32, 16, 3, 3, 30, 30, 1, 1, 0, 0);
1118        assert!(src.contains("@compute @workgroup_size(8, 8)"));
1119    }
1120
1121    #[test]
1122    fn wgsl_conv2d_contains_storage_bindings() {
1123        let src = conv2d_wgsl(1, 3, 32, 32, 16, 3, 3, 30, 30, 1, 1, 0, 0);
1124        assert!(src.contains("var<storage, read>       input:"));
1125        // "filter" is a reserved WGSL keyword; the binding was renamed to kernel_w.
1126        assert!(src.contains("var<storage, read>       kernel_w:"));
1127        assert!(src.contains("var<storage, read_write> output:"));
1128    }
1129
1130    #[test]
1131    fn wgsl_conv2d_embeds_dimensions() {
1132        let src = conv2d_wgsl(2, 8, 64, 64, 32, 5, 5, 60, 60, 1, 1, 0, 0);
1133        // Check that the shape constants appear in the shader
1134        assert!(src.contains("8u")); // c_in
1135        assert!(src.contains("64u")); // h_in or w_in
1136        assert!(src.contains("32u")); // k_out
1137        assert!(src.contains("5u")); // fh or fw
1138        assert!(src.contains("60u")); // oh or ow
1139    }
1140
1141    #[test]
1142    fn wgsl_conv2d_has_padding_check() {
1143        let src = conv2d_wgsl(1, 1, 8, 8, 1, 3, 3, 8, 8, 1, 1, 1, 1);
1144        // Padding check with signed comparison
1145        assert!(src.contains("iy_raw >= 0"));
1146        assert!(src.contains("ix_raw >= 0"));
1147    }
1148
1149    #[test]
1150    fn wgsl_conv2d_has_stride() {
1151        let src = conv2d_wgsl(1, 1, 8, 8, 1, 3, 3, 3, 3, 2, 2, 0, 0);
1152        assert!(src.contains("2u")); // stride
1153    }
1154
1155    // ── attention_wgsl tests ──────────────────────────────────────────────
1156
1157    #[test]
1158    fn wgsl_attention_contains_workgroup() {
1159        let src = attention_wgsl(4, 8, 8, 64, 0.125, false);
1160        assert!(src.contains("@compute @workgroup_size(64)"));
1161    }
1162
1163    #[test]
1164    fn wgsl_attention_contains_storage_bindings() {
1165        let src = attention_wgsl(4, 8, 8, 64, 0.125, false);
1166        assert!(src.contains("var<storage, read>       q_buf:"));
1167        assert!(src.contains("var<storage, read>       k_buf:"));
1168        assert!(src.contains("var<storage, read>       v_buf:"));
1169        assert!(src.contains("var<storage, read_write> o_buf:"));
1170    }
1171
1172    #[test]
1173    fn wgsl_attention_stable_softmax() {
1174        let src = attention_wgsl(1, 4, 4, 32, 0.25, false);
1175        assert!(src.contains("max_score"));
1176        assert!(src.contains("exp(score - max_score)"));
1177        assert!(src.contains("sum_exp"));
1178    }
1179
1180    #[test]
1181    fn wgsl_attention_causal_mask() {
1182        let src_causal = attention_wgsl(1, 4, 4, 32, 0.25, true);
1183        assert!(src_causal.contains("sk > sq"));
1184
1185        let src_non_causal = attention_wgsl(1, 4, 4, 32, 0.25, false);
1186        assert!(!src_non_causal.contains("sk > sq"));
1187    }
1188
1189    #[test]
1190    fn wgsl_attention_embeds_scale() {
1191        let src = attention_wgsl(2, 16, 16, 64, 0.125, false);
1192        assert!(src.contains("0.125"));
1193    }
1194
1195    // ── batched_gemm_wgsl tests ────────────────────────────────────────────
1196
1197    #[test]
1198    fn wgsl_batched_gemm_contains_batch_params() {
1199        let src = batched_gemm_wgsl(16);
1200        assert!(src.contains("batch_count"));
1201        assert!(src.contains("stride_a"));
1202        assert!(src.contains("stride_b"));
1203        assert!(src.contains("stride_c"));
1204    }
1205
1206    #[test]
1207    fn wgsl_batched_gemm_contains_workgroup() {
1208        let src = batched_gemm_wgsl(16);
1209        assert!(src.contains("@compute @workgroup_size(16, 16)"));
1210        assert!(src.contains("BatchedGemmParams"));
1211    }
1212
1213    #[test]
1214    fn wgsl_batched_gemm_uses_batch_index() {
1215        let src = batched_gemm_wgsl(8);
1216        assert!(src.contains("batch_index"));
1217        assert!(src.contains("gid.z"));
1218    }
1219
1220    #[test]
1221    fn wgsl_batched_gemm_tile_size_embedded() {
1222        let src8 = batched_gemm_wgsl(8);
1223        assert!(src8.contains("@workgroup_size(8, 8)"));
1224        let src32 = batched_gemm_wgsl(32);
1225        assert!(src32.contains("@workgroup_size(32, 32)"));
1226    }
1227
1228    #[test]
1229    fn wgsl_batched_gemm_has_transpose_flags() {
1230        let src = batched_gemm_wgsl(8);
1231        assert!(src.contains("trans_a:  u32"));
1232        assert!(src.contains("trans_b:  u32"));
1233        assert!(src.contains("lda:      u32"));
1234        assert!(src.contains("ldb:      u32"));
1235        assert!(src.contains("ldc:      u32"));
1236        // Per-batch offset is applied to every index form, keyed on the
1237        // per-batch leading dimensions.
1238        assert!(src.contains("a[a_offset + r * params.lda + i]"));
1239        assert!(src.contains("a[a_offset + i * params.lda + r]"));
1240        assert!(src.contains("b[b_offset + i * params.ldb + col]"));
1241        assert!(src.contains("b[b_offset + col * params.ldb + i]"));
1242        assert!(src.contains("row * params.ldc + col"));
1243    }
1244
1245    #[test]
1246    fn wgsl_batched_gemm_uses_shared_memory_tiling() {
1247        let src = batched_gemm_wgsl(8);
1248        assert!(src.contains("var<workgroup> tile_a"));
1249        assert!(src.contains("var<workgroup> tile_b"));
1250        assert!(src.contains("workgroupBarrier"));
1251        assert!(src.contains("array<array<f32, 8>, 8>"));
1252    }
1253
1254    // ── gemm_wgsl_f16 tests ─────────────────────────────────────────────
1255
1256    #[test]
1257    fn wgsl_gemm_f16_enables_extension() {
1258        let src = gemm_wgsl_f16(16);
1259        assert!(src.contains("enable f16;"));
1260    }
1261
1262    #[test]
1263    fn wgsl_gemm_f16_uses_f16_storage() {
1264        let src = gemm_wgsl_f16(16);
1265        assert!(src.contains("array<f16>"));
1266    }
1267
1268    #[test]
1269    fn wgsl_gemm_f16_accumulates_in_f32() {
1270        let src = gemm_wgsl_f16(16);
1271        assert!(src.contains("var acc: f32 = 0.0;"));
1272        // The f16-typed tiles are widened to f32 at the point they are
1273        // multiplied — accumulation itself is f32, but the intermediate
1274        // shared-memory staging holds f16 (see `wgsl_gemm_f16_uses_shared_memory_tiling`).
1275        assert!(src.contains("f32(tile_a["));
1276        assert!(src.contains("f32(tile_b["));
1277    }
1278
1279    #[test]
1280    fn wgsl_gemm_f16_uses_shared_memory_tiling() {
1281        let src = gemm_wgsl_f16(16);
1282        assert!(src.contains("var<workgroup> tile_a"));
1283        assert!(src.contains("var<workgroup> tile_b"));
1284        assert!(src.contains("workgroupBarrier"));
1285        // The tile itself is f16 (halves workgroup memory vs. staging f32),
1286        // and the tile dimension is embedded in the array declaration.
1287        assert!(src.contains("array<array<f16, 16>, 16>"));
1288    }
1289
1290    #[test]
1291    fn wgsl_gemm_f16_load_helpers_guard_last_partial_tile() {
1292        // Regression: a per-thread dot-product loop only ever indexes
1293        // `i < params.k`, so the original (untiled) `load_a`/`load_b` had no
1294        // bounds guard. The tiled staging loop reads `t * ts + lc` /
1295        // `t * ts + lr`, which routinely exceeds `params.k` / `params.m` on
1296        // the last (partial) tile — an unguarded read would silently pull in
1297        // a neighbouring row/column instead of contributing a zero pad,
1298        // corrupting the result rather than erroring.
1299        let src = gemm_wgsl_f16(16);
1300        assert!(src.contains("if (r >= params.m || i >= params.k) { return 0.0h; }"));
1301        assert!(src.contains("if (i >= params.k || col >= params.n) { return 0.0h; }"));
1302    }
1303
1304    #[test]
1305    fn wgsl_gemm_f16_contains_workgroup() {
1306        let src = gemm_wgsl_f16(8);
1307        assert!(src.contains("@compute @workgroup_size(8, 8)"));
1308        assert!(src.contains("GemmParams"));
1309    }
1310
1311    #[test]
1312    fn wgsl_gemm_f16_has_transpose_flags() {
1313        let src = gemm_wgsl_f16(8);
1314        // Transpose flags and leading dimensions live in the uniform struct,
1315        // mirroring gemm_wgsl (see wgsl_gemm_has_transpose_flags).
1316        assert!(src.contains("trans_a: u32"));
1317        assert!(src.contains("trans_b: u32"));
1318        assert!(src.contains("lda:     u32"));
1319        assert!(src.contains("ldb:     u32"));
1320        assert!(src.contains("ldc:     u32"));
1321        // Both row-major and column-major index forms. `load_a`/`load_b`
1322        // return raw `f16` (the tiled staging loop stores them into an
1323        // f16-typed workgroup tile); the f16 -> f32 widening happens later,
1324        // at the point a tile element is multiplied — see
1325        // `wgsl_gemm_f16_accumulates_in_f32`.
1326        assert!(src.contains("return a[r * params.lda + i];"));
1327        assert!(src.contains("return a[i * params.lda + r];"));
1328        assert!(src.contains("return b[i * params.ldb + col];"));
1329        assert!(src.contains("return b[col * params.ldb + i];"));
1330        assert!(src.contains("row * params.ldc + col"));
1331    }
1332
1333    #[test]
1334    fn wgsl_attention_embeds_dimensions() {
1335        let src = attention_wgsl(8, 32, 32, 128, 0.088, true);
1336        assert!(src.contains("128u")); // head_dim
1337        assert!(src.contains("32u")); // seq_q or seq_kv
1338        assert!(src.contains("8u")); // batch_heads
1339    }
1340}