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///
225/// Uses `enable f16;` WGSL extension. Storage buffers use `array<f16>`,
226/// but accumulation is done in f32 for precision.
227///
228/// Requires the device to have enabled the `SHADER_F16` feature; otherwise the
229/// module fails validation for the missing capability.
230///
231/// # Arguments
232///
233/// * `tile_size` — workgroup tile dimension.  `tile_size * tile_size` must not
234///   exceed WebGPU's baseline `maxComputeInvocationsPerWorkgroup` of 256, so 16
235///   is the portable maximum (e.g. 8 or 16).
236pub fn gemm_wgsl_f16(tile_size: u32) -> String {
237    format!(
238        r#"
239enable f16;
240
241struct GemmParams {{
242    m:     u32,
243    n:     u32,
244    k:     u32,
245    alpha: f32,
246    beta:  f32,
247}}
248
249@group(0) @binding(0) var<storage, read>       a:      array<f16>;
250@group(0) @binding(1) var<storage, read>       b:      array<f16>;
251@group(0) @binding(2) var<storage, read_write> c:      array<f16>;
252@group(0) @binding(3) var<uniform>             params: GemmParams;
253
254@compute @workgroup_size({ts}, {ts})
255fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
256    let row = gid.y;
257    let col = gid.x;
258    if (row >= params.m || col >= params.n) {{ return; }}
259
260    var acc: f32 = 0.0;
261    for (var i: u32 = 0u; i < params.k; i = i + 1u) {{
262        acc += f32(a[row * params.k + i]) * f32(b[i * params.n + col]);
263    }}
264
265    let idx = row * params.n + col;
266    let prev = f32(c[idx]);
267    c[idx] = f16(params.alpha * acc + params.beta * prev);
268}}
269"#,
270        ts = tile_size
271    )
272}
273
274/// Generate WGSL source for an element-wise unary operation.
275///
276/// The shader reads `n` elements from `input`, applies the operation, and
277/// writes the results to `output`.  Both buffers have `arrayLength` elements.
278///
279/// # Arguments
280///
281/// * `op` — one of: `"relu"`, `"sigmoid"`, `"tanh"`, `"exp"`, `"log"`,
282///   `"sqrt"`, `"abs"`, `"neg"`.  Unknown ops are treated as identity.
283pub fn elementwise_wgsl(op: &str) -> String {
284    let op_expr = match op {
285        "relu" => "max(x, 0.0)",
286        "sigmoid" => "1.0 / (1.0 + exp(-x))",
287        "tanh" => "tanh(x)",
288        "exp" => "exp(x)",
289        "log" => "log(x)",
290        "sqrt" => "sqrt(x)",
291        "abs" => "abs(x)",
292        "neg" => "-x",
293        _ => "x",
294    };
295
296    format!(
297        r#"
298@group(0) @binding(0) var<storage, read>       input:  array<f32>;
299@group(0) @binding(1) var<storage, read_write> output: array<f32>;
300
301@compute @workgroup_size(256)
302fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
303    let i = gid.x;
304    if (i >= arrayLength(&input)) {{ return; }}
305    let x = input[i];
306    output[i] = {op};
307}}
308"#,
309        op = op_expr
310    )
311}
312
313/// Generate WGSL source for an element-wise binary operation.
314///
315/// The shader reads `n` elements from two input buffers (`lhs` and `rhs`),
316/// applies the operation, and writes the results to `output`.
317///
318/// # Arguments
319///
320/// * `op` — one of: `"add"`, `"sub"`, `"mul"`, `"div"`, `"max"`, `"min"`,
321///   `"pow"`.  Unknown ops fall back to identity on `lhs`.
322pub fn binary_wgsl(op: &str) -> String {
323    let op_expr = match op {
324        "add" => "a + b",
325        "sub" => "a - b",
326        "mul" => "a * b",
327        "div" => "a / b",
328        "max" => "max(a, b)",
329        "min" => "min(a, b)",
330        "pow" => "pow(a, b)",
331        _ => "a",
332    };
333
334    format!(
335        r#"
336@group(0) @binding(0) var<storage, read>       lhs:    array<f32>;
337@group(0) @binding(1) var<storage, read>       rhs:    array<f32>;
338@group(0) @binding(2) var<storage, read_write> output: array<f32>;
339
340@compute @workgroup_size(256)
341fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
342    let i = gid.x;
343    if (i >= arrayLength(&lhs)) {{ return; }}
344    let a = lhs[i];
345    let b = rhs[i];
346    output[i] = {op};
347}}
348"#,
349        op = op_expr
350    )
351}
352
353/// Generate WGSL source for a parallel workgroup-level reduction.
354///
355/// Performs a two-pass approach: each workgroup of 256 threads reduces its
356/// tile to a single value in shared memory, then the results are written to
357/// a partial-sums buffer.  A second dispatch (with a single workgroup) then
358/// reduces the partial-sums to the final scalar.
359///
360/// # Arguments
361///
362/// * `op` — one of: `"sum"`, `"max"`, `"min"`, `"mean"`.  `"mean"` behaves
363///   like `"sum"` in the shader; the CPU is responsible for dividing by N.
364///   Unknown ops fall back to `"sum"`.
365pub fn reduction_wgsl(op: &str) -> String {
366    // Neutral elements and combine expressions for each operation.
367    let (neutral, combine) = match op {
368        "max" => ("f32(-1e38)", "max(acc, val)"),
369        "min" => ("f32(1e38)", "min(acc, val)"),
370        // "sum" and "mean" use the same reduction body.
371        _ => ("f32(0.0)", "acc + val"),
372    };
373
374    format!(
375        r#"
376// Reduction params: total element count.
377struct ReduceParams {{
378    n: u32,
379}}
380
381@group(0) @binding(0) var<storage, read>       input:        array<f32>;
382@group(0) @binding(1) var<storage, read_write> partial_sums: array<f32>;
383@group(0) @binding(2) var<uniform>             params:       ReduceParams;
384
385var<workgroup> shared_data: array<f32, 256>;
386
387@compute @workgroup_size(256)
388fn main(
389    @builtin(global_invocation_id) gid:  vec3<u32>,
390    @builtin(local_invocation_id)  lid:  vec3<u32>,
391    @builtin(workgroup_id)         wgid: vec3<u32>,
392) {{
393    let tid         = lid.x;
394    let global_idx  = gid.x;
395
396    // Load or use neutral element when out of range.
397    if (global_idx < params.n) {{
398        shared_data[tid] = input[global_idx];
399    }} else {{
400        shared_data[tid] = {neutral};
401    }}
402    workgroupBarrier();
403
404    // Parallel tree reduction within the workgroup.
405    var stride: u32 = 128u;
406    loop {{
407        if (stride == 0u) {{ break; }}
408        if (tid < stride) {{
409            let acc = shared_data[tid];
410            let val = shared_data[tid + stride];
411            shared_data[tid] = {combine};
412        }}
413        workgroupBarrier();
414        stride = stride >> 1u;
415    }}
416
417    // Thread 0 writes the workgroup result to the partial-sums buffer.
418    if (tid == 0u) {{
419        partial_sums[wgid.x] = shared_data[0];
420    }}
421}}
422"#,
423        neutral = neutral,
424        combine = combine,
425    )
426}
427
428/// Generate a WGSL compute shader for 2D convolution in NCHW format.
429///
430/// The shader reads from `input` (NCHW) and `filter` (K×C×FH×FW), writing
431/// the result to `output` (N×K×OH×OW).  Padding is handled via bounds
432/// checking — out-of-range input positions contribute zero.
433///
434/// # Arguments
435///
436/// * `n` — batch size
437/// * `c_in` — number of input channels
438/// * `h_in`, `w_in` — spatial input dimensions
439/// * `k_out` — number of output channels (filters)
440/// * `fh`, `fw` — filter height / width
441/// * `oh`, `ow` — output height / width
442/// * `stride_h`, `stride_w` — convolution strides
443/// * `pad_h`, `pad_w` — zero-padding applied to the input
444#[allow(clippy::too_many_arguments)]
445pub fn conv2d_wgsl(
446    n: u32,
447    c_in: u32,
448    h_in: u32,
449    w_in: u32,
450    k_out: u32,
451    fh: u32,
452    fw: u32,
453    oh: u32,
454    ow: u32,
455    stride_h: u32,
456    stride_w: u32,
457    pad_h: u32,
458    pad_w: u32,
459) -> String {
460    format!(
461        r#"
462// Conv2D NCHW — generated by oxicuda-webgpu
463// input   : [{n}, {c_in}, {h_in}, {w_in}]
464// kernel_w: [{k_out}, {c_in}, {fh}, {fw}]
465// output  : [{n}, {k_out}, {oh}, {ow}]
466
467@group(0) @binding(0) var<storage, read>       input:    array<f32>;
468@group(0) @binding(1) var<storage, read>       kernel_w: array<f32>;
469@group(0) @binding(2) var<storage, read_write> output:   array<f32>;
470
471@compute @workgroup_size(8, 8)
472fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
473    // gid.x = output x (ox mapped across batches*k_out*oh)
474    // We flatten (batch, k, oy) into gid.y and ox into gid.x
475    let ox = gid.x;
476    let linear_y = gid.y;
477
478    let batch_k_oh = {n}u * {k_out}u * {oh}u;
479    if (ox >= {ow}u || linear_y >= batch_k_oh) {{ return; }}
480
481    let b  = linear_y / ({k_out}u * {oh}u);
482    let rem = linear_y % ({k_out}u * {oh}u);
483    let kf = rem / {oh}u;
484    let oy = rem % {oh}u;
485
486    var acc: f32 = 0.0;
487    for (var ci: u32 = 0u; ci < {c_in}u; ci = ci + 1u) {{
488        for (var fy: u32 = 0u; fy < {fh}u; fy = fy + 1u) {{
489            for (var fx: u32 = 0u; fx < {fw}u; fx = fx + 1u) {{
490                let iy_raw = i32(oy * {stride_h}u + fy) - i32({pad_h}u);
491                let ix_raw = i32(ox * {stride_w}u + fx) - i32({pad_w}u);
492                if (iy_raw >= 0 && iy_raw < i32({h_in}u) && ix_raw >= 0 && ix_raw < i32({w_in}u)) {{
493                    let iy = u32(iy_raw);
494                    let ix = u32(ix_raw);
495                    let in_idx = ((b * {c_in}u + ci) * {h_in}u + iy) * {w_in}u + ix;
496                    let f_idx  = ((kf * {c_in}u + ci) * {fh}u + fy) * {fw}u + fx;
497                    acc += input[in_idx] * kernel_w[f_idx];
498                }}
499            }}
500        }}
501    }}
502
503    let o_idx = ((b * {k_out}u + kf) * {oh}u + oy) * {ow}u + ox;
504    output[o_idx] = acc;
505}}
506"#,
507        n = n,
508        c_in = c_in,
509        h_in = h_in,
510        w_in = w_in,
511        k_out = k_out,
512        fh = fh,
513        fw = fw,
514        oh = oh,
515        ow = ow,
516        stride_h = stride_h,
517        stride_w = stride_w,
518        pad_h = pad_h,
519        pad_w = pad_w,
520    )
521}
522
523/// Generate a WGSL compute shader for scaled dot-product attention.
524///
525/// Implements: `O = softmax(Q·K^T * scale [+ causal_mask]) · V`
526///
527/// The softmax is numerically stable (subtracts max before exp).
528/// When `causal` is true, positions where `sk > sq` are masked to −∞.
529///
530/// # Arguments
531///
532/// * `batch_heads` — combined batch × heads dimension
533/// * `seq_q` — query sequence length
534/// * `seq_kv` — key/value sequence length
535/// * `head_dim` — dimension of each head
536/// * `scale` — scaling factor (typically `1 / sqrt(head_dim)`)
537/// * `causal` — whether to apply a causal (upper-triangular) mask
538pub fn attention_wgsl(
539    batch_heads: u32,
540    seq_q: u32,
541    seq_kv: u32,
542    head_dim: u32,
543    scale: f32,
544    causal: bool,
545) -> String {
546    let causal_check = if causal {
547        "if (sk > sq) { score = f32(-1e38); } else {"
548    } else {
549        "{"
550    };
551
552    format!(
553        r#"
554// Scaled dot-product attention — generated by oxicuda-webgpu
555// Q, K, V : [{batch_heads}, seq, {head_dim}]
556// O       : [{batch_heads}, {seq_q}, {head_dim}]
557// scale   : {scale}
558// causal  : {causal}
559
560@group(0) @binding(0) var<storage, read>       q_buf: array<f32>;
561@group(0) @binding(1) var<storage, read>       k_buf: array<f32>;
562@group(0) @binding(2) var<storage, read>       v_buf: array<f32>;
563@group(0) @binding(3) var<storage, read_write> o_buf: array<f32>;
564
565@compute @workgroup_size(64)
566fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
567    let linear = gid.x;
568    let total = {batch_heads}u * {seq_q}u;
569    if (linear >= total) {{ return; }}
570
571    let bh = linear / {seq_q}u;
572    let sq = linear % {seq_q}u;
573
574    let q_base = (bh * {seq_q}u + sq) * {head_dim}u;
575
576    // Pass 1: find max score for numerical stability
577    var max_score: f32 = f32(-1e38);
578    for (var sk: u32 = 0u; sk < {seq_kv}u; sk = sk + 1u) {{
579        var score: f32 = 0.0;
580        {causal_check}
581            let k_base = (bh * {seq_kv}u + sk) * {head_dim}u;
582            for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
583                score += q_buf[q_base + d] * k_buf[k_base + d];
584            }}
585            score *= f32({scale});
586        }}
587        if (score > max_score) {{ max_score = score; }}
588    }}
589
590    // Pass 2: compute exp(score - max), accumulate weighted V
591    var sum_exp: f32 = 0.0;
592    for (var sk: u32 = 0u; sk < {seq_kv}u; sk = sk + 1u) {{
593        var score: f32 = 0.0;
594        {causal_check}
595            let k_base = (bh * {seq_kv}u + sk) * {head_dim}u;
596            for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
597                score += q_buf[q_base + d] * k_buf[k_base + d];
598            }}
599            score *= f32({scale});
600        }}
601        let w = exp(score - max_score);
602        sum_exp += w;
603        let v_base = (bh * {seq_kv}u + sk) * {head_dim}u;
604        let o_base = (bh * {seq_q}u + sq) * {head_dim}u;
605        for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
606            // Accumulate in-place (we normalise after the loop).
607            o_buf[o_base + d] += w * v_buf[v_base + d];
608        }}
609    }}
610
611    // Pass 3: normalise
612    if (sum_exp > 0.0) {{
613        let o_base = (bh * {seq_q}u + sq) * {head_dim}u;
614        for (var d: u32 = 0u; d < {head_dim}u; d = d + 1u) {{
615            o_buf[o_base + d] /= sum_exp;
616        }}
617    }}
618}}
619"#,
620        batch_heads = batch_heads,
621        seq_q = seq_q,
622        seq_kv = seq_kv,
623        head_dim = head_dim,
624        scale = scale,
625        causal = causal,
626        causal_check = causal_check,
627    )
628}
629
630/// Generate WGSL source for an N-D reduction along a single axis.
631///
632/// The tensor is logically reshaped to `[outer, dk, inner]`, where the reduce
633/// axis spans `dk` elements, `outer` is the product of dimensions before the
634/// axis, and `inner` is the product of dimensions after the axis.
635///
636/// Output shape is `[outer, inner]` (flattened to a 1-D buffer of length
637/// `outer * inner`).  Each output slot is computed by a full workgroup of
638/// 256 threads, which cooperatively reduce the `dk` elements via a strided
639/// loop and a shared-memory tree reduction.
640///
641/// Dispatch must be 2-D: `(grid_x, ceil((outer * inner) / grid_x), 1)` to
642/// stay below WebGPU's per-axis limit of 65 535 workgroups.  The shader
643/// decodes its slot via `wgid.y * params.grid_x + wgid.x` and early-returns
644/// if `slot >= outer * inner`.
645///
646/// For `Mean`, the shader divides each output by `dk` directly (no host-side
647/// post-processing required).
648///
649/// # Arguments
650///
651/// * `op` — one of: `"sum"`, `"max"`, `"min"`, `"mean"`.  Unknown ops fall
652///   back to `"sum"`.
653pub fn reduction_nd_wgsl(op: &str) -> String {
654    // The first form combines `acc` with `val` (per-thread strided loop).
655    // The second form combines `acc2` with `val` (in-shared-memory tree
656    // reduction).  Listing both explicitly is more robust than string-
657    // substitution for future ops.
658    let (neutral, combine, combine_alias) = match op {
659        "max" => ("f32(-1e38)", "max(acc, val)", "max(acc2, val)"),
660        "min" => ("f32(1e38)", "min(acc, val)", "min(acc2, val)"),
661        // "sum" and "mean" use the same combine; "mean" divides at the end.
662        _ => ("f32(0.0)", "acc + val", "acc2 + val"),
663    };
664
665    // For "mean", divide the final reduced value by dk; otherwise pass-through.
666    let final_expr = if op == "mean" {
667        "shared_data[0] / f32(params.dk)"
668    } else {
669        "shared_data[0]"
670    };
671
672    format!(
673        r#"
674struct ReduceNdParams {{
675    outer:        u32,
676    dk:           u32,
677    inner:        u32,
678    outer_stride: u32,
679    dk_stride:    u32,
680    inner_stride: u32,
681    grid_x:       u32,
682    _pad:         u32,
683}}
684
685@group(0) @binding(0) var<storage, read>       input:  array<f32>;
686@group(0) @binding(1) var<storage, read_write> output: array<f32>;
687@group(0) @binding(2) var<uniform>             params: ReduceNdParams;
688
689var<workgroup> shared_data: array<f32, 256>;
690
691@compute @workgroup_size(256)
692fn main(
693    @builtin(local_invocation_id) lid:  vec3<u32>,
694    @builtin(workgroup_id)        wgid: vec3<u32>,
695) {{
696    let tid = lid.x;
697    let total = params.outer * params.inner;
698
699    // Decode 2-D workgroup id back to a linear output slot.
700    let slot = wgid.y * params.grid_x + wgid.x;
701    if (slot >= total) {{ return; }}
702
703    let o = slot / params.inner;
704    let j = slot % params.inner;
705    let base = o * params.outer_stride + j * params.inner_stride;
706
707    // Strided per-thread reduction across the dk axis.
708    var acc: f32 = {neutral};
709    var i: u32 = tid;
710    loop {{
711        if (i >= params.dk) {{ break; }}
712        let val = input[base + i * params.dk_stride];
713        acc = {combine};
714        i = i + 256u;
715    }}
716
717    shared_data[tid] = acc;
718    workgroupBarrier();
719
720    // Tree reduction within the workgroup.
721    var stride: u32 = 128u;
722    loop {{
723        if (stride == 0u) {{ break; }}
724        if (tid < stride) {{
725            let acc2 = shared_data[tid];
726            let val  = shared_data[tid + stride];
727            shared_data[tid] = {combine_alias};
728        }}
729        workgroupBarrier();
730        stride = stride >> 1u;
731    }}
732
733    if (tid == 0u) {{
734        output[slot] = {final_expr};
735    }}
736}}
737"#,
738        neutral = neutral,
739        combine = combine,
740        combine_alias = combine_alias,
741        final_expr = final_expr,
742    )
743}
744
745/// Generate WGSL for the final scalar reduction of partial sums.
746///
747/// Takes a `partial_sums` array of length `num_groups` and reduces it to a
748/// single value at `output[0]`.  Dispatched with a single workgroup of 256
749/// threads: each thread first folds every partial it owns via a grid-stride
750/// loop (`partial_sums[tid]`, `partial_sums[tid + 256]`, …) so that an
751/// arbitrary `num_groups` — not just the first 256 — is reduced, then the 256
752/// per-thread accumulators are combined with a shared-memory tree reduction.
753pub fn reduction_final_wgsl(op: &str) -> String {
754    let (neutral, combine) = match op {
755        "max" => ("f32(-1e38)", "max(acc, val)"),
756        "min" => ("f32(1e38)", "min(acc, val)"),
757        _ => ("f32(0.0)", "acc + val"),
758    };
759
760    format!(
761        r#"
762struct FinalReduceParams {{
763    num_groups: u32,
764}}
765
766@group(0) @binding(0) var<storage, read>       partial_sums: array<f32>;
767@group(0) @binding(1) var<storage, read_write> output:       array<f32>;
768@group(0) @binding(2) var<uniform>             params:       FinalReduceParams;
769
770var<workgroup> shared_data: array<f32, 256>;
771
772@compute @workgroup_size(256)
773fn main(
774    @builtin(local_invocation_id) lid: vec3<u32>,
775) {{
776    let tid = lid.x;
777
778    // Grid-stride fold: each of the 256 threads accumulates every partial at
779    // index tid, tid+256, tid+512, …  Without this, partials beyond index 255
780    // (num_groups > 256, i.e. > 65 536 input elements) would be silently
781    // dropped.
782    var acc: f32 = {neutral};
783    var i: u32 = tid;
784    loop {{
785        if (i >= params.num_groups) {{ break; }}
786        let val = partial_sums[i];
787        acc = {combine};
788        i = i + 256u;
789    }}
790    shared_data[tid] = acc;
791    workgroupBarrier();
792
793    var stride: u32 = 128u;
794    loop {{
795        if (stride == 0u) {{ break; }}
796        if (tid < stride) {{
797            let acc = shared_data[tid];
798            let val = shared_data[tid + stride];
799            shared_data[tid] = {combine};
800        }}
801        workgroupBarrier();
802        stride = stride >> 1u;
803    }}
804
805    if (tid == 0u) {{
806        output[0] = shared_data[0];
807    }}
808}}
809"#,
810        neutral = neutral,
811        combine = combine,
812    )
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn wgsl_gemm_contains_workgroup() {
821        let src = gemm_wgsl(16);
822        assert!(src.contains("@compute @workgroup_size(16, 16)"));
823        assert!(src.contains("GemmParams"));
824        assert!(src.contains("alpha"));
825        assert!(src.contains("beta"));
826    }
827
828    #[test]
829    fn wgsl_gemm_tile_size_embedded() {
830        let src8 = gemm_wgsl(8);
831        assert!(src8.contains("@workgroup_size(8, 8)"));
832        let src32 = gemm_wgsl(32);
833        assert!(src32.contains("@workgroup_size(32, 32)"));
834    }
835
836    #[test]
837    fn wgsl_gemm_has_transpose_flags() {
838        let src = gemm_wgsl(8);
839        // Transpose flags live in the uniform struct.
840        assert!(src.contains("trans_a: u32"));
841        assert!(src.contains("trans_b: u32"));
842        // Leading-dimension uniforms drive the physical row strides.
843        assert!(src.contains("lda:     u32"));
844        assert!(src.contains("ldb:     u32"));
845        assert!(src.contains("ldc:     u32"));
846        // Both row-major and column-major index forms must be present, keyed on
847        // the leading dimensions rather than the packed extents.
848        assert!(src.contains("a[r * params.lda + i]"));
849        assert!(src.contains("a[i * params.lda + r]"));
850        assert!(src.contains("b[i * params.ldb + col]"));
851        assert!(src.contains("b[col * params.ldb + i]"));
852        assert!(src.contains("row * params.ldc + col"));
853    }
854
855    #[test]
856    fn wgsl_gemm_uses_shared_memory_tiling() {
857        let src = gemm_wgsl(16);
858        assert!(src.contains("var<workgroup> tile_a"));
859        assert!(src.contains("var<workgroup> tile_b"));
860        assert!(src.contains("workgroupBarrier"));
861        // Tile dimension is embedded in the workgroup-array declaration.
862        assert!(src.contains("array<array<f32, 16>, 16>"));
863    }
864
865    #[test]
866    fn wgsl_elementwise_relu_contains_max() {
867        let src = elementwise_wgsl("relu");
868        assert!(src.contains("max(x, 0.0)"));
869    }
870
871    #[test]
872    fn wgsl_elementwise_all_ops() {
873        assert!(elementwise_wgsl("sigmoid").contains("exp(-x)"));
874        assert!(elementwise_wgsl("tanh").contains("tanh(x)"));
875        assert!(elementwise_wgsl("exp").contains("exp(x)"));
876        assert!(elementwise_wgsl("log").contains("log(x)"));
877        assert!(elementwise_wgsl("sqrt").contains("sqrt(x)"));
878        assert!(elementwise_wgsl("abs").contains("abs(x)"));
879        assert!(elementwise_wgsl("neg").contains("-x"));
880        // Unknown op is identity.
881        assert!(elementwise_wgsl("identity_op").contains("output[i] = x;"));
882    }
883
884    #[test]
885    fn wgsl_reduction_sum_contains_addition() {
886        let src = reduction_wgsl("sum");
887        assert!(src.contains("acc + val"));
888        assert!(src.contains("workgroupBarrier"));
889    }
890
891    #[test]
892    fn wgsl_reduction_max_uses_max_fn() {
893        let src = reduction_wgsl("max");
894        assert!(src.contains("max(acc, val)"));
895    }
896
897    #[test]
898    fn wgsl_reduction_min_uses_min_fn() {
899        let src = reduction_wgsl("min");
900        assert!(src.contains("min(acc, val)"));
901    }
902
903    #[test]
904    fn wgsl_reduction_mean_same_as_sum() {
905        // "mean" divides on the CPU side; the shader is identical to sum.
906        let sum_src = reduction_wgsl("sum");
907        let mean_src = reduction_wgsl("mean");
908        assert_eq!(sum_src, mean_src);
909    }
910
911    #[test]
912    fn wgsl_reduction_final_sum() {
913        let src = reduction_final_wgsl("sum");
914        assert!(src.contains("num_groups"));
915        assert!(src.contains("output[0]"));
916    }
917
918    #[test]
919    fn wgsl_reduction_final_grid_strides_over_all_groups() {
920        // The final pass must fold *every* partial via a grid-stride loop, not
921        // just the first 256 (which would drop reductions over > 65 536
922        // elements).  Verify the strided loop is present for all ops.
923        for op in ["sum", "max", "min", "mean"] {
924            let src = reduction_final_wgsl(op);
925            assert!(
926                src.contains("i = i + 256u"),
927                "final reduction for {op} lacks the grid-stride loop"
928            );
929            assert!(
930                src.contains("if (i >= params.num_groups)"),
931                "final reduction for {op} lacks the num_groups loop bound"
932            );
933        }
934    }
935
936    // ── reduction_nd_wgsl tests ───────────────────────────────────────────
937
938    #[test]
939    fn wgsl_reduction_nd_sum_contains_addition() {
940        let src = reduction_nd_wgsl("sum");
941        assert!(src.contains("acc + val"));
942        // Tree-step reuses the same combine with renamed lhs.
943        assert!(src.contains("acc2 + val"));
944        assert!(src.contains("workgroupBarrier"));
945        assert!(src.contains("ReduceNdParams"));
946    }
947
948    #[test]
949    fn wgsl_reduction_nd_max_uses_max_fn() {
950        let src = reduction_nd_wgsl("max");
951        assert!(src.contains("max(acc, val)"));
952        assert!(src.contains("max(acc2, val)"));
953    }
954
955    #[test]
956    fn wgsl_reduction_nd_min_uses_min_fn() {
957        let src = reduction_nd_wgsl("min");
958        assert!(src.contains("min(acc, val)"));
959        assert!(src.contains("min(acc2, val)"));
960    }
961
962    #[test]
963    fn wgsl_reduction_nd_mean_divides_by_dk() {
964        let src = reduction_nd_wgsl("mean");
965        assert!(src.contains("shared_data[0] / f32(params.dk)"));
966        assert!(src.contains("acc + val"));
967    }
968
969    #[test]
970    fn wgsl_reduction_nd_sum_does_not_divide() {
971        let src = reduction_nd_wgsl("sum");
972        assert!(!src.contains("/ f32(params.dk)"));
973    }
974
975    #[test]
976    fn wgsl_reduction_nd_decodes_2d_dispatch() {
977        let src = reduction_nd_wgsl("sum");
978        assert!(src.contains("wgid.y * params.grid_x + wgid.x"));
979    }
980
981    #[test]
982    fn wgsl_reduction_nd_uses_strided_loop() {
983        let src = reduction_nd_wgsl("sum");
984        assert!(src.contains("i = i + 256u"));
985    }
986
987    // ── binary_wgsl tests ─────────────────────────────────────────────────
988
989    #[test]
990    fn wgsl_binary_add() {
991        let src = binary_wgsl("add");
992        assert!(src.contains("a + b"));
993        assert!(src.contains("lhs"));
994        assert!(src.contains("rhs"));
995    }
996
997    #[test]
998    fn wgsl_binary_all_ops() {
999        assert!(binary_wgsl("sub").contains("a - b"));
1000        assert!(binary_wgsl("mul").contains("a * b"));
1001        assert!(binary_wgsl("div").contains("a / b"));
1002        assert!(binary_wgsl("max").contains("max(a, b)"));
1003        assert!(binary_wgsl("min").contains("min(a, b)"));
1004        assert!(binary_wgsl("pow").contains("pow(a, b)"));
1005        // Unknown op is identity on lhs.
1006        assert!(binary_wgsl("unknown_op").contains("output[i] = a;"));
1007    }
1008
1009    #[test]
1010    fn wgsl_binary_workgroup_size() {
1011        let src = binary_wgsl("add");
1012        assert!(src.contains("@workgroup_size(256)"));
1013    }
1014
1015    // ── conv2d_wgsl tests ─────────────────────────────────────────────────
1016
1017    #[test]
1018    fn wgsl_conv2d_contains_workgroup() {
1019        let src = conv2d_wgsl(1, 3, 32, 32, 16, 3, 3, 30, 30, 1, 1, 0, 0);
1020        assert!(src.contains("@compute @workgroup_size(8, 8)"));
1021    }
1022
1023    #[test]
1024    fn wgsl_conv2d_contains_storage_bindings() {
1025        let src = conv2d_wgsl(1, 3, 32, 32, 16, 3, 3, 30, 30, 1, 1, 0, 0);
1026        assert!(src.contains("var<storage, read>       input:"));
1027        // "filter" is a reserved WGSL keyword; the binding was renamed to kernel_w.
1028        assert!(src.contains("var<storage, read>       kernel_w:"));
1029        assert!(src.contains("var<storage, read_write> output:"));
1030    }
1031
1032    #[test]
1033    fn wgsl_conv2d_embeds_dimensions() {
1034        let src = conv2d_wgsl(2, 8, 64, 64, 32, 5, 5, 60, 60, 1, 1, 0, 0);
1035        // Check that the shape constants appear in the shader
1036        assert!(src.contains("8u")); // c_in
1037        assert!(src.contains("64u")); // h_in or w_in
1038        assert!(src.contains("32u")); // k_out
1039        assert!(src.contains("5u")); // fh or fw
1040        assert!(src.contains("60u")); // oh or ow
1041    }
1042
1043    #[test]
1044    fn wgsl_conv2d_has_padding_check() {
1045        let src = conv2d_wgsl(1, 1, 8, 8, 1, 3, 3, 8, 8, 1, 1, 1, 1);
1046        // Padding check with signed comparison
1047        assert!(src.contains("iy_raw >= 0"));
1048        assert!(src.contains("ix_raw >= 0"));
1049    }
1050
1051    #[test]
1052    fn wgsl_conv2d_has_stride() {
1053        let src = conv2d_wgsl(1, 1, 8, 8, 1, 3, 3, 3, 3, 2, 2, 0, 0);
1054        assert!(src.contains("2u")); // stride
1055    }
1056
1057    // ── attention_wgsl tests ──────────────────────────────────────────────
1058
1059    #[test]
1060    fn wgsl_attention_contains_workgroup() {
1061        let src = attention_wgsl(4, 8, 8, 64, 0.125, false);
1062        assert!(src.contains("@compute @workgroup_size(64)"));
1063    }
1064
1065    #[test]
1066    fn wgsl_attention_contains_storage_bindings() {
1067        let src = attention_wgsl(4, 8, 8, 64, 0.125, false);
1068        assert!(src.contains("var<storage, read>       q_buf:"));
1069        assert!(src.contains("var<storage, read>       k_buf:"));
1070        assert!(src.contains("var<storage, read>       v_buf:"));
1071        assert!(src.contains("var<storage, read_write> o_buf:"));
1072    }
1073
1074    #[test]
1075    fn wgsl_attention_stable_softmax() {
1076        let src = attention_wgsl(1, 4, 4, 32, 0.25, false);
1077        assert!(src.contains("max_score"));
1078        assert!(src.contains("exp(score - max_score)"));
1079        assert!(src.contains("sum_exp"));
1080    }
1081
1082    #[test]
1083    fn wgsl_attention_causal_mask() {
1084        let src_causal = attention_wgsl(1, 4, 4, 32, 0.25, true);
1085        assert!(src_causal.contains("sk > sq"));
1086
1087        let src_non_causal = attention_wgsl(1, 4, 4, 32, 0.25, false);
1088        assert!(!src_non_causal.contains("sk > sq"));
1089    }
1090
1091    #[test]
1092    fn wgsl_attention_embeds_scale() {
1093        let src = attention_wgsl(2, 16, 16, 64, 0.125, false);
1094        assert!(src.contains("0.125"));
1095    }
1096
1097    // ── batched_gemm_wgsl tests ────────────────────────────────────────────
1098
1099    #[test]
1100    fn wgsl_batched_gemm_contains_batch_params() {
1101        let src = batched_gemm_wgsl(16);
1102        assert!(src.contains("batch_count"));
1103        assert!(src.contains("stride_a"));
1104        assert!(src.contains("stride_b"));
1105        assert!(src.contains("stride_c"));
1106    }
1107
1108    #[test]
1109    fn wgsl_batched_gemm_contains_workgroup() {
1110        let src = batched_gemm_wgsl(16);
1111        assert!(src.contains("@compute @workgroup_size(16, 16)"));
1112        assert!(src.contains("BatchedGemmParams"));
1113    }
1114
1115    #[test]
1116    fn wgsl_batched_gemm_uses_batch_index() {
1117        let src = batched_gemm_wgsl(8);
1118        assert!(src.contains("batch_index"));
1119        assert!(src.contains("gid.z"));
1120    }
1121
1122    #[test]
1123    fn wgsl_batched_gemm_tile_size_embedded() {
1124        let src8 = batched_gemm_wgsl(8);
1125        assert!(src8.contains("@workgroup_size(8, 8)"));
1126        let src32 = batched_gemm_wgsl(32);
1127        assert!(src32.contains("@workgroup_size(32, 32)"));
1128    }
1129
1130    #[test]
1131    fn wgsl_batched_gemm_has_transpose_flags() {
1132        let src = batched_gemm_wgsl(8);
1133        assert!(src.contains("trans_a:  u32"));
1134        assert!(src.contains("trans_b:  u32"));
1135        assert!(src.contains("lda:      u32"));
1136        assert!(src.contains("ldb:      u32"));
1137        assert!(src.contains("ldc:      u32"));
1138        // Per-batch offset is applied to every index form, keyed on the
1139        // per-batch leading dimensions.
1140        assert!(src.contains("a[a_offset + r * params.lda + i]"));
1141        assert!(src.contains("a[a_offset + i * params.lda + r]"));
1142        assert!(src.contains("b[b_offset + i * params.ldb + col]"));
1143        assert!(src.contains("b[b_offset + col * params.ldb + i]"));
1144        assert!(src.contains("row * params.ldc + col"));
1145    }
1146
1147    #[test]
1148    fn wgsl_batched_gemm_uses_shared_memory_tiling() {
1149        let src = batched_gemm_wgsl(8);
1150        assert!(src.contains("var<workgroup> tile_a"));
1151        assert!(src.contains("var<workgroup> tile_b"));
1152        assert!(src.contains("workgroupBarrier"));
1153        assert!(src.contains("array<array<f32, 8>, 8>"));
1154    }
1155
1156    // ── gemm_wgsl_f16 tests ─────────────────────────────────────────────
1157
1158    #[test]
1159    fn wgsl_gemm_f16_enables_extension() {
1160        let src = gemm_wgsl_f16(16);
1161        assert!(src.contains("enable f16;"));
1162    }
1163
1164    #[test]
1165    fn wgsl_gemm_f16_uses_f16_storage() {
1166        let src = gemm_wgsl_f16(16);
1167        assert!(src.contains("array<f16>"));
1168    }
1169
1170    #[test]
1171    fn wgsl_gemm_f16_accumulates_in_f32() {
1172        let src = gemm_wgsl_f16(16);
1173        assert!(src.contains("var acc: f32 = 0.0;"));
1174        assert!(src.contains("f32(a["));
1175        assert!(src.contains("f32(b["));
1176    }
1177
1178    #[test]
1179    fn wgsl_gemm_f16_contains_workgroup() {
1180        let src = gemm_wgsl_f16(8);
1181        assert!(src.contains("@compute @workgroup_size(8, 8)"));
1182        assert!(src.contains("GemmParams"));
1183    }
1184
1185    #[test]
1186    fn wgsl_attention_embeds_dimensions() {
1187        let src = attention_wgsl(8, 32, 32, 128, 0.088, true);
1188        assert!(src.contains("128u")); // head_dim
1189        assert!(src.contains("32u")); // seq_q or seq_kv
1190        assert!(src.contains("8u")); // batch_heads
1191    }
1192}