1#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum ScanKind {
181 Inclusive,
183 Exclusive,
185}
186
187#[must_use]
202pub fn scan_wgsl(block_size: u32, kind: ScanKind) -> String {
203 let threads = (block_size / 2).max(1);
204 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#[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#[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 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#[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 #[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 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 assert!(src.contains("src[in_r * params.cols + in_c]"));
592 assert!(src.contains("dst[out_r * params.rows + out_c]"));
593 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 #[test]
608 fn wgsl_softmax_is_numerically_stable() {
609 let src = softmax_wgsl();
610 assert!(src.contains("input[base + i] - row_max"));
612 assert!(src.contains("exp(input[base + i] - row_max)"));
613 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 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 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 #[test]
648 fn wgsl_scan_inclusive_adds_input_back() {
649 let src = scan_wgsl(256, ScanKind::Inclusive);
650 assert!(src.contains("inclusive"));
651 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 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 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 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 assert!(src.contains("@compute @workgroup_size(256)"));
704 assert!(src.contains("array<f32, 512>"));
705 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 #[test]
720 fn wgsl_layernorm_centers_and_scales() {
721 let src = layernorm_wgsl(1e-5);
722 assert!(src.contains("input[base + i] - mean"));
724 assert!(src.contains("sqrt(variance + f32(0.00001"));
725 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 assert!(layernorm_wgsl(0.001).contains("0.001"));
752 }
753
754 #[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 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 #[test]
801 fn wgsl_f64_emul_uses_double_single() {
802 let src = f64_emul_add_wgsl();
803 assert!(src.contains("vec2<f32>"));
805 assert!(src.contains("fn two_sum"));
807 assert!(src.contains("fn df_add"));
808 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 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}