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.
118    var local_max: f32 = f32(-1e38);
119    var i: u32 = tid;
120    loop {
121        if (i >= params.cols) { break; }
122        local_max = max(local_max, input[base + i]);
123        i = i + 256u;
124    }
125    shared_max[tid] = local_max;
126    workgroupBarrier();
127    var stride: u32 = 128u;
128    loop {
129        if (stride == 0u) { break; }
130        if (tid < stride) {
131            shared_max[tid] = max(shared_max[tid], shared_max[tid + stride]);
132        }
133        workgroupBarrier();
134        stride = stride >> 1u;
135    }
136    let row_max = shared_max[0];
137    workgroupBarrier();
138
139    // Pass 2: per-thread partial sum of exp(x - row_max).
140    var local_sum: f32 = 0.0;
141    i = tid;
142    loop {
143        if (i >= params.cols) { break; }
144        local_sum = local_sum + exp(input[base + i] - row_max);
145        i = i + 256u;
146    }
147    shared_sum[tid] = local_sum;
148    workgroupBarrier();
149    stride = 128u;
150    loop {
151        if (stride == 0u) { break; }
152        if (tid < stride) {
153            shared_sum[tid] = shared_sum[tid] + shared_sum[tid + stride];
154        }
155        workgroupBarrier();
156        stride = stride >> 1u;
157    }
158    let row_sum = shared_sum[0];
159    let inv_sum = 1.0 / row_sum;
160    workgroupBarrier();
161
162    // Pass 3: write normalised probabilities.
163    i = tid;
164    loop {
165        if (i >= params.cols) { break; }
166        output[base + i] = exp(input[base + i] - row_max) * inv_sum;
167        i = i + 256u;
168    }
169}
170"#
171    .to_string()
172}
173
174/// Whether a prefix scan is inclusive (`out[i]` includes `in[i]`) or exclusive
175/// (`out[i]` is the sum of all strictly-earlier elements).
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum ScanKind {
178    /// Inclusive scan: `out[i] = in[0] + ... + in[i]`.
179    Inclusive,
180    /// Exclusive scan: `out[i] = in[0] + ... + in[i-1]`, `out[0] = 0`.
181    Exclusive,
182}
183
184/// Generate WGSL for a single-block Blelloch work-efficient prefix scan.
185///
186/// Implements the up-sweep (reduce) + down-sweep phases of the Blelloch
187/// (1990) scan over a power-of-two-sized shared array of `block_size`
188/// elements.  This is the per-block primitive; a multi-block scan composes
189/// these with a block-sum carry pass at the host level.
190///
191/// `block_size` should be a power of two; the emitted `@workgroup_size` is
192/// `block_size / 2` because each thread handles two elements (the canonical
193/// Blelloch mapping).
194///
195/// For an exclusive scan the algorithm clears the last element before the
196/// down-sweep; the inclusive variant additionally adds the original input back
197/// after the exclusive down-sweep.
198#[must_use]
199pub fn scan_wgsl(block_size: u32, kind: ScanKind) -> String {
200    let threads = (block_size / 2).max(1);
201    // Inclusive = exclusive scan plus the original element added back.
202    let inclusive_fixup = match kind {
203        ScanKind::Inclusive => {
204            "    // Inclusive: add the original input back to the exclusive result.\n    \
205             output[base + 2u * tid]      = shared_data[2u * tid]      + input[base + 2u * tid];\n    \
206             output[base + 2u * tid + 1u] = shared_data[2u * tid + 1u] + input[base + 2u * tid + 1u];"
207        }
208        ScanKind::Exclusive => {
209            "    output[base + 2u * tid]      = shared_data[2u * tid];\n    \
210             output[base + 2u * tid + 1u] = shared_data[2u * tid + 1u];"
211        }
212    };
213    let kind_comment = match kind {
214        ScanKind::Inclusive => "inclusive",
215        ScanKind::Exclusive => "exclusive",
216    };
217
218    format!(
219        r#"
220// Blelloch work-efficient {kind_comment} prefix scan (block size {bs}).
221struct ScanParams {{
222    n: u32,
223}}
224
225@group(0) @binding(0) var<storage, read>       input:  array<f32>;
226@group(0) @binding(1) var<storage, read_write> output: array<f32>;
227@group(0) @binding(2) var<uniform>             params: ScanParams;
228
229var<workgroup> shared_data: array<f32, {bs}>;
230
231@compute @workgroup_size({threads})
232fn main(
233    @builtin(workgroup_id)        wgid: vec3<u32>,
234    @builtin(local_invocation_id) lid:  vec3<u32>,
235) {{
236    let tid  = lid.x;
237    let base = wgid.x * {bs}u;
238
239    // Load two elements per thread (zero-pad out-of-range).
240    let i0 = 2u * tid;
241    let i1 = 2u * tid + 1u;
242    if (base + i0 < params.n) {{ shared_data[i0] = input[base + i0]; }} else {{ shared_data[i0] = 0.0; }}
243    if (base + i1 < params.n) {{ shared_data[i1] = input[base + i1]; }} else {{ shared_data[i1] = 0.0; }}
244
245    // Up-sweep (reduce) phase.
246    var offset: u32 = 1u;
247    var d: u32 = {bs}u >> 1u;
248    loop {{
249        workgroupBarrier();
250        if (tid < d) {{
251            let ai = offset * (2u * tid + 1u) - 1u;
252            let bi = offset * (2u * tid + 2u) - 1u;
253            shared_data[bi] = shared_data[bi] + shared_data[ai];
254        }}
255        offset = offset << 1u;
256        if (d == 1u) {{ break; }}
257        d = d >> 1u;
258    }}
259
260    // Clear the last element (root) for the exclusive down-sweep.
261    if (tid == 0u) {{ shared_data[{bs}u - 1u] = 0.0; }}
262
263    // Down-sweep phase.
264    d = 1u;
265    loop {{
266        offset = offset >> 1u;
267        workgroupBarrier();
268        if (tid < d) {{
269            let ai = offset * (2u * tid + 1u) - 1u;
270            let bi = offset * (2u * tid + 2u) - 1u;
271            let t = shared_data[ai];
272            shared_data[ai] = shared_data[bi];
273            shared_data[bi] = shared_data[bi] + t;
274        }}
275        if (d == {bs}u >> 1u) {{ break; }}
276        d = d << 1u;
277    }}
278    workgroupBarrier();
279
280    // Write results (exclusive in shared_data; inclusive adds input back).
281    if (base + i0 < params.n) {{
282{inclusive_fixup}
283    }}
284}}
285"#,
286        bs = block_size,
287        threads = threads,
288        kind_comment = kind_comment,
289        inclusive_fixup = inclusive_fixup,
290    )
291}
292
293/// Generate WGSL for row-wise layer normalisation (Ba, Kiros & Hinton 2016).
294///
295/// For each row of a row-major `rows × cols` matrix, computes
296/// `y = (x - mean) / sqrt(var + eps) * gamma + beta`, where `mean` and `var`
297/// are the per-row mean and (biased) variance.  `gamma` and `beta` are
298/// per-column affine parameters of length `cols`.  `eps` is embedded as a
299/// constant.
300///
301/// Each row is processed by one workgroup of 256 threads with two cooperative
302/// tree reductions (sum, then sum-of-squares for variance).
303///
304/// # Arguments
305///
306/// * `eps` — numerical-stability epsilon added to the variance.
307#[must_use]
308pub fn layernorm_wgsl(eps: f32) -> String {
309    format!(
310        r#"
311struct LayerNormParams {{
312    rows: u32,
313    cols: u32,
314}}
315
316@group(0) @binding(0) var<storage, read>       input:  array<f32>;
317@group(0) @binding(1) var<storage, read>       gamma:  array<f32>;
318@group(0) @binding(2) var<storage, read>       beta:   array<f32>;
319@group(0) @binding(3) var<storage, read_write> output: array<f32>;
320@group(0) @binding(4) var<uniform>             params: LayerNormParams;
321
322var<workgroup> shared_acc: array<f32, 256>;
323
324@compute @workgroup_size(256)
325fn main(
326    @builtin(workgroup_id)        wgid: vec3<u32>,
327    @builtin(local_invocation_id) lid:  vec3<u32>,
328) {{
329    let row = wgid.x;
330    if (row >= params.rows) {{ return; }}
331    let tid  = lid.x;
332    let base = row * params.cols;
333    let inv_n = 1.0 / f32(params.cols);
334
335    // Pass 1: mean.
336    var local_sum: f32 = 0.0;
337    var i: u32 = tid;
338    loop {{
339        if (i >= params.cols) {{ break; }}
340        local_sum = local_sum + input[base + i];
341        i = i + 256u;
342    }}
343    shared_acc[tid] = local_sum;
344    workgroupBarrier();
345    var stride: u32 = 128u;
346    loop {{
347        if (stride == 0u) {{ break; }}
348        if (tid < stride) {{
349            shared_acc[tid] = shared_acc[tid] + shared_acc[tid + stride];
350        }}
351        workgroupBarrier();
352        stride = stride >> 1u;
353    }}
354    let mean = shared_acc[0] * inv_n;
355    workgroupBarrier();
356
357    // Pass 2: variance (mean of squared deviations).
358    var local_var: f32 = 0.0;
359    i = tid;
360    loop {{
361        if (i >= params.cols) {{ break; }}
362        let d = input[base + i] - mean;
363        local_var = local_var + d * d;
364        i = i + 256u;
365    }}
366    shared_acc[tid] = local_var;
367    workgroupBarrier();
368    stride = 128u;
369    loop {{
370        if (stride == 0u) {{ break; }}
371        if (tid < stride) {{
372            shared_acc[tid] = shared_acc[tid] + shared_acc[tid + stride];
373        }}
374        workgroupBarrier();
375        stride = stride >> 1u;
376    }}
377    let variance = shared_acc[0] * inv_n;
378    let inv_std = 1.0 / sqrt(variance + f32({eps}));
379    workgroupBarrier();
380
381    // Pass 3: normalise + affine.
382    i = tid;
383    loop {{
384        if (i >= params.cols) {{ break; }}
385        let norm = (input[base + i] - mean) * inv_std;
386        output[base + i] = norm * gamma[i] + beta[i];
387        i = i + 256u;
388    }}
389}}
390"#,
391        eps = eps,
392    )
393}
394
395/// Generate WGSL for a warp-style subgroup reduction (P0 roadmap item).
396///
397/// Emits a compute shader that uses the WGSL `subgroups` extension and the
398/// `subgroupAdd` / `subgroupMax` / `subgroupMin` built-ins (stabilising in
399/// Chrome 125+ and Firefox 135+).  The shader reduces each subgroup's lane
400/// values with a single built-in call, then the subgroup leaders combine their
401/// partials through shared memory.
402///
403/// **Device note:** actually *dispatching* this requires an adapter that
404/// reports `wgpu::Features::SUBGROUP`; the emitted source and its
405/// `enable subgroups;` directive are generated and tested here on CPU, but
406/// on-hardware execution is gated on a real GPU that supports subgroups.
407///
408/// # Arguments
409///
410/// * `op` — one of `"sum"`, `"max"`, `"min"` (unknown ops fall back to
411///   `"sum"`).
412/// * `chromium_experimental` — when `true`, emit the pre-standard
413///   `enable chromium_experimental_subgroups;` directive instead of the
414///   standard `enable subgroups;` (Chromium native path).
415#[must_use]
416pub fn subgroup_reduction_wgsl(op: &str, chromium_experimental: bool) -> String {
417    let (subgroup_fn, neutral) = match op {
418        "max" => ("subgroupMax", "f32(-1e38)"),
419        "min" => ("subgroupMin", "f32(1e38)"),
420        _ => ("subgroupAdd", "f32(0.0)"),
421    };
422    // Combine across subgroup leaders in shared memory.
423    let combine = match op {
424        "max" => "max(acc, val)",
425        "min" => "min(acc, val)",
426        _ => "acc + val",
427    };
428    let enable = if chromium_experimental {
429        "enable chromium_experimental_subgroups;"
430    } else {
431        "enable subgroups;"
432    };
433
434    format!(
435        r#"
436{enable}
437
438struct SubgroupReduceParams {{
439    n: u32,
440}}
441
442@group(0) @binding(0) var<storage, read>       input:        array<f32>;
443@group(0) @binding(1) var<storage, read_write> partial_sums: array<f32>;
444@group(0) @binding(2) var<uniform>             params:       SubgroupReduceParams;
445
446// Up to 256 lanes / min-subgroup-size of 4 = 64 leader slots, padded to 64.
447var<workgroup> leader_vals: array<f32, 64>;
448
449@compute @workgroup_size(256)
450fn main(
451    @builtin(global_invocation_id)   gid:  vec3<u32>,
452    @builtin(local_invocation_id)    lid:  vec3<u32>,
453    @builtin(workgroup_id)           wgid: vec3<u32>,
454    @builtin(subgroup_invocation_id) sg_id:   u32,
455    @builtin(subgroup_size)          sg_size: u32,
456) {{
457    let tid = lid.x;
458    var v: f32 = {neutral};
459    if (gid.x < params.n) {{ v = input[gid.x]; }}
460
461    // One built-in call reduces the whole subgroup.
462    let sg_reduced = {subgroup_fn}(v);
463
464    // Subgroup leaders publish their reduced value.
465    let leader_index = tid / sg_size;
466    if (sg_id == 0u) {{
467        leader_vals[leader_index] = sg_reduced;
468    }}
469    workgroupBarrier();
470
471    // Thread 0 folds the leader partials and writes the workgroup result.
472    if (tid == 0u) {{
473        let num_leaders = (256u + sg_size - 1u) / sg_size;
474        var acc: f32 = {neutral};
475        for (var i: u32 = 0u; i < num_leaders; i = i + 1u) {{
476            let val = leader_vals[i];
477            acc = {combine};
478        }}
479        partial_sums[wgid.x] = acc;
480    }}
481}}
482"#,
483        enable = enable,
484        subgroup_fn = subgroup_fn,
485        neutral = neutral,
486        combine = combine,
487    )
488}
489
490/// Generate WGSL for emulated double-precision **addition** using the
491/// double-single ("double-float") technique (P2 roadmap item).
492///
493/// WebGPU has **no native FP64**.  Each logical f64 value is stored as a
494/// `vec2<f32>` = `(hi, lo)` where `hi` is the leading f32 and `lo` is the
495/// round-off residual, giving ~46 bits of mantissa.  The kernel adds two such
496/// arrays element-wise using Knuth's TwoSum / Dekker error-free transformation
497/// so the residual is carried correctly.
498///
499/// Buffers are laid out as interleaved `(hi, lo)` pairs, i.e. element `i`
500/// occupies indices `2*i` (hi) and `2*i + 1` (lo).
501#[must_use]
502pub fn f64_emul_add_wgsl() -> String {
503    r#"
504// Double-single (emulated f64) element-wise add.  No native FP64 on WebGPU.
505// Each value is a (hi, lo) pair: lo carries the round-off residual of hi.
506struct DfParams {
507    n: u32,
508}
509
510@group(0) @binding(0) var<storage, read>       a:      array<f32>;
511@group(0) @binding(1) var<storage, read>       b:      array<f32>;
512@group(0) @binding(2) var<storage, read_write> c:      array<f32>;
513@group(0) @binding(3) var<uniform>             params: DfParams;
514
515// Knuth TwoSum: returns (s, e) with a + b == s + e exactly (in f32).
516fn two_sum(av: f32, bv: f32) -> vec2<f32> {
517    let s = av + bv;
518    let bb = s - av;
519    let err = (av - (s - bb)) + (bv - bb);
520    return vec2<f32>(s, err);
521}
522
523// Add two double-single numbers (hi, lo) + (hi, lo).
524fn df_add(x: vec2<f32>, y: vec2<f32>) -> vec2<f32> {
525    let sh = two_sum(x.x, y.x);
526    let sl = two_sum(x.y, y.y);
527    var hi = sh.x;
528    var lo = sh.y + sl.x;
529    // Renormalise the high/low split.
530    let r1 = two_sum(hi, lo);
531    hi = r1.x;
532    lo = r1.y + sl.y;
533    let r2 = two_sum(hi, lo);
534    return vec2<f32>(r2.x, r2.y);
535}
536
537@compute @workgroup_size(256)
538fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
539    let i = gid.x;
540    if (i >= params.n) { return; }
541    let av = vec2<f32>(a[2u * i], a[2u * i + 1u]);
542    let bv = vec2<f32>(b[2u * i], b[2u * i + 1u]);
543    let r = df_add(av, bv);
544    c[2u * i]      = r.x;
545    c[2u * i + 1u] = r.y;
546}
547"#
548    .to_string()
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    // ── transpose_wgsl ────────────────────────────────────────────────────
556
557    #[test]
558    fn wgsl_transpose_contains_workgroup() {
559        let src = transpose_wgsl(16);
560        assert!(src.contains("@compute @workgroup_size(16, 16)"));
561        assert!(src.contains("TransposeParams"));
562    }
563
564    #[test]
565    fn wgsl_transpose_padded_tile_avoids_bank_conflict() {
566        let src = transpose_wgsl(16);
567        // Tile is padded to tile+1 columns.
568        assert!(src.contains("array<array<f32, 17>, 16>"));
569    }
570
571    #[test]
572    fn wgsl_transpose_swaps_indices() {
573        let src = transpose_wgsl(8);
574        // Read uses cols stride; write uses rows stride (the transpose).
575        assert!(src.contains("src[in_r * params.cols + in_c]"));
576        assert!(src.contains("dst[out_r * params.rows + out_c]"));
577        // Write reads the tile with swapped local indices.
578        assert!(src.contains("tile[lc][lr]"));
579        assert!(src.contains("workgroupBarrier"));
580    }
581
582    #[test]
583    fn wgsl_transpose_has_bounds_guards() {
584        let src = transpose_wgsl(16);
585        assert!(src.contains("in_r < params.rows && in_c < params.cols"));
586        assert!(src.contains("out_r < params.cols && out_c < params.rows"));
587    }
588
589    // ── softmax_wgsl ──────────────────────────────────────────────────────
590
591    #[test]
592    fn wgsl_softmax_is_numerically_stable() {
593        let src = softmax_wgsl();
594        // Must subtract the row max before exp (stability).
595        assert!(src.contains("input[base + i] - row_max"));
596        assert!(src.contains("exp(input[base + i] - row_max)"));
597        // The final write divides by the sum (probabilities), not raw exp.
598        assert!(src.contains("inv_sum"));
599        assert!(src.contains("* inv_sum"));
600    }
601
602    #[test]
603    fn wgsl_softmax_does_not_naively_exp_then_divide_without_max() {
604        let src = softmax_wgsl();
605        // Guard: there must be NO `exp(input[base + i])` without the `- row_max`.
606        // i.e. every exp call subtracts the max.
607        assert!(!src.contains("exp(input[base + i])"));
608    }
609
610    #[test]
611    fn wgsl_softmax_bindings_and_workgroup() {
612        let src = softmax_wgsl();
613        assert!(src.contains("@compute @workgroup_size(256)"));
614        assert!(src.contains("var<storage, read>       input:"));
615        assert!(src.contains("var<storage, read_write> output:"));
616        assert!(src.contains("var<uniform>             params:"));
617        // Two distinct reductions (max then sum).
618        assert!(src.contains("shared_max"));
619        assert!(src.contains("shared_sum"));
620    }
621
622    #[test]
623    fn wgsl_softmax_row_per_workgroup() {
624        let src = softmax_wgsl();
625        assert!(src.contains("let row = wgid.x"));
626        assert!(src.contains("if (row >= params.rows) { return; }"));
627    }
628
629    // ── scan_wgsl ─────────────────────────────────────────────────────────
630
631    #[test]
632    fn wgsl_scan_inclusive_adds_input_back() {
633        let src = scan_wgsl(256, ScanKind::Inclusive);
634        assert!(src.contains("inclusive"));
635        // Inclusive = exclusive + original element.
636        assert!(src.contains("shared_data[2u * tid]      + input[base + 2u * tid]"));
637    }
638
639    #[test]
640    fn wgsl_scan_exclusive_writes_shared_directly() {
641        let src = scan_wgsl(256, ScanKind::Exclusive);
642        assert!(src.contains("exclusive"));
643        assert!(src.contains("output[base + 2u * tid]      = shared_data[2u * tid];"));
644        // Exclusive must NOT add the input back.
645        assert!(!src.contains("shared_data[2u * tid]      + input[base + 2u * tid]"));
646    }
647
648    #[test]
649    fn wgsl_scan_has_up_and_down_sweep() {
650        let src = scan_wgsl(512, ScanKind::Inclusive);
651        // Half as many threads as block size (two elements per thread).
652        assert!(src.contains("@compute @workgroup_size(256)"));
653        assert!(src.contains("array<f32, 512>"));
654        // Blelloch clears the root before the down-sweep.
655        assert!(src.contains("shared_data[512u - 1u] = 0.0"));
656        assert!(src.contains("workgroupBarrier"));
657    }
658
659    #[test]
660    fn wgsl_scan_block_size_64() {
661        let src = scan_wgsl(64, ScanKind::Exclusive);
662        assert!(src.contains("@compute @workgroup_size(32)"));
663        assert!(src.contains("array<f32, 64>"));
664    }
665
666    // ── layernorm_wgsl ────────────────────────────────────────────────────
667
668    #[test]
669    fn wgsl_layernorm_centers_and_scales() {
670        let src = layernorm_wgsl(1e-5);
671        // Mean-centering then division by sqrt(var + eps).
672        assert!(src.contains("input[base + i] - mean"));
673        assert!(src.contains("sqrt(variance + f32(0.00001"));
674        // Affine: norm * gamma + beta.
675        assert!(src.contains("norm * gamma[i] + beta[i]"));
676    }
677
678    #[test]
679    fn wgsl_layernorm_variance_is_mean_of_squared_dev() {
680        let src = layernorm_wgsl(1e-5);
681        assert!(src.contains("let d = input[base + i] - mean;"));
682        assert!(src.contains("local_var = local_var + d * d;"));
683        assert!(src.contains("let variance = shared_acc[0] * inv_n;"));
684    }
685
686    #[test]
687    fn wgsl_layernorm_bindings() {
688        let src = layernorm_wgsl(1e-6);
689        assert!(src.contains("@group(0) @binding(0) var<storage, read>       input:"));
690        assert!(src.contains("@group(0) @binding(1) var<storage, read>       gamma:"));
691        assert!(src.contains("@group(0) @binding(2) var<storage, read>       beta:"));
692        assert!(src.contains("@group(0) @binding(3) var<storage, read_write> output:"));
693        assert!(src.contains("@group(0) @binding(4) var<uniform>             params:"));
694        assert!(src.contains("@compute @workgroup_size(256)"));
695    }
696
697    #[test]
698    fn wgsl_layernorm_embeds_eps() {
699        // eps appears verbatim in the source.
700        assert!(layernorm_wgsl(0.001).contains("0.001"));
701    }
702
703    // ── subgroup_reduction_wgsl ───────────────────────────────────────────
704
705    #[test]
706    fn wgsl_subgroup_sum_uses_subgroup_add() {
707        let src = subgroup_reduction_wgsl("sum", false);
708        assert!(src.contains("enable subgroups;"));
709        assert!(src.contains("subgroupAdd(v)"));
710        assert!(src.contains("acc + val"));
711    }
712
713    #[test]
714    fn wgsl_subgroup_max_uses_subgroup_max() {
715        let src = subgroup_reduction_wgsl("max", false);
716        assert!(src.contains("subgroupMax(v)"));
717        assert!(src.contains("max(acc, val)"));
718        assert!(src.contains("f32(-1e38)"));
719    }
720
721    #[test]
722    fn wgsl_subgroup_min_uses_subgroup_min() {
723        let src = subgroup_reduction_wgsl("min", false);
724        assert!(src.contains("subgroupMin(v)"));
725        assert!(src.contains("min(acc, val)"));
726    }
727
728    #[test]
729    fn wgsl_subgroup_chromium_experimental_directive() {
730        let std_src = subgroup_reduction_wgsl("sum", false);
731        assert!(std_src.contains("enable subgroups;"));
732        assert!(!std_src.contains("chromium_experimental"));
733
734        let exp_src = subgroup_reduction_wgsl("sum", true);
735        assert!(exp_src.contains("enable chromium_experimental_subgroups;"));
736    }
737
738    #[test]
739    fn wgsl_subgroup_uses_subgroup_builtins() {
740        let src = subgroup_reduction_wgsl("sum", false);
741        assert!(src.contains("@builtin(subgroup_invocation_id)"));
742        assert!(src.contains("@builtin(subgroup_size)"));
743        assert!(src.contains("@compute @workgroup_size(256)"));
744    }
745
746    // ── f64_emul_add_wgsl ─────────────────────────────────────────────────
747
748    #[test]
749    fn wgsl_f64_emul_uses_double_single() {
750        let src = f64_emul_add_wgsl();
751        // vec2<f32> = (hi, lo) representation.
752        assert!(src.contains("vec2<f32>"));
753        // Knuth TwoSum error-free transform.
754        assert!(src.contains("fn two_sum"));
755        assert!(src.contains("fn df_add"));
756        // Interleaved (hi, lo) addressing.
757        assert!(src.contains("a[2u * i]"));
758        assert!(src.contains("a[2u * i + 1u]"));
759    }
760
761    #[test]
762    fn wgsl_f64_emul_two_sum_is_error_free() {
763        let src = f64_emul_add_wgsl();
764        // The classic TwoSum residual computation.
765        assert!(src.contains("let bb = s - av;"));
766        assert!(src.contains("(av - (s - bb)) + (bv - bb)"));
767    }
768
769    #[test]
770    fn wgsl_f64_emul_bindings_and_guard() {
771        let src = f64_emul_add_wgsl();
772        assert!(src.contains("@compute @workgroup_size(256)"));
773        assert!(src.contains("if (i >= params.n) { return; }"));
774        assert!(src.contains("var<storage, read_write> c:"));
775    }
776}