Skip to main content

oxicuda_webgpu/
shader_ext.rs

1//! Additional WGSL shader-source generators that extend [`crate::shader`].
2//!
3//! Like the base module, every function here returns a complete, self-contained
4//! WGSL source string ready for `device.create_shader_module()`.  None of these
5//! require a GPU to *generate* or to test structurally; they are verified by
6//! asserting on the emitted text (correct `@group`/`@binding`, `@workgroup_size`,
7//! the right arithmetic, bounds guards, and — for softmax/layernorm — that the
8//! numerically-stable max-subtraction / mean-centering steps are present).
9//!
10//! Covered kernels (all genuinely missing from `shader.rs`):
11//!
12//! * [`transpose_wgsl`] — tiled 2-D matrix transpose with a padded shared tile.
13//! * [`softmax_wgsl`] — row-wise numerically-stable softmax (max-subtraction).
14//! * [`scan_wgsl`] — Blelloch work-efficient inclusive/exclusive prefix scan.
15//! * [`layernorm_wgsl`] — row-wise layer normalisation (Ba et al. 2016).
16//! * [`subgroup_reduction_wgsl`] — warp-style subgroup reduction (Chrome 125+
17//!   / Firefox 135+); the WGSL *source* and its `enable subgroups;` gate are
18//!   generated and tested here even though on-device dispatch is HW-gated.
19//! * [`f64_emul_add_wgsl`] — double-single (`vec2<f32>`) emulated f64 add for
20//!   adapters that lack native FP64 (which is all of WebGPU).
21
22/// Generate WGSL for a tiled 2-D matrix transpose: `out[c, r] = in[r, c]`.
23///
24/// `in` is a row-major `rows × cols` matrix; `out` is a row-major
25/// `cols × rows` matrix.  A `tile × (tile + 1)` shared-memory staging array is
26/// used so that the read and write phases are both coalesced and the `+1`
27/// padding avoids shared-memory bank conflicts.
28///
29/// # Arguments
30///
31/// * `tile_size` — workgroup tile dimension (e.g. 8, 16, 32).
32#[must_use]
33pub fn transpose_wgsl(tile_size: u32) -> String {
34    let padded = tile_size + 1;
35    format!(
36        r#"
37struct TransposeParams {{
38    rows: u32,
39    cols: u32,
40}}
41
42@group(0) @binding(0) var<storage, read>       src: array<f32>;
43@group(0) @binding(1) var<storage, read_write> dst: array<f32>;
44@group(0) @binding(2) var<uniform>             params: TransposeParams;
45
46// Padded by +1 column to avoid shared-memory bank conflicts.
47var<workgroup> tile: array<array<f32, {padded}>, {ts}>;
48
49@compute @workgroup_size({ts}, {ts})
50fn main(
51    @builtin(workgroup_id)        wgid: vec3<u32>,
52    @builtin(local_invocation_id) lid:  vec3<u32>,
53) {{
54    let lr = lid.y;
55    let lc = lid.x;
56
57    // Read phase: coalesced load of a tile of the source.
58    let in_r = wgid.y * {ts}u + lr;
59    let in_c = wgid.x * {ts}u + lc;
60    if (in_r < params.rows && in_c < params.cols) {{
61        tile[lr][lc] = src[in_r * params.cols + in_c];
62    }} else {{
63        tile[lr][lc] = 0.0;
64    }}
65    workgroupBarrier();
66
67    // Write phase: transposed coordinates, coalesced store to the destination.
68    let out_r = wgid.x * {ts}u + lr;
69    let out_c = wgid.y * {ts}u + lc;
70    if (out_r < params.cols && out_c < params.rows) {{
71        dst[out_r * params.rows + out_c] = tile[lc][lr];
72    }}
73}}
74"#,
75        ts = tile_size,
76        padded = padded,
77    )
78}
79
80/// Generate WGSL for a row-wise, numerically-stable softmax.
81///
82/// The input is a row-major `rows × cols` matrix; softmax is applied
83/// independently to each of the `rows` rows.  Each row is handled by one
84/// workgroup of 256 threads in three cooperative passes:
85///
86/// 1. row max via a shared-memory tree reduction (numerical stability),
87/// 2. `sum(exp(x - max))` via a second tree reduction, and
88/// 3. write `exp(x - max) / sum`.
89///
90/// Subtracting the row max before `exp` is what keeps the result finite for
91/// large logits; a naïve `exp(x) / sum(exp(x))` would overflow.
92#[must_use]
93pub fn softmax_wgsl() -> String {
94    r#"
95struct SoftmaxParams {
96    rows: u32,
97    cols: u32,
98}
99
100@group(0) @binding(0) var<storage, read>       input:  array<f32>;
101@group(0) @binding(1) var<storage, read_write> output: array<f32>;
102@group(0) @binding(2) var<uniform>             params: SoftmaxParams;
103
104var<workgroup> shared_max: array<f32, 256>;
105var<workgroup> shared_sum: array<f32, 256>;
106
107@compute @workgroup_size(256)
108fn main(
109    @builtin(workgroup_id)        wgid: vec3<u32>,
110    @builtin(local_invocation_id) lid:  vec3<u32>,
111) {
112    let row = wgid.x;
113    if (row >= params.rows) { return; }
114    let tid = lid.x;
115    let base = row * params.cols;
116
117    // Pass 1: per-thread partial max over a strided slice of the row.  True
118    // negative infinity (not a finite `-1e38` sentinel), so a row whose real
119    // maximum lies below that magnitude is never masked by the neutral
120    // element.
121    var local_max: f32 = bitcast<f32>(0xFF800000u);
122    var i: u32 = tid;
123    loop {
124        if (i >= params.cols) { break; }
125        local_max = max(local_max, input[base + i]);
126        i = i + 256u;
127    }
128    shared_max[tid] = local_max;
129    workgroupBarrier();
130    var stride: u32 = 128u;
131    loop {
132        if (stride == 0u) { break; }
133        if (tid < stride) {
134            shared_max[tid] = max(shared_max[tid], shared_max[tid + stride]);
135        }
136        workgroupBarrier();
137        stride = stride >> 1u;
138    }
139    let row_max = shared_max[0];
140    workgroupBarrier();
141
142    // Pass 2: per-thread partial sum of exp(x - row_max).
143    var local_sum: f32 = 0.0;
144    i = tid;
145    loop {
146        if (i >= params.cols) { break; }
147        local_sum = local_sum + exp(input[base + i] - row_max);
148        i = i + 256u;
149    }
150    shared_sum[tid] = local_sum;
151    workgroupBarrier();
152    stride = 128u;
153    loop {
154        if (stride == 0u) { break; }
155        if (tid < stride) {
156            shared_sum[tid] = shared_sum[tid] + shared_sum[tid + stride];
157        }
158        workgroupBarrier();
159        stride = stride >> 1u;
160    }
161    let row_sum = shared_sum[0];
162    let inv_sum = 1.0 / row_sum;
163    workgroupBarrier();
164
165    // Pass 3: write normalised probabilities.
166    i = tid;
167    loop {
168        if (i >= params.cols) { break; }
169        output[base + i] = exp(input[base + i] - row_max) * inv_sum;
170        i = i + 256u;
171    }
172}
173"#
174    .to_string()
175}
176
177/// Whether a prefix scan is inclusive (`out[i]` includes `in[i]`) or exclusive
178/// (`out[i]` is the sum of all strictly-earlier elements).
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum ScanKind {
181    /// Inclusive scan: `out[i] = in[0] + ... + in[i]`.
182    Inclusive,
183    /// Exclusive scan: `out[i] = in[0] + ... + in[i-1]`, `out[0] = 0`.
184    Exclusive,
185}
186
187/// Generate WGSL for a single-block Blelloch work-efficient prefix scan.
188///
189/// Implements the up-sweep (reduce) + down-sweep phases of the Blelloch
190/// (1990) scan over a power-of-two-sized shared array of `block_size`
191/// elements.  This is the per-block primitive; a multi-block scan composes
192/// these with a block-sum carry pass at the host level.
193///
194/// `block_size` should be a power of two; the emitted `@workgroup_size` is
195/// `block_size / 2` because each thread handles two elements (the canonical
196/// Blelloch mapping).
197///
198/// For an exclusive scan the algorithm clears the last element before the
199/// down-sweep; the inclusive variant additionally adds the original input back
200/// after the exclusive down-sweep.
201#[must_use]
202pub fn scan_wgsl(block_size: u32, kind: ScanKind) -> String {
203    let threads = (block_size / 2).max(1);
204    // Inclusive = exclusive scan plus the original element added back.
205    //
206    // Each of the two elements a thread owns (`2*tid`, `2*tid + 1`) is
207    // bounds-checked *independently* against `params.n`.  A single shared
208    // guard on just the first element would let the second write land one
209    // element past `n` whenever the block's tail is odd-length (i.e. `n`
210    // falls strictly between `base + 2*tid` and `base + 2*tid + 1`).
211    let inclusive_fixup = match kind {
212        ScanKind::Inclusive => {
213            "    // Inclusive: add the original input back to the exclusive result.\n    \
214             if (base + 2u * tid < params.n) {\n        \
215             output[base + 2u * tid] = shared_data[2u * tid] + input[base + 2u * tid];\n    \
216             }\n    \
217             if (base + 2u * tid + 1u < params.n) {\n        \
218             output[base + 2u * tid + 1u] = shared_data[2u * tid + 1u] + input[base + 2u * tid + 1u];\n    \
219             }"
220        }
221        ScanKind::Exclusive => {
222            "    if (base + 2u * tid < params.n) {\n        \
223             output[base + 2u * tid] = shared_data[2u * tid];\n    \
224             }\n    \
225             if (base + 2u * tid + 1u < params.n) {\n        \
226             output[base + 2u * tid + 1u] = shared_data[2u * tid + 1u];\n    \
227             }"
228        }
229    };
230    let kind_comment = match kind {
231        ScanKind::Inclusive => "inclusive",
232        ScanKind::Exclusive => "exclusive",
233    };
234
235    format!(
236        r#"
237// Blelloch work-efficient {kind_comment} prefix scan (block size {bs}).
238struct ScanParams {{
239    n: u32,
240}}
241
242@group(0) @binding(0) var<storage, read>       input:  array<f32>;
243@group(0) @binding(1) var<storage, read_write> output: array<f32>;
244@group(0) @binding(2) var<uniform>             params: ScanParams;
245
246var<workgroup> shared_data: array<f32, {bs}>;
247
248@compute @workgroup_size({threads})
249fn main(
250    @builtin(workgroup_id)        wgid: vec3<u32>,
251    @builtin(local_invocation_id) lid:  vec3<u32>,
252) {{
253    let tid  = lid.x;
254    let base = wgid.x * {bs}u;
255
256    // Load two elements per thread (zero-pad out-of-range).
257    let i0 = 2u * tid;
258    let i1 = 2u * tid + 1u;
259    if (base + i0 < params.n) {{ shared_data[i0] = input[base + i0]; }} else {{ shared_data[i0] = 0.0; }}
260    if (base + i1 < params.n) {{ shared_data[i1] = input[base + i1]; }} else {{ shared_data[i1] = 0.0; }}
261
262    // Up-sweep (reduce) phase.
263    var offset: u32 = 1u;
264    var d: u32 = {bs}u >> 1u;
265    loop {{
266        workgroupBarrier();
267        if (tid < d) {{
268            let ai = offset * (2u * tid + 1u) - 1u;
269            let bi = offset * (2u * tid + 2u) - 1u;
270            shared_data[bi] = shared_data[bi] + shared_data[ai];
271        }}
272        offset = offset << 1u;
273        if (d == 1u) {{ break; }}
274        d = d >> 1u;
275    }}
276
277    // Clear the last element (root) for the exclusive down-sweep.
278    if (tid == 0u) {{ shared_data[{bs}u - 1u] = 0.0; }}
279
280    // Down-sweep phase.
281    d = 1u;
282    loop {{
283        offset = offset >> 1u;
284        workgroupBarrier();
285        if (tid < d) {{
286            let ai = offset * (2u * tid + 1u) - 1u;
287            let bi = offset * (2u * tid + 2u) - 1u;
288            let t = shared_data[ai];
289            shared_data[ai] = shared_data[bi];
290            shared_data[bi] = shared_data[bi] + t;
291        }}
292        if (d == {bs}u >> 1u) {{ break; }}
293        d = d << 1u;
294    }}
295    workgroupBarrier();
296
297    // Write results (exclusive in shared_data; inclusive adds input back).
298    // Bounds-checked per-element inside `inclusive_fixup` (see above).
299{inclusive_fixup}
300}}
301"#,
302        bs = block_size,
303        threads = threads,
304        kind_comment = kind_comment,
305        inclusive_fixup = inclusive_fixup,
306    )
307}
308
309/// Generate WGSL for row-wise layer normalisation (Ba, Kiros & Hinton 2016).
310///
311/// For each row of a row-major `rows × cols` matrix, computes
312/// `y = (x - mean) / sqrt(var + eps) * gamma + beta`, where `mean` and `var`
313/// are the per-row mean and (biased) variance.  `gamma` and `beta` are
314/// per-column affine parameters of length `cols`.  `eps` is embedded as a
315/// constant.
316///
317/// Each row is processed by one workgroup of 256 threads with two cooperative
318/// tree reductions (sum, then sum-of-squares for variance).
319///
320/// # Arguments
321///
322/// * `eps` — numerical-stability epsilon added to the variance.
323#[must_use]
324pub fn layernorm_wgsl(eps: f32) -> String {
325    format!(
326        r#"
327struct LayerNormParams {{
328    rows: u32,
329    cols: u32,
330}}
331
332@group(0) @binding(0) var<storage, read>       input:  array<f32>;
333@group(0) @binding(1) var<storage, read>       gamma:  array<f32>;
334@group(0) @binding(2) var<storage, read>       beta:   array<f32>;
335@group(0) @binding(3) var<storage, read_write> output: array<f32>;
336@group(0) @binding(4) var<uniform>             params: LayerNormParams;
337
338var<workgroup> shared_acc: array<f32, 256>;
339
340@compute @workgroup_size(256)
341fn main(
342    @builtin(workgroup_id)        wgid: vec3<u32>,
343    @builtin(local_invocation_id) lid:  vec3<u32>,
344) {{
345    let row = wgid.x;
346    if (row >= params.rows) {{ return; }}
347    let tid  = lid.x;
348    let base = row * params.cols;
349    let inv_n = 1.0 / f32(params.cols);
350
351    // Pass 1: mean.
352    var local_sum: f32 = 0.0;
353    var i: u32 = tid;
354    loop {{
355        if (i >= params.cols) {{ break; }}
356        local_sum = local_sum + input[base + i];
357        i = i + 256u;
358    }}
359    shared_acc[tid] = local_sum;
360    workgroupBarrier();
361    var stride: u32 = 128u;
362    loop {{
363        if (stride == 0u) {{ break; }}
364        if (tid < stride) {{
365            shared_acc[tid] = shared_acc[tid] + shared_acc[tid + stride];
366        }}
367        workgroupBarrier();
368        stride = stride >> 1u;
369    }}
370    let mean = shared_acc[0] * inv_n;
371    workgroupBarrier();
372
373    // Pass 2: variance (mean of squared deviations).
374    var local_var: f32 = 0.0;
375    i = tid;
376    loop {{
377        if (i >= params.cols) {{ break; }}
378        let d = input[base + i] - mean;
379        local_var = local_var + d * d;
380        i = i + 256u;
381    }}
382    shared_acc[tid] = local_var;
383    workgroupBarrier();
384    stride = 128u;
385    loop {{
386        if (stride == 0u) {{ break; }}
387        if (tid < stride) {{
388            shared_acc[tid] = shared_acc[tid] + shared_acc[tid + stride];
389        }}
390        workgroupBarrier();
391        stride = stride >> 1u;
392    }}
393    let variance = shared_acc[0] * inv_n;
394    let inv_std = 1.0 / sqrt(variance + f32({eps}));
395    workgroupBarrier();
396
397    // Pass 3: normalise + affine.
398    i = tid;
399    loop {{
400        if (i >= params.cols) {{ break; }}
401        let norm = (input[base + i] - mean) * inv_std;
402        output[base + i] = norm * gamma[i] + beta[i];
403        i = i + 256u;
404    }}
405}}
406"#,
407        eps = eps,
408    )
409}
410
411/// Generate WGSL for a warp-style subgroup reduction (P0 roadmap item).
412///
413/// Emits a compute shader that uses the WGSL `subgroups` extension and the
414/// `subgroupAdd` / `subgroupMax` / `subgroupMin` built-ins (stabilising in
415/// Chrome 125+ and Firefox 135+).  The shader reduces each subgroup's lane
416/// values with a single built-in call, then the subgroup leaders combine their
417/// partials through shared memory.
418///
419/// **Device note:** actually *dispatching* this requires an adapter that
420/// reports `wgpu::Features::SUBGROUP`; the emitted source and its
421/// `enable subgroups;` directive are generated and tested here on CPU, but
422/// on-hardware execution is gated on a real GPU that supports subgroups.
423///
424/// # Arguments
425///
426/// * `op` — one of `"sum"`, `"max"`, `"min"` (unknown ops fall back to
427///   `"sum"`).
428/// * `chromium_experimental` — when `true`, emit the pre-standard
429///   `enable chromium_experimental_subgroups;` directive instead of the
430///   standard `enable subgroups;` (Chromium native path).
431#[must_use]
432pub fn subgroup_reduction_wgsl(op: &str, chromium_experimental: bool) -> String {
433    let (subgroup_fn, neutral) = match op {
434        "max" => ("subgroupMax", "bitcast<f32>(0xFF800000u)"),
435        "min" => ("subgroupMin", "bitcast<f32>(0x7F800000u)"),
436        _ => ("subgroupAdd", "f32(0.0)"),
437    };
438    // Combine across subgroup leaders in shared memory.
439    let combine = match op {
440        "max" => "max(acc, val)",
441        "min" => "min(acc, val)",
442        _ => "acc + val",
443    };
444    let enable = if chromium_experimental {
445        "enable chromium_experimental_subgroups;"
446    } else {
447        "enable subgroups;"
448    };
449
450    format!(
451        r#"
452{enable}
453
454struct SubgroupReduceParams {{
455    n: u32,
456}}
457
458@group(0) @binding(0) var<storage, read>       input:        array<f32>;
459@group(0) @binding(1) var<storage, read_write> partial_sums: array<f32>;
460@group(0) @binding(2) var<uniform>             params:       SubgroupReduceParams;
461
462// Up to 256 lanes / min-subgroup-size of 4 = 64 leader slots, padded to 64.
463var<workgroup> leader_vals: array<f32, 64>;
464
465@compute @workgroup_size(256)
466fn main(
467    @builtin(global_invocation_id)   gid:  vec3<u32>,
468    @builtin(local_invocation_id)    lid:  vec3<u32>,
469    @builtin(workgroup_id)           wgid: vec3<u32>,
470    @builtin(subgroup_invocation_id) sg_id:   u32,
471    @builtin(subgroup_size)          sg_size: u32,
472) {{
473    let tid = lid.x;
474    var v: f32 = {neutral};
475    if (gid.x < params.n) {{ v = input[gid.x]; }}
476
477    // One built-in call reduces the whole subgroup.
478    let sg_reduced = {subgroup_fn}(v);
479
480    // Subgroup leaders publish their reduced value.
481    let leader_index = tid / sg_size;
482    if (sg_id == 0u) {{
483        leader_vals[leader_index] = sg_reduced;
484    }}
485    workgroupBarrier();
486
487    // Thread 0 folds the leader partials and writes the workgroup result.
488    if (tid == 0u) {{
489        let num_leaders = (256u + sg_size - 1u) / sg_size;
490        var acc: f32 = {neutral};
491        for (var i: u32 = 0u; i < num_leaders; i = i + 1u) {{
492            let val = leader_vals[i];
493            acc = {combine};
494        }}
495        partial_sums[wgid.x] = acc;
496    }}
497}}
498"#,
499        enable = enable,
500        subgroup_fn = subgroup_fn,
501        neutral = neutral,
502        combine = combine,
503    )
504}
505
506/// Generate WGSL for emulated double-precision **addition** using the
507/// double-single ("double-float") technique (P2 roadmap item).
508///
509/// WebGPU has **no native FP64**.  Each logical f64 value is stored as a
510/// `vec2<f32>` = `(hi, lo)` where `hi` is the leading f32 and `lo` is the
511/// round-off residual, giving ~46 bits of mantissa.  The kernel adds two such
512/// arrays element-wise using Knuth's TwoSum / Dekker error-free transformation
513/// so the residual is carried correctly.
514///
515/// Buffers are laid out as interleaved `(hi, lo)` pairs, i.e. element `i`
516/// occupies indices `2*i` (hi) and `2*i + 1` (lo).
517#[must_use]
518pub fn f64_emul_add_wgsl() -> String {
519    r#"
520// Double-single (emulated f64) element-wise add.  No native FP64 on WebGPU.
521// Each value is a (hi, lo) pair: lo carries the round-off residual of hi.
522struct DfParams {
523    n: u32,
524}
525
526@group(0) @binding(0) var<storage, read>       a:      array<f32>;
527@group(0) @binding(1) var<storage, read>       b:      array<f32>;
528@group(0) @binding(2) var<storage, read_write> c:      array<f32>;
529@group(0) @binding(3) var<uniform>             params: DfParams;
530
531// Knuth TwoSum: returns (s, e) with a + b == s + e exactly (in f32).
532fn two_sum(av: f32, bv: f32) -> vec2<f32> {
533    let s = av + bv;
534    let bb = s - av;
535    let err = (av - (s - bb)) + (bv - bb);
536    return vec2<f32>(s, err);
537}
538
539// Add two double-single numbers (hi, lo) + (hi, lo).
540fn df_add(x: vec2<f32>, y: vec2<f32>) -> vec2<f32> {
541    let sh = two_sum(x.x, y.x);
542    let sl = two_sum(x.y, y.y);
543    var hi = sh.x;
544    var lo = sh.y + sl.x;
545    // Renormalise the high/low split.
546    let r1 = two_sum(hi, lo);
547    hi = r1.x;
548    lo = r1.y + sl.y;
549    let r2 = two_sum(hi, lo);
550    return vec2<f32>(r2.x, r2.y);
551}
552
553@compute @workgroup_size(256)
554fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
555    let i = gid.x;
556    if (i >= params.n) { return; }
557    let av = vec2<f32>(a[2u * i], a[2u * i + 1u]);
558    let bv = vec2<f32>(b[2u * i], b[2u * i + 1u]);
559    let r = df_add(av, bv);
560    c[2u * i]      = r.x;
561    c[2u * i + 1u] = r.y;
562}
563"#
564    .to_string()
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    // ── transpose_wgsl ────────────────────────────────────────────────────
572
573    #[test]
574    fn wgsl_transpose_contains_workgroup() {
575        let src = transpose_wgsl(16);
576        assert!(src.contains("@compute @workgroup_size(16, 16)"));
577        assert!(src.contains("TransposeParams"));
578    }
579
580    #[test]
581    fn wgsl_transpose_padded_tile_avoids_bank_conflict() {
582        let src = transpose_wgsl(16);
583        // Tile is padded to tile+1 columns.
584        assert!(src.contains("array<array<f32, 17>, 16>"));
585    }
586
587    #[test]
588    fn wgsl_transpose_swaps_indices() {
589        let src = transpose_wgsl(8);
590        // Read uses cols stride; write uses rows stride (the transpose).
591        assert!(src.contains("src[in_r * params.cols + in_c]"));
592        assert!(src.contains("dst[out_r * params.rows + out_c]"));
593        // Write reads the tile with swapped local indices.
594        assert!(src.contains("tile[lc][lr]"));
595        assert!(src.contains("workgroupBarrier"));
596    }
597
598    #[test]
599    fn wgsl_transpose_has_bounds_guards() {
600        let src = transpose_wgsl(16);
601        assert!(src.contains("in_r < params.rows && in_c < params.cols"));
602        assert!(src.contains("out_r < params.cols && out_c < params.rows"));
603    }
604
605    // ── softmax_wgsl ──────────────────────────────────────────────────────
606
607    #[test]
608    fn wgsl_softmax_is_numerically_stable() {
609        let src = softmax_wgsl();
610        // Must subtract the row max before exp (stability).
611        assert!(src.contains("input[base + i] - row_max"));
612        assert!(src.contains("exp(input[base + i] - row_max)"));
613        // The final write divides by the sum (probabilities), not raw exp.
614        assert!(src.contains("inv_sum"));
615        assert!(src.contains("* inv_sum"));
616    }
617
618    #[test]
619    fn wgsl_softmax_does_not_naively_exp_then_divide_without_max() {
620        let src = softmax_wgsl();
621        // Guard: there must be NO `exp(input[base + i])` without the `- row_max`.
622        // i.e. every exp call subtracts the max.
623        assert!(!src.contains("exp(input[base + i])"));
624    }
625
626    #[test]
627    fn wgsl_softmax_bindings_and_workgroup() {
628        let src = softmax_wgsl();
629        assert!(src.contains("@compute @workgroup_size(256)"));
630        assert!(src.contains("var<storage, read>       input:"));
631        assert!(src.contains("var<storage, read_write> output:"));
632        assert!(src.contains("var<uniform>             params:"));
633        // Two distinct reductions (max then sum).
634        assert!(src.contains("shared_max"));
635        assert!(src.contains("shared_sum"));
636    }
637
638    #[test]
639    fn wgsl_softmax_row_per_workgroup() {
640        let src = softmax_wgsl();
641        assert!(src.contains("let row = wgid.x"));
642        assert!(src.contains("if (row >= params.rows) { return; }"));
643    }
644
645    // ── scan_wgsl ─────────────────────────────────────────────────────────
646
647    #[test]
648    fn wgsl_scan_inclusive_adds_input_back() {
649        let src = scan_wgsl(256, ScanKind::Inclusive);
650        assert!(src.contains("inclusive"));
651        // Inclusive = exclusive + original element.
652        assert!(src.contains("shared_data[2u * tid] + input[base + 2u * tid]"));
653        assert!(src.contains("shared_data[2u * tid + 1u] + input[base + 2u * tid + 1u]"));
654    }
655
656    #[test]
657    fn wgsl_scan_exclusive_writes_shared_directly() {
658        let src = scan_wgsl(256, ScanKind::Exclusive);
659        assert!(src.contains("exclusive"));
660        assert!(src.contains("output[base + 2u * tid] = shared_data[2u * tid];"));
661        // Exclusive must NOT add the input back.
662        assert!(!src.contains("shared_data[2u * tid] + input[base + 2u * tid]"));
663    }
664
665    #[test]
666    fn wgsl_scan_write_stage_guards_each_element_independently() {
667        // Regression for the odd-length-tail bug: the write stage previously
668        // wrapped BOTH `output[base + 2*tid]` and `output[base + 2*tid + 1]`
669        // in a single `if (base + 2*tid < n)` guard, so whenever
670        // `base + 2*tid < n <= base + 2*tid + 1` (an odd remainder within the
671        // block) the second write landed one element past `n`.  Each element
672        // must now carry its own bound.
673        for kind in [ScanKind::Inclusive, ScanKind::Exclusive] {
674            let src = scan_wgsl(256, kind);
675            assert!(
676                src.contains("if (base + 2u * tid < params.n) {"),
677                "{kind:?} scan lacks an independent guard for the first element"
678            );
679            assert!(
680                src.contains("if (base + 2u * tid + 1u < params.n) {"),
681                "{kind:?} scan lacks an independent guard for the second element"
682            );
683            // Structural count: the (unrelated, always-correct) load stage
684            // contributes exactly 2 `if (base + ...)` guards (`i0`, `i1`).
685            // The old buggy write stage added exactly 1 more (one shared
686            // guard for both writes) for a total of 3; the fix adds 2 (one
687            // per write) for a total of 4.  A regression back to the shared
688            // guard would drop this count to 3.
689            let guard_count = src.matches("if (base + ").count();
690            assert_eq!(
691                guard_count, 4,
692                "{kind:?} scan: expected 2 load guards + 2 independent write \
693                 guards (4 total), got {guard_count} — the write stage may have \
694                 regressed to a single shared guard"
695            );
696        }
697    }
698
699    #[test]
700    fn wgsl_scan_has_up_and_down_sweep() {
701        let src = scan_wgsl(512, ScanKind::Inclusive);
702        // Half as many threads as block size (two elements per thread).
703        assert!(src.contains("@compute @workgroup_size(256)"));
704        assert!(src.contains("array<f32, 512>"));
705        // Blelloch clears the root before the down-sweep.
706        assert!(src.contains("shared_data[512u - 1u] = 0.0"));
707        assert!(src.contains("workgroupBarrier"));
708    }
709
710    #[test]
711    fn wgsl_scan_block_size_64() {
712        let src = scan_wgsl(64, ScanKind::Exclusive);
713        assert!(src.contains("@compute @workgroup_size(32)"));
714        assert!(src.contains("array<f32, 64>"));
715    }
716
717    // ── layernorm_wgsl ────────────────────────────────────────────────────
718
719    #[test]
720    fn wgsl_layernorm_centers_and_scales() {
721        let src = layernorm_wgsl(1e-5);
722        // Mean-centering then division by sqrt(var + eps).
723        assert!(src.contains("input[base + i] - mean"));
724        assert!(src.contains("sqrt(variance + f32(0.00001"));
725        // Affine: norm * gamma + beta.
726        assert!(src.contains("norm * gamma[i] + beta[i]"));
727    }
728
729    #[test]
730    fn wgsl_layernorm_variance_is_mean_of_squared_dev() {
731        let src = layernorm_wgsl(1e-5);
732        assert!(src.contains("let d = input[base + i] - mean;"));
733        assert!(src.contains("local_var = local_var + d * d;"));
734        assert!(src.contains("let variance = shared_acc[0] * inv_n;"));
735    }
736
737    #[test]
738    fn wgsl_layernorm_bindings() {
739        let src = layernorm_wgsl(1e-6);
740        assert!(src.contains("@group(0) @binding(0) var<storage, read>       input:"));
741        assert!(src.contains("@group(0) @binding(1) var<storage, read>       gamma:"));
742        assert!(src.contains("@group(0) @binding(2) var<storage, read>       beta:"));
743        assert!(src.contains("@group(0) @binding(3) var<storage, read_write> output:"));
744        assert!(src.contains("@group(0) @binding(4) var<uniform>             params:"));
745        assert!(src.contains("@compute @workgroup_size(256)"));
746    }
747
748    #[test]
749    fn wgsl_layernorm_embeds_eps() {
750        // eps appears verbatim in the source.
751        assert!(layernorm_wgsl(0.001).contains("0.001"));
752    }
753
754    // ── subgroup_reduction_wgsl ───────────────────────────────────────────
755
756    #[test]
757    fn wgsl_subgroup_sum_uses_subgroup_add() {
758        let src = subgroup_reduction_wgsl("sum", false);
759        assert!(src.contains("enable subgroups;"));
760        assert!(src.contains("subgroupAdd(v)"));
761        assert!(src.contains("acc + val"));
762    }
763
764    #[test]
765    fn wgsl_subgroup_max_uses_subgroup_max() {
766        let src = subgroup_reduction_wgsl("max", false);
767        assert!(src.contains("subgroupMax(v)"));
768        assert!(src.contains("max(acc, val)"));
769        // True negative infinity, not a finite `-1e38` sentinel.
770        assert!(src.contains("bitcast<f32>(0xFF800000u)"));
771    }
772
773    #[test]
774    fn wgsl_subgroup_min_uses_subgroup_min() {
775        let src = subgroup_reduction_wgsl("min", false);
776        assert!(src.contains("subgroupMin(v)"));
777        assert!(src.contains("min(acc, val)"));
778    }
779
780    #[test]
781    fn wgsl_subgroup_chromium_experimental_directive() {
782        let std_src = subgroup_reduction_wgsl("sum", false);
783        assert!(std_src.contains("enable subgroups;"));
784        assert!(!std_src.contains("chromium_experimental"));
785
786        let exp_src = subgroup_reduction_wgsl("sum", true);
787        assert!(exp_src.contains("enable chromium_experimental_subgroups;"));
788    }
789
790    #[test]
791    fn wgsl_subgroup_uses_subgroup_builtins() {
792        let src = subgroup_reduction_wgsl("sum", false);
793        assert!(src.contains("@builtin(subgroup_invocation_id)"));
794        assert!(src.contains("@builtin(subgroup_size)"));
795        assert!(src.contains("@compute @workgroup_size(256)"));
796    }
797
798    // ── f64_emul_add_wgsl ─────────────────────────────────────────────────
799
800    #[test]
801    fn wgsl_f64_emul_uses_double_single() {
802        let src = f64_emul_add_wgsl();
803        // vec2<f32> = (hi, lo) representation.
804        assert!(src.contains("vec2<f32>"));
805        // Knuth TwoSum error-free transform.
806        assert!(src.contains("fn two_sum"));
807        assert!(src.contains("fn df_add"));
808        // Interleaved (hi, lo) addressing.
809        assert!(src.contains("a[2u * i]"));
810        assert!(src.contains("a[2u * i + 1u]"));
811    }
812
813    #[test]
814    fn wgsl_f64_emul_two_sum_is_error_free() {
815        let src = f64_emul_add_wgsl();
816        // The classic TwoSum residual computation.
817        assert!(src.contains("let bb = s - av;"));
818        assert!(src.contains("(av - (s - bb)) + (bv - bb)"));
819    }
820
821    #[test]
822    fn wgsl_f64_emul_bindings_and_guard() {
823        let src = f64_emul_add_wgsl();
824        assert!(src.contains("@compute @workgroup_size(256)"));
825        assert!(src.contains("if (i >= params.n) { return; }"));
826        assert!(src.contains("var<storage, read_write> c:"));
827    }
828}